summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-18 14:32:23 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-18 14:32:23 +0200
commita5d3ddf6d512bc459cfa450c6b449c2e7a245373 (patch)
treee14c2b28e3cd6d7b744f301b5bf498f5feb8f03e
parent07b5e5890fedbf71a1503f984dfbc2baa872223c (diff)
downloadbokf-a5d3ddf6d512bc459cfa450c6b449c2e7a245373.tar.gz
bokf-a5d3ddf6d512bc459cfa450c6b449c2e7a245373.zip
reports: Kapitas-style TUI tables, corrected moms rules (schema v3)v0.1.26
The report views dumped JSON; they now render Saldobalans, Resultatrapport (previous-year column, 89xx bokfört/ej bokfört), Balansrapport (Ing balans/Ing saldo/Period/Utg balans, Beräknat resultat) and Momsrapport ruta för ruta, with Swedish amount formatting (1 234,56). The moms starter rules missed 33xx sales, sent reverse-charge VAT 2614 to box 10 instead of 30 and had box 48 positive. Rules may now share a box and report.vat sums them; box 49 is the sum of the moms boxes only. Schema v3 replaces the rules for existing orgs. Verified on a copy of the live DB: 05=703 200, 10=175 800, 20=1 453, 30=851, 48=-1 030, 49=175 621, matching the Kapitas 2027 export. Ctrl+R reload passes --socket and auto-login no longer rewrites tui.conf; pty tests now run through scripts/tui-sandbox.sh so they cannot touch the real config, cache or bw session.
-rw-r--r--AGENTS.md5
-rw-r--r--clients/bokftui.c643
-rw-r--r--docs/PROTOCOL.md6
-rw-r--r--docs/SCHEMA.md8
-rw-r--r--docs/STATE.md25
-rwxr-xr-xscripts/tui-sandbox.sh36
-rw-r--r--src/db.c30
-rw-r--r--src/db.h2
-rw-r--r--src/reports.c71
-rw-r--r--src/seed.c32
-rw-r--r--src/seed.h3
-rw-r--r--tests/test_core.c71
12 files changed, 889 insertions, 43 deletions
diff --git a/AGENTS.md b/AGENTS.md
index e54f786..b82e119 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,5 +73,10 @@ forms behave the same everywhere, hints always visible, errors shown as
- Server changes: `make test`. UI changes: also drive `bokftui` over a pty
(`script -qec`) or against the demo daemon and check the real behaviour.
+- Never run `bokftui` for tests without `scripts/tui-sandbox.sh`: it
+ isolates `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` so a run cannot overwrite the
+ human's `~/.config/bokf/tui.conf`, `~/.cache/bokf/tui.log` or bw session.
+ The daemon/test database must likewise live in `/tmp`, never in
+ `~/bokf-demo` or the live NAS volume.
- Never commit unless the human asks. When asked, keep the message short and
in the repo's style.
diff --git a/clients/bokftui.c b/clients/bokftui.c
index 1662c87..9ea6eb6 100644
--- a/clients/bokftui.c
+++ b/clients/bokftui.c
@@ -832,11 +832,21 @@ static int parse_kr(const char *s, int64_t *out)
static void kr_format(int64_t ore, char *buf, size_t n)
{
long long v = (long long)ore;
- if (v < 0) {
- snprintf(buf, n, "-%lld.%02lld", -v / 100, -v % 100);
- } else {
- snprintf(buf, n, "%lld.%02lld", v / 100, v % 100);
+ int neg = v < 0;
+ if (neg)
+ v = -v;
+ char digits[32];
+ snprintf(digits, sizeof digits, "%lld", v / 100);
+ char out[28];
+ size_t o = 0;
+ size_t len = strlen(digits);
+ for (size_t i = 0; i < len && o + 2 < sizeof out; i++) {
+ if (i > 0 && (len - i) % 3 == 0)
+ out[o++] = ' ';
+ out[o++] = digits[i];
}
+ out[o] = '\0';
+ snprintf(buf, n, "%s%s,%02lld", neg ? "-" : "", out, v % 100);
}
static int parse_x_double(const char *s, double *out)
@@ -2512,6 +2522,589 @@ static void accounts_report(struct app *a)
}
}
+/* ------------------------------------------------------------------ */
+/* report formatting (Kapitas-like text tables) */
+/* ------------------------------------------------------------------ */
+
+static const char *vstr(yyjson_val *o, const char *k)
+{
+ yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL;
+ return v && yyjson_is_str(v) ? yyjson_get_str(v) : "";
+}
+
+static int64_t vint(yyjson_val *o, const char *k)
+{
+ yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL;
+ return v && yyjson_is_int(v) ? (int64_t)yyjson_get_int(v) : 0;
+}
+
+static void buf_line(struct buf *b, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+
+static void buf_line(struct buf *b, const char *fmt, ...)
+{
+ char line[1024];
+ va_list ap;
+ va_start(ap, fmt);
+ int n = vsnprintf(line, sizeof line, fmt, ap);
+ va_end(ap);
+ if (n < 0)
+ return;
+ buf_append(b, line, (size_t)n < sizeof line ? (size_t)n : sizeof line - 1);
+}
+
+static void fmt_head(struct buf *t, const struct app *a, const char *title,
+ yyjson_val *res)
+{
+ yyjson_val *fy = yyjson_obj_get(res, "fiscal_year");
+ const char *start = vstr(fy, "start_date");
+ const char *end = vstr(fy, "end_date");
+ buf_line(t, "%s\n%s\n\n", title, a->org_name);
+ buf_line(t, "Räkenskapsår: %s - %s\n", start, end);
+ buf_line(t, "Period: %s - %s\n\n", start, end);
+}
+
+static int acct_in(const char *number, int lo, int hi)
+{
+ int n = atoi(number);
+ return n >= lo && n <= hi;
+}
+
+static void pad_label(char *buf, size_t n, const char *text, int width)
+{
+ snprintf(buf, n, "%s", text);
+ pad_field(buf, n, width);
+}
+
+static void amt_col(char *buf, size_t n, int width, int64_t ore)
+{
+ char a[48];
+ kr_format(ore, a, sizeof a);
+ snprintf(buf, n, "%*s", width, a);
+}
+
+/* ---------------- balansräkning ---------------- */
+
+static const char *const BS_KEYS[3] = { "assets", "equity", "liabilities" };
+
+struct bs_sum {
+ int64_t ib, per, ub;
+};
+
+static void bs_col_head(struct buf *t)
+{
+ buf_line(t, " %5s %-48s %14s %14s %14s %14s\n", "", "", "Ing balans",
+ "Ing saldo", "Period", "Utg balans");
+}
+
+static int bs_rows(struct buf *t, yyjson_val *res, int lo, int hi,
+ struct bs_sum *sum)
+{
+ int found = 0;
+ for (int k = 0; k < 3; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ const char *acc = vstr(row, "account");
+ if (!acct_in(acc, lo, hi))
+ continue;
+ int64_t ib = vint(row, "ib_ore");
+ int64_t ub = vint(row, "ub_ore");
+ int64_t per = ub - ib;
+ if (t) {
+ char nm[128], a1[48], a2[48], a3[48], a4[48];
+ pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ amt_col(a1, sizeof a1, 14, ib);
+ amt_col(a2, sizeof a2, 14, ib);
+ amt_col(a3, sizeof a3, 14, per);
+ amt_col(a4, sizeof a4, 14, ub);
+ buf_line(t, "%-6s %s %s %s %s %s\n", acc, nm, a1, a2, a3, a4);
+ }
+ sum->ib += ib;
+ sum->per += per;
+ sum->ub += ub;
+ found = 1;
+ }
+ }
+ return found;
+}
+
+static void bs_sum_line(struct buf *t, const char *label,
+ const struct bs_sum *s)
+{
+ char lbl[64], a1[48], a2[48], a3[48], a4[48];
+ pad_label(lbl, sizeof lbl, label, 48);
+ amt_col(a1, sizeof a1, 14, s->ib);
+ amt_col(a2, sizeof a2, 14, s->ib);
+ amt_col(a3, sizeof a3, 14, s->per);
+ amt_col(a4, sizeof a4, 14, s->ub);
+ buf_line(t, " %5s %s %s %s %s %s\n", "", lbl, a1, a2, a3, a4);
+}
+
+static int bs_group(struct buf *t, yyjson_val *res, const char *title,
+ const char *sumlabel, int lo, int hi, struct bs_sum *acc)
+{
+ struct bs_sum probe = { 0, 0, 0 };
+ if (!bs_rows(NULL, res, lo, hi, &probe))
+ return 0;
+ buf_line(t, "%s\n", title);
+ struct bs_sum s = { 0, 0, 0 };
+ bs_rows(t, res, lo, hi, &s);
+ bs_sum_line(t, sumlabel, &s);
+ if (acc) {
+ acc->ib += s.ib;
+ acc->per += s.per;
+ acc->ub += s.ub;
+ }
+ return 1;
+}
+
+static void fmt_balans(struct buf *t, const struct app *a, yyjson_val *res)
+{
+ static const struct {
+ const char *title;
+ const char *sum;
+ int lo, hi;
+ } OMS[] = {
+ { "Varulager", "Summa varulager", 1400, 1499 },
+ { "Kortfristiga fordringar", "Summa kortfristiga fordringar", 1500,
+ 1699 },
+ { "Kortfristiga placeringar", "Summa kortfristiga placeringar", 1700,
+ 1899 },
+ { "Kassa och bank", "Summa kassa och bank", 1900, 1999 },
+ };
+ fmt_head(t, a, "Balansrapport", res);
+ struct bs_sum anl = { 0, 0, 0 }, oms = { 0, 0, 0 };
+ struct bs_sum eget = { 0, 0, 0 }, skuld = { 0, 0, 0 };
+ buf_line(t, "TILLGÅNGAR\n");
+ buf_line(t, "Anläggningstillgångar\n");
+ bs_rows(t, res, 1000, 1399, &anl);
+ bs_sum_line(t, "Summa anläggningstillgångar", &anl);
+ buf_line(t, "\nOmsättningstillgångar\n");
+ for (size_t i = 0; i < sizeof OMS / sizeof OMS[0]; i++) {
+ if (i)
+ buf_line(t, "\n");
+ bs_group(t, res, OMS[i].title, OMS[i].sum, OMS[i].lo, OMS[i].hi,
+ &oms);
+ }
+ bs_sum_line(t, "Summa omsättningstillgångar", &oms);
+ struct bs_sum as = { anl.ib + oms.ib, anl.per + oms.per, anl.ub + oms.ub };
+ bs_sum_line(t, "Summa tillgångar", &as);
+
+ buf_line(t, "\nEGET KAPITAL OCH SKULDER\n");
+ bs_col_head(t);
+ buf_line(t, "\nEget kapital\n");
+ bs_group(t, res, "Bundet eget kapital", "Summa bundet eget kapital", 2000,
+ 2090, &eget);
+ buf_line(t, "\n");
+ bs_group(t, res, "Fritt eget kapital", "Summa fritt eget kapital", 2091,
+ 2099, &eget);
+ bs_sum_line(t, "Summa eget kapital", &eget);
+ buf_line(t, "\n");
+ if (bs_group(t, res, "Långfristiga skulder", "Summa långfristiga skulder",
+ 2100, 2399, &skuld))
+ buf_line(t, "\n");
+ bs_group(t, res, "Kortfristiga skulder", "Summa kortfristiga skulder",
+ 2400, 2999, &skuld);
+ struct bs_sum es = { eget.ib + skuld.ib, eget.per + skuld.per,
+ eget.ub + skuld.ub };
+ bs_sum_line(t, "Summa eget kapital och skulder", &es);
+
+ yyjson_val *eq = yyjson_obj_get(res, "equity");
+ int64_t result = eq ? vint(eq, "result_ore") : 0;
+ char lbl[64], zero[48], a1[48];
+ pad_label(lbl, sizeof lbl, "Beräknat resultat", 36);
+ amt_col(zero, sizeof zero, 14, 0);
+ amt_col(a1, sizeof a1, 14, result);
+ buf_line(t, "\n\n%-6s %s %s %s %s %s\n", "", lbl, zero, zero, a1, a1);
+}
+
+/* ---------------- resultaträkning ---------------- */
+
+struct is_group {
+ const char *section;
+ const char *title;
+ int lo, hi;
+ int rev;
+};
+
+static const struct is_group IS_GROUPS[] = {
+ { "Rörelseintäkter, lagerförändringar m.m.", "Nettoomsättning", 3000, 3799,
+ 1 },
+ { "Rörelseintäkter, lagerförändringar m.m.", "Övriga rörelseintäkter",
+ 3800, 3999, 1 },
+ { "Rörelsekostnader", "Råvaror och förnödenheter", 4000, 4999, 0 },
+ { "Rörelsekostnader", "Övriga externa kostnader", 5000, 6999, 0 },
+ { "Rörelsekostnader", "Personalkostnader", 7000, 7699, 0 },
+ { "Rörelsekostnader", "Avskrivningar och nedskrivningar", 7700, 7899, 0 },
+ { "Rörelsekostnader", "Övriga rörelsekostnader", 7900, 7999, 0 },
+};
+
+static const char *const IS_KEYS[2] = { "revenue", "expenses" };
+
+static int is_group_has(yyjson_val *res, const struct is_group *g)
+{
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++)
+ if (acct_in(vstr(yyjson_arr_get(arr, i), "account"), g->lo,
+ g->hi))
+ return 1;
+ }
+ return 0;
+}
+
+static int64_t is_prev_lookup(yyjson_val *prev, const char *account,
+ int negate)
+{
+ if (!prev)
+ return 0;
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(prev, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ if (strcmp(vstr(row, "account"), account) == 0) {
+ int64_t a = vint(row, "amount_ore");
+ return negate ? -a : a;
+ }
+ }
+ }
+ return 0;
+}
+
+static void is_summary(struct buf *t, const char *label, int64_t cur,
+ int64_t prev, int blank)
+{
+ char lbl[64], a1[48], a2[48];
+ if (blank)
+ buf_line(t, "\n");
+ pad_label(lbl, sizeof lbl, label, 48);
+ amt_col(a1, sizeof a1, 15, cur);
+ amt_col(a2, sizeof a2, 15, prev);
+ buf_line(t, " %5s %s %s %s %s\n", "", lbl, a1, a1, a2);
+}
+
+static void is_print_group(struct buf *t, yyjson_val *res, yyjson_val *prev,
+ const struct is_group *gr, int64_t *cur,
+ int64_t *pre)
+{
+ int64_t sc = 0, sp = 0;
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ const char *acc = vstr(row, "account");
+ if (!acct_in(acc, gr->lo, gr->hi))
+ continue;
+ int64_t c = gr->rev ? vint(row, "amount_ore")
+ : -vint(row, "amount_ore");
+ int64_t p = is_prev_lookup(prev, acc, !gr->rev);
+ char nm[128], a1[48], a2[48];
+ pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ amt_col(a1, sizeof a1, 15, c);
+ amt_col(a2, sizeof a2, 15, p);
+ buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2);
+ sc += c;
+ sp += p;
+ }
+ }
+ is_summary(t, "Summa", sc, sp, 0);
+ *cur = sc;
+ *pre = sp;
+}
+
+static void fmt_resultat(struct buf *t, const struct app *a, yyjson_val *res,
+ yyjson_val *prev)
+{
+ fmt_head(t, a, "Resultatrapport", res);
+ const char *sect = NULL;
+ int64_t rev = 0, rev_pre = 0, exp = 0, exp_pre = 0;
+ for (size_t g = 0; g < sizeof IS_GROUPS / sizeof IS_GROUPS[0]; g++) {
+ const struct is_group *gr = &IS_GROUPS[g];
+ if (!is_group_has(res, gr))
+ continue;
+ if (!sect || strcmp(sect, gr->section) != 0) {
+ if (sect) {
+ is_summary(t, "Summa rörelseintäkter, lagerförändringar m.m.",
+ rev, rev_pre, 0);
+ buf_line(t, "\n");
+ buf_line(t, "%s\n", gr->section);
+ } else {
+ buf_line(t, " %5s %-48s %15s %15s %15s\n", "",
+ gr->section, "Period", "Ackumulerat",
+ "Föreg. per.");
+ buf_line(t, "\n");
+ buf_line(t, "%s\n", gr->title);
+ }
+ sect = gr->section;
+ } else {
+ buf_line(t, "%s\n", gr->title);
+ }
+ int64_t sc = 0, sp = 0;
+ is_print_group(t, res, prev, gr, &sc, &sp);
+ if (gr->rev) {
+ rev += sc;
+ rev_pre += sp;
+ } else {
+ exp += sc;
+ exp_pre += sp;
+ }
+ }
+ is_summary(t, "Summa rörelsekostnader", exp, exp_pre, 0);
+
+ /* finansiella poster */
+ int64_t fin = 0, fin_pre = 0, booked = 0, booked_pre = 0;
+ int has_fin = 0, has_booked = 0;
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ const char *acc = vstr(row, "account");
+ if (acct_in(acc, 8000, 8899))
+ has_fin = 1;
+ else if (acct_in(acc, 8900, 8999))
+ has_booked = 1;
+ }
+ }
+ if (has_fin) {
+ buf_line(t, "\nFinansiella poster\n");
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ const char *acc = vstr(row, "account");
+ if (!acct_in(acc, 8000, 8899))
+ continue;
+ int64_t c = k == 0 ? vint(row, "amount_ore")
+ : -vint(row, "amount_ore");
+ int64_t p = is_prev_lookup(prev, acc, k != 0);
+ char nm[128], a1[48], a2[48];
+ pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ amt_col(a1, sizeof a1, 15, c);
+ amt_col(a2, sizeof a2, 15, p);
+ buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2);
+ fin += c;
+ fin_pre += p;
+ }
+ }
+ is_summary(t, "Summa", fin, fin_pre, 0);
+ }
+ int64_t ror = rev + exp, ror_pre = rev_pre + exp_pre;
+ int64_t after = ror + fin, after_pre = ror_pre + fin_pre;
+ is_summary(t, "Rörelseresultat", ror, ror_pre, 1);
+ is_summary(t, "Resultat efter finansiella poster", after, after_pre, 1);
+ is_summary(t, "Resultat före skatt", after, after_pre, 1);
+ is_summary(t, "Årets resultat", after, after_pre, 1);
+ if (has_booked) {
+ buf_line(t, "Bokfört resultat\n");
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ const char *acc = vstr(row, "account");
+ if (!acct_in(acc, 8900, 8999))
+ continue;
+ int64_t c = -vint(row, "amount_ore");
+ int64_t p = is_prev_lookup(prev, acc, 1);
+ char nm[128], a1[48], a2[48];
+ pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ amt_col(a1, sizeof a1, 15, c);
+ amt_col(a2, sizeof a2, 15, p);
+ buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2);
+ booked += c;
+ booked_pre += p;
+ }
+ }
+ is_summary(t, "Summa", booked, booked_pre, 0);
+ is_summary(t, "Ej bokfört resultat", after + booked,
+ after_pre + booked_pre, 2);
+ } else {
+ is_summary(t, "Ej bokfört resultat", after, after_pre, 2);
+ }
+}
+
+/* ---------------- saldobalans ---------------- */
+
+static void fmt_saldobalans(struct buf *t, const struct app *a,
+ yyjson_val *res)
+{
+ fmt_head(t, a, "Saldobalans", res);
+ buf_line(t, " %5s %-48s %14s %14s %14s %14s\n", "Konto", "Benämning",
+ "Ingående", "Debet", "Kredit", "Utgående");
+ yyjson_val *arr = yyjson_obj_get(res, "accounts");
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ char nm[128], a1[48], a2[48], a3[48], a4[48];
+ pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ amt_col(a1, sizeof a1, 14, vint(row, "ib_ore"));
+ amt_col(a2, sizeof a2, 14, vint(row, "debit_ore"));
+ amt_col(a3, sizeof a3, 14, vint(row, "credit_ore"));
+ amt_col(a4, sizeof a4, 14, vint(row, "ub_ore"));
+ buf_line(t, " %5s %s %s %s %s %s\n", vstr(row, "account"), nm, a1,
+ a2, a3, a4);
+ }
+ yyjson_val *tot = yyjson_obj_get(res, "totals");
+ char lbl[64], a1[48], a2[48], a3[48], a4[48];
+ pad_label(lbl, sizeof lbl, "Summa", 48);
+ amt_col(a1, sizeof a1, 14, vint(tot, "ib_ore"));
+ amt_col(a2, sizeof a2, 14, vint(tot, "debit_ore"));
+ amt_col(a3, sizeof a3, 14, vint(tot, "credit_ore"));
+ amt_col(a4, sizeof a4, 14, vint(tot, "ub_ore"));
+ buf_line(t, "\n %5s %s %s %s %s %s\n", "", lbl, a1, a2, a3, a4);
+}
+
+/* ---------------- momsdeklaration ---------------- */
+
+struct vat_row {
+ const char *section;
+ const char *box;
+ const char *label;
+};
+
+static const struct vat_row VAT_ROWS[] = {
+ { "A.", "05",
+ "Momspliktig försäljning som inte ingår i ruta 06, 07 eller 08" },
+ { "A.", "06", "Momspliktiga uttag" },
+ { "A.", "07", "Beskattningsunderlag vid vinstmarginalbeskattning" },
+ { "A.", "08", "Hyresinkomster vid frivillig beskattning" },
+ { "B.", "10", "Utgående moms 25%" },
+ { "B.", "11", "Utgående moms 12%" },
+ { "B.", "12", "Utgående moms 6%" },
+ { "C.", "20", "Inköp av varor från annat EU-land" },
+ { "C.", "21", "Inköp av tjänster från annat EU-land enligt huvudregeln" },
+ { "C.", "22", "Inköp av tjänster från land utanför EU" },
+ { "C.", "23", "Inköp av varor i Sverige som köparen är betalningsskyldig för" },
+ { "C.", "24", "Övriga inköp av tjänster i Sverige som köparen är betalningsskyldig för" },
+ { "D.", "30", "Utgående moms 25%" },
+ { "D.", "31", "Utgående moms 12%" },
+ { "D.", "32", "Utgående moms 6%" },
+ { "H.", "50", "Beskattningsunderlag vid import" },
+ { "I.", "60", "Utgående moms 25%" },
+ { "I.", "61", "Utgående moms 12%" },
+ { "I.", "62", "Utgående moms 6%" },
+ { "E.", "35", "Försäljning av varor till annat EU-land" },
+ { "E.", "36", "Försäljning av varor utanför EU" },
+ { "E.", "37", "Mellanmans inköp av varor vid trepartshandel" },
+ { "E.", "38", "Mellanmans försäljning av varor vid trepartshandel" },
+ { "E.", "39", "Försäljning av tjänster till beskattningsbar person i annat EU-land enligt huvudregeln" },
+ { "E.", "40", "Övrig försäljning av tjänster som tillhandahållits utomlands" },
+ { "E.", "41", "Försäljning när köparen är betalningsskyldig i Sverige" },
+ { "E.", "42", "Övrig försäljning m.m." },
+ { "F.", "48", "Ingående moms att dra av" },
+ { "G.", "49", "Moms att betala (+) eller att få tillbaka (-)" },
+};
+
+static const char *vat_section_title(const char *s)
+{
+ if (strcmp(s, "A.") == 0)
+ return "Momspliktig försäljning eller uttag exklusive moms";
+ if (strcmp(s, "B.") == 0)
+ return "Utgående moms på försäljning eller uttag i ruta 05 - 08";
+ if (strcmp(s, "C.") == 0)
+ return "Momspliktiga inköp vid omvänd skattskyldighet";
+ if (strcmp(s, "D.") == 0)
+ return "Utgående moms på inköp i ruta 20 - 24";
+ if (strcmp(s, "H.") == 0)
+ return "Import";
+ if (strcmp(s, "I.") == 0)
+ return "Utgående moms på import i ruta 50";
+ if (strcmp(s, "E.") == 0)
+ return "Försäljning m.m. som är undantagen från moms";
+ if (strcmp(s, "F.") == 0)
+ return "Ingående moms";
+ return "Moms att betala eller få tillbaka";
+}
+
+static void fmt_moms(struct buf *t, const struct app *a, yyjson_val *res)
+{
+ buf_line(t, "Momsrapport\n\n%s\n\n", a->org_name);
+ buf_line(t, "Räkenskapsår: %s - %s\n", a->fy_start, a->fy_end);
+ buf_line(t, "Period: %s - %s\n", vstr(res, "from"),
+ vstr(res, "to"));
+ const char *sect = NULL;
+ yyjson_val *boxes = yyjson_obj_get(res, "boxes");
+ size_t n = yyjson_arr_size(boxes);
+ for (size_t i = 0; i < sizeof VAT_ROWS / sizeof VAT_ROWS[0]; i++) {
+ const struct vat_row *vr = &VAT_ROWS[i];
+ if (strcmp(vr->box, "49") == 0)
+ continue; /* last */
+ if (!sect || strcmp(sect, vr->section) != 0) {
+ sect = vr->section;
+ buf_line(t, "\n%s %s\n", vr->section,
+ vat_section_title(vr->section));
+ }
+ int64_t ore = 0;
+ for (size_t j = 0; j < n; j++) {
+ yyjson_val *b = yyjson_arr_get(boxes, j);
+ if (strcmp(vstr(b, "box"), vr->box) == 0) {
+ ore = vint(b, "amount_ore");
+ break;
+ }
+ }
+ char amt[32] = "";
+ if (ore)
+ snprintf(amt, sizeof amt, "%lld",
+ (long long)(ore / 100)); /* Kapitas truncates öre */
+ buf_line(t, "%s. %-66s %14s\n", vr->box, vr->label, amt);
+ }
+ int64_t ore49 = 0;
+ for (size_t j = 0; j < n; j++) {
+ yyjson_val *b = yyjson_arr_get(boxes, j);
+ if (strcmp(vstr(b, "box"), "49") == 0) {
+ ore49 = vint(b, "amount_ore");
+ break;
+ }
+ }
+ buf_line(t, "\nG. %s\n", vat_section_title("G."));
+ buf_line(t, "49. %-66s %14lld\n",
+ "Moms att betala (+) eller att få tillbaka (-)",
+ (long long)(ore49 / 100));
+}
+
+static int64_t prev_fy_id(struct app *a)
+{
+ char *resp =
+ client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ free(resp);
+ return 0;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ int64_t best = 0;
+ char best_end[16] = "";
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.end_date", i);
+ char *e = jstr_dup(resp, path);
+ if (e && strcmp(e, a->fy_start) < 0 && strcmp(e, best_end) > 0) {
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ int64_t id = jint_val(resp, path, 0);
+ if (id) {
+ best = id;
+ snprintf(best_end, sizeof best_end, "%s", e);
+ }
+ }
+ free(e);
+ }
+ free(resp);
+ return best;
+}
+
static void reports_screen(struct app *a)
{
static const char *const report_items[] = {
@@ -2575,10 +3168,40 @@ static void reports_screen(struct app *a)
yyjson_doc *d = parse(resp);
yyjson_val *res =
d ? jget(yyjson_doc_get_root(d), "result") : NULL;
- char *pretty =
- res ? yyjson_val_write(res, YYJSON_WRITE_PRETTY, NULL) : NULL;
- int again = text_view("Rapport", pretty ? pretty : resp);
- free(pretty);
+ struct buf out;
+ buf_init(&out);
+ if (!res) {
+ buf_line(&out, "%s", resp);
+ } else if (sel == 0) {
+ fmt_saldobalans(&out, a, res);
+ } else if (sel == 1) {
+ yyjson_doc *pd = NULL;
+ yyjson_val *prev = NULL;
+ int64_t pf = prev_fy_id(a);
+ if (pf) {
+ char pargs[64];
+ snprintf(pargs, sizeof pargs, "{\"fiscal_year\":%lld}",
+ (long long)pf);
+ char *presp = client_rpc(&a->conn,
+ "report.income_statement",
+ a->session, a->org, pargs);
+ if (presp && client_ok(presp)) {
+ pd = parse(presp);
+ prev = pd ? jget(yyjson_doc_get_root(pd), "result")
+ : NULL;
+ }
+ free(presp);
+ }
+ fmt_resultat(&out, a, res, prev);
+ yyjson_doc_free(pd);
+ } else if (sel == 2) {
+ fmt_balans(&out, a, res);
+ } else {
+ fmt_moms(&out, a, res);
+ }
+ buf_append(&out, "", 1);
+ int again = text_view("Rapport", (const char *)out.p);
+ buf_free(&out);
yyjson_doc_free(d);
free(resp);
if (!again)
@@ -3998,6 +4621,8 @@ static void do_reload(struct app *a, const char *self)
snprintf(orgbuf, sizeof orgbuf, "%lld", (long long)a->org);
snprintf(fybuf, sizeof fybuf, "%lld", (long long)a->fy);
argv[n++] = (char *)(self && *self ? self : "bokftui");
+ argv[n++] = "--socket";
+ argv[n++] = a->socket;
argv[n++] = "--org";
argv[n++] = orgbuf;
if (a->fy > 0) {
@@ -4517,7 +5142,6 @@ int main(int argc, char **argv)
char *err = NULL;
int rc = try_login(&app, NULL, NULL, token, &err);
if (rc == 0) {
- config_save(&app);
tui_log("auto-login: token ok");
break;
}
@@ -4536,7 +5160,6 @@ int main(int argc, char **argv)
if (rc == 0) {
snprintf(app.password, sizeof app.password, "%s",
env_pass);
- config_save(&app);
tui_log("auto-login: password ok");
break;
}
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 1baf607..35390ae 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -367,7 +367,11 @@ vouchers at posting time (`attachment_ids`) or afterwards via
| `report.vat` | `from`, `to`, `period_type?` | momsdeklaration ruta för ruta |
All reports are pure reads, respect locks, and return JSON rows. Amounts are
-öre. `report.vat` returns `{"boxes":[{"box":"05","label":"...","amount_ore":...}],"period":{...}}`.
+öre. `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
+is signed like the blankett (ingående moms negative); underlag boxes do not
+change what is payable.
### 7.7 SIE 4
diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md
index 4b22271..db03e43 100644
--- a/docs/SCHEMA.md
+++ b/docs/SCHEMA.md
@@ -393,6 +393,11 @@ is editable by owners when Skatteverket changes the blankett. The seed rules
are data, not code: the system ships a reviewed default set per fiscal year and
keeps older sets for older years.
+Several rules may target the same `box`; `report.vat` sums them into one entry
+per box. Ruta 49 is the sum of the payable boxes only (`10`,`11`,`12`,`30`,
+`31`,`32`,`48`,`60`,`61`,`62`); the other boxes are underlag and never change
+what is payable.
+
### 10.1 Voucher templates (schema v2)
Konteringsmallar: named sets of rows with a formula over the variable `x`.
@@ -459,7 +464,8 @@ another voucher is posted in between) — clients must not persist it.
## 12. Migrations and versioning
- `meta(key TEXT PRIMARY KEY, value TEXT)` holds `schema_version` (integer)
- and `created_at`. Current version: **2** (v2 adds the two template tables).
+ and `created_at`. Current version: **3** (v3 replaces the seeded moms rules
+ with the corrected mapping; v2 adds the two template tables).
- Migrations are forward-only, applied automatically at daemon start, each in
one transaction, and require an automatic `VACUUM INTO` snapshot next to the
database before starting (`bokfd.db.pre-migration-<version>`).
diff --git a/docs/STATE.md b/docs/STATE.md
index 458833e..0f8a915 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -1,7 +1,7 @@
# bokf — project state
Snapshot for resuming work in a new session. Read with `AGENTS.md` (rules)
-and `docs/TUI-GUIDELINES.md` (UI conventions). Dated 2026-09-17.
+and `docs/TUI-GUIDELINES.md` (UI conventions). Dated 2026-09-18.
## Status
@@ -68,6 +68,17 @@ server/protocol/ledger only.
differ. `scripts/deploy.sh --dev` cross-compiles the binaries here and
hot-reloads the daemon (SIGHUP re-exec via `docker cp`), skipping the
image build and container recreate.
+15. **Reports in the TUI**: rendered as fixed-width Swedish tables that mirror
+ the Kapitas PDF exports (Saldobalans, Resultatrapport with previous-year
+ column and 89xx bokfört/ej bokfört, Balansrapport with Ing balans/Ing
+ saldo/Period/Utg balans and Beräknat resultat, Momsrapport ruta för ruta).
+ The TUI never shows report JSON. Amounts are Swedish formatted
+ (`1 234,56`); moms rutas are whole kronor truncated like Kapitas.
+16. **Moms rules (schema v3)**: default seed covers 05 over 3000-3019,
+ 3100-3199, 3300-3399, reverse-charge sales (32xx) in 41, EU purchases in
+ 20/21, reverse-charge output VAT 2614/2624/2634 in 30/31/32, and box 48
+ signed negative. Rules may share a box and are summed; box 49 is the sum
+ of the moms boxes only. v3 migrates existing databases.
## Pending decisions
@@ -77,8 +88,9 @@ server/protocol/ledger only.
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).
-- Moms `report_rules` seed is a reviewed starter mapping only; must be
- checked against the current Skatteverket blankett before filing.
+- 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)
@@ -123,11 +135,14 @@ server/protocol/ledger only.
it is still wanted.
- TUI smoke tests: drive over a pty with `script -qec`; function-key escape
sequences are timing-sensitive there (not an app bug). `Ctrl+N/C/F` are
- single bytes and reliable.
+ single bytes and reliable. Always wrap the run in
+ `scripts/tui-sandbox.sh -- ./build/bokftui ...`: it isolates
+ `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` so a test can never overwrite the real
+ `~/.config/bokf/tui.conf` or `~/.cache/bokf/tui.log`.
## Known caveats
- Never commit unless the human asks.
- SQLite files must not be backed up live with restic; use
`backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots.
-- Schema version is 2; forward migrations are in `db.c`.
+- Schema version is 3; forward migrations are in `db.c`.
diff --git a/scripts/tui-sandbox.sh b/scripts/tui-sandbox.sh
new file mode 100755
index 0000000..b6b592d
--- /dev/null
+++ b/scripts/tui-sandbox.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Run a command with isolated bokftui config/cache for pty smoke tests, so a
+# test can never clobber the real ~/.config/bokf/tui.conf or
+# ~/.cache/bokf/tui.log.
+#
+# scripts/tui-sandbox.sh [--keep] -- command [args...]
+#
+# XDG_CONFIG_HOME and XDG_CACHE_HOME point at a fresh temp dir (printed on
+# stderr). The dir is removed on exit unless --keep is given.
+set -eu
+
+keep=0
+if [ "${1:-}" = "--keep" ]; then
+ keep=1
+ shift
+fi
+if [ "${1:-}" = "--" ]; then
+ shift
+fi
+if [ "$#" -eq 0 ]; then
+ echo "usage: scripts/tui-sandbox.sh [--keep] -- command [args...]" >&2
+ exit 2
+fi
+
+dir=$(mktemp -d "${TMPDIR:-/tmp}/bokf-tui-XXXXXX")
+mkdir -p "$dir/config" "$dir/cache"
+export XDG_CONFIG_HOME="$dir/config"
+export XDG_CACHE_HOME="$dir/cache"
+
+cleanup() {
+ [ "$keep" -eq 1 ] || rm -rf "$dir"
+}
+trap cleanup EXIT INT TERM
+
+echo "tui-sandbox: config=$dir/config cache=$dir/cache" >&2
+"$@"
diff --git a/src/db.c b/src/db.c
index c52d763..0de40ea 100644
--- a/src/db.c
+++ b/src/db.c
@@ -8,6 +8,7 @@
#include "auth.h"
#include "config.h"
#include "log.h"
+#include "seed.h"
#include "util.h"
static const char SCHEMA_V1[] =
@@ -336,6 +337,31 @@ int db_migrate(sqlite3 *db, char **err)
}
/* Forward-only upgrades for existing databases. */
+
+/* v3: the moms starter rules missed 33xx sales, sent 2614 to box 10 and
+ had box 48 with the wrong sign. Rules were never editable, so replace
+ them with the corrected defaults for every org. */
+static int db_upgrade_v3(sqlite3 *db, char **err)
+{
+ if (db_exec(db, "DELETE FROM report_rules WHERE report='vat'", err) != 0)
+ return -1;
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(db, "SELECT id FROM orgs", -1, &st, NULL) !=
+ SQLITE_OK) {
+ set_err(err, "database error");
+ return -1;
+ }
+ while (sqlite3_step(st) == SQLITE_ROW) {
+ int64_t id = sqlite3_column_int64(st, 0);
+ if (seed_vat_rules(db, id, err) != 0) {
+ sqlite3_finalize(st);
+ return -1;
+ }
+ }
+ sqlite3_finalize(st);
+ return 0;
+}
+
static int db_upgrade(sqlite3 *db, int from, char **err)
{
if (db_exec(db, "BEGIN IMMEDIATE", err) != 0)
@@ -344,6 +370,10 @@ static int db_upgrade(sqlite3 *db, int from, char **err)
db_exec(db, "ROLLBACK", NULL);
return -1;
}
+ if (from < 3 && db_upgrade_v3(db, err) != 0) {
+ db_exec(db, "ROLLBACK", NULL);
+ return -1;
+ }
char *sql = sqlite3_mprintf(
"UPDATE meta SET value='%d' WHERE key='schema_version'",
BOKF_SCHEMA_VERSION);
diff --git a/src/db.h b/src/db.h
index 2473d93..aa34c81 100644
--- a/src/db.h
+++ b/src/db.h
@@ -4,7 +4,7 @@
#include <sqlite3.h>
#include <stdint.h>
-#define BOKF_SCHEMA_VERSION 2
+#define BOKF_SCHEMA_VERSION 3
int db_open(const char *path, sqlite3 **out, char **err);
int db_migrate(sqlite3 *db, char **err);
diff --git a/src/reports.c b/src/reports.c
index 1bfdcfb..ee2bbe1 100644
--- a/src/reports.c
+++ b/src/reports.c
@@ -328,13 +328,20 @@ struct box_label {
};
static const struct box_label BOX_LABELS[] = {
- { "05", "Momspliktig försäljning 25%" },
- { "06", "Momspliktig försäljning 12%" },
- { "07", "Momspliktig försäljning 6%" },
+ { "05", "Momspliktig försäljning som inte ingår i ruta 06, 07 eller 08" },
+ { "06", "Momspliktiga uttag" },
+ { "07", "Beskattningsunderlag vid vinstmarginalbeskattning" },
{ "10", "Utgående moms 25%" },
{ "11", "Utgående moms 12%" },
{ "12", "Utgående moms 6%" },
- { "48", "Ingående moms" },
+ { "20", "Inköp av varor från annat EU-land" },
+ { "21", "Inköp av tjänster från annat EU-land enligt huvudregeln" },
+ { "30", "Utgående moms 25%" },
+ { "31", "Utgående moms 12%" },
+ { "32", "Utgående moms 6%" },
+ { "41", "Försäljning när köparen är betalningsskyldig i Sverige" },
+ { "48", "Ingående moms att dra av" },
+ { "49", "Moms att betala eller få tillbaka" },
};
static const char *box_label(const char *box)
@@ -345,6 +352,19 @@ static const char *box_label(const char *box)
return "Ruta";
}
+/* Boxes that affect box 49; every other box is only a base (underlag). */
+static int vat_box_payable(const char *box)
+{
+ static const char *const boxes[] = { "10", "11", "12", "30", "31",
+ "32", "48", "60", "61", "62" };
+ for (size_t i = 0; i < sizeof boxes / sizeof boxes[0]; i++)
+ if (strcmp(box, boxes[i]) == 0)
+ return 1;
+ return 0;
+}
+
+#define VAT_MAX_BOXES 64
+
yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
const char *from, const char *to, char **err)
{
@@ -352,8 +372,11 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
set_err(err, "from and to must be YYYY-MM-DD");
return NULL;
}
- yyjson_mut_val *boxes = yyjson_mut_arr(doc);
- int64_t out_total = 0, in_total = 0;
+ struct {
+ char box[8];
+ int64_t amount;
+ } acc[VAT_MAX_BOXES];
+ size_t nacc = 0;
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(
db,
@@ -408,20 +431,36 @@ yyjson_mut_val *report_vat(yyjson_mut_doc *doc, sqlite3 *db, int64_t org_id,
sqlite3_column_int64(qs, 1)) *
sign;
sqlite3_finalize(qs);
- if (strcmp(box, "48") == 0)
- in_total += amount;
- else
- out_total += amount;
- yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, boxes);
- yyjson_mut_obj_add_strcpy(doc, o, "box", box);
- yyjson_mut_obj_add_strcpy(doc, o, "label", box_label(box));
- yyjson_mut_obj_add_int(doc, o, "amount_ore", amount);
+ size_t i = 0;
+ for (; i < nacc; i++) {
+ if (strcmp(acc[i].box, box) == 0) {
+ acc[i].amount += amount;
+ break;
+ }
+ }
+ if (i == nacc && nacc < VAT_MAX_BOXES) {
+ snprintf(acc[nacc].box, sizeof acc[nacc].box, "%s", box);
+ acc[nacc].amount = amount;
+ nacc++;
+ }
}
sqlite3_finalize(st);
+
+ yyjson_mut_val *boxes = yyjson_mut_arr(doc);
+ int64_t payable = 0;
+ for (size_t i = 0; i < nacc; i++) {
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, boxes);
+ yyjson_mut_obj_add_strcpy(doc, o, "box", acc[i].box);
+ yyjson_mut_obj_add_strcpy(doc, o, "label", box_label(acc[i].box));
+ yyjson_mut_obj_add_int(doc, o, "amount_ore", acc[i].amount);
+ if (vat_box_payable(acc[i].box))
+ payable += acc[i].amount;
+ }
yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, boxes);
yyjson_mut_obj_add_strcpy(doc, o, "box", "49");
- yyjson_mut_obj_add_strcpy(doc, o, "label", "Moms att betala eller få tillbaka");
- yyjson_mut_obj_add_int(doc, o, "amount_ore", out_total - in_total);
+ yyjson_mut_obj_add_strcpy(doc, o, "label",
+ "Moms att betala eller få tillbaka");
+ yyjson_mut_obj_add_int(doc, o, "amount_ore", payable);
yyjson_mut_val *res = yyjson_mut_obj(doc);
yyjson_mut_obj_add_strcpy(doc, res, "from", from);
diff --git a/src/seed.c b/src/seed.c
index cd1bf71..7be0535 100644
--- a/src/seed.c
+++ b/src/seed.c
@@ -216,16 +216,25 @@ struct vat_rule {
};
/* Starter mapping from BAS account ranges to momsdeklaration boxes. It
- covers the common domestic cases only and must be reviewed against the
- current Skatteverket blankett before filing. */
+ covers the common domestic and EU cases; rules may share a box, the
+ report sums them. Must be reviewed against the current Skatteverket
+ blankett before filing. */
static const struct vat_rule VAT_RULES[] = {
- { "05", "range", "3001-3019", -1, 10 },
- { "06", "range", "3021-3029", -1, 20 },
- { "07", "range", "3031-3039", -1, 30 },
- { "10", "range", "2610-2619", -1, 40 },
- { "11", "range", "2620-2629", -1, 50 },
- { "12", "range", "2630-2639", -1, 60 },
- { "48", "range", "2640-2649", 1, 70 },
+ { "05", "range", "3000-3019", -1, 10 },
+ { "05", "range", "3100-3199", -1, 11 },
+ { "05", "range", "3300-3399", -1, 12 },
+ { "41", "range", "3200-3299", -1, 20 },
+ { "06", "range", "3020-3029", -1, 30 },
+ { "07", "range", "3030-3039", -1, 40 },
+ { "10", "range", "2610-2613", -1, 50 },
+ { "11", "range", "2620-2623", -1, 60 },
+ { "12", "range", "2630-2633", -1, 70 },
+ { "20", "range", "4510-4529", 1, 80 },
+ { "21", "range", "4530-4549", 1, 90 },
+ { "30", "range", "2614-2619", -1, 100 },
+ { "31", "range", "2624-2629", -1, 110 },
+ { "32", "range", "2634-2639", -1, 120 },
+ { "48", "range", "2640-2649", -1, 130 },
};
static int seed_rules(sqlite3 *db, int64_t org_id, char **err)
@@ -256,6 +265,11 @@ static int seed_rules(sqlite3 *db, int64_t org_id, char **err)
return 0;
}
+int seed_vat_rules(sqlite3 *db, int64_t org_id, char **err)
+{
+ return seed_rules(db, org_id, err);
+}
+
static int seed_default_fiscal_year(sqlite3 *db, int64_t org_id,
int fy_start_month, int64_t *out_fy,
char **err)
diff --git a/src/seed.h b/src/seed.h
index 826391b..7192398 100644
--- a/src/seed.h
+++ b/src/seed.h
@@ -9,4 +9,7 @@
int seed_org(sqlite3 *db, int64_t org_id, const char *framework,
int fy_start_month, int64_t *out_fy_id, char **err);
+/* Inserts the default moms report rules for an org (no transaction). */
+int seed_vat_rules(sqlite3 *db, int64_t org_id, char **err);
+
#endif
diff --git a/tests/test_core.c b/tests/test_core.c
index fa808ac..27948d6 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -1301,6 +1301,77 @@ int main(void)
CHECK_STR(d, "result.attachment_dir", "/tmp/bilagor");
yyjson_doc_free(d);
+ /* ---------------- moms rules: ranges merge per box ------------- */
+ d = call(reqf("{\"v\":1,\"id\":\"110\",\"cmd\":\"org.create\","
+ "\"session\":\"%s\",\"args\":{\"name\":\"Moms AB\"}}",
+ g_session));
+ CHECK_OK(d);
+ int64_t vat_org = jint(d, "result.id");
+ CHECK(vat_org > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"111\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-02-01\",\"description\":\"Försäljning A\",\"rows\":["
+ "{\"account\":\"1930\",\"debit_ore\":125000},"
+ "{\"account\":\"3001\",\"credit_ore\":100000},"
+ "{\"account\":\"2611\",\"credit_ore\":25000}]}}",
+ g_session, (int)vat_org));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"112\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-02-02\",\"description\":\"Försäljning B\",\"rows\":["
+ "{\"account\":\"1930\",\"debit_ore\":62500},"
+ "{\"account\":\"3105\",\"credit_ore\":50000},"
+ "{\"account\":\"2611\",\"credit_ore\":12500}]}}",
+ g_session, (int)vat_org));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"113\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-02-03\",\"description\":\"EU-inköp\",\"rows\":["
+ "{\"account\":\"4515\",\"debit_ore\":10000},"
+ "{\"account\":\"2645\",\"debit_ore\":2500},"
+ "{\"account\":\"2614\",\"credit_ore\":2500},"
+ "{\"account\":\"2410\",\"credit_ore\":10000}]}}",
+ g_session, (int)vat_org));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"114\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"
+ "\"2026-02-04\",\"description\":\"IT-tjänst\",\"rows\":["
+ "{\"account\":\"6540\",\"debit_ore\":8000},"
+ "{\"account\":\"2641\",\"debit_ore\":2000},"
+ "{\"account\":\"2410\",\"credit_ore\":10000}]}}",
+ g_session, (int)vat_org));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"115\",\"cmd\":\"report.vat\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"from\":\"2026-02-01\",\"to\":\"2026-02-28\"}}",
+ g_session, (int)vat_org));
+ CHECK_OK(d);
+ CHECK(vat_box(d, "05") == 150000);
+ CHECK(vat_box(d, "10") == 37500);
+ CHECK(vat_box(d, "20") == 10000);
+ CHECK(vat_box(d, "30") == 2500);
+ CHECK(vat_box(d, "48") == -4500);
+ CHECK(vat_box(d, "49") == 35500);
+ int n05 = 0;
+ yyjson_val *boxes = jget(d, "result.boxes");
+ for (size_t i = 0; i < yyjson_arr_size(boxes); i++) {
+ yyjson_val *box = yyjson_obj_get(yyjson_arr_get(boxes, i), "box");
+ if (box && yyjson_is_str(box) && strcmp(yyjson_get_str(box), "05") == 0)
+ n05++;
+ }
+ CHECK(n05 == 1);
+ yyjson_doc_free(d);
+
/* close fiscal year and verify hard stop */
d = call(reqf("{\"v\":1,\"id\":\"73\",\"cmd\":\"fiscal_year.close\","
"\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"