Posting rules — <Acme Wallet>
Copy to
docs/POSTING-RULES.md. This table is the heart of the system: it is the complete mapping from business event to journal entry, and it is written before any code.
How to use it
Read this section once, then never again — but do read it once.
- Whoever owns the books reviews and signs this table before implementation starts. Not the design doc, not the PR — this table. An account or a rule added later without the finance owner's sign-off is a control failure (M11), not a refactor.
- One row per
(event, condition)pair. If an event books differently depending on a condition, that is two rows with the same rule id and differentConditioncells — not one row with a sentence of prose in it. - Every row is implemented by exactly one function, named after the rule id, and tested by a golden entry (§ How to test a rule). A rule with no golden entry is not implemented, whatever the code coverage says.
- Amount expressions are exact.
total − Σ partsis an expression; "the remainder" is not.
Conventions
| Convention | Rule |
|---|---|
| Sign | Storage is debit-positive signed integers in minor units. Presentation flips the sign for credit-normal accounts (asset/expense are debit-normal; liability/equity/income are credit-normal). The presentation convention never reaches storage. |
| Balance | Every rule produces an entry where Σ debits = Σ credits, per entry and per currency (M2). A rule that cannot balance per currency needs an FX position account as the bridge — see FX-01. |
| Multi-leg | A rule with more than two legs lists each on the same line separated by ·. Three legs is normal; netting them into one is not (M16). |
| Currency | Every leg carries an ISO 4217 code. There is no "default currency" and no leg without one (M4). |
| Dimensions | [account_id], [merchant_id] on control-account legs. A control-account leg with no dimension is a bug that surfaces as an unallocatable balance. |
| Amounts | Integer minor units, at the currency's ISO 4217 exponent. Never a float, never a bare number (M4). |
| Memo accounts | 9xxx accounts are off-balance-sheet memoranda (authorisation holds). They balance like any other entry (M2) but are excluded from the statutory trial balance. |
| Safeguarding | Client money is held per currency: 1050 (EUR) / 1051 (GBP) / 1052 (USD) balance = Σ(2000 + 2010) for that same currency, checked daily on confirmed bank balances (M6, M9). Never a cross-currency sum, and never a total. SG-01/SG-02 are what keep the identity true; nothing else may move value in or out of a safeguarding account. |
| Timing | E = event time, B = booking time, P = accounting period, derived from E by the cutoff rule (M10). |
| Reversal | Corrections are reversing entries citing the original entry id (M3). No rule in this table is ever implemented with an UPDATE. |
The entry envelope
Every entry produced by every rule carries these fields alongside its legs. They are not metadata — they are what makes an entry explainable a year later, and each one is load-bearing for an invariant. A field you cannot populate is a rule you have not finished designing.
| Field | Example | Why it exists |
|---|---|---|
entry_id |
<01JB4K…> |
Immutable identity; cited by any reversal (M3) |
rule_id, rule_version |
PU-02, 1.2 |
An entry you cannot attribute to a rule version is unexplainable at audit |
event_time |
<2026-09-08T23:47:11Z> |
When it happened, in the source's zone (M10) |
booking_time |
<2026-09-09T00:02:04Z> |
When you recorded it — makes an "as of" report reproducible |
period |
<2026-09> |
Derived from event_time by the cutoff rule; never from booking_time (M10) |
idempotency_key |
purchase:capture:pur_8812 |
Unique index; a replay returns the original entry (M7) |
source_event_id |
<adyen:8836…> |
The provider's id, deduped before posting (M8) |
actor |
<service:payments> / <user:m.duarte> |
Audit trail; also how you prove automation did not approve (M11, M14) |
narrative |
<Capture pur_8812, order ord_4471> |
What a human reads at 02:00 during an incident |
reverses_entry_id |
<null> / <01JB3…> |
Present only on reversals; corrections are never edits (M3) |
fx |
<{mid: "0.8500", src: "<provider>", ts: "…", spread_bps: 75}> |
Present on any conversion; a rate without a source and timestamp is not a rate (M6) |
The rules
Fill the <…> cells for your product; the rows below are a complete, working set for a
wallet-and-marketplace product and are meant to be edited, not admired. Account codes refer to
accounts/chart-of-accounts.yaml.
| Rule | Event | Trigger / source | Condition | Debit | Credit | Amount expression | Ccy | Timing (M10) | Idempotency key (M7) | Reversal rule (M3) | Notes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| TU-01 | Top-up authorised | PSP webhook AUTHORISATION |
Always | 9000 Memo — card auths | 9010 Memo — auth offset | auth_amount |
Txn | E: auth ts · B: on webhook · P: from E | topup:auth:{topup_id} |
Released by TU-02 or TU-03 | An approved auth is not cash and not revenue — a hold on someone else's account (M13). No balance-sheet movement. |
| TU-02 | Top-up captured | PSP webhook CAPTURE |
Capture ≤ auth amount | 1200 PSP receivable · 9010 Memo — auth offset | 2000 Wallet available [account_id] · 9000 Memo — card auths |
capture_amount; memo legs at auth_amount |
Txn | E: capture ts · B: on webhook · P: from E | topup:capture:{topup_id}:{seq} |
RF-01 (refund) or CB-01 (chargeback) | Releases the TU-01 memo in the same entry. {seq} supports multi-capture. Money in the PSP balance is a receivable, not cash. |
| TU-03 | Top-up failed | PSP webhook REFUSED / auth expiry / void |
No capture occurred | 9010 Memo — auth offset | 9000 Memo — card auths | auth_amount |
Txn | E: refusal/expiry ts · B: on webhook · P: from E | topup:fail:{topup_id} |
None — terminal | Failure still posts: an unreleased memo hold is how a phantom exposure survives to the close. |
| PU-01 | Purchase authorised (hold) | Purchase API | available ≥ amount, checked and held in one transaction |
2000 Wallet available [account_id] |
2010 Wallet reserved [account_id] |
purchase_total |
Wallet | E: request ts · B: same · P: from E | purchase:hold:{purchase_id} |
Reversed on cancel or hold expiry (<24h>), same legs swapped |
M13. Never read-then-write: two concurrent spends both pass a read-then-check. Holds expire on a declared schedule and release explicitly. |
| PU-02 | Purchase captured | Fulfilment confirmed | Hold exists and is unexpired | 2010 Wallet reserved [account_id] |
2200 Merchant payable [merchant_id] · 4000 Platform fee income · 2100 VAT payable |
merchant_share = total − commission; vat = commission − round(commission / (1 + rate)); fee_net = commission − vat |
Wallet | E: fulfilment ts · B: same · P: from E | purchase:capture:{purchase_id} |
RF-01 | Worked: total 3000, commission 300 incl. 20% VAT → 2200 CR 2700 · 4000 CR 250 · 2100 CR 50. Fee and VAT are their own legs (M16). |
| FE-01 | PSP fee | Settlement report fee row | One row per fee type | 5000 PSP processing · 5010 Interchange · 5020 Scheme fees | 1200 PSP receivable | fee_amount per row, by fee_type |
Settle | E: PSP fee date · B: on ingest · P: from E | psp_fee:{balance_transaction_id} |
Provider adjustment posts a new entry, never an edit | Fees are never netted into TU-02 (M16) — netting makes "what did payments cost this month?" unanswerable. Classify every row before summing any file. |
| TX-01 | VAT on PSP fee | Settlement report / supplier invoice | Domestic supplier, VAT charged | 1300 VAT receivable | 1200 PSP receivable | fee_net × vat_rate, rounded once (M5) |
Settle | E: invoice date · B: on ingest · P: from E | psp_fee_vat:{invoice_id}:{line} |
Credit note posts a new entry | Output VAT on our commission is a leg of PU-02, not here. |
| TX-01b | VAT on PSP fee | Settlement report / supplier invoice | Cross-border, reverse charge | 1300 VAT receivable | 2100 VAT payable | fee_net × vat_rate |
Settle | as TX-01 | psp_fee_vat_rc:{invoice_id}:{line} |
as TX-01 | Nets to zero in cash but must appear on both sides of the return. Place-of-supply is a tax-engine question, not a table you write (see human decisions). |
| RF-01 | Refund | Refund API / PSP webhook | Full or partial | 2200 Merchant payable [merchant_id] · 4000 Platform fee income · 2100 VAT payable |
2000 Wallet available [account_id] |
Original legs allocated pro-rata by allocate(refund_amount, [merchant_share, fee_net, vat]) (M5) |
Txn | E: refund ts · B: on webhook · P: from E | refund:{purchase_id}:{refund_request_id} |
None — a refund is itself the correction | Key on the request id, not the amount: two identical refunds are legitimate. The provider's refund fee is its own entry — RF-02. |
| RF-02 | Refund fee | Refund confirmed / settlement report fee row | Provider charges a per-refund fee | 5030 Cost of payments — refund fees | 1200 PSP receivable | refund_fee |
Settle | E: PSP fee date · B: on ingest · P: from E | refund_fee:{refund_id} |
Provider adjustment posts a new entry, never an edit | RF-01 returns the customer's money; this is what the provider charges you for doing it. Its own entry and its own account — netted into RF-01 it silently makes refunds look free (M16). |
| CB-00 | Chargeback provision raised or released | Period-end close run | Always, at close, from the dispute cohort model | 5100 Chargeback losses | 2350 Chargeback provision | movement = expected_loss(period) − provision_balance_before; a negative movement posts the same two legs swapped |
Func | E: period end · B: close run D+3 · P: = E | cb_provision:{period} |
Superseded by the next period's movement, never edited (M3) | The only rule that raises the provision, and with CB-01c the only one that debits 5100. The rate and basis are an accounting estimate owned by finance (human decisions #4), never a constant in code. Without this row, 2350 is debit-only and 5100 credit-only, and CB-03 books a gain. Consumed by CB-03. |
| CB-01 | Chargeback received | PSP webhook NOTIFICATION_OF_CHARGEBACK |
Wallet balance ≥ disputed amount | 2000 Wallet available [account_id] · 5110 Chargeback fees |
1200 PSP receivable | disputed_amount; fee leg at scheme_fee |
Settle | E: dispute ts · B: on webhook · P: from E | chargeback:{dispute_id}:received |
CB-02 if won | Claw back where you can. The dispute fee is a separate leg and is not refunded on a win. |
| CB-01b | Chargeback received | as CB-01 | Wallet balance < disputed amount and recovery will be pursued | 2000 Wallet available [account_id] · 1220 Customer receivable [account_id] · 5110 Chargeback fees |
1200 PSP receivable | clawback = wallet_available; shortfall = disputed_amount − clawback; fee leg at scheme_fee |
Settle | as CB-01 | as CB-01 | CB-02 if won | A wallet driven to a debit balance is a credit product built by accident: claw back what is there, and carry the shortfall as a receivable (1220) — never as a negative wallet. No loss yet. Recovered, or written off later via WO-01 (→ 6100). |
| CB-01c | Chargeback received | as CB-01 | Wallet balance < disputed amount and recovery not pursued (account closed, below the chase threshold, or the loss is accepted) | 2000 Wallet available [account_id] · 5100 Chargeback losses · 5110 Chargeback fees |
1200 PSP receivable | clawback = wallet_available; loss = disputed_amount − clawback; fee leg at scheme_fee |
Settle | as CB-01 | as CB-01 | CB-02 if won | This is the loss event — the row that puts value into 5100. Whether to pursue is a declared finance threshold applied at triage, not a code default. CB-03 then consumes the provision against this loss. |
| CB-02 | Chargeback won | PSP webhook CHARGEBACK_REVERSED |
Representment successful | 1200 PSP receivable | 2000 Wallet available [account_id] and/or 1220 Customer receivable and/or 5100 Chargeback losses |
disputed_amount, credited back to whichever accounts the original entry debited, in the same amounts |
Settle | E: reversal ts · B: on webhook · P: from E | chargeback:{dispute_id}:won |
None | Reverses the principal legs of CB-01, CB-01b or CB-01c — crediting exactly the accounts that entry debited — and cites that entry id (M3). The 5110 fee leg stays: you paid it either way. |
| CB-03 | Chargeback lost | PSP webhook CHARGEBACK final |
A loss was recognised for this dispute (CB-01c) and provision_balance > 0 |
2350 Chargeback provision | 5100 Chargeback losses | min(provision_balance, loss_recognised), where loss_recognised is the 5100 amount booked for this dispute by CB-01c |
Settle | E: final ts · B: on webhook · P: from E | chargeback:{dispute_id}:lost |
None — terminal | Consumes the provision raised at CB-00 against the loss booked at CB-01c, so the estimate and the actual are not both expensed. If there is no provision, the loss simply stays where CB-01c put it and this rule posts nothing. It never posts where CB-01 or CB-01b clawed the value back: there is no loss to relieve, and crediting 5100 then recognises a gain out of a dispute you lost. |
| FX-01 | FX conversion with spread | Conversion API | Rate age ≤ <26h>; else fail closed |
2000 Wallet available [account_id, EUR] · 2900 FX position [GBP] |
2900 FX position [EUR] · 2000 Wallet available [account_id, GBP] · 4100 FX spread income [GBP] |
sell = amount_eur; mid_gbp = round(amount_eur × mid); customer_gbp = round(amount_eur × mid × (1 − spread_bps/10000)); spread = mid_gbp − customer_gbp |
Both | E: quote ts · B: same · P: from E | fx:{account_id}:{conversion_request_id} |
Reversal posts both currency legs; never one side alone | Worked: 10000 EUR, mid 0.8500, 75 bps → EUR legs 10000/10000; GBP legs DR 8500, CR 8436 + CR 64. Balances per currency (M2); 2900 is the bridge because no entry may mix currencies without one (M6). Record rate, source, timestamp and spread on the conversion event. |
| SE-01 | PSP settlement instructed | PSP payout notification / balance report | The PSP has instructed a payout of its balance to our bank | 1500 Settlement in transit | 1200 PSP receivable | settlement_amount — the provider's own figure, with fees left where FE-01 booked them |
Settle | E: PSP payout ts · B: on ingest · P: from E | psp_settlement:{psp_payout_id} |
Reversed on a returned settlement, citing the original (M3) | Between SE-01 and SE-02 the money is in neither the PSP balance nor the bank; 1500 is what keeps it visible. Never net fees in here — FE-01 books them as their own rows (M16). |
| SE-02 | PSP settlement received | camt.053 line matched | Bank credits the settlement | 1000 (EUR) / 1001 (GBP) / 1002 (USD) Cash at bank — the account for [ccy] |
1500 Settlement in transit | credited_amount |
Settle | E: value date · B: on ingest · P: from E | psp_settlement_received:{psp_payout_id} |
None | Clears 1500 for that payout to exactly zero. A residual is the break — and it is Fee not booked far more often than Amount mismatch (runbook §8). |
| PO-01 | Payout initiated | Payout batch | Approved per the matrix; KYC complete; no open hold | 2200 Merchant payable [merchant_id] |
1510 Payouts in transit | payable_balance at cutoff, less withheld reserve |
Payout | E: instruction ts · B: same · P: from E | payout:{merchant_id}:{period}:{seq} |
PO-03 | Never key on amount — the amount changes on recompute and a re-keyed payout pays twice (M7). Automation initiates; a human approves (M11). |
| PO-02 | Payout settled | camt.053 line matched | Bank confirms debit | 1510 Payouts in transit · 6000 Bank charges | 1000 Cash at bank | payout_amount; charge leg at bank_charge |
Payout | E: value date · B: on ingest · P: from E | payout_settle:{payout_id} |
None | "Sent" ≠ "settled". Between PO-01 and PO-02 the money is in neither account — 1510 is what keeps it visible. |
| PO-03 | Payout failed / returned | Return file (pain.002 / camt.054) | Instruction rejected or returned | 1510 Payouts in transit or 1000 Cash at bank | 2200 Merchant payable [merchant_id] or 1510 Payouts in transit |
returned_amount |
Payout | E: return ts · B: on ingest · P: from E | payout_return:{payout_id}:{return_ref} |
None | Restores the payable. Never blind-retry: re-instruct under a new idempotency key after confirming the original did not land. |
| SG-01 | Client funds swept into safeguarding | Daily safeguarding sweep, one run per currency | Σ(2000 + 2010) for [ccy] > confirmed safeguarding balance for [ccy] |
1050 (EUR) / 1051 (GBP) / 1052 (USD) Safeguarded client funds — the account for [ccy] |
1000 (EUR) / 1001 (GBP) / 1002 (USD) Cash at bank — the account for [ccy] or 1200 PSP receivable, where the PSP pays direct into the safeguarding account |
deficit = Σ(2000 + 2010)[ccy] − safeguarded_balance[ccy] |
Per ccy | E: sweep instruction ts · B: on bank confirmation · P: from E | safeguard:sweep:{ccy}:{date}:{seq} |
SG-02 is the opposite movement; a failed sweep reverses on the return file, citing the original (M3) | Nothing else in this table moves value into 105x. Until the bank confirms, the money is still in 1000/1200 — the daily identity is checked on confirmed balances, not on instructions. Per currency, always (M6, M9): the identity is 105x = Σ(2000 + 2010) in that currency and is never a total. |
| SG-02 | Earned funds swept out of safeguarding | as SG-01 | Confirmed safeguarding balance for [ccy] > Σ(2000 + 2010) for [ccy] |
1000 (EUR) / 1001 (GBP) / 1002 (USD) Cash at bank — the account for [ccy] |
1050 (EUR) / 1051 (GBP) / 1052 (USD) Safeguarded client funds — the account for [ccy] |
excess = safeguarded_balance[ccy] − Σ(2000 + 2010)[ccy] |
Per ccy | as SG-01 | safeguard:release:{ccy}:{date}:{seq} |
SG-01 | No commingling in either direction: our own fee income may not sit in the safeguarded pool, and client money may not sit in the operating account. Releasing more than excess is a safeguarding breach, not an overdraft — fail closed if excess cannot be computed for the currency rather than sweeping a guess. |
| IN-01 | Interest accrual | Nightly job | Safeguarded balance > 0 | 1250 Interest receivable | 4300 Interest income | round(principal × rate × days / 360) — ACT/360, rounded once at posting (M5) |
Func | E: accrual date · B: nightly · P: = E | interest:{account}:{accrual_date} |
Reversed when actual interest is received | Accrual is recalculated, never copied from last month. Whether interest passes through to customers is a policy decision — see human decisions. |
| SU-01 | Unexplained movement parked in suspense | Reconciliation triage (runbook §4) | Value has left an account and cannot yet be attributed | 1900 Suspense — unreconciled | 1000 (EUR) / 1001 (GBP) / 1002 (USD) Cash at bank — the account for [ccy] or 1200 PSP receivable or 1500 Settlement in transit |
unexplained_amount |
Per ccy | E: statement value date · B: on triage · P: from E | suspense:{break_id} |
Cleared by the reclassification entry when the item is identified, or by WO-01 | The only rule that puts value into 1900; WO-01 and reclassification are the only ways out. Suspense is where value waits with a named owner and an age (M9), never where it hides: nothing enters without a break id, the balance oscillates near zero, and monotonic growth is an incident, not a backlog. An unexplained receipt is the mirror and does not come here — it credits 2400 Unapplied receipts, never revenue. |
| WO-01 | Write-off | Break resolution | Approved at the threshold for the amount (M11, M12) | 6200 Recon write-offs or 6100 Bad debt | 1900 Suspense or 1220 Customer receivable | break_amount |
Break | E: approval ts · B: same · P: open period | writeoff:{break_id} |
Reversed on later recovery, citing the original | The entry carries the break id in its narrative. Never buried in revenue or cost of payments (M16). Write-off volume is a reported metric — growth means reconciliation is failing. |
| RD-01 | Rounding residue | Allocation or FX conversion | abs(residue) ≤ 1 minor unit × n_parts |
6900 Rounding difference | Counter-account of the allocation | total − Σ allocated_parts |
Ccy of the total | E: = the originating event · B: same · P: from E | rounding:{source_entry_id} |
Reversed with the source entry | Should be zero when allocate() is used (M5). A residue larger than the cap means an allocation bypassed the helper — alert, do not book. |
Worked example: one customer, end to end
Run your own rule set through a sequence like this before you write code. If the trial balance does
not tie by hand, it will not tie in production — and you will be debugging it against a customer.
EUR, integer minor units, user:42 tops up 100.00 and spends 30.00 with merch:7.
| # | Rule | Event | Debit | Credit |
|---|---|---|---|---|
| 1 | TU-01 | Top-up authorised 100.00 | 9000 Memo auths 10 000 | 9010 Memo offset 10 000 |
| 2 | TU-02 | Top-up captured | 1200 PSP receivable 10 000 · 9010 Memo offset 10 000 | 2000 Wallet user:42 10 000 · 9000 Memo auths 10 000 |
| 3 | FE-01 | PSP fee 2.00 | 5000 PSP processing 200 | 1200 PSP receivable 200 |
| 4 | PU-01 | Purchase 30.00 authorised | 2000 Wallet user:42 3 000 |
2010 Reserved user:42 3 000 |
| 5 | PU-02 | Purchase captured, commission 3.00 incl. 20% VAT | 2010 Reserved user:42 3 000 |
2200 Payable merch:7 2 700 · 4000 Fee income 250 · 2100 VAT payable 50 |
| 6 | SE-01 | PSP settles 98.00 (instructed) | 1500 Settlement in transit 9 800 | 1200 PSP receivable 9 800 |
| 7 | SE-02 | Settlement lands on the statement | 1000 Cash at bank 9 800 | 1500 Settlement in transit 9 800 |
| 8 | PO-01 | Merchant payout 27.00 instructed | 2200 Payable merch:7 2 700 |
1510 Payouts in transit 2 700 |
| 9 | PO-02 | Payout settles, bank charge 0.20 | 1510 Payouts in transit 2 700 · 6000 Bank charges 20 | 1000 Cash at bank 2 720 |
Trial balance after step 9
| Account | Debit | Credit |
|---|---|---|
| 1000 Cash at bank | 7 080 | |
| 5000 Cost of payments — PSP processing | 200 | |
| 6000 Bank charges | 20 | |
| 2000 Customer wallet — available | 7 000 | |
| 2100 VAT payable | 50 | |
| 4000 Platform fee income | 250 | |
| Total | 7 300 | 7 300 |
Everything else nets to zero: 1200, 1500, 1510, 2010, 2200 and both memo accounts are
transit and hold accounts that must clear — a residual balance in any of them at this point is the
bug, and it is much cheaper to find here than in month three.
Read the result: you hold 70.80 in cash, owe 70.50 (70.00 to the customer, 0.50 to the tax authority),
and the 0.30 difference is profit — fee income 2.50 less PSP cost 2.00 less bank charge 0.20. Every
one of those numbers is a query over postings, not a stored field (M1). Steps 6 and 7 exist because
between them the 98.00 is in neither the PSP account nor the bank; without 1500 you either lose
sight of it or book the bank credit before it happened.
The sequence stops one step short on purpose. In production a tenth step runs the same evening:
SG-01 moves 70.00 out of 1000 into 1050, because that is the customer's money and it may not sit
in the operating account. 1000 is then left holding 80 — the 0.50 owed to the tax authority and the
0.30 of margin, both ours — and the daily control holds exactly: 1050 EUR 7 000 = Σ(2000 + 2010)
EUR 7 000. Run your own sequence to that point, not to step 9, or you will ship a set of rules that
balances beautifully and safeguards nothing.
Rule versioning and replay
Posting rules change. The question is never "can we change it" but "what happens to the entries the old rule produced". A bad answer edits the rule and reruns the job.
| Requirement | How |
|---|---|
| Version on the rule | Every rule has a version; every entry stores (rule_id, rule_version) it was produced by. An entry you cannot attribute to a rule version is unexplainable at audit. |
| Effective dating | A rule change takes effect from a declared effective_from event time, never "from deploy". Two versions coexist while events straddle the boundary. |
| Never retroactive | A new version does not re-post historical events. Entries already booked stay booked (M3); if the old treatment was wrong, that is a correction programme with reversing entries and a named approver, not a redeploy. |
| Replay is additive | Replaying a source event under the same idempotency key must produce no new posting (M7). Replay exists to fill gaps, not to rebuild the ledger. |
| Closed periods | A replay whose events fall in a closed period posts to the earliest open period with the original event date recorded (M10). It never reopens a period. |
| Change control | Rule changes are reviewed by the finance owner, versioned in git, and the diff is the audit evidence (M14). |
Change log
| Version | Date | Rule(s) | Change | Effective from | Approved by |
|---|---|---|---|---|---|
<1.2> |
<2026-08-14> |
<FE-01> |
<Split blended PSP fee into 5000 / 5010 / 5020> |
<2026-09-01 events> |
<M. Duarte> |
<1.1> |
<2026-06-02> |
<PU-02> |
<VAT computed as residual of the inclusive commission, not as a separate multiplication> |
<2026-06-02> |
<M. Duarte> |
How to test a rule (M18)
A golden entry is the rule's specification in executable form: a fixed input event, and the exact entry it must produce, byte for byte. If a code change alters an entry, the diff appears in the test before it appears in the books.
Every rule needs all four:
| Test | What it asserts | Failure means |
|---|---|---|
| Golden entry | Fixed input → exact expected legs (account, dimension, signed minor amount, currency) |
The rule's meaning changed. Someone must say so deliberately. |
| Balance property | For any valid input, Σ debits = Σ credits per currency (M2) | The rule can create or destroy money. |
| Idempotency | Applying the rule twice with the same key produces one entry and returns the original outcome (M7) | Retries double-post. |
| Reversal | Rule then reversal leaves every touched account at its opening balance | The correction path is broken, which is discovered during an incident. |
def test_PU_02_captures_purchase_with_inclusive_vat():
"""Golden entry: total 30.00 EUR, commission 3.00 incl. 20% VAT."""
entry = apply_rule("PU-02", purchase(total=3000, commission=300, vat_rate="0.20",
account_id="user:42", merchant_id="merch:7"))
assert entry.legs == [
Leg("2010", "user:42", +3000, "EUR"), # debit wallet reserved
Leg("2200", "merch:7", -2700, "EUR"), # credit merchant payable
Leg("4000", None, -250, "EUR"), # credit fee income, net of VAT
Leg("2100", None, -50, "EUR"), # credit VAT payable
]
assert sum(l.minor for l in entry.legs) == 0 # M2, single currency
assert entry.rule_version == "1.2"Plus, in CI on every build: a trial-balance assertion over the whole fixture ledger (Σ debits =
Σ credits per currency), and python3 scripts/audit_ledger.py fixtures/entries.csv --fail-on warn.
Rules that need a human decision
Do not resolve these in code. Propose, cite the reasoning, get the finance owner's sign-off, and
record the answer in FINANCIAL-SYSTEM-SPEC.md §10 until it is signed.
| # | Question | Why it is not an engineering call | Owner | Status |
|---|---|---|---|---|
| 1 | On a refund (RF-01), is the platform commission refunded pro-rata, or retained? | Changes revenue recognition and the customer contract | <Finance owner> |
<open> |
| 2 | Is the PSP fee borne by the platform (5000) or passed to the merchant (reduces 2200)? | Changes the merchant's economics and the contract | <Finance owner> |
<decided: platform, 2026-07-02> |
| 3 | Principal or agent — is the merchant's share ever our revenue (gross) or never (net)? | IFRS 15 judgement; it moves the top line by an order of magnitude | <Finance owner + auditor> |
<open> |
| 4 | Chargeback provisioning: rate, basis, and when the provision is released | An accounting estimate, not a constant | <Finance owner> |
<open> |
| 5 | Does interest on safeguarded balances belong to Acme (4300) or pass through to customers (2000)? | Contractual and regulatory, possibly licence-conditional | <Finance + Compliance> |
<open> |
| 6 | VAT place-of-supply for cross-border commission | Specialist domain — wire in a tax engine, never improvise a rate table | <Tax adviser> |
<open> |
| 7 | Write-off thresholds and the batch-resolution cap for sub-materiality breaks | A control limit, set by finance and enforced by the system (M12) | <Controller> |
<open> |