From bf41362ba9af181841b8a6107e0a4b9e9a220a15 Mon Sep 17 00:00:00 2001 From: Pallaoro Date: Thu, 10 Sep 2026 17:02:55 +0200 Subject: [PATCH 1/5] Make the ledger audit-proof: reverse entries instead of deleting them A posted journal entry could be deleted outright. Cancelling an invoice removed its entry, deleting an invoice removed both, and re-posting wiped what was there first. Books you can rewrite are books an accountant has to redo, so none of the export work on top of them is worth much. A correction is now a second, mirror-image entry. The original and its Storno both stay `posted` and net to zero, so the trial balance is right without either leaving the record; the pair is linked by reverses_entry_id / reversed_by_entry_id. There is deliberately no 'reversed' status, because dropping the original out of the three `status = 'posted'` queries while adding a posted Storno subtracts the same amount twice. The document behind the entry is frozen too. The editor already hid its controls on a non-draft invoice, but the API did not, and the API is what an agent calls: an issued invoice could be silently rewritten while its journal entry stood unchanged. addLine, updateLine, deleteLine and updateInvoice now refuse on anything but a draft, and a posted invoice is cancelled rather than deleted. Months can be locked closed. Nothing posts into a locked period -- which is what stops a back-dated draft being issued into a month already reported -- and a reversal of an entry inside one is dated into the open period instead, so a closed month keeps the numbers it was reported with. Locking is one-way; an unlock would make every lock a suggestion. Every change to the books is recorded with who made it, taken from the platform's own caller identity, so the log separates a person from the org's agent acting on its own. Refusals come back as 409 with a message saying what to do instead, and the UI surfaces it rather than failing silently. Verified against an in-memory SQLite build of the real schema: 35 checks covering the reversal arithmetic, the frozen-document guards, period locking, reversal out of a locked period, and that a refused issue leaves the invoice a draft with its number sequence untouched. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 +- package.json | 2 +- pnpm-lock.yaml | 27 ++++--- src/client/pages/invoice-editor.tsx | 19 +++-- src/client/pages/invoices.tsx | 10 ++- src/client/pages/journals.tsx | 21 +++++- src/client/refusal.ts | 16 +++++ src/server/domain/audit.ts | 81 +++++++++++++++++++++ src/server/domain/errors.ts | 14 ++++ src/server/domain/invoices.ts | 76 ++++++++++++++++---- src/server/domain/journals.ts | 106 +++++++++++++++++++++++++--- src/server/domain/periods.ts | 70 ++++++++++++++++++ src/server/routes.ts | 49 +++++++++++-- src/server/schema.sql | 49 ++++++++++++- 14 files changed, 495 insertions(+), 48 deletions(-) create mode 100644 src/client/refusal.ts create mode 100644 src/server/domain/audit.ts create mode 100644 src/server/domain/errors.ts create mode 100644 src/server/domain/periods.ts diff --git a/README.md b/README.md index 31f7709..4882a30 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Every SMB needs compliant accounting software. The existing open-source options - **Editable invoice editor** with inline line CRUD, product picker, RGS account assignment, server-side recompute on every change. - **HTML preview + PDF download + Peppol BIS Billing 3.0 UBL export** — including correct `AE` tax category and `VATEX-EU-AE` exemption code for reverse charge invoices. - **Pluggable PDF backends** — Clawnify-managed (Browser Rendering proxy), direct `BROWSER` binding, or a Gotenberg sidecar. +- **Audit-proof ledger** — a posted entry is never edited or deleted. Corrections are booked as a mirror-image reversal, so the original and its correction both stay on the record and the trial balance still nets out. Posted invoices are frozen against edits, months can be locked closed, and every change to the books is recorded with who made it — a person, or an agent acting on its own. ## What's next @@ -32,7 +33,7 @@ Roughly in order: - **CI XSD validation** of generated UBL via `libxml2-wasm` against the Peppol BIS Billing 3.0 schema. - **Settings page** to fill in company name / VAT / KVK / IBAN (currently API-only). -- **Double-entry bookkeeping engine** — journals, period close, P&L, balance sheet (Phase 2). +- **Accountant handoff** — export the locked periods to the formats a bookkeeper's software actually ingests, plus a read-only share so they can look without an account. - **VAT returns + iXBRL** — BTW-aangifte and ICP, generated against the Nederlandse Taxonomie (Phase 3). - **Country plugins** as separate repos: `open-books-fr`, `open-books-de`, `open-books-it`. - **FatturaPA** XML for IT. diff --git a/package.json b/package.json index 505e11c..1a0d479 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "deploy": "clawnify deploy" }, "dependencies": { - "@clawnify/app": "^0.1.0", + "@clawnify/app": "^0.2.1", "@clawnify/db": "^0.4.1", "@hono/zod-openapi": "^0.18.0", "hono": "^4.13.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44bb7e7..743ecba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@clawnify/app': - specifier: ^0.1.0 - version: 0.1.0(@cloudflare/workers-types@4.20260503.1)(@hono/zod-openapi@0.18.4(hono@4.13.7)(zod@3.25.76))(hono@4.13.7) + specifier: ^0.2.1 + version: 0.2.1(@cloudflare/workers-types@4.20260503.1)(@hono/zod-openapi@0.18.4(hono@4.13.7)(zod@3.25.76))(hono@4.13.7)(react@19.2.5) '@clawnify/db': specifier: ^0.4.1 version: 0.4.1(@cloudflare/workers-types@4.20260503.1) @@ -145,17 +145,24 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@clawnify/app@0.1.0': - resolution: {integrity: sha512-U0m4EzjXlesnjDfx7rVF7sgb4JwnENlelJ2mQPwGoCxKYTTzNwhQcpjMdfq71RD6eNJ1/EGe6qEYIRWv/1xxLQ==} + '@clawnify/app@0.2.1': + resolution: {integrity: sha512-t6fx4rnbEAewU3fH/WGKfWhfhZgVbLp1JgnpCR0YSHaE6rYwxEmgHAYlN3q5gSjYutsWmXANFqro3wuyxWZlTA==} peerDependencies: '@hono/zod-openapi': '>=0.18 <1' + '@phosphor-icons/react': '>=2' hono: '*' + react: '>=18' + peerDependenciesMeta: + '@phosphor-icons/react': + optional: true + react: + optional: true '@clawnify/db@0.4.1': resolution: {integrity: sha512-Ck9RGqEElt6P/3S9quxvr2MIg8mZk1n9wFftH3PpGPFnpA6hbk0td67oaioeMRuDCn7vePcJCeyYEkbvNVYy/g==} - '@clawnify/routes@0.2.1': - resolution: {integrity: sha512-syrH1Sk4uxXVI9Yo76yLrccAEpcwAUDxvxL3/K+QnLHFetPU/RaterpwXNbzonA2gtY7xBmYfJ66mgEw280HaQ==} + '@clawnify/routes@0.2.2': + resolution: {integrity: sha512-+s7gjRyH8bmIUHQDHmmwGyUeEcV5m+IoWEYr2tLiWdVW7WKlFlgeKIuJ61vW9mc9k4TizL+B+Tg8qJ72zAbvOA==} peerDependencies: '@hono/zod-openapi': '>=0.18 <1' hono: '*' @@ -1976,12 +1983,14 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@clawnify/app@0.1.0(@cloudflare/workers-types@4.20260503.1)(@hono/zod-openapi@0.18.4(hono@4.13.7)(zod@3.25.76))(hono@4.13.7)': + '@clawnify/app@0.2.1(@cloudflare/workers-types@4.20260503.1)(@hono/zod-openapi@0.18.4(hono@4.13.7)(zod@3.25.76))(hono@4.13.7)(react@19.2.5)': dependencies: '@clawnify/db': 0.4.1(@cloudflare/workers-types@4.20260503.1) - '@clawnify/routes': 0.2.1(@hono/zod-openapi@0.18.4(hono@4.13.7)(zod@3.25.76))(hono@4.13.7) + '@clawnify/routes': 0.2.2(@hono/zod-openapi@0.18.4(hono@4.13.7)(zod@3.25.76))(hono@4.13.7) '@hono/zod-openapi': 0.18.4(hono@4.13.7)(zod@3.25.76) hono: 4.13.7 + optionalDependencies: + react: 19.2.5 transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@cloudflare/workers-types' @@ -2047,7 +2056,7 @@ snapshots: - sql.js - sqlite3 - '@clawnify/routes@0.2.1(@hono/zod-openapi@0.18.4(hono@4.13.7)(zod@3.25.76))(hono@4.13.7)': + '@clawnify/routes@0.2.2(@hono/zod-openapi@0.18.4(hono@4.13.7)(zod@3.25.76))(hono@4.13.7)': dependencies: '@hono/zod-openapi': 0.18.4(hono@4.13.7)(zod@3.25.76) hono: 4.13.7 diff --git a/src/client/pages/invoice-editor.tsx b/src/client/pages/invoice-editor.tsx index 4672812..89afc6d 100644 --- a/src/client/pages/invoice-editor.tsx +++ b/src/client/pages/invoice-editor.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { refused } from "../refusal"; import { Link, navigate } from "../router"; type InvoiceStatus = "draft" | "issued" | "sent" | "paid" | "cancelled"; @@ -160,7 +161,8 @@ export function InvoiceEditorPage({ id }: { id: number }) { } async function removeLine(lineId: number) { - await fetch(`/api/lines/${lineId}`, { method: "DELETE" }); + const res = await fetch(`/api/lines/${lineId}`, { method: "DELETE" }); + if (await refused(res)) return; reload(); } @@ -169,22 +171,25 @@ export function InvoiceEditorPage({ id }: { id: number }) { alert("Add at least one line before issuing."); return; } - await fetch(`/api/invoices/${id}/issue`, { method: "POST" }); + const res = await fetch(`/api/invoices/${id}/issue`, { method: "POST" }); + if (await refused(res)) return; reload(); } async function setStatus(status: InvoiceStatus) { - await fetch(`/api/invoices/${id}/status`, { + const res = await fetch(`/api/invoices/${id}/status`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status }), }); + if (await refused(res)) return; reload(); } async function remove() { - if (!confirm("Delete this invoice?")) return; - await fetch(`/api/invoices/${id}`, { method: "DELETE" }); + if (!confirm("Delete this draft?")) return; + const res = await fetch(`/api/invoices/${id}`, { method: "DELETE" }); + if (await refused(res)) return; navigate("/invoices"); } @@ -374,7 +379,9 @@ export function InvoiceEditorPage({ id }: { id: number }) { {invoice.status !== "cancelled" && invoice.status !== "paid" && invoice.status !== "draft" && ( )} - + {invoice.status === "draft" && ( + + )} ); diff --git a/src/client/pages/invoices.tsx b/src/client/pages/invoices.tsx index a0780b6..c496b42 100644 --- a/src/client/pages/invoices.tsx +++ b/src/client/pages/invoices.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { refused } from "../refusal"; import { Link, navigate } from "../router"; type InvoiceStatus = "draft" | "issued" | "sent" | "paid" | "cancelled"; @@ -73,8 +74,9 @@ export function InvoicesPage() { } async function remove(id: number) { - if (!confirm("Delete this invoice (and all lines)?")) return; - await fetch(`/api/invoices/${id}`, { method: "DELETE" }); + if (!confirm("Delete this draft (and all lines)?")) return; + const res = await fetch(`/api/invoices/${id}`, { method: "DELETE" }); + if (await refused(res)) return; load(); } @@ -167,7 +169,9 @@ export function InvoicesPage() { Mark paid )} - + {inv.status === "draft" && ( + + )} ))} diff --git a/src/client/pages/journals.tsx b/src/client/pages/journals.tsx index 5c9ef94..9d22d5c 100644 --- a/src/client/pages/journals.tsx +++ b/src/client/pages/journals.tsx @@ -17,6 +17,8 @@ interface JournalEntry { source_type: string | null; source_id: number | null; status: string; + reverses_entry_id: number | null; + reversed_by_entry_id: number | null; posted_at: string; lines: JournalLine[]; total_debit_cents: number; @@ -67,6 +69,7 @@ export function JournalsPage() {

Double-entry ledger. Auto-posted from issued invoices and credit notes; debits and credits must balance. + A posted entry is never edited or deleted — a correction is a second, mirror-image entry, and both stay on the record.

@@ -138,12 +141,26 @@ export function JournalsPage() { function EntryCard({ entry }: { entry: JournalEntry }) { const balanced = entry.total_debit_cents === entry.total_credit_cents; + const isReversal = entry.reverses_entry_id !== null; + const wasReversed = entry.reversed_by_entry_id !== null; return ( -
+
{entry.date} {entry.reference} - {entry.description ?? ""} + + {entry.description ?? ""} + + {wasReversed && ( + + reversed by #{entry.reversed_by_entry_id} + + )} + {isReversal && ( + + reverses #{entry.reverses_entry_id} + + )} {balanced ? entry.status : "imbalanced"} diff --git a/src/client/refusal.ts b/src/client/refusal.ts new file mode 100644 index 0000000..75739d7 --- /dev/null +++ b/src/client/refusal.ts @@ -0,0 +1,16 @@ +/** + * Surface a refused write. + * + * The server answers 409 with `{ error }` when a change would break an + * accounting rule -- editing a posted document, deleting a posted invoice, + * posting into a locked period. Those messages say what to do instead, so they + * are shown as-is rather than replaced with a generic failure. + * + * Returns true when the request was refused, so callers can stop. + */ +export async function refused(res: Response): Promise { + if (res.ok) return false; + const data = (await res.json().catch(() => ({}))) as { error?: string }; + alert(data.error || "That change was refused."); + return true; +} diff --git a/src/server/domain/audit.ts b/src/server/domain/audit.ts new file mode 100644 index 0000000..89e27f7 --- /dev/null +++ b/src/server/domain/audit.ts @@ -0,0 +1,81 @@ +import { caller, user, type Caller, type RequestLike } from "@clawnify/app"; +import { query, run } from "../db"; + +/** + * Who is making a change, as recorded in the books. + * + * `kind` is the platform's own caller classification, so the log distinguishes + * a person from the org's agent without the app having to guess. + */ +export interface Actor { + actor: string | null; + actor_kind: Caller; +} + +/** + * Read the actor off a request. + * + * Off-platform (a local `pnpm dev`) there are no identity headers, so this + * yields `{ actor: null, actor_kind: "public" }` rather than inventing a user. + */ +export function actorOf(c: RequestLike): Actor { + const u = user(c); + return { actor: u?.email ?? u?.id ?? null, actor_kind: caller(c) }; +} + +/** An actor for writes with no request behind them (backfills, scripts). */ +export const SYSTEM_ACTOR: Actor = { actor: null, actor_kind: "system" }; + +export interface AuditRow { + id: number; + at: string; + actor: string | null; + actor_kind: string; + action: string; + entity: string; + entity_id: number | null; + before_json: string | null; + after_json: string | null; +} + +/** + * Append one line to the audit log. + * + * Deliberately allowed to throw. If the books can be changed without the change + * being recorded, the log is worth nothing -- a loud failure is the honest + * outcome, not a silent unrecorded write. + */ +export async function record( + actor: Actor, + action: string, + entity: string, + entityId: number | null, + before?: unknown, + after?: unknown, +): Promise { + await run( + `INSERT INTO audit_log (actor, actor_kind, action, entity, entity_id, before_json, after_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + actor.actor, + actor.actor_kind, + action, + entity, + entityId, + before === undefined ? null : JSON.stringify(before), + after === undefined ? null : JSON.stringify(after), + ], + ); +} + +export async function listAudit(filters: { entity?: string; entity_id?: number; limit?: number } = {}): Promise { + const where: string[] = []; + const params: unknown[] = []; + if (filters.entity) { where.push("entity = ?"); params.push(filters.entity); } + if (filters.entity_id) { where.push("entity_id = ?"); params.push(filters.entity_id); } + const limit = Math.min(Math.max(filters.limit ?? 200, 1), 1000); + return query( + `SELECT * FROM audit_log ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY id DESC LIMIT ${limit}`, + params, + ); +} diff --git a/src/server/domain/errors.ts b/src/server/domain/errors.ts new file mode 100644 index 0000000..5fd57e1 --- /dev/null +++ b/src/server/domain/errors.ts @@ -0,0 +1,14 @@ +/** + * A refusal to change the books. + * + * Thrown when a write would break an accounting rule the app is not allowed to + * bend: editing a posted document, deleting an entry, or posting into a locked + * period. Routes turn it into a 409 with the message shown to the caller -- + * including an agent, which is why the messages say what to do instead. + */ +export class LedgerError extends Error { + constructor(message: string) { + super(message); + this.name = "LedgerError"; + } +} diff --git a/src/server/domain/invoices.ts b/src/server/domain/invoices.ts index 3ac2287..e8c4106 100644 --- a/src/server/domain/invoices.ts +++ b/src/server/domain/invoices.ts @@ -1,6 +1,9 @@ import { get, query, run } from "../db"; +import { record, type Actor } from "./audit"; import { getCompany } from "./company"; -import { createFromInvoice as createJournalFromInvoice, deleteEntriesForInvoice } from "./journals"; +import { LedgerError } from "./errors"; +import { assertPeriodOpen } from "./periods"; +import { createFromInvoice as createJournalFromInvoice, reverseEntriesForInvoice } from "./journals"; import { getParty } from "./parties"; import { nextNumber, type NumberingScope } from "./numbering"; import { computeVat } from "./vat"; @@ -101,10 +104,37 @@ export async function createDraft(input: CreateDraftInput): Promise { return created; } -export async function deleteInvoice(id: number): Promise { - await deleteEntriesForInvoice(id); +/** + * Refuse to change a document that has already been posted. + * + * The UI hides these controls on a non-draft invoice, but the API is a public + * surface that the org's agent calls directly, so the rule has to live here + * too. Without it an issued invoice could be silently rewritten while its + * journal entry stood unchanged, and the books would drift from the documents + * behind them. + */ +function assertEditable(inv: Invoice): void { + if (inv.status !== "draft") { + throw new LedgerError( + `Invoice ${inv.number ?? inv.id} is ${inv.status} and can no longer be edited. ` + + `Cancel it or raise a credit note -- a posted document stays as it was issued.`, + ); + } +} + +export async function deleteInvoice(id: number, actor: Actor): Promise { + const inv = await getInvoice(id); + if (!inv) return; + if (inv.status !== "draft") { + throw new LedgerError( + `Invoice ${inv.number ?? id} is ${inv.status} and cannot be deleted. ` + + `Cancel it instead -- that reverses its journal entry and leaves both on the record.`, + ); + } + // A draft was never posted, so there is nothing in the ledger to reverse. await run("DELETE FROM invoice_lines WHERE invoice_id = ?", [id]); await run("DELETE FROM invoices WHERE id = ?", [id]); + await record(actor, "invoice.delete", "invoice", id, inv, null); } const SCOPE_FOR_TYPE: Record = { @@ -113,12 +143,16 @@ const SCOPE_FOR_TYPE: Record = { quote: "quote", }; -export async function issueInvoice(id: number): Promise { +export async function issueInvoice(id: number, actor: Actor): Promise { const inv = await getInvoice(id); if (!inv) return undefined; if (inv.status !== "draft") return inv; - const number = await nextNumber(SCOPE_FOR_TYPE[inv.type]); const today = new Date().toISOString().slice(0, 10); + // Check the period before anything is written. Issuing assigns a number from + // a gap-free sequence and posts to the ledger; if the posting were refused + // afterwards the invoice would be left issued, numbered and unposted. + await assertPeriodOpen(inv.issue_date ?? today, `Invoice ${id}`); + const number = await nextNumber(SCOPE_FOR_TYPE[inv.type]); await run( `UPDATE invoices SET number = ?, @@ -128,25 +162,36 @@ export async function issueInvoice(id: number): Promise { WHERE id = ?`, [number, today, id], ); - await createJournalFromInvoice(id); - return getInvoice(id); + await createJournalFromInvoice(id, actor); + const issued = await getInvoice(id); + await record(actor, "invoice.issue", "invoice", id, { status: inv.status }, issued); + return issued; } -export async function setStatus(id: number, status: InvoiceStatus): Promise { +export async function setStatus(id: number, status: InvoiceStatus, actor: Actor): Promise { + const before = await getInvoice(id); + if (!before) return undefined; + // Reverse first: it is the step that can be refused (a locked period), and + // doing it before the status write means a refusal leaves nothing changed. + if (status === "cancelled") { + await reverseEntriesForInvoice(id, actor); + } await run( `UPDATE invoices SET status = ?, updated_at = datetime('now') WHERE id = ?`, [status, id], ); - if (status === "cancelled") { - await deleteEntriesForInvoice(id); - } - return getInvoice(id); + const after = await getInvoice(id); + await record(actor, "invoice.status", "invoice", id, { status: before.status }, { status }); + return after; } export async function updateInvoice( id: number, input: Partial>, ): Promise { + const inv = await getInvoice(id); + if (!inv) return undefined; + assertEditable(inv); const sets: string[] = []; const params: unknown[] = []; for (const col of ["party_id", "currency", "issue_date", "due_date", "reference", "notes"] as const) { @@ -178,6 +223,7 @@ export interface LineInput { export async function addLine(invoiceId: number, input: LineInput): Promise { const inv = await getInvoice(invoiceId); if (!inv) return undefined; + assertEditable(inv); const maxRow = await get<{ max_pos: number | null }>( "SELECT MAX(position) AS max_pos FROM invoice_lines WHERE invoice_id = ?", [invoiceId], @@ -209,6 +255,9 @@ export async function updateLine(lineId: number, input: LineInput): Promise { [lineId], ); if (!owner) return; + const parent = await getInvoice(owner.invoice_id); + if (!parent) return; + assertEditable(parent); await run("DELETE FROM invoice_lines WHERE id = ?", [lineId]); await recomputeTotals(owner.invoice_id); } diff --git a/src/server/domain/journals.ts b/src/server/domain/journals.ts index e991193..c78b44b 100644 --- a/src/server/domain/journals.ts +++ b/src/server/domain/journals.ts @@ -1,4 +1,6 @@ import { get, query, run } from "../db"; +import { record, SYSTEM_ACTOR, type Actor } from "./audit"; +import { assertPeriodOpen, isPeriodLocked } from "./periods"; import { getInvoice, getLines } from "./invoices"; export interface JournalEntry { @@ -9,6 +11,8 @@ export interface JournalEntry { source_type: string | null; source_id: number | null; status: string; + reverses_entry_id: number | null; + reversed_by_entry_id: number | null; posted_at: string; created_at: string; } @@ -80,11 +84,90 @@ export async function getEntry(id: number): Promise { - await run( - "DELETE FROM journal_entries WHERE source_type IN ('invoice','credit_note') AND source_id = ?", +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** + * Reverse one posted entry with a mirror-image Storno. + * + * Both entries stay `posted` and net to zero, so the trial balance is right + * without either one leaving the books. The Storno is dated in the original's + * period when that period is still open, and today when it is not -- a closed + * month keeps the numbers it was reported with, and the correction lands where + * it can still be seen. + */ +async function reverseEntry(entry: JournalEntry, actor: Actor): Promise { + const stornoDate = (await isPeriodLocked(entry.date)) ? today() : entry.date; + await assertPeriodOpen(stornoDate, `The reversal of ${entry.reference}`); + + const lines = await query( + "SELECT * FROM journal_lines WHERE entry_id = ? ORDER BY position", + [entry.id], + ); + + // There is no transaction primitive here, so the writes are ordered to fail + // safe: the Storno is only counted once its lines exist. A crash part-way + // leaves a 'pending' entry, which no report reads, rather than half a + // reversal that silently unbalances the books. + const result = await run( + `INSERT INTO journal_entries (reference, description, date, source_type, source_id, status, reverses_entry_id) + VALUES (?, ?, ?, ?, ?, 'pending', ?)`, + [ + entry.reference, + `Reversal of ${entry.description ?? entry.reference}`, + stornoDate, + entry.source_type, + entry.source_id, + entry.id, + ], + ); + const stornoId = result.lastInsertRowid; + + for (const line of lines) { + await run( + `INSERT INTO journal_lines (entry_id, position, account_code, description, debit_cents, credit_cents) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + stornoId, + line.position, + line.account_code, + `Reversal ${line.description ?? ""}`.trim(), + line.credit_cents, + line.debit_cents, + ], + ); + } + + await run("UPDATE journal_entries SET reversed_by_entry_id = ? WHERE id = ?", [stornoId, entry.id]); + await run("UPDATE journal_entries SET status = 'posted' WHERE id = ?", [stornoId]); + + await record(actor, "journal.reverse", "journal_entry", entry.id, entry, { + reversed_by_entry_id: stornoId, + date: stornoDate, + }); + return stornoId; +} + +/** + * Reverse every posted entry behind an invoice. + * + * Replaces the delete this used to be: a posted entry is never removed, so + * cancelling a document leaves the original and its Storno both on the record. + */ +export async function reverseEntriesForInvoice(invoiceId: number, actor: Actor): Promise { + const entries = await query( + `SELECT * FROM journal_entries + WHERE source_type IN ('invoice','credit_note') + AND source_id = ? + AND status = 'posted' + AND reversed_by_entry_id IS NULL`, [invoiceId], ); + for (const entry of entries) { + await reverseEntry(entry, actor); + } + return entries.length; } interface PendingLine { @@ -94,13 +177,15 @@ interface PendingLine { credit_cents: number; } -export async function createFromInvoice(invoiceId: number): Promise { +export async function createFromInvoice(invoiceId: number, actor: Actor = SYSTEM_ACTOR): Promise { const inv = await getInvoice(invoiceId); if (!inv) return undefined; if (!inv.number) return undefined; if (inv.status === "draft" || inv.status === "cancelled") return undefined; - await deleteEntriesForInvoice(invoiceId); + // Re-posting reverses what was there before rather than deleting it, so the + // superseded entry stays visible next to its replacement. + await reverseEntriesForInvoice(invoiceId, actor); const lines = await getLines(invoiceId); if (lines.length === 0) return undefined; @@ -172,7 +257,8 @@ export async function createFromInvoice(invoiceId: number): Promise("SELECT * FROM journal_entries WHERE id = ?", [entryId]); + const entry = await get("SELECT * FROM journal_entries WHERE id = ?", [entryId]); + await record(actor, "journal.post", "journal_entry", entryId, null, entry); + return entry; } -export async function backfillFromInvoices(): Promise<{ created: number }> { +export async function backfillFromInvoices(actor: Actor = SYSTEM_ACTOR): Promise<{ created: number }> { const candidates = await query<{ id: number }>( `SELECT i.id FROM invoices i LEFT JOIN journal_entries j @@ -207,7 +295,7 @@ export async function backfillFromInvoices(): Promise<{ created: number }> { ); let created = 0; for (const row of candidates) { - const entry = await createFromInvoice(row.id); + const entry = await createFromInvoice(row.id, actor); if (entry) created++; } return { created }; diff --git a/src/server/domain/periods.ts b/src/server/domain/periods.ts new file mode 100644 index 0000000..9fcb742 --- /dev/null +++ b/src/server/domain/periods.ts @@ -0,0 +1,70 @@ +import { get, query, run } from "../db"; +import { record, type Actor } from "./audit"; +import { LedgerError } from "./errors"; + +export interface Period { + year: number; + month: number; + locked_at: string; + locked_by: string | null; + locked_by_kind: string | null; +} + +/** Split a 'YYYY-MM-DD' date into the period it belongs to. */ +export function periodOf(date: string): { year: number; month: number } { + return { year: Number(date.slice(0, 4)), month: Number(date.slice(5, 7)) }; +} + +export async function isPeriodLocked(date: string): Promise { + const { year, month } = periodOf(date); + const row = await get<{ year: number }>( + "SELECT year FROM periods WHERE year = ? AND month = ?", + [year, month], + ); + return !!row; +} + +/** + * Refuse a write whose entry date falls in a closed period. Called on every + * path that puts something into the ledger, so a back-dated document cannot + * slip into a month that has already been reported. + */ +export async function assertPeriodOpen(date: string, what: string): Promise { + if (await isPeriodLocked(date)) { + const { year, month } = periodOf(date); + throw new LedgerError( + `${what} is dated ${date}, but ${year}-${String(month).padStart(2, "0")} is locked. ` + + `Post it into an open period instead -- a locked period cannot be reopened.`, + ); + } +} + +export async function listPeriods(): Promise { + return query("SELECT * FROM periods ORDER BY year DESC, month DESC"); +} + +export async function lockPeriod(year: number, month: number, actor: Actor): Promise { + if (!Number.isInteger(year) || year < 1900 || year > 9999) { + throw new LedgerError(`'${year}' is not a valid year.`); + } + if (!Number.isInteger(month) || month < 1 || month > 12) { + throw new LedgerError(`'${month}' is not a month between 1 and 12.`); + } + const existing = await get( + "SELECT * FROM periods WHERE year = ? AND month = ?", + [year, month], + ); + if (existing) return existing; + + await run( + "INSERT INTO periods (year, month, locked_by, locked_by_kind) VALUES (?, ?, ?, ?)", + [year, month, actor.actor, actor.actor_kind], + ); + const locked = await get( + "SELECT * FROM periods WHERE year = ? AND month = ?", + [year, month], + ); + if (!locked) throw new Error("Failed to load locked period"); + await record(actor, "period.lock", "period", null, null, locked); + return locked; +} diff --git a/src/server/routes.ts b/src/server/routes.ts index 1f63451..a4b8809 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -18,6 +18,9 @@ import { type InvoiceType, } from "./domain/invoices"; import { getCompany, updateCompany } from "./domain/company"; +import { actorOf, listAudit } from "./domain/audit"; +import { LedgerError } from "./domain/errors"; +import { listPeriods, lockPeriod } from "./domain/periods"; import { backfillFromInvoices, getEntry, listEntries, trialBalance } from "./domain/journals"; import { balanceSheet, profitLoss } from "./domain/reports"; import { COUNTRY_VAT, computeVat, EU_MEMBER_STATES } from "./domain/vat"; @@ -27,6 +30,18 @@ import { renderInvoiceUBL } from "./exports/ubl"; const api = new Hono(); +// A refused write is not a server fault -- it is the books saying no. Turned +// into a 409 here so every route reports it the same way, with the message the +// domain wrote (agents call these routes directly and act on that text). +api.use("*", async (c, next) => { + try { + await next(); + } catch (err) { + if (err instanceof LedgerError) return c.json({ error: err.message }, 409); + throw err; + } +}); + api.get("/api/accounts", async (c) => { const bw = c.req.query("bw"); const nivoStr = c.req.query("nivo"); @@ -229,7 +244,7 @@ api.patch("/api/invoices/:id", async (c) => { }); api.post("/api/invoices/:id/issue", async (c) => { - const inv = await issueInvoice(Number(c.req.param("id"))); + const inv = await issueInvoice(Number(c.req.param("id")), actorOf(c)); if (!inv) return c.json({ error: "Not found" }, 404); return c.json(inv); }); @@ -276,7 +291,7 @@ api.get("/api/journals/:id", async (c) => { }); api.post("/api/journals/backfill", async (c) => { - return c.json(await backfillFromInvoices()); + return c.json(await backfillFromInvoices(actorOf(c))); }); api.get("/api/reports/trial-balance", async (c) => { @@ -299,16 +314,42 @@ api.get("/api/reports/balance-sheet", async (c) => { api.post("/api/invoices/:id/status", async (c) => { const id = Number(c.req.param("id")); const { status } = await c.req.json<{ status: InvoiceStatus }>(); - const inv = await setStatus(id, status); + const inv = await setStatus(id, status, actorOf(c)); if (!inv) return c.json({ error: "Not found" }, 404); return c.json(inv); }); api.delete("/api/invoices/:id", async (c) => { - await deleteInvoice(Number(c.req.param("id"))); + await deleteInvoice(Number(c.req.param("id")), actorOf(c)); return c.json({ ok: true }); }); +api.get("/api/periods", async (c) => { + return c.json(await listPeriods()); +}); + +api.post("/api/periods/:year/:month/lock", async (c) => { + const period = await lockPeriod( + Number(c.req.param("year")), + Number(c.req.param("month")), + actorOf(c), + ); + return c.json(period); +}); + +api.get("/api/audit", async (c) => { + const entity = c.req.query("entity") || undefined; + const entityIdStr = c.req.query("entity_id"); + const limitStr = c.req.query("limit"); + return c.json( + await listAudit({ + entity, + entity_id: entityIdStr ? Number(entityIdStr) : undefined, + limit: limitStr ? Number(limitStr) : undefined, + }), + ); +}); + api.get("/api/vat/countries", (c) => { return c.json({ eu: EU_MEMBER_STATES, rates: COUNTRY_VAT }); }); diff --git a/src/server/schema.sql b/src/server/schema.sql index f6bb6e7..290a824 100644 --- a/src/server/schema.sql +++ b/src/server/schema.sql @@ -136,6 +136,12 @@ CREATE TABLE IF NOT EXISTS company ( -- The singleton company row is created by the app (ensureCompanyRow in -- src/server/domain/company.ts): a deploy applies this file as DDL only. +-- A posted entry is never deleted or edited. A correction is a second, +-- mirror-image entry (a Storno): both stay `posted` and net to zero, so the +-- trial balance stays right while the history stays complete. The pair is +-- linked by reverses_entry_id / reversed_by_entry_id -- there is deliberately +-- no 'reversed' status, because dropping the original out of the +-- `status = 'posted'` reports would subtract the same amount twice. CREATE TABLE IF NOT EXISTS journal_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, reference TEXT NOT NULL, @@ -143,9 +149,16 @@ CREATE TABLE IF NOT EXISTS journal_entries ( date TEXT NOT NULL, source_type TEXT, source_id INTEGER, + -- 'posted' counts towards the books. 'pending' is the brief window while a + -- Storno is being written; there is no transaction primitive, so the entry + -- is flipped to 'posted' only once its lines are in. status TEXT NOT NULL DEFAULT 'posted', + reverses_entry_id INTEGER, + reversed_by_entry_id INTEGER, posted_at TEXT NOT NULL DEFAULT (datetime('now')), - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (reverses_entry_id) REFERENCES journal_entries(id), + FOREIGN KEY (reversed_by_entry_id) REFERENCES journal_entries(id) ); CREATE TABLE IF NOT EXISTS journal_lines ( @@ -164,3 +177,37 @@ CREATE INDEX IF NOT EXISTS idx_journal_entries_date ON journal_entries(date); CREATE INDEX IF NOT EXISTS idx_journal_entries_source ON journal_entries(source_type, source_id); CREATE INDEX IF NOT EXISTS idx_journal_lines_entry ON journal_lines(entry_id); CREATE INDEX IF NOT EXISTS idx_journal_lines_account ON journal_lines(account_code); + +CREATE INDEX IF NOT EXISTS idx_journal_entries_reverses ON journal_entries(reverses_entry_id); +CREATE INDEX IF NOT EXISTS idx_journal_entries_reversed_by ON journal_entries(reversed_by_entry_id); + +-- A locked period is closed for good: nothing may be posted into it again. +-- Locking is one-way on purpose -- an "unlock" would make every lock a +-- suggestion, and the point of the lock is that it is not one. +CREATE TABLE IF NOT EXISTS periods ( + year INTEGER NOT NULL, + month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12), + locked_at TEXT NOT NULL DEFAULT (datetime('now')), + locked_by TEXT, + locked_by_kind TEXT, + PRIMARY KEY (year, month) +); + +-- Append-only record of everything that touched the books, and who did it. +-- actor_kind comes straight from the platform's caller() -- 'user' and 'api' +-- are a person, 'agent' and 'agent-browser' are the org's agent acting on its +-- own, which is exactly the distinction an auditor asks about. +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL DEFAULT (datetime('now')), + actor TEXT, + actor_kind TEXT NOT NULL DEFAULT 'public', + action TEXT NOT NULL, + entity TEXT NOT NULL, + entity_id INTEGER, + before_json TEXT, + after_json TEXT +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_at ON audit_log(at); +CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity, entity_id); From 66640a4066b3817f45b8d924d812ed499b840ae3 Mon Sep 17 00:00:00 2001 From: Pallaoro Date: Fri, 11 Sep 2026 13:48:25 +0200 Subject: [PATCH 2/5] Close 4 correctness gaps a second review found in the audit-proof ledger Independent re-review of this branch before merge surfaced real bugs the first pass's sqlite harness didn't exercise: - setStatus had no restriction on the target status, so POST /api/invoices/:id/status could set a posted invoice back to 'draft', reopening every edit path assertEditable exists to close. Restrict it to the three legitimate transitions (sent/paid/cancelled) and refuse on a still-draft invoice. - reverseEntry set the original's reversed_by_entry_id before flipping the Storno to 'posted'. A crash between those two writes left the Storno stuck pending (invisible to every report) while the original looked already reversed and unretryable -- a silent, permanent loss of the correction. Flip the Storno to posted first, and make the whole operation idempotent (reuse an already-posted Storno on retry) so a crash there can no longer double-post either. - createFromInvoice reversed the superseded entry before checking the new one would balance, have any lines, or land in an open period. A refusal there left the original reversed with nothing posted to replace it. Move the reversal after every check that can still refuse the call. - lockPeriod accepted the current or a future month. There is no unlock route, so locking a month before it's over would have permanently refused every invoice in it. Also: listAudit's limit clamp let a non-numeric ?limit= reach the query as `LIMIT NaN` and 500. Verified via the node:sqlite domain harness from the original commit (memory: template-sqlite-harness) -- all 35 existing checks still pass, plus 13 new checks targeting these 5 bugs specifically, each confirmed to fail against the pre-fix code first. tsc --noEmit and vite build both clean (same 15 pre-existing tsc errors as baseline, all in client code unrelated to this change). Co-Authored-By: Claude Sonnet 5 --- src/server/domain/audit.ts | 5 ++++- src/server/domain/invoices.ts | 13 +++++++++++++ src/server/domain/journals.ts | 32 +++++++++++++++++++++++++++----- src/server/domain/periods.ts | 11 +++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/server/domain/audit.ts b/src/server/domain/audit.ts index 89e27f7..789a2fe 100644 --- a/src/server/domain/audit.ts +++ b/src/server/domain/audit.ts @@ -73,7 +73,10 @@ export async function listAudit(filters: { entity?: string; entity_id?: number; const params: unknown[] = []; if (filters.entity) { where.push("entity = ?"); params.push(filters.entity); } if (filters.entity_id) { where.push("entity_id = ?"); params.push(filters.entity_id); } - const limit = Math.min(Math.max(filters.limit ?? 200, 1), 1000); + // ?? only falls back on null/undefined, so an unparsable `limit` (Number("abc") + // is NaN, not undefined) would otherwise reach the query below as `LIMIT NaN`. + const requested = Number.isFinite(filters.limit) ? (filters.limit as number) : 200; + const limit = Math.min(Math.max(requested, 1), 1000); return query( `SELECT * FROM audit_log ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY id DESC LIMIT ${limit}`, params, diff --git a/src/server/domain/invoices.ts b/src/server/domain/invoices.ts index e8c4106..38682f2 100644 --- a/src/server/domain/invoices.ts +++ b/src/server/domain/invoices.ts @@ -168,9 +168,22 @@ export async function issueInvoice(id: number, actor: Actor): Promise(["sent", "paid", "cancelled"]); + export async function setStatus(id: number, status: InvoiceStatus, actor: Actor): Promise { const before = await getInvoice(id); if (!before) return undefined; + // This is a public API surface, not just the three buttons the UI shows. + // Without these checks an agent (or a stray request) could set an issued + // invoice back to 'draft', which reopens editing on a document whose + // journal entry has already been posted -- the exact drift assertEditable + // exists to prevent. + if (before.status === "draft") { + throw new LedgerError(`Invoice ${before.number ?? id} is still a draft -- issue it first.`); + } + if (!SETTABLE_STATUSES.has(status)) { + throw new LedgerError(`Cannot set an invoice to '${status}'. Valid transitions are sent, paid, or cancelled.`); + } // Reverse first: it is the step that can be refused (a locked period), and // doing it before the status write means a refusal leaves nothing changed. if (status === "cancelled") { diff --git a/src/server/domain/journals.ts b/src/server/domain/journals.ts index c78b44b..476b652 100644 --- a/src/server/domain/journals.ts +++ b/src/server/domain/journals.ts @@ -98,6 +98,20 @@ function today(): string { * it can still be seen. */ async function reverseEntry(entry: JournalEntry, actor: Actor): Promise { + // Idempotent: a prior call may have posted the Storno and then crashed + // before linking it back (see the write order below). Reuse it rather + // than posting a second reversal for the same entry. + const existingStorno = await get( + "SELECT * FROM journal_entries WHERE reverses_entry_id = ? AND status = 'posted'", + [entry.id], + ); + if (existingStorno) { + if (!entry.reversed_by_entry_id) { + await run("UPDATE journal_entries SET reversed_by_entry_id = ? WHERE id = ?", [existingStorno.id, entry.id]); + } + return existingStorno.id; + } + const stornoDate = (await isPeriodLocked(entry.date)) ? today() : entry.date; await assertPeriodOpen(stornoDate, `The reversal of ${entry.reference}`); @@ -139,8 +153,13 @@ async function reverseEntry(entry: JournalEntry, actor: Actor): Promise ); } - await run("UPDATE journal_entries SET reversed_by_entry_id = ? WHERE id = ?", [stornoId, entry.id]); + // Post the Storno before linking it back. A crash between these two writes + // then leaves the books already correct (every report already counts the + // posted Storno) with only the back-link missing -- and the idempotency + // check above finishes that link on retry instead of posting a second, + // duplicate reversal. await run("UPDATE journal_entries SET status = 'posted' WHERE id = ?", [stornoId]); + await run("UPDATE journal_entries SET reversed_by_entry_id = ? WHERE id = ?", [stornoId, entry.id]); await record(actor, "journal.reverse", "journal_entry", entry.id, entry, { reversed_by_entry_id: stornoId, @@ -183,10 +202,6 @@ export async function createFromInvoice(invoiceId: number, actor: Actor = SYSTEM if (!inv.number) return undefined; if (inv.status === "draft" || inv.status === "cancelled") return undefined; - // Re-posting reverses what was there before rather than deleting it, so the - // superseded entry stays visible next to its replacement. - await reverseEntriesForInvoice(invoiceId, actor); - const lines = await getLines(invoiceId); if (lines.length === 0) return undefined; @@ -259,6 +274,13 @@ export async function createFromInvoice(invoiceId: number, actor: Actor = SYSTEM const date = inv.issue_date ?? today(); await assertPeriodOpen(date, `${isCreditNote ? "Credit note" : "Invoice"} ${inv.number}`); + + // Re-posting reverses what was there before rather than deleting it, so the + // superseded entry stays visible next to its replacement. Done last, after + // every check that can still refuse this call -- a refusal here would + // otherwise leave the old entry reversed with nothing posted to replace it. + await reverseEntriesForInvoice(invoiceId, actor); + const description = isCreditNote ? `Credit note ${inv.number}` : `Invoice ${inv.number}`; diff --git a/src/server/domain/periods.ts b/src/server/domain/periods.ts index 9fcb742..477bbee 100644 --- a/src/server/domain/periods.ts +++ b/src/server/domain/periods.ts @@ -50,6 +50,17 @@ export async function lockPeriod(year: number, month: number, actor: Actor): Pro if (!Number.isInteger(month) || month < 1 || month > 12) { throw new LedgerError(`'${month}' is not a month between 1 and 12.`); } + // A lock is one-way and there is no unlock route, so locking a month that + // hasn't ended yet would permanently refuse every invoice for the rest of + // it with no way back. + const now = new Date(); + const currentYear = now.getUTCFullYear(); + const currentMonth = now.getUTCMonth() + 1; + if (year > currentYear || (year === currentYear && month >= currentMonth)) { + throw new LedgerError( + `${year}-${String(month).padStart(2, "0")} has not ended yet. A period can only be locked once its month is over.`, + ); + } const existing = await get( "SELECT * FROM periods WHERE year = ? AND month = ?", [year, month], From 815410a26da4f0124060d12dbd4c79f1824f38e4 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Sun, 13 Sep 2026 22:02:22 +0200 Subject: [PATCH 3/5] Fix repeat invoice reversals and ledger error responses --- package.json | 4 +- pnpm-lock.yaml | 299 ++++++++++++++++++++++++++++++- src/server/domain/journals.ts | 3 +- src/server/routes.ts | 14 +- tests/ledger-regressions.test.ts | 110 ++++++++++++ 5 files changed, 414 insertions(+), 16 deletions(-) create mode 100644 tests/ledger-regressions.test.ts diff --git a/package.json b/package.json index 1a0d479..c00da73 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "test": "tsx --test tests/*.test.ts", "dev": "clawnify dev", "build": "vite build", "deploy": "clawnify deploy" @@ -23,6 +24,7 @@ "@vitejs/plugin-react": "^4.0.0", "clawnify": "^0.1.13", "tailwindcss": "^4.0.0", + "tsx": "^4.23.13", "vite": "^6.0.0", "wrangler": "^4.0.0" }, @@ -34,4 +36,4 @@ "esbuild" ] } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 743ecba..6562ea6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: version: 4.20260503.1 '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.2.4(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 4.2.4(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)) '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -41,16 +41,19 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.0.0 - version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0)) clawnify: specifier: ^0.1.13 version: 0.1.13 tailwindcss: specifier: ^4.0.0 version: 4.2.4 + tsx: + specifier: ^4.23.13 + version: 4.23.13 vite: specifier: ^6.0.0 - version: 6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.0) + version: 6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0) wrangler: specifier: ^4.0.0 version: 4.87.0(@cloudflare/workers-types@4.20260503.1) @@ -232,6 +235,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -244,6 +253,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -256,6 +271,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -268,6 +289,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -280,6 +307,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -292,6 +325,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -304,6 +343,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -316,6 +361,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -328,6 +379,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -340,6 +397,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -352,6 +415,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -364,6 +433,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -376,6 +451,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -388,6 +469,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -400,6 +487,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -412,6 +505,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -424,6 +523,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -436,6 +541,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -448,6 +559,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -460,6 +577,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -472,6 +595,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -484,6 +613,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -496,6 +631,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -508,6 +649,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -520,6 +667,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -532,6 +685,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -1270,6 +1429,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1738,6 +1902,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -2101,156 +2270,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.3': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.3': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@hono/node-server@1.19.14(hono@4.13.7)': dependencies: hono: 4.13.7 @@ -2563,12 +2810,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.2.4(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0) '@types/babel__core@7.20.5': dependencies: @@ -2601,7 +2848,7 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.0))': + '@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -2609,7 +2856,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -2816,6 +3063,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -3300,6 +3576,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -3322,7 +3604,7 @@ snapshots: vary@1.1.2: {} - vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.9.0): + vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.23.13)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -3334,6 +3616,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 + tsx: 4.23.13 yaml: 2.9.0 which@2.0.2: diff --git a/src/server/domain/journals.ts b/src/server/domain/journals.ts index 476b652..3556ca8 100644 --- a/src/server/domain/journals.ts +++ b/src/server/domain/journals.ts @@ -169,7 +169,7 @@ async function reverseEntry(entry: JournalEntry, actor: Actor): Promise } /** - * Reverse every posted entry behind an invoice. + * Reverse every original posted entry behind an invoice. * * Replaces the delete this used to be: a posted entry is never removed, so * cancelling a document leaves the original and its Storno both on the record. @@ -180,6 +180,7 @@ export async function reverseEntriesForInvoice(invoiceId: number, actor: Actor): WHERE source_type IN ('invoice','credit_note') AND source_id = ? AND status = 'posted' + AND reverses_entry_id IS NULL AND reversed_by_entry_id IS NULL`, [invoiceId], ); diff --git a/src/server/routes.ts b/src/server/routes.ts index a4b8809..915fa91 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; import { clearAccounts, getAccount, getTree, listAccounts, loadStarter } from "./domain/rgs"; import { createParty, deleteParty, getParty, listParties, updateParty, type PartyKind } from "./domain/parties"; import { createProduct, deleteProduct, getProduct, listProducts, updateProduct } from "./domain/products"; @@ -33,13 +34,14 @@ const api = new Hono(); // A refused write is not a server fault -- it is the books saying no. Turned // into a 409 here so every route reports it the same way, with the message the // domain wrote (agents call these routes directly and act on that text). -api.use("*", async (c, next) => { - try { - await next(); - } catch (err) { - if (err instanceof LedgerError) return c.json({ error: err.message }, 409); - throw err; +api.onError((err, c) => { + if (err instanceof LedgerError) return c.json({ error: err.message }, 409); + if (err instanceof HTTPException) { + const response = err.getResponse(); + return c.newResponse(response.body, response); } + console.error(err); + return c.text("Internal Server Error", 500); }); api.get("/api/accounts", async (c) => { diff --git a/tests/ledger-regressions.test.ts b/tests/ledger-regressions.test.ts new file mode 100644 index 0000000..303a918 --- /dev/null +++ b/tests/ledger-regressions.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, test } from "node:test"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { initDB } from "../src/server/db"; +import { setStatus } from "../src/server/domain/invoices"; +import { trialBalance } from "../src/server/domain/journals"; +import api from "../src/server/routes"; + +const actor = { actor: "regression-test", actor_kind: "user" as const }; +let db: DatabaseSync; +let beforeQuery: ((sql: string) => void) | undefined; + +beforeEach(() => { + db = new DatabaseSync(":memory:"); + db.exec(readFileSync(new URL("../src/server/schema.sql", import.meta.url), "utf8")); + beforeQuery = undefined; + initDB({ + STORAGE: { + async query(sql: string, params: (string | number | null)[] = []) { + beforeQuery?.(sql); + const statement = db.prepare(sql); + if (statement.columns().length) return { rows: statement.all(...params), meta: {} }; + const result = statement.run(...params); + return { rows: [], meta: { changes: result.changes, last_row_id: Number(result.lastInsertRowid) } }; + }, + }, + }); + db.exec(` + INSERT INTO parties(id, kind, name) VALUES(1, 'customer', 'Test'); + INSERT INTO accounts(rgs_code, nivo, omskort, bw) + VALUES('BVor', 1, 'Receivables', 'B'), ('WOmz', 1, 'Sales', 'W'); + INSERT INTO invoices(id, number, status, party_id, issue_date, subtotal_cents, total_cents) + VALUES(1, 'INV-TEST', 'issued', 1, '2025-01-10', 10000, 10000); + INSERT INTO journal_entries(id, reference, date, source_type, source_id) + VALUES(1, 'INV-TEST', '2025-01-10', 'invoice', 1); + INSERT INTO journal_lines(entry_id, position, account_code, debit_cents, credit_cents) + VALUES(1, 1, 'BVor', 10000, 0), (1, 2, 'WOmz', 0, 10000); + `); +}); + +afterEach(() => db.close()); + +async function assertCancelledBooks() { + const balances = await trialBalance(); + assert.equal(balances.find((row) => row.account_code === "BVor")?.balance_cents, 0); + assert.equal(balances.find((row) => row.account_code === "WOmz")?.balance_cents, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 2); + assert.equal(db.prepare("SELECT status FROM invoices WHERE id = 1").get()?.status, "cancelled"); + assert.equal(db.prepare("SELECT reversed_by_entry_id FROM journal_entries WHERE id = 1").get()?.reversed_by_entry_id, 2); +} + +test("repeated cancellation keeps the original and a single reversal netted to zero", async () => { + await setStatus(1, "cancelled", actor); + await assertCancelledBooks(); + await setStatus(1, "cancelled", actor); + await assertCancelledBooks(); +}); + +test("retry after a posted reversal repairs the backlink without reversing the reversal", async () => { + beforeQuery = (sql) => { + if (sql.includes("UPDATE journal_entries SET reversed_by_entry_id")) { + throw new Error("crash before backlink"); + } + }; + await assert.rejects(setStatus(1, "cancelled", actor), /crash before backlink/); + assert.equal(db.prepare("SELECT status FROM journal_entries WHERE id = 2").get()?.status, "posted"); + assert.equal(db.prepare("SELECT reversed_by_entry_id FROM journal_entries WHERE id = 1").get()?.reversed_by_entry_id, null); + beforeQuery = undefined; + await setStatus(1, "cancelled", actor); + await assertCancelledBooks(); + await setStatus(1, "cancelled", actor); + await assertCancelledBooks(); +}); + +for (const mounted of [false, true]) { + test(`DELETE issued invoice returns 409 JSON (${mounted ? "mounted" : "direct"} API)`, async () => { + const app = mounted ? new Hono().route("/", api) : api; + const response = await app.request("/api/invoices/1", { method: "DELETE" }); + assert.equal(response.status, 409); + assert.match(response.headers.get("content-type") ?? "", /application\/json/); + assert.deepEqual(await response.json(), { + error: "Invoice INV-TEST is issued and cannot be deleted. Cancel it instead -- that reverses its journal entry and leaves both on the record.", + }); + assert.equal(db.prepare("SELECT status FROM invoices WHERE id = 1").get()?.status, "issued"); + }); + + test(`unexpected errors retain generic 500 response (${mounted ? "mounted" : "direct"} API)`, async () => { + beforeQuery = () => { throw new Error("private storage failure"); }; + const app = mounted ? new Hono().route("/", api) : api; + const response = await app.request("/api/invoices/1", { method: "DELETE" }); + assert.equal(response.status, 500); + assert.equal(await response.text(), "Internal Server Error"); + }); + + test(`HTTPException retains its response (${mounted ? "mounted" : "direct"} API)`, async () => { + beforeQuery = () => { + throw new HTTPException(503, { + res: new Response("Storage unavailable", { status: 503, headers: { "Retry-After": "30" } }), + }); + }; + const app = mounted ? new Hono().route("/", api) : api; + const response = await app.request("/api/invoices/1", { method: "DELETE" }); + assert.equal(response.status, 503); + assert.equal(response.headers.get("Retry-After"), "30"); + assert.equal(await response.text(), "Storage unavailable"); + }); +} From 5e359a7e31df295ed08873be0874b28e70db876a Mon Sep 17 00:00:00 2001 From: pallaoro Date: Sun, 13 Sep 2026 22:12:34 +0200 Subject: [PATCH 4/5] Publish ledger changes and period locks atomically --- src/server/domain/errors.ts | 19 +++ src/server/domain/invoices.ts | 30 ++-- src/server/domain/journals.ts | 260 ++++--------------------------- src/server/domain/periods.ts | 24 ++- src/server/schema.sql | 196 ++++++++++++++++++++++- tests/ledger-regressions.test.ts | 147 +++++++++++++++-- 6 files changed, 406 insertions(+), 270 deletions(-) diff --git a/src/server/domain/errors.ts b/src/server/domain/errors.ts index 5fd57e1..721645e 100644 --- a/src/server/domain/errors.ts +++ b/src/server/domain/errors.ts @@ -12,3 +12,22 @@ export class LedgerError extends Error { this.name = "LedgerError"; } } + +const SQL_REFUSALS: Record = { + "invalid-date": "Use a valid calendar date in YYYY-MM-DD format (1900 or later).", + "period-locked": "That accounting period is locked. Post into an open period instead.", + "invalid-period": "Use a valid accounting year and month.", + "period-not-ended": "A period can only be locked once its month is over.", + "invalid-original": "Only an unreversed original posted entry can be reversed.", + "invalid-source": "Only a numbered, issued invoice or credit note can be posted.", + "empty-invoice": "Add at least one line before posting an invoice.", + "unbalanced-entry": "The journal entry must have lines with equal total debits and credits.", + "invalid-transition": "That invoice status transition is not allowed. A cancelled invoice stays cancelled.", +}; + +/** Only our explicit SQLite refusals are conflicts; storage faults remain 500s. */ +export function rethrowLedgerError(error: unknown): never { + const marker = error instanceof Error ? /\bledger:([a-z-]+)\b/.exec(error.message)?.[1] : undefined; + if (marker && SQL_REFUSALS[marker]) throw new LedgerError(SQL_REFUSALS[marker]); + throw error; +} diff --git a/src/server/domain/invoices.ts b/src/server/domain/invoices.ts index 38682f2..74d4701 100644 --- a/src/server/domain/invoices.ts +++ b/src/server/domain/invoices.ts @@ -1,9 +1,9 @@ import { get, query, run } from "../db"; import { record, type Actor } from "./audit"; import { getCompany } from "./company"; -import { LedgerError } from "./errors"; +import { LedgerError, rethrowLedgerError } from "./errors"; import { assertPeriodOpen } from "./periods"; -import { createFromInvoice as createJournalFromInvoice, reverseEntriesForInvoice } from "./journals"; +import { createFromInvoice as createJournalFromInvoice } from "./journals"; import { getParty } from "./parties"; import { nextNumber, type NumberingScope } from "./numbering"; import { computeVat } from "./vat"; @@ -184,17 +184,25 @@ export async function setStatus(id: number, status: InvoiceStatus, actor: Actor) if (!SETTABLE_STATUSES.has(status)) { throw new LedgerError(`Cannot set an invoice to '${status}'. Valid transitions are sent, paid, or cancelled.`); } - // Reverse first: it is the step that can be refused (a locked period), and - // doing it before the status write means a refusal leaves nothing changed. - if (status === "cancelled") { - await reverseEntriesForInvoice(id, actor); + if (before.status === "cancelled" && status !== "cancelled") { + throw new LedgerError("A cancelled invoice cannot be sent or paid. Create a new invoice instead."); + } + try { + // SQLite owns reversal, period checks and status audit in this statement. + // Its conditional write makes retries/no-op requests produce no audit. + await run( + `UPDATE invoices SET status = ?, mutation_actor = ?, mutation_actor_kind = ?, + updated_at = datetime('now') + WHERE id = ? AND status IN ('issued','sent','paid') AND status <> ?`, + [status, actor.actor, actor.actor_kind, id, status], + ); + } catch (error) { + rethrowLedgerError(error); } - await run( - `UPDATE invoices SET status = ?, updated_at = datetime('now') WHERE id = ?`, - [status, id], - ); const after = await getInvoice(id); - await record(actor, "invoice.status", "invoice", id, { status: before.status }, { status }); + if (after?.status === "cancelled" && status !== "cancelled") { + throw new LedgerError("A cancelled invoice cannot be sent or paid. Create a new invoice instead."); + } return after; } diff --git a/src/server/domain/journals.ts b/src/server/domain/journals.ts index 3556ca8..d18b733 100644 --- a/src/server/domain/journals.ts +++ b/src/server/domain/journals.ts @@ -1,7 +1,6 @@ -import { get, query, run } from "../db"; -import { record, SYSTEM_ACTOR, type Actor } from "./audit"; -import { assertPeriodOpen, isPeriodLocked } from "./periods"; -import { getInvoice, getLines } from "./invoices"; +import { get, query } from "../db"; +import { SYSTEM_ACTOR, type Actor } from "./audit"; +import { rethrowLedgerError } from "./errors"; export interface JournalEntry { id: number; @@ -33,10 +32,6 @@ export interface JournalEntryWithLines extends JournalEntry { total_credit_cents: number; } -const RECEIVABLE_ACCOUNT = "BVor"; -const REVENUE_ACCOUNT_FALLBACK = "WOmz"; -const VAT_PAYABLE_ACCOUNT = "BKas"; - export async function listEntries(filters: { source_type?: string; from?: string; to?: string } = {}): Promise { const where: string[] = []; const params: unknown[] = []; @@ -84,242 +79,39 @@ export async function getEntry(id: number): Promise { - // Idempotent: a prior call may have posted the Storno and then crashed - // before linking it back (see the write order below). Reuse it rather - // than posting a second reversal for the same entry. - const existingStorno = await get( - "SELECT * FROM journal_entries WHERE reverses_entry_id = ? AND status = 'posted'", - [entry.id], - ); - if (existingStorno) { - if (!entry.reversed_by_entry_id) { - await run("UPDATE journal_entries SET reversed_by_entry_id = ? WHERE id = ?", [existingStorno.id, entry.id]); - } - return existingStorno.id; - } - - const stornoDate = (await isPeriodLocked(entry.date)) ? today() : entry.date; - await assertPeriodOpen(stornoDate, `The reversal of ${entry.reference}`); - - const lines = await query( - "SELECT * FROM journal_lines WHERE entry_id = ? ORDER BY position", - [entry.id], - ); - - // There is no transaction primitive here, so the writes are ordered to fail - // safe: the Storno is only counted once its lines exist. A crash part-way - // leaves a 'pending' entry, which no report reads, rather than half a - // reversal that silently unbalances the books. - const result = await run( - `INSERT INTO journal_entries (reference, description, date, source_type, source_id, status, reverses_entry_id) - VALUES (?, ?, ?, ?, ?, 'pending', ?)`, - [ - entry.reference, - `Reversal of ${entry.description ?? entry.reference}`, - stornoDate, - entry.source_type, - entry.source_id, - entry.id, - ], - ); - const stornoId = result.lastInsertRowid; - - for (const line of lines) { - await run( - `INSERT INTO journal_lines (entry_id, position, account_code, description, debit_cents, credit_cents) - VALUES (?, ?, ?, ?, ?, ?)`, - [ - stornoId, - line.position, - line.account_code, - `Reversal ${line.description ?? ""}`.trim(), - line.credit_cents, - line.debit_cents, - ], - ); - } - - // Post the Storno before linking it back. A crash between these two writes - // then leaves the books already correct (every report already counts the - // posted Storno) with only the back-link missing -- and the idempotency - // check above finishes that link on retry instead of posting a second, - // duplicate reversal. - await run("UPDATE journal_entries SET status = 'posted' WHERE id = ?", [stornoId]); - await run("UPDATE journal_entries SET reversed_by_entry_id = ? WHERE id = ?", [stornoId, entry.id]); - - await record(actor, "journal.reverse", "journal_entry", entry.id, entry, { - reversed_by_entry_id: stornoId, - date: stornoDate, - }); - return stornoId; -} - -/** - * Reverse every original posted entry behind an invoice. - * - * Replaces the delete this used to be: a posted entry is never removed, so - * cancelling a document leaves the original and its Storno both on the record. - */ -export async function reverseEntriesForInvoice(invoiceId: number, actor: Actor): Promise { - const entries = await query( - `SELECT * FROM journal_entries - WHERE source_type IN ('invoice','credit_note') - AND source_id = ? - AND status = 'posted' - AND reverses_entry_id IS NULL - AND reversed_by_entry_id IS NULL`, - [invoiceId], - ); - for (const entry of entries) { - await reverseEntry(entry, actor); - } - return entries.length; -} - -interface PendingLine { - account_code: string; - description: string; - debit_cents: number; - credit_cents: number; -} - +/** Publish a complete original once; historical pending rows do not block retry. */ export async function createFromInvoice(invoiceId: number, actor: Actor = SYSTEM_ACTOR): Promise { - const inv = await getInvoice(invoiceId); - if (!inv) return undefined; - if (!inv.number) return undefined; - if (inv.status === "draft" || inv.status === "cancelled") return undefined; - - const lines = await getLines(invoiceId); - if (lines.length === 0) return undefined; - - const isCreditNote = inv.type === "credit_note"; - - // Group revenue by account_code - const revenueByAccount = new Map(); - for (const line of lines) { - const acc = line.account_code || REVENUE_ACCOUNT_FALLBACK; - revenueByAccount.set(acc, (revenueByAccount.get(acc) ?? 0) + line.subtotal_cents); - } - - const totalVat = lines.reduce((s, l) => s + l.vat_cents, 0); - const totalGross = inv.total_cents; - - const pending: PendingLine[] = []; - if (isCreditNote) { - pending.push({ - account_code: RECEIVABLE_ACCOUNT, - description: `Credit ${inv.number}`, - debit_cents: 0, - credit_cents: totalGross, - }); - for (const [acc, amount] of revenueByAccount) { - pending.push({ - account_code: acc, - description: `Revenue reversal ${inv.number}`, - debit_cents: amount, - credit_cents: 0, - }); - } - if (totalVat > 0) { - pending.push({ - account_code: VAT_PAYABLE_ACCOUNT, - description: `VAT reversal ${inv.number}`, - debit_cents: totalVat, - credit_cents: 0, - }); - } - } else { - pending.push({ - account_code: RECEIVABLE_ACCOUNT, - description: `Invoice ${inv.number}`, - debit_cents: totalGross, - credit_cents: 0, - }); - for (const [acc, amount] of revenueByAccount) { - pending.push({ - account_code: acc, - description: `Revenue ${inv.number}`, - debit_cents: 0, - credit_cents: amount, - }); - } - if (totalVat > 0) { - pending.push({ - account_code: VAT_PAYABLE_ACCOUNT, - description: `VAT payable ${inv.number}`, - debit_cents: 0, - credit_cents: totalVat, - }); - } - } - - const totalDebit = pending.reduce((s, l) => s + l.debit_cents, 0); - const totalCredit = pending.reduce((s, l) => s + l.credit_cents, 0); - if (totalDebit !== totalCredit) { - throw new Error(`Journal entry would not balance: debit ${totalDebit} ≠ credit ${totalCredit}`); - } - - const date = inv.issue_date ?? today(); - await assertPeriodOpen(date, `${isCreditNote ? "Credit note" : "Invoice"} ${inv.number}`); - - // Re-posting reverses what was there before rather than deleting it, so the - // superseded entry stays visible next to its replacement. Done last, after - // every check that can still refuse this call -- a refusal here would - // otherwise leave the old entry reversed with nothing posted to replace it. - await reverseEntriesForInvoice(invoiceId, actor); - - const description = isCreditNote - ? `Credit note ${inv.number}` - : `Invoice ${inv.number}`; - - const result = await run( - `INSERT INTO journal_entries (reference, description, date, source_type, source_id, status) - VALUES (?, ?, ?, ?, ?, 'posted')`, - [inv.number, description, date, inv.type, inv.id], - ); - const entryId = result.lastInsertRowid; - - for (let i = 0; i < pending.length; i++) { - const p = pending[i]; - await run( - `INSERT INTO journal_lines (entry_id, position, account_code, description, debit_cents, credit_cents) - VALUES (?, ?, ?, ?, ?, ?)`, - [entryId, i + 1, p.account_code, p.description, p.debit_cents, p.credit_cents], + try { + // RETURNING works with both D1 and Storage bindings; Storage need not + // provide lastInsertRowid metadata. All lines/audit publish in this INSERT. + const inserted = await query<{ id: number }>( + `INSERT INTO journal_entries + (reference, description, date, source_type, source_id, status, mutation_actor, mutation_actor_kind) + SELECT number, type || ' ' || number, COALESCE(issue_date, date('now')), type, id, 'pending', ?, ? + FROM invoices i WHERE id = ? AND type IN ('invoice','credit_note') + AND number IS NOT NULL AND status IN ('issued','sent','paid') + AND NOT EXISTS (SELECT 1 FROM journal_entries j WHERE j.source_id = i.id + AND j.source_type = i.type AND j.status = 'posted' AND j.reverses_entry_id IS NULL) + RETURNING id`, + [actor.actor, actor.actor_kind, invoiceId], ); + if (!inserted.length) return undefined; + return get("SELECT * FROM journal_entries WHERE id = ?", [inserted[0].id]); + } catch (error) { + rethrowLedgerError(error); } - - const entry = await get("SELECT * FROM journal_entries WHERE id = ?", [entryId]); - await record(actor, "journal.post", "journal_entry", entryId, null, entry); - return entry; } export async function backfillFromInvoices(actor: Actor = SYSTEM_ACTOR): Promise<{ created: number }> { const candidates = await query<{ id: number }>( - `SELECT i.id FROM invoices i - LEFT JOIN journal_entries j - ON j.source_id = i.id AND j.source_type IN ('invoice','credit_note') - WHERE i.number IS NOT NULL - AND i.status IN ('issued','sent','paid') - AND j.id IS NULL`, + `SELECT i.id FROM invoices i WHERE i.number IS NOT NULL + AND i.type IN ('invoice','credit_note') AND i.status IN ('issued','sent','paid') + AND NOT EXISTS (SELECT 1 FROM journal_entries j WHERE j.source_id = i.id + AND j.source_type = i.type AND j.status = 'posted' AND j.reverses_entry_id IS NULL)`, ); let created = 0; for (const row of candidates) { - const entry = await createFromInvoice(row.id, actor); - if (entry) created++; + if (await createFromInvoice(row.id, actor)) created++; } return { created }; } diff --git a/src/server/domain/periods.ts b/src/server/domain/periods.ts index 477bbee..10e529b 100644 --- a/src/server/domain/periods.ts +++ b/src/server/domain/periods.ts @@ -1,6 +1,6 @@ import { get, query, run } from "../db"; -import { record, type Actor } from "./audit"; -import { LedgerError } from "./errors"; +import { type Actor } from "./audit"; +import { LedgerError, rethrowLedgerError } from "./errors"; export interface Period { year: number; @@ -61,21 +61,19 @@ export async function lockPeriod(year: number, month: number, actor: Actor): Pro `${year}-${String(month).padStart(2, "0")} has not ended yet. A period can only be locked once its month is over.`, ); } - const existing = await get( - "SELECT * FROM periods WHERE year = ? AND month = ?", - [year, month], - ); - if (existing) return existing; - - await run( - "INSERT INTO periods (year, month, locked_by, locked_by_kind) VALUES (?, ?, ?, ?)", - [year, month, actor.actor, actor.actor_kind], - ); + try { + await run( + `INSERT INTO periods (year, month, locked_by, locked_by_kind) VALUES (?, ?, ?, ?) + ON CONFLICT(year, month) DO NOTHING`, + [year, month, actor.actor, actor.actor_kind], + ); + } catch (error) { + rethrowLedgerError(error); + } const locked = await get( "SELECT * FROM periods WHERE year = ? AND month = ?", [year, month], ); if (!locked) throw new Error("Failed to load locked period"); - await record(actor, "period.lock", "period", null, null, locked); return locked; } diff --git a/src/server/schema.sql b/src/server/schema.sql index 290a824..ed5ee07 100644 --- a/src/server/schema.sql +++ b/src/server/schema.sql @@ -85,6 +85,8 @@ CREATE TABLE IF NOT EXISTS invoices ( vat_cents INTEGER NOT NULL DEFAULT 0, total_cents INTEGER NOT NULL DEFAULT 0, reverse_charge INTEGER NOT NULL DEFAULT 0, + mutation_actor TEXT, + mutation_actor_kind TEXT, reference TEXT, notes TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -149,12 +151,12 @@ CREATE TABLE IF NOT EXISTS journal_entries ( date TEXT NOT NULL, source_type TEXT, source_id INTEGER, - -- 'posted' counts towards the books. 'pending' is the brief window while a - -- Storno is being written; there is no transaction primitive, so the entry - -- is flipped to 'posted' only once its lines are in. + -- Triggers publish pending entries with their lines and audit in one statement. status TEXT NOT NULL DEFAULT 'posted', reverses_entry_id INTEGER, reversed_by_entry_id INTEGER, + mutation_actor TEXT, + mutation_actor_kind TEXT, posted_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (reverses_entry_id) REFERENCES journal_entries(id), @@ -211,3 +213,191 @@ CREATE TABLE IF NOT EXISTS audit_log ( CREATE INDEX IF NOT EXISTS idx_audit_log_at ON audit_log(at); CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity, entity_id); + +-- These constraints deliberately refuse a migration over duplicate historical +-- postings; repairing those books requires an explicit accounting decision. +CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_posted_reversal_v1 + ON journal_entries(reverses_entry_id) + WHERE status = 'posted' AND reverses_entry_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_active_original_v1 + ON journal_entries(source_type, source_id) + WHERE status = 'posted' AND reverses_entry_id IS NULL + AND reversed_by_entry_id IS NULL AND source_type IN ('invoice','credit_note'); + +-- SQLite runs each initiating statement and its trigger effects atomically, +-- including through the platform's single-query Storage binding. Date and lock +-- checks belong here so a concurrent lock cannot pass a stale application check. +CREATE TRIGGER IF NOT EXISTS ledger_entry_insert_guard_v1 +BEFORE INSERT ON journal_entries +BEGIN + SELECT RAISE(ABORT, 'ledger:invalid-date') + WHERE NEW.date IS NULL + OR NEW.date NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + OR NEW.date < '1900-01-01' OR date(NEW.date, '+0 days') IS NOT NEW.date; + SELECT RAISE(ABORT, 'ledger:period-locked') WHERE EXISTS ( + SELECT 1 FROM periods + WHERE year = CAST(substr(NEW.date, 1, 4) AS INTEGER) + AND month = CAST(substr(NEW.date, 6, 2) AS INTEGER) + ); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_entry_publish_guard_v1 +BEFORE UPDATE OF status ON journal_entries +WHEN NEW.status = 'posted' AND OLD.status <> 'posted' +BEGIN + SELECT RAISE(ABORT, 'ledger:invalid-date') + WHERE NEW.date IS NULL + OR NEW.date NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + OR NEW.date < '1900-01-01' OR date(NEW.date, '+0 days') IS NOT NEW.date; + SELECT RAISE(ABORT, 'ledger:period-locked') WHERE EXISTS ( + SELECT 1 FROM periods + WHERE year = CAST(substr(NEW.date, 1, 4) AS INTEGER) + AND month = CAST(substr(NEW.date, 6, 2) AS INTEGER) + ); + SELECT RAISE(ABORT, 'ledger:unbalanced-entry') + WHERE NOT EXISTS (SELECT 1 FROM journal_lines WHERE entry_id = NEW.id) + OR (SELECT SUM(debit_cents - credit_cents) FROM journal_lines WHERE entry_id = NEW.id) <> 0; + SELECT RAISE(ABORT, 'ledger:invalid-source') + WHERE NEW.reverses_entry_id IS NULL AND NEW.source_type IN ('invoice','credit_note') + AND NOT EXISTS (SELECT 1 FROM invoices WHERE id = NEW.source_id AND type = NEW.source_type + AND number = NEW.reference AND COALESCE(issue_date, date('now')) = NEW.date + AND status IN ('issued','sent','paid')); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_reverse_insert_v1 +AFTER INSERT ON journal_entries +WHEN NEW.reverses_entry_id IS NOT NULL AND NEW.status = 'pending' +BEGIN + SELECT RAISE(ABORT, 'ledger:invalid-original') WHERE NOT EXISTS ( + SELECT 1 FROM journal_entries WHERE id = NEW.reverses_entry_id + AND status = 'posted' AND reverses_entry_id IS NULL AND reversed_by_entry_id IS NULL + AND source_type = NEW.source_type AND source_id = NEW.source_id + AND date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + AND date >= '1900-01-01' AND date(date, '+0 days') IS date + ); + INSERT INTO journal_lines(entry_id, position, account_code, description, debit_cents, credit_cents) + SELECT NEW.id, position, account_code, trim('Reversal ' || COALESCE(description, '')), + credit_cents, debit_cents FROM journal_lines WHERE entry_id = NEW.reverses_entry_id; + UPDATE journal_entries SET status = 'posted' WHERE id = NEW.id; + UPDATE journal_entries SET reversed_by_entry_id = NEW.id WHERE id = NEW.reverses_entry_id; + INSERT INTO audit_log(actor, actor_kind, action, entity, entity_id, after_json) + VALUES(NEW.mutation_actor, COALESCE(NEW.mutation_actor_kind, 'system'), + 'journal.reverse', 'journal_entry', NEW.reverses_entry_id, + json_object('reversed_by_entry_id', NEW.id, 'date', NEW.date)); +END; + +-- Ordinary posting is idempotent. Issuing/backfill insert a pending header only +-- when no posted original exists; the trigger derives every line from the same +-- database snapshot and publishes only a complete, balanced entry. +CREATE TRIGGER IF NOT EXISTS ledger_original_insert_v1 +AFTER INSERT ON journal_entries +WHEN NEW.reverses_entry_id IS NULL AND NEW.status = 'pending' + AND NEW.source_type IN ('invoice','credit_note') +BEGIN + SELECT RAISE(ABORT, 'ledger:invalid-source') WHERE NOT EXISTS ( + SELECT 1 FROM invoices WHERE id = NEW.source_id AND type = NEW.source_type + AND number = NEW.reference AND COALESCE(issue_date, date('now')) = NEW.date + AND status IN ('issued','sent','paid') + ); + SELECT RAISE(ABORT, 'ledger:empty-invoice') + WHERE NOT EXISTS (SELECT 1 FROM invoice_lines WHERE invoice_id = NEW.source_id); + INSERT INTO journal_lines(entry_id, position, account_code, description, debit_cents, credit_cents) + SELECT NEW.id, 1, 'BVor', 'Invoice ' || number, total_cents, 0 + FROM invoices WHERE id = NEW.source_id AND type = 'invoice' + UNION ALL SELECT NEW.id, 1, 'BVor', 'Credit ' || number, 0, total_cents + FROM invoices WHERE id = NEW.source_id AND type = 'credit_note'; + INSERT INTO journal_lines(entry_id, position, account_code, description, debit_cents, credit_cents) + SELECT NEW.id, 1 + ROW_NUMBER() OVER (ORDER BY COALESCE(NULLIF(account_code, ''), 'WOmz')), + COALESCE(NULLIF(account_code, ''), 'WOmz'), 'Revenue ' || NEW.reference, 0, SUM(subtotal_cents) + FROM invoice_lines WHERE invoice_id = NEW.source_id AND NEW.source_type = 'invoice' + GROUP BY COALESCE(NULLIF(account_code, ''), 'WOmz') + UNION ALL + SELECT NEW.id, 1 + ROW_NUMBER() OVER (ORDER BY COALESCE(NULLIF(account_code, ''), 'WOmz')), + COALESCE(NULLIF(account_code, ''), 'WOmz'), 'Revenue reversal ' || NEW.reference, SUM(subtotal_cents), 0 + FROM invoice_lines WHERE invoice_id = NEW.source_id AND NEW.source_type = 'credit_note' + GROUP BY COALESCE(NULLIF(account_code, ''), 'WOmz'); + INSERT INTO journal_lines(entry_id, position, account_code, description, debit_cents, credit_cents) + SELECT NEW.id, (SELECT COUNT(*) + 1 FROM journal_lines WHERE entry_id = NEW.id), + 'BKas', 'VAT payable ' || NEW.reference, 0, SUM(vat_cents) + FROM invoice_lines WHERE invoice_id = NEW.source_id AND NEW.source_type = 'invoice' + HAVING SUM(vat_cents) > 0 + UNION ALL + SELECT NEW.id, (SELECT COUNT(*) + 1 FROM journal_lines WHERE entry_id = NEW.id), + 'BKas', 'VAT reversal ' || NEW.reference, SUM(vat_cents), 0 + FROM invoice_lines WHERE invoice_id = NEW.source_id AND NEW.source_type = 'credit_note' + HAVING SUM(vat_cents) > 0; + UPDATE journal_entries SET status = 'posted' WHERE id = NEW.id; + INSERT INTO audit_log(actor, actor_kind, action, entity, entity_id, after_json) + VALUES(NEW.mutation_actor, COALESCE(NEW.mutation_actor_kind, 'system'), + 'journal.post', 'journal_entry', NEW.id, + json_object('reference', NEW.reference, 'date', NEW.date, 'source_type', NEW.source_type, 'source_id', NEW.source_id)); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_invoice_status_guard_v1 +BEFORE UPDATE OF status ON invoices +WHEN OLD.status <> NEW.status +BEGIN + SELECT RAISE(ABORT, 'ledger:invalid-transition') + WHERE OLD.status = 'cancelled' + OR (OLD.status <> 'draft' AND NEW.status NOT IN ('sent','paid','cancelled')) + OR (OLD.status = 'draft' AND NEW.status <> 'issued'); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_invoice_cancel_v1 +AFTER UPDATE OF status ON invoices +WHEN NEW.status = 'cancelled' AND OLD.status <> 'cancelled' +BEGIN + -- Repair the legacy crash window without fabricating a historical actor or + -- reversal timestamp: this event records who repaired the backlink now. + INSERT INTO audit_log(actor, actor_kind, action, entity, entity_id, after_json) + SELECT NEW.mutation_actor, COALESCE(NEW.mutation_actor_kind, 'system'), + 'journal.recovery', 'journal_entry', e.id, json_object('reversed_by_entry_id', r.id) + FROM journal_entries e JOIN journal_entries r ON r.reverses_entry_id = e.id AND r.status = 'posted' + WHERE e.source_id = NEW.id AND e.source_type IN ('invoice','credit_note') + AND e.status = 'posted' AND e.reverses_entry_id IS NULL AND e.reversed_by_entry_id IS NULL; + UPDATE journal_entries SET reversed_by_entry_id = ( + SELECT r.id FROM journal_entries r WHERE r.reverses_entry_id = journal_entries.id AND r.status = 'posted' + ) WHERE source_id = NEW.id AND source_type IN ('invoice','credit_note') + AND status = 'posted' AND reverses_entry_id IS NULL AND reversed_by_entry_id IS NULL + AND EXISTS (SELECT 1 FROM journal_entries r WHERE r.reverses_entry_id = journal_entries.id AND r.status = 'posted'); + INSERT INTO journal_entries(reference, description, date, source_type, source_id, status, + reverses_entry_id, mutation_actor, mutation_actor_kind) + SELECT reference, 'Reversal of ' || COALESCE(description, reference), + COALESCE((SELECT date('now') FROM periods + WHERE year = CAST(substr(e.date, 1, 4) AS INTEGER) + AND month = CAST(substr(e.date, 6, 2) AS INTEGER)), e.date), + source_type, source_id, 'pending', id, NEW.mutation_actor, NEW.mutation_actor_kind + FROM journal_entries e WHERE source_id = NEW.id AND source_type IN ('invoice','credit_note') + AND status = 'posted' AND reverses_entry_id IS NULL AND reversed_by_entry_id IS NULL; + INSERT INTO audit_log(actor, actor_kind, action, entity, entity_id, before_json, after_json) + VALUES(NEW.mutation_actor, COALESCE(NEW.mutation_actor_kind, 'system'), 'invoice.status', 'invoice', NEW.id, + json_object('status', OLD.status), json_object('status', NEW.status)); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_invoice_status_audit_v1 +AFTER UPDATE OF status ON invoices +WHEN NEW.status IN ('sent','paid') AND OLD.status <> NEW.status +BEGIN + INSERT INTO audit_log(actor, actor_kind, action, entity, entity_id, before_json, after_json) + VALUES(NEW.mutation_actor, COALESCE(NEW.mutation_actor_kind, 'system'), 'invoice.status', 'invoice', NEW.id, + json_object('status', OLD.status), json_object('status', NEW.status)); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_period_guard_v1 +BEFORE INSERT ON periods +BEGIN + SELECT RAISE(ABORT, 'ledger:invalid-period') + WHERE NEW.year < 1900 OR NEW.year > 9999 OR NEW.year <> CAST(NEW.year AS INTEGER) + OR NEW.month NOT BETWEEN 1 AND 12 OR NEW.month <> CAST(NEW.month AS INTEGER); + SELECT RAISE(ABORT, 'ledger:period-not-ended') + WHERE printf('%04d-%02d', NEW.year, NEW.month) >= strftime('%Y-%m', 'now'); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_period_audit_v1 +AFTER INSERT ON periods +BEGIN + INSERT INTO audit_log(actor, actor_kind, action, entity, after_json) + VALUES(NEW.locked_by, COALESCE(NEW.locked_by_kind, 'system'), 'period.lock', 'period', + json_object('year', NEW.year, 'month', NEW.month, 'locked_at', NEW.locked_at, + 'locked_by', NEW.locked_by, 'locked_by_kind', NEW.locked_by_kind)); +END; diff --git a/tests/ledger-regressions.test.ts b/tests/ledger-regressions.test.ts index 303a918..f4a3db4 100644 --- a/tests/ledger-regressions.test.ts +++ b/tests/ledger-regressions.test.ts @@ -6,12 +6,14 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { initDB } from "../src/server/db"; import { setStatus } from "../src/server/domain/invoices"; -import { trialBalance } from "../src/server/domain/journals"; +import { backfillFromInvoices, createFromInvoice, trialBalance } from "../src/server/domain/journals"; +import { lockPeriod } from "../src/server/domain/periods"; +import { LedgerError } from "../src/server/domain/errors"; import api from "../src/server/routes"; const actor = { actor: "regression-test", actor_kind: "user" as const }; let db: DatabaseSync; -let beforeQuery: ((sql: string) => void) | undefined; +let beforeQuery: ((sql: string) => void | Promise) | undefined; beforeEach(() => { db = new DatabaseSync(":memory:"); @@ -20,7 +22,7 @@ beforeEach(() => { initDB({ STORAGE: { async query(sql: string, params: (string | number | null)[] = []) { - beforeQuery?.(sql); + await beforeQuery?.(sql); const statement = db.prepare(sql); if (statement.columns().length) return { rows: statement.all(...params), meta: {} }; const result = statement.run(...params); @@ -60,12 +62,11 @@ test("repeated cancellation keeps the original and a single reversal netted to z }); test("retry after a posted reversal repairs the backlink without reversing the reversal", async () => { - beforeQuery = (sql) => { - if (sql.includes("UPDATE journal_entries SET reversed_by_entry_id")) { - throw new Error("crash before backlink"); - } - }; - await assert.rejects(setStatus(1, "cancelled", actor), /crash before backlink/); + // Legacy data from before reversal publication and its backlink were atomic. + db.exec(`INSERT INTO journal_entries(id, reference, date, source_type, source_id, reverses_entry_id) + VALUES(2, 'INV-TEST', '2025-01-10', 'invoice', 1, 1); + INSERT INTO journal_lines(entry_id, position, account_code, debit_cents, credit_cents) + VALUES(2, 1, 'BVor', 0, 10000), (2, 2, 'WOmz', 10000, 0);`); assert.equal(db.prepare("SELECT status FROM journal_entries WHERE id = 2").get()?.status, "posted"); assert.equal(db.prepare("SELECT reversed_by_entry_id FROM journal_entries WHERE id = 1").get()?.reversed_by_entry_id, null); beforeQuery = undefined; @@ -75,6 +76,134 @@ test("retry after a posted reversal repairs the backlink without reversing the r await assertCancelledBooks(); }); +test("20 concurrent cancellations produce one reversal and retain the winning actor", async () => { + await Promise.all(Array.from({ length: 20 }, (_, i) => setStatus(1, "cancelled", { + actor: `actor-${i}`, actor_kind: i % 2 ? "agent" : "user", + }))); + await assertCancelledBooks(); + const audit = db.prepare("SELECT actor, actor_kind, action FROM audit_log ORDER BY id").all(); + assert.equal(audit.length, 2); + assert.deepEqual(audit.map((row) => row.action), ["journal.reverse", "invoice.status"]); + assert.equal(audit[0].actor, audit[1].actor); + assert.equal(audit[0].actor_kind, audit[1].actor_kind); +}); + +for (const fault of [ + "BEFORE INSERT ON journal_entries WHEN NEW.reverses_entry_id IS NOT NULL", + "BEFORE INSERT ON journal_lines WHEN NEW.entry_id <> 1", + "BEFORE UPDATE OF status ON journal_entries WHEN NEW.status = 'posted'", + "BEFORE UPDATE OF reversed_by_entry_id ON journal_entries", + "BEFORE INSERT ON audit_log WHEN NEW.action = 'journal.reverse'", + "BEFORE INSERT ON audit_log WHEN NEW.action = 'invoice.status'", +]) { + test(`cancellation rolls back all effects on ${fault}`, async () => { + db.exec(`CREATE TRIGGER inject_fault ${fault} BEGIN SELECT RAISE(ABORT, 'injected failure'); END;`); + await assert.rejects(setStatus(1, "cancelled", actor), /injected failure/); + assert.equal(db.prepare("SELECT status FROM invoices WHERE id = 1").get()?.status, "issued"); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_lines").get()?.n, 2); + assert.equal(db.prepare("SELECT reversed_by_entry_id FROM journal_entries WHERE id = 1").get()?.reversed_by_entry_id, null); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM audit_log").get()?.n, 0); + db.exec("DROP TRIGGER inject_fault"); + await setStatus(1, "cancelled", actor); + await assertCancelledBooks(); + }); +} + +test("a lock committed while cancellation waits keeps the closed month's balance", async () => { + let entered!: () => void; + let release!: () => void; + const ready = new Promise((resolve) => { entered = resolve; }); + const resume = new Promise((resolve) => { release = resolve; }); + beforeQuery = async (sql) => { + if (sql.includes("UPDATE invoices SET status")) { + entered(); + await resume; + } + }; + const cancelling = setStatus(1, "cancelled", actor); + await ready; + await lockPeriod(2025, 1, actor); + const locked = await trialBalance({ from: "2025-01-01", to: "2025-01-31" }); + release(); + await cancelling; + assert.deepEqual(await trialBalance({ from: "2025-01-01", to: "2025-01-31" }), locked); + assert.equal(db.prepare("SELECT date FROM journal_entries WHERE id = 2").get()?.date, new Date().toISOString().slice(0, 10)); + await assertCancelledBooks(); +}); + +test("lock audit failure rolls back the lock, and concurrent retries audit once", async () => { + db.exec("CREATE TRIGGER inject_fault BEFORE INSERT ON audit_log BEGIN SELECT RAISE(ABORT, 'injected failure'); END;"); + await assert.rejects(lockPeriod(2025, 1, actor), /injected failure/); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM periods").get()?.n, 0); + db.exec("DROP TRIGGER inject_fault"); + await Promise.all(Array.from({ length: 20 }, () => lockPeriod(2025, 1, actor))); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM periods").get()?.n, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM audit_log").get()?.n, 1); +}); + +test("cancelled invoices cannot become sent or paid, including a stale concurrent request", async () => { + await Promise.all([setStatus(1, "cancelled", actor), assert.rejects(setStatus(1, "paid", actor), LedgerError)]); + await assert.rejects(setStatus(1, "sent", actor), LedgerError); + await assert.rejects(setStatus(1, "paid", actor), LedgerError); + await assertCancelledBooks(); +}); + +function unpostedInvoice() { + db.exec(`DELETE FROM journal_lines; DELETE FROM journal_entries; + INSERT INTO invoice_lines(invoice_id, position, description, subtotal_cents, total_cents, vat_cents) + VALUES(1, 1, 'Work', 10000, 10000, 0);`); +} + +test("concurrent backfill publishes one complete original and one audit", async () => { + unpostedInvoice(); + await Promise.all(Array.from({ length: 20 }, () => backfillFromInvoices(actor))); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 1); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_lines").get()?.n, 2); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM audit_log").get()?.n, 1); + assert.equal((await trialBalance()).find((row) => row.account_code === "BVor")?.balance_cents, 10000); + assert.equal(await createFromInvoice(1, actor), undefined); +}); + +for (const action of ["BEFORE INSERT ON journal_lines", "BEFORE INSERT ON audit_log"]) { + test(`original publication rolls back on ${action}`, async () => { + unpostedInvoice(); + db.exec(`CREATE TRIGGER inject_fault ${action} BEGIN SELECT RAISE(ABORT, 'injected failure'); END;`); + await assert.rejects(createFromInvoice(1, actor), /injected failure/); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_lines").get()?.n, 0); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM audit_log").get()?.n, 0); + db.exec("DROP TRIGGER inject_fault"); + assert.equal((await createFromInvoice(1, actor))?.status, "posted"); + }); +} + +for (const date of ["2025-02-29", "2024-02-30", "2025-13-01", "2025-1-01", "2025-01-01T00:00:00Z", "1899-12-31"]) { + test(`publication refuses invalid accounting date ${date}`, async () => { + unpostedInvoice(); + db.prepare("UPDATE invoices SET issue_date = ? WHERE id = 1").run(date); + await assert.rejects(createFromInvoice(1, actor), LedgerError); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 0); + }); +} + +test("a lock committed while posting waits prevents any publication", async () => { + unpostedInvoice(); + let entered!: () => void; + let release!: () => void; + const ready = new Promise((resolve) => { entered = resolve; }); + const resume = new Promise((resolve) => { release = resolve; }); + beforeQuery = async (sql) => { + if (sql.includes("INSERT INTO journal_entries")) { entered(); await resume; } + }; + const posting = createFromInvoice(1, actor); + await ready; + await lockPeriod(2025, 1, actor); + release(); + await assert.rejects(posting, LedgerError); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 0); +}); + for (const mounted of [false, true]) { test(`DELETE issued invoice returns 409 JSON (${mounted ? "mounted" : "direct"} API)`, async () => { const app = mounted ? new Hono().route("/", api) : api; From b8883896c0e583b348c3abd388793f00f0b798cc Mon Sep 17 00:00:00 2001 From: pallaoro Date: Sun, 13 Sep 2026 22:20:23 +0200 Subject: [PATCH 5/5] Make invoice issue and deletion atomic and freeze issued documents --- src/server/domain/audit.ts | 34 +- src/server/domain/errors.ts | 11 +- src/server/domain/invoices.ts | 67 ++-- src/server/domain/numbering.ts | 34 -- src/server/domain/periods.ts | 31 +- src/server/routes.ts | 5 +- src/server/schema.sql | 93 ++++- tests/ledger-http-adversarial.test.ts | 494 ++++++++++++++++++++++++++ tests/ledger-regressions.test.ts | 54 ++- 9 files changed, 676 insertions(+), 147 deletions(-) delete mode 100644 src/server/domain/numbering.ts create mode 100644 tests/ledger-http-adversarial.test.ts diff --git a/src/server/domain/audit.ts b/src/server/domain/audit.ts index 789a2fe..0077485 100644 --- a/src/server/domain/audit.ts +++ b/src/server/domain/audit.ts @@ -1,5 +1,5 @@ import { caller, user, type Caller, type RequestLike } from "@clawnify/app"; -import { query, run } from "../db"; +import { query } from "../db"; /** * Who is making a change, as recorded in the books. @@ -38,36 +38,6 @@ export interface AuditRow { after_json: string | null; } -/** - * Append one line to the audit log. - * - * Deliberately allowed to throw. If the books can be changed without the change - * being recorded, the log is worth nothing -- a loud failure is the honest - * outcome, not a silent unrecorded write. - */ -export async function record( - actor: Actor, - action: string, - entity: string, - entityId: number | null, - before?: unknown, - after?: unknown, -): Promise { - await run( - `INSERT INTO audit_log (actor, actor_kind, action, entity, entity_id, before_json, after_json) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - actor.actor, - actor.actor_kind, - action, - entity, - entityId, - before === undefined ? null : JSON.stringify(before), - after === undefined ? null : JSON.stringify(after), - ], - ); -} - export async function listAudit(filters: { entity?: string; entity_id?: number; limit?: number } = {}): Promise { const where: string[] = []; const params: unknown[] = []; @@ -76,7 +46,7 @@ export async function listAudit(filters: { entity?: string; entity_id?: number; // ?? only falls back on null/undefined, so an unparsable `limit` (Number("abc") // is NaN, not undefined) would otherwise reach the query below as `LIMIT NaN`. const requested = Number.isFinite(filters.limit) ? (filters.limit as number) : 200; - const limit = Math.min(Math.max(requested, 1), 1000); + const limit = Math.min(Math.max(Math.trunc(requested), 1), 1000); return query( `SELECT * FROM audit_log ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY id DESC LIMIT ${limit}`, params, diff --git a/src/server/domain/errors.ts b/src/server/domain/errors.ts index 721645e..3d65fdd 100644 --- a/src/server/domain/errors.ts +++ b/src/server/domain/errors.ts @@ -23,11 +23,16 @@ const SQL_REFUSALS: Record = { "empty-invoice": "Add at least one line before posting an invoice.", "unbalanced-entry": "The journal entry must have lines with equal total debits and credits.", "invalid-transition": "That invoice status transition is not allowed. A cancelled invoice stays cancelled.", + "issued-delete": "An issued invoice cannot be deleted. Cancel it instead to reverse its journal entry.", + "frozen-invoice": "An issued invoice and its lines can no longer be edited. Cancel it or raise a credit note instead.", }; /** Only our explicit SQLite refusals are conflicts; storage faults remain 500s. */ -export function rethrowLedgerError(error: unknown): never { +export function ledgerErrorFromSQL(error: unknown): LedgerError | undefined { const marker = error instanceof Error ? /\bledger:([a-z-]+)\b/.exec(error.message)?.[1] : undefined; - if (marker && SQL_REFUSALS[marker]) throw new LedgerError(SQL_REFUSALS[marker]); - throw error; + return marker && SQL_REFUSALS[marker] ? new LedgerError(SQL_REFUSALS[marker]) : undefined; +} + +export function rethrowLedgerError(error: unknown): never { + throw ledgerErrorFromSQL(error) ?? error; } diff --git a/src/server/domain/invoices.ts b/src/server/domain/invoices.ts index 74d4701..dc40245 100644 --- a/src/server/domain/invoices.ts +++ b/src/server/domain/invoices.ts @@ -1,11 +1,8 @@ import { get, query, run } from "../db"; -import { record, type Actor } from "./audit"; +import type { Actor } from "./audit"; import { getCompany } from "./company"; import { LedgerError, rethrowLedgerError } from "./errors"; -import { assertPeriodOpen } from "./periods"; -import { createFromInvoice as createJournalFromInvoice } from "./journals"; import { getParty } from "./parties"; -import { nextNumber, type NumberingScope } from "./numbering"; import { computeVat } from "./vat"; export type InvoiceType = "invoice" | "credit_note" | "quote"; @@ -131,41 +128,39 @@ export async function deleteInvoice(id: number, actor: Actor): Promise { `Cancel it instead -- that reverses its journal entry and leaves both on the record.`, ); } - // A draft was never posted, so there is nothing in the ledger to reverse. - await run("DELETE FROM invoice_lines WHERE invoice_id = ?", [id]); - await run("DELETE FROM invoices WHERE id = ?", [id]); - await record(actor, "invoice.delete", "invoice", id, inv, null); + try { + // The audit INSERT captures the current row and deletes it in one trigger + // transaction. A concurrent issue is refused before either effect commits. + await run( + `INSERT INTO audit_log(actor, actor_kind, action, entity, entity_id, before_json) + SELECT ?, ?, 'invoice.delete', 'invoice', id, + json_object('id', id, 'number', number, 'type', type, 'status', status, + 'party_id', party_id, 'issue_date', issue_date, 'due_date', due_date, + 'currency', currency, 'fx_rate', fx_rate, 'subtotal_cents', subtotal_cents, + 'vat_cents', vat_cents, 'total_cents', total_cents, 'reverse_charge', reverse_charge, + 'reference', reference, 'notes', notes, 'created_at', created_at, 'updated_at', updated_at) + FROM invoices WHERE id = ?`, + [actor.actor, actor.actor_kind, id], + ); + } catch (error) { + rethrowLedgerError(error); + } } -const SCOPE_FOR_TYPE: Record = { - invoice: "invoice", - credit_note: "credit_note", - quote: "quote", -}; - export async function issueInvoice(id: number, actor: Actor): Promise { - const inv = await getInvoice(id); - if (!inv) return undefined; - if (inv.status !== "draft") return inv; - const today = new Date().toISOString().slice(0, 10); - // Check the period before anything is written. Issuing assigns a number from - // a gap-free sequence and posts to the ledger; if the posting were refused - // afterwards the invoice would be left issued, numbered and unposted. - await assertPeriodOpen(inv.issue_date ?? today, `Invoice ${id}`); - const number = await nextNumber(SCOPE_FOR_TYPE[inv.type]); - await run( - `UPDATE invoices - SET number = ?, - status = 'issued', - issue_date = COALESCE(issue_date, ?), - updated_at = datetime('now') - WHERE id = ?`, - [number, today, id], - ); - await createJournalFromInvoice(id, actor); - const issued = await getInvoice(id); - await record(actor, "invoice.issue", "invoice", id, { status: inv.status }, issued); - return issued; + try { + // Number assignment, journal publication and both audits belong to this + // conditional statement. Retry after a lost response consumes no number. + await run( + `UPDATE invoices SET status = 'issued', mutation_actor = ?, mutation_actor_kind = ?, + issue_date = COALESCE(issue_date, date('now')), + updated_at = datetime('now') WHERE id = ? AND status = 'draft'`, + [actor.actor, actor.actor_kind, id], + ); + } catch (error) { + rethrowLedgerError(error); + } + return getInvoice(id); } const SETTABLE_STATUSES = new Set(["sent", "paid", "cancelled"]); diff --git a/src/server/domain/numbering.ts b/src/server/domain/numbering.ts deleted file mode 100644 index cc4e208..0000000 --- a/src/server/domain/numbering.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { query, run } from "../db"; - -export type NumberingScope = "invoice" | "credit_note" | "quote"; - -const DEFAULT_PREFIX: Record = { - invoice: "INV", - credit_note: "CN", - quote: "Q", -}; - -export async function nextNumber(scope: NumberingScope, year = new Date().getFullYear()): Promise { - await run( - `INSERT OR IGNORE INTO numbering_sequences (scope, year, next_number, prefix) VALUES (?, ?, 1, ?)`, - [scope, year, DEFAULT_PREFIX[scope]], - ); - const rows = await query<{ assigned: number; prefix: string }>( - `UPDATE numbering_sequences - SET next_number = next_number + 1 - WHERE scope = ? AND year = ? - RETURNING (next_number - 1) AS assigned, prefix`, - [scope, year], - ); - if (rows.length === 0) throw new Error(`Failed to assign ${scope} number for ${year}`); - const { assigned, prefix } = rows[0]; - return `${prefix}-${year}-${String(assigned).padStart(4, "0")}`; -} - -export async function peekSequence(scope: NumberingScope, year = new Date().getFullYear()): Promise<{ next_number: number; prefix: string } | undefined> { - const rows = await query<{ next_number: number; prefix: string }>( - `SELECT next_number, prefix FROM numbering_sequences WHERE scope = ? AND year = ?`, - [scope, year], - ); - return rows[0]; -} diff --git a/src/server/domain/periods.ts b/src/server/domain/periods.ts index 10e529b..e8e2737 100644 --- a/src/server/domain/periods.ts +++ b/src/server/domain/periods.ts @@ -1,5 +1,5 @@ import { get, query, run } from "../db"; -import { type Actor } from "./audit"; +import type { Actor } from "./audit"; import { LedgerError, rethrowLedgerError } from "./errors"; export interface Period { @@ -10,35 +10,6 @@ export interface Period { locked_by_kind: string | null; } -/** Split a 'YYYY-MM-DD' date into the period it belongs to. */ -export function periodOf(date: string): { year: number; month: number } { - return { year: Number(date.slice(0, 4)), month: Number(date.slice(5, 7)) }; -} - -export async function isPeriodLocked(date: string): Promise { - const { year, month } = periodOf(date); - const row = await get<{ year: number }>( - "SELECT year FROM periods WHERE year = ? AND month = ?", - [year, month], - ); - return !!row; -} - -/** - * Refuse a write whose entry date falls in a closed period. Called on every - * path that puts something into the ledger, so a back-dated document cannot - * slip into a month that has already been reported. - */ -export async function assertPeriodOpen(date: string, what: string): Promise { - if (await isPeriodLocked(date)) { - const { year, month } = periodOf(date); - throw new LedgerError( - `${what} is dated ${date}, but ${year}-${String(month).padStart(2, "0")} is locked. ` + - `Post it into an open period instead -- a locked period cannot be reopened.`, - ); - } -} - export async function listPeriods(): Promise { return query("SELECT * FROM periods ORDER BY year DESC, month DESC"); } diff --git a/src/server/routes.ts b/src/server/routes.ts index 915fa91..39f5cee 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -20,7 +20,7 @@ import { } from "./domain/invoices"; import { getCompany, updateCompany } from "./domain/company"; import { actorOf, listAudit } from "./domain/audit"; -import { LedgerError } from "./domain/errors"; +import { LedgerError, ledgerErrorFromSQL } from "./domain/errors"; import { listPeriods, lockPeriod } from "./domain/periods"; import { backfillFromInvoices, getEntry, listEntries, trialBalance } from "./domain/journals"; import { balanceSheet, profitLoss } from "./domain/reports"; @@ -35,7 +35,8 @@ const api = new Hono(); // into a 409 here so every route reports it the same way, with the message the // domain wrote (agents call these routes directly and act on that text). api.onError((err, c) => { - if (err instanceof LedgerError) return c.json({ error: err.message }, 409); + const refusal = err instanceof LedgerError ? err : ledgerErrorFromSQL(err); + if (refusal) return c.json({ error: refusal.message }, 409); if (err instanceof HTTPException) { const response = err.getResponse(); return c.newResponse(response.body, response); diff --git a/src/server/schema.sql b/src/server/schema.sql index ed5ee07..28c64b7 100644 --- a/src/server/schema.sql +++ b/src/server/schema.sql @@ -153,14 +153,12 @@ CREATE TABLE IF NOT EXISTS journal_entries ( source_id INTEGER, -- Triggers publish pending entries with their lines and audit in one statement. status TEXT NOT NULL DEFAULT 'posted', - reverses_entry_id INTEGER, - reversed_by_entry_id INTEGER, + reverses_entry_id INTEGER REFERENCES journal_entries(id), + reversed_by_entry_id INTEGER REFERENCES journal_entries(id), mutation_actor TEXT, mutation_actor_kind TEXT, posted_at TEXT NOT NULL DEFAULT (datetime('now')), - created_at TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY (reverses_entry_id) REFERENCES journal_entries(id), - FOREIGN KEY (reversed_by_entry_id) REFERENCES journal_entries(id) + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS journal_lines ( @@ -401,3 +399,88 @@ BEGIN json_object('year', NEW.year, 'month', NEW.month, 'locked_at', NEW.locked_at, 'locked_by', NEW.locked_by, 'locked_by_kind', NEW.locked_by_kind)); END; + +CREATE TRIGGER IF NOT EXISTS ledger_invoice_issue_v1 +AFTER UPDATE OF status ON invoices +WHEN OLD.status = 'draft' AND NEW.status = 'issued' +BEGIN + SELECT RAISE(ABORT, 'ledger:invalid-date') + WHERE COALESCE(NEW.issue_date, date('now')) NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + OR COALESCE(NEW.issue_date, date('now')) < '1900-01-01' + OR date(COALESCE(NEW.issue_date, date('now')), '+0 days') IS NOT COALESCE(NEW.issue_date, date('now')); + -- Numbering uses the current UTC year, including for a backdated document. + INSERT INTO numbering_sequences(scope, year, next_number, prefix) + SELECT NEW.type, CAST(strftime('%Y', 'now') AS INTEGER), 1, prefix FROM ( + SELECT 'invoice' AS scope, 'INV' AS prefix + UNION ALL SELECT 'credit_note', 'CN' + UNION ALL SELECT 'quote', 'Q' + ) WHERE scope = NEW.type + ON CONFLICT(scope, year) DO NOTHING; + UPDATE numbering_sequences SET next_number = next_number + 1 + WHERE scope = NEW.type AND year = CAST(strftime('%Y', 'now') AS INTEGER); + UPDATE invoices SET number = (SELECT prefix || '-' || year || '-' || printf('%04d', next_number - 1) + FROM numbering_sequences WHERE scope = NEW.type AND year = CAST(strftime('%Y', 'now') AS INTEGER)) + WHERE id = NEW.id; + INSERT INTO journal_entries(reference, description, date, source_type, source_id, status, + mutation_actor, mutation_actor_kind) + SELECT number, type || ' ' || number, issue_date, type, id, 'pending', + NEW.mutation_actor, NEW.mutation_actor_kind + FROM invoices WHERE id = NEW.id AND type IN ('invoice','credit_note'); + INSERT INTO audit_log(actor, actor_kind, action, entity, entity_id, before_json, after_json) + SELECT NEW.mutation_actor, COALESCE(NEW.mutation_actor_kind, 'system'), + 'invoice.issue', 'invoice', id, json_object('status', OLD.status), + json_object('id', id, 'number', number, 'type', type, 'status', status, + 'party_id', party_id, 'issue_date', issue_date, 'due_date', due_date, + 'currency', currency, 'fx_rate', fx_rate, 'subtotal_cents', subtotal_cents, + 'vat_cents', vat_cents, 'total_cents', total_cents, 'reverse_charge', reverse_charge, + 'reference', reference, 'notes', notes, 'created_at', created_at, 'updated_at', updated_at) + FROM invoices WHERE id = NEW.id; +END; + +-- An invoice.delete audit INSERT is the deletion command: its actor and +-- before-snapshot cannot be separated from the DELETE by another request. +CREATE TRIGGER IF NOT EXISTS ledger_invoice_delete_v1 +AFTER INSERT ON audit_log +WHEN NEW.action = 'invoice.delete' AND NEW.entity = 'invoice' +BEGIN + SELECT RAISE(ABORT, 'ledger:issued-delete') + WHERE NOT EXISTS (SELECT 1 FROM invoices WHERE id = NEW.entity_id AND status = 'draft'); + DELETE FROM invoices WHERE id = NEW.entity_id; +END; + +-- A draft edit that passed application validation may race an issue request. +-- Refuse its eventual write against the current status, not the old snapshot. +CREATE TRIGGER IF NOT EXISTS ledger_invoice_freeze_v1 +BEFORE UPDATE ON invoices +WHEN OLD.status <> 'draft' AND ( + OLD.type IS NOT NEW.type OR OLD.party_id IS NOT NEW.party_id + OR OLD.issue_date IS NOT NEW.issue_date OR OLD.due_date IS NOT NEW.due_date + OR OLD.currency IS NOT NEW.currency OR OLD.fx_rate IS NOT NEW.fx_rate + OR OLD.subtotal_cents IS NOT NEW.subtotal_cents OR OLD.vat_cents IS NOT NEW.vat_cents + OR OLD.total_cents IS NOT NEW.total_cents OR OLD.reverse_charge IS NOT NEW.reverse_charge + OR OLD.reference IS NOT NEW.reference OR OLD.notes IS NOT NEW.notes +) +BEGIN + SELECT RAISE(ABORT, 'ledger:frozen-invoice'); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_invoice_line_insert_guard_v1 +BEFORE INSERT ON invoice_lines +WHEN EXISTS (SELECT 1 FROM invoices WHERE id = NEW.invoice_id AND status <> 'draft') +BEGIN + SELECT RAISE(ABORT, 'ledger:frozen-invoice'); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_invoice_line_update_guard_v1 +BEFORE UPDATE ON invoice_lines +WHEN EXISTS (SELECT 1 FROM invoices WHERE id IN (OLD.invoice_id, NEW.invoice_id) AND status <> 'draft') +BEGIN + SELECT RAISE(ABORT, 'ledger:frozen-invoice'); +END; + +CREATE TRIGGER IF NOT EXISTS ledger_invoice_line_delete_guard_v1 +BEFORE DELETE ON invoice_lines +WHEN EXISTS (SELECT 1 FROM invoices WHERE id = OLD.invoice_id AND status <> 'draft') +BEGIN + SELECT RAISE(ABORT, 'ledger:frozen-invoice'); +END; diff --git a/tests/ledger-http-adversarial.test.ts b/tests/ledger-http-adversarial.test.ts new file mode 100644 index 0000000..e5bef4d --- /dev/null +++ b/tests/ledger-http-adversarial.test.ts @@ -0,0 +1,494 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "node:fs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import app from "../src/server/index"; + +const schema = readFileSync( + new URL("../src/server/schema.sql", import.meta.url), + "utf8", +); + +// Exercise actual HTTP handlers against the metadata-less STORAGE contract. +// Production Facet query() returns rows but not lastInsertRowid metadata. +function setup() { + const db = new DatabaseSync(":memory:"); + db.exec(schema); + const q = async (sql: string, params: any[] = []) => { + const statement = db.prepare(sql); + if (statement.columns().length) return statement.all(...params) as any[]; + statement.run(...params); + return []; + }; + const env = { + STORAGE: { + query: async (sql: string, params: any[] = []) => ({ + rows: await q(sql, params), + }), + }, + }; + db.exec( + "INSERT INTO accounts(rgs_code,nivo,omskort,bw) VALUES('BVor',1,'AR','B'),('WOmz',1,'Sales','W'),('BKas',1,'VAT','B'); INSERT INTO parties(id,kind,name) VALUES(1,'customer','Fixture'); INSERT INTO company(id,name,country) VALUES(1,'Fixture','NL');", + ); + const req = async ( + path, + method = "POST", + data: any = undefined, + actor = "user", + ) => { + const r = await app.request( + path, + { + method, + headers: { + "content-type": "application/json", + "X-Clawnify-Caller": actor, + "X-Clawnify-User-Id": actor === "agent" ? "agent-id" : "user-id", + "X-Clawnify-User-Email": actor + "@test.invalid", + }, + ...(data !== undefined ? { body: JSON.stringify(data) } : {}), + }, + env, + ); + const text = await r.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: r.status, body }; + }; + const draft = async ({ + id = 1, + type = "invoice", + date = "2025-01-10", + amount = 10000, + vat = 0, + } = {}) => { + await q( + "INSERT INTO invoices(id,type,status,party_id,issue_date,subtotal_cents,vat_cents,total_cents) VALUES(?,?,'draft',1,?,?,?,?)", + [id, type, date, amount, vat, amount + vat], + ); + await q( + "INSERT INTO invoice_lines(invoice_id,position,description,quantity,unit_price_cents,vat_rate,account_code,subtotal_cents,vat_cents,total_cents) VALUES(?,1,'Work',1,?,0,'WOmz',?,?,?)", + [id, amount, amount, vat, amount + vat], + ); + }; + const issue = (id = 1, actor = "user") => + req(`/api/invoices/${id}/issue`, "POST", undefined, actor); + const cancel = (id = 1, actor = "user") => + req(`/api/invoices/${id}/status`, "POST", { status: "cancelled" }, actor); + const ar = async (from = "", to = "9999-12-31") => + ( + await q( + "SELECT COALESCE(SUM(l.debit_cents-l.credit_cents),0) n FROM journal_lines l JOIN journal_entries e ON e.id=l.entry_id WHERE e.status='posted' AND l.account_code='BVor' AND e.date>=? AND e.date<=?", + [from, to], + ) + )[0].n; + const snapshot = async () => { + const out = {}; + for (const t of [ + "invoices", + "invoice_lines", + "journal_entries", + "journal_lines", + "periods", + "audit_log", + "numbering_sequences", + ]) + out[t] = await q("SELECT * FROM " + t); + return out; + }; + const fault = async (condition = "1") => + q( + `CREATE TRIGGER review_fault BEFORE INSERT ON audit_log WHEN ${condition} BEGIN SELECT RAISE(ABORT,'review:fault'); END`, + ); + return { + q, + req, + draft, + issue, + cancel, + ar, + snapshot, + fault, + env, + close: () => db.close(), + }; +} +function check( + name: string, + run: (fixture: ReturnType) => Promise, +) { + test(name, async () => { + const fixture = setup(); + try { + await run(fixture); + } finally { + fixture.close(); + } + }); +} +check("invoice issue has balanced complete ledger and audits", async (t) => { + await t.draft(); + const r = await t.issue(); + assert.equal(r.status, 200, JSON.stringify(r)); + assert.equal(await t.ar(), 10000); + assert.equal( + (await t.q("SELECT SUM(debit_cents-credit_cents) n FROM journal_lines"))[0] + .n, + 0, + ); + assert.equal( + ( + await t.q("SELECT COUNT(*) n FROM journal_entries WHERE status='posted'") + )[0].n, + 1, + ); + for (const action of ["invoice.issue", "journal.post"]) + assert.equal( + ( + await t.q("SELECT COUNT(*) n FROM audit_log WHERE action=?", [action]) + )[0].n, + 1, + ); +}); +check("credit note and quote have correct financial scope", async (t) => { + await t.draft({ type: "credit_note" }); + assert.equal((await t.issue()).status, 200); + assert.equal(await t.ar(), -10000); + await t.draft({ id: 2, type: "quote" }); + assert.equal((await t.issue(2)).status, 200); + assert.equal( + (await t.q("SELECT COUNT(*) n FROM journal_entries WHERE source_id=2"))[0] + .n, + 0, + ); +}); +check("sequential cancellation retry is a true no-op", async (t) => { + await t.draft(); + assert.equal((await t.issue()).status, 200); + assert.equal((await t.cancel()).status, 200); + assert.equal(await t.ar(), 0); + const s = await t.snapshot(); + assert.equal((await t.cancel()).status, 200); + assert.deepEqual(await t.snapshot(), s); +}); +check( + "20 parallel cancellations have one winner and consistent audit actor", + async (t) => { + await t.draft(); + await t.issue(); + const rs = await Promise.all( + Array.from({ length: 20 }, (_, i) => + t.cancel(1, i % 2 ? "agent" : "user"), + ), + ); + assert.ok( + rs.every((r) => r.status === 200), + JSON.stringify(rs), + ); + assert.equal(await t.ar(), 0); + assert.equal( + ( + await t.q( + "SELECT COUNT(*) n FROM journal_entries WHERE reverses_entry_id IS NOT NULL", + ) + )[0].n, + 1, + ); + const a = await t.q( + "SELECT actor,actor_kind FROM audit_log WHERE action IN ('journal.reverse','invoice.status')", + ); + assert.equal(a.length, 2); + assert.equal(a[0].actor, a[1].actor); + assert.equal(a[0].actor_kind, a[1].actor_kind); + }, +); +check( + "journal reversal audit failure rolls back entire cancellation", + async (t) => { + await t.draft(); + await t.issue(); + await t.fault("NEW.action='journal.reverse'"); + const s = await t.snapshot(); + assert.equal((await t.cancel()).status, 500); + assert.deepEqual(await t.snapshot(), s); + }, +); +check( + "final invoice audit failure rolls back earlier journal effects/audits", + async (t) => { + await t.draft(); + await t.issue(); + await t.fault("NEW.action='invoice.status'"); + const s = await t.snapshot(); + assert.equal((await t.cancel()).status, 500); + assert.deepEqual(await t.snapshot(), s); + }, +); +check("mirror-line fault rolls back cancellation", async (t) => { + await t.draft(); + await t.issue(); + await t.q( + "CREATE TRIGGER review_line_fault BEFORE INSERT ON journal_lines WHEN NEW.entry_id<>(SELECT MIN(id) FROM journal_entries) BEGIN SELECT RAISE(ABORT,'review:line'); END", + ); + const s = await t.snapshot(); + assert.equal((await t.cancel()).status, 500); + assert.deepEqual(await t.snapshot(), s); +}); +check("cancelled document cannot return to paid/sent", async (t) => { + await t.draft(); + await t.issue(); + await t.cancel(); + const s = await t.snapshot(); + for (const status of ["sent", "paid", "issued", "draft"]) + assert.equal( + (await t.req("/api/invoices/1/status", "POST", { status })).status, + 409, + ); + assert.deepEqual(await t.snapshot(), s); +}); +check( + "locking old month moves reversal to today without changing old balances", + async (t) => { + await t.draft(); + await t.issue(); + assert.equal((await t.req("/api/periods/2025/1/lock")).status, 200); + const old = await t.ar("2025-01-01", "2025-01-31"); + assert.equal((await t.cancel()).status, 200); + assert.equal(await t.ar("2025-01-01", "2025-01-31"), old); + assert.equal(await t.ar(), 0); + }, +); +check( + "parallel lock/cancel leaves closed period stable after both complete", + async (t) => { + await t.draft(); + await t.issue(); + const rs = await Promise.all([ + t.req("/api/periods/2025/1/lock"), + t.cancel(), + ]); + assert.ok( + rs.every((r) => r.status === 200), + JSON.stringify(rs), + ); + const old = await t.ar("2025-01-01", "2025-01-31"); + assert.ok([0, 10000].includes(old)); + await t.cancel(); + assert.equal(await t.ar("2025-01-01", "2025-01-31"), old); + assert.equal(await t.ar(), 0); + }, +); +check("lock audit failure does not leave lock behind", async (t) => { + await t.fault("NEW.action='period.lock'"); + const s = await t.snapshot(); + assert.equal((await t.req("/api/periods/2025/1/lock")).status, 500); + assert.deepEqual(await t.snapshot(), s); +}); +check("20 concurrent locks are idempotent and have one audit", async (t) => { + const rs = await Promise.all( + Array.from({ length: 20 }, () => t.req("/api/periods/2025/1/lock")), + ); + assert.ok( + rs.every((r) => r.status === 200), + JSON.stringify(rs), + ); + assert.equal((await t.q("SELECT COUNT(*) n FROM periods"))[0].n, 1); + assert.equal( + ( + await t.q("SELECT COUNT(*) n FROM audit_log WHERE action='period.lock'") + )[0].n, + 1, + ); +}); +check( + "closed period refuses issue without numbering or status effects", + async (t) => { + await t.draft(); + await t.req("/api/periods/2025/1/lock"); + const s = await t.snapshot(); + assert.equal((await t.issue()).status, 409); + assert.deepEqual(await t.snapshot(), s); + }, +); +check("invalid raw dates cannot issue or bypass period lock", async (t) => { + await t.req("/api/periods/2025/1/lock"); + for (const [i, date] of [ + "2025-1-10", + "2025-02-30", + "2025-01-10T00:00:00", + "junk", + "0000-01-01", + "2025-13-01", + ].entries()) { + await t.draft({ id: i + 1, date }); + const s = await t.snapshot(); + assert.equal((await t.issue(i + 1)).status, 409, date); + assert.deepEqual(await t.snapshot(), s, date); + } +}); +check("journal-post audit failure restores draft and numbering", async (t) => { + await t.draft(); + await t.fault("NEW.action='journal.post'"); + const s = await t.snapshot(); + assert.equal((await t.issue()).status, 500); + assert.deepEqual(await t.snapshot(), s); +}); +check("final issue audit failure restores all issue effects", async (t) => { + await t.draft(); + await t.fault("NEW.action='invoice.issue'"); + const s = await t.snapshot(); + assert.equal((await t.issue()).status, 500); + assert.deepEqual(await t.snapshot(), s); +}); +check( + "20 parallel issues publish one entry and one invoice number", + async (t) => { + await t.draft(); + const rs = await Promise.all(Array.from({ length: 20 }, () => t.issue())); + assert.ok( + rs.every((r) => r.status === 200), + JSON.stringify(rs), + ); + assert.equal( + ( + await t.q( + "SELECT COUNT(*) n FROM journal_entries WHERE status='posted' AND reverses_entry_id IS NULL", + ) + )[0].n, + 1, + ); + assert.equal( + (await t.q("SELECT next_number FROM numbering_sequences"))[0].next_number, + 2, + ); + }, +); +check( + "issued deletion refuses with JSON 409 and draft deletion audit failure rolls back", + async (t) => { + await t.draft(); + await t.issue(); + const r = await t.req("/api/invoices/1", "DELETE"); + assert.equal(r.status, 409); + assert.equal(typeof r.body.error, "string"); + await t.draft({ id: 2 }); + await t.fault("NEW.action='invoice.delete'"); + const s = await t.snapshot(); + assert.equal((await t.req("/api/invoices/2", "DELETE")).status, 500); + assert.deepEqual(await t.snapshot(), s); + }, +); +check("status audit failure preserves old status", async (t) => { + await t.draft(); + await t.issue(); + await t.fault("NEW.action='invoice.status'"); + const s = await t.snapshot(); + assert.equal( + (await t.req("/api/invoices/1/status", "POST", { status: "paid" })).status, + 500, + ); + assert.deepEqual(await t.snapshot(), s); +}); +check( + "backfill concurrent retries publish a legacy issued invoice once", + async (t) => { + await t.draft(); + /* Seed legacy issued fixture without firing issue trigger. */ const issueTriggers = + await t.q( + "SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='invoices'", + ); + for (const x of issueTriggers) + await t.q("DROP TRIGGER " + JSON.stringify(x.name)); + await t.q( + "UPDATE invoices SET status='issued',number='INV-LEGACY' WHERE id=1", + ); + const rs = await Promise.all( + Array.from({ length: 10 }, () => t.req("/api/journals/backfill")), + ); + assert.ok( + rs.every((r) => r.status === 200), + JSON.stringify(rs), + ); + assert.equal( + ( + await t.q( + "SELECT COUNT(*) n FROM journal_entries WHERE status='posted' AND reverses_entry_id IS NULL", + ) + )[0].n, + 1, + ); + assert.equal(await t.ar(), 10000); + }, +); + +for (const race of [ + { + name: "document update", + path: "/api/invoices/1", + method: "PATCH", + body: { notes: "late edit" }, + sql: "UPDATE invoices SET notes =", + }, + { + name: "line insert", + path: "/api/invoices/1/lines", + method: "POST", + body: { description: "late line", unit_price_cents: 5000 }, + sql: "INSERT INTO invoice_lines", + }, + { + name: "line update", + path: "/api/lines/1", + method: "PATCH", + body: { description: "late edit" }, + sql: "UPDATE invoice_lines SET description =", + }, + { + name: "line delete", + path: "/api/lines/1", + method: "DELETE", + body: undefined, + sql: "DELETE FROM invoice_lines WHERE id =", + }, +]) { + check( + `a ${race.name} that passed its draft check cannot write after issue`, + async (t) => { + await t.draft(); + let release!: () => void; + let signal!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const entered = new Promise((resolve) => { + signal = resolve; + }); + let once = true; + const original = t.env.STORAGE.query; + t.env.STORAGE.query = async (sql, params = []) => { + if (once && sql.trimStart().startsWith(race.sql)) { + once = false; + signal(); + await held; + } + return original(sql, params); + }; + const editing = t.req(race.path, race.method, race.body); + await entered; + let issuedState; + try { + assert.equal((await t.issue()).status, 200); + issuedState = await t.snapshot(); + } finally { + release(); + } + const refusal = await editing; + assert.equal(refusal.status, 409, JSON.stringify(refusal)); + assert.deepEqual(await t.snapshot(), issuedState); + }, + ); +} diff --git a/tests/ledger-regressions.test.ts b/tests/ledger-regressions.test.ts index f4a3db4..3bb6973 100644 --- a/tests/ledger-regressions.test.ts +++ b/tests/ledger-regressions.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, test } from "node:test"; import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { initDB } from "../src/server/db"; -import { setStatus } from "../src/server/domain/invoices"; +import { issueInvoice, setStatus } from "../src/server/domain/invoices"; import { backfillFromInvoices, createFromInvoice, trialBalance } from "../src/server/domain/journals"; import { lockPeriod } from "../src/server/domain/periods"; import { LedgerError } from "../src/server/domain/errors"; @@ -149,10 +149,16 @@ test("cancelled invoices cannot become sent or paid, including a stale concurren await assertCancelledBooks(); }); -function unpostedInvoice() { - db.exec(`DELETE FROM journal_lines; DELETE FROM journal_entries; +function unpostedInvoice(date = "2025-01-10") { + // Model data written before freeze guards existed; restore the full schema + // before invoking any operation under test. + db.exec(`DROP TRIGGER ledger_invoice_line_insert_guard_v1; + DROP TRIGGER ledger_invoice_freeze_v1; + DELETE FROM journal_lines; DELETE FROM journal_entries; INSERT INTO invoice_lines(invoice_id, position, description, subtotal_cents, total_cents, vat_cents) VALUES(1, 1, 'Work', 10000, 10000, 0);`); + db.prepare("UPDATE invoices SET issue_date = ? WHERE id = 1").run(date); + db.exec(readFileSync(new URL("../src/server/schema.sql", import.meta.url), "utf8")); } test("concurrent backfill publishes one complete original and one audit", async () => { @@ -180,8 +186,7 @@ for (const action of ["BEFORE INSERT ON journal_lines", "BEFORE INSERT ON audit_ for (const date of ["2025-02-29", "2024-02-30", "2025-13-01", "2025-1-01", "2025-01-01T00:00:00Z", "1899-12-31"]) { test(`publication refuses invalid accounting date ${date}`, async () => { - unpostedInvoice(); - db.prepare("UPDATE invoices SET issue_date = ? WHERE id = 1").run(date); + unpostedInvoice(date); await assert.rejects(createFromInvoice(1, actor), LedgerError); assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 0); }); @@ -204,6 +209,45 @@ test("a lock committed while posting waits prevents any publication", async () = assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 0); }); +function draftForIssue(type = "invoice") { + db.exec("DELETE FROM journal_lines; DELETE FROM journal_entries; DELETE FROM invoices;"); + db.prepare(`INSERT INTO invoices(id, type, party_id, issue_date, subtotal_cents, total_cents) + VALUES(1, ?, 1, '2025-01-10', 10000, 10000)`).run(type); + db.exec(`INSERT INTO invoice_lines(invoice_id, position, description, subtotal_cents, total_cents, vat_cents) + VALUES(1, 1, 'Work', 10000, 10000, 0);`); +} + +test("issue retains a custom prefix and does not truncate sequence numbers over 9999", async () => { + draftForIssue(); + const year = new Date().getUTCFullYear(); + db.prepare("INSERT INTO numbering_sequences(scope, year, next_number, prefix) VALUES('invoice', ?, 10001, 'CUSTOM')").run(year); + const issued = await issueInvoice(1, actor); + assert.equal(issued?.number, `CUSTOM-${year}-10001`); + assert.equal((await issueInvoice(1, actor))?.number, issued?.number); + assert.equal(db.prepare("SELECT next_number FROM numbering_sequences").get()?.next_number, 10002); +}); + +test("a quote in a locked month still receives a number without changing the ledger", async () => { + draftForIssue("quote"); + await lockPeriod(2025, 1, actor); + const issued = await issueInvoice(1, actor); + assert.equal(issued?.number, `Q-${new Date().getUTCFullYear()}-0001`); + assert.equal(issued?.status, "issued"); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM journal_entries").get()?.n, 0); +}); + +test("valid leap day is accepted", async () => { + unpostedInvoice("2024-02-29"); + assert.equal((await createFromInvoice(1, actor))?.date, "2024-02-29"); +}); + +test("audit endpoint accepts a fractional limit and returns a whole number of rows", async () => { + await setStatus(1, "cancelled", actor); + const response = await api.request("/api/audit?limit=1.5"); + assert.equal(response.status, 200); + assert.equal((await response.json() as unknown[]).length, 1); +}); + for (const mounted of [false, true]) { test(`DELETE issued invoice returns 409 JSON (${mounted ? "mounted" : "direct"} API)`, async () => { const app = mounted ? new Hono().route("/", api) : api;