aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/DECISIONS.md10
-rw-r--r--docs/PROTOCOL.md8
-rw-r--r--docs/STATE.md28
-rw-r--r--src/reports.c29
-rw-r--r--tests/test_core.c111
5 files changed, 168 insertions, 18 deletions
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index a0c613b..5499610 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 when they are SIE-imported, 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 d50f5ee..a28ff21 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -455,7 +455,13 @@ All reports are pure reads, respect locks, and return JSON rows. Amounts are
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. `report.general_ledger` (huvudbok) returns account blocks:
+with a narrowed `from`, the year's movements before it.
+`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
+closed straight to 2099 and would otherwise net to zero; the TUI
+årsredovisning 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 29fbada..db68efd 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 (merged to `main`, not deployed), 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
@@ -72,16 +86,16 @@ unit tests and the docs consistency check.
`TUI-GUIDELINES.md`: drafts of encrypted fields (an employee's
personnummer) and `Enter` on action rows. Spec in `TUI-GUIDELINES.md`
"Interaction model"; decisions in `DECISIONS.md` #28.
-- **IB carry-forward (2026-09-22, branch `eff/ib-carry`, not deployed)**:
- reports and `sie.export` now carry earlier years' `IB` vouchers into the
+- **IB carry-forward (2026-09-22, merged to `main`, not deployed)**:
+ reports and `sie.export` carry earlier years' `IB` vouchers into the
opening balance (Makandra's 2021 IB voucher held aktiekapital 2081/1940,
which vanished from every later year) and restart P&L accounts at each
fiscal-year start (they used to accumulate since 2021). SIE writes
- `#IB`/`#UB` only for balance accounts and `#RES` only for P&L. With it,
- Makandra's 2025/26 opening balance shows the real 17 857,63 kr gap from
- 2022/23 (result transfer short of the P&L) instead of hiding it.
- `test_core` has `ib_carry`. Touches the same `balance_query` SQL as
- `eff/imported-closings` — expect a small textual conflict when merging.
+ `#IB`/`#UB` only for balance accounts and `#RES` only for P&L. Makandra's
+ opening balances now show the 17 857,63 kr 2022/23 gap openly (balance
+ accounts' IB sums to that instead of 0). Merged together with
+ `eff/imported-closings`, whose "Stäng" skip now applies only to
+ `sie_import` vouchers. `test_core` has `ib_carry` and `imported_closings`.
- **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.c b/src/reports.c
index d8ed0d2..96b1344 100644
--- a/src/reports.c
+++ b/src/reports.c
@@ -59,12 +59,16 @@ 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 as defined by
- REPORT_IB_ROW_SQL.
- Amounts signed: debit positive. */
+ REPORT_IB_ROW_SQL. skip_closings drops the source system's "Stäng ..."
+ closing vouchers (SIE-imported only) from the period movements: imported
+ years close the P&L accounts straight to 2099, so the year otherwise nets
+ to zero; the TUI årsredovisning uses 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,12 @@ 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 (?6 = 0 OR v.source <> 'sie_import'"
+ " OR COALESCE(v.description,'') NOT LIKE 'Stäng%%')"
" 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 (?6 = 0 OR v.source <> 'sie_import'"
+ " OR COALESCE(v.description,'') NOT LIKE 'Stäng%%')"
" AND v.date BETWEEN ?2 AND ?3 THEN r.credit_ore END),0),"
" COALESCE(SUM(CASE WHEN " REPORT_IB_ROW_SQL
" THEN r.debit_ore END),0),"
@@ -97,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);
@@ -131,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;
@@ -190,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;
@@ -247,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;
@@ -283,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);
@@ -541,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 50dde15..018b0aa 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -1561,6 +1561,116 @@ 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);
+
+ /* The source system closes the P&L accounts straight to 2099; the income
+ statement must still show the year's real figures. The file is PC8
+ (CP437) like real exports: \x84 = ä, \x94 = ö. */
+ static const char sie[] =
+ "#FLAGGA 0\n"
+ "#FORMAT PC8\n"
+ "#SIETYP 4\n"
+ "#FNAMN \"Import AB\"\n"
+ "#RAR 0 20260101 20261231\n"
+ "#VER \"V\" \"1\" 20260201 \"F\x94rs\x84" "ljning\"\n"
+ "{\n#TRANS 1930 {} 1250.00\n#TRANS 3001 {} -1000.00\n"
+ "#TRANS 2611 {} -250.00\n}\n"
+ "#VER \"V\" \"2\" 20260202 \"Ink\x94" "p\"\n"
+ "{\n#TRANS 5410 {} 200.00\n#TRANS 2640 {} 50.00\n"
+ "#TRANS 1930 {} -250.00\n}\n"
+ "#VER \"V\" \"3\" 20260430 \"St\x84" "ng intäktskonton\"\n"
+ "{\n#TRANS 3001 {} 1000.00\n#TRANS 2099 {} -1000.00\n}\n"
+ "#VER \"V\" \"4\" 20260430 \"St\x84" "ng kostnadskonton\"\n"
+ "{\n#TRANS 2099 {} 200.00\n#TRANS 5410 {} -200.00\n}\n";
+ char *sie_b64 = util_b64((const unsigned char *)sie, strlen(sie));
+ d = call_sie_import(g_session, org, sie_b64, 0);
+ free(sie_b64);
+ CHECK_OK(d);
+ CHECK(jint(d, "result.vouchers") == 4);
+ 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);
+
+ /* a voucher entered in bokf is never skipped, whatever its text */
+ d = call(reqf("{\"v\":1,\"id\":\"ic10\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-05-02\",\"description\":\"Stängsel\",\"rows\":["
+ "{\"account\":\"1930\",\"debit_ore\":100},"
+ "{\"account\":\"3001\",\"credit_ore\":100}]}}",
+ g_session, (int)org));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"ic11\",\"cmd\":\"report.income_statement\","
+ "\"session\":\"%s\",\"org\":%d}",
+ g_session, (int)org));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.result_ore") == 80100);
+ yyjson_doc_free(d);
+}
+
static void test_general_ledger(struct tctx *t)
{
yyjson_doc *d;
@@ -5614,6 +5724,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" },