aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-19 21:10:54 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-19 21:10:54 +0200
commitbeb52c4a2ca23f29dc992e145810a47241c7d43b (patch)
treeb8fff8dce1cb01d42a1783040d7270558831bd2e
parent77c298b3b64f2493e509734df0084068ad24441f (diff)
downloadbokf-0.1.34.tar.gz
bokf-0.1.34.zip
årsredovisning: Information om året per räkenskapsår (schema v6)v0.1.34
-rw-r--r--clients/bokftui.c1252
-rw-r--r--docs/PROTOCOL.md10
-rw-r--r--docs/SCHEMA.md16
-rw-r--r--docs/STATE.md18
-rw-r--r--src/commands.c336
-rw-r--r--src/db.c65
-rw-r--r--src/db.h2
-rw-r--r--tests/test_core.c146
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", &dividend))
- return fail(r, "INVALID_ARGS", "dividend_ore is required");
- if (dividend < 0)
+ int have_div = arg_int(r->args, "dividend_ore", &dividend);
+ 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,
diff --git a/src/db.c b/src/db.c
index ebee37b..d3bd15f 100644
--- a/src/db.c
+++ b/src/db.c
@@ -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);
diff --git a/src/db.h b/src/db.h
index 7353c3c..fe3bfbc 100644
--- a/src/db.h
+++ b/src/db.h
@@ -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\":"