What it is
db.ledger is one ledger per app, reached as db.ledger from any
function, separate from any collection you create. Every entry is a signed
amount in minor units against a kind, a party, and a currency. Balances are
maintained values, kept current as entries post, clear, void, or reverse;
reading a balance is one load of those values, never a sum over 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 the name up on each call:
run them once at module load, not per request. Every other method takes the
resolved integer, not 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, named with the error and the index, into the given array, of
the entry that first 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 answers
EXISTING with the same ids instead of writing a second time, and an empty
or absent key always posts fresh.
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 kind (what ledger.kind
answered), party, and currency (strings). It answers:
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), answers the account's balance
with that one organization: the entries of the account whose org is that
text. An entry posted with no org counts in the account's balance and in no
organization's. undefined is the three argument call; an empty string or a
value that is not a string is a 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) answers the n (1 to 1000) (kind, party)
accounts with the largest summed balance in currency, largest first. An
account with none of that currency is left out, never a zero entry. Over HTTP it is
GET /v1/_ledger/balances/top?currency=USD&limit=10.
accounts
ledger.accounts(kind, currency) answers every (kind, party) account that
holds currency, each as { party, amount } (pending plus cleared, voided
never counting), parties in no particular order; a party with none of that
currency is left out, never a zero entry. No entry is read, and there is no
limit and no sort (unlike topBalances, which is every kind, sorted, and
capped at n). Over HTTP (where kind is the catalog name, not the resolved index) it is
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 a
foursome of opt-in maintained aggregates: a scope's (or a reference's, the
whole ledger's, or one account kind's parties') totals by category, type and
status (plus account kind, for the first three), kept current the same way
balance is, but only once an app calls ledger.declareTotals(by) (by:
ledger.by.SCOPE, the default, ledger.by.REFERENCE, ledger.by.ALL or
ledger.by.PARTY; ledger.by.ORG throws TypeError; idempotent:
'DECLARED' the first time, 'EXISTING' after, each by declared
independently). An app that never declares one pays nothing extra for it on
any post, status change, or balance read; a declared one adds one maintained
update to each post and each status change, the same work balance already
does. ledger.by.PARTY is read back through
accountTotals, below, never through ledger.totals itself (ledger.totals's
own by argument only ever takes 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 answer
'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
answers 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, never a money or index field: notes (a string, or null/undefined
to leave it as it was) replaces data.admin_notes; tags (an array of
strings, or null/undefined to leave it as it was) replaces data.tags;
data.last_annotated_at and data.last_annotated_by (by) are always set.
Answers 'CHANGED'; a refusal throws LedgerRefused named NOT_FOUND (no
such id), or 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. Answers '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). Answers { 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) answers 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: integers, milliseconds.
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) answers the id that post (or import) would give an
entry posted with that key, without reading anything: a key nothing was
ever posted with still answers an id, and ledger.get of it is 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) answers 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 answer'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 answer'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.