@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
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { LedgerError, assertIsoDate, byString, exponentFor, formatMajor, multiplyMinor, parseFactor, parseMajor, parseMinorUnits, yearOf, } from "./amount";
|
|
2
|
+
import { inImmediateTransaction } from "./db";
|
|
3
|
+
export const DOCUMENT_KINDS = ["invoice", "receipt", "credit_note", "quote"];
|
|
4
|
+
export const OUTPUT_FORMATS = ["html", "markdown", "json"];
|
|
5
|
+
export const INVOICE_LIMITS = {
|
|
6
|
+
lines: 1_000,
|
|
7
|
+
templateBytes: 256 * 1024,
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Minor units as the document shows them. No `Intl`, on purpose (see above):
|
|
11
|
+
* the digits come from exact bigint arithmetic and the separators come from
|
|
12
|
+
* the caller, so the same input renders the same bytes on every machine.
|
|
13
|
+
*/
|
|
14
|
+
export function formatAmount(minor, exponent, options = {}) {
|
|
15
|
+
const plain = formatMajor(minor, exponent);
|
|
16
|
+
const negative = plain.startsWith("-");
|
|
17
|
+
const body = negative ? plain.slice(1) : plain;
|
|
18
|
+
const dot = body.indexOf(".");
|
|
19
|
+
let whole = dot === -1 ? body : body.slice(0, dot);
|
|
20
|
+
const fraction = dot === -1 ? "" : body.slice(dot + 1);
|
|
21
|
+
const group = options.groupSeparator ?? "";
|
|
22
|
+
if (group !== "")
|
|
23
|
+
whole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, group);
|
|
24
|
+
const decimal = options.decimalSeparator ?? ".";
|
|
25
|
+
return `${negative ? "-" : ""}${whole}${fraction === "" ? "" : decimal + fraction}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Totals are computed, never accepted.
|
|
29
|
+
*
|
|
30
|
+
* A total supplied alongside the lines is a second source of truth, and the
|
|
31
|
+
* one that gets believed is the one a model wrote. Quantities are exact
|
|
32
|
+
* decimals so "1.5 hours" is not a float, and each line rounds once.
|
|
33
|
+
*
|
|
34
|
+
* Tax here is a flat per-line rate, which covers the ordinary case. For
|
|
35
|
+
* compound rates, exemptions, reverse charge or per-invoice rounding scope,
|
|
36
|
+
* run `@crewhaus/tool-money`'s `TaxCalculate` and pass the answer in as
|
|
37
|
+
* `taxMinor` — this refuses both at once rather than guessing which wins.
|
|
38
|
+
*/
|
|
39
|
+
export function computeInvoice(lines, exponent) {
|
|
40
|
+
if (lines.length === 0)
|
|
41
|
+
throw new LedgerError("an invoice with no lines is not a document");
|
|
42
|
+
if (lines.length > INVOICE_LIMITS.lines) {
|
|
43
|
+
throw new LedgerError(`${lines.length} lines is over the ${INVOICE_LIMITS.lines} limit`);
|
|
44
|
+
}
|
|
45
|
+
const computed = [];
|
|
46
|
+
let grossTotal = 0n;
|
|
47
|
+
let discountTotal = 0n;
|
|
48
|
+
let taxTotal = 0n;
|
|
49
|
+
for (const [index, line] of lines.entries()) {
|
|
50
|
+
const at = `line ${index}`;
|
|
51
|
+
// A description is one table cell. A line break inside it splits the row
|
|
52
|
+
// and shifts every column after it, so the Total column ends up showing
|
|
53
|
+
// the Tax — a wrong number on a document somebody pays from, arrived at
|
|
54
|
+
// without a single wrong figure being computed.
|
|
55
|
+
if (/[\r\n]/.test(line.description)) {
|
|
56
|
+
throw new LedgerError(`${at} has a line break inside its description — a description is one cell of one row, and a break would push the amounts into the wrong columns; put the detail in "notes"`);
|
|
57
|
+
}
|
|
58
|
+
if (line.unitAmount !== undefined && line.unitAmountMinor !== undefined) {
|
|
59
|
+
throw new LedgerError(`${at} gives both unitAmount and unitAmountMinor — pick one spelling`);
|
|
60
|
+
}
|
|
61
|
+
const unit = line.unitAmount !== undefined
|
|
62
|
+
? parseMajor(line.unitAmount, exponent, `${at} unitAmount`)
|
|
63
|
+
: line.unitAmountMinor !== undefined
|
|
64
|
+
? parseMinorUnits(line.unitAmountMinor, `${at} unitAmountMinor`)
|
|
65
|
+
: null;
|
|
66
|
+
if (unit === null)
|
|
67
|
+
throw new LedgerError(`${at} has no unit amount`);
|
|
68
|
+
const quantity = parseFactor(line.quantity ?? "1", `${at} quantity`);
|
|
69
|
+
if (quantity.unscaled < 0n) {
|
|
70
|
+
throw new LedgerError(`${at} has a negative quantity — a return is a credit note, which is a different document kind`);
|
|
71
|
+
}
|
|
72
|
+
const gross = multiplyMinor(unit, quantity);
|
|
73
|
+
if (line.discount !== undefined && line.discountMinor !== undefined) {
|
|
74
|
+
throw new LedgerError(`${at} gives both discount and discountMinor — pick one spelling`);
|
|
75
|
+
}
|
|
76
|
+
const discount = line.discount !== undefined
|
|
77
|
+
? parseMajor(line.discount, exponent, `${at} discount`)
|
|
78
|
+
: line.discountMinor !== undefined
|
|
79
|
+
? parseMinorUnits(line.discountMinor, `${at} discountMinor`)
|
|
80
|
+
: 0n;
|
|
81
|
+
if (discount < 0n)
|
|
82
|
+
throw new LedgerError(`${at} has a negative discount, which is a surcharge`);
|
|
83
|
+
if (discount > gross) {
|
|
84
|
+
throw new LedgerError(`${at} discounts ${formatMajor(discount, exponent)} off a line worth ${formatMajor(gross, exponent)} — a line cannot be worth less than nothing`);
|
|
85
|
+
}
|
|
86
|
+
const net = gross - discount;
|
|
87
|
+
if (line.taxRateBps !== undefined && line.taxMinor !== undefined) {
|
|
88
|
+
throw new LedgerError(`${at} gives both taxRateBps and taxMinor — two sources of truth for the same number, and this will not choose between them`);
|
|
89
|
+
}
|
|
90
|
+
const tax = line.taxMinor !== undefined
|
|
91
|
+
? parseMinorUnits(line.taxMinor, `${at} taxMinor`)
|
|
92
|
+
: line.taxRateBps !== undefined
|
|
93
|
+
? multiplyMinor(net, { unscaled: BigInt(line.taxRateBps), scale: 4 })
|
|
94
|
+
: 0n;
|
|
95
|
+
if (tax < 0n)
|
|
96
|
+
throw new LedgerError(`${at} has negative tax`);
|
|
97
|
+
grossTotal += gross;
|
|
98
|
+
discountTotal += discount;
|
|
99
|
+
taxTotal += tax;
|
|
100
|
+
computed.push({
|
|
101
|
+
index,
|
|
102
|
+
description: line.description,
|
|
103
|
+
quantity: line.quantity ?? "1",
|
|
104
|
+
unitAmountMinor: unit.toString(),
|
|
105
|
+
netMinor: net.toString(),
|
|
106
|
+
discountMinor: discount.toString(),
|
|
107
|
+
taxMinor: tax.toString(),
|
|
108
|
+
taxRateBps: line.taxRateBps ?? null,
|
|
109
|
+
totalMinor: (net + tax).toString(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// subtotal − discount + tax = total, which is the sum the document invites
|
|
113
|
+
// a reader to check. `net` is the same as the sum of the lines' netMinor,
|
|
114
|
+
// and `total` is the same as the sum of their totalMinor, so the line column
|
|
115
|
+
// and the totals block agree as well.
|
|
116
|
+
const net = grossTotal - discountTotal;
|
|
117
|
+
return {
|
|
118
|
+
lines: computed,
|
|
119
|
+
totals: {
|
|
120
|
+
subtotalMinor: grossTotal.toString(),
|
|
121
|
+
discountMinor: discountTotal.toString(),
|
|
122
|
+
netMinor: net.toString(),
|
|
123
|
+
taxMinor: taxTotal.toString(),
|
|
124
|
+
totalMinor: (net + taxTotal).toString(),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** `INV-` + the issue date's year when the sequence resets yearly. */
|
|
129
|
+
export function sequenceNameFor(kind, config, issueDate) {
|
|
130
|
+
return config.resetYearly ? `${kind}:${yearOf(issueDate)}` : kind;
|
|
131
|
+
}
|
|
132
|
+
export function numberFor(config, issueDate, ordinal) {
|
|
133
|
+
const year = config.resetYearly ? `${yearOf(issueDate)}-` : "";
|
|
134
|
+
return `${config.prefix}${year}${String(ordinal).padStart(config.pad, "0")}`;
|
|
135
|
+
}
|
|
136
|
+
/** Characters a document number may hold, because it also names a file. */
|
|
137
|
+
const FILENAME_SAFE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
138
|
+
/**
|
|
139
|
+
* A document number is also a FILE NAME, so it is contained like one.
|
|
140
|
+
*
|
|
141
|
+
* `outDir` goes through `resolveSafe`, but the leaf does not — the file is
|
|
142
|
+
* `${number}.${format}` under that directory — and the number is
|
|
143
|
+
* caller-supplied twice over: directly as `number`, and indirectly through
|
|
144
|
+
* `numbering.prefix`. Without this check `number: "../../escaped"` writes
|
|
145
|
+
* outside the workspace root, and because the write finishes with a rename it
|
|
146
|
+
* silently REPLACES whatever was already there. Containing the directory and
|
|
147
|
+
* not the leaf contains nothing.
|
|
148
|
+
*
|
|
149
|
+
* Called before the sequence is touched, for the reason the rest of this file
|
|
150
|
+
* is shaped the way it is: a refusal must not cost a number.
|
|
151
|
+
*/
|
|
152
|
+
export function assertNumberIsAFileName(number, where) {
|
|
153
|
+
if (!FILENAME_SAFE.test(number) || number.includes("..")) {
|
|
154
|
+
throw new LedgerError(`${where} ("${number}") cannot be used as a document number: it becomes the name of a file under outDir, so it may hold only letters, digits, ".", "-" and "_", must start with a letter or a digit, and may not contain ".." — a number that walks out of the directory would overwrite a file nobody asked it to touch`);
|
|
155
|
+
}
|
|
156
|
+
return number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Take the next number and record the document, in one transaction.
|
|
160
|
+
*
|
|
161
|
+
* The idempotency key is what makes a retry safe. A second call with the same
|
|
162
|
+
* key and the same payload gets the same number back and posts nothing; a
|
|
163
|
+
* second call with the same key and a DIFFERENT payload is refused, because
|
|
164
|
+
* that is somebody re-using a key for a new document and the alternative is
|
|
165
|
+
* two different invoices claiming one number.
|
|
166
|
+
*/
|
|
167
|
+
export function allocateDocument(db, args) {
|
|
168
|
+
const sequenceName = sequenceNameFor(args.kind, args.config, args.issueDate);
|
|
169
|
+
return inImmediateTransaction(db, () => {
|
|
170
|
+
const existing = db
|
|
171
|
+
.query("SELECT * FROM documents WHERE idempotency_key = ?")
|
|
172
|
+
.get(args.idempotencyKey);
|
|
173
|
+
if (existing !== null) {
|
|
174
|
+
if (existing.payload_hash !== args.payloadHash) {
|
|
175
|
+
throw new LedgerError(`idempotency key "${args.idempotencyKey}" already issued ${existing.number} for different content — a document number identifies one document, so this will not re-use the key; issue a credit note and a new document instead`);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
number: existing.number,
|
|
179
|
+
ordinal: existing.ordinal,
|
|
180
|
+
sequenceName: existing.sequence_name,
|
|
181
|
+
replayed: true,
|
|
182
|
+
gapFree: checkGapFree(db, existing.sequence_name),
|
|
183
|
+
files: JSON.parse(existing.files),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const row = db.query("SELECT next, start FROM sequences WHERE name = ?").get(sequenceName);
|
|
187
|
+
const start = row?.start ?? args.config.start;
|
|
188
|
+
const ordinal = row?.next ?? args.config.start;
|
|
189
|
+
const number = numberFor(args.config, args.issueDate, ordinal);
|
|
190
|
+
db.run("INSERT INTO documents (number, kind, sequence_name, ordinal, issue_date, idempotency_key, payload_hash, files, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [
|
|
191
|
+
number,
|
|
192
|
+
args.kind,
|
|
193
|
+
sequenceName,
|
|
194
|
+
ordinal,
|
|
195
|
+
args.issueDate,
|
|
196
|
+
args.idempotencyKey,
|
|
197
|
+
args.payloadHash,
|
|
198
|
+
JSON.stringify(args.files),
|
|
199
|
+
args.createdAt,
|
|
200
|
+
]);
|
|
201
|
+
db.run("INSERT INTO sequences (name, next, start) VALUES (?, ?, ?) ON CONFLICT (name) DO UPDATE SET next = ?", [sequenceName, ordinal + 1, start, ordinal + 1]);
|
|
202
|
+
return {
|
|
203
|
+
number,
|
|
204
|
+
ordinal,
|
|
205
|
+
sequenceName,
|
|
206
|
+
replayed: false,
|
|
207
|
+
gapFree: checkGapFree(db, sequenceName),
|
|
208
|
+
files: args.files,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Whether the sequence has issued every number between its start and its next.
|
|
214
|
+
*
|
|
215
|
+
* This is a check, not an assumption: the transaction above makes a gap
|
|
216
|
+
* impossible through this tool, and this is what notices if one appears
|
|
217
|
+
* anyway — a row deleted by hand, a restored backup.
|
|
218
|
+
*/
|
|
219
|
+
function checkGapFree(db, sequenceName) {
|
|
220
|
+
const seq = db.query("SELECT next, start FROM sequences WHERE name = ?").get(sequenceName);
|
|
221
|
+
if (seq === null)
|
|
222
|
+
return true;
|
|
223
|
+
const count = db
|
|
224
|
+
.query("SELECT COUNT(*) AS n FROM documents WHERE sequence_name = ?")
|
|
225
|
+
.get(sequenceName);
|
|
226
|
+
return count.n === seq.next - seq.start;
|
|
227
|
+
}
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// the template
|
|
230
|
+
/**
|
|
231
|
+
* Markdown output escapes a pipe, because a pipe ENDS A TABLE CELL.
|
|
232
|
+
*
|
|
233
|
+
* A description of "Widget | Deluxe" renders six cells into a five-column
|
|
234
|
+
* table: the reader sees "Deluxe" as the quantity, the unit amount as the tax,
|
|
235
|
+
* and — because the last cell is dropped — a Total of 0.00 on a line worth a
|
|
236
|
+
* hundred. Nothing computes a wrong number; the document simply shows the
|
|
237
|
+
* wrong one. `renderRows` in ./query escapes the same character for the same
|
|
238
|
+
* reason, and this is the invoice's copy of that rule.
|
|
239
|
+
*/
|
|
240
|
+
export function escapeMarkdown(text) {
|
|
241
|
+
return text.split("|").join("\\|");
|
|
242
|
+
}
|
|
243
|
+
export function escapeHtml(text) {
|
|
244
|
+
return text
|
|
245
|
+
.split("&")
|
|
246
|
+
.join("&")
|
|
247
|
+
.split("<")
|
|
248
|
+
.join("<")
|
|
249
|
+
.split(">")
|
|
250
|
+
.join(">")
|
|
251
|
+
.split('"')
|
|
252
|
+
.join(""")
|
|
253
|
+
.split("'")
|
|
254
|
+
.join("'");
|
|
255
|
+
}
|
|
256
|
+
const TAG = /\{\{(\{)?([#^/]?)\s*([A-Za-z0-9_.]+)\s*(\})?\}\}/g;
|
|
257
|
+
function parseTemplate(template) {
|
|
258
|
+
const root = [];
|
|
259
|
+
const stack = [];
|
|
260
|
+
const push = (node) => {
|
|
261
|
+
const top = stack[stack.length - 1];
|
|
262
|
+
(top === undefined ? root : top.children).push(node);
|
|
263
|
+
};
|
|
264
|
+
let cursor = 0;
|
|
265
|
+
TAG.lastIndex = 0;
|
|
266
|
+
for (let m = TAG.exec(template); m !== null; m = TAG.exec(template)) {
|
|
267
|
+
if (m.index > cursor)
|
|
268
|
+
push({ kind: "text", text: template.slice(cursor, m.index) });
|
|
269
|
+
cursor = m.index + m[0].length;
|
|
270
|
+
const sigil = m[2] ?? "";
|
|
271
|
+
const path = m[3];
|
|
272
|
+
if (sigil === "#" || sigil === "^") {
|
|
273
|
+
const node = { kind: "section", path, inverted: sigil === "^", children: [] };
|
|
274
|
+
push(node);
|
|
275
|
+
stack.push({ path, children: node.children });
|
|
276
|
+
}
|
|
277
|
+
else if (sigil === "/") {
|
|
278
|
+
const top = stack.pop();
|
|
279
|
+
if (top === undefined || top.path !== path) {
|
|
280
|
+
throw new LedgerError(`the template closes {{/${path}}} where ${top === undefined ? "nothing" : `{{#${top.path}}}`} is open — a mismatched section renders silently wrong, so it is refused`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
push({ kind: "var", path, raw: m[1] === "{" });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (cursor < template.length)
|
|
288
|
+
push({ kind: "text", text: template.slice(cursor) });
|
|
289
|
+
const unclosed = stack[stack.length - 1];
|
|
290
|
+
if (unclosed !== undefined) {
|
|
291
|
+
throw new LedgerError(`the template leaves {{#${unclosed.path}}} unclosed`);
|
|
292
|
+
}
|
|
293
|
+
return root;
|
|
294
|
+
}
|
|
295
|
+
function lookup(context, path) {
|
|
296
|
+
for (let i = context.length - 1; i >= 0; i--) {
|
|
297
|
+
let value = context[i];
|
|
298
|
+
if (path === ".")
|
|
299
|
+
return value;
|
|
300
|
+
let found = true;
|
|
301
|
+
for (const segment of path.split(".")) {
|
|
302
|
+
if (value === null || typeof value !== "object" || !(segment in value)) {
|
|
303
|
+
found = false;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
value = value[segment];
|
|
307
|
+
}
|
|
308
|
+
if (found)
|
|
309
|
+
return value;
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* A logic-less template: variables, array sections, inverted sections. No
|
|
315
|
+
* partials, no lambdas, no expressions — a template that can compute is a
|
|
316
|
+
* template that can compute a total, and totals come from `computeInvoice`.
|
|
317
|
+
*
|
|
318
|
+
* `strict` (on by default) refuses an unknown path instead of rendering the
|
|
319
|
+
* empty string. Mustache's silence is the right default for a web page and
|
|
320
|
+
* the wrong one for an invoice: a mistyped `{{seller.taxId}}` would drop the
|
|
321
|
+
* VAT number off the document and nothing would say so.
|
|
322
|
+
*
|
|
323
|
+
* Only ARRAYS iterate. A plain object never does, because the order it would
|
|
324
|
+
* iterate in is insertion order, and that is not a property of the document.
|
|
325
|
+
*/
|
|
326
|
+
export function renderTemplate(template, data, options) {
|
|
327
|
+
if (template.length > INVOICE_LIMITS.templateBytes) {
|
|
328
|
+
throw new LedgerError(`the template is ${template.length} bytes, over the ${INVOICE_LIMITS.templateBytes} limit`);
|
|
329
|
+
}
|
|
330
|
+
const nodes = parseTemplate(template);
|
|
331
|
+
const out = [];
|
|
332
|
+
const walk = (list, context) => {
|
|
333
|
+
for (const node of list) {
|
|
334
|
+
if (node.kind === "text") {
|
|
335
|
+
out.push(node.text);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const value = lookup(context, node.path);
|
|
339
|
+
if (node.kind === "var") {
|
|
340
|
+
if (value === undefined || value === null) {
|
|
341
|
+
if (options.strict) {
|
|
342
|
+
throw new LedgerError(`the template refers to "${node.path}", which this document has no value for — refusing rather than rendering a blank where a tax number or a total should be`);
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const text = String(value);
|
|
347
|
+
out.push(node.raw ? text : options.escape(text));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const truthy = Array.isArray(value) ? value.length > 0 : Boolean(value);
|
|
351
|
+
if (node.inverted) {
|
|
352
|
+
if (!truthy)
|
|
353
|
+
walk(node.children, context);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!truthy)
|
|
357
|
+
continue;
|
|
358
|
+
if (Array.isArray(value)) {
|
|
359
|
+
for (const item of value)
|
|
360
|
+
walk(node.children, [...context, item]);
|
|
361
|
+
}
|
|
362
|
+
else if (typeof value === "object") {
|
|
363
|
+
walk(node.children, [...context, value]);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
walk(node.children, context);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
walk(nodes, [data]);
|
|
371
|
+
return out.join("");
|
|
372
|
+
}
|
|
373
|
+
/** Column order in the built-in templates, written out rather than iterated. */
|
|
374
|
+
export const LINE_COLUMNS = ["description", "quantity", "unitAmount", "tax", "total"];
|
|
375
|
+
export const BUILTIN_TEMPLATES = Object.freeze({
|
|
376
|
+
markdown: `# {{kindLabel}} {{number}}
|
|
377
|
+
|
|
378
|
+
**{{seller.name}}**{{#seller.taxId}} · {{seller.taxId}}{{/seller.taxId}}
|
|
379
|
+
{{#seller.address}}{{.}}
|
|
380
|
+
{{/seller.address}}
|
|
381
|
+
|
|
382
|
+
**Billed to:** {{buyer.name}}{{#buyer.taxId}} · {{buyer.taxId}}{{/buyer.taxId}}
|
|
383
|
+
{{#buyer.address}}{{.}}
|
|
384
|
+
{{/buyer.address}}
|
|
385
|
+
|
|
386
|
+
Issued: {{issueDate}}{{#dueDate}} · Due: {{dueDate}}{{/dueDate}}{{#reference}} · Ref: {{reference}}{{/reference}}
|
|
387
|
+
|
|
388
|
+
| Description | Qty | Unit | Tax | Total |
|
|
389
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
390
|
+
{{#lines}}| {{description}} | {{quantity}} | {{unitAmount}} | {{tax}} | {{total}} |
|
|
391
|
+
{{/lines}}
|
|
392
|
+
|
|
393
|
+
| | |
|
|
394
|
+
| --- | ---: |
|
|
395
|
+
| Subtotal | {{totals.subtotal}} {{currency}} |
|
|
396
|
+
| Discount | {{totals.discount}} {{currency}} |
|
|
397
|
+
| Tax | {{totals.tax}} {{currency}} |
|
|
398
|
+
| **Total** | **{{totals.total}} {{currency}}** |
|
|
399
|
+
{{#notes}}
|
|
400
|
+
|
|
401
|
+
{{notes}}
|
|
402
|
+
{{/notes}}{{#paymentInstructions}}
|
|
403
|
+
|
|
404
|
+
{{paymentInstructions}}
|
|
405
|
+
{{/paymentInstructions}}
|
|
406
|
+
`,
|
|
407
|
+
html: `<!doctype html>
|
|
408
|
+
<html lang="en"><head><meta charset="utf-8"><title>{{kindLabel}} {{number}}</title>
|
|
409
|
+
<style>body{font:14px/1.5 system-ui,sans-serif;margin:2rem;color:#111}table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid #ddd;padding:.4rem .6rem;text-align:left}td.n,th.n{text-align:right}.tot td{border:0}</style>
|
|
410
|
+
</head><body>
|
|
411
|
+
<h1>{{kindLabel}} {{number}}</h1>
|
|
412
|
+
<p><strong>{{seller.name}}</strong>{{#seller.taxId}} · {{seller.taxId}}{{/seller.taxId}}<br>
|
|
413
|
+
{{#seller.address}}{{.}}<br>{{/seller.address}}</p>
|
|
414
|
+
<p><strong>Billed to:</strong> {{buyer.name}}{{#buyer.taxId}} · {{buyer.taxId}}{{/buyer.taxId}}<br>
|
|
415
|
+
{{#buyer.address}}{{.}}<br>{{/buyer.address}}</p>
|
|
416
|
+
<p>Issued: {{issueDate}}{{#dueDate}} · Due: {{dueDate}}{{/dueDate}}{{#reference}} · Ref: {{reference}}{{/reference}}</p>
|
|
417
|
+
<table><thead><tr><th>Description</th><th class="n">Qty</th><th class="n">Unit</th><th class="n">Tax</th><th class="n">Total</th></tr></thead><tbody>
|
|
418
|
+
{{#lines}}<tr><td>{{description}}</td><td class="n">{{quantity}}</td><td class="n">{{unitAmount}}</td><td class="n">{{tax}}</td><td class="n">{{total}}</td></tr>
|
|
419
|
+
{{/lines}}</tbody><tfoot>
|
|
420
|
+
<tr class="tot"><td colspan="4" class="n">Subtotal</td><td class="n">{{totals.subtotal}} {{currency}}</td></tr>
|
|
421
|
+
<tr class="tot"><td colspan="4" class="n">Discount</td><td class="n">{{totals.discount}} {{currency}}</td></tr>
|
|
422
|
+
<tr class="tot"><td colspan="4" class="n">Tax</td><td class="n">{{totals.tax}} {{currency}}</td></tr>
|
|
423
|
+
<tr class="tot"><td colspan="4" class="n"><strong>Total</strong></td><td class="n"><strong>{{totals.total}} {{currency}}</strong></td></tr>
|
|
424
|
+
</tfoot></table>
|
|
425
|
+
{{#notes}}<p>{{notes}}</p>{{/notes}}
|
|
426
|
+
{{#paymentInstructions}}<p>{{paymentInstructions}}</p>{{/paymentInstructions}}
|
|
427
|
+
</body></html>
|
|
428
|
+
`,
|
|
429
|
+
});
|
|
430
|
+
const KIND_LABELS = Object.freeze({
|
|
431
|
+
invoice: "Invoice",
|
|
432
|
+
receipt: "Receipt",
|
|
433
|
+
credit_note: "Credit note",
|
|
434
|
+
quote: "Quote",
|
|
435
|
+
});
|
|
436
|
+
/** The object a template sees, and the `json` output verbatim. */
|
|
437
|
+
export function documentModel(args) {
|
|
438
|
+
const money = (minor) => formatAmount(BigInt(minor), args.exponent, args.numberFormat);
|
|
439
|
+
return {
|
|
440
|
+
kind: args.kind,
|
|
441
|
+
kindLabel: KIND_LABELS[args.kind],
|
|
442
|
+
number: args.number,
|
|
443
|
+
currency: args.currency,
|
|
444
|
+
issueDate: args.issueDate,
|
|
445
|
+
dueDate: args.dueDate ?? "",
|
|
446
|
+
reference: args.reference ?? "",
|
|
447
|
+
notes: args.notes ?? "",
|
|
448
|
+
paymentInstructions: args.paymentInstructions ?? "",
|
|
449
|
+
seller: party(args.seller),
|
|
450
|
+
buyer: party(args.buyer),
|
|
451
|
+
lines: args.computed.lines.map((line) => ({
|
|
452
|
+
index: line.index,
|
|
453
|
+
description: line.description,
|
|
454
|
+
quantity: line.quantity,
|
|
455
|
+
unitAmount: money(line.unitAmountMinor),
|
|
456
|
+
unitAmountMinor: line.unitAmountMinor,
|
|
457
|
+
discount: money(line.discountMinor),
|
|
458
|
+
discountMinor: line.discountMinor,
|
|
459
|
+
taxRateBps: line.taxRateBps ?? "",
|
|
460
|
+
tax: money(line.taxMinor),
|
|
461
|
+
taxMinor: line.taxMinor,
|
|
462
|
+
net: money(line.netMinor),
|
|
463
|
+
netMinor: line.netMinor,
|
|
464
|
+
total: money(line.totalMinor),
|
|
465
|
+
totalMinor: line.totalMinor,
|
|
466
|
+
})),
|
|
467
|
+
totals: {
|
|
468
|
+
subtotal: money(args.computed.totals.subtotalMinor),
|
|
469
|
+
subtotalMinor: args.computed.totals.subtotalMinor,
|
|
470
|
+
discount: money(args.computed.totals.discountMinor),
|
|
471
|
+
discountMinor: args.computed.totals.discountMinor,
|
|
472
|
+
net: money(args.computed.totals.netMinor),
|
|
473
|
+
netMinor: args.computed.totals.netMinor,
|
|
474
|
+
tax: money(args.computed.totals.taxMinor),
|
|
475
|
+
taxMinor: args.computed.totals.taxMinor,
|
|
476
|
+
total: money(args.computed.totals.totalMinor),
|
|
477
|
+
totalMinor: args.computed.totals.totalMinor,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function party(input) {
|
|
482
|
+
return {
|
|
483
|
+
name: input.name,
|
|
484
|
+
address: [...(input.address ?? [])],
|
|
485
|
+
taxId: input.taxId ?? "",
|
|
486
|
+
email: input.email ?? "",
|
|
487
|
+
phone: input.phone ?? "",
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** Validate the dates an invoice carries, in one place. */
|
|
491
|
+
export function validateInvoiceDates(issueDate, dueDate) {
|
|
492
|
+
assertIsoDate(issueDate, "issueDate");
|
|
493
|
+
if (dueDate === undefined)
|
|
494
|
+
return;
|
|
495
|
+
assertIsoDate(dueDate, "dueDate");
|
|
496
|
+
if (dueDate < issueDate) {
|
|
497
|
+
throw new LedgerError(`dueDate ${dueDate} is before issueDate ${issueDate} — a document that is overdue the moment it is issued is a transposed pair of dates`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
export { byString, exponentFor };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Posting: what a balanced entry is, and the one transaction that writes it.
|
|
3
|
+
*
|
|
4
|
+
* DOUBLE ENTRY IS THE WHOLE POINT. Debits equal credits, per entry, checked
|
|
5
|
+
* here and refused here. A ledger that accepts an unbalanced entry is not a
|
|
6
|
+
* ledger with a small error in it; it is a pile of rows, and every report over
|
|
7
|
+
* it is unfalsifiable. So an unbalanced entry never reaches the database.
|
|
8
|
+
*
|
|
9
|
+
* MULTI-CURRENCY is where that rule gets interesting, and where the sketch
|
|
10
|
+
* this was built from is too generous. An entry with a foreign line balances
|
|
11
|
+
* in the BASE currency after conversion, and the conversions round — so a
|
|
12
|
+
* residue of a minor unit or two is arithmetic, and it goes to the configured
|
|
13
|
+
* fx account. A residue of nine hundred is not rounding; it is a rate entered
|
|
14
|
+
* as 1.09 instead of 0.91, and booking it to "FX rounding" hides a real loss
|
|
15
|
+
* in the one account nobody reads. So the residue is BOUNDED: at most one
|
|
16
|
+
* minor unit per converted line, which is the most that rounding can produce,
|
|
17
|
+
* and anything larger is refused with both numbers in the message.
|
|
18
|
+
*/
|
|
19
|
+
import type { Database } from "bun:sqlite";
|
|
20
|
+
import { type AccountType, type EntryRow, GENESIS_HASH, type LineRow } from "./db";
|
|
21
|
+
/** Ceilings that keep one call's work bounded. */
|
|
22
|
+
export declare const POST_LIMITS: {
|
|
23
|
+
readonly entriesPerCall: 500;
|
|
24
|
+
readonly linesPerEntry: 200;
|
|
25
|
+
readonly tagsPerEntry: 32;
|
|
26
|
+
};
|
|
27
|
+
export type LineInput = {
|
|
28
|
+
readonly account: string;
|
|
29
|
+
readonly debit?: string;
|
|
30
|
+
readonly credit?: string;
|
|
31
|
+
readonly debitMinor?: string | number;
|
|
32
|
+
readonly creditMinor?: string | number;
|
|
33
|
+
readonly currency?: string;
|
|
34
|
+
readonly exponent?: number;
|
|
35
|
+
readonly fxRate?: string;
|
|
36
|
+
readonly counterparty?: string;
|
|
37
|
+
readonly dueDate?: string;
|
|
38
|
+
readonly memo?: string;
|
|
39
|
+
};
|
|
40
|
+
export type EntryInput = {
|
|
41
|
+
readonly date: string;
|
|
42
|
+
readonly memo: string;
|
|
43
|
+
readonly reference?: string;
|
|
44
|
+
readonly lines: ReadonlyArray<LineInput>;
|
|
45
|
+
readonly tags?: ReadonlyArray<string>;
|
|
46
|
+
readonly source?: {
|
|
47
|
+
readonly system: string;
|
|
48
|
+
readonly id: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type PostConfig = {
|
|
52
|
+
readonly baseCurrency: string;
|
|
53
|
+
readonly baseExponent: number;
|
|
54
|
+
readonly autoCreateAccounts: boolean;
|
|
55
|
+
readonly lockedBefore?: string;
|
|
56
|
+
readonly fxAccount?: string;
|
|
57
|
+
readonly maxFxResidueMinor?: number;
|
|
58
|
+
readonly postedAt: string;
|
|
59
|
+
readonly idempotencyKey: string;
|
|
60
|
+
readonly dryRun: boolean;
|
|
61
|
+
};
|
|
62
|
+
export type Rejection = {
|
|
63
|
+
readonly index: number;
|
|
64
|
+
readonly reason: string;
|
|
65
|
+
};
|
|
66
|
+
/** The bytes an entry's chain link is taken over. */
|
|
67
|
+
export declare function entryPayload(entry: EntryRow, lines: ReadonlyArray<LineRow>): unknown;
|
|
68
|
+
export type PostedEntry = {
|
|
69
|
+
readonly entryId: string;
|
|
70
|
+
readonly seq: number;
|
|
71
|
+
readonly hash: string;
|
|
72
|
+
readonly date: string;
|
|
73
|
+
readonly totalMinor: string;
|
|
74
|
+
readonly total: string;
|
|
75
|
+
readonly lines: number;
|
|
76
|
+
readonly fxResidueMinor: string;
|
|
77
|
+
readonly perCurrency: ReadonlyArray<{
|
|
78
|
+
readonly currency: string;
|
|
79
|
+
readonly debitMinor: string;
|
|
80
|
+
readonly creditMinor: string;
|
|
81
|
+
}>;
|
|
82
|
+
};
|
|
83
|
+
export type PostResult = {
|
|
84
|
+
readonly posted: ReadonlyArray<PostedEntry>;
|
|
85
|
+
readonly rejected: ReadonlyArray<Rejection>;
|
|
86
|
+
readonly head: {
|
|
87
|
+
readonly hash: string;
|
|
88
|
+
readonly length: number;
|
|
89
|
+
};
|
|
90
|
+
readonly idempotencyKey: string;
|
|
91
|
+
readonly replayed: boolean;
|
|
92
|
+
readonly dryRun: boolean;
|
|
93
|
+
readonly baseCurrency: string;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Post a batch.
|
|
97
|
+
*
|
|
98
|
+
* Pure validation runs first, outside the lock, because it needs nothing from
|
|
99
|
+
* the database and holding a write lock while parsing five hundred entries
|
|
100
|
+
* would block every other writer for no reason. Everything that depends on the
|
|
101
|
+
* ledger's state — does this account exist, has this source already posted, is
|
|
102
|
+
* this key claimed, what is the chain head — runs INSIDE the one immediate
|
|
103
|
+
* transaction, together with the writes, because each of those is a
|
|
104
|
+
* check-then-act whose two halves must not be separable by a crash or by
|
|
105
|
+
* another process.
|
|
106
|
+
*/
|
|
107
|
+
export declare function postBatch(db: Database, entries: ReadonlyArray<EntryInput>, config: PostConfig): PostResult;
|
|
108
|
+
/**
|
|
109
|
+
* Upsert the chart of accounts.
|
|
110
|
+
*
|
|
111
|
+
* Deliberately NOT inside the posting transaction: the chart is a declaration
|
|
112
|
+
* about the world, not part of the entry, and a batch that is refused for
|
|
113
|
+
* being unbalanced should not also un-declare an account the caller named.
|
|
114
|
+
* Nothing here is amount-bearing, so nothing here can be half-applied in a way
|
|
115
|
+
* that matters.
|
|
116
|
+
*/
|
|
117
|
+
export declare function ensureAccounts(db: Database, accounts: ReadonlyArray<{
|
|
118
|
+
readonly code: string;
|
|
119
|
+
readonly name?: string;
|
|
120
|
+
readonly type: AccountType;
|
|
121
|
+
}>): number;
|
|
122
|
+
/**
|
|
123
|
+
* A guess at an auto-created account's type, from the numbering convention
|
|
124
|
+
* most charts follow (1xxx assets, 2xxx liabilities, 3xxx equity, 4xxx income,
|
|
125
|
+
* 5xxx+ expenses). It is a guess, which is exactly why `autoCreateAccounts` is
|
|
126
|
+
* off by default: a report signs its sums by this field.
|
|
127
|
+
*/
|
|
128
|
+
export declare function inferAccountType(code: string): AccountType;
|
|
129
|
+
export { GENESIS_HASH };
|