
کاربر یک میلیون تومان پرداخت میکند. بانک مبلغ را از حسابش کم میکند، اما پاسخ درگاه به سرور ما نمیرسد. صفحه همچنان در حال بارگذاری است و کاربر دوباره روی «پرداخت» میزند.
چند ثانیه بعد، یک رویداد موفقیت از درگاه میرسد. 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 شوند، هنوز داخل یک تراکنش محلی باقی میمانند.

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 این تصمیم داخلی را نگه میدارد.
مبلغ از سفارش یا محاسبه معتبر سرور استخراج میشود. 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 تازهای روی درگاه دیگری بسازیم. ابتدا باید بفهمیم تلاش قبلی قابل بازیابی است یا نه؛ وگرنه یک خرید میتواند به دو پرداخت واقعی تبدیل شود.
یک ستون آزاد به نام 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 جا نمیدهیم. پرداختی که قبلاً تسویه شده، ممکن است بعداً بازپرداخت بخورد. در این طراحی، وضعیت دریافت وجه، چرخه بازپرداخت و چرخه تسویه جدا هستند و وضعیت نمایشی کاربر از ترکیب آنها ساخته میشود.
فرض کنید کاربر همان دکمه پرداخت را دوباره میزند. اگر دو درخواست به یک تصمیم کسبوکار مربوط باشند، باید به همان عملیات برسند.
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های دیگر باید جداگانه بررسی شود.
بازگشت مرورگر از درگاه برای هدایت کاربر مفید است، اما دلیل کافی برای ثبت موفقیت نیست. کاربر ممکن است 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، دیتابیس و ابزار پشتیبانی یکسان باشد. وقتی هر تیم معنای متفاوتی برای «موجودی» داشته باشد، یک عدد ظاهراً درست هم میتواند تصمیم اشتباه ایجاد کند.
دفترکل به سه مفهوم اصلی نیاز دارد: 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 مناسب و دسترسی محدود تقویت میشود.
محاسبه دوباره کل تاریخچه برای هر درخواست موجودی، در حسابهای پرترافیک پرهزینه میشود. بنابراین یک مقدار 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 عبور میکند.
بعد از 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 روشن نگه داریم.
فرض کنیم از یک پرداخت یکمیلیونتومانی، دو درخواست بازپرداخت ۶۰۰ هزار تومانی همزمان برسند. اگر فقط مجموع 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 برمیگرداند.
برگردیم به کاربر ابتدای مقاله. درگاه پرداخت را موفق میداند، اما 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 پرونده ساخته میشود؛ سطر قبلی دفترکل دستکاری نمیشود.
سفارش، موجودی کالا، درگاه و بانک معمولاً در یک دیتابیس نیستند. در این مرزها به workflow نیاز داریم: مثلاً رزرو کالا، گرفتن مجوز پرداخت، تأیید سفارش و capture، البته فقط اگر provider چنین قابلیتهایی داشته باشد.
اگر یک مرحله شکست خورد، باید اقدام جبرانی متناسب انجام شود. این اقدام همیشه rollback فوری نیست؛ آزادسازی رزرو، لغو authorization یا refund میتواند زمان ببرد یا خودش نامشخص شود.
Saga همین مراحل و جبرانها را هماهنگ میکند. اما ثبت Ledger و تغییر موجودی داخل یک PostgreSQL را بیدلیل به چند مرحله غیرهمزمان تقسیم نمیکنیم. آنجا تراکنش محلی همچنان ابزار مناسب است.
Workflow engine برای برداشت، refund یا تسویه طولانی میتواند timer، retry و وضعیت پایدار را سامان دهد. با این حال، اجرای دوباره activity هنوز باید امن باشد. داشتن موتور workflow، یک تماس بانکی غیرقابلتکرار را خودکار idempotent نمیکند.
حالا ببینیم اجزای طراحی در سناریوی اولیه چگونه کنار هم قرار میگیرند.

۱. پیش از تماس با درگاه، 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 کاربر را به نامهای داخلی 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: ثبت قصد انتشار همراه تراکنش و احتمال ارسال تکراری.