@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 +243 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +888 -0
- package/dist/lib/amount.d.ts +73 -0
- package/dist/lib/amount.js +204 -0
- package/dist/lib/db.d.ts +171 -0
- package/dist/lib/db.js +326 -0
- package/dist/lib/invoice.d.ts +222 -0
- package/dist/lib/invoice.js +500 -0
- package/dist/lib/post.d.ts +129 -0
- package/dist/lib/post.js +440 -0
- package/dist/lib/query.d.ts +62 -0
- package/dist/lib/query.js +480 -0
- package/dist/lib/reconcile.d.ts +117 -0
- package/dist/lib/reconcile.js +335 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +48 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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>;
|