@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.
package/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # @crewhaus/tool-ledger
2
+
3
+ A local double-entry ledger, and the reports and reconciliation that make one
4
+ usable.
5
+
6
+ | Tool | Answers |
7
+ |---|---|
8
+ | `LedgerPost` | record these entries, or say exactly why not |
9
+ | `LedgerQuery` | what is the balance, the P&L, the sheet, the aging, the chain |
10
+ | `LedgerReconcile` | which of these rows are the same event, and which are not |
11
+ | `InvoiceRender` | produce a numbered document, with the totals computed |
12
+
13
+ Storage is `bun:sqlite`, which is built into Bun. There are no runtime
14
+ dependencies outside the workspace.
15
+
16
+ ## Nothing here moves money
17
+
18
+ Nothing in this package opens a socket. No schema in it accepts an account
19
+ number, a card, a routing number, an API key or any other credential —
20
+ `index.test.ts` walks every schema and asserts that, and scans every source
21
+ file for anything that could dial out, and both checks assert their own hit
22
+ count so a scan that matched nothing cannot pass as a clean result.
23
+
24
+ An invoice is rendered to a file under the workspace. A reconciliation returns
25
+ entries a person could post. Sending a payment instruction is a different job,
26
+ in a different package, with different gates.
27
+
28
+ ## Debits equal credits, or the entry does not post
29
+
30
+ That is the whole of double entry, and a ledger that accepts an unbalanced
31
+ entry is not a ledger with a small error in it — it is a pile of rows, and
32
+ every report over it is unfalsifiable. So the check happens at POST time and
33
+ an unbalanced entry is rejected with the difference named. One rejected entry
34
+ does not stop the rest of the batch; each rejection carries its index and its
35
+ reason.
36
+
37
+ Also refused, each with a reason rather than a correction: a negative debit
38
+ (it is a credit, so write it as one), a zero line, a line carrying both sides,
39
+ an entry with fewer than two lines, an amount whose decimals the currency
40
+ cannot hold (`1.005` as USD is not `1.01`, it is a caller who has not
41
+ decided), a date that does not exist, an account not in the chart, and a date
42
+ inside a locked period.
43
+
44
+ ## Amounts are never JS floats
45
+
46
+ Decimal strings in, integer minor units out, bigints in between. The
47
+ arithmetic and the ISO 4217 exponent table come from `@crewhaus/tool-math`'s
48
+ money kernel — one table, so JPY has no cents and a Gulf dinar has three
49
+ decimal places. An unknown currency code is refused rather than assumed to
50
+ have two.
51
+
52
+ Amounts are stored as TEXT holding a bigint's digits. SQLite would take them
53
+ as INTEGER, but `bun:sqlite` hands an INTEGER column back as a JS number and
54
+ `SUM()` silently promotes a 64-bit overflow to a float. Totals are therefore
55
+ summed here, exactly.
56
+
57
+ ## The one transaction
58
+
59
+ A posting is three writes that are only correct together:
60
+
61
+ 1. the entry and line rows,
62
+ 2. the hash chain's new head,
63
+ 3. the idempotency claim that says this batch has been seen.
64
+
65
+ Commit them separately and there is a window in which a crash leaves a ledger
66
+ whose chain **verifies perfectly** and whose duplicate suppression has
67
+ forgotten the entry — so the retry posts the same payment a second time, and
68
+ every check anybody runs says the books are fine. All three go inside one
69
+ `BEGIN IMMEDIATE` transaction, following `@crewhaus/durable-state`'s pattern:
70
+ `IMMEDIATE` takes the write lock at `BEGIN` rather than at the first write, so
71
+ the read half (is this key claimed? what is the head?) serializes against
72
+ another writer instead of racing it, and `busy_timeout` makes the loser wait
73
+ rather than fail instantly.
74
+
75
+ `dryRun` is the same code path, rolled back on purpose. A separate "check
76
+ only" path is how a dry run comes back clean and the real call fails.
77
+
78
+ Invoice numbering has the same shape from the other side. Allocate the number
79
+ and then write the file, and a crash in between burns a number out of a
80
+ sequence that is legally required not to have gaps; write the file and then
81
+ allocate, and two concurrent calls issue one number twice. So the number and
82
+ the document record — including the hash of everything that determines the
83
+ rendered bytes — are allocated in one transaction, and the render happens
84
+ after. If the render dies, repeating the call with the same idempotency key
85
+ returns the **same number** and re-renders the **same bytes**. And because a
86
+ bad template is found at render time, the render is proved once against a
87
+ provisional number before the sequence is touched at all.
88
+
89
+ ## Multi-currency: rounding is bounded
90
+
91
+ An entry with a foreign line balances in the base currency after conversion,
92
+ and conversions round, so a residue of a minor unit or two is arithmetic and
93
+ goes to the configured `fxAccount`. A residue of nine hundred is not rounding;
94
+ it is a rate entered as 1.09 instead of 0.91. So the residue is capped at one
95
+ minor unit per converted line — the most rounding can produce — and anything
96
+ larger is refused with both numbers in the message. Booking it to "FX
97
+ rounding" would hide a real loss in the one account nobody reads.
98
+
99
+ With no `fxAccount` configured, a residue is a refusal rather than an extra
100
+ cent added to whichever line happened to be last.
101
+
102
+ ## A tolerance does not widen matching
103
+
104
+ `LedgerReconcile` reports **matched**, **unmatched on each side**, and **near
105
+ misses** as three separate lists. A match requires the amounts to be *equal*.
106
+ The tolerance defines the near-miss radius: a 100.00 and a 100.01 come back as
107
+ a near miss for a person to look at, and both rows stay unmatched.
108
+
109
+ This is the opposite of what most reconcilers do, and it is the reason this
110
+ one is worth running unattended. Those two rows are two facts that disagree by
111
+ a penny; pairing them destroys the only evidence that anything is wrong. For
112
+ the same reason, a matching *reference* with a different amount is a near miss
113
+ rather than a match — that pair is the most interesting row in the file.
114
+
115
+ Bundled payouts are found by a bounded subset-sum. Several different bundles
116
+ of charges routinely add up to the same payout, so the search walks subsets by
117
+ ascending size and then lexicographically over a pool sorted by (date, id) and
118
+ takes the first exact hit: the same bundle is chosen on every rerun, and the
119
+ fee entry proposed next to it does not move. The search is capped, and a row
120
+ it gave up on is named in `groupingTruncated` rather than reported as
121
+ unmatched.
122
+
123
+ Rows on both sides use `@crewhaus/tool-money`'s `Transaction` — the shape
124
+ `StatementParse` produces — imported rather than re-declared. A side with
125
+ `kind: "statement"` is read through tool-money's own parser, so a file whose
126
+ dates could be day-first or month-first is refused there too.
127
+
128
+ ## Aging refuses rather than answering partially
129
+
130
+ Aging needs a `counterparty` and a `dueDate` on the lines it ages, and an
131
+ explicit `asOf`. Without a due date, every amount lands in "current" and the
132
+ report says nothing is overdue — an answer that looks like an answer. So:
133
+
134
+ - no `asOf` is a refusal (the clock would make the same ledger produce a
135
+ different report tomorrow, and an aging report is a document somebody
136
+ files);
137
+ - `asOf` narrows the selection as well as labelling it, as it does on a
138
+ balance sheet: an aging as of June does not include an invoice dated August,
139
+ which would otherwise land in "current" and inflate what a counterparty
140
+ owes;
141
+ - no `accounts` is a refusal (ageing every account produces a table with no
142
+ meaning);
143
+ - a line with no `counterparty` is a refusal, naming the field and the count;
144
+ - *no* line carrying a `dueDate` is a refusal.
145
+
146
+ Lines that do have a counterparty but no due date are payments and credits.
147
+ They are reported as `unapplied` against that counterparty rather than folded
148
+ into "current", where they would quietly cancel an overdue invoice.
149
+
150
+ ## Signs come from the chart of accounts
151
+
152
+ A trial balance says `balanced: true` or `false` only when the selection holds
153
+ whole entries. Filtering by account, counterparty, text or amount keeps one leg
154
+ and drops the other, so the totals cannot pair up; `balanced` is `null` there
155
+ with a note saying why, because `false` would read as "somebody edited the
156
+ file" about a caller who typed a filter.
157
+
158
+ Whether a balance reads positive depends on the account's normal side, which
159
+ is a property of the account, not of the query. A report that decides it from
160
+ the account's *name* is wrong the first time somebody books a contra account.
161
+ `autoCreateAccounts` is off by default for the same reason: an auto-created
162
+ account's type is guessed from the numbering convention, and reports sign
163
+ their sums by that field.
164
+
165
+ ## Invoice totals add up on the page
166
+
167
+ The four figures a document shows are `subtotal`, `discount`, `tax` and
168
+ `total`, and they satisfy the sum a reader will do on them:
169
+
170
+ ```
171
+ subtotal − discount + tax = total
172
+ ```
173
+
174
+ So `subtotal` is the lines *before* discount. Reporting the net there leaves
175
+ every individual figure defensible and the document as a whole wrong — 90.00
176
+ less 10.00 plus 18.00 is not the 108.00 printed at the bottom, and the first
177
+ person to check the arithmetic is the customer being asked to pay it. The
178
+ after-discount figure is carried as `net` for anyone who wants it without
179
+ subtracting, and the lines' own `total` column sums to the same `total`.
180
+
181
+ A document number is also a file name. It is constrained to letters, digits,
182
+ `.`, `-` and `_`, whether it came from `number` or from `numbering.prefix`,
183
+ and the check runs before the sequence is touched — so a number that would
184
+ walk out of `outDir` is a refusal rather than a burnt number and an
185
+ overwritten file somewhere else. `gapFree` comes back `null`, never `true`,
186
+ for a number the caller assigned: there is no sequence there to check, and a
187
+ gap in a document sequence is a finding.
188
+
189
+ ## Invoice bytes do not move
190
+
191
+ `Intl.NumberFormat` output varies with the ICU version the runtime was built
192
+ against, so the same invoice can render differently on a laptop and in CI — on
193
+ a document somebody files. This package has no locale formatter at all.
194
+ Amounts are rendered from exact decimal strings with separators the caller
195
+ names; dates are ISO. The yearly sequence reset takes its year from
196
+ `issueDate`, never from the clock, so a document back-dated across New Year
197
+ lands in the right sequence.
198
+
199
+ The template is logic-less: variables, array sections, inverted sections.
200
+ Nothing computes, because a template that can compute can compute a total, and
201
+ totals come from the line data. Only arrays iterate — a plain object would
202
+ iterate in insertion order, and that is not a property of the document. An
203
+ unknown placeholder is refused by default rather than rendered as a blank: a
204
+ mistyped `{{seller.taxId}}` would drop the VAT number off the invoice and
205
+ nothing would say so.
206
+
207
+ ## The chain, and what it can and cannot tell you
208
+
209
+ Each entry hashes its predecessor's hash together with a canonical
210
+ serialization of the entry and all of its lines, so editing a memo or an
211
+ amount after the fact is detectable. `LedgerQuery view: "chain"` walks it and
212
+ reports **every** break rather than the first — "entries 4, 900 and 901" and
213
+ "entry 4" are different findings.
214
+
215
+ The head is stored as well as derivable, because a prefix of a valid chain is
216
+ itself a valid chain: recomputing the head cannot tell a complete ledger from
217
+ a truncated one, and the recorded head and length can.
218
+
219
+ What this is not, in two parts. The file is on disk and whoever can write it
220
+ can rewrite the whole chain from any point: tamper-**evident** against an
221
+ edit, not tamper-proof against an author. For that, publish the head somewhere
222
+ the same person cannot write. And the chain covers the entries and their
223
+ lines — not the chart of accounts. An account's *type* is outside the hash, so
224
+ re-typing one from income to expense turns a P&L over and the chain still
225
+ reports `ok`. The `chain` view says so in its notes; check account types
226
+ against your own record separately.
227
+
228
+ ## Bounds
229
+
230
+ Everything that can grow is capped, and hitting a cap is said rather than
231
+ silently truncating: 500 entries per post, 200 lines per entry, 200 000 lines
232
+ scanned by one query (past that it refuses and names the range to narrow),
233
+ 20 000 rows per reconciliation side, 64 MB per statement file, 256 KB per
234
+ template.
235
+
236
+ ## Testing
237
+
238
+ `bun test packages/tool-ledger`. Nothing in the suite opens a socket or reads
239
+ a wall clock — `_setClock` is the one seam, because `postedAt` is inside the
240
+ hash chain. The transaction guarantees are tested by aborting inside the
241
+ transaction and asserting the rows, the head *and* the claim all rolled back,
242
+ and by holding the write lock on a second connection and asserting the failure
243
+ names the lock rather than merely happening.
@@ -0,0 +1,15 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ import { LedgerError } from "./lib/amount";
3
+ /**
4
+ * Replace the clock. Tests drive this so that `postedAt` — which is inside the
5
+ * hashed payload — is fixed, and so no assertion in this package depends on
6
+ * wall-clock time.
7
+ */
8
+ export declare function _setClock(fn: () => number): void;
9
+ export declare const ledgerPost: RegisteredTool;
10
+ export declare const ledgerQuery: RegisteredTool;
11
+ export declare const ledgerReconcile: RegisteredTool;
12
+ export declare const invoiceRender: RegisteredTool;
13
+ export { LedgerError };
14
+ /** Every tool this package registers, in the order a catalog should list them. */
15
+ export declare const LEDGER_TOOLS: ReadonlyArray<RegisteredTool>;