summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-18 21:07:38 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-18 21:07:38 +0200
commit0ec59a0dfce8d350f26783ccc2456e5cb68c37a5 (patch)
tree69a9764e8d75df8d33aebe1b5a18e95909e4f9a4
parenta5d3ddf6d512bc459cfa450c6b449c2e7a245373 (diff)
downloadbokf-0ec59a0dfce8d350f26783ccc2456e5cb68c37a5.tar.gz
bokf-0ec59a0dfce8d350f26783ccc2456e5cb68c37a5.zip
reports: huvudbok and verifikationslista; fiscal_year.reopen
report.general_ledger lists every account with activity or IB, its postings in date order with a running saldo, and Omslutning/Utgående saldo; accounts?/from?/to? narrow it. report.voucher_list lists the year's vouchers with rows and totals, with an optional series filter. fiscal_year.reopen undoes a close (owner-only, confirm:true, audited) so the Räkenskapsår screen can toggle the status.
-rw-r--r--docs/PROTOCOL.md23
-rw-r--r--docs/STATE.md16
-rw-r--r--src/commands.c81
-rw-r--r--src/reports.c229
-rw-r--r--src/reports.h7
-rw-r--r--tests/test_core.c88
6 files changed, 430 insertions, 14 deletions
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 35390ae..b8ec759 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -109,7 +109,7 @@ Scopes on a token can narrow but never widen the user's role.
| Read: vouchers, reports, audit, accounts | ● | ● | ● | ● |
| `voucher.post`, `voucher.correct`, `attachment.put` | | ● | ● | |
| `sie.import`, `account.create`, `account.update` | | ● | ● | |
-| `period.lock`, `fiscal_year.open/close`, `org.update` | | | ● | |
+| `period.lock`, `fiscal_year.open/close/reopen`, `org.update` | | | ● | |
| `org.member_*`, `token.create` for others | | | ● | |
| `user.create`, any org | | | | ● |
| `backup.snapshot` | | | ● | ● |
@@ -239,8 +239,9 @@ policy versions with the software. It covers, at minimum:
(`voucher.correct`)
- receipts: `attachment.put` before or together with posting
- locked periods and closed years are hard stops — ask the human
-- `fiscal_year.close`, `period.lock` and `sie.import` are irreversible
- operations: confirm with the human first
+- `fiscal_year.close` can be undone with `fiscal_year.reopen`; both are
+ owner-only and audited. `period.lock` and `sie.import` are irreversible:
+ confirm with the human first
- how to read `report.vat` boxes and `report.balance_sheet`
- on `CONFLICT`/`replayed`, fetch the existing object instead of retrying
@@ -285,7 +286,8 @@ Account `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`.
|---|---|---|
| `fiscal_year.list` / `fiscal_year.get` | `org?` / `id` | |
| `fiscal_year.open` | `label`, `start_date`, `end_date` | owner |
-| `fiscal_year.close` | `id`, `confirm:true` | owner; irreversible |
+| `fiscal_year.close` | `id`, `confirm:true` | owner; audited |
+| `fiscal_year.reopen` | `id`, `confirm:true` | owner; undoes a close |
| `period.lock` | `fiscal_year`, `until`, `reason?` | owner; `until` inclusive |
| `period.unlock` | `fiscal_year`, `reason` | owner; audited with reason |
@@ -367,7 +369,18 @@ vouchers at posting time (`attachment_ids`) or afterwards via
| `report.vat` | `from`, `to`, `period_type?` | momsdeklaration ruta för ruta |
All reports are pure reads, respect locks, and return JSON rows. Amounts are
-öre. `report.vat` returns `{"from","to","boxes":[{"box":"05","label":"...","amount_ore":...}],"note"}`.
+öre. `report.general_ledger` (huvudbok) returns account blocks:
+`{"fiscal_year","from","to","last_voucher":{...},"accounts":[{"account",
+"name","ib_ore","debit_ore","credit_ore","ub_ore","rows":[{"series",
+"number","date","description","row_description","debit_ore","credit_ore",
+"saldo_ore"}]}]}`; accounts without IB or period movement are omitted, and
+`accounts` (array of account numbers) filters the list.
+`report.voucher_list` (verifikationslista) returns `{"fiscal_year","from",
+"to","last_voucher":{...},"vouchers":[{"id","series","number","date",
+"description","rows":[{"account","name","debit_ore","credit_ore",
+"description"}]}],"totals":{"debit_ore","credit_ore"}}` and takes an
+optional `series` filter. Both cover the whole fiscal year; the ledger's
+period can be narrowed with `from`/`to`. `report.vat` returns `{"from","to","boxes":[{"box":"05","label":"...","amount_ore":...}],"note"}`.
Rules sharing a box are summed into a single entry. `box 49` is the sum of
the moms boxes (`10`,`11`,`12`,`30`,`31`,`32`,`48`,`60`,`61`,`62`), so box 48
is signed like the blankett (ingående moms negative); underlag boxes do not
diff --git a/docs/STATE.md b/docs/STATE.md
index 0f8a915..b158de8 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -100,8 +100,9 @@ server/protocol/ledger only.
3. K2 årsredovisning document + SRU files (INK2/INK2R/INK2S).
4. `audit.verify` must also verify the **voucher** hash chain (today only the
audit chain is verified).
-5. `report.general_ledger` and `report.voucher_list` (documented, not
- implemented).
+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`.
6. `describe` argument schemas (currently name/summary/permission only).
7. Pre-migration `VACUUM INTO` snapshot (promised in SCHEMA.md, not built).
8. ~~Docker image + compose (multi-arch amd64/arm64, GHCR) and systemd unit.~~
@@ -134,11 +135,12 @@ server/protocol/ledger only.
(schema v1, old binary) — recreate or migrate it with the current build if
it is still wanted.
- TUI smoke tests: drive over a pty with `script -qec`; function-key escape
- sequences are timing-sensitive there (not an app bug). `Ctrl+N/C/F` are
- single bytes and reliable. Always wrap the run in
- `scripts/tui-sandbox.sh -- ./build/bokftui ...`: it isolates
- `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` so a test can never overwrite the real
- `~/.config/bokf/tui.conf` or `~/.cache/bokf/tui.log`.
+ sequences are timing-sensitive there (not an app bug). Arrows arrive as
+ application-mode sequences (`ESC O B` for Down), not `ESC [ B`, because
+ curses enables the keypad. `Ctrl+N/C/F` are single bytes and reliable.
+ Always wrap the run in `scripts/tui-sandbox.sh -- ./build/bokftui ...`: it
+ isolates `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` so a test can never overwrite
+ the real `~/.config/bokf/tui.conf` or `~/.cache/bokf/tui.log`.
## Known caveats
diff --git a/src/commands.c b/src/commands.c
index 64212f7..1034b31 100644
--- a/src/commands.c
+++ b/src/commands.c
@@ -1842,6 +1842,41 @@ static yyjson_mut_val *h_fiscal_year_close(struct req *r)
return yyjson_mut_obj(r->rdoc);
}
+static yyjson_mut_val *h_fiscal_year_reopen(struct req *r)
+{
+ int64_t id = 0;
+ int confirm = 0;
+ arg_int(r->args, "id", &id);
+ arg_bool(r->args, "confirm", &confirm);
+ if (id <= 0 || !confirm)
+ return fail(r, "INVALID_ARGS", "id and confirm:true are required");
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE fiscal_years SET status='open', closed_at=NULL,"
+ " closed_by=NULL WHERE org_id=?1 AND id=?2 AND status='closed'",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "NOT_FOUND", "closed fiscal year not found");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "fiscal_year.reopen", reqjson, "OK", NULL);
+ free(reqjson);
+ return yyjson_mut_obj(r->rdoc);
+}
+
static yyjson_mut_val *h_period_lock(struct req *r)
{
int64_t fy = 0;
@@ -3529,6 +3564,42 @@ static yyjson_mut_val *h_report_vat(struct req *r)
return res;
}
+static yyjson_mut_val *h_report_general_ledger(struct req *r)
+{
+ int64_t fy = 0;
+ if (req_fy(r, &fy) != 0)
+ return NULL;
+ yyjson_val *accounts = yyjson_obj_get(r->args, "accounts");
+ if (accounts && !yyjson_is_arr(accounts))
+ return fail(r, "INVALID_ARGS", "accounts must be an array of strings");
+ char *err = NULL;
+ yyjson_mut_val *res = report_general_ledger(
+ r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "from"),
+ arg_str(r->args, "to"), accounts, &err);
+ if (!res) {
+ yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed");
+ free(err);
+ return e;
+ }
+ return res;
+}
+
+static yyjson_mut_val *h_report_voucher_list(struct req *r)
+{
+ int64_t fy = 0;
+ if (req_fy(r, &fy) != 0)
+ return NULL;
+ char *err = NULL;
+ yyjson_mut_val *res = report_voucher_list(
+ r->rdoc, r->db, r->org_id, fy, arg_str(r->args, "series"), &err);
+ if (!res) {
+ yyjson_mut_val *e = fail(r, "INTERNAL", err ? err : "report failed");
+ free(err);
+ return e;
+ }
+ return res;
+}
+
static unsigned char *read_file(const char *path, size_t *out_len)
{
FILE *f = fopen(path, "rb");
@@ -3723,8 +3794,10 @@ const struct command g_commands[] = {
h_fiscal_year_get },
{ "fiscal_year.open", "Open a new fiscal year", PERM_OWNER, 1, 1, 1,
h_fiscal_year_open },
- { "fiscal_year.close", "Close a fiscal year (irreversible)", PERM_OWNER, 1,
- 1, 0, h_fiscal_year_close },
+ { "fiscal_year.close", "Close a fiscal year", PERM_OWNER, 1, 1, 0,
+ h_fiscal_year_close },
+ { "fiscal_year.reopen", "Reopen a closed fiscal year", PERM_OWNER, 1, 1, 1,
+ h_fiscal_year_reopen },
{ "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1,
h_period_lock },
{ "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1,
@@ -3764,6 +3837,10 @@ const struct command g_commands[] = {
h_report_balance_sheet },
{ "report.vat", "Momsdeklaration ruta för ruta", PERM_READ, 1, 0, 0,
h_report_vat },
+ { "report.general_ledger", "Huvudbok", PERM_READ, 1, 0, 0,
+ h_report_general_ledger },
+ { "report.voucher_list", "Verifikationslista", PERM_READ, 1, 0, 0,
+ h_report_voucher_list },
{ "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export },
{ "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1,
1, h_sie_import },
diff --git a/src/reports.c b/src/reports.c
index ee2bbe1..11c65f2 100644
--- a/src/reports.c
+++ b/src/reports.c
@@ -471,3 +471,232 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
" before filing");
return res;
}
+
+/* ---------------- huvudbok (general ledger) ---------------- */
+
+static int str_in_arr(yyjson_val *arr, const char *s)
+{
+ if (!arr)
+ return 1;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *v = yyjson_arr_get(arr, i);
+ if (v && yyjson_is_str(v) && strcmp(yyjson_get_str(v), s) == 0)
+ return 1;
+ }
+ return 0;
+}
+
+static yyjson_mut_val *last_voucher_json(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id)
+{
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT series,number FROM vouchers"
+ " WHERE org_id=?1 AND fiscal_year_id=?2"
+ " ORDER BY series DESC, number DESC LIMIT 1",
+ -1, &st, NULL) != SQLITE_OK)
+ return NULL;
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, fy_id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW) {
+ o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_strcpy(doc, o, "series",
+ (const char *)sqlite3_column_text(st, 0));
+ yyjson_mut_obj_add_int(doc, o, "number", sqlite3_column_int64(st, 1));
+ }
+ sqlite3_finalize(st);
+ return o;
+}
+
+yyjson_mut_val *report_general_ledger(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *from, const char *to,
+ yyjson_val *accounts, char **err)
+{
+ struct fy_info fy;
+ if (load_fy(db, org_id, fy_id, &fy, err) != 0)
+ return NULL;
+ if (!from)
+ from = fy.start;
+ if (!to)
+ to = fy.end;
+ yyjson_mut_val *bal =
+ balance_query(doc, db, org_id, fy.id, from, to, NULL, err);
+ if (!bal)
+ return NULL;
+ sqlite3_stmt *rs = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT v.series,v.number,v.date,COALESCE(v.description,''),"
+ "r.debit_ore,r.credit_ore,COALESCE(r.description,'')"
+ " FROM voucher_rows r"
+ " JOIN vouchers v ON v.org_id=r.org_id AND v.id=r.voucher_id"
+ " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
+ " WHERE r.org_id=?1 AND a.number=?2 AND v.fiscal_year_id=?3"
+ " AND v.series <> 'IB' AND v.date BETWEEN ?4 AND ?5"
+ " ORDER BY v.date,v.series,v.number,r.id",
+ -1, &rs, NULL) != SQLITE_OK) {
+ set_err(err, "database error");
+ return NULL;
+ }
+ yyjson_mut_val *out = yyjson_mut_arr(doc);
+ size_t nbal = yyjson_mut_arr_size(bal);
+ for (size_t i = 0; i < nbal; i++) {
+ yyjson_mut_val *row = yyjson_mut_arr_get(bal, i);
+ const char *acc =
+ yyjson_mut_get_str(yyjson_mut_obj_get(row, "account"));
+ if (!acc || !*acc || !str_in_arr(accounts, acc))
+ continue;
+ int64_t ib = yyjson_mut_get_int(yyjson_mut_obj_get(row, "ib_ore"));
+ int64_t d = yyjson_mut_get_int(yyjson_mut_obj_get(row, "debit_ore"));
+ int64_t c = yyjson_mut_get_int(yyjson_mut_obj_get(row, "credit_ore"));
+ if (ib == 0 && d == 0 && c == 0)
+ continue;
+ const char *name =
+ yyjson_mut_get_str(yyjson_mut_obj_get(row, "name"));
+ yyjson_mut_val *ao = yyjson_mut_arr_add_obj(doc, out);
+ yyjson_mut_obj_add_strcpy(doc, ao, "account", acc);
+ yyjson_mut_obj_add_strcpy(doc, ao, "name", name ? name : "");
+ yyjson_mut_obj_add_int(doc, ao, "ib_ore", ib);
+ yyjson_mut_val *rows = yyjson_mut_arr(doc);
+ int64_t saldo = ib;
+ sqlite3_bind_int64(rs, 1, org_id);
+ sqlite3_bind_text(rs, 2, acc, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(rs, 3, fy.id);
+ sqlite3_bind_text(rs, 4, from, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_text(rs, 5, to, -1, SQLITE_TRANSIENT);
+ while (sqlite3_step(rs) == SQLITE_ROW) {
+ int64_t rd = sqlite3_column_int64(rs, 4);
+ int64_t rc = sqlite3_column_int64(rs, 5);
+ saldo += rd - rc;
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(doc, rows);
+ yyjson_mut_obj_add_strcpy(
+ doc, ro, "series",
+ (const char *)sqlite3_column_text(rs, 0));
+ yyjson_mut_obj_add_int(doc, ro, "number",
+ sqlite3_column_int64(rs, 1));
+ yyjson_mut_obj_add_strcpy(
+ doc, ro, "date",
+ (const char *)sqlite3_column_text(rs, 2));
+ yyjson_mut_obj_add_strcpy(
+ doc, ro, "description",
+ (const char *)sqlite3_column_text(rs, 3));
+ yyjson_mut_obj_add_int(doc, ro, "debit_ore", rd);
+ yyjson_mut_obj_add_int(doc, ro, "credit_ore", rc);
+ yyjson_mut_obj_add_strcpy(
+ doc, ro, "row_description",
+ (const char *)sqlite3_column_text(rs, 6));
+ yyjson_mut_obj_add_int(doc, ro, "saldo_ore", saldo);
+ }
+ sqlite3_reset(rs);
+ yyjson_mut_obj_add_val(doc, ao, "rows", rows);
+ yyjson_mut_obj_add_int(doc, ao, "debit_ore", d);
+ yyjson_mut_obj_add_int(doc, ao, "credit_ore", c);
+ yyjson_mut_obj_add_int(doc, ao, "ub_ore", ib + d - c);
+ }
+ sqlite3_finalize(rs);
+
+ yyjson_mut_val *o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, o, "fiscal_year", fy_json(doc, &fy));
+ yyjson_mut_obj_add_strcpy(doc, o, "from", from);
+ yyjson_mut_obj_add_strcpy(doc, o, "to", to);
+ yyjson_mut_val *lv = last_voucher_json(doc, db, org_id, fy_id);
+ if (lv)
+ yyjson_mut_obj_add_val(doc, o, "last_voucher", lv);
+ yyjson_mut_obj_add_val(doc, o, "accounts", out);
+ return o;
+}
+
+/* ---------------- verifikationslista (voucher list) ---------------- */
+
+yyjson_mut_val *report_voucher_list(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *series, char **err)
+{
+ struct fy_info fy;
+ if (load_fy(db, org_id, fy_id, &fy, err) != 0)
+ return NULL;
+ char sql[256];
+ snprintf(sql, sizeof sql,
+ "SELECT id,series,number,date,COALESCE(description,'')"
+ " FROM vouchers WHERE org_id=?1 AND fiscal_year_id=?2%s"
+ " ORDER BY series,number",
+ series ? " AND series=?3" : "");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
+ set_err(err, "database error");
+ return NULL;
+ }
+ sqlite3_bind_int64(st, 1, org_id);
+ sqlite3_bind_int64(st, 2, fy.id);
+ if (series)
+ sqlite3_bind_text(st, 3, series, -1, SQLITE_TRANSIENT);
+ sqlite3_stmt *rs = NULL;
+ if (sqlite3_prepare_v2(
+ db,
+ "SELECT a.number,a.name,r.debit_ore,r.credit_ore,"
+ "COALESCE(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.id",
+ -1, &rs, NULL) != SQLITE_OK) {
+ sqlite3_finalize(st);
+ set_err(err, "database error");
+ return NULL;
+ }
+ yyjson_mut_val *vouchers = yyjson_mut_arr(doc);
+ int64_t t_debit = 0, t_credit = 0;
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ int64_t vid = sqlite3_column_int64(st, 0);
+ yyjson_mut_val *vo = yyjson_mut_arr_add_obj(doc, vouchers);
+ yyjson_mut_obj_add_int(doc, vo, "id", vid);
+ yyjson_mut_obj_add_strcpy(doc, vo, "series",
+ (const char *)sqlite3_column_text(st, 1));
+ yyjson_mut_obj_add_int(doc, vo, "number", sqlite3_column_int64(st, 2));
+ yyjson_mut_obj_add_strcpy(doc, vo, "date",
+ (const char *)sqlite3_column_text(st, 3));
+ yyjson_mut_obj_add_strcpy(doc, vo, "description",
+ (const char *)sqlite3_column_text(st, 4));
+ yyjson_mut_val *vrows = yyjson_mut_arr(doc);
+ sqlite3_bind_int64(rs, 1, org_id);
+ sqlite3_bind_int64(rs, 2, vid);
+ while (sqlite3_step(rs) == SQLITE_ROW) {
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(doc, vrows);
+ yyjson_mut_obj_add_strcpy(
+ doc, ro, "account",
+ (const char *)sqlite3_column_text(rs, 0));
+ yyjson_mut_obj_add_strcpy(
+ doc, ro, "name",
+ (const char *)sqlite3_column_text(rs, 1));
+ int64_t rd = sqlite3_column_int64(rs, 2);
+ int64_t rc = sqlite3_column_int64(rs, 3);
+ yyjson_mut_obj_add_int(doc, ro, "debit_ore", rd);
+ yyjson_mut_obj_add_int(doc, ro, "credit_ore", rc);
+ yyjson_mut_obj_add_strcpy(
+ doc, ro, "description",
+ (const char *)sqlite3_column_text(rs, 4));
+ t_debit += rd;
+ t_credit += rc;
+ }
+ sqlite3_reset(rs);
+ yyjson_mut_obj_add_val(doc, vo, "rows", vrows);
+ }
+ sqlite3_finalize(rs);
+ sqlite3_finalize(st);
+
+ yyjson_mut_val *o = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_val(doc, o, "fiscal_year", fy_json(doc, &fy));
+ yyjson_mut_obj_add_strcpy(doc, o, "from", fy.start);
+ yyjson_mut_obj_add_strcpy(doc, o, "to", fy.end);
+ yyjson_mut_val *lv = last_voucher_json(doc, db, org_id, fy_id);
+ if (lv)
+ yyjson_mut_obj_add_val(doc, o, "last_voucher", lv);
+ yyjson_mut_obj_add_val(doc, o, "vouchers", vouchers);
+ yyjson_mut_val *tot = yyjson_mut_obj(doc);
+ yyjson_mut_obj_add_int(doc, tot, "debit_ore", t_debit);
+ yyjson_mut_obj_add_int(doc, tot, "credit_ore", t_credit);
+ yyjson_mut_obj_add_val(doc, o, "totals", tot);
+ return o;
+}
diff --git a/src/reports.h b/src/reports.h
index 37d5f67..d4a0545 100644
--- a/src/reports.h
+++ b/src/reports.h
@@ -19,5 +19,12 @@ yyjson_mut_val *report_balance_sheet(yyjson_mut_doc *doc, sqlite3 *db,
const char *to, char **err);
yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
const char *from, const char *to, char **err);
+yyjson_mut_val *report_general_ledger(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *from, const char *to,
+ yyjson_val *accounts, char **err);
+yyjson_mut_val *report_voucher_list(yyjson_mut_doc *doc, sqlite3 *db,
+ int64_t org_id, int64_t fy_id,
+ const char *series, char **err);
#endif
diff --git a/tests/test_core.c b/tests/test_core.c
index 27948d6..5740db3 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -771,6 +771,55 @@ int main(void)
CHECK(vat_box(d, "10") == 25000);
yyjson_doc_free(d);
+ /* huvudbok: one account block per account with activity or IB */
+ d = call(reqf("{\"v\":1,\"id\":\"54a\",\"cmd\":\"report.general_ledger\""
+ ",\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"fiscal_year\":%lld}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.accounts")) == 3);
+ CHECK(find_amount(d, "result.accounts", "account", "1930", "debit_ore") ==
+ 125000);
+ CHECK(find_amount(d, "result.accounts", "account", "1930", "ub_ore") ==
+ 125000);
+ CHECK(find_amount(d, "result.accounts", "account", "3001", "ub_ore") ==
+ -100000);
+ CHECK(yyjson_arr_size(jget(d, "result.accounts.0.rows")) == 1);
+ CHECK(jint(d, "result.accounts.0.rows.0.saldo_ore") == 125000);
+ CHECK_STR(d, "result.last_voucher.series", "A");
+ CHECK(jint(d, "result.last_voucher.number") == 1);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"54b\",\"cmd\":\"report.general_ledger\""
+ ",\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"fiscal_year\":%lld,\"accounts\":[\"1930\"]}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.accounts")) == 1);
+ yyjson_doc_free(d);
+
+ /* verifikationslista: vouchers with their rows and totals */
+ d = call(reqf("{\"v\":1,\"id\":\"54c\",\"cmd\":\"report.voucher_list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"fiscal_year\":%lld}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 1);
+ CHECK_STR(d, "result.vouchers.0.series", "A");
+ CHECK(jint(d, "result.vouchers.0.number") == 1);
+ CHECK(yyjson_arr_size(jget(d, "result.vouchers.0.rows")) == 3);
+ CHECK(jint(d, "result.totals.debit_ore") == 125000);
+ CHECK(jint(d, "result.totals.credit_ore") == 125000);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"54d\",\"cmd\":\"report.voucher_list\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"fiscal_year\":%lld,\"series\":\"ZZ\"}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 0);
+ yyjson_doc_free(d);
+
d = call(reqf("{\"v\":1,\"id\":\"54\",\"cmd\":\"report.trial_balance\","
"\"session\":\"%s\",\"org\":%d}",
g_session, (int)org_id));
@@ -1389,6 +1438,45 @@ int main(void)
CHECK_STR(d, "error.code", "FISCAL_YEAR_CLOSED");
yyjson_doc_free(d);
+ /* reopen is the owner-only undo */
+ d = call(reqf("{\"v\":1,\"id\":\"76\",\"cmd\":\"fiscal_year.reopen\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,"
+ "\"args\":{\"id\":%lld,\"confirm\":true}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"77\",\"cmd\":\"fiscal_year.reopen\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"confirm\":true}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"78\",\"cmd\":\"fiscal_year.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.status", "open");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"79\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,"
+ "\"args\":{\"date\":\"2026-03-01\",\"description\":\"Öppet\","
+ "\"rows\":[{\"account\":\"1930\",\"debit_ore\":100},"
+ "{\"account\":\"3001\",\"credit_ore\":100}]}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"79b\",\"cmd\":\"fiscal_year.reopen\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"confirm\":true}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_STR(d, "error.code", "NOT_FOUND");
+ yyjson_doc_free(d);
+
d = call(reqf("{\"v\":1,\"id\":\"75\",\"cmd\":\"audit.verify\","
"\"session\":\"%s\"}",
g_session));