| name | financial-systems-patterns |
| description | Expert financial systems design patterns โ invoke when building payment processing, ledgers, accounting, money arithmetic, reconciliation, fraud detection, or any system where correctness of monetary values is critical. |
Financial Systems Design Patterns
Double-Entry Bookkeeping
Every financial transaction touches at least two accounts: one account is debited and another is credited. The invariant is strict โ debits must always equal credits. This is the foundation of all modern accounting systems and the reason financial systems can detect corruption or bugs: if debits != credits, something is wrong.
Chart of Accounts
ASSETS (debit increases, credit decreases)
LIABILITIES (credit increases, debit decreases)
EQUITY (credit increases, debit decreases)
REVENUE (credit increases, debit decreases)
EXPENSES (debit increases, credit decreases)
The accounting equation holds at all times: Assets = Liabilities + Equity
Ledger Entry Schema
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
account_id UUID NOT NULL REFERENCES accounts(id),
entry_type VARCHAR(6) NOT NULL CHECK (entry_type IN ('DEBIT', 'CREDIT')),
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
currency CHAR(3) NOT NULL,
description TEXT NOT NULL,
reference VARCHAR(255),
created_by UUID NOT NULL
);
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
description TEXT NOT NULL,
metadata JSONB
);
Why Minor Units (Cents, Pence)
Never store money as floating-point. 0.1 + 0.2 = 0.30000000000000004 in IEEE 754. Rounding errors compound over millions of transactions.
$12.50 -> stored as 1250 (cents)
ยฃ99.99 -> stored as 9999 (pence)
JPY 500 -> stored as 500 (yen have no minor units; exponent = 0)
BHD 1.5 -> stored as 1500 (Bahraini Dinar has 3 decimal places)
ISO 4217 defines the exponent (number of decimal places) per currency. Always look it up โ do not assume 2.
Immutability: Never Update or Delete
Ledger entries are immutable. If you made an error, create a reversing entry:
INSERT INTO ledger_entries (transaction_id, account_id, entry_type, amount_minor, currency, description)
VALUES ('txn-001', 'acc-revenue', 'CREDIT', 10000, 'USD', 'Invoice #42 payment');
INSERT INTO ledger_entries (transaction_id, account_id, entry_type, amount_minor, currency, description)
VALUES ('txn-002', 'acc-revenue', 'DEBIT', 10000, 'USD', 'REVERSAL: Invoice #42 payment (txn-001)');
INSERT INTO ledger_entries (transaction_id, account_id, entry_type, amount_minor, currency, description)
VALUES ('txn-002', 'acc-revenue', 'CREDIT', 9500, 'USD', 'Invoice #42 payment (corrected)');
Balance Calculation
SELECT
SUM(CASE WHEN entry_type = 'CREDIT' THEN amount_minor ELSE 0 END) -
SUM(CASE WHEN entry_type = 'DEBIT' THEN amount_minor ELSE 0 END) AS balance_minor
FROM ledger_entries
WHERE account_id = :account_id
AND currency = :currency;
SELECT transaction_id,
SUM(CASE WHEN entry_type = 'DEBIT' THEN amount_minor ELSE 0 END) AS total_debits,
SUM(CASE WHEN entry_type = 'CREDIT' THEN amount_minor ELSE 0 END) AS total_credits
FROM ledger_entries
WHERE transaction_id = :transaction_id
GROUP BY transaction_id
HAVING SUM(CASE WHEN entry_type = 'DEBIT' THEN amount_minor ELSE 0 END) !=
SUM(CASE WHEN entry_type = 'CREDIT' THEN amount_minor ELSE 0 END);
Money as Value Object
Anti-Patterns
float price = 9.99f;
float tax = price * 0.1f;
double total = 0.1 + 0.2;
String amount = "12.50";
Java/Kotlin Money Value Object
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Currency
data class Money(
val amountMinor: Long,
val currency: Currency
) {
companion object {
fun of(amount: BigDecimal, currency: Currency): Money {
val scale = currency.defaultFractionDigits
val scaled = amount.setScale(scale, RoundingMode.HALF_EVEN)
val minor = scaled.movePointRight(scale).toLong()
return Money(minor, currency)
}
fun ofMinor(amountMinor: Long, currencyCode: String): Money =
Money(amountMinor, Currency.getInstance(currencyCode))
}
fun toDecimal(): BigDecimal {
val scale = currency.defaultFractionDigits
return BigDecimal(amountMinor).movePointLeft(scale)
}
operator fun plus(other: Money): Money {
require(currency == other.currency) {
"Cannot add ${currency.currencyCode} and ${other.currency.currencyCode}"
}
return Money(amountMinor + other.amountMinor, currency)
}
operator fun minus(other: Money): Money {
require(currency == other.currency) {
"Cannot subtract ${other.currency.currencyCode} from ${currency.currencyCode}"
}
return Money(amountMinor - other.amountMinor, currency)
}
operator fun times(factor: BigDecimal): Money {
val scale = currency.defaultFractionDigits
val result = toDecimal().multiply(factor).setScale(scale, RoundingMode.HALF_EVEN)
return of(result, currency)
}
fun isPositive() = amountMinor > 0
fun isNegative() = amountMinor < 0
fun isZero() = amountMinor == 0L
override fun toString() = "${currency.currencyCode} ${toDecimal()}"
}
val price = Money.ofMinor(999, "USD")
val tax = price * BigDecimal("0.10")
val total = price + tax
Rounding Modes
| Mode | Use Case | Example (2.5 -> int) |
|---|
| HALF_UP | Retail prices, consumer-facing amounts | 3 |
| HALF_EVEN | Banking (Banker's Rounding) | 2 (rounds to even) |
| FLOOR | Interest calculations favoring bank | 2 |
| CEILING | Interest calculations favoring customer | 3 |
Define the rounding mode per operation in your domain spec. Do not leave it to language defaults.
Currency Conversion
data class FxConversion(
val fromAmount: Money,
val toAmount: Money,
val rate: BigDecimal,
val rateSource: String,
val rateTimestamp: Instant,
val appliedAt: Instant
)
fun convert(amount: Money, toCurrency: Currency, rate: BigDecimal): FxConversion {
val converted = amount * rate
return FxConversion(
fromAmount = amount,
toAmount = Money.of(converted.toDecimal(), toCurrency),
rate = rate,
rateSource = "ECB",
rateTimestamp = Instant.now(),
appliedAt = Instant.now()
)
}
Idempotency in Payments
The Problem
Networks fail. Clients retry. Without idempotency:
Client sends: charge $100
Network fails after server processes but before response
Client retries: charge $100 again
Result: customer charged $200
Idempotency Key Pattern
Client Server
| |
|-- POST /payments |
| Idempotency-Key: uuid-123 |
| body: {amount: 1000} |
| |-- Store key + process payment
|<-- 200 {payment_id: p-abc} |-- Store response
| |
|-- POST /payments (retry) |
| Idempotency-Key: uuid-123 |
| |-- Key exists! Return stored response
|<-- 200 {payment_id: p-abc} | (payment NOT processed again)
Database Implementation
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
response JSONB NOT NULL,
status_code INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '48 hours'
);
CREATE INDEX idx_idempotency_keys_expires_at ON idempotency_keys(expires_at);
@Transactional
fun processPayment(request: PaymentRequest, idempotencyKey: String): PaymentResponse {
val existing = idempotencyRepo.findByKey(idempotencyKey)
if (existing != null) {
return existing.response
}
val response = paymentGateway.charge(request)
idempotencyRepo.storeResult(
key = idempotencyKey,
response = response,
statusCode = 200
)
return response
}
INSERT INTO idempotency_keys (key, response, status_code)
VALUES (:key, :response, :status_code)
ON CONFLICT (key) DO NOTHING;
SELECT response, status_code FROM idempotency_keys WHERE key = :key;
Key Requirements for Callers
- Client generates a UUID per payment attempt (not per retry of same attempt)
- Same idempotency key = same request; different payment = different key
- Key must be sent in header:
Idempotency-Key: <uuid>
- Keys expire after 24-48 hours; documented in API spec
- After expiry, the same key can be reused (risk: caller must manage key lifecycle)
Payment State Machine
INITIATED
|
v
PENDING
|
+---------+---------+
| |
v v
PROCESSING CANCELLED
|
+--------+--------+
| |
v v
COMPLETED FAILED
|
v
REFUND_REQUESTED
|
v
REFUNDED
State Transition Table
| From State | To State | Trigger | Guard |
|---|
| INITIATED | PENDING | Payment submitted | Amount > 0, valid account |
| PENDING | PROCESSING | Gateway accepted | Gateway response OK |
| PENDING | CANCELLED | User/system cancels | Before PROCESSING |
| PROCESSING | COMPLETED | Gateway confirms success | Gateway confirms |
| PROCESSING | FAILED | Gateway decline / timeout | Gateway declines |
| COMPLETED | REFUND_REQUESTED | Refund requested | Within refund window |
| REFUND_REQUESTED | REFUNDED | Refund processed by gateway | Gateway confirms refund |
Schema
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'INITIATED',
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE payment_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id UUID NOT NULL REFERENCES payments(id),
from_status VARCHAR(20),
to_status VARCHAR(20) NOT NULL,
actor VARCHAR(255) NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Kotlin State Machine Implementation
enum class PaymentStatus {
INITIATED, PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED,
REFUND_REQUESTED, REFUNDED
}
val ALLOWED_TRANSITIONS = mapOf(
PaymentStatus.INITIATED to setOf(PaymentStatus.PENDING),
PaymentStatus.PENDING to setOf(PaymentStatus.PROCESSING, PaymentStatus.CANCELLED),
PaymentStatus.PROCESSING to setOf(PaymentStatus.COMPLETED, PaymentStatus.FAILED),
PaymentStatus.COMPLETED to setOf(PaymentStatus.REFUND_REQUESTED),
PaymentStatus.REFUND_REQUESTED to setOf(PaymentStatus.REFUNDED),
PaymentStatus.FAILED to emptySet(),
PaymentStatus.CANCELLED to emptySet(),
PaymentStatus.REFUNDED to emptySet()
)
fun transition(payment: Payment, toStatus: PaymentStatus, actor: String, reason: String? = null) {
val allowed = ALLOWED_TRANSITIONS[payment.status] ?: emptySet()
check(toStatus in allowed) {
"Illegal transition: ${payment.status} -> $toStatus for payment ${payment.id}"
}
val fromStatus = payment.status
payment.status = toStatus
payment.updatedAt = Instant.now()
paymentRepo.save(payment)
historyRepo.save(PaymentStatusHistory(
paymentId = payment.id,
fromStatus = fromStatus,
toStatus = toStatus,
actor = actor,
reason = reason
))
}
Reconciliation
Architecture
Internal System Payment Processor (Stripe / Adyen)
| |
| [webhooks - real-time] ---|
| |
| [settlement report - T+1]-|
| |
Reconciliation Job
|
+-- Load internal payments for period
+-- Load processor report for period
+-- Match by processor reference ID
+-- Flag discrepancies:
- Missing in internal (ghost payment)
- Missing in processor (orphan record)
- Amount mismatch
- Status mismatch
+-- Generate reconciliation report
+-- Alert on-call if discrepancy count exceeds threshold
Idempotent Webhook Handling
@Transactional
fun handleWebhook(event: WebhookEvent) {
if (webhookEventRepo.existsByExternalId(event.id)) {
log.info("Duplicate webhook event ${event.id} โ skipping")
return
}
processEvent(event)
webhookEventRepo.save(WebhookEvent(
externalId = event.id,
type = event.type,
processedAt = Instant.now()
))
}
Polling Fallback
Webhook arrives -> Process immediately
-> Also schedule verification check in 5 min
Verification check -> Re-query processor for payment status
-> If status diverges from internal state, reconcile
-> This catches missed webhooks
Nightly batch -> Full reconciliation against settlement file
-> Mandatory regardless of webhook reliability
Audit Trail
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
action VARCHAR(50) NOT NULL,
actor_type VARCHAR(20) NOT NULL,
actor_id VARCHAR(255) NOT NULL,
from_value JSONB,
to_value JSONB NOT NULL,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id, created_at);
Regulatory requirements:
- PCI-DSS: retain audit logs for 12 months (3 months immediately available)
- SOC 2: changes to financial data must be logged with actor and timestamp
- GDPR: audit logs may contain personal data โ factor into retention and deletion policies
Currency Handling
Multi-Currency Storage
CREATE TABLE account_balances (
account_id UUID NOT NULL,
currency CHAR(3) NOT NULL,
balance_minor BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, currency)
);
CREATE TABLE fx_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_ccy CHAR(3) NOT NULL,
to_ccy CHAR(3) NOT NULL,
rate NUMERIC(20, 10) NOT NULL,
source VARCHAR(50) NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL,
valid_until TIMESTAMPTZ
);
Settlement Flow
Customer pays EUR 100
|
v
Stored as EUR 10000 (minor units) in internal ledger
|
v
Merchant settlement (weekly)
|
v
Convert EUR -> USD at rate from fx_rates table
|
+-- Record FX conversion with rate_id foreign key
+-- Debit EUR account, credit USD account
|
v
Initiate USD bank transfer to merchant
Fraud Detection Patterns
Velocity Checks
fun checkVelocity(userId: String, amount: Money): RiskSignal {
val oneHourAgo = Instant.now().minusSeconds(3600)
val recentCount = txnRepo.countByUserSince(userId, oneHourAgo)
val recentTotal = txnRepo.sumByUserSince(userId, oneHourAgo)
return when {
recentCount > 10 -> RiskSignal.HIGH("velocity: $recentCount txns/hour")
recentTotal > Money.ofMinor(500_00, "USD") -> RiskSignal.MEDIUM("velocity: high volume")
else -> RiskSignal.LOW
}
}
Risk Scoring Pipeline
Transaction arrives
|
v
+-------+--------+
| Velocity check | -> HIGH risk score if N txns/hour exceeded
+-------+--------+
|
v
+-------+--------+
| Amount anomaly | -> score if amount > 3 std dev from user avg
+-------+--------+
|
v
+-------+--------+
| Geo anomaly | -> score if country differs from last 30 days
+-------+--------+
|
v
+-------+--------+
| Device check | -> score if new device + amount > threshold
+-------+--------+
|
v
Aggregate score
|
+----+----+
| |
LOW HIGH
| |
Allow Block / 3DS challenge / manual review
Risk Score Table
CREATE TABLE transaction_risk_assessments (
transaction_id UUID PRIMARY KEY,
velocity_score INT NOT NULL DEFAULT 0,
amount_score INT NOT NULL DEFAULT 0,
geo_score INT NOT NULL DEFAULT 0,
device_score INT NOT NULL DEFAULT 0,
aggregate_score INT NOT NULL DEFAULT 0,
decision VARCHAR(20) NOT NULL,
assessed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Anti-Patterns Checklist
| Anti-Pattern | Correct Approach |
|---|
double amount = 9.99 | Long amountMinor = 999 (cents) |
| Updating ledger entries to fix mistakes | Reversing entries + correcting entries |
| Currency conversion without storing the rate | Store rate, source, timestamp with conversion |
| Payment API without idempotency key | Require Idempotency-Key header |
| Skipping state transitions (PENDING -> COMPLETED) | Enforce state machine; reject illegal jumps |
| Relying solely on webhooks for status | Webhook + polling + nightly reconciliation |
| Audit log in the same table as operational data | Separate append-only audit log table |
| Implicit currency in money fields | Always store ISO 4217 currency code alongside |
| Processing the same webhook event twice | Deduplicate by external event ID |
| Assuming all currencies have 2 decimal places | Look up ISO 4217 exponent per currency |
Production Checklist