Payment systems are boring — or rather, they should be. The ones that are novel, clever, or elegant are usually the ones that blow up later. This article covers the principles that keep money-moving code correct, safe, and auditable.
The first rule
Never lose money. Never duplicate money. Always be able to explain where the money is.
Every pattern below is in service of that.
Idempotency is not optional
Networks drop. Retries happen. Users click twice. If a charge endpoint isn’t idempotent, duplicates are a matter of when, not if.
Every write endpoint accepts an idempotency key. Store the key + result. On duplicate: return the stored result. No re-execution.
@PostMapping("/payments")
public PaymentResponse charge(
@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest req) {
return idempotencyStore.findByKey(key)
.orElseGet(() -> {
PaymentResponse result = paymentService.charge(req);
idempotencyStore.save(key, req, result);
return result;
});
}Make the lookup + save atomic (DB unique constraint on key). No exceptions.
Double-entry accounting
Every money movement is a pair of ledger entries. If $100 moves from customer account A to merchant account B, you write:
- A: debit $100
- B: credit $100
The sum of all debits equals the sum of all credits. If they don’t match, something is wrong.
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY,
transfer_id UUID NOT NULL,
account_id UUID NOT NULL,
amount_cents BIGINT NOT NULL, -- positive = credit, negative = debit
currency CHAR(3) NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
CHECK (amount_cents != 0)
);
-- Invariant (enforce with constraint or periodic check)
-- SELECT SUM(amount_cents) FROM ledger_entries WHERE transfer_id = ?
-- must equal zeroBalance of an account = sum of amounts for that account. Fast with an index on account_id. Ground truth is the ledger; balances cached in a separate table if needed for performance, reconciled against ledger periodically.
This pattern makes bugs obvious. If sum ≠ 0 on some transfer, alerts fire immediately.
Money as integers, always
Never use double or float for money. Floating-point arithmetic is inexact:
0.1 + 0.2 == 0.3 // false in float!Use integer cents (or smaller unit for low-value currencies like JPY). Or BigDecimal with explicit scale. Most systems I’ve seen use long cents throughout, converting to decimal at display time.
Strong consistency for money, eventual for everything else
Ledger writes need strong consistency — ACID against a single database. No “probably correct” here.
Related side effects (notifications, analytics, email receipts) can be eventually consistent — published as events, consumed async.
@Transactional
public TransferResult transfer(TransferCommand cmd) {
// Strong consistency here
ledgerRepo.save(new LedgerEntry(cmd.transferId(), cmd.fromAccount(), -cmd.amountCents(), now));
ledgerRepo.save(new LedgerEntry(cmd.transferId(), cmd.toAccount(), cmd.amountCents(), now));
// Event for async stuff
outboxRepo.save(new OutboxMessage("transfer.completed", TransferCompletedEvent.from(cmd)));
return TransferResult.success(cmd.transferId());
}Never delete
Ledger entries are immutable. Errors are corrected with reversing entries, not deletes:
- Original: A debit $100, B credit $100
- Correction: A credit $100, B debit $100 (reverses the original)
- Net effect: zero, same as if the transfer never happened
The history stays complete. Auditors can see what happened and when. DELETE is never the answer.
External rail integration
Payment rails (Stripe, card networks, SWIFT) are themselves remote services with their own failure modes. Rules:
- Single writer per transfer. A transfer’s charge to Stripe is owned by exactly one component. No two parts of your system can retry independently.
- Track state via our ID + their ID. Store our transfer_id + their reference. On retries, check “did we already send this to Stripe?” before sending again.
- Poll before assume. If a charge response is ambiguous (timeout), don’t assume failure. Query the rail with our reference to find the actual status.
- Reconciliation. Regular jobs compare our ledger against the rail’s records. Differences are investigated.
Saga for multi-step flows
Transfer involving external verification, rail charge, and internal ledger update:
- Verify (external)
- Charge card (external)
- Post to ledger (internal)
All must succeed or the transfer must not happen. In distributed terms: saga with compensations.
If step 2 succeeds but step 3 fails: automated reversal of the charge. If step 2 is ambiguous (timeout): poll + decide based on actual state.
Audit and observability
Every money movement generates:
- Ledger entries (the truth)
- Event log (what our system attempted)
- Audit log (who initiated, when, from where, with what idempotency key)
- Trace (technical path through our services)
Debugging a missing $50 requires all four. Build them from day one.
Testing
Payment code must be tested differently from normal code:
- Property tests. “For any valid transfer, debit sum == credit sum”. Randomly generate transfers, verify invariant.
- Chaos tests. Inject failures at every step; assert the system recovers without losing money.
- Reconciliation tests. Generate thousands of transfers, run reconciliation, verify balances match ledger.
Unit tests alone aren’t enough. Property and invariant testing catches the bugs that matter.
Regulatory and compliance hooks
Depending on jurisdiction:
- AML / KYC. Flagging suspicious patterns, sanctioned entities.
- PCI-DSS. Never store raw card numbers; tokenize.
- GDPR. Pseudonymization of PII linked to transfers.
- Record retention. 5-10 years depending on region.
Build these in from the start. Retrofitting is expensive.
Closing note
Payment systems reward conservatism. Every “clever” optimization I’ve seen caused an incident sooner or later. Double-entry accounting, strict idempotency, integer money, immutable ledger, explicit sagas, extensive testing — boring principles that keep the money where it should be. Teams that respect them ship payment systems that work for decades. Teams that don’t learn the rules the expensive way.