summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-17 23:02:41 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-17 23:02:41 +0200
commit2a98cce8434f7ea27f777d9e6422a1eb92ba8be7 (patch)
treeac380bc2e3e64f73adac50fbb63a7cbf1348e417
parent0241122e05ac0bd174d4ec43a5882397c146aa89 (diff)
downloadbokf-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.md2
-rw-r--r--docs/STATE.md7
-rw-r--r--src/sie.c46
-rw-r--r--tests/test_core.c70
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
diff --git a/src/sie.c b/src/sie.c
index 05604d2..ba4c1a8 100644
--- a/src/sie.c
+++ b/src/sie.c
@@ -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\","