Markdown Editor

Payment Ledger System Design Template (Reference Design)

Payments and double-entry ledger: idempotency keys, payment state machine, PSPs, outbox, reconciliation, audit, PCI DSS scope

Mode d'emploi: Use when taking payments or moving money. Agree the money-correctness rules and chart of accounts with finance first. The main pitfall is treating a PSP timeout as failure; retry with idempotency keys and reconcile instead.

Aperçu

System Design: [Payment and ledger system name]

Reference design for accepting payments through external payment service providers (PSPs) and recording every money movement in a double-entry ledger. It covers idempotency keys, the payment state machine, PSP integration and webhooks, journals and postings, balances, reconciliation, exactly-once side effects through a transactional outbox, auditability and PCI DSS scope reduction. It follows the structure of the System Design Document template and is pre-filled with a worked design to adapt. Values marked Example are assumptions. This is an engineering format, not accounting, legal or regulatory advice: confirm accounting treatment and compliance scope with your finance and compliance teams.

Document control

FieldValue
Document ID[SDD-PAY-001]
System / feature[Payments and ledger]
Version[0.1]
Status[Draft / In review / Approved / Implemented / Superseded]
Author(s)[@author]
Reviewers[@reviewer-1], [@reviewer-2]
Last updatedYYYY-MM-DD
Related[PRD], [Chart of accounts], [PCI DSS scope document], [Threat model]

Approvals

RoleNameDecisionDate
Tech lead[@name][Approved / Changes requested]YYYY-MM-DD
Staff / principal engineer[@name][Decision]YYYY-MM-DD
Finance / financial controller[@name][Decision]YYYY-MM-DD
Security and compliance[@name][Decision]YYYY-MM-DD
SRE / operations[@name][Decision]YYYY-MM-DD

Revision history

VersionDateAuthorChange summary
0.1YYYY-MM-DD[@name]First draft for design review
0.2YYYY-MM-DD[@name]Example: Added transactional outbox for PSP calls and ledger events
0.3YYYY-MM-DD[@name]Example: Hot-account sharding for the platform fee account
1.0YYYY-MM-DD[@name]Example: Approved

Contents

1. Overview

1.1 Problem statement

[Example: Our marketplace charges buyers and pays out sellers. Payment status lives in the orders table, refunds are handled by a script, and the monthly close takes finance 9 days because PSP reports do not match our data. Last quarter 0.03% of orders were charged twice after client retries.]

1.2 Proposed solution

[Example: A payment service owns a payment state machine and talks to PSPs through adapters. Every request carries an idempotency key. Every state change that moves money writes a balanced journal entry to an append-only double-entry ledger in the same database transaction as the state change and an outbox record. Outbox relays perform PSP calls and publish events exactly once in effect. A reconciliation job matches ledger entries against PSP settlement reports daily and opens cases for every difference. Card data never touches our servers: clients tokenise through the PSP's hosted fields.]

1.3 Decisions requested from reviewers

IDQuestionRecommended optionSection
Q-01Example: Ledger in the payments database or a separate service?Same database, separate schema, in v17.4, 10
Q-02Example: Amount type?Integer minor units plus ISO 4217 currency5.2
Q-03Example: Authorise and capture separately?Yes; capture on shipment, auto-void after 7 days7.2
Q-04Example: Balance storage?Materialised balance with optimistic version, derived from postings7.5
[Q-nn][Question][Option][Section]

2. Requirements

2.1 Functional requirements

IDRequirementPriority
FR-01Example: Create a payment, authorise a card or wallet through a PSP, handle 3-D Secure challengesMust
FR-02Example: Capture fully or partially; void uncaptured authorisationsMust
FR-03Example: Refund fully or partially, multiple times up to the captured amountMust
FR-04Example: Record every money movement as a balanced double-entry journalMust
FR-05Example: Report balances per account (seller payable, fees, PSP receivable) at any point in timeMust
FR-06Example: Reconcile daily against PSP settlement reportsMust
FR-07Example: Route to a secondary PSP when the primary is degradedShould
[FR-nn][Requirement][Must / Should / Could]

2.2 Non-functional requirements

IDQualityTargetMeasured as
NFR-01CorrectnessExample: Every journal entry balances to zero per currencyDatabase constraint + nightly check
NFR-02No double chargeExample: Same idempotency key never creates two PSP chargesReconciliation and duplicate detector
NFR-03LatencyExample: Create-and-authorise p99 at or below 2.5 s including PSPService metrics
NFR-04AvailabilityExample: 99.95% monthly for payment creationSynthetic checks against PSP sandbox
NFR-05DurabilityExample: RPO 0 for ledger (synchronous replication)Failover drills
NFR-06AuditabilityExample: Every change traceable to actor, request and time; retained [7] yearsAudit sampling
NFR-07ComplianceExample: PCI DSS scope limited to SAQ A-type integrationQSA assessment
[NFR-nn][Quality][Target][Measurement]

2.3 Out of scope

  • [Example: seller payouts to bank accounts (next design, reuses the ledger)]
  • [Example: multi-currency FX conversion inside the ledger]
  • [Example: tax calculation]
  • [Item deferred, with the release that will take it]

3. Capacity estimation

3.1 Assumptions

InputSymbolValueSource
Payments per dayNNExample: 10 millionBusiness forecast
Peak to average factorkkExample: 5 (sales events)Last peak season
Journal entries per payment lifecyclejjExample: 3 (authorise, capture, settle)7.4
Postings per journal entryqqExample: 3 on averageChart of accounts
Stored posting row incl. indexesssExample: 250 bytesSchema in 5.2
RetentionYYExample: 10 yearsFinance policy

3.2 Throughput

QPSpay=N86 400=10786 400≈116 payments/s,peak=5×116≈580 payments/sQPS_{pay} = \frac{N}{86\,400} = \frac{10^{7}}{86\,400} \approx 116 \ \text{payments/s}, \qquad \text{peak} = 5 \times 116 \approx 580 \ \text{payments/s}

Ledger postings:

Pday=N×j×q=107×3×3=9×107 postings/day≈1 040 /s average, ≈5 200 /s peakP_{day} = N \times j \times q = 10^{7} \times 3 \times 3 = 9 \times 10^{7} \ \text{postings/day} \approx 1\,040 \ \text{/s average}, \ \approx 5\,200 \ \text{/s peak}

These rates fit a single well-provisioned relational primary; correctness, not throughput, is the driver of this design.

3.3 Storage

Sday=9×107×250 B=22.5 GB/day,Syear≈8.2 TB,S10y≈82 TBS_{day} = 9 \times 10^{7} \times 250 \ \text{B} = 22.5 \ \text{GB/day}, \qquad S_{year} \approx 8.2 \ \text{TB}, \qquad S_{10y} \approx 82 \ \text{TB}

Postings older than the current and previous fiscal year move to partitioned archive tables (still queryable for audit), keeping the hot set near 16 TB.

3.4 PSP connections

With PSP authorisation latency tpsp≈800t_{psp} \approx 800 ms, concurrent in-flight calls at peak are, by Little's law:

L=λ×W=580×0.8≈464 concurrent callsL = \lambda \times W = 580 \times 0.8 \approx 464 \ \text{concurrent calls}

Size the outbound connection pool and PSP rate-limit agreement for at least twice that.

3.5 Summary

QuantityEstimateDesign consequence
Payments~116/s average, ~580/s peakSingle-region relational primary with synchronous replica
Postings~1 040/s average, ~5 200/s peakBatch inserts per journal; partition by month
Storage~8 TB/yearMonthly partitions, archive tier
PSP concurrency~460 in flight at peakPooled HTTP clients, circuit breakers

4. API design

4.1 Endpoints

MethodPathPurposeAuthIdempotent
POST/v1/paymentsCreate and authorise a paymentService tokenRequired Idempotency-Key
POST/v1/payments/{id}/captureCapture full or partial amountService tokenRequired Idempotency-Key
POST/v1/payments/{id}/voidCancel an uncaptured authorisationService tokenRequired Idempotency-Key
POST/v1/payments/{id}/refundsRefund full or partialService tokenRequired Idempotency-Key
GET/v1/payments/{id}Payment with state historyService tokenYes
POST/v1/psp-webhooks/{psp}Receive PSP notificationsPSP signatureDeduplicated by PSP event ID
GET/v1/ledger/accounts/{id}/balance?asOf=Balance at a timeFinance roleYes

4.2 Request and response examples

Create a payment with a PSP token obtained client side; amounts are integers in minor units:

POST /v1/payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: ord_88123-attempt-1

{"orderId": "ord_88123", "amount": 4599, "currency": "EUR", "paymentMethodToken": "pm_tok_3fa9", "captureMode": "manual", "sellerAccountId": "acct_s_211"}
HTTP/1.1 201 Created
Content-Type: application/json

{"id": "pay_01JA2", "state": "authorized", "amount": 4599, "currency": "EUR", "pspReference": "psp_7712", "authorizedAt": "2026-03-14T10:02:03Z"}

4.3 Idempotency semantics and errors

The server stores (client_id, idempotency_key) with a hash of the request body and the final response. A repeated key with the same body returns the stored response; with a different body it returns 422. A repeated key while the first request is still running returns 409 so the client retries later.

StatusProblem typeWhen
402payment-declinedPSP declined; includes a normalised decline code
409request-in-progressSame idempotency key still processing
409invalid-stateFor example capture on a voided payment
422idempotency-key-reusedSame key, different request body
503psp-unavailableAll routes degraded; safe to retry with the same key
  • Versioning: /v1 path; money fields never change type within a version.

5. Data model

5.1 Entities

Payments and their attempts hold operational state; journal entries and postings form the immutable ledger; the outbox links them to the outside world.

5.2 Storage choices and invariants

DataStoreKeyInvariant
Payments, attempts, idempotency keysExample: Relational database (serializable or row-locked updates)payment_idState changes only along 7.2 transitions; version checked
Journal entries, postingsSame database, ledger schema, partitioned by monthentry_idAppend-only; sum of postings per entry and currency is 0
Account balancesSame databaseaccount_idEquals sum of postings; verified nightly
OutboxSame databaseidWritten in the same transaction as the state change
PSP reports, reconciliation casesObject storage + relational tablesFile date, PSPEvery line matched or in a case

Amounts are signed 64-bit integers in minor units with an ISO 4217 currency code; floating-point types are forbidden in money paths. A database trigger or deferred constraint rejects a journal entry whose postings do not sum to zero.

5.3 Chart of accounts (excerpt)

AccountTypeMeaning
psp_receivable:{psp}AssetMoney the PSP owes us after capture, until settlement
bank_cash:{bank}AssetMoney in our bank account
buyer_funds_authorizedOff-balance memoAuthorised but not captured (tracked, not booked as revenue)
seller_payable:{seller}LiabilityMoney we owe the seller
platform_fee_revenueRevenueOur commission
psp_fee_expense:{psp}ExpenseFees charged by the PSP

6. High-level design

All money state lives in one transactional database; everything that talks to the outside world is driven from the outbox.

ComponentResponsibilityScales by
Payment serviceIdempotency, state machine, journal creation, outbox writesStateless replicas
Outbox relayRead unpublished outbox rows in order, call PSP router or publish events, mark donePartitioned by payment_id hash
PSP router and adaptersMap our operations to each PSP's API, normalise responses and decline codesStateless
Webhook receiverVerify signatures, deduplicate, translate into state transitionsStateless
Reconciliation jobImport settlement reports, match, open casesBatch, daily
[Component][Responsibility][Scaling unit]

7. Detailed design

7.1 Authorise path (critical path)

For a synchronous checkout response the payment service calls the PSP inline after committing the intent, and the outbox guarantees a retry if it crashes.

The PSP idempotency key is our payment_id plus operation, so a retried call after a timeout returns the original PSP result rather than charging again. If a PSP does not support idempotency keys, the relay first queries the PSP by our reference before retrying.

7.2 Payment state machine

States change only through the transitions below; each transition is one database transaction that also writes the matching journal entry and outbox events.

Out-of-order webhooks (for example "captured" arriving before our capture call returns) are applied only if the transition is valid from the current state; otherwise they are stored and re-evaluated, never dropped.

7.3 Exactly-once effects through the outbox

Delivery between systems is at-least-once; effects are exactly-once because every receiver is idempotent:

  1. The state change, journal entry and outbox row commit in one transaction. There is no moment where money moved in our books but no event exists.
  2. The relay publishes or calls, then marks the row done. A crash between the two repeats the call; the PSP idempotency key and consumer deduplication by event ID absorb the repeat.
  3. Consumers (order service, notifications) store processed event IDs in the same transaction as their own effect.

7.4 Double-entry ledger

Every journal entry has at least two postings and sums to zero per currency (debits positive, credits negative in this convention). For a EUR 45.99 payment with a 10% platform fee and a EUR 0.35 PSP fee:

EventAccountDebitCredit
Capturepsp_receivable:psp_a45.99
Captureseller_payable:acct_s_21141.39
Captureplatform_fee_revenue4.60
Settlementbank_cash:main45.64
Settlementpsp_fee_expense:psp_a0.35
Settlementpsp_receivable:psp_a45.99

Check: capture 45.99−41.39−4.60=045.99 - 41.39 - 4.60 = 0; settlement 45.64+0.35−45.99=045.64 + 0.35 - 45.99 = 0. The fee split (whether the fee is rounded down, and who bears PSP fees) is a finance decision; record it in the chart of accounts.

Corrections never edit postings. A wrong entry is cancelled by a reversing entry (reverses_entry_id) and replaced by a correct one, so the history of what we believed and when stays visible to auditors.

7.5 Balances and the consistency model

  • Strong consistency inside the ledger: postings and balance updates commit together; the account row's version is checked (optimistic concurrency) so concurrent entries cannot lose updates.
  • Point-in-time balances are the sum of postings with effective_at <= asOf, served from daily closing snapshots plus the delta.
  • Hot accounts such as platform_fee_revenue receive a posting for almost every payment. They are split into Example: 16 sub-accounts chosen by hash of payment_id; reports sum the sub-accounts.
  • Eventual consistency with the outside world: PSP and bank state converge through webhooks and reconciliation; the ledger records what we know, with effective_at set to when the event happened.

7.6 Reconciliation

StepWhat happensOutput
ImportFetch PSP settlement and fee reports and bank statements dailyNormalised report lines
MatchMatch each line to a ledger entry by PSP reference, amount and currencyMatched pairs
ClassifyUnmatched lines become cases: missing in ledger, missing at PSP, amount mismatch, timingReconciliation cases
ResolveFinance or automation posts adjusting entries with a case IDClosed cases
ReportDaily reconciliation rate and ageing of open casesClose readiness

7.7 Auditability and PCI DSS scope reduction

  • Ledger tables are append-only; the application database role has no UPDATE or DELETE grant on them. Every entry records actor, request ID and source event.
  • A nightly job recomputes balances from postings and compares them to stored balances and to a chained hash of the day's entries.
  • Card numbers are entered into PSP-hosted fields or a redirect page, so our systems receive only tokens. This is designed to keep our environment in the reduced PCI DSS self-assessment scope; confirm the exact scope with a qualified assessor.

8. Scalability and performance

ConcernApproachLimit before redesign
Write rateSingle primary, batched postings, monthly partitionsExample: ~20 000 postings/s on current hardware
Hot accountsSub-account shardingOne sub-account at ~1 000 postings/s
PSP latencyAsync capture and refund via outbox; inline only for authorisePSP rate limits
Reporting loadRead replica and daily snapshots for finance queries[Limit]
Growth beyond one primaryShard by merchant with cross-shard entries via clearing accountsDocumented, not built

Latency budget for create-and-authorise (target p99 2.5 s):

StepBudget
Idempotency check and intent commit30 ms
PSP authorisation (p99)2 000 ms
Result commit with journal and outbox40 ms
Network and serialisation50 ms
Headroom380 ms

9. Reliability, security and operations

9.1 Failure modes

FailureImpactDetectionMitigation
PSP timeout after chargingUnknown outcomePending payments older than 60 sRetry with PSP idempotency key; query by reference; webhook resolves
PSP outagePayments failError rate per PSPCircuit breaker; route new payments to secondary PSP
Duplicate webhookDouble transition attemptDedup table hitsDedup by PSP event ID; transitions are idempotent
Database primary failureWrites stopHealth checksSynchronous replica promotion; RPO 0
Outbox relay stuckEvents and captures delayedOldest unpublished row ageAlert at 2 min; relay restarts; rows are not lost
Unbalanced entry attemptedBug in posting logicConstraint violationTransaction rejected; alert to on-call
[Failure][Impact][Signal][Mitigation]

9.2 Security and abuse

ThreatControl
Card data exposureTokenisation via PSP hosted fields; no PAN in logs, enforced by log scrubbing tests
Forged webhooksSignature verification with PSP secret, timestamp tolerance, allowlisted source where offered
Insider ledger tamperingAppend-only grants, four-eyes approval for manual adjusting entries, hash chain
Card testing fraudVelocity limits per card fingerprint, device and IP; risk scoring before authorise
Refund abuseRefund limited to captured minus refunded; manual review above threshold
[Threat][Control]

9.3 Observability

SignalMetric or logAlert threshold
Authorisation success ratePer PSP, method and countryDrop of 5 points vs. 7-day baseline
Pending paymentsCount older than 5 minAbove 50
Ledger balance checkNightly recompute mismatchesAny
Reconciliation rateMatched lines / totalBelow 99.9%
Outbox ageOldest unpublished rowAbove 2 min

9.4 Cost estimate

ItemUnit costQuantityMonthly cost
Database (primary + sync replica + read replica)[$/instance-month]3[$]
Storage incl. archive[$/TB-month]Example: ~0.7 TB added per month[$]
Services and relays[$/node-month][n][$]
PSP fees[% + fixed per transaction]Example: 300 million transactions[$]
Total[$]

10. Trade-offs and alternatives

DecisionOptionsChosenWhyWhat we give up
Ledger locationSeparate ledger service; same database as paymentsSame database, separate schemaAtomic state change, journal and outbox in one transactionHarder to share ledger with other products later
Exactly-onceDistributed transactions (2PC); outbox + idempotent consumersOutboxNo coordinator, works with PSPs that cannot join transactionsConsumers must deduplicate
BalancesComputed on read; materialised with versionMaterialisedFast balance checks for refundsHot-row contention, needs sub-accounts
Money typeDecimal; float; integer minor unitsInteger minor unitsExact, portable, fastCare with currencies of 0 or 3 decimals
Card entryOwn card form (full PCI scope); PSP hosted fieldsHosted fieldsSmallest PCI DSS scopeLess UI control
Event-sourced ledger onlyLedger as sole source of truth; state tables + ledgerState tables + ledgerSimpler queries for operationsTwo representations to keep consistent (same transaction)
[Decision][Options][Chosen][Why][Cost]

11. Delivery plan and risks

11.1 Milestones

MilestoneScopeExit criterionTarget date
M1Payments with one PSP, idempotency, state machineZero duplicates in chaos test with injected timeoutsYYYY-MM-DD
M2Double-entry ledger and balancesNightly balance check clean for 30 days in shadow modeYYYY-MM-DD
M3Reconciliation99.9% automatic match for one monthYYYY-MM-DD
M4Secondary PSP routingFailover drill passesYYYY-MM-DD

11.2 Rollout and rollback

[Example: Run the ledger in shadow mode, deriving entries from the old system's events, and compare balances daily. Move new payments per merchant cohort. Rollback routes new payments to the old path; payments already created complete in the new system.]

11.3 Risks

IDRiskLikelihoodImpactMitigationOwner
R-01Example: PSP lacks idempotency on refundsMediumHighQuery-before-retry; manual queue for unknown outcomes[@name]
R-02Example: Finance and engineering disagree on account semanticsMediumHighChart of accounts sign-off before M2[@name]
R-03Example: Hot-account contention at peakLowMediumSub-accounts; load test at 5x[@name]
[R-nn][Risk][L / M / H][L / M / H][Mitigation][@name]

12. Appendices

12.1 Open questions

IDQuestionOwnerDue
OQ-01Example: When is seller revenue recognised: capture or delivery?[@finance]YYYY-MM-DD
OQ-02Example: Retention period per jurisdiction?[@compliance]YYYY-MM-DD
[OQ-nn][Question][@name]YYYY-MM-DD

12.2 Glossary

TermMeaning
PSPPayment service provider that processes cards and wallets
AuthorisationReservation of funds on the payer's account
CaptureInstruction to collect authorised funds
Journal entryOne business event in the ledger, made of balanced postings
PostingA signed amount on one account within a journal entry
OutboxTable of pending messages written in the same transaction as the change
[Term][Meaning]

12.3 References

ReferenceUse
ISO 4217 currency codesCurrency field and minor units
PCI DSS (PCI Security Standards Council)Scope reduction in 7.7
RFC 9457 Problem Details for HTTP APIsError format in 4.3
IETF draft "The Idempotency-Key HTTP Header Field"Idempotency semantics in 4.3
[PSP API documentation]Adapter behaviour

Standard and licence note

Based on common industry practice for payment systems and double-entry bookkeeping, and on the references in 12.3. This is a reference design to adapt, with illustrative numbers; it is a format, not accounting, legal or regulatory advice. Original text written for this collection; no text from PCI DSS or other standards is reproduced. Template licence: MIT.

Modèles associés