From 251eb8c54c77029f0b43aa987ea50c44016147f3 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Fri, 18 Sep 2026 21:35:10 +0200 Subject: eskd: generate the momsdeklaration file (eSKDUpload 6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit report.vat_eskd builds the ISO-8859-1 XML from report.vat: whole kronor with öre truncated like the blankett, box 48 positive as filed, MomsBetala computed from the whole-krona boxes, and an optional upplysning converted from UTF-8, XML-escaped and capped at 300 characters. The TUI momsrapport gains "s = spara eSKD", prompting for a path and writing the bytes verbatim. Verified against the FY2027 Kapitas report: 05=703200, 10=175800, 20=1453, 30=851, 48=1030, 49=175621. --- clients/bokftui.c | 77 ++++++++++++++++-- docs/COMPLIANCE.md | 3 +- docs/PROTOCOL.md | 8 +- docs/STATE.md | 7 +- src/commands.c | 21 +++++ src/reports.c | 230 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/reports.h | 4 + tests/test_core.c | 29 +++++++ 8 files changed, 369 insertions(+), 10 deletions(-) diff --git a/clients/bokftui.c b/clients/bokftui.c index eedf5fc..ddff00b 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -540,7 +540,8 @@ static char *prompt(const char *label, const char *def, int mask) return buf; } -static int text_view(const char *title, const char *text) +static int text_view(const char *title, const char *text, + const char *action_hint) { int ret = 0; int nlines = 0; @@ -570,7 +571,17 @@ static int text_view(const char *title, const char *text) if (top + i < n) mvaddnstr(2 + i, 2, lines[top + i], COLS - 4); } - hints("piltangenter/PgUp/PgDn rullar F5 = uppdatera q = tillbaka"); + char hint[256]; + if (action_hint) + snprintf(hint, sizeof hint, + "piltangenter/PgUp/PgDn rullar F5 = uppdatera %s " + "q = tillbaka", + action_hint); + else + snprintf(hint, sizeof hint, + "piltangenter/PgUp/PgDn rullar F5 = uppdatera " + "q = tillbaka"); + hints(hint); refresh(); int ch = ui_getch(); if (ch == 'q' || ch == 27) @@ -579,6 +590,10 @@ static int text_view(const char *title, const char *text) ret = 1; break; } + if (ch == 's' && action_hint) { + ret = 2; + break; + } if (ch == KEY_DOWN && top + view < n) top++; else if (ch == KEY_UP && top > 0) @@ -2590,7 +2605,7 @@ static void accounts_report(struct app *a) (long long)n, (long long)active_count); buf_append(&text, line, strlen(line)); buf_append(&text, "\0", 1); - int again = text_view("Kontolista", (char *)text.p); + int again = text_view("Kontolista", (char *)text.p, NULL); buf_free(&text); free(resp); if (!again) @@ -3358,6 +3373,50 @@ static void fmt_verifikationslista(struct buf *t, const struct app *a, buf_line(t, "\n%-69s %14s %14s\n", "Omslutning:", s1, s2); } +static void eskd_save(struct app *a, const char *from, const char *to) +{ + const char *home = getenv("HOME"); + char period[8], def[512], full[512]; + snprintf(period, sizeof period, "%.4s%.2s", to, to + 5); + snprintf(def, sizeof def, "%s/eskd_%s.xml", home && *home ? home : ".", + period); + char *path = prompt("Spara eSKD-fil: ", def, 0); + if (!path || !*path) + return; + if (path[0] == '~' && (path[1] == '/' || path[1] == '\0')) + snprintf(full, sizeof full, "%s%s", home ? home : "", path + 1); + else + snprintf(full, sizeof full, "%s", path); + char args[64]; + snprintf(args, sizeof args, "{\"from\":\"%s\",\"to\":\"%s\"}", from, to); + char *resp = + client_rpc(&a->conn, "report.vat_eskd", a->session, a->org, args); + if (!resp || !client_ok(resp)) { + show_error("Kunde inte skapa eSKD", resp); + free(resp); + return; + } + char *b64 = jstr_dup(resp, "result.content_base64"); + unsigned char *data = NULL; + size_t n = 0; + if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { + message("eSKD", "Kunde inte avkoda filen från servern."); + } else { + FILE *f = fopen(full, "wb"); + if (!f || fwrite(data, 1, n, f) != n) { + message("eSKD", "Kunde inte skriva %s: %s", full, strerror(errno)); + if (f) + fclose(f); + } else { + fclose(f); + message("eSKD", "Sparat: %s\n(%zu byte, ISO-8859-1)", full, n); + } + } + free(data); + free(b64); + free(resp); +} + static void reports_screen(struct app *a) { static const char *const report_items[] = { @@ -3381,6 +3440,7 @@ static void reports_screen(struct app *a) continue; } char args[256]; + char vat_from[16] = "", vat_to[16] = ""; const char *cmd = NULL; switch (sel) { case 0: @@ -3419,6 +3479,8 @@ static void reports_screen(struct app *a) char *to = date_prompt("Till (YYYY-MM-DD): ", a->fy_end); if (!to) continue; + snprintf(vat_from, sizeof vat_from, "%s", f); + snprintf(vat_to, sizeof vat_to, "%s", to); snprintf(args, sizeof args, "{\"from\":\"%s\",\"to\":\"%s\"}", f, to); break; @@ -3470,10 +3532,15 @@ static void reports_screen(struct app *a) fmt_moms(&out, a, res); } buf_append(&out, "", 1); - int again = text_view("Rapport", (const char *)out.p); + int again = text_view("Rapport", (const char *)out.p, + sel == 5 ? "s = spara eSKD" : NULL); buf_free(&out); yyjson_doc_free(d); free(resp); + if (again == 2 && sel == 5) { + eskd_save(a, vat_from, vat_to); + break; + } if (!again) break; } @@ -3648,7 +3715,7 @@ static void audit_screen(struct app *a) free(code); } buf_append(&text, "\0", 1); - int again = text_view("Behandlingshistorik", (char *)text.p); + int again = text_view("Behandlingshistorik", (char *)text.p, NULL); buf_free(&text); free(resp); if (!again) diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md index a152cd4..abc6247 100644 --- a/docs/COMPLIANCE.md +++ b/docs/COMPLIANCE.md @@ -153,7 +153,8 @@ approval by any authority — none exists. 1. Ledger core + protocol + CLI + TUI (MVP). 2. SIE round-trip test suite and migration dry-run against files exported from Fortnox/Visma/BL. -3. eSKD file for momsdeklaration; AGI file for payroll. +3. ~~eSKD file for momsdeklaration~~ (`report.vat_eskd`, upload in + Skatteverket's e-service); AGI file for payroll. 4. K2 årsredovisning document generation + SRU files for INK2. 5. Invoicing and reskontra; bank import (CSV, then PSD2). 6. Peppol e-invoicing, if still relevant when it is due. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index b8ec759..e3e629b 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -367,6 +367,7 @@ vouchers at posting time (`attachment_ids`) or afterwards via | `report.general_ledger` | `fiscal_year`, `accounts?`, `from?`, `to?` | huvudbok | | `report.voucher_list` | `fiscal_year`, `series?` | grundbok/verifikationslista | | `report.vat` | `from`, `to`, `period_type?` | momsdeklaration ruta för ruta | +| `report.vat_eskd` | `from`, `to`, `upplysning?` | eSKD XML (ISO-8859-1) as `content_base64` | All reports are pure reads, respect locks, and return JSON rows. Amounts are öre. `report.general_ledger` (huvudbok) returns account blocks: @@ -379,7 +380,12 @@ All reports are pure reads, respect locks, and return JSON rows. Amounts are "to","last_voucher":{...},"vouchers":[{"id","series","number","date", "description","rows":[{"account","name","debit_ore","credit_ore", "description"}]}],"totals":{"debit_ore","credit_ore"}}` and takes an -optional `series` filter. Both cover the whole fiscal year; the ledger's +`series` filter. `report.vat_eskd` builds Skatteverket's `eSKDUpload` +Version 6.0 XML for the period ending at `to` (whole kronor, öre truncated +like the blankett; box 48 positive as filed) and returns `{"org_nr", +"period","from","to","filename","sha256","size","content_base64"}`; the +bytes are ISO-8859-1, so write them verbatim to a `.xml` file. Both cover +the whole fiscal year; the ledger's period can be narrowed with `from`/`to`. `report.vat` returns `{"from","to","boxes":[{"box":"05","label":"...","amount_ore":...}],"note"}`. Rules sharing a box are summed into a single entry. `box 49` is the sum of the moms boxes (`10`,`11`,`12`,`30`,`31`,`32`,`48`,`60`,`61`,`62`), so box 48 diff --git a/docs/STATE.md b/docs/STATE.md index b158de8..0e5d36e 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -86,15 +86,16 @@ server/protocol/ledger only. `attachment.put {voucher_id}`): proposed TUI actions — `Ctrl+F` in the voucher detail to upload+link, and selecting an inbox item and pressing a key to link it to a voucher picked from a list. Waiting for a go-ahead. -- Priority between **eSKD moms filing** and **bokslut/K2+SRU** for the next - backend milestone (eSKD was suggested first). +- Next backend milestone: **bokslut automation** vs **K2/SRU** (eSKD is + done: `report.vat_eskd` + `s = spara eSKD` in the momsrapport). - Moms `report_rules` seed is a corrected starter mapping (schema v3), but there is still no command/TUI to edit rules per org; add one before filing if the mapping needs adjustments (SCHEMA.md §10 promises owner editing). ## Backlog (prioritized, from COMPLIANCE.md §10 and the audit) -1. eSKD file generation for momsdeklaration. +1. ~~eSKD file generation for momsdeklaration.~~ `report.vat_eskd` (eSKDUpload + 6.0, ISO-8859-1, whole kronor) with a save action in the TUI momsrapport. 2. Bokslut automation (avskrivningar, periodiseringsfond, skatt, resultatdisposition). 3. K2 årsredovisning document + SRU files (INK2/INK2R/INK2S). diff --git a/src/commands.c b/src/commands.c index 1034b31..e16ba75 100644 --- a/src/commands.c +++ b/src/commands.c @@ -3600,6 +3600,25 @@ static yyjson_mut_val *h_report_voucher_list(struct req *r) return res; } +static yyjson_mut_val *h_report_vat_eskd(struct req *r) +{ + const char *from = arg_str(r->args, "from"); + const char *to = arg_str(r->args, "to"); + if (!from || !to) + return fail(r, "INVALID_ARGS", "from and to are required"); + char *err = NULL; + yyjson_mut_val *res = + report_vat_eskd(r->rdoc, r->db, r->org_id, from, to, + arg_str(r->args, "upplysning"), &err); + if (!res) { + yyjson_mut_val *e = fail(r, "INVALID_ARGS", + err ? err : "could not build the eSKD file"); + free(err); + return e; + } + return res; +} + static unsigned char *read_file(const char *path, size_t *out_len) { FILE *f = fopen(path, "rb"); @@ -3841,6 +3860,8 @@ const struct command g_commands[] = { h_report_general_ledger }, { "report.voucher_list", "Verifikationslista", PERM_READ, 1, 0, 0, h_report_voucher_list }, + { "report.vat_eskd", "eSKD XML for the momsdeklaration (ISO-8859-1)", + PERM_READ, 1, 0, 0, h_report_vat_eskd }, { "sie.export", "Export SIE 4 (PC8)", PERM_READ, 1, 0, 0, h_sie_export }, { "sie.import", "Import SIE 4 into an empty fiscal year", PERM_WRITE, 1, 1, 1, h_sie_import }, diff --git a/src/reports.c b/src/reports.c index 11c65f2..4368eb0 100644 --- a/src/reports.c +++ b/src/reports.c @@ -700,3 +700,233 @@ yyjson_mut_val *report_voucher_list(yyjson_mut_doc *doc, sqlite3 *db, yyjson_mut_obj_add_val(doc, o, "totals", tot); return o; } + +/* ---------------- eSKD (momsdeklaration on file) ---------------- */ + +struct eskd_box { + const char *box; + const char *tag; +}; + +/* Skatteverket's eSKDUpload 6.0 tag order. */ +static const struct eskd_box ESKD_BOXES[] = { + { "05", "ForsMomsEjAnnan" }, + { "06", "UttagMoms" }, + { "07", "UlagMargbesk" }, + { "08", "HyrinkomstFriv" }, + { "10", "MomsUtgHog" }, + { "11", "MomsUtgMedel" }, + { "12", "MomsUtgLag" }, + { "20", "InkopVaruAnnatEg" }, + { "21", "InkopTjanstAnnatEg" }, + { "22", "InkopTjanstUtomEg" }, + { "23", "InkopVaruSverige" }, + { "24", "InkopTjanstSverige" }, + { "30", "MomsInkopUtgHog" }, + { "31", "MomsInkopUtgMedel" }, + { "32", "MomsInkopUtgLag" }, + { "35", "ForsVaruAnnatEg" }, + { "36", "ForsVaruUtomEg" }, + { "37", "InkopVaruMellan3p" }, + { "38", "ForsVaruMellan3p" }, + { "39", "ForsTjSkskAnnatEg" }, + { "40", "ForsTjOvrUtomEg" }, + { "41", "ForsKopareSkskSverige" }, + { "42", "ForsOvrigt" }, + { "50", "MomsUlagImport" }, + { "60", "MomsImportUtgHog" }, + { "61", "MomsImportUtgMedel" }, + { "62", "MomsImportUtgLag" }, +}; + +/* Öre to whole kronor, truncated toward zero like the paper blankett. */ +static int64_t eskd_kronor(int64_t ore) +{ + return ore / 100; +} + +static void eskd_add(struct buf *b, const char *s) +{ + buf_append(b, s, strlen(s)); +} + +static int eskd_append_text(struct buf *b, const char *utf8, char **err) +{ + int count = 0; + for (const unsigned char *p = (const unsigned char *)utf8; *p;) { + uint32_t cp; + int len; + if (*p < 0x80) { + cp = *p; + len = 1; + } else if ((*p & 0xE0) == 0xC0) { + cp = *p & 0x1F; + len = 2; + } else if ((*p & 0xF0) == 0xE0) { + cp = *p & 0x0F; + len = 3; + } else if ((*p & 0xF8) == 0xF0) { + cp = *p & 0x07; + len = 4; + } else { + set_err(err, "upplysning is not valid UTF-8"); + return -1; + } + for (int i = 1; i < len; i++) { + if ((p[i] & 0xC0) != 0x80) { + set_err(err, "upplysning is not valid UTF-8"); + return -1; + } + cp = (cp << 6) | (uint32_t)(p[i] & 0x3F); + } + if (cp > 0xFF) { + set_err(err, "upplysning contains a character outside ISO-8859-1"); + return -1; + } + unsigned char c = (unsigned char)cp; + if (++count > 300) { + set_err(err, "upplysning may be at most 300 characters"); + return -1; + } + if (c == '&') + eskd_add(b, "&"); + else if (c == '<') + eskd_add(b, "<"); + else if (c == '>') + eskd_add(b, ">"); + else + buf_append(b, &c, 1); + p += len; + } + return 0; +} + +static int eskd_org_nr(sqlite3 *db, int64_t org_id, char out[16], char **err) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "SELECT COALESCE(org_nr,'') FROM orgs WHERE id=?1", + -1, &st, NULL) != SQLITE_OK) { + set_err(err, "database error"); + return -1; + } + sqlite3_bind_int64(st, 1, org_id); + char raw[32] = ""; + if (sqlite3_step(st) == SQLITE_ROW) + snprintf(raw, sizeof raw, "%s", + (const char *)sqlite3_column_text(st, 0)); + sqlite3_finalize(st); + char digits[16]; + size_t n = 0; + for (const char *p = raw; *p; p++) + if (*p >= '0' && *p <= '9' && n < sizeof digits - 1) + digits[n++] = *p; + digits[n] = '\0'; + if (n == 12) + memmove(digits, digits + 2, 11); + else if (n != 10) { + set_err(err, "org_nr must be xxxxxx-xxxx; set it in Företagsuppgifter"); + return -1; + } + snprintf(out, 16, "%.6s-%.4s", digits, digits + 6); + return 0; +} + +yyjson_mut_val *report_vat_eskd(yyjson_mut_doc *doc, sqlite3 *db, + int64_t org_id, const char *from, + const char *to, const char *upplysning, + char **err) +{ + if (!util_parse_iso_date(from) || !util_parse_iso_date(to)) { + set_err(err, "from and to must be YYYY-MM-DD"); + return NULL; + } + char orgnr[16]; + if (eskd_org_nr(db, org_id, orgnr, err) != 0) + return NULL; + yyjson_mut_val *vat = report_vat(doc, db, org_id, from, to, err); + if (!vat) + return NULL; + + int64_t box[64]; + memset(box, 0, sizeof box); + yyjson_mut_val *boxes = yyjson_mut_obj_get(vat, "boxes"); + size_t nb = yyjson_mut_arr_size(boxes); + for (size_t i = 0; i < nb; i++) { + yyjson_mut_val *b = yyjson_mut_arr_get(boxes, i); + const char *code = + yyjson_mut_get_str(yyjson_mut_obj_get(b, "box")); + if (!code || !code[0] || !code[1] || code[2]) + continue; + int hi = (code[0] - '0') * 10 + (code[1] - '0'); + if (hi >= 0 && hi < 64) + box[hi] = yyjson_mut_get_int(yyjson_mut_obj_get(b, "amount_ore")); + } + static const int out_boxes[] = { 10, 11, 12, 30, 31, 32, 60, 61, 62 }; + int64_t out_whole = 0; + for (size_t i = 0; i < sizeof out_boxes / sizeof out_boxes[0]; i++) + out_whole += eskd_kronor(box[out_boxes[i]]); + /* box 48 is signed like the blankett (negative when deducting). */ + int64_t ing = -eskd_kronor(box[48]); + int64_t betala = out_whole - ing; + char period[8]; + snprintf(period, sizeof period, "%.4s%.2s", to, to + 5); + + struct buf xml; + buf_init(&xml); + eskd_add(&xml, "\n"); + eskd_add(&xml, "\n"); + char line[160]; + int n = snprintf(line, sizeof line, "%s\n", orgnr); + buf_append(&xml, line, (size_t)n); + eskd_add(&xml, "\n"); + n = snprintf(line, sizeof line, "%s\n", period); + buf_append(&xml, line, (size_t)n); + for (size_t i = 0; i < sizeof ESKD_BOXES / sizeof ESKD_BOXES[0]; i++) { + int hi = + (ESKD_BOXES[i].box[0] - '0') * 10 + (ESKD_BOXES[i].box[1] - '0'); + int64_t kr = eskd_kronor(box[hi]); + if (kr == 0) + continue; + n = snprintf(line, sizeof line, "<%s>%lld\n", ESKD_BOXES[i].tag, + (long long)kr, ESKD_BOXES[i].tag); + buf_append(&xml, line, (size_t)n); + } + if (ing != 0) { + n = snprintf(line, sizeof line, "%lld\n", + (long long)ing); + buf_append(&xml, line, (size_t)n); + } + n = snprintf(line, sizeof line, "%lld\n", + (long long)betala); + buf_append(&xml, line, (size_t)n); + if (upplysning && *upplysning) { + eskd_add(&xml, ""); + if (eskd_append_text(&xml, upplysning, err) != 0) { + buf_free(&xml); + return NULL; + } + eskd_add(&xml, "\n"); + } + eskd_add(&xml, "\n\n"); + + unsigned char sha_raw[32]; + char sha[65]; + util_sha256(xml.p, xml.len, sha_raw); + util_hex(sha_raw, sizeof sha_raw, sha); + char *b64 = util_b64(xml.p, xml.len); + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, o, "org_nr", orgnr); + yyjson_mut_obj_add_strcpy(doc, o, "period", period); + yyjson_mut_obj_add_strcpy(doc, o, "from", from); + yyjson_mut_obj_add_strcpy(doc, o, "to", to); + char fname[32]; + snprintf(fname, sizeof fname, "eskd_%s.xml", period); + yyjson_mut_obj_add_strcpy(doc, o, "filename", fname); + yyjson_mut_obj_add_strcpy(doc, o, "content_base64", b64 ? b64 : ""); + yyjson_mut_obj_add_strcpy(doc, o, "sha256", sha); + yyjson_mut_obj_add_int(doc, o, "size", (int64_t)xml.len); + free(b64); + buf_free(&xml); + return o; +} diff --git a/src/reports.h b/src/reports.h index d4a0545..c4a6e4e 100644 --- a/src/reports.h +++ b/src/reports.h @@ -26,5 +26,9 @@ yyjson_mut_val *report_general_ledger(yyjson_mut_doc *doc, sqlite3 *db, yyjson_mut_val *report_voucher_list(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id, int64_t fy_id, const char *series, char **err); +yyjson_mut_val *report_vat_eskd(yyjson_mut_doc *doc, sqlite3 *db, + int64_t org_id, const char *from, + const char *to, const char *upplysning, + char **err); #endif diff --git a/tests/test_core.c b/tests/test_core.c index 5740db3..aa2bd4a 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -820,6 +820,35 @@ int main(void) CHECK(yyjson_arr_size(jget(d, "result.vouchers")) == 0); yyjson_doc_free(d); + /* eSKD: ISO-8859-1 XML for the momsdeklaration */ + d = call(reqf("{\"v\":1,\"id\":\"54e\",\"cmd\":\"report.vat_eskd\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"from\":\"2026-01-01\",\"to\":\"2026-01-31\"," + "\"upplysning\":\"Blå\"}}", + g_session, (int)org_id)); + CHECK_OK(d); + CHECK_STR(d, "result.org_nr", "559331-2126"); + CHECK_STR(d, "result.period", "202601"); + char *b64 = jstr(d, "result.content_base64"); + unsigned char *xmlb = NULL; + size_t xmll = 0; + CHECK(b64 && util_b64_decode(b64, strlen(b64), &xmlb, &xmll) == 0); + char *xml = xmalloc(xmll + 1); + memcpy(xml, xmlb, xmll); + xml[xmll] = '\0'; + free(xmlb); + CHECK(strstr(xml, "") != + NULL); + CHECK(strstr(xml, "559331-2126") != NULL); + CHECK(strstr(xml, "202601") != NULL); + CHECK(strstr(xml, "1000") != NULL); + CHECK(strstr(xml, "250") != NULL); + CHECK(strstr(xml, "250") != NULL); + CHECK(strstr(xml, "Bl\xe5") != + NULL); + free(xml); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"54\",\"cmd\":\"report.trial_balance\"," "\"session\":\"%s\",\"org\":%d}", g_session, (int)org_id)); -- cgit v1.3