diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-18 14:32:23 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-18 14:32:23 +0200 |
| commit | a5d3ddf6d512bc459cfa450c6b449c2e7a245373 (patch) | |
| tree | e14c2b28e3cd6d7b744f301b5bf498f5feb8f03e | |
| parent | 07b5e5890fedbf71a1503f984dfbc2baa872223c (diff) | |
| download | bokf-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.md | 5 | ||||
| -rw-r--r-- | clients/bokftui.c | 643 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 6 | ||||
| -rw-r--r-- | docs/SCHEMA.md | 8 | ||||
| -rw-r--r-- | docs/STATE.md | 25 | ||||
| -rwxr-xr-x | scripts/tui-sandbox.sh | 36 | ||||
| -rw-r--r-- | src/db.c | 30 | ||||
| -rw-r--r-- | src/db.h | 2 | ||||
| -rw-r--r-- | src/reports.c | 71 | ||||
| -rw-r--r-- | src/seed.c | 32 | ||||
| -rw-r--r-- | src/seed.h | 3 | ||||
| -rw-r--r-- | tests/test_core.c | 71 |
12 files changed, 889 insertions, 43 deletions
@@ -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 +"$@" @@ -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); @@ -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); @@ -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) @@ -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," |
