The embedded ledger models the parts of gateway accounting needed by integration tests. It is not a production bank: it holds no real money, full bank account numbers, PAN or CVV.
#Why it exists
A payment gateway does not transfer a customer's wallet directly to a merchant. It receives or commits funds, clears them, keeps a fee, holds the merchant's net amount for a period and finally makes it withdrawable. Refunds and chargebacks must reverse those facts without corrupting the books.
apps/api/src/modules/ledger/ implements that behaviour once for every provider.
Provider plugins declare fee and timing numbers; they never write entries.
#Accounting primitives
- Account: belongs to one application and currency, with a debit or credit normal side.
- Entry: immutable economic event with a reference and metadata.
- Posting: positive minor-unit debit or credit belonging to an entry.
- Hold: commitment against a wallet for an authorized, not-yet-captured payment.
- Balance: derived from all postings for an account; never stored in a mutable column.
LedgerService.postWithEntityManager() refuses fewer than two postings,
non-positive amounts, mixed application/currency accounts and unequal debit and
credit totals before flushing.
#Chart of accounts
Accounts are provisioned on first use per application and currency by
apps/api/src/modules/ledger/accounts.ts.
| Code | Meaning | Normal side |
|---|---|---|
asset:cash:{currency} |
Cash inside the gateway; external payments enter here and withdrawals leave | Debit |
liability:wallet:customer:{id} |
Balance owed to a customer | Credit |
clearing:{provider}:{currency} |
Money in transit inside the gateway | Credit |
liability:reserve:merchant:{id} |
Captured merchant net still held | Credit |
liability:payable:merchant:{id} |
Released merchant money available to withdraw | Credit |
revenue:fee:{provider} |
Gateway fee revenue | Credit |
#Payment movements
| Event | Debit | Credit |
|---|---|---|
| Capture from balance | customer wallet | clearing |
| Capture from external method | system cash | clearing |
| Accreditation | clearing | merchant reserve + fee revenue |
| Settlement T+N | merchant reserve | merchant payable |
| Withdrawal | merchant payable | system cash |
| Refund reversal | merchant reserve/payable (+ fee when returned) | clearing |
| Refund payout | clearing | original wallet or system cash |
| Chargeback | merchant payable | clearing + dispute fee revenue |
A merchant balance may become negative after a refund or chargeback. Refusing to record that debt would make the ledger look safer while becoming less truthful. Only withdrawals enforce sufficient payable balance.
#Authorization and capture
A manual-capture payment reaches authorized and creates a LedgerHoldEntity
against the payer wallet. No money moves yet. Capture closes the hold as captured
and posts the normal capture/accreditation entries; cancellation releases it.
Available wallet balance is the posting-derived balance minus active holds.
#Settlement
On capture, releaseAt is calculated from the effective provider ledger profile.
The worker calls SettlementService.runDue() every 60 seconds by default. A
control-plane caller can move the test clock through
POST /applications/{applicationId}/ledger/run-settlement with an asOf value.
Each release takes a PostgreSQL advisory lock and re-reads the intent inside the transaction, so overlapping workers cannot release the same reserve twice.
#Provider defaults and administered overrides
Plugins declare fee.percentBasisPoints, fee.fixedMinor, releaseDays,
refundReturnsFee, disputeFeeMinor, currencies and a default currency.
GatewaysService may override only the accounting numbers and currencies for an
installation. refundReturnsFee remains behaviour and cannot be edited through
the database.
#Banks
banks is the catalogue of institutions an account can be opened at. A bank
carries three identifiers with three jobs, and they are not interchangeable:
| Field | For |
|---|---|
id |
The opaque reference the foreign key points at (bnk_…) |
code |
What a human, a seed or a test writes: banco-ribera |
prefix |
The head of every account number the bank issues |
code and prefix are identity, not data: neither appears in UpdateBankDto.
The prefix in particular cannot be edited because an account stores only its
last four digits — the prefix is not recoverable from anything written down,
so changing it would rewrite what every existing account claims about itself
with nothing left able to contradict it.
An account number is checked against its bank once, at the only moment the whole
number exists: it must start with the prefix and be accountNumberLength long.
There is deliberately no check digit — a developer has to be able to type a
number by hand, and a checksum would make every hand-typed one invalid. Both the
generator behind POST /banks/:id/account-number and the check that accepts a
new account come from modules/banks/account-number.ts, so they cannot drift.
The starter catalogue ships in the migration rather than the seed, because
bank_accounts.bank_id is not null: a migrated installation with no banks has
a form nobody can submit. Its marks are drawn in the console
(app/assets/images/banks/), not stored in the API — the same split that keeps a
provider's behaviour here and its visual identity in the checkout.
Writing is administrator-only; reading is open to every role, because choosing
where your account is means seeing the list. DELETE /banks/:id disables rather
than deletes, and the foreign key is restrict.
#External bank accounts
bank_accounts are counterparties owned by users, not ledger accounts. Each one
belongs to a bank. Funding and withdrawal entries reference its ID, its bank's id
and name, and the last four characters in metadata — the name as a snapshot, so
renaming a bank later does not rewrite what an old withdrawal says. Movement
history is derived from ledger entries rather than duplicated in a second
transaction log. Deleting through the API closes an account so old entries keep a
valid reference.
Neither the bank nor the number can be edited afterwards: moving an account to another institution is not an edit, it is a different account, and the number that was checked against the old prefix no longer exists to check against a new one.
#Where to make changes
- Generic balanced posting engine:
ledger.service.ts. - Gateway money movements:
gateway-ledger.service.ts. - Account codes/normal sides:
accounts.ts. - Timed release:
settlement.service.tsandapps/api/src/worker.ts. - Control-plane views/withdrawals:
ledger.controller.ts. - External account ownership:
modules/bank-accounts/. - The catalogue of institutions and the shape of their numbers:
modules/banks/. - Provider fee/timing declarations:
src/providers/<id>/<id>.plugin.ts.
Never write ledger_entries or ledger_postings directly, add a mutable balance
column or duplicate bank movements outside the ledger.
#Tests
apps/api/test/ledger-invariants.spec.ts uses fast-check for fee and posting
invariants. test/integration/gateway-ledger.spec.ts, bank-accounts.spec.ts,
banks.spec.ts, checkout.spec.ts and provider conformance cover real database flows,
concurrency, holds, settlement, refunds and withdrawals.