Payment Ledger System Design Template (Reference Design)
Payments and double-entry ledger: idempotency keys, payment state machine, PSPs, outbox, reconciliation, audit, PCI DSS scope
사용법: 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.
미리보기
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
| Field | Value |
|---|---|
| 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 updated | YYYY-MM-DD |
| Related | [PRD], [Chart of accounts], [PCI DSS scope document], [Threat model] |
Approvals
| Role | Name | Decision | Date |
|---|---|---|---|
| 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
| Version | Date | Author | Change summary |
|---|---|---|---|
| 0.1 | YYYY-MM-DD | [@name] | First draft for design review |
| 0.2 | YYYY-MM-DD | [@name] | Example: Added transactional outbox for PSP calls and ledger events |
| 0.3 | YYYY-MM-DD | [@name] | Example: Hot-account sharding for the platform fee account |
| 1.0 | YYYY-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
| ID | Question | Recommended option | Section |
|---|---|---|---|
| Q-01 | Example: Ledger in the payments database or a separate service? | Same database, separate schema, in v1 | 7.4, 10 |
| Q-02 | Example: Amount type? | Integer minor units plus ISO 4217 currency | 5.2 |
| Q-03 | Example: Authorise and capture separately? | Yes; capture on shipment, auto-void after 7 days | 7.2 |
| Q-04 | Example: Balance storage? | Materialised balance with optimistic version, derived from postings | 7.5 |
| [Q-nn] | [Question] | [Option] | [Section] |
2. Requirements
2.1 Functional requirements
| ID | Requirement | Priority |
|---|---|---|
| FR-01 | Example: Create a payment, authorise a card or wallet through a PSP, handle 3-D Secure challenges | Must |
| FR-02 | Example: Capture fully or partially; void uncaptured authorisations | Must |
| FR-03 | Example: Refund fully or partially, multiple times up to the captured amount | Must |
| FR-04 | Example: Record every money movement as a balanced double-entry journal | Must |
| FR-05 | Example: Report balances per account (seller payable, fees, PSP receivable) at any point in time | Must |
| FR-06 | Example: Reconcile daily against PSP settlement reports | Must |
| FR-07 | Example: Route to a secondary PSP when the primary is degraded | Should |
| [FR-nn] | [Requirement] | [Must / Should / Could] |
2.2 Non-functional requirements
| ID | Quality | Target | Measured as |
|---|---|---|---|
| NFR-01 | Correctness | Example: Every journal entry balances to zero per currency | Database constraint + nightly check |
| NFR-02 | No double charge | Example: Same idempotency key never creates two PSP charges | Reconciliation and duplicate detector |
| NFR-03 | Latency | Example: Create-and-authorise p99 at or below 2.5 s including PSP | Service metrics |
| NFR-04 | Availability | Example: 99.95% monthly for payment creation | Synthetic checks against PSP sandbox |
| NFR-05 | Durability | Example: RPO 0 for ledger (synchronous replication) | Failover drills |
| NFR-06 | Auditability | Example: Every change traceable to actor, request and time; retained [7] years | Audit sampling |
| NFR-07 | Compliance | Example: PCI DSS scope limited to SAQ A-type integration | QSA 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
| Input | Symbol | Value | Source |
|---|---|---|---|
| Payments per day | Example: 10 million | Business forecast | |
| Peak to average factor | Example: 5 (sales events) | Last peak season | |
| Journal entries per payment lifecycle | Example: 3 (authorise, capture, settle) | 7.4 | |
| Postings per journal entry | Example: 3 on average | Chart of accounts | |
| Stored posting row incl. indexes | Example: 250 bytes | Schema in 5.2 | |
| Retention | Example: 10 years | Finance policy |
3.2 Throughput
Ledger postings:
These rates fit a single well-provisioned relational primary; correctness, not throughput, is the driver of this design.
3.3 Storage
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 ms, concurrent in-flight calls at peak are, by Little's law:
Size the outbound connection pool and PSP rate-limit agreement for at least twice that.
3.5 Summary
| Quantity | Estimate | Design consequence |
|---|---|---|
| Payments | ~116/s average, ~580/s peak | Single-region relational primary with synchronous replica |
| Postings | ~1 040/s average, ~5 200/s peak | Batch inserts per journal; partition by month |
| Storage | ~8 TB/year | Monthly partitions, archive tier |
| PSP concurrency | ~460 in flight at peak | Pooled HTTP clients, circuit breakers |
4. API design
4.1 Endpoints
| Method | Path | Purpose | Auth | Idempotent |
|---|---|---|---|---|
| POST | /v1/payments | Create and authorise a payment | Service token | Required Idempotency-Key |
| POST | /v1/payments/{id}/capture | Capture full or partial amount | Service token | Required Idempotency-Key |
| POST | /v1/payments/{id}/void | Cancel an uncaptured authorisation | Service token | Required Idempotency-Key |
| POST | /v1/payments/{id}/refunds | Refund full or partial | Service token | Required Idempotency-Key |
| GET | /v1/payments/{id} | Payment with state history | Service token | Yes |
| POST | /v1/psp-webhooks/{psp} | Receive PSP notifications | PSP signature | Deduplicated by PSP event ID |
| GET | /v1/ledger/accounts/{id}/balance?asOf= | Balance at a time | Finance role | Yes |
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.
| Status | Problem type | When |
|---|---|---|
| 402 | payment-declined | PSP declined; includes a normalised decline code |
| 409 | request-in-progress | Same idempotency key still processing |
| 409 | invalid-state | For example capture on a voided payment |
| 422 | idempotency-key-reused | Same key, different request body |
| 503 | psp-unavailable | All routes degraded; safe to retry with the same key |
- Versioning:
/v1path; 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
| Data | Store | Key | Invariant |
|---|---|---|---|
| Payments, attempts, idempotency keys | Example: Relational database (serializable or row-locked updates) | payment_id | State changes only along 7.2 transitions; version checked |
| Journal entries, postings | Same database, ledger schema, partitioned by month | entry_id | Append-only; sum of postings per entry and currency is 0 |
| Account balances | Same database | account_id | Equals sum of postings; verified nightly |
| Outbox | Same database | id | Written in the same transaction as the state change |
| PSP reports, reconciliation cases | Object storage + relational tables | File date, PSP | Every 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)
| Account | Type | Meaning |
|---|---|---|
psp_receivable:{psp} | Asset | Money the PSP owes us after capture, until settlement |
bank_cash:{bank} | Asset | Money in our bank account |
buyer_funds_authorized | Off-balance memo | Authorised but not captured (tracked, not booked as revenue) |
seller_payable:{seller} | Liability | Money we owe the seller |
platform_fee_revenue | Revenue | Our commission |
psp_fee_expense:{psp} | Expense | Fees 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.
| Component | Responsibility | Scales by |
|---|---|---|
| Payment service | Idempotency, state machine, journal creation, outbox writes | Stateless replicas |
| Outbox relay | Read unpublished outbox rows in order, call PSP router or publish events, mark done | Partitioned by payment_id hash |
| PSP router and adapters | Map our operations to each PSP's API, normalise responses and decline codes | Stateless |
| Webhook receiver | Verify signatures, deduplicate, translate into state transitions | Stateless |
| Reconciliation job | Import settlement reports, match, open cases | Batch, 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:
- 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.
- 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.
- 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:
| Event | Account | Debit | Credit |
|---|---|---|---|
| Capture | psp_receivable:psp_a | 45.99 | |
| Capture | seller_payable:acct_s_211 | 41.39 | |
| Capture | platform_fee_revenue | 4.60 | |
| Settlement | bank_cash:main | 45.64 | |
| Settlement | psp_fee_expense:psp_a | 0.35 | |
| Settlement | psp_receivable:psp_a | 45.99 |
Check: capture ; settlement . 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
versionis 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_revenuereceive a posting for almost every payment. They are split into Example: 16 sub-accounts chosen by hash ofpayment_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_atset to when the event happened.
7.6 Reconciliation
| Step | What happens | Output |
|---|---|---|
| Import | Fetch PSP settlement and fee reports and bank statements daily | Normalised report lines |
| Match | Match each line to a ledger entry by PSP reference, amount and currency | Matched pairs |
| Classify | Unmatched lines become cases: missing in ledger, missing at PSP, amount mismatch, timing | Reconciliation cases |
| Resolve | Finance or automation posts adjusting entries with a case ID | Closed cases |
| Report | Daily reconciliation rate and ageing of open cases | Close 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
| Concern | Approach | Limit before redesign |
|---|---|---|
| Write rate | Single primary, batched postings, monthly partitions | Example: ~20 000 postings/s on current hardware |
| Hot accounts | Sub-account sharding | One sub-account at ~1 000 postings/s |
| PSP latency | Async capture and refund via outbox; inline only for authorise | PSP rate limits |
| Reporting load | Read replica and daily snapshots for finance queries | [Limit] |
| Growth beyond one primary | Shard by merchant with cross-shard entries via clearing accounts | Documented, not built |
Latency budget for create-and-authorise (target p99 2.5 s):
| Step | Budget |
|---|---|
| Idempotency check and intent commit | 30 ms |
| PSP authorisation (p99) | 2 000 ms |
| Result commit with journal and outbox | 40 ms |
| Network and serialisation | 50 ms |
| Headroom | 380 ms |
9. Reliability, security and operations
9.1 Failure modes
| Failure | Impact | Detection | Mitigation |
|---|---|---|---|
| PSP timeout after charging | Unknown outcome | Pending payments older than 60 s | Retry with PSP idempotency key; query by reference; webhook resolves |
| PSP outage | Payments fail | Error rate per PSP | Circuit breaker; route new payments to secondary PSP |
| Duplicate webhook | Double transition attempt | Dedup table hits | Dedup by PSP event ID; transitions are idempotent |
| Database primary failure | Writes stop | Health checks | Synchronous replica promotion; RPO 0 |
| Outbox relay stuck | Events and captures delayed | Oldest unpublished row age | Alert at 2 min; relay restarts; rows are not lost |
| Unbalanced entry attempted | Bug in posting logic | Constraint violation | Transaction rejected; alert to on-call |
| [Failure] | [Impact] | [Signal] | [Mitigation] |
9.2 Security and abuse
| Threat | Control |
|---|---|
| Card data exposure | Tokenisation via PSP hosted fields; no PAN in logs, enforced by log scrubbing tests |
| Forged webhooks | Signature verification with PSP secret, timestamp tolerance, allowlisted source where offered |
| Insider ledger tampering | Append-only grants, four-eyes approval for manual adjusting entries, hash chain |
| Card testing fraud | Velocity limits per card fingerprint, device and IP; risk scoring before authorise |
| Refund abuse | Refund limited to captured minus refunded; manual review above threshold |
| [Threat] | [Control] |
9.3 Observability
| Signal | Metric or log | Alert threshold |
|---|---|---|
| Authorisation success rate | Per PSP, method and country | Drop of 5 points vs. 7-day baseline |
| Pending payments | Count older than 5 min | Above 50 |
| Ledger balance check | Nightly recompute mismatches | Any |
| Reconciliation rate | Matched lines / total | Below 99.9% |
| Outbox age | Oldest unpublished row | Above 2 min |
9.4 Cost estimate
| Item | Unit cost | Quantity | Monthly 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
| Decision | Options | Chosen | Why | What we give up |
|---|---|---|---|---|
| Ledger location | Separate ledger service; same database as payments | Same database, separate schema | Atomic state change, journal and outbox in one transaction | Harder to share ledger with other products later |
| Exactly-once | Distributed transactions (2PC); outbox + idempotent consumers | Outbox | No coordinator, works with PSPs that cannot join transactions | Consumers must deduplicate |
| Balances | Computed on read; materialised with version | Materialised | Fast balance checks for refunds | Hot-row contention, needs sub-accounts |
| Money type | Decimal; float; integer minor units | Integer minor units | Exact, portable, fast | Care with currencies of 0 or 3 decimals |
| Card entry | Own card form (full PCI scope); PSP hosted fields | Hosted fields | Smallest PCI DSS scope | Less UI control |
| Event-sourced ledger only | Ledger as sole source of truth; state tables + ledger | State tables + ledger | Simpler queries for operations | Two representations to keep consistent (same transaction) |
| [Decision] | [Options] | [Chosen] | [Why] | [Cost] |
11. Delivery plan and risks
11.1 Milestones
| Milestone | Scope | Exit criterion | Target date |
|---|---|---|---|
| M1 | Payments with one PSP, idempotency, state machine | Zero duplicates in chaos test with injected timeouts | YYYY-MM-DD |
| M2 | Double-entry ledger and balances | Nightly balance check clean for 30 days in shadow mode | YYYY-MM-DD |
| M3 | Reconciliation | 99.9% automatic match for one month | YYYY-MM-DD |
| M4 | Secondary PSP routing | Failover drill passes | YYYY-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
| ID | Risk | Likelihood | Impact | Mitigation | Owner |
|---|---|---|---|---|---|
| R-01 | Example: PSP lacks idempotency on refunds | Medium | High | Query-before-retry; manual queue for unknown outcomes | [@name] |
| R-02 | Example: Finance and engineering disagree on account semantics | Medium | High | Chart of accounts sign-off before M2 | [@name] |
| R-03 | Example: Hot-account contention at peak | Low | Medium | Sub-accounts; load test at 5x | [@name] |
| [R-nn] | [Risk] | [L / M / H] | [L / M / H] | [Mitigation] | [@name] |
12. Appendices
12.1 Open questions
| ID | Question | Owner | Due |
|---|---|---|---|
| OQ-01 | Example: When is seller revenue recognised: capture or delivery? | [@finance] | YYYY-MM-DD |
| OQ-02 | Example: Retention period per jurisdiction? | [@compliance] | YYYY-MM-DD |
| [OQ-nn] | [Question] | [@name] | YYYY-MM-DD |
12.2 Glossary
| Term | Meaning |
|---|---|
| PSP | Payment service provider that processes cards and wallets |
| Authorisation | Reservation of funds on the payer's account |
| Capture | Instruction to collect authorised funds |
| Journal entry | One business event in the ledger, made of balanced postings |
| Posting | A signed amount on one account within a journal entry |
| Outbox | Table of pending messages written in the same transaction as the change |
| [Term] | [Meaning] |
12.3 References
| Reference | Use |
|---|---|
| ISO 4217 currency codes | Currency field and minor units |
| PCI DSS (PCI Security Standards Council) | Scope reduction in 7.7 |
| RFC 9457 Problem Details for HTTP APIs | Error 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.
관련 템플릿
Database Design Document Template (ERD and Physical Model)
Database design: DBMS choice, conceptual, logical and physical models, indexes, partitioning, integrity, security, backup, capacity
아키텍처 및 시스템 설계Chat System Design Template (Reference Design)
Real-time 1:1 and group chat: WebSocket gateways, per-conversation ordering, fan-out, receipts, offline sync, push, E2EE
아키텍처 및 시스템 설계Collaborative Editing System Design Template (Reference Design)
Real-time co-editing: OT vs CRDT, sequence CRDT, state-vector sync, presence, offline merge, snapshots and compaction, access