@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/post.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { LedgerError, assertIsoDate, byString, canonicalJson, convertMinor, exponentFor, formatMajor, parseFactor, parseMajor, parseMinorUnits, sha256Hex, } from "./amount";
|
|
2
|
+
import { DryRunRollback, GENESIS_HASH, entryHash, inImmediateTransaction, readHead, writeMeta, } from "./db";
|
|
3
|
+
/** Ceilings that keep one call's work bounded. */
|
|
4
|
+
export const POST_LIMITS = {
|
|
5
|
+
entriesPerCall: 500,
|
|
6
|
+
linesPerEntry: 200,
|
|
7
|
+
tagsPerEntry: 32,
|
|
8
|
+
};
|
|
9
|
+
/** One amount on one side, from whichever of the four spellings the caller used. */
|
|
10
|
+
function readSide(line, side, exponent, where) {
|
|
11
|
+
const major = side === "debit" ? line.debit : line.credit;
|
|
12
|
+
const minor = side === "debit" ? line.debitMinor : line.creditMinor;
|
|
13
|
+
if (major !== undefined && minor !== undefined) {
|
|
14
|
+
throw new LedgerError(`${where} gives both ${side} and ${side}Minor — one amount, one spelling, because two that disagree would be resolved by whichever this code read first`);
|
|
15
|
+
}
|
|
16
|
+
if (major !== undefined)
|
|
17
|
+
return parseMajor(major, exponent, `${where} ${side}`);
|
|
18
|
+
if (minor !== undefined)
|
|
19
|
+
return parseMinorUnits(minor, `${where} ${side}Minor`);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Validate and normalize one entry. Throws `LedgerError` with the reason; the
|
|
24
|
+
* caller turns that into a rejection rather than failing the whole batch,
|
|
25
|
+
* because "entry 7 is unbalanced" should not stop entries 1 through 6.
|
|
26
|
+
*/
|
|
27
|
+
function normalizeEntry(entry, index, config) {
|
|
28
|
+
const where = `entry ${index}`;
|
|
29
|
+
const date = assertIsoDate(entry.date, `${where} date`);
|
|
30
|
+
if (config.lockedBefore !== undefined && date < config.lockedBefore) {
|
|
31
|
+
throw new LedgerError(`${where} is dated ${date}, before the locked period boundary ${config.lockedBefore} — a closed period is closed; post the correction into an open one`);
|
|
32
|
+
}
|
|
33
|
+
if (entry.memo.trim() === "") {
|
|
34
|
+
throw new LedgerError(`${where} has an empty memo — an entry nobody can read is an entry nobody can audit`);
|
|
35
|
+
}
|
|
36
|
+
if (entry.lines.length < 2) {
|
|
37
|
+
throw new LedgerError(`${where} has ${entry.lines.length} line(s); double entry needs at least two, one on each side`);
|
|
38
|
+
}
|
|
39
|
+
if (entry.lines.length > POST_LIMITS.linesPerEntry) {
|
|
40
|
+
throw new LedgerError(`${where} has ${entry.lines.length} lines, over the ${POST_LIMITS.linesPerEntry} limit`);
|
|
41
|
+
}
|
|
42
|
+
const lines = [];
|
|
43
|
+
let convertedLines = 0;
|
|
44
|
+
for (const [i, line] of entry.lines.entries()) {
|
|
45
|
+
const at = `${where} line ${i}`;
|
|
46
|
+
if (line.account.trim() === "")
|
|
47
|
+
throw new LedgerError(`${at} has no account`);
|
|
48
|
+
const currency = (line.currency ?? config.baseCurrency).toUpperCase();
|
|
49
|
+
const exponent = exponentFor(currency, line.exponent);
|
|
50
|
+
const debit = readSide(line, "debit", exponent, at);
|
|
51
|
+
const credit = readSide(line, "credit", exponent, at);
|
|
52
|
+
if (debit !== null && credit !== null) {
|
|
53
|
+
throw new LedgerError(`${at} carries both a debit and a credit — split it into two lines so the entry says which side each amount is on`);
|
|
54
|
+
}
|
|
55
|
+
if (debit === null && credit === null) {
|
|
56
|
+
throw new LedgerError(`${at} has no amount on either side`);
|
|
57
|
+
}
|
|
58
|
+
const amount = (debit ?? credit);
|
|
59
|
+
if (amount < 0n) {
|
|
60
|
+
throw new LedgerError(`${at} has a negative ${debit === null ? "credit" : "debit"} (${formatMajor(amount, exponent)}) — a negative debit is a credit, so write it as one; letting both spellings in is how a sign error survives a review`);
|
|
61
|
+
}
|
|
62
|
+
if (amount === 0n) {
|
|
63
|
+
throw new LedgerError(`${at} has a zero amount, which records nothing`);
|
|
64
|
+
}
|
|
65
|
+
let fxRate = null;
|
|
66
|
+
let baseDebit = debit ?? 0n;
|
|
67
|
+
let baseCredit = credit ?? 0n;
|
|
68
|
+
if (currency !== config.baseCurrency) {
|
|
69
|
+
if (line.fxRate === undefined) {
|
|
70
|
+
throw new LedgerError(`${at} is in ${currency} but the ledger's base currency is ${config.baseCurrency} and the line carries no fxRate — this package will not invent a rate, and a rate a model recalled is not a rate`);
|
|
71
|
+
}
|
|
72
|
+
const rate = parseFactor(line.fxRate, `${at} fxRate`);
|
|
73
|
+
if (rate.unscaled <= 0n)
|
|
74
|
+
throw new LedgerError(`${at} fxRate must be greater than zero`);
|
|
75
|
+
fxRate = line.fxRate;
|
|
76
|
+
baseDebit = debit === null ? 0n : convertMinor(debit, rate, exponent, config.baseExponent);
|
|
77
|
+
baseCredit = credit === null ? 0n : convertMinor(credit, rate, exponent, config.baseExponent);
|
|
78
|
+
convertedLines += 1;
|
|
79
|
+
}
|
|
80
|
+
else if (line.fxRate !== undefined) {
|
|
81
|
+
throw new LedgerError(`${at} is already in the base currency ${config.baseCurrency} and also carries an fxRate — one of the two is a mistake and this code cannot tell which`);
|
|
82
|
+
}
|
|
83
|
+
if (line.dueDate !== undefined)
|
|
84
|
+
assertIsoDate(line.dueDate, `${at} dueDate`);
|
|
85
|
+
lines.push({
|
|
86
|
+
account: line.account,
|
|
87
|
+
debitMinor: debit ?? 0n,
|
|
88
|
+
creditMinor: credit ?? 0n,
|
|
89
|
+
currency,
|
|
90
|
+
exponent,
|
|
91
|
+
fxRate,
|
|
92
|
+
baseDebitMinor: baseDebit,
|
|
93
|
+
baseCreditMinor: baseCredit,
|
|
94
|
+
counterparty: line.counterparty ?? null,
|
|
95
|
+
dueDate: line.dueDate ?? null,
|
|
96
|
+
lineMemo: line.memo ?? null,
|
|
97
|
+
fxResidue: false,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// Per-currency totals are reported whether or not they are the test, because
|
|
101
|
+
// "the entry balances in EUR but not in GBP" is the sentence a bookkeeper
|
|
102
|
+
// needs and "unbalanced by 3" is not.
|
|
103
|
+
const perCurrency = summarizePerCurrency(lines);
|
|
104
|
+
if (convertedLines === 0) {
|
|
105
|
+
const debits = lines.reduce((s, l) => s + l.debitMinor, 0n);
|
|
106
|
+
const credits = lines.reduce((s, l) => s + l.creditMinor, 0n);
|
|
107
|
+
if (debits !== credits) {
|
|
108
|
+
throw new LedgerError(`${where} does not balance: debits ${formatMajor(debits, config.baseExponent)} ${config.baseCurrency} against credits ${formatMajor(credits, config.baseExponent)} ${config.baseCurrency}, a difference of ${formatMajor(debits - credits, config.baseExponent)}`);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
index,
|
|
112
|
+
entryId: "",
|
|
113
|
+
date,
|
|
114
|
+
memo: entry.memo,
|
|
115
|
+
reference: entry.reference ?? "",
|
|
116
|
+
tags: canonicalTags(entry.tags ?? [], where),
|
|
117
|
+
sourceSystem: entry.source?.system ?? null,
|
|
118
|
+
sourceId: entry.source?.id ?? null,
|
|
119
|
+
lines,
|
|
120
|
+
totalDebitMinor: debits,
|
|
121
|
+
fxResidueMinor: 0n,
|
|
122
|
+
perCurrency,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const baseDebits = lines.reduce((s, l) => s + l.baseDebitMinor, 0n);
|
|
126
|
+
const baseCredits = lines.reduce((s, l) => s + l.baseCreditMinor, 0n);
|
|
127
|
+
const residue = baseDebits - baseCredits;
|
|
128
|
+
if (residue !== 0n) {
|
|
129
|
+
// One minor unit per conversion is the most that rounding can produce.
|
|
130
|
+
// Anything past it is a wrong rate wearing rounding's coat.
|
|
131
|
+
const bound = BigInt(config.maxFxResidueMinor ?? convertedLines);
|
|
132
|
+
const magnitude = residue < 0n ? -residue : residue;
|
|
133
|
+
if (magnitude > bound) {
|
|
134
|
+
throw new LedgerError(`${where} is out by ${formatMajor(residue, config.baseExponent)} ${config.baseCurrency} after converting ${convertedLines} foreign line(s), which is more than the ${bound} minor unit(s) that rounding can account for — this is a wrong fx rate, not a rounding residue, and booking it to the fx account would hide a real difference`);
|
|
135
|
+
}
|
|
136
|
+
if (config.fxAccount === undefined) {
|
|
137
|
+
throw new LedgerError(`${where} rounds to a residue of ${formatMajor(residue, config.baseExponent)} ${config.baseCurrency} and no fxAccount is configured — set ledger.fxAccount to the account that absorbs conversion rounding, because the alternative is silently adding it to whichever line came last`);
|
|
138
|
+
}
|
|
139
|
+
lines.push({
|
|
140
|
+
account: config.fxAccount,
|
|
141
|
+
debitMinor: residue < 0n ? -residue : 0n,
|
|
142
|
+
creditMinor: residue > 0n ? residue : 0n,
|
|
143
|
+
currency: config.baseCurrency,
|
|
144
|
+
exponent: config.baseExponent,
|
|
145
|
+
fxRate: null,
|
|
146
|
+
baseDebitMinor: residue < 0n ? -residue : 0n,
|
|
147
|
+
baseCreditMinor: residue > 0n ? residue : 0n,
|
|
148
|
+
counterparty: null,
|
|
149
|
+
dueDate: null,
|
|
150
|
+
lineMemo: "fx conversion rounding",
|
|
151
|
+
fxResidue: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
index,
|
|
156
|
+
entryId: "",
|
|
157
|
+
date,
|
|
158
|
+
memo: entry.memo,
|
|
159
|
+
reference: entry.reference ?? "",
|
|
160
|
+
tags: canonicalTags(entry.tags ?? [], where),
|
|
161
|
+
sourceSystem: entry.source?.system ?? null,
|
|
162
|
+
sourceId: entry.source?.id ?? null,
|
|
163
|
+
lines,
|
|
164
|
+
totalDebitMinor: lines.reduce((s, l) => s + l.baseDebitMinor, 0n),
|
|
165
|
+
fxResidueMinor: residue,
|
|
166
|
+
perCurrency,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function summarizePerCurrency(lines) {
|
|
170
|
+
const totals = new Map();
|
|
171
|
+
for (const line of lines) {
|
|
172
|
+
const bucket = totals.get(line.currency) ?? { debit: 0n, credit: 0n };
|
|
173
|
+
bucket.debit += line.debitMinor;
|
|
174
|
+
bucket.credit += line.creditMinor;
|
|
175
|
+
totals.set(line.currency, bucket);
|
|
176
|
+
}
|
|
177
|
+
return [...totals.entries()]
|
|
178
|
+
.sort((a, b) => byString(a[0], b[0]))
|
|
179
|
+
.map(([currency, t]) => ({
|
|
180
|
+
currency,
|
|
181
|
+
debitMinor: t.debit.toString(),
|
|
182
|
+
creditMinor: t.credit.toString(),
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
function canonicalTags(tags, where) {
|
|
186
|
+
if (tags.length > POST_LIMITS.tagsPerEntry) {
|
|
187
|
+
throw new LedgerError(`${where} has ${tags.length} tags, over the ${POST_LIMITS.tagsPerEntry} limit`);
|
|
188
|
+
}
|
|
189
|
+
// Sorted and deduplicated so the same entry written twice hashes the same.
|
|
190
|
+
return [...new Set(tags.map((t) => t.trim()).filter((t) => t !== ""))].sort(byString);
|
|
191
|
+
}
|
|
192
|
+
/** The bytes an entry's chain link is taken over. */
|
|
193
|
+
export function entryPayload(entry, lines) {
|
|
194
|
+
return {
|
|
195
|
+
seq: entry.seq,
|
|
196
|
+
entryId: entry.entry_id,
|
|
197
|
+
date: entry.date,
|
|
198
|
+
memo: entry.memo,
|
|
199
|
+
reference: entry.reference,
|
|
200
|
+
tags: JSON.parse(entry.tags),
|
|
201
|
+
source: entry.source_system === null ? null : { system: entry.source_system, id: entry.source_id },
|
|
202
|
+
baseCurrency: entry.base_currency,
|
|
203
|
+
postedAt: entry.posted_at,
|
|
204
|
+
lines: lines.map((l) => ({
|
|
205
|
+
idx: l.idx,
|
|
206
|
+
account: l.account,
|
|
207
|
+
debitMinor: l.debit_minor,
|
|
208
|
+
creditMinor: l.credit_minor,
|
|
209
|
+
currency: l.currency,
|
|
210
|
+
exponent: l.exponent,
|
|
211
|
+
fxRate: l.fx_rate,
|
|
212
|
+
baseDebitMinor: l.base_debit_minor,
|
|
213
|
+
baseCreditMinor: l.base_credit_minor,
|
|
214
|
+
counterparty: l.counterparty,
|
|
215
|
+
dueDate: l.due_date,
|
|
216
|
+
memo: l.line_memo,
|
|
217
|
+
})),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Post a batch.
|
|
222
|
+
*
|
|
223
|
+
* Pure validation runs first, outside the lock, because it needs nothing from
|
|
224
|
+
* the database and holding a write lock while parsing five hundred entries
|
|
225
|
+
* would block every other writer for no reason. Everything that depends on the
|
|
226
|
+
* ledger's state — does this account exist, has this source already posted, is
|
|
227
|
+
* this key claimed, what is the chain head — runs INSIDE the one immediate
|
|
228
|
+
* transaction, together with the writes, because each of those is a
|
|
229
|
+
* check-then-act whose two halves must not be separable by a crash or by
|
|
230
|
+
* another process.
|
|
231
|
+
*/
|
|
232
|
+
export function postBatch(db, entries, config) {
|
|
233
|
+
const normalized = [];
|
|
234
|
+
const rejected = [];
|
|
235
|
+
for (const [index, entry] of entries.entries()) {
|
|
236
|
+
try {
|
|
237
|
+
normalized.push(normalizeEntry(entry, index, config));
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
if (!(err instanceof LedgerError))
|
|
241
|
+
throw err;
|
|
242
|
+
rejected.push({ index, reason: err.message });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// The fingerprint an idempotent replay is checked against covers everything
|
|
246
|
+
// that could change what gets written — including the configuration, because
|
|
247
|
+
// the same entries posted against a different base currency are different
|
|
248
|
+
// entries.
|
|
249
|
+
const payloadHash = sha256Hex(canonicalJson({
|
|
250
|
+
entries,
|
|
251
|
+
baseCurrency: config.baseCurrency,
|
|
252
|
+
baseExponent: config.baseExponent,
|
|
253
|
+
fxAccount: config.fxAccount ?? null,
|
|
254
|
+
lockedBefore: config.lockedBefore ?? null,
|
|
255
|
+
postedAt: config.postedAt,
|
|
256
|
+
}));
|
|
257
|
+
const run = () => {
|
|
258
|
+
const claimed = db
|
|
259
|
+
.query("SELECT payload_hash, result FROM idempotency WHERE key = ?")
|
|
260
|
+
.get(config.idempotencyKey);
|
|
261
|
+
if (claimed !== null) {
|
|
262
|
+
if (claimed.payload_hash !== payloadHash) {
|
|
263
|
+
throw new LedgerError(`idempotency key "${config.idempotencyKey}" was already used for a DIFFERENT batch — reusing a key for new content is the failure an idempotency key exists to catch, so nothing was posted; use a new key`);
|
|
264
|
+
}
|
|
265
|
+
const previous = JSON.parse(claimed.result);
|
|
266
|
+
return { ...previous, replayed: true };
|
|
267
|
+
}
|
|
268
|
+
const head = readHead(db);
|
|
269
|
+
const maxSeqRow = db.query("SELECT COALESCE(MAX(seq), 0) AS n FROM entries").get();
|
|
270
|
+
if (maxSeqRow.n !== head.length) {
|
|
271
|
+
throw new LedgerError(`the ledger records ${head.length} entries but the highest sequence number is ${maxSeqRow.n} — the file has been modified outside this tool and appending to it would extend a broken chain`);
|
|
272
|
+
}
|
|
273
|
+
const insertEntry = db.prepare("INSERT INTO entries (seq, entry_id, date, memo, reference, tags, source_system, source_id, base_currency, posted_at, prev_hash, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
274
|
+
const insertLine = db.prepare("INSERT INTO lines (entry_seq, idx, account, debit_minor, credit_minor, currency, exponent, fx_rate, base_debit_minor, base_credit_minor, counterparty, due_date, line_memo) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
275
|
+
const findAccount = db.query("SELECT code, name, type FROM accounts WHERE code = ?");
|
|
276
|
+
const findSource = db.query("SELECT entry_id FROM entries WHERE source_system = ? AND source_id = ?");
|
|
277
|
+
const posted = [];
|
|
278
|
+
const lateRejections = [];
|
|
279
|
+
let prevHash = head.hash;
|
|
280
|
+
let seq = head.length;
|
|
281
|
+
for (const entry of normalized) {
|
|
282
|
+
const duplicate = entry.sourceSystem === null || entry.sourceId === null
|
|
283
|
+
? null
|
|
284
|
+
: findSource.get(entry.sourceSystem, entry.sourceId);
|
|
285
|
+
if (duplicate !== null) {
|
|
286
|
+
lateRejections.push({
|
|
287
|
+
index: entry.index,
|
|
288
|
+
reason: `${entry.sourceSystem}/${entry.sourceId} was already posted as entry ${duplicate.entry_id} — the same payment does not get two entries`,
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
let missing = null;
|
|
293
|
+
for (const line of entry.lines) {
|
|
294
|
+
if (findAccount.get(line.account) !== null)
|
|
295
|
+
continue;
|
|
296
|
+
if (!config.autoCreateAccounts) {
|
|
297
|
+
missing = line.account;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
db.run("INSERT INTO accounts (code, name, type) VALUES (?, ?, ?)", [
|
|
301
|
+
line.account,
|
|
302
|
+
line.account,
|
|
303
|
+
inferAccountType(line.account),
|
|
304
|
+
]);
|
|
305
|
+
}
|
|
306
|
+
if (missing !== null) {
|
|
307
|
+
lateRejections.push({
|
|
308
|
+
index: entry.index,
|
|
309
|
+
reason: `account "${missing}" is not in the chart of accounts — add it, or set autoCreateAccounts, because a typo that creates an account silently is a balance that reconciles to nothing`,
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
seq += 1;
|
|
314
|
+
const entryId = `e_${sha256Hex(canonicalJson({ key: config.idempotencyKey, index: entry.index, seq })).slice(0, 24)}`;
|
|
315
|
+
const entryRow = {
|
|
316
|
+
seq,
|
|
317
|
+
entry_id: entryId,
|
|
318
|
+
date: entry.date,
|
|
319
|
+
memo: entry.memo,
|
|
320
|
+
reference: entry.reference,
|
|
321
|
+
tags: JSON.stringify(entry.tags),
|
|
322
|
+
source_system: entry.sourceSystem,
|
|
323
|
+
source_id: entry.sourceId,
|
|
324
|
+
base_currency: config.baseCurrency,
|
|
325
|
+
posted_at: config.postedAt,
|
|
326
|
+
prev_hash: prevHash,
|
|
327
|
+
hash: "",
|
|
328
|
+
};
|
|
329
|
+
const lineRows = entry.lines.map((l, idx) => ({
|
|
330
|
+
entry_seq: seq,
|
|
331
|
+
idx,
|
|
332
|
+
account: l.account,
|
|
333
|
+
debit_minor: l.debitMinor.toString(),
|
|
334
|
+
credit_minor: l.creditMinor.toString(),
|
|
335
|
+
currency: l.currency,
|
|
336
|
+
exponent: l.exponent,
|
|
337
|
+
fx_rate: l.fxRate,
|
|
338
|
+
base_debit_minor: l.baseDebitMinor.toString(),
|
|
339
|
+
base_credit_minor: l.baseCreditMinor.toString(),
|
|
340
|
+
counterparty: l.counterparty,
|
|
341
|
+
due_date: l.dueDate,
|
|
342
|
+
line_memo: l.lineMemo,
|
|
343
|
+
}));
|
|
344
|
+
const hash = entryHash(prevHash, entryPayload(entryRow, lineRows));
|
|
345
|
+
insertEntry.run(seq, entryId, entry.date, entry.memo, entry.reference, entryRow.tags, entry.sourceSystem, entry.sourceId, config.baseCurrency, config.postedAt, prevHash, hash);
|
|
346
|
+
for (const row of lineRows) {
|
|
347
|
+
insertLine.run(row.entry_seq, row.idx, row.account, row.debit_minor, row.credit_minor, row.currency, row.exponent, row.fx_rate, row.base_debit_minor, row.base_credit_minor, row.counterparty, row.due_date, row.line_memo);
|
|
348
|
+
}
|
|
349
|
+
prevHash = hash;
|
|
350
|
+
posted.push({
|
|
351
|
+
entryId,
|
|
352
|
+
seq,
|
|
353
|
+
hash,
|
|
354
|
+
date: entry.date,
|
|
355
|
+
totalMinor: entry.totalDebitMinor.toString(),
|
|
356
|
+
total: formatMajor(entry.totalDebitMinor, config.baseExponent),
|
|
357
|
+
lines: entry.lines.length,
|
|
358
|
+
fxResidueMinor: entry.fxResidueMinor.toString(),
|
|
359
|
+
perCurrency: entry.perCurrency,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
writeMeta(db, "chainHead", prevHash);
|
|
363
|
+
writeMeta(db, "chainLength", String(seq));
|
|
364
|
+
const result = {
|
|
365
|
+
posted,
|
|
366
|
+
rejected: [...rejected, ...lateRejections].sort((a, b) => a.index - b.index),
|
|
367
|
+
head: { hash: prevHash, length: seq },
|
|
368
|
+
idempotencyKey: config.idempotencyKey,
|
|
369
|
+
replayed: false,
|
|
370
|
+
dryRun: config.dryRun,
|
|
371
|
+
baseCurrency: config.baseCurrency,
|
|
372
|
+
};
|
|
373
|
+
// The claim goes in the SAME transaction as the rows and the head. This
|
|
374
|
+
// line is the entire reason this function is shaped the way it is.
|
|
375
|
+
db.run("INSERT INTO idempotency (key, payload_hash, result, claimed_at) VALUES (?, ?, ?, ?)", [
|
|
376
|
+
config.idempotencyKey,
|
|
377
|
+
payloadHash,
|
|
378
|
+
JSON.stringify(result),
|
|
379
|
+
config.postedAt,
|
|
380
|
+
]);
|
|
381
|
+
if (config.dryRun)
|
|
382
|
+
throw new DryRunRollback(result);
|
|
383
|
+
return result;
|
|
384
|
+
};
|
|
385
|
+
try {
|
|
386
|
+
return inImmediateTransaction(db, run);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
if (err instanceof DryRunRollback)
|
|
390
|
+
return err.payload;
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Upsert the chart of accounts.
|
|
396
|
+
*
|
|
397
|
+
* Deliberately NOT inside the posting transaction: the chart is a declaration
|
|
398
|
+
* about the world, not part of the entry, and a batch that is refused for
|
|
399
|
+
* being unbalanced should not also un-declare an account the caller named.
|
|
400
|
+
* Nothing here is amount-bearing, so nothing here can be half-applied in a way
|
|
401
|
+
* that matters.
|
|
402
|
+
*/
|
|
403
|
+
export function ensureAccounts(db, accounts) {
|
|
404
|
+
if (accounts.length === 0)
|
|
405
|
+
return 0;
|
|
406
|
+
return inImmediateTransaction(db, () => {
|
|
407
|
+
for (const account of accounts) {
|
|
408
|
+
db.run("INSERT INTO accounts (code, name, type) VALUES (?, ?, ?) ON CONFLICT (code) DO UPDATE SET name = ?, type = ?", [
|
|
409
|
+
account.code,
|
|
410
|
+
account.name ?? account.code,
|
|
411
|
+
account.type,
|
|
412
|
+
account.name ?? account.code,
|
|
413
|
+
account.type,
|
|
414
|
+
]);
|
|
415
|
+
}
|
|
416
|
+
return accounts.length;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* A guess at an auto-created account's type, from the numbering convention
|
|
421
|
+
* most charts follow (1xxx assets, 2xxx liabilities, 3xxx equity, 4xxx income,
|
|
422
|
+
* 5xxx+ expenses). It is a guess, which is exactly why `autoCreateAccounts` is
|
|
423
|
+
* off by default: a report signs its sums by this field.
|
|
424
|
+
*/
|
|
425
|
+
export function inferAccountType(code) {
|
|
426
|
+
const digit = /^(\d)/.exec(code.trim())?.[1];
|
|
427
|
+
switch (digit) {
|
|
428
|
+
case "1":
|
|
429
|
+
return "asset";
|
|
430
|
+
case "2":
|
|
431
|
+
return "liability";
|
|
432
|
+
case "3":
|
|
433
|
+
return "equity";
|
|
434
|
+
case "4":
|
|
435
|
+
return "income";
|
|
436
|
+
default:
|
|
437
|
+
return "expense";
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
export { GENESIS_HASH };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reports. One selection, then aggregation in JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* Two decisions here differ from the obvious one, and both are about SQLite
|
|
5
|
+
* rather than about accounting:
|
|
6
|
+
*
|
|
7
|
+
* - **Totals are summed in bigint, not in `SUM()`.** Amounts are stored as
|
|
8
|
+
* TEXT (see `./db`), so `SUM()` would coerce them through a float, and even
|
|
9
|
+
* over INTEGER columns SQLite promotes a 64-bit overflow to a float without
|
|
10
|
+
* saying so. A trial balance that is off by a fraction of a cent is a
|
|
11
|
+
* trial balance nobody can sign.
|
|
12
|
+
* - **Signs come from the chart of accounts, never from the query.** Whether a
|
|
13
|
+
* balance is "positive" depends on the account's normal side, which is a
|
|
14
|
+
* property of the account. A report that decides it from the account's name,
|
|
15
|
+
* or from the sign of the number it just computed, is wrong the first time
|
|
16
|
+
* somebody books a contra account.
|
|
17
|
+
*
|
|
18
|
+
* Every view that can return an unbounded number of rows takes a limit and
|
|
19
|
+
* says `truncated` when it hit it, and the scan itself is capped: a question
|
|
20
|
+
* that would pull the whole ledger into memory is refused with the range to
|
|
21
|
+
* narrow, not answered slowly.
|
|
22
|
+
*/
|
|
23
|
+
import type { Database } from "bun:sqlite";
|
|
24
|
+
import { type ChainVerification } from "./db";
|
|
25
|
+
export declare const VIEWS: readonly ["trial_balance", "balance", "account_ledger", "journal", "pnl", "balance_sheet", "aging", "search", "chain"];
|
|
26
|
+
export type View = (typeof VIEWS)[number];
|
|
27
|
+
export declare const FORMATS: readonly ["json", "csv", "markdown"];
|
|
28
|
+
export type Format = (typeof FORMATS)[number];
|
|
29
|
+
/** Lines one call will pull out of SQLite before it refuses to continue. */
|
|
30
|
+
export declare const MAX_SCAN_ROWS = 200000;
|
|
31
|
+
export type QueryFilter = {
|
|
32
|
+
readonly accounts?: ReadonlyArray<string>;
|
|
33
|
+
readonly from?: string;
|
|
34
|
+
readonly to?: string;
|
|
35
|
+
readonly tag?: string;
|
|
36
|
+
readonly reference?: string;
|
|
37
|
+
readonly sourceSystem?: string;
|
|
38
|
+
readonly counterparty?: string;
|
|
39
|
+
readonly text?: string;
|
|
40
|
+
readonly amountMinMinor?: string;
|
|
41
|
+
readonly amountMaxMinor?: string;
|
|
42
|
+
};
|
|
43
|
+
export type QueryResult = {
|
|
44
|
+
readonly view: View;
|
|
45
|
+
readonly rows: ReadonlyArray<Record<string, string | number | null>>;
|
|
46
|
+
readonly totals: Record<string, string | number | boolean | null>;
|
|
47
|
+
readonly rowCount: number;
|
|
48
|
+
readonly truncated: boolean;
|
|
49
|
+
readonly notes?: ReadonlyArray<string>;
|
|
50
|
+
readonly chain?: ChainVerification;
|
|
51
|
+
};
|
|
52
|
+
export type QueryOptions = {
|
|
53
|
+
readonly view: View;
|
|
54
|
+
readonly filter: QueryFilter;
|
|
55
|
+
readonly baseCurrency: string;
|
|
56
|
+
readonly baseExponent: number;
|
|
57
|
+
readonly limit: number;
|
|
58
|
+
/** Required by `aging` and `balance_sheet`; never taken from the clock. */
|
|
59
|
+
readonly asOf?: string;
|
|
60
|
+
};
|
|
61
|
+
export declare function runQuery(db: Database, options: QueryOptions): QueryResult;
|
|
62
|
+
export declare function renderRows(rows: ReadonlyArray<Record<string, string | number | null>>, format: Format): string;
|