diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-22 19:52:19 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-22 19:52:19 +0200 |
| commit | e1a24f6250f5e498e524ce14a5acbed5fc5c2a91 (patch) | |
| tree | 6583ad2bf0e22bf83e37dc135ce663cb97a104af | |
| parent | 4dee87e83257c81413b29ccb33989e4df109a61c (diff) | |
| download | bokf-e1a24f6250f5e498e524ce14a5acbed5fc5c2a91.tar.gz bokf-e1a24f6250f5e498e524ce14a5acbed5fc5c2a91.zip | |
reports: income statement skips imported Stäng closings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/DECISIONS.md | 10 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 6 | ||||
| -rw-r--r-- | docs/STATE.md | 14 | ||||
| -rw-r--r-- | src/reports.c | 25 | ||||
| -rw-r--r-- | tests/test_core.c | 109 |
5 files changed, 154 insertions, 10 deletions
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a0c613b..a109bda 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -333,7 +333,15 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21). statement (Kapitas 2022-2026). **Decided 2026-09-19: no importer change.** Locked years stay locked and the source's closings stay in the books; the årsredovisning export flags incomplete jämförelsetal for those years and - points to the previous year's annual report. + points to the previous year's annual report. **Amended 2026-09-22**: the + income statement (and therefore INK2/SRU and the TUI resultatrapport) now + skips the same "Stäng ..." vouchers, so a year being declared shows its + real figures; the balance sheet keeps them (the result sits in 2099 and + must not be counted twice). The source's own #IB/#UB corrections that the + vouchers do not reproduce (e.g. Makandra's FY 2022/2023 result transfer + is 17 857.63 kr short of that year's P&L) remain a data divergence: the + derived balance sheet is off by that amount until a correction is booked + in the current year. 11. ~~SIE import only into an empty fiscal year; consider broader import.~~ Chronological multi-year import works (CRLF, `#RAR 0`, zero rows, `#IB` rule handled); each year must still target an empty fiscal year. Note: diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 081eb6a..7d7a5e2 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -451,7 +451,11 @@ linked to that voucher and each item carries that `voucher_id`. | `sru.export` | `fiscal_year`, `adjustments?`, `submitter?`, `assisted?`, `audited?`, `ignore_unmapped?` | `INFO.SRU` + `BLANKETTER.SRU` (ISO-8859-1, base64) | All reports are pure reads, respect locks, and return JSON rows. Amounts are -öre. `report.general_ledger` (huvudbok) returns account blocks: +öre. `report.income_statement` (and therefore `sru.export` and the TUI +resultatrapport) ignores the source system's `"Stäng ..."` closing vouchers +in imported history years, where the P&L accounts are closed straight to +2099 and would otherwise net to zero; the K2 report uses the same rule. +`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", diff --git a/docs/STATE.md b/docs/STATE.md index 4d448fe..19f22f6 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -14,6 +14,20 @@ unit tests and the docs consistency check. ## Resume here (2026-09-22) +- **Deklaration 2025/2026 (org 2, 2026-09-22)**: the imported year is closed + by the source's `Stäng intäktskonton/kostnadskonton`, so the resultatrapport + (and the INK2/SRU derived from it) showed 0. `report.income_statement` now + skips those vouchers (branch `eff/imported-closings`), which gives + **241 817,48 kr** resultat efter skatt för 2025/2026. In the TUI the + "Bokfört resultat" section is absent for such years (the close goes straight + to 2099). Two **data drifts** to settle with the accountant before filing: + the imported FY 2022/2023 result transfer is **17 857,63 kr** short of that + year's P&L (its tax was never booked as an expense), so the derived balance + sheet does not balance by that amount; and 2099 is 1,07 kr off the P&L + result. Locked years stay locked — a correction belongs in the open year + (2026/2027). The owner runs Bokslutshubben → Inkomstdeklaration after the + daemon is redeployed with the report fix. + - **Deployed**: `v0.1.64`, healthy on `nas` (Alpine runtime, static aarch64 binaries cross-compiled on this machine — ~20 s, the host only assembles the image; image 33.7 MB, no `libssl3`). `v0.1.55` brought the payroll diff --git a/src/reports.c b/src/reports.c index 2ca7e45..0316abf 100644 --- a/src/reports.c +++ b/src/reports.c @@ -60,11 +60,15 @@ static yyjson_mut_val *fy_json(yyjson_mut_doc *doc, const struct fy_info *fy) /* One row per account with IB and period movements. IB is the series "IB" voucher(s) of this fiscal year plus all non-IB history before `from`. - Amounts signed: debit positive. */ + skip_closings drops the source system's "Stäng ..." closing vouchers from + the period movements (imported years close the P&L accounts straight to + 2099, so the year otherwise nets to zero); the K2 report and the SRU + export use the same convention. Amounts signed: debit positive. */ static yyjson_mut_val *balance_query(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, int64_t fy_id, const char *from, const char *to, - const char *types_filter, char **err) + const char *types_filter, + int skip_closings, char **err) { char ib_series[16]; db_setting_copy(db, org_id, "series_ib", "IB", ib_series, @@ -73,8 +77,10 @@ static yyjson_mut_val *balance_query(yyjson_mut_doc *doc, sqlite3 *db, snprintf(sql, sizeof sql, "SELECT a.number,a.name,a.type," " COALESCE(SUM(CASE WHEN v.series <> 'IB' AND v.series <> ?5" + " AND (COALESCE(v.description,'') NOT LIKE 'Stäng%%' OR ?6 = 0)" " AND v.date BETWEEN ?2 AND ?3 THEN r.debit_ore END),0)," " COALESCE(SUM(CASE WHEN v.series <> 'IB' AND v.series <> ?5" + " AND (COALESCE(v.description,'') NOT LIKE 'Stäng%%' OR ?6 = 0)" " AND v.date BETWEEN ?2 AND ?3 THEN r.credit_ore END),0)," " COALESCE(SUM(CASE WHEN ((v.series = 'IB' OR v.series = ?5)" " AND v.fiscal_year_id = ?4) OR (v.series <> 'IB'" @@ -99,6 +105,7 @@ static yyjson_mut_val *balance_query(yyjson_mut_doc *doc, sqlite3 *db, sqlite3_bind_text(st, 3, to, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 4, fy_id); sqlite3_bind_text(st, 5, ib_series, -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 6, skip_closings); yyjson_mut_val *arr = yyjson_mut_arr(doc); while (sqlite3_step(st) == SQLITE_ROW) { const char *number = (const char *)sqlite3_column_text(st, 0); @@ -133,7 +140,8 @@ yyjson_mut_val *report_trial_balance(yyjson_mut_doc *doc, sqlite3 *db, if (!to) to = fy.end; - yyjson_mut_val *rows = balance_query(doc, db, org_id, fy.id, from, to, NULL, err); + yyjson_mut_val *rows = balance_query(doc, db, org_id, fy.id, from, to, NULL, + 0, err); if (!rows) return NULL; @@ -192,7 +200,7 @@ yyjson_mut_val *report_income_statement(yyjson_mut_doc *doc, sqlite3 *db, yyjson_mut_val *rows = balance_query( doc, db, org_id, fy.id, from, to, - " AND a.type IN ('revenue','expense')", err); + " AND a.type IN ('revenue','expense')", 1, err); if (!rows) return NULL; @@ -249,7 +257,7 @@ yyjson_mut_val *report_balance_sheet(yyjson_mut_doc *doc, sqlite3 *db, yyjson_mut_val *rows = balance_query( doc, db, org_id, fy.id, fy.start, to, - " AND a.type IN ('asset','liability','equity')", err); + " AND a.type IN ('asset','liability','equity')", 0, err); if (!rows) return NULL; @@ -285,10 +293,11 @@ yyjson_mut_val *report_balance_sheet(yyjson_mut_doc *doc, sqlite3 *db, yyjson_mut_arr_add_val(*target, copy); } - /* current year result belongs to equity */ + /* current year result belongs to equity; the closings stay included so a + transferred result (2099) does not get counted twice */ yyjson_mut_val *inc = balance_query( doc, db, org_id, fy.id, fy.start, to, - " AND a.type IN ('revenue','expense')", err); + " AND a.type IN ('revenue','expense')", 0, err); int64_t result = 0; if (inc) { size_t m = yyjson_mut_arr_size(inc); @@ -543,7 +552,7 @@ yyjson_mut_val *report_general_ledger(yyjson_mut_doc *doc, sqlite3 *db, if (!to) to = fy.end; yyjson_mut_val *bal = - balance_query(doc, db, org_id, fy.id, from, to, NULL, err); + balance_query(doc, db, org_id, fy.id, from, to, NULL, 0, err); if (!bal) return NULL; sqlite3_stmt *rs = NULL; diff --git a/tests/test_core.c b/tests/test_core.c index a000ffe..1d86b2b 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -1561,6 +1561,114 @@ static void test_reports(struct tctx *t) yyjson_doc_free(d); } +static void test_imported_closings(struct tctx *t) +{ + yyjson_doc *d; + int64_t org = 0; + + (void)t; + d = call(reqf("{\"v\":1,\"id\":\"ic1\",\"cmd\":\"org.create\"," + "\"session\":\"%s\",\"args\":{\"name\":\"Import AB\"}}", + g_session)); + CHECK_OK(d); + org = jint(d, "result.id"); + CHECK(org > 0); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ic2\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-02-01\",\"description\":\"Försäljning\",\"rows\":[" + "{\"account\":\"1930\",\"debit_ore\":125000}," + "{\"account\":\"3001\",\"credit_ore\":100000}," + "{\"account\":\"2611\",\"credit_ore\":25000}]}}", + g_session, (int)org)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ic3\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-02-02\",\"description\":\"Inköp\",\"rows\":[" + "{\"account\":\"5410\",\"debit_ore\":20000}," + "{\"account\":\"2640\",\"debit_ore\":5000}," + "{\"account\":\"1930\",\"credit_ore\":25000}]}}", + g_session, (int)org)); + CHECK_OK(d); + yyjson_doc_free(d); + + /* The source system closes the P&L accounts straight to 2099; the income + statement must still show the year's real figures. */ + d = call(reqf("{\"v\":1,\"id\":\"ic4\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-04-30\",\"description\":\"Stäng intäktskonton\"," + "\"rows\":[{\"account\":\"3001\",\"debit_ore\":100000}," + "{\"account\":\"2099\",\"credit_ore\":100000}]}}", + g_session, (int)org)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ic5\",\"cmd\":\"voucher.post\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":" + "\"2026-04-30\",\"description\":\"Stäng kostnadskonton\"," + "\"rows\":[{\"account\":\"2099\",\"debit_ore\":20000}," + "{\"account\":\"5410\",\"credit_ore\":20000}]}}", + g_session, (int)org)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ic6\",\"cmd\":\"report.income_statement\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)org)); + CHECK_OK(d); + CHECK(jint(d, "result.result_ore") == 80000); + CHECK(find_amount(d, "result.revenue.accounts", "account", "3001", + "amount_ore") == 100000); + CHECK(find_amount(d, "result.expenses.accounts", "account", "5410", + "amount_ore") == 20000); + yyjson_doc_free(d); + + /* The result sits in 2099, so the balance sheet must not count it twice. */ + d = call(reqf("{\"v\":1,\"id\":\"ic7\",\"cmd\":\"report.balance_sheet\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)org)); + CHECK_OK(d); + /* 2640 is chart-typed as a liability (debit balance shows negative) */ + CHECK(jint(d, "result.assets.total_ore") == 100000); + CHECK(jint(d, "result.liabilities.total_ore") == 20000); + CHECK(jint(d, "result.equity.total_ore") == 80000); + CHECK(jint(d, "result.assets.total_ore") == + jint(d, "result.liabilities.total_ore") + + jint(d, "result.equity.total_ore")); + CHECK(find_amount(d, "result.equity.accounts", "account", "2099", + "amount_ore") == 80000); + yyjson_doc_free(d); + + /* the declaration path: the SRU result must not be zeroed either */ + d = call(reqf("{\"v\":1,\"id\":\"ic8\",\"cmd\":\"org.update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"org_nr\":\"559331-2126\",\"postal_code\":\"192 48\"," + "\"city\":\"Sollentuna\"}}", + g_session, (int)org)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"ic9\",\"cmd\":\"sru.export\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{}}", + g_session, (int)org)); + CHECK_OK(d); + const char *sb64 = jstr(d, "result.blanketter.content_base64"); + unsigned char *srub = NULL; + size_t srul = 0; + CHECK(sb64 && util_b64_decode(sb64, strlen(sb64), &srub, &srul) == 0); + char *sru = xmalloc(srul + 1); + memcpy(sru, srub, srul); + sru[srul] = '\0'; + free(srub); + CHECK(strstr(sru, "#UPPGIFT 7450 800") != NULL); + CHECK(strstr(sru, "#UPPGIFT 7650 800") != NULL); + free(sru); + yyjson_doc_free(d); +} + static void test_general_ledger(struct tctx *t) { yyjson_doc *d; @@ -5494,6 +5602,7 @@ static const struct ttest TESTS[] = { { "vouchers", test_vouchers, "org_members" }, { "voucher_errors", test_voucher_errors, "org_members" }, { "reports", test_reports, "vouchers" }, + { "imported_closings", test_imported_closings, "org_members" }, { "general_ledger", test_general_ledger, "vouchers" }, { "voucher_list", test_voucher_list, "vouchers" }, { "vat_eskd", test_vat_eskd, "vouchers" }, |
