ویرگول
ورودثبت نام
افشین یاری نیا
افشین یاری نیا
افشین یاری نیا
افشین یاری نیا
خواندن ۲۷ دقیقه·۲ ماه پیش

طراحی پرداخت و کیف پول در مقیاس بالا؛ وقتی پول جابه‌جا شده، اما پاسخ نرسیده است


کاربر یک میلیون تومان پرداخت می‌کند. بانک مبلغ را از حسابش کم می‌کند، اما پاسخ درگاه به سرور ما نمی‌رسد. صفحه همچنان در حال بارگذاری است و کاربر دوباره روی «پرداخت» می‌زند.

چند ثانیه بعد، یک رویداد موفقیت از درگاه می‌رسد. Worker پردازش را شروع می‌کند، تغییرات دیتابیس را ثبت می‌کند و درست قبل از اعلام پایان کار از دسترس خارج می‌شود. درگاه هم چون پاسخ روشنی نگرفته، همان رویداد را دوباره می‌فرستد.

از نگاه کاربر، فقط یک خرید اتفاق افتاده است. از نگاه backend، چند درخواست، چند پیام و چند نتیجه نامشخص داریم.

چطور مطمئن شویم این تلاش‌های تکراری، پول را دوبار جابه‌جا نمی‌کنند و هر مبلغی که واقعاً منتقل شده، در سیستم ما قابل پیگیری است؟

طراحی پرداخت از همین سؤال شروع می‌شود. داشتن APIهای pay، refund و transfer کافی نیست. باید بدانیم وقتی پاسخ گم می‌شود، دو درخواست هم‌زمان می‌رسند یا واقعیت درگاه با دیتابیس ما اختلاف پیدا می‌کند، سیستم چه رفتاری دارد.
در این مقاله یک پلتفرم فرضی با پرداخت آنلاین، کیف پول و تسویه با فروشندگان طراحی می‌کنیم. معماری و اعداد، پیشنهاد و فرض طراحی‌اند و زیرساخت داخلی هیچ شرکت مشخصی را توصیف نمی‌کنند. منظور از «پول» در ادامه، هم حرکت واقعی وجه در سیستم بیرونی است و هم ثبت بدهی‌ها و مطالبات متناظر در دفترکل داخلی؛ این دو باید با یکدیگر تطبیق داده شوند.

۱. اول مشخص کنیم چه قولی به کاربر می‌دهیم

سیستم باید به کاربر اجازه دهد پرداخت ایجاد کند، کیف پولش را شارژ کند، موجودی و تاریخچه را ببیند، پول منتقل کند و درخواست برداشت یا بازپرداخت بدهد. فروشنده هم باید بداند چه مبلغی به او تعلق دارد، چه بخشی هنوز قابل تسویه نیست و کدام انتقال بانکی انجام شده است.

اما پشت این قابلیت‌ها چند تعهد مهم‌تر وجود دارد:

  • یک درخواست تکراری نباید اثر مالی تازه‌ای ایجاد کند.

  • خرج‌کردن نباید از موجودی مجاز بیشتر شود.

  • بازپرداخت‌های موفق و در حال انجام نباید بیش از مبلغ قابل بازپرداخت تعهد ایجاد کنند.

  • هر ثبت مالی باید مبلغ، واحد پول، دلیل و مرجع مشخص داشته باشد.

  • وضعیت نامشخص باید قابل بازیابی باشد و به‌اشتباه «ناموفق» نمایش داده نشود.

  • اصلاح اشتباه باید تاریخچه را حفظ کند.

این‌ها invariantهای دامنه‌اند؛ قانون‌هایی که شکستنشان به معنای رفتار مالی نادرست است. سرعت و دسترس‌پذیری را باید در چارچوب همین قانون‌ها بهبود دهیم.

برای مثال، اگر هنگام failover دیتابیس نمی‌توانیم موجودی معتبر را بررسی کنیم، متوقف‌کردن موقت انتقال تصمیم قابل دفاعی است. پذیرفتن انتقال با یک موجودی قدیمی، صرفاً برای اینکه API همچنان پاسخ موفق بدهد، مسئله را به آینده منتقل می‌کند.

این انتخاب به معنی کم‌اهمیت‌بودن availability نیست. طراحی باید مسیرهای خواندن وضعیت، پیگیری سفارش و نمایش پیام مناسب را تا جای ممکن حفظ کند؛ حتی زمانی که ثبت حرکت مالی جدید موقتاً محدود شده است.

۲. مقیاس، شکل بار را مشخص می‌کند

فرض کنیم پلتفرم ۱۰ میلیون کاربر ثبت‌شده، ۲ میلیون کاربر فعال ماهانه و روزانه ۵۰۰ هزار عملیات مالی دارد. میانگین تعداد عملیات در ثانیه چندان بزرگ نیست:

500,000 / 86,400 ≈ 5.8 business operations / second

اما میانگین، ساعت شلوغ سیستم را توضیح نمی‌دهد. فروش ویژه یا روز تسویه ممکن است بار را در یک بازه کوتاه متمرکز کند. برای این طراحی، پیک عادی ۱۰۰ تا ۲۵۰ عملیات در ثانیه و یک سناریوی آزمون فشار بالاتر از ۱۰۰۰ عملیات در ثانیه در نظر می‌گیریم. این اعداد فرض‌اند و باید با داده واقعی اصلاح شوند.

یک عملیات کسب‌وکار هم لزوماً یک write نیست. اگر هر عملیات به‌طور متوسط چهار سطر دفترکل بسازد، روزانه حدود دو میلیون Ledger Entry خواهیم داشت. با فرض ۳۰۰ تا ۷۰۰ بایت برای هر سطر و سهم ایندکس‌های آن، حجم پایه روزانه حدود ۰٫۶ تا ۱٫۴ گیگابایت می‌شود؛ WAL، replica، backup و داده‌های جانبی جداگانه محاسبه می‌شوند.

بار خواندن وضعیت و تاریخچه نیز با بار ثبت پول یکسان نیست. Callbackهای تکراری، بررسی‌های دوره‌ای و گزارش‌های تسویه هرکدام الگوی متفاوتی دارند. به همین دلیل، صرف دانستن TPS برای انتخاب معماری کافی نیست؛ باید hot accountها، تعداد ثبت‌های هر عملیات و زمان نگه‌داشتن lockها را هم بشناسیم.

چند هدف اولیه برای آزمون طراحی می‌تواند این‌ها باشد:

  • ایجاد Payment Intent داخلی: تأخیر p95 کمتر از ۲۵۰ میلی‌ثانیه، بدون احتساب زمان پاسخ درگاه.

  • خواندن موجودی: p95 کمتر از ۱۰۰ میلی‌ثانیه.

  • انتقال داخلی کیف پول: p95 کمتر از ۴۰۰ میلی‌ثانیه در بار تعریف‌شده.

  • دریافت و ذخیره پایدار Webhook معتبر: p95 کمتر از ۲۰۰ میلی‌ثانیه.

نهایی‌شدن پرداخت یا تسویه به رفتار provider هم وابسته است. این بخش را باید با سنجه‌هایی مثل عمر پرداخت نامشخص و زمان تطبیق اندازه گرفت، نه با یک وعده سراسری که وابستگی بیرونی را نادیده می‌گیرد.

۳. معماری را از مرزهای مالی بسازیم

برای شروع، یک Modular Monolith با ماژول‌های روشن و PostgreSQL مشترک انتخاب مناسبی است. Payment، Wallet، Ledger، Refund و Settlement مسئولیت‌های جدا دارند، اما تغییراتی که باید با هم commit شوند، هنوز داخل یک تراکنش محلی باقی می‌مانند.

شکل ۱ — تأیید بیرونی پیش از ثبت نهایی بررسی می‌شود. تغییر وضعیت پرداخت، ثبت دفترکل، موجودی مرتبط و Outbox داخل یک تراکنش PostgreSQL قرار می‌گیرند؛ تماس شبکه‌ای جزو این تراکنش نیست.
شکل ۱ — تأیید بیرونی پیش از ثبت نهایی بررسی می‌شود. تغییر وضعیت پرداخت، ثبت دفترکل، موجودی مرتبط و Outbox داخل یک تراکنش PostgreSQL قرار می‌گیرند؛ تماس شبکه‌ای جزو این تراکنش نیست.

Payment مالک تلاش‌های پرداخت و هماهنگی با درگاه است. Wallet سیاست موجودی قابل خرج و رزرو را کنترل می‌کند. Ledger ثبت مالی را انجام می‌دهد. Refund و Settlement هم چرخه عمر مستقل خودشان را دارند.

Gateway Adapter تفاوت providerها را از هسته جدا می‌کند. یک درگاه ممکن است authorization و capture جدا داشته باشد؛ دیگری فقط پرداخت و verify ارائه کند. وجود Webhook امضاشده، امکان inquiry و پشتیبانی از idempotency نیز باید جزو مدل قابلیت‌های هر provider باشد.

Reconciliation اختلاف میان داده داخلی و گزارش بیرونی را پیدا می‌کند. این ماژول فقط مصرف‌کننده رویدادهای موفق نیست؛ باید بتواند مستقل از پیام‌ها، پرداخت‌های متوقف‌شده و گزارش‌های دوره‌ای را بررسی کند.

Redis برای cache، rate limit و داده موقت مناسب است. Kafka یا یک broker دیگر، انتشار رویدادها و پردازش‌های جانبی را جدا می‌کند. هیچ‌کدام جای مرجع مالی PostgreSQL را نمی‌گیرند.

استخراج یک سرویس زمانی ارزش دارد که بار، مالکیت تیم، نیاز امنیتی یا چرخه انتشار مستقل آن روشن شده باشد. جداکردن Ledger از دیتابیس بقیه ماژول‌ها، مرز atomic فعلی را تغییر می‌دهد و به طراحی تازه‌ای نیاز دارد؛ صرفاً جابه‌جایی چند کلاس به یک پروژه دیگر نیست.

۴. Payment Intent، هویت عملیات پیش از تماس با درگاه

قبل از فرستادن کاربر به درگاه، باید بدانیم او برای چه چیزی، به چه کسی و با چه مبلغی پرداخت می‌کند. Payment Intent این تصمیم داخلی را نگه می‌دارد.

مبلغ از سفارش یا محاسبه معتبر سرور استخراج می‌شود. amount ارسالی مرورگر به‌تنهایی مبنای دریافت پول نیست. شناسه کاربر، فروشنده، سفارش، واحد پول و نسخه مبلغ پذیرفته‌شده نیز به این تصمیم متصل می‌شوند.

در کنار Intent، بهتر است تلاش‌های ارتباط با provider را در payment_attempts نگه داریم. یک Intent ممکن است در طول عمرش چند تلاش داشته باشد، اما هر attempt باید مرجع بیرونی و نتیجه خودش را حفظ کند.

Payment Intent id, order_id, user_id, merchant_id amount, currency, status, version Payment Attempt id, payment_id, provider, provider_account_id merchant_reference, external_reference status, requested_at, last_checked_at

وجود Intent به ما اجازه نمی‌دهد موفقیت درگاه را حدس بزنیم. درگاه مرجع وضعیت عملیات خودش است؛ ما هم مالک وضعیت کسب‌وکار و ثبت داخلی هستیم. اتصال این دو حقیقت با reference معتبر و تطبیق انجام می‌شود.

همین نکته در retry اهمیت پیدا می‌کند. اگر ساخت session نامشخص شد، نباید فوراً attempt تازه‌ای روی درگاه دیگری بسازیم. ابتدا باید بفهمیم تلاش قبلی قابل بازیابی است یا نه؛ وگرنه یک خرید می‌تواند به دو پرداخت واقعی تبدیل شود.

۵. وضعیت نامشخص، یک state واقعی است

یک ستون آزاد به نام status که هر بخش برنامه آن را تغییر دهد، مدل قابل اتکایی برای پرداخت نیست. هر transition باید command، پیش‌شرط و شواهد مشخص داشته باشد.

برای یک جریان ساده می‌توان وضعیت‌های زیر را در نظر گرفت:

CREATED -> PENDING -> CAPTURED -> FAILED_CONFIRMED -> UNKNOWN UNKNOWN -> CAPTURED [verified success] UNKNOWN -> FAILED_CONFIRMED [verified final failure]

این مدل نمونه است. درگاهی که authorization مستقل دارد به stateهای بیشتری نیاز دارد. نام‌های داخلی هم نباید بدون توجه به قرارداد provider، معادل نام‌های API او فرض شوند.

Timeout فقط می‌گوید پاسخ به ما نرسیده است. ممکن است عملیات اصلاً شروع نشده باشد، هنوز ادامه داشته باشد یا کامل شده باشد. بنابراین نتیجه نامشخص را با شکست قطعی یکی نمی‌کنیم.

انقضای صفحه پرداخت نیز به‌تنهایی ثابت نمی‌کند پول جابه‌جا نشده است. موفقیت دیرهنگام باید از مسیر recovery پردازش شود و سیاست سفارش تعیین کند که خدمت ارائه شود یا وجه بازگردد.

Refund و تسویه را هم در یک زنجیره خطی با Payment جا نمی‌دهیم. پرداختی که قبلاً تسویه شده، ممکن است بعداً بازپرداخت بخورد. در این طراحی، وضعیت دریافت وجه، چرخه بازپرداخت و چرخه تسویه جدا هستند و وضعیت نمایشی کاربر از ترکیب آن‌ها ساخته می‌شود.

۶. Idempotency باید قرارداد داشته باشد

فرض کنید کاربر همان دکمه پرداخت را دوباره می‌زند. اگر دو درخواست به یک تصمیم کسب‌وکار مربوط باشند، باید به همان عملیات برسند.

POST /v1/payments Idempotency-Key: order-123-payment-v1

کلید به‌تنهایی کافی نیست. باید scope آن مشخص باشد: مثلاً tenant، کاربر و نوع عملیات. یک fingerprint از ورودی‌های معنادار نیز نگه می‌داریم تا استفاده دوباره از همان کلید با مبلغ یا مقصد متفاوت، conflict بدهد.

UNIQUE (scope, idempotency_key)

این constraint رقابت دو درخواست هم‌زمان را در دیتابیس کنترل می‌کند. بررسی قبلی با یک SELECT یا گرفتن lock موقت در Redis، جای آن را نمی‌گیرد.

رکورد idempotency باید وضعیت در حال انجام را هم بشناسد. اگر درخواست دوم زمانی برسد که اولی هنوز تمام نشده، پاسخ کاملِ ساختگی برنمی‌گردانیم. قرارداد API می‌تواند همان resource و وضعیت جاری را بدهد یا پاسخ مشخص «در حال پردازش» داشته باشد.

برای عملیات محلی، ثبت کلید و اثر مالی باید در همان تراکنش انجام شوند. برای تماس بیرونی، ابتدا attempt پایدار ساخته می‌شود و نتیجه تماس بعداً به همان attempt برمی‌گردد. دوره نگهداری کلید نیز باید با طول عمر retry هماهنگ باشد؛ حذف زودهنگام آن می‌تواند یک تلاش قدیمی را دوباره قابل اجرا کند.

Idempotency در سطح API با یکتایی عملیات مالی یکسان نیست. حتی اگر کلاینت کلید تازه‌ای بسازد، قاعده کسب‌وکار باید جلوی دریافت غیرمجاز دوباره برای همان سفارش را بگیرد. همین موضوع برای reference درگاه و عملیات Ledger نیز constraint مستقل می‌خواهد.

مستندات Idempotent requests در Stripe نمونه‌ای از تعریف دقیق این قرارداد است. رفتار نگهداری کلید و پاسخ در providerهای دیگر باید جداگانه بررسی شود.

۷. Callback خبر می‌دهد؛ تأیید مالی به شواهد معتبر نیاز دارد

بازگشت مرورگر از درگاه برای هدایت کاربر مفید است، اما دلیل کافی برای ثبت موفقیت نیست. کاربر ممکن است tab را ببندد، redirect نرسد یا پارامترهای callback دست‌کاری شوند.

درگاه‌های مختلف روش‌های متفاوتی دارند. اگر Webhook امضاشده ارائه می‌شود، امضا باید مطابق مستندات provider و روی محتوای مورد انتظار او بررسی شود. اگر قرارداد به verify یا inquiry سمت سرور نیاز دارد، همان مسیر اجرا می‌شود. نبود امضا در یک provider به معنی اعتماد به داده مرورگر نیست.

در هر دو حالت، reference، مبلغ، واحد پول و حساب پذیرنده باید با attempt داخلی مطابقت داشته باشند.

برای Webhook معتبر، مسیر دریافت را کوتاه نگه می‌داریم: اعتبارسنجی، ذخیره پایدار در Inbox و سپس پاسخ مناسب. پردازش مالی سنگین بعداً انجام می‌شود. اگر ذخیره‌سازی شکست خورد، نباید به provider وانمود کنیم پیام را پذیرفته‌ایم.

در مستندات Webhookهای Stripe هم دریافت تکراری و نامرتب رویدادها مطرح شده است. بنابراین ترتیب رسیدن پیام‌ها را جایگزین قواعد state machine نمی‌کنیم.

دو تفاوت مهم وجود دارد:

  • received یعنی پیام را داریم؛ processed یعنی اثر آن با موفقیت ثبت شده است.

  • یکتایی event با یکتایی اثر مالی فرق دارد. دو event متفاوت ممکن است درباره یک capture واحد خبر بدهند.

برای اولی، شناسه رویداد در scope درست provider و حساب پذیرنده یکتا می‌شود. برای دومی، reference عملیات بیرونی و هویت ثبت Ledger محافظت مستقل دارند. رویداد معتبر با مرجع ناشناخته نیز در صف بررسی نگه داشته می‌شود؛ نه اینکه از روی آن خودکار یک پرداخت تازه بسازیم.

۸. کیف پول یک عدد نیست

فرض کنیم کاربر یک میلیون تومان موجودی ثبت‌شده دارد و ۲۰۰ هزار تومان آن برای یک برداشت رزرو شده است. هم‌زمان، شارژ دیگری به مبلغ ۱۵۰ هزار تومان هنوز تأیید نهایی نشده است.

در این طراحی:

Posted balance: 1,000,000 toman Active holds: 200,000 toman Available balance: 800,000 toman Pending incoming: 150,000 toman

مبلغ pending هنوز قابل خرج نیست. رزرو هم باید با یک عملیات مشخص، وضعیت و مسیر آزادسازی مرتبط باشد. کم‌کردن دستی یک ستون reserved_balance بدون داشتن رکورد hold، امکان توضیح و بازیابی را از بین می‌برد.

مدل دقیق می‌تواند متفاوت باشد. در این مقاله، posted_balance از ثبت‌های نهایی Ledger به دست می‌آید، holdهای فعال جدا نگه داشته می‌شوند و موجودی قابل خرج از ترکیب آن‌ها و محدودیت‌های دامنه محاسبه می‌شود.

این تعریف باید در API، دیتابیس و ابزار پشتیبانی یکسان باشد. وقتی هر تیم معنای متفاوتی برای «موجودی» داشته باشد، یک عدد ظاهراً درست هم می‌تواند تصمیم اشتباه ایجاد کند.

۹. Ledger، توضیح پایدار حرکت پول

دفترکل به سه مفهوم اصلی نیاز دارد: Account، Ledger Transaction و Ledger Entry. Account حسابی با نوع و واحد پول مشخص است. Transaction یک ثبت مالی واحد را گروه‌بندی می‌کند و Entry اثر آن را روی یک حساب نشان می‌دهد.

در ثبت دوطرفه، مجموع debit و credit هر تراکنش برای هر واحد پول برابر است. اما debit همیشه به معنی کاهش موجودی نیست؛ اثر آن به نوع حساب بستگی دارد.

از نگاه پلتفرم، موجودی کیف پول کاربر معمولاً بدهی پلتفرم به کاربر است. حساب دارایی بانکی ماهیت دیگری دارد. نمونه Digital Wallet در Modern Treasury همین تفاوت حساب دارایی و بدهی را نشان می‌دهد.

برای مثال، فرض کنیم کاربر از کیف پول خود یک میلیون تومان خرید می‌کند و کارمزد پلتفرم ۲۰ هزار تومان است. در مدل ساده این مقاله، ثبت زیر داریم:

Account Debit Credit User wallet liability 1,000,000 0 Merchant payable 0 980,000 Platform fee revenue 0 20,000 --------------------------------------------------- Total 1,000,000 1,000,000 Display unit: toman

بدهی ما به کاربر کم شده، بدهی به فروشنده افزایش یافته و سهم کارمزد جدا ثبت شده است. این مثال مالیات، ذخایر و قراردادهای پیچیده را مدل نمی‌کند؛ سرفصل‌ها و زمان شناسایی درآمد باید با مدل مالی واقعی محصول هماهنگ شوند.

شارژ کیف پول ثبت متفاوتی دارد: افزایش دارایی یا مطالبه از درگاه، در برابر افزایش بدهی به کاربر. بنابراین نباید یک الگوی علامت مثبت و منفی را بدون توجه به نوع حساب به همه عملیات تعمیم دهیم.

توازن لازم است، اما کافی نیست. تراکنشی که حساب اشتباه را با مبلغ اشتباه debit و credit کند، هنوز می‌تواند متوازن باشد. کنترل مبلغ، مالکیت حساب، واحد پول و reference کسب‌وکار در کنار توازن قرار می‌گیرند.

۱۰. داده مالی باید قابل بازسازی باشد

هسته مدل داده را می‌توان این‌طور خلاصه کرد:

ledger_accounts id, owner_id, account_type, currency, status ledger_transactions id, operation_id, posting_type, occurred_at, posted_at ledger_entries id, transaction_id, account_id, direction, amount account_balances account_id, posted_balance, reserved_balance, version wallet_holds id, account_id, operation_id, amount, status, expires_at

کنار این هسته، جدول‌های پرداخت، attempt، بازپرداخت، برداشت، تسویه، Inbox، Outbox و پرونده‌های تطبیق قرار می‌گیرند. هر ثبت مالی باید به عملیاتی مشخص برسد و هر account در یک واحد پول تعریف شود.

مبلغ را با integer در واحد پایه یا نوع decimal دقیق مانند NUMERIC نگه می‌داریم. در این طراحی، ذخیره و تبادل داخلی بر اساس ریال و currency = IRR است؛ تومان فقط در مثال‌های متنی و نمایش کاربر استفاده می‌شود. تبدیل واحد باید در مرز مشخص و فقط یک بار انجام شود.

رکوردهای Ledger پس از نهایی‌شدن تغییرناپذیرند. اشتباه با تراکنش جبرانی اصلاح می‌شود و reference ثبت قبلی حفظ می‌ماند. محدودیت UPDATE و DELETE در مسیر برنامه، کنترل دسترسی مدیریتی و ثبت اقدام‌های پشتیبانی از این اصل محافظت می‌کنند.

توازن چند Entry را نمی‌توان با یک CHECK ساده روی هر سطر تضمین کرد. مسیر posting باید تنها راه ثبت نهایی باشد و توازن، نوع حساب‌ها و یکتایی عملیات را در مرز تراکنش کنترل کند؛ بسته به پیاده‌سازی، این کنترل با routine دیتابیس یا constraint trigger مناسب و دسترسی محدود تقویت می‌شود.

۱۱. Balance یک projection است؛ اما projection حساس

محاسبه دوباره کل تاریخچه برای هر درخواست موجودی، در حساب‌های پرترافیک پرهزینه می‌شود. بنابراین یک مقدار materialized نگه می‌داریم تا خواندن و کنترل خرج سریع باشد.

برای موجودی قابل خرج، به‌روزرسانی projection را به یک consumer با تأخیر نامشخص نمی‌سپاریم. ثبت Ledger، تغییر موجودی مرتبط و تغییر hold در یک تراکنش محلی انجام می‌شوند. گزارش تحلیلی می‌تواند دیرتر به‌روز شود؛ مجوز خرج نمی‌تواند از همان قرارداد استفاده کند.

اگر اختلافی پیدا شد، rebuild فقط جمع‌زدن Ledger نیست. موجودی posted از دفترکل بازسازی می‌شود، اما holdهای فعال و محدودیت‌های حساب هم باید در محاسبه available دیده شوند.

بازیابی باید روی snapshot سازگار انجام شود. یا حساب موقتاً برای write متوقف می‌شود، یا فرآیند بازسازی با version و watermark مشخص، تغییرات هم‌زمان را پوشش می‌دهد. بازنویسی projection با نتیجه یک محاسبه قدیمی می‌تواند همان خطایی را ایجاد کند که قصد اصلاحش را داشتیم.

۱۲. دو درخواست هم‌زمان، یک موجودی مشترک

کاربر ۱۰۰ هزار تومان دارد. درخواست اول می‌خواهد ۸۰ هزار تومان خرج کند و درخواست دوم ۷۰ هزار تومان. اگر هر دو موجودی اولیه را بخوانند، هر دو ممکن است مجاز به نظر برسند.

مسئله با خواندن موجودی و سپس save() حل نمی‌شود. بررسی و تغییر باید تحت کنترل concurrency یکسان انجام شوند.

یک گزینه، قفل سطر با SELECT ... FOR UPDATE است. گزینه دیگر، به‌روزرسانی شرطی است:

UPDATE account_balances SET posted_balance = posted_balance - :amount, version = version + 1 WHERE account_id = :account_id AND :amount > 0 AND posted_balance - reserved_balance >= :amount RETURNING posted_balance, reserved_balance, version;

این قطعه فقط شرط موجودی در یک خرج مستقیم را نشان می‌دهد. به‌تنهایی پیاده‌سازی انتقال نیست. یکتایی عملیات، ثبت متوازن Ledger، تغییر حساب مقابل و Outbox باید در همان تراکنش تکمیل شوند؛ شکست هر بخش، همه تغییرات را rollback می‌کند.

در انتقال بین دو کیف پول، حساب‌ها را با ترتیب ثابتی، مثلاً بر اساس شناسه، lock می‌کنیم تا ریسک deadlock کاهش یابد. خود deadlock و serialization failure هم باید با retry محدودِ کل تراکنش و همان هویت عملیات مدیریت شوند. رفتار قفل‌های سطری در مستندات PostgreSQL توضیح داده شده است.

Optimistic locking برای contention پایین مفید است، اما روی حساب بسیار داغ می‌تواند retry زیادی بسازد. سطح SERIALIZABLE هم ممکن است برای invariantهای پیچیده مناسب باشد، ولی نیاز به رسیدگی به serialization failure را حذف نمی‌کند.

۱۳. رزرو، برداشت و خرج نهایی را جدا کنیم

برداشت از کیف پول یک عملیات بیرونی است. نمی‌توانیم موجودی را آزاد بگذاریم تا بانک نتیجه بدهد، چون کاربر ممکن است همان مبلغ را جای دیگری خرج کند.

ابتدا مبلغ در یک hold پایدار رزرو می‌شود. ساخت hold، بررسی موجودی و تغییر projection داخل تراکنش انجام می‌شوند. سپس درخواست انتقال با reference ثابت و خارج از lock دیتابیس به provider ارسال می‌شود.

اگر موفقیت تأیید شد، hold مصرف می‌شود و ثبت مالی نهایی انجام می‌گیرد. اگر شکست قطعی بود، hold آزاد می‌شود. در نتیجه نامشخص، مبلغ همچنان رزرو می‌ماند تا inquiry یا بررسی عملیاتی وضعیت را روشن کند.

انقضای زمانی hold به‌تنهایی مجوز آزادکردن پولی نیست که شاید انتقالش موفق شده باشد. پس job انقضا باید وضعیت attempt بیرونی را در نظر بگیرد. Timer برای کشف عملیات متوقف‌شده مفید است؛ جای شواهد نتیجه انتقال را نمی‌گیرد.

۱۴. مرز تراکنش، جایی است که صحت را حفظ می‌کنیم

فرض کنیم شواهد معتبر capture دریافت شده و حالا باید نتیجه را داخل سیستم ثبت کنیم. ترتیب کلی چنین است:

BEGIN claim business operation / check prior result lock payment and affected balance rows validate evidence, amount, currency and transition post balanced ledger transaction update related balance projections update payment state mark input processed insert outbox event COMMIT

این شبه‌کد مرز atomic را توضیح می‌دهد. شواهدی که پیش‌تر از provider گرفته‌ایم، هنگام ثبت با وضعیت فعلی دوباره تطبیق داده می‌شوند تا یک worker قدیمی نتواند تصمیم ناسازگار اعمال کند.

تماس با درگاه، بانک، broker یا سرویس پیامک را داخل این تراکنش طولانی نگه نمی‌داریم. قطع ارتباط بیرونی نباید lockهای مالی را برای چند ثانیه یا چند دقیقه اشغال کند.

اگر نتیجه خود COMMIT هم به دلیل قطع اتصال نامشخص شد، فرض نمی‌کنیم rollback رخ داده است. عملیات را با شناسه پایدارش جست‌وجو می‌کنیم و retry از همان قرارداد idempotency عبور می‌کند.

۱۵. Outbox و Inbox دو شکاف متفاوت را می‌بندند

بعد از commit، باید خبر پرداخت را به سرویس‌های دیگر برسانیم. اگر ابتدا دیتابیس را تغییر دهیم و سپس پیام منتشر کنیم، crash میان این دو کار می‌تواند پیام را گم کند.

Outbox قصد انتشار را همراه تغییر مالی در همان تراکنش ثبت می‌کند. Dispatcher بعداً پیام را می‌فرستد و پس از تأیید broker، آن را منتشرشده علامت می‌زند.

اما اگر ارسال موفق شود و Dispatcher پیش از ثبت علامت crash کند، پیام دوباره منتشر خواهد شد. بنابراین Outbox به‌تنهایی تحویل یک‌باره نمی‌سازد. این رفتار در شرح Transactional Outbox نیز مطرح شده است.

سمت مصرف‌کننده، شناسه پیام و اثر محلی آن در یک تراکنش ثبت می‌شوند. برای consumerهای مستقل می‌توان از هویت ترکیبی استفاده کرد:

UNIQUE (consumer_name, message_id)

اگر consumer بعد از commit و قبل از ack از دسترس خارج شود، اجرای بعدی متوجه ثبت قبلی می‌شود. ولی اگر اثر اصلی تماس با سرویس بیرونی باشد، Inbox محلی به‌تنهایی کافی نیست؛ آن تماس هم به attempt پایدار، idempotency یا inquiry نیاز دارد.

هدف عملی، اعمال یک‌باره اثر مالی در مرزی است که کنترلش می‌کنیم. کد ممکن است چند بار اجرا شود و پیام ممکن است چند بار برسد. این تفاوت را باید در طراحی و ادعاهای مربوط به exactly-once روشن نگه داریم.

۱۶. بازپرداخت جزئی، یک race پنهان دارد

فرض کنیم از یک پرداخت یک‌میلیون‌تومانی، دو درخواست بازپرداخت ۶۰۰ هزار تومانی هم‌زمان برسند. اگر فقط مجموع refundهای موفق را بررسی کنیم، هر دو ممکن است پذیرفته شوند؛ چون هنوز هیچ‌کدام موفق نشده‌اند.

بنابراین ظرفیت بازپرداخت فقط با نتیجه‌های نهایی کنترل نمی‌شود. درخواست‌های پذیرفته‌شده و حل‌نشده هم تعهد ایجاد کرده‌اند:

successful_refunds + reserved_nonfinal_refunds <= refundable_limit

هر refund دقیقاً در یکی از این دو گروه قرار می‌گیرد؛ یک مبلغ را هم‌زمان در هر دو جمع نمی‌زنیم. refundable_limit نیز از capture معتبر و قواعد محصول به دست می‌آید.

در تراکنش محلی، Payment را lock می‌کنیم، ظرفیت را می‌سنجیم، مبلغ refund را رزرو می‌کنیم و فرمان بیرونی را پایدار ثبت می‌کنیم. تماس provider بعد از commit انجام می‌شود.

نتیجه موفق، ثبت جبرانی تازه می‌سازد. شکست قطعی، رزرو را آزاد می‌کند. Timeout، refund را به وضعیت UNKNOWN یا RECONCILING می‌برد و رزرو را تا روشن‌شدن نتیجه نگه می‌دارد.

اگر وجه قبلاً با فروشنده تسویه شده باشد، بازپرداخت ممکن است از ذخیره، موجودی قابل پرداخت بعدی یا یک مطالبه از فروشنده تأمین شود. این تصمیم باید سیاست مالی روشن داشته باشد؛ تغییر پنهانی تاریخچه تسویه راه‌حل نیست.

Reversal، لغو authorization و Refund نیز در همه providerها معنای یکسانی ندارند. آزادکردن مجوز پرداختِ captureنشده با بازگرداندن وجه دریافت‌شده دو جریان متفاوت است و Adapter باید تفاوت قراردادها را حفظ کند.

۱۷. تسویه یعنی انتخاب دقیقِ پولی که هنوز پرداخت نشده است

فرض کنیم فروشنده ۱۰۰ میلیون تومان فروش، ۵ میلیون تومان بازپرداخت و ۲ میلیون تومان کارمزد دارد. در مدل ساده، مبلغ قابل پرداخت ۹۳ میلیون تومان است. اما این عدد به‌تنهایی برای ساخت انتقال بانکی کافی نیست.

باید بدانیم کدام اقلام واجد شرایط‌اند، چه چیزهایی قبلاً تسویه شده‌اند و چه مبالغی به دلیل رزرو یا اختلاف مالی فعلاً کنار گذاشته شده‌اند. محاسبه نیز نباید کارمزدی را که قبلاً از payable کم شده، دوباره کسر کند.

در این طراحی، batch از اقلام مشخص ساخته می‌شود. settlement_items هر قلم را به batch مربوط وصل می‌کند و constraint اجازه تخصیص دوباره همان قلم را نمی‌دهد. مبلغ و محتوای batch پس از تأیید freeze می‌شوند.

CALCULATED -> APPROVED -> TRANSFER_REQUESTED -> PROCESSING -> PAID -> FAILED_CONFIRMED -> UNKNOWN

پاسخ «درخواست پذیرفته شد» از بانک، لزوماً به معنای انتقال نهایی نیست. در نتیجه نامشخص، قبل از ارسال دوباره انتقال، با همان reference استعلام می‌گیریم. اگر provider امکان تشخیص امن را ندهد، پرونده به بررسی عملیاتی می‌رود.

تسویه باید از اقلام واجد شرایط دفترکل و تخصیص‌های پایدار ساخته شود، نه از یک query متغیر که هر بار نتیجه دیگری برای همان batch برمی‌گرداند.

۱۸. Reconciliation، راه پیدا کردن واقعیت بعد از اختلاف

برگردیم به کاربر ابتدای مقاله. درگاه پرداخت را موفق می‌داند، اما Payment داخلی هنوز UNKNOWN است. منتظرماندن همیشگی برای Webhook، مسیر بازیابی محسوب نمی‌شود.

Reconciliation وضعیت داخلی را با inquiry، گزارش provider و در صورت نیاز گزارش بانکی مقایسه می‌کند. برخی بررسی‌ها پیوسته و برخی وابسته به زمان آماده‌شدن فایل‌های تسویه‌اند.

نمونه اختلاف‌ها شامل پرداخت بیرونی بدون ثبت داخلی، مبلغ نامنطبق، refund حل‌نشده، انتقال بانکی بدون تأیید نهایی و اختلاف projection با Ledger است. همه این موارد یک علت و یک راه‌حل ندارند.

برای هر اختلاف، یک پرونده پایدار می‌سازیم:

internal_reference, external_reference expected_amount, observed_amount, currency discrepancy_type, evidence_reference status, first_seen_at, last_checked_at resolution, resolved_by, resolved_at

اگر موفقیت بیرونی تأیید شده، هویت و مبلغ دقیقاً مطابق‌اند و اثر داخلی وجود ندارد، recovery از همان command کنترل‌شده posting عبور می‌کند. مسیر تطبیق نباید راه دومی برای دورزدن invariantها بسازد.

اختلاف مبلغ، مرجع ناشناخته یا شواهد متناقض به review می‌رود. Operator باید دلیل، مدارک و اقدام مجاز را ببیند. در صورت نیاز به اصلاح، ثبت جبرانی با reference پرونده ساخته می‌شود؛ سطر قبلی دفترکل دست‌کاری نمی‌شود.

۱۹. Saga کجا کمک می‌کند؟

سفارش، موجودی کالا، درگاه و بانک معمولاً در یک دیتابیس نیستند. در این مرزها به workflow نیاز داریم: مثلاً رزرو کالا، گرفتن مجوز پرداخت، تأیید سفارش و capture، البته فقط اگر provider چنین قابلیت‌هایی داشته باشد.

اگر یک مرحله شکست خورد، باید اقدام جبرانی متناسب انجام شود. این اقدام همیشه rollback فوری نیست؛ آزادسازی رزرو، لغو authorization یا refund می‌تواند زمان ببرد یا خودش نامشخص شود.

Saga همین مراحل و جبران‌ها را هماهنگ می‌کند. اما ثبت Ledger و تغییر موجودی داخل یک PostgreSQL را بی‌دلیل به چند مرحله غیرهم‌زمان تقسیم نمی‌کنیم. آنجا تراکنش محلی همچنان ابزار مناسب است.

Workflow engine برای برداشت، refund یا تسویه طولانی می‌تواند timer، retry و وضعیت پایدار را سامان دهد. با این حال، اجرای دوباره activity هنوز باید امن باشد. داشتن موتور workflow، یک تماس بانکی غیرقابل‌تکرار را خودکار idempotent نمی‌کند.

۲۰. همان پرداخت، این بار با crash و پیام تکراری

حالا ببینیم اجزای طراحی در سناریوی اولیه چگونه کنار هم قرار می‌گیرند.

شکل ۲ — Timeout به وضعیت نامشخص می‌رسد. استعلام، شواهد بیرونی را روشن می‌کند و موفقیت تأییدشده از مسیر ثبت یکتای داخلی عبور می‌کند؛ نتیجه مبهم به retry کورکورانه تبدیل نمی‌شود.
شکل ۲ — Timeout به وضعیت نامشخص می‌رسد. استعلام، شواهد بیرونی را روشن می‌کند و موفقیت تأییدشده از مسیر ثبت یکتای داخلی عبور می‌کند؛ نتیجه مبهم به retry کورکورانه تبدیل نمی‌شود.


۱. پیش از تماس با درگاه، Intent و attempt پایدار ساخته شده‌اند؛ بنابراین درخواست بی‌هویت نداریم.

۲. پاسخ درگاه گم می‌شود. attempt نامشخص می‌ماند و کاربر به صفحه پیگیری همان Payment برمی‌گردد.

۳. رویداد معتبر می‌رسد و در Inbox ذخیره می‌شود. Processor شواهد را با پرداخت تطبیق می‌دهد.

۴. Ledger، موجودی مرتبط، وضعیت Payment و Outbox با هم commit می‌شوند. Worker قبل از ack از دسترس خارج می‌شود.

۵. پیام دوباره می‌رسد. Inbox و هویت مستقل ثبت مالی مانع اثر دوباره می‌شوند.

۶. Kafka موقتاً در دسترس نیست. Outbox باقی می‌ماند تا Dispatcher بعداً انتشار را انجام دهد. اگر انتشار تکرار شود، مصرف‌کننده‌ها هم باید تکرار را تحمل کنند.

۷. Reconciliation نتیجه درگاه، Payment و ثبت Ledger را با هم تطبیق می‌دهد. پیامک ناموفق می‌تواند جداگانه retry شود؛ موفقیت مالی به موفقیت پیامک وابسته نیست.

این جریان تضمین نمی‌کند هیچ خرابی رخ ندهد. تضمین مورد نظر این است که اثر مالی در مرز تعریف‌شده محافظت شود و کار باقی‌مانده هویت، وضعیت و مسیر بازیابی داشته باشد.

۲۱. خرابی باید به اقدام مشخص برسد

چند سناریوی دیگر طراحی را دقیق‌تر می‌کنند:

  • دیتابیس وسط commit قطع می‌شود: نتیجه را با شناسه عملیات بررسی می‌کنیم. قطع اتصال را معادل rollback فرض نمی‌کنیم.

  • Ledger نوشته می‌شود، اما به‌روزرسانی موجودی خطا می‌دهد: چون در یک تراکنش‌اند، هیچ‌کدام به‌تنهایی commit نمی‌شوند.

  • یک event قدیمی پس از موفقیت می‌رسد: با قواعد transition و شواهد provider بررسی می‌شود؛ وضعیت موفق به‌خاطر ترتیب دریافت بازنویسی نمی‌شود.

  • درگاه refund را انجام داده، اما پاسخ نرسیده است: ظرفیت refund رزرو می‌ماند و inquiry با reference قبلی ادامه پیدا می‌کند.

  • Outbox عقب افتاده است: عمر قدیمی‌ترین پیام منتشرنشده اندازه‌گیری می‌شود. اگر backlog ظرفیت ذخیره‌سازی یا زمان قابل قبول را تهدید کند، محدودسازی ورودی بخشی از پاسخ عملیاتی است.

  • Operator اصلاح اشتباه درخواست می‌کند: نقش مجاز، دلیل، reference و برای اقدام‌های حساس تأیید دوم لازم است. ابزار پشتیبانی حق تغییر مستقیم ثبت‌های نهایی را ندارد.

برای این مسیرها، آزمون فقط happy path نیست. باید درخواست هم‌زمان با کلید یکسان، refundهای هم‌زمان، crash قبل و بعد از commit، پیام تکراری، پاسخ دیرهنگام و failover را آزمایش کنیم. این‌ها معیارهای پیشنهادی ارزیابی طراحی‌اند؛ کدهای مقاله پیاده‌سازی کامل یا آزموده‌شده یک محصول نیستند.

۲۲. امنیت و تقلب، با مسئولیت‌های روشن

Authorization باید روی هر عملیات اعمال شود. داشتن payment_id نباید به کاربر دیگری اجازه دیدن پرداخت بدهد و فروشنده نباید به تسویه فروشنده دیگر دسترسی پیدا کند.

مسیر دریافت داده کارت را تا حد امکان به provider معتبر بسپاریم و فقط token یا reference لازم را نگه داریم. این انتخاب به‌تنهایی اثبات انطباق با الزامات پرداخت نیست؛ دامنه الزامات به جریان واقعی داده و قراردادها بستگی دارد.

کلیدهای درگاه باید با دسترسی محدود، rotation و سازوکار مدیریت secrets نگهداری شوند. داده‌های حساس نباید وارد log، payload رویدادهای عمومی یا خروجی ابزار پشتیبانی شوند. نگهداری Webhook و گزارش خام نیز به حداقل‌سازی داده، دسترسی کنترل‌شده و retention مشخص نیاز دارد.

برای جلوگیری از سوءاستفاده، ایجاد پرداخت، انتقال و برداشت محدودیت متناسب می‌خواهند. در Webhook هم کنترل ورودی لازم است، اما محدودسازی باید با الگوی retry provider سازگار باشد تا رویدادهای معتبر را به‌طور گسترده از دست ندهیم.

Risk engine می‌تواند برای گردش سریع شارژ و برداشت، مقصد مشکوک یا نشانه‌های تصاحب حساب، تصمیم REVIEW، احراز هویت تکمیلی یا تأخیر برداشت بدهد. اما تصمیم ریسک، اجازه شکستن invariantهای Ledger نیست. حتی مسدودکردن وجه هم باید وضعیت و دلیل قابل پیگیری داشته باشد.

۲۳. تیم عملیات باید بداند چه چیزی در خطر است

نمودار CPU برای عیب‌یابی مفید است، اما به‌تنهایی وضعیت مالی را توضیح نمی‌دهد. تیم باید بداند چه تعداد کاربر منتظر نتیجه‌اند، چه مبلغی در وضعیت نامشخص است و کدام provider بیشترین تأخیر را ایجاد کرده است.

سنجه‌های مهم این طراحی عبارت‌اند از:

  • تعداد، مبلغ و عمر Payment، Refund و Withdrawalهای نامشخص.

  • اختلاف‌های Reconciliation به تفکیک علت و provider.

  • شکست invariantهای Ledger و اختلاف projection.

  • عمر قدیمی‌ترین Inbox پردازش‌نشده و Outbox منتشرنشده.

  • نرخ timeout و خطای verify درگاه.

  • lock wait، deadlock و تعارض‌های موجودی.

  • اقلام تسویه‌ای که از پنجره مورد انتظار عبور کرده‌اند.

Alert باید اقدام‌پذیر باشد. «پرداخت‌های نامشخص بیش از ده دقیقه با مبلغ در معرض ریسک رو به افزایش» مسئله مشخص‌تری از «تعداد خطا زیاد شده» نشان می‌دهد. آستانه‌ها هم باید بر اساس رفتار provider و حساسیت عملیات تعیین شوند.

Application log برای تشخیص خطای اجراست. Audit trail برای توضیح اقدام مالی و مسئول آن است. یک correlation_id می‌تواند درخواست، attempt، رویداد و ثبت مالی را به هم وصل کند، اما به‌تنهایی جای تاریخچه مالی را نمی‌گیرد.

دوام داده نیز به backup و بازیابی آزموده‌شده نیاز دارد. replication غیرهم‌زمان ممکن است هنگام failover آخرین writeها را از دست بدهد؛ بنابراین ادعای دوام باید با تنظیمات واقعی، مدل خرابی، RPO/RTO و تمرین restore سازگار باشد.

۲۴. رشد سیستم از اندازه‌گیری شروع می‌شود

پیش از sharding، queryهای واقعی، ایندکس‌ها، مدت تراکنش، connection pooling و حساب‌های پرترافیک را بررسی می‌کنیم. تاریخچه کاربر، پیدا کردن reference درگاه و خواندن Outbox هرکدام ایندکس متفاوتی می‌خواهند.

Read replica برای گزارش و تاریخچه مناسب است، اگر تأخیر آن در قرارداد خواندن پذیرفته شده باشد. تصمیم خرج یا بررسی نتیجه بلافاصله پس از write نباید بی‌اطلاع از lag به replica فرستاده شود.

Partitioning زمانی Ledger می‌تواند نگهداری و آرشیو را ساده‌تر کند، اما query طولانی یک account ممکن است همچنان چند partition را بخواند. یکتایی referenceهای مالی نیز باید با محدودیت‌های topology جدید سازگار بماند؛ partition کردن نباید محافظت در برابر ثبت تکراری را تضعیف کند.

در

Kafka، event_id برای deduplication، aggregate_id برای هویت موجودیت، aggregate_version برای تشخیص ترتیب منطقی و correlation_id برای پیگیری جریان مفیدند. Ordering درون partition را نباید ترتیب سراسری فرض کنیم؛ Publisher و consumer هم باید نسخه‌ها و ترتیب مورد نیاز دامنه را رعایت کنند.

اگر روزی حساب‌ها روی shardهای جدا قرار گرفتند، انتقال بین دو حساب دیگر همان تراکنش محلی ساده نیست. به مدل رزرو، حساب واسط، هماهنگی و بازیابی تازه نیاز داریم. همچنین تمرکز همه فروش‌ها روی یک حساب درآمد مرکزی می‌تواند گلوگاه بسازد؛ جزئیات حسابداری و تجمیع باید این فشار را در نظر بگیرند.

مسیر تکامل پیشنهادی چنین است: ابتدا هسته مالی درست با PostgreSQL، Inbox، Outbox و worker؛ سپس چند provider، ابزار تطبیق و تسویه قوی‌تر؛ و بعد، در صورت وجود فشار اندازه‌گیری‌شده، استخراج سرویس یا تغییر توزیع داده.

۲۵. قرارداد API باید وضعیت واقعی را نشان دهد

API کاربر را به نام‌های داخلی state machine وابسته نمی‌کند، اما نباید ابهام مالی را هم پنهان کند. ایجاد درخواست برداشت با انتقال موفق وجه تفاوت دارد.

POST /v1/payments GET /v1/payments/{id} POST /v1/payments/{id}/refunds GET /v1/wallets/{id} GET /v1/wallets/{id}/transactions POST /v1/wallets/{id}/transfers POST /v1/withdrawals GET /v1/merchants/{id}/settlements POST /v1/webhooks/{provider}

عملیات تغییردهنده مالی قرارداد idempotency دارند. اگر endpoint تأیید لازم است، کلاینت فقط درخواست بررسی می‌دهد؛ نمی‌تواند خودش CAPTURED را انتخاب کند.

پاسخ موجودی می‌تواند زمان و نسخه داده را نیز نشان دهد:

{ "posted_balance": 10000000, "reserved_balance": 2000000, "available_balance": 8000000, "pending_incoming": 1500000, "currency": "IRR", "version": 42, "as_of": "2026-09-11T10:00:00Z" }

این نمونه همان اعداد مثال کیف پول است، اما در واحد ذخیره‌سازی ریال. برای انتقالی که هنوز بررسی می‌شود، پاسخ پذیرش به همراه شناسه و مسیر پیگیری مناسب‌تر از اعلام موفقیت زودهنگام است.

جمع‌بندی

کاربر ابتدای مقاله یک میلیون تومان پرداخت کرده بود و پاسخ روشنی ندیده بود. طراحی خوب باید بتواند بعداً توضیح دهد آن پرداخت به کدام Intent مربوط بوده، درگاه چه شواهدی داده، کدام ثبت مالی ایجاد شده و آیا وجه با فروشنده تسویه یا به کاربر بازگردانده شده است.

برای رسیدن به این پاسخ، Ledger متوازن و تغییرناپذیر، موجودی قابل بازسازی، کنترل concurrency، هویت پایدار عملیات و stateهای قابل بازیابی لازم‌اند. Outbox و Inbox فاصله میان commit و پیام‌رسانی را مدیریت می‌کنند و Reconciliation اختلاف با سیستم‌های بیرونی را دنبال می‌کند.

ارزش این معماری زمانی مشخص می‌شود که پاسخ گم شده، پیام دوباره رسیده یا Worker از دسترس خارج شده است. در چنین شرایطی، سیستم باید هنوز بتواند حرکت پول را توضیح دهد و اقدام بعدی را با شواهد انتخاب کند.

همین قابلیت، یک مجموعه API پرداخت را به هسته مالی قابل اتکا نزدیک می‌کند.

منابع فنی

  • Stripe — Webhooks: امضا، پردازش رویدادهای تکراری و ترتیب دریافت.

  • Stripe — Idempotent requests: قرارداد کلید و پارامترهای درخواست.

  • PostgreSQL — Explicit Locking: قفل سطری، تعارض و deadlock.

  • Modern Treasury — Digital Wallet: حساب‌های دارایی و بدهی در مثال کیف پول.

  • Microservices.io — Transactional Outbox: ثبت قصد انتشار همراه تراکنش و احتمال ارسال تکراری.

کیف پولسیستم دیزاینسیستم مالیبک اند
۵
۰
افشین یاری نیا
افشین یاری نیا
شاید از این پست‌ها خوشتان بیاید