What it is
Each app has a ledger accessible as db.ledger from any function, separate
from the collections you create. Every entry is a signed amount in minor units
against a kind, party, and currency. Balances are maintained as entries post,
clear, void, or reverse. Reading a balance loads those values without summing
the entries that produced them.
kind and category
Kinds and categories are declared once, outside the request path. A module resolves each name to its integer index at load time and keeps the number:
const ledger = db.ledger;
const HOST = ledger.kind('HOST');
const SALE = ledger.category('SALE');
ledger.kind(name) and ledger.category(name) look up the name on each call.
Run them once at module load instead of per request. Other methods take the
resolved integer rather than the name.
post
ledger.post(entries) takes an array of 1 to 1000 plain objects and returns
{ outcome: 'POSTED' | 'EXISTING', ids: [text] }. A refusal throws
LedgerRefused with the error and the array index of the first entry that failed.
Each entry object's members:
| member | required | type | refusal name |
|---|---|---|---|
key |
no | string | KEY_INVALID |
kind |
yes | number (what ledger.kind(name) answered) |
KIND_REQUIRED |
party |
yes | string | PARTY_REQUIRED |
currency |
yes | string, three upper-case letters | CURRENCY_REQUIRED, or CURRENCY_INVALID from the write itself |
amount |
yes | integer, minor units, signed | AMOUNT_INVALID |
type |
yes | number, ledger.type.CREDIT to ledger.type.TRANSFER_OUT (REVERSAL is never posted directly) |
TYPE_INVALID |
status |
no | number, ledger.status.PENDING or ledger.status.CLEARED (VOIDED is never posted directly); absent leaves PENDING |
STATUS_INVALID |
category |
no | number (what ledger.category(name) answered); absent leaves none |
CATEGORY_INVALID |
reference |
no | string | REFERENCE_INVALID |
scope |
no | string | SCOPE_INVALID |
org |
no | string | ORG_INVALID |
counterparty |
no | string | COUNTERPARTY_INVALID |
clear_at |
no | integer, milliseconds | CLEAR_AT_INVALID |
external_ref |
no | string | EXTERNAL_REF_INVALID |
description |
no | string | DESCRIPTION_INVALID |
data |
no | plain object, not an array; serialized to its own JSON text once, never interpreted | DATA_INVALID |
key is an idempotency key. Posting the same key again returns EXISTING
with the same ids without writing a second time. An empty or absent key
always creates a fresh post.
Beyond per-member validation, the write itself can still refuse with
ACCOUNT_KIND_UNKNOWN (kind is not a declared index), CATEGORY_UNKNOWN
(category is set and not a declared index), AMOUNT_ZERO, NOT_BALANCED
(a balanced post whose amounts in one currency do not sum to zero), or
WRITE_FAILED (thrown as LedgerWriteFailed, not LedgerRefused).
const HOST = ledger.kind('HOST');
const result = ledger.post([
{ kind: HOST, party: 'acct_demo_1', currency: 'USD', amount: 500, type: ledger.type.CREDIT },
]);
// result -> { outcome: 'POSTED', ids: ['...'] }
balance
ledger.balance(kind, party, currency) requires the kind returned by
ledger.kind, and string values for party and currency. It returns:
ledger.balance(HOST, 'acct_demo_1', 'USD');
// -> { pending, cleared, pending_rows, cleared_rows }
pending and cleared are the maintained signed totals for that status;
pending_rows and cleared_rows are the entry counts behind each total.
A fourth argument, org (a non-empty string), returns the account's balance
for that organization, counting entries whose org matches it. An entry
posted without org counts in the account balance and in no organization's
balance. undefined is equivalent to the three-argument call. An empty string
or a value that is not a string throws TypeError.
ledger.balance(HOST, 'acct_demo_1', 'USD', 'org_demo_1');
Neither form reads an entry. Over HTTP the same
narrowing is the org query value of GET /v1/_ledger/balance.
topBalances
ledger.topBalances(currency, n) returns the n (1 to 1000) (kind, party)
accounts with the largest summed balance in currency, largest first. Accounts
without that currency are omitted rather than returned with a zero balance. Over HTTP it is
GET /v1/_ledger/balances/top?currency=USD&limit=10.
accounts
ledger.accounts(kind, currency) returns every (kind, party) account with
that currency as { party, amount }, in no particular order. The amount is
pending plus cleared; voided entries do not count. A party without the currency
is omitted rather than returned with a zero balance. No entries are read, and
there is no limit or sort. topBalances covers every kind, sorts the results,
and caps them at n. Over HTTP, where kind is the catalog name rather than
the resolved index, use
GET /v1/_ledger/accounts?kind=HOST¤cy=USD.
ledger.accounts(HOST, 'USD'); // -> [{ party, amount }, ...]
totals
scope_totals, reference_totals, all_totals, and party_totals are
optional maintained aggregates. They group totals by category, type, and status
for a scope, a reference, the whole ledger, or the parties of an account kind.
The first three also group by account kind. Declare each aggregate independently
with ledger.declareTotals(by), using ledger.by.SCOPE (the default),
ledger.by.REFERENCE, ledger.by.ALL, or ledger.by.PARTY. ledger.by.ORG
throws TypeError. The declaration is idempotent and returns 'DECLARED' the
first time and 'EXISTING' afterward.
An undeclared aggregate adds no work to posts, status changes, or balance reads.
Each declared aggregate adds one maintained update to each post and status
change. Read ledger.by.PARTY through accountTotals, described below.
ledger.totals accepts only ledger.by.SCOPE, .REFERENCE, or .ALL.
ledger.totals(value, currency, by) answers every one of that scope's,
reference's or (for ledger.by.ALL, value null or undefined: one ledger
has only one all_totals) the whole ledger's groups in that currency, with no
entry read: [{ account_kind, category, type, status, amount, rows }], category left out of a group whose entries never named
one, account_kind/category the catalog's own indices and type/status
the numbers of ledger.type and ledger.status (as get/entries answer
them, never a name; over HTTP type and status are their names). An
unknown value, or a currency it never posted in, answers []; reading before
declaring throws LedgerRefused named TOTALS_NOT_DECLARED. Over HTTP: POST /v1/_ledger/totals/declare with body { "by": "scope" | "reference" | "all" | "party" }
(no body or no by is scope; anything else is 400 BY_INVALID; { outcome }; the CLI's
substrio op ledger.totals.declare by=reference) and GET /v1/_ledger/totals?scope=¤cy=,
?reference=¤cy= or ?all=true¤cy= (exactly one of
scope/reference/all=true; { totals }, 409 TOTALS_NOT_DECLARED before declaring).
accountTotals
ledger.accountTotals(kind, currency) reads party_totals (the
ledger.by.PARTY totals declared above): every (party, category, type, status) group of kind in currency, each as { party, category?, type, status, first?, amount, rows }, the same shape as totals's own rows, but
by party instead of account_kind, and with one addition: first: true on
the group of the one live (not voided) positive entry that holds that
(kind, party, reference)'s ref_first (exactly one live positive entry per
reference holds it, handed to another on a void), so a first group's rows
counts that party's distinct references credited, such as an account's paid
orders. No entry is read. Reading before ledger.declareTotals(ledger.by.PARTY)
throws LedgerRefused named TOTALS_NOT_DECLARED. Over HTTP it is
GET /v1/_ledger/accounts/totals?kind=HOST¤cy=USD (again, kind the catalog name over HTTP).
ledger.declareTotals(ledger.by.PARTY); // -> 'DECLARED' | 'EXISTING'
ledger.accountTotals(HOST, 'USD'); // -> [{ party, category?, type, status, first?, amount, rows }, ...]
clear, void, reverse
ledger.clear(id) and ledger.void(id) take an entry id and return
'CHANGED' or 'UNCHANGED'. A refusal throws LedgerRefused named
NOT_FOUND (no such id) or WRONG_STATUS (the entry cannot move to that
status from where it is), or LedgerWriteFailed named WRITE_FAILED.
ledger.clear(entryId); // -> 'CHANGED' | 'UNCHANGED'
ledger.void(entryId); // -> 'CHANGED' | 'UNCHANGED'
ledger.reverse(id) posts a new REVERSAL entry against the original and
returns the same shape as post: { outcome, ids }. It refuses with the
same per-write LedgerRefused names as post (ACCOUNT_KIND_UNKNOWN,
CATEGORY_UNKNOWN, CURRENCY_INVALID, AMOUNT_ZERO, NOT_BALANCED), plus
ORIGINAL_NOT_FOUND (no entry at that id), ORIGINAL_VOIDED (a voided
entry is never reversed), and ORIGINAL_IS_REVERSAL (a reversal is never
reversed again); LedgerWriteFailed named WRITE_FAILED on a failed write.
ledger.reverse(entryId); // -> { outcome: 'POSTED' | 'EXISTING', ids: [text] }
annotate
ledger.annotate(id, notes, tags, by) sets an administrator's note on an
entry without changing money or index fields. notes replaces data.admin_notes,
and tags replaces data.tags. Pass null or undefined to leave either
unchanged. data.last_annotated_at and data.last_annotated_by (by) are
always set. The call returns 'CHANGED'. A missing id throws LedgerRefused
named NOT_FOUND; a failed write throws LedgerWriteFailed named WRITE_FAILED.
ledger.annotate(entryId, 'flagged for review', ['fraud-review'], adminId);
// -> 'CHANGED'
clearAt
ledger.clearAt(id, ms) sets or clears a pending entry's clear_at (and its
due_at index key) without moving its status. ms > 0 sets both (only while
status is PENDING); ms === 0 unsets both. It returns 'CHANGED' or
'UNCHANGED'; a refusal throws LedgerRefused named NOT_FOUND (no such
id) or WRONG_STATUS (not pending), or LedgerWriteFailed named
WRITE_FAILED.
ledger.clearAt(entryId, Date.now() + 1000 * 60 * 60); // -> 'CHANGED' | 'UNCHANGED'
ledger.clearAt(entryId, 0); // unsets clear_at and due_at
clearDue
ledger.clearDue(limit) clears every pending entry whose clear_at has
passed as of now, oldest first, at most limit (absent: 1000, at most
10000). It returns { cleared, more }; a cron function calls it again while
more is true, since a batch may leave older due entries uncleared.
ledger.clearDue(); // -> { cleared, more }
get
ledger.get(id) returns the entry as a plain object, or null if there is
no entry at that id.
ledger.get(entryId); // -> { _id, account_kind, party, currency, amount, type, status, ... } | null
A read entry (from get, and each element of entries) holds its closed
sets as numbers, never as the words post took:
account_kind: the kind's position, whatledger.kind(name)answers. There is nokindmember.type: one ofledger.type.*(0 to 4).status: one ofledger.status.*(0 to 2).category: the category's position, whatledger.category(name)answers.original: on a reversal, the_idof the entry it reverses.created_atandclear_at: aDatein JavaScript, as every datetime a collection read answers. Over HTTP and in Rust they are integers, milliseconds since 1970.
An optional member that was not posted is absent, not null: category,
reference, scope, org, counterparty, original, clear_at,
external_ref, description, data. data is the object that was posted.
Compare a read entry with the resolved numbers (entry.account_kind === ledger.kind('HOST'), resolved once at load), never with a word.
idOf
ledger.idOf(key) returns the id that post or import would assign to an
entry with that key, without a read. A key that has never been posted still
returns an id, and ledger.get for it returns null. An empty or missing
key throws TypeError.
ledger.idOf('order-42'); // -> text, the same id post() gave that key
ledger.get(ledger.idOf('order-42')); // -> that entry, or null if never posted
entries
ledger.entries(by, value, before, limit) returns a newest-first page of
entries matching one plain index, or (by is ledger.by.ALL) of the whole
ledger.
by: required, a number, whatledger.by.<NAME>answered.value: required, a string, the value to match against that index; forledger.by.ALLit must be null or undefined instead (TypeErrorotherwise), and the walk is over every entry, no index.before: optional, an integer 0 toUINT32_MAX; absent or 0 means start from the newest entry.limit: optional, an integer 1 to 1000; absent defaults to 50; out of range refusesLedgerRefusednamedLIMIT_INVALID.
Over HTTP, GET /v1/_ledger/entries?by=all&limit=... (no value; a value
with by=all is 400 VALUE_INVALID) answers the same page as by=party etc.
Over MCP (substrio_ledger_entries {"by": "party", "value": "acct_demo_1"})
and POST /v1/_tools/invoke, every argument of the ledger reads
(ledger.entries, ledger.balance, ledger.balances.top, ledger.accounts,
ledger.totals, ledger.accounts.totals) goes in that query string.
ledger.entries(ledger.by.PARTY, 'acct_demo_1');
// -> { entries: [plain entry, ...], next_before, more }
The response's next_before is 0 when there is no further page; more is
next_before != 0. To read the next page, call again with before set to
the previous response's next_before:
let page = ledger.entries(ledger.by.PARTY, 'acct_demo_1');
while (page.more) {
page = ledger.entries(ledger.by.PARTY, 'acct_demo_1', page.next_before);
}
import
POST /v1/_ledger/import (operation ledger.import) brings entries from an
older system into the ledger, each keeping its own id and time; there is no
db.ledger method for it, only this route and substrio ledger import.
Body { entries: [...] }, 1 to 1000 objects, each post's own entry
members (above) plus:
| member | required | type | refusal name |
|---|---|---|---|
created_at |
yes | integer, milliseconds | CREATED_AT_INVALID |
original_key |
required for a REVERSAL, refused on any other type |
string | ORIGINAL_KEY_INVALID, or ORIGINAL_KEY_REQUIRED / ORIGINAL_KEY_UNEXPECTED from the write itself |
Unlike post, key is required here (KEY_REQUIRED), status may be
VOIDED, and type may be REVERSAL. An entry holding a member this route
does not take refuses the call, MEMBER_UNKNOWN, with the member named
({ error, entry, member }): history moved in never loses a misnamed member
without a word. The whole body is validated before
anything is written: the first entry that fails refuses the call and writes
nothing. It answers { posted, existing }; an import of the same keys again
answers every entry existing and writes nothing. A VOIDED entry imports
like any other write, then is excluded from its balance the same way void
excludes one; a PENDING entry past its clear_at is cleared normally by
clear_due.
Enums
ledger.type: CREDIT, DEBIT, TRANSFER_IN, TRANSFER_OUT, REVERSAL.
ledger.status: PENDING, CLEARED, VOIDED.
ledger.by: PARTY, REFERENCE, SCOPE, ORG, ALL.
All three are read-only, frozen objects on db.ledger.
Errors
A refused write throws LedgerRefused, with the error named as one of the
strings listed above (KEY_INVALID, KIND_REQUIRED, PARTY_REQUIRED,
CURRENCY_REQUIRED, AMOUNT_INVALID, TYPE_INVALID, STATUS_INVALID,
CATEGORY_INVALID, REFERENCE_INVALID, SCOPE_INVALID, ORG_INVALID,
COUNTERPARTY_INVALID, CLEAR_AT_INVALID, EXTERNAL_REF_INVALID,
DESCRIPTION_INVALID, DATA_INVALID, ID_INVALID, LIMIT_INVALID,
ACCOUNT_KIND_UNKNOWN, CATEGORY_UNKNOWN, CURRENCY_INVALID,
AMOUNT_ZERO, NOT_BALANCED, ORIGINAL_NOT_FOUND, ORIGINAL_VOIDED,
ORIGINAL_IS_REVERSAL, NOT_FOUND, WRONG_STATUS); a failed write throws
LedgerWriteFailed named WRITE_FAILED. A bad argument (wrong type, missing
required argument) throws a plain TypeError, with a message naming the
argument.
From the command line
substrio ledger catalog: ledger.catalog.get, the declared kind and category names.substrio ledger declare: ledger.catalog.set, declares kinds and categories.substrio ledger post: ledger.post, posts 1 to 1000 entries.substrio ledger balance: ledger.balance, one (kind, party, currency) group's balance.substrio ledger accounts: ledger.accounts, every (kind, currency) account's balance.substrio ledger accounts totals: ledger.accounts.totals, party totals for one (kind, currency).substrio ledger totals: ledger.totals, one scope's, reference's or the whole ledger's totals.substrio ledger totals declare: ledger.totals.declare, declares scope/reference/all/party totals.substrio ledger entries: ledger.entries, a page of entries by one index.substrio ledger entry ID: ledger.entry.get, one entry by its id.substrio ledger clear ID: ledger.entry.clear, moves a pending entry to cleared.substrio ledger void ID: ledger.entry.void, stops an entry counting toward its balance.substrio ledger reverse ID: ledger.entry.reverse, posts a reversal of an entry.substrio ledger clear-due: ledger.clear_due, clears every pending entry past its clear_at.substrio ledger import: ledger.import, imports 1 to 1000 entries from an older system.
substrio ledger declare kinds=HOST,AGENT categories=SALE,REFUND
substrio ledger balance kind=0 party=acct_demo_1 currency=USD
In substrio.toml
[ledger]
kinds = ["HOST", "AGENT"]
categories = ["SALE", "REFUND"]
substrio deploy sends this table as one ledger.catalog.set call. It is
append only, the same as the operation itself: each list given must be a
strict prefix extension of what the app already has, never a removal or
reorder. A deploy whose substrio.toml has no [ledger] table sends nothing,
and leaves the stored catalog exactly as it was.