@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/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 };