# 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.

<!--
Guidance: How to use this template
- Fill 2.2 with your money-correctness rules before anything else: "the ledger
  always balances", "no double charge", "every PSP movement is reconciled".
  They drive every other choice.
- Keep the chart of accounts (5.3) small and reviewed by finance. Engineers
  should not invent account semantics alone.
- Treat the PSP as an unreliable, eventually consistent peer: every call can
  time out after succeeding. Section 7.3 shows how to survive that.
- Never update or delete ledger rows. Corrections are new reversing entries.
-->

## Document control

<!-- Guidance: A design is a proposal until it is approved; record who decided and when. -->

| 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

<!-- Guidance: Note what changed in the design, not the wording. -->

| 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

<!-- Guidance: Keep aligned with the headings. -->

1. [Overview](#1-overview)
2. [Requirements](#2-requirements)
3. [Capacity estimation](#3-capacity-estimation)
4. [API design](#4-api-design)
5. [Data model](#5-data-model)
6. [High-level design](#6-high-level-design)
7. [Detailed design](#7-detailed-design)
8. [Scalability and performance](#8-scalability-and-performance)
9. [Reliability, security and operations](#9-reliability-security-and-operations)
10. [Trade-offs and alternatives](#10-trade-offs-and-alternatives)
11. [Delivery plan and risks](#11-delivery-plan-and-risks)
12. [Appendices](#12-appendices)

## 1. Overview

<!-- Guidance: One page that a busy reviewer can read alone: problem, proposed solution in a paragraph, and the decisions that need their attention. -->

### 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

<!-- Guidance: Functional requirements describe behaviour; non-functional requirements describe qualities with numbers. Mark what is out of scope so nobody designs for it. -->

### 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

<!-- Guidance: Back-of-the-envelope numbers decide the shape of the design. Show inputs, formulas and results; round generously and say which way you rounded. -->

### 3.1 Assumptions

| Input | Symbol | Value | Source |
|---|---|---|---|
| Payments per day | $N$ | *Example:* 10 million | Business forecast |
| Peak to average factor | $k$ | *Example:* 5 (sales events) | Last peak season |
| Journal entries per payment lifecycle | $j$ | *Example:* 3 (authorise, capture, settle) | 7.4 |
| Postings per journal entry | $q$ | *Example:* 3 on average | Chart of accounts |
| Stored posting row incl. indexes | $s$ | *Example:* 250 bytes | Schema in 5.2 |
| Retention | $Y$ | *Example:* 10 years | Finance policy |

### 3.2 Throughput

$$
QPS_{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:

$$
P_{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

$$
S_{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 $t_{psp} \approx 800$ ms, concurrent in-flight
calls at peak are, by Little's law:

$$
L = \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

| 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

<!-- Guidance: Define the contract first: resources, methods, request and response shapes, errors, idempotency, pagination, rate limits and versioning. Use RFC 9457 problem details for errors. -->

### 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:

```http
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
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: `/v1` path; money fields never change type within a version.

## 5. Data model

<!-- Guidance: Choose storage by access pattern, not by habit. Show entities, keys, partitioning and the queries each store must answer. -->

### 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.

```mermaid
erDiagram
  PAYMENT {
    string payment_id PK
    string order_id
    bigint amount_minor
    string currency
    string state
    int version
  }
  PSP_ATTEMPT {
    string attempt_id PK
    string payment_id FK
    string psp
    string operation
    string psp_reference
    string status
  }
  IDEMPOTENCY_KEY {
    string client_id PK
    string idem_key PK
    string request_hash
    string response_body
    datetime expires_at
  }
  JOURNAL_ENTRY {
    string entry_id PK
    string payment_id FK
    string kind
    datetime effective_at
    string reverses_entry_id
  }
  POSTING {
    string posting_id PK
    string entry_id FK
    string account_id FK
    bigint amount_minor
    string currency
  }
  ACCOUNT {
    string account_id PK
    string type
    string normal_side
    bigint balance_minor
    int version
  }
  OUTBOX {
    bigint id PK
    string topic
    string payload
    datetime published_at
  }
  PAYMENT ||--o{ PSP_ATTEMPT : has
  PAYMENT ||--o{ JOURNAL_ENTRY : produces
  JOURNAL_ENTRY ||--|{ POSTING : contains
  ACCOUNT ||--o{ POSTING : receives
```

### 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

<!-- Guidance: One diagram that shows every component on the request paths and where state lives. Name components by responsibility. -->

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

```mermaid
flowchart LR
  client([Checkout client]) -->|card data| hosted[PSP hosted fields]
  hosted -->|token| client
  client --> orders[Order service]
  orders --> pay[Payment service]
  pay --> db[(Payments + ledger DB)]
  db --> relay[Outbox relay]
  relay --> router[PSP router]
  router --> pspA[PSP A adapter]
  router --> pspB[PSP B adapter]
  pspA --> extA([PSP A])
  pspB --> extB([PSP B])
  extA -->|webhooks| hook[Webhook receiver]
  hook --> pay
  relay --> bus[[Event bus]]
  bus --> orders
  recon[Reconciliation job] --> db
  extA -->|settlement files| recon
```

| 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

<!-- Guidance: Go deep on the parts that are new, risky or contested. Show the sequence of calls and the failure behaviour of each step. -->

### 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.

```mermaid
sequenceDiagram
  participant O as Order service
  participant P as Payment service
  participant D as Payments + ledger DB
  participant R as PSP router
  participant X as PSP
  O->>P: POST /payments (Idempotency-Key)
  P->>D: insert idempotency key (unique) + payment(created) + outbox(authorize)
  D-->>P: committed
  P->>R: authorize(payment_id as PSP idempotency key)
  R->>X: authorise request
  alt PSP responds
    X-->>R: authorised, psp_reference
    R-->>P: result
    P->>D: tx: state=authorized, attempt, memo entry, mark outbox done, store response
    P-->>O: 201 authorized
  else timeout
    P-->>O: 202 pending
    Note over P,X: Outbox relay retries with the same PSP idempotency key, or the webhook resolves the state
  end
```

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.

```mermaid
stateDiagram-v2
  [*] --> created
  created --> requires_action: 3-D Secure challenge
  requires_action --> authorized: challenge passed
  created --> authorized: PSP approved
  created --> failed: declined or error
  requires_action --> failed: challenge failed or expired
  authorized --> captured: capture
  authorized --> voided: void or auto-void after 7 days
  captured --> settled: settlement report matched
  captured --> partially_refunded: partial refund
  settled --> partially_refunded: partial refund
  partially_refunded --> refunded: remaining amount refunded
  captured --> refunded: full refund
  settled --> refunded: full refund
  settled --> disputed: chargeback notice
  disputed --> settled: dispute won
  disputed --> refunded: dispute lost
  failed --> [*]
  voided --> [*]
  refunded --> [*]
```

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:

| 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 $45.99 - 41.39 - 4.60 = 0$; settlement $45.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

| 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

<!-- Guidance: Show how the design meets the numbers in section 3 and where it will break first. -->

| 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

<!-- Guidance: Name the failure modes, the threats and the signals operators watch. Each mitigation should be testable. -->

### 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

<!-- Guidance: For each major decision list the options, what each costs and why you chose one. Reviewers will propose the alternatives anyway; answer them here. -->

| 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

<!-- Guidance: How the design reaches production safely, and what could stop it. -->

### 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

<!-- Guidance: Supporting detail, glossary and open questions. -->

### 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.
