summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--clients/bokftui.c70
-rw-r--r--docs/PROTOCOL.md5
-rw-r--r--docs/STATE.md69
-rw-r--r--src/commands.c114
-rw-r--r--src/ledger.c171
-rw-r--r--src/ledger.h11
-rw-r--r--tests/test_core.c140
7 files changed, 546 insertions, 34 deletions
diff --git a/clients/bokftui.c b/clients/bokftui.c
index f7500bf..7c31ccb 100644
--- a/clients/bokftui.c
+++ b/clients/bokftui.c
@@ -3307,31 +3307,73 @@ static void inbox_screen(struct app *a)
}
}
+static void audit_verify_run(struct app *a, int full)
+{
+ char *resp = client_rpc(&a->conn, "audit.verify", a->session, a->org,
+ full ? "{\"full\":true}" : "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Revision", resp);
+ free(resp);
+ return;
+ }
+ int64_t checked = jint_val(resp, "result.checked", 0);
+ int64_t vouchers = jint_val(resp, "result.vouchers_checked", 0);
+ int is_ok = jbool_val(resp, "result.ok", 0);
+ if (is_ok) {
+ if (full)
+ tui_message("Hashkedjor",
+ "Kedjorna är intakta.\n%lld verifikat och %lld "
+ "händelser kontrollerade.\n%lld underlag "
+ "hash-kontrollerade.",
+ (long long)vouchers, (long long)checked,
+ (long long)jint_val(resp, "result.attachments_checked",
+ 0));
+ else
+ tui_message("Hashkedjor",
+ "Kedjorna är intakta.\n%lld verifikat och %lld "
+ "händelser kontrollerade.",
+ (long long)vouchers, (long long)checked);
+ } else {
+ int64_t bad_v = jint_val(resp, "result.first_bad_voucher_id", 0);
+ int64_t bad_a = jint_val(resp, "result.first_bad_seq", 0);
+ int64_t unbal = jint_val(resp, "result.first_unbalanced_voucher_id", 0);
+ int64_t bad_att =
+ jint_val(resp, "result.first_bad_attachment_id", 0);
+ char detail[320] = "";
+ if (bad_v)
+ snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
+ "Första trasiga verifikat: %lld\n", (long long)bad_v);
+ if (bad_a)
+ snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
+ "Första trasiga händelse: %lld\n", (long long)bad_a);
+ if (unbal)
+ snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
+ "Första obalanserade verifikat: %lld\n",
+ (long long)unbal);
+ if (bad_att)
+ snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
+ "Första trasiga underlag: %lld\n", (long long)bad_att);
+ tui_message("Hashkedjor", "KEDJORNA ÄR BRUTNA!\n%s", detail);
+ }
+ free(resp);
+}
+
static void audit_screen(struct app *a)
{
static const char *const audit_items[] = { "Verifiera hashkedjan",
+ "Verifiera allt (inkl. underlag)",
"Senaste händelser" };
static int last_sel = 0;
for (;;) {
if (g_quit)
return;
- int sel = tui_menu("Revision", audit_items, 2, 0, &last_sel);
+ int sel = tui_menu("Revision", audit_items, 3, 0, &last_sel);
if (sel < 0)
return;
if (sel == 0) {
- char *resp =
- client_rpc(&a->conn, "audit.verify", a->session, a->org, "{}");
- if (!resp || !client_ok(resp)) {
- show_error("Revision", resp);
- free(resp);
- continue;
- }
- int64_t checked = jint_val(resp, "result.checked", 0);
- int is_ok = jbool_val(resp, "result.ok", 0);
- tui_message("Hashkedja", "%s\n%d händelser kontrollerade.",
- is_ok ? "Kedjan är intakt." : "KEDJAN ÄR BRUTEN!",
- (int)checked);
- free(resp);
+ audit_verify_run(a, 0);
+ } else if (sel == 1) {
+ audit_verify_run(a, 1);
} else {
for (;;) {
char *resp = client_rpc(&a->conn, "audit.list", a->session,
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 7052b83..47f6f26 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -443,10 +443,11 @@ be created.
|---|---|---|
| `backup.snapshot` | `dest?` | `path`, `sha256`, `size`, `at` — uses SQLite `VACUUM INTO`, no downtime |
| `audit.list` | `from?`, `to?`, `action?`, `actor?`, `limit`, `cursor` | behandlingshistorik |
-| `audit.verify` | `full?` | recomputes voucher and audit hash chains; `ok`, `checked`, first/last mismatch if any |
+| `audit.verify` | `full?` | recomputes the voucher and audit hash chains, flags unbalanced vouchers, and with `full:true` re-hashes attachments; `ok`, `checked` (audit entries), `vouchers_checked`, `attachments_checked`, `unbalanced_vouchers` and the first bad `first_bad_voucher_id` / `first_bad_seq` / `first_bad_attachment_id` / `first_unbalanced_voucher_id` on failure |
`audit.verify` is cheap enough to run after every import and before every
-backup; `full:true` includes attachment hashes.
+backup; `full:true` includes attachment hashes. The voucher chain is verified
+per org in posting order (SCHEMA.md §7.1); the audit chain globally.
## 8. The TUI is just a client
diff --git a/docs/STATE.md b/docs/STATE.md
index d75f3cb..9f2f29f 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -74,11 +74,14 @@ server/protocol/ledger only.
cert reload on file change) with client targets `tls:host:port` and
system-trust verification (`BOKFD_TLS_CA` for private CAs). Certificates
come from a lego sidecar using INWX DNS-01 (`compose.yaml`). Externals
- get accounts/roles/tokens, never VPN access. `scripts/deploy.sh` builds
- locally and ships over SSH, or builds on the host when architectures
- differ. `scripts/deploy.sh --dev` cross-compiles the binaries here and
- hot-reloads the daemon (SIGHUP re-exec via `docker cp`), skipping the
- image build and container recreate.
+ get accounts/roles/tokens, never VPN access. The runtime image is
+ **Alpine + backend only** (`bokfd`, `bokfctl`; the ncurses TUI is a
+ frontend built on the client machine). `scripts/deploy.sh` builds locally
+ and ships over SSH, or builds the same image natively on the host when
+ architectures differ (no cross-compilation — a musl cross toolchain is
+ not trusted yet). `scripts/deploy.sh --dev` hot-reloads the binaries in
+ the running container (SIGHUP re-exec via `docker cp`) when the
+ architecture matches, otherwise it falls back to a full remote build.
15. **Reports in the TUI**: rendered as fixed-width Swedish tables that mirror
the Kapitas PDF exports (Saldobalans, Resultatrapport with previous-year
column and 89xx bokfört/ej bokfört, Balansrapport with Ing balans/Ing
@@ -152,8 +155,15 @@ server/protocol/ledger only.
years are flagged in the document. ~~SRU files (INK2/INK2R/INK2S)~~ done
as `sru.export` (official 2025P4 field tables, BAS mapping, TUI save in
Rapporter -> Inkomstdeklaration).
-4. `audit.verify` must also verify the **voucher** hash chain (today only the
- audit chain is verified).
+4. ~~`audit.verify` must also verify the **voucher** hash chain (today only the
+ audit chain is verified).~~ Done: recomputes every org's voucher chain in
+ posting order (SCHEMA.md §7.1), flags unbalanced vouchers as a backstop,
+ and `full:true` re-hashes attachment content; result carries
+ `vouchers_checked`, `unbalanced_vouchers`, `attachments_checked` and the
+ first bad voucher/audit/attachment id. TUI Revision shows both counts and
+ the bad ids. **Found while testing: `attachments` has no
+ append-only triggers** (COMPLIANCE.md §2 claims it does); content changes
+ are detected only by `audit.verify full:true`.
5. ~~`report.general_ledger` and `report.voucher_list`~~ implemented
(Huvudbok, Verifikationslista) with Kapitas-style TUI tables; the ledger
API supports `accounts`/`from`/`to`, the list an optional `series`.
@@ -182,6 +192,21 @@ server/protocol/ledger only.
## Environment / how to run
+- **Deployed**: `scripts/deploy.sh` (latest `v0.1.44`, healthy on nas).
+ Live daemon `tls:bokf.makandra.eu:8788`, token
+ `~/.config/bokf/migration-token` (scopes `read,write`; owner-only actions
+ like closing years must be done by the human in the TUI). Git remote
+ `origin` is `nas:/mnt/data/git-repos/bokf.git` (push `main` and tags).
+- **Local test rig** (transient, `/tmp`): daemon
+ `./build/bokfd --db /tmp/opencode/bokf-local/t.db --socket
+ /tmp/opencode/bokf-local/sock`, org 1, login `admin`/`testpass123`.
+ Drive the TUI over a pty with `scripts/tui-sandbox.sh -- ./build/bokftui
+ --socket /tmp/opencode/bokf-local/sock --org 1 --fy 1 ...` plus a small
+ driver that feeds keys and an ANSI renderer (recreate if gone; arrows are
+ `ESC O B/A`, Tab `\t`, `^X` `\x18`, `^Enter` `ESC[27;5;13~`, F5
+ `ESC[15~`). Never test against the live daemon.
+
+
- Demo: db `~/bokf-demo/bokfd.db`, socket `~/bokf-demo/bokfd.sock`,
pid file `~/bokf-demo/bokfd.pid`; login `admin` / `demo1234`.
Start TUI: `cd ~/work/bokf && BOKFD_SOCKET=$HOME/bokf-demo/bokfd.sock \
@@ -207,4 +232,32 @@ server/protocol/ledger only.
- Never commit unless the human asks.
- SQLite files must not be backed up live with restic; use
`backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots.
-- Schema version is 3; forward migrations are in `db.c`.
+- Schema version is 6 (v3 moms rules; v4/v6 year info; v5 org
+ description/shares + board members); forward migrations are in `db.c`.
+
+## Makandra driftstatus (org 2)
+
+- **Org**: Makandra AB, org 2. Räkenskapsår (id): 2022=3, 2023=4, 2024=5,
+ 2025=6, 2026=7, **2027=2 (öppet, aktuellt)**. Bokslut/AR/deklaration görs
+ för det år som är valt i sessionen.
+- **FK2027**: importerade Kapitas-böcker + 28 bokförda verifikat (V21–V48)
+ för bank/skatt maj–sep 2026, samt V49 som makulerar en dubblett (V20).
+ 1930 stämmer mot banken utom **CDON 2 409 kr** (väntar på kvittots del
+ 2–4; bokförs när det kommer). 1630 = 40 721 (exakt enligt Skatteverket).
+- **Underlag**: 279 attachment i org 2 (alla historikdokument + insamlade
+ underlag). Bank-/SKV-utdrag ligger i `~/Makandra AB/{bank,skatteverket}`
+ (Syncthing), källkorpus i `~/Downloads/Makandra AB-…/Bokföring/`.
+- **Stängning**: 2022–2026 ska stängas av ägaren via **Räkenskapsår** i
+ TUI:n; låt FK2027 vara öppen till nästa bokslut.
+- **Deklaration**: FK2026 är deklarerad av revisorn. FK2027 deklareras
+ våren 2027 (INK2/SRU via Bokslutshubben → Inkomstdeklaration).
+- **Årshäftet**: fylls i Bokslutshubben (händelser, stämma, utdelning +
+ datum, medelantal, noter). OBS: `dividend_ore` för FK2027 kan vara ett
+ testvärde (10 000) — kontrollera före AR/deklaration.
+- **Beslut/regler från bokföringsarbetet**: inga bokföringar utan
+ godkännande; låsta år förblir låsta (rättelser görs i aktuellt år); SIE-
+ importören ändras inte och importerad data "manipuleras" inte;
+ historikårens P&L nettar noll pga källsystemets stängningar (AR hoppar
+ över "Stäng"-verifikat i flerårsöversikten); utdelning bokförs vid stämman
+ med mallen **Utdelning** (D 2099/K 2898); pappersoriginal finns i fysisk
+ pärm (får refereras i efterhand, även i stängda år).
diff --git a/src/commands.c b/src/commands.c
index 448d03e..d21d4c8 100644
--- a/src/commands.c
+++ b/src/commands.c
@@ -1545,25 +1545,119 @@ static yyjson_mut_val *h_audit_list(struct req *r)
return out;
}
+static int verify_attachments(sqlite3 *db, int64_t *checked, int64_t *bad_id,
+ char **err)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(db,
+ "SELECT id,sha256,size_bytes,content FROM attachments"
+ " ORDER BY id",
+ -1, &st, NULL) != SQLITE_OK) {
+ if (err && !*err)
+ *err = xstrdup(sqlite3_errmsg(db));
+ return -1;
+ }
+ int ret = 0;
+ for (;;) {
+ int step = sqlite3_step(st);
+ if (step == SQLITE_DONE)
+ break;
+ if (step != SQLITE_ROW) {
+ if (err && !*err)
+ *err = xstrdup(sqlite3_errmsg(db));
+ ret = -1;
+ break;
+ }
+ int64_t id = sqlite3_column_int64(st, 0);
+ const void *sha = sqlite3_column_blob(st, 1);
+ int sha_n = sqlite3_column_bytes(st, 1);
+ int64_t size = sqlite3_column_int64(st, 2);
+ const void *content = sqlite3_column_blob(st, 3);
+ int content_n = sqlite3_column_bytes(st, 3);
+ unsigned char digest[32];
+ util_sha256(content, (size_t)content_n, digest);
+ (*checked)++;
+ if (sha_n != 32 || memcmp(sha, digest, 32) != 0 ||
+ size != content_n) {
+ if (!*bad_id)
+ *bad_id = id;
+ ret = 1;
+ }
+ }
+ sqlite3_finalize(st);
+ return ret;
+}
+
static yyjson_mut_val *h_audit_verify(struct req *r)
{
- int64_t checked = 0, bad_seq = 0;
+ int full = 0;
+ arg_bool(r->args, "full", &full);
+
+ int64_t audit_checked = 0, audit_bad = 0;
char *err = NULL;
- int rc = audit_verify(r->db, &checked, &bad_seq, &err);
- if (rc < 0) {
+ int audit_rc = audit_verify(r->db, &audit_checked, &audit_bad, &err);
+ if (audit_rc < 0) {
+ yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed");
+ free(err);
+ return res;
+ }
+ free(err);
+ err = NULL;
+
+ struct ledger_verify_result lv;
+ int ledger_rc = ledger_verify(r->db, &lv, &err);
+ if (ledger_rc < 0) {
yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed");
free(err);
return res;
}
free(err);
+ err = NULL;
+
+ int64_t att_checked = 0, att_bad = 0;
+ int att_rc = 0;
+ if (full) {
+ att_rc = verify_attachments(r->db, &att_checked, &att_bad, &err);
+ if (att_rc < 0) {
+ yyjson_mut_val *res =
+ fail(r, "INTERNAL", err ? err : "verify failed");
+ free(err);
+ return res;
+ }
+ free(err);
+ }
+
+ int ok = audit_rc == 0 && ledger_rc == 0 && att_rc == 0;
yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
- yyjson_mut_obj_add_bool(r->rdoc, o, "ok", rc == 0);
- yyjson_mut_obj_add_strcpy(r->rdoc, o, "kind", "audit");
- yyjson_mut_obj_add_int(r->rdoc, o, "checked", checked);
- if (rc == 1)
- yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", bad_seq);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "ok", ok);
+ yyjson_mut_obj_add_int(r->rdoc, o, "checked", audit_checked);
+ yyjson_mut_obj_add_int(r->rdoc, o, "vouchers_checked",
+ lv.vouchers_checked);
+ yyjson_mut_obj_add_int(r->rdoc, o, "unbalanced_vouchers",
+ lv.unbalanced_vouchers);
+ if (audit_bad)
+ yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", audit_bad);
else
yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq");
+ if (lv.first_bad_voucher_id)
+ yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_voucher_id",
+ lv.first_bad_voucher_id);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_voucher_id");
+ if (lv.first_unbalanced_voucher_id)
+ yyjson_mut_obj_add_int(r->rdoc, o, "first_unbalanced_voucher_id",
+ lv.first_unbalanced_voucher_id);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, o, "first_unbalanced_voucher_id");
+ if (full) {
+ yyjson_mut_obj_add_int(r->rdoc, o, "attachments_checked",
+ att_checked);
+ if (att_bad)
+ yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_attachment_id",
+ att_bad);
+ else
+ yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_attachment_id");
+ }
return o;
}
@@ -4438,8 +4532,8 @@ const struct command g_commands[] = {
h_agent_instructions },
{ "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0,
0, h_audit_list },
- { "audit.verify", "Verify the audit hash chain", PERM_READ, 0, 0, 0,
- h_audit_verify },
+ { "audit.verify", "Verify the voucher and audit hash chains", PERM_READ, 0,
+ 0, 0, h_audit_verify },
{ "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN,
0, 1, 0, h_backup_snapshot },
{ "account.list", "List the chart of accounts", PERM_READ, 1, 0, 0,
diff --git a/src/ledger.c b/src/ledger.c
index 72402f0..4dacde4 100644
--- a/src/ledger.c
+++ b/src/ledger.c
@@ -66,6 +66,177 @@ void ledger_voucher_hash(const unsigned char prev[32], int64_t org_id,
buf_free(&b);
}
+static void verify_err(char **err, const char *msg)
+{
+ if (err && !*err)
+ *err = xstrdup(msg);
+}
+
+static char *copy_text(const unsigned char *p)
+{
+ return xstrdup(p ? (const char *)p : "");
+}
+
+static void free_rows(struct ledger_row *rows, size_t nrows)
+{
+ for (size_t i = 0; i < nrows; i++) {
+ free((char *)rows[i].account);
+ free((char *)rows[i].description);
+ }
+ free(rows);
+}
+
+static int verify_org(sqlite3 *db, int64_t org_id,
+ struct ledger_verify_result *out, char **err)
+{
+ sqlite3_stmt *vs = NULL, *rs = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT v.id,v.hash_prev,v.hash,fy.label,v.series,v.number,v.date,"
+ "v.description FROM vouchers v JOIN fiscal_years fy"
+ " ON fy.org_id=v.org_id AND fy.id=v.fiscal_year_id"
+ " WHERE v.org_id=?1 ORDER BY v.id",
+ -1, &vs, NULL) != SQLITE_OK) {
+ verify_err(err, sqlite3_errmsg(db));
+ return -1;
+ }
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT a.number,r.debit_ore,r.credit_ore,r.description"
+ " FROM voucher_rows r JOIN accounts a"
+ " ON a.org_id=r.org_id AND a.id=r.account_id"
+ " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no",
+ -1, &rs, NULL) != SQLITE_OK) {
+ sqlite3_finalize(vs);
+ verify_err(err, sqlite3_errmsg(db));
+ return -1;
+ }
+
+ unsigned char running[32];
+ memset(running, 0, sizeof running);
+ int ret = 0;
+ sqlite3_bind_int64(vs, 1, org_id);
+ for (;;) {
+ int step = sqlite3_step(vs);
+ if (step == SQLITE_DONE)
+ break;
+ if (step != SQLITE_ROW) {
+ verify_err(err, sqlite3_errmsg(db));
+ ret = -1;
+ break;
+ }
+ int64_t id = sqlite3_column_int64(vs, 0);
+ const void *hprev = sqlite3_column_blob(vs, 1);
+ int hprev_n = sqlite3_column_bytes(vs, 1);
+ const void *hstored = sqlite3_column_blob(vs, 2);
+ int hstored_n = sqlite3_column_bytes(vs, 2);
+ char *label = copy_text(sqlite3_column_text(vs, 3));
+ char *series = copy_text(sqlite3_column_text(vs, 4));
+ int64_t number = sqlite3_column_int64(vs, 5);
+ char *date = copy_text(sqlite3_column_text(vs, 6));
+ char *vdesc = copy_text(sqlite3_column_text(vs, 7));
+
+ struct ledger_row *rows = NULL;
+ size_t nrows = 0, cap = 0;
+ int64_t sum_debit = 0, sum_credit = 0;
+ sqlite3_bind_int64(rs, 1, org_id);
+ sqlite3_bind_int64(rs, 2, id);
+ int rstep;
+ while ((rstep = sqlite3_step(rs)) == SQLITE_ROW) {
+ if (nrows == cap) {
+ cap = cap ? cap * 2 : 8;
+ rows = xrealloc(rows, cap * sizeof *rows);
+ }
+ rows[nrows].account = copy_text(sqlite3_column_text(rs, 0));
+ rows[nrows].debit_ore = sqlite3_column_int64(rs, 1);
+ rows[nrows].credit_ore = sqlite3_column_int64(rs, 2);
+ rows[nrows].description = copy_text(sqlite3_column_text(rs, 3));
+ sum_debit += rows[nrows].debit_ore;
+ sum_credit += rows[nrows].credit_ore;
+ nrows++;
+ }
+ sqlite3_reset(rs);
+ sqlite3_clear_bindings(rs);
+ if (rstep != SQLITE_DONE) {
+ verify_err(err, sqlite3_errmsg(db));
+ free_rows(rows, nrows);
+ free(label);
+ free(series);
+ free(date);
+ free(vdesc);
+ ret = -1;
+ break;
+ }
+
+ unsigned char expect[32];
+ ledger_voucher_hash(running, org_id, label, series, number, date, vdesc,
+ rows, nrows, expect);
+ out->vouchers_checked++;
+ if (sum_debit != sum_credit) {
+ out->unbalanced_vouchers++;
+ if (!out->first_unbalanced_voucher_id)
+ out->first_unbalanced_voucher_id = id;
+ if (ret == 0)
+ ret = 2;
+ }
+ if (hprev_n != 32 || hstored_n != 32 ||
+ memcmp(hprev, running, 32) != 0 ||
+ memcmp(hstored, expect, 32) != 0) {
+ out->first_bad_voucher_id = id;
+ ret = 1;
+ } else {
+ memcpy(running, expect, 32);
+ }
+ free_rows(rows, nrows);
+ free(label);
+ free(series);
+ free(date);
+ free(vdesc);
+ if (ret == 1)
+ break;
+ }
+ sqlite3_finalize(vs);
+ sqlite3_finalize(rs);
+ return ret;
+}
+
+int ledger_verify(sqlite3 *db, struct ledger_verify_result *out, char **err)
+{
+ memset(out, 0, sizeof *out);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(db, "SELECT DISTINCT org_id FROM vouchers"
+ " ORDER BY org_id",
+ -1, &st, NULL) != SQLITE_OK) {
+ verify_err(err, sqlite3_errmsg(db));
+ return -1;
+ }
+ int rc = 0;
+ for (;;) {
+ int step = sqlite3_step(st);
+ if (step == SQLITE_DONE)
+ break;
+ if (step != SQLITE_ROW) {
+ verify_err(err, sqlite3_errmsg(db));
+ rc = -1;
+ break;
+ }
+ int64_t org_id = sqlite3_column_int64(st, 0);
+ int r = verify_org(db, org_id, out, err);
+ if (r < 0) {
+ rc = -1;
+ break;
+ }
+ if (r == 1) {
+ rc = 1;
+ break;
+ }
+ if (r == 2)
+ rc = 1;
+ }
+ sqlite3_finalize(st);
+ return rc;
+}
+
static int idempotent_lookup(sqlite3 *db, int64_t org_id, const char *client_ref,
char **out_json)
{
diff --git a/src/ledger.h b/src/ledger.h
index 30271d0..cf8f9b1 100644
--- a/src/ledger.h
+++ b/src/ledger.h
@@ -48,4 +48,15 @@ void ledger_voucher_hash(const unsigned char prev[32], int64_t org_id,
const struct ledger_row *rows, size_t nrows,
unsigned char out[32]);
+struct ledger_verify_result {
+ int64_t vouchers_checked;
+ int64_t first_bad_voucher_id; /* 0 = none */
+ int64_t unbalanced_vouchers;
+ int64_t first_unbalanced_voucher_id; /* 0 = none */
+};
+
+/* Recomputes every org's voucher hash chain (SCHEMA.md 7.1) and scans for
+ unbalanced vouchers. 0 = intact, 1 = broken, -1 = database error. */
+int ledger_verify(sqlite3 *db, struct ledger_verify_result *out, char **err);
+
#endif
diff --git a/tests/test_core.c b/tests/test_core.c
index 6cd7b18..885523c 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -635,6 +635,9 @@ int main(void)
CHECK_OK(d);
CHECK(jbool(d, "result.ok"));
CHECK(jint(d, "result.checked") > 5);
+ CHECK(jint(d, "result.vouchers_checked") == 0);
+ CHECK(jint(d, "result.unbalanced_vouchers") == 0);
+ CHECK(jint(d, "result.first_bad_voucher_id") == -1);
yyjson_doc_free(d);
d = call(reqf("{\"v\":1,\"id\":\"27\",\"cmd\":\"audit.list\","
@@ -1890,6 +1893,143 @@ int main(void)
CHECK_OK(d);
CHECK(jbool(d, "result.ok"));
CHECK(jint(d, "result.checked") > 20);
+ CHECK(jint(d, "result.vouchers_checked") > 10);
+ CHECK(jint(d, "result.unbalanced_vouchers") == 0);
+ yyjson_doc_free(d);
+
+ /* ------------------- audit.verify tamper detection ---------------- */
+
+ d = call(reqf("{\"v\":1,\"id\":\"80\",\"cmd\":\"audit.verify\","
+ "\"session\":\"%s\",\"args\":{\"full\":true}}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.ok"));
+ CHECK(jint(d, "result.vouchers_checked") > 10);
+ CHECK(jint(d, "result.attachments_checked") >= 1);
+ CHECK(jint(d, "result.unbalanced_vouchers") == 0);
+ yyjson_doc_free(d);
+
+ int64_t voucher_id = 0;
+ char voucher_desc[256] = "";
+ sqlite3_stmt *vst = NULL;
+ CHECK(sqlite3_prepare_v2(g_db,
+ "SELECT id,description FROM vouchers"
+ " ORDER BY id LIMIT 1",
+ -1, &vst, NULL) == SQLITE_OK);
+ if (sqlite3_step(vst) == SQLITE_ROW) {
+ voucher_id = sqlite3_column_int64(vst, 0);
+ snprintf(voucher_desc, sizeof voucher_desc, "%s",
+ (const char *)sqlite3_column_text(vst, 1));
+ }
+ sqlite3_finalize(vst);
+ CHECK(voucher_id > 0);
+
+ int64_t attachment_id = 0;
+ sqlite3_stmt *ast = NULL;
+ CHECK(sqlite3_prepare_v2(g_db,
+ "SELECT id FROM attachments ORDER BY id LIMIT 1",
+ -1, &ast, NULL) == SQLITE_OK);
+ if (sqlite3_step(ast) == SQLITE_ROW)
+ attachment_id = sqlite3_column_int64(ast, 0);
+ sqlite3_finalize(ast);
+ CHECK(attachment_id > 0);
+
+ /* attachments are re-hashed only with full:true */
+ err = NULL;
+ CHECK(db_exec(g_db, reqf("UPDATE attachments SET content=x'00'"
+ " WHERE id=%lld", (long long)attachment_id),
+ &err) == 0);
+ free(err);
+ err = NULL;
+
+ d = call(reqf("{\"v\":1,\"id\":\"81\",\"cmd\":\"audit.verify\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.ok"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"82\",\"cmd\":\"audit.verify\","
+ "\"session\":\"%s\",\"args\":{\"full\":true}}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(!jbool(d, "result.ok"));
+ CHECK(jint(d, "result.first_bad_attachment_id") == attachment_id);
+ CHECK(jint(d, "result.vouchers_checked") > 10);
+ yyjson_doc_free(d);
+
+ /* the append-only trigger blocks the tamper; after dropping it the
+ modified description must break the chain at that voucher */
+ err = NULL;
+ CHECK(db_exec(g_db, reqf("UPDATE vouchers SET description='x'"
+ " WHERE id=%lld", (long long)voucher_id),
+ &err) != 0);
+ free(err);
+ err = NULL;
+ CHECK(db_exec(g_db, "DROP TRIGGER vouchers_no_update", &err) == 0);
+ free(err);
+ err = NULL;
+ CHECK(db_exec(g_db, reqf("UPDATE vouchers SET description='kapad'"
+ " WHERE id=%lld", (long long)voucher_id),
+ &err) == 0);
+ free(err);
+ err = NULL;
+
+ d = call(reqf("{\"v\":1,\"id\":\"83\",\"cmd\":\"audit.verify\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(!jbool(d, "result.ok"));
+ CHECK(jint(d, "result.first_bad_voucher_id") == voucher_id);
+ CHECK(jint(d, "result.first_bad_seq") == -1);
+ yyjson_doc_free(d);
+
+ char *fix = sqlite3_mprintf("UPDATE vouchers SET description=%Q"
+ " WHERE id=%lld", voucher_desc,
+ (long long)voucher_id);
+ CHECK(fix != NULL);
+ err = NULL;
+ CHECK(db_exec(g_db, fix, &err) == 0);
+ sqlite3_free(fix);
+ free(err);
+ err = NULL;
+ CHECK(db_exec(g_db, "CREATE TRIGGER vouchers_no_update BEFORE UPDATE ON"
+ " vouchers BEGIN SELECT RAISE(ABORT,"
+ " 'vouchers are append-only'); END",
+ &err) == 0);
+ free(err);
+ err = NULL;
+
+ d = call(reqf("{\"v\":1,\"id\":\"84\",\"cmd\":\"audit.verify\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.ok"));
+ CHECK(jint(d, "result.first_bad_voucher_id") == -1);
+ yyjson_doc_free(d);
+
+ /* the balance backstop catches a row appended outside the ledger path */
+ err = NULL;
+ CHECK(db_exec(g_db,
+ reqf("INSERT INTO voucher_rows(org_id,voucher_id,line_no,"
+ "account_id,debit_ore,credit_ore,description)"
+ " SELECT org_id,id,99,"
+ "(SELECT id FROM accounts WHERE org_id=%d"
+ " AND number='3001'),1,0,NULL"
+ " FROM vouchers WHERE id=%lld",
+ (int)org_id, (long long)voucher_id),
+ &err) == 0);
+ free(err);
+ err = NULL;
+
+ d = call(reqf("{\"v\":1,\"id\":\"85\",\"cmd\":\"audit.verify\","
+ "\"session\":\"%s\"}",
+ g_session));
+ CHECK_OK(d);
+ CHECK(!jbool(d, "result.ok"));
+ CHECK(jint(d, "result.first_bad_voucher_id") == voucher_id);
+ CHECK(jint(d, "result.first_unbalanced_voucher_id") == voucher_id);
+ CHECK(jint(d, "result.unbalanced_vouchers") == 1);
yyjson_doc_free(d);
test_transport();