Ledger

Post signed entries in minor units against a kind, a party and a currency, and read balances kept current without summing entries.

Docs menu

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&currency=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=&currency=, ?reference=&currency= or ?all=true&currency= (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&currency=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, what ledger.kind(name) answers. There is no kind member.
  • type: one of ledger.type.* (0 to 4). status: one of ledger.status.* (0 to 2).
  • category: the category's position, what ledger.category(name) answers.
  • original: on a reversal, the _id of the entry it reverses.
  • created_at and clear_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, what ledger.by.<NAME> answered.
  • value: required, a string, the value to match against that index; for ledger.by.ALL it must be null or undefined instead (TypeError otherwise), and the walk is over every entry, no index.
  • before: optional, an integer 0 to UINT32_MAX; absent or 0 means start from the newest entry.
  • limit: optional, an integer 1 to 1000; absent defaults to 50; out of range refuses LedgerRefused named LIMIT_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.