Domain Engineering · مهندسی دامنه سنیورSenior ~110 دقیقه مطالعه~98 min read
مهندسی سیستمهای بانکی و پرداختEngineering Banking & Payment Systems
از مدل درست پول و BigDecimal تا دفترداری دوطرفه، چرخهی کامل authorisation تا settlement، idempotency و حل تایماوت نامعلوم، مغایرتگیری روزانه، قفلگذاری روی حساب، ISO 8583 فیلد به فیلد، توکنسازی و PCI-DSS، واقعیت core banking و یک معماری مرجع Spring — همراه با پاسخهای مصاحبهای که کارفرمای بانکی انتظار دارد.From the money model and BigDecimal to double-entry bookkeeping, the full authorisation-to-settlement lifecycle, idempotency and resolving the unknown timeout, daily reconciliation, locking on account rows, ISO 8583 field by field, tokenization and PCI-DSS scope, core banking reality and a Spring reference architecture — with the interview answers banking and fintech employers expect.
پیشنیاز:Prerequisites: تراکنش، ACID، سطوح ایزوله و MVCCTransactions, ACID, Isolation & MVCCمدیریتِ داده: Database-per-Service، Saga، Outbox و CQRSData Management: DB-per-Service, Saga, Outbox, CQRS
اگر آگهیهای استخدام بانک، فینتک، شرکتهای PSP و شرکتهای زیرساخت پرداخت را بخوانی، یک الگوی تکراری میبینی: مهارتهای جاوا و Spring را «پیشفرض» فرض کردهاند، و چیزی که واقعاً دنبالش میگردند دانش دامنه است. جملههایی مثل «آشنایی با دفترداری دوطرفه»، «تجربهی کار با ISO 8583»، «مغایرتگیری»، «settlement»، «idempotency در تراکنش مالی». اینها اسم رمز نیستند؛ یک بدنهی دانشی منسجماند که هیچکس در دانشگاه به تو یاد نداده و تقریباً هیچ آموزش جاوایی هم پوششش نمیدهد.
خبر خوب این است که این دانش یاد گرفتنی است و آنقدرها هم بزرگ نیست. خبر بد این است که اگر نداشته باشی، در پانزده دقیقهی اول مصاحبه لو میروی — چون سؤالهای غربالگرِ این حوزه بیرحمانه سادهاند: «چرا برای پول از double استفاده نمیکنی؟»، «اگر تایماوت گرفتی و نمیدانی پول کم شده یا نه چه میکنی؟»، «موجودی حساب را کجا نگه میداری؟».
این فصل از صفر شروع میکند. فرض میگیرم هیچوقت در حوزهی مالی کار نکردهای و حتی نمیدانی «بدهکار» و «بستانکار» یعنی چه. تا انتهای فصل باید بتوانی یک سرویس پرداخت با دفتر کل درست، مرز API خنثینسبتبهتکرار، و job مغایرتگیری طراحی کنی — و در مصاحبه در موردش دفاع کنی.
۱) مدل پول — چرا اعشار شناور جنایت است، BigDecimal با scale و RoundingMode صریح، واحد خرد (minor unit)، تقسیم مبلغ و توزیع باقیمانده، و ذخیرهی پول در دیتابیس.
۲) دفترداری دوطرفه — حساب، بدهکار، بستانکار، سند حسابداری، دفتر کل فقط-افزودنی، تراز آزمایشی، و چرا ستون balance قابل تغییر یک باگ منتظر وقوع است.
۳) چرخهی عمر تراکنش — authorisation، capture، clearing، settlement، refund، reversal، chargeback، dispute — و اینکه در هر مرحله پول واقعاً کجاست.
۴) Idempotency — کلید تکرارناپذیری، اثر انگشت درخواست، توهم exactly-once، و تایماوتی که نمیدانی موفق بوده یا نه.
۵) مغایرتگیری (reconciliation) — مقایسهی دفتر تو با فایل سوییچ یا صورتحساب بانک، استراتژیهای تطبیق، پنجرهی تحمل، دستهبندی مغایرتها، با SQL واقعی.
۶) همزمانی — قفل بدبینانه در برابر خوشبینانه روی سطر حساب، سطح ایزولاسیون، بهروزرسانی گمشده، ترتیب قفلگیری و deadlock.
۷) کارت و سوییچ — ISO 8583 فیلد به فیلد، HSM و PIN block، ماسک PAN، توکنسازی و کاهش دامنهی PCI-DSS.
۸) واقعیت core banking — پردازش پایان روز، cut-off، تاریخ ارزش در برابر تاریخ ثبت، رابطهای فایلی و SOAP.
۹) وظایف نظارتی — رد حسابرسی، KYC/AML، سقف تراکنش، نگهداشت داده، تأیید چهارچشمی، تفکیک وظایف.
۱۰) مدیریت خطا و معماری مرجع با نمودار، و در پایان واقعیت مصاحبه.
بعضی موضوعات را عمداً اینجا دوباره درس نمیدهم چون فصل اختصاصی دارند: الگوی Saga و Transactional Outbox و CQRS در فصل «داده در میکروسرویس» هستند، جزئیات رمزنگاری HSM و چرخهی عمر کلید در فصل «مدیریت کلید و رمز»، تراکنش و ACID در فصل «تراکنش و ACID»، و WSDL/SOAP در فصل «SOAP و SOA». اینجا فقط به آنها ارجاع میدهم و روی چیزی تمرکز میکنم که هیچجای دیگر نیست: دامنهی پول.
۱. مدل پول: اولین جایی که مهندسها میمیرند
۱.۱ چرا double برای پول ممنوع است
تصور کن یک خطکش داری که فقط میتواند کسرهایی با مخرج توان دو را نشان دهد: نصف، یکچهارم، یکهشتم، یکشانزدهم. حالا از تو میخواهند «یکدهم متر» را علامت بزنی. نزدیکترین خط را میزنی — کمی جلوتر یا عقبتر از یکدهم واقعی. یک بار مهم نیست. ولی اگر هزار بار این کار را بکنی و خطاها روی هم جمع شوند، آخر کار چند سانتیمتر پرت شدهای.
double و float دقیقاً همان خطکشاند: عدد را در مبنای دو ذخیره میکنند. 0.1 در مبنای دو یک کسر متناوب بیپایان است، درست مثل 1/3 در مبنای ده. پس ذخیرهاش دقیق نیست — تقریبی است.
بیایید ببینیم در عمل چه شکلی است:
public class WhyNotDouble {
public static void main(String[] args) {
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(1.03 - 0.42); // 0.6100000000000001
System.out.println(0.1 + 0.1 + 0.1 == 0.3); // false
double balance = 0.0;
for (int i = 0; i < 10_000; i++) balance += 0.01;
System.out.println(balance); // 100.00000000000163
}
}
آن 0.00000000000163 تومان اضافه بیضرر به نظر میرسد. نیست. در یک سیستم بانکی این عدد یعنی:
- تراز آزمایشی (trial balance) صفر نمیشود و حسابداری روز بسته نمیشود.
- جمع ریز تراکنشها با جمع کل صورتحساب یکی نمیشود و مغایرتگیری خودکار fail میکند.
- مقایسهی
if (balance == 0)هیچوقت درست نمیشود و حساب صفرشده هرگز بسته نمیشود.
یک خطای رایج این است: «محاسبات را با BigDecimal میکنیم ولی در DTO خروجی double میگذاریم، فقط برای JSON». همانجا هم میسوزی — سریالسازی 0.1 + 0.2 به 0.30000000000000004 در پاسخ API، در گزارش مشتری ظاهر میشود. در DTO هم BigDecimal یا String یا long (واحد خرد) بگذار. در اسکیمای JSON، عدد پول را بهصورت رشته منتقل کردن یک الگوی رایج و درست است چون بعضی پارسرها (از جمله JavaScript) هر عدد JSON را به double تبدیل میکنند و بالای ۲^۵۳ دقت را از دست میدهند.
۱.۲ BigDecimal — ولی با scale و RoundingMode صریح
BigDecimal عدد را بهصورت یک عدد صحیح بزرگ (unscaledValue) بهعلاوهی یک scale (تعداد رقم اعشار) نگه میدارد. یعنی 12.34 میشود unscaledValue = 1234, scale = 2. چون مبنا ده است، 0.1 دقیقاً 0.1 است.
سه قانون که اگر رعایت نکنی BigDecimal هم به تو خیانت میکند:
قانون یک: هیچوقت از سازندهی BigDecimal(double) استفاده نکن.
new BigDecimal(0.1);
// 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1"); // 0.1 ✅
BigDecimal.valueOf(0.1); // 0.1 (از Double.toString عبور میکند)
سازندهی double عدد باینریِ تقریبی را عیناً به دهدهی ترجمه میکند، پس آشغال را حفظ میکند. همیشه سازندهی String یا valueOf را به کار ببر.
قانون دو: هر divide بدون RoundingMode یک ArithmeticException بالقوه است.
BigDecimal one = new BigDecimal("1");
BigDecimal three = new BigDecimal("3");
one.divide(three);
// ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
one.divide(three, 2, RoundingMode.HALF_UP); // 0.33 ✅
قانون سه: equals را با compareTo اشتباه نگیر.
new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false — scale متفاوت
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0 ✅ برابرند
این یکی در Set<BigDecimal> و در کلیدهای Map و در تستهای assertEquals مو به تنت سیخ میکند. برای مقایسهی مقدار همیشه compareTo(...) == 0.
RoundingMode.HALF_UP گرد کردن مدرسهای است: 2.5 → 3. این پیشفرض عرف مالی در بیشتر کشورهاست و قانون مالیاتی معمولاً همین را میخواهد.
RoundingMode.HALF_EVEN (گرد کردن بانکدار — banker's rounding) در .5 به نزدیکترین عدد زوج میرود: 2.5 → 2 و 3.5 → 4. این سوگیری آماری را در حجم بالا حذف میکند و در محاسبات آماری/ریسک رایج است.
RoundingMode.DOWN (بریدن) در محاسبهی کارمزدی که به نفع مشتری باید کم شود کاربرد دارد.
RoundingMode.UNNECESSARY طلایی است: به معنی «من ادعا میکنم اینجا گرد کردن لازم نیست» و اگر لازم باشد استثنا پرتاب میکند. در محل ثبت سند حسابداری از این استفاده کن تا اگر مبلغی با scale نامنتظره وارد شد، سریع بترکد نه اینکه بیصدا گرد شود.
۱.۳ واحد خرد (minor unit) و مبلغ صحیح
هر ارز یک واحد خرد دارد: کوچکترین واحد قابل مبادله. برای دلار، سِنت (یکصدم). استاندارد ISO 4217 برای هر ارز یک «نما» (exponent) تعریف میکند:
| نما | یعنی | نمونه ارزها |
|---|---|---|
| ۰ | بدون اعشار | JPY، KRW، VND، ISK، XAF |
| ۲ | دو رقم اعشار (اکثریت قاطع) | USD، EUR، GBP، CHF |
| ۳ | سه رقم اعشار | KWD، BHD، OMR، JOD، TND، IQD، LYD |
| ۴ | چهار رقم اعشار | CLF |
یعنی «۱۰۰» در یک پیام پرداخت با ارز JPY یعنی صد ین، ولی با ارز USD یعنی یک دلار، و با ارز KWD یعنی ۰.۱ دینار. اگر این را hard-code کنی که «همیشه تقسیم بر ۱۰۰»، سیستمت روی ژاپن و کویت غلط جواب میدهد.
در جاوا این عدد را از JDK بگیر، نه از جدول دستیِ خودت:
import java.util.Currency;
Currency jpy = Currency.getInstance("JPY");
jpy.getDefaultFractionDigits(); // 0
jpy.getNumericCode(); // 392 (کد عددی ISO 4217، همان چیزی که در ISO 8583 میرود)
Currency kwd = Currency.getInstance("KWD");
kwd.getDefaultFractionDigits(); // 3
اگر سیستمت فلزات گرانبها یا واحدهای حسابداری مثل XDR را هم لمس میکند، حتماً -1 را جداگانه مدیریت کن وگرنه movePointRight(-1) رفتار بیمعنا میدهد.
دو مکتب برای نگهداشتن مبلغ در کد وجود دارد:
الف) long از واحد خرد. مبلغ همیشه عدد صحیح است: 12345 سنت یعنی ۱۲۳٫۴۵ دلار. جمع و تفریق سریع و بیخطا، مقایسه بدیهی، سریالسازی JSON امن. عیبش این است که بدون ارز، عدد بیمعناست و باید همیشه جفتش حمل شود، و برای درصد و نرخ باز هم به حساب دقیق نیاز داری.
ب) BigDecimal + Currency. خواناتر، مستقیماً قابل ثبت در ستون numeric، محاسبهی درصد طبیعی. عیبش کندی نسبی و خطر فراموش کردن scale است.
در عمل بهترین کار ترکیب هر دو است: یک value object به نام Money که هر دو نمایش را میداند و تمام قوانین را داخل خودش نگه میدارد.
package com.example.payments.money;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
/** یک مبلغ تغییرناپذیر که ارز و scale خودش را میشناسد. */
public final class Money implements Comparable<Money> {
private final BigDecimal amount; // همیشه با scale برابر واحد خرد ارز
private final Currency currency;
private Money(BigDecimal amount, Currency currency) {
this.currency = Objects.requireNonNull(currency, "currency");
int scale = currency.getDefaultFractionDigits();
if (scale < 0) throw new IllegalArgumentException("non-decimal currency: " + currency);
// UNNECESSARY یعنی اگر ورودی رقم اضافه داشت، سکوت نکن — بترک
this.amount = amount.setScale(scale, RoundingMode.UNNECESSARY);
}
public static Money of(String amount, String currencyCode) {
return new Money(new BigDecimal(amount), Currency.getInstance(currencyCode));
}
/** از واحد خرد میسازد — مثلاً ofMinor(12345, "USD") یعنی 123.45 USD */
public static Money ofMinor(long minorUnits, String currencyCode) {
Currency c = Currency.getInstance(currencyCode);
return new Money(BigDecimal.valueOf(minorUnits, c.getDefaultFractionDigits()), c);
}
public static Money zero(String currencyCode) {
return ofMinor(0L, currencyCode);
}
public long toMinorUnits() {
return amount.movePointRight(currency.getDefaultFractionDigits()).longValueExact();
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(this.amount.add(other.amount), currency);
}
public Money minus(Money other) {
requireSameCurrency(other);
return new Money(this.amount.subtract(other.amount), currency);
}
public Money negated() {
return new Money(this.amount.negate(), currency);
}
/** ضرب در نرخ (مثلاً کارمزد) — گرد کردن اینجا اجباری و صریح است. */
public Money multipliedBy(BigDecimal rate, RoundingMode rounding) {
BigDecimal raw = this.amount.multiply(rate);
return new Money(raw.setScale(currency.getDefaultFractionDigits(), rounding), currency);
}
public boolean isNegative() { return amount.signum() < 0; }
public boolean isZero() { return amount.signum() == 0; }
public BigDecimal amount() { return amount; }
public Currency currency() { return currency; }
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"currency mismatch: " + currency + " vs " + other.currency);
}
}
@Override public int compareTo(Money o) { requireSameCurrency(o); return amount.compareTo(o.amount); }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money m)) return false;
return currency.equals(m.currency) && amount.compareTo(m.amount) == 0;
}
@Override public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }
@Override public String toString() { return amount.toPlainString() + " " + currency.getCurrencyCode(); }
}
یکی از رایجترین باگهای مالی این است که amount یک BigDecimal لخت است و کسی یورو را با دلار جمع میکند و هیچکس نمیفهمد. با Money بهعنوان value object، کامپایلر و runtime جلویش را میگیرند. اگر تبدیل ارز لازم داری، باید یک عملیات صریح با نرخ و تاریخ نرخ باشد — نه یک جمع ساده. این دقیقاً همان چیزی است که در فصل DDD به آن «value object که قواعد دامنه را حبس میکند» میگوییم.
double عدد را در مبنای دو با دقت محدود (IEEE 754) ذخیره میکند. کسرهای دهدهی رایج مثل 0.1 در مبنای دو متناوب بیپایاناند، پس گرد میشوند. خطا در هر عملیات کوچک است ولی در جمع تجمعی و در مقایسهها خودش را نشان میدهد: 0.1 + 0.2 != 0.3، و بعد از دههزار جمعِ یکصدم، به 100.00000000000163 میرسی.
BigDecimal عدد را بهصورت «عدد صحیح بزرگ × ۱۰ به توان منفیِ scale» نگه میدارد، پس هر عدد دهدهیِ متناهی دقیقاً نمایش داده میشود. ولی خودِ BigDecimal کافی نیست: باید سازندهی String را استفاده کنی نه double، برای divide حتماً RoundingMode بدهی وگرنه روی کسر متناوب ArithmeticException میگیری، و برای برابری از compareTo استفاده کنی چون equals به scale هم حساس است.
جواب سطح سنیور یک قدم جلوتر میرود: راهحل واقعی «BigDecimal لخت» نیست، یک نوع Money است که ارز و scale و RoundingMode را در خودش حبس میکند، تا امکان جمع دو ارز متفاوت یا گرد کردن ضمنی از نظر تایپی وجود نداشته باشد. جایگزین رایج دیگر، نگهداری مبلغ بهصورت long از واحد خرد است که برای سیستمهای با throughput بالا (مثل سوییچ کارت) مرسوم است.
۱.۴ تقسیم مبلغ و توزیع باقیمانده — مسئلهی «یک سنت گمشده»
یک پیتزای ۱۰ تکهای بین سه نفر. هر کس ۳ تکه، و یک تکه میماند. تو نمیتوانی تکه را به سه قسمت اعشاری تقسیم کنی چون تکه، کوچکترین واحد است. باید تصمیم بگیری آن یک تکه به چه کسی برسد — و تصمیمت باید قطعی، تکرارپذیر و مستند باشد.
فرض کن ۱۰۰٫۰۰ دلار باید بین سه ذینفع مساوی تقسیم شود. هر کدام 33.333... میشوند. اگر ساده گرد کنی به 33.33، جمعشان 99.99 میشود و یک سنت گم شده — و آن یک سنت، تراز دفتر کل تو را میشکند.
این یک قانون سخت است، نه سلیقه. اگر ۱۰۰ دلار وارد یک تراکنش شد، مجموع خروجیها با احتساب کارمزد و مالیات باید دقیقاً ۱۰۰ دلار باشد. «نزدیک به ۱۰۰» یعنی دفتر کل تو نامتوازن است و حسابداری روز بسته نمیشود. الگوریتم درست را allocation میگویند و هیچوقت نباید با گرد کردن مستقلِ هر جزء انجام شود.
الگوریتم استاندارد: مبلغ را در واحد خرد به عدد صحیح ببر، تقسیم صحیح کن، و باقیمانده را یکییکی به اولین دریافتکنندهها اضافه کن.
package com.example.payments.money;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
public final class Allocator {
/** تقسیم مساوی بین n سهم — جمع خروجی دقیقاً برابر ورودی است. */
public static List<Money> allocateEvenly(Money total, int parts) {
if (parts <= 0) throw new IllegalArgumentException("parts must be > 0");
long minor = total.toMinorUnits();
long base = minor / parts;
long remainder = Math.abs(minor % parts);
long step = minor < 0 ? -1 : 1;
List<Money> out = new ArrayList<>(parts);
for (int i = 0; i < parts; i++) {
long v = base + (i < remainder ? step : 0);
out.add(Money.ofMinor(v, total.currency().getCurrencyCode()));
}
return out;
}
/** تقسیم به نسبت وزنها — روش «بزرگترین باقیمانده». */
public static List<Money> allocateByRatios(Money total, List<BigDecimal> weights) {
BigDecimal sumW = weights.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (sumW.signum() == 0) throw new IllegalArgumentException("weights sum to zero");
long minor = total.toMinorUnits();
long[] floors = new long[weights.size()];
BigDecimal[] fracs = new BigDecimal[weights.size()];
long assigned = 0;
for (int i = 0; i < weights.size(); i++) {
BigDecimal exact = BigDecimal.valueOf(minor)
.multiply(weights.get(i))
.divide(sumW, 10, RoundingMode.HALF_UP);
floors[i] = exact.setScale(0, RoundingMode.FLOOR).longValueExact();
fracs[i] = exact.subtract(BigDecimal.valueOf(floors[i]));
assigned += floors[i];
}
long remainder = minor - assigned; // همیشه بین 0 و n-1
// باقیمانده را به بزرگترین بخش کسریها میدهیم — قطعی و قابل توضیح به حسابرس
List<Integer> order = new ArrayList<>();
for (int i = 0; i < weights.size(); i++) order.add(i);
order.sort((a, b) -> fracs[b].compareTo(fracs[a]));
for (int k = 0; k < remainder; k++) floors[order.get(k)] += 1;
List<Money> out = new ArrayList<>(weights.size());
String cc = total.currency().getCurrencyCode();
for (long f : floors) out.add(Money.ofMinor(f, cc));
return out;
}
}
سه سیاست رایج برای مقصد باقیمانده وجود دارد: (۱) به اولین دریافتکنندهی فهرست، (۲) به بزرگترین بخش کسری (روش بزرگترین باقیمانده)، (۳) به یک حساب مشخص از قبل تعیینشده که معمولاً rounding_difference نام دارد و در دفتر کل بهعنوان درآمد/هزینهی گرد کردن ثبت میشود. گزینهی سوم تمیزترین گزینه از دید حسابرسی است چون سنتهای سرگردان در یک حساب قابل ردیابی جمع میشوند و کسی احساس بیعدالتی نمیکند. آنچه اهمیت دارد این است که سیاست قطعی باشد: با ورودی یکسان، همیشه خروجی یکسان — چون job مغایرتگیری قرار است دوباره همان محاسبه را انجام دهد.
۱.۵ ذخیرهی پول در دیتابیس
هرگز FLOAT، REAL، DOUBLE PRECISION یا BINARY_DOUBLE. نوع درست، عدد دهدهیِ دقیق با precision و scale صریح است.
-- Postgres: numeric(p,s) دقیق است و دقیقاً همان BigDecimal را نگه میدارد
CREATE TABLE ledger_entry (
id bigserial PRIMARY KEY,
account_id bigint NOT NULL,
-- 4 رقم اعشار جا برای ارزهای سهرقمی و برای نرخها میگذارد
amount numeric(20,4) NOT NULL,
currency char(3) NOT NULL,
direction char(1) NOT NULL CHECK (direction IN ('D','C')),
posted_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT amount_positive CHECK (amount > 0)
);-- Oracle: NUMBER(p,s) دقیق است. از BINARY_DOUBLE برای پول استفاده نکن
CREATE TABLE ledger_entry (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
account_id NUMBER(19) NOT NULL,
amount NUMBER(20,4) NOT NULL,
currency CHAR(3) NOT NULL,
direction CHAR(1) NOT NULL CHECK (direction IN ('D','C')),
posted_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT amount_positive CHECK (amount > 0)
);در Postgres، numeric بدون precision («numeric لخت») مجاز است و دقت نامحدود میدهد، ولی scale را تثبیت نمیکند — یعنی 10.5 و 10.50 هر دو ذخیره میشوند و درایور JDBC دو BigDecimal با scale متفاوت به تو میدهد که با equals نابرابرند. همیشه numeric(p,s) صریح بنویس.
در Oracle، NUMBER بدون precision هر چیزی را میپذیرد و بیسروصدا تا ۳۸ رقم بامعنا نگه میدارد — پس یک مبلغ با پنج رقم اعشار وارد میشود و هیچکس خبردار نمیشود تا روزی که جمعها نمیخواند. NUMBER(20,4) بنویس تا خود دیتابیس ورودی نامعتبر را رد کند.
و مهمتر از همه: هرگز NUMBER را با BINARY_FLOAT/BINARY_DOUBLE عوضی نگیر — این دو نوع دقیقاً همان IEEE 754 هستند که از آن فرار میکنیم.
اگر مکتب long از واحد خرد را انتخاب کردهای، ستون را bigint/NUMBER(19) بگیر و حتماً ستون ارز را کنارش نگه دار، وگرنه عدد بیمعناست.
CREATE TABLE payment (
id bigserial PRIMARY KEY,
amount_minor bigint NOT NULL CHECK (amount_minor > 0),
currency char(3) NOT NULL,
-- exponent را هم ذخیره کن تا خواندن ردیفهای تاریخی مستقل از جدول ارز باشد
currency_exp smallint NOT NULL CHECK (currency_exp BETWEEN 0 AND 4)
);CREATE TABLE payment (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
amount_minor NUMBER(19) NOT NULL CHECK (amount_minor > 0),
currency CHAR(3) NOT NULL,
currency_exp NUMBER(1) NOT NULL CHECK (currency_exp BETWEEN 0 AND 4)
);نوع باید عدد دهدهی دقیق با precision و scale صریح باشد: numeric(20,4) در Postgres و NUMBER(20,4) در Oracle. چهار رقم اعشار میگیرم نه دو، چون ارزهایی با سه رقم اعشار وجود دارند (KWD، BHD، OMR) و چون محاسبهی میانیِ کارمزد و نرخ ممکن است دقت بیشتری بخواهد؛ ولی مبلغ نهاییِ قابل ثبت را همیشه به scale واقعی ارز گرد میکنم.
کنار مبلغ همیشه سه چیز دیگر میگذارم: کد ارز (char(3)) چون عدد بدون ارز بیمعناست، جهت (بدهکار/بستانکار) یا علامت، و در سیستمهای چندارزی معمولاً نمای واحد خرد را هم ذخیره میکنم تا خواندن ردیفهای دهسالپیش وابسته به جدول ارزِ امروز نباشد.
چیزی که هرگز نمیگذارم float، real، double precision یا BINARY_DOUBLE است. و CHECK میگذارم که مبلغ منفی نشود، چون در مدل دوطرفه جهت با ستون direction بیان میشود نه با علامت — این باعث میشود اشتباهات علامت در زمان درج بترکند نه شش ماه بعد در گزارش.
۲. دفترداری دوطرفه: قلب هر سیستم مالی
این بخش مهمترین بخش فصل است. اگر فقط یک چیز از این فصل با خودت ببری، این باشد.
۲.۱ آنالوژی: دو ستون بهجای یک عدد
تصور کن دو سطل داری و یک لیتر آب از سطل A به سطل B میریزی. اگر فقط بنویسی «سطل B یک لیتر گرفت»، هیچوقت نمیفهمی این آب از کجا آمد. اگر روزی مجموع آب سیستم بیشتر از چیزی شد که باید، نمیدانی کجا نشتی کرده.
دفترداری دوطرفه میگوید: هر جابهجایی را دو بار بنویس — یک بار بهعنوان «از کجا رفت» و یک بار بهعنوان «به کجا آمد». حالا اگر جمع همهی «از کجا»ها با جمع همهی «به کجا»ها یکی نبود، فوراً میفهمی که یک جایی خطا کردهای. این یک مکانیزم کشف خطای ساختاری است که از قرن پانزدهم کار میکند و هنوز جایگزین ندارد.
۲.۲ واژهنامه: حساب، بدهکار، بستانکار
حساب (account): یک سطل. یک ظرف نامدار که پول در آن جمع میشود. حسابها به پنج خانواده تقسیم میشوند:
| نوع حساب | یعنی چه | نمونه در یک PSP | ماندهی طبیعی |
|---|---|---|---|
| Asset (دارایی) | چیزی که داری یا کسی به تو بدهکار است | موجودی حساب بانکی شرکت، مطالبات از سوییچ | بدهکار |
| Liability (بدهی) | چیزی که به دیگری بدهکاری | موجودی کیف پول کاربران، وجه در راهِ پذیرندگان | بستانکار |
| Equity (حقوق صاحبان سهام) | سهم مالکان | سرمایهی اولیه | بستانکار |
| Revenue (درآمد) | چیزی که کسب کردهای | درآمد کارمزد | بستانکار |
| Expense (هزینه) | چیزی که خرج کردهای | کارمزد پرداختی به شبکه کارت | بدهکار |
وقتی کاربر ۱۰۰ دلار در کیف پول شارژ میکند، آن ۱۰۰ دلار دارایی تو نیست — پول در حساب بانکی تو نشسته (که دارایی است) ولی همزمان یک بدهی به آن کاربر ایجاد شده. اگر همهی کاربرها فردا برداشت کنند، باید بتوانی پرداخت کنی. به همین دلیل هر شارژ کیف پول دو سطر ایجاد میکند: بدهکار کردن دارایی (حساب بانکی) و بستانکار کردن بدهی (کیف پول کاربر). فهمیدن این نکته در مصاحبه فوراً تو را از کسانی که فقط «یک ستون balance دارم» جدا میکند.
بدهکار (debit، به اختصار Dr) و بستانکار (credit، به اختصار Cr): این دو کلمه در حسابداری معنیِ «کم شدن» و «زیاد شدن» ندارند. آنها فقط دو سمت هستند: چپ و راست. اثرشان به نوع حساب بستگی دارد:
- در حسابهای دارایی و هزینه: بدهکار یعنی افزایش، بستانکار یعنی کاهش.
- در حسابهای بدهی، حقوق صاحبان سهام و درآمد: بستانکار یعنی افزایش، بدهکار یعنی کاهش.
معادلهی پایهی حسابداری این است:
Assets = Liabilities + Equity
و اگر درآمد و هزینه را هم باز کنیم:
Assets + Expenses = Liabilities + Equity + Revenue
سمت چپ معادله با بدهکار زیاد میشود، سمت راست با بستانکار. همین. هر وقت گیج شدی، حساب را در معادله پیدا کن و ببین کدام سمت است. این تنها چیزی است که باید حفظ کنی — بقیه از آن نتیجه میشود.
سند حسابداری (journal entry): یک رویداد مالی کامل. هر سند از دو یا چند سطر (line / posting) تشکیل میشود و قانون آهنین این است:
مجموع بدهکارهای هر سند = مجموع بستانکارهای هر سند
اگر این برقرار نباشد، سند نامعتبر است و نباید ثبت شود.
دفتر کل (ledger / general ledger): مجموعهی همهی اسناد. در سیستمهای مدرن یک جدول فقط-افزودنی (append-only) است.
تراز آزمایشی (trial balance): جمع همهی بدهکارها در برابر جمع همهی بستانکارهای کل دفتر. اگر برابر نباشند، سیستم شکسته است.
نمودار زیر مدل را نشان میدهد. مدل دادهی دفتر کل دوطرفه · The double-entry ledger data model
flowchart TD
T[Business event<br/>payment, refund, fee] --> J[Journal entry<br/>immutable, has entry_id]
J --> L1[Line 1 DEBIT<br/>account A, amount]
J --> L2[Line 2 CREDIT<br/>account B, amount]
J --> L3[Line n more lines...]
L1 --> A[(Account A)]
L2 --> B[(Account B)]
A --> BAL[Balance = SUM debits - SUM credits<br/>derived, never stored raw]
B --> BAL
J -.invariant.-> INV[SUM debits == SUM credits<br/>per entry]
۲.۳ اسکیمای رابطهای دفتر کل
CREATE TABLE account (
id bigserial PRIMARY KEY,
code text NOT NULL UNIQUE, -- 'wallet:user:1042'
type text NOT NULL CHECK (type IN ('ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE')),
currency char(3) NOT NULL,
opened_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE TABLE journal_entry (
id bigserial PRIMARY KEY,
external_ref text NOT NULL, -- شناسهی رویداد کسبوکار
description text NOT NULL,
posted_at timestamptz NOT NULL DEFAULT now(), -- زمان ثبت در سیستم
value_date date NOT NULL, -- تاریخ ارزش حسابداری
created_by text NOT NULL,
CONSTRAINT journal_entry_ref_uniq UNIQUE (external_ref)
);
CREATE TABLE journal_line (
id bigserial PRIMARY KEY,
entry_id bigint NOT NULL REFERENCES journal_entry(id),
account_id bigint NOT NULL REFERENCES account(id),
direction char(1) NOT NULL CHECK (direction IN ('D','C')),
amount numeric(20,4) NOT NULL CHECK (amount > 0),
currency char(3) NOT NULL
);
CREATE INDEX journal_line_account_idx ON journal_line (account_id, entry_id);
-- هیچکس حق UPDATE یا DELETE ندارد — فقط INSERT
REVOKE UPDATE, DELETE ON journal_entry, journal_line FROM PUBLIC;CREATE TABLE account (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
code VARCHAR2(100) NOT NULL UNIQUE,
type VARCHAR2(10) NOT NULL CHECK (type IN ('ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE')),
currency CHAR(3) NOT NULL,
opened_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
closed_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE journal_entry (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
external_ref VARCHAR2(200) NOT NULL,
description VARCHAR2(400) NOT NULL,
posted_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
value_date DATE NOT NULL,
created_by VARCHAR2(100) NOT NULL,
CONSTRAINT journal_entry_ref_uniq UNIQUE (external_ref)
);
CREATE TABLE journal_line (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
entry_id NUMBER(19) NOT NULL REFERENCES journal_entry(id),
account_id NUMBER(19) NOT NULL REFERENCES account(id),
direction CHAR(1) NOT NULL CHECK (direction IN ('D','C')),
amount NUMBER(20,4) NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL
);
CREATE INDEX journal_line_account_idx ON journal_line (account_id, entry_id);
-- در Oracle معمولاً دسترسی را از طریق نقش و پکیج کنترل میکنی
REVOKE UPDATE, DELETE ON journal_line FROM app_writer;اگر علامت را داخل مبلغ بگذاری، یک اشتباه علامت به یک ردیف «درست به نظر رسنده» تبدیل میشود که سالها کشف نمیشود. با direction جدا و CHECK (amount > 0)، هر اشتباه علامت در لحظهی INSERT میترکد. این همان اصل «خطا را زود و پرصدا بروز بده» است که در فصل «کد تمیز» هم دیدی، فقط اینجا با پشتوانهی دیتابیس.
تراز هر سند را در سطح دیتابیس تضمین کن، نه فقط در کد جاوا:
CREATE OR REPLACE FUNCTION assert_entry_balanced() RETURNS trigger AS $$
DECLARE d numeric(20,4); c numeric(20,4);
BEGIN
SELECT COALESCE(SUM(amount) FILTER (WHERE direction = 'D'), 0),
COALESCE(SUM(amount) FILTER (WHERE direction = 'C'), 0)
INTO d, c
FROM journal_line WHERE entry_id = NEW.entry_id;
IF d <> c THEN
RAISE EXCEPTION 'entry % unbalanced: debits=% credits=%', NEW.entry_id, d, c;
END IF;
RETURN NULL;
END $$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER journal_line_balanced
AFTER INSERT ON journal_line
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_entry_balanced();-- Oracle تریگر DEFERRABLE ندارد؛ الگوی معادل، بررسی در پایان تراکنش با یک رویه است
CREATE OR REPLACE PROCEDURE assert_entry_balanced(p_entry_id IN NUMBER) IS
v_d NUMBER(20,4); v_c NUMBER(20,4);
BEGIN
SELECT NVL(SUM(CASE WHEN direction = 'D' THEN amount END), 0),
NVL(SUM(CASE WHEN direction = 'C' THEN amount END), 0)
INTO v_d, v_c
FROM journal_line WHERE entry_id = p_entry_id;
IF v_d <> v_c THEN
RAISE_APPLICATION_ERROR(-20001,
'entry ' || p_entry_id || ' unbalanced: D=' || v_d || ' C=' || v_c);
END IF;
END;
/در Postgres با CONSTRAINT TRIGGER ... DEFERRABLE INITIALLY DEFERRED بررسی تا لحظهی COMMIT عقب میافتد، پس میتوانی سطرها را یکییکی درج کنی و در پایان تراز چک شود. در Oracle چنین چیزی نداری و اگر تریگر سطری بنویسی، در سطر اول همیشه نامتوازن است و بیدلیل میترکد. راه درست در Oracle این است که درج را فقط از طریق یک package procedure اجازه بدهی که کل سند را یکجا میگیرد، درج میکند و در انتها assert_entry_balanced را صدا میزند.
۲.۴ چرا ستون balance قابل تغییر یک باگ منتظر وقوع است
فرض کن جدول account یک ستون balance numeric(20,4) دارد و هر تراکنش آن را UPDATE میکند. چهار فاجعه در انتظار توست:
۱. بهروزرسانی گمشده (lost update). دو تراکنش همزمان مانده را ۱۰۰ میخوانند، یکی ۱۰ کم میکند و دیگری ۲۰، هر دو مینویسند؛ نتیجه ۸۰ یا ۹۰ است نه ۷۰. پول از هوا ساخته شد.
۲. تاریخچهی صفر. ستون فقط «الان» را میداند. اگر کسی بپرسد «ماندهی این حساب در پایان ماه گذشته چقدر بود؟» جوابی نداری.
۳. غیرقابل حسابرسی. یک UPDATE اشتباه، حالت قبلی را برای همیشه نابود میکند. حسابرس میپرسد «این عدد از کجا آمد؟» و تو جوابی نداری.
۴. انحراف بیصدا. روزی که یک باگ سطر دفتر را ثبت کند ولی مانده را بهروز نکند (یا برعکس)، دو منبع حقیقت واگرا میشوند و ماهها کسی نمیفهمد.
دفتر منبع حقیقت است، مانده یک نتیجهی محاسبهشده است. مانده هیچوقت دادهی اولیه نیست؛ همیشه یا از دفتر جمع میشود، یا از یک snapshot معتبر بهاضافهی سطرهای بعد از آن. اگر مانده و دفتر هر دو نوشتنی باشند، سیستم تو دو منبع حقیقت دارد و دیر یا زود واگرا میشوند.
۲.۵ سه راه امن برای گرفتن مانده
راه اول: جمع کامل دفتر. درستترین، ولی با میلیونها سطر کند.
SELECT COALESCE(SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END), 0) AS balance
FROM journal_line
WHERE account_id = :accountId;SELECT NVL(SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END), 0) AS balance
FROM journal_line
WHERE account_id = :accountId;برای حسابهای بدهی، درآمد و حقوق صاحبان سهام، ماندهی طبیعی بستانکار است و فرمول برعکس میشود (C مثبت، D منفی). در عمل یک تابع natural_sign(account_type) بنویس یا مانده را همیشه بهصورت «بدهکار خالص» ذخیره کن و در لایهی نمایش علامت را بر اساس نوع حساب معکوس کن — دومی سادهتر و کماشتباهتر است.
راه دوم: ماندهی جاری (running balance) در هر سطر. هنگام درج، ماندهی بعد از این سطر را هم مینویسی. خواندن مانده میشود «آخرین سطر». عالی برای صورتحساب چون هر سطر با ماندهی لحظهی خودش نمایش داده میشود. هزینهاش این است که درج در یک حساب باید سریالی شود (وگرنه دو سطر با ماندهی جاری یکسان میگیری) — یعنی برای هر حساب یک قفل.
راه سوم: snapshot دورهای. هر شب (یا هر N سطر) مانده را در جدول account_balance_snapshot ثبت میکنی. ماندهی لحظهای = آخرین snapshot + جمع سطرهای بعد از آن.
CREATE TABLE account_balance_snapshot (
account_id bigint NOT NULL,
as_of_entry_id bigint NOT NULL, -- تا این سند (شامل) محاسبه شده
balance numeric(20,4) NOT NULL,
taken_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, as_of_entry_id)
);
WITH snap AS (
SELECT balance, as_of_entry_id
FROM account_balance_snapshot
WHERE account_id = :accountId
ORDER BY as_of_entry_id DESC
LIMIT 1
)
SELECT COALESCE((SELECT balance FROM snap), 0)
+ COALESCE(SUM(CASE WHEN l.direction = 'D' THEN l.amount ELSE -l.amount END), 0) AS balance
FROM journal_line l
WHERE l.account_id = :accountId
AND l.entry_id > COALESCE((SELECT as_of_entry_id FROM snap), 0);CREATE TABLE account_balance_snapshot (
account_id NUMBER(19) NOT NULL,
as_of_entry_id NUMBER(19) NOT NULL,
balance NUMBER(20,4) NOT NULL,
taken_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
PRIMARY KEY (account_id, as_of_entry_id)
);
WITH snap AS (
SELECT balance, as_of_entry_id
FROM account_balance_snapshot
WHERE account_id = :accountId
ORDER BY as_of_entry_id DESC
FETCH FIRST 1 ROW ONLY
)
SELECT NVL((SELECT balance FROM snap), 0)
+ NVL(SUM(CASE WHEN l.direction = 'D' THEN l.amount ELSE -l.amount END), 0) AS balance
FROM journal_line l
WHERE l.account_id = :accountId
AND l.entry_id > NVL((SELECT as_of_entry_id FROM snap), 0);اگر پروژه event-sourced است، این دقیقاً همان projection است: دفتر جریان رویدادهاست و مانده یک view خوانشیِ بازساختنی. جزئیات CQRS و projection در فصل «داده در میکروسرویس» آمده — اینجا فقط بدان که مدل دفتر کل بهطور طبیعی event-sourced است، چون سطر دفتر یک رویداد تغییرناپذیر است.
۲.۶ تراز آزمایشی: تست سلامت روزانه
SELECT currency,
SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END) AS total_debits,
SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END) AS total_credits,
SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) AS difference
FROM journal_line
GROUP BY currency
HAVING SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) <> 0;SELECT currency,
SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END) AS total_debits,
SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END) AS total_credits,
SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) AS difference
FROM journal_line
GROUP BY currency
HAVING SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) <> 0;هر سطری که این کوئری برگرداند یک حادثه است. این را بهعنوان یک alert دائمی بگذار، نه یک گزارش ماهانه.
دفترداری دوطرفه یعنی هر رویداد مالی بهصورت یک سند با حداقل دو سطر ثبت میشود: یک یا چند سطر بدهکار و یک یا چند سطر بستانکار، بهطوریکه مجموع بدهکارها دقیقاً برابر مجموع بستانکارها باشد. بدهکار و بستانکار معنی «کم/زیاد» ندارند؛ دو سمت معادلهی Assets + Expenses = Liabilities + Equity + Revenue هستند. دارایی و هزینه با بدهکار زیاد میشوند، بدهی و درآمد و سرمایه با بستانکار.
دفتر append-only است: هیچ سطری UPDATE یا DELETE نمیشود. اصلاح اشتباه با یک سند معکوس (reversal) انجام میشود، نه با پاک کردن. این باعث میشود کل تاریخچه قابل بازسازی و قابل حسابرسی بماند.
ستون ماندهی قابلویرایش چهار مشکل دارد: در همزمانی بهروزرسانی گمشده میدهد و پول از هوا میسازد؛ تاریخچه ندارد پس نمیتوانی ماندهی گذشته را جواب بدهی؛ حسابرسیناپذیر است چون UPDATE حالت قبل را نابود میکند؛ و منبع حقیقت دوم میسازد که دیر یا زود از دفتر واگرا میشود. راه درست این است که مانده همیشه مشتق باشد — یا جمع کامل دفتر، یا snapshot دورهای بهاضافهی دلتای بعد از آن، یا running balance با ثبت سریالی برای هر حساب. اگر برای کارایی مانده را cache میکنم، آن را صراحتاً «cache» مینامم، در همان تراکنش با دفتر مینویسم، و یک job روزانه دارم که آن را با جمع واقعی دفتر مقایسه و در صورت اختلاف alert میدهد.
۳. چرخهی عمر یک تراکنش: پول واقعاً کجاست؟
وقتی هتل رزرو میکنی، هتل کارتت را «میگیرد» ولی پول برداشته نمیشود — فقط از سقف اعتبارت کم میشود و کنار گذاشته میشود. این authorisation است. روزی که check-out میکنی، هتل مبلغ نهایی را برمیدارد؛ این capture است. چند روز بعد، بانکها بین خودشان حساب میکنند و پول واقعاً جابهجا میشود؛ این clearing و settlement است. اگر رزرو را لغو کنی قبل از check-out، آن کنارگذاری آزاد میشود؛ این reversal/void است. اگر بعد از پرداخت، شکایت کنی که خدمات ارائه نشده، فرایند dispute و chargeback شروع میشود.
۳.۱ واژهنامهی مراحل
Authorisation (مجوزگیری). یک درخواست به بانک صادرکنندهی کارت (issuer): «آیا این کارت معتبر است و این مبلغ موجود است؟» issuer پاسخ میدهد و در صورت تأیید، مبلغ را روی حساب دارندهی کارت hold میکند (به آن open-to-buy reduction یا pending authorisation میگویند). هیچ پولی جابهجا نشده است. فقط اعتبار قابل استفاده کم شده. مجوز یک عمر محدود دارد — معمولاً چند روز — و اگر capture نشود منقضی میشود.
Capture (تأیید برداشت). پذیرنده اعلام میکند «کالا را تحویل دادم، این مبلغ را واقعاً بردار». میتواند برابر مبلغ مجوز باشد، کمتر (partial capture)، یا در چند نوبت (split capture). باز هم پول همان لحظه جابهجا نمیشود — capture یک تعهد ثبتشده است.
Clearing (تهاتر). پذیرنده و سوییچ در پایان روز فایل تراکنشها را رد و بدل میکنند. شبکه محاسبه میکند هر بانک به هر بانک چقدر بدهکار است و به یک عدد خالص (net) میرسد.
Settlement (تسویه). انتقال واقعی وجه بین بانکها بر اساس اعداد خالص clearing، معمولاً از طریق یک سامانهی تسویهی ناخالص آنی (RTGS) یا اتاق پایاپای. اینجا و فقط اینجا پول واقعاً حرکت میکند.
Refund (بازپرداخت). یک تراکنش جدید و مستقل در جهت معکوس، بعد از اینکه تراکنش اصلی تسویه شده. تاریخ خودش، شناسهی خودش و چرخهی تسویهی خودش را دارد.
Reversal / Void (ابطال). لغو یک تراکنش که هنوز تسویه نشده — مثلاً مجوزی که capture نشده، یا تراکنشی که همان روز باطل میشود. برخلاف refund، رد پایی روی صورتحساب مشتری نمیگذارد (یا فقط hold آزاد میشود). در ISO 8583 با پیامهای کلاس ۴ (reversal) انجام میشود.
Chargeback (اعادهی وجه با دستور issuer). دارندهی کارت به بانک خودش شکایت میکند و issuer بهزور پول را از پذیرنده پس میگیرد. این یک فرایند شبهقضایی با مهلتهای سختگیرانه و کدهای دلیل استاندارد است.
Dispute (اختلاف). چتر کلی روی اعتراض مشتری، که ممکن است به chargeback ختم شود یا با مدارک پذیرنده (representment) رد شود.
یکی از بزرگترین سوءتفاهمهای مهندسهای تازهوارد به این حوزه این است که فکر میکنند «کد پاسخ ۰۰ گرفتم پس پول رسید». نه. کد پاسخ موفق در authorisation فقط یعنی issuer قول داده. اگر capture نکنی، مجوز منقضی میشود و هیچ پولی جابهجا نمیشود. اگر capture کنی ولی فایل clearing از دست برود، پول تسویه نمیشود. در دفتر کل تو باید این حالتها جدا مدل شوند: پول در «حساب مجوزهای باز» با پول در «مطالبات تسویهنشده» با پول در «موجودی بانکی» سه چیز متفاوتاند.
چرخهی عمر یک پرداخت کارتی · The lifecycle of a card payment
stateDiagram-v2
[*] --> Authorised
Authorised --> Voided
Authorised --> Expired
Authorised --> Captured
Captured --> Cleared
Cleared --> Settled
Settled --> Refunded
Settled --> Disputed
Disputed --> ChargedBack
Disputed --> Represented
Represented --> Settled
Voided --> [*]
Expired --> [*]
Refunded --> [*]
ChargedBack --> [*]
مهندسهای باتجربه برای هر مرحله یک حساب واسط (clearing account / suspense account) میسازند. مثلاً هنگام capture: بدهکار «مطالبات از سوییچ» و بستانکار «بدهی به پذیرنده». هنگام settlement: بدهکار «حساب بانکی» و بستانکار «مطالبات از سوییچ». حساب «مطالبات از سوییچ» بعد از تسویه باید صفر شود؛ هر باقیماندهای در آن یعنی یک تراکنش گیر کرده. این حسابها ابزار تشخیص خودکار مشکلاند، نه صرفاً تشریفات حسابداری.
reversal لغو یک تراکنشی است که هنوز به تسویه نرسیده. در دنیای کارت معمولاً پیام کلاس ۴ ISO 8583 است و اثرش این است که hold روی حساب دارنده آزاد میشود یا رکورد تراکنش قبل از ورود به فایل clearing حذف میشود. از نظر مشتری معمولاً هیچ ردی روی صورتحساب نمیماند.
refund یک تراکنش کاملاً جدید در جهت معکوس است که بعد از تسویهی تراکنش اصلی انجام میشود. شناسهی خودش، تاریخ خودش و چرخهی تسویهی خودش را دارد و روی صورتحساب مشتری بهعنوان یک قلم مستقل ظاهر میشود. معمولاً چند روز طول میکشد.
چرا مهم است: اگر جایی که باید reversal بزنی refund بزنی، دو حرکت پول ثبت میکنی که یکیشان هرگز تسویه نشده بود — نتیجهاش این است که پذیرنده پولی را پس میدهد که هرگز نگرفته، و دفترت منفی میشود. برعکسش هم بد است: تلاش برای reversal روی تراکنشی که تسویه شده، از طرف سوییچ رد میشود و تراکنش تو در حالت «گیرکرده» میماند. در طراحی، حالت تراکنش باید صریح باشد و عملیات مجاز از روی حالت تعیین شود — یک state machine واقعی، نه یک if.
۴. Idempotency: مهمترین الزام غیرِکارکردی این دامنه
دکمهی آسانسور را ده بار فشار میدهی؛ آسانسور ده بار نمیآید. اولین فشار درخواست را ثبت میکند و بقیه همان نتیجه را برمیگردانند. یک API پرداخت باید دقیقاً همینطور باشد: اگر کلاینت بهخاطر تایماوت شبکه سه بار همان درخواست را بفرستد، باید یک پرداخت انجام شود و هر سه بار همان پاسخ برگردد.
۴.۱ چرا exactly-once یک توهم است
بین دو ماشین روی یک شبکهی غیرقابلاعتماد، «دقیقاً یک بار» غیرممکن است. چیزی که داری این است:
- at-most-once: نمیفرستی دوباره؛ ممکن است پیام گم شود.
- at-least-once: تا تأیید نگرفتی میفرستی؛ ممکن است تکراری برسد.
راه حل واقعی: at-least-once در انتقال + idempotency در گیرنده = اثرِ exactly-once. دقت کن که این «اثر» است نه تضمین انتقال. اگر کسی در مصاحبه بگوید «ما exactly-once داریم»، سؤال بعدی باید این باشد: «کجا de-duplicate میکنید؟»
۴.۲ کلید idempotency و اثر انگشت درخواست
قرارداد استاندارد صنعتی این است که کلاینت یک هدر Idempotency-Key با یک UUID تولیدشده در سمت خودش میفرستد و همان کلید را در همهی retryهای همان عملیات تکرار میکند.
سرور سه کار میکند:
- کلید را با یک قید یکتایی در دیتابیس ثبت میکند (این همان قفلی است که مسابقه را میبرد).
- اثر انگشت (fingerprint) بدنهی درخواست را ذخیره میکند — یک هش از فیلدهای معنادار.
- پاسخ نهایی را ذخیره میکند تا در تکرارها بدون اجرای دوباره برگرداند.
سناریوی واقعی: کلاینت بهخاطر یک باگ همان کلید را برای مبلغ ۱۰ و بعد ۱۰۰ میفرستد. اگر فقط کلید را چک کنی، پاسخ پرداخت ۱۰ را برای درخواست ۱۰۰ برمیگردانی و کلاینت فکر میکند ۱۰۰ پرداخت شده. باید هش بدنه را هم مقایسه کنی و در صورت تفاوت با 422 Unprocessable Entity یا 409 Conflict رد کنی. این تفاوت بین یک API درست و یک API خطرناک است.
CREATE TABLE idempotency_record (
idempotency_key text PRIMARY KEY,
request_hash char(64) NOT NULL, -- SHA-256 هگز
endpoint text NOT NULL,
status text NOT NULL CHECK (status IN ('IN_PROGRESS','COMPLETED')),
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE INDEX idempotency_created_idx ON idempotency_record (created_at);CREATE TABLE idempotency_record (
idempotency_key VARCHAR2(200) PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
endpoint VARCHAR2(200) NOT NULL,
status VARCHAR2(20) NOT NULL CHECK (status IN ('IN_PROGRESS','COMPLETED')),
response_code NUMBER(3),
response_body CLOB CHECK (response_body IS JSON),
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idempotency_created_idx ON idempotency_record (created_at);پیادهسازی در Spring:
package com.example.payments.api;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/v1/payments")
public class PaymentController {
private final IdempotencyStore store;
private final PaymentService payments;
public PaymentController(IdempotencyStore store, PaymentService payments) {
this.store = store;
this.payments = payments;
}
@PostMapping
public ResponseEntity<PaymentResponse> create(
@RequestHeader("Idempotency-Key") String key,
@RequestBody @jakarta.validation.Valid PaymentRequest req) {
String fingerprint = Fingerprint.sha256Hex(req.canonicalForm());
// 1) تلاش برای ثبت کلید — قید PRIMARY KEY برندهی مسابقه را تعیین میکند
IdempotencyStore.Claim claim = store.claim(key, "/v1/payments", fingerprint);
switch (claim.outcome()) {
case FRESH -> { /* ما مالک اجراییم */ }
case REPLAY_COMPLETED -> {
return ResponseEntity.status(claim.responseCode())
.header("Idempotent-Replay", "true")
.body(claim.responseBody(PaymentResponse.class));
}
case IN_FLIGHT -> throw new ResponseStatusException(
HttpStatus.CONFLICT, "a request with this key is still in progress");
case FINGERPRINT_MISMATCH -> throw new ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY,
"Idempotency-Key reused with a different request body");
}
PaymentResponse body = payments.execute(req, key);
store.complete(key, HttpStatus.CREATED.value(), body);
return ResponseEntity.status(HttpStatus.CREATED)
.header("Idempotent-Replay", "false")
.body(body);
}
}
package com.example.payments.api;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public final class Fingerprint {
private Fingerprint() {}
public static String sha256Hex(String canonical) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
}
اگر مستقیماً بایتهای خام JSON را هش کنی، تغییر ترتیب فیلدها یا یک فاصلهی اضافه باعث میشود همان درخواست، هش متفاوت بدهد و بهاشتباه 422 بگیرد. یک canonicalForm() بنویس که فقط فیلدهای معنادار (مبلغ، ارز، مقصد، مرجع) را با ترتیب ثابت و قالب ثابت کنار هم بگذارد. فیلدهایی مثل timestamp کلاینت یا trace-id را بیرون بگذار، وگرنه هر retry هش جدید میسازد و کل مکانیزم بیاثر میشود.
۴.۳ تایماوتی که نمیدانی موفق بوده یا نه
این سختترین سناریوی این دامنه است: درخواست را به سوییچ فرستادی و تایماوت خوردی. سه احتمال وجود دارد:
- درخواست هرگز نرسید — پولی جابهجا نشده.
- رسید و پردازش شد ولی پاسخ گم شد — پول جابهجا شده.
- رسید و در حال پردازش است — نتیجه نامعلوم.
دو ابزار استاندارد داری. اول تراکنش پرسوجو (status inquiry / echo): با همان شناسهی یکتای تراکنش از سوییچ میپرسی «این تراکنش را داری؟». دوم reversal: پیامی میفرستی که میگوید «اگر این تراکنش را داری، باطلش کن؛ اگر نداری، هیچ». reversal طبق طراحی idempotent است و آن را تا وقتی پاسخ قطعی بگیری تکرار میکنی. تا رسیدن پاسخ قطعی، تراکنش را در حالت UNKNOWN/PENDING_RESOLUTION نگه میداری و هیچوقت به کاربر «موفق» یا «ناموفق» قطعی نمیگویی. گفتن «ناموفق» به کاربر در حالی که پول کم شده، بدترین حالت ممکن برای اعتماد است.
اکثر مدلهای سادهانگارانه دو حالت دارند: SUCCESS و FAILED. سیستم مالی واقعی حداقل پنج حالت دارد: PENDING، SUCCESS، FAILED، UNKNOWN و REVERSED. حالت UNKNOWN توسط job پرسوجو یا فایل مغایرتگیری حل میشود. اگر این حالت را در مدل نداشته باشی، مهندسها مجبور میشوند حدس بزنند، و حدس در دامنهی پول یعنی ضرر.
برای همبستگی بین درخواست، خروجی و رویدادهای پیامدی، از الگوی Transactional Outbox استفاده کن (فصل «داده در میکروسرویس»): سطر دفتر و رویداد خروجی را در یک تراکنش دیتابیسی بنویس و یک relay آن را منتشر کند. و برای هماهنگی چندسرویسه، Saga با گامهای جبرانی — که در همان فصل با جزئیات آمده. اینجا فقط نکتهی دامنهای را اضافه میکنم: گام جبرانی در دنیای پول تقریباً هرگز «حذف» نیست، همیشه «سند معکوس» است.
کلاینت یک هدر Idempotency-Key با UUID تولیدشدهی خودش میفرستد و در تمام retryهای همان عملیات همان مقدار را تکرار میکند. سمت سرور یک جدول با کلید اصلیِ همان کلید دارم. اولین کار در سرویس، INSERT این سطر با وضعیت IN_PROGRESS است؛ برندهی مسابقه از قید یکتایی دیتابیس معلوم میشود، نه از یک if (exists) که خودش شرط رقابتی دارد.
کنار کلید، هش SHA-256 از شکل متعارفشدهی بدنه را ذخیره میکنم. اگر همان کلید با هش متفاوت آمد، 422 میدهم — چون یعنی کلاینت کلید را برای درخواست دیگری بازاستفاده کرده. اگر کلید موجود و COMPLETED بود، پاسخ ذخیرهشده را با هدر Idempotent-Replay: true عیناً برمیگردانم. اگر IN_PROGRESS بود، 409 میدهم تا کلاینت با backoff دوباره تلاش کند.
نکتهی ظریف تراکنشی: ثبت کلید و اجرای پرداخت باید طوری هماهنگ شوند که اگر پرداخت شکست خورد، سطر idempotency در وضعیت گیرکرده نماند. الگوی امن این است که ثبت کلید در یک تراکنش جدا (REQUIRES_NEW) انجام شود، اثر کسبوکاری در تراکنش خودش، و تکمیل رکورد در پایان. سطرهای IN_PROGRESS که از یک آستانهی زمانی گذشتهاند توسط یک job با پرسوجوی وضعیت واقعی حل میشوند.
و نگهداشت: این جدول را با یک سیاست حذف مبتنی بر created_at هرس میکنم — بازهاش باید از حداکثر پنجرهی retry کلاینتها بیشتر باشد، معمولاً ۲۴ تا ۷۲ ساعت.
جواب کوتاه: منبع حقیقت باید دیتابیس تراکنشی باشد، Redis میتواند لایهی شتاب باشد. دلیلش این است که ثبت کلید و اثر کسبوکاری (سطر دفتر) باید از نظر دوام همسرنوشت باشند. اگر کلید در Redis باشد و Redis یک failover با از دست رفتن نوشتهی اخیر داشته باشد، کلید ناپدید میشود و retry بعدی یک پرداخت دوم میسازد — دقیقاً همان چیزی که میخواستی جلویش را بگیری.
Redis برای گرفتن یک قفل کوتاهمدت و رد کردن سریع سیل retryها عالی است، ولی بهعنوان لایهی بهینهسازی جلوی دیتابیس، نه بهجای آن. اگر پاسخ ذخیرهشده بزرگ است، میتوانی خودِ پاسخ را در Redis cache کنی ولی وجود کلید و وضعیتش را در دیتابیس نگه داری.
۵. مغایرتگیری (Reconciliation): جایی که حقیقت آشکار میشود
هر ماه دفترچهی خرج شخصیات را با صورتحساب بانک مقایسه میکنی. چند حالت پیش میآید: قلمی در هر دو هست و مبلغش یکی است (تطبیق کامل)؛ قلمی در دفترچهات هست ولی در صورتحساب نیست (شاید هنوز نشسته، شاید هرگز انجام نشده)؛ قلمی در صورتحساب هست ولی در دفترچهات نیست (کارمزدی که یادت رفته)؛ و قلمی که در هر دو هست ولی مبلغش فرق دارد. مغایرتگیری در یک سیستم پرداخت دقیقاً همین است، فقط با میلیونها ردیف و بهصورت خودکار.
هر روز فایلی از سوییچ، شبکهی کارت یا بانک دریافت میکنی — معمولاً یک فایل با طول ثابت، CSV، یا امروزه یک پیام camt.053 (صورتحساب مشتری در استاندارد ISO 20022). آن را با دفتر کل خودت مقایسه میکنی.
۵.۱ دستهبندی مغایرتها (break categories)
| دسته | معنی | اقدام معمول |
|---|---|---|
| Matched | در هر دو طرف، مبلغ و تاریخ در تحمل مجاز | هیچ — علامتگذاری و بایگانی |
| Missing at counterparty | در دفتر تو هست، در فایل نیست | اگر داخل پنجرهی تأخیر است صبر کن، وگرنه پرسوجوی وضعیت |
| Missing internally | در فایل هست، در دفتر تو نیست | تراکنش «یتیم» — احتمالاً پاسخی که گم شده بود. سند جبرانی ثبت کن |
| Amount mismatch | هر دو طرف هست، مبلغ فرق دارد | معمولاً partial capture یا کارمزد. بررسی دستی |
| Duplicate | یک تراکنش دو بار در یک طرف | نشانهی شکست idempotency — حادثه است |
| Status mismatch | یکی موفق، دیگری برگشتی | نیازمند بررسی — بالاترین اولویت |
تحمل زمانی یعنی تراکنشی که ساعت ۲۳:۵۹ انجام شده ممکن است در فایل روز بعد بیاید؛ پس تطبیق باید بازهای باشد (مثلاً ±۱ روز کاری) نه دقیقاً روی تاریخ. تحمل مبلغی موضوع کاملاً دیگری است و در پرداخت داخلی باید صفر باشد: یک ریال اختلاف یعنی باگ. تحمل مبلغی فقط در سناریوهای ارز خارجی با نرخ تبدیل معنا دارد و آنجا هم باید سقف مطلق داشته باشد، نه درصدی نامحدود. تیمهایی که برای «کم کردن نویز» تحمل مبلغی ۱٪ میگذارند، عملاً اختلاس و باگ را زیر فرش میکنند.
۵.۲ SQL واقعی مغایرتگیری
فایل را در یک جدول staging بارگذاری کن و با FULL OUTER JOIN هر دو طرف را ببین:
CREATE TABLE settlement_file_row (
file_id bigint NOT NULL,
rrn varchar(12) NOT NULL, -- Retrieval Reference Number, DE37
stan varchar(6) NOT NULL, -- System Trace Audit Number, DE11
amount numeric(20,4) NOT NULL,
currency char(3) NOT NULL,
txn_date date NOT NULL,
response_code char(2) NOT NULL,
PRIMARY KEY (file_id, rrn)
);
WITH ours AS (
SELECT p.rrn, p.amount, p.currency, p.value_date, p.status
FROM payment p
WHERE p.value_date BETWEEN :from - INTERVAL '1 day' AND :to + INTERVAL '1 day'
),
theirs AS (
SELECT s.rrn, s.amount, s.currency, s.txn_date, s.response_code
FROM settlement_file_row s
WHERE s.file_id = :fileId
)
SELECT COALESCE(o.rrn, t.rrn) AS rrn,
o.amount AS our_amount,
t.amount AS their_amount,
CASE
WHEN t.rrn IS NULL THEN 'MISSING_AT_COUNTERPARTY'
WHEN o.rrn IS NULL THEN 'MISSING_INTERNALLY'
WHEN o.amount <> t.amount THEN 'AMOUNT_MISMATCH'
WHEN o.currency <> t.currency THEN 'CURRENCY_MISMATCH'
WHEN (o.status = 'SUCCESS') <> (t.response_code = '00') THEN 'STATUS_MISMATCH'
WHEN ABS(o.value_date - t.txn_date) > 1 THEN 'DATE_OUT_OF_TOLERANCE'
ELSE 'MATCHED'
END AS break_type
FROM ours o
FULL OUTER JOIN theirs t ON o.rrn = t.rrn
ORDER BY break_type, rrn;CREATE TABLE settlement_file_row (
file_id NUMBER(19) NOT NULL,
rrn VARCHAR2(12) NOT NULL,
stan VARCHAR2(6) NOT NULL,
amount NUMBER(20,4) NOT NULL,
currency CHAR(3) NOT NULL,
txn_date DATE NOT NULL,
response_code CHAR(2) NOT NULL,
PRIMARY KEY (file_id, rrn)
);
WITH ours AS (
SELECT p.rrn, p.amount, p.currency, p.value_date, p.status
FROM payment p
WHERE p.value_date BETWEEN :from - 1 AND :to + 1
),
theirs AS (
SELECT s.rrn, s.amount, s.currency, s.txn_date, s.response_code
FROM settlement_file_row s
WHERE s.file_id = :fileId
)
SELECT COALESCE(o.rrn, t.rrn) AS rrn,
o.amount AS our_amount,
t.amount AS their_amount,
CASE
WHEN t.rrn IS NULL THEN 'MISSING_AT_COUNTERPARTY'
WHEN o.rrn IS NULL THEN 'MISSING_INTERNALLY'
WHEN o.amount <> t.amount THEN 'AMOUNT_MISMATCH'
WHEN o.currency <> t.currency THEN 'CURRENCY_MISMATCH'
WHEN CASE WHEN o.status = 'SUCCESS' THEN 1 ELSE 0 END
<> CASE WHEN t.response_code = '00' THEN 1 ELSE 0 END THEN 'STATUS_MISMATCH'
WHEN ABS(o.value_date - t.txn_date) > 1 THEN 'DATE_OUT_OF_TOLERANCE'
ELSE 'MATCHED'
END AS break_type
FROM ours o
FULL OUTER JOIN theirs t ON o.rrn = t.rrn
ORDER BY break_type, rrn;در Oracle تفریق دو DATE مستقیماً عدد روز میدهد، پس ABS(a - b) > 1 کار میکند. در Postgres تفریق دو date یک integer روز میدهد و همان عبارت کار میکند، ولی اگر ستونها timestamptz باشند نتیجه interval است و باید ABS(EXTRACT(EPOCH FROM a - b)) > 86400 بنویسی. این نوع اختلاف بیصدا در جابهای شبانه کشف میشود — با تست بگیرش.
۵.۳ کلید تطبیق را درست انتخاب کن
RRN (فیلد ۳۷ در ISO 8583) رایجترین کلید تطبیق است ولی یکتایی مطلق ندارد — معمولاً در یک بازهی زمانی و برای یک پذیرنده یکتاست. STAN (فیلد ۱۱) فقط شش رقم است و در ۱۰۰۰۰۰۰ تراکنش میچرخد. پس کلید تطبیق واقعی معمولاً یک تاپل است: (acquirer_id, terminal_id, stan, txn_date) یا (rrn, txn_date, amount).
مغایرتگیری بالغ سه پاس دارد. پاس یک: تطبیق دقیق روی کلید قوی — معمولاً ۹۷ تا ۹۹ درصد ردیفها همینجا حل میشوند. پاس دو: تطبیق فازی روی باقیماندهها با کلید ضعیفتر و پنجرهی زمانی (مثلاً همان مبلغ و همان ترمینال در بازهی ±۲ دقیقه). پاس سه: تطبیق چند-به-یک برای مواردی مثل partial capture که یک مجوز به چند capture خورده. هر تطبیقی که در پاس دو یا سه انجام شود باید علامتگذاری شده و قابل بازبینی باشد، نه اینکه بیصدا Matched شمرده شود.
۵.۴ job مغایرتگیری در Spring
package com.example.payments.recon;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
@Component
public class DailyReconciliationJob {
private final SettlementFileClient files;
private final ReconRepository repo;
private final BreakHandler handler;
private final Clock clock;
public DailyReconciliationJob(SettlementFileClient files, ReconRepository repo,
BreakHandler handler, Clock clock) {
this.files = files; this.repo = repo; this.handler = handler; this.clock = clock;
}
// ساعت 03:00 به وقت منطقهی تسویه — نه به وقت سرور
@Scheduled(cron = "0 0 3 * * *", zone = "UTC")
public void run() {
LocalDate businessDate = LocalDate.now(clock).minusDays(1);
// ثبت اجرای run با کلید یکتا: job خودش باید idempotent باشد
long runId = repo.startRun(businessDate);
try {
long fileId = files.downloadAndStage(businessDate);
List<ReconBreak> breaks = repo.computeBreaks(fileId, businessDate);
for (ReconBreak b : breaks) handler.handle(runId, b);
repo.completeRun(runId, breaks.size());
} catch (RuntimeException e) {
repo.failRun(runId, e.getMessage());
throw e; // بگذار alerting ببیند
}
}
}
اگر job نیمهکاره بمیرد و دوباره اجرا شود، نباید سندهای جبرانی تکراری بسازد. راه درست: هر ReconBreak یک شناسهی قطعی و مشتقشده دارد (مثلاً هش (businessDate, rrn, breakType)) و هر سند جبرانی با external_ref برابر همان شناسه ثبت میشود. قید یکتایی روی journal_entry.external_ref دومین ثبت را رد میکند. این همان idempotency است، فقط در لایهی batch.
فایل تسویه را در یک جدول staging با کلید (file_id, rrn) بارگذاری میکنم و با FULL OUTER JOIN روی کلید تطبیق، هر ردیف را در یکی از شش دسته میگذارم: matched، missing at counterparty، missing internally، amount mismatch، status mismatch و duplicate. کلید تطبیق را هیچوقت فقط RRN نمیگذارم چون یکتایی سراسری ندارد؛ معمولاً تاپلی از acquirer، terminal، STAN و تاریخ تراکنش میسازم و پنجرهی زمانی ±۱ روز کاری میگذارم چون تراکنش نزدیک نیمهشب در فایل روز بعد میآید.
تراکنش یتیم — یعنی چیزی که در فایل سوییچ هست و در دفتر من نیست — تقریباً همیشه یعنی درخواست به سوییچ رسیده و پردازش شده ولی پاسخش به من نرسیده و من تراکنش را ناموفق ثبت کردهام. اقدام درست این است که یک سند حسابداری جبرانی با external_ref قطعی ثبت کنم تا دفتر با واقعیت پول همراستا شود، تراکنش را در وضعیت درست قرار دهم، و اگر به کاربر «ناموفق» گفته بودم، فرایند اطلاعرسانی و در صورت لزوم بازپرداخت را trigger کنم. آنچه هرگز نمیکنم این است که ردیف را در دفتر «تصحیح» کنم — دفتر append-only است و اصلاح فقط با سند جدید انجام میشود.
معیار سلامت این فرایند هم یک عدد است: نرخ تطبیق خودکار. اگر زیر ۹۹ درصد بیاید یا تعداد breakهای حلنشدهی قدیمیتر از N روز رشد کند، این یک alert عملیاتی است نه یک گزارش.
۶. همزمانی: نگهبانی از یک عدد که همه میخواهند تغییرش دهند
فصل «همگامسازی و JMM» به تو قفل درونفرایندی را یاد داد و فصل «تراکنش و ACID» سطوح ایزولاسیون را. اینجا فقط تصمیمهای دامنهای را میگویم.
۶.۱ بهروزرسانی گمشده روی مانده
// ❌ باگ کلاسیک — بین read و write یک پنجره وجود دارد
BigDecimal balance = accountRepo.findBalance(accountId);
if (balance.compareTo(amount) >= 0) {
accountRepo.updateBalance(accountId, balance.subtract(amount));
}
دو نخ همزمان با ماندهی ۱۰۰ و برداشت ۸۰: هر دو شرط را پاس میکنند و مانده منفی میشود یا یکی از برداشتها گم میشود. سه راهحل درست:
۱) بهروزرسانی اتمی شرطی (بهترین گزینه وقتی میشود).
UPDATE account_balance
SET balance = balance - :amount,
version = version + 1
WHERE account_id = :accountId
AND balance >= :amount;
-- تعداد سطر متأثر 0 یعنی موجودی کافی نبودUPDATE account_balance
SET balance = balance - :amount,
version = version + 1
WHERE account_id = :accountId
AND balance >= :amount;
-- SQL%ROWCOUNT برابر 0 یعنی موجودی کافی نبود۲) قفل بدبینانه (pessimistic).
SELECT balance FROM account_balance
WHERE account_id = :accountId
FOR UPDATE; -- تا پایان تراکنش قفل انحصاریSELECT balance FROM account_balance
WHERE account_id = :accountId
FOR UPDATE; -- رفتار یکسان؛ NOWAIT یا WAIT n برای کنترل انتظاردر JPA همین میشود:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Optional<AccountBalance> findAndLockByAccountId(Long accountId);
۳) قفل خوشبینانه (optimistic) با @Version.
@Entity
public class AccountBalance {
@Id private Long accountId;
private BigDecimal balance;
@Version private long version; // JPA خودش در WHERE میگذارد
}
اگر نسخه عوض شده باشد، ObjectOptimisticLockingFailureException میگیری و باید کل عملیات را از نو اجرا کنی.
| معیار | قفل بدبینانه | قفل خوشبینانه |
|---|---|---|
| رقابت بالا روی یک حساب | مناسب — انتظار بهجای شکست | بد — طوفان retry و throughput پایین |
| رقابت کم | هزینهی بیمورد و نگهداری قفل | عالی — بدون هزینه در مسیر خوشبینانه |
| تراکنش طولانی | خطرناک — قفل را طولانی نگه میدارد | امنتر — قفلی نگه نمیدارد |
| خطر deadlock | بالا اگر ترتیب قفلگیری ثابت نباشد | صفر (ولی livelock ممکن است) |
| رفتار در شکست | انتظار یا timeout | استثنا و نیاز به retry در لایهی بالا |
| حساب «داغ» (حساب تسویهی مرکزی) | معمولاً تنها گزینهی عملی | عملاً غیرقابل استفاده |
حساب کارمزد یا حساب تسویهی مرکزی در هر تراکنش لمس میشود. اگر روی آن قفل بگیری، کل throughput سیستم به سرعت یک تراکنش روی آن حساب محدود میشود. راهحل استاندارد sharding حساب است: حساب داغ را به N زیرحساب (fee:shard:0 تا fee:shard:15) بشکن، هر تراکنش بهصورت تصادفی یا بر اساس هش یکی را انتخاب کند، و ماندهی منطقی جمع همه باشد. تجمیع به یک حساب واحد را به job شبانه بسپار. این دقیقاً همان الگویی است که در شمارندههای پرترافیک هم استفاده میشود.
در یک انتقال از A به B، اگر نخ اول A بعد B را قفل کند و نخ دوم برای انتقال B به A، B بعد A را قفل کند، هر دو برای همیشه منتظر میمانند تا دیتابیس یکی را قربانی کند. قانون: همیشه حسابها را بر اساس یک ترتیب کلی قطعی (مثلاً account_id صعودی) قفل کن، مستقل از جهت انتقال. این یک خط کد است و یک کلاس کامل از حوادث شبانه را حذف میکند.
@Transactional
public void transfer(long fromId, long toId, Money amount) {
long first = Math.min(fromId, toId);
long second = Math.max(fromId, toId);
AccountBalance a = repo.findAndLockByAccountId(first).orElseThrow();
AccountBalance b = repo.findAndLockByAccountId(second).orElseThrow();
// ... حالا با ترتیب قفل ثابت، سند دوطرفه را ثبت کن
}
انتخاب به الگوی رقابت بستگی دارد، نه سلیقه. برای حساب کاربر معمولی که رقابت کم است، قفل خوشبینانه با @Version بهترین است چون در مسیر عادی هیچ هزینهای ندارد. برای حساب داغ مثل حساب کارمزد یا تسویه، قفل خوشبینانه به طوفان retry تبدیل میشود و باید یا قفل بدبینانه با SELECT ... FOR UPDATE بگذارم یا بهتر از آن حساب را shard کنم.
ولی جواب اولِ من هیچکدام نیست: اگر عملیات را بتوانم به یک UPDATE ... WHERE balance >= :amount اتمی تبدیل کنم، همان بهترین است چون هم قفل را به کوتاهترین بازه محدود میکند و هم شرط کسبوکاری را در همان جمله چک میکند.
دربارهی سطح ایزولاسیون: READ COMMITTED برای همین بهروزرسانیهای اتمی و قفلهای صریح کافی است و پیشفرض هر دو Postgres و Oracle است. SERIALIZABLE را وقتی لازم دارم که ناوردایی روی مجموعهای از سطرها باشد نه یک سطر — مثلاً «مجموع برداشتهای امروز نباید از سقف بگذرد»، که با read committed دچار write skew میشود چون هر دو تراکنش وضعیتی را میخوانند که دیگری در حال تغییرش است.
نکتهی مهم پیادهسازی: در Postgres، SERIALIZABLE با SSI پیاده شده و ممکن است تراکنش را با 40001 serialization_failure رد کند؛ در Oracle ORA-08177 میگیری. در هر دو حالت باید یک لایهی retry با backoff داشته باشی، وگرنه سطح ایزولاسیون بالا صرفاً نرخ خطای تولید را بالا میبرد. و در نهایت، من ناوردایی نهایی را در دیتابیس هم میبندم — با CHECK، قید یکتایی و تریگر تراز — چون سطح ایزولاسیون از هوش برنامهنویس محافظت نمیکند.
یک job که ده میلیون سطر را در یک تراکنش پردازش میکند، در Postgres باعث تورم شدید و عقب افتادن VACUUM میشود و در Oracle ORA-01555 snapshot too old میگیرد. الگوی درست: پردازش را به chunk بشکن (مثلاً ۱۰۰۰ سطر)، هر chunk تراکنش خودش را داشته باشد، پیشرفت را در یک جدول checkpoint ثبت کن، و job طوری نوشته شود که با اجرای مجدد از آخرین checkpoint ادامه بدهد. Spring Batch دقیقاً برای همین ساخته شده و مفهوم chunk و ExecutionContext را آماده میدهد.
۷. کارت و سوییچ: ISO 8583 که هنوز ریل را میراند
تصور کن باید یک پیام تلگرافی بفرستی و هزینهی هر کاراکتر بالاست. پس بهجای نوشتن «نام فرستنده: ...»، یک فرم شمارهدار از قبل توافق میکنی: خانهی ۲ همیشه شمارهی کارت است، خانهی ۴ همیشه مبلغ. بعد در ابتدای پیام یک نقشه میگذاری که میگوید کدام خانهها پر شدهاند. این دقیقاً ISO 8583 است — یک قالب باینری فشرده که در دههی ۱۹۸۰ برای خطوط کند طراحی شد و امروز هنوز زیر اکثر تراکنشهای کارتی جهان کار میکند.
۷.۱ ساختار پیام
هر پیام ISO 8583 سه بخش دارد: MTI، bitmap(ها)، و data elements.
MTI (Message Type Indicator) چهار رقم است و هر رقم معنی دارد:
| موقعیت | معنی | مقادیر کلیدی |
|---|---|---|
| ۱ | نسخهی استاندارد | 0 = ۱۹۸۷، 1 = ۱۹۹۳، 2 = ۲۰۰۳ |
| ۲ | کلاس پیام | 1 مجوز، 2 مالی، 3 عملیات فایل، 4 برگشت/chargeback، 5 مغایرتگیری، 6 اداری، 7 وصول کارمزد، 8 مدیریت شبکه |
| ۳ | کارکرد پیام | 0 درخواست، 1 پاسخ درخواست، 2 اعلان (advice)، 3 پاسخ اعلان، 4 اطلاعرسانی |
| ۴ | مبدأ پیام | 0 acquirer، 1 تکرار acquirer، 2 issuer، 3 تکرار issuer، 4 سایر |
پس 0100 یعنی «درخواست مجوز از سمت acquirer» و 0110 پاسخ آن است. 0200 درخواست مالی (معمولاً خرید در POS) و 0210 پاسخش. 0400 درخواست برگشت (reversal) و 0410 پاسخش. 0800 پیام مدیریت شبکه (مثل echo test یا تبادل کلید) و 0810 پاسخش.
Bitmap یک نقشهی ۶۴ بیتی (۸ بایت یا ۱۶ کاراکتر هگز) است که میگوید کدام data elementها در پیام حاضرند. بیت شمارهی n روشن یعنی DE شمارهی n وجود دارد. بیت اول معنی خاصی دارد: اگر روشن باشد یعنی یک bitmap ثانویه هم بلافاصله بعدش میآید که فیلدهای ۶۵ تا ۱۲۸ را پوشش میدهد.
۷.۲ برگهی تقلب فیلدهای ISO 8583
قالبها: n عددی، a حرفی، an حرفی-عددی، ans حرفی-عددی-نمادی، b باینری، z دادهی نوار مغناطیسی. LLVAR یعنی طول متغیر با دو رقم پیشوند طول، LLLVAR با سه رقم.
| DE | نام | قالب | چرا اهمیت دارد |
|---|---|---|---|
| ۱ | Secondary bitmap | b 64 | فقط اگر بیت ۱ روشن باشد حاضر است |
| ۲ | Primary Account Number (PAN) | n..19 (LLVAR) | شمارهی کارت — دادهی حساس PCI |
| ۳ | Processing Code | n 6 | نوع تراکنش + نوع حساب مبدأ + نوع حساب مقصد |
| ۴ | Amount, Transaction | n 12 | مبلغ در واحد خرد، با صفر پیشوند، بدون نقطه |
| ۷ | Transmission Date & Time | n 10 | MMDDhhmmss به وقت UTC |
| ۱۱ | System Trace Audit Number (STAN) | n 6 | همبستهسازی درخواست و پاسخ — فقط ۶ رقم، میچرخد |
| ۱۲ | Local Transaction Time | n 6 | hhmmss به وقت محلی ترمینال |
| ۱۳ | Local Transaction Date | n 4 | MMDD به وقت محلی |
| ۱۴ | Expiration Date | n 4 | YYMM انقضای کارت |
| ۱۸ | Merchant Category Code (MCC) | n 4 | نوع کسبوکار پذیرنده — پایهی قواعد ریسک |
| ۲۲ | POS Entry Mode | n 3 | چیپ، مغناطیسی، بدون تماس، دستی — مؤثر بر مسئولیت تقلب |
| ۲۵ | POS Condition Code | n 2 | حضور کارت/دارنده |
| ۳۲ | Acquiring Institution ID | n..11 (LLVAR) | شناسهی بانک پذیرنده |
| ۳۵ | Track 2 Data | z..37 (LLVAR) | دادهی نوار — شامل PAN، حساسترین فیلد |
| ۳۷ | Retrieval Reference Number (RRN) | an 12 | کلید اصلی مغایرتگیری |
| ۳۸ | Authorization ID Response | an 6 | کد تأیید issuer |
| ۳۹ | Response Code | an 2 | 00 = تأیید. هر چیز دیگر یعنی رد |
| ۴۱ | Card Acceptor Terminal ID | ans 8 | شناسهی ترمینال |
| ۴۲ | Card Acceptor ID Code | ans 15 | شناسهی پذیرنده |
| ۴۳ | Card Acceptor Name/Location | ans 40 | چیزی که روی صورتحساب مشتری دیده میشود |
| ۴۸ | Additional Data, Private | ans...999 (LLLVAR) | زبالهدان اختصاصی هر شبکه |
| ۴۹ | Currency Code, Transaction | n 3 | کد عددی ISO 4217 — مثلاً 840 برای USD |
| ۵۲ | PIN Data | b 64 | PIN block رمزشده — هرگز متن ساده |
| ۵۳ | Security Related Control Information | n 16 | شاخص کلید/الگوریتم |
| ۵۴ | Additional Amounts | an...120 (LLLVAR) | ماندهی حساب، مبلغ cashback |
| ۵۵ | ICC Data (EMV) | ans...999 (LLLVAR) | TLVهای چیپ — cryptogram و نتیجهی ترمینال |
| ۹۰ | Original Data Elements | n 42 | در reversal — MTI و STAN و تاریخ پیام اصلی |
| ۹۵ | Replacement Amounts | an 42 | در partial reversal — مبلغ جدید |
| ۱۲۸ | Message Authentication Code (MAC) | b 64 | امضای تمامیت پیام |
000000012345 با ارز 840 (دلار) یعنی 123.45 دلار. ولی همین عدد با ارز 392 (ین) یعنی 12345 ین و با ارز 414 (دینار کویت) یعنی 12.345 دینار. کد ارز فیلد ۴۹ عددی است نه حرفی. اگر فرض کنی «همیشه تقسیم بر ۱۰۰»، در اولین تراکنش ارز غیرمعمول هزار برابر اشتباه میکنی. تبدیل را همیشه از Currency.getDefaultFractionDigits() بگیر.
DE11 بعد از یک میلیون تراکنش دوباره از صفر شروع میکند. در یک سیستم با ترافیک بالا این ممکن است در چند ساعت اتفاق بیفتد. هرگز STAN را بهتنهایی کلید یکتای دیتابیس نکن. کلید عملی معمولاً (acquirer_id, terminal_id, stan, transmission_date) است. همین اشتباه ساده باعث «گم شدن» تراکنشها در سیستمهای واقعی شده است.
پیام از سه بخش تشکیل شده. MTI چهار رقم است: رقم اول نسخهی استاندارد، رقم دوم کلاس پیام (۱ مجوز، ۲ مالی، ۴ برگشت، ۸ مدیریت شبکه)، رقم سوم کارکرد (۰ درخواست، ۱ پاسخ، ۲ advice) و رقم چهارم مبدأ (۰ acquirer، ۲ issuer). پس 0200 درخواست مالی و 0210 پاسخش است و 0400 درخواست reversal.
بعد bitmap میآید: ۶۴ بیت که میگوید کدام فیلدها حاضرند. اگر بیت اول روشن باشد یک bitmap ثانویه برای فیلدهای ۶۵ تا ۱۲۸ هم بلافاصله بعدش میآید. مزیت این طراحی این است که فیلدهای غایب اصلاً بایت مصرف نمیکنند — که در خطوط کند دههی هشتاد حیاتی بود.
فیلدهایی که هر روز لمس میکنم: DE2 شمارهی کارت، DE3 processing code، DE4 مبلغ در واحد خرد بدون نقطه، DE11 STAN برای همبستهسازی درخواست و پاسخ، DE37 RRN که کلید مغایرتگیری است، DE39 response code که 00 یعنی تأیید، DE41 و DE42 شناسهی ترمینال و پذیرنده، DE49 کد عددی ارز، DE52 که PIN block رمزشده است، و DE55 که TLVهای EMV را حمل میکند. در پیامهای reversal، DE90 اطلاعات پیام اصلی را حمل میکند تا سوییچ بداند چه چیزی را باطل کند.
نکتهای که سنیورها اضافه میکنند: STAN ششرقمی یکتا نیست و میچرخد، پس کلید یکتای واقعی یک تاپل از acquirer، ترمینال، STAN و تاریخ است. و اینکه ISO 8583 «یک استاندارد» نیست — هر شبکه dialect خودش را دارد، مخصوصاً در فیلدهای خصوصی ۴۸، ۶۲ و ۶۳. پس همیشه باید مشخصات دقیق طرف مقابل را داشته باشی.
۷.۳ PIN، HSM و تبادل کلید در سطح رابط
PIN هیچوقت بهصورت متن ساده در شبکه یا در حافظهی برنامه نمیگردد. ترمینال PIN را داخل یک PIN block (قالب استاندارد ISO 9564) قرار میدهد و آن را با یک کلید رمز میکند. در هر گرهی مسیر، عملیات translate انجام میشود: PIN block با کلید ورودی باز و با کلید خروجی دوباره بسته میشود — و این کار فقط داخل یک HSM انجام میشود، یک ماژول سختافزاری که کلیدها هرگز از آن خارج نمیشوند.
از دید یک مهندس جاوا، رابط عملی معمولاً یکی از اینهاست: کتابخانهی PKCS#11 برای دسترسی به ماژول رمزنگاری، یا یک پروتکل فرمان متنی روی TCP که به HSM دستور میدهی. کد تو هرگز کلید یا PIN را نمیبیند؛ فقط ورودی رمزشده را میفرستد و خروجی رمزشده را میگیرد.
سلسلهمراتب کلید سه سطح دارد و اسمهایش را باید بشناسی: LMK (Local Master Key) که داخل HSM زندگی میکند و همهچیز را محافظت میکند، ZMK (Zone Master Key) که بین دو سازمان بهصورت دستی و تحت مراسم کلید (key ceremony) با چند جزء و چند نگهبان تبادل میشود، و ZPK (Zone PIN Key) که کلید کاری روزمره است و بهصورت رمزشده تحت ZMK رد و بدل میشود.
الگوریتمها، حالتهای عملیاتی، مشتقسازی کلید، DUKPT و چرخهی عمر کلید در فصل «مدیریت کلید و رمز» با عمق کامل آمدهاند. آنچه اینجا باید بدانی صرفاً مدل مسئولیت است: کلید هرگز در برنامهی تو نیست، عملیات حساس در HSM انجام میشود، و تبادل کلید یک فرایند سازمانی با چند نفر است نه یک فراخوانی API.
۷.۴ ماسک PAN، توکنسازی و کاهش دامنهی PCI-DSS
PAN شمارهی ۱۳ تا ۱۹ رقمی کارت است. استاندارد PCI-DSS در الزام ۳.۴.۱ میگوید PAN هنگام نمایش باید ماسک شود و حداکثر شش رقم اول و چهار رقم آخر قابل مشاهده باشد، مگر برای افرادی با نیاز کاری مستند.
public static String maskPan(String pan) {
if (pan == null || pan.length() < 10) throw new IllegalArgumentException("invalid PAN");
String head = pan.substring(0, 6);
String tail = pan.substring(pan.length() - 4);
return head + "*".repeat(pan.length() - 10) + tail;
}
تیمها معمولاً یک فیلتر لاگ مینویسند که PAN را ماسک میکند و خیالشان راحت میشود. ولی PAN از راههای دیگری هم بیرون میرود: پیام استثنا، متن درخواست در APM، فایل heap dump، پارامتر URL در access log، و بدنهی درخواست ذخیرهشده در جدول idempotency. راه درست این است که PAN از همان لبهی ورودی با یک توکن جایگزین شود و در هیچ لایهی دیگری وجود نداشته باشد. و CVV/CVC هرگز نباید ذخیره شود — حتی رمزشده، حتی موقت، حتی برای دیباگ. این یکی از صریحترین ممنوعیتهای PCI-DSS است.
توکنسازی (tokenization) یعنی PAN را در یک سیستم ایزوله و کوچک (token vault) نگه داری و به بقیهی سیستم فقط یک شناسهی بیمعنا بدهی. تفاوت کلیدی با رمزنگاری این است که توکن هیچ رابطهی ریاضی با PAN ندارد؛ فقط یک نگاشت در یک دیتابیس است. اثر معماریاش بزرگ است: هر سرویسی که فقط توکن را میبیند از دامنهی PCI-DSS خارج میشود و لازم نیست ممیزی شود. همین کاهش دامنه، دلیل اصلی وجود توکنسازی است — نه فقط امنیت، بلکه هزینه و سرعت انطباق.
PAN دادهی کارتخوان است و PCI-DSS برایش قواعد سختی دارد. سه قاعدهی عملی که رعایت میکنم: هنگام نمایش حداکثر شش رقم اول و چهار رقم آخر (الزام ۳.۴.۱)؛ در حالت ذخیره غیرقابلخواندن با یکی از روشهای مجاز؛ و دادههای احراز حساس مثل CVV و محتوای کامل نوار و PIN block هرگز بعد از تأیید ذخیره نمیشوند.
ولی جواب معماری این است که PAN را اصلاً وارد سیستم نکنم. در لبهی ورودی — معمولاً یک سرویس کوچک و بهشدت محدودشده — PAN با یک توکن بیمعنا جایگزین میشود و از آن به بعد همهی سرویسها، دیتابیسها، لاگها و صفها فقط توکن را میبینند. توکن برخلاف متن رمزشده هیچ رابطهی ریاضی با PAN ندارد، پس افشای آن بهتنهایی بیارزش است.
چیزی که این کار واقعاً حل میکند کاهش دامنهی ممیزی است. هر سیستمی که دادهی کارت را ذخیره، پردازش یا منتقل کند وارد دامنهی PCI-DSS میشود و باید کنترلهای کامل را پیاده کند. با توکنسازی، بهجای ممیزی سی سرویس، یک vault کوچک را ممیزی میکنی. این هم هزینه را چند برابر کم میکند و هم سرعت تحویل بقیهی تیم را آزاد میکند.
۸. واقعیت core banking و یکپارچهسازی
۸.۱ پردازش پایان روز و cut-off
سیستمهای core banking سنتی حول یک چرخهی روزانه ساخته شدهاند. در یک زمان قطع (cut-off time) — مثلاً ساعت ۲۰:۰۰ — روز کاری بسته میشود. تراکنشهای بعد از آن به روز کاری بعد تعلق میگیرند، حتی اگر امروز اتفاق افتاده باشند.
بعد از cut-off، پردازش پایان روز (EOD) اجرا میشود: محاسبهی سود، اعمال کارمزدها، تولید صورتحسابها، بستن دفتر روز و باز کردن روز بعد. در این پنجره — که ممکن است ساعتها طول بکشد — core معمولاً یا کاملاً در دسترس نیست، یا فقط خواندنی است، یا فقط تراکنشهای خاصی را میپذیرد.
اگر سرویس تو در ساعت EOD درخواست بفرستد، پاسخهای عجیبی میگیری: تایماوت، کد خطای «سیستم در دسترس نیست»، یا بدتر — تراکنشی که پذیرفته میشود ولی به روز بعد میرود در حالی که تو فکر میکنی امروز ثبت شده. طراحی درست این است که پنجرهی EOD را در پیکربندی داشته باشی، در آن بازه درخواستها را در صف نگه داری (نه اینکه fail کنی)، و به کاربر زمان پردازش واقعی را اعلام کنی.
۸.۲ تاریخ ارزش در برابر تاریخ ثبت
این تمایز در مصاحبههای بانکی مرتب پرسیده میشود و مهندسها معمولاً نمیدانند.
- تاریخ ثبت (posting date / booking date): تاریخی که تراکنش در سیستم ثبت شد. یک واقعیت فنی.
- تاریخ ارزش (value date): تاریخی که تراکنش از نظر مالی مؤثر است — مبنای محاسبهی سود، سقف روزانه و گزارشهای حسابداری.
این دو میتوانند متفاوت باشند: چکی که جمعه ثبت میشود ممکن است تاریخ ارزش دوشنبه داشته باشد. تراکنش بعد از cut-off امروز ثبت میشود ولی تاریخ ارزشش فرداست. یک اصلاح که امروز ثبت میشود ممکن است تاریخ ارزش ماه گذشته را داشته باشد (backdating).
اگر فقط یک ستون تاریخ داری، دیر یا زود گزارشهایت غلط میشوند. posted_at را timestamptz بگیر (لحظهی واقعی، غیرقابل تغییر، مبنای حسابرسی) و value_date را date (تاریخ کسبوکاری، ممکن است گذشته باشد). گزارشهای مالی روی value_date گروهبندی میشوند و گزارشهای عملیاتی روی posted_at. و همیشه منطقهی زمانی روز کاری را صریح کن — «روز» در بانک به وقت منطقهی سیستم تسویه است، نه به وقت سرور تو.
۸.۳ رابطهای واقعی: SOAP، فایل با طول ثابت، و صبر
انتظار نداشته باشه که core بانکی یک REST API تمیز با JSON بدهد. آنچه در عمل میبینی:
- SOAP روی HTTPS با یک WSDL بزرگ و اسکیمای سختگیر (جزئیات در فصل «SOAP و SOA»).
- ISO 8583 روی TCP با فریمبندی طول-پیشوند، معمولاً روی یک اتصال دائمی با پیامهای echo دورهای برای زنده نگه داشتن آن.
- فایل با طول ثابت روی SFTP: هر رکورد یک خط با موقعیتهای ثابت، بدون جداکننده، با padding با فاصله یا صفر.
- ISO 20022 XML در سامانههای مدرنتر:
pain.001برای دستور پرداخت مشتری،pacs.008برای انتقال بین بانکها،camt.053برای صورتحساب.
پارس کردن رکورد با طول ثابت در جاوا ساده است ولی باید دقیق باشد:
public record SettlementRecord(String rrn, long amountMinor, String currency, String date) {
// مثال: موقعیتها بر اساس مشخصات طرف مقابل — همیشه مستند را مبنا بگیر
public static SettlementRecord parse(String line) {
if (line.length() < 33) throw new IllegalArgumentException("short record: " + line.length());
String rrn = line.substring(0, 12).trim();
long amount = Long.parseLong(line.substring(12, 24)); // بدون نقطه، با صفر پیشوند
String currency = line.substring(24, 27);
String date = line.substring(27, 35); // YYYYMMDD
return new SettlementRecord(rrn, amount, currency, date);
}
}
اول، کدگذاری کاراکتر. خیلی از هستههای قدیمی خروجی را با یک code page غیر UTF-8 تولید میکنند. new String(bytes) از charset پیشفرض JVM استفاده میکند که روی سرورهای مختلف فرق دارد و در تولید کاراکترها را خراب میکند. همیشه charset را صریح بده.
دوم، طول بر حسب بایت یا کاراکتر. در فایل با طول ثابت، «موقعیت ۱۲ تا ۲۴» یعنی بایت، نه کاراکتر. اگر فایل کاراکتر چندبایتی داشته باشد و تو با String.substring کار کنی، همهچیز جابهجا میشود. برای فایلهای واقعاً باینری روی byte[] کار کن.
سوم، اتمی نبودن آپلود. اگر فایل را روی SFTP در حال نوشتن بخوانی، یک فایل ناقص پردازش میکنی. قرارداد استاندارد این است که فرستنده بعد از اتمام یک فایل نشانگر (trigger/done file) میسازد، یا فایل را با نام موقت مینویسد و در پایان تغییر نام میدهد. هرگز صرفاً بر اساس وجود فایل شروع نکن.
posting date لحظهای است که تراکنش در سیستم ثبت شد — یک واقعیت فنی و غیرقابل تغییر که مبنای حسابرسی است. value date تاریخی است که تراکنش از نظر مالی مؤثر است و مبنای محاسبهی سود، سقفهای روزانه و گزارشهای حسابداری قرار میگیرد.
این دو مرتب از هم جدا میشوند: تراکنشی که بعد از cut-off انجام شود امروز ثبت میشود ولی تاریخ ارزشش روز کاری بعد است؛ اصلاحی که امروز ثبت میشود ممکن است تاریخ ارزش ماه گذشته داشته باشد؛ و ابزارهایی مثل چک تاریخ ارزش آینده دارند.
در طراحی، هر سطر دفتر باید هر دو را داشته باشد و هیچ گزارشی نباید از تاریخ اشتباه استفاده کند. گزارش سود و زیان و تراز باید روی value date گروهبندی شود، وگرنه بستن ماه غلط میشود. گزارش عملیاتی و پایش باید روی posting date باشد. و چون تاریخ ارزش میتواند گذشته باشد، هر گزارشی که قبلاً برای آن دوره تولید شده باید بتواند دوباره تولید شود — یعنی گزارشهای نهاییشده را باید snapshot کنی نه اینکه هر بار از نو محاسبه کنی، وگرنه عدد دیروزت با عدد امروزت فرق میکند و کسی به سیستم اعتماد نمیکند.
۹. وظایف نظارتی که مهندس پیاده میکند
اینجا فهرست چیزهایی است که در آگهی شغلی نوشته نمیشود ولی در روز اول از تو انتظار میرود.
رد حسابرسی (audit trail) و تغییرناپذیری. هر تغییر وضعیت باید ثبت شود: چه کسی، چه زمانی، از چه حالتی به چه حالتی، و چرا. جدول audit باید فقط-افزودنی باشد و دسترسی UPDATE/DELETE حتی برای کاربر برنامه هم گرفته شود.
نقاط تماس KYC و AML. KYC (Know Your Customer) یعنی احراز و تأیید هویت مشتری قبل از فعال شدن حساب. AML (Anti-Money Laundering) یعنی پایش تراکنشها برای الگوهای مشکوک. از دید مهندسی، اینها معمولاً سه نقطهی تماساند: غربالگری هنگام ثبتنام در برابر فهرستهای تحریم و افراد در معرض خطر سیاسی، غربالگری همزمان (inline) قبل از اجرای تراکنش، و پایش رفتاری غیرهمزمان که الگوها را روی تاریخچه بررسی میکند.
اگر سرویس غربالگری در دسترس نباشد، رفتار پیشفرض چه باشد؟ در دامنهی مالی جواب معمولاً fail-closed است: تراکنش را رد یا معلق کن. این دقیقاً برعکس شهود مهندسی معمول است که میگوید «سرویس جانبی نباید مسیر اصلی را بشکند». اینجا سرویس جانبی نیست، یک الزام قانونی است و اجرای تراکنشی که باید مسدود میشد، جریمه و ریسک مجوز دارد. این تصمیم را باید صریح، مستند و با تأیید کسبوکار بگیری — نه بهصورت ضمنی در یک fallback.
سقف تراکنش (limits). سقف به ازای تراکنش، روزانه، ماهانه، به ازای کانال و به ازای سطح احراز هویت. نکتهی فنی مهم: سقف روزانه یک ناوردایی روی مجموعه است، پس دقیقاً همان جایی است که به SERIALIZABLE یا به یک شمارندهی اتمی نیاز داری.
-- شمارندهی مصرف روزانه با درج-یا-بهروزرسانی اتمی
INSERT INTO daily_usage (customer_id, usage_date, used_amount)
VALUES (:customerId, :date, :amount)
ON CONFLICT (customer_id, usage_date)
DO UPDATE SET used_amount = daily_usage.used_amount + EXCLUDED.used_amount
WHERE daily_usage.used_amount + EXCLUDED.used_amount <= :dailyLimit
RETURNING used_amount;
-- بازگشت بدون سطر یعنی سقف رد میشدMERGE INTO daily_usage d
USING (SELECT :customerId AS customer_id, :date AS usage_date, :amount AS amt FROM dual) s
ON (d.customer_id = s.customer_id AND d.usage_date = s.usage_date)
WHEN MATCHED THEN UPDATE SET d.used_amount = d.used_amount + s.amt
WHERE d.used_amount + s.amt <= :dailyLimit
WHEN NOT MATCHED THEN INSERT (customer_id, usage_date, used_amount)
VALUES (s.customer_id, s.usage_date, s.amt);
-- SQL%ROWCOUNT برابر 0 یعنی سقف رد میشدنگهداشت داده (retention). دادهی مالی معمولاً باید سالها نگه داشته شود، در حالی که دادهی شخصی طبق قوانین حریم خصوصی باید بعد از پایان هدف حذف شود. این دو در تعارضاند و راهحل استاندارد جداسازی است: دفتر کل شناسهی مصنوعی نگه میدارد، اطلاعات شخصی در یک سرویس جدا با چرخهی عمر خودش زندگی میکند. حذف دادهی شخصی نباید دفتر را بشکند.
تأیید چهارچشمی (four-eyes) و تفکیک وظایف (segregation of duties). عملیات پرریسک — تغییر سقف، بازپرداخت دستی، ثبت سند اصلاحی — باید توسط یک نفر آغاز و توسط شخص دیگری تأیید شود. تفکیک وظایف یعنی هیچ نقشی نباید هم بتواند عملیات را بسازد و هم تأیید کند و هم لاگش را پاک کند.
@Service
public class ManualAdjustmentService {
@PreAuthorize("hasRole('OPS_MAKER')")
@Transactional
public long propose(AdjustmentRequest req, String actor) {
return repo.saveProposal(req, actor); // فقط پیشنهاد — هیچ اثر مالی
}
@PreAuthorize("hasRole('OPS_CHECKER')")
@Transactional
public void approve(long proposalId, String approver) {
Proposal p = repo.load(proposalId);
if (p.proposedBy().equals(approver)) {
throw new IllegalStateException("four-eyes violation: proposer cannot approve");
}
ledger.post(p.toJournalEntry(), approver); // اینجا و فقط اینجا پول حرکت میکند
}
}
پنهان کردن دکمهی «تأیید» برای کاربری که پیشنهاد را ساخته یک بهبود تجربه است، نه یک کنترل امنیتی. کنترل واقعی همان if سمت سرور بهاضافهی نقشهای جداست. حسابرس دقیقاً همین را میپرسد: «نشان بده که کاربری با نقش maker نمیتواند از طریق API مستقیم تأیید کند». اگر جوابت «در UI دکمه ندارد» باشد، ممیزی رد میشود.
در چند لایه. لایهی اول دیتابیس است: مجوز UPDATE و DELETE روی جداول دفتر از نقش برنامه گرفته میشود و درج فقط از طریق یک view یا رویهی مجاز انجام میشود. لایهی دوم مدل دامنه است: entity دفتر هیچ setter ندارد و هیچ مسیر کدی برای تغییرش وجود ندارد؛ اصلاح فقط با ثبت یک سند معکوس که به سند اصلی ارجاع میدهد.
لایهی سوم تشخیص است: یک زنجیرهی درهمسازی روی سطرها نگه میدارم — هر سطر هش خودش را بههمراه هش سطر قبلی ذخیره میکند — و یک job دورهای زنجیره را بازبینی میکند. این کار جلوی تغییر را نمیگیرد ولی هر تغییری را قابل کشف میکند، که برای حسابرس همان اندازه ارزش دارد.
لایهی چهارم عملیاتی است: تفکیک وظایف بین کسی که کد را مینویسد، کسی که به تولید دسترسی دارد و کسی که حسابرسی میکند؛ بهعلاوهی نگهداری بکاپها و لاگهای حسابرسی روی یک ذخیرهساز فقط-افزودنی جدا.
و یک نکتهی مهم که خیلیها فراموش میکنند: مهاجرت اسکیما (migration) هم میتواند دفتر را تغییر دهد. هر migration که جداول مالی را لمس میکند باید بازبینی جداگانه و مسیر تأیید جداگانه داشته باشد.
۱۰. مدیریت خطا در دامنهای که خطا گران است
فصل «تابآوری» الگوهای عمومی (circuit breaker، bulkhead، retry با backoff) را داده. تفاوتهای دامنهای اینهاست:
۱) retry فقط روی عملیات idempotent. یک درخواست مجوز که تایماوت خورده را نمیتوانی کورکورانه دوباره بفرستی — ممکن است دو hold روی حساب مشتری بگذاری. اول reversal یا پرسوجوی وضعیت، بعد تصمیم.
۲) circuit breaker باید حالت را «نامعلوم» بگذارد، نه «ناموفق». وقتی مدار باز است و درخواستی رد میشود، آن تراکنش هرگز فرستاده نشده پس واقعاً ناموفق است. ولی درخواستی که فرستاده شد و تایماوت خورد، UNKNOWN است. این دو را در کد یکی نکن.
۳) تسویهی جزئی (partial settlement). فایل تسویه ممکن است مبلغی کمتر از مبلغ مجوز داشته باشد — بهخاطر partial capture، کسر کارمزد شبکه، یا تعدیل ارزی. سیستم باید این را بهعنوان یک حالت معتبر بشناسد و مابهالتفاوت را به حساب مناسب ببرد، نه اینکه آن را break بشمارد.
۴) تراکنش گیرکرده و ابزار مداخلهی انسانی. همیشه تراکنشهایی میمانند که خودکار حل نمیشوند. باید ابزاری بسازی که: تراکنشهای گیرکرده را با سن و مبلغ فهرست کند، تاریخچهی کامل رویدادها و پیامهای خام را نشان دهد، اقدامات مجاز را بر اساس حالت ارائه کند، هر اقدام را با چهارچشمی و لاگ کامل انجام دهد.
تیمهایی که ابزار عملیات نمیسازند، بهجایش به مهندسها دسترسی مستقیم به دیتابیس تولید میدهند. این بدترین حالت ممکن است: بدون حسابرسی، بدون چهارچشمی، بدون اعتبارسنجی، و با ریسک یک UPDATE بدون WHERE. برای هر سناریوی مداخلهای که سه بار تکرار شد، یک عملیات صریح در ابزار بساز. معیار سنجش بلوغ یک تیم پرداخت این است: چند بار در ماه کسی مجبور میشود در دیتابیس تولید SQL دستی بزند؟ جواب باید صفر باشد.
وسوسهی «فقط این یک سطر را درست میکنم» کشنده است. اگر کسی مستقیم وضعیت تراکنش را در دیتابیس عوض کند بدون اینکه سند دفتری متناظر ثبت شود، دفتر و وضعیت واگرا میشوند و مغایرتگیری فردا شب یک break غیرقابل توضیح تولید میکند که هیچکس منشأش را پیدا نمیکند. قانون: هیچ تغییر وضعیت مالی بدون سند.
۱۱. معماری مرجع: یک سرویس پرداخت با Spring
اجزای اصلی و مسئولیت هرکدام:
معماری یک سرویس پرداخت · Reference architecture of a payment service
flowchart TD
C[Client] -->|POST with Idempotency-Key| API[Payment API<br/>validation + idempotency]
API --> SVC[Payment orchestrator]
SVC --> LED[(Ledger<br/>append-only journal)]
SVC --> OBX[(Outbox table)]
SVC --> SW[Switch adapter<br/>ISO 8583 over TCP]
OBX --> REL[Outbox relay] --> MQ[[Message broker]]
MQ --> NOTIF[Notification service]
MQ --> RPT[Reporting projection]
FILE[Settlement file] --> RECON[Reconciliation job]
RECON --> LED
RECON --> OPS[Ops console<br/>four-eyes actions]
OPS --> LED
- مرز API اعتبارسنجی، idempotency و ترجمه به مدل دامنه را انجام میدهد. هیچ منطق مالی اینجا نیست.
- هماهنگکننده state machine تراکنش را پیش میبرد و تصمیم میگیرد چه اسنادی ثبت شوند.
- دفتر کل تنها منبع حقیقت مالی است، فقط-افزودنی.
- Outbox رویدادها را در همان تراکنش دفتر مینویسد تا انتشار و ثبت همسرنوشت شوند (الگو در فصل «داده در میکروسرویس»).
- آداپتور سوییچ تنها جایی است که ISO 8583 را میفهمد. بقیهی سیستم مدل دامنه میبیند — همان مرز پورت/آداپتور فصل «معماری ششضلعی».
- مغایرتگیری حقیقت بیرونی را با دفتر تطبیق میدهد و سند جبرانی میسازد.
- کنسول عملیات تنها راه مداخلهی انسانی است، با چهارچشمی و لاگ کامل.
جریان یک پرداخت موفق · Sequence of a successful payment
sequenceDiagram
participant C as Client
participant A as Payment API
participant L as Ledger DB
participant S as Switch
participant R as Outbox relay
C->>A: POST /payments (Idempotency-Key)
A->>L: INSERT idempotency IN_PROGRESS
A->>L: INSERT journal entry (authorised)
A->>S: 0200 financial request
S-->>A: 0210 response code 00
A->>L: INSERT journal entry (captured) + outbox row
A->>L: UPDATE idempotency COMPLETED
A-->>C: 201 Created
R->>R: poll outbox and publish event
جریان یک پرداخت شکستخورده که با مغایرتگیری حل میشود · Sequence of a failed payment resolved by reconciliation
sequenceDiagram
participant C as Client
participant A as Payment API
participant L as Ledger DB
participant S as Switch
participant J as Recon job
C->>A: POST /payments (Idempotency-Key)
A->>L: INSERT journal entry (authorised)
A->>S: 0200 financial request
S--xA: timeout, no response
A->>L: mark payment UNKNOWN
A-->>C: 202 Accepted, result pending
A->>S: 0400 reversal advice (retried)
Note over J: next morning batch
J->>J: load settlement file
J->>L: found in file, not settled in ledger
J->>L: INSERT compensating journal entry
J->>L: mark payment SETTLED
۱) هر تغییر پول یک سند متوازن در دفتر دارد و دفتر هرگز ویرایش نمیشود. ۲) هر درخواست بیرونی حداکثر یک بار اثر مالی میگذارد، مستقل از تعداد retry. ۳) هیچ رویدادی منتشر نمیشود مگر اینکه اثر دیتابیسیاش commit شده باشد (و برعکس). ۴) هر اختلافی با دنیای بیرون حداکثر تا یک چرخهی مغایرتگیری کشف میشود و با سند جبرانی حل میشود، نه با ویرایش.
۱۲. واقعیت مصاحبه
سؤالهای این حوزه دو لایه دارند. لایهی اول غربالگر است: اگر جواب ندهی، مصاحبه همانجا تمام میشود. لایهی دوم قضاوت سنیوری است: جواب درست کافی نیست، باید نشان دهی که trade-off را میفهمی.
اول تفکیک میکنم که کدام مانده را میخواهند. اگر ماندهای است که تصمیم مالی روی آن گرفته میشود (مثل چک موجودی قبل از برداشت)، باید از منبع تراکنشی و در همان تراکنش خوانده شود، معمولاً با یک UPDATE ... WHERE balance >= :amount اتمی که همزمان میخواند و مینویسد. اگر ماندهای است که فقط نمایش داده میشود، میتوانم از یک projection خوانشی با تأخیر کوتاه استفاده کنم و صراحتاً به کاربر بگویم که ممکن است چند ثانیه عقب باشد.
برای کارایی، سه راه دارم: جمع کامل دفتر که درستترین ولی کندترین است؛ snapshot دورهای بهاضافهی دلتای بعد از آن که تعادل خوبی است؛ و ماندهی جاری در هر سطر که خواندن را به یک سطر تبدیل میکند ولی درج در هر حساب را سریالی میکند.
چیزی که هرگز نمیکنم این است که مانده را دادهی اولیهی قابل ویرایش بگیرم. اگر cache میکنم، آن را در همان تراکنش دفتر بهروز میکنم و یک job روزانه دارم که cache را با جمع واقعی دفتر مقایسه میکند. اگر اختلافی پیدا شد، cache را از دفتر بازسازی میکنم و alert میدهم — چون اختلاف یعنی یک باگ جایی وجود دارد.
اول اینکه چه چیزی نمیکنم: نه درخواست را کورکورانه دوباره میفرستم و نه به کاربر «ناموفق» میگویم. هیچکدام را نمیدانم.
تراکنش را در حالت UNKNOWN میگذارم — یک حالت درجهیک در state machine، نه یک flag. به کاربر پاسخ «در حال پردازش» میدهم با یک شناسهی پیگیری.
بعد یکی از دو مسیر قطعیسازی را میروم. اگر پروتکل پرسوجوی وضعیت دارد، با شناسهی یکتای تراکنش میپرسم که آیا سوییچ آن را دارد. اگر ندارد، پیام reversal با ارجاع به دادههای پیام اصلی میفرستم — reversal طبق طراحی idempotent است و تا گرفتن پاسخ قطعی با backoff تکرارش میکنم.
اگر هیچکدام در پنجرهی زمانی جواب نداد، تراکنش وارد صف مغایرتگیری میشود و فایل تسویهی صبح فردا حقیقت را میگوید: اگر در فایل بود، سند جبرانی ثبت میکنم و وضعیت را موفق میکنم؛ اگر نبود، ناموفق قطعی میشود و hold آزاد شده.
و مهمتر از همه: در تمام این مدت، پول کاربر در دفتر من در یک حساب واسط «در انتظار تعیین تکلیف» نشسته است، نه اینکه ناپدید شده باشد. هر ریالی همیشه در یک حساب هست — این خاصیت دفترداری دوطرفه است که نمیگذارد پول گم شود، فقط نشان میدهد که کجا گیر کرده.
هرگز 100 / 3 با گرد کردن مستقل. هر سه 33.33 میشوند و جمعشان 99.99 است — یک سنت گم میشود و دفتر نامتوازن میماند.
الگوریتم درست: مبلغ را به واحد خرد ببر (۱۰۰۰۰ سنت)، تقسیم صحیح کن (۳۳۳۳ به هر نفر)، باقیمانده را حساب کن (۱ سنت)، و آن را طبق یک سیاست قطعی توزیع کن — یا به اولین دریافتکنندهها، یا به بزرگترین بخش کسری، یا به یک حساب مشخص rounding_difference.
سه نکتهای که سنیور اضافه میکند: اول، الگوریتم باید برای مبالغ منفی هم درست کار کند (باقیمانده در جهت درست توزیع شود). دوم، سیاست باید مستند و قابل توضیح به حسابرس باشد، نه یک تصادف پیادهسازی. سوم، باید تست property-based بنویسم که برای هر مبلغ و هر تعداد سهم بررسی کند که جمع خروجی دقیقاً برابر ورودی است و بیشترین اختلاف بین سهمها حداکثر یک واحد خرد است. این تست دقیقاً همان چیزی است که اثبات میکند فهمیدهای مسئله چیست.
جدول تراکنش میگوید «چه اتفاقی افتاد» ولی نمیگوید «پول الان کجاست». دفتر دوطرفه یک ناوردایی ساختاری اضافه میکند: مجموع همهی بدهکارها همیشه برابر مجموع همهی بستانکارهاست. این یعنی هر باگی که پول را از هوا بسازد یا ناپدید کند، در تراز آزمایشی خودش را نشان میدهد — نه شش ماه بعد در شکایت مشتری.
دوم اینکه دفتر دوطرفه پول در حال گذار را قابل مشاهده میکند. با حسابهای واسط، همیشه میتوانی بپرسی «چقدر پول الان در وضعیت مجوزگرفته ولی تسویهنشده است؟» و جواب یک عدد قابل محاسبه است. با جدول تراکنش ساده، این سؤال به یک کوئری شکننده با کلی CASE تبدیل میشود.
سوم، زبان مشترک با تیم مالی و حسابرس. آنها با تراز و دفتر کار میکنند. اگر مدل تو با مدل آنها یکی باشد، هر گزارشی که میسازی مستقیماً قابل تطبیق است. اگر نباشد، یک لایهی ترجمهی شکننده میسازی که خودش منبع اختلاف میشود.
هزینهاش را هم انکار نمیکنم: سطرهای بیشتر، کوئریهای سنگینتر برای مانده، و نیاز به snapshot. ولی این هزینهای است که برای قابل کشف بودن خطا میدهی، و در دامنهی پول این معامله همیشه بهصرفه است.
در این دامنه، درستی همیشه بر throughput مقدم است. مهندس میانی میگوید «قفل throughput را پایین میآورد، بیایید بدون قفل بنویسیم». سنیور میگوید «اول ناوردایی را تضمین کن، بعد اندازه بگیر، و اگر واقعاً گلوگاه بود با sharding حساب یا batching حلش کن — نه با ول کردن ناوردایی».
سه جملهای که در مصاحبه امتیاز میگیرند: «این حالت را نمیدانم، پس آن را UNKNOWN مدل میکنم نه FAILED»؛ «این را در دیتابیس هم میبندم چون کد ممکن است دور زده شود»؛ و «چطور میفهمیم اگر این خراب شود؟» — چون در سیستم مالی، خطایی که کشف نشود بدتر از خطایی است که سیستم را میخواباند. یک سیستم خوابیده معلوم است؛ یک سیستم که بیصدا پول را اشتباه جابهجا میکند، ماهها معلوم نیست.
پول را هرگز در اعشار شناور نگه ندار: BigDecimal با scale و RoundingMode صریح، یا عدد صحیح از واحد خرد، همیشه همراه با ارز — و آن را در یک نوع Money حبس کن تا جمع دو ارز یا گرد کردن ضمنی غیرممکن شود. در دیتابیس numeric(20,4) یا NUMBER(20,4) بگذار، هرگز float.
قلب سیستم دفتر کل دوطرفه است: هر رویداد یک سند متوازن با سطرهای بدهکار و بستانکار، دفتر فقط-افزودنی، اصلاح فقط با سند معکوس، و مانده همیشه مشتق — از جمع کامل، از snapshot بهاضافهی دلتا، یا از یک projection. ستون ماندهی قابل ویرایش یک باگ منتظر وقوع است.
چرخهی عمر تراکنش را با نامهای درستش بشناس: مجوز پول را جابهجا نمیکند، capture تعهد است، clearing تهاتر است و فقط settlement پول را حرکت میدهد. reversal برای قبل از تسویه و refund برای بعد از آن است.
مرز API را idempotent بساز: کلید تکرارناپذیری با قید یکتایی دیتابیس، اثر انگشت روی شکل متعارفشده، پاسخ ذخیرهشده برای replay. تایماوت را هرگز حدس نزن — حالت UNKNOWN بساز و با پرسوجو، reversal یا مغایرتگیری قطعیاش کن. مغایرتگیری روزانه با دستهبندی مغایرتها و سند جبرانی، شبکهی ایمنی نهایی توست.
در همزمانی، بهروزرسانی اتمی شرطی بهترین گزینه است، قفل بدبینانه برای حساب داغ، قفل خوشبینانه برای رقابت کم، ترتیب قفل ثابت برای پرهیز از deadlock، و SERIALIZABLE با retry فقط وقتی ناوردایی روی مجموعه است. در سمت کارت، ساختار ISO 8583 را بشناس — MTI، bitmap و فیلدهای کلیدی — و بدان که PAN باید توکن شود، CVV هرگز ذخیره نشود و PIN فقط داخل HSM باز شود.
و در نهایت، وظایف نظارتی بخشی از کد توست نه کار حقوقیها: رد حسابرسی تغییرناپذیر، نقاط تماس KYC/AML با رفتار fail-closed، سقفهای اتمی، تفکیک نگهداشت داده، و تأیید چهارچشمی در سرور. اگر یک جمله از این فصل با خودت ببری این باشد: در دامنهی پول، خطایی که کشف نشود بدتر از خطایی است که سیستم را میخواباند.
Read enough job postings from banks, fintechs, PSPs and payment-infrastructure companies and a pattern jumps out: Java and Spring are treated as the baseline, and what they are actually hunting for is domain knowledge. Phrases like "familiarity with double-entry bookkeeping", "experience with ISO 8583", "reconciliation", "settlement", "idempotency in financial transactions". These are not buzzwords. They are a coherent body of knowledge that nobody taught you at university and that almost no Java course covers.
The good news is that this knowledge is learnable and it is not that large. The bad news is that without it you are exposed in the first fifteen minutes of an interview, because the screening questions in this domain are brutally simple: "Why don't you use double for money?", "You got a timeout and you don't know whether the money moved — now what?", "Where do you store the account balance?".
This chapter starts from zero. I assume you have never worked in finance and do not know what "debit" and "credit" mean. By the end you should be able to design a payment service with a correct ledger, an idempotent API boundary and a reconciliation job — and defend it in an interview.
- The money model — why floating point is malpractice,
BigDecimalwith explicit scale and RoundingMode, minor units, splitting an amount and distributing the remainder, and how to store money in a database. - Double-entry bookkeeping — accounts, debits, credits, journal entries, the append-only ledger, the trial balance, and why a mutable
balancecolumn is a bug waiting to happen. - The transaction lifecycle — authorisation, capture, clearing, settlement, refund, reversal, chargeback, dispute — and where the money actually is at each step.
- Idempotency — idempotency keys, request fingerprinting, why exactly-once is an illusion, and the timeout where you genuinely do not know what happened.
- Reconciliation — comparing your ledger against a switch file or bank statement, matching strategies, tolerance windows, break categories, with real SQL.
- Concurrency — pessimistic versus optimistic locking on an account row, isolation levels, lost updates, lock ordering and deadlocks.
- Cards and switches — ISO 8583 field by field, HSMs and PIN blocks, PAN masking, tokenization and PCI-DSS scope reduction.
- Core banking reality — end-of-day processing, cut-off times, value date versus posting date, file-based and SOAP interfaces.
- Regulatory duties — audit trail, KYC/AML touchpoints, transaction limits, data retention, four-eyes approval, segregation of duties.
- Failure handling and a reference architecture with diagrams, and finally the interview reality.
Some topics are deliberately not re-taught here because they have their own chapters: the Saga and Transactional Outbox patterns and CQRS live in the microservices data chapter, HSM cryptography and key lifecycle detail live in the key-management chapter, transactions and ACID live in the ACID chapter, and WSDL/SOAP lives in the SOAP and SOA chapter. I reference them and focus on what exists nowhere else: the money domain.
1. The money model: the first place engineers die
1.1 Why double is banned for money
Imagine a ruler that can only mark fractions whose denominator is a power of two: a half, a quarter, an eighth, a sixteenth. Now you are asked to mark "one tenth of a metre". You pick the nearest line — slightly before or after the true tenth. Once, it does not matter. Do it a thousand times and let the errors accumulate, and you are centimetres off.
double and float are exactly that ruler: they store numbers in base two. 0.1 in base two is an infinitely repeating fraction, exactly like 1/3 in base ten. So it is not stored exactly — it is approximated.
Here is what that looks like in practice:
public class WhyNotDouble {
public static void main(String[] args) {
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(1.03 - 0.42); // 0.6100000000000001
System.out.println(0.1 + 0.1 + 0.1 == 0.3); // false
double balance = 0.0;
for (int i = 0; i < 10_000; i++) balance += 0.01;
System.out.println(balance); // 100.00000000000163
}
}
That extra 0.00000000000163 looks harmless. It is not. In a banking system it means:
- The trial balance does not come to zero and the accounting day cannot be closed.
- The sum of line items does not match the statement total and automated reconciliation fails.
if (balance == 0)is never true and a zeroed account is never closed.
A common mistake: "we compute with BigDecimal but the outgoing DTO uses double, only for JSON". You burn there too — serialising 0.1 + 0.2 as 0.30000000000000004 in an API response ends up in a customer-facing report. Use BigDecimal, String or long minor units in the DTO as well. Transporting monetary amounts as JSON strings is a common and correct pattern, because some parsers (JavaScript among them) coerce every JSON number to a double and lose precision above 2^53.
1.2 BigDecimal — but with explicit scale and RoundingMode
BigDecimal stores a number as a big integer (unscaledValue) plus a scale (the number of decimal digits). So 12.34 is unscaledValue = 1234, scale = 2. Because the base is ten, 0.1 is exactly 0.1.
Three rules, without which BigDecimal will betray you too:
Rule one: never use the BigDecimal(double) constructor.
new BigDecimal(0.1);
// 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1"); // 0.1 ✅
BigDecimal.valueOf(0.1); // 0.1 (goes through Double.toString)
The double constructor faithfully translates the approximate binary value into decimal, so it preserves the garbage. Always use the String constructor or valueOf.
Rule two: every divide without a RoundingMode is a latent ArithmeticException.
BigDecimal one = new BigDecimal("1");
BigDecimal three = new BigDecimal("3");
one.divide(three);
// ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
one.divide(three, 2, RoundingMode.HALF_UP); // 0.33 ✅
Rule three: do not confuse equals with compareTo.
new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false — different scale
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0 ✅ equal in value
This one bites in Set<BigDecimal>, in Map keys, and in assertEquals tests. For value comparison always use compareTo(...) == 0.
RoundingMode.HALF_UP is schoolbook rounding: 2.5 → 3. It is the financial convention in most jurisdictions and usually what tax rules require.
RoundingMode.HALF_EVEN (banker's rounding) rounds a .5 to the nearest even number: 2.5 → 2, 3.5 → 4. This removes statistical bias at volume and is common in statistical and risk calculations.
RoundingMode.DOWN (truncation) is used where a fee must be rounded in the customer's favour.
RoundingMode.UNNECESSARY is the golden one: it asserts "no rounding should be needed here" and throws if any is. Use it at the point where a journal entry is created, so an amount arriving with an unexpected scale explodes loudly instead of being silently rounded.
1.3 Minor units and integer amounts
Every currency has a minor unit: the smallest tradeable denomination. For the dollar it is the cent, one hundredth. The ISO 4217 standard defines an exponent per currency:
| Exponent | Meaning | Example currencies |
|---|---|---|
| 0 | No decimals | JPY, KRW, VND, ISK, XAF |
| 2 | Two decimals (the vast majority) | USD, EUR, GBP, CHF |
| 3 | Three decimals | KWD, BHD, OMR, JOD, TND, IQD, LYD |
| 4 | Four decimals | CLF |
So a 100 in a payment message means one hundred yen in JPY, one dollar in USD, and 0.1 dinar in KWD. Hard-code "always divide by 100" and your system is wrong in Japan and Kuwait.
In Java, get this from the JDK, not from a hand-maintained table:
import java.util.Currency;
Currency jpy = Currency.getInstance("JPY");
jpy.getDefaultFractionDigits(); // 0
jpy.getNumericCode(); // 392 (the ISO 4217 numeric code — the one that goes into ISO 8583)
Currency kwd = Currency.getInstance("KWD");
kwd.getDefaultFractionDigits(); // 3
If your system touches precious metals or accounting units like XDR, handle -1 explicitly, otherwise movePointRight(-1) produces nonsense.
There are two schools for representing an amount in code:
(a) long of minor units. The amount is always an integer: 12345 cents is 123.45 dollars. Addition and subtraction are fast and exact, comparison is trivial, JSON serialisation is safe. The downside is that the number is meaningless without a currency travelling alongside it, and percentages and rates still need exact arithmetic.
(b) BigDecimal + Currency. More readable, maps straight onto a numeric column, percentages are natural. The downside is relative slowness and the risk of forgetting scale.
In practice the best answer combines both: a value object called Money that knows both representations and keeps every rule inside itself.
package com.example.payments.money;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
/** An immutable amount that knows its own currency and scale. */
public final class Money implements Comparable<Money> {
private final BigDecimal amount; // always scaled to the currency's minor unit
private final Currency currency;
private Money(BigDecimal amount, Currency currency) {
this.currency = Objects.requireNonNull(currency, "currency");
int scale = currency.getDefaultFractionDigits();
if (scale < 0) throw new IllegalArgumentException("non-decimal currency: " + currency);
// UNNECESSARY means: if the input carried extra digits, do not stay silent — blow up
this.amount = amount.setScale(scale, RoundingMode.UNNECESSARY);
}
public static Money of(String amount, String currencyCode) {
return new Money(new BigDecimal(amount), Currency.getInstance(currencyCode));
}
/** Builds from minor units — ofMinor(12345, "USD") is 123.45 USD */
public static Money ofMinor(long minorUnits, String currencyCode) {
Currency c = Currency.getInstance(currencyCode);
return new Money(BigDecimal.valueOf(minorUnits, c.getDefaultFractionDigits()), c);
}
public static Money zero(String currencyCode) {
return ofMinor(0L, currencyCode);
}
public long toMinorUnits() {
return amount.movePointRight(currency.getDefaultFractionDigits()).longValueExact();
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(this.amount.add(other.amount), currency);
}
public Money minus(Money other) {
requireSameCurrency(other);
return new Money(this.amount.subtract(other.amount), currency);
}
public Money negated() {
return new Money(this.amount.negate(), currency);
}
/** Multiply by a rate (a fee, say) — rounding here is mandatory and explicit. */
public Money multipliedBy(BigDecimal rate, RoundingMode rounding) {
BigDecimal raw = this.amount.multiply(rate);
return new Money(raw.setScale(currency.getDefaultFractionDigits(), rounding), currency);
}
public boolean isNegative() { return amount.signum() < 0; }
public boolean isZero() { return amount.signum() == 0; }
public BigDecimal amount() { return amount; }
public Currency currency() { return currency; }
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"currency mismatch: " + currency + " vs " + other.currency);
}
}
@Override public int compareTo(Money o) { requireSameCurrency(o); return amount.compareTo(o.amount); }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money m)) return false;
return currency.equals(m.currency) && amount.compareTo(m.amount) == 0;
}
@Override public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }
@Override public String toString() { return amount.toPlainString() + " " + currency.getCurrencyCode(); }
}
One of the most common financial bugs is that amount is a bare BigDecimal and somebody adds euros to dollars and nobody notices. With Money as a value object, the compiler and the runtime stop it. If you need currency conversion it must be an explicit operation carrying a rate and a rate date — not a plain addition. This is exactly what the DDD chapter calls "a value object that imprisons domain rules".
double stores numbers in base two with limited precision (IEEE 754). Common decimal fractions like 0.1 are infinitely repeating in base two, so they get rounded. The error per operation is tiny but shows up in accumulated sums and in comparisons: 0.1 + 0.2 != 0.3, and after ten thousand additions of one hundredth you land on 100.00000000000163.
BigDecimal stores a value as "big integer times ten to the negative scale", so every finite decimal is represented exactly. But BigDecimal alone is not enough: you must use the String constructor rather than the double one, always pass a RoundingMode to divide or you get an ArithmeticException on a repeating quotient, and compare with compareTo because equals is also scale-sensitive.
The senior answer goes one step further: the real solution is not a bare BigDecimal but a Money type that encapsulates currency, scale and rounding, so that adding two currencies or rounding implicitly is not even expressible. The other common approach is holding amounts as a long of minor units, which is standard in high-throughput systems such as card switches.
1.4 Splitting an amount and distributing the remainder — the "missing cent" problem
A ten-slice pizza between three people. Three slices each, and one slice left over. You cannot cut the slice into decimal thirds because the slice is the smallest unit. You have to decide who gets that last slice — and your decision must be deterministic, repeatable and documented.
Suppose 100.00 dollars must be split equally between three beneficiaries. Each gets 33.333.... Naively rounding to 33.33 gives a total of 99.99 — one cent has vanished, and that cent breaks your ledger's balance.
This is a hard rule, not a preference. If 100 dollars entered a transaction, the sum of the outputs including fees and tax must be exactly 100 dollars. "Close to 100" means your ledger is unbalanced and the accounting day will not close. The correct algorithm is called allocation and must never be done by rounding each part independently.
The standard algorithm: convert to minor units as an integer, do integer division, and hand out the remainder one unit at a time.
package com.example.payments.money;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
public final class Allocator {
/** Even split into n parts — the output sums exactly to the input. */
public static List<Money> allocateEvenly(Money total, int parts) {
if (parts <= 0) throw new IllegalArgumentException("parts must be > 0");
long minor = total.toMinorUnits();
long base = minor / parts;
long remainder = Math.abs(minor % parts);
long step = minor < 0 ? -1 : 1;
List<Money> out = new ArrayList<>(parts);
for (int i = 0; i < parts; i++) {
long v = base + (i < remainder ? step : 0);
out.add(Money.ofMinor(v, total.currency().getCurrencyCode()));
}
return out;
}
/** Split proportionally to weights — the largest-remainder method. */
public static List<Money> allocateByRatios(Money total, List<BigDecimal> weights) {
BigDecimal sumW = weights.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (sumW.signum() == 0) throw new IllegalArgumentException("weights sum to zero");
long minor = total.toMinorUnits();
long[] floors = new long[weights.size()];
BigDecimal[] fracs = new BigDecimal[weights.size()];
long assigned = 0;
for (int i = 0; i < weights.size(); i++) {
BigDecimal exact = BigDecimal.valueOf(minor)
.multiply(weights.get(i))
.divide(sumW, 10, RoundingMode.HALF_UP);
floors[i] = exact.setScale(0, RoundingMode.FLOOR).longValueExact();
fracs[i] = exact.subtract(BigDecimal.valueOf(floors[i]));
assigned += floors[i];
}
long remainder = minor - assigned; // always between 0 and n-1
// Give the remainder to the largest fractional parts — deterministic and auditable
List<Integer> order = new ArrayList<>();
for (int i = 0; i < weights.size(); i++) order.add(i);
order.sort((a, b) -> fracs[b].compareTo(fracs[a]));
for (int k = 0; k < remainder; k++) floors[order.get(k)] += 1;
List<Money> out = new ArrayList<>(weights.size());
String cc = total.currency().getCurrencyCode();
for (long f : floors) out.add(Money.ofMinor(f, cc));
return out;
}
}
Three common policies exist for where the remainder goes: (1) to the first recipients in list order, (2) to the largest fractional parts (the largest-remainder method), (3) to a designated account usually called rounding_difference, posted to the ledger as rounding income or expense. The third is the cleanest from an audit point of view because stray cents accumulate somewhere traceable and nobody feels cheated. What matters most is that the policy is deterministic: same input, same output every time — because the reconciliation job is going to redo the same computation.
1.5 Storing money in a database
Never FLOAT, REAL, DOUBLE PRECISION or BINARY_DOUBLE. The correct type is exact decimal with explicit precision and scale.
-- Postgres: numeric(p,s) is exact and round-trips a BigDecimal faithfully
CREATE TABLE ledger_entry (
id bigserial PRIMARY KEY,
account_id bigint NOT NULL,
-- 4 decimals leaves room for 3-decimal currencies and for rates
amount numeric(20,4) NOT NULL,
currency char(3) NOT NULL,
direction char(1) NOT NULL CHECK (direction IN ('D','C')),
posted_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT amount_positive CHECK (amount > 0)
);-- Oracle: NUMBER(p,s) is exact. Never use BINARY_DOUBLE for money
CREATE TABLE ledger_entry (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
account_id NUMBER(19) NOT NULL,
amount NUMBER(20,4) NOT NULL,
currency CHAR(3) NOT NULL,
direction CHAR(1) NOT NULL CHECK (direction IN ('D','C')),
posted_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT amount_positive CHECK (amount > 0)
);In Postgres, an unconstrained numeric (no precision) is legal and gives unlimited precision, but it does not fix the scale — 10.5 and 10.50 both persist, and the JDBC driver hands you two BigDecimal objects that are unequal under equals. Always write an explicit numeric(p,s).
In Oracle, a bare NUMBER accepts anything and silently keeps up to 38 significant digits — so an amount with five decimals slips in and nobody notices until the totals stop matching. Write NUMBER(20,4) so the database itself rejects bad input.
And most importantly: never confuse NUMBER with BINARY_FLOAT/BINARY_DOUBLE — those two are precisely the IEEE 754 types you are running away from.
If you chose the long minor-units school, make the column bigint/NUMBER(19) and always keep the currency next to it, otherwise the number is meaningless.
CREATE TABLE payment (
id bigserial PRIMARY KEY,
amount_minor bigint NOT NULL CHECK (amount_minor > 0),
currency char(3) NOT NULL,
-- store the exponent too, so reading historical rows does not depend on today's currency table
currency_exp smallint NOT NULL CHECK (currency_exp BETWEEN 0 AND 4)
);CREATE TABLE payment (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
amount_minor NUMBER(19) NOT NULL CHECK (amount_minor > 0),
currency CHAR(3) NOT NULL,
currency_exp NUMBER(1) NOT NULL CHECK (currency_exp BETWEEN 0 AND 4)
);The type must be exact decimal with explicit precision and scale: numeric(20,4) in Postgres, NUMBER(20,4) in Oracle. I take four decimals rather than two because three-decimal currencies exist (KWD, BHD, OMR) and because intermediate fee and rate calculations may need more precision — but the final postable amount is always rounded to the currency's real scale.
Next to the amount I always keep three things: the currency code (char(3)), because a number without a currency is meaningless; the direction (debit/credit) or the sign; and in multi-currency systems the minor-unit exponent, so reading a ten-year-old row does not depend on today's currency reference table.
What I never use is float, real, double precision or BINARY_DOUBLE. And I add a CHECK that the amount is not negative, because in a double-entry model direction is expressed by the direction column, not by the sign — that way sign mistakes explode at insert time instead of six months later in a report.
2. Double-entry bookkeeping: the heart of every financial system
This is the most important section of the chapter. If you take one thing away, take this.
2.1 The analogy: two columns instead of one number
Picture two buckets and one litre of water poured from bucket A into bucket B. If you only record "bucket B received one litre", you can never tell where the water came from. And if one day the total water in the system exceeds what it should be, you have no idea where the leak is.
Double-entry bookkeeping says: record every movement twice — once as "where it left from" and once as "where it arrived". Now, if the sum of all the "from" entries does not equal the sum of all the "to" entries, you know instantly that something is wrong. It is a structural error-detection mechanism that has worked since the fifteenth century and still has no replacement.
2.2 Vocabulary: account, debit, credit
Account: a bucket. A named container that money accumulates in. Accounts fall into five families:
| Account type | Meaning | Example at a PSP | Natural balance |
|---|---|---|---|
| Asset | Something you own, or someone owes you | The company's bank balance, receivables from the switch | Debit |
| Liability | Something you owe someone else | User wallet balances, funds payable to merchants | Credit |
| Equity | The owners' stake | Initial capital | Credit |
| Revenue | Something you earned | Fee income | Credit |
| Expense | Something you spent | Scheme fees paid | Debit |
When a user tops up a wallet with 100 dollars, that 100 is not your asset — the cash sits in your bank account (which is an asset) but simultaneously creates a liability to that user. If every user withdrew tomorrow you would have to pay. That is why every top-up creates two lines: debit an asset (the bank account) and credit a liability (the user wallet). Understanding this instantly separates you from candidates who "just have a balance column".
Debit (Dr) and credit (Cr): in accounting these words do not mean "decrease" and "increase". They are simply two sides: left and right. Their effect depends on the account type:
- On asset and expense accounts: a debit increases, a credit decreases.
- On liability, equity and revenue accounts: a credit increases, a debit decreases.
The fundamental accounting equation is:
Assets = Liabilities + Equity
Expanded to include revenue and expenses:
Assets + Expenses = Liabilities + Equity + Revenue
The left-hand side increases with debits, the right-hand side with credits. That is all. Whenever you get confused, locate the account in the equation and see which side it is on. This is the only thing you have to memorise — everything else follows.
Journal entry: one complete financial event. It consists of two or more lines (postings), and the iron rule is:
Sum of debits in an entry = sum of credits in that entry
If that does not hold, the entry is invalid and must not be posted.
Ledger (general ledger): the collection of all entries. In modern systems it is an append-only table.
Trial balance: the sum of all debits against the sum of all credits across the whole ledger. If they differ, the system is broken.
The diagram below shows the model. مدل دادهی دفتر کل دوطرفه · The double-entry ledger data model
flowchart TD
T[Business event<br/>payment, refund, fee] --> J[Journal entry<br/>immutable, has entry_id]
J --> L1[Line 1 DEBIT<br/>account A, amount]
J --> L2[Line 2 CREDIT<br/>account B, amount]
J --> L3[Line n more lines...]
L1 --> A[(Account A)]
L2 --> B[(Account B)]
A --> BAL[Balance = SUM debits - SUM credits<br/>derived, never stored raw]
B --> BAL
J -.invariant.-> INV[SUM debits == SUM credits<br/>per entry]
2.3 The relational ledger schema
CREATE TABLE account (
id bigserial PRIMARY KEY,
code text NOT NULL UNIQUE, -- 'wallet:user:1042'
type text NOT NULL CHECK (type IN ('ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE')),
currency char(3) NOT NULL,
opened_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE TABLE journal_entry (
id bigserial PRIMARY KEY,
external_ref text NOT NULL, -- the business event identifier
description text NOT NULL,
posted_at timestamptz NOT NULL DEFAULT now(), -- when it hit the system
value_date date NOT NULL, -- the accounting value date
created_by text NOT NULL,
CONSTRAINT journal_entry_ref_uniq UNIQUE (external_ref)
);
CREATE TABLE journal_line (
id bigserial PRIMARY KEY,
entry_id bigint NOT NULL REFERENCES journal_entry(id),
account_id bigint NOT NULL REFERENCES account(id),
direction char(1) NOT NULL CHECK (direction IN ('D','C')),
amount numeric(20,4) NOT NULL CHECK (amount > 0),
currency char(3) NOT NULL
);
CREATE INDEX journal_line_account_idx ON journal_line (account_id, entry_id);
-- Nobody gets UPDATE or DELETE — INSERT only
REVOKE UPDATE, DELETE ON journal_entry, journal_line FROM PUBLIC;CREATE TABLE account (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
code VARCHAR2(100) NOT NULL UNIQUE,
type VARCHAR2(10) NOT NULL CHECK (type IN ('ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE')),
currency CHAR(3) NOT NULL,
opened_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
closed_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE journal_entry (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
external_ref VARCHAR2(200) NOT NULL,
description VARCHAR2(400) NOT NULL,
posted_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
value_date DATE NOT NULL,
created_by VARCHAR2(100) NOT NULL,
CONSTRAINT journal_entry_ref_uniq UNIQUE (external_ref)
);
CREATE TABLE journal_line (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
entry_id NUMBER(19) NOT NULL REFERENCES journal_entry(id),
account_id NUMBER(19) NOT NULL REFERENCES account(id),
direction CHAR(1) NOT NULL CHECK (direction IN ('D','C')),
amount NUMBER(20,4) NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL
);
CREATE INDEX journal_line_account_idx ON journal_line (account_id, entry_id);
-- In Oracle you typically gate access through roles and packages
REVOKE UPDATE, DELETE ON journal_line FROM app_writer;If you put the sign inside the amount, a sign mistake becomes a row that "looks fine" and goes undiscovered for years. With a separate direction and CHECK (amount > 0), every sign mistake explodes at INSERT time. This is the "fail early and loudly" principle from the clean-code chapter, here backed by the database.
Enforce the per-entry balance at the database level, not only in Java:
CREATE OR REPLACE FUNCTION assert_entry_balanced() RETURNS trigger AS $$
DECLARE d numeric(20,4); c numeric(20,4);
BEGIN
SELECT COALESCE(SUM(amount) FILTER (WHERE direction = 'D'), 0),
COALESCE(SUM(amount) FILTER (WHERE direction = 'C'), 0)
INTO d, c
FROM journal_line WHERE entry_id = NEW.entry_id;
IF d <> c THEN
RAISE EXCEPTION 'entry % unbalanced: debits=% credits=%', NEW.entry_id, d, c;
END IF;
RETURN NULL;
END $$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER journal_line_balanced
AFTER INSERT ON journal_line
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_entry_balanced();-- Oracle has no deferrable trigger; the equivalent pattern is an end-of-entry check in a procedure
CREATE OR REPLACE PROCEDURE assert_entry_balanced(p_entry_id IN NUMBER) IS
v_d NUMBER(20,4); v_c NUMBER(20,4);
BEGIN
SELECT NVL(SUM(CASE WHEN direction = 'D' THEN amount END), 0),
NVL(SUM(CASE WHEN direction = 'C' THEN amount END), 0)
INTO v_d, v_c
FROM journal_line WHERE entry_id = p_entry_id;
IF v_d <> v_c THEN
RAISE_APPLICATION_ERROR(-20001,
'entry ' || p_entry_id || ' unbalanced: D=' || v_d || ' C=' || v_c);
END IF;
END;
/In Postgres, CONSTRAINT TRIGGER ... DEFERRABLE INITIALLY DEFERRED postpones the check to COMMIT, so you can insert lines one at a time and the balance is verified at the end. Oracle has no such thing, and a naive row trigger there is always unbalanced on the first row and fails for no reason. The correct Oracle approach is to allow inserts only through a package procedure that receives the whole entry, inserts it, and calls assert_entry_balanced at the end.
2.4 Why a mutable balance column is a bug waiting to happen
Suppose the account table has a balance numeric(20,4) column that every transaction UPDATEs. Four disasters await:
- Lost update. Two concurrent transactions read the balance as 100, one subtracts 10 and the other 20, both write; the result is 80 or 90 instead of 70. Money was created out of thin air.
- No history. The column only knows "now". If someone asks "what was this account's balance at the end of last month?", you have no answer.
- Not auditable. One wrong
UPDATEdestroys the previous state forever. The auditor asks "where did this number come from?" and you have nothing. - Silent drift. The day a bug posts a ledger line without updating the balance (or vice versa), your two sources of truth diverge and nobody notices for months.
The ledger is the source of truth. The balance is a derived result. A balance is never primary data; it is either summed from the ledger, or read from a trusted snapshot plus the lines that came after it. If both the balance and the ledger are writable, you have two sources of truth and they will diverge.
2.5 Three safe ways to derive a balance
Option one: full ledger sum. The most correct, but slow across millions of rows.
SELECT COALESCE(SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END), 0) AS balance
FROM journal_line
WHERE account_id = :accountId;SELECT NVL(SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END), 0) AS balance
FROM journal_line
WHERE account_id = :accountId;For liability, revenue and equity accounts the natural balance is a credit and the formula flips (C positive, D negative). In practice either write a natural_sign(account_type) helper, or always store the balance as a "net debit" and flip the sign in the presentation layer based on account type — the second is simpler and less error-prone.
Option two: a running balance on every line. At insert time you also write the balance after that line. Reading the balance becomes "the last row". Excellent for statements, because every line displays the balance at its own moment. The cost is that inserts on a given account must be serialised (otherwise two lines carry the same running balance) — that is, one lock per account.
Option three: periodic snapshots. Every night (or every N lines) you record the balance in account_balance_snapshot. The live balance is the last snapshot plus the sum of lines after it.
CREATE TABLE account_balance_snapshot (
account_id bigint NOT NULL,
as_of_entry_id bigint NOT NULL, -- computed up to and including this entry
balance numeric(20,4) NOT NULL,
taken_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, as_of_entry_id)
);
WITH snap AS (
SELECT balance, as_of_entry_id
FROM account_balance_snapshot
WHERE account_id = :accountId
ORDER BY as_of_entry_id DESC
LIMIT 1
)
SELECT COALESCE((SELECT balance FROM snap), 0)
+ COALESCE(SUM(CASE WHEN l.direction = 'D' THEN l.amount ELSE -l.amount END), 0) AS balance
FROM journal_line l
WHERE l.account_id = :accountId
AND l.entry_id > COALESCE((SELECT as_of_entry_id FROM snap), 0);CREATE TABLE account_balance_snapshot (
account_id NUMBER(19) NOT NULL,
as_of_entry_id NUMBER(19) NOT NULL,
balance NUMBER(20,4) NOT NULL,
taken_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
PRIMARY KEY (account_id, as_of_entry_id)
);
WITH snap AS (
SELECT balance, as_of_entry_id
FROM account_balance_snapshot
WHERE account_id = :accountId
ORDER BY as_of_entry_id DESC
FETCH FIRST 1 ROW ONLY
)
SELECT NVL((SELECT balance FROM snap), 0)
+ NVL(SUM(CASE WHEN l.direction = 'D' THEN l.amount ELSE -l.amount END), 0) AS balance
FROM journal_line l
WHERE l.account_id = :accountId
AND l.entry_id > NVL((SELECT as_of_entry_id FROM snap), 0);If your project is event-sourced, this is exactly a projection: the ledger is the event stream and the balance is a rebuildable read model. CQRS and projection detail live in the microservices data chapter — here just note that the ledger model is naturally event-sourced, because a ledger line is an immutable event.
2.6 The trial balance: your daily health check
SELECT currency,
SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END) AS total_debits,
SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END) AS total_credits,
SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) AS difference
FROM journal_line
GROUP BY currency
HAVING SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) <> 0;SELECT currency,
SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END) AS total_debits,
SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END) AS total_credits,
SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) AS difference
FROM journal_line
GROUP BY currency
HAVING SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) <> 0;Any row this query returns is an incident. Wire it up as a permanent alert, not a monthly report.
Double-entry means every financial event is recorded as an entry with at least two lines: one or more debits and one or more credits, such that the sum of debits exactly equals the sum of credits. Debit and credit do not mean "less" and "more"; they are the two sides of Assets + Expenses = Liabilities + Equity + Revenue. Assets and expenses grow with debits; liabilities, revenue and equity grow with credits.
The ledger is append-only: no line is ever updated or deleted. Corrections are made by posting a reversing entry, not by editing. That keeps the whole history reconstructible and auditable.
An editable balance column has four problems: under concurrency it produces lost updates and invents money; it has no history, so you cannot answer questions about past balances; it is unauditable because the UPDATE destroys the prior state; and it creates a second source of truth that will eventually diverge from the ledger. The right approach is a derived balance — a full ledger sum, or a periodic snapshot plus the delta after it, or a running balance with serialised inserts per account. If I cache a balance for performance, I call it a cache explicitly, write it in the same transaction as the ledger, and run a daily job that compares it against the true ledger sum and alerts on any difference.
3. The transaction lifecycle: where is the money, really?
When you book a hotel, the hotel "takes" your card but no money is withdrawn — it is simply set aside from your available credit. That is authorisation. On check-out day the hotel takes the final amount; that is capture. Days later the banks settle up between themselves and money actually moves; that is clearing and settlement. Cancel before check-out and the hold is released; that is a reversal / void. Complain after paying that the service was never delivered, and the dispute and chargeback process starts.
3.1 The vocabulary of the stages
Authorisation. A request to the card issuer: "is this card valid and is this amount available?" The issuer responds and, if approved, places a hold on the cardholder's account (often called an open-to-buy reduction or pending authorisation). No money has moved. Only available credit has been reduced. An authorisation has a limited life — typically a few days — and expires if never captured.
Capture. The merchant declares "I delivered the goods, actually take this amount". It can equal the authorised amount, be less (partial capture), or happen in several tranches (split capture). Even here, money does not move at that instant — a capture is a recorded obligation.
Clearing. Merchant, acquirer and scheme exchange transaction files at the end of the day. The network computes how much each bank owes each other bank and reduces it to a net figure.
Settlement. The actual transfer of funds between banks based on the clearing nets, usually through a real-time gross settlement system or a clearing house. Here, and only here, does money actually move.
Refund. A new, independent transaction in the opposite direction, after the original transaction has settled. It has its own date, its own identifier and its own settlement cycle.
Reversal / void. Cancellation of a transaction that has not yet settled — an authorisation that was never captured, or a same-day void. Unlike a refund, it usually leaves no trace on the customer's statement (or simply releases the hold). In ISO 8583 it is carried by class-4 messages.
Chargeback. The cardholder complains to their own bank and the issuer forcibly takes the money back from the acquirer. It is a quasi-judicial process with strict deadlines and standard reason codes.
Dispute. The umbrella term for a customer challenge, which may end in a chargeback or be defeated by merchant evidence (representment).
One of the biggest misconceptions among engineers new to this domain is "I got response code 00, so the money arrived". No. A successful authorisation response only means the issuer made a promise. If you never capture, the authorisation expires and no money moves. If you capture but the clearing file is lost, nothing settles. Your ledger must model these states separately: money in "open authorisations", money in "unsettled receivables" and money in "the bank account" are three different things.
چرخهی عمر یک پرداخت کارتی · The lifecycle of a card payment
stateDiagram-v2
[*] --> Authorised
Authorised --> Voided
Authorised --> Expired
Authorised --> Captured
Captured --> Cleared
Cleared --> Settled
Settled --> Refunded
Settled --> Disputed
Disputed --> ChargedBack
Disputed --> Represented
Represented --> Settled
Voided --> [*]
Expired --> [*]
Refunded --> [*]
ChargedBack --> [*]
Experienced engineers create a suspense or clearing account per stage. On capture: debit "receivable from the switch" and credit "payable to the merchant". On settlement: debit "bank account" and credit "receivable from the switch". The "receivable from the switch" account must go to zero after settlement; any residue means a transaction is stuck. These accounts are automatic problem detectors, not just accounting ceremony.
A reversal cancels a transaction that has not yet reached settlement. In the card world it is usually an ISO 8583 class-4 message, and its effect is to release the hold on the cardholder's account or remove the record before it enters the clearing file. From the customer's point of view it typically leaves no statement trace.
A refund is a brand-new transaction in the opposite direction, executed after the original has settled. It has its own identifier, its own date and its own settlement cycle, and appears on the customer's statement as an independent item. It normally takes a few days.
Why it matters: if you issue a refund where you should have issued a reversal, you record two money movements, one of which never settled — so the merchant gives back money it never received, and your ledger goes negative. The opposite is just as bad: attempting a reversal on an already-settled transaction is rejected by the switch and leaves your transaction stuck. In the design, transaction state must be explicit and the permitted operations derived from the state — a real state machine, not an if.
4. Idempotency: the most important non-functional requirement in this domain
You press the lift button ten times; the lift does not arrive ten times. The first press registers the request and the rest return the same outcome. A payment API must behave exactly like that: if the client resends the same request three times because of network timeouts, one payment must happen and all three calls must return the same answer.
4.1 Why exactly-once is an illusion
Between two machines over an unreliable network, "exactly once" is impossible. What you actually have is:
- at-most-once: you never resend; the message may be lost.
- at-least-once: you resend until acknowledged; duplicates may arrive.
The real solution: at-least-once delivery plus idempotency at the receiver equals the effect of exactly-once. Note that this is an effect, not a delivery guarantee. If someone in an interview says "we have exactly-once", the follow-up question must be: "where do you de-duplicate?"
4.2 The idempotency key and the request fingerprint
The industry-standard contract is that the client sends an Idempotency-Key header containing a UUID it generated itself, and repeats the same key on every retry of that same operation.
The server does three things:
- Records the key with a uniqueness constraint in the database (that constraint is the lock that decides who wins the race).
- Stores a fingerprint of the request body — a hash of the meaningful fields.
- Stores the final response so that replays return it without re-executing.
A real scenario: a client bug sends the same key first for an amount of 10 and then for 100. If you only check the key, you return the 10-payment response for the 100 request, and the client believes 100 was paid. You must compare the body hash too and reject a mismatch with 422 Unprocessable Entity or 409 Conflict. That is the difference between a correct API and a dangerous one.
CREATE TABLE idempotency_record (
idempotency_key text PRIMARY KEY,
request_hash char(64) NOT NULL, -- SHA-256 hex
endpoint text NOT NULL,
status text NOT NULL CHECK (status IN ('IN_PROGRESS','COMPLETED')),
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE INDEX idempotency_created_idx ON idempotency_record (created_at);CREATE TABLE idempotency_record (
idempotency_key VARCHAR2(200) PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
endpoint VARCHAR2(200) NOT NULL,
status VARCHAR2(20) NOT NULL CHECK (status IN ('IN_PROGRESS','COMPLETED')),
response_code NUMBER(3),
response_body CLOB CHECK (response_body IS JSON),
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idempotency_created_idx ON idempotency_record (created_at);The Spring implementation:
package com.example.payments.api;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/v1/payments")
public class PaymentController {
private final IdempotencyStore store;
private final PaymentService payments;
public PaymentController(IdempotencyStore store, PaymentService payments) {
this.store = store;
this.payments = payments;
}
@PostMapping
public ResponseEntity<PaymentResponse> create(
@RequestHeader("Idempotency-Key") String key,
@RequestBody @jakarta.validation.Valid PaymentRequest req) {
String fingerprint = Fingerprint.sha256Hex(req.canonicalForm());
// 1) Try to claim the key — the PRIMARY KEY constraint decides the race winner
IdempotencyStore.Claim claim = store.claim(key, "/v1/payments", fingerprint);
switch (claim.outcome()) {
case FRESH -> { /* we own the execution */ }
case REPLAY_COMPLETED -> {
return ResponseEntity.status(claim.responseCode())
.header("Idempotent-Replay", "true")
.body(claim.responseBody(PaymentResponse.class));
}
case IN_FLIGHT -> throw new ResponseStatusException(
HttpStatus.CONFLICT, "a request with this key is still in progress");
case FINGERPRINT_MISMATCH -> throw new ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY,
"Idempotency-Key reused with a different request body");
}
PaymentResponse body = payments.execute(req, key);
store.complete(key, HttpStatus.CREATED.value(), body);
return ResponseEntity.status(HttpStatus.CREATED)
.header("Idempotent-Replay", "false")
.body(body);
}
}
package com.example.payments.api;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public final class Fingerprint {
private Fingerprint() {}
public static String sha256Hex(String canonical) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
}
If you hash the raw JSON bytes, a reordered field or one extra space makes the same request produce a different hash and get a spurious 422. Write a canonicalForm() that concatenates only the meaningful fields (amount, currency, destination, reference) in a fixed order and fixed format. Leave things like client timestamps or trace ids out — otherwise every retry produces a new hash and the whole mechanism becomes useless.
4.3 The timeout where you do not know whether it worked
This is the hardest scenario in the domain: you sent a request to the switch and timed out. Three possibilities exist:
- The request never arrived — no money moved.
- It arrived and was processed but the response was lost — money moved.
- It arrived and is still processing — outcome unknown.
You have two standard tools. First, a status inquiry / echo transaction: using the same unique transaction identifier, ask the switch "do you have this transaction?". Second, a reversal: a message saying "if you have this transaction, void it; if not, do nothing". A reversal is idempotent by design and you retry it until you get a definitive answer. Until then the transaction stays in an UNKNOWN / PENDING_RESOLUTION state and you never tell the user a definitive "succeeded" or "failed". Telling a user "failed" when their money has actually left is the worst possible outcome for trust.
Most naive models have two states: SUCCESS and FAILED. A real financial system has at least five: PENDING, SUCCESS, FAILED, UNKNOWN and REVERSED. The UNKNOWN state is resolved by an inquiry job or by the reconciliation file. Without that state in the model, engineers are forced to guess — and guessing in the money domain means losses.
For correlating the request, the outgoing message and downstream events, use the Transactional Outbox pattern (microservices data chapter): write the ledger line and the outgoing event in one database transaction and let a relay publish it. For multi-service coordination, use a Saga with compensating steps — covered in detail in the same chapter. The domain-specific note I will add here is this: a compensating step in the money world is almost never a delete, it is always a reversing entry.
The client sends an Idempotency-Key header with a UUID it generates and repeats it on every retry of the same operation. Server-side I have a table whose primary key is that key. The first thing the service does is INSERT the row with status IN_PROGRESS; the race winner is decided by the database uniqueness constraint, not by an if (exists) check which has its own race.
Next to the key I store a SHA-256 hash of the canonical form of the body. If the same key arrives with a different hash I return 422, because that means the client reused the key for a different request. If the key exists and is COMPLETED, I return the stored response verbatim with an Idempotent-Replay: true header. If it is IN_PROGRESS, I return 409 so the client retries with backoff.
The subtle transactional point: claiming the key and executing the payment must be coordinated so that a failed payment does not leave a stuck idempotency row. The safe pattern is to claim the key in its own transaction (REQUIRES_NEW), do the business effect in its own, and complete the record at the end. Rows stuck in IN_PROGRESS past a threshold are resolved by a job that queries the real downstream status.
And retention: I prune the table by created_at with a window longer than the maximum client retry window, typically 24 to 72 hours.
The short answer: the source of truth must be the transactional database; Redis can be an acceleration layer. The reason is that the key claim and the business effect (the ledger line) must share the same durability fate. If the key lives in Redis and Redis fails over with recent writes lost, the key disappears and the next retry creates a second payment — exactly what you were trying to prevent.
Redis is excellent for taking a short-lived lock and cheaply shedding a flood of retries, but as an optimisation in front of the database, not instead of it. If the stored response is large you can cache the response body in Redis while still keeping the key's existence and status in the database.
5. Reconciliation: where the truth comes out
Every month you compare your personal spending notebook against the bank statement. Several cases appear: an item in both with matching amounts (a clean match); an item in your notebook that is not on the statement (maybe still in flight, maybe never happened); an item on the statement that is not in your notebook (a fee you forgot); and an item in both where the amounts differ. Reconciliation in a payment system is exactly this, just with millions of rows and automated.
Every day you receive a file from the switch, the card scheme or the bank — usually a fixed-width file, a CSV, or nowadays a camt.053 message (a bank-to-customer statement in ISO 20022). You compare it against your own ledger.
5.1 Break categories
| Category | Meaning | Typical action |
|---|---|---|
| Matched | Present on both sides, amount and date within tolerance | Nothing — flag and archive |
| Missing at counterparty | In your ledger, not in the file | Wait if inside the delay window, otherwise status inquiry |
| Missing internally | In the file, not in your ledger | An "orphan" — probably a lost response. Post a compensating entry |
| Amount mismatch | On both sides with different amounts | Usually partial capture or a fee. Manual review |
| Duplicate | One transaction appears twice on one side | A sign that idempotency failed — this is an incident |
| Status mismatch | One side successful, the other reversed | Needs investigation — highest priority |
A date tolerance acknowledges that a transaction done at 23:59 may land in the next day's file, so matching must be a range (say ±1 business day) rather than an exact date. An amount tolerance is a completely different thing and in domestic payments it must be zero: one unit of difference is a bug. Amount tolerance only makes sense in cross-currency scenarios with conversion rates, and even then it must have an absolute cap, not an open-ended percentage. Teams that set a 1% amount tolerance "to reduce noise" are sweeping fraud and bugs under the rug.
5.2 Real reconciliation SQL
Load the file into a staging table and use a FULL OUTER JOIN to see both sides:
CREATE TABLE settlement_file_row (
file_id bigint NOT NULL,
rrn varchar(12) NOT NULL, -- Retrieval Reference Number, DE37
stan varchar(6) NOT NULL, -- System Trace Audit Number, DE11
amount numeric(20,4) NOT NULL,
currency char(3) NOT NULL,
txn_date date NOT NULL,
response_code char(2) NOT NULL,
PRIMARY KEY (file_id, rrn)
);
WITH ours AS (
SELECT p.rrn, p.amount, p.currency, p.value_date, p.status
FROM payment p
WHERE p.value_date BETWEEN :from - INTERVAL '1 day' AND :to + INTERVAL '1 day'
),
theirs AS (
SELECT s.rrn, s.amount, s.currency, s.txn_date, s.response_code
FROM settlement_file_row s
WHERE s.file_id = :fileId
)
SELECT COALESCE(o.rrn, t.rrn) AS rrn,
o.amount AS our_amount,
t.amount AS their_amount,
CASE
WHEN t.rrn IS NULL THEN 'MISSING_AT_COUNTERPARTY'
WHEN o.rrn IS NULL THEN 'MISSING_INTERNALLY'
WHEN o.amount <> t.amount THEN 'AMOUNT_MISMATCH'
WHEN o.currency <> t.currency THEN 'CURRENCY_MISMATCH'
WHEN (o.status = 'SUCCESS') <> (t.response_code = '00') THEN 'STATUS_MISMATCH'
WHEN ABS(o.value_date - t.txn_date) > 1 THEN 'DATE_OUT_OF_TOLERANCE'
ELSE 'MATCHED'
END AS break_type
FROM ours o
FULL OUTER JOIN theirs t ON o.rrn = t.rrn
ORDER BY break_type, rrn;CREATE TABLE settlement_file_row (
file_id NUMBER(19) NOT NULL,
rrn VARCHAR2(12) NOT NULL,
stan VARCHAR2(6) NOT NULL,
amount NUMBER(20,4) NOT NULL,
currency CHAR(3) NOT NULL,
txn_date DATE NOT NULL,
response_code CHAR(2) NOT NULL,
PRIMARY KEY (file_id, rrn)
);
WITH ours AS (
SELECT p.rrn, p.amount, p.currency, p.value_date, p.status
FROM payment p
WHERE p.value_date BETWEEN :from - 1 AND :to + 1
),
theirs AS (
SELECT s.rrn, s.amount, s.currency, s.txn_date, s.response_code
FROM settlement_file_row s
WHERE s.file_id = :fileId
)
SELECT COALESCE(o.rrn, t.rrn) AS rrn,
o.amount AS our_amount,
t.amount AS their_amount,
CASE
WHEN t.rrn IS NULL THEN 'MISSING_AT_COUNTERPARTY'
WHEN o.rrn IS NULL THEN 'MISSING_INTERNALLY'
WHEN o.amount <> t.amount THEN 'AMOUNT_MISMATCH'
WHEN o.currency <> t.currency THEN 'CURRENCY_MISMATCH'
WHEN CASE WHEN o.status = 'SUCCESS' THEN 1 ELSE 0 END
<> CASE WHEN t.response_code = '00' THEN 1 ELSE 0 END THEN 'STATUS_MISMATCH'
WHEN ABS(o.value_date - t.txn_date) > 1 THEN 'DATE_OUT_OF_TOLERANCE'
ELSE 'MATCHED'
END AS break_type
FROM ours o
FULL OUTER JOIN theirs t ON o.rrn = t.rrn
ORDER BY break_type, rrn;In Oracle, subtracting two DATE values yields a number of days directly, so ABS(a - b) > 1 works. In Postgres, subtracting two date values yields an integer number of days and the same expression works — but if the columns are timestamptz the result is an interval and you must write ABS(EXTRACT(EPOCH FROM a - b)) > 86400. This kind of silent difference is discovered in a nightly job. Catch it with a test.
5.3 Choose the matching key carefully
RRN (DE37 in ISO 8583) is the most common matching key but it is not absolutely unique — it is typically unique within a time window and for one acquirer. STAN (DE11) is only six digits and wraps after a million transactions. So the real matching key is usually a tuple: (acquirer_id, terminal_id, stan, txn_date) or (rrn, txn_date, amount).
Mature reconciliation runs three passes. Pass one: exact match on the strong key — typically 97 to 99 percent of rows resolve here. Pass two: fuzzy match on the remainder using a weaker key plus a time window (same amount, same terminal, within ±2 minutes). Pass three: many-to-one matching for cases like partial captures where one authorisation maps to several captures. Anything matched in pass two or three must be flagged and reviewable, never silently counted as Matched.
5.4 The reconciliation job in Spring
package com.example.payments.recon;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
@Component
public class DailyReconciliationJob {
private final SettlementFileClient files;
private final ReconRepository repo;
private final BreakHandler handler;
private final Clock clock;
public DailyReconciliationJob(SettlementFileClient files, ReconRepository repo,
BreakHandler handler, Clock clock) {
this.files = files; this.repo = repo; this.handler = handler; this.clock = clock;
}
// 03:00 in the settlement zone — not the server's local time
@Scheduled(cron = "0 0 3 * * *", zone = "UTC")
public void run() {
LocalDate businessDate = LocalDate.now(clock).minusDays(1);
// Record the run under a unique key: the job itself must be idempotent
long runId = repo.startRun(businessDate);
try {
long fileId = files.downloadAndStage(businessDate);
List<ReconBreak> breaks = repo.computeBreaks(fileId, businessDate);
for (ReconBreak b : breaks) handler.handle(runId, b);
repo.completeRun(runId, breaks.size());
} catch (RuntimeException e) {
repo.failRun(runId, e.getMessage());
throw e; // let alerting see it
}
}
}
If the job dies half-way and runs again, it must not create duplicate compensating entries. The right approach: every ReconBreak has a deterministic derived identifier (a hash of (businessDate, rrn, breakType), say) and every compensating entry is posted with external_ref equal to that identifier. The uniqueness constraint on journal_entry.external_ref rejects the second post. This is idempotency again, just at the batch layer.
I load the settlement file into a staging table keyed on (file_id, rrn) and use a FULL OUTER JOIN on the matching key to classify every row into one of six categories: matched, missing at counterparty, missing internally, amount mismatch, status mismatch and duplicate. I never use RRN alone as the matching key because it is not globally unique; I usually build a tuple of acquirer, terminal, STAN and transaction date, and I apply a ±1 business day window because a transaction made near midnight appears in the next day's file.
An orphan — something present in the switch file but absent from my ledger — almost always means the request reached the switch and was processed but the response never came back and I recorded a failure. The right action is to post a compensating journal entry with a deterministic external_ref so the ledger matches the reality of the money, move the transaction to the correct state, and if I already told the customer "failed", trigger the notification and if necessary the refund process. What I never do is "correct" the row in the ledger — the ledger is append-only and corrections happen only through new entries.
The health metric for this process is a single number: the auto-match rate. If it falls below 99 percent, or if the count of unresolved breaks older than N days is growing, that is an operational alert, not a report.
6. Concurrency: guarding one number that everybody wants to change
The synchronisation and JMM chapter taught you in-process locking, and the ACID chapter taught you isolation levels. Here I only cover the domain decisions.
6.1 Lost updates on a balance
// ❌ The classic bug — there is a window between the read and the write
BigDecimal balance = accountRepo.findBalance(accountId);
if (balance.compareTo(amount) >= 0) {
accountRepo.updateBalance(accountId, balance.subtract(amount));
}
Two concurrent threads with a balance of 100 and a withdrawal of 80: both pass the check, and the balance goes negative or one withdrawal is lost. Three correct fixes:
1) A conditional atomic update (the best option whenever possible).
UPDATE account_balance
SET balance = balance - :amount,
version = version + 1
WHERE account_id = :accountId
AND balance >= :amount;
-- an affected row count of 0 means insufficient fundsUPDATE account_balance
SET balance = balance - :amount,
version = version + 1
WHERE account_id = :accountId
AND balance >= :amount;
-- SQL%ROWCOUNT of 0 means insufficient funds2) Pessimistic locking.
SELECT balance FROM account_balance
WHERE account_id = :accountId
FOR UPDATE; -- exclusive lock until the transaction endsSELECT balance FROM account_balance
WHERE account_id = :accountId
FOR UPDATE; -- same behaviour; NOWAIT or WAIT n controls the waitIn JPA that becomes:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Optional<AccountBalance> findAndLockByAccountId(Long accountId);
3) Optimistic locking with @Version.
@Entity
public class AccountBalance {
@Id private Long accountId;
private BigDecimal balance;
@Version private long version; // JPA adds it to the WHERE clause itself
}
If the version changed you get an ObjectOptimisticLockingFailureException and must replay the whole operation.
| Criterion | Pessimistic locking | Optimistic locking |
|---|---|---|
| High contention on one account | Good — waiting instead of failing | Bad — retry storms and poor throughput |
| Low contention | Needless cost and lock upkeep | Excellent — free on the happy path |
| Long transactions | Dangerous — holds the lock too long | Safer — holds no lock |
| Deadlock risk | High if lock ordering is inconsistent | Zero (though livelock is possible) |
| Failure behaviour | Wait or time out | Exception, needs a retry layer above |
| A "hot" account (central settlement) | Usually the only workable option | Effectively unusable |
The fee account or the central settlement account is touched by every single transaction. Lock it and your whole system's throughput collapses to the rate of one transaction on that row. The standard fix is account sharding: split the hot account into N sub-accounts (fee:shard:0 through fee:shard:15), have each transaction pick one at random or by hash, and define the logical balance as the sum of all shards. Leave consolidation into a single account to a nightly job. It is the same pattern used for high-traffic counters.
In a transfer from A to B, if one thread locks A then B while another (transferring B to A) locks B then A, both wait forever until the database kills one as a victim. The rule: always lock accounts in one deterministic total order (ascending account_id, say), regardless of the direction of the transfer. It is one line of code and it eliminates an entire class of 3 a.m. incidents.
@Transactional
public void transfer(long fromId, long toId, Money amount) {
long first = Math.min(fromId, toId);
long second = Math.max(fromId, toId);
AccountBalance a = repo.findAndLockByAccountId(first).orElseThrow();
AccountBalance b = repo.findAndLockByAccountId(second).orElseThrow();
// ... now, with a fixed lock order, post the double-entry
}
It depends on the contention pattern, not on taste. For an ordinary customer account with low contention, optimistic locking with @Version is best because it costs nothing on the happy path. For a hot account such as fees or settlement, optimistic locking degenerates into a retry storm, so I either use pessimistic locking with SELECT ... FOR UPDATE or, better, shard the account.
But my first answer is neither: if I can express the operation as an atomic UPDATE ... WHERE balance >= :amount, that is best of all, because it holds the lock for the shortest possible window and checks the business condition inside the same statement.
On isolation: READ COMMITTED is sufficient for those atomic updates and explicit locks, and it is the default in both Postgres and Oracle. I reach for SERIALIZABLE when the invariant spans a set of rows rather than a single row — for example "the sum of today's withdrawals must not exceed a limit", which suffers write skew under read committed because both transactions read a state the other is changing.
An important implementation note: Postgres implements SERIALIZABLE with SSI and may abort a transaction with 40001 serialization_failure; in Oracle you get ORA-08177. In both cases you must have a retry layer with backoff, otherwise a higher isolation level simply raises your production error rate. And finally, I close the ultimate invariant in the database too — with CHECK constraints, uniqueness and the balance trigger — because an isolation level does not protect you from a programmer's mistake.
A job that processes ten million rows in a single transaction causes severe bloat and stalls VACUUM in Postgres, and hits ORA-01555 snapshot too old in Oracle. The right pattern: break processing into chunks (1000 rows, say), give each chunk its own transaction, record progress in a checkpoint table, and write the job so a rerun continues from the last checkpoint. Spring Batch was built exactly for this and hands you chunk and ExecutionContext out of the box.
7. Cards and switches: the ISO 8583 that still runs the rails
Imagine you must send a telegram and every character costs money. So instead of writing "sender name: ...", you agree in advance on a numbered form: box 2 is always the card number, box 4 is always the amount. Then you put a map at the front of the message saying which boxes are filled. That is precisely ISO 8583 — a compact binary format designed for slow lines in the 1980s that still underpins most of the world's card transactions.
7.1 Message structure
Every ISO 8583 message has three parts: the MTI, the bitmap(s), and the data elements.
The MTI (Message Type Indicator) is four digits, each with meaning:
| Position | Meaning | Key values |
|---|---|---|
| 1 | Standard version | 0 = 1987, 1 = 1993, 2 = 2003 |
| 2 | Message class | 1 authorisation, 2 financial, 3 file actions, 4 reversal/chargeback, 5 reconciliation, 6 administrative, 7 fee collection, 8 network management |
| 3 | Message function | 0 request, 1 request response, 2 advice, 3 advice response, 4 notification |
| 4 | Message origin | 0 acquirer, 1 acquirer repeat, 2 issuer, 3 issuer repeat, 4 other |
So 0100 is "authorisation request from the acquirer" and 0110 is its response. 0200 is a financial request (typically a POS purchase) and 0210 its response. 0400 is a reversal request and 0410 its response. 0800 is a network management message (echo test or key exchange) with 0810 as its response.
A bitmap is a 64-bit map (8 bytes, or 16 hex characters) that says which data elements are present. Bit n set means DE n is present. Bit 1 has a special meaning: if it is set, a secondary bitmap immediately follows covering fields 65 through 128.
7.2 ISO 8583 field cheat sheet
Formats: n numeric, a alphabetic, an alphanumeric, ans alphanumeric-special, b binary, z magnetic-stripe track data. LLVAR means variable length with a two-digit length prefix, LLLVAR with three digits.
| DE | Name | Format | Why it matters |
|---|---|---|---|
| 1 | Secondary bitmap | b 64 | Present only if bit 1 is set |
| 2 | Primary Account Number (PAN) | n..19 (LLVAR) | The card number — PCI sensitive data |
| 3 | Processing Code | n 6 | Transaction type + from-account type + to-account type |
| 4 | Amount, Transaction | n 12 | Amount in minor units, zero-padded, no decimal point |
| 7 | Transmission Date & Time | n 10 | MMDDhhmmss in UTC |
| 11 | System Trace Audit Number (STAN) | n 6 | Correlates request and response — only 6 digits, it wraps |
| 12 | Local Transaction Time | n 6 | hhmmss in terminal local time |
| 13 | Local Transaction Date | n 4 | MMDD in local time |
| 14 | Expiration Date | n 4 | YYMM card expiry |
| 18 | Merchant Category Code (MCC) | n 4 | Merchant business type — the basis of risk rules |
| 22 | POS Entry Mode | n 3 | Chip, magstripe, contactless, keyed — drives fraud liability |
| 25 | POS Condition Code | n 2 | Card/cardholder presence |
| 32 | Acquiring Institution ID | n..11 (LLVAR) | The acquiring bank identifier |
| 35 | Track 2 Data | z..37 (LLVAR) | Stripe data, contains the PAN — the most sensitive field |
| 37 | Retrieval Reference Number (RRN) | an 12 | The primary reconciliation key |
| 38 | Authorization ID Response | an 6 | The issuer's approval code |
| 39 | Response Code | an 2 | 00 = approved. Anything else is a decline |
| 41 | Card Acceptor Terminal ID | ans 8 | Terminal identifier |
| 42 | Card Acceptor ID Code | ans 15 | Merchant identifier |
| 43 | Card Acceptor Name/Location | ans 40 | What the customer sees on their statement |
| 48 | Additional Data, Private | ans...999 (LLLVAR) | Every network's private dumping ground |
| 49 | Currency Code, Transaction | n 3 | The numeric ISO 4217 code — 840 for USD |
| 52 | PIN Data | b 64 | The encrypted PIN block — never plaintext |
| 53 | Security Related Control Information | n 16 | Key/algorithm indicator |
| 54 | Additional Amounts | an...120 (LLLVAR) | Account balance, cashback amount |
| 55 | ICC Data (EMV) | ans...999 (LLLVAR) | Chip TLVs — cryptogram and terminal verification results |
| 90 | Original Data Elements | n 42 | In a reversal — the original MTI, STAN and date |
| 95 | Replacement Amounts | an 42 | In a partial reversal — the new amount |
| 128 | Message Authentication Code (MAC) | b 64 | Message integrity signature |
000000012345 with currency 840 (USD) means 123.45 dollars. The same digits with currency 392 (JPY) mean 12345 yen, and with 414 (Kuwaiti dinar) mean 12.345 dinars. The DE49 currency code is numeric, not alphabetic. Assume "always divide by 100" and your first unusual-currency transaction is off by a factor of a thousand. Always derive the conversion from Currency.getDefaultFractionDigits().
DE11 rolls over after a million transactions. In a high-traffic system that can happen within hours. Never make STAN alone a unique database key. The practical key is usually (acquirer_id, terminal_id, stan, transmission_date). This simple mistake has genuinely caused transactions to "disappear" in real systems.
The message has three parts. The MTI is four digits: the first is the standard version, the second is the message class (1 authorisation, 2 financial, 4 reversal, 8 network management), the third is the function (0 request, 1 response, 2 advice) and the fourth is the origin (0 acquirer, 2 issuer). So 0200 is a financial request and 0210 its response, and 0400 is a reversal request.
Then comes the bitmap: 64 bits saying which fields are present. If bit 1 is set, a secondary bitmap for fields 65 to 128 immediately follows. The benefit of this design is that absent fields consume no bytes at all — which was vital on the slow lines of the 1980s.
The fields I touch daily: DE2 the card number, DE3 the processing code, DE4 the amount in minor units without a decimal point, DE11 the STAN that correlates request and response, DE37 the RRN which is the reconciliation key, DE39 the response code where 00 means approved, DE41 and DE42 the terminal and merchant identifiers, DE49 the numeric currency code, DE52 the encrypted PIN block, and DE55 carrying the EMV TLVs. In reversal messages DE90 carries the original message's data so the switch knows what to void.
The bit seniors add: the six-digit STAN is not unique and wraps, so the real unique key is a tuple of acquirer, terminal, STAN and date. And ISO 8583 is not really "one standard" — every network has its own dialect, especially in the private fields 48, 62 and 63. You always need the counterparty's exact specification.
7.3 PIN, HSMs and key exchange at an interface level
A PIN never travels the network or lives in application memory in the clear. The terminal places the PIN inside a PIN block (an ISO 9564 standard format) and encrypts it under a key. At every node along the path a translate operation happens: the PIN block is decrypted under the inbound key and re-encrypted under the outbound key — and that operation happens only inside an HSM, a hardware module that keys never leave.
From a Java engineer's point of view the practical interface is usually one of: a PKCS#11 library to reach the cryptographic module, or a text command protocol over TCP through which you issue instructions to the HSM. Your code never sees a key or a PIN; it sends an encrypted input and receives an encrypted output.
The key hierarchy has three levels whose names you should know: the LMK (Local Master Key) lives inside the HSM and protects everything; the ZMK (Zone Master Key) is exchanged between two organisations manually under a key ceremony with multiple components and multiple custodians; and the ZPK (Zone PIN Key) is the day-to-day working key, exchanged encrypted under the ZMK.
Algorithms, modes of operation, key derivation, DUKPT and key lifecycle are covered in depth in the key-management chapter. What you need here is only the responsibility model: the key is never in your application, sensitive operations happen inside the HSM, and key exchange is an organisational ceremony involving several people, not an API call.
7.4 PAN masking, tokenization and PCI-DSS scope reduction
The PAN is the 13-to-19-digit card number. PCI-DSS requirement 3.4.1 states that the PAN must be masked when displayed, with at most the first six and last four digits visible, except for people with a documented business need.
public static String maskPan(String pan) {
if (pan == null || pan.length() < 10) throw new IllegalArgumentException("invalid PAN");
String head = pan.substring(0, 6);
String tail = pan.substring(pan.length() - 4);
return head + "*".repeat(pan.length() - 10) + tail;
}
Teams often write a log filter that masks PANs and feel safe. But the PAN escapes other ways too: exception messages, request payloads captured by an APM, heap dumps, URL parameters in an access log, and the stored request body in the idempotency table. The right approach is to replace the PAN with a token at the very edge and have it exist nowhere else. And the CVV/CVC must never be stored — not encrypted, not temporarily, not for debugging. That is one of the most explicit PCI-DSS prohibitions.
Tokenization means keeping the PAN in a small isolated system (a token vault) and giving the rest of the system nothing but a meaningless identifier. The key difference from encryption is that a token bears no mathematical relationship to the PAN; it is just a mapping in a database. Its architectural effect is large: any service that only ever sees tokens falls out of PCI-DSS scope and does not have to be audited. That scope reduction is the main reason tokenization exists — not just security, but audit cost and delivery speed.
The PAN is cardholder data with strict PCI-DSS rules. Three practical rules I follow: on display, at most the first six and last four digits (requirement 3.4.1); at rest, rendered unreadable by one of the approved methods; and sensitive authentication data — CVV, full track content, PIN blocks — never stored after authorisation.
But the architectural answer is not to let the PAN into the system at all. At the edge — usually a small, tightly restricted service — the PAN is replaced by a meaningless token, and from that point every service, database, log and queue only sees the token. Unlike ciphertext, a token has no mathematical relationship to the PAN, so leaking it alone is worthless.
What this really buys you is audit scope reduction. Any system that stores, processes or transmits card data falls into PCI-DSS scope and must implement the full control set. With tokenization, instead of auditing thirty services you audit one small vault. That cuts cost by an order of magnitude and unblocks the delivery speed of everyone else.
8. Core banking and integration reality
8.1 End-of-day processing and cut-off
Traditional core banking systems are built around a daily cycle. At a cut-off time — 20:00, say — the business day is closed. Transactions after that belong to the next business day, even though they physically happened today.
After cut-off, end-of-day (EOD) processing runs: interest accrual, fee application, statement generation, closing the day's books and opening the next. During that window — which can take hours — the core is typically either unavailable, read-only, or accepting only certain transaction types.
If your service sends requests during EOD you get strange responses: timeouts, "system unavailable" error codes, or worse — a transaction that is accepted but lands on the next day while you believe it posted today. The correct design is to hold the EOD window in configuration, queue requests during that window rather than failing them, and communicate the real processing time to the user.
8.2 Value date versus posting date
This distinction is asked constantly in banking interviews and engineers usually do not know it.
- Posting date (booking date): the date the transaction was recorded in the system. A technical fact.
- Value date: the date on which the transaction is financially effective — the basis for interest calculation, daily limits and accounting reports.
The two can differ: a cheque booked on Friday may have a Monday value date. A transaction after cut-off is booked today but has tomorrow's value date. A correction booked today may carry last month's value date (backdating).
With only one date column your reports will eventually be wrong. Make posted_at a timestamptz (the real, immutable instant, the audit basis) and value_date a date (the business date, possibly in the past). Financial reports group by value_date; operational reports group by posted_at. And always make the business day's time zone explicit — a "day" in banking is defined by the settlement system's zone, not by your server's clock.
8.3 Real interfaces: SOAP, fixed-width files, and patience
Do not expect a core banking system to offer a clean REST API with JSON. What you actually meet:
- SOAP over HTTPS with a large WSDL and a strict schema (detail in the SOAP and SOA chapter).
- ISO 8583 over TCP with length-prefixed framing, usually over a persistent connection kept alive with periodic echo messages.
- Fixed-width files over SFTP: each record is one line at fixed offsets, no delimiters, padded with spaces or zeros.
- ISO 20022 XML in more modern rails:
pain.001for a customer credit transfer initiation,pacs.008for the interbank transfer,camt.053for the statement.
Parsing a fixed-width record in Java is simple but must be precise:
public record SettlementRecord(String rrn, long amountMinor, String currency, String date) {
// Offsets follow the counterparty's specification — always work from their document
public static SettlementRecord parse(String line) {
if (line.length() < 35) throw new IllegalArgumentException("short record: " + line.length());
String rrn = line.substring(0, 12).trim();
long amount = Long.parseLong(line.substring(12, 24)); // no decimal point, zero-padded
String currency = line.substring(24, 27);
String date = line.substring(27, 35); // YYYYMMDD
return new SettlementRecord(rrn, amount, currency, date);
}
}
First, character encoding. Many legacy cores emit output in a non-UTF-8 code page. new String(bytes) uses the JVM default charset, which differs between servers and corrupts characters in production. Always pass the charset explicitly.
Second, length in bytes versus characters. In a fixed-width file "offsets 12 to 24" means bytes, not characters. If the file contains multi-byte characters and you use String.substring, everything shifts. For genuinely binary files, work on byte[].
Third, non-atomic uploads. Read a file that is still being written over SFTP and you process a truncated file. The standard contract is that the sender writes a trigger/done file on completion, or writes under a temporary name and renames at the end. Never start work purely because a file exists.
The posting date is the instant the transaction was recorded in the system — a technical, immutable fact and the audit basis. The value date is the date on which the transaction is financially effective, and it drives interest accrual, daily limits and accounting reports.
The two diverge constantly: a transaction after cut-off is booked today but has the next business day's value date; a correction booked today may carry last month's value date; and instruments like cheques carry future value dates.
In design, every ledger line must carry both, and no report may use the wrong one. Profit-and-loss and balance reports must group by value date or month-end closing is wrong. Operational and monitoring reports must use the posting date. And because value dates can be backdated, any report already produced for that period must be reproducible — meaning finalised reports have to be snapshotted, not recomputed from scratch each time, otherwise yesterday's number differs from today's and nobody trusts the system.
9. The regulatory duties an engineer implements
Here is the list of things nobody writes in the job posting but everybody expects from day one.
Audit trail and immutability. Every state change must be recorded: who, when, from which state to which state, and why. The audit table must be append-only, with UPDATE/DELETE revoked even from the application user.
KYC and AML touchpoints. KYC (Know Your Customer) means verifying customer identity before an account is activated. AML (Anti-Money Laundering) means monitoring transactions for suspicious patterns. From an engineering perspective there are typically three touchpoints: screening at onboarding against sanctions and politically-exposed-person lists, inline screening before a transaction executes, and asynchronous behavioural monitoring across history.
If the screening service is unavailable, what is the default behaviour? In the financial domain the answer is usually fail-closed: reject or suspend the transaction. That is the exact opposite of ordinary engineering instinct, which says "an auxiliary service must not break the main path". Here it is not an auxiliary service, it is a legal requirement, and executing a transaction that should have been blocked carries fines and licence risk. This decision must be made explicitly, documented, and signed off by the business — never left implicit in a fallback.
Transaction limits. Per-transaction, daily, monthly, per-channel and per-verification-tier limits. The important technical point: a daily limit is an invariant over a set, so it is exactly where you need SERIALIZABLE or an atomic counter.
-- Daily usage counter with an atomic upsert
INSERT INTO daily_usage (customer_id, usage_date, used_amount)
VALUES (:customerId, :date, :amount)
ON CONFLICT (customer_id, usage_date)
DO UPDATE SET used_amount = daily_usage.used_amount + EXCLUDED.used_amount
WHERE daily_usage.used_amount + EXCLUDED.used_amount <= :dailyLimit
RETURNING used_amount;
-- returning no row means the limit would be exceededMERGE INTO daily_usage d
USING (SELECT :customerId AS customer_id, :date AS usage_date, :amount AS amt FROM dual) s
ON (d.customer_id = s.customer_id AND d.usage_date = s.usage_date)
WHEN MATCHED THEN UPDATE SET d.used_amount = d.used_amount + s.amt
WHERE d.used_amount + s.amt <= :dailyLimit
WHEN NOT MATCHED THEN INSERT (customer_id, usage_date, used_amount)
VALUES (s.customer_id, s.usage_date, s.amt);
-- SQL%ROWCOUNT of 0 means the limit would be exceededData retention. Financial data usually must be kept for years, while personal data must be deleted after its purpose ends under privacy law. These conflict, and the standard resolution is separation: the ledger keeps a synthetic identifier, and personal information lives in a separate service with its own lifecycle. Deleting personal data must not break the ledger.
Four-eyes approval and segregation of duties. High-risk operations — changing a limit, a manual refund, posting an adjustment entry — must be initiated by one person and approved by a different person. Segregation of duties means no single role can create an operation, approve it, and also erase its log.
@Service
public class ManualAdjustmentService {
@PreAuthorize("hasRole('OPS_MAKER')")
@Transactional
public long propose(AdjustmentRequest req, String actor) {
return repo.saveProposal(req, actor); // a proposal only — no financial effect
}
@PreAuthorize("hasRole('OPS_CHECKER')")
@Transactional
public void approve(long proposalId, String approver) {
Proposal p = repo.load(proposalId);
if (p.proposedBy().equals(approver)) {
throw new IllegalStateException("four-eyes violation: proposer cannot approve");
}
ledger.post(p.toJournalEntry(), approver); // here and only here does money move
}
}
Hiding the "approve" button from the user who created the proposal is a UX improvement, not a security control. The real control is that server-side if plus separate roles. This is exactly what an auditor asks: "demonstrate that a user with the maker role cannot approve via the API directly". If your answer is "the button is hidden", you fail the audit.
In layers. The first layer is the database: UPDATE and DELETE privileges on the ledger tables are revoked from the application role and inserts go only through an approved view or procedure. The second layer is the domain model: the ledger entity has no setters and there is no code path that mutates it; corrections happen only by posting a reversing entry that references the original.
The third layer is detection: I keep a hash chain over the rows — each row stores its own hash along with the previous row's hash — and a periodic job verifies the chain. That does not prevent tampering but makes any tampering discoverable, which for an auditor is worth just as much.
The fourth layer is operational: segregation of duties between whoever writes the code, whoever has production access and whoever audits; plus backups and audit logs kept on a separate append-only store.
And one point many people forget: schema migrations can also mutate the ledger. Any migration touching financial tables must have its own review and its own approval path.
10. Failure handling in a domain where failure is expensive
The resilience chapter covers the generic patterns (circuit breaker, bulkhead, retry with backoff). Here are the domain-specific differences:
1) Retry only idempotent operations. You cannot blindly resend an authorisation request that timed out — you might place two holds on the customer's account. Reverse or query first, then decide.
2) A circuit breaker must produce "unknown", not "failed". When the breaker is open and a request is rejected, that transaction was never sent, so it genuinely failed. But a request that was sent and timed out is UNKNOWN. Do not conflate the two in code.
3) Partial settlement. A settlement file may carry an amount lower than the authorised amount — because of partial capture, a scheme fee deduction, or an FX adjustment. The system must recognise this as a valid state and post the difference to the appropriate account rather than counting it as a break.
4) Stuck transactions and manual-intervention tooling. There will always be transactions that no automation resolves. You need a tool that lists stuck transactions by age and amount, shows the complete event history and raw messages, offers the operations permitted by the current state, and performs each one under four-eyes with full logging.
Teams that do not build ops tooling instead hand engineers direct production database access. That is the worst possible outcome: no audit, no four-eyes, no validation, and the risk of an UPDATE without a WHERE. For every intervention scenario that repeats three times, build an explicit operation in the tool. A good maturity metric for a payments team: how many times a month does somebody have to run manual SQL in production? The answer should be zero.
The temptation to "just fix this one row" is lethal. If someone changes a transaction's status directly in the database without a corresponding ledger entry, the ledger and the status diverge, and tomorrow night's reconciliation produces an unexplainable break whose origin nobody ever finds. The rule: no financial state change without an entry.
11. A reference architecture: a payment service with Spring
The main components and their responsibilities:
معماری یک سرویس پرداخت · Reference architecture of a payment service
flowchart TD
C[Client] -->|POST with Idempotency-Key| API[Payment API<br/>validation + idempotency]
API --> SVC[Payment orchestrator]
SVC --> LED[(Ledger<br/>append-only journal)]
SVC --> OBX[(Outbox table)]
SVC --> SW[Switch adapter<br/>ISO 8583 over TCP]
OBX --> REL[Outbox relay] --> MQ[[Message broker]]
MQ --> NOTIF[Notification service]
MQ --> RPT[Reporting projection]
FILE[Settlement file] --> RECON[Reconciliation job]
RECON --> LED
RECON --> OPS[Ops console<br/>four-eyes actions]
OPS --> LED
- The API boundary does validation, idempotency and translation into the domain model. No financial logic lives here.
- The orchestrator drives the transaction state machine and decides which entries are posted.
- The ledger is the only financial source of truth, append-only.
- The outbox writes events in the same transaction as the ledger so publishing and posting share a fate (pattern in the microservices data chapter).
- The switch adapter is the only place that understands ISO 8583. The rest of the system sees the domain model — the port/adapter boundary from the hexagonal architecture chapter.
- Reconciliation matches external truth against the ledger and posts compensating entries.
- The ops console is the only route for human intervention, with four-eyes and full logging.
جریان یک پرداخت موفق · Sequence of a successful payment
sequenceDiagram
participant C as Client
participant A as Payment API
participant L as Ledger DB
participant S as Switch
participant R as Outbox relay
C->>A: POST /payments (Idempotency-Key)
A->>L: INSERT idempotency IN_PROGRESS
A->>L: INSERT journal entry (authorised)
A->>S: 0200 financial request
S-->>A: 0210 response code 00
A->>L: INSERT journal entry (captured) + outbox row
A->>L: UPDATE idempotency COMPLETED
A-->>C: 201 Created
R->>R: poll outbox and publish event
جریان یک پرداخت شکستخورده که با مغایرتگیری حل میشود · Sequence of a failed payment resolved by reconciliation
sequenceDiagram
participant C as Client
participant A as Payment API
participant L as Ledger DB
participant S as Switch
participant J as Recon job
C->>A: POST /payments (Idempotency-Key)
A->>L: INSERT journal entry (authorised)
A->>S: 0200 financial request
S--xA: timeout, no response
A->>L: mark payment UNKNOWN
A-->>C: 202 Accepted, result pending
A->>S: 0400 reversal advice (retried)
Note over J: next morning batch
J->>J: load settlement file
J->>L: found in file, not settled in ledger
J->>L: INSERT compensating journal entry
J->>L: mark payment SETTLED
- Every money movement has a balanced journal entry and the ledger is never edited.
- Every external request produces at most one financial effect, regardless of retries.
- No event is published unless its database effect committed, and vice versa.
- Any divergence from the outside world is detected within at most one reconciliation cycle and resolved by a compensating entry, never by an edit.
12. The interview reality
Questions in this space come in two layers. The first is the screen: get it wrong and the interview ends there. The second is senior judgment: a correct answer is not enough, you must show you understand the trade-off.
First I separate which balance they mean. If it is a balance on which a financial decision depends (a funds check before a withdrawal), it must be read from the transactional source inside the same transaction, usually as an atomic UPDATE ... WHERE balance >= :amount that reads and writes together. If it is a balance that is merely displayed, I can serve it from a read projection with a short lag and tell the user explicitly that it may be a few seconds behind.
For performance I have three options: a full ledger sum, which is the most correct and the slowest; a periodic snapshot plus the delta after it, which is a good balance; and a running balance on each line, which makes reads a single row but serialises inserts per account.
What I never do is treat the balance as editable primary data. If I cache it, I update the cache in the same transaction as the ledger and run a daily job comparing the cache against the true ledger sum. If a difference is found I rebuild the cache from the ledger and raise an alert — because a difference means a bug exists somewhere.
First, what I do not do: I do not blindly resend, and I do not tell the user "failed". I know neither to be true.
I put the transaction into an UNKNOWN state — a first-class state in the machine, not a flag. I answer the user with "processing" and a tracking identifier.
Then I take one of two resolution paths. If the protocol supports a status inquiry, I ask the switch by the unique transaction identifier whether it has the transaction. If it does not, I send a reversal message referencing the original message's data elements — a reversal is idempotent by design, and I retry it with backoff until I get a definitive answer.
If neither resolves within the window, the transaction enters the reconciliation queue and tomorrow morning's settlement file tells the truth: if it is in the file, I post a compensating entry and mark it successful; if it is not, it becomes a definitive failure and the hold has been released.
And most importantly: throughout all of this, the customer's money is sitting in a "pending resolution" suspense account in my ledger, not vanished. Every unit of currency is always in some account — that is the property of double-entry that stops money from getting lost; it only shows you where it is stuck.
Never 100 / 3 with independent rounding. All three become 33.33 and the total is 99.99 — a cent goes missing and the ledger stays unbalanced.
The correct algorithm: convert to minor units (10000 cents), do integer division (3333 each), compute the remainder (1 cent), and distribute it under a deterministic policy — either to the first recipients, or to the largest fractional parts, or to a designated rounding_difference account.
Three things a senior adds: first, the algorithm must also be correct for negative amounts (the remainder must be distributed in the right direction). Second, the policy must be documented and explainable to an auditor, not an implementation accident. Third, I write a property-based test asserting that for any amount and any number of parts the output sums exactly to the input and the largest difference between parts is at most one minor unit. That test is precisely what proves you understood the problem.
A transactions table tells you "what happened" but not "where the money is now". Double-entry adds a structural invariant: the sum of all debits always equals the sum of all credits. That means any bug that creates or destroys money shows up in the trial balance — not six months later in a customer complaint.
Second, double-entry makes in-flight money visible. With suspense accounts you can always ask "how much money is currently authorised but not settled?" and the answer is a computable number. With a plain transactions table that question becomes a fragile query full of CASE expressions.
Third, it gives you a shared language with finance and audit. They work in ledgers and trial balances. If your model matches theirs, every report you produce is directly reconcilable. If it does not, you build a fragile translation layer that itself becomes a source of discrepancy.
I do not deny the cost: more rows, heavier balance queries, and the need for snapshots. But that is the price you pay for making errors detectable, and in the money domain that trade is always worth it.
In this domain, correctness always outranks throughput. The mid-level engineer says "locking kills throughput, let's write without locks". The senior says "guarantee the invariant first, then measure, and if it is genuinely the bottleneck solve it with account sharding or batching — not by abandoning the invariant".
Three sentences that score points in an interview: "I do not know this state, so I model it as UNKNOWN rather than FAILED"; "I close this in the database too, because the code path can be bypassed"; and "how will we know if this breaks?" — because in a financial system, an undetected error is worse than one that takes the system down. A down system is obvious; a system silently moving money incorrectly stays invisible for months.
Never hold money in floating point: BigDecimal with explicit scale and RoundingMode, or an integer of minor units, always paired with a currency — and imprison it in a Money type so that adding two currencies or rounding implicitly becomes impossible. In the database use numeric(20,4) or NUMBER(20,4), never float.
The heart of the system is the double-entry ledger: every event is a balanced entry of debit and credit lines, the ledger is append-only, corrections happen only through reversing entries, and the balance is always derived — from a full sum, from a snapshot plus delta, or from a projection. A mutable balance column is a bug waiting to happen.
Know the transaction lifecycle by its proper names: an authorisation moves no money, a capture is an obligation, clearing is netting, and only settlement moves funds. A reversal is for before settlement, a refund is for after.
Make the API boundary idempotent: an idempotency key backed by a database uniqueness constraint, a fingerprint over a canonical form, and a stored response for replays. Never guess after a timeout — create an UNKNOWN state and resolve it by inquiry, reversal or reconciliation. Daily reconciliation with break categories and compensating entries is your final safety net.
Under concurrency, a conditional atomic update is the best option, pessimistic locking for hot accounts, optimistic locking for low contention, a fixed lock order to avoid deadlocks, and SERIALIZABLE with retries only when the invariant spans a set. On the card side, know the ISO 8583 structure — MTI, bitmap and the key fields — and know that the PAN must be tokenized, the CVV never stored, and the PIN only opened inside an HSM.
Finally, the regulatory duties are part of your code, not the lawyers' job: an immutable audit trail, KYC/AML touchpoints with fail-closed behaviour, atomic limits, separated retention, and four-eyes enforced on the server. If you take one sentence from this chapter, take this: in the money domain, an error that goes undetected is worse than one that takes the system down.