diff options
| -rw-r--r-- | clients/bokftui.c | 1252 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 10 | ||||
| -rw-r--r-- | docs/SCHEMA.md | 16 | ||||
| -rw-r--r-- | docs/STATE.md | 18 | ||||
| -rw-r--r-- | src/commands.c | 336 | ||||
| -rw-r--r-- | src/db.c | 65 | ||||
| -rw-r--r-- | src/db.h | 2 | ||||
| -rw-r--r-- | tests/test_core.c | 146 |
8 files changed, 1386 insertions, 459 deletions
diff --git a/clients/bokftui.c b/clients/bokftui.c index 69fe3a3..ade7948 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -5221,7 +5221,7 @@ static void ib_screen(struct app *a) struct company_field { const char *key; const char *label; - int numeric; + int numeric; /* 0 = text, 1 = month (1-12), 2 = unsigned integer */ }; static const struct company_field COMPANY_FIELDS[] = { @@ -5234,6 +5234,8 @@ static const struct company_field COMPANY_FIELDS[] = { { "country", "Land", 0 }, { "email", "E-post", 0 }, { "phone", "Telefon", 0 }, + { "description", "Verksamhetsbeskrivning", 0 }, + { "shares", "Antal aktier", 2 }, { "moms_period", "Momsperiod (month/quarter/year)", 0 }, { "framework", "Regelverk (K2/K3)", 0 }, { "fiscal_year_start_month", "Startmånad räkenskapsår (1-12)", 1 }, @@ -5244,7 +5246,9 @@ static int company_field_valid(const struct company_field *f, const char *v) if (f->numeric) { char *end = NULL; long n = strtol(v, &end, 10); - return end && *end == '\0' && n >= 1 && n <= 12; + if (!end || *end != '\0') + return 0; + return f->numeric == 2 ? n >= 0 : (n >= 1 && n <= 12); } if (strcmp(f->key, "moms_period") == 0) return strcmp(v, "month") == 0 || strcmp(v, "quarter") == 0 || @@ -5254,6 +5258,272 @@ static int company_field_valid(const struct company_field *f, const char *v) return 1; } +/* Board members shown in the årsredovisning (name + title). */ +static void board_screen(struct app *a) +{ + int owner = a->role[0] && strcmp(a->role, "owner") == 0; + int cursor = 0; + for (;;) { + if (g_quit) + return; + char *resp = client_rpc(&a->conn, "board.list", a->session, a->org, + "{}"); + if (!resp || !client_ok(resp)) { + show_error("Styrelseledamöter", resp); + free(resp); + return; + } + size_t n = jarr_size(resp, "result.items"); + int rows = (int)n + (owner ? 1 : 0); + char **lines = xcalloc(rows ? rows : 1, sizeof(char *)); + char **names = xcalloc(n + 1, sizeof(char *)); + char **titles = xcalloc(n + 1, sizeof(char *)); + int64_t *ids = xcalloc(n + 1, sizeof(int64_t)); + for (size_t i = 0; i < n; i++) { + char path[64], line[512]; + snprintf(path, sizeof path, "result.items.%zu.id", i); + ids[i] = jint_val(resp, path, 0); + snprintf(path, sizeof path, "result.items.%zu.name", i); + names[i] = jstr_dup(resp, path); + snprintf(path, sizeof path, "result.items.%zu.title", i); + titles[i] = jstr_dup(resp, path); + snprintf(line, sizeof line, "%s %s", + titles[i] ? titles[i] : "", names[i] ? names[i] : ""); + lines[i] = xstrdup(line); + } + if (owner) + lines[n] = xstrdup("+ Ny ledamot (^N)"); + int s = select_list("Styrelseledamöter", lines, rows, cursor, 1, + &cursor, owner ? 1 : 0, + owner && n > 0 ? "ta bort" : NULL, 0); + if (s == -6 && cursor >= 0 && (size_t)cursor < n) { + char q[320]; + snprintf(q, sizeof q, "Ta bort '%s'? (j/n): ", lines[cursor]); + char *ans = prompt(q, "n", 0); + if (ans && (ans[0] == 'j' || ans[0] == 'J')) { + char args[64]; + snprintf(args, sizeof args, "{\"id\":%lld}", + (long long)ids[cursor]); + char *r = client_rpc(&a->conn, "board.remove", a->session, + a->org, args); + if (r && client_ok(r)) { + message("Styrelseledamöter", "Borttagen."); + } else { + show_error("Kunde inte ta bort", r); + } + free(r); + } + } else if (s == -4 || (s >= 0 && owner && (size_t)s == n)) { + char namebuf[256] = ""; + char *name = prompt("Namn: ", "", 0); + if (name && *name) + snprintf(namebuf, sizeof namebuf, "%s", name); + char *title = namebuf[0] + ? prompt("Titel: ", "Styrelseledamot", 0) + : NULL; + if (namebuf[0] && title && *title) { + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_strcpy(d, o, "name", namebuf); + yyjson_mut_obj_add_strcpy(d, o, "title", title); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *r = client_rpc(&a->conn, "board.add", a->session, + a->org, args); + free(args); + if (r && client_ok(r)) + message("Styrelseledamöter", "Tillagd."); + else + show_error("Kunde inte lägga till", r); + free(r); + } + } else if (s >= 0 && (size_t)s < n) { + if (!owner) { + message("Styrelseledamöter", + "Endast ägare kan ändra styrelseledamöterna."); + } else { + char namebuf[256] = ""; + char *name = prompt("Namn: ", names[s] ? names[s] : "", 0); + if (name && *name) + snprintf(namebuf, sizeof namebuf, "%s", name); + char *title = namebuf[0] + ? prompt("Titel: ", + titles[s] ? titles[s] : "", 0) + : NULL; + if (namebuf[0] && title && *title) { + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_int(d, o, "id", ids[s]); + yyjson_mut_obj_add_strcpy(d, o, "name", namebuf); + yyjson_mut_obj_add_strcpy(d, o, "title", title); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *r = client_rpc(&a->conn, "board.update", a->session, + a->org, args); + free(args); + if (r && client_ok(r)) + message("Styrelseledamöter", "Sparad."); + else + show_error("Kunde inte spara", r); + free(r); + } + } + } + int back = s == -1; + for (int i = 0; i < rows; i++) + free(lines[i]); + free(lines); + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(titles[i]); + } + free(names); + free(titles); + free(ids); + free(resp); + if (back) + return; + } +} + +/* "Information om året": the per-year årsredovisning details. New years + inherit the stable fields, so only what changed needs editing. */ +static void yearinfo_screen(struct app *a) +{ + static const char *const labels[6] = { + "Väsentliga händelser", + "Årsstämma (YYYY-MM-DD)", + "Föreslagen utdelning (kr)", + "Utdelningens datum (YYYY-MM-DD)", + "Medelantal anställda", + "Övriga noter (t.ex. revisor)", + }; + static const char *const keys[6] = { "events", "agm_date", "dividend_ore", + "dividend_date", "employees", + "notes" }; + const int nf = 6; + int can_edit = strcmp(a->role, "owner") == 0 || + strcmp(a->role, "bookkeeper") == 0; + for (;;) { + if (g_quit) + return; + char args[48]; + snprintf(args, sizeof args, "{\"id\":%lld}", (long long)a->fy); + char *resp = client_rpc(&a->conn, "fiscal_year.get", a->session, + a->org, args); + if (!resp || !client_ok(resp)) { + show_error("Information om året", resp); + free(resp); + return; + } + char *vals[6]; + vals[0] = jstr_dup(resp, "result.events"); + vals[1] = jstr_dup(resp, "result.agm_date"); + char divbuf[48]; + kr_format(jint_val(resp, "result.dividend_ore", 0), divbuf, + sizeof divbuf); + vals[2] = xstrdup(divbuf); + vals[3] = jstr_dup(resp, "result.dividend_date"); + vals[4] = jstr_dup(resp, "result.employees"); + vals[5] = jstr_dup(resp, "result.notes"); + for (int i = 0; i < nf; i++) + if (!vals[i]) + vals[i] = xstrdup(""); + free(resp); + + int sel = 0; + int back = 0; + for (;;) { + frame("Information om året"); + attron(A_BOLD); + mvaddstr(3, 2, "Uppgift"); + mvaddstr(3, 34, "Värde"); + attroff(A_BOLD); + for (int i = 0; i < nf; i++) { + char lbl[128], line[640]; + 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(5 + i, 2, line, COLS - 4); + attroff(A_REVERSE); + } else { + if (!can_edit) + attron(A_DIM); + mvaddnstr(5 + i, 2, line, COLS - 4); + if (!can_edit) + attroff(A_DIM); + } + } + hints(can_edit + ? "upp/ned Enter = ändra F5 = uppdatera" + " Esc/q = tillbaka ^C = avsluta" + : "upp/ned F5 = uppdatera Esc/q = tillbaka" + " ^C = avsluta (endast ägare/redovisare)"); + refresh(); + int ch = ui_getch(); + if (ch == KEY_UP && sel > 0) + sel--; + else if (ch == KEY_DOWN && sel < nf - 1) + sel++; + else if (ch == KEY_F(5)) + break; /* reload */ + else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + if (!can_edit) { + message("Information om året", + "Endast ägare eller redovisare kan ändra."); + continue; + } + char label[160]; + snprintf(label, sizeof label, "%s: ", labels[sel]); + char *v = prompt(label, vals[sel], 0); + if (!v) + continue; + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_int(d, o, "id", a->fy); + if (sel == 2) { + int64_t ore = 0; + if (parse_kr(v, &ore) != 0) { + message("Ogiltigt värde", "%s", labels[sel]); + yyjson_mut_doc_free(d); + continue; + } + yyjson_mut_obj_add_int(d, o, keys[sel], ore); + } else { + if ((sel == 1 || sel == 3) && *v && + !util_parse_iso_date(v)) { + message("Ogiltigt datum", "%s", v); + yyjson_mut_doc_free(d); + continue; + } + yyjson_mut_obj_add_strcpy(d, o, keys[sel], v); + } + char *uargs = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *r = client_rpc(&a->conn, "fiscal_year.update", + a->session, a->org, uargs); + free(uargs); + if (!r || !client_ok(r)) + show_error("Kunde inte spara", r); + free(r); + break; /* reload from the server */ + } else if (ch == 27 || ch == 'q') { + back = 1; + break; + } + } + for (int i = 0; i < nf; i++) + free(vals[i]); + if (back) + return; + } +} + static void company_screen(struct app *a) { const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]); @@ -5291,20 +5561,26 @@ static void company_screen(struct app *a) mvaddstr(3, 2, "Uppgift"); mvaddstr(3, 34, "Värde"); attroff(A_BOLD); - for (int i = 0; i < nf; i++) { + for (int i = 0; i <= nf; i++) { char lbl[128], line[512]; - snprintf(lbl, sizeof lbl, "%s", COMPANY_FIELDS[i].label); - pad_field(lbl, sizeof lbl, 31); - snprintf(line, sizeof line, "%s %s", lbl, vals[i]); + if (i < nf) { + snprintf(lbl, sizeof lbl, "%s", COMPANY_FIELDS[i].label); + pad_field(lbl, sizeof lbl, 31); + snprintf(line, sizeof line, "%s %s", lbl, vals[i]); + } else { + snprintf(lbl, sizeof lbl, "Styrelseledamöter"); + pad_field(lbl, sizeof lbl, 31); + snprintf(line, sizeof line, "%s (lista)", lbl); + } if (i == sel) { attron(A_REVERSE); mvaddnstr(5 + i, 2, line, COLS - 4); attroff(A_REVERSE); } else { - if (!owner) + if (!owner && i < nf) attron(A_DIM); mvaddnstr(5 + i, 2, line, COLS - 4); - if (!owner) + if (!owner && i < nf) attroff(A_DIM); } } @@ -5317,11 +5593,21 @@ static void company_screen(struct app *a) int ch = ui_getch(); if (ch == KEY_UP && sel > 0) sel--; - else if (ch == KEY_DOWN && sel < nf - 1) + else if (ch == KEY_DOWN && sel < nf) sel++; else if (ch == KEY_F(5)) break; /* reload */ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + if (sel == nf) { + if (!owner) { + message("Företagsuppgifter", + "Endast ägare kan ändra" + " styrelseledamöterna."); + continue; + } + board_screen(a); + break; /* reload */ + } if (!owner) { message("Företagsuppgifter", "Endast ägare kan ändra företagsuppgifterna."); @@ -5739,81 +6025,46 @@ static void bokslut_screen(struct app *a) /* 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) +/* Whole kronor with space thousands; rounded half away from zero. */ +static void kr0_format(int64_t ore, char *buf, size_t n) { - 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; - } + long long v = (long long)(ore >= 0 ? (ore + 50) / 100 + : (ore - 50) / 100); + int neg = v < 0; + if (neg) + v = -v; + char digits[32], out[40]; + snprintf(digits, sizeof digits, "%lld", v); + size_t o = 0, 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]; } - return s; + out[o] = '\0'; + snprintf(buf, n, "%s%s", neg ? "-" : "", out); } -static void ar_is_rows(struct buf *t, yyjson_val *res, yyjson_val *prev, - int lo, int hi, int64_t *cur, int64_t *pre) +/* Thousands of kronor for the flerårsöversikt. */ +static long long ar_tkr(int64_t ore) { - 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; - } - } + return (long long)(ore >= 0 ? (ore + 50000) / 100000 + : (ore - 50000) / 100000); } -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; - } - } -} +#define AR_YEARS 5 +#define AR_GROUPS 7 +struct ar_year { + char label[8]; + char from[16], to[16]; + int64_t netto, ror, res_fin, skatt, res, disp; + int64_t fin_in, fin_out; + int64_t groups[AR_GROUPS]; +}; + +/* Balance-sheet amounts are shown the way an annual report reads them: + credit balances (equity, liabilities) as positive numbers. */ static int64_t ar_bs_ib_sum(yyjson_val *res, int lo, int hi) { int64_t s = 0; @@ -5834,8 +6085,6 @@ static int64_t ar_bs_ib_sum(yyjson_val *res, int lo, int hi) 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; @@ -5856,298 +6105,423 @@ static int64_t ar_bs_sum(yyjson_val *res, int lo, int hi) return s; } -static int ar_bs_rows(struct buf *t, yyjson_val *res, int lo, int hi, - int64_t *cur, int64_t *pre) +/* One year's income statement from its general ledger. Vouchers whose + description starts with "Stäng" are the source system's closings in + imported history years and are skipped, so those years show their real + figures instead of a zeroed P&L. */ +static void ar_load_year(struct ar_year *y, yyjson_val *gl) { - 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)) + memset(y, 0, sizeof *y); + yyjson_val *fy = yyjson_obj_get(gl, "fiscal_year"); + snprintf(y->label, sizeof y->label, "%.4s", vstr(fy, "start_date")); + snprintf(y->from, sizeof y->from, "%s", vstr(fy, "start_date")); + snprintf(y->to, sizeof y->to, "%s", vstr(fy, "end_date")); + yyjson_val *accts = yyjson_obj_get(gl, "accounts"); + size_t na = yyjson_arr_size(accts); + for (size_t i = 0; i < na; i++) { + yyjson_val *acct = yyjson_arr_get(accts, i); + const char *num = vstr(acct, "account"); + int n = atoi(num); + if (n < 3000 || n > 8999) + continue; + int64_t net = 0; + yyjson_val *rows = yyjson_obj_get(acct, "rows"); + size_t nr = yyjson_arr_size(rows); + for (size_t k = 0; k < nr; k++) { + yyjson_val *row = yyjson_arr_get(rows, k); + if (strncmp(vstr(row, "description"), "Stäng", 7) == 0) continue; - int64_t ub = vint(row, "ub_ore"), ib = vint(row, "ib_ore"); - if (lo >= 2000) { - ub = -ub; - ib = -ib; + net += vint(row, "debit_ore") - vint(row, "credit_ore"); + } + int64_t disp = -net; + int done = 0; + for (size_t g = 0; g < AR_GROUPS; g++) { + if (acct_in(num, IS_GROUPS[g].lo, IS_GROUPS[g].hi)) { + y->groups[g] += disp; + done = 1; + break; } - if (t) - ar_amounts(t, acc, vstr(row, "name"), ub, ib); - if (cur) - *cur += ub; - if (pre) - *pre += ib; - found = 1; } + if (done) + continue; + if (n >= 8000 && n <= 8399) + y->fin_in += disp; + else if (n >= 8400 && n <= 8599) + y->fin_out += disp; + else if (n >= 8800 && n <= 8899) + y->disp += disp; + else if (n >= 8900 && n <= 8989) + y->skatt += disp; } - return found; + int64_t rev = 0, cost = 0; + for (size_t g = 0; g < AR_GROUPS; g++) + if (IS_GROUPS[g].rev) + rev += y->groups[g]; + else + cost += y->groups[g]; + y->netto = y->groups[0]; + y->ror = rev + cost; + y->res_fin = y->ror + y->fin_in + y->fin_out; + y->res = y->res_fin + y->disp + y->skatt; } -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) +static void ar_amt_col(char *buf, size_t n, int width, int64_t ore) { - 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; + char tmp[40]; + kr0_format(ore, tmp, sizeof tmp); + snprintf(buf, n, "%*s", width, tmp); } -static int64_t ar_is_result(yyjson_val *is) +static void ar_row(struct buf *t, const char *label, int64_t cur, int64_t prev, + int indent) { - return ar_is_sum(is, 3000, 3999) + ar_is_sum(is, 4000, 8989); + char nm[160], padded[200], a1[40], a2[40]; + snprintf(nm, sizeof nm, "%s%s", indent ? " " : "", label); + pad_label(padded, sizeof padded, nm, 48); + ar_amt_col(a1, sizeof a1, 16, cur); + ar_amt_col(a2, sizeof a2, 16, prev); + buf_line(t, "%s %s %s\n", padded, a1, a2); } -static int64_t ar_resultat(struct buf *t, yyjson_val *is, yyjson_val *prev, - const char *cur_lbl, const char *prev_lbl) +/* Equity table: four amount columns. */ +static void ar_eq_row(struct buf *t, const char *label, int64_t ak, + int64_t br, int64_t ar, int indent) { - 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; + char nm[160], padded[200], c1[32], c2[32], c3[32], c4[32]; + snprintf(nm, sizeof nm, "%s%s", indent ? " " : "", label); + pad_label(padded, sizeof padded, nm, 34); + ar_amt_col(c1, sizeof c1, 12, ak); + ar_amt_col(c2, sizeof c2, 14, br); + ar_amt_col(c3, sizeof c3, 14, ar); + ar_amt_col(c4, sizeof c4, 14, ak + br + ar); + buf_line(t, "%s %s %s %s %s\n", padded, c1, c2, c3, c4); } -static int64_t ar_equity(yyjson_val *bs, int64_t result) +static void ar_wrap(struct buf *t, const char *text) { - return ar_bs_sum(bs, 2000, 2090) + ar_bs_sum(bs, 2091, 2098) + result; + char line[128] = ""; + const char *p = text; + while (*p) { + while (*p == ' ' || *p == '\n' || *p == '\t') + p++; + if (!*p) + break; + size_t n = strcspn(p, " \n\t"); + size_t ll = strlen(line); + if (ll + (ll ? 1 : 0) + n > 76) { + buf_line(t, "%s\n", line); + ll = 0; + line[0] = '\0'; + } + if (ll) { + line[ll++] = ' '; + line[ll] = '\0'; + } + if (n > sizeof line - 1 - ll) + n = sizeof line - 1 - ll; + memcpy(line + ll, p, n); + line[ll + n] = '\0'; + p += n; + } + if (line[0]) + buf_line(t, "%s\n", line); } -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) +/* "2026-06-04" -> "4 juni 2026"; anything else becomes a placeholder. */ +static void ar_sv_date(const char *iso, char *out, size_t n) { - 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 }, + static const char *const months[12] = { + "januari", "februari", "mars", "april", "maj", "juni", "juli", + "augusti", "september", "oktober", "november", "december" }; - 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); + int y = 0, m = 0, d = 0; + if (!iso || strlen(iso) != 10 || sscanf(iso, "%d-%d-%d", &y, &m, &d) != 3 || + m < 1 || m > 12 || d < 1 || d > 31) { + snprintf(out, n, "[datum]"); + return; + } + snprintf(out, n, "%d %s %d", d, months[m - 1], y); } 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 *description, int64_t shares, + yyjson_val *fyj, const char *board_text, + const char *first_board, yyjson_val *bs, + struct ar_year *years, int nyears) { - 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); + struct ar_year *cur = &years[nyears - 1]; + struct ar_year *prev = nyears > 1 ? &years[nyears - 2] : NULL; + int64_t ars = cur->res; + int64_t prev_ars = prev ? prev->res : 0; + const char *events = vstr(fyj, "events"); + const char *employees = vstr(fyj, "employees"); + const char *notes = vstr(fyj, "notes"); + int64_t dividend = vint(fyj, "dividend_ore"); + char agm_sv[64], pay_sv[64]; + ar_sv_date(vstr(fyj, "agm_date"), agm_sv, sizeof agm_sv); + ar_sv_date(vstr(fyj, "dividend_date"), pay_sv, sizeof pay_sv); + + buf_line(t, "ÅRSREDOVISNING\n\n%s\nOrg nr %s\n\n", a->org_name, org_nr); + buf_line(t, "Årsredovisning för räkenskapsåret %s - %s\n\n", a->fy_start, + a->fy_end); + buf_line(t, "Styrelsen avger följande årsredovisning.\n\n"); + buf_line(t, "Om inte annat särskilt anges, redovisas alla belopp i hela" + " kronor.\n"); + + buf_line(t, "\nFastställelseintyg\n\n"); + buf_line(t, "Undertecknad styrelseledamot i %s intygar, dels att denna" + " kopia av\nårsredovisningen stämmer överens med originalet," + " dels att resultaträkningen\noch balansräkningen har" + " fastställts på årsstämma den %s. Årsstämman\nbeslutade" + " att godkänna styrelsens förslag till hur vinsten ska" + " fördelas.\n\n", a->org_name, agm_sv); + buf_line(t, "%s %s\n\nNamnteckning:" + " ___________________________\n\nNamnförtydligande: %s\n", + city, agm_sv, first_board); + + buf_line(t, "\nFörvaltningsberättelse\n\nVerksamheten\n\nAllmänt om" + " verksamheten\n"); + if (description && *description) + ar_wrap(t, description); + else + buf_line(t, "[Kort beskrivning av verksamheten saknas — fyll i under" + " Företagsuppgifter.]\n"); + buf_line(t, "\nBolaget har sitt säte i %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); + if (events && *events) + ar_wrap(t, events); + else + buf_line(t, "[Beskriv väsentliga händelser.]\n"); + + if (nyears > 1) { + char row[512]; + int off = snprintf(row, sizeof row, "%-38s", "Flerårsöversikt"); + for (int i = 0; i < nyears; i++) + off += snprintf(row + off, sizeof row - (size_t)off, "%9s", + years[i].label); + buf_line(t, "\n%s\n\n", row); + off = snprintf(row, sizeof row, "%-38s", "Nettoomsättning, tkr"); + for (int i = 0; i < nyears; i++) + off += snprintf(row + off, sizeof row - (size_t)off, "%9lld", + ar_tkr(years[i].netto)); + buf_line(t, "%s\n", row); + off = snprintf(row, sizeof row, "%-38s", + "Resultat efter finansiella poster, tkr"); + for (int i = 0; i < nyears; i++) + off += snprintf(row + off, sizeof row - (size_t)off, "%9lld", + ar_tkr(years[i].res_fin)); + buf_line(t, "%s\n", row); + } + + int64_t ak_ib = ar_bs_ib_sum(bs, 2081, 2089); + int64_t ak_ub = ar_bs_sum(bs, 2081, 2089); + int64_t br_ib = ar_bs_ib_sum(bs, 2091, 2098); + int64_t br_ub = ar_bs_sum(bs, 2091, 2098); + int64_t movement = br_ub - br_ib; + int64_t paid_div = prev_ars - movement; + if (paid_div < 0) + paid_div = 0; + int64_t balanced = prev_ars - paid_div; + if (prev) { + char row[512]; + int off = snprintf(row, sizeof row, "%-34s", "Förändringar i eget" + " kapital"); + off += snprintf(row + off, sizeof row - (size_t)off, "%12s", + "Aktiekapital"); + off += snprintf(row + off, sizeof row - (size_t)off, "%14s", + "Balanserat"); + off += snprintf(row + off, sizeof row - (size_t)off, "%14s", + "Årets"); + off += snprintf(row + off, sizeof row - (size_t)off, "%14s", + "Totalt"); + buf_line(t, "\n%s\n", row); + buf_line(t, "%s\n", " resultat" + " resultat"); + int has_disp = movement != 0; + int64_t ar_ib = has_disp ? prev_ars : 0; + ar_eq_row(t, "Belopp vid årets ingång", ak_ib, br_ib, ar_ib, 0); + if (has_disp) { + buf_line(t, "Resultatdisposition enligt årsstämman\n"); + ar_eq_row(t, "Utdelning", 0, -paid_div, 0, 1); + ar_eq_row(t, "Balanseras i ny räkning", 0, balanced, -prev_ars, 1); + } + ar_eq_row(t, "Årets resultat", 0, 0, ars, 0); + ar_eq_row(t, "Belopp vid årets utgång", ak_ub, br_ub, ars, 0); + } + + int64_t dispon = br_ub + ars; + buf_line(t, "\nResultatdisposition\n\nTill årsstämmans förfogande står" + " följande vinstmedel:\n"); + ar_row(t, "Balanserat resultat", br_ub, 0, 1); + ar_row(t, "Årets resultat", ars, 0, 1); + ar_row(t, "Totalt", dispon, 0, 1); + buf_line(t, "\nStyrelsen föreslår att vinstmedlen disponeras enligt" + " följande:\n"); + ar_row(t, "Utdelning till ägarna", dividend, 0, 1); + ar_row(t, "Balanseras i ny räkning", dispon - dividend, 0, 1); + ar_row(t, "Totalt", dispon, 0, 1); + + if (dividend > 0) { + buf_line(t, "\nStyrelsens yttrande över den föreslagna" + " vinstutdelningen\n\n"); + if (shares > 0) { + char per[48]; + kr_format((dividend + shares / 2) / shares, per, sizeof per); + buf_line(t, "Utdelning per aktie: %s kr. Datum för utdelning är" + " %s.\n\n", per, pay_sv); + } else { + buf_line(t, "Datum för utdelning är %s.\n\n", pay_sv); + } + buf_line(t, "Likviditeten i bolaget bedöms kunna upprätthållas på en" + " likaledes betryggande nivå.\n\n"); + buf_line(t, "Styrelsens uppfattning är att den föreslagna utdelningen" + " ej hindrar bolaget\nfrån att fullgöra sina förpliktelser" + " på kort och lång sikt, ej heller att fullgöra\nerforderliga" + " investeringar. Den föreslagna utdelningen kan därmed" + " försvaras med\nhänsyn till vad som anförs i ABL 17 kap." + " 3 § 2 st. (försiktighetsregeln).\n"); + } + + buf_line(t, "\nResultaträkning\nSamtliga belopp anges i svenska" + " kronor.\n\n"); { - 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); + char h1[40], h2[40], row[512]; + snprintf(h1, sizeof h1, "%s - %s", cur->from, cur->to); + if (prev) + snprintf(h2, sizeof h2, "%s - %s", prev->from, prev->to); else - buf_line(t, "\nStyrelsen föreslår att vinstmedlen disponeras så" - " att i ny räkning överförs %s kr.\n", tmpr); + h2[0] = '\0'; + int off = snprintf(row, sizeof row, "%-48s", ""); + off += snprintf(row + off, sizeof row - (size_t)off, "%16s", h1); + if (h2[0]) + off += snprintf(row + off, sizeof row - (size_t)off, "%17s", h2); + buf_line(t, "%s\n", row); } - 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, "Rörelseintäkter, lagerförändringar m.m.\n"); + ar_row(t, "Nettoomsättning", cur->groups[0], + prev ? prev->groups[0] : 0, 1); + int64_t rev_cur = cur->groups[0] + cur->groups[1]; + int64_t rev_pre = prev ? prev->groups[0] + prev->groups[1] : 0; + ar_row(t, "Summa Rörelseintäkter, lagerförändringar m.m.", rev_cur, + rev_pre, 1); + buf_line(t, "\nRörelsekostnader\n"); + int64_t cost_cur = 0, cost_pre = 0; + for (size_t g = 2; g < AR_GROUPS; g++) { + int64_t c = cur->groups[g], p = prev ? prev->groups[g] : 0; + if (c || p) + ar_row(t, IS_GROUPS[g].title, c, p, 1); + cost_cur += c; + cost_pre += p; + } + ar_row(t, "Summa Rörelsens kostnader", cost_cur, cost_pre, 1); + buf_line(t, "\n"); + ar_row(t, "Rörelseresultat", cur->ror, prev ? prev->ror : 0, 0); + if (cur->fin_in || (prev && prev->fin_in)) + ar_row(t, "Ränteintäkter", cur->fin_in, prev ? prev->fin_in : 0, 0); + if (cur->fin_out || (prev && prev->fin_out)) + ar_row(t, "Räntekostnader", cur->fin_out, prev ? prev->fin_out : 0, 0); + ar_row(t, "Resultat efter finansiella poster", cur->res_fin, + prev ? prev->res_fin : 0, 0); + int64_t btax = cur->res_fin + cur->disp; + int64_t btax_pre = prev ? prev->res_fin + prev->disp : 0; + if (cur->disp || (prev && prev->disp)) { + buf_line(t, "\nBokslutsdispositioner\n"); + ar_row(t, "Summa", cur->disp, prev ? prev->disp : 0, 1); + } + ar_row(t, "Resultat före skatt", btax, btax_pre, 0); + if (cur->skatt || (prev && prev->skatt)) + ar_row(t, "Skatt på årets resultat", cur->skatt, + prev ? prev->skatt : 0, 0); + buf_line(t, "\n"); + ar_row(t, "Årets resultat", ars, prev_ars, 0); + + buf_line(t, "\nBalansräkning\n\n"); + { + char h1[40], h2[40], row[256]; + snprintf(h1, sizeof h1, "%s", cur->to); + if (prev) + snprintf(h2, sizeof h2, "%s", prev->to); + else + h2[0] = '\0'; + int off = snprintf(row, sizeof row, "%-48s", ""); + off += snprintf(row + off, sizeof row - (size_t)off, "%16s", h1); + if (h2[0]) + off += snprintf(row + off, sizeof row - (size_t)off, "%17s", h2); + buf_line(t, "%s\n", row); + } + buf_line(t, "Tillgångar\nAnläggningstillgångar\n"); + ar_row(t, "Summa anläggningstillgångar", ar_bs_sum(bs, 1000, 1399), + ar_bs_ib_sum(bs, 1000, 1399), 1); + buf_line(t, "Omsättningstillgångar\n"); + int64_t v = ar_bs_sum(bs, 1400, 1499), vp = ar_bs_ib_sum(bs, 1400, 1499); + if (v || vp) + ar_row(t, "Varulager", v, vp, 0); + ar_row(t, "Kortfristiga fordringar", ar_bs_sum(bs, 1500, 1699), + ar_bs_ib_sum(bs, 1500, 1699), 0); + v = ar_bs_sum(bs, 1700, 1899); + vp = ar_bs_ib_sum(bs, 1700, 1899); + if (v || vp) + ar_row(t, "Kortfristiga placeringar", v, vp, 0); + ar_row(t, "Kassa och bank", ar_bs_sum(bs, 1900, 1999), + ar_bs_ib_sum(bs, 1900, 1999), 0); + ar_row(t, "Summa tillgångar", ar_bs_sum(bs, 1000, 1999), + ar_bs_ib_sum(bs, 1000, 1999), 0); + buf_line(t, "\nEget kapital och skulder\nBundet eget kapital\n"); + ar_row(t, "Aktiekapital", ak_ub, ak_ib, 1); + ar_row(t, "Summa bundet eget kapital", ar_bs_sum(bs, 2000, 2090), + ar_bs_ib_sum(bs, 2000, 2090), 1); + buf_line(t, "Fritt eget kapital\n"); + ar_row(t, "Balanserat resultat", br_ub, br_ib, 1); + ar_row(t, "Årets resultat", ars, prev_ars, 1); + ar_row(t, "Summa fritt eget kapital", ar_bs_sum(bs, 2091, 2098) + ars, + ar_bs_ib_sum(bs, 2091, 2098) + prev_ars, 1); + ar_row(t, "Summa eget kapital", + ar_bs_sum(bs, 2000, 2090) + ar_bs_sum(bs, 2091, 2098) + ars, + ar_bs_ib_sum(bs, 2000, 2090) + ar_bs_ib_sum(bs, 2091, 2098) + + prev_ars, + 0); + buf_line(t, "\n"); + ar_row(t, "Obeskattade reserver", ar_bs_sum(bs, 2100, 2199), + ar_bs_ib_sum(bs, 2100, 2199), 0); + ar_row(t, "Avsättningar", ar_bs_sum(bs, 2200, 2299), + ar_bs_ib_sum(bs, 2200, 2299), 0); + buf_line(t, "Kortfristiga skulder\n"); + ar_row(t, "Skatteskulder", ar_bs_sum(bs, 2500, 2599), + ar_bs_ib_sum(bs, 2500, 2599), 1); + ar_row(t, "Mervärdesskatt", ar_bs_sum(bs, 2600, 2699), + ar_bs_ib_sum(bs, 2600, 2699), 1); + ar_row(t, "Personalskatter och sociala avgifter", + ar_bs_sum(bs, 2700, 2799), ar_bs_ib_sum(bs, 2700, 2799), 1); + ar_row(t, "Övriga kortfristiga skulder", ar_bs_sum(bs, 2800, 2999) + + ar_bs_sum(bs, 2400, 2499), + ar_bs_ib_sum(bs, 2800, 2999) + ar_bs_ib_sum(bs, 2400, 2499), 1); + int64_t skuld = ar_bs_sum(bs, 2400, 2999); + int64_t skuld_p = ar_bs_ib_sum(bs, 2400, 2999); + ar_row(t, "Summa kortfristiga skulder", skuld, skuld_p, 1); + ar_row(t, "Summa eget kapital, avsättningar och skulder", + ar_bs_sum(bs, 2000, 2999) + ars, + ar_bs_ib_sum(bs, 2000, 2999) + prev_ars, 0); + + buf_line(t, "\nNoter\n\nNot 1 Redovisnings- och värderingsprinciper\n"); + buf_line(t, "Årsredovisningen är upprättad i enlighet med" + " årsredovisningslagen och\nBFNAR 2016:10 Årsredovisning i" + " mindre företag.\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)); + " till %s.\n", employees && *employees ? employees + : "[antal]"); + if (notes && *notes) { + buf_line(t, "\nNot 3 Övriga upplysningar\n"); + ar_wrap(t, notes); } - 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"); + + buf_line(t, "\nUnderskrifter\n\n%s %s\n\n", city, agm_sv); + if (board_text && *board_text) + buf_line(t, "%s", board_text); + else + buf_line(t, "[Lägg till styrelseledamöter under" + " Företagsuppgifter.]\n"); } static void arsredovisning_save(struct app *a, const char *text) @@ -6169,8 +6543,13 @@ static void arsredovisning_save(struct app *a, const char *text) size_t fl = strlen(full); if (fl && full[fl - 1] != '/') snprintf(full + fl, sizeof full - fl, "/"); + char name[80]; + snprintf(name, sizeof name, "%s", a->fy_label); + for (char *p = name; *p; p++) + if (*p == '/') + *p = '-'; snprintf(full + strlen(full), sizeof full - strlen(full), - "Årsredovisning %.4s-%.4s.txt", a->fy_start, a->fy_end); + "Årsredovisning %s.txt", *name ? name : "utkast"); FILE *f = fopen(full, "wb"); size_t n = strlen(text); if (!f || fwrite(text, 1, n, f) != n) { @@ -6187,88 +6566,115 @@ static void arsredovisning_save(struct app *a, const char *text) 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}", + char *fresp = client_rpc(&a->conn, "fiscal_year.list", a->session, + a->org, "{}"); + if (!fresp || !client_ok(fresp)) { + show_error("Årsredovisning", fresp); + free(fresp); + return; + } + struct ar_year years[AR_YEARS]; + int nyears = 0; + { + yyjson_doc *fd = parse(fresp); + yyjson_val *items = + fd ? jget(yyjson_doc_get_root(fd), "result.items") : NULL; + size_t n = yyjson_arr_size(items); + int idx = -1; + for (size_t i = 0; i < n; i++) { + yyjson_val *it = yyjson_arr_get(items, i); + if (vint(it, "id") == a->fy) + idx = (int)i; + } + if (idx < 0) + idx = (int)n - 1; + int start = idx - (AR_YEARS - 1); + if (start < 0) + start = 0; + for (int i = start; i <= idx && nyears < AR_YEARS; i++) { + yyjson_val *it = yyjson_arr_get(items, (size_t)i); + char gargs[64]; + snprintf(gargs, sizeof gargs, "{\"fiscal_year\":%lld}", + (long long)vint(it, "id")); + char *gresp = client_rpc(&a->conn, "report.general_ledger", + a->session, a->org, gargs); + if (!gresp || !client_ok(gresp)) { + show_error("Årsredovisning", gresp); + free(gresp); + yyjson_doc_free(fd); + free(fresp); + return; + } + yyjson_doc *gd = parse(gresp); + yyjson_val *g = + gd ? jget(yyjson_doc_get_root(gd), "result") : NULL; + if (g) + ar_load_year(&years[nyears++], g); + yyjson_doc_free(gd); + free(gresp); + } + yyjson_doc_free(fd); + } + free(fresp); + + char bargs[64]; + snprintf(bargs, sizeof bargs, "{\"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); + a->org, bargs); + char *oresp = client_rpc(&a->conn, "org.get", a->session, a->org, + "{}"); + char *yresp = client_rpc(&a->conn, "fiscal_year.get", a->session, + a->org, "{}"); + char *mresp = client_rpc(&a->conn, "board.list", a->session, a->org, + "{}"); + if (!bresp || !client_ok(bresp) || !oresp || !client_ok(oresp) || + !yresp || !client_ok(yresp) || !mresp || !client_ok(mresp) || + nyears == 0) { + show_error("Årsredovisning", bresp && !client_ok(bresp) ? bresp + : oresp); free(bresp); free(oresp); + free(yresp); + free(mresp); 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_doc *bd = parse(bresp), *od = parse(oresp), *yd = parse(yresp); 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); - } - } + yyjson_val *org = od ? jget(yyjson_doc_get_root(od), "result") : NULL; + yyjson_val *fyj = yd ? jget(yyjson_doc_get_root(yd), "result") : NULL; + char *onr = org ? jstr_dup(oresp, "result.org_nr") : NULL; + char *city = org ? jstr_dup(oresp, "result.city") : NULL; + char *desc = org ? jstr_dup(oresp, "result.description") : NULL; + int64_t shares = org ? jint_val(oresp, "result.shares", 0) : 0; + char board_text[1024] = ""; + char first_board[128] = "[namn]"; + size_t nb = jarr_size(mresp, "result.items"); + for (size_t i = 0; i < nb && strlen(board_text) < 900; i++) { + char path[64]; + snprintf(path, sizeof path, "result.items.%zu.title", i); + char *ti = jstr_dup(mresp, path); + snprintf(path, sizeof path, "result.items.%zu.name", i); + char *nm = jstr_dup(mresp, path); + char line[256]; + snprintf(line, sizeof line, "%s %s\n\n", ti ? ti : "", + nm ? nm : ""); + strncat(board_text, line, + sizeof board_text - strlen(board_text) - 1); + if (i == 0) + snprintf(first_board, sizeof first_board, "%s", + nm && *nm ? nm : "[namn]"); + free(ti); + free(nm); } struct buf t; buf_init(&t); - if (is && bs) + if (bs && nyears > 0) fmt_arsredovisning(&t, a, onr && *onr ? onr : "[org.nr]", - city && *city ? city : "[Ort]", is, pis, bs, - div_ore); + city && *city ? city : "[Ort]", + desc ? desc : "", shares, fyj, board_text, + first_board, bs, years, nyears); else buf_line(&t, "Kunde inte läsa rapporterna."); buf_append(&t, "", 1); @@ -6279,13 +6685,14 @@ static void arsredovisning_screen(struct app *a) buf_free(&t); free(onr); free(city); - yyjson_doc_free(id); + free(desc); yyjson_doc_free(bd); - yyjson_doc_free(pid); - free(iresp); + yyjson_doc_free(od); + yyjson_doc_free(yd); free(bresp); free(oresp); - free(pis_resp); + free(yresp); + free(mresp); if (again != 1 && again != 2) return; } @@ -6314,6 +6721,8 @@ static void open_scene(struct app *a, const char *scene) company_screen(a); else if (strcmp(scene, "bokslut") == 0) bokslut_screen(a); + else if (strcmp(scene, "yearinfo") == 0) + yearinfo_screen(a); } static void do_reload(struct app *a, const char *self) @@ -6348,10 +6757,10 @@ 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", "Bokslut", - "Räkenskapsår", "Logga ut / avsluta", + "Verifikat", "Underlag (inkorg)", "Ingående balans", + "Mallar", "Rapporter", "Revision", + "Inställningar", "Företagsuppgifter", "Bokslut", + "Information om året", "Räkenskapsår", "Logga ut / avsluta", }; static void dashboard(struct app *a) @@ -6360,7 +6769,7 @@ static void dashboard(struct app *a) for (;;) { if (g_reload) return; - int sel = menu("Bokf", MAIN_ITEMS, 11, 1, &last_sel); + int sel = menu("Bokf", MAIN_ITEMS, 12, 1, &last_sel); snprintf(g_scene, sizeof g_scene, "%s", "dashboard"); if (g_quit || g_reload) return; @@ -6401,9 +6810,12 @@ static void dashboard(struct app *a) open_scene(a, "bokslut"); break; case 9: - select_fiscal_year(a); + open_scene(a, "yearinfo"); break; case 10: + select_fiscal_year(a); + break; + case 11: return; } } @@ -6741,8 +7153,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, bokslut, audit,\n" - " settings, company\n" + " ib, templates, reports, bokslut, yearinfo,\n" + " audit, settings, company\n" " --version\n"); } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 6f617fd..7052b83 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -264,6 +264,8 @@ Arguments are shown abbreviated; `describe` is authoritative. | `org.member_add` | `org`, `username`, `role` | `{}` | | `org.member_set_role` | `org`, `username`, `role` | `{}` | | `org.member_remove` | `org`, `username` | `{}` | +| `board.list` | — | `items[{id,name,title}]` (årsredovisning signatures) | +| `board.add` / `board.update` / `board.remove` | `name`,`title?` / `id`,`name?`,`title?` / `id` | owner; audited | | `user.create` | `username`, `password`, `display_name`, `is_admin?` | `user` (system admin) | | `user.list` | — | `items[]` (system admin) | | `token.create` | `label`, `scopes[]`, `org`, `expires_at?` | `token` (shown once), `id` | @@ -285,10 +287,10 @@ Account `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`. | Command | Args | Notes | |---|---|---| | `fiscal_year.list` / `fiscal_year.get` | `org?` / `id` | | -| `fiscal_year.open` | `label`, `start_date`, `end_date` | owner | +| `fiscal_year.open` | `label`, `start_date`, `end_date` | owner; inherits events/employees/notes from the previous year | | `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 | +| `fiscal_year.update` | `id`, any of `dividend_ore`, `events`, `agm_date`, `dividend_date`, `employees`, `notes` | bookkeeper; "Information om året", audited | | `period.lock` | `fiscal_year`, `until`, `reason?` | owner; `until` inclusive | | `period.unlock` | `fiscal_year`, `reason` | owner; audited with reason | @@ -480,6 +482,10 @@ commands. Implemented screens (0.1.0-dev): - **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. +- **Information om året** — the per-year årsredovisning details (material + events, AGM and payment dates, proposed dividend, employees, other notes), + edited per field; a new fiscal year inherits the stable fields. The + årsredovisning draft reads them without prompting. - **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 4fc4397..2100990 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -68,6 +68,8 @@ CREATE TABLE orgs ( country TEXT NOT NULL DEFAULT 'SE', email TEXT, phone TEXT, + description TEXT NOT NULL DEFAULT '', -- verksamhetsbeskrivning + shares INTEGER NOT NULL DEFAULT 0,-- antal aktier fiscal_year_start_month INTEGER NOT NULL DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12), moms_period TEXT NOT NULL DEFAULT 'month' CHECK (moms_period IN ('month','quarter','year')), framework TEXT NOT NULL DEFAULT 'K2' CHECK (framework IN ('K2','K3')), @@ -86,6 +88,15 @@ CREATE TABLE users ( disabled_at TEXT ) STRICT; +CREATE TABLE board_members ( -- signatures in the årsredovisning + org_id INTEGER NOT NULL REFERENCES orgs(id), + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + title TEXT NOT NULL, -- "Styrelseledamot", "Ordförande", … + created_at TEXT NOT NULL, + UNIQUE (org_id, id) +) STRICT; + CREATE TABLE memberships ( org_id INTEGER NOT NULL REFERENCES orgs(id), user_id INTEGER NOT NULL REFERENCES users(id), @@ -149,6 +160,11 @@ CREATE TABLE fiscal_years ( 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 + events TEXT NOT NULL DEFAULT '', -- väsentliga händelser (årsredovisning) + agm_date TEXT NOT NULL DEFAULT '', -- årsstämmodatum (YYYY-MM-DD) + dividend_date TEXT NOT NULL DEFAULT '', -- utbetalningsdatum (YYYY-MM-DD) + employees TEXT NOT NULL DEFAULT '', -- medelantal anställda + notes TEXT NOT NULL DEFAULT '', -- övriga upplysningar (t.ex. revisor) 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 b0f5b4a..60c70d9 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -88,10 +88,20 @@ server/protocol/ledger only. (`k`). - 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). + in Rapporter. The draft follows the filed reports (whole kronor, no + account numbers, säte, förvaltningsberättelse with the org's static + verksamhetsbeskrivning, flerårsöversikt and changes-in-equity from the + books, resultatdisposition, styrelsens yttrande, fastställelseintyg, + board signatures) and needs no prompting: everything comes from the + books, the org record (Företagsuppgifter) and the per-year + **Information om året** (`fiscal_year.update`, schema v6: events, AGM + and payment dates, proposed dividend, employees, notes — inherited from + the previous year when a year is opened). Board members are a list per + org (`board.*`, edited under Företagsuppgifter) and the stämma decision + is posted with a `Utdelning` template (D 2099/K 2898). Imported history + years are read with their "Stäng" closings skipped, so comparisons and + the flerårsöversikt show real figures. 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). diff --git a/src/commands.c b/src/commands.c index 62de0be..448d03e 100644 --- a/src/commands.c +++ b/src/commands.c @@ -517,12 +517,17 @@ static yyjson_mut_val *org_json(struct req *r, sqlite3_stmt *st) sq(sqlite3_column_text(st, 12))); yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", sq(sqlite3_column_text(st, 13))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "description", + sq(sqlite3_column_text(st, 14))); + yyjson_mut_obj_add_int(r->rdoc, o, "shares", + sqlite3_column_int64(st, 15)); return o; } #define ORG_COLUMNS \ "id,name,org_nr,vat_nr,address,postal_code,city,country,email," \ - "phone,fiscal_year_start_month,moms_period,framework,created_at" + "phone,fiscal_year_start_month,moms_period,framework,created_at," \ + "description,shares" static yyjson_mut_val *h_org_create(struct req *r) { @@ -689,15 +694,21 @@ static yyjson_mut_val *h_org_update(struct req *r) const char *phone = arg_str(r->args, "phone"); const char *moms = arg_str(r->args, "moms_period"); const char *framework = arg_str(r->args, "framework"); + const char *description = arg_str(r->args, "description"); int64_t fy_month = 0; arg_int(r->args, "fiscal_year_start_month", &fy_month); + int64_t shares = -1; + arg_int(r->args, "shares", &shares); if (!name && !org_nr && !vat_nr && !address && !postal && !city && - !country && !email && !phone && !moms && !framework && fy_month == 0) + !country && !email && !phone && !moms && !framework && fy_month == 0 && + !description && shares < 0) return fail(r, "INVALID_ARGS", "nothing to update"); if (name && !*name) return fail(r, "INVALID_ARGS", "name cannot be empty"); if (fy_month && (fy_month < 1 || fy_month > 12)) return fail(r, "INVALID_ARGS", "fiscal_year_start_month must be 1-12"); + if (shares < -1) + return fail(r, "INVALID_ARGS", "shares must be >= 0"); if (moms && strcmp(moms, "month") != 0 && strcmp(moms, "quarter") != 0 && strcmp(moms, "year") != 0) return fail(r, "INVALID_ARGS", "moms_period must be month, quarter or year"); @@ -705,15 +716,16 @@ static yyjson_mut_val *h_org_update(struct req *r) strcmp(framework, "K3") != 0) return fail(r, "INVALID_ARGS", "framework must be K2 or K3"); - static const char *const keys[9] = { "name", "org_nr", "vat_nr", - "address", "postal_code", "city", - "country", "email", "phone" }; - const char *texts[9] = { name, org_nr, vat_nr, address, postal, - city, country, email, phone }; + static const char *const keys[10] = { "name", "org_nr", "vat_nr", + "address", "postal_code", "city", + "country", "email", "phone", + "description" }; + const char *texts[10] = { name, org_nr, vat_nr, address, postal, + city, country, email, phone, description }; if (r->dry_run) { yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - for (int i = 0; i < 9; i++) + for (int i = 0; i < 10; i++) if (texts[i]) yyjson_mut_obj_add_strcpy(r->rdoc, o, keys[i], texts[i]); if (fy_month) @@ -723,6 +735,8 @@ static yyjson_mut_val *h_org_update(struct req *r) yyjson_mut_obj_add_strcpy(r->rdoc, o, "moms_period", moms); if (framework) yyjson_mut_obj_add_strcpy(r->rdoc, o, "framework", framework); + if (shares >= 0) + yyjson_mut_obj_add_int(r->rdoc, o, "shares", shares); yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); return o; } @@ -735,30 +749,33 @@ static yyjson_mut_val *h_org_update(struct req *r) " vat_nr=COALESCE(?4,vat_nr), address=COALESCE(?5,address)," " postal_code=COALESCE(?6,postal_code), city=COALESCE(?7,city)," " country=COALESCE(?8,country), email=COALESCE(?9,email)," - " phone=COALESCE(?10,phone)," - " fiscal_year_start_month=CASE WHEN ?11=0" - " THEN fiscal_year_start_month ELSE ?11 END," - " moms_period=COALESCE(?12,moms_period)," - " framework=COALESCE(?13,framework)" + " phone=COALESCE(?10,phone), description=COALESCE(?11," + " description)," + " fiscal_year_start_month=CASE WHEN ?12=0" + " THEN fiscal_year_start_month ELSE ?12 END," + " moms_period=COALESCE(?13,moms_period)," + " framework=COALESCE(?14,framework)," + " shares=CASE WHEN ?15<0 THEN shares ELSE ?15 END" " WHERE id=?1", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); - for (int i = 0; i < 9; i++) { + for (int i = 0; i < 10; i++) { if (texts[i]) sqlite3_bind_text(st, i + 2, texts[i], -1, SQLITE_TRANSIENT); else sqlite3_bind_null(st, i + 2); } - sqlite3_bind_int64(st, 11, fy_month); + sqlite3_bind_int64(st, 12, fy_month); if (moms) - sqlite3_bind_text(st, 12, moms, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 13, moms, -1, SQLITE_TRANSIENT); else - sqlite3_bind_null(st, 12); + sqlite3_bind_null(st, 13); if (framework) - sqlite3_bind_text(st, 13, framework, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 14, framework, -1, SQLITE_TRANSIENT); else - sqlite3_bind_null(st, 13); + sqlite3_bind_null(st, 14); + sqlite3_bind_int64(st, 15, shares); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) @@ -953,6 +970,182 @@ static yyjson_mut_val *h_org_member_remove(struct req *r) } /* ------------------------------------------------------------------ */ +/* board members (årsredovisning signatures) */ +/* ------------------------------------------------------------------ */ + +static yyjson_mut_val *board_json(struct req *r, sqlite3_stmt *st) +{ + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", sqlite3_column_int64(st, 0)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", + sq(sqlite3_column_text(st, 2))); + return o; +} + +static yyjson_mut_val *h_board_list(struct req *r) +{ + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT id,name,title FROM board_members" + " WHERE org_id=?1 ORDER BY id", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + while (sqlite3_step(st) == SQLITE_ROW) + yyjson_mut_arr_add_val(items, board_json(r, st)); + sqlite3_finalize(st); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, o, "items", items); + return o; +} + +static yyjson_mut_val *board_get(struct req *r, int64_t id) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "SELECT id,name,title FROM board_members" + " 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 = board_json(r, st); + sqlite3_finalize(st); + if (!o) + return fail(r, "NOT_FOUND", "board member not found"); + return o; +} + +static yyjson_mut_val *h_board_add(struct req *r) +{ + const char *name = arg_str(r->args, "name"); + const char *title = arg_str(r->args, "title"); + if (!name || !*name) + return fail(r, "INVALID_ARGS", "name is required"); + if (!title || !*title) + title = "Styrelseledamot"; + 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_strcpy(r->rdoc, o, "name", name); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title); + return o; + } + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "INSERT INTO board_members(org_id,name,title," + "created_at) VALUES(?1,?2,?3,?4)", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + int64_t id = db_last_id(r->db); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "board.add", reqjson, "OK", NULL); + free(reqjson); + return board_get(r, id); +} + +static yyjson_mut_val *h_board_update(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + const char *name = arg_str(r->args, "name"); + const char *title = arg_str(r->args, "title"); + if (!name && !title) + return fail(r, "INVALID_ARGS", "nothing to update"); + if (name && !*name) + return fail(r, "INVALID_ARGS", "name cannot be empty"); + if (title && !*title) + return fail(r, "INVALID_ARGS", "title cannot be empty"); + 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); + if (name) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "name", name); + if (title) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "title", title); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "UPDATE board_members SET name=COALESCE(?3,name)," + " title=COALESCE(?4,title)" + " 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); + if (name) + sqlite3_bind_text(st, 3, name, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 3); + if (title) + sqlite3_bind_text(st, 4, title, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 4); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "board member not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "board.update", reqjson, "OK", NULL); + free(reqjson); + return board_get(r, id); +} + +static yyjson_mut_val *h_board_remove(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + 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); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(r->db, + "DELETE FROM board_members" + " 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); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "board member not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "board.remove", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +/* ------------------------------------------------------------------ */ /* users and tokens */ /* ------------------------------------------------------------------ */ @@ -1698,10 +1891,22 @@ static yyjson_mut_val *fy_json(struct req *r, sqlite3_stmt *st) sq(sqlite3_column_text(st, 5))); yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", sqlite3_column_int64(st, 6)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "events", + sq(sqlite3_column_text(st, 7))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "agm_date", + sq(sqlite3_column_text(st, 8))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "dividend_date", + sq(sqlite3_column_text(st, 9))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "employees", + sq(sqlite3_column_text(st, 10))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", + sq(sqlite3_column_text(st, 11))); return o; } -#define FY_COLUMNS "id,label,start_date,end_date,status,locked_until,dividend_ore" +#define FY_COLUMNS \ + "id,label,start_date,end_date,status,locked_until,dividend_ore," \ + "events,agm_date,dividend_date,employees,notes" static yyjson_mut_val *h_fiscal_year_list(struct req *r) { @@ -1779,20 +1984,46 @@ static yyjson_mut_val *h_fiscal_year_open(struct req *r) return fail(r, "INVALID_ARGS", "start_date must be before end_date"); if (fy_exists_in_range(r->db, r->org_id, start, end)) return fail(r, "CONFLICT", "fiscal year overlaps an existing one"); + /* "Information om året" carries over from the latest earlier year, so + only what changes needs to be edited. Dates and the dividend are + year-specific and start empty. */ + char prev_events[600] = "", prev_emp[64] = "", prev_notes[600] = ""; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT events,employees,notes FROM fiscal_years" + " WHERE org_id=?1 AND end_date < ?2" + " ORDER BY end_date DESC LIMIT 1", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_text(st, 2, start, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) == SQLITE_ROW) { + snprintf(prev_events, sizeof prev_events, "%s", + (const char *)sqlite3_column_text(st, 0)); + snprintf(prev_emp, sizeof prev_emp, "%s", + (const char *)sqlite3_column_text(st, 1)); + snprintf(prev_notes, sizeof prev_notes, "%s", + (const char *)sqlite3_column_text(st, 2)); + } + sqlite3_finalize(st); + } char ts[32]; util_iso8601(util_now(), ts, sizeof ts); - sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2( r->db, "INSERT INTO fiscal_years(org_id,label,start_date,end_date," - "created_at) VALUES(?1,?2,?3,?4,?5)", + "events,employees,notes,created_at)" + " VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", -1, &st, NULL) != SQLITE_OK) return fail(r, "INTERNAL", "database error"); sqlite3_bind_int64(st, 1, r->org_id); sqlite3_bind_text(st, 2, label, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, start, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, end, -1, SQLITE_TRANSIENT); - sqlite3_bind_text(st, 5, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 5, prev_events, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 6, prev_emp, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, prev_notes, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 8, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) @@ -1880,17 +2111,31 @@ 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. */ +/* Fiscal-year metadata that is not a posting: the board's proposed + dividend and the year's material events, kept for the årsredovisning + draft. Both fields are optional; at least one must be given. */ 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", ÷nd)) - return fail(r, "INVALID_ARGS", "dividend_ore is required"); - if (dividend < 0) + int have_div = arg_int(r->args, "dividend_ore", ÷nd); + const char *events = arg_str(r->args, "events"); + const char *agm = arg_str(r->args, "agm_date"); + const char *pay = arg_str(r->args, "dividend_date"); + const char *employees = arg_str(r->args, "employees"); + const char *notes = arg_str(r->args, "notes"); + if (!have_div && !events && !agm && !pay && !employees && !notes) + return fail(r, "INVALID_ARGS", "nothing to update"); + if (have_div && dividend < 0) return fail(r, "INVALID_ARGS", "dividend_ore must be >= 0"); + if (agm && *agm && !util_parse_iso_date(agm)) + return fail(r, "INVALID_ARGS", "agm_date must be YYYY-MM-DD"); + if (pay && *pay && !util_parse_iso_date(pay)) + return fail(r, "INVALID_ARGS", "dividend_date must be YYYY-MM-DD"); + static const char *const skeys[4] = { "events", "agm_date", + "dividend_date", "employees" }; + const char *svals[4] = { events, agm, pay, employees }; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(r->db, "SELECT count(*) FROM fiscal_years" @@ -1909,17 +2154,40 @@ static yyjson_mut_val *h_fiscal_year_update(struct req *r) 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); + if (have_div) + yyjson_mut_obj_add_int(r->rdoc, o, "dividend_ore", dividend); + for (int i = 0; i < 4; i++) + if (svals[i]) + yyjson_mut_obj_add_strcpy(r->rdoc, o, skeys[i], svals[i]); + if (notes) + yyjson_mut_obj_add_strcpy(r->rdoc, o, "notes", notes); return o; } if (sqlite3_prepare_v2(r->db, - "UPDATE fiscal_years SET dividend_ore=?3" + "UPDATE fiscal_years SET" + " dividend_ore=CASE WHEN ?3<0 THEN dividend_ore" + " ELSE ?3 END," + " events=COALESCE(?4,events)," + " agm_date=COALESCE(?5,agm_date)," + " dividend_date=COALESCE(?6,dividend_date)," + " employees=COALESCE(?7,employees)," + " notes=COALESCE(?8,notes)" " 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); + sqlite3_bind_int64(st, 3, have_div ? dividend : -1); + for (int i = 0; i < 4; i++) { + if (svals[i]) + sqlite3_bind_text(st, i + 4, svals[i], -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, i + 4); + } + if (notes) + sqlite3_bind_text(st, 8, notes, -1, SQLITE_TRANSIENT); + else + sqlite3_bind_null(st, 8); int rc = sqlite3_step(st); sqlite3_finalize(st); if (rc != SQLITE_DONE) @@ -4148,6 +4416,12 @@ const struct command g_commands[] = { h_org_member_set_role }, { "org.member_remove", "Remove a member from the org", PERM_OWNER, 1, 1, 1, h_org_member_remove }, + { "board.list", "List board members", PERM_READ, 1, 0, 0, h_board_list }, + { "board.add", "Add a board member", PERM_OWNER, 1, 1, 1, h_board_add }, + { "board.update", "Update a board member", PERM_OWNER, 1, 1, 1, + h_board_update }, + { "board.remove", "Remove a board member", PERM_OWNER, 1, 1, 1, + h_board_remove }, { "user.create", "Create a user (system admin)", PERM_ADMIN, 0, 1, 1, h_user_create }, { "user.list", "List users (system admin)", PERM_ADMIN, 0, 0, 0, @@ -38,6 +38,8 @@ static const char SCHEMA_V1[] = " country TEXT NOT NULL DEFAULT 'SE'," " email TEXT," " phone TEXT," + " description TEXT NOT NULL DEFAULT ''," + " shares INTEGER NOT NULL DEFAULT 0," " fiscal_year_start_month INTEGER NOT NULL DEFAULT 1" " CHECK (fiscal_year_start_month BETWEEN 1 AND 12)," " moms_period TEXT NOT NULL DEFAULT 'month'" @@ -49,6 +51,15 @@ static const char SCHEMA_V1[] = " archived_at TEXT" ") STRICT;\n" + "CREATE TABLE board_members (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " name TEXT NOT NULL," + " title TEXT NOT NULL," + " created_at TEXT NOT NULL," + " UNIQUE (org_id, id)" + ") STRICT;\n" + "CREATE TABLE memberships (" " org_id INTEGER NOT NULL REFERENCES orgs(id)," " user_id INTEGER NOT NULL REFERENCES users(id)," @@ -97,6 +108,11 @@ static const char SCHEMA_V1[] = " status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed'))," " locked_until TEXT," " dividend_ore INTEGER NOT NULL DEFAULT 0," + " events TEXT NOT NULL DEFAULT ''," + " agm_date TEXT NOT NULL DEFAULT ''," + " dividend_date TEXT NOT NULL DEFAULT ''," + " employees TEXT NOT NULL DEFAULT ''," + " notes TEXT NOT NULL DEFAULT ''," " created_at TEXT NOT NULL," " closed_at TEXT," " closed_by INTEGER REFERENCES users(id)," @@ -372,6 +388,47 @@ static int db_upgrade_v4(sqlite3 *db, char **err) err); } +/* v5: årsredovisning details — the company description and share count, + the year's material events, and a board-member list for signatures. */ +static int db_upgrade_v5(sqlite3 *db, char **err) +{ + if (db_exec(db, "ALTER TABLE orgs ADD COLUMN description TEXT NOT NULL" + " DEFAULT ''", err) != 0) + return -1; + if (db_exec(db, "ALTER TABLE orgs ADD COLUMN shares INTEGER NOT NULL" + " DEFAULT 0", err) != 0) + return -1; + if (db_exec(db, "ALTER TABLE fiscal_years ADD COLUMN events TEXT NOT NULL" + " DEFAULT ''", err) != 0) + return -1; + return db_exec(db, + "CREATE TABLE IF NOT EXISTS board_members (" + " org_id INTEGER NOT NULL REFERENCES orgs(id)," + " id INTEGER PRIMARY KEY," + " name TEXT NOT NULL," + " title TEXT NOT NULL," + " created_at TEXT NOT NULL," + " UNIQUE (org_id, id)" + ") STRICT", + err); +} + +/* v6: "Information om året" — the year-specific årsredovisning details. */ +static int db_upgrade_v6(sqlite3 *db, char **err) +{ + if (db_exec(db, "ALTER TABLE fiscal_years ADD COLUMN agm_date TEXT NOT NULL" + " DEFAULT ''", err) != 0) + return -1; + if (db_exec(db, "ALTER TABLE fiscal_years ADD COLUMN dividend_date TEXT" + " NOT NULL DEFAULT ''", err) != 0) + return -1; + if (db_exec(db, "ALTER TABLE fiscal_years ADD COLUMN employees TEXT NOT NULL" + " DEFAULT ''", err) != 0) + return -1; + return db_exec(db, "ALTER TABLE fiscal_years ADD COLUMN notes TEXT NOT NULL" + " DEFAULT ''", err); +} + static int db_upgrade(sqlite3 *db, int from, char **err) { if (db_exec(db, "BEGIN IMMEDIATE", err) != 0) @@ -388,6 +445,14 @@ static int db_upgrade(sqlite3 *db, int from, char **err) db_exec(db, "ROLLBACK", NULL); return -1; } + if (from < 5 && db_upgrade_v5(db, err) != 0) { + db_exec(db, "ROLLBACK", NULL); + return -1; + } + if (from < 6 && db_upgrade_v6(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 4 +#define BOKF_SCHEMA_VERSION 6 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 0dfdd08..6cd7b18 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -398,11 +398,15 @@ int main(void) "{\"org_nr\":\"559331-2126\",\"vat_nr\":\"SE559331212601\"," "\"address\":\"Lingonvägen 9\",\"postal_code\":\"192 48\"," "\"city\":\"Sollentuna\",\"email\":\"a@b.se\"," - "\"phone\":\"+46700000000\"}}", + "\"phone\":\"+46700000000\"," + "\"description\":\"Konsultverksamhet inom it.\"," + "\"shares\":25000}}", g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.vat_nr", "SE559331212601"); CHECK_STR(d, "result.org_nr", "559331-2126"); + CHECK_STR(d, "result.description", "Konsultverksamhet inom it."); + CHECK(jint(d, "result.shares") == 25000); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"8c\",\"cmd\":\"org.get\"," @@ -433,6 +437,71 @@ int main(void) g_session, (int)org_id)); CHECK_OK(d); CHECK_STR(d, "result.city", "Sollentuna"); + CHECK_STR(d, "result.description", "Konsultverksamhet inom it."); + CHECK(jint(d, "result.shares") == 25000); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"8g\",\"cmd\":\"board.add\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"name\":\"Anders Bergsten\"}}", + g_session, (int)org_id)); + CHECK_OK(d); + int64_t bm1 = jint(d, "result.id"); + CHECK(bm1 > 0); + CHECK_STR(d, "result.title", "Styrelseledamot"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"8h\",\"cmd\":\"board.add\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"name\":\"Saphira Bergsten\"," + "\"title\":\"Styrelseledamot\"}}", + g_session, (int)org_id)); + CHECK_OK(d); + int64_t bm2 = jint(d, "result.id"); + CHECK(bm2 > 0); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"8i\",\"cmd\":\"board.list\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)org_id)); + CHECK_OK(d); + CHECK(yyjson_arr_size(jget(d, "result.items")) == 2); + CHECK_STR(d, "result.items.0.name", "Anders Bergsten"); + CHECK_STR(d, "result.items.1.name", "Saphira Bergsten"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"8j\",\"cmd\":\"board.update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"title\":\"Suppleant\"}}", + g_session, (int)org_id, (long long)bm2)); + CHECK_OK(d); + CHECK_STR(d, "result.title", "Suppleant"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"8k\",\"cmd\":\"board.add\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":\"\"}}", + g_session, (int)org_id)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"8l\",\"cmd\":\"board.update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":999999," + "\"name\":\"Ingen\"}}", + g_session, (int)org_id)); + CHECK_STR(d, "error.code", "NOT_FOUND"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"8m\",\"cmd\":\"board.remove\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)org_id, (long long)bm2)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"8n\",\"cmd\":\"board.list\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)org_id)); + CHECK_OK(d); + CHECK(yyjson_arr_size(jget(d, "result.items")) == 1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"9\",\"cmd\":\"session.use_org\"," @@ -721,6 +790,81 @@ int main(void) CHECK_STR(d, "error.code", "INVALID_ARGS"); yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"46d\",\"cmd\":\"fiscal_year.update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"events\":\"Inga väsentliga händelser.\"," + "\"agm_date\":\"2026-06-04\"," + "\"dividend_date\":\"2026-06-05\"," + "\"employees\":\"1\",\"notes\":\"Revisor saknas.\"}}", + g_session, (int)org_id, (long long)fy1)); + CHECK_OK(d); + CHECK_STR(d, "result.events", "Inga väsentliga händelser."); + CHECK_STR(d, "result.agm_date", "2026-06-04"); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"46e\",\"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); + CHECK_STR(d, "result.events", "Inga väsentliga händelser."); + CHECK_STR(d, "result.agm_date", "2026-06-04"); + CHECK_STR(d, "result.dividend_date", "2026-06-05"); + CHECK_STR(d, "result.employees", "1"); + CHECK_STR(d, "result.notes", "Revisor saknas."); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"46f\",\"cmd\":\"fiscal_year.update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"agm_date\":\"2026-6-4\"}}", + g_session, (int)org_id, (long long)fy1)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + + /* a new year inherits the stable "Information om året" fields */ + { + d = call(reqf("{\"v\":1,\"id\":\"46g\",\"cmd\":\"org.create\"," + "\"session\":\"%s\",\"args\":{\"name\":\"Årskopia AB\"}}", + g_session)); + CHECK_OK(d); + int64_t ocopy = jint(d, "result.id"); + CHECK(ocopy > 0); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"46h\",\"cmd\":\"fiscal_year.list\"," + "\"session\":\"%s\",\"org\":%d}", + g_session, (int)ocopy)); + CHECK_OK(d); + int64_t fysrc = jint(d, "result.items.0.id"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"46i\",\"cmd\":\"fiscal_year.update\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld," + "\"events\":\"E\",\"employees\":\"2\"," + "\"notes\":\"N\",\"agm_date\":\"2026-06-04\"," + "\"dividend_date\":\"2026-06-05\"}}", + g_session, (int)ocopy, (long long)fysrc)); + CHECK_OK(d); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"46j\",\"cmd\":\"fiscal_year.open\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"label\":" + "\"2027\",\"start_date\":\"2027-01-01\"," + "\"end_date\":\"2027-12-31\"}}", + g_session, (int)ocopy)); + CHECK_OK(d); + int64_t fynext = jint(d, "result.id"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"46k\",\"cmd\":\"fiscal_year.get\"," + "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}", + g_session, (int)ocopy, (long long)fynext)); + CHECK_OK(d); + CHECK_STR(d, "result.events", "E"); + CHECK_STR(d, "result.employees", "2"); + CHECK_STR(d, "result.notes", "N"); + CHECK_STR(d, "result.agm_date", ""); + CHECK_STR(d, "result.dividend_date", ""); + CHECK(jint(d, "result.dividend_ore") == 0); + 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\":" |
