@crewhaus/tool-ledger 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ import { decimalKernel } from "@crewhaus/tool-math";
3
+ /** Every refusal this package makes, so a caller can tell one from a crash. */
4
+ export declare class LedgerError extends CrewhausError {
5
+ readonly name = "LedgerError";
6
+ constructor(message: string);
7
+ }
8
+ export declare const ROUNDING_MODES: readonly ["halfUp", "halfEven", "halfDown", "ceiling", "floor", "up", "down"];
9
+ export type RoundingMode = decimalKernel.RoundingMode;
10
+ /**
11
+ * Half-even everywhere rounding is unavoidable. It is the mode that does not
12
+ * drift upward over a long series of conversions, which is the whole of what a
13
+ * ledger is.
14
+ */
15
+ export declare const LEDGER_ROUNDING: RoundingMode;
16
+ /** The minor-unit exponent for a currency, refusing a code nobody knows. */
17
+ export declare function exponentFor(currency: string, override?: number): number;
18
+ /** Minor units rendered as a major-unit decimal string: 1050 @2 -> "10.50". */
19
+ export declare function formatMajor(minor: bigint, exponent: number): string;
20
+ /**
21
+ * A major-unit decimal string ("100.00", "-3.5") as minor units.
22
+ *
23
+ * A fraction longer than the currency's exponent is REFUSED. Rounding it here
24
+ * would mean the entry posts an amount the source document does not contain,
25
+ * and the difference is invisible until a reconciliation a year later.
26
+ */
27
+ export declare function parseMajor(text: string, exponent: number, label: string): bigint;
28
+ /** An integer count of minor units, from a string or a safe integer. */
29
+ export declare function parseMinorUnits(value: string | number, label: string): bigint;
30
+ /** A decimal factor — an fx rate, a quantity — parsed exactly. */
31
+ export declare function parseFactor(text: string, label: string): decimalKernel.Decimal;
32
+ /** Multiply minor units by an exact decimal and round once, at the end. */
33
+ export declare function multiplyMinor(minor: bigint, factor: decimalKernel.Decimal, mode?: RoundingMode): bigint;
34
+ /** Apply an fx rate, honouring both currencies' exponents, rounding once. */
35
+ export declare function convertMinor(minor: bigint, rate: decimalKernel.Decimal, fromExponent: number, toExponent: number, mode?: RoundingMode): bigint;
36
+ /**
37
+ * A calendar date, validated against the calendar.
38
+ *
39
+ * `Date.parse` accepts "2026-02-30" and rolls it to 2 March, which puts an
40
+ * entry in the wrong month without anybody typing a wrong month. The round
41
+ * trip through the constructed date is what catches it.
42
+ */
43
+ export declare function assertIsoDate(text: string, label: string): string;
44
+ /** Whole days since the epoch, in UTC. Dates are compared and differenced here. */
45
+ export declare function epochDay(isoDate: string): number;
46
+ /** Signed day difference, `a - b`. */
47
+ export declare function dayDiff(a: string, b: string): number;
48
+ /** The year an invoice sequence resets on comes from the DOCUMENT, not the clock. */
49
+ export declare function yearOf(isoDate: string): string;
50
+ /**
51
+ * An ISO-8601 instant that carries a UTC offset.
52
+ *
53
+ * Matches `@crewhaus/tool-money`'s rule: an offset-less string means local
54
+ * time per ECMAScript, so the same posting would carry a different instant on
55
+ * two machines — and the instant is inside the hash chain.
56
+ */
57
+ export declare function assertInstant(text: string, label: string): string;
58
+ /**
59
+ * JSON with every object key in codepoint order and no incidental whitespace.
60
+ *
61
+ * This is what the hash chain and every idempotency fingerprint are taken
62
+ * over, so it has to be stable against the one thing JSON is not stable
63
+ * against: insertion order. A bigint serializes as its decimal string, and a
64
+ * non-integer number is refused outright — a float in the bytes that identify
65
+ * a payment is the defect this whole package exists to avoid.
66
+ */
67
+ export declare function canonicalJson(value: unknown): string;
68
+ /** Locale-independent string order. */
69
+ export declare function byString(a: string, b: string): number;
70
+ /** SHA-256 hex of a string. The chain link and every fingerprint use this. */
71
+ export declare function sha256Hex(text: string): string;
72
+ /** Compact JSON — every byte returned is a byte in somebody's context window. */
73
+ export declare const json: (value: unknown) => string;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Amounts, dates and the canonical bytes everything else is hashed over.
3
+ *
4
+ * Three rules hold everywhere below this line, and each of them is a bug that
5
+ * has shipped in somebody's books:
6
+ *
7
+ * 1. **No float ever touches an amount.** Every amount is a bigint count of
8
+ * minor units. The arithmetic and the ISO 4217 exponent table come from
9
+ * `@crewhaus/tool-math`'s money kernel rather than from a second copy here
10
+ * — a JPY exponent guessed at 2 is a 100x error, and there is exactly one
11
+ * table in this repo that knows it is 0.
12
+ * 2. **A scale the currency cannot hold is refused, not rounded.** "1.005" as
13
+ * USD is not $1.01; it is a caller who has not decided. A ledger that
14
+ * rounds an input silently is a ledger whose total disagrees with the
15
+ * document it was posted from.
16
+ * 3. **Totals accumulate in bigint, not in a JS number.** A single amount fits
17
+ * a safe integer — that is why `@crewhaus/tool-money` types one as
18
+ * `number` and this package matches it at the tool boundary — but a year of
19
+ * them summed does not, and SQLite's own `SUM()` silently promotes a
20
+ * 64-bit overflow to a float. So amounts are STORED as text and summed
21
+ * here.
22
+ */
23
+ import { createHash } from "node:crypto";
24
+ import { CrewhausError } from "@crewhaus/errors";
25
+ import { decimalKernel, moneyKernel } from "@crewhaus/tool-math";
26
+ /** Every refusal this package makes, so a caller can tell one from a crash. */
27
+ export class LedgerError extends CrewhausError {
28
+ name = "LedgerError";
29
+ constructor(message) {
30
+ super("tool", message);
31
+ }
32
+ }
33
+ export const ROUNDING_MODES = decimalKernel.ROUNDING_MODES;
34
+ /**
35
+ * Half-even everywhere rounding is unavoidable. It is the mode that does not
36
+ * drift upward over a long series of conversions, which is the whole of what a
37
+ * ledger is.
38
+ */
39
+ export const LEDGER_ROUNDING = "halfEven";
40
+ /** The minor-unit exponent for a currency, refusing a code nobody knows. */
41
+ export function exponentFor(currency, override) {
42
+ try {
43
+ return moneyKernel.minorUnitExponent(currency, override);
44
+ }
45
+ catch (err) {
46
+ throw new LedgerError(err instanceof Error ? err.message : String(err));
47
+ }
48
+ }
49
+ /** Minor units rendered as a major-unit decimal string: 1050 @2 -> "10.50". */
50
+ export function formatMajor(minor, exponent) {
51
+ return moneyKernel.formatMinor(minor, exponent);
52
+ }
53
+ /**
54
+ * A major-unit decimal string ("100.00", "-3.5") as minor units.
55
+ *
56
+ * A fraction longer than the currency's exponent is REFUSED. Rounding it here
57
+ * would mean the entry posts an amount the source document does not contain,
58
+ * and the difference is invisible until a reconciliation a year later.
59
+ */
60
+ export function parseMajor(text, exponent, label) {
61
+ let parsed;
62
+ try {
63
+ parsed = decimalKernel.parseDecimal(text);
64
+ }
65
+ catch (err) {
66
+ throw new LedgerError(`${label} ("${text}") is not a decimal number: ${err instanceof Error ? err.message : String(err)}`);
67
+ }
68
+ if (parsed.scale > exponent) {
69
+ throw new LedgerError(`${label} ("${text}") has ${parsed.scale} decimal places but this currency has ${exponent} — refusing to round an amount that was written down, decide the rounding before posting it`);
70
+ }
71
+ return parsed.unscaled * decimalKernel.pow10(exponent - parsed.scale);
72
+ }
73
+ /** An integer count of minor units, from a string or a safe integer. */
74
+ export function parseMinorUnits(value, label) {
75
+ try {
76
+ return moneyKernel.toMinor(value, label);
77
+ }
78
+ catch (err) {
79
+ throw new LedgerError(err instanceof Error ? err.message : String(err));
80
+ }
81
+ }
82
+ /** A decimal factor — an fx rate, a quantity — parsed exactly. */
83
+ export function parseFactor(text, label) {
84
+ try {
85
+ return decimalKernel.parseDecimal(text);
86
+ }
87
+ catch (err) {
88
+ throw new LedgerError(`${label} ("${text}") is not a decimal number: ${err instanceof Error ? err.message : String(err)}`);
89
+ }
90
+ }
91
+ /** Multiply minor units by an exact decimal and round once, at the end. */
92
+ export function multiplyMinor(minor, factor, mode = LEDGER_ROUNDING) {
93
+ return moneyKernel.moneyMultiply(minor, factor, mode).rounded;
94
+ }
95
+ /** Apply an fx rate, honouring both currencies' exponents, rounding once. */
96
+ export function convertMinor(minor, rate, fromExponent, toExponent, mode = LEDGER_ROUNDING) {
97
+ return moneyKernel.convertMinor(minor, rate, fromExponent, toExponent, mode);
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // dates
101
+ // ---------------------------------------------------------------------------
102
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
103
+ /**
104
+ * A calendar date, validated against the calendar.
105
+ *
106
+ * `Date.parse` accepts "2026-02-30" and rolls it to 2 March, which puts an
107
+ * entry in the wrong month without anybody typing a wrong month. The round
108
+ * trip through the constructed date is what catches it.
109
+ */
110
+ export function assertIsoDate(text, label) {
111
+ const m = ISO_DATE.exec(text);
112
+ if (!m) {
113
+ throw new LedgerError(`${label} ("${text}") must be an ISO calendar date, YYYY-MM-DD`);
114
+ }
115
+ const [, y, mo, d] = m;
116
+ const at = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
117
+ if (at.getUTCFullYear() !== Number(y) ||
118
+ at.getUTCMonth() + 1 !== Number(mo) ||
119
+ at.getUTCDate() !== Number(d)) {
120
+ throw new LedgerError(`${label} ("${text}") is not a date that exists`);
121
+ }
122
+ return text;
123
+ }
124
+ /** Whole days since the epoch, in UTC. Dates are compared and differenced here. */
125
+ export function epochDay(isoDate) {
126
+ const m = ISO_DATE.exec(isoDate);
127
+ if (!m)
128
+ throw new LedgerError(`"${isoDate}" is not an ISO calendar date`);
129
+ const [, y, mo, d] = m;
130
+ return Math.floor(Date.UTC(Number(y), Number(mo) - 1, Number(d)) / 86_400_000);
131
+ }
132
+ /** Signed day difference, `a - b`. */
133
+ export function dayDiff(a, b) {
134
+ return epochDay(a) - epochDay(b);
135
+ }
136
+ /** The year an invoice sequence resets on comes from the DOCUMENT, not the clock. */
137
+ export function yearOf(isoDate) {
138
+ return isoDate.slice(0, 4);
139
+ }
140
+ /**
141
+ * An ISO-8601 instant that carries a UTC offset.
142
+ *
143
+ * Matches `@crewhaus/tool-money`'s rule: an offset-less string means local
144
+ * time per ECMAScript, so the same posting would carry a different instant on
145
+ * two machines — and the instant is inside the hash chain.
146
+ */
147
+ export function assertInstant(text, label) {
148
+ if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text.trim())) {
149
+ throw new LedgerError(`${label} ("${text}") has no UTC offset — write it as e.g. 2026-01-01T00:00:00Z, because an offset-less string means local time and would hash differently on two machines`);
150
+ }
151
+ const parsed = Date.parse(text);
152
+ if (Number.isNaN(parsed))
153
+ throw new LedgerError(`${label} ("${text}") is not a valid instant`);
154
+ return new Date(parsed).toISOString();
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // canonical bytes
158
+ // ---------------------------------------------------------------------------
159
+ /**
160
+ * JSON with every object key in codepoint order and no incidental whitespace.
161
+ *
162
+ * This is what the hash chain and every idempotency fingerprint are taken
163
+ * over, so it has to be stable against the one thing JSON is not stable
164
+ * against: insertion order. A bigint serializes as its decimal string, and a
165
+ * non-integer number is refused outright — a float in the bytes that identify
166
+ * a payment is the defect this whole package exists to avoid.
167
+ */
168
+ export function canonicalJson(value) {
169
+ return JSON.stringify(canonicalize(value));
170
+ }
171
+ function canonicalize(value) {
172
+ if (typeof value === "bigint")
173
+ return value.toString();
174
+ if (typeof value === "number") {
175
+ if (!Number.isFinite(value))
176
+ throw new LedgerError("a non-finite number cannot be canonicalized");
177
+ return value;
178
+ }
179
+ if (value === null || typeof value !== "object")
180
+ return value;
181
+ if (Array.isArray(value))
182
+ return value.map(canonicalize);
183
+ const source = value;
184
+ const out = {};
185
+ // Codepoint order, never `localeCompare` — a locale-aware sort is not the
186
+ // same order on two machines, and the hash would move with it.
187
+ for (const key of Object.keys(source).sort(byString)) {
188
+ const entry = source[key];
189
+ if (entry === undefined)
190
+ continue;
191
+ out[key] = canonicalize(entry);
192
+ }
193
+ return out;
194
+ }
195
+ /** Locale-independent string order. */
196
+ export function byString(a, b) {
197
+ return a < b ? -1 : a > b ? 1 : 0;
198
+ }
199
+ /** SHA-256 hex of a string. The chain link and every fingerprint use this. */
200
+ export function sha256Hex(text) {
201
+ return createHash("sha256").update(text, "utf8").digest("hex");
202
+ }
203
+ /** Compact JSON — every byte returned is a byte in somebody's context window. */
204
+ export const json = (value) => JSON.stringify(value);
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The store: one `bun:sqlite` file, opened the way `@crewhaus/durable-state`
3
+ * opens one, with the whole of a posting inside a single `BEGIN IMMEDIATE`.
4
+ *
5
+ * THE INVARIANT THIS FILE EXISTS FOR
6
+ *
7
+ * A posting is three writes that are only correct together: the rows, the hash
8
+ * chain's new head, and the idempotency claim that says this batch has been
9
+ * seen. Commit them separately and there is a window in which the process can
10
+ * die leaving a ledger whose chain verifies perfectly and whose duplicate
11
+ * suppression has forgotten the entry — so the retry posts the same payment a
12
+ * second time, and every check anybody runs says the books are fine. Invoice
13
+ * numbering has the same shape from the other side: allocate the number, die
14
+ * before the document is recorded, and the sequence has a gap, which in most
15
+ * jurisdictions is not untidiness but a finding.
16
+ *
17
+ * So every mutation below happens inside ONE `tx.immediate(...)`. `IMMEDIATE`
18
+ * takes the write lock at BEGIN rather than at the first write, which is what
19
+ * makes the read half (is this key claimed? what is the chain head? what is the
20
+ * next invoice number?) serialize against another writer instead of racing it;
21
+ * `busy_timeout` is what makes the loser wait rather than fail instantly. Bun's
22
+ * `db.transaction()` rolls the whole thing back on a throw, so a failure
23
+ * anywhere in the batch leaves the file exactly as it was — no rows, no claim,
24
+ * no advanced head, no burnt invoice number.
25
+ *
26
+ * Amounts are stored as TEXT holding a bigint's decimal digits. SQLite would
27
+ * take them as INTEGER, but `bun:sqlite` hands an INTEGER column back as a JS
28
+ * number, and `SUM()` over one silently becomes a float once it overflows —
29
+ * both of which lose exactly the pennies this package is for.
30
+ */
31
+ import { Database } from "bun:sqlite";
32
+ /** Bumped when the on-disk shape changes. A mismatch is refused, never migrated silently. */
33
+ export declare const SCHEMA_VERSION = 1;
34
+ /** The `prev_hash` of the first entry. Nothing precedes it. */
35
+ export declare const GENESIS_HASH: string;
36
+ /** How long a writer waits for another writer's lock before giving up. */
37
+ export declare const DEFAULT_BUSY_TIMEOUT_MS = 5000;
38
+ /** The five account types, and the side each one increases on. */
39
+ export declare const ACCOUNT_TYPES: readonly ["asset", "liability", "equity", "income", "expense"];
40
+ export type AccountType = (typeof ACCOUNT_TYPES)[number];
41
+ /**
42
+ * Which side an account's balance is normally on.
43
+ *
44
+ * This lives with the chart of accounts, never in a query. A P&L that decides
45
+ * the sign of "revenue" by looking at the account's name is a report that is
46
+ * wrong the first time somebody calls an account "Revenue refunds".
47
+ */
48
+ export declare const NORMAL_BALANCE: Readonly<Record<AccountType, "debit" | "credit">>;
49
+ export type AccountRow = {
50
+ readonly code: string;
51
+ readonly name: string;
52
+ readonly type: AccountType;
53
+ };
54
+ export type EntryRow = {
55
+ readonly seq: number;
56
+ readonly entry_id: string;
57
+ readonly date: string;
58
+ readonly memo: string;
59
+ readonly reference: string;
60
+ readonly tags: string;
61
+ readonly source_system: string | null;
62
+ readonly source_id: string | null;
63
+ readonly base_currency: string;
64
+ readonly posted_at: string;
65
+ readonly prev_hash: string;
66
+ readonly hash: string;
67
+ };
68
+ export type LineRow = {
69
+ readonly entry_seq: number;
70
+ readonly idx: number;
71
+ readonly account: string;
72
+ readonly debit_minor: string;
73
+ readonly credit_minor: string;
74
+ readonly currency: string;
75
+ readonly exponent: number;
76
+ readonly fx_rate: string | null;
77
+ readonly base_debit_minor: string;
78
+ readonly base_credit_minor: string;
79
+ readonly counterparty: string | null;
80
+ readonly due_date: string | null;
81
+ readonly line_memo: string | null;
82
+ };
83
+ export type DocumentRow = {
84
+ readonly number: string;
85
+ readonly kind: string;
86
+ readonly sequence_name: string;
87
+ readonly ordinal: number;
88
+ readonly issue_date: string;
89
+ readonly idempotency_key: string;
90
+ readonly payload_hash: string;
91
+ readonly files: string;
92
+ readonly created_at: string;
93
+ };
94
+ export type OpenMode = "read" | "write";
95
+ /**
96
+ * Open the ledger file.
97
+ *
98
+ * `read` opens with SQLite's own read-only flag rather than a promise to
99
+ * behave: `LedgerQuery` and `LedgerReconcile` are declared read-only tools,
100
+ * and a declaration the database does not enforce is a comment. It also means
101
+ * a query against a ledger that does not exist is a refusal naming the path,
102
+ * not an empty ledger conjured into existence by the act of reading it.
103
+ */
104
+ export declare function openDb(realPath: string, mode: OpenMode, busyTimeoutMs: number,
105
+ /**
106
+ * What to call the file in a message. Every other refusal in this package
107
+ * names the workspace-relative path, and this one used to name the absolute
108
+ * one — putting the machine's home directory into a result that goes
109
+ * straight into a model's context. Defaults to the real path for the
110
+ * in-memory and direct-library callers, which have no relative form.
111
+ */
112
+ label?: string): Database;
113
+ export declare function readMeta(db: Database, key: string): string | null;
114
+ export declare function writeMeta(db: Database, key: string, value: string): void;
115
+ /** The chain's tip: the last entry's hash and how many entries it covers. */
116
+ export type ChainHead = {
117
+ readonly hash: string;
118
+ readonly length: number;
119
+ };
120
+ /**
121
+ * The head is stored, not derived.
122
+ *
123
+ * A prefix of a valid hash chain is itself a valid hash chain, so recomputing
124
+ * the head from the rows cannot tell a complete ledger from one somebody
125
+ * truncated. The stored head can: `length` disagreeing with the row count is
126
+ * the only evidence that entries were removed.
127
+ */
128
+ export declare function readHead(db: Database): ChainHead;
129
+ /** The bytes one entry's hash is taken over. Order is fixed by canonicalJson. */
130
+ export declare function entryHash(prevHash: string, payload: unknown): string;
131
+ export type ChainVerification = {
132
+ readonly ok: boolean;
133
+ readonly entries: number;
134
+ readonly head: string;
135
+ readonly storedHead: string;
136
+ readonly storedLength: number;
137
+ readonly problems: ReadonlyArray<{
138
+ readonly seq: number | null;
139
+ readonly reason: string;
140
+ }>;
141
+ };
142
+ /**
143
+ * Walk the chain from the genesis hash and report every break.
144
+ *
145
+ * Reports ALL of them rather than the first: "the chain broke at entry 4" and
146
+ * "the chain broke at entries 4, 900 and 901" are different findings, and the
147
+ * second one is the one that means somebody edited the file.
148
+ */
149
+ export declare function verifyChain(db: Database, payloadOf: (entry: EntryRow, lines: ReadonlyArray<LineRow>) => unknown): ChainVerification;
150
+ /**
151
+ * A rollback asked for on purpose.
152
+ *
153
+ * `dryRun` runs the real posting — every validation, every insert, the chain
154
+ * advance, the claim — and then throws this to undo it. Running a separate
155
+ * "check only" path instead is how a dry run comes back clean and the real
156
+ * call fails: the two paths drift, and the one nobody exercises is the one
157
+ * that matters.
158
+ */
159
+ export declare class DryRunRollback extends Error {
160
+ readonly payload: unknown;
161
+ constructor(payload: unknown);
162
+ }
163
+ /**
164
+ * Run `work` inside one `BEGIN IMMEDIATE`, and turn SQLite's lock failure into
165
+ * a sentence that names the cause.
166
+ *
167
+ * The distinction matters for a caller deciding whether to retry: "another
168
+ * writer holds the ledger" is worth retrying and "debits do not equal credits"
169
+ * never is, and a bare `SQLITE_BUSY` stack trace tells a model neither.
170
+ */
171
+ export declare function inImmediateTransaction<T>(db: Database, work: () => T): T;