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..c00da73 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,13 @@
"private": true,
"type": "module",
"scripts": {
+ "test": "tsx --test tests/*.test.ts",
"dev": "clawnify dev",
"build": "vite build",
"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",
@@ -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 44bb7e7..6562ea6 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)
@@ -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)
@@ -145,17 +148,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: '*'
@@ -225,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'}
@@ -237,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'}
@@ -249,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'}
@@ -261,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'}
@@ -273,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'}
@@ -285,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'}
@@ -297,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'}
@@ -309,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'}
@@ -321,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'}
@@ -333,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'}
@@ -345,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'}
@@ -357,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'}
@@ -369,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'}
@@ -381,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'}
@@ -393,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'}
@@ -405,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'}
@@ -417,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'}
@@ -429,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'}
@@ -441,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'}
@@ -453,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'}
@@ -465,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'}
@@ -477,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'}
@@ -489,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'}
@@ -501,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'}
@@ -513,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'}
@@ -525,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'}
@@ -1263,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'}
@@ -1731,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'}
@@ -1976,12 +2152,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 +2225,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
@@ -2092,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
@@ -2554,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:
@@ -2592,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)
@@ -2600,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
@@ -2807,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: {}
@@ -3291,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
@@ -3313,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)
@@ -3325,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/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" && (
setStatus("cancelled")} className="px-3 py-1.5 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50">Cancel
)}
- Delete
+ {invoice.status === "draft" && (
+ Delete
+ )}
);
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
)}
- remove(inv.id)} className="text-gray-300 hover:text-red-600 text-lg leading-none px-1">×
+ {inv.status === "draft" && (
+ remove(inv.id)} className="text-gray-300 hover:text-red-600 text-lg leading-none px-1" title="Delete 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..0077485
--- /dev/null
+++ b/src/server/domain/audit.ts
@@ -0,0 +1,54 @@
+import { caller, user, type Caller, type RequestLike } from "@clawnify/app";
+import { query } 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;
+}
+
+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); }
+ // ?? 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(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
new file mode 100644
index 0000000..3d65fdd
--- /dev/null
+++ b/src/server/domain/errors.ts
@@ -0,0 +1,38 @@
+/**
+ * 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";
+ }
+}
+
+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.",
+ "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 ledgerErrorFromSQL(error: unknown): LedgerError | undefined {
+ const marker = error instanceof Error ? /\bledger:([a-z-]+)\b/.exec(error.message)?.[1] : undefined;
+ 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 3ac2287..dc40245 100644
--- a/src/server/domain/invoices.ts
+++ b/src/server/domain/invoices.ts
@@ -1,8 +1,8 @@
import { get, query, run } from "../db";
+import type { Actor } from "./audit";
import { getCompany } from "./company";
-import { createFromInvoice as createJournalFromInvoice, deleteEntriesForInvoice } from "./journals";
+import { LedgerError, rethrowLedgerError } from "./errors";
import { getParty } from "./parties";
-import { nextNumber, type NumberingScope } from "./numbering";
import { computeVat } from "./vat";
export type InvoiceType = "invoice" | "credit_note" | "quote";
@@ -101,52 +101,113 @@ export async function createDraft(input: CreateDraftInput): Promise {
return created;
}
-export async function deleteInvoice(id: number): Promise {
- await deleteEntriesForInvoice(id);
- await run("DELETE FROM invoice_lines WHERE invoice_id = ?", [id]);
- await run("DELETE FROM invoices WHERE id = ?", [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.`,
+ );
+ }
}
-const SCOPE_FOR_TYPE: Record = {
- invoice: "invoice",
- credit_note: "credit_note",
- quote: "quote",
-};
-
-export async function issueInvoice(id: number): Promise {
+export async function deleteInvoice(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);
- 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);
- return 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.`,
+ );
+ }
+ 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);
+ }
}
-export async function setStatus(id: number, status: InvoiceStatus): Promise {
- await run(
- `UPDATE invoices SET status = ?, updated_at = datetime('now') WHERE id = ?`,
- [status, id],
- );
- if (status === "cancelled") {
- await deleteEntriesForInvoice(id);
+export async function issueInvoice(id: number, actor: Actor): Promise {
+ 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"]);
+
+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.`);
+ }
+ 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);
+ }
+ const after = await getInvoice(id);
+ if (after?.status === "cancelled" && status !== "cancelled") {
+ throw new LedgerError("A cancelled invoice cannot be sent or paid. Create a new invoice instead.");
+ }
+ 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 +239,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 +271,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..d18b733 100644
--- a/src/server/domain/journals.ts
+++ b/src/server/domain/journals.ts
@@ -1,5 +1,6 @@
-import { get, query, run } from "../db";
-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;
@@ -9,6 +10,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;
}
@@ -29,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[] = [];
@@ -80,135 +79,39 @@ export async function getEntry(id: number): Promise {
- await run(
- "DELETE FROM journal_entries WHERE source_type IN ('invoice','credit_note') AND source_id = ?",
- [invoiceId],
- );
-}
-
-interface PendingLine {
- account_code: string;
- description: string;
- debit_cents: number;
- credit_cents: number;
-}
-
-export async function createFromInvoice(invoiceId: number): 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);
-
- 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 ?? new Date().toISOString().slice(0, 10);
- 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],
+/** Publish a complete original once; historical pending rows do not block retry. */
+export async function createFromInvoice(invoiceId: number, actor: Actor = SYSTEM_ACTOR): Promise {
+ 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);
}
-
- return get("SELECT * FROM journal_entries WHERE id = ?", [entryId]);
}
-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
- 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);
- if (entry) created++;
+ if (await createFromInvoice(row.id, actor)) created++;
}
return { created };
}
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
new file mode 100644
index 0000000..e8e2737
--- /dev/null
+++ b/src/server/domain/periods.ts
@@ -0,0 +1,50 @@
+import { get, query, run } from "../db";
+import type { Actor } from "./audit";
+import { LedgerError, rethrowLedgerError } from "./errors";
+
+export interface Period {
+ year: number;
+ month: number;
+ locked_at: string;
+ locked_by: string | null;
+ locked_by_kind: string | null;
+}
+
+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.`);
+ }
+ // 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.`,
+ );
+ }
+ 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");
+ return locked;
+}
diff --git a/src/server/routes.ts b/src/server/routes.ts
index 1f63451..39f5cee 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";
@@ -18,6 +19,9 @@ import {
type InvoiceType,
} from "./domain/invoices";
import { getCompany, updateCompany } from "./domain/company";
+import { actorOf, listAudit } from "./domain/audit";
+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";
import { COUNTRY_VAT, computeVat, EU_MEMBER_STATES } from "./domain/vat";
@@ -27,6 +31,20 @@ 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.onError((err, c) => {
+ 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);
+ }
+ console.error(err);
+ return c.text("Internal Server Error", 500);
+});
+
api.get("/api/accounts", async (c) => {
const bw = c.req.query("bw");
const nivoStr = c.req.query("nivo");
@@ -229,7 +247,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 +294,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 +317,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..28c64b7 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')),
@@ -136,6 +138,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,7 +151,12 @@ CREATE TABLE IF NOT EXISTS journal_entries (
date TEXT NOT NULL,
source_type TEXT,
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 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'))
);
@@ -164,3 +177,310 @@ 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);
+
+-- 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;
+
+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
new file mode 100644
index 0000000..3bb6973
--- /dev/null
+++ b/tests/ledger-regressions.test.ts
@@ -0,0 +1,283 @@
+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 { 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";
+import api from "../src/server/routes";
+
+const actor = { actor: "regression-test", actor_kind: "user" as const };
+let db: DatabaseSync;
+let beforeQuery: ((sql: string) => void | Promise) | 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)[] = []) {
+ await 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 () => {
+ // 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;
+ await setStatus(1, "cancelled", actor);
+ await assertCancelledBooks();
+ await setStatus(1, "cancelled", actor);
+ 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(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 () => {
+ 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(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);
+});
+
+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;
+ 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");
+ });
+}