Skip to content
Payment Emulator LabDocumentationOpen console

Runtime

Fake bank and accounting

The embedded ledger models the parts of gateway accounting needed by integrationtests. It is not a production bank: it holds no real money, full bank accountnumbers, PAN or CVV.

5 min read

Índice / Index · Español

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.ts and apps/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.

Payment Emulator Lab · RonuSoftwareMIT