@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/dist/lib/db.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
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
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
33
|
+
import { dirname } from "node:path";
|
|
34
|
+
import { LedgerError, byString, canonicalJson, sha256Hex } from "./amount";
|
|
35
|
+
/** Bumped when the on-disk shape changes. A mismatch is refused, never migrated silently. */
|
|
36
|
+
export const SCHEMA_VERSION = 1;
|
|
37
|
+
/** The `prev_hash` of the first entry. Nothing precedes it. */
|
|
38
|
+
export const GENESIS_HASH = "0".repeat(64);
|
|
39
|
+
/** How long a writer waits for another writer's lock before giving up. */
|
|
40
|
+
export const DEFAULT_BUSY_TIMEOUT_MS = 5_000;
|
|
41
|
+
/** The five account types, and the side each one increases on. */
|
|
42
|
+
export const ACCOUNT_TYPES = ["asset", "liability", "equity", "income", "expense"];
|
|
43
|
+
/**
|
|
44
|
+
* Which side an account's balance is normally on.
|
|
45
|
+
*
|
|
46
|
+
* This lives with the chart of accounts, never in a query. A P&L that decides
|
|
47
|
+
* the sign of "revenue" by looking at the account's name is a report that is
|
|
48
|
+
* wrong the first time somebody calls an account "Revenue refunds".
|
|
49
|
+
*/
|
|
50
|
+
export const NORMAL_BALANCE = Object.freeze({
|
|
51
|
+
asset: "debit",
|
|
52
|
+
expense: "debit",
|
|
53
|
+
liability: "credit",
|
|
54
|
+
equity: "credit",
|
|
55
|
+
income: "credit",
|
|
56
|
+
});
|
|
57
|
+
const SCHEMA = `
|
|
58
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
59
|
+
key TEXT PRIMARY KEY,
|
|
60
|
+
value TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
63
|
+
code TEXT PRIMARY KEY,
|
|
64
|
+
name TEXT NOT NULL,
|
|
65
|
+
type TEXT NOT NULL CHECK (type IN ('asset','liability','equity','income','expense'))
|
|
66
|
+
);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
68
|
+
seq INTEGER PRIMARY KEY,
|
|
69
|
+
entry_id TEXT NOT NULL UNIQUE,
|
|
70
|
+
date TEXT NOT NULL,
|
|
71
|
+
memo TEXT NOT NULL,
|
|
72
|
+
reference TEXT NOT NULL,
|
|
73
|
+
tags TEXT NOT NULL,
|
|
74
|
+
source_system TEXT,
|
|
75
|
+
source_id TEXT,
|
|
76
|
+
base_currency TEXT NOT NULL,
|
|
77
|
+
posted_at TEXT NOT NULL,
|
|
78
|
+
prev_hash TEXT NOT NULL,
|
|
79
|
+
hash TEXT NOT NULL
|
|
80
|
+
);
|
|
81
|
+
-- A partial index: the pair is unique WHEN there is a source, and an entry
|
|
82
|
+
-- posted by hand has neither, so a thousand manual entries do not collide on
|
|
83
|
+
-- (NULL, NULL). This is the constraint that actually stops a double post; the
|
|
84
|
+
-- SELECT in the same transaction is the readable half of the same rule.
|
|
85
|
+
CREATE UNIQUE INDEX IF NOT EXISTS entries_source
|
|
86
|
+
ON entries (source_system, source_id)
|
|
87
|
+
WHERE source_system IS NOT NULL AND source_id IS NOT NULL;
|
|
88
|
+
CREATE INDEX IF NOT EXISTS entries_date ON entries (date);
|
|
89
|
+
CREATE TABLE IF NOT EXISTS lines (
|
|
90
|
+
entry_seq INTEGER NOT NULL REFERENCES entries (seq),
|
|
91
|
+
idx INTEGER NOT NULL,
|
|
92
|
+
account TEXT NOT NULL,
|
|
93
|
+
debit_minor TEXT NOT NULL,
|
|
94
|
+
credit_minor TEXT NOT NULL,
|
|
95
|
+
currency TEXT NOT NULL,
|
|
96
|
+
exponent INTEGER NOT NULL,
|
|
97
|
+
fx_rate TEXT,
|
|
98
|
+
base_debit_minor TEXT NOT NULL,
|
|
99
|
+
base_credit_minor TEXT NOT NULL,
|
|
100
|
+
counterparty TEXT,
|
|
101
|
+
due_date TEXT,
|
|
102
|
+
line_memo TEXT,
|
|
103
|
+
PRIMARY KEY (entry_seq, idx)
|
|
104
|
+
);
|
|
105
|
+
CREATE INDEX IF NOT EXISTS lines_account ON lines (account);
|
|
106
|
+
CREATE TABLE IF NOT EXISTS idempotency (
|
|
107
|
+
key TEXT PRIMARY KEY,
|
|
108
|
+
payload_hash TEXT NOT NULL,
|
|
109
|
+
result TEXT NOT NULL,
|
|
110
|
+
claimed_at TEXT NOT NULL
|
|
111
|
+
);
|
|
112
|
+
CREATE TABLE IF NOT EXISTS sequences (
|
|
113
|
+
name TEXT PRIMARY KEY,
|
|
114
|
+
next INTEGER NOT NULL,
|
|
115
|
+
start INTEGER NOT NULL
|
|
116
|
+
);
|
|
117
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
118
|
+
number TEXT PRIMARY KEY,
|
|
119
|
+
kind TEXT NOT NULL,
|
|
120
|
+
sequence_name TEXT NOT NULL,
|
|
121
|
+
ordinal INTEGER NOT NULL,
|
|
122
|
+
issue_date TEXT NOT NULL,
|
|
123
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
124
|
+
payload_hash TEXT NOT NULL,
|
|
125
|
+
files TEXT NOT NULL,
|
|
126
|
+
created_at TEXT NOT NULL
|
|
127
|
+
);
|
|
128
|
+
`;
|
|
129
|
+
/**
|
|
130
|
+
* Open the ledger file.
|
|
131
|
+
*
|
|
132
|
+
* `read` opens with SQLite's own read-only flag rather than a promise to
|
|
133
|
+
* behave: `LedgerQuery` and `LedgerReconcile` are declared read-only tools,
|
|
134
|
+
* and a declaration the database does not enforce is a comment. It also means
|
|
135
|
+
* a query against a ledger that does not exist is a refusal naming the path,
|
|
136
|
+
* not an empty ledger conjured into existence by the act of reading it.
|
|
137
|
+
*/
|
|
138
|
+
export function openDb(realPath, mode, busyTimeoutMs,
|
|
139
|
+
/**
|
|
140
|
+
* What to call the file in a message. Every other refusal in this package
|
|
141
|
+
* names the workspace-relative path, and this one used to name the absolute
|
|
142
|
+
* one — putting the machine's home directory into a result that goes
|
|
143
|
+
* straight into a model's context. Defaults to the real path for the
|
|
144
|
+
* in-memory and direct-library callers, which have no relative form.
|
|
145
|
+
*/
|
|
146
|
+
label = realPath) {
|
|
147
|
+
if (mode === "read") {
|
|
148
|
+
// Checked before the open rather than after: SQLite defers the real work,
|
|
149
|
+
// so a missing file surfaces as a confusing error on the first query
|
|
150
|
+
// instead of here, where the path can still be named.
|
|
151
|
+
if (!existsSync(realPath)) {
|
|
152
|
+
throw new LedgerError(`no ledger at "${label}" — LedgerPost creates one; a read will not, because an empty ledger that answers "balance: 0" is worse than a refusal`);
|
|
153
|
+
}
|
|
154
|
+
const db = new Database(realPath, { readonly: true });
|
|
155
|
+
db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
156
|
+
assertSchemaVersion(db);
|
|
157
|
+
return db;
|
|
158
|
+
}
|
|
159
|
+
mkdirSync(dirname(realPath), { recursive: true });
|
|
160
|
+
const db = new Database(realPath, { create: true });
|
|
161
|
+
// WAL so a reader is never blocked by the writer, and the busy timeout so a
|
|
162
|
+
// second writer waits for the lock instead of failing the posting outright.
|
|
163
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
164
|
+
db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
165
|
+
db.run("PRAGMA foreign_keys = ON");
|
|
166
|
+
// The DDL runs only when the tables are absent. `CREATE TABLE IF NOT EXISTS`
|
|
167
|
+
// is a no-op on an existing schema but still wants the write lock, so a
|
|
168
|
+
// second writer would fail HERE — with a raw SQLITE_BUSY from an open, not
|
|
169
|
+
// with the sentence `inImmediateTransaction` produces — and the caller would
|
|
170
|
+
// be told the wrong thing about what went wrong.
|
|
171
|
+
const built = db
|
|
172
|
+
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entries'")
|
|
173
|
+
.get();
|
|
174
|
+
if (built === null) {
|
|
175
|
+
db.run(SCHEMA);
|
|
176
|
+
writeMeta(db, "schemaVersion", String(SCHEMA_VERSION));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
assertSchemaVersion(db);
|
|
180
|
+
}
|
|
181
|
+
return db;
|
|
182
|
+
}
|
|
183
|
+
function assertSchemaVersion(db) {
|
|
184
|
+
const found = readMeta(db, "schemaVersion");
|
|
185
|
+
if (found === null) {
|
|
186
|
+
throw new LedgerError("this file is not a crewhaus ledger (it has no schema version) — refusing to write into a database somebody else owns");
|
|
187
|
+
}
|
|
188
|
+
if (found !== String(SCHEMA_VERSION)) {
|
|
189
|
+
throw new LedgerError(`the ledger was written by schema version ${found} and this build speaks ${SCHEMA_VERSION} — refusing rather than migrating a book of record without being asked`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export function readMeta(db, key) {
|
|
193
|
+
const row = db.query("SELECT value FROM meta WHERE key = ?").get(key);
|
|
194
|
+
return row === null ? null : row.value;
|
|
195
|
+
}
|
|
196
|
+
export function writeMeta(db, key, value) {
|
|
197
|
+
db.run("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?", [
|
|
198
|
+
key,
|
|
199
|
+
value,
|
|
200
|
+
value,
|
|
201
|
+
]);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The head is stored, not derived.
|
|
205
|
+
*
|
|
206
|
+
* A prefix of a valid hash chain is itself a valid hash chain, so recomputing
|
|
207
|
+
* the head from the rows cannot tell a complete ledger from one somebody
|
|
208
|
+
* truncated. The stored head can: `length` disagreeing with the row count is
|
|
209
|
+
* the only evidence that entries were removed.
|
|
210
|
+
*/
|
|
211
|
+
export function readHead(db) {
|
|
212
|
+
const hash = readMeta(db, "chainHead") ?? GENESIS_HASH;
|
|
213
|
+
const length = Number(readMeta(db, "chainLength") ?? "0");
|
|
214
|
+
return { hash, length };
|
|
215
|
+
}
|
|
216
|
+
/** The bytes one entry's hash is taken over. Order is fixed by canonicalJson. */
|
|
217
|
+
export function entryHash(prevHash, payload) {
|
|
218
|
+
return sha256Hex(`${prevHash}\n${canonicalJson(payload)}`);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Walk the chain from the genesis hash and report every break.
|
|
222
|
+
*
|
|
223
|
+
* Reports ALL of them rather than the first: "the chain broke at entry 4" and
|
|
224
|
+
* "the chain broke at entries 4, 900 and 901" are different findings, and the
|
|
225
|
+
* second one is the one that means somebody edited the file.
|
|
226
|
+
*/
|
|
227
|
+
export function verifyChain(db, payloadOf) {
|
|
228
|
+
const entries = db.query("SELECT * FROM entries ORDER BY seq ASC").all();
|
|
229
|
+
const lines = db.query("SELECT * FROM lines ORDER BY entry_seq ASC, idx ASC").all();
|
|
230
|
+
const byEntry = new Map();
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
const bucket = byEntry.get(line.entry_seq);
|
|
233
|
+
if (bucket === undefined)
|
|
234
|
+
byEntry.set(line.entry_seq, [line]);
|
|
235
|
+
else
|
|
236
|
+
bucket.push(line);
|
|
237
|
+
}
|
|
238
|
+
const problems = [];
|
|
239
|
+
let prev = GENESIS_HASH;
|
|
240
|
+
let expectedSeq = 1;
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (entry.seq !== expectedSeq) {
|
|
243
|
+
problems.push({
|
|
244
|
+
seq: entry.seq,
|
|
245
|
+
reason: `sequence jumps from ${expectedSeq - 1} to ${entry.seq}; entries were removed`,
|
|
246
|
+
});
|
|
247
|
+
expectedSeq = entry.seq;
|
|
248
|
+
}
|
|
249
|
+
if (entry.prev_hash !== prev) {
|
|
250
|
+
problems.push({
|
|
251
|
+
seq: entry.seq,
|
|
252
|
+
reason: `prev_hash ${entry.prev_hash.slice(0, 12)}… does not follow ${prev.slice(0, 12)}…`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const recomputed = entryHash(entry.prev_hash, payloadOf(entry, byEntry.get(entry.seq) ?? []));
|
|
256
|
+
if (recomputed !== entry.hash) {
|
|
257
|
+
problems.push({
|
|
258
|
+
seq: entry.seq,
|
|
259
|
+
reason: `the stored hash does not match the entry's contents — this entry was edited after it was posted`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
prev = entry.hash;
|
|
263
|
+
expectedSeq += 1;
|
|
264
|
+
}
|
|
265
|
+
const stored = readHead(db);
|
|
266
|
+
if (stored.hash !== prev) {
|
|
267
|
+
problems.push({
|
|
268
|
+
seq: null,
|
|
269
|
+
reason: `the recorded head ${stored.hash.slice(0, 12)}… is not the last entry's hash ${prev.slice(0, 12)}… — entries were removed from the end`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (stored.length !== entries.length) {
|
|
273
|
+
problems.push({
|
|
274
|
+
seq: null,
|
|
275
|
+
reason: `the ledger records ${stored.length} entries and ${entries.length} are present`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
ok: problems.length === 0,
|
|
280
|
+
entries: entries.length,
|
|
281
|
+
head: prev,
|
|
282
|
+
storedHead: stored.hash,
|
|
283
|
+
storedLength: stored.length,
|
|
284
|
+
problems: problems.sort((a, b) => (a.seq ?? 1e9) - (b.seq ?? 1e9) || byString(a.reason, b.reason)),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* A rollback asked for on purpose.
|
|
289
|
+
*
|
|
290
|
+
* `dryRun` runs the real posting — every validation, every insert, the chain
|
|
291
|
+
* advance, the claim — and then throws this to undo it. Running a separate
|
|
292
|
+
* "check only" path instead is how a dry run comes back clean and the real
|
|
293
|
+
* call fails: the two paths drift, and the one nobody exercises is the one
|
|
294
|
+
* that matters.
|
|
295
|
+
*/
|
|
296
|
+
export class DryRunRollback extends Error {
|
|
297
|
+
payload;
|
|
298
|
+
constructor(payload) {
|
|
299
|
+
super("dry run: rolled back on purpose");
|
|
300
|
+
this.name = "DryRunRollback";
|
|
301
|
+
this.payload = payload;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Run `work` inside one `BEGIN IMMEDIATE`, and turn SQLite's lock failure into
|
|
306
|
+
* a sentence that names the cause.
|
|
307
|
+
*
|
|
308
|
+
* The distinction matters for a caller deciding whether to retry: "another
|
|
309
|
+
* writer holds the ledger" is worth retrying and "debits do not equal credits"
|
|
310
|
+
* never is, and a bare `SQLITE_BUSY` stack trace tells a model neither.
|
|
311
|
+
*/
|
|
312
|
+
export function inImmediateTransaction(db, work) {
|
|
313
|
+
const tx = db.transaction(work);
|
|
314
|
+
try {
|
|
315
|
+
return tx.immediate();
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
if (err instanceof DryRunRollback)
|
|
319
|
+
throw err;
|
|
320
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
321
|
+
if (/SQLITE_BUSY|database is locked|database table is locked/i.test(message)) {
|
|
322
|
+
throw new LedgerError(`another writer holds the ledger's write lock and it did not clear within the busy timeout — nothing was posted, the claim was not taken and the chain head did not move; retry the call unchanged (${message})`);
|
|
323
|
+
}
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invoices: the totals, the logic-less template, and the number.
|
|
3
|
+
*
|
|
4
|
+
* THE NUMBER IS THE HARD PART. A legally required document sequence has to be
|
|
5
|
+
* gap-free and monotonic, and the two obvious orderings both break it:
|
|
6
|
+
* allocate the number and then write the file, and a crash in between burns a
|
|
7
|
+
* number forever; write the file and then allocate, and two concurrent calls
|
|
8
|
+
* write two documents with the same number. What works is neither: allocate
|
|
9
|
+
* the number AND record the document — its idempotency key, and the hash of
|
|
10
|
+
* everything that determines its bytes — in one `BEGIN IMMEDIATE`
|
|
11
|
+
* transaction, then render. If the render dies, the same call repeated with
|
|
12
|
+
* the same key gets the SAME number back and re-renders the SAME bytes,
|
|
13
|
+
* because the rendering is a pure function of the recorded payload. Nothing is
|
|
14
|
+
* burnt and nothing is duplicated. `resetYearly` takes its year from
|
|
15
|
+
* `issueDate`, never from the clock, or an invoice back-dated across New Year
|
|
16
|
+
* lands in the wrong sequence.
|
|
17
|
+
*
|
|
18
|
+
* THE SECOND HARD PART IS BYTES. `Intl.NumberFormat` output moves with the
|
|
19
|
+
* ICU version the runtime was built against, so an invoice rendered on a
|
|
20
|
+
* developer's Mac and on a CI container can differ in the separator between
|
|
21
|
+
* thousands — on a document somebody files. This package therefore has no
|
|
22
|
+
* locale formatter at all: amounts are rendered from exact decimal strings
|
|
23
|
+
* with separators the caller names, and dates are ISO. Nothing here can move
|
|
24
|
+
* with an ICU upgrade because nothing here asks ICU anything.
|
|
25
|
+
*/
|
|
26
|
+
import type { Database } from "bun:sqlite";
|
|
27
|
+
import { byString, exponentFor } from "./amount";
|
|
28
|
+
export declare const DOCUMENT_KINDS: readonly ["invoice", "receipt", "credit_note", "quote"];
|
|
29
|
+
export type DocumentKind = (typeof DOCUMENT_KINDS)[number];
|
|
30
|
+
export declare const OUTPUT_FORMATS: readonly ["html", "markdown", "json"];
|
|
31
|
+
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
|
32
|
+
export declare const INVOICE_LIMITS: {
|
|
33
|
+
readonly lines: 1000;
|
|
34
|
+
readonly templateBytes: number;
|
|
35
|
+
};
|
|
36
|
+
export type PartyInput = {
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly address?: ReadonlyArray<string>;
|
|
39
|
+
readonly taxId?: string;
|
|
40
|
+
readonly email?: string;
|
|
41
|
+
readonly phone?: string;
|
|
42
|
+
};
|
|
43
|
+
export type InvoiceLineInput = {
|
|
44
|
+
readonly description: string;
|
|
45
|
+
readonly quantity?: string;
|
|
46
|
+
readonly unitAmount?: string;
|
|
47
|
+
readonly unitAmountMinor?: string | number;
|
|
48
|
+
readonly discount?: string;
|
|
49
|
+
readonly discountMinor?: string | number;
|
|
50
|
+
readonly taxRateBps?: number;
|
|
51
|
+
readonly taxMinor?: string | number;
|
|
52
|
+
};
|
|
53
|
+
export type NumberFormatOptions = {
|
|
54
|
+
readonly decimalSeparator?: string;
|
|
55
|
+
readonly groupSeparator?: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Minor units as the document shows them. No `Intl`, on purpose (see above):
|
|
59
|
+
* the digits come from exact bigint arithmetic and the separators come from
|
|
60
|
+
* the caller, so the same input renders the same bytes on every machine.
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatAmount(minor: bigint, exponent: number, options?: NumberFormatOptions): string;
|
|
63
|
+
export type ComputedLine = {
|
|
64
|
+
readonly index: number;
|
|
65
|
+
readonly description: string;
|
|
66
|
+
readonly quantity: string;
|
|
67
|
+
readonly unitAmountMinor: string;
|
|
68
|
+
readonly netMinor: string;
|
|
69
|
+
readonly discountMinor: string;
|
|
70
|
+
readonly taxMinor: string;
|
|
71
|
+
readonly taxRateBps: number | null;
|
|
72
|
+
readonly totalMinor: string;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* The four figures a document shows, in the order it shows them and with the
|
|
76
|
+
* arithmetic a reader will do on them:
|
|
77
|
+
*
|
|
78
|
+
* subtotal − discount + tax = total
|
|
79
|
+
*
|
|
80
|
+
* `subtotalMinor` is therefore the sum of the lines BEFORE discount. Making it
|
|
81
|
+
* the net instead — the sum after discount — leaves every individual figure
|
|
82
|
+
* defensible and the document as a whole wrong: 90.00 subtotal, 10.00
|
|
83
|
+
* discount, 18.00 tax and a 108.00 total is a page whose own sum is 98.00, and
|
|
84
|
+
* the first person to check the arithmetic is the customer being asked to pay
|
|
85
|
+
* it. `netMinor` is carried alongside for anybody who wants the after-discount
|
|
86
|
+
* figure without subtracting.
|
|
87
|
+
*/
|
|
88
|
+
export type ComputedTotals = {
|
|
89
|
+
readonly subtotalMinor: string;
|
|
90
|
+
readonly discountMinor: string;
|
|
91
|
+
readonly netMinor: string;
|
|
92
|
+
readonly taxMinor: string;
|
|
93
|
+
readonly totalMinor: string;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Totals are computed, never accepted.
|
|
97
|
+
*
|
|
98
|
+
* A total supplied alongside the lines is a second source of truth, and the
|
|
99
|
+
* one that gets believed is the one a model wrote. Quantities are exact
|
|
100
|
+
* decimals so "1.5 hours" is not a float, and each line rounds once.
|
|
101
|
+
*
|
|
102
|
+
* Tax here is a flat per-line rate, which covers the ordinary case. For
|
|
103
|
+
* compound rates, exemptions, reverse charge or per-invoice rounding scope,
|
|
104
|
+
* run `@crewhaus/tool-money`'s `TaxCalculate` and pass the answer in as
|
|
105
|
+
* `taxMinor` — this refuses both at once rather than guessing which wins.
|
|
106
|
+
*/
|
|
107
|
+
export declare function computeInvoice(lines: ReadonlyArray<InvoiceLineInput>, exponent: number): {
|
|
108
|
+
lines: ComputedLine[];
|
|
109
|
+
totals: ComputedTotals;
|
|
110
|
+
};
|
|
111
|
+
export type NumberingConfig = {
|
|
112
|
+
readonly prefix: string;
|
|
113
|
+
readonly pad: number;
|
|
114
|
+
readonly resetYearly: boolean;
|
|
115
|
+
readonly start: number;
|
|
116
|
+
};
|
|
117
|
+
export type Allocation = {
|
|
118
|
+
readonly number: string;
|
|
119
|
+
readonly ordinal: number;
|
|
120
|
+
readonly sequenceName: string;
|
|
121
|
+
readonly replayed: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* Whether the sequence has issued every number between its start and its
|
|
124
|
+
* next — or `null` when there is no sequence to check, which is the case for
|
|
125
|
+
* a number the caller assigned. `null` is not `false` and it is emphatically
|
|
126
|
+
* not `true`: "I could not check this" has to survive to the caller, because
|
|
127
|
+
* a gap in a document sequence is a finding in most jurisdictions and a
|
|
128
|
+
* confident `true` from a check that never ran is how it gets missed.
|
|
129
|
+
*/
|
|
130
|
+
readonly gapFree: boolean | null;
|
|
131
|
+
readonly files: ReadonlyArray<string>;
|
|
132
|
+
};
|
|
133
|
+
/** `INV-` + the issue date's year when the sequence resets yearly. */
|
|
134
|
+
export declare function sequenceNameFor(kind: DocumentKind, config: NumberingConfig, issueDate: string): string;
|
|
135
|
+
export declare function numberFor(config: NumberingConfig, issueDate: string, ordinal: number): string;
|
|
136
|
+
/**
|
|
137
|
+
* A document number is also a FILE NAME, so it is contained like one.
|
|
138
|
+
*
|
|
139
|
+
* `outDir` goes through `resolveSafe`, but the leaf does not — the file is
|
|
140
|
+
* `${number}.${format}` under that directory — and the number is
|
|
141
|
+
* caller-supplied twice over: directly as `number`, and indirectly through
|
|
142
|
+
* `numbering.prefix`. Without this check `number: "../../escaped"` writes
|
|
143
|
+
* outside the workspace root, and because the write finishes with a rename it
|
|
144
|
+
* silently REPLACES whatever was already there. Containing the directory and
|
|
145
|
+
* not the leaf contains nothing.
|
|
146
|
+
*
|
|
147
|
+
* Called before the sequence is touched, for the reason the rest of this file
|
|
148
|
+
* is shaped the way it is: a refusal must not cost a number.
|
|
149
|
+
*/
|
|
150
|
+
export declare function assertNumberIsAFileName(number: string, where: string): string;
|
|
151
|
+
/**
|
|
152
|
+
* Take the next number and record the document, in one transaction.
|
|
153
|
+
*
|
|
154
|
+
* The idempotency key is what makes a retry safe. A second call with the same
|
|
155
|
+
* key and the same payload gets the same number back and posts nothing; a
|
|
156
|
+
* second call with the same key and a DIFFERENT payload is refused, because
|
|
157
|
+
* that is somebody re-using a key for a new document and the alternative is
|
|
158
|
+
* two different invoices claiming one number.
|
|
159
|
+
*/
|
|
160
|
+
export declare function allocateDocument(db: Database, args: {
|
|
161
|
+
readonly kind: DocumentKind;
|
|
162
|
+
readonly config: NumberingConfig;
|
|
163
|
+
readonly issueDate: string;
|
|
164
|
+
readonly idempotencyKey: string;
|
|
165
|
+
readonly payloadHash: string;
|
|
166
|
+
readonly files: ReadonlyArray<string>;
|
|
167
|
+
readonly createdAt: string;
|
|
168
|
+
}): Allocation;
|
|
169
|
+
/**
|
|
170
|
+
* Markdown output escapes a pipe, because a pipe ENDS A TABLE CELL.
|
|
171
|
+
*
|
|
172
|
+
* A description of "Widget | Deluxe" renders six cells into a five-column
|
|
173
|
+
* table: the reader sees "Deluxe" as the quantity, the unit amount as the tax,
|
|
174
|
+
* and — because the last cell is dropped — a Total of 0.00 on a line worth a
|
|
175
|
+
* hundred. Nothing computes a wrong number; the document simply shows the
|
|
176
|
+
* wrong one. `renderRows` in ./query escapes the same character for the same
|
|
177
|
+
* reason, and this is the invoice's copy of that rule.
|
|
178
|
+
*/
|
|
179
|
+
export declare function escapeMarkdown(text: string): string;
|
|
180
|
+
export declare function escapeHtml(text: string): string;
|
|
181
|
+
/**
|
|
182
|
+
* A logic-less template: variables, array sections, inverted sections. No
|
|
183
|
+
* partials, no lambdas, no expressions — a template that can compute is a
|
|
184
|
+
* template that can compute a total, and totals come from `computeInvoice`.
|
|
185
|
+
*
|
|
186
|
+
* `strict` (on by default) refuses an unknown path instead of rendering the
|
|
187
|
+
* empty string. Mustache's silence is the right default for a web page and
|
|
188
|
+
* the wrong one for an invoice: a mistyped `{{seller.taxId}}` would drop the
|
|
189
|
+
* VAT number off the document and nothing would say so.
|
|
190
|
+
*
|
|
191
|
+
* Only ARRAYS iterate. A plain object never does, because the order it would
|
|
192
|
+
* iterate in is insertion order, and that is not a property of the document.
|
|
193
|
+
*/
|
|
194
|
+
export declare function renderTemplate(template: string, data: Record<string, unknown>, options: {
|
|
195
|
+
readonly escape: (text: string) => string;
|
|
196
|
+
readonly strict: boolean;
|
|
197
|
+
}): string;
|
|
198
|
+
/** Column order in the built-in templates, written out rather than iterated. */
|
|
199
|
+
export declare const LINE_COLUMNS: readonly ["description", "quantity", "unitAmount", "tax", "total"];
|
|
200
|
+
export declare const BUILTIN_TEMPLATES: Readonly<Record<"html" | "markdown", string>>;
|
|
201
|
+
/** The object a template sees, and the `json` output verbatim. */
|
|
202
|
+
export declare function documentModel(args: {
|
|
203
|
+
readonly kind: DocumentKind;
|
|
204
|
+
readonly number: string;
|
|
205
|
+
readonly currency: string;
|
|
206
|
+
readonly exponent: number;
|
|
207
|
+
readonly issueDate: string;
|
|
208
|
+
readonly dueDate?: string;
|
|
209
|
+
readonly reference?: string;
|
|
210
|
+
readonly notes?: string;
|
|
211
|
+
readonly paymentInstructions?: string;
|
|
212
|
+
readonly seller: PartyInput;
|
|
213
|
+
readonly buyer: PartyInput;
|
|
214
|
+
readonly computed: {
|
|
215
|
+
lines: ComputedLine[];
|
|
216
|
+
totals: ComputedTotals;
|
|
217
|
+
};
|
|
218
|
+
readonly numberFormat: NumberFormatOptions;
|
|
219
|
+
}): Record<string, unknown>;
|
|
220
|
+
/** Validate the dates an invoice carries, in one place. */
|
|
221
|
+
export declare function validateInvoiceDates(issueDate: string, dueDate?: string): void;
|
|
222
|
+
export { byString, exponentFor };
|