aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-22 21:53:13 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-22 21:53:13 +0200
commitbbdbb32c28a7b37da8caee60cd3756720fe88f4f (patch)
tree296073e6ab0bacef67a6cab1660f6b64b74f3085
parent8e3ff5a7ae7cb5e43b6eed3be8d77f2997888669 (diff)
downloadbokf-bbdbb32c28a7b37da8caee60cd3756720fe88f4f.tar.gz
bokf-bbdbb32c28a7b37da8caee60cd3756720fe88f4f.zip
reports: ignore IB rows on P&L accounts; bokfctl --dry-runv0.1.66
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--clients/bokfctl.c26
-rw-r--r--docs/PROTOCOL.md5
-rw-r--r--docs/STATE.md7
-rw-r--r--src/reports.h15
-rw-r--r--tests/test_core.c21
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)