diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-17 23:02:41 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-17 23:02:41 +0200 |
| commit | 2a98cce8434f7ea27f777d9e6422a1eb92ba8be7 (patch) | |
| tree | ac380bc2e3e64f73adac50fbb63a7cbf1348e417 | |
| parent | 0241122e05ac0bd174d4ec43a5882397c146aa89 (diff) | |
| download | bokf-0.1.6.tar.gz bokf-0.1.6.zip | |
sie: import CRLF, #RAR 0, zero rows; skip #IB with prior historyv0.1.6
- tokenizer treats CR as whitespace; closing brace matches with CRLF
- only #RAR with year indicator 0 selects the fiscal year
- zero-amount #TRANS rows are dropped instead of violating the schema
- #IB becomes an IB voucher only when the year has no earlier history
| -rw-r--r-- | docs/PROTOCOL.md | 2 | ||||
| -rw-r--r-- | docs/STATE.md | 7 | ||||
| -rw-r--r-- | src/sie.c | 46 | ||||
| -rw-r--r-- | tests/test_core.c | 70 |
4 files changed, 114 insertions, 11 deletions
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 25c9250..f3d7cac 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -374,7 +374,7 @@ All reports are pure reads, respect locks, and return JSON rows. Amounts are | Command | Args | Result | |---|---|---| | `sie.export` | `fiscal_year`, `inline?` | by default writes `<export_dir>/<org>_<fy>.se` and returns `path`, `sha256`, `size`; `inline:true` also returns `content_base64` | -| `sie.import` | `content_base64` or `path`, `dry_run?` | creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year | +| `sie.import` | `content_base64` or `path`, `dry_run?` | one file per call; creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year; `#IB` becomes an `IB` voucher when the year has no earlier history, otherwise the earlier vouchers carry the balances | SIE 4 files are written in CP437 with PC8 format, `#SIETYP 4`, `#FNR`, `#ORGNR`, `#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. Import is the migration path diff --git a/docs/STATE.md b/docs/STATE.md index f4a563a..f130f14 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -95,7 +95,12 @@ server/protocol/ledger only. 9. Password change, user disable, TOTP. 10. Bank import/reconciliation (CSV first, then PSD2), invoicing/reskontra, AGI/payroll if employees. -11. SIE import only into an empty fiscal year; consider broader import. +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: + years whose source system kept corrected `#IB`/`#UB` that the vouchers + do not reproduce (like the Kapitas 2022-2026 books) diverge from the + source when imported as history; the latest year imports exactly. 12. TUI polish: horizontal scrolling in long text fields, bracketed paste. ## Environment / how to run @@ -9,6 +9,7 @@ #include <time.h> #include "ledger.h" +#include "log.h" #include "util.h" #include "version.h" @@ -266,7 +267,7 @@ static int tokenize(char *line, char *toks[], int max, int *out_n) int n = 0; char *p = line; while (*p) { - while (*p == ' ' || *p == '\t') + while (*p == ' ' || *p == '\t' || *p == '\r') p++; if (!*p) break; @@ -298,7 +299,7 @@ static int tokenize(char *line, char *toks[], int max, int *out_n) } } else { toks[n++] = p; - while (*p && *p != ' ' && *p != '\t') + while (*p && *p != ' ' && *p != '\t' && *p != '\r') p++; if (*p) *p++ = '\0'; @@ -603,15 +604,17 @@ int sie_import_content(sqlite3 *db, int64_t org_id, int64_t user_id, continue; } if (toks[0][0] != '#') { - if (in_ver && strcmp(toks[0], "}") == 0) + if (in_ver && toks[0][0] == '}') in_ver = 0; line = strtok_r(NULL, "\n", &save); continue; } if (strcmp(toks[0], "#RAR") == 0 && n >= 4) { - if (sie_date(toks[2], fy_start) != 0 || - sie_date(toks[3], fy_end) != 0) - FAIL("SIE line %d: bad #RAR dates", line_no); + if (strcmp(toks[1], "0") == 0) { + if (sie_date(toks[2], fy_start) != 0 || + sie_date(toks[3], fy_end) != 0) + FAIL("SIE line %d: bad #RAR dates", line_no); + } } else if (strcmp(toks[0], "#KONTO") == 0 && n >= 2) { const char *name = n >= 3 ? toks[2] : ""; if (konto_n == konto_cap) { @@ -747,7 +750,25 @@ int sie_import_content(sqlite3 *db, int64_t org_id, int64_t user_id, FAIL("could not create account %s", ib_rows[i].account); } + /* The #IB balances are the previous year's closing balances. When this + org already has earlier non-IB vouchers, those carry the opening + balances and a synthetic IB voucher would double count them. */ + int64_t prior_history = 0; if (ib_n > 0 && !has_ib_ver) { + if (sqlite3_prepare_v2( + db, + "SELECT count(*) FROM vouchers WHERE org_id=?1" + " AND series <> 'IB' AND date < ?2", + -1, &st, NULL) != SQLITE_OK) + FAIL("database error"); + sqlite3_bind_int64(st, 1, org_id); + sqlite3_bind_text(st, 2, fy_start, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) + prior_history = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + } + + if (ib_n > 0 && !has_ib_ver && prior_history == 0) { struct import_ver ibver; memset(&ibver, 0, sizeof ibver); snprintf(ibver.series, sizeof ibver.series, "IB"); @@ -766,6 +787,9 @@ int sie_import_content(sqlite3 *db, int64_t org_id, int64_t user_id, update_sequence(db, org_id, fy_id, "IB", 1); free(ibver.rows); vouchers++; + } else if (ib_n > 0 && prior_history > 0) { + log_info("SIE: #IB skipped; %lld earlier vouchers carry the balances", + (long long)prior_history); } /* re-walk the text to post vouchers (rows were kept only per block) */ @@ -811,12 +835,16 @@ int sie_import_content(sqlite3 *db, int64_t org_id, int64_t user_id, } else if (m > 0 && strcmp(t2[0], "#TRANS") == 0 && in_ver && m >= 3) { int64_t amt = 0; + int got = 0; for (int i = 2; i < m; i++) { - if (parse_amount(t2[i], &amt) == 0) + if (parse_amount(t2[i], &amt) == 0) { + got = 1; break; + } } - ver_add_row(&ver, t2[1], amt > 0 ? amt : 0, - amt < 0 ? -amt : 0, ""); + if (got && amt != 0) + ver_add_row(&ver, t2[1], amt > 0 ? amt : 0, + amt < 0 ? -amt : 0, ""); } } l2 = strtok_r(NULL, "\n", &save2); diff --git a/tests/test_core.c b/tests/test_core.c index a9910e9..bf5bfe4 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -1143,6 +1143,76 @@ int main(void) CHECK(!jbool(d, "ok")); yyjson_doc_free(d); + /* CRLF, #RAR -1 ignored, zero-amount rows dropped */ + { + static const char sie_crlf[] = + "#FLAGGA 0\r\n" + "#FORMAT PC8\r\n" + "#SIETYP 4\r\n" + "#FNAMN \"CRLF AB\"\r\n" + "#RAR 0 20240101 20241231\r\n" + "#RAR -1 20230101 20231231\r\n" + "#KONTO 1930 \"Företagskonto\"\r\n" + "#KONTO 2010 \"Eget kapital\"\r\n" + "#VER \"A\" \"1\" 20240201 \"Insättning\"\r\n" + "{\r\n" + "#TRANS 1930 {} 1000.00\r\n" + "#TRANS 2010 {} -1000.00\r\n" + "#TRANS 2010 {} 0.00\r\n" + "}\r\n"; + char *b64 = util_b64((const unsigned char *)sie_crlf, + strlen(sie_crlf)); + d = call_sie_import(g_session, org2, b64, 0); + CHECK_OK(d); + CHECK(jint(d, "result.vouchers") == 1); + int64_t fy_a = jint(d, "result.fiscal_year_id"); + yyjson_doc_free(d); + free(b64); + + d = call(reqf("{\"v\":1,\"id\":\"77a\",\"cmd\":\"fiscal_year.get\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)org2, (long long)fy_a)); + CHECK_OK(d); + CHECK_STR(d, "result.start_date", "2024-01-01"); + CHECK_STR(d, "result.end_date", "2024-12-31"); + yyjson_doc_free(d); + } + + /* a later year's #IB must not duplicate carried balances */ + { + static const char sie_year2[] = + "#FLAGGA 0\n" + "#FORMAT PC8\n" + "#SIETYP 4\n" + "#FNAMN \"CRLF AB\"\n" + "#RAR 0 20250101 20251231\n" + "#IB 0 1930 1000.00\n" + "#IB 0 2010 -1000.00\n" + "#VER \"A\" \"1\" 20250201 \"Insättning\"\n" + "{\n" + "#TRANS 1930 {} 50.00\n" + "#TRANS 2010 {} -50.00\n" + "}\n"; + char *b64 = util_b64((const unsigned char *)sie_year2, + strlen(sie_year2)); + d = call_sie_import(g_session, org2, b64, 0); + CHECK_OK(d); + int64_t fy_b = jint(d, "result.fiscal_year_id"); + yyjson_doc_free(d); + free(b64); + + d = call(reqf("{\"v\":1,\"id\":\"77b\",\"cmd\":\"report.trial_balance\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"fiscal_year\":%lld}}", + g_session, (int)org2, (long long)fy_b)); + CHECK_OK(d); + CHECK(find_amount(d, "result.accounts", "account", "1930", + "ib_ore") == 100000); + CHECK(find_amount(d, "result.accounts", "account", "1930", + "ub_ore") == 105000); + yyjson_doc_free(d); + } + /* ---------------- settings: default series ----------------------- */ d = call(reqf("{\"v\":1,\"id\":\"95\",\"cmd\":\"settings.get\"," |
