aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-19 20:13:02 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-19 20:13:02 +0200
commit77c298b3b64f2493e509734df0084068ad24441f (patch)
tree8a8182fbecdb0dc63cdc71842511cc6d00e21716
parent30c309e15d3211e7d0e8a8a2752c3e9afa0a0638 (diff)
downloadbokf-77c298b3b64f2493e509734df0084068ad24441f.tar.gz
bokf-77c298b3b64f2493e509734df0084068ad24441f.zip
bokslut: TUI screen, K2 årsredovisning draft, storable dividend (schema v4)v0.1.33
-rw-r--r--clients/bokftui.c782
-rw-r--r--docs/PROTOCOL.md10
-rw-r--r--docs/SCHEMA.md1
-rw-r--r--docs/STATE.md31
-rw-r--r--src/commands.c70
-rw-r--r--src/db.c14
-rw-r--r--src/db.h2
-rw-r--r--tests/test_core.c22
8 files changed, 910 insertions, 22 deletions
diff --git a/clients/bokftui.c b/clients/bokftui.c
index 33b21de..69fe3a3 100644
--- a/clients/bokftui.c
+++ b/clients/bokftui.c
@@ -147,6 +147,9 @@ static void tui_log(const char *fmt, ...)
__attribute__((format(printf, 1, 2)));
static void show_error(const char *title, const char *resp);
+static void bokslut_screen(struct app *a);
+static void arsredovisning_screen(struct app *a);
+static char *rpc_dry(struct app *a, const char *cmd, const char *args);
static int64_t vouchers_new(struct app *a);
static void acct_cache_free(void);
static void acct_cache_load(struct app *a);
@@ -3910,13 +3913,14 @@ static void reports_screen(struct app *a)
"Verifikationslista",
"Momsdeklaration",
"Inkomstdeklaration (INK2/SRU)",
+ "Årsredovisning (K2)",
"Kontolista (alla konton)",
};
static int last_sel = 0;
for (;;) {
if (g_quit)
return;
- int sel = menu("Rapporter", report_items, 8, 0, &last_sel);
+ int sel = menu("Rapporter", report_items, 9, 0, &last_sel);
if (sel < 0)
return;
if (sel == 6) {
@@ -3954,6 +3958,10 @@ static void reports_screen(struct app *a)
continue;
}
if (sel == 7) {
+ arsredovisning_screen(a);
+ continue;
+ }
+ if (sel == 8) {
accounts_report(a);
continue;
}
@@ -4652,12 +4660,13 @@ static int template_form(struct app *a, const char *load_name)
rows[i].text);
}
yyjson_mut_obj_add_val(d, o, "rows", arr);
- if (ch == KEY_F(5))
- yyjson_mut_obj_add_bool(d, o, "dry_run", true);
char *args = yyjson_mut_write(d, 0, NULL);
yyjson_mut_doc_free(d);
const char *cmd = tpl_id ? "template.update" : "template.create";
- char *resp = client_rpc(&a->conn, cmd, a->session, a->org, args);
+ char *resp = ch == KEY_F(5)
+ ? rpc_dry(a, cmd, args)
+ : client_rpc(&a->conn, cmd, a->session, a->org,
+ args);
free(args);
if (resp && client_ok(resp)) {
if (ch == KEY_F(5)) {
@@ -5533,6 +5542,755 @@ static void templates_screen(struct app *a)
}
}
+/* ------------------------------------------------------------------ */
+/* bokslut */
+/* ------------------------------------------------------------------ */
+
+/* client_rpc() cannot carry the protocol's top-level dry_run, so build
+ the request by hand. */
+static char *rpc_dry(struct app *a, const char *cmd, const char *args)
+{
+ size_t n = strlen(args) + strlen(cmd) + strlen(a->session) + 160;
+ char *raw = xmalloc(n);
+ snprintf(raw, n,
+ "{\"v\":1,\"id\":\"tui\",\"cmd\":\"%s\",\"session\":\"%s\","
+ "\"org\":%lld,\"dry_run\":true,\"args\":%s}",
+ cmd, a->session, (long long)a->org, args);
+ char *r = NULL;
+ if (client_send_line(&a->conn, raw) == 0)
+ r = client_read_line(&a->conn);
+ free(raw);
+ return r;
+}
+
+static void fmt_bokslut_plan(struct buf *t, yyjson_val *res)
+{
+ yyjson_val *fy = yyjson_obj_get(res, "fiscal_year");
+ if (fy)
+ buf_line(t, "Bokslutsplan %s %s - %s\n", vstr(fy, "label"),
+ vstr(fy, "start_date"), vstr(fy, "end_date"));
+ yyjson_val *vs = yyjson_obj_get(res, "vouchers");
+ size_t n = yyjson_arr_size(vs);
+ if (n == 0) {
+ buf_line(t, "\nInget att bokföra.\n");
+ return;
+ }
+ char pser[16] = "";
+ long long pnum = 0;
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *v = yyjson_arr_get(vs, i);
+ yyjson_val *pv = yyjson_obj_get(v, "voucher");
+ const char *ser = vstr(pv, "series");
+ long long num = (long long)vint(pv, "number");
+ if (strcmp(ser, pser) != 0) {
+ snprintf(pser, sizeof pser, "%s", ser);
+ pnum = num;
+ } else if (num <= pnum) {
+ num = pnum + 1;
+ }
+ pnum = num;
+ buf_line(t, "\n%s%lld %s\n", pser, num, vstr(v, "description"));
+ yyjson_val *rows = yyjson_obj_get(v, "rows");
+ size_t nr = yyjson_arr_size(rows);
+ for (size_t k = 0; k < nr; k++) {
+ yyjson_val *r = yyjson_arr_get(rows, k);
+ char a1[48], a2[48];
+ amt_col(a1, sizeof a1, 15, vint(r, "debit_ore"));
+ amt_col(a2, sizeof a2, 15, vint(r, "credit_ore"));
+ buf_line(t, " %-5s %s %s\n", vstr(r, "account"), a1, a2);
+ }
+ }
+}
+
+static void bokslut_plan(struct app *a, const char *fond, const char *rate,
+ int do_post)
+{
+ int64_t fond_ore = 0, rate_ore = 0;
+ if (parse_kr(fond, &fond_ore) != 0) {
+ message("Bokslut", "Ogiltigt fondbelopp: %s", fond);
+ return;
+ }
+ if (parse_kr(rate, &rate_ore) != 0 || rate_ore < 0 || rate_ore > 10000) {
+ message("Bokslut", "Ogiltig skattesats: %s", rate);
+ return;
+ }
+ char args[160];
+ snprintf(args, sizeof args,
+ "{\"fiscal_year\":%lld,\"periodiseringsfond_ore\":%lld,"
+ "\"tax_rate\":%.2f}",
+ (long long)a->fy, (long long)fond_ore,
+ (double)rate_ore / 100.0);
+ char *resp = rpc_dry(a, "bokslut.post", args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Bokslut", resp);
+ free(resp);
+ return;
+ }
+ yyjson_doc *d = parse(resp);
+ yyjson_val *res = d ? jget(yyjson_doc_get_root(d), "result") : NULL;
+ struct buf t;
+ buf_init(&t);
+ if (res)
+ fmt_bokslut_plan(&t, res);
+ else
+ buf_line(&t, "%s", resp);
+ buf_append(&t, "", 1);
+ text_view("Bokslut - plan (torrkörning)", (const char *)t.p, NULL);
+ buf_free(&t);
+ yyjson_doc_free(d);
+ free(resp);
+ if (!do_post)
+ return;
+ char *ans = prompt("Bokför planen? (j/n): ", "n", 0);
+ if (!ans || !(ans[0] == 'j' || ans[0] == 'J'))
+ return;
+ char *presp = client_rpc(&a->conn, "bokslut.post", a->session, a->org,
+ args);
+ if (!presp || !client_ok(presp)) {
+ show_error("Bokslut", presp);
+ free(presp);
+ return;
+ }
+ yyjson_doc *pd = parse(presp);
+ yyjson_val *pr = pd ? jget(yyjson_doc_get_root(pd), "result") : NULL;
+ struct buf m;
+ buf_init(&m);
+ buf_line(&m, "Bokslutet är bokfört:");
+ yyjson_val *vs = pr ? yyjson_obj_get(pr, "vouchers") : NULL;
+ size_t n = yyjson_arr_size(vs);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *v = yyjson_arr_get(vs, i);
+ yyjson_val *pv = yyjson_obj_get(v, "voucher");
+ buf_line(&m, "\n %s%lld %s", vstr(pv, "series"),
+ (long long)vint(pv, "number"), vstr(v, "description"));
+ }
+ message("Bokslut", "%s", (const char *)m.p);
+ buf_free(&m);
+ yyjson_doc_free(pd);
+ free(presp);
+}
+
+static void bokslut_screen(struct app *a)
+{
+ char fond[32] = "0,00";
+ char rate[16] = "20,6";
+ int sel = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ frame("Bokslut");
+ attron(A_BOLD);
+ mvaddstr(4, 2, "Fält");
+ mvaddstr(4, 34, "Värde");
+ attroff(A_BOLD);
+ static const char *const labels[2] = {
+ "Periodiseringsfond (kr)", "Skattesats (%)"
+ };
+ const char *vals[2] = { fond, rate };
+ for (int i = 0; i < 2; i++) {
+ char lbl[64], line[128];
+ snprintf(lbl, sizeof lbl, "%s", labels[i]);
+ pad_field(lbl, sizeof lbl, 31);
+ snprintf(line, sizeof line, "%s %s", lbl, vals[i]);
+ if (i == sel)
+ attron(A_REVERSE);
+ mvaddnstr(6 + i, 2, line, COLS - 4);
+ if (i == sel)
+ attroff(A_REVERSE);
+ }
+ mvaddstr(9, 2, "Fondbelopp i kr, tomt = ingen avsättning.");
+ mvaddstr(10, 2, "Skattesatsen tillämpas på resultat efter"
+ " bokslutsdispositioner.");
+ hints("upp/ned Enter = ändra F5 = visa plan ^Enter = bokför"
+ " Esc/q = tillbaka ^C = avsluta");
+ refresh();
+ int ch = ui_getch();
+ if (ch == KEY_UP && sel > 0)
+ sel--;
+ else if (ch == KEY_DOWN && sel < 1)
+ sel++;
+ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
+ char label[80];
+ snprintf(label, sizeof label, "%s: ", labels[sel]);
+ char *v = prompt(label, vals[sel], 0);
+ if (!v)
+ continue;
+ int64_t ore = 0;
+ if (parse_kr(v, &ore) != 0 ||
+ (sel == 1 && (ore < 0 || ore > 10000))) {
+ message("Bokslut", "Ogiltigt värde: %s", v);
+ continue;
+ }
+ if (sel == 0)
+ snprintf(fond, sizeof fond, "%s", v);
+ else
+ snprintf(rate, sizeof rate, "%s", v);
+ } else if (ch == KEY_F(5)) {
+ bokslut_plan(a, fond, rate, 0);
+ } else if (ch == KEY_CTRL_ENTER || ch == KEY_F(9)) {
+ bokslut_plan(a, fond, rate, 1);
+ } else if (ch == 27 || ch == 'q') {
+ return;
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* K2 årsredovisning (draft text) */
+/* ------------------------------------------------------------------ */
+
+static void ar_amounts(struct buf *t, const char *acct, const char *label,
+ int64_t cur, int64_t prev)
+{
+ char nm[160], a1[48], a2[48];
+ pad_label(nm, sizeof nm, label, 48);
+ amt_col(a1, sizeof a1, 15, cur);
+ amt_col(a2, sizeof a2, 15, prev);
+ buf_line(t, " %-5s %s %s %s\n", acct ? acct : "", nm, a1, a2);
+}
+
+static int64_t ar_is_sum(yyjson_val *res, int lo, int hi)
+{
+ int64_t s = 0;
+ if (!res)
+ return 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);
+ if (!acct_in(vstr(row, "account"), lo, hi))
+ continue;
+ int64_t a = vint(row, "amount_ore");
+ s += k == 0 ? a : -a;
+ }
+ }
+ return s;
+}
+
+static void ar_is_rows(struct buf *t, yyjson_val *res, yyjson_val *prev,
+ int lo, int hi, int64_t *cur, int64_t *pre)
+{
+ 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, lo, hi))
+ continue;
+ int64_t c = k == 0 ? vint(row, "amount_ore")
+ : -vint(row, "amount_ore");
+ int64_t p = is_prev_lookup(prev, acc, k != 0);
+ ar_amounts(t, acc, vstr(row, "name"), c, p);
+ *cur += c;
+ *pre += p;
+ }
+ }
+}
+
+static void ar_is_group(struct buf *t, yyjson_val *res, yyjson_val *prev,
+ const struct is_group *gr, int64_t *cur,
+ int64_t *pre)
+{
+ 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);
+ ar_amounts(t, acc, vstr(row, "name"), c, p);
+ *cur += c;
+ *pre += p;
+ }
+ }
+}
+
+static int64_t ar_bs_ib_sum(yyjson_val *res, int lo, int hi)
+{
+ int64_t s = 0;
+ if (!res)
+ return 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);
+ if (!acct_in(vstr(row, "account"), lo, hi))
+ continue;
+ int64_t ib = vint(row, "ib_ore");
+ s += lo >= 2000 ? -ib : ib;
+ }
+ }
+ return s;
+}
+
+/* Balance-sheet amounts are shown the way an annual report reads them:
+ credit balances (equity, liabilities) as positive numbers. */
+static int64_t ar_bs_sum(yyjson_val *res, int lo, int hi)
+{
+ int64_t s = 0;
+ if (!res)
+ return 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);
+ if (!acct_in(vstr(row, "account"), lo, hi))
+ continue;
+ int64_t ub = vint(row, "ub_ore");
+ s += lo >= 2000 ? -ub : ub;
+ }
+ }
+ return s;
+}
+
+static int ar_bs_rows(struct buf *t, yyjson_val *res, int lo, int hi,
+ int64_t *cur, int64_t *pre)
+{
+ 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 ub = vint(row, "ub_ore"), ib = vint(row, "ib_ore");
+ if (lo >= 2000) {
+ ub = -ub;
+ ib = -ib;
+ }
+ if (t)
+ ar_amounts(t, acc, vstr(row, "name"), ub, ib);
+ if (cur)
+ *cur += ub;
+ if (pre)
+ *pre += ib;
+ found = 1;
+ }
+ }
+ return found;
+}
+
+static int ar_bs_group(struct buf *t, yyjson_val *res, const char *title,
+ const char *sumlabel, int lo, int hi, int64_t *cur,
+ int64_t *pre)
+{
+ int64_t c = 0, p = 0;
+ if (!ar_bs_rows(NULL, res, lo, hi, &c, &p))
+ return 0;
+ buf_line(t, "%s\n", title);
+ ar_bs_rows(t, res, lo, hi, NULL, NULL);
+ ar_amounts(t, "", sumlabel, c, p);
+ if (cur)
+ *cur += c;
+ if (pre)
+ *pre += p;
+ return 1;
+}
+
+static int64_t ar_is_result(yyjson_val *is)
+{
+ return ar_is_sum(is, 3000, 3999) + ar_is_sum(is, 4000, 8989);
+}
+
+static int64_t ar_resultat(struct buf *t, yyjson_val *is, yyjson_val *prev,
+ const char *cur_lbl, const char *prev_lbl)
+{
+ buf_line(t, "\nResultaträkning\n\n");
+ char h1[32], h2[32], blank[64];
+ pad_label(blank, sizeof blank, "", 48);
+ pad_hdr(h1, sizeof h1, 15, cur_lbl);
+ pad_hdr(h2, sizeof h2, 15, prev_lbl);
+ buf_line(t, " %5s %s %s %s\n", "", blank, h1, h2);
+ buf_rule(t, 88);
+ const char *sect = NULL;
+ int64_t rev = 0, rev_pre = 0, exp = 0, exp_pre = 0;
+ int rev_groups = 0, exp_groups = 0, rev_summed = 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(is, gr))
+ continue;
+ if (!sect || strcmp(sect, gr->section) != 0) {
+ if (sect && rev_groups && !rev_summed) {
+ ar_amounts(t, "", "Summa rörelseintäkter,"
+ " lagerförändringar m.m.", rev, rev_pre);
+ rev_summed = 1;
+ buf_line(t, "\n");
+ }
+ buf_line(t, "%s\n", gr->section);
+ sect = gr->section;
+ }
+ buf_line(t, "%s\n", gr->title);
+ int64_t sc = 0, sp = 0;
+ ar_is_group(t, is, prev, gr, &sc, &sp);
+ if (gr->rev) {
+ rev += sc;
+ rev_pre += sp;
+ rev_groups++;
+ } else {
+ exp += sc;
+ exp_pre += sp;
+ exp_groups++;
+ }
+ }
+ if (rev_groups && !rev_summed)
+ ar_amounts(t, "", "Summa rörelseintäkter, lagerförändringar m.m.",
+ rev, rev_pre);
+ if (exp_groups)
+ ar_amounts(t, "", "Summa rörelsekostnader", exp, exp_pre);
+ int64_t ror = rev + exp, ror_pre = rev_pre + exp_pre;
+ int64_t fin = 0, fin_pre = 0, disp = 0, disp_pre = 0;
+ int64_t tax = 0, tax_pre = 0;
+ if (is_range_has(is, 8000, 8599)) {
+ buf_line(t, "\nFinansiella poster\n");
+ ar_is_rows(t, is, prev, 8000, 8599, &fin, &fin_pre);
+ ar_amounts(t, "", "Summa", fin, fin_pre);
+ }
+ int64_t after = ror + fin, after_pre = ror_pre + fin_pre;
+ buf_line(t, "\n");
+ buf_rule(t, 88);
+ ar_amounts(t, "", "Rörelseresultat", ror, ror_pre);
+ ar_amounts(t, "", "Resultat efter finansiella poster", after, after_pre);
+ if (is_range_has(is, 8800, 8899)) {
+ buf_line(t, "\nBokslutsdispositioner\n");
+ ar_is_rows(t, is, prev, 8800, 8899, &disp, &disp_pre);
+ ar_amounts(t, "", "Summa", disp, disp_pre);
+ }
+ int64_t btax = after + disp, btax_pre = after_pre + disp_pre;
+ ar_amounts(t, "", "Resultat före skatt", btax, btax_pre);
+ if (is_range_has(is, 8900, 8989)) {
+ buf_line(t, "\nSkatt på årets resultat\n");
+ ar_is_rows(t, is, prev, 8900, 8989, &tax, &tax_pre);
+ }
+ int64_t ars = btax + tax, ars_pre = btax_pre + tax_pre;
+ buf_line(t, "\n");
+ buf_rule(t, 88);
+ ar_amounts(t, "", "Årets resultat", ars, ars_pre);
+ return ars;
+}
+
+static int64_t ar_equity(yyjson_val *bs, int64_t result)
+{
+ return ar_bs_sum(bs, 2000, 2090) + ar_bs_sum(bs, 2091, 2098) + result;
+}
+
+static void ar_balans(struct buf *t, yyjson_val *bs, const char *cur_lbl,
+ const char *prev_lbl, int64_t ars, int64_t ars_pre)
+{
+ buf_line(t, "\nBalansräkning\n\n");
+ char h1[32], h2[32], blank[64];
+ pad_label(blank, sizeof blank, "", 48);
+ pad_hdr(h1, sizeof h1, 15, cur_lbl);
+ pad_hdr(h2, sizeof h2, 15, prev_lbl);
+ buf_line(t, " %5s %s %s %s\n", "", blank, h1, h2);
+ buf_rule(t, 88);
+ buf_line(t, "TILLGÅNGAR\n");
+ int64_t anl = 0, anl_p = 0, oms = 0, oms_p = 0;
+ ar_bs_group(t, bs, "Anläggningstillgångar",
+ "Summa anläggningstillgångar", 1000, 1399, &anl, &anl_p);
+ buf_line(t, "\nOmsättningstillgångar\n");
+ 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 },
+ };
+ for (size_t i = 0; i < sizeof OMS / sizeof OMS[0]; i++)
+ ar_bs_group(t, bs, OMS[i].title, OMS[i].sum, OMS[i].lo, OMS[i].hi,
+ &oms, &oms_p);
+ ar_amounts(t, "", "Summa omsättningstillgångar", oms, oms_p);
+ int64_t tok = anl + oms, tok_p = anl_p + oms_p;
+ buf_line(t, "\n");
+ buf_rule(t, 88);
+ ar_amounts(t, "", "SUMMA TILLGÅNGAR", tok, tok_p);
+ buf_line(t, "\nEGET KAPITAL OCH SKULDER\n");
+ int64_t eget = 0, eget_p = 0;
+ ar_bs_group(t, bs, "Bundet eget kapital", "Summa bundet eget kapital",
+ 2000, 2090, &eget, &eget_p);
+ buf_line(t, "\n");
+ int64_t free_c = 0, free_p = 0;
+ ar_bs_rows(NULL, bs, 2091, 2098, &free_c, &free_p);
+ buf_line(t, "Fritt eget kapital\n");
+ ar_bs_rows(t, bs, 2091, 2098, NULL, NULL);
+ ar_amounts(t, "", "Årets resultat", ars, ars_pre);
+ free_c += ars;
+ free_p += ars_pre;
+ ar_amounts(t, "", "Summa fritt eget kapital", free_c, free_p);
+ eget += free_c;
+ eget_p += free_p;
+ buf_line(t, "\n");
+ ar_amounts(t, "", "Summa eget kapital", eget, eget_p);
+ int64_t skuld = 0, skuld_p = 0;
+ buf_line(t, "\n");
+ if (ar_bs_group(t, bs, "Obeskattade reserver",
+ "Summa obeskattade reserver", 2100, 2199, &skuld,
+ &skuld_p))
+ buf_line(t, "\n");
+ if (ar_bs_group(t, bs, "Avsättningar", "Summa avsättningar", 2200, 2299,
+ &skuld, &skuld_p))
+ buf_line(t, "\n");
+ if (ar_bs_group(t, bs, "Långfristiga skulder",
+ "Summa långfristiga skulder", 2300, 2399, &skuld,
+ &skuld_p))
+ buf_line(t, "\n");
+ ar_bs_group(t, bs, "Kortfristiga skulder", "Summa kortfristiga skulder",
+ 2400, 2999, &skuld, &skuld_p);
+ buf_line(t, "\n");
+ buf_rule(t, 88);
+ ar_amounts(t, "", "SUMMA EGET KAPITAL OCH SKULDER", eget + skuld,
+ eget_p + skuld_p);
+}
+
+static void fmt_arsredovisning(struct buf *t, const struct app *a,
+ const char *org_nr, const char *city,
+ yyjson_val *is, yyjson_val *prev_is,
+ yyjson_val *bs, int64_t dividend)
+{
+ const char *cur_lbl = vstr(yyjson_obj_get(is, "fiscal_year"), "label");
+ const char *prev_lbl =
+ prev_is ? vstr(yyjson_obj_get(prev_is, "fiscal_year"), "label") : "";
+ int64_t ars = ar_is_result(is);
+ int64_t prev_ars = ar_is_result(prev_is);
+ buf_line(t, "ÅRSREDOVISNING\n\n%s\nOrganisationsnummer %s\n",
+ a->org_name, org_nr);
+ buf_line(t, "\nRäkenskapsåret %s - %s\n", a->fy_start, a->fy_end);
+ buf_line(t, "\nFörvaltningsberättelse\n\nVerksamhet\n");
+ buf_line(t, "Bolaget bedriver [kort beskrivning av verksamheten]."
+ " Verksamheten bedrivs från %s.\n", city);
+ buf_line(t, "\nVäsentliga händelser under räkenskapsåret\n");
+ buf_line(t, "[Beskriv väsentliga händelser, eller skriv att inga"
+ " väsentliga händelser har inträffat.]\n");
+ buf_line(t, "\nEkonomisk översikt\n\n");
+ buf_line(t, " %-40s %15s %15s\n", "", cur_lbl, prev_lbl);
+ buf_rule(t, 72);
+ ar_amounts(t, "", "Nettoomsättning", ar_is_sum(is, 3000, 3799),
+ prev_is ? ar_is_sum(prev_is, 3000, 3799) : 0);
+ int64_t ror = ar_is_sum(is, 3000, 3999) + ar_is_sum(is, 4000, 7999);
+ int64_t ror_p = prev_is ? ar_is_sum(prev_is, 3000, 3999) +
+ ar_is_sum(prev_is, 4000, 7999)
+ : 0;
+ ar_amounts(t, "", "Rörelseresultat", ror, ror_p);
+ ar_amounts(t, "", "Årets resultat", ars, prev_ars);
+ ar_amounts(t, "", "Balansomslutning",
+ vint(yyjson_obj_get(bs, "assets"), "total_ore"),
+ ar_bs_ib_sum(bs, 1000, 1999));
+ ar_amounts(t, "", "Eget kapital", ar_equity(bs, ars),
+ ar_bs_ib_sum(bs, 2000, 2090) + ar_bs_ib_sum(bs, 2091, 2098) +
+ prev_ars);
+ if (prev_is && prev_ars == 0 && ar_is_sum(prev_is, 3000, 3799) == 0)
+ buf_line(t, "\n[Jämförelsetalen för %s är inte kompletta i den"
+ " löpande bokföringen\n(historikåret importerades med"
+ " källsystemets stängningsverifikat). Hämta dem från\n"
+ "föregående års årsredovisning.]\n", prev_lbl);
+ int64_t disponibelt = ar_bs_sum(bs, 2091, 2098) + ars;
+ buf_line(t, "\nFörslag till disposition av bolagets resultat\n\n");
+ ar_amounts(t, "", "Balanserade vinstmedel", ar_bs_sum(bs, 2091, 2098),
+ 0);
+ ar_amounts(t, "", "Årets resultat", ars, 0);
+ ar_amounts(t, "", "Summa", disponibelt, 0);
+ {
+ char tmpd[48], tmpr[48];
+ kr_format(dividend, tmpd, sizeof tmpd);
+ kr_format(disponibelt - dividend, tmpr, sizeof tmpr);
+ if (dividend > 0)
+ buf_line(t, "\nStyrelsen föreslår att vinstmedlen disponeras så"
+ " att\n till aktieägarna utdelas %s kr och\n"
+ " i ny räkning överförs %s kr.\n",
+ tmpd, tmpr);
+ else
+ buf_line(t, "\nStyrelsen föreslår att vinstmedlen disponeras så"
+ " att i ny räkning överförs %s kr.\n", tmpr);
+ }
+ ar_resultat(t, is, prev_is, cur_lbl, prev_lbl);
+ ar_balans(t, bs, cur_lbl, prev_lbl, ars, prev_ars);
+ buf_line(t, "\nNoter\n\nNot 1 Redovisningsprinciper\n");
+ buf_line(t, "Årsredovisningen har upprättats enligt"
+ " årsredovisningslagen och BFNAR 2016:10 Årsredovisning i"
+ " mindre företag (K2). Tillgångar och skulder värderas till"
+ " anskaffningsvärde.\n");
+ buf_line(t, "\nNot 2 Medelantal anställda\n");
+ buf_line(t, "Medelantalet anställda under räkenskapsåret har uppgått"
+ " till [antal].\n");
+ int64_t fond_avs = -(ar_is_sum(is, 8811, 8811));
+ if (fond_avs != 0 || ar_bs_sum(bs, 2110, 2199) != 0) {
+ buf_line(t, "\nNot 3 Periodiseringsfond\n");
+ buf_line(t, "Årets avsättning till periodiseringsfond uppgår till"
+ " %lld kr. Utgående balans uppgår till %lld kr.\n",
+ (long long)(fond_avs / 100),
+ (long long)(ar_bs_sum(bs, 2110, 2199) / 100));
+ }
+ buf_line(t, "\nUnderskrifter\n\n%s den [datum]\n\n", city);
+ buf_line(t, "____________________________ "
+ "____________________________\n");
+ buf_line(t, "[Namn], styrelseledamot "
+ "[Namn], styrelseledamot\n");
+ buf_line(t, "\nRevisionsberättelse\n");
+ buf_line(t, "[Bolaget har inte någon revisor; någon revisionsberättelse"
+ " har därför inte lämnats.]\n");
+}
+
+static void arsredovisning_save(struct app *a, const char *text)
+{
+ const char *home = getenv("HOME");
+ char def[512];
+ snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : ".");
+ struct stat sb;
+ if (stat(def, &sb) != 0 || !S_ISDIR(sb.st_mode))
+ snprintf(def, sizeof def, "%s", home && *home ? home : ".");
+ char *dir = prompt("Spara årsredovisningen i: ", def, 0);
+ if (!dir || !*dir)
+ return;
+ char full[640];
+ if (dir[0] == '~' && (dir[1] == '/' || dir[1] == '\0'))
+ snprintf(full, sizeof full, "%s%s", home ? home : "", dir + 1);
+ else
+ snprintf(full, sizeof full, "%s", dir);
+ size_t fl = strlen(full);
+ if (fl && full[fl - 1] != '/')
+ snprintf(full + fl, sizeof full - fl, "/");
+ snprintf(full + strlen(full), sizeof full - strlen(full),
+ "Årsredovisning %.4s-%.4s.txt", a->fy_start, a->fy_end);
+ FILE *f = fopen(full, "wb");
+ size_t n = strlen(text);
+ if (!f || fwrite(text, 1, n, f) != n) {
+ if (f)
+ fclose(f);
+ message("Årsredovisning", "Kunde inte skriva %s: %s", full,
+ strerror(errno));
+ return;
+ }
+ fclose(f);
+ message("Årsredovisning", "Sparat: %s\n\nFilen är ett utkast i textform"
+ " att redigera vidare (t.ex. i Word).", full);
+}
+
+static void arsredovisning_screen(struct app *a)
+{
+ int have_div = 0;
+ int64_t div_ore = 0;
+ for (;;) {
+ char args[64];
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ char *iresp =
+ client_rpc(&a->conn, "report.income_statement", a->session,
+ a->org, args);
+ char *bresp = client_rpc(&a->conn, "report.balance_sheet", a->session,
+ a->org, args);
+ char *oresp =
+ client_rpc(&a->conn, "org.get", a->session, a->org, "{}");
+ if (!iresp || !client_ok(iresp) || !bresp || !client_ok(bresp)) {
+ show_error("Årsredovisning",
+ iresp && !client_ok(iresp) ? iresp : bresp);
+ free(iresp);
+ free(bresp);
+ free(oresp);
+ return;
+ }
+ char *pis_resp = NULL;
+ int64_t pf = prev_fy_id(a);
+ if (pf) {
+ char pargs[64];
+ snprintf(pargs, sizeof pargs, "{\"fiscal_year\":%lld}",
+ (long long)pf);
+ pis_resp = client_rpc(&a->conn, "report.income_statement",
+ a->session, a->org, pargs);
+ }
+ yyjson_doc *id = parse(iresp), *bd = parse(bresp);
+ yyjson_doc *pid = pis_resp ? parse(pis_resp) : NULL;
+ yyjson_val *is = id ? jget(yyjson_doc_get_root(id), "result") : NULL;
+ yyjson_val *bs = bd ? jget(yyjson_doc_get_root(bd), "result") : NULL;
+ yyjson_val *pis =
+ pid ? jget(yyjson_doc_get_root(pid), "result") : NULL;
+ char *onr = oresp ? jstr_dup(oresp, "result.org_nr") : NULL;
+ char *city = oresp ? jstr_dup(oresp, "result.city") : NULL;
+ if (is && bs && !have_div) {
+ char fargs[48];
+ snprintf(fargs, sizeof fargs, "{\"id\":%lld}", (long long)a->fy);
+ char *fresp = client_rpc(&a->conn, "fiscal_year.get", a->session,
+ a->org, fargs);
+ if (fresp && client_ok(fresp))
+ div_ore = jint_val(fresp, "result.dividend_ore", 0);
+ free(fresp);
+ have_div = 1;
+ int64_t dispon = ar_bs_sum(bs, 2091, 2098) + ar_is_result(is);
+ char cur[48];
+ kr_format(div_ore, cur, sizeof cur);
+ char *val = prompt("Föreslagen utdelning (kr): ", cur, 0);
+ if (val) {
+ int64_t v = 0;
+ char dtmp[48];
+ kr_format(dispon, dtmp, sizeof dtmp);
+ if (parse_kr(val, &v) != 0)
+ message("Årsredovisning", "Ogiltigt belopp: %s", val);
+ else if (v > dispon)
+ message("Årsredovisning", "Utdelningen överstiger"
+ " disponibla medel (%s kr).",
+ dtmp);
+ else if (v != div_ore) {
+ char uargs[96];
+ snprintf(uargs, sizeof uargs,
+ "{\"id\":%lld,\"dividend_ore\":%lld}",
+ (long long)a->fy, (long long)v);
+ char *uresp = client_rpc(&a->conn, "fiscal_year.update",
+ a->session, a->org, uargs);
+ if (uresp && client_ok(uresp))
+ div_ore = v;
+ else
+ show_error("Årsredovisning", uresp);
+ free(uresp);
+ }
+ }
+ }
+ struct buf t;
+ buf_init(&t);
+ if (is && bs)
+ fmt_arsredovisning(&t, a, onr && *onr ? onr : "[org.nr]",
+ city && *city ? city : "[Ort]", is, pis, bs,
+ div_ore);
+ else
+ buf_line(&t, "Kunde inte läsa rapporterna.");
+ buf_append(&t, "", 1);
+ int again =
+ text_view("Årsredovisning (K2)", (const char *)t.p, "s = spara");
+ if (again == 2)
+ arsredovisning_save(a, (const char *)t.p);
+ buf_free(&t);
+ free(onr);
+ free(city);
+ yyjson_doc_free(id);
+ yyjson_doc_free(bd);
+ yyjson_doc_free(pid);
+ free(iresp);
+ free(bresp);
+ free(oresp);
+ free(pis_resp);
+ if (again != 1 && again != 2)
+ return;
+ }
+}
+
/* Screen names for ^R restoration and --screen. Only the top-level
views; sub-screens unwind to their parent. */
static void open_scene(struct app *a, const char *scene)
@@ -5554,6 +6312,8 @@ static void open_scene(struct app *a, const char *scene)
settings_screen(a);
else if (strcmp(scene, "company") == 0)
company_screen(a);
+ else if (strcmp(scene, "bokslut") == 0)
+ bokslut_screen(a);
}
static void do_reload(struct app *a, const char *self)
@@ -5590,8 +6350,8 @@ static void do_reload(struct app *a, const char *self)
static const char *const MAIN_ITEMS[] = {
"Verifikat", "Underlag (inkorg)", "Ingående balans",
"Mallar", "Rapporter", "Revision",
- "Inställningar", "Företagsuppgifter", "Räkenskapsår",
- "Logga ut / avsluta",
+ "Inställningar", "Företagsuppgifter", "Bokslut",
+ "Räkenskapsår", "Logga ut / avsluta",
};
static void dashboard(struct app *a)
@@ -5600,7 +6360,7 @@ static void dashboard(struct app *a)
for (;;) {
if (g_reload)
return;
- int sel = menu("Bokf", MAIN_ITEMS, 10, 1, &last_sel);
+ int sel = menu("Bokf", MAIN_ITEMS, 11, 1, &last_sel);
snprintf(g_scene, sizeof g_scene, "%s", "dashboard");
if (g_quit || g_reload)
return;
@@ -5638,9 +6398,12 @@ static void dashboard(struct app *a)
open_scene(a, "company");
break;
case 8:
- select_fiscal_year(a);
+ open_scene(a, "bokslut");
break;
case 9:
+ select_fiscal_year(a);
+ break;
+ case 10:
return;
}
}
@@ -5978,7 +6741,8 @@ static void usage(FILE *f)
" --fy ID select fiscal year directly\n"
" --session ID reuse an open session (or BOKFD_SESSION)\n"
" --screen NAME start in a view: dashboard, vouchers, inbox,\n"
- " ib, templates, reports, audit, settings, company\n"
+ " ib, templates, reports, bokslut, audit,\n"
+ " settings, company\n"
" --version\n");
}
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 8ee1b2c..6f617fd 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -288,6 +288,7 @@ Account `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`.
| `fiscal_year.open` | `label`, `start_date`, `end_date` | owner |
| `fiscal_year.close` | `id`, `confirm:true` | owner; audited |
| `fiscal_year.reopen` | `id`, `confirm:true` | owner; undoes a close |
+| `fiscal_year.update` | `id`, `dividend_ore` | bookkeeper; year metadata (proposed dividend), audited |
| `period.lock` | `fiscal_year`, `until`, `reason?` | owner; `until` inclusive |
| `period.unlock` | `fiscal_year`, `reason` | owner; audited with reason |
@@ -470,8 +471,15 @@ commands. Implemented screens (0.1.0-dev):
vouchers (Tab, dynamic rows, F7 clear row, F5 validate, F9 save); archive
keeps the template but hides it from the list.
- **Underlag** — inbox of unlinked attachments; `a` uploads a file.
-- **Rapporter** — saldobalans, resultaträkning, balansräkning, moms and
+- **Rapporter** — saldobalans, resultaträkning, balansräkning, moms,
+ inkomstdeklaration (INK2/SRU), årsredovisning (K2 text draft) and
kontolista (all accounts with type, moms treatment, SRU and status).
+ `s` saves the SRU files, eSKD XML and the årsredovisning respectively.
+ The draft asks for the board's proposed dividend, kept per fiscal year
+ with `fiscal_year.update`.
+- **Bokslut** — periodiseringsfond and tax rate fields; F5 shows the posting
+ plan as a `bokslut.post` dry run, `^Enter` (or F9) asks for confirmation
+ and posts the plan.
- **Företagsuppgifter** — the org record (name, org number, VAT number,
address, e-mail, phone, moms period, framework, fiscal-year start month),
editable in place; only owners can save, others see it read-only.
diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md
index db03e43..4fc4397 100644
--- a/docs/SCHEMA.md
+++ b/docs/SCHEMA.md
@@ -148,6 +148,7 @@ CREATE TABLE fiscal_years (
end_date TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')),
locked_until TEXT, -- inclusive last locked date
+ dividend_ore INTEGER NOT NULL DEFAULT 0, -- board's proposed dividend
created_at TEXT NOT NULL,
closed_at TEXT,
closed_by INTEGER REFERENCES users(id),
diff --git a/docs/STATE.md b/docs/STATE.md
index 5be11e7..b0f5b4a 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-18.
+and `docs/TUI-GUIDELINES.md` (UI conventions). Dated 2026-09-19.
## Status
@@ -86,8 +86,12 @@ server/protocol/ledger only.
SHA-256 verified, text inline), attach to an existing voucher (`^F`),
remove a link (`d` in the `f` picker) and link an inbox item to a voucher
(`k`).
-- Next backend milestone: **K2/SRU** (INK2/INK2R/INK2S + årsredovisning);
- bokslut.post is done server-side, its TUI screen pending.
+- K2/SRU is complete: `sru.export` with the INK2 view, `bokslut.post` with
+ the Bokslut screen, and a K2 årsredovisning text draft with a save action
+ in Rapporter. The draft asks for the board's proposed dividend and keeps it
+ per fiscal year (`fiscal_year.update`, schema v4); the stämma decision is
+ posted with a `Utdelning` template (D 2099/K 2898). Still missing before
+ filing: a command/TUI to edit the moms `report_rules` per org (below).
- 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).
@@ -96,12 +100,17 @@ server/protocol/ledger only.
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: `bokslut.post` is in (entries/avskrivningar,
+2. ~~Bokslut automation~~ done: `bokslut.post` (entries/avskrivningar,
periodiseringsfond, skatt at a given rate, resultatdisposition, dry-run
- plan, audited) and the resultatrapport separates bokslutsdispositioner
- and skatt per K2. TUI screen still to do.
-3. K2 årsredovisning document; ~~SRU files (INK2/INK2R/INK2S)~~ done as
- `sru.export` (official 2025P4 field tables, BAS mapping, TUI save in
+ plan, audited), the TUI Bokslut screen (fond/rate, F5 plan, ^Enter posts
+ after confirmation) and the K2 resultatrapport order.
+3. ~~K2 årsredovisning document~~ done as a text draft built in the TUI from
+ `report.income_statement`/`report.balance_sheet` (Förvaltningsberättelse,
+ K2 RR/BR with previous-year column and the result inside equity, noter,
+ underskrifter); `s` saves `Årsredovisning <år>.txt`. Placeholders mark
+ qualitative facts, and incomplete jämförelsetal for imported history
+ years are flagged in the document. ~~SRU files (INK2/INK2R/INK2S)~~ done
+ as `sru.export` (official 2025P4 field tables, BAS mapping, TUI save in
Rapporter -> Inkomstdeklaration).
4. `audit.verify` must also verify the **voucher** hash chain (today only the
audit chain is verified).
@@ -119,8 +128,10 @@ server/protocol/ledger only.
AGI/payroll if employees.
11a. Imported history years whose SIE contains the source's P&L closings
("Stäng intäktskonton/kostnadskonton") net to zero in the income
- statement (Kapitas 2022-2026). The importer should skip those closing
- verifications (keeping the resultatdisposition) or mark them; decide.
+ statement (Kapitas 2022-2026). **Decided 2026-09-19: no importer change.**
+ Locked years stay locked and the source's closings stay in the books; the
+ årsredovisning export flags incomplete jämförelsetal for those years and
+ points to the previous year's annual report.
11. ~~SIE import only into an empty fiscal year; consider broader import.~~
Chronological multi-year import works (CRLF, `#RAR 0`, zero rows, `#IB`
rule handled); each year must still target an empty fiscal year. Note:
diff --git a/src/commands.c b/src/commands.c
index f03e0e4..62de0be 100644
--- a/src/commands.c
+++ b/src/commands.c
@@ -1696,10 +1696,12 @@ static yyjson_mut_val *fy_json(struct req *r, sqlite3_stmt *st)
else
yyjson_mut_obj_add_strcpy(r->rdoc, o, "locked_until",
sq(sqlite3_column_text(st, 5)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore",
+ sqlite3_column_int64(st, 6));
return o;
}
-#define FY_COLUMNS "id,label,start_date,end_date,status,locked_until"
+#define FY_COLUMNS "id,label,start_date,end_date,status,locked_until,dividend_ore"
static yyjson_mut_val *h_fiscal_year_list(struct req *r)
{
@@ -1878,6 +1880,70 @@ static yyjson_mut_val *h_fiscal_year_reopen(struct req *r)
return yyjson_mut_obj(r->rdoc);
}
+/* Fiscal-year metadata that is not a posting: today only the board's
+ proposed dividend, kept for the årsredovisning draft. */
+static yyjson_mut_val *h_fiscal_year_update(struct req *r)
+{
+ int64_t id = 0, dividend = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ if (!arg_int(r->args, "dividend_ore", &dividend))
+ return fail(r, "INVALID_ARGS", "dividend_ore is required");
+ if (dividend < 0)
+ return fail(r, "INVALID_ARGS", "dividend_ore must be >= 0");
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(r->db,
+ "SELECT count(*) FROM fiscal_years"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ int64_t exists = 0;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ exists = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ if (!exists)
+ return fail(r, "NOT_FOUND", "fiscal year not found");
+ if (r->dry_run) {
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", dividend);
+ return o;
+ }
+ if (sqlite3_prepare_v2(r->db,
+ "UPDATE fiscal_years SET dividend_ore=?3"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ sqlite3_bind_int64(st, 3, dividend);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return fail(r, "INTERNAL", sqlite3_errmsg(r->db));
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "fiscal_year.update", reqjson, "OK", NULL);
+ free(reqjson);
+ if (sqlite3_prepare_v2(r->db,
+ "SELECT " FY_COLUMNS " FROM fiscal_years"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return fail(r, "INTERNAL", "database error");
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ yyjson_mut_val *o = NULL;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ o = fy_json(r, st);
+ sqlite3_finalize(st);
+ if (!o)
+ return fail(r, "NOT_FOUND", "fiscal year not found");
+ return o;
+}
+
static yyjson_mut_val *h_period_lock(struct req *r)
{
int64_t fy = 0;
@@ -4119,6 +4185,8 @@ const struct command g_commands[] = {
h_fiscal_year_close },
{ "fiscal_year.reopen", "Reopen a closed fiscal year", PERM_OWNER, 1, 1, 1,
h_fiscal_year_reopen },
+ { "fiscal_year.update", "Update fiscal-year metadata (dividend)",
+ PERM_WRITE, 1, 1, 1, h_fiscal_year_update },
{ "period.lock", "Lock a period through a date", PERM_OWNER, 1, 1, 1,
h_period_lock },
{ "period.unlock", "Remove a period lock", PERM_OWNER, 1, 1, 1,
diff --git a/src/db.c b/src/db.c
index 0de40ea..ebee37b 100644
--- a/src/db.c
+++ b/src/db.c
@@ -96,6 +96,7 @@ static const char SCHEMA_V1[] =
" end_date TEXT NOT NULL,"
" status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')),"
" locked_until TEXT,"
+ " dividend_ore INTEGER NOT NULL DEFAULT 0,"
" created_at TEXT NOT NULL,"
" closed_at TEXT,"
" closed_by INTEGER REFERENCES users(id),"
@@ -362,6 +363,15 @@ static int db_upgrade_v3(sqlite3 *db, char **err)
return 0;
}
+/* v4: the årsredovisning keeps the board's proposed dividend per year. */
+static int db_upgrade_v4(sqlite3 *db, char **err)
+{
+ return db_exec(db,
+ "ALTER TABLE fiscal_years ADD COLUMN dividend_ore"
+ " INTEGER NOT NULL DEFAULT 0",
+ err);
+}
+
static int db_upgrade(sqlite3 *db, int from, char **err)
{
if (db_exec(db, "BEGIN IMMEDIATE", err) != 0)
@@ -374,6 +384,10 @@ static int db_upgrade(sqlite3 *db, int from, char **err)
db_exec(db, "ROLLBACK", NULL);
return -1;
}
+ if (from < 4 && db_upgrade_v4(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 aa34c81..7353c3c 100644
--- a/src/db.h
+++ b/src/db.h
@@ -4,7 +4,7 @@
#include <sqlite3.h>
#include <stdint.h>
-#define BOKF_SCHEMA_VERSION 3
+#define BOKF_SCHEMA_VERSION 4
int db_open(const char *path, sqlite3 **out, char **err);
int db_migrate(sqlite3 *db, char **err);
diff --git a/tests/test_core.c b/tests/test_core.c
index 6520d4b..0dfdd08 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -699,6 +699,28 @@ int main(void)
CHECK(fy1 > 0);
yyjson_doc_free(d);
+ d = call(reqf("{\"v\":1,\"id\":\"46a\",\"cmd\":\"fiscal_year.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"dividend_ore\":123456}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.dividend_ore") == 123456);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"46b\",\"cmd\":\"fiscal_year.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.dividend_ore") == 123456);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"46c\",\"cmd\":\"fiscal_year.update\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld,"
+ "\"dividend_ore\":-1}}",
+ g_session, (int)org_id, (long long)fy1));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ yyjson_doc_free(d);
+
/* sale: 1250.00 kr incl 25% moms */
d = call(reqf("{\"v\":1,\"id\":\"47\",\"cmd\":\"voucher.post\","
"\"session\":\"%s\",\"org\":%d,\"args\":{\"date\":"