From bbdbb32c28a7b37da8caee60cd3756720fe88f4f Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Tue, 22 Sep 2026 21:53:13 +0200 Subject: reports: ignore IB rows on P&L accounts; bokfctl --dry-run Co-Authored-By: Claude Opus 5.5 --- clients/bokfctl.c | 26 ++++++++++++++++++++++++++ docs/PROTOCOL.md | 5 +++-- docs/STATE.md | 7 +++++++ src/reports.h | 15 ++++++++------- tests/test_core.c | 21 +++++++++++++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/clients/bokfctl.c b/clients/bokfctl.c index b5ecf47..47e42ac 100644 --- a/clients/bokfctl.c +++ b/clients/bokfctl.c @@ -19,6 +19,7 @@ static void usage(void) " --password PW login password (env BOKFD_PASSWORD)\n" " --token TOKEN API token instead of user/password (env BOKFD_TOKEN)\n" " --org ID active org for this request\n" + " --dry-run validate a mutating command without writing\n" " --version\n" "\n" "examples:\n" @@ -44,6 +45,7 @@ int main(int argc, char **argv) int64_t org = 0; const char *cmd = NULL; const char *args_json = NULL; + int dry_run = 0; for (int i = 1; i < argc; i++) { const char *a = argv[i]; @@ -88,6 +90,8 @@ int main(int argc, char **argv) return 2; } org = strtoll(val, NULL, 10); + } else if (strcmp(a, "--dry-run") == 0) { + dry_run = 1; } else if (strcmp(a, "--version") == 0) { printf("bokfctl %s\n", BOKF_VERSION); return 0; @@ -160,6 +164,28 @@ int main(int argc, char **argv) } } free(session); + if (dry_run) { + yyjson_mut_doc *md = yyjson_mut_doc_new(NULL); + yyjson_doc *qd = yyjson_read(req, strlen(req), 0); + yyjson_mut_val *root = qd ? yyjson_val_mut_copy(md, yyjson_doc_get_root(qd)) + : NULL; + char *out = NULL; + if (root && yyjson_mut_is_obj(root)) { + yyjson_mut_obj_remove_key(root, "dry_run"); + yyjson_mut_obj_add_bool(md, root, "dry_run", true); + yyjson_mut_doc_set_root(md, root); + out = yyjson_mut_write(md, 0, NULL); + } + yyjson_doc_free(qd); + yyjson_mut_doc_free(md); + free(req); + if (!out) { + fprintf(stderr, "bokfctl: request must be a JSON object\n"); + client_close(&conn); + return 2; + } + req = out; + } if (client_send_line(&conn, req) != 0) { fprintf(stderr, "bokfctl: send failed: %s\n", client_last_error()); free(req); diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 75ae963..0b69d53 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -454,8 +454,9 @@ All reports are pure reads, respect locks, and return JSON rows. Amounts are öre. IB (ingående balans) for a balance account (asset, liability, equity) is all earlier history, including earlier years' `IB` vouchers, plus this year's `IB` vouchers; a P&L account (revenue, expense) restarts at zero at -every fiscal-year start, so its IB is only this year's `IB` vouchers and, -with a narrowed `from`, the year's movements before it. +every fiscal-year start, so its IB is only, with a narrowed `from`, the +year's movements before it; `IB` rows on P&L accounts (the balancing side of +an opening-balance adjustment) are ignored. `report.income_statement` (and therefore `sru.export` and the TUI resultatrapport) ignores the source system's `"Stäng ..."` closing vouchers in SIE-imported years (`source:"sie_import"`), where the P&L accounts are diff --git a/docs/STATE.md b/docs/STATE.md index 5e9dc04..754bd9b 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -100,6 +100,13 @@ unit tests and the docs consistency check. a 2650 row) and SIE-imported `Stäng` closings. Makandra's 2025/26 report was all zeros because V107 (Momsdeklaration) and V109 were counted; the rules themselves were fine. `test_core` has `vat_settlement`. +- **IB rows on P&L accounts (2026-09-22, not deployed)**: after `v0.1.65`, + Makandra's 2026/27 `IB 1` (an agent's delta from 2026-09-18, computed + against the old IB rule) showed its balancing P&L rows as P&L opening + balances and double-counted aktiekapital (2081 −50 000, 1940 25 000). + Reports now ignore `IB` rows on P&L accounts; the aktiekapital double + needs the IB delta 2081 D / 1940 K 25 000 in 2026/27 (dry-run ok, awaiting + the owner). `bokfctl --dry-run` sets the request's `dry_run`. - **Mail configuration**: Makandra AB (org 2) has **no** `smtp_*` settings in bokf, so `invoice.send` there is `SMTP_NOT_CONFIGURED` (fine if invoices are sent elsewhere — set them up when wanted). Mock AB (org 1) diff --git a/src/reports.h b/src/reports.h index 82c29e5..3b0e479 100644 --- a/src/reports.h +++ b/src/reports.h @@ -8,14 +8,15 @@ /* SQL condition: voucher row `r` of voucher `v` on account `a` belongs to the opening balance of fiscal year ?4 (org ?1) for a period starting ?2; ?5 is - the configured IB series. Balance accounts carry all earlier history, - including earlier years' IB vouchers; P&L accounts restart at the year - start, so only this year's IB vouchers and movements before ?2 count. */ + the configured IB series. Balance accounts carry all earlier history plus + the IB vouchers of this and earlier years; P&L accounts restart at the year + start, so only this year's movements before ?2 count and IB rows on them + (the balancing side of an IB adjustment) are ignored. */ #define REPORT_IB_ROW_SQL \ - "((v.series = 'IB' OR v.series = ?5) AND (v.fiscal_year_id = ?4" \ - " OR (v.date < (SELECT f.start_date FROM fiscal_years f" \ - " WHERE f.org_id = ?1 AND f.id = ?4)" \ - " AND a.type NOT IN ('revenue','expense'))))" \ + "((v.series = 'IB' OR v.series = ?5)" \ + " AND a.type NOT IN ('revenue','expense')" \ + " AND (v.fiscal_year_id = ?4 OR v.date < (SELECT f.start_date" \ + " FROM fiscal_years f WHERE f.org_id = ?1 AND f.id = ?4)))" \ " OR (v.series <> 'IB' AND v.series <> ?5 AND v.date < ?2" \ " AND (a.type NOT IN ('revenue','expense') OR v.date >=" \ " (SELECT f.start_date FROM fiscal_years f" \ diff --git a/tests/test_core.c b/tests/test_core.c index 54e73f5..c9c969e 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -2295,6 +2295,27 @@ static void test_ib_carry(struct tctx *t) CHECK(strstr(sbuf, "#UB 0 3001") == NULL); CHECK(strstr(sbuf, "#RES 0 1930") == NULL); } + + /* an IB adjustment balanced against a P&L account moves only the balance + side; the P&L account still opens at zero */ + d = call(reqf("{\"v\":1,\"id\":\"ibc7a\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"%d-01-01\",\"description\":\"IB-justering\"," + "\"series\":\"IB\",\"rows\":[" + "{\"account\":\"1930\",\"debit_ore\":700}," + "{\"account\":\"3001\",\"credit_ore\":700}]}}", + g_session, org, y)); + CHECK_OK(d); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"ibc7b\",\"cmd\":\"report.trial_balance\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"fiscal_year\":" + "%lld}}", + g_session, org, (long long)fy2)); + CHECK_OK(d); + CHECK(find_amount(d, "result.accounts", "account", "1930", "ib_ore") == + 100700); + CHECK(find_amount(d, "result.accounts", "account", "3001", "ib_ore") == 0); + yyjson_doc_free(d); } static void test_period_lock(struct tctx *t) -- cgit v1.3