@book.dev/sdk 3.5.0 → 3.6.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.
- package/dist/backup.d.ts +75 -1
- package/dist/backup.js +8 -1
- package/dist/backup.js.map +1 -1
- package/dist/client.d.ts +134 -2
- package/dist/client.js +171 -0
- package/dist/client.js.map +1 -1
- package/dist/csv.d.ts +49 -0
- package/dist/csv.js +116 -0
- package/dist/csv.js.map +1 -0
- package/dist/importAssets.d.ts +10 -0
- package/dist/importAssets.js +17 -0
- package/dist/importAssets.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +772 -0
- package/dist/ledger.js +493 -0
- package/dist/ledger.js.map +1 -0
- package/dist/ledgerBeancount.d.ts +117 -0
- package/dist/ledgerBeancount.js +309 -0
- package/dist/ledgerBeancount.js.map +1 -0
- package/dist/ledgerBeancountFixture.d.ts +61 -0
- package/dist/ledgerBeancountFixture.js +459 -0
- package/dist/ledgerBeancountFixture.js.map +1 -0
- package/dist/ledgerCsv.d.ts +76 -0
- package/dist/ledgerCsv.js +197 -0
- package/dist/ledgerCsv.js.map +1 -0
- package/dist/money.d.ts +179 -0
- package/dist/money.js +283 -0
- package/dist/money.js.map +1 -0
- package/dist/notionImport.d.ts +5 -3
- package/dist/notionImport.js +6 -56
- package/dist/notionImport.js.map +1 -1
- package/dist/plugins.d.ts +24 -0
- package/dist/plugins.js +25 -0
- package/dist/plugins.js.map +1 -1
- package/dist/provenance.d.ts +17 -0
- package/dist/provenance.js.map +1 -1
- package/dist/routes.d.ts +121 -0
- package/dist/routes.js +122 -0
- package/dist/routes.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger contract (LGR-3): the shared types, typed errors, and pure audit-replay
|
|
3
|
+
* reducer for the server-enforced double-entry ledger.
|
|
4
|
+
*
|
|
5
|
+
* Data model: ledger data lives in FOUR server-managed OpenBook databases
|
|
6
|
+
* (accounts / transactions / postings / reconciliations) seeded on a restricted
|
|
7
|
+
* host page. Rows are ordinary pages; every ledger value is a database property.
|
|
8
|
+
* The server's `LedgerStore` is the ONLY writer — the generic page/row mutation
|
|
9
|
+
* surface (HTTP routes AND direct store calls) rejects writes to ledger rows, so
|
|
10
|
+
* the invariants hold in both server mode and browser-local (PGlite) mode.
|
|
11
|
+
*
|
|
12
|
+
* Money: amounts are SIGNED INTEGER MINOR UNITS (LGR-2, see `./money`). Amounts
|
|
13
|
+
* on the wire are integers — never parsed strings.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* HTTP status the API maps each {@link LedgerErrorCode} to.
|
|
17
|
+
*
|
|
18
|
+
* The three LGR-11 reconciliation rejections are 409, not 400: they are all
|
|
19
|
+
* "not valid for the CURRENT state of the book" rather than malformed requests.
|
|
20
|
+
* The identical call becomes legal once the open reconciliation is finished,
|
|
21
|
+
* once the difference reaches zero, or once the posting is posted — which is
|
|
22
|
+
* what tells a client to re-read and retry instead of fixing its payload.
|
|
23
|
+
*/
|
|
24
|
+
export function ledgerErrorStatus(code) {
|
|
25
|
+
switch (code) {
|
|
26
|
+
case 'not-initialized':
|
|
27
|
+
case 'not-found':
|
|
28
|
+
return 404;
|
|
29
|
+
case 'managed':
|
|
30
|
+
case 'immutable':
|
|
31
|
+
case 'reconciled-locked':
|
|
32
|
+
return 403;
|
|
33
|
+
// The three LGR-12 period rejections are state-of-the-book conflicts too:
|
|
34
|
+
// the identical call becomes legal once the period is reopened (or, for a
|
|
35
|
+
// close conflict, simply retried against the settled book). So is the LGR-14
|
|
36
|
+
// evidence rejection: the identical post becomes legal once evidence is
|
|
37
|
+
// attached to the draft (or the account's evidence-required toggle is turned
|
|
38
|
+
// off) — re-read and retry, don't fix the payload.
|
|
39
|
+
case 'invalid-state':
|
|
40
|
+
case 'nonzero-balance':
|
|
41
|
+
case 'reconciliation-exists':
|
|
42
|
+
case 'reconciliation-unbalanced':
|
|
43
|
+
case 'posting-not-reconcilable':
|
|
44
|
+
case 'reconciliation-too-large':
|
|
45
|
+
case 'period-closed':
|
|
46
|
+
case 'period-overlap':
|
|
47
|
+
case 'period-out-of-order':
|
|
48
|
+
case 'period-close-conflict':
|
|
49
|
+
case 'evidence-required':
|
|
50
|
+
return 409;
|
|
51
|
+
default:
|
|
52
|
+
return 400;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Typed ledger error. The server REJECTS invariant violations with these (never
|
|
57
|
+
* advises); the HTTP layer maps them to `{error, code}` bodies via
|
|
58
|
+
* {@link ledgerErrorStatus} and `HttpDataClient` re-materializes them, so a
|
|
59
|
+
* caller catches the same error class over either transport.
|
|
60
|
+
*/
|
|
61
|
+
export class LedgerError extends Error {
|
|
62
|
+
constructor(code, message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = 'LedgerError';
|
|
65
|
+
this.code = code;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The set of valid codes, for wire-body → {@link LedgerError} re-materialization. */
|
|
69
|
+
export const LEDGER_ERROR_CODES = [
|
|
70
|
+
'not-initialized',
|
|
71
|
+
'not-found',
|
|
72
|
+
'managed',
|
|
73
|
+
'immutable',
|
|
74
|
+
'unbalanced',
|
|
75
|
+
'too-few-postings',
|
|
76
|
+
'invalid-amount',
|
|
77
|
+
'account-not-found',
|
|
78
|
+
'account-closed',
|
|
79
|
+
'currency-mismatch',
|
|
80
|
+
'invalid-state',
|
|
81
|
+
'nonzero-balance',
|
|
82
|
+
'reconciled-locked',
|
|
83
|
+
'reconciliation-exists',
|
|
84
|
+
'reconciliation-unbalanced',
|
|
85
|
+
'posting-not-reconcilable',
|
|
86
|
+
'reconciliation-too-large',
|
|
87
|
+
'period-closed',
|
|
88
|
+
'period-overlap',
|
|
89
|
+
'period-out-of-order',
|
|
90
|
+
'period-close-conflict',
|
|
91
|
+
'evidence-required',
|
|
92
|
+
'invalid-input',
|
|
93
|
+
];
|
|
94
|
+
// ── Domain enums ───────────────────────────────────────────────────────────────
|
|
95
|
+
export const LEDGER_ACCOUNT_TYPES = ['asset', 'liability', 'equity', 'revenue', 'expense'];
|
|
96
|
+
export const LEDGER_ACCOUNT_STATUSES = ['open', 'closed'];
|
|
97
|
+
export const LEDGER_TRANSACTION_STATES = ['draft', 'posted', 'void'];
|
|
98
|
+
export const LEDGER_CLEARED_STATES = ['pending', 'cleared', 'reconciled'];
|
|
99
|
+
/**
|
|
100
|
+
* A reconciliation's lifecycle (LGR-11 + LGR-22).
|
|
101
|
+
*
|
|
102
|
+
* `abandoned` is a TERMINAL status, not a deletion: a statement someone opened
|
|
103
|
+
* against the wrong closing balance still happened, and the record of it — with
|
|
104
|
+
* the audit events that amended and ended it — is what lets a later reader tell
|
|
105
|
+
* "this account was never reconciled" apart from "this attempt was given up on".
|
|
106
|
+
* It is invisible to the one-open-per-account rule (which asks only for `open`),
|
|
107
|
+
* so abandoning immediately frees the account for a fresh start.
|
|
108
|
+
*/
|
|
109
|
+
export const LEDGER_RECONCILIATION_STATUSES = ['open', 'finished', 'abandoned'];
|
|
110
|
+
/**
|
|
111
|
+
* A period's lifecycle (LGR-12). `reopened` is TERMINAL for the RECORD, not for
|
|
112
|
+
* the range: reopening keeps the row (with the reversal that voided its closing
|
|
113
|
+
* entry) so the settings UI and the audit trail can show that a close happened
|
|
114
|
+
* and was undone; closing the range again writes a NEW period record. Only
|
|
115
|
+
* `closed` periods lock dates.
|
|
116
|
+
*/
|
|
117
|
+
export const LEDGER_PERIOD_STATUSES = ['closed', 'reopened'];
|
|
118
|
+
/**
|
|
119
|
+
* The account types a period close sweeps into retained earnings (LGR-12): the
|
|
120
|
+
* income-statement ("flow") accounts. ONE list, exported, because the server's
|
|
121
|
+
* closing-entry generator and any report fold that classifies flow accounts
|
|
122
|
+
* must agree — this epic's recurring defect is two copies of one fact.
|
|
123
|
+
*/
|
|
124
|
+
export const LEDGER_INCOME_STATEMENT_ACCOUNT_TYPES = ['revenue', 'expense'];
|
|
125
|
+
/** Whether `type` names an income-statement (flow) account. */
|
|
126
|
+
export function isIncomeStatementAccountType(type) {
|
|
127
|
+
return LEDGER_INCOME_STATEMENT_ACCOUNT_TYPES.includes(type);
|
|
128
|
+
}
|
|
129
|
+
// ── Read bounds ────────────────────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* The server's HARD CAP on one `listTransactions` page: a larger `limit` is
|
|
132
|
+
* clamped to this, silently.
|
|
133
|
+
*
|
|
134
|
+
* Exported because a reader that totals what it fetched — a report — cannot
|
|
135
|
+
* tell a full page from a complete book without it, and a duplicated literal
|
|
136
|
+
* would rot: if this cap ever dropped, a report asking for the old number would
|
|
137
|
+
* be clamped, conclude it had read everything, and render a PARTIAL total as a
|
|
138
|
+
* complete one. Both the server clamp and the ledger plugin's report read this
|
|
139
|
+
* one constant, and `ledger.test.ts` asserts the clamp actually holds.
|
|
140
|
+
*/
|
|
141
|
+
export const LEDGER_MAX_TRANSACTION_LIMIT = 1000;
|
|
142
|
+
/** The page size `listTransactions` uses when the caller names none. */
|
|
143
|
+
export const LEDGER_DEFAULT_TRANSACTION_LIMIT = 500;
|
|
144
|
+
// ── Property ids (stable — the seeded database schemas key rows by these) ──────
|
|
145
|
+
/** Stable property ids for the seeded ledger database schemas. */
|
|
146
|
+
export const LEDGER_PROP = {
|
|
147
|
+
account: {
|
|
148
|
+
type: 'lp_type',
|
|
149
|
+
status: 'lp_status',
|
|
150
|
+
currency: 'lp_currency',
|
|
151
|
+
/** LGR-14: `true` on an account that refuses to accept a posting from an
|
|
152
|
+
* entry with no evidence attached; absent everywhere else (additive — no
|
|
153
|
+
* migration, pre-LGR-14 accounts read back `false`). */
|
|
154
|
+
evidenceRequired: 'lp_evidence_required',
|
|
155
|
+
},
|
|
156
|
+
transaction: {
|
|
157
|
+
date: 'lp_date',
|
|
158
|
+
description: 'lp_description',
|
|
159
|
+
state: 'lp_state',
|
|
160
|
+
postedAt: 'lp_posted_at',
|
|
161
|
+
postedBy: 'lp_posted_by',
|
|
162
|
+
reverses: 'lp_reverses',
|
|
163
|
+
evidence: 'lp_evidence',
|
|
164
|
+
entryNo: 'lp_entry_no',
|
|
165
|
+
/** LGR-12: `'closing'` on a period-close entry; absent on ordinary entries. */
|
|
166
|
+
kind: 'lp_kind',
|
|
167
|
+
},
|
|
168
|
+
posting: {
|
|
169
|
+
transaction: 'lp_transaction',
|
|
170
|
+
account: 'lp_account',
|
|
171
|
+
amount: 'lp_amount_minor',
|
|
172
|
+
cleared: 'lp_cleared',
|
|
173
|
+
reconciliation: 'lp_reconciliation',
|
|
174
|
+
memo: 'lp_memo',
|
|
175
|
+
},
|
|
176
|
+
reconciliation: {
|
|
177
|
+
account: 'lp_account',
|
|
178
|
+
statementDate: 'lp_statement_date',
|
|
179
|
+
statementBalance: 'lp_statement_balance_minor',
|
|
180
|
+
status: 'lp_status',
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* THE period predicate (LGR-12), shared by the store's post/reverse guards and
|
|
185
|
+
* every UI surface that wants to say WHY a date is refused: the `closed` period
|
|
186
|
+
* containing `date`, or `null`. Pure; string comparison is safe because both
|
|
187
|
+
* sides are ISO `YYYY-MM-DD`.
|
|
188
|
+
*/
|
|
189
|
+
export function closedPeriodContaining(periods, date) {
|
|
190
|
+
for (const period of periods) {
|
|
191
|
+
if (period.status !== 'closed')
|
|
192
|
+
continue;
|
|
193
|
+
if (period.start <= date && date <= period.end)
|
|
194
|
+
return period;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Every CLOSED period overlapping the inclusive range `[from, to]` (`''` means
|
|
200
|
+
* open at that end) — the display-only "this report crosses a closed period"
|
|
201
|
+
* marker the report blocks render. Sorted by start date so the marker reads in
|
|
202
|
+
* calendar order.
|
|
203
|
+
*/
|
|
204
|
+
export function closedPeriodsOverlapping(periods, from, to) {
|
|
205
|
+
return periods
|
|
206
|
+
.filter((p) => p.status === 'closed' && (to === '' || p.start <= to) && (from === '' || from <= p.end))
|
|
207
|
+
.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.id < b.id ? -1 : 1));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Every known audit action — the validation set for a stored `action` value.
|
|
211
|
+
*
|
|
212
|
+
* `as const satisfies` (not a widened annotation) so the array's element type is
|
|
213
|
+
* the literal union: adding a member to {@link LedgerAuditAction} without adding
|
|
214
|
+
* it here is then caught by the exhaustiveness check below at COMPILE time,
|
|
215
|
+
* rather than surfacing at runtime as a log this build refuses to interpret.
|
|
216
|
+
*/
|
|
217
|
+
export const LEDGER_AUDIT_ACTIONS = [
|
|
218
|
+
'ledger.init',
|
|
219
|
+
'ledger.acl',
|
|
220
|
+
'account.create',
|
|
221
|
+
'account.update',
|
|
222
|
+
'transaction.create',
|
|
223
|
+
'transaction.update',
|
|
224
|
+
'transaction.delete',
|
|
225
|
+
'transaction.post',
|
|
226
|
+
'transaction.reverse',
|
|
227
|
+
'posting.cleared',
|
|
228
|
+
'reconciliation.start',
|
|
229
|
+
'reconciliation.finish',
|
|
230
|
+
'reconciliation.reopen',
|
|
231
|
+
'reconciliation.amend',
|
|
232
|
+
'reconciliation.abandon',
|
|
233
|
+
'period.close',
|
|
234
|
+
'period.reopen',
|
|
235
|
+
'ledger.autoExportPath',
|
|
236
|
+
'ledger.restore',
|
|
237
|
+
];
|
|
238
|
+
/**
|
|
239
|
+
* Compile-time exhaustiveness: every {@link LedgerAuditAction} must appear in
|
|
240
|
+
* {@link LEDGER_AUDIT_ACTIONS}. If a new union member is added without listing
|
|
241
|
+
* it, this assignment fails to typecheck (the union is not assignable to the
|
|
242
|
+
* array's narrower element type).
|
|
243
|
+
*/
|
|
244
|
+
const _LEDGER_AUDIT_ACTIONS_EXHAUSTIVE = null;
|
|
245
|
+
void _LEDGER_AUDIT_ACTIONS_EXHAUSTIVE;
|
|
246
|
+
/**
|
|
247
|
+
* Pure reducer: fold the audit event stream (ascending `seq`) into the expected
|
|
248
|
+
* current ledger state. Deterministic and side-effect free — the replay test
|
|
249
|
+
* compares this against the live store to prove the audit log is a complete
|
|
250
|
+
* record of every ledger mutation.
|
|
251
|
+
*/
|
|
252
|
+
export function replayLedgerAudit(events) {
|
|
253
|
+
const state = { initialized: false, accounts: {}, transactions: {}, reconciliations: {}, periods: {} };
|
|
254
|
+
/** Apply one recorded posting change to the replayed transaction holding it. */
|
|
255
|
+
const applyPostingChange = (change) => {
|
|
256
|
+
const tx = state.transactions[change.transactionId];
|
|
257
|
+
if (!tx)
|
|
258
|
+
return;
|
|
259
|
+
state.transactions[change.transactionId] = {
|
|
260
|
+
...tx,
|
|
261
|
+
postings: tx.postings.map((p) => p.id === change.postingId ? { ...p, cleared: change.cleared, reconciliationId: change.reconciliationId } : p),
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
for (const ev of events) {
|
|
265
|
+
// An action this reducer does not know about means the log records a
|
|
266
|
+
// mutation the replay cannot account for — so the reconstruction would be
|
|
267
|
+
// silently INCOMPLETE (state drifting from the books with no signal). That
|
|
268
|
+
// is exactly the failure a replayable audit log exists to rule out, so it is
|
|
269
|
+
// a hard error, not a skipped iteration. Any new action MUST land here.
|
|
270
|
+
const p = ev.payload;
|
|
271
|
+
switch (ev.action) {
|
|
272
|
+
case 'ledger.init':
|
|
273
|
+
state.initialized = true;
|
|
274
|
+
break;
|
|
275
|
+
case 'ledger.acl':
|
|
276
|
+
// A sharing grant/revoke on a ledger page: recorded for the trail, but it
|
|
277
|
+
// carries no ledger CONTENT, so replayed state is unchanged.
|
|
278
|
+
break;
|
|
279
|
+
case 'ledger.autoExportPath':
|
|
280
|
+
// An instance-policy change (where the canonical export is written):
|
|
281
|
+
// recorded for the trail, but it touches no ledger entity, so replayed
|
|
282
|
+
// state is unchanged. Explicit rather than falling through to `default`,
|
|
283
|
+
// which must keep throwing on actions this build genuinely cannot model.
|
|
284
|
+
break;
|
|
285
|
+
case 'ledger.restore':
|
|
286
|
+
// A bundle's history was installed (LGR-15): provenance for the trail —
|
|
287
|
+
// the restored entity events precede this one and already carry all the
|
|
288
|
+
// content, so replayed state is unchanged. Explicit for the same reason
|
|
289
|
+
// as `autoExportPath` above.
|
|
290
|
+
break;
|
|
291
|
+
case 'account.create':
|
|
292
|
+
case 'account.update':
|
|
293
|
+
if (p.account)
|
|
294
|
+
state.accounts[p.account.id] = p.account;
|
|
295
|
+
break;
|
|
296
|
+
case 'transaction.create':
|
|
297
|
+
case 'transaction.update':
|
|
298
|
+
case 'transaction.post':
|
|
299
|
+
if (p.transaction)
|
|
300
|
+
state.transactions[p.transaction.id] = p.transaction;
|
|
301
|
+
break;
|
|
302
|
+
case 'transaction.delete':
|
|
303
|
+
if (p.transactionId)
|
|
304
|
+
delete state.transactions[p.transactionId];
|
|
305
|
+
break;
|
|
306
|
+
case 'transaction.reverse':
|
|
307
|
+
if (p.transaction)
|
|
308
|
+
state.transactions[p.transaction.id] = p.transaction;
|
|
309
|
+
if (p.originalId && state.transactions[p.originalId]) {
|
|
310
|
+
state.transactions[p.originalId] = {
|
|
311
|
+
...state.transactions[p.originalId],
|
|
312
|
+
state: p.originalState ?? 'void',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
case 'posting.cleared':
|
|
317
|
+
if (p.transactionId && p.postingId && p.cleared && state.transactions[p.transactionId]) {
|
|
318
|
+
const tx = state.transactions[p.transactionId];
|
|
319
|
+
state.transactions[p.transactionId] = {
|
|
320
|
+
...tx,
|
|
321
|
+
postings: tx.postings.map((post) => (post.id === p.postingId ? { ...post, cleared: p.cleared } : post)),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
break;
|
|
325
|
+
case 'reconciliation.start':
|
|
326
|
+
case 'reconciliation.amend':
|
|
327
|
+
case 'reconciliation.abandon':
|
|
328
|
+
// ROW-ONLY events. Neither an amend nor an abandon touches a posting —
|
|
329
|
+
// that is the LGR-22 guarantee, stated here as well as enforced in the
|
|
330
|
+
// store — so unlike finish/reopen below there is no posting change to
|
|
331
|
+
// apply, and a payload that carried one would be a bug in the writer.
|
|
332
|
+
if (p.reconciliation)
|
|
333
|
+
state.reconciliations[p.reconciliation.id] = p.reconciliation;
|
|
334
|
+
break;
|
|
335
|
+
case 'reconciliation.finish':
|
|
336
|
+
case 'reconciliation.reopen':
|
|
337
|
+
// ONE event, TWO effects: the reconciliation's new status, and the
|
|
338
|
+
// cleared/reconciliationId state of every posting it froze or unfroze.
|
|
339
|
+
// Both must land, or the replayed postings drift from the book and the
|
|
340
|
+
// verifier's content-hash comparison reports a clean ledger as tampered.
|
|
341
|
+
if (p.reconciliation)
|
|
342
|
+
state.reconciliations[p.reconciliation.id] = p.reconciliation;
|
|
343
|
+
for (const change of p.postings ?? [])
|
|
344
|
+
applyPostingChange(change);
|
|
345
|
+
break;
|
|
346
|
+
case 'period.close':
|
|
347
|
+
// ONE event, TWO effects (LGR-12): the period record, and the closing
|
|
348
|
+
// entry it posted (absent when the range held nothing to close).
|
|
349
|
+
if (p.period)
|
|
350
|
+
state.periods[p.period.id] = p.period;
|
|
351
|
+
if (p.transaction)
|
|
352
|
+
state.transactions[p.transaction.id] = p.transaction;
|
|
353
|
+
break;
|
|
354
|
+
case 'period.reopen':
|
|
355
|
+
// The reversal pair rides in the same shape `transaction.reverse` uses:
|
|
356
|
+
// the reversal lands, and the original closing entry flips to void.
|
|
357
|
+
if (p.period)
|
|
358
|
+
state.periods[p.period.id] = p.period;
|
|
359
|
+
if (p.transaction)
|
|
360
|
+
state.transactions[p.transaction.id] = p.transaction;
|
|
361
|
+
if (p.originalId && state.transactions[p.originalId]) {
|
|
362
|
+
state.transactions[p.originalId] = {
|
|
363
|
+
...state.transactions[p.originalId],
|
|
364
|
+
state: p.originalState ?? 'void',
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
default:
|
|
369
|
+
throw new LedgerError('invalid-state', `replayLedgerAudit: unknown audit action ${JSON.stringify(ev.action)} — the replay cannot reconstruct state it does not model`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return state;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The tamper-evidence hash of ONE audit event: SHA-256 over its canonical
|
|
376
|
+
* content INCLUDING `prevHash`, which is what links each event to its
|
|
377
|
+
* predecessor. Async because it uses WebCrypto (isomorphic: Node, browser,
|
|
378
|
+
* sidecar). `seq` is deliberately part of the hashed content, so renumbering
|
|
379
|
+
* events breaks the chain just as rewriting one does.
|
|
380
|
+
*/
|
|
381
|
+
export async function ledgerAuditEventHash(event) {
|
|
382
|
+
const canonical = canonicalLedgerJson({
|
|
383
|
+
seq: event.seq,
|
|
384
|
+
id: event.id,
|
|
385
|
+
actorSubject: event.actorSubject,
|
|
386
|
+
actorName: event.actorName,
|
|
387
|
+
action: event.action,
|
|
388
|
+
entityIds: event.entityIds,
|
|
389
|
+
payload: event.payload,
|
|
390
|
+
beforeHash: event.beforeHash,
|
|
391
|
+
afterHash: event.afterHash,
|
|
392
|
+
prevHash: event.prevHash,
|
|
393
|
+
createdAt: event.createdAt,
|
|
394
|
+
});
|
|
395
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
|
|
396
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Verify the audit log's hash chain over `events` (ASCENDING `seq`, contiguous
|
|
400
|
+
* from the genesis event). Each event must carry its predecessor's
|
|
401
|
+
* {@link ledgerAuditEventHash} in `prevHash`; the first must carry `null`.
|
|
402
|
+
*
|
|
403
|
+
* THE PRECISE GUARANTEE — a green result is NOT "this log is authentic":
|
|
404
|
+
*
|
|
405
|
+
* - DETECTED: any edit, reordering, or middle deletion whose perpetrator does
|
|
406
|
+
* not also recompute every following link. That covers the realistic
|
|
407
|
+
* accident-and-opportunist cases (a stray `UPDATE`, a row deleted to hide one
|
|
408
|
+
* entry), and it distinguishes a deleted event from the innocuous BIGSERIAL
|
|
409
|
+
* gap a rolled-back transaction leaves.
|
|
410
|
+
* - NOT DETECTED: truncation of the HEAD or the TAIL — a prefix or suffix of
|
|
411
|
+
* the log removed wholesale still verifies clean, because nothing in the data
|
|
412
|
+
* states where the chain is supposed to start or end. Nor a WHOLESALE REWRITE:
|
|
413
|
+
* this hash is unkeyed and this function is exported, so an actor with
|
|
414
|
+
* database access can rewrite every event and recompute every link.
|
|
415
|
+
*
|
|
416
|
+
* Detecting those requires an off-box ANCHOR — periodically publishing the tail
|
|
417
|
+
* hash somewhere the database's owner cannot rewrite — which is tracked
|
|
418
|
+
* separately (LGR-18) and is not implemented here. Treat this as integrity
|
|
419
|
+
* against tampering that does not control the whole log, not as a signature.
|
|
420
|
+
*
|
|
421
|
+
* Verifying a PAGE of the log (not starting at genesis) is supported: pass the
|
|
422
|
+
* slice and it checks the links WITHIN it, reporting the first break.
|
|
423
|
+
*/
|
|
424
|
+
export async function verifyLedgerAuditChain(events) {
|
|
425
|
+
let expectedPrev = undefined; // undefined = first seen
|
|
426
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
427
|
+
const event = events[i];
|
|
428
|
+
if (i > 0 && event.seq <= events[i - 1].seq) {
|
|
429
|
+
return { ok: false, checked: i, brokenAtSeq: event.seq, reason: 'events are not in ascending seq order' };
|
|
430
|
+
}
|
|
431
|
+
if (expectedPrev !== undefined && event.prevHash !== expectedPrev) {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
checked: i,
|
|
435
|
+
brokenAtSeq: event.seq,
|
|
436
|
+
reason: `prevHash mismatch: expected ${String(expectedPrev)}, got ${String(event.prevHash)} — an event was rewritten, reordered, or removed`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
expectedPrev = await ledgerAuditEventHash(event);
|
|
440
|
+
}
|
|
441
|
+
return { ok: true, checked: events.length, brokenAtSeq: null, reason: null };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Canonical JSON of an entity for audit content-hashing: keys sorted at every
|
|
445
|
+
* depth, so a byte-identical serialization is independent of insertion order.
|
|
446
|
+
*/
|
|
447
|
+
export function canonicalLedgerJson(value) {
|
|
448
|
+
const sortValue = (v) => {
|
|
449
|
+
if (Array.isArray(v))
|
|
450
|
+
return v.map(sortValue);
|
|
451
|
+
if (v !== null && typeof v === 'object') {
|
|
452
|
+
const out = {};
|
|
453
|
+
for (const key of Object.keys(v).sort()) {
|
|
454
|
+
out[key] = sortValue(v[key]);
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
return v;
|
|
459
|
+
};
|
|
460
|
+
return JSON.stringify(sortValue(value));
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Validate a hierarchical, colon-delimited account name (`Assets:Bank:Checking`):
|
|
464
|
+
* one or more non-empty, non-whitespace-only segments separated by single colons.
|
|
465
|
+
*/
|
|
466
|
+
export function isValidLedgerAccountName(name) {
|
|
467
|
+
if (typeof name !== 'string' || name.trim() === '')
|
|
468
|
+
return false;
|
|
469
|
+
return name.split(':').every((seg) => seg.trim().length > 0);
|
|
470
|
+
}
|
|
471
|
+
/** Validate an ISO `YYYY-MM-DD` date string (a real calendar date). */
|
|
472
|
+
export function isValidLedgerDate(date) {
|
|
473
|
+
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
474
|
+
return false;
|
|
475
|
+
const parsed = new Date(`${date}T00:00:00Z`);
|
|
476
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === date;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The ADVISORY band of the finding-code union (LGR-14 Q3): codes that report
|
|
480
|
+
* "the book does not satisfy today's POLICY", never "storage was tampered
|
|
481
|
+
* with". Every other code is the tamper band. Keyed off the code union — not
|
|
482
|
+
* message text — because exit policies and alerting hang off this distinction:
|
|
483
|
+
* `--verify-ledger` exits 0 on an advisory-only report (still printed), so a
|
|
484
|
+
* healthy book that enables evidence-required over bare history does not turn
|
|
485
|
+
* every backup-verification script permanently red and teach operators to
|
|
486
|
+
* ignore the one alarm that matters. Any future advisory code MUST land here.
|
|
487
|
+
*/
|
|
488
|
+
export const LEDGER_VERIFY_ADVISORY_CODES = ['evidence-required-missing'];
|
|
489
|
+
/** Whether `code` is an advisory (current-policy) finding, not a tamper one. */
|
|
490
|
+
export function isLedgerVerifyAdvisory(code) {
|
|
491
|
+
return LEDGER_VERIFY_ADVISORY_CODES.includes(code);
|
|
492
|
+
}
|
|
493
|
+
//# sourceMappingURL=ledger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ledger.js","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beancount export (LGR-13): the ledger serialized as a Beancount journal, so
|
|
3
|
+
* the whole book can be re-checked by an INDEPENDENT accounting implementation
|
|
4
|
+
* (`bean-check`, the beancount loader, Fava) — free QA for every invariant the
|
|
5
|
+
* ledger claims to enforce.
|
|
6
|
+
*
|
|
7
|
+
* This module is a PURE function from ledger entities to journal text — no
|
|
8
|
+
* I/O, no clock, no locale — with the same hard guarantee as the canonical CSV
|
|
9
|
+
* (`./ledgerCsv`): the SAME DATA always produces IDENTICAL BYTES. It reads the
|
|
10
|
+
* same entities the CSV export reads (one read model, two serializers).
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS EMITTED (and why — the full contract lives in
|
|
13
|
+
* `docs/ledger/beancount-export.md`):
|
|
14
|
+
*
|
|
15
|
+
* - `open` for every account (earliest reported posting date, else the
|
|
16
|
+
* account's creation date), pinning the account's currency so bean-check
|
|
17
|
+
* enforces the ledger's own one-currency-per-account rule independently;
|
|
18
|
+
* - one `txn` per POSTED or VOID transaction — the exact set the report folds
|
|
19
|
+
* count (`REPORTED_STATES` in the ledger plugin's reports): a void original
|
|
20
|
+
* is offset by its posted reversal, and exporting only one half of the pair
|
|
21
|
+
* would misstate every balance. Drafts are not on the books and never
|
|
22
|
+
* export. Ledger-only facts (id, entry number, state, kind, reverses,
|
|
23
|
+
* evidence, memo, cleared state) ride as `lp-*` metadata;
|
|
24
|
+
* - `balance` assertions the day after each CLOSED period's end, for every
|
|
25
|
+
* account with a reported posting on or before the end — so bean-check
|
|
26
|
+
* re-verifies both the arithmetic and the closing sweep (income-statement
|
|
27
|
+
* accounts assert to zero) from the directives alone.
|
|
28
|
+
*
|
|
29
|
+
* SIGN MAPPING — the identity. The ledger stores signed DEBIT-POSITIVE minor
|
|
30
|
+
* units (LGR-2); Beancount amounts are signed decimals where an asset increase
|
|
31
|
+
* is positive and a credit-normal balance (Income/Liabilities/Equity) is
|
|
32
|
+
* negative — the same convention. No re-signing happens anywhere, including
|
|
33
|
+
* for contra/credit-normal accounts: a revenue balance exports negative, which
|
|
34
|
+
* is exactly how Beancount's own Income accounts carry it.
|
|
35
|
+
*
|
|
36
|
+
* MONEY DISCIPLINE: amounts pass through {@link formatBeancountAmount} (exact
|
|
37
|
+
* BigInt digit math, the `formatAmount` discipline without display grouping)
|
|
38
|
+
* and {@link sumAmounts} — never float arithmetic.
|
|
39
|
+
*
|
|
40
|
+
* CORRUPT BOOKS: unlike the insurance CSV (which must always leave the
|
|
41
|
+
* building), this export REFUSES a damaged book — an unresolvable account or a
|
|
42
|
+
* non-safe-integer amount throws a typed error instead of serializing a
|
|
43
|
+
* plausible-but-wrong journal. The reference implementation must never be
|
|
44
|
+
* handed data the ledger itself cannot vouch for; the LGR-7 verifier is the
|
|
45
|
+
* tool that names the damage.
|
|
46
|
+
*/
|
|
47
|
+
import { type LedgerAccount, type LedgerAccountType, type LedgerPeriod, type LedgerTransaction } from './ledger';
|
|
48
|
+
/**
|
|
49
|
+
* The Beancount ROOT for each ledger account type. Beancount requires every
|
|
50
|
+
* account to live under one of exactly five roots; the ledger's five types map
|
|
51
|
+
* onto them 1:1 (`revenue` → `Income` is the only rename).
|
|
52
|
+
*/
|
|
53
|
+
export declare const BEANCOUNT_ROOT_BY_TYPE: Readonly<Record<LedgerAccountType, string>>;
|
|
54
|
+
/**
|
|
55
|
+
* Serialize signed integer minor units as Beancount's plain decimal
|
|
56
|
+
* (`-1234.56`, `0.00`) — {@link formatAmount}'s exact BigInt digit math minus
|
|
57
|
+
* the display affordances (no thousands grouping, no symbols, no parens),
|
|
58
|
+
* because Beancount wants a machine number. Negative zero normalises to
|
|
59
|
+
* `0.00`. Throws {@link MoneyRangeError} on anything that is not a safe
|
|
60
|
+
* integer — see the module doc's corrupt-books stance.
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatBeancountAmount(minor: number): string;
|
|
63
|
+
/**
|
|
64
|
+
* Quote a string for Beancount: backslash and double-quote are escaped (the
|
|
65
|
+
* two characters the lexer treats specially inside a string); everything else
|
|
66
|
+
* — including literal newlines and non-ASCII — is verbatim, which the lexer
|
|
67
|
+
* accepts (verified against beancount 3.1.0). Escaping only these two keeps
|
|
68
|
+
* the mapping injective: a re-importer that unescapes `\\` and `\"` recovers
|
|
69
|
+
* the original exactly.
|
|
70
|
+
*/
|
|
71
|
+
export declare function quoteBeancountString(value: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Mangle ONE colon-separated component of a ledger account name into
|
|
74
|
+
* Beancount's component charset, deterministically:
|
|
75
|
+
*
|
|
76
|
+
* 1. every character outside `[A-Za-z0-9-]` becomes `-` (one `-` per
|
|
77
|
+
* character — no collapsing, so distinct inputs stay distinct more often);
|
|
78
|
+
* 2. a leading lowercase letter is uppercased; any other invalid lead
|
|
79
|
+
* (digit-ok, letter-ok; a `-` or nothing) is prefixed with `X`.
|
|
80
|
+
*
|
|
81
|
+
* The ledger's own name validator guarantees non-empty, non-whitespace-only
|
|
82
|
+
* components, but raw storage is not trusted: an empty component mangles to
|
|
83
|
+
* `X` instead of producing an invalid name.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mangleBeancountComponent(segment: string): string;
|
|
86
|
+
/**
|
|
87
|
+
* Map ONE ledger account name onto a Beancount account name (before collision
|
|
88
|
+
* handling — see {@link buildBeancountAccountNames} for the full map):
|
|
89
|
+
*
|
|
90
|
+
* - the ROOT comes from the account TYPE ({@link BEANCOUNT_ROOT_BY_TYPE}),
|
|
91
|
+
* never from the name — `Revenue:Sales` typed `revenue` becomes
|
|
92
|
+
* `Income:Revenue:Sales`;
|
|
93
|
+
* - each component is mangled ({@link mangleBeancountComponent});
|
|
94
|
+
* - a first component that already equals the root is not repeated
|
|
95
|
+
* (`Assets:Bank` stays `Assets:Bank`, not `Assets:Assets:Bank`) — unless
|
|
96
|
+
* dropping it would leave the bare root, which Beancount rejects (an
|
|
97
|
+
* account named exactly `Assets` becomes `Assets:Assets`).
|
|
98
|
+
*/
|
|
99
|
+
export declare function beancountAccountName(account: Pick<LedgerAccount, 'name' | 'type'>): string;
|
|
100
|
+
/**
|
|
101
|
+
* The full account-id → Beancount-name map, with DETERMINISTIC collision
|
|
102
|
+
* handling: accounts are visited in (createdAt, id) order; the first claimant
|
|
103
|
+
* keeps the mapped name, later ones append `-2`, `-3`, … to the final
|
|
104
|
+
* component (bumping until free). Stable for a given book — the suffix order
|
|
105
|
+
* depends only on stored creation data, never on read order.
|
|
106
|
+
*/
|
|
107
|
+
export declare function buildBeancountAccountNames(accounts: readonly LedgerAccount[]): Map<string, string>;
|
|
108
|
+
/**
|
|
109
|
+
* Build the Beancount journal from the full account list, the full
|
|
110
|
+
* transaction list (with postings) and the period records — the same read
|
|
111
|
+
* model the canonical CSV uses. Pure and byte-deterministic; see module doc.
|
|
112
|
+
*
|
|
113
|
+
* Throws {@link LedgerError} `account-not-found` when a posting references an
|
|
114
|
+
* account the list does not carry, and {@link MoneyRangeError} on a stored
|
|
115
|
+
* amount that is not a safe integer — the corrupt-books stance.
|
|
116
|
+
*/
|
|
117
|
+
export declare function buildLedgerBeancount(accounts: readonly LedgerAccount[], transactions: readonly LedgerTransaction[], periods: readonly LedgerPeriod[]): string;
|