Description
Ledgers & Balance Management
The CoreBanq Ledger System is a high-performance, double-entry accounting engine. It serves as the "source of truth" for all financial positions, providing real-time balance tracking, hierarchical aggregation, and administrative holds.
Purpose and use
Ledgers record the bank's financial truth: customer money, bank money, fees, suspense, settlement, holds, and control balances. Every balance-affecting operation should be explainable from ledger entries and their roll-up into the chart of accounts.
Who uses this. Finance controllers, operations staff, reconciliation teams, auditors, treasury users, and support teams use ledgers to answer balance, posting, safeguarding, and audit questions. Regulatory reporting teams use the same subledger structure to prepare safeguarding figures, and product teams use it to decide where a new money flow should be booked.
How it works. Customer-facing accounts map to subledgers. Subledgers roll into control ledgers. Ledger entries debit and credit those ledgers, while holds adjust available balance without changing posted balance until settlement occurs.
What users do. Users review ledger hierarchy, inspect balances, place or release holds, post journal entries through controlled flows, and trace a transfer or fee back to its accounting entries.
Outcomes and side effects. Ledger postings update posted and local-currency balances, create audit evidence, and feed account balances, statements, reconciliation, and reporting. Holds affect spendable balance and must be released or settled through traceable operations.
See also:
Object Relationships
The system follows a strict hierarchical and relational model to ensure data integrity and auditability.
Hierarchy Breakdown
- Control Ledgers: Represent categories in the Chart of Accounts (e.g., "Public Customer Deposits"). They aggregate balances from children but never hold individual customer funds directly — a control ledger that has any subledger under it stops accepting direct postings.
- Subledgers: An account or a customer can own several subledgers, one per purpose (Current, Hold, Fees receivable, Interest accrual, Collateral) and currency — see Why one customer has several subledgers below. Every account still gets its Current subledger automatically at opening; the rest open lazily, the first time each is actually needed.
- Customer Account: The user-facing entity. The account balance is a projection of its Current subledger specifically — the account never changes shape when a customer also picks up a Hold or Fees receivable subledger elsewhere.
Monetary Amount Format
All monetary amounts in API responses use the CcyAmtWithPrecision format. This self-describing format expresses amounts in minor/atomic units (e.g., cents for EUR, satoshi for BTC) as a string, alongside the currency code and precision.
{
"amount": "123456",
"currency": "EUR",
"precision": 2
}| Field | Type | Description |
|---|---|---|
amount | string | Integer in minor units (e.g., "123456" = 1234.56 EUR). |
currency | string | ISO 4217 code for fiat or asset code for crypto. |
precision | int | Number of decimal places (e.g., 2 for EUR, 8 for BTC, 0 for JPY). |
To convert to a display value: displayAmount = amount / 10^precision.
This format is used for all balance, amount, and monetary fields including: balance_posted, balance_available, balance_pending, balance_lcy, overdraft_limit, total_amount, amount, amount_lcy, debit, credit, etc.
Balance Management Concepts
CoreBanq distinguishes between different balance states to support complex banking operations like "authorized but not settled" payments and administrative freezes.
Balance Types
| Type | Calculation | Description |
|---|---|---|
| Posted Balance | Sum(Credits) - Sum(Debits) | The settled, legally recognized balance. |
| Pending Balance | Posted + Expected Future Entries | Includes entries that are authorized but not yet value-dated. |
| Available Balance | Posted - Sum(Active Holds) - Pending Debits | The "spendable" amount. This is what the user sees as their current buying power. |
The available-balance formula above is canonical and normative. It has exactly one
implementation, AvailableBalance in , and every
sufficiency gate reads it through CheckSufficientFunds. Pending credits deliberately do
not raise available: money that has not settled is not spendable.
balance_available is maintained by delta as entries and holds move. It is never assigned
the posted balance outright — doing so would set available == posted and erase every active
hold on the ledger. DeriveAvailableBalance recomputes it from the source rows, and the daily
consistency scan (OPS job ledgers.balance_consistency_scan) compares the two and reports any
divergence.
Overdraft
overdraft_enabled alone does not permit an unbounded debit. The spendable amount is
available + overdraft_limit, and a debit beyond it is rejected with
ledgers.insufficient_funds and HTTP 402.
When a ledger stops accepting postings
Two states close a ledger to new entries, and both are rejected with HTTP 409:
| State | Condition | Message code |
|---|---|---|
| Deactivated | active = false | ledgers.ledger_inactive |
| Period closed | closing_date is set and has passed | ledgers.ledger_closed |
A closing_date in the future is a planned closure and does not block anything yet.
This applies to every path that posts to a ledger: the direct entry endpoint
(POST /v1/ledgers/entries), journals, memo posts and holds. The direct entry endpoint is itself
switchable: with ledgers.allow_legacy_direct_posting off it refuses every request with 400
ledgers.legacy_direct_posting_disabled and points at POST /v1/ledgers/journals. Transfers are checked at
initiation, where all three ledgers — source, transit and destination — must be postable.
Completion is deliberately not re-checked: it settles money that initiation already moved onto
transit, so refusing it because the destination closed in between would strand the amount with
every retry failing. See the transfers documentation.
A transfer moves the amount as it stands: nothing on this path converts it, so both ledgers must be
in the same currency. A source and a destination denominated differently are refused at initiation
with ledgers.currency_mismatch and HTTP 400 — before the source is debited — because the only way
to settle such a pair here would be at an implied rate of 1.0. A ledger carrying no currency at all is
refused with ledgers.transfer_currency_missing, which names both sides so the caller can see which
one to fix. perform_conversion on the request is accepted and ignored; no conversion is implemented.
Which transit ledger the amount is parked on comes from configuration, one ledger code per ISO
currency under ledgers.transit_ledgers.{CCY} — for example ledgers.transit_ledgers.CHF. The
currency in the key is matched however either side spelled it: the ledger's stored code is trimmed and
upper-cased, and a setting written in lower case is still honoured. That setting is read once, at
initiation, and the resulting ledger is recorded on the transfer as
transit_ledger_id, together with the amount's base-currency value, fixed at initiation from the source ledger's currency through the FX rates. Completion books that same value on the transit and destination ledgers, so the transit ledger's base-currency balance nets to zero over the two requests; a rate that moved in between is picked up by FX revaluation of the balances, not by the settlement. Completion settles off the recorded ledger rather than reading the setting
again, so a setting that changes between the two requests cannot leave the parked amount behind on a
ledger nothing will clear. A currency with no code configured is refused with
currencies_m.missing_currency_info and HTTP 400, and a configured code naming no existing ledger
with ledgers.ledger_not_found and HTTP 404. A configured transit ledger that is denominated in another
currency, or carries none at all, is refused with ledgers.transit_ledger_currency_mismatch or
ledgers.transit_ledger_currency_missing and HTTP 422 — both name the ledger and say the fault is the
configuration rather than the request, because the caller chose neither.
The configured ledger also has to be denominated in the currency it is configured for. Nothing in the
database enforces that, so it is checked before the amount is parked: a CHF transfer whose
ledgers.transit_ledgers.CHF names a EUR ledger is refused with
ledgers.transit_ledger_currency_mismatch and HTTP 422, naming the ledger and both currencies. That
is a configuration defect rather than a bad request — the caller cannot correct it — and refusing at
initiation is what keeps the amount from being parked where completion would then refuse to release it.
Field names
| Field | Meaning | Where |
|---|---|---|
balance_posted | Posted balance | Ledger, ledger balance history |
balance_pending | Pending balance | Ledger |
balance_available | Available balance | Ledger, ledger balance history |
balance_lcy | Posted balance in the base currency | Ledger, ledger balance history |
balance_current | Deprecated alias of balance_posted | Ledger balance history only |
One name means one balance across every ledger endpoint. balance_current is the single
legacy exception: it carries the posted balance in the balance-history response and is still
emitted alongside balance_posted for existing consumers. New integrations should read
balance_posted.
Balance Holds
Holds are administrative or system-driven restrictions on a ledger's available balance. Holds do not decrease the posted balance; they only reduce the spendable amount until they are released or expire.
Hold Scenarios:
- Card Authorizations: A merchant authorizes $50. A hold of $50 is placed.
- Administrative Hold: Compliance department freezes $10,000 pending verification.
Not implemented. Two capabilities were previously documented here that the service does not provide, and integrations must not be designed around them:
- Hold settlement. A hold cannot be converted into a debit entry. Release the hold and post the debit as two separate operations.
- Negative holds. A hold amount must be positive — enforced by the service and by a
CHECK (amount > 0)constraint onledgers.holds. Buying power cannot be raised with a negative hold; useoverdraft_enabledandoverdraft_limit.
Releasing a hold is idempotent: releasing one that is already released succeeds without restoring the balance a second time.
Balance Monitoring
Balance monitoring watches a control ledger's posted balance against a ladder of limits and raises an alert — or flags a suspension — the moment a posting carries the balance across one of them. The Audax configuration uses it for the licence-relevant figure: public customer deposits, which is control group 221 — Customer Deposits (Available) – Type A Customer (every Type A customer, every currency, rolled up in the base currency).
Who uses it. Compliance and risk officers receive the alerts; finance and operations read the same limits as lines on the deposit chart of the Admin UI home page and the Configurator dashboard.
How it works.
- The monitored ledgers and their limits are part of the ledgers configuration (
balance_monitoring, one block per ledger code). Each rule has an order, a limit in minor units, a comparison (at or above / at or below), an action (alert or suspend) and the alert recipients and e-mail template. - The check runs on every posting that touches a monitored ledger, including postings on a subledger that roll up into it. Rules are evaluated from the most severe order downwards; the first one the new balance satisfies is the level reached, and an alert is raised only when that posting is the one that crossed it — a balance that stays above a limit does not alert again on every further posting. The alert is queued together with the posting and sent right after the posting is booked, so a posting is never delayed or failed by the notification. Every rule must name its e-mail recipients; a rule without them, or with an unknown comparison, action or alert type, is rejected when the configuration is read.
- Customer deposits are a liability with a credit (positive) balance, so their rules read "at or above 75 000 000.00". A ledger that aggregates several currencies (221 sums CHF and EUR children) or is kept in a foreign currency is compared on its base-currency balance; a base-currency leaf on its own posted balance. The chart plots the same figure. Details in Balance monitoring.
- A changed limit applies within about thirty seconds on every instance, without a restart or re-seed. A configuration that cannot be read keeps the last good ladder; if there is none, monitoring is off and the error is logged until the configuration is fixed.
The Audax deposit ladder (all figures CHF, on ledger 221):
| Order | Limit | Action |
|---|---|---|
| 4 | 75 000 000.00 | Alert compliance |
| 3 | 85 000 000.00 | Alert compliance and risk |
| 2 | 95 000 000.00 | Alert compliance and risk |
| 1 | 100 000 000.00 | Suspend level — alert compliance and risk (refusing postings is not enforced yet; see Balance monitoring) |
The same limits are returned with the ledger's balance-history chart (see the chart endpoint below), so every screen that plots the balance draws the ladder from one source.
Anatomy of a Ledger Account
A ledger account (a "ledger") is one account in the book of record — the thing postings land on and balances accumulate against. A ledger is either a control ledger (a chart-of-accounts line that aggregates, e.g. "Customer Deposits — Type A") or a subledger (one customer's or one account's own leaf that rolls up into a control). The attributes below give each ledger its identity, its money behaviour, its place in the hierarchy, and — for a subledger — what it belongs to. Several attributes have their own deep section already; those are cross-referenced rather than repeated.
Who reads these attributes. Finance sets up and maintains the chart of accounts; operations open and monitor subledgers; compliance and audit read the same attributes to understand what a ledger is and what it may hold.
Identity and nature
| Attribute | Functional role |
|---|---|
| Code | The unique, human-readable account number — the chart-of-accounts identifier operators quote (e.g. "221"). One code, one ledger. |
| Description | The account's name as it reads on screens and reports. For a control ledger it can also carry the pattern used to name the subledgers opened under it — see Subledger description pattern. |
| Type | The account's accounting nature — asset, liability, equity, income or expense. It decides the account's normal side: whether a debit or a credit increases its balance, and which side of the balance sheet (or income statement) it belongs to. |
| Currency | The single currency the ledger keeps its money in. Every posting to the ledger is in this currency; the account's balances are expressed in it. |
| Icon / icon title | Presentation only — the symbol and label shown for the ledger in the UI. No accounting meaning. |
| Show on dashboard | Whether the ledger surfaces on the dashboard/home views. A reporting convenience, not a control. |
Balances and credit
The four balances and the overdraft facility each have their own section; the roles in brief:
| Attribute | Functional role |
|---|---|
| Posted balance | The official book balance — the sum of settled (posted) postings. See Balance Types. |
| Pending balance | The expected balance including postings recorded but not yet settled. |
| Available balance | What may actually be spent right now — posted, less holds, plus any overdraft headroom. |
| Base-currency balance | The account's balance restated in the institution's base currency, so a multi-currency book can be totalled and monitored in one currency. |
| Overdraft (enabled / limit) | Whether the ledger may go negative and by how much — the credit facility on the account. See Overdraft. |
Lifecycle and hierarchy
| Attribute | Functional role |
|---|---|
| Opening date | When the account was opened for posting. |
| Closing date | When the account was closed; while set, the ledger no longer accepts new postings — see When a ledger stops accepting postings. Empty for an open account. |
| Last activity date | The date the ledger last saw a posting — a dormancy/health signal for operations. |
| Parent ledger | The control ledger this one rolls up into (a subledger's parent, or a lower control's higher control). Empty at the top of a branch. |
| Child ledgers | The ledgers that roll up into this one — the aggregation that lets a control show the total of everything beneath it. See Object Relationships. |
Accounting behaviour flags
| Attribute | Functional role |
|---|---|
| Off balance sheet | The ledger is kept off the balance sheet (a memorandum/contingent account) rather than counting toward it. |
| Direct booking | Postings may be booked straight to this ledger, rather than only reaching it by rolling up from a subledger. |
| Revalue | The ledger's foreign-currency balance is periodically restated at current rates (FX revaluation), with the difference posted as a gain or loss — set for accounts held in a currency other than the base currency. |
| Rate source | Which exchange-rate source the ledger uses for its base-currency conversion and revaluation. |
| Export | Whether this ledger's movements are included in the exported accounting/reporting extracts. |
| Stream | Whether the ledger's entries originate from an external feed streamed in, rather than being booked in-house. |
Subledger anchoring (subledgers only)
A subledger is one customer's or one account's own leaf. These attributes say what it belongs to and what it is for; they are empty on a control ledger.
| Attribute | Functional role |
|---|---|
| Is subledger | Marks the ledger as an account-level leaf rather than a control line. |
| Account | The customer account this subledger belongs to (when the leaf is anchored to a specific account). |
| Customer | The customer this subledger belongs to (when the leaf is anchored at customer level). A subledger anchors to exactly one of an account or a customer. |
| Purpose | What this leaf holds for its owner — current (the everyday balance), hold (held/reserved funds), fees receivable, interest accrual, collateral. It is why one account or customer can own several subledgers, one per purpose, instead of one commingled balance. See Why one customer has several subledgers. |
| Control purpose | On a control leaf, the segregation purpose it routes — which purpose of subledger opens beneath it (empty means the everyday deposit/current control). Drives the customer-type routing editor. See Routing by customer type. |
| Customer types | On a control ledger, which customer types route their subledgers under it — the segregation rule that sends, say, every Type A customer's current subledger to control 221. |
| Description pattern | On a control ledger, the template used to name the subledgers auto-created beneath it. See Subledger description pattern. |
Record-keeping attributes (every ledger carries these)
Like every record, a ledger carries an identifier, created / modified timestamps and actors, and an active flag — all system-set, so every account and every change to it is attributable in the audit trail.
Anatomy of a Ledger Entry
A ledger entry is one posting line — a single debit or credit landed on one ledger. Entries are never booked alone: each one belongs to a transaction (a journal) whose lines must balance, debits equal to credits in every currency involved, so an entry is always one half of a larger, self-balancing movement (see Journal Transactions). Once posted, an entry is immutable — a mistake is corrected by a reversing entry, never by editing the original. Every field on an entry has a distinct functional role: some place it (which ledger, which journal), some carry the money (the amount, its base-currency equivalent, the running balance), some place it in time (booking timestamp versus value date), and some explain and govern it (event, description, status, metadata). This section describes each.
Who reads these fields. Finance and operations staff reconciling a ledger, compliance officers tracing a movement in an audit, and support staff answering a customer query all read the same entry — the field roles below are the shared vocabulary.
Placement — which ledger, which journal
| Field | Functional role |
|---|---|
| Ledger | The account this line posts to. An entry lives on exactly one ledger; the ledger's nature (asset, liability, equity, income, expense) decides whether a debit or a credit increases its balance. On the write side a ledger may be named by its code or its short numeric id instead of its full identifier — a convenience for operators, resolving to the same ledger. |
| Transaction (journal) | The balanced movement this line is part of. All the debit and credit lines that share one transaction are booked together, all-or-nothing, and together sum to zero per currency. When a posting is added to an existing journal the transaction is given; when it starts a new movement the system opens the transaction and attaches the line to it. |
The money — amount, base-currency equivalent, running balance
Every monetary figure on an entry is held in minor units (the smallest indivisible unit of the currency — centimes for CHF, and the equivalent for each asset) and is presented on the API as a value together with its currency and the number of decimal places, so no rounding is ever assumed by the reader. See Monetary Amount Format.
| Field | Functional role |
|---|---|
| Type | Whether the line is a debit or a credit. This is the accounting direction; combined with the ledger's nature it determines whether the posting raises or lowers the balance. Debits and credits must balance across the journal. |
| Amount | The value of the posting in the ledger's own currency. This is the figure that moves the ledger's balance. |
| Amount (base currency) | The same posting expressed in the institution's base (local) currency. It lets a multi-currency book be totalled, monitored and reported in one currency; for a base-currency ledger it equals the amount. |
| Balance | The ledger's running balance immediately after this line posted — the audit-grade "balance as at this posting". Because it is stamped on the entry, the balance at any past moment can be read back from the entry itself rather than recomputed. |
Time — when it was booked, when it takes effect
| Field | Functional role |
|---|---|
| Timestamp | When the entry was booked into the ledger (system/booking time). Drives ordering and the audit trail. |
| Value date | The date the posting takes economic effect — the date used for interest, for balance-as-at reporting and for value-dated reconciliation. It can differ from the booking timestamp (a value-date adjustment, a back-valued correction, a next-business-day settlement). On the write side, if no value date is supplied the current date is used. |
Explanation and governance — event, description, status, metadata
| Field | Functional role |
|---|---|
| Event | The business event that caused the posting — a short label naming why the line exists (a fee, a deposit, a transfer leg, a reversal). It groups related postings for search and reporting and ties the ledger movement back to the operational action that produced it. |
| Description | Free-text narrative for humans — what an operator or the customer sees against the line. It carries no processing meaning; it explains the entry. |
| Status | Where the entry stands in its lifecycle: posted (effective and counting toward the balance — the normal state), pending (recorded but not yet effective), or archived (retained for history, no longer active). Only posted entries move the live balance. |
| Metadata | A structured extra-data slot for information a specific flow needs to carry on the entry (references back to the originating instrument, routing hints, reconciliation keys) without adding a fixed field. It is descriptive, not a control the booking logic depends on. |
Record-keeping fields (every entry carries these)
Like every record in the platform, an entry also carries an identifier, created / modified timestamps and actors (who booked or last touched it, for the audit trail), and an active flag. These are set by the system, not by the person booking the entry, and exist so every posting is fully attributable.
A note on immutability. An entry's money and placement fields are written once, at posting, and never changed. Anything that looks like an "edit" — a correction, a reversal, a value-date change — is a new entry (or a new balancing journal), so the original line and the correction both stand in the history. This is what lets the ledger be trusted as the book of record.
API Reference
Ledger Management (V2)
GET /v1/ledgers
Returns a paginated list of ledgers. By default it lists Control Ledgers only (the Chart of Accounts view); subledgers are included only when explicitly requested.
GET /v1/ledgers/chart
Returns a balance-history time series for charting, not the Chart of Accounts tree. Each series is a list of {x, y} points (x = date, y = balance in major currency units) at the requested interval, taken from the daily balance snapshots. A ledger that aggregates other currencies, or is kept in a foreign currency, is plotted on its base-currency balance — the figure its monitoring ladder is compared with; a base-currency leaf on its posted balance. The ledgers to plot are selected with the required search.ledger_code.in query parameter (a comma-separated list of ledger codes); an optional color seeds the series colour. Requesting no valid codes is a validation error.
A series for a ledger that is under balance monitoring also carries its thresholds: the monitoring ladder in the same scale as the points (absolute major units, lowest limit first), each with its order and action, so a chart can draw the limit lines without knowing the configuration. The element is for drawing only — it does not carry the rule's comparison, which the monitor evaluates on the signed balance. Unmonitored ledgers carry no thresholds element.
The snapshot window is a half-open date range (search.snapshot_date.gte … search.snapshot_date.lt; .gt and .lte are accepted too); to include today's snapshot, end the range on tomorrow's date. The older start_date / end_date pair is still accepted and yields to the search.snapshot_date.* bounds when both are given.
Snapshots are not captured automatically yet: the series contains only the days generated on request with POST /v1/ledgers/history/generate (see Balance monitoring). Scheduled daily capture is an outstanding deliverable.
GET /v1/ledgers/{id}
Retrieve full details of any ledger (Control or Subledger) including current and available balances.
POST /v1/ledgers
Create a new Control Ledger. Note: Subledgers are managed automatically by the Account service.
PATCH /v1/ledgers/{id}
Change individual settings of a ledger — description, overdraft, dashboard visibility, direct booking, revaluation flag, and the control-ledger routing fields. Only the fields present in the request change; everything omitted keeps its value, so a single toggle (for example putting a ledger on the dashboard) is a one-field request. A status value is accepted for compatibility but not applied — a ledger has no status of its own; use the active flag.
Balance Holds (V2)
POST /v2/ledgers/holds
Place a new hold on a ledger. Decreases balance_available immediately.
GET /v2/accounts/{id}/holds
Retrieve all active holds for an account's subledger.
GET /v2/ledgers/holds/{id}
Get detailed information about a specific hold (reason, expiry, notes).
POST /v2/ledgers/holds/{id}/release
Release an active hold. Restores balance_available.
Technical Details
Automatic Subledger Creation
When a new Customer Account is created, the system executes an atomic transaction:
- Creates the Account record.
- Identifies the correct Control Ledger (Parent) based on currency and account type.
- Creates a Subledger linked to that parent.
- Updates the Account to reference this new Subledger.
Routing by customer type
Which control ledger a new account routes to is set directly on the control ledger's own properties — a Customer Types list (Corebanq Configurator → Ledger Browser → open the control ledger → "Customer Types"). Routing is resolved by purpose + customer type + currency: for each purpose (the everyday deposit account, and each segregation purpose such as pending settlement, compliance hold, or accruals for fees), a given customer type + currency routes to exactly one control ledger. The system rejects assigning the same purpose + type + currency to a second ledger, so routing can never become ambiguous — but the same customer type + currency can legitimately route to different control ledgers for different purposes (its deposits to one control, its fee accruals to another). Each control ledger manages the routing for its own purpose: editing "Customer Types" on the deposits control sets deposit routing, and editing it on the accruals-for- fees control sets fee-accrual routing, without the two colliding. This replaced an external configuration file — there is nothing to edit outside the ledger's own properties anymore.
Only a leaf control ledger — one with no child ledgers underneath it — can carry customer types. A parent or group ledger (e.g. the top-level "22" Customer Deposits group, or the "221" Type A Customer Deposits group) exists purely to aggregate its children's balances and can never itself receive an account booking, so it cannot carry a routing either; the Configurator hides the "Customer Types" control for such ledgers, and the API rejects the assignment if attempted directly. Set customer types on the specific currency leaf under the group instead (e.g. "2211" for a Type A customer's CHF deposits, not "221").
On a brand-new environment, the standard chart of accounts already ships with this routing —
the public and exempt customer-deposit control ledgers come pre-assigned to their customer types
when the chart is imported, so a fresh deploy can open accounts immediately without any manual
setup. Chart import is tracked per file in seed_history (hash-aware): a partial import retries on
the next boot, an edited chart file re-imports, and re-running a file only creates codes that are
missing — existing ledgers are never rewritten or deleted. Because of that create-only rule, boot
reconcilers re-align upgraded environments where the YAML gained fields after the row was first
imported: purpose-control routing is backfilled, and direct_booking is enabled (one-way,
false → true) on plain operational leaves whose seed declares direct_booking: true — never on
control ledgers, whose flag is runtime state owned by the subledger machinery. Transit ledgers are
not part of the chart: every code named in the ledgers.transit_ledgers.{CCY} config is
provisioned at boot if missing, with the currency taken from the config key.
Subledger description pattern
Subledgers are named automatically. By default, every control ledger produces descriptions like
"Customer Deposits Liabilities - Type A Customer (EUR) - Customer ABC Ltd" — the control ledger's
own description plus the customer's name — with nothing to configure. Admins only need to touch
this when one specific ledger should read differently: open that control ledger's own properties
(Corebanq Configurator → Ledger Browser → "Subledger Description Pattern") and set an override
template, e.g. {parent_description} (Direct Client: {customer_name}). Clearing the field returns
that ledger to the automatic default — there is no need to define the same pattern on every
ledger just to get sensible naming.
Supported tokens: {parent_description} (the control ledger's own description),
{customer_name}, {account_id_short}, {iban_suffix}. If an override references a token with
no value for the account being opened, the legacy "Account {id}" naming is used instead — a
partially rendered description is never posted to the books.
Why one customer has several subledgers
The Current subledger created above is only one of several a customer may end up with. Every franc, euro or dollar the bank holds for a customer lives in a subledger: an individual ledger account that belongs to exactly one customer (or one of their accounts) and rolls up into a bank-level control ledger. Regulation requires that client money be clearly identified in the bank's own accounting, per client — not merely as a pooled wallet total:
- PSD2 Article 10 (safeguarding): client funds must be identifiable in accounting records and never commingled with any other person's funds.
- FCA CASS 7.16.22E (individual client balance method): the firm keeps one balance per client, and may keep one per product or business line per client.
- FCA CASS 15 / PS25/12 (in force 7 May 2026): records must distinguish relevant funds held for each client, support a daily internal reconciliation, a daily external reconciliation against the safeguarding bank, and a D+1 segregation check; a per-customer balance list (resolution pack) must be producible within 48 hours and records retained for 5 years.
A single account balance cannot separate "money the customer can spend" from "fees the customer owes the bank" or "funds reserved under a hold". Each of those is therefore its own subledger, each rolling into its own control ledger, so both the customer view and the bank's financial statements stay precise — and the safeguarding requirement can be computed directly from the ledger.
The structure: one customer, several accounts, several purposes
A customer may hold several accounts in the same currency (for example a main account and a payroll account, both CHF). Every account has its own Current subledger — the one created automatically above. Purposes that concern the relationship rather than a single account — such as fees receivable — are kept once per customer and currency:
The Fees receivable subledger is an asset (what the customer owes the bank), so it must roll into an asset receivables control ledger — never into fee income control 311, which would combine a receivable balance with earned revenue. The current chart does not yet seed such a receivables control, so the roll-up above is shown dashed and no live flow books the fees-receivable purpose until one is added and mapped (see Rollout note).
Reading the picture:
- Account-level purposes (Current, Hold) attach to one account. Two same-currency accounts never share a Current subledger; each account's spendable and held money stays separately traceable.
- Customer-level purposes (Fees receivable, collateral) attach to the customer. What Acme AG owes the bank in fees is one figure regardless of which account eventually pays it.
- Control ledgers hold only summary balances. They are populated exclusively by the roll-up of their subledgers; manual journals against a control ledger are rejected. This mirrors long-standing core-banking practice and structurally prevents the most common cause of subledger-versus-general-ledger reconciliation breaks.
- The sum of all subledgers under a control ledger always equals the control ledger's balance. This invariant is what the daily internal reconciliation verifies.
Codes in these examples. Account codes shown here (e.g.
2211Type A CHF customer deposits,311Type A fee income) come from the standard Audax chart of accounts, grouped by leading digit: Group 1 Assets, Group 2 Liabilities, Group 3 Profit & Loss. The Fees receivable purpose is defined in the system, but the current chart does not yet seed a dedicated fees-receivable control ledger for its subledgers to roll into; until one is added, no live flow books to it (see Rollout note).
When each subledger is opened
Subledgers are opened lazily: only when the first booking of that purpose actually happens. Most customers never need a collateral subledger; none is created for them.
| Moment | What is opened | Anchored to |
|---|---|---|
| Account opening | Current subledger for the account's currency | The account |
| First administrative or card hold on an account | Hold subledger | The account |
| First fee charged to the customer in a currency | Fees receivable subledger | The customer |
| First interest-bearing product on an account | Interest accrual subledger | The account |
| First collateral pledge by the customer | Collateral subledger | The customer |
The customer-facing account is unchanged by any of this: the IBAN, statements and the primary spendable balance continue to work exactly as before. Purpose subledgers are the bank's internal precision, surfaced to operations and reporting.
Booking walkthrough 1 — a monthly fee
Fees are booked in two steps, and the split matters for safeguarding: the moment a fee is charged, that money stops being client money (the requirement drops), even though the cash may briefly remain inside the safeguarding boundary until swept.
Step 1 — Accrual (fee falls due; customer has not yet paid):
| Leg | Subledger | Effect |
|---|---|---|
| Debit | Fees receivable subledger (customer-level) | The bank's claim on Acme AG increases |
| Credit | Fee income 311 (Type A revenues) | Income is recognized |
Step 2 — Settlement (collection from whichever account holds funds):
| Leg | Subledger | Effect |
|---|---|---|
| Debit | Current 2211-a1b2c3d4 (Main account) | Customer's spendable money decreases |
| Credit | Fees receivable subledger | The claim is cleared |
Because the receivable is a real ledger line per customer, the fee queue, dunning and write-off decisions read directly from ledger balances — and the safeguarding calculation can exclude charged-but-unswept fees from the client-money requirement while the external reconciliation still sees the cash, exactly the classification the regulator expects.
If the customer holds several same-currency accounts, settlement may debit any funded account's Current subledger (primary first). The receivable side is unaffected: it is customer-level.
Booking walkthrough 2 — incoming funds and suspense
Safeguarding duties start when funds are received, not when they are allocated. An incoming
payment that cannot be applied immediately (unknown reference, name mismatch) is booked to an
inward suspense account (281 Suspense – IWT – SIC in the standard chart) — a bank-level
internal account, not a customer subledger — so the money is on the books from minute one:
Regulatory anchors for this flow:
- D+1 segregation check (CASS 15 / PSD2 Art. 10): funds received must reach a designated safeguarding account by close of the following business day. Because receipt is booked immediately, the daily D+1 check is a simple aging query over suspense and transit balances, with an audit trail of remediation postings.
- Suspense discipline: suspense accounts are open-item managed — every entry carries a reference and must be reversed by a matching entry, netting to zero per item. Aged-item reports over suspense are a standing operations control; long-outstanding items are the classic early-warning signal auditors look for.
- Individual client balances: the moment allocation happens, the money sits on the specific customer's Current subledger. The per-customer balance list required for the resolution pack is a direct ledger read, reproducible for any past business day from end-of-day balance snapshots.
Daily controls this structure enables
| Control | Frequency | What it checks |
|---|---|---|
| Internal reconciliation | Every business day | Sum of client-money subledgers (requirement) vs sum of safeguarding mirror accounts (resource); shortfalls topped up from own funds, excesses swept out, same day |
| External reconciliation | Every business day | Safeguarding mirror accounts vs the safeguarding bank's records |
| D+1 segregation check | Every business day | No client-attributable transit/suspense balance older than one business day |
| Suspense aging | Every business day | Open suspense items by age, with owner and deadline |
| Control roll-up check | Continuous | Each control ledger equals the sum of its subledgers |
Negative individual balances are never netted against other clients when computing the requirement; they are excluded and surfaced separately as a funding obligation.
Rollout note
Existing accounts already have their Current subledger; nothing changed for them when multi-purpose subledgers went live. Purpose subledgers open lazily, the first time each is actually needed — the first hold, the first payment in transit, the first compliance action — never in advance and never simply because a customer is activated. A customer who is active for years without ever triggering a hold or a compliance action never gets one.
The mechanism described above — several subledgers per account or customer, opened lazily, rolling into their own control ledgers, with a control ledger refusing direct postings the moment it owns any subledger — is live today. No live flow has been moved onto a non-Current purpose yet (no product books to Hold, Fees receivable, Interest accrual or Collateral in production), so day-to-day behaviour is unchanged until a specific flow is migrated onto one. Safeguarding pool classification, end-of-day snapshots and the daily reconciliation views described above arrive in a second phase.
Identification Priority
For all entry and transfer endpoints, you can identify ledgers using:
- UUID (
ledger_id): Most performant, immutable. - Code (
ledger_code): Human-readable (e.g.,2211-abc12345). - Numeric ID (
ledger_num_id): Legacy support.
The system resolves them in the order listed.
Sends the transaction to the configured crypto KYT provider and stores the verdict. Answers 201 Created even when the provider is still working — status PROCESSING or PENDING with no verdict fields — so 201 means "screening accepted", not "screening decided". IDEMPOTENCY. The key is taken from idempotency_key, then the X-Idempotency-Key header, then a hash derived from the request as the client sent it. That derivation happens BEFORE the handler's own tx_timestamp default, so the key is stable across an identical retry — but the PAYLOAD is not, because the default stamps the current clock. The provider hashes the payload against the key and answers a mismatch with a conflict, so an identical retry that omitted tx_timestamp earns a 409. Send tx_timestamp and the retry is safe. The tenant id comes from the kyt.xziel.tenant_id setting, not from the caller or the token. THE PROVIDER SERVICE IS MEMOISED, THE LICENCE CHECK IS NOT. GetCryptoKYTService caches the built service per driver name under a mutex and returns it unchanged thereafter, and loadXZielConfig runs only inside NewXZielService. So once xziel has been constructed successfully, later edits to kyt.xziel.base_url, api_key and timeout have NO EFFECT for the life of the process. tenant_id is the exception and is worse for it: the handler re-reads it on every request and stores it on the row, while the X-Tenant-Id header keeps using the memoised copy — so after an edit the tenant recorded against a screening and the tenant the provider was asked about disagree, and emptying the key breaks the route at once with 500 kyt_m.crypto_provider_not_configured. The contrast is with base_url, api_key and timeout, which the memo pins; the licence check also runs per request, before the memo is consulted at all. A FAILED build is not cached, so fixing a bad setting does take effect without a restart; changing a good one does not. IDEMPOTENCY IS THE PROVIDER'S, AND THE DEFAULT DRIVER HAS NONE. Everything below about replayed keys and the 409 describes XZiel. CryptoMockService.ScreenTransaction reads req.IdempotencyKey only to persist it and assigns a fresh uuid.New() every call, and kytCryptoRepository.GetByIdempotencyKey is called from nowhere — the handler's own comment says wiring it in would need scoping first, since its WHERE clause is the key alone. So on a seeded install two byte-identical requests carrying the same idempotency_key both answer 201 with DIFFERENT screening ids — the mock mints a fresh uuid each time. They do not leave two rows: a unique partial index on idempotency_key means the second insert violates it, and persist swallows that error. One row exists, holding the FIRST screening's id, and the second caller holds an id whose read answers 500 common.record_not_found. THE 201 DOES NOT GUARANTEE THE ROW EXISTS. Both drivers persist best-effort: persistScreening and the mock's persist log repo.Create's error and return, and the handler answers 201 with the verdict regardless. THREE classes of reachable loss, one of them entirely caller-controlled. (a) A customer_id or a currency the foreign keys fk_kyt_crypto_customer and fk_kyt_crypto_currency do not resolve. (b) The duplicate idempotency_key above. (c) COLUMN LENGTH: the request struct carries only required and oneof tags, no max, while the table bounds every string — network VARCHAR(50), transaction_id, wallet_address, tx_hash, external_ref_id and idempotency_key VARCHAR(255), currency VARCHAR(10). So a 51-character network passes validation, reaches the provider, is answered 201 with a verdict, and then fails the insert with 22001. Under xziel the same applies to values the PROVIDER supplies: status and provider_status and risk_score_label VARCHAR(50), kyt_flag VARCHAR(30), action VARCHAR(20). In each case a caller holds a screening_id from a 201 whose GET /v1/kyt/crypto/screenings/{screening_id} answers 500 common.record_not_found. (An amount with more decimal places than the column's scale is NOT one of them — NUMERIC(30,10) rounds to scale and overflows only above 10^20.) Treat the 201 body as the authoritative verdict and the stored row as a convenience. AND A REPLAY LEAVES THE TWO ROUTES DISAGREEING. mapTransactionResponse takes the screening id from the provider's response, and persistScreening uses it as the primary key, so a replayed key produces a duplicate-key insert that is swallowed. The stored row stays as first written while this route returns the provider's current verdict: replay a key whose screening moved from PENDING to COMPLETED and you get COMPLETED here and PENDING from the read-one route until the poller catches up. THE CONNECTOR RETRIES BEFORE YOU SEE ANYTHING. postJSON makes up to kyt.xziel.max_retries + 1 attempts — four by default — with jittered backoff. Retry-After is NOT clamped to five seconds: a longer delay than retryWaitMax aborts the loop with the last provider error, so a peer asking for thirty seconds produces a FAST 502 after one attempt. Nor does every 502 follow four calls — retryAdvice never retries 401 or 403, never retries a 429 without a Retry-After, and never retries xziel_m.provider_credentials_invalid at any status. Those follow exactly one. A retryable failure does take up to four, and can far exceed kyt.xziel.timeout. That setting is never named elsewhere in this document.
Ledger Balance Monitoring and Threshold Alerts
Next Page