aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md4
-rw-r--r--Makefile14
-rw-r--r--clients/bokftui.c7485
-rw-r--r--clients/screens_accounts.c173
-rw-r--r--clients/screens_attachments.c163
-rw-r--r--clients/screens_audit.c132
-rw-r--r--clients/screens_bank.c368
-rw-r--r--clients/screens_bokslut.c998
-rw-r--r--clients/screens_dashboard.c354
-rw-r--r--clients/screens_ib.c354
-rw-r--r--clients/screens_invoices.c1324
-rw-r--r--clients/screens_reports.c1176
-rw-r--r--clients/screens_rules.c226
-rw-r--r--clients/screens_settings.c424
-rw-r--r--clients/screens_templates.c343
-rw-r--r--clients/screens_vouchers.c958
-rw-r--r--clients/ui.c674
-rw-r--r--clients/ui.h125
-rw-r--r--docs/STATE.md9
19 files changed, 7846 insertions, 7458 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 63c8adf..9798b4a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -78,6 +78,10 @@ forms behave the same everywhere, hints always visible, errors shown as
`tui_pad_field`, `tui_message`, `show_error`) — never ncurses directly and
never a local one-off loop. If no widget fits, extend the widget layer
first (spec in `TUI-GUIDELINES.md` + tests in `tests/test_tui.c`).
+Views live one file per group in `clients/screens_<group>.c`; shared UI
+helpers and `struct app` are in `clients/ui.[ch]`; the scene registry
+(`struct scene SCENES[]` and `open_scene`) is in `clients/bokftui.c` with
+`main`, login and the Ctrl+R reload.
## Verifying
diff --git a/Makefile b/Makefile
index 4061c1f..f0b2255 100644
--- a/Makefile
+++ b/Makefile
@@ -44,7 +44,18 @@ $(BUILD)/bokfctl: $(BUILD)/clients/bokfctl.o $(BUILD)/clients/client.o \
$(BUILD)/vendor/sha256.o
$(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS)
-$(BUILD)/bokftui: $(BUILD)/clients/bokftui.o $(BUILD)/clients/tui.o \
+TUI_SCREENS = clients/screens_dashboard.c clients/screens_vouchers.c \
+ clients/screens_accounts.c clients/screens_reports.c \
+ clients/screens_rules.c clients/screens_attachments.c \
+ clients/screens_bank.c clients/screens_audit.c \
+ clients/screens_templates.c clients/screens_ib.c \
+ clients/screens_settings.c clients/screens_invoices.c \
+ clients/screens_bokslut.c
+TUI_SCREEN_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(TUI_SCREENS))
+TUI_SCREEN_DEP = $(TUI_SCREEN_OBJ:.o=.d)
+
+$(BUILD)/bokftui: $(BUILD)/clients/bokftui.o $(BUILD)/clients/ui.o \
+ $(TUI_SCREEN_OBJ) $(BUILD)/clients/tui.o \
$(BUILD)/clients/client.o \
$(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/src/formula.o \
$(BUILD)/vendor/yyjson.o $(BUILD)/vendor/sha256.o
@@ -116,6 +127,7 @@ $(BUILD)/tests/%.o: tests/%.c
-include $(VENDOR_OBJ:.o=.d) $(CORE_OBJ:.o=.d) $(BUILD)/src/bokfd.d \
$(BUILD)/clients/bokfctl.d $(BUILD)/clients/bokftui.d \
+ $(BUILD)/clients/ui.d $(TUI_SCREEN_DEP) \
$(BUILD)/clients/tui.d $(BUILD)/clients/client.d \
$(BUILD)/tests/test_core.d $(BUILD)/tests/test_tui.d
diff --git a/clients/bokftui.c b/clients/bokftui.c
index f00bdc1..84d1a18 100644
--- a/clients/bokftui.c
+++ b/clients/bokftui.c
@@ -19,7352 +19,44 @@
#include "util.h"
#include "version.h"
#include "yyjson.h"
+#include "ui.h"
-/* universal "new/add" hotkey: ^N (0x0e), the de-facto Linux convention */
-#define KEY_CTRL_N 0x0e
-/* ^X clears the focused row in row editors */
-#define KEY_CTRL_X 0x18
-/* ^Enter saves/posts the form; sent via xterm modifyOtherKeys */
-#define KEY_CTRL_ENTER (KEY_MAX + 1)
-/* Pager markup: headings, dimmed derived lines and full-width rules. */
-#define MK_HEAD "\001"
-#define MK_DIM "\002"
-#define MK_RULE "\003"
-#define MK_STICKY "\004"
-/* Menu item prefix for a non-selectable section header. */
-#define MK_SECTION MK_HEAD
-/* ^C quits the application; Esc is navigation only */
-static int g_quit = 0;
-/* ^R re-execs the binary, keeping session, org, year and screen */
-static int g_reload = 0;
-static char g_scene[32] = "dashboard";
-static char g_reload_scene[32];
-
-static int ui_getch(void)
-{
- int ch = getch();
- if (ch == 3) { /* ^C */
- g_quit = 1;
- return 27; /* behave like "back" so screens unwind */
- }
- if (ch == 18) { /* ^R */
- g_reload = 1;
- snprintf(g_reload_scene, sizeof g_reload_scene, "%s", g_scene);
- return 27;
- }
- return ch;
-}
-
-static void request_quit(void)
-{
- g_quit = 1;
-}
-
-struct app {
- struct client_conn conn;
- char socket[256];
- char session[128];
- char username[64];
- char password[128]; /* typed or from BOKFD_PASSWORD, kept for ^R */
- int64_t org;
- char org_name[128];
- char role[32];
- int64_t fy;
- int64_t voucher_sel; /* last selected voucher id in the list view */
- int64_t invoice_sel; /* last selected invoice id in the list view */
- int64_t customer_sel; /* last selected customer id in the list view */
- char default_series[16];
- char attachment_dir[256];
- long max_attachment_bytes;
- char fy_label[64];
- char fy_start[16];
- char fy_end[16];
-};
-
-/* ------------------------------------------------------------------ */
-/* json helpers */
-/* ------------------------------------------------------------------ */
-
-static yyjson_val *jget(yyjson_val *root, const char *path)
-{
- char tmp[128];
- snprintf(tmp, sizeof tmp, "%s", path);
- yyjson_val *v = root;
- char *save = NULL;
- for (char *tok = strtok_r(tmp, ".", &save); tok;
- tok = strtok_r(NULL, ".", &save)) {
- if (!v)
- return NULL;
- if (yyjson_is_obj(v))
- v = yyjson_obj_get(v, tok);
- else if (yyjson_is_arr(v)) {
- char *end = NULL;
- long idx = strtol(tok, &end, 10);
- if (!end || *end)
- return NULL;
- v = yyjson_arr_get(v, (size_t)idx);
- } else {
- return NULL;
- }
- }
- return v;
-}
-
-static yyjson_doc *parse(const char *resp)
-{
- return resp ? yyjson_read(resp, strlen(resp), 0) : NULL;
-}
-
-static char *jstr_dup(const char *resp, const char *path)
-{
- yyjson_doc *d = parse(resp);
- yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
- char *out = v && yyjson_is_str(v) ? xstrdup(yyjson_get_str(v)) : NULL;
- yyjson_doc_free(d);
- return out;
-}
-
-static int64_t jint_val(const char *resp, const char *path, int64_t def)
-{
- yyjson_doc *d = parse(resp);
- yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
- int64_t out = v && yyjson_is_int(v) ? yyjson_get_int(v) : def;
- yyjson_doc_free(d);
- return out;
-}
-
-static size_t jarr_size(const char *resp, const char *path)
-{
- yyjson_doc *d = parse(resp);
- yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
- size_t n = v && yyjson_is_arr(v) ? yyjson_arr_size(v) : 0;
- yyjson_doc_free(d);
- return n;
-}
-
-static int jbool_val(const char *resp, const char *path, int def)
-{
- yyjson_doc *d = parse(resp);
- yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
- int out = v && yyjson_is_bool(v) ? yyjson_get_bool(v) : def;
- yyjson_doc_free(d);
- return out;
-}
-
-/* ------------------------------------------------------------------ */
-/* ui primitives */
-/* ------------------------------------------------------------------ */
-
-static char g_server_version[32];
-
-static void tui_log(const char *fmt, ...)
- __attribute__((format(printf, 1, 2)));
-
-static void show_error(const char *title, const char *resp);
-static void bokslut_screen(struct app *a);
-static void arsredovisning_screen(struct app *a);
-static char *rpc_dry(struct app *a, const char *cmd, const char *args);
-static int64_t vouchers_new(struct app *a);
-static void config_mkdirs(const char *path);
-
-/* Whole kronor with space thousands, öre truncated like the blankett. */
-static void kr_whole(int64_t ore, char *buf, size_t n)
-{
- char tmp[48];
- tui_kr_format((ore / 100) * 100, tmp, sizeof tmp);
- char *comma = strchr(tmp, ',');
- if (comma)
- *comma = '\0';
- snprintf(buf, n, "%s", tmp);
-}
-
-static int parse_x_double(const char *s, double *out)
-{
- if (!s)
- return 0;
- char buf[64];
- size_t j = 0;
- for (const char *p = s; *p && j < sizeof buf - 1; p++) {
- if (*p == ' ' || *p == '\t')
- continue;
- buf[j++] = (*p == ',') ? '.' : *p;
- }
- buf[j] = '\0';
- if (!j)
- return 0;
- char *end = NULL;
- double v = strtod(buf, &end);
- if (!end || *end)
- return 0;
- *out = v;
- return 1;
-}
-
-static void replace_x_into(const char *src, double x, char *dst, size_t cap)
-{
- size_t o = 0;
- for (const char *p = src; *p && o + 1 < cap;) {
- if (p[0] == '{' && p[1] == 'x' && p[2] == '}') {
- char tmp[64];
- snprintf(tmp, sizeof tmp, "%.2f", x);
- for (size_t k = 0; tmp[k] && o + 1 < cap; k++)
- dst[o++] = tmp[k];
- p += 3;
- } else {
- dst[o++] = *p++;
- }
- }
- dst[o] = '\0';
-}
-
-/* Display width in terminal columns, counting UTF-8 lead bytes. Good enough
- for Swedish text; combining marks are ignored. */
-
-
-/* ------------------------------------------------------------------ */
-/* app helpers */
-/* ------------------------------------------------------------------ */
-
-static void app_refresh_context(struct app *a)
-{
- char args[64];
- snprintf(args, sizeof args, "{\"org\":%lld}", (long long)a->org);
- char *resp = client_rpc(&a->conn, "session.use_org", a->session, 0, args);
- free(resp);
-
- resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}");
- if (resp && client_ok(resp)) {
- char *name = jstr_dup(resp, "result.name");
- if (name) {
- snprintf(a->org_name, sizeof a->org_name, "%s", name);
- free(name);
- }
- }
- free(resp);
-
- resp = client_rpc(&a->conn, "org.list", a->session, 0, "{}");
- if (resp) {
- size_t n = jarr_size(resp, "result.items");
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.items.%zu.id", i);
- if (jint_val(resp, path, -1) == a->org) {
- snprintf(path, sizeof path, "result.items.%zu.role", i);
- char *role = jstr_dup(resp, path);
- if (role) {
- snprintf(a->role, sizeof a->role, "%s", role);
- free(role);
- }
- }
- }
- }
- free(resp);
-
- snprintf(a->default_series, sizeof a->default_series, "%s", "A");
- a->attachment_dir[0] = '\0';
- resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
- if (resp && client_ok(resp)) {
- char *ser = jstr_dup(resp, "result.default_series");
- if (ser && *ser)
- snprintf(a->default_series, sizeof a->default_series, "%s", ser);
- free(ser);
- char *dir = jstr_dup(resp, "result.attachment_dir");
- if (dir && *dir)
- snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", dir);
- free(dir);
- }
- free(resp);
-
- a->max_attachment_bytes = 10 * 1024 * 1024;
- resp = client_rpc(&a->conn, "meta", NULL, 0, NULL);
- if (resp && client_ok(resp)) {
- char *ver = jstr_dup(resp, "result.version");
- if (ver && *ver)
- snprintf(g_server_version, sizeof g_server_version, "%s", ver);
- free(ver);
- int64_t v =
- jint_val(resp, "result.limits.max_attachment_bytes", 0);
- if (v > 0)
- a->max_attachment_bytes = (long)v;
- }
- free(resp);
-
- resp = client_rpc(&a->conn, "fiscal_year.get", a->session, a->org, "{}");
- if (resp && client_ok(resp)) {
- a->fy = jint_val(resp, "result.id", 0);
- char *label = jstr_dup(resp, "result.label");
- char *start = jstr_dup(resp, "result.start_date");
- char *end = jstr_dup(resp, "result.end_date");
- if (label)
- snprintf(a->fy_label, sizeof a->fy_label, "%s", label);
- if (start)
- snprintf(a->fy_start, sizeof a->fy_start, "%s", start);
- if (end)
- snprintf(a->fy_end, sizeof a->fy_end, "%s", end);
- free(label);
- free(start);
- free(end);
- }
- free(resp);
-}
-
-static void update_status(struct app *a)
-{
- char st[512], right[160];
- snprintf(st, sizeof st, "%s | %s - %s | %s | %s", a->org_name,
- a->fy_start, a->fy_end, a->role, a->username);
- if (g_server_version[0])
- snprintf(right, sizeof right, "tui %s srv %s", BOKF_VERSION,
- g_server_version);
- else
- snprintf(right, sizeof right, "tui %s", BOKF_VERSION);
- tui_set_status(st, right);
-}
-
-
-/* Create a new fiscal year. Returns the new id (>0) or 0. */
-static int64_t fy_new_form(struct app *a)
-{
- static int sel = 0;
- char label[32] = "", start[16] = "", end[16] = "";
- char *resp =
- client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}");
- if (resp && client_ok(resp)) {
- size_t n = jarr_size(resp, "result.items");
- char max_end[16] = "";
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.items.%zu.end_date", i);
- char *e = jstr_dup(resp, path);
- if (e && strcmp(e, max_end) > 0)
- snprintf(max_end, sizeof max_end, "%s", e);
- free(e);
- }
- if (max_end[0])
- util_date_add_days(max_end, 1, start, sizeof start);
- }
- free(resp);
- if (!start[0]) {
- time_t t = time(NULL);
- struct tm tm;
- gmtime_r(&t, &tm);
- int y = tm.tm_year + 1900;
- if (y < 1900 || y > 2200)
- y = 2026;
- snprintf(start, sizeof start, "%04d-01-01", y);
- }
- util_date_add_months(start, 12, end, sizeof end);
- util_date_add_days(end, -1, end, sizeof end);
- int sy = 0, sm = 0, sd = 0, ey = 0, em = 0, ed = 0;
- util_date_parse(start, &sy, &sm, &sd);
- util_date_parse(end, &ey, &em, &ed);
- if (sm == 1 && sd == 1)
- snprintf(label, sizeof label, "%d", ey);
- else
- snprintf(label, sizeof label, "%d/%d", sy, ey);
-
- struct tui_form_field ff[3];
- for (;;) {
- memset(ff, 0, sizeof ff);
- ff[0].label = "Etikett";
- ff[0].value = label;
- ff[0].cap = sizeof label;
- ff[0].kind = TUI_F_TEXT;
- ff[1].label = "Startdatum";
- ff[1].value = start;
- ff[1].cap = sizeof start;
- ff[1].kind = TUI_F_DATE;
- ff[2].label = "Slutdatum";
- ff[2].value = end;
- ff[2].cap = sizeof end;
- ff[2].kind = TUI_F_DATE;
- int r = tui_form_run("Nytt räkenskapsår", ff, 3, 1, &sel);
- if (r == TUI_FORM_BACK)
- return 0;
- if (r != TUI_FORM_REFRESH && r != TUI_FORM_SUBMIT)
- continue; /* a field was edited; keep the form up */
- if (!label[0]) {
- tui_message("Räkenskapsår", "Etiketten får inte vara tom.");
- continue;
- }
- if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) {
- tui_message("Räkenskapsår",
- "Start- och slutdatum måste vara ÅÅÅÅ-MM-DD.");
- continue;
- }
- if (strcmp(start, end) >= 0) {
- tui_message("Räkenskapsår",
- "Startdatum måste ligga före slutdatum.");
- continue;
- }
- if (r == TUI_FORM_REFRESH) {
- tui_message("Validering OK", "%s %s - %s", label, start, end);
- 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_strcpy(d, o, "label", label);
- yyjson_mut_obj_add_strcpy(d, o, "start_date", start);
- yyjson_mut_obj_add_strcpy(d, o, "end_date", end);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *oresp = client_rpc(&a->conn, "fiscal_year.open", a->session,
- a->org, args);
- free(args);
- if (oresp && client_ok(oresp)) {
- int64_t id = jint_val(oresp, "result.id", 0);
- tui_message("Räkenskapsår", "%s skapat.", label);
- free(oresp);
- return id;
- }
- show_error("Kunde inte skapa räkenskapsåret", oresp);
- free(oresp);
- }
-}
-
-/* Pick which fiscal year the screens work in. Shown as its real range so
- broken fiscal years (not starting in January) are obvious. Ctrl+A closes
- or reopens the highlighted year. */
-static void select_fiscal_year(struct app *a)
-{
- int64_t prefer = a->fy;
- int cursor = -1;
- for (;;) {
- if (g_quit)
- return;
- char *resp =
- client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}");
- if (!resp || !client_ok(resp)) {
- show_error("Räkenskapsår", resp);
- free(resp);
- return;
- }
- size_t n = jarr_size(resp, "result.items");
- if (n == 0) {
- free(resp);
- int64_t id = fy_new_form(a);
- if (id > 0)
- prefer = id;
- continue;
- }
- char **lines = xcalloc(n, sizeof(char *));
- int64_t *ids = xcalloc(n, sizeof(int64_t));
- int *closed = xcalloc(n, sizeof(int));
- char (*ranges)[40] = xcalloc(n, sizeof *ranges);
- char (*labels)[64] = xcalloc(n, sizeof *labels);
- int sel_default = 0;
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.items.%zu.id", i);
- ids[i] = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.label", i);
- char *label = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.start_date", i);
- char *sdate = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.end_date", i);
- char *edate = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.status", i);
- char *status = jstr_dup(resp, path);
- snprintf(ranges[i], sizeof ranges[i], "%s - %s",
- sdate ? sdate : "?", edate ? edate : "?");
- snprintf(labels[i], sizeof labels[i], "%s", label ? label : "");
- closed[i] = strcmp(status ? status : "", "closed") == 0;
- char line[256], stbuf[16];
- snprintf(stbuf, sizeof stbuf, "%s", closed[i] ? "stängd" : "öppen");
- tui_pad_field(stbuf, sizeof stbuf, 6);
- snprintf(line, sizeof line, "%-25s %s %s", ranges[i], stbuf,
- labels[i]);
- lines[i] = xstrdup(line);
- if (ids[i] == prefer)
- sel_default = (int)i;
- free(label);
- free(sdate);
- free(edate);
- free(status);
- }
- int start = cursor >= 0 ? cursor : sel_default;
- if (start >= (int)n)
- start = (int)n - 1;
- int sel = tui_select_list("Räkenskapsår", lines, (int)n, start, 0,
- &cursor, 1, NULL, 1);
- for (size_t i = 0; i < n; i++)
- free(lines[i]);
- free(lines);
- free(resp);
- if (sel == -4) {
- free(ids);
- free(closed);
- free(ranges);
- free(labels);
- int64_t id = fy_new_form(a);
- if (id > 0)
- prefer = id;
- continue;
- }
- if (sel == -7) {
- if (cursor >= 0 && (size_t)cursor < n) {
- char label[192];
- snprintf(label, sizeof label, "%s räkenskapsåret %s? (j/n): ",
- closed[cursor] ? "Återöppna" : "Avsluta",
- labels[cursor]);
- char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, label, "n", 0) ? ansbuf : NULL;
- if (ans && (ans[0] == 'j' || ans[0] == 'J')) {
- char args[64];
- snprintf(args, sizeof args,
- "{\"id\":%lld,\"confirm\":true}",
- (long long)ids[cursor]);
- char *r = client_rpc(&a->conn,
- closed[cursor]
- ? "fiscal_year.reopen"
- : "fiscal_year.close",
- a->session, a->org, args);
- if (r && client_ok(r))
- tui_message("Räkenskapsår", "Räkenskapsåret %s är %s.",
- labels[cursor],
- closed[cursor] ? "återöppnat" : "avslutat");
- else
- show_error("Kunde inte ändra status", r);
- free(r);
- }
- }
- free(ids);
- free(closed);
- free(ranges);
- free(labels);
- continue;
- }
- if (sel < 0) {
- free(ids);
- free(closed);
- free(ranges);
- free(labels);
- return;
- }
- a->fy = ids[sel];
- snprintf(a->fy_label, sizeof a->fy_label, "%s", labels[sel]);
- /* range is "start - end" */
- snprintf(a->fy_start, sizeof a->fy_start, "%.10s", ranges[sel]);
- snprintf(a->fy_end, sizeof a->fy_end, "%s", ranges[sel] + 13);
- free(ids);
- free(closed);
- free(ranges);
- free(labels);
- a->voucher_sel = 0;
- update_status(a);
- return;
- }
-}
-
-static void show_error(const char *title, const char *resp)
-{
- char *code = jstr_dup(resp, "error.code");
- char *msg = jstr_dup(resp, "error.message");
- if ((!code || !*code) && (!msg || !*msg)) {
- tui_message(title, "%s", resp && *resp
- ? resp
- : "Inget svar från servern (kör daemonen?)");
- } else {
- tui_message(title, "%s: %s", code && *code ? code : "fel",
- msg && *msg ? msg : "okänt fel");
- }
- free(code);
- free(msg);
-}
-
-static char *read_file_b64(const char *path)
-{
- FILE *f = fopen(path, "rb");
- if (!f)
- return NULL;
- struct buf b;
- buf_init(&b);
- unsigned char chunk[65536];
- size_t rn;
- while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0)
- buf_append(&b, chunk, rn);
- int bad = ferror(f);
- fclose(f);
- if (bad) {
- buf_free(&b);
- return NULL;
- }
- char *b64 = util_b64(b.p ? b.p : (const unsigned char *)"", b.len);
- buf_free(&b);
- return b64;
-}
-
-/* ------------------------------------------------------------------ */
-/* screens */
-/* ------------------------------------------------------------------ */
-
-struct fentry {
- char name[300];
- int isdir;
-};
-
-static int fentry_cmp(const void *a, const void *b)
-{
- const struct fentry *x = a;
- const struct fentry *y = b;
- if (x->isdir != y->isdir)
- return y->isdir - x->isdir;
- return strcasecmp(x->name, y->name);
-}
-
-/* Expands a leading ~ to $HOME (no ~user support). */
-static void expand_path(const char *in, char *out, size_t n)
-{
- if (in && in[0] == '~' && (in[1] == '\0' || in[1] == '/')) {
- const char *home = getenv("HOME");
- if (home)
- snprintf(out, n, "%s%s", home, in + 1);
- else
- snprintf(out, n, "%s", in[1] ? in + 2 : ".");
- } else {
- snprintf(out, n, "%s", in ? in : ".");
- }
-}
-
-/* File browser for choosing underlag. Starts in start_dir or $HOME.
- Returns a malloc'd path, or NULL on cancel. */
-static char *file_browser(struct app *a, const char *start_dir)
-{
- (void)a;
- if (g_quit)
- return NULL;
- char dir[1024];
- const char *start = (start_dir && *start_dir) ? start_dir : getenv("HOME");
- expand_path(start, dir, sizeof dir);
- if (!dir[0])
- snprintf(dir, sizeof dir, ".");
- for (;;) {
- DIR *d = opendir(dir);
- if (!d) {
- tui_message("Filväljare", "Kan inte öppna %s", dir);
- return NULL;
- }
- struct fentry *ents = NULL;
- size_t n = 0, cap = 0;
- struct dirent *de;
- while ((de = readdir(d)) != NULL) {
- if (strcmp(de->d_name, ".") == 0 ||
- strcmp(de->d_name, "..") == 0)
- continue; /* ".." is the first item in the list */
- if (de->d_name[0] == '.')
- continue; /* hidden files */
- char full[1600];
- snprintf(full, sizeof full, "%.1200s/%.299s", dir, de->d_name);
- struct stat st;
- if (stat(full, &st) != 0)
- continue;
- if (n == cap) {
- cap = cap ? cap * 2 : 64;
- ents = xrealloc(ents, cap * sizeof *ents);
- }
- snprintf(ents[n].name, sizeof ents[n].name, "%s", de->d_name);
- ents[n].isdir = S_ISDIR(st.st_mode);
- n++;
- if (n >= 2000)
- break;
- }
- closedir(d);
- qsort(ents, n, sizeof *ents, fentry_cmp);
- char **items = xcalloc(n + 1, sizeof(char *));
- items[0] = xstrdup(".. (uppåt)");
- for (size_t i = 0; i < n; i++) {
- char line[340];
- snprintf(line, sizeof line, "%s%s", ents[i].name,
- ents[i].isdir ? "/" : "");
- items[i + 1] = xstrdup(line);
- }
- char title[1200];
- snprintf(title, sizeof title, "Välj fil — %.200s", dir);
- int sel = tui_select_list(title, items, (int)n + 1, 0, 1, NULL, 0, NULL, 0);
- for (size_t i = 0; i <= n; i++)
- free(items[i]);
- free(items);
- if (sel < 0) {
- free(ents);
- return NULL;
- }
- if (sel == 0) {
- char *slash = strrchr(dir, '/');
- if (slash && slash != dir)
- *slash = '\0';
- else if (slash == dir)
- dir[1] = '\0';
- free(ents);
- continue;
- }
- size_t idx = (size_t)sel - 1;
- char full[1600];
- snprintf(full, sizeof full, "%.1200s/%.299s", dir, ents[idx].name);
- if (ents[idx].isdir) {
- snprintf(dir, sizeof dir, "%.1023s", full);
- free(ents);
- continue;
- }
- free(ents);
- return xstrdup(full);
- }
-}
-
-/* Valid UTF-8 without NUL bytes: good enough to call a receipt text. */
-static int bytes_look_text(const unsigned char *p, size_t n)
-{
- for (size_t i = 0; i < n;) {
- unsigned char c = p[i];
- if (c == 0)
- return 0;
- if (c < 0x80) {
- i++;
- continue;
- }
- int len;
- if ((c & 0xE0) == 0xC0)
- len = 2;
- else if ((c & 0xF0) == 0xE0)
- len = 3;
- else if ((c & 0xF8) == 0xF0)
- len = 4;
- else
- return 0;
- if (i + (size_t)len > n)
- return 0;
- for (int k = 1; k < len; k++)
- if ((p[i + k] & 0xC0) != 0x80)
- return 0;
- i += (size_t)len;
- }
- return 1;
-}
-
-/* Fetch one attachment, save it to a prompted path and verify its hash.
- Text attachments are shown inline after saving. */
-static void attachment_download(struct app *a, int64_t att_id)
-{
- char args[64];
- snprintf(args, sizeof args, "{\"id\":%lld}", (long long)att_id);
- char *resp =
- client_rpc(&a->conn, "attachment.get", a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Kunde inte hämta underlaget", resp);
- free(resp);
- return;
- }
- char *fn = jstr_dup(resp, "result.filename");
- char *mime = jstr_dup(resp, "result.mime");
- char *sha = jstr_dup(resp, "result.sha256");
- char *b64 = jstr_dup(resp, "result.content_base64");
- unsigned char *data = NULL;
- size_t n = 0;
- if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) {
- tui_message("Underlag", "Kunde inte avkoda innehållet.");
- goto done;
- }
- const char *home = getenv("HOME");
- char def[600], dl[512];
- snprintf(dl, sizeof dl, "%s/Downloads", home && *home ? home : ".");
- struct stat sb;
- if (stat(dl, &sb) != 0 || !S_ISDIR(sb.st_mode))
- snprintf(dl, sizeof dl, "%s", home && *home ? home : ".");
- snprintf(def, sizeof def, "%s/%s", dl, fn && *fn ? fn : "underlag");
- char pathbuf[512]; char *path = tui_prompt_into(pathbuf, sizeof pathbuf, "Spara underlag: ", def, 0) ? pathbuf : NULL;
- if (!path || !*path)
- goto done;
- char full[600];
- if (path[0] == '~' && (path[1] == '/' || path[1] == '\0'))
- snprintf(full, sizeof full, "%s%s", home ? home : "", path + 1);
- else
- snprintf(full, sizeof full, "%s", path);
- if (stat(full, &sb) == 0) {
- char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, "Filen finns, skriv över? (j/n): ", "n", 0) ? ansbuf : NULL;
- if (!ans || (ans[0] != 'j' && ans[0] != 'J'))
- goto done;
- }
- FILE *f = fopen(full, "wb");
- if (!f || fwrite(data, 1, n, f) != n) {
- tui_message("Underlag", "Kunde inte skriva %s: %s", full, strerror(errno));
- if (f)
- fclose(f);
- goto done;
- }
- fclose(f);
- unsigned char raw[32];
- char hex[65];
- util_sha256(data, n, raw);
- util_hex(raw, sizeof raw, hex);
- if (sha && *sha && strcmp(hex, sha) != 0)
- tui_message("Underlag", "VARNING: kontrollsumman stämmer inte.\nSparat: %s",
- full);
- else
- tui_message("Underlag", "Sparat: %s\n(%zu byte)", full, n);
- int is_text = (mime && strncmp(mime, "text/", 5) == 0) ||
- (n > 0 && bytes_look_text(data, n));
- if (is_text && n <= 256 * 1024) {
- char *copy = xmalloc(n + 1);
- memcpy(copy, data, n);
- copy[n] = '\0';
- tui_pager("Underlag", copy, NULL);
- free(copy);
- }
-done:
- free(data);
- free(b64);
- free(sha);
- free(mime);
- free(fn);
- free(resp);
-}
-
-struct vdctx {
- struct app *a;
- int64_t id;
- size_t natts;
- const char *resp;
- int corrected;
- int want_refresh;
- int posted_new;
- int64_t posted_id;
-};
-
-static int vd_key(void *ud, int ch)
-{
- struct vdctx *ctx = ud;
- struct app *a = ctx->a;
- if (ch == TUI_KEY_CTRL_N) {
- int64_t nid = vouchers_new(a);
- if (nid > 0) {
- ctx->posted_id = nid;
- ctx->posted_new = 1;
- return TUI_HOOK_BACK;
- }
- return TUI_HOOK_STAY;
- }
- if (ch == 'c') {
- char reasonbuf[512];
- char *reason =
- tui_prompt_into(reasonbuf, sizeof reasonbuf, "Beskriv rättelsen: ",
- "", 0)
- ? reasonbuf
- : NULL;
- if (reason && *reason) {
- 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, "voucher", ctx->id);
- yyjson_mut_obj_add_strcpy(d, o, "description", reason);
- char *ref = util_random_id("tui-", 8);
- yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref);
- char *cargs = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *r = client_rpc(&a->conn, "voucher.correct", a->session,
- a->org, cargs);
- if (r && client_ok(r)) {
- int64_t new_id = jint_val(r, "result.id", 0);
- tui_message("Rättat", "Ändringsverifikat %lld skapat",
- (long long)new_id);
- ctx->corrected = 1;
- } else {
- show_error("Kunde inte rätta", r);
- }
- free(r);
- free(cargs);
- free(ref);
- }
- return TUI_HOOK_BACK;
- }
- if (ch == 6) { /* ^F: attach a file to this voucher */
- char *path = file_browser(a, a->attachment_dir);
- if (path) {
- struct stat sb;
- if (stat(path, &sb) == 0 &&
- (long)sb.st_size > a->max_attachment_bytes) {
- tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.",
- (double)sb.st_size / (1024 * 1024),
- a->max_attachment_bytes / (1024 * 1024));
- free(path);
- } else {
- char *b64 = read_file_b64(path);
- if (!b64) {
- tui_message("Fel", "Kunde inte läsa %s", path);
- free(path);
- } else {
- const char *base = strrchr(path, '/');
- base = base ? base + 1 : path;
- 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, "filename", base);
- yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
- yyjson_mut_obj_add_int(d, o, "voucher_id", ctx->id);
- char *fargs = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- free(b64);
- free(path);
- char *r = client_rpc(&a->conn, "attachment.put", a->session,
- a->org, fargs);
- if (r && client_ok(r))
- tui_message("Underlag", "Bifogat.");
- else
- show_error("Kunde inte bifoga", r);
- free(r);
- free(fargs);
- ctx->want_refresh = 1;
- return TUI_HOOK_BACK;
- }
- }
- }
- return TUI_HOOK_STAY;
- }
- if (ch == 'f' && ctx->natts > 0) {
- char **alines = xcalloc(ctx->natts, sizeof(char *));
- int64_t *aids = xcalloc(ctx->natts, sizeof(int64_t));
- for (size_t i = 0; i < ctx->natts; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.attachments.%zu.id", i);
- aids[i] = jint_val(ctx->resp, path, 0);
- snprintf(path, sizeof path, "result.attachments.%zu.filename", i);
- char *fn = jstr_dup(ctx->resp, path);
- alines[i] = xstrdup(fn ? fn : "(namnlös)");
- free(fn);
- }
- int acursor = 0;
- int asel = tui_select_list("Underlag", alines, (int)ctx->natts, 0, 0,
- &acursor, 0, "ta bort", 0);
- if (asel >= 0) {
- attachment_download(a, aids[asel]);
- } else if (asel == -6 && acursor >= 0 &&
- (size_t)acursor < ctx->natts) {
- char q[320];
- snprintf(q, sizeof q, "Ta bort '%s' från verifikatet? (j/n): ",
- alines[acursor]);
- char ansbuf[512];
- char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, q, "n", 0)
- ? ansbuf
- : NULL;
- if (ans && (ans[0] == 'j' || ans[0] == 'J')) {
- char uargs[64];
- snprintf(uargs, sizeof uargs, "{\"id\":%lld,\"voucher_id\":%lld}",
- (long long)aids[acursor], (long long)ctx->id);
- char *r = client_rpc(&a->conn, "attachment.unlink", a->session,
- a->org, uargs);
- if (r && client_ok(r)) {
- tui_message("Underlag", "Länken borttagen.");
- ctx->want_refresh = 1;
- for (size_t i = 0; i < ctx->natts; i++)
- free(alines[i]);
- free(alines);
- free(aids);
- return TUI_HOOK_REFRESH;
- }
- show_error("Kunde inte ta bort", r);
- free(r);
- }
- }
- for (size_t i = 0; i < ctx->natts; i++)
- free(alines[i]);
- free(alines);
- free(aids);
- return TUI_HOOK_STAY;
- }
- return TUI_HOOK_NONE;
-}
-
-static int voucher_detail(struct app *a, int64_t id, int64_t *out_new)
-{
- if (out_new)
- *out_new = 0;
- char args[64];
- snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id);
- for (;;) {
- char *resp =
- client_rpc(&a->conn, "voucher.get", a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Verifikat", resp);
- free(resp);
- return 0;
- }
- struct buf text;
- buf_init(&text);
- char *series = jstr_dup(resp, "result.series");
- char *date = jstr_dup(resp, "result.date");
- char *desc = jstr_dup(resp, "result.description");
- char *hash = jstr_dup(resp, "result.hash");
- int64_t number = jint_val(resp, "result.number", 0);
- char line[1024];
- snprintf(line, sizeof line, "%s%lld %s %s\n\n",
- series ? series : "", (long long)number, date ? date : "",
- desc ? desc : "");
- buf_append(&text, line, strlen(line));
- int hdr = snprintf(
- line, sizeof line, MK_HEAD " %-6s %-34s D %12s K %12s\n",
- "Konto", "Benämning", "Debet", "Kredit");
- buf_append(&text, line, (size_t)hdr);
- size_t nrows = jarr_size(resp, "result.rows");
- for (size_t i = 0; i < nrows; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.rows.%zu.account", i);
- char *acc = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.name", i);
- char *name = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.debit_ore", i);
- int64_t debit = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.rows.%zu.credit_ore", i);
- int64_t credit = jint_val(resp, path, 0);
- char d[32], c[32];
- tui_kr_format(debit, d, sizeof d);
- tui_kr_format(credit, c, sizeof c);
- char acol[32], ncol[256];
- snprintf(acol, sizeof acol, "%s", acc ? acc : "");
- tui_pad_field(acol, sizeof acol, 6);
- snprintf(ncol, sizeof ncol, "%s", name ? name : "");
- tui_pad_field(ncol, sizeof ncol, 34);
- snprintf(line, sizeof line, " %s %s D %12s K %12s\n", acol,
- ncol, d, c);
- buf_append(&text, line, strlen(line));
- free(acc);
- free(name);
- }
- size_t natts = jarr_size(resp, "result.attachments");
- if (natts) {
- buf_append(&text, "\n", 1);
- line[0] = MK_RULE[0];
- for (int i = 1; i <= 98; i++)
- line[i] = '-';
- line[99] = '\n';
- buf_append(&text, line, 100);
- buf_append(&text, MK_HEAD "Underlag:\n", 11);
- for (size_t i = 0; i < natts; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.attachments.%zu.filename",
- i);
- char *fn = jstr_dup(resp, path);
- snprintf(line, sizeof line, " %s\n", fn ? fn : "");
- buf_append(&text, line, strlen(line));
- free(fn);
- }
- }
- if (hash) {
- snprintf(line, sizeof line, "\nHash: %s\n", hash);
- buf_append(&text, line, strlen(line));
- }
- int64_t corrects = jint_val(resp, "result.corrects_voucher_id", 0);
- if (corrects) {
- snprintf(line, sizeof line, "Rättar verifikat: %lld\n",
- (long long)corrects);
- buf_append(&text, line, strlen(line));
- }
- buf_append(&text, "\0", 1);
-
- struct vdctx ctx;
- memset(&ctx, 0, sizeof ctx);
- ctx.a = a;
- ctx.id = id;
- ctx.natts = natts;
- ctx.resp = resp;
- char hint[256];
- snprintf(hint, sizeof hint,
- "^N = nytt verifikat c = rätta ^F = bifoga%s",
- natts ? " f = underlag" : "");
- int want_refresh = 0, corrected = 0;
- int ret = tui_pager_hook("Verifikat", (const char *)text.p, hint, 0,
- vd_key, &ctx);
- if (ret == 1)
- want_refresh = 1;
- if (ctx.want_refresh)
- want_refresh = 1;
- corrected = ctx.corrected;
- int posted_new = ctx.posted_new;
- int64_t posted_id = ctx.posted_id;
- free(series);
- free(date);
- free(desc);
- free(hash);
- buf_free(&text);
- free(resp);
- if (posted_new) {
- if (out_new)
- *out_new = posted_id;
- return 2;
- }
- if (want_refresh)
- continue;
- return corrected;
- }
-}
-
-struct tui_vrow {
- char account[16];
- char debit[32];
- char credit[32];
- char text[64];
-};
-
-struct acct_cache {
- int loaded;
- int64_t org;
- char **numbers;
- char **names;
- size_t n;
-};
-
-static struct acct_cache g_accts;
-
-static void acct_cache_free(void)
-{
- for (size_t i = 0; i < g_accts.n; i++) {
- free(g_accts.numbers[i]);
- free(g_accts.names[i]);
- }
- free(g_accts.numbers);
- free(g_accts.names);
- memset(&g_accts, 0, sizeof g_accts);
-}
-
-static void acct_cache_load(struct app *a)
-{
- if (g_accts.loaded && g_accts.org == a->org)
- return;
- acct_cache_free();
- char *resp = client_rpc(&a->conn, "account.list", a->session, a->org,
- "{\"active_only\":true}");
- if (!resp || !client_ok(resp)) {
- free(resp);
- return;
- }
- size_t n = jarr_size(resp, "result.items");
- g_accts.numbers = xcalloc(n ? n : 1, sizeof(char *));
- g_accts.names = xcalloc(n ? n : 1, sizeof(char *));
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.items.%zu.number", i);
- char *number = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.name", i);
- char *name = jstr_dup(resp, path);
- g_accts.numbers[i] = number ? number : xstrdup("");
- g_accts.names[i] = name ? name : xstrdup("");
- }
- g_accts.n = n;
- g_accts.org = a->org;
- g_accts.loaded = 1;
- free(resp);
-}
-
-static const char *acct_name(struct app *a, const char *number)
-{
- if (!number || !*number)
- return NULL;
- acct_cache_load(a);
- for (size_t i = 0; i < g_accts.n; i++)
- if (strcmp(g_accts.numbers[i], number) == 0)
- return g_accts.names[i];
- return NULL;
-}
-
-static int acct_exists(struct app *a, const char *number)
-{
- if (!number || !*number)
- return 0;
- acct_cache_load(a);
- for (size_t i = 0; i < g_accts.n; i++)
- if (strcmp(g_accts.numbers[i], number) == 0)
- return 1;
- return 0;
-}
-
-
-struct vform {
- struct app *a;
- struct tui_rt rt;
- struct tui_vrow rows[64];
- int64_t att_ids[32];
- char att_names[32][80];
- int natt;
- char date[16];
- char series[16];
- char desc[256];
- char client_ref[64];
-};
-
-static void vform_cell(void *ud, int row, int col, char **buf, size_t *cap)
-{
- struct vform *vf = ud;
- struct tui_vrow *r = &vf->rows[row];
- if (col == 0) {
- *buf = r->account;
- *cap = sizeof r->account;
- } else if (col == 2) {
- *buf = r->debit;
- *cap = sizeof r->debit;
- } else if (col == 3) {
- *buf = r->credit;
- *cap = sizeof r->credit;
- } else {
- *buf = r->text;
- *cap = sizeof r->text;
- }
-}
-
-static void vform_info(void *ud, int row, char *buf, size_t n)
-{
- struct vform *vf = ud;
- const char *nm = acct_name(vf->a, vf->rows[row].account);
- snprintf(buf, n, "%s", nm ? nm : "");
-}
-
-static void vform_footer(void *ud, char *buf, size_t n)
-{
- struct vform *vf = ud;
- int64_t sum_d = 0, sum_c = 0;
- for (int i = 0; i < vf->rt.nrows; i++) {
- int64_t v;
- if (tui_parse_kr(vf->rows[i].debit, &v) == 0)
- sum_d += v;
- if (tui_parse_kr(vf->rows[i].credit, &v) == 0)
- sum_c += v;
- }
- char d1[32], c1[32], diff[32], line[1024];
- tui_kr_format(sum_d, d1, sizeof d1);
- tui_kr_format(sum_c, c1, sizeof c1);
- tui_kr_format(sum_d - sum_c, diff, sizeof diff);
- int balanced = sum_d == sum_c;
- struct buf t;
- buf_init(&t);
- if (vf->natt > 0) {
- snprintf(line, sizeof line, "Bilagor: ");
- for (int i = 0; i < vf->natt; i++) {
- size_t used = strlen(line);
- if (used + 1 >= sizeof line)
- break;
- snprintf(line + used, sizeof line - used, "%.79s%s",
- vf->att_names[i], i + 1 < vf->natt ? ", " : "");
- }
- buf_append(&t, line, strlen(line));
- buf_append(&t, "\n", 1);
- }
- snprintf(line, sizeof line, "Summa debet %12s kredit %12s %s", d1, c1,
- balanced ? "I BALANS" : "DIFF");
- buf_append(&t, line, strlen(line));
- if (!balanced) {
- snprintf(line, sizeof line, "\nDifferens: %s", diff);
- buf_append(&t, line, strlen(line));
- }
- buf_append(&t, "\0", 1);
- snprintf(buf, n, "%s", (char *)t.p);
- buf_free(&t);
-}
-
-static void vform_attach(struct vform *vf)
-{
- struct app *a = vf->a;
- char *path = file_browser(a, a->attachment_dir);
- if (!path)
- return;
- struct stat sb;
- if (stat(path, &sb) == 0 &&
- (long)sb.st_size > a->max_attachment_bytes) {
- tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.",
- (double)sb.st_size / (1024 * 1024),
- a->max_attachment_bytes / (1024 * 1024));
- free(path);
- return;
- }
- char *b64 = read_file_b64(path);
- if (!b64) {
- tui_message("Bilaga", "Kunde inte läsa %s", path);
- free(path);
- return;
- }
- if (vf->natt >= 32) {
- tui_message("Bilaga", "Max 32 bilagor per verifikat.");
- free(b64);
- free(path);
- return;
- }
- const char *base = strrchr(path, '/');
- base = base ? base + 1 : path;
- 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, "filename", base);
- yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- size_t need = strlen(args) + 256;
- char *raw = xmalloc(need);
- snprintf(raw, need,
- "{\"v\":1,\"id\":\"tui\",\"cmd\":\"attachment.put\","
- "\"session\":\"%s\",\"org\":%lld,\"args\":%s}",
- a->session, (long long)a->org, args);
- free(args);
- char *r = NULL;
- if (client_send_line(&a->conn, raw) == 0)
- r = client_read_line(&a->conn);
- free(raw);
- if (r && client_ok(r)) {
- vf->att_ids[vf->natt] = jint_val(r, "result.id", 0);
- snprintf(vf->att_names[vf->natt], sizeof vf->att_names[vf->natt],
- "%s", base);
- vf->natt++;
- } else {
- show_error("Kunde inte bifoga filen", r);
- }
- free(r);
- free(b64);
- free(path);
-}
-
-static void vform_template(struct vform *vf)
-{
- struct app *a = vf->a;
- char *lresp = client_rpc(&a->conn, "template.list", a->session, a->org,
- "{\"active_only\":true}");
- if (!lresp || !client_ok(lresp)) {
- show_error("Mallar", lresp);
- free(lresp);
- return;
- }
- size_t ln = jarr_size(lresp, "result.items");
- if (ln == 0) {
- tui_message("Mallar",
- "Inga mallar ännu. Skapa en under Mallar först.");
- free(lresp);
- return;
- }
- char **names = xcalloc(ln, sizeof(char *));
- char **lines = xcalloc(ln, sizeof(char *));
- for (size_t i = 0; i < ln; i++) {
- char path[64], line[512];
- snprintf(path, sizeof path, "result.items.%zu.name", i);
- char *nm = jstr_dup(lresp, path);
- snprintf(path, sizeof path, "result.items.%zu.series", i);
- char *ser = jstr_dup(lresp, path);
- snprintf(path, sizeof path, "result.items.%zu.row_count", i);
- int64_t rc = jint_val(lresp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.description", i);
- char *ds = jstr_dup(lresp, path);
- char nmbuf[128];
- snprintf(nmbuf, sizeof nmbuf, "%s", nm ? nm : "");
- tui_pad_field(nmbuf, sizeof nmbuf, 24);
- snprintf(line, sizeof line, "%s %-3s %2lld rader %s", nmbuf,
- ser ? ser : "", (long long)rc, ds ? ds : "");
- names[i] = xstrdup(nm ? nm : "");
- lines[i] = xstrdup(line);
- free(nm);
- free(ser);
- free(ds);
- }
- int tsel = tui_select_list("Välj mall", lines, (int)ln, 0, 1, NULL, 0,
- NULL, 0);
- char tname[128] = "";
- if (tsel >= 0)
- snprintf(tname, sizeof tname, "%s", names[tsel]);
- for (size_t i = 0; i < ln; i++) {
- free(names[i]);
- free(lines[i]);
- }
- free(names);
- free(lines);
- free(lresp);
- if (tsel < 0)
- return;
- yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
- yyjson_mut_val *to = yyjson_mut_obj(d);
- yyjson_mut_doc_set_root(d, to);
- yyjson_mut_obj_add_strcpy(d, to, "name", tname);
- char *targs = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *resp =
- client_rpc(&a->conn, "template.get", a->session, a->org, targs);
- free(targs);
- if (!resp || !client_ok(resp)) {
- show_error("Mall", resp);
- free(resp);
- return;
- }
- char xsbuf[512];
- char *xs = tui_prompt_into(xsbuf, sizeof xsbuf, "Belopp (x, kr): ", "",
- 0)
- ? xsbuf
- : NULL;
- double xv = 0;
- if (xs && *xs && !parse_x_double(xs, &xv)) {
- tui_message("Fel", "Ogiltigt belopp.");
- free(resp);
- return;
- }
- size_t n = jarr_size(resp, "result.rows");
- char **accts = xcalloc(n ? n : 1, sizeof(char *));
- char **forms = xcalloc(n ? n : 1, sizeof(char *));
- char **descs = xcalloc(n ? n : 1, sizeof(char *));
- struct template_row *in = xcalloc(n ? n : 1, sizeof *in);
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.rows.%zu.account", i);
- accts[i] = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.formula", i);
- forms[i] = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.description", i);
- descs[i] = jstr_dup(resp, path);
- in[i].account = accts[i] ? accts[i] : "";
- in[i].formula = forms[i] ? forms[i] : "";
- in[i].description = descs[i] && *descs[i] ? descs[i] : NULL;
- }
- struct resolved_row *out = xcalloc(n ? n : 1, sizeof *out);
- size_t on = 0;
- char ferr[256] = "";
- if (formula_resolve_rows(in, n, xv, out, &on, ferr, sizeof ferr) != 0) {
- tui_message("Mall", "%s",
- ferr[0] ? ferr : "kunde inte lösa mallen");
- } else {
- memset(vf->rows, 0, sizeof vf->rows);
- int use = (int)(on > 64 ? 64 : on);
- for (int i = 0; i < use; i++) {
- snprintf(vf->rows[i].account, sizeof vf->rows[i].account, "%s",
- out[i].account);
- char tmp[32];
- if (out[i].debit_ore) {
- tui_kr_format(out[i].debit_ore, tmp, sizeof tmp);
- snprintf(vf->rows[i].debit, sizeof vf->rows[i].debit, "%s",
- tmp);
- } else {
- tui_kr_format(out[i].credit_ore, tmp, sizeof tmp);
- snprintf(vf->rows[i].credit, sizeof vf->rows[i].credit, "%s",
- tmp);
- }
- if (out[i].description[0])
- snprintf(vf->rows[i].text, sizeof vf->rows[i].text, "%s",
- out[i].description);
- }
- vf->rt.nrows = use > 0 ? use : 1;
- char *tds = jstr_dup(resp, "result.description");
- char *tser = jstr_dup(resp, "result.series");
- if (!vf->desc[0] && tds && *tds)
- replace_x_into(tds, xv, vf->desc, sizeof vf->desc);
- if (tser && *tser)
- snprintf(vf->series, sizeof vf->series, "%s", tser);
- free(tds);
- free(tser);
- vf->rt.row = 0;
- vf->rt.col = 0;
- vf->rt.focus = -1;
- vf->rt.fresh = 1;
- vf->rt.pos = (size_t)-1;
- tui_rt_normalize(&vf->rt);
- }
- free(out);
- for (size_t i = 0; i < n; i++) {
- free(accts[i]);
- free(forms[i]);
- free(descs[i]);
- }
- free(accts);
- free(forms);
- free(descs);
- free(in);
- free(resp);
-}
-
-/* Returns the posted voucher id, or 0 to stay in the form. */
-static int64_t vform_post(struct vform *vf, int dry)
-{
- struct app *a = vf->a;
- 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, "date", vf->date);
- yyjson_mut_obj_add_strcpy(d, o, "description", vf->desc);
- yyjson_mut_obj_add_strcpy(d, o, "series", vf->series);
- yyjson_mut_obj_add_strcpy(d, o, "client_ref", vf->client_ref);
- yyjson_mut_val *arr = yyjson_mut_arr(d);
- for (int i = 0; i < vf->rt.nrows; i++) {
- if (!vf->rows[i].account[0])
- continue;
- int64_t dv = 0, cv = 0;
- if (tui_parse_kr(vf->rows[i].debit, &dv) != 0 ||
- tui_parse_kr(vf->rows[i].credit, &cv) != 0) {
- tui_message("Fel", "Ogiltigt belopp på rad %d", i + 1);
- yyjson_mut_doc_free(d);
- return 0;
- }
- yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
- yyjson_mut_obj_add_strcpy(d, ro, "account", vf->rows[i].account);
- yyjson_mut_obj_add_int(d, ro, "debit_ore", dv);
- yyjson_mut_obj_add_int(d, ro, "credit_ore", cv);
- if (vf->rows[i].text[0])
- yyjson_mut_obj_add_strcpy(d, ro, "description",
- vf->rows[i].text);
- }
- yyjson_mut_obj_add_val(d, o, "rows", arr);
- if (vf->natt > 0) {
- yyjson_mut_val *aa = yyjson_mut_arr(d);
- for (int i = 0; i < vf->natt; i++)
- yyjson_mut_arr_add_int(d, aa, vf->att_ids[i]);
- yyjson_mut_obj_add_val(d, o, "attachment_ids", aa);
- }
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- if (dry) {
- char *raw = xmalloc(strlen(args) + 256);
- sprintf(raw,
- "{\"v\":1,\"id\":\"tui\",\"cmd\":\"voucher.post\","
- "\"session\":\"%s\",\"org\":%lld,\"dry_run\":true,"
- "\"args\":%s}",
- a->session, (long long)a->org, args);
- if (client_send_line(&a->conn, raw) == 0) {
- char *r = client_read_line(&a->conn);
- if (r && client_ok(r)) {
- int64_t num = jint_val(r, "result.number", 0);
- tui_message("Validering OK",
- "Nummer %lld skulle tilldelas.", (long long)num);
- } else {
- show_error("Validering misslyckades", r);
- }
- free(r);
- } else {
- tui_message("Fel", "Kunde inte nå servern");
- }
- free(raw);
- } else {
- char *r = client_rpc(&a->conn, "voucher.post", a->session, a->org,
- args);
- if (r && client_ok(r)) {
- int64_t id = jint_val(r, "result.id", 0);
- int64_t num = jint_val(r, "result.number", 0);
- char *ser = jstr_dup(r, "result.series");
- int replayed = jbool_val(r, "result.replayed", 0);
- tui_message("Bokfört", "%s%lld (id %lld)%s", ser ? ser : "",
- (long long)num, (long long)id,
- replayed ? " — redan bokfört (idempotent)" : "");
- free(ser);
- free(r);
- free(args);
- return id;
- }
- show_error("Kunde inte bokföra", r);
- free(r);
- }
- free(args);
- return 0;
-}
-
-static int vform_key(void *ud, int ch)
-{
- struct vform *vf = ud;
- if (ch == KEY_F(4)) {
- vform_template(vf);
- return TUI_HOOK_STAY;
- }
- if (ch == 6) {
- vform_attach(vf);
- return TUI_HOOK_STAY;
- }
- return TUI_HOOK_NONE;
-}
-
-static int64_t vouchers_new(struct app *a)
-{
- struct vform vf;
- memset(&vf, 0, sizeof vf);
- vf.a = a;
- snprintf(vf.date, sizeof vf.date, "%s",
- a->fy_start[0] ? a->fy_start : "2026-01-01");
- snprintf(vf.series, sizeof vf.series, "%s", a->default_series);
- char *ref = util_random_id("tui-", 8);
- snprintf(vf.client_ref, sizeof vf.client_ref, "%s", ref);
- free(ref);
- int text_w = COLS - 60;
- if (text_w < 8)
- text_w = 8;
- struct tui_rt_col cols[5] = {
- { "Konto", 2, 6, TUI_RT_EDIT },
- { "Namn", 9, 24, TUI_RT_INFO },
- { "Debet", 34, 11, TUI_RT_EDIT },
- { "Kredit", 46, 11, TUI_RT_EDIT },
- { "Text", 58, text_w, TUI_RT_EDIT },
- };
- tui_rt_init(&vf.rt, 1, 64, 5, cols, 7, vform_cell, vform_info, &vf);
- tui_rt_set_footer(&vf.rt, vform_footer);
- tui_rt_set_key(&vf.rt, vform_key);
- struct tui_form_field ff[3];
- memset(ff, 0, sizeof ff);
- ff[0].label = "Datum";
- ff[0].value = vf.date;
- ff[0].cap = sizeof vf.date;
- ff[0].kind = TUI_F_DATE;
- ff[1].label = "Serie";
- ff[1].value = vf.series;
- ff[1].cap = sizeof vf.series;
- ff[1].kind = TUI_F_TEXT;
- ff[2].label = "Text";
- ff[2].value = vf.desc;
- ff[2].cap = sizeof vf.desc;
- ff[2].kind = TUI_F_TEXT;
- tui_rt_set_fields(&vf.rt, ff, 3);
- const char *hint = "Enter = ändra fält Tab = byta fält F4 = mall"
- " ^F = bifoga fil F5 = validera ^X = rensa rad"
- " ^Enter = bokför Esc = avbryt";
- for (;;) {
- int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint);
- if (rr == -1)
- return 0;
- if (rr == -2) {
- vform_post(&vf, 1);
- continue;
- }
- if (rr == -3) {
- int64_t id = vform_post(&vf, 0);
- if (id > 0)
- return id;
- }
- }
-}
-
-static void vouchers_screen(struct app *a)
-{
- char **items = NULL;
- int64_t *ids = NULL;
- size_t n = 0;
- int fetch = 1;
- for (;;) {
- if (g_quit) {
- for (size_t i = 0; i < n; i++)
- free(items[i]);
- free(items);
- free(ids);
- return;
- }
- if (fetch) {
- for (size_t i = 0; i < n; i++)
- free(items[i]);
- free(items);
- free(ids);
- items = NULL;
- ids = NULL;
- n = 0;
- fetch = 0;
- char largs[96];
- snprintf(largs, sizeof largs,
- "{\"limit\":200,\"fiscal_year\":%lld}",
- (long long)a->fy);
- char *resp = client_rpc(&a->conn, "voucher.list", a->session, a->org,
- largs);
- if (!resp || !client_ok(resp)) {
- show_error("Verifikat", resp);
- free(resp);
- return;
- }
- n = jarr_size(resp, "result.items");
- items = xcalloc(n + 1, sizeof(char *));
- ids = xcalloc(n ? n : 1, sizeof(int64_t));
- char **idstr = xcalloc(n ? n : 1, sizeof(char *));
- int idw = 4;
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.items.%zu.id", i);
- ids[i] = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.series", i);
- char *ser = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.number", i);
- int64_t number = jint_val(resp, path, 0);
- char idbuf[32];
- snprintf(idbuf, sizeof idbuf, "%s%lld", ser ? ser : "",
- (long long)number);
- idstr[i] = xstrdup(idbuf);
- int l = (int)strlen(idbuf);
- if (l > idw)
- idw = l;
- free(ser);
- }
- for (size_t i = 0; i < n; i++) {
- char path[64], line[512];
- snprintf(path, sizeof path, "result.items.%zu.date", i);
- char *date = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.description", i);
- char *desc = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.attachment_count",
- i);
- int atts = (int)jint_val(resp, path, 0);
- snprintf(line, sizeof line, " %c %-*s %s %s",
- atts ? 'x' : ' ', idw, idstr[i], date ? date : "",
- desc ? desc : "");
- items[i] = xstrdup(line);
- free(date);
- free(desc);
- free(idstr[i]);
- }
- free(idstr);
- free(resp);
- items[n] = xstrdup("+ Nytt verifikat (^N)");
- }
- size_t total = n + 1;
- int start = 0;
- if (a->voucher_sel) {
- for (size_t i = 0; i < n; i++)
- if (ids[i] == a->voucher_sel) {
- start = (int)i;
- break;
- }
- }
- int cur = start;
- int sel = tui_select_list("Verifikat", items, (int)total, start, 1, &cur,
- 1, NULL, 0);
- if (cur >= 0 && (size_t)cur < n)
- a->voucher_sel = ids[cur];
- if (sel == -2) {
- fetch = 1;
- continue;
- }
- int64_t open_id = 0;
- int want_new = (sel == -4);
- if (sel >= 0) {
- if ((size_t)sel == n)
- want_new = 1;
- else if ((size_t)sel < n)
- open_id = ids[sel];
- } else if (sel != -4) {
- for (size_t i = 0; i < n; i++)
- free(items[i]);
- free(items);
- free(ids);
- return;
- }
- if (want_new) {
- open_id = vouchers_new(a);
- if (open_id <= 0)
- continue;
- a->voucher_sel = open_id;
- fetch = 1;
- }
- for (;;) {
- int64_t nv = 0;
- int act = voucher_detail(a, open_id, &nv);
- if (act == 2) {
- open_id = nv;
- a->voucher_sel = nv;
- fetch = 1;
- continue;
- }
- if (act == 1)
- fetch = 1;
- break;
- }
- }
-}
-
-static const char *type_sv(const char *t)
-{
- if (!t)
- return "";
- if (strcmp(t, "asset") == 0)
- return "Tillgång";
- if (strcmp(t, "liability") == 0)
- return "Skuld";
- if (strcmp(t, "equity") == 0)
- return "Eget kapital";
- if (strcmp(t, "revenue") == 0)
- return "Intäkt";
- if (strcmp(t, "expense") == 0)
- return "Kostnad";
- return t;
-}
-
-/* Moms treatment for an account: the stored vat_code when set, otherwise
- derived from the BAS account name (which carries the rate). Accounts
- whose treatment depends on the transaction stay blank. */
-static const char *vat_display(const char *name, const char *vat_code,
- char *buf, size_t cap)
-{
- if (vat_code && *vat_code) {
- snprintf(buf, cap, "%s", vat_code);
- return buf;
- }
- if (!name || !*name)
- return "";
- int pct = 0;
- for (const char *p = name; *p;) {
- if (*p >= '0' && *p <= '9') {
- const char *q = p;
- int v = 0;
- while (*q >= '0' && *q <= '9') {
- v = v * 10 + (*q - '0');
- q++;
- }
- while (*q == ' ')
- q++;
- if (*q == '%') {
- pct = v;
- break;
- }
- p = q;
- continue;
- }
- p++;
- }
- int in = strstr(name, "Ingående moms") != NULL;
- int out = strstr(name, "Utgående moms") != NULL;
- int reverse = strstr(name, "omvänd") != NULL;
- if (in || out) {
- if (pct)
- snprintf(buf, cap, "%s %d%%", in ? "Ing" : "Utg", pct);
- else
- snprintf(buf, cap, "%s", in ? "Ing" : "Utg");
- return buf;
- }
- if (pct) {
- snprintf(buf, cap, "%d%%%s", pct, reverse ? " omv" : "");
- return buf;
- }
- if (strstr(name, "momsfri") || strstr(name, "momsfritt") ||
- strstr(name, "momsefri"))
- return "Momsfri";
- if (strstr(name, "Redovisningskonto för moms"))
- return "Avräkning";
- if (reverse)
- return "Omvänd";
- if (strstr(name, "moms"))
- return "Moms";
- return "";
-}
-
-/* Kontolista: every account with the details that apply to it. */
-static void accounts_report(struct app *a)
-{
- for (;;) {
- char *resp = client_rpc(&a->conn, "account.list", a->session, a->org,
- "{\"active_only\":false}");
- if (!resp || !client_ok(resp)) {
- show_error("Kontolista", resp);
- free(resp);
- return;
- }
- size_t n = jarr_size(resp, "result.items");
- int name_w = COLS - 45;
- if (name_w < 12)
- name_w = 12;
- struct buf text;
- buf_init(&text);
- char line[2048];
- int hdr = snprintf(line, sizeof line,
- MK_STICKY "%-6s %-*s %-12s %-5s %-6s %-8s\n",
- "Konto", name_w, "Namn", "Typ", "Aktiv", "SRU",
- "Moms");
- buf_append(&text, line, (size_t)hdr);
- int sep_w = 6 + 1 + name_w + 1 + 12 + 1 + 5 + 1 + 6 + 1 + 8;
- for (int i = 0; i < sep_w && i < (int)sizeof line - 1; i++)
- line[i] = '-';
- line[sep_w < (int)sizeof line ? sep_w : (int)sizeof line - 1] = '\n';
- buf_append(&text, line, (size_t)(sep_w < (int)sizeof line ? sep_w + 1
- : (int)sizeof line));
- int64_t active_count = 0;
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.items.%zu.number", i);
- char *number = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.name", i);
- char *name = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.type", i);
- char *type = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.active", i);
- int active = jbool_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.sru_code", i);
- char *sru = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.vat_code", i);
- char *vat = jstr_dup(resp, path);
- char ncol[512];
- char vatbuf[32];
- char tibuf[32];
- snprintf(ncol, sizeof ncol, "%s", name ? name : "");
- tui_pad_field(ncol, sizeof ncol, name_w);
- snprintf(tibuf, sizeof tibuf, "%s", type_sv(type));
- tui_pad_field(tibuf, sizeof tibuf, 12);
- snprintf(line, sizeof line, "%-6s %s %s %-5s %-6s %-8s\n",
- number ? number : "", ncol, tibuf,
- active ? "ja" : "nej", sru ? sru : "",
- vat_display(name, vat, vatbuf, sizeof vatbuf));
- buf_append(&text, line, strlen(line));
- if (active)
- active_count++;
- free(number);
- free(name);
- free(type);
- free(sru);
- free(vat);
- }
- snprintf(line, sizeof line, "\n%lld konton, varav %lld aktiva.\n",
- (long long)n, (long long)active_count);
- buf_append(&text, line, strlen(line));
- buf_append(&text, "\0", 1);
- int again = tui_pager("Kontolista", (char *)text.p, NULL);
- buf_free(&text);
- free(resp);
- if (!again)
- return;
- }
-}
-
-/* ------------------------------------------------------------------ */
-/* report formatting (Kapitas-like text tables) */
-/* ------------------------------------------------------------------ */
-
-static const char *vstr(yyjson_val *o, const char *k)
-{
- yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL;
- return v && yyjson_is_str(v) ? yyjson_get_str(v) : "";
-}
-
-static int64_t vint(yyjson_val *o, const char *k)
-{
- yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL;
- return v && yyjson_is_int(v) ? (int64_t)yyjson_get_int(v) : 0;
-}
-
-static void buf_line(struct buf *b, const char *fmt, ...)
- __attribute__((format(printf, 2, 3)));
-
-static void buf_line(struct buf *b, const char *fmt, ...)
-{
- char line[1024];
- va_list ap;
- va_start(ap, fmt);
- int n = vsnprintf(line, sizeof line, fmt, ap);
- va_end(ap);
- if (n < 0)
- return;
- buf_append(b, line, (size_t)n < sizeof line ? (size_t)n : sizeof line - 1);
-}
-
-static void buf_rule(struct buf *b, int width)
-{
- char line[128];
- if (width > (int)sizeof line - 3)
- width = (int)sizeof line - 3;
- if (width < 1)
- return;
- line[0] = MK_RULE[0];
- memset(line + 1, '-', (size_t)width);
- line[width + 1] = '\n';
- buf_append(b, line, (size_t)width + 2);
-}
-
-/* prefix (8) + label (48) + amount columns */
-#define BS_RULE_W (8 + 48 + 4 * 14)
-#define IS_RULE_W (8 + 48 + 3 * 15)
-
-static void fmt_head(struct buf *t, const struct app *a, const char *title,
- yyjson_val *res)
-{
- yyjson_val *fy = yyjson_obj_get(res, "fiscal_year");
- const char *start = vstr(fy, "start_date");
- const char *end = vstr(fy, "end_date");
- buf_line(t, MK_HEAD "%s\n%s\n\n", title, a->org_name);
- buf_line(t, MK_DIM "Räkenskapsår: %s - %s\n", start, end);
- buf_line(t, MK_DIM "Period: %s - %s\n\n", start, end);
-}
-
-static int acct_in(const char *number, int lo, int hi)
-{
- int n = atoi(number);
- return n >= lo && n <= hi;
-}
-
-/* Pad to `width` display columns without truncating (labels may be longer). */
-static void pad_min(char *buf, size_t n, int width)
-{
- int w = tui_disp_width(buf);
- size_t len = strlen(buf);
- while (w < width && len + 1 < n) {
- buf[len++] = ' ';
- buf[len] = '\0';
- w++;
- }
-}
-
-/* Right-align `text` in `width` display columns (headers with å/ä/ö). */
-
-/* ---------------- balansräkning ---------------- */
-
-static const char *const BS_KEYS[3] = { "assets", "equity", "liabilities" };
-
-struct bs_sum {
- int64_t ib, per, ub;
-};
-
-static void bs_col_head(struct buf *t, int sticky)
-{
- buf_line(t, "%s %5s %-48s %14s %14s %14s %14s\n",
- sticky ? MK_STICKY MK_HEAD : "", "", "", "Ing balans", "Ing saldo",
- "Period", "Utg balans");
- buf_rule(t, BS_RULE_W);
-}
-
-static int bs_rows(struct buf *t, yyjson_val *res, int lo, int hi,
- struct bs_sum *sum)
-{
- int found = 0;
- for (int k = 0; k < 3; k++) {
- yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]);
- yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *row = yyjson_arr_get(arr, i);
- const char *acc = vstr(row, "account");
- if (!acct_in(acc, lo, hi))
- continue;
- int64_t ib = vint(row, "ib_ore");
- int64_t ub = vint(row, "ub_ore");
- int64_t per = ub - ib;
- if (t) {
- char nm[128], a1[48], a2[48], a3[48], a4[48];
- tui_pad_label(nm, sizeof nm, vstr(row, "name"), 48);
- tui_amt_col(a1, sizeof a1, 14, ib);
- tui_amt_col(a2, sizeof a2, 14, ib);
- tui_amt_col(a3, sizeof a3, 14, per);
- tui_amt_col(a4, sizeof a4, 14, ub);
- buf_line(t, " %5s %s %s %s %s %s\n", acc, nm, a1, a2, a3, a4);
- }
- sum->ib += ib;
- sum->per += per;
- sum->ub += ub;
- found = 1;
- }
- }
- return found;
-}
-
-static void bs_sum_line(struct buf *t, const char *label,
- const struct bs_sum *s)
-{
- char lbl[64], a1[48], a2[48], a3[48], a4[48];
- tui_pad_label(lbl, sizeof lbl, label, 48);
- tui_amt_col(a1, sizeof a1, 14, s->ib);
- tui_amt_col(a2, sizeof a2, 14, s->ib);
- tui_amt_col(a3, sizeof a3, 14, s->per);
- tui_amt_col(a4, sizeof a4, 14, s->ub);
- buf_line(t, MK_DIM " %5s %s %s %s %s %s\n", "", lbl, a1, a2, a3, a4);
-}
-
-static int bs_group(struct buf *t, yyjson_val *res, const char *title,
- const char *sumlabel, int lo, int hi, struct bs_sum *acc)
-{
- struct bs_sum probe = { 0, 0, 0 };
- if (!bs_rows(NULL, res, lo, hi, &probe))
- return 0;
- buf_line(t, MK_HEAD "%s\n", title);
- struct bs_sum s = { 0, 0, 0 };
- bs_rows(t, res, lo, hi, &s);
- bs_sum_line(t, sumlabel, &s);
- if (acc) {
- acc->ib += s.ib;
- acc->per += s.per;
- acc->ub += s.ub;
- }
- return 1;
-}
-
-static void fmt_balans(struct buf *t, const struct app *a, yyjson_val *res)
-{
- static const struct {
- const char *title;
- const char *sum;
- int lo, hi;
- } OMS[] = {
- { "Varulager", "Summa varulager", 1400, 1499 },
- { "Kortfristiga fordringar", "Summa kortfristiga fordringar", 1500,
- 1699 },
- { "Kortfristiga placeringar", "Summa kortfristiga placeringar", 1700,
- 1899 },
- { "Kassa och bank", "Summa kassa och bank", 1900, 1999 },
- };
- fmt_head(t, a, "Balansrapport", res);
- struct bs_sum anl = { 0, 0, 0 }, oms = { 0, 0, 0 };
- struct bs_sum eget = { 0, 0, 0 }, skuld = { 0, 0, 0 };
- buf_line(t, MK_HEAD "TILLGÅNGAR\n");
- bs_col_head(t, 1);
- buf_line(t, "\n" MK_HEAD "Anläggningstillgångar\n");
- bs_rows(t, res, 1000, 1399, &anl);
- bs_sum_line(t, "Summa anläggningstillgångar", &anl);
- buf_line(t, "\n" MK_HEAD "Omsättningstillgångar\n");
- int printed = 0;
- for (size_t i = 0; i < sizeof OMS / sizeof OMS[0]; i++) {
- struct bs_sum probe = { 0, 0, 0 };
- if (!bs_rows(NULL, res, OMS[i].lo, OMS[i].hi, &probe))
- continue;
- if (printed)
- buf_line(t, "\n");
- bs_group(t, res, OMS[i].title, OMS[i].sum, OMS[i].lo, OMS[i].hi,
- &oms);
- printed = 1;
- }
- bs_sum_line(t, "Summa omsättningstillgångar", &oms);
- struct bs_sum as = { anl.ib + oms.ib, anl.per + oms.per, anl.ub + oms.ub };
- buf_rule(t, BS_RULE_W);
- bs_sum_line(t, "Summa tillgångar", &as);
-
- buf_line(t, "\n" MK_HEAD "EGET KAPITAL OCH SKULDER\n");
- bs_col_head(t, 0);
- buf_line(t, "\n" MK_HEAD "Eget kapital\n");
- bs_group(t, res, "Bundet eget kapital", "Summa bundet eget kapital", 2000,
- 2090, &eget);
- buf_line(t, "\n");
- bs_group(t, res, "Fritt eget kapital", "Summa fritt eget kapital", 2091,
- 2099, &eget);
- bs_sum_line(t, "Summa eget kapital", &eget);
- buf_line(t, "\n");
- if (bs_group(t, res, "Långfristiga skulder", "Summa långfristiga skulder",
- 2100, 2399, &skuld))
- buf_line(t, "\n");
- bs_group(t, res, "Kortfristiga skulder", "Summa kortfristiga skulder",
- 2400, 2999, &skuld);
- struct bs_sum es = { eget.ib + skuld.ib, eget.per + skuld.per,
- eget.ub + skuld.ub };
- buf_rule(t, BS_RULE_W);
- bs_sum_line(t, "Summa eget kapital och skulder", &es);
-
- yyjson_val *eq = yyjson_obj_get(res, "equity");
- int64_t result = eq ? vint(eq, "result_ore") : 0;
- char lbl[64], zero[48], a1[48];
- tui_pad_label(lbl, sizeof lbl, "Beräknat resultat", 48);
- tui_amt_col(zero, sizeof zero, 14, 0);
- tui_amt_col(a1, sizeof a1, 14, result);
- buf_line(t, "\n");
- buf_rule(t, BS_RULE_W);
- buf_line(t, MK_DIM " %5s %s %s %s %s %s\n", "", lbl, zero, zero, a1, a1);
-}
-
-/* ---------------- resultaträkning ---------------- */
-
-struct is_group {
- const char *section;
- const char *title;
- int lo, hi;
- int rev;
-};
-
-static const struct is_group IS_GROUPS[] = {
- { "Rörelseintäkter, lagerförändringar m.m.", "Nettoomsättning", 3000, 3799,
- 1 },
- { "Rörelseintäkter, lagerförändringar m.m.", "Övriga rörelseintäkter",
- 3800, 3999, 1 },
- { "Rörelsekostnader", "Råvaror och förnödenheter", 4000, 4999, 0 },
- { "Rörelsekostnader", "Övriga externa kostnader", 5000, 6999, 0 },
- { "Rörelsekostnader", "Personalkostnader", 7000, 7699, 0 },
- { "Rörelsekostnader", "Avskrivningar och nedskrivningar", 7700, 7899, 0 },
- { "Rörelsekostnader", "Övriga rörelsekostnader", 7900, 7999, 0 },
-};
-
-static const char *const IS_KEYS[2] = { "revenue", "expenses" };
-
-static int is_group_has(yyjson_val *res, const struct is_group *g)
-{
- for (int k = 0; k < 2; k++) {
- yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
- yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++)
- if (acct_in(vstr(yyjson_arr_get(arr, i), "account"), g->lo,
- g->hi))
- return 1;
- }
- return 0;
-}
-
-static int64_t is_prev_lookup(yyjson_val *prev, const char *account,
- int negate)
-{
- if (!prev)
- return 0;
- for (int k = 0; k < 2; k++) {
- yyjson_val *sec = yyjson_obj_get(prev, IS_KEYS[k]);
- yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *row = yyjson_arr_get(arr, i);
- if (strcmp(vstr(row, "account"), account) == 0) {
- int64_t a = vint(row, "amount_ore");
- return negate ? -a : a;
- }
- }
- }
- return 0;
-}
-
-static void is_summary(struct buf *t, const char *label, int64_t cur,
- int64_t prev, int blank)
-{
- char lbl[64], a1[48], a2[48];
- if (blank)
- buf_line(t, "\n");
- tui_pad_label(lbl, sizeof lbl, label, 48);
- tui_amt_col(a1, sizeof a1, 15, cur);
- tui_amt_col(a2, sizeof a2, 15, prev);
- buf_line(t, MK_DIM " %5s %s %s %s %s\n", "", lbl, a1, a1, a2);
-}
-
-static void is_print_group(struct buf *t, yyjson_val *res, yyjson_val *prev,
- const struct is_group *gr, int64_t *cur,
- int64_t *pre)
-{
- int64_t sc = 0, sp = 0;
- for (int k = 0; k < 2; k++) {
- yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
- yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *row = yyjson_arr_get(arr, i);
- const char *acc = vstr(row, "account");
- if (!acct_in(acc, gr->lo, gr->hi))
- continue;
- int64_t c = gr->rev ? vint(row, "amount_ore")
- : -vint(row, "amount_ore");
- int64_t p = is_prev_lookup(prev, acc, !gr->rev);
- char nm[128], a1[48], a2[48];
- tui_pad_label(nm, sizeof nm, vstr(row, "name"), 48);
- tui_amt_col(a1, sizeof a1, 15, c);
- tui_amt_col(a2, sizeof a2, 15, p);
- buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2);
- sc += c;
- sp += p;
- }
- }
- is_summary(t, "Summa", sc, sp, 0);
- *cur = sc;
- *pre = sp;
-}
-
-static int is_range_has(yyjson_val *res, int lo, int hi)
-{
- for (int k = 0; k < 2; k++) {
- yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
- yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++)
- if (acct_in(vstr(yyjson_arr_get(arr, i), "account"), lo, hi))
- return 1;
- }
- return 0;
-}
-
-/* Rows in one account range, printed with the previous-year column; the
- signed display sums are added to *sum / *sum_pre. */
-static void is_range_rows(struct buf *t, yyjson_val *res, yyjson_val *prev,
- int lo, int hi, int64_t *sum, int64_t *sum_pre)
-{
- for (int k = 0; k < 2; k++) {
- yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
- yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *row = yyjson_arr_get(arr, i);
- const char *acc = vstr(row, "account");
- if (!acct_in(acc, lo, hi))
- continue;
- int64_t c = k == 0 ? vint(row, "amount_ore")
- : -vint(row, "amount_ore");
- int64_t p = is_prev_lookup(prev, acc, k != 0);
- char nm[128], a1[48], a2[48];
- tui_pad_label(nm, sizeof nm, vstr(row, "name"), 48);
- tui_amt_col(a1, sizeof a1, 15, c);
- tui_amt_col(a2, sizeof a2, 15, p);
- buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2);
- *sum += c;
- *sum_pre += p;
- }
- }
-}
-
-static void fmt_resultat(struct buf *t, const struct app *a, yyjson_val *res,
- yyjson_val *prev)
-{
- fmt_head(t, a, "Resultatrapport", res);
- const char *sect = NULL;
- int64_t rev = 0, rev_pre = 0, exp = 0, exp_pre = 0;
- for (size_t g = 0; g < sizeof IS_GROUPS / sizeof IS_GROUPS[0]; g++) {
- const struct is_group *gr = &IS_GROUPS[g];
- if (!is_group_has(res, gr))
- continue;
- if (!sect || strcmp(sect, gr->section) != 0) {
- if (sect) {
- is_summary(t, "Summa rörelseintäkter, lagerförändringar m.m.",
- rev, rev_pre, 0);
- buf_line(t, "\n");
- buf_line(t, MK_HEAD "%s\n", gr->section);
- } else {
- char lbl[80], h1[32], h2[32], h3[32];
- tui_pad_label(lbl, sizeof lbl, gr->section, 48);
- tui_pad_hdr(h1, sizeof h1, 15, "Period");
- tui_pad_hdr(h2, sizeof h2, 15, "Ackumulerat");
- tui_pad_hdr(h3, sizeof h3, 15, "Föreg. per.");
- buf_line(t, MK_STICKY MK_HEAD " %5s %s %s %s %s\n", "", lbl,
- h1, h2, h3);
- buf_rule(t, IS_RULE_W);
- buf_line(t, "\n");
- buf_line(t, MK_HEAD "%s\n", gr->title);
- }
- sect = gr->section;
- } else {
- buf_line(t, "\n");
- buf_line(t, MK_HEAD "%s\n", gr->title);
- }
- int64_t sc = 0, sp = 0;
- is_print_group(t, res, prev, gr, &sc, &sp);
- if (gr->rev) {
- rev += sc;
- rev_pre += sp;
- } else {
- exp += sc;
- exp_pre += sp;
- }
- }
- is_summary(t, "Summa rörelsekostnader", exp, exp_pre, 0);
-
- int64_t fin = 0, fin_pre = 0, disp = 0, disp_pre = 0;
- int64_t tax = 0, tax_pre = 0, booked = 0, booked_pre = 0;
- int has_disp = is_range_has(res, 8800, 8899);
- int has_tax = is_range_has(res, 8900, 8989);
- int has_booked = is_range_has(res, 8999, 8999);
- if (is_range_has(res, 8000, 8599)) {
- buf_line(t, "\n" MK_HEAD "Finansiella poster\n");
- is_range_rows(t, res, prev, 8000, 8599, &fin, &fin_pre);
- is_summary(t, "Summa", fin, fin_pre, 0);
- }
- int64_t ror = rev + exp, ror_pre = rev_pre + exp_pre;
- int64_t after = ror + fin, after_pre = ror_pre + fin_pre;
- buf_line(t, "\n");
- buf_rule(t, IS_RULE_W);
- is_summary(t, "Rörelseresultat", ror, ror_pre, 0);
- is_summary(t, "Resultat efter finansiella poster", after, after_pre, 1);
- if (has_disp) {
- buf_line(t, "\n" MK_HEAD "Bokslutsdispositioner\n");
- is_range_rows(t, res, prev, 8800, 8899, &disp, &disp_pre);
- is_summary(t, "Summa", disp, disp_pre, 0);
- }
- int64_t before_tax = after + disp, before_tax_pre = after_pre + disp_pre;
- is_summary(t, "Resultat före skatt", before_tax, before_tax_pre, 1);
- if (has_tax) {
- buf_line(t, "\n" MK_HEAD "Skatt\n");
- is_range_rows(t, res, prev, 8900, 8989, &tax, &tax_pre);
- is_summary(t, "Summa", tax, tax_pre, 0);
- }
- int64_t ars = before_tax + tax, ars_pre = before_tax_pre + tax_pre;
- buf_line(t, "\n");
- buf_rule(t, IS_RULE_W);
- is_summary(t, "Årets resultat", ars, ars_pre, 0);
- if (has_booked) {
- buf_line(t, "\n" MK_HEAD "Bokfört resultat\n");
- is_range_rows(t, res, prev, 8999, 8999, &booked, &booked_pre);
- is_summary(t, "Summa", booked, booked_pre, 0);
- buf_line(t, "\n");
- buf_rule(t, IS_RULE_W);
- is_summary(t, "Ej bokfört resultat", ars + booked,
- ars_pre + booked_pre, 0);
- } else {
- buf_line(t, "\n");
- buf_rule(t, IS_RULE_W);
- is_summary(t, "Ej bokfört resultat", ars, ars_pre, 0);
- }
-}
-
-/* ---------------- saldobalans ---------------- */
-
-static void fmt_saldobalans(struct buf *t, const struct app *a,
- yyjson_val *res)
-{
- fmt_head(t, a, "Saldobalans", res);
- char nmh[64], h1[32], h2[32], h3[32], h4[32];
- tui_pad_label(nmh, sizeof nmh, "Benämning", 48);
- tui_pad_hdr(h1, sizeof h1, 14, "Ingående");
- tui_pad_hdr(h2, sizeof h2, 14, "Debet");
- tui_pad_hdr(h3, sizeof h3, 14, "Kredit");
- tui_pad_hdr(h4, sizeof h4, 14, "Utgående");
- buf_line(t, MK_STICKY MK_HEAD " %5s %s %s %s %s %s\n", "Konto", nmh, h1,
- h2, h3, h4);
- buf_rule(t, BS_RULE_W);
- yyjson_val *arr = yyjson_obj_get(res, "accounts");
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *row = yyjson_arr_get(arr, i);
- char nm[128], a1[48], a2[48], a3[48], a4[48];
- tui_pad_label(nm, sizeof nm, vstr(row, "name"), 48);
- tui_amt_col(a1, sizeof a1, 14, vint(row, "ib_ore"));
- tui_amt_col(a2, sizeof a2, 14, vint(row, "debit_ore"));
- tui_amt_col(a3, sizeof a3, 14, vint(row, "credit_ore"));
- tui_amt_col(a4, sizeof a4, 14, vint(row, "ub_ore"));
- buf_line(t, " %5s %s %s %s %s %s\n", vstr(row, "account"), nm, a1,
- a2, a3, a4);
- }
- yyjson_val *tot = yyjson_obj_get(res, "totals");
- char lbl[64], a1[48], a2[48], a3[48], a4[48];
- tui_pad_label(lbl, sizeof lbl, "Summa", 48);
- tui_amt_col(a1, sizeof a1, 14, vint(tot, "ib_ore"));
- tui_amt_col(a2, sizeof a2, 14, vint(tot, "debit_ore"));
- tui_amt_col(a3, sizeof a3, 14, vint(tot, "credit_ore"));
- tui_amt_col(a4, sizeof a4, 14, vint(tot, "ub_ore"));
- buf_line(t, "\n");
- buf_rule(t, BS_RULE_W);
- buf_line(t, MK_DIM " %5s %s %s %s %s %s\n", "", lbl, a1, a2, a3, a4);
-}
-
-/* ---------------- momsdeklaration ---------------- */
-
-struct vat_row {
- const char *section;
- const char *box;
- const char *label;
-};
-
-static const struct vat_row VAT_ROWS[] = {
- { "A.", "05",
- "Momspliktig försäljning som inte ingår i ruta 06, 07 eller 08" },
- { "A.", "06", "Momspliktiga uttag" },
- { "A.", "07", "Beskattningsunderlag vid vinstmarginalbeskattning" },
- { "A.", "08", "Hyresinkomster vid frivillig beskattning" },
- { "B.", "10", "Utgående moms 25%" },
- { "B.", "11", "Utgående moms 12%" },
- { "B.", "12", "Utgående moms 6%" },
- { "C.", "20", "Inköp av varor från annat EU-land" },
- { "C.", "21", "Inköp av tjänster från annat EU-land enligt huvudregeln" },
- { "C.", "22", "Inköp av tjänster från land utanför EU" },
- { "C.", "23", "Inköp av varor i Sverige som köparen är betalningsskyldig för" },
- { "C.", "24", "Övriga inköp av tjänster i Sverige som köparen är betalningsskyldig för" },
- { "D.", "30", "Utgående moms 25%" },
- { "D.", "31", "Utgående moms 12%" },
- { "D.", "32", "Utgående moms 6%" },
- { "H.", "50", "Beskattningsunderlag vid import" },
- { "I.", "60", "Utgående moms 25%" },
- { "I.", "61", "Utgående moms 12%" },
- { "I.", "62", "Utgående moms 6%" },
- { "E.", "35", "Försäljning av varor till annat EU-land" },
- { "E.", "36", "Försäljning av varor utanför EU" },
- { "E.", "37", "Mellanmans inköp av varor vid trepartshandel" },
- { "E.", "38", "Mellanmans försäljning av varor vid trepartshandel" },
- { "E.", "39", "Försäljning av tjänster till beskattningsbar person i annat EU-land enligt huvudregeln" },
- { "E.", "40", "Övrig försäljning av tjänster som tillhandahållits utomlands" },
- { "E.", "41", "Försäljning när köparen är betalningsskyldig i Sverige" },
- { "E.", "42", "Övrig försäljning m.m." },
- { "F.", "48", "Ingående moms att dra av" },
- { "G.", "49", "Moms att betala (+) eller att få tillbaka (-)" },
-};
-
-static const char *vat_section_title(const char *s)
-{
- if (strcmp(s, "A.") == 0)
- return "Momspliktig försäljning eller uttag exklusive moms";
- if (strcmp(s, "B.") == 0)
- return "Utgående moms på försäljning eller uttag i ruta 05 - 08";
- if (strcmp(s, "C.") == 0)
- return "Momspliktiga inköp vid omvänd skattskyldighet";
- if (strcmp(s, "D.") == 0)
- return "Utgående moms på inköp i ruta 20 - 24";
- if (strcmp(s, "H.") == 0)
- return "Import";
- if (strcmp(s, "I.") == 0)
- return "Utgående moms på import i ruta 50";
- if (strcmp(s, "E.") == 0)
- return "Försäljning m.m. som är undantagen från moms";
- if (strcmp(s, "F.") == 0)
- return "Ingående moms";
- return "Moms att betala eller få tillbaka";
-}
-
-static void fmt_moms(struct buf *t, const struct app *a, yyjson_val *res)
-{
- buf_line(t, MK_HEAD "Momsrapport\n\n%s\n\n", a->org_name);
- buf_line(t, MK_DIM "Räkenskapsår: %s - %s\n", a->fy_start, a->fy_end);
- buf_line(t, MK_DIM "Period: %s - %s\n", vstr(res, "from"),
- vstr(res, "to"));
- const char *sect = NULL;
- yyjson_val *boxes = yyjson_obj_get(res, "boxes");
- size_t n = yyjson_arr_size(boxes);
- for (size_t i = 0; i < sizeof VAT_ROWS / sizeof VAT_ROWS[0]; i++) {
- const struct vat_row *vr = &VAT_ROWS[i];
- if (strcmp(vr->box, "49") == 0)
- continue; /* last */
- if (!sect || strcmp(sect, vr->section) != 0) {
- sect = vr->section;
- buf_line(t, "\n" MK_HEAD "%s %s\n", vr->section,
- vat_section_title(vr->section));
- }
- int64_t ore = 0;
- for (size_t j = 0; j < n; j++) {
- yyjson_val *b = yyjson_arr_get(boxes, j);
- if (strcmp(vstr(b, "box"), vr->box) == 0) {
- ore = vint(b, "amount_ore");
- break;
- }
- }
- char amt[48] = "", lbl[192];
- if (ore)
- kr_whole(ore, amt, sizeof amt);
- snprintf(lbl, sizeof lbl, "%s", vr->label);
- pad_min(lbl, sizeof lbl, 66);
- buf_line(t, "%s. %s %14s\n", vr->box, lbl, amt);
- }
- int64_t ore49 = 0;
- for (size_t j = 0; j < n; j++) {
- yyjson_val *b = yyjson_arr_get(boxes, j);
- if (strcmp(vstr(b, "box"), "49") == 0) {
- ore49 = vint(b, "amount_ore");
- break;
- }
- }
- buf_line(t, "\n" MK_HEAD "G. %s\n", vat_section_title("G."));
- buf_rule(t, 87);
- char amt49[48], lbl49[192];
- kr_whole(ore49, amt49, sizeof amt49);
- snprintf(lbl49, sizeof lbl49, "%s",
- "Moms att betala (+) eller att få tillbaka (-)");
- pad_min(lbl49, sizeof lbl49, 66);
- buf_line(t, MK_DIM "49. %s %14s\n", lbl49, amt49);
-}
-
-static int64_t prev_fy_id(struct app *a)
-{
- char *resp =
- client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}");
- if (!resp || !client_ok(resp)) {
- free(resp);
- return 0;
- }
- size_t n = jarr_size(resp, "result.items");
- int64_t best = 0;
- char best_end[16] = "";
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.items.%zu.end_date", i);
- char *e = jstr_dup(resp, path);
- if (e && strcmp(e, a->fy_start) < 0 && strcmp(e, best_end) > 0) {
- snprintf(path, sizeof path, "result.items.%zu.id", i);
- int64_t id = jint_val(resp, path, 0);
- if (id) {
- best = id;
- snprintf(best_end, sizeof best_end, "%s", e);
- }
- }
- free(e);
- }
- free(resp);
- return best;
-}
-
-/* ---------------- huvudbok ---------------- */
-
-static void fmt_huvudbok(struct buf *t, const struct app *a, yyjson_val *res)
-{
- fmt_head(t, a, "Huvudbok", res);
- yyjson_val *lv = yyjson_obj_get(res, "last_voucher");
- if (lv)
- buf_line(t, MK_DIM "Senaste ver.: %s%lld\n\n", vstr(lv, "series"),
- (long long)vint(lv, "number"));
- buf_line(t, MK_STICKY MK_HEAD "%-8s %-58s %14s %14s %16s\n", "Konto", "",
- "Debet", "Kredit", "Saldo");
- buf_rule(t, 114);
- yyjson_val *accs = yyjson_obj_get(res, "accounts");
- size_t n = yyjson_arr_size(accs);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *ao = yyjson_arr_get(accs, i);
- int64_t ib = vint(ao, "ib_ore");
- int64_t d = vint(ao, "debit_ore");
- int64_t c = vint(ao, "credit_ore");
- buf_line(t, "\n" MK_HEAD "%s %s\n\n", vstr(ao, "account"),
- vstr(ao, "name"));
- char a1[48];
- tui_amt_col(a1, sizeof a1, 16, ib);
- char lbl[96];
- tui_pad_label(lbl, sizeof lbl, "Ingående balans:", 59);
- buf_line(t, MK_DIM " %s %14s %14s %16s\n", lbl, "", "", a1);
- tui_pad_label(lbl, sizeof lbl, "Ingående saldo:", 59);
- buf_line(t, MK_DIM " %s %14s %14s %16s\n", lbl, "", "", a1);
- buf_line(t, "\n");
- yyjson_val *rows = yyjson_obj_get(ao, "rows");
- size_t nr = yyjson_arr_size(rows);
- for (size_t k = 0; k < nr; k++) {
- yyjson_val *ro = yyjson_arr_get(rows, k);
- char ver[32], nm[256], dd[48], cc[48], deb[48], kre[48], sal[48];
- snprintf(ver, sizeof ver, "%s%lld", vstr(ro, "series"),
- (long long)vint(ro, "number"));
- const char *txt = vstr(ro, "row_description");
- if (!*txt)
- txt = vstr(ro, "description");
- tui_pad_label(nm, sizeof nm, txt, 40);
- tui_amt_col(dd, sizeof dd, 14, vint(ro, "debit_ore"));
- tui_amt_col(cc, sizeof cc, 14, vint(ro, "credit_ore"));
- snprintf(deb, sizeof deb, "%s", vint(ro, "debit_ore") ? dd : "");
- snprintf(kre, sizeof kre, "%s", vint(ro, "credit_ore") ? cc : "");
- tui_amt_col(sal, sizeof sal, 16, vint(ro, "saldo_ore"));
- buf_line(t, " %-7s %-10s %s %14s %14s %16s\n", ver,
- vstr(ro, "date"), nm, deb, kre, sal);
- }
- char s1[48], s2[48], s3[48];
- tui_amt_col(s1, sizeof s1, 14, d);
- tui_amt_col(s2, sizeof s2, 14, c);
- tui_amt_col(s3, sizeof s3, 16, d - c);
- tui_pad_label(lbl, sizeof lbl, "Omslutning:", 59);
- buf_line(t, MK_DIM " %s %14s %14s %16s\n", lbl, s1, s2, s3);
- tui_amt_col(s3, sizeof s3, 16, vint(ao, "ub_ore"));
- tui_pad_label(lbl, sizeof lbl, "Utgående saldo:", 59);
- buf_line(t, MK_DIM " %s %14s %14s %16s\n", lbl, s1, s2, s3);
- }
-}
-
-/* ---------------- verifikationslista ---------------- */
-
-static void fmt_verifikationslista(struct buf *t, const struct app *a,
- yyjson_val *res)
-{
- fmt_head(t, a, "Verifikationslista", res);
- yyjson_val *lv = yyjson_obj_get(res, "last_voucher");
- if (lv)
- buf_line(t, MK_DIM "Senaste ver.: %s%lld\n\n", vstr(lv, "series"),
- (long long)vint(lv, "number"));
- char nmh[64];
- tui_pad_label(nmh, sizeof nmh, "Benämning", 48);
- buf_line(t, MK_STICKY MK_HEAD "%-8s %-11s %s %14s %14s\n", "Ver.",
- "Datum/Konto", nmh, "Debet", "Kredit");
- buf_rule(t, 98);
- yyjson_val *vs = yyjson_obj_get(res, "vouchers");
- size_t n = yyjson_arr_size(vs);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *vo = yyjson_arr_get(vs, i);
- char ver[32];
- snprintf(ver, sizeof ver, "%s%lld", vstr(vo, "series"),
- (long long)vint(vo, "number"));
- buf_line(t, MK_HEAD "%-8s %-11s %s\n", ver, vstr(vo, "date"),
- vstr(vo, "description"));
- yyjson_val *rows = yyjson_obj_get(vo, "rows");
- size_t nr = yyjson_arr_size(rows);
- for (size_t k = 0; k < nr; k++) {
- yyjson_val *ro = yyjson_arr_get(rows, k);
- char nm[256], dd[48], cc[48], deb[48], kre[48];
- tui_pad_label(nm, sizeof nm, vstr(ro, "name"), 48);
- tui_amt_col(dd, sizeof dd, 14, vint(ro, "debit_ore"));
- tui_amt_col(cc, sizeof cc, 14, vint(ro, "credit_ore"));
- snprintf(deb, sizeof deb, "%s", vint(ro, "debit_ore") ? dd : "");
- snprintf(kre, sizeof kre, "%s", vint(ro, "credit_ore") ? cc : "");
- buf_line(t, " %-10s %s %14s %14s\n", vstr(ro, "account"),
- nm, deb, kre);
- }
- }
- yyjson_val *tot = yyjson_obj_get(res, "totals");
- char s1[48], s2[48];
- tui_amt_col(s1, sizeof s1, 14, vint(tot, "debit_ore"));
- tui_amt_col(s2, sizeof s2, 14, vint(tot, "credit_ore"));
- buf_line(t, "\n" MK_DIM "%-69s %14s %14s\n", "Omslutning:", s1, s2);
-}
-
-static void eskd_save(struct app *a, const char *from, const char *to)
-{
- const char *home = getenv("HOME");
- char period[8], def[512], full[512];
- snprintf(period, sizeof period, "%.4s%.2s", to, to + 5);
- snprintf(def, sizeof def, "%s/eskd_%s.xml", home && *home ? home : ".",
- period);
- char pathbuf[512]; char *path = tui_prompt_into(pathbuf, sizeof pathbuf, "Spara eSKD-fil: ", def, 0) ? pathbuf : NULL;
- if (!path || !*path)
- return;
- if (path[0] == '~' && (path[1] == '/' || path[1] == '\0'))
- snprintf(full, sizeof full, "%s%s", home ? home : "", path + 1);
- else
- snprintf(full, sizeof full, "%s", path);
- char args[64];
- snprintf(args, sizeof args, "{\"from\":\"%s\",\"to\":\"%s\"}", from, to);
- char *resp =
- client_rpc(&a->conn, "report.vat_eskd", a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Kunde inte skapa eSKD", resp);
- free(resp);
- return;
- }
- char *b64 = jstr_dup(resp, "result.content_base64");
- unsigned char *data = NULL;
- size_t n = 0;
- if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) {
- tui_message("eSKD", "Kunde inte avkoda filen från servern.");
- } else {
- FILE *f = fopen(full, "wb");
- if (!f || fwrite(data, 1, n, f) != n) {
- tui_message("eSKD", "Kunde inte skriva %s: %s", full, strerror(errno));
- if (f)
- fclose(f);
- } else {
- fclose(f);
- tui_message("eSKD", "Sparat: %s\n(%zu byte, ISO-8859-1)", full, n);
- }
- }
- free(data);
- free(b64);
- free(resp);
-}
-
-/* Official fältnamn (short) for the INK2/INK2R/INK2S codes; 2025P4. */
-static const struct {
- const char *code;
- const char *label;
-} SRU_LABELS[] = {
- { "7011", "Räkenskapsårets början" },
- { "7012", "Räkenskapsårets slut" },
- { "7104", "1.1 Överskott av näringsverksamhet" },
- { "7114", "1.2 Underskott av näringsverksamhet" },
- { "7131", "1.3 Kreditinstituts underlag för riskskatt" },
- { "7132", "1.4 Underlag för särskild löneskatt på pensionskostnader" },
- { "7133", "1.5 Negativt underlag för särskild löneskatt på pensionskostnader" },
- { "7153", "1.6 a Försäkringsföretag m.fl. samt avsatt till pensioner 15 %" },
- { "7154", "1.6 b Utländska pensions- försäkringar 15 %" },
- { "7155", "1.7 a Försäkringsföretag m.fl 30 %" },
- { "7156", "1.7 b Utländska kapitalförsäkringar 30 %" },
- { "80", "1.8 Småhus/ ägarlägenhet" },
- { "93", "1.9 Hyreshus: bostäder" },
- { "84", "1.10 Småhus/ägarlägenhet: tomtmark, byggnad under uppförande" },
- { "86", "1.11 Hyreshus: tomtmark, bostäder under uppförande" },
- { "95", "1.12 Hyreshus: lokaler" },
- { "96", "1.13 Industrienhet och elproduktionsenhet: värme- kraftverk" },
- { "97", "1.14 Elproduktionsenhet, vattenkraftverk" },
- { "98", "1.15 Elproduktionsenhet, vindkraftverk" },
- { "90", "Övriga upplysningar på bilaga" },
- { "1582", "1.16 Förnybar el (kilowattimmar)" },
- { "7011", "Räkenskapsårets början" },
- { "7012", "Räkenskapsårets slut" },
- { "7201", "2.1 Immateriella anläggningstillgångar Koncessioner, patent, licenser, varumärken, hyresrätter, goodwill och liknande rättigheter" },
- { "7202", "2.2 Immateriella anläggningstillgångar Förskott avseende immateriella anläggningstillgångar" },
- { "7214", "2.3 Materiella anläggningstillgångar Byggnader och mark" },
- { "7215", "2.4 Materiella anläggningstillgångar Maskiner, inventarier och övriga materiella anläggningstillgångar" },
- { "7216", "2.5 Materiella anläggningstillgångar Förbättringsutgifter på annans fastighet" },
- { "7217", "2.6 Materiella anläggningstillgångar Pågående nyanläggningar och förskott avseende materiella anläggningstillgångar" },
- { "7230", "2.7 Finansiella anläggningstillgångar Andelar i koncernföretag" },
- { "7231", "2.8 Finansiella anläggningstillgångar Andelar i intresseföretag och gemensamt styrda företag" },
- { "7233", "2.9 Ägarintressen i övriga företag och andra långfristiga värdepappersinnehav" },
- { "7232", "2.10 Finansiella anläggningstillgångar Fordringar hos koncern-, intresse- och gemensamt styrda företag" },
- { "7234", "2.11 Finansiella anläggningstillgångar Lån till delägare eller närstående" },
- { "7235", "2.12 Fordringar hos övriga företag som det finns ett ägarintresse i och andra långfristiga fordringar" },
- { "7241", "2.13 Varulager m.m. Råvaror och förnödenheter" },
- { "7242", "2.14 Varulager Varor under tillverkning" },
- { "7243", "2.15 Varulager Färdiga varor och handelsvaror" },
- { "7244", "2.16 Varulager Övriga lagertillgångar" },
- { "7245", "2.17 Varulager Pågående arbeten för annans räkning" },
- { "7246", "2.18 Varulager Förskott till leverantörer" },
- { "7251", "2.19 Kortfristiga fordringar Kundfordringar" },
- { "7252", "2.20 Kortfristiga fordringar Fordringar hos koncern-, intresse- och gemensamt styrda företag" },
- { "7261", "2.21 Fordringar hos övriga företag som det finns ett ägarintresse i och övriga fordringar" },
- { "7262", "2.22 Kortfristiga fordringar Upparbetad men ej fakturerad intäkt" },
- { "7263", "2.23 Kortfristiga fordringar Förutbetalda kostnader och upplupna intäkter" },
- { "7270", "2.24 Kortfristiga placeringar Andelar i koncernföretag" },
- { "7271", "2.25 Kortfristiga placeringar Övriga kortfristiga placeringar" },
- { "7281", "2.26 Kassa och bank Kassa, bank och redovisningsmedel" },
- { "7301", "2.27 Eget kapital Bundet eget kapital" },
- { "7302", "2.28 Eget kapital Fritt eget kapital" },
- { "7321", "2.29 Obeskattade reserver Periodiseringsfonder" },
- { "7322", "2.30 Obeskattade reserver Ackumulerade överavskrivningar" },
- { "7323", "2.31 Obeskattade reserver Övriga obeskattade reserver" },
- { "7331", "2.32 Avsättningar Avsättningar för pensioner och liknande förpliktelser enligt lag (1967:531)..." },
- { "7332", "2.33 Avsättningar Övriga avsättningar för pensioner och liknande förpliktelser" },
- { "7333", "2.34 Avsättningar Övriga avsättningar" },
- { "7350", "2.35 Långfristiga skulder Obligationslån" },
- { "7351", "2.36 Långfristiga skulder Checkräkningskredit" },
- { "7352", "2.37 Långfristiga skulder Övriga skulder till kreditinstitut" },
- { "7353", "2.38 Långfristiga skulder Skulder till koncern-, intresse- och gemensamt styrda företag" },
- { "7354", "2.39 Skulder till övriga företag som det finns ett ägarintresse i och övriga skulder" },
- { "7360", "2.40 Kortfristiga skulder Checkräkningskredit" },
- { "7361", "2.41 Kortfristiga skulder Övriga skulder till kreditinstitut" },
- { "7362", "2.42 Kortfristiga skulder Förskott från kunder" },
- { "7363", "2.43 Kortfristiga skulder Pågående arbeten för annans räkning" },
- { "7364", "2.44 Kortfristiga skulder Fakturerad men ej upparbetad intäkt" },
- { "7365", "2.45 Kortfristiga skulder Leverantörsskulder" },
- { "7366", "2.46 Kortfristiga skulder Växelskulder" },
- { "7367", "2.47 Kortfristiga skulder Skulder till koncern-, intresse- och gemensamt styrda företag" },
- { "7369", "2.48 Skulder till övriga företag som det finns ett ägarintresse i och övriga skulder" },
- { "7368", "2.49 Kortfristiga skulder Skattesskulder" },
- { "7370", "2.50 Kortfristiga skulder Upplupna kostnader och förutbetalda intäkter" },
- { "7410", "3.1 Nettoomsättning" },
- { "7411", "3.2 Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning. +" },
- { "7510", "3.2 Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning. -" },
- { "7412", "3.3 Aktiverat arbete för egen räkning" },
- { "7413", "3.4 Övriga rörelseintäkter" },
- { "7511", "3.5 Råvaror och förnödenheter" },
- { "7512", "3.6 Handelsvaror" },
- { "7513", "3.7 Övriga externa kostnader" },
- { "7514", "3.8 Personalkostnader" },
- { "7515", "3.9 Av- och nedskrivningar av materiella och immateriella anläggningstillgångar" },
- { "7516", "3.10 Nedskrivningar av omsättningstillgångar utöver normala nedskrivningar" },
- { "7517", "3.11 Övriga rörelsekostnader" },
- { "7414", "3.12 Resultat från andelar i koncernföretag +" },
- { "7518", "3.12 Resultat från andelar i koncernföretag -" },
- { "7415", "3.13 Resultat från andelar i intresseföretag och gemensamt styrda företag +" },
- { "7519", "3.13 Resultat från andelar i intresseföretag och gemensamt styrda företag -" },
- { "7423", "3.14 Resultat från övriga företag som det finns ett ägarintresse i +" },
- { "7530", "3.14 Resultat från övriga företag som det finns ett ägarintresse i -" },
- { "7416", "3.15 Resultat från övriga finansiella anläggningstillgångar +" },
- { "7520", "3.15 Resultat från övriga finansiella anläggningstillgångar -" },
- { "7417", "3.16 Övriga ränteintäkter och liknande resultatposter" },
- { "7521", "3.17 Nedskrivning av finansiella anläggningstillgångar och kortfristiga placeringar" },
- { "7522", "3.18 Räntekostnader och liknande resultatposter" },
- { "7524", "3.19 Lämnade koncernbidrag" },
- { "7419", "3.20 Mottagna koncernbidrag" },
- { "7420", "3.21 Återföring av periodiseringsfond" },
- { "7525", "3.22 Avsättning till periodiseringsfond" },
- { "7421", "3.23 Förändring av överavskrivningar +" },
- { "7526", "3.23 Förändring av överavskrivningar -" },
- { "7422", "3.24 Övriga bokslutsdispositioner +" },
- { "7527", "3.24 Övriga bokslutsdispositioner -" },
- { "7528", "3.25 Skatt på årets resultat" },
- { "7450", "3.26 Årets resultat, vinst (flyttas till p. 4.1)" },
- { "7550", "3.27 Årets resultat, förlust (flyttas till p. 4.2)" },
- { "7011", "Räkenskapsårets början" },
- { "7012", "Räkenskapsårets slut" },
- { "7650", "4.1 Årets resultat, vinst" },
- { "7750", "4.2 Årets resultat, förlust" },
- { "7651", "4.3a. Bokförda kostnader som inte ska dras av: a. Skatt på årets resultat" },
- { "7652", "b. Bokförda kostnader som inte ska dras av: b. Nedskrivning av finansiella tillgångar" },
- { "7653", "c. Bokförda kostnader som inte ska dras av: c. Andra bokförda kostnader" },
- { "7751", "4.4a. Kostnader som ska dras av men som inte ingår i det redovisade resultatet: a. Lämnade koncernbidrag" },
- { "7764", "b. Kostnader som ska dras av men som inte ingår i det redovisade resultatet: b. Andra ej bokförda kostnader" },
- { "7752", "4.5a. Bokförda intäkter som inte ska tas upp: a. Ackordsvinster" },
- { "7753", "b. Bokförda intäkter som inte ska tas upp: b. Utdelning" },
- { "7754", "c. Bokförda intäkter som inte ska tas upp: c. Andra bokförda intäkter" },
- { "7654", "4.6a. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet a. Beräknad schablonintäkt på periodiseringsfonder vid beskattningsårets ingång" },
- { "7668", "b. Beräknad schablonintäkt på fondandelar ägda vid kalenderårets ingång" },
- { "7655", "c. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: c. Mottagna koncernbidrag" },
- { "7673", "d. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: d. Uppräknat belopp vid återföring av periodiseringsfond" },
- { "7665", "e. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: e. Andra ej bokförda intäkter" },
- { "7755", "4.7a. Avyttring av delägarrätter: a. Bokförd vinst" },
- { "7656", "b. Avyttring av delägarrätter: b. Bokförd förlust" },
- { "7756", "c. Avyttring av delägarrätter: c. Uppskov med kapitalvinst enligt blankett N4" },
- { "7657", "d. Avyttring av delägarrätter: d. Återfört uppskov med kapitalvinst enligt blankett N4" },
- { "7658", "e. Avyttring av delägarrätter: e. Kapitalvinst för beskattningsåret" },
- { "7757", "f. Avyttring av delägarrätter: f. Kapitalförlust som ska dras av" },
- { "7758", "4.8a. Andel i handelsbolag (inkl. avyttring): a. Bokförd intäkt/vinst" },
- { "7659", "b. Andel i handelsbolag (inkl. avyttring): b. Skattemässigt överskott enligt N3B" },
- { "7660", "c. Andel i handelsbolag (inkl. avyttring): c. Bokförd kostnad/förlust" },
- { "7759", "d. Andel i handelsbolag (inkl. avyttring): d. Skattemässigt underskott enligt N3B" },
- { "7666", "4.9 Skattemässig justering av bokfört resultat för avskrivningar på byggnader och annan fast egendom samt restvärdesavskrivning på maskiner och inventarier (+)" },
- { "7765", "4.9 Skattemässig justering av bokfört resultat för avskrivningar på byggnader och annan fast egendom samt restvärdesavskrivning på maskiner och inventarier (-)" },
- { "7661", "4.10 Skattemässig justering av bokfört resultat vid avyttring av näringsfastighet och näringsbostadsrätt" },
- { "7760", "4.10 Skattemässig korrigering av bokfört resultat vid avyttring av näringsfastighet och näringsbostadsrätt: -" },
- { "7761", "4.11 Skogs-/substansminskningsavdrag (specificeras på blankett N8)" },
- { "7662", "4.12 Återföringar vid avyttring av fastighet t.ex. värdeminskningsavdrag, skogsavdrag och substansminskningsavdrag..." },
- { "7663", "4.13 Andra skattemässiga justeringar av resultatet: +" },
- { "7762", "4.13 Andra skattemässiga justeringar av resultatet: -" },
- { "7763", "4.14a. Underskott: a. Outnyttjat underskott från föregående år" },
- { "7671", "4.14b. Reduktion av outnyttjat underskott med hänsyn till beloppsspärr, ackord eller konkurs" },
- { "7672", "4.14c. Reduktion av outnyttjat underskott med hänsyn till koncernbidragsspärr, fusionsspärr m.m. (beloppet ska också tas upp vid p. 1.2. på sid. 1)" },
- { "7670", "4.15 Överskott (flyttas till p. 1.1 på sid. 1)" },
- { "7770", "4.16 Underskott (flyttas till p. 1.2 på sid. 1)" },
- { "8020", "4.17 Årets begärda och tidigare års medgivna värdeminskningsavdrag som finns vid beskattningsårets utgång avseende byggnader." },
- { "8021", "4.18 Årets begärda och tidigare års medgivna värdeminskningsavdrag som finns vid beskattningsårets utgång avseende markanläggningar." },
- { "8023", "4.19 Vid restvärdesavskrivning: återförda belopp för av- och nedskrivning, försäljning, utrangering" },
- { "8026", "4.20 Lån från aktieägare (fysisk person) vid räkenskapsårets utgång" },
- { "8022", "4.21 Pensionskostnader (som ingår i p. 3.8)" },
- { "8028", "4.22 Koncernbidragsspärrat och fusionsspärrat underskott m.m. (frivillig uppgift)" },
- { "8040", "Uppdragstagare (t.ex. redovisningskonsult) har biträtt vid upprättandet av årsredovisningen: Ja" },
- { "8041", "Uppdragstagare (t.ex. redovisningskonsult) har biträtt vid upprättandet av årsredovisningen: Nej" },
- { "8044", "Årsredovisningen har varit föremål för revision: Ja" },
- { "8045", "Årsredovisningen har varit föremål för revision: Nej" },
-};
-
-static const char *sru_label(const char *code)
-{
- for (size_t i = 0; i < sizeof SRU_LABELS / sizeof SRU_LABELS[0]; i++)
- if (strcmp(SRU_LABELS[i].code, code) == 0)
- return SRU_LABELS[i].label;
- return code;
-}
-
-static void sru_rows(struct buf *t, yyjson_val *arr)
-{
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *row = yyjson_arr_get(arr, i);
- char lbl[256], amt[48];
- tui_pad_label(lbl, sizeof lbl, sru_label(vstr(row, "code")), 66);
- tui_kr_format(vint(row, "amount") * 100, amt, sizeof amt);
- buf_line(t, "%s %14s\n", lbl, amt);
- }
-}
-
-static void fmt_ink2(struct buf *t, const struct app *a, yyjson_val *res)
-{
- buf_line(t, MK_HEAD "Inkomstdeklaration 2 (INK2)\n%s\n\n", a->org_name);
- buf_line(t, MK_DIM "Räkenskapsår: %s - %s\n", vstr(res, "from"),
- vstr(res, "to"));
- buf_line(t, MK_DIM "Blankett: %s\n", vstr(res, "period"));
- buf_line(t, "\n" MK_HEAD "INK2 - huvudblankett\n");
- buf_rule(t, 81);
- sru_rows(t, yyjson_obj_get(res, "ink2"));
- buf_line(t, "\n" MK_HEAD "INK2R - räkenskapsschema\n");
- buf_rule(t, 81);
- sru_rows(t, yyjson_obj_get(res, "ink2r"));
- buf_line(t, "\n" MK_HEAD "INK2S - skattemässiga justeringar\n");
- buf_rule(t, 81);
- sru_rows(t, yyjson_obj_get(res, "ink2s"));
- yyjson_val *un = yyjson_obj_get(res, "unmapped");
- size_t nu = yyjson_arr_size(un);
- if (nu) {
- buf_line(t, "\n" MK_HEAD "Konton utan SRU-mappning (måste åtgärdas):\n");
- for (size_t i = 0; i < nu; i++) {
- yyjson_val *row = yyjson_arr_get(un, i);
- char amt[48];
- tui_kr_format(vint(row, "amount_ore"), amt, sizeof amt);
- buf_line(t, " %s %s\n", vstr(row, "account"), amt);
- }
- }
-}
-
-static void sru_save(struct app *a)
-{
- const char *home = getenv("HOME");
- char def[512];
- snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : ".");
- struct stat sb;
- if (stat(def, &sb) != 0 || !S_ISDIR(sb.st_mode))
- snprintf(def, sizeof def, "%s", home && *home ? home : ".");
- char dirbuf[512]; char *dir = tui_prompt_into(dirbuf, sizeof dirbuf, "Spara SRU-filer i: ", def, 0) ? dirbuf : NULL;
- if (!dir || !*dir)
- return;
- char full[512];
- if (dir[0] == '~' && (dir[1] == '/' || dir[1] == '\0'))
- snprintf(full, sizeof full, "%s%s", home ? home : "", dir + 1);
- else
- snprintf(full, sizeof full, "%s", dir);
- char args[64];
- snprintf(args, sizeof args, "{\"fiscal_year\":%lld}", (long long)a->fy);
- char *resp = client_rpc(&a->conn, "sru.export", a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Kunde inte skapa SRU-filerna", resp);
- free(resp);
- return;
- }
- const char *keys[2] = { "result.info.content_base64",
- "result.blanketter.content_base64" };
- const char *names[2] = { "INFO.SRU", "BLANKETTER.SRU" };
- char saved[2][600] = { "", "" };
- int ok = 0;
- for (int i = 0; i < 2; i++) {
- char *b64 = jstr_dup(resp, keys[i]);
- unsigned char *data = NULL;
- size_t n = 0;
- if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) {
- free(b64);
- continue;
- }
- snprintf(saved[i], sizeof saved[i], "%s/%s", full, names[i]);
- FILE *f = fopen(saved[i], "wb");
- if (f && fwrite(data, 1, n, f) == n) {
- fclose(f);
- ok++;
- } else {
- if (f)
- fclose(f);
- tui_message("SRU", "Kunde inte skriva %s: %s", saved[i],
- strerror(errno));
- }
- free(data);
- free(b64);
- }
- free(resp);
- if (ok == 2)
- tui_message("SRU", "Sparat:\n%s\n%s\n\nLadda upp båda i Skatteverkets"
- " filöverföring.",
- saved[0], saved[1]);
-}
-
-static void ink2_screen(struct app *a)
-{
- char sargs[64];
- snprintf(sargs, sizeof sargs, "{\"fiscal_year\":%lld}", (long long)a->fy);
- for (;;) {
- char *resp = client_rpc(&a->conn, "sru.export", a->session, a->org,
- sargs);
- if (!resp || !client_ok(resp)) {
- show_error("Inkomstdeklaration", resp);
- free(resp);
- break;
- }
- yyjson_doc *sd = parse(resp);
- yyjson_val *sres = sd ? jget(yyjson_doc_get_root(sd), "result") : NULL;
- struct buf out;
- buf_init(&out);
- if (sres)
- fmt_ink2(&out, a, sres);
- else
- buf_line(&out, "%s", resp);
- buf_append(&out, "", 1);
- int sagain = tui_pager("Rapport", (const char *)out.p, "s = spara SRU");
- buf_free(&out);
- yyjson_doc_free(sd);
- free(resp);
- if (sagain == 2)
- sru_save(a);
- if (!sagain)
- break;
- }
-}
-
-static void reports_screen(struct app *a)
-{
- static const char *const report_items[] = {
- "Saldobalans (trial balance)",
- "Resultaträkning",
- "Balansräkning",
- "Huvudbok",
- "Verifikationslista",
- "Momsdeklaration",
- "Kontolista (alla konton)",
- };
- static int last_sel = 0;
- for (;;) {
- if (g_quit)
- return;
- int sel = tui_menu("Rapporter", report_items, 7, 0, &last_sel);
- if (sel < 0)
- return;
- if (sel == 6) {
- accounts_report(a);
- continue;
- }
- char args[256];
- char vat_from[16] = "", vat_to[16] = "";
- const char *cmd = NULL;
- switch (sel) {
- case 0:
- cmd = "report.trial_balance";
- snprintf(args, sizeof args,
- "{\"include_zero\":false,\"fiscal_year\":%lld}",
- (long long)a->fy);
- break;
- case 1:
- cmd = "report.income_statement";
- snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
- (long long)a->fy);
- break;
- case 2:
- cmd = "report.balance_sheet";
- snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
- (long long)a->fy);
- break;
- case 3:
- cmd = "report.general_ledger";
- snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
- (long long)a->fy);
- break;
- case 4:
- cmd = "report.voucher_list";
- snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
- (long long)a->fy);
- break;
- case 5: {
- cmd = "report.vat";
- char frombuf[16]; char *from = tui_date_prompt_into(frombuf, sizeof frombuf, "Från (YYYY-MM-DD): ", a->fy_start) ? frombuf : NULL;
- if (!from)
- continue;
- char f[16];
- snprintf(f, sizeof f, "%s", from);
- char tobuf[16]; char *to = tui_date_prompt_into(tobuf, sizeof tobuf, "Till (YYYY-MM-DD): ", a->fy_end) ? tobuf : NULL;
- if (!to)
- continue;
- snprintf(vat_from, sizeof vat_from, "%s", f);
- snprintf(vat_to, sizeof vat_to, "%s", to);
- snprintf(args, sizeof args,
- "{\"from\":\"%s\",\"to\":\"%s\"}", f, to);
- break;
- }
- }
- for (;;) {
- char *resp = client_rpc(&a->conn, cmd, a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Rapport", resp);
- free(resp);
- break;
- }
- yyjson_doc *d = parse(resp);
- yyjson_val *res =
- d ? jget(yyjson_doc_get_root(d), "result") : NULL;
- struct buf out;
- buf_init(&out);
- if (!res) {
- buf_line(&out, "%s", resp);
- } else if (sel == 0) {
- fmt_saldobalans(&out, a, res);
- } else if (sel == 1) {
- yyjson_doc *pd = NULL;
- yyjson_val *prev = NULL;
- int64_t pf = prev_fy_id(a);
- if (pf) {
- char pargs[64];
- snprintf(pargs, sizeof pargs, "{\"fiscal_year\":%lld}",
- (long long)pf);
- char *presp = client_rpc(&a->conn,
- "report.income_statement",
- a->session, a->org, pargs);
- if (presp && client_ok(presp)) {
- pd = parse(presp);
- prev = pd ? jget(yyjson_doc_get_root(pd), "result")
- : NULL;
- }
- free(presp);
- }
- fmt_resultat(&out, a, res, prev);
- yyjson_doc_free(pd);
- } else if (sel == 2) {
- fmt_balans(&out, a, res);
- } else if (sel == 3) {
- fmt_huvudbok(&out, a, res);
- } else if (sel == 4) {
- fmt_verifikationslista(&out, a, res);
- } else {
- fmt_moms(&out, a, res);
- }
- buf_append(&out, "", 1);
- int again = tui_pager("Rapport", (const char *)out.p,
- sel == 5 ? "s = spara eSKD" : NULL);
- buf_free(&out);
- yyjson_doc_free(d);
- free(resp);
- if (again == 2 && sel == 5) {
- eskd_save(a, vat_from, vat_to);
- break;
- }
- if (!again)
- break;
- }
- }
-}
-
-/* Pick a voucher in the active fiscal year; returns its id or 0. */
-static int64_t pick_voucher(struct app *a)
-{
- char args[96];
- snprintf(args, sizeof args, "{\"fiscal_year\":%lld,\"limit\":200}",
- (long long)a->fy);
- char *resp =
- client_rpc(&a->conn, "voucher.list", a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Verifikat", resp);
- free(resp);
- return 0;
- }
- size_t n = jarr_size(resp, "result.items");
- if (n == 0) {
- tui_message("Verifikat", "Inga verifikat i räkenskapsåret.");
- free(resp);
- return 0;
- }
- char **items = xcalloc(n, sizeof(char *));
- int64_t *ids = xcalloc(n, sizeof(int64_t));
- for (size_t i = 0; i < n; i++) {
- char path[64], ver[32], 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.series", i);
- char *series = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.number", i);
- int64_t number = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.date", i);
- char *date = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.description", i);
- char *desc = jstr_dup(resp, path);
- snprintf(ver, sizeof ver, "%s%lld", series ? series : "",
- (long long)number);
- snprintf(line, sizeof line, "%-8s %-10s %s", ver, date ? date : "",
- desc ? desc : "");
- items[i] = xstrdup(line);
- free(series);
- free(date);
- free(desc);
- }
- int sel = tui_select_list("Välj verifikat", items, (int)n, 0, 1, NULL, 0,
- NULL, 0);
- int64_t out = sel >= 0 ? ids[sel] : 0;
- for (size_t i = 0; i < n; i++)
- free(items[i]);
- free(items);
- free(ids);
- free(resp);
- return out;
-}
-
-static void inbox_upload(struct app *a)
-{
- char *path = file_browser(a, a->attachment_dir);
- if (!path)
- return;
- struct stat sb;
- if (stat(path, &sb) == 0 && (long)sb.st_size > a->max_attachment_bytes) {
- tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.",
- (double)sb.st_size / (1024 * 1024),
- a->max_attachment_bytes / (1024 * 1024));
- free(path);
- return;
- }
- char *b64 = read_file_b64(path);
- if (!b64) {
- tui_message("Fel", "Kunde inte läsa %s", path);
- free(path);
- return;
- }
- const char *base = strrchr(path, '/');
- base = base ? base + 1 : path;
- 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, "filename", base);
- yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- free(b64);
- size_t reqlen = strlen(args) + 256;
- char *raw = xmalloc(reqlen);
- snprintf(raw, reqlen,
- "{\"v\":1,\"id\":\"tui\",\"cmd\":\"attachment.put\","
- "\"session\":\"%s\",\"org\":%lld,\"args\":%s}",
- a->session, (long long)a->org, args);
- free(args);
- char *r = NULL;
- if (client_send_line(&a->conn, raw) == 0)
- r = client_read_line(&a->conn);
- free(raw);
- if (r && client_ok(r))
- tui_message("Underlag", "Sparat.");
- else
- show_error("Kunde inte spara", r);
- free(r);
- free(path);
-}
-
-struct inboxctx {
- struct app *a;
- int64_t *ids;
- size_t n;
- int cursor;
-};
-
-static int inbox_key(void *ud, int ch)
-{
- struct inboxctx *ctx = ud;
- struct app *a = ctx->a;
- int64_t sel_id = (ctx->cursor >= 0 && (size_t)ctx->cursor < ctx->n)
- ? ctx->ids[ctx->cursor]
- : 0;
- if (ch == 'k' && sel_id) {
- int64_t vid = pick_voucher(a);
- if (vid) {
- char kargs[64];
- snprintf(kargs, sizeof kargs, "{\"id\":%lld,\"voucher_id\":%lld}",
- (long long)sel_id, (long long)vid);
- char *r = client_rpc(&a->conn, "attachment.link", a->session,
- a->org, kargs);
- if (r && client_ok(r))
- tui_message("Underlag", "Kopplat till verifikatet.");
- else
- show_error("Kunde inte koppla", r);
- free(r);
- }
- return TUI_HOOK_REFRESH;
- }
- if (ch == 'a' || ch == 'A') {
- inbox_upload(a);
- return TUI_HOOK_REFRESH;
- }
- return TUI_HOOK_NONE;
-}
-
-static void inbox_screen(struct app *a)
-{
- int cursor = 0;
- for (;;) {
- if (g_quit)
- return;
- char *resp = client_rpc(&a->conn, "attachment.list", a->session, a->org,
- "{\"unlinked\":true,\"limit\":200}");
- if (!resp || !client_ok(resp)) {
- show_error("Underlag", resp);
- free(resp);
- return;
- }
- size_t n = jarr_size(resp, "result.items");
- char **items = xcalloc(n ? n : 1, sizeof(char *));
- int64_t *ids = xcalloc(n ? 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.filename", i);
- char *fn = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.size_bytes", i);
- int64_t size = jint_val(resp, path, 0);
- char fnbuf[256];
- snprintf(fnbuf, sizeof fnbuf, "%s", fn ? fn : "");
- tui_pad_field(fnbuf, sizeof fnbuf, 40);
- snprintf(line, sizeof line, "%s %8lld byte", fnbuf,
- (long long)size);
- items[i] = xstrdup(line);
- free(fn);
- }
- struct inboxctx ctx = { a, ids, n, cursor };
- int sel = tui_select_list_hook("Underlag (inkorg)", items, (int)n,
- cursor, 1, &cursor, 1, NULL, 0,
- inbox_key, &ctx);
- int64_t sel_id =
- (sel >= 0 && (size_t)sel < n) ? ids[sel] : 0;
- for (size_t i = 0; i < n; i++)
- free(items[i]);
- free(items);
- free(ids);
- free(resp);
- if (sel == -1)
- return;
- if (sel == -2)
- continue;
- if (sel == -4) {
- inbox_upload(a);
- continue;
- }
- if (sel_id)
- attachment_download(a, sel_id);
- }
-}
-
-/* ------------------------------------------------------------------ */
-/* bankavstämning */
-/* ------------------------------------------------------------------ */
-
-struct bank_match {
- int64_t voucher_id;
- char series[16];
- int64_t number;
-};
-
-struct bank_sugg {
- int64_t voucher_id;
- char series[16];
- int64_t number;
- char date[16];
- int64_t amount_ore;
-};
-
-struct bank_tx {
- int64_t id;
- char booked_at[16];
- char text[256];
- int64_t amount_ore;
- struct bank_match *matches;
- size_t nmatches;
- struct bank_sugg *suggestions;
- size_t nsuggestions;
-};
-
-static void bank_ver(char *buf, size_t n, const char *series, int64_t number)
-{
- snprintf(buf, n, "%s%lld", series ? series : "", (long long)number);
-}
-
-/* Unmatched first, then newest booked_at first. */
-static int bank_tx_cmp(const void *pa, const void *pb)
-{
- const struct bank_tx *x = pa, *y = pb;
- int xm = x->nmatches > 0, ym = y->nmatches > 0;
- if (xm != ym)
- return xm - ym;
- int c = strcmp(y->booked_at, x->booked_at);
- if (c)
- return c;
- return x->id < y->id ? -1 : x->id > y->id;
-}
-
-static void bank_txs_free(struct bank_tx *txs, size_t n)
-{
- for (size_t i = 0; i < n; i++) {
- free(txs[i].matches);
- free(txs[i].suggestions);
- }
- free(txs);
-}
-
-static void bank_tx_row(const struct bank_tx *t, char *line, size_t cap)
-{
- char text[256], amt[48], tail[64] = "";
- snprintf(text, sizeof text, "%s", t->text);
- tui_pad_field(text, sizeof text, 32);
- tui_amt_col(amt, sizeof amt, 13, t->amount_ore);
- if (t->nmatches > 0) {
- char ver[32];
- bank_ver(ver, sizeof ver, t->matches[0].series,
- t->matches[0].number);
- if (t->nmatches > 1)
- snprintf(tail, sizeof tail, "→ %s +%zu", ver, t->nmatches - 1);
- else
- snprintf(tail, sizeof tail, "→ %s", ver);
- } else if (t->nsuggestions > 0) {
- char ver[32];
- bank_ver(ver, sizeof ver, t->suggestions[0].series,
- t->suggestions[0].number);
- snprintf(tail, sizeof tail, "(förslag %s)", ver);
- }
- snprintf(line, cap, "%s %s %s %s %s", t->nmatches > 0 ? "✓" : "·",
- t->booked_at, text, amt, tail);
-}
-
-/* Enter on an unmatched row: suggest vouchers, or pick one freely. */
-static void bank_match_tx(struct app *a, const struct bank_tx *t)
-{
- char **items = xcalloc(t->nsuggestions + 1, sizeof(char *));
- for (size_t i = 0; i < t->nsuggestions; i++) {
- const struct bank_sugg *s = &t->suggestions[i];
- char ver[32], amt[48], diff[48], line[256];
- bank_ver(ver, sizeof ver, s->series, s->number);
- tui_amt_col(amt, sizeof amt, 13, s->amount_ore);
- tui_amt_col(diff, sizeof diff, 13, t->amount_ore - s->amount_ore);
- snprintf(line, sizeof line, "%s %-8s %s %s", s->date, ver, amt,
- diff);
- items[i] = xstrdup(line);
- }
- items[t->nsuggestions] = xstrdup("Välj annat verifikat…");
- int sel = tui_select_list("Matcha transaktion", items,
- (int)t->nsuggestions + 1, 0, 0, NULL, 0, NULL,
- 0);
- for (size_t i = 0; i <= t->nsuggestions; i++)
- free(items[i]);
- free(items);
- if (sel < 0)
- return;
- int64_t vid;
- if ((size_t)sel < t->nsuggestions) {
- vid = t->suggestions[sel].voucher_id;
- } else {
- vid = pick_voucher(a);
- if (!vid)
- return;
- }
- char args[64];
- snprintf(args, sizeof args,
- "{\"transaction_id\":%lld,\"voucher_id\":%lld}",
- (long long)t->id, (long long)vid);
- char *resp = client_rpc(&a->conn, "bank.match", a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Bankavstämning", resp);
- free(resp);
- return;
- }
- int64_t diff = jint_val(resp, "result.difference_ore", 0);
- if (diff) {
- char dbuf[48];
- tui_kr_format(diff, dbuf, sizeof dbuf);
- tui_message("Bankavstämning", "Matchat. Differens: %s", dbuf);
- } else {
- tui_message("Bankavstämning", "Matchat.");
- }
- free(resp);
-}
-
-static void bank_unmatch_tx(struct app *a, const struct bank_tx *t)
-{
- if (t->nmatches == 0)
- return;
- if (t->nmatches == 1) {
- if (!tui_confirm("Bankavstämning",
- "Koppla bort transaktionen %s från verifikatet?",
- t->text))
- return;
- } else if (!tui_confirm("Bankavstämning",
- "Koppla bort transaktionen %s från %zu verifikat?",
- t->text, t->nmatches)) {
- return;
- }
- int ok = 1;
- for (size_t i = 0; i < t->nmatches; i++) {
- char args[64];
- snprintf(args, sizeof args,
- "{\"transaction_id\":%lld,\"voucher_id\":%lld}",
- (long long)t->id, (long long)t->matches[i].voucher_id);
- char *resp = client_rpc(&a->conn, "bank.unmatch", a->session, a->org,
- args);
- if (!resp || !client_ok(resp)) {
- show_error("Bankavstämning", resp);
- ok = 0;
- }
- free(resp);
- }
- if (ok)
- tui_message("Bankavstämning", "Matchningen borttagen.");
-}
-
-static void bank_import_file(struct app *a)
-{
- char *path = file_browser(a, a->attachment_dir);
- if (!path)
- return;
- char *b64 = read_file_b64(path);
- if (!b64) {
- tui_message("Fel", "Kunde inte läsa %s", path);
- free(path);
- return;
- }
- free(path);
- 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, "format", "seb");
- yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
- yyjson_mut_obj_add_strcpy(d, o, "account", "1930");
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- free(b64);
- if (!args)
- return;
- char *resp = rpc_dry(a, "bank.import", args);
- if (!resp || !client_ok(resp)) {
- show_error("Bankavstämning", resp);
- free(resp);
- free(args);
- return;
- }
- int64_t imported = jint_val(resp, "result.imported", 0);
- int64_t duplicates = jint_val(resp, "result.duplicates", 0);
- free(resp);
- if (!tui_confirm("Bankavstämning",
- "%lld transaktioner (%lld dubbletter). Importera?",
- (long long)imported, (long long)duplicates)) {
- free(args);
- return;
- }
- resp = client_rpc(&a->conn, "bank.import", a->session, a->org, args);
- free(args);
- if (resp && client_ok(resp))
- tui_message("Bankavstämning", "%lld transaktioner (%lld dubbletter).",
- (long long)jint_val(resp, "result.imported", 0),
- (long long)jint_val(resp, "result.duplicates", 0));
- else
- show_error("Bankavstämning", resp);
- free(resp);
-}
-
-struct bankctx {
- struct app *a;
- struct bank_tx *txs;
- size_t n;
- int cursor;
-};
-
-static int bank_key(void *ud, int ch)
-{
- struct bankctx *ctx = ud;
- if (ch == 'a' || ch == 'A') {
- bank_import_file(ctx->a);
- return TUI_HOOK_REFRESH;
- }
- if ((ch == 'u' || ch == 'U') && ctx->cursor >= 0 &&
- (size_t)ctx->cursor < ctx->n) {
- bank_unmatch_tx(ctx->a, &ctx->txs[ctx->cursor]);
- return TUI_HOOK_REFRESH;
- }
- return TUI_HOOK_NONE;
-}
-
-static void bank_screen(struct app *a)
-{
- int64_t keep_id = 0;
- for (;;) {
- if (g_quit)
- return;
- char *resp = client_rpc(&a->conn, "bank.list", a->session, a->org,
- "{\"status\":\"all\",\"limit\":500}");
- if (!resp || !client_ok(resp)) {
- show_error("Bankavstämning", resp);
- free(resp);
- return;
- }
- yyjson_doc *doc = parse(resp);
- yyjson_val *items =
- doc ? jget(yyjson_doc_get_root(doc), "result.items") : NULL;
- size_t n = items && yyjson_is_arr(items) ? yyjson_arr_size(items) : 0;
- struct bank_tx *txs = xcalloc(n ? n : 1, sizeof *txs);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *it = yyjson_arr_get(items, i);
- txs[i].id = vint(it, "id");
- snprintf(txs[i].booked_at, sizeof txs[i].booked_at, "%s",
- vstr(it, "booked_at"));
- snprintf(txs[i].text, sizeof txs[i].text, "%s", vstr(it, "text"));
- txs[i].amount_ore = vint(it, "amount_ore");
- yyjson_val *ms = yyjson_obj_get(it, "matches");
- size_t nm = ms && yyjson_is_arr(ms) ? yyjson_arr_size(ms) : 0;
- if (nm > 0) {
- txs[i].matches = xcalloc(nm, sizeof *txs[i].matches);
- txs[i].nmatches = nm;
- for (size_t k = 0; k < nm; k++) {
- yyjson_val *m = yyjson_arr_get(ms, k);
- txs[i].matches[k].voucher_id = vint(m, "voucher_id");
- snprintf(txs[i].matches[k].series,
- sizeof txs[i].matches[k].series, "%s",
- vstr(m, "series"));
- txs[i].matches[k].number = vint(m, "number");
- }
- }
- yyjson_val *ss = yyjson_obj_get(it, "suggestions");
- size_t ns = ss && yyjson_is_arr(ss) ? yyjson_arr_size(ss) : 0;
- if (ns > 0) {
- txs[i].suggestions = xcalloc(ns, sizeof *txs[i].suggestions);
- txs[i].nsuggestions = ns;
- for (size_t k = 0; k < ns; k++) {
- yyjson_val *s = yyjson_arr_get(ss, k);
- txs[i].suggestions[k].voucher_id = vint(s, "voucher_id");
- snprintf(txs[i].suggestions[k].series,
- sizeof txs[i].suggestions[k].series, "%s",
- vstr(s, "series"));
- txs[i].suggestions[k].number = vint(s, "number");
- snprintf(txs[i].suggestions[k].date,
- sizeof txs[i].suggestions[k].date, "%s",
- vstr(s, "date"));
- txs[i].suggestions[k].amount_ore = vint(s, "amount_ore");
- }
- }
- }
- qsort(txs, n, sizeof *txs, bank_tx_cmp);
- int start = 0;
- for (size_t i = 0; i < n; i++)
- if (txs[i].id == keep_id)
- start = (int)i;
- int64_t unmatched = jint_val(resp, "result.summary.unmatched", -1);
- if (unmatched < 0) {
- unmatched = 0;
- for (size_t i = 0; i < n; i++)
- if (txs[i].nmatches == 0)
- unmatched++;
- }
- char title[64];
- snprintf(title, sizeof title, "Bankavstämning (%lld omatchade)",
- (long long)unmatched);
- size_t rows = n > 0 ? n : 1;
- char **lines = xcalloc(rows, sizeof(char *));
- if (n == 0) {
- lines[0] = xstrdup("+ Importera bankfil (^N)");
- } else {
- for (size_t i = 0; i < n; i++) {
- char line[512];
- bank_tx_row(&txs[i], line, sizeof line);
- lines[i] = xstrdup(line);
- }
- }
- struct bankctx ctx = { a, txs, n, start };
- int sel = tui_select_list_hook(title, lines, (int)rows, start, 1,
- &ctx.cursor, 1, NULL, 0, bank_key,
- &ctx);
- for (size_t i = 0; i < rows; i++)
- free(lines[i]);
- free(lines);
- if (sel == -1) {
- bank_txs_free(txs, n);
- free(resp);
- yyjson_doc_free(doc);
- return;
- }
- if (sel >= 0 && (size_t)sel < n) {
- keep_id = txs[sel].id;
- if (txs[sel].nmatches == 0)
- bank_match_tx(a, &txs[sel]);
- } else if (sel >= 0) {
- bank_import_file(a);
- }
- bank_txs_free(txs, n);
- free(resp);
- yyjson_doc_free(doc);
- }
-}
-
-static void audit_verify_run(struct app *a, int full)
-{
- char *resp = client_rpc(&a->conn, "audit.verify", a->session, a->org,
- full ? "{\"full\":true}" : "{}");
- if (!resp || !client_ok(resp)) {
- show_error("Revision", resp);
- free(resp);
- return;
- }
- int64_t checked = jint_val(resp, "result.checked", 0);
- int64_t vouchers = jint_val(resp, "result.vouchers_checked", 0);
- int is_ok = jbool_val(resp, "result.ok", 0);
- if (is_ok) {
- if (full)
- tui_message("Hashkedjor",
- "Kedjorna är intakta.\n%lld verifikat och %lld "
- "händelser kontrollerade.\n%lld underlag "
- "hash-kontrollerade.",
- (long long)vouchers, (long long)checked,
- (long long)jint_val(resp, "result.attachments_checked",
- 0));
- else
- tui_message("Hashkedjor",
- "Kedjorna är intakta.\n%lld verifikat och %lld "
- "händelser kontrollerade.",
- (long long)vouchers, (long long)checked);
- } else {
- int64_t bad_v = jint_val(resp, "result.first_bad_voucher_id", 0);
- int64_t bad_a = jint_val(resp, "result.first_bad_seq", 0);
- int64_t unbal = jint_val(resp, "result.first_unbalanced_voucher_id", 0);
- int64_t bad_att =
- jint_val(resp, "result.first_bad_attachment_id", 0);
- char detail[320] = "";
- if (bad_v)
- snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
- "Första trasiga verifikat: %lld\n", (long long)bad_v);
- if (bad_a)
- snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
- "Första trasiga händelse: %lld\n", (long long)bad_a);
- if (unbal)
- snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
- "Första obalanserade verifikat: %lld\n",
- (long long)unbal);
- if (bad_att)
- snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
- "Första trasiga underlag: %lld\n", (long long)bad_att);
- tui_message("Hashkedjor", "KEDJORNA ÄR BRUTNA!\n%s", detail);
- }
- free(resp);
-}
-
-static void audit_screen(struct app *a)
-{
- static const char *const audit_items[] = { "Verifiera hashkedjan",
- "Verifiera allt (inkl. underlag)",
- "Senaste händelser" };
- static int last_sel = 0;
- for (;;) {
- if (g_quit)
- return;
- int sel = tui_menu("Revision", audit_items, 3, 0, &last_sel);
- if (sel < 0)
- return;
- if (sel == 0) {
- audit_verify_run(a, 0);
- } else if (sel == 1) {
- audit_verify_run(a, 1);
- } else {
- for (;;) {
- char *resp = client_rpc(&a->conn, "audit.list", a->session,
- a->org, "{\"limit\":200}");
- if (!resp || !client_ok(resp)) {
- show_error("Revision", resp);
- free(resp);
- break;
- }
- struct buf text;
- buf_init(&text);
- size_t n = jarr_size(resp, "result.items");
- for (size_t i = 0; i < n; i++) {
- char path[64], line[512];
- snprintf(path, sizeof path, "result.items.%zu.at", i);
- char *at = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.action", i);
- char *action = jstr_dup(resp, path);
- snprintf(path, sizeof path,
- "result.items.%zu.result_code", i);
- char *code = jstr_dup(resp, path);
- snprintf(path, sizeof path,
- "result.items.%zu.actor_user_id", i);
- int64_t actor = jint_val(resp, path, 0);
- snprintf(line, sizeof line, "%s anv %lld %-22s %s\n",
- at ? at : "", (long long)actor,
- action ? action : "", code ? code : "");
- buf_append(&text, line, strlen(line));
- free(at);
- free(action);
- free(code);
- }
- buf_append(&text, "\0", 1);
- int again = tui_pager("Behandlingshistorik", (char *)text.p, NULL);
- buf_free(&text);
- free(resp);
- if (!again)
- break;
- }
- }
- }
-}
-
-struct tui_trow {
- char account[16];
- char formula[64];
- char text[64];
-};
-
-struct tplform {
- struct app *a;
- struct tui_rt rt;
- struct tui_trow rows[64];
- int nrows;
- char name[128];
- char desc[256];
- char series[16];
- int64_t id;
-};
-
-static void tpl_cell(void *ud, int row, int col, char **buf, size_t *cap)
-{
- struct tplform *tf = ud;
- struct tui_trow *r = &tf->rows[row];
- if (col == 0) {
- *buf = r->account;
- *cap = sizeof r->account;
- } else if (col == 2) {
- *buf = r->formula;
- *cap = sizeof r->formula;
- } else {
- *buf = r->text;
- *cap = sizeof r->text;
- }
-}
-
-static void tpl_info(void *ud, int row, char *buf, size_t n)
-{
- struct tplform *tf = ud;
- const char *nm = acct_name(tf->a, tf->rows[row].account);
- snprintf(buf, n, "%s", nm ? nm : "");
-}
-
-static void tpl_footer(void *ud, char *buf, size_t n)
-{
- (void)ud;
- snprintf(buf, n, "x = belopp i kronor. Positivt blir debet, negativt"
- " kredit.");
-}
-
-/* F5 dry-runs; Ctrl+Enter creates/updates. Returns 1 when saved. */
-static int tpl_save(struct tplform *tf, int dry)
-{
- const char *problem = NULL;
- char msg[256] = "";
- if (!tf->name[0])
- problem = "Namn saknas.";
- int used = 0;
- for (int i = 0; i < tf->rt.nrows && !problem; i++) {
- if (!tf->rows[i].account[0])
- continue;
- if (!acct_exists(tf->a, tf->rows[i].account)) {
- snprintf(msg, sizeof msg, "Konto %.15s finns inte (rad %d).",
- tf->rows[i].account, i + 1);
- problem = msg;
- break;
- }
- if (!tf->rows[i].formula[0] || !formula_valid(tf->rows[i].formula)) {
- snprintf(msg, sizeof msg,
- "Ogiltig formel på rad %d: '%.60s'.", i + 1,
- tf->rows[i].formula);
- problem = msg;
- break;
- }
- used++;
- }
- if (!problem && used == 0)
- problem = "Mallen behöver minst en rad.";
- if (problem) {
- tui_message("Mall", "%s", problem);
- return 0;
- }
-
- yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
- yyjson_mut_val *o = yyjson_mut_obj(d);
- yyjson_mut_doc_set_root(d, o);
- if (tf->id)
- yyjson_mut_obj_add_int(d, o, "id", tf->id);
- yyjson_mut_obj_add_strcpy(d, o, "name", tf->name);
- yyjson_mut_obj_add_strcpy(d, o, "series", tf->series);
- yyjson_mut_obj_add_strcpy(d, o, "description", tf->desc);
- yyjson_mut_val *arr = yyjson_mut_arr(d);
- for (int i = 0; i < tf->rt.nrows; i++) {
- if (!tf->rows[i].account[0])
- continue;
- yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
- yyjson_mut_obj_add_strcpy(d, ro, "account", tf->rows[i].account);
- yyjson_mut_obj_add_strcpy(d, ro, "formula", tf->rows[i].formula);
- if (tf->rows[i].text[0])
- yyjson_mut_obj_add_strcpy(d, ro, "description",
- tf->rows[i].text);
- }
- yyjson_mut_obj_add_val(d, o, "rows", arr);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- const char *cmd = tf->id ? "template.update" : "template.create";
- char *resp = dry ? rpc_dry(tf->a, cmd, args)
- : client_rpc(&tf->a->conn, cmd, tf->a->session,
- tf->a->org, args);
- free(args);
- if (resp && client_ok(resp)) {
- if (dry) {
- tui_message("Validering OK", "Mallen är korrekt (%d rader).",
- used);
- } else {
- tui_message("Mall", "Mallen '%s' sparad.", tf->name);
- free(resp);
- return 1;
- }
- } else {
- show_error("Kunde inte spara mallen", resp);
- }
- free(resp);
- return 0;
-}
-
-/* Template editor: a form, not a wizard. load_name == NULL creates a new
- template, otherwise the named template is loaded and saved via
- template.update. */
-static int template_form(struct app *a, const char *load_name)
-{
- struct tplform tf;
- memset(&tf, 0, sizeof tf);
- tf.a = a;
- snprintf(tf.series, sizeof tf.series, "%s", a->default_series);
- if (load_name && *load_name) {
- 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", load_name);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *resp =
- client_rpc(&a->conn, "template.get", a->session, a->org, args);
- free(args);
- if (!resp || !client_ok(resp)) {
- show_error("Mall", resp);
- free(resp);
- return 0;
- }
- tf.id = jint_val(resp, "result.id", 0);
- char *nm = jstr_dup(resp, "result.name");
- char *ser = jstr_dup(resp, "result.series");
- char *ds = jstr_dup(resp, "result.description");
- if (nm)
- snprintf(tf.name, sizeof tf.name, "%s", nm);
- if (ser && *ser)
- snprintf(tf.series, sizeof tf.series, "%s", ser);
- if (ds)
- snprintf(tf.desc, sizeof tf.desc, "%s", ds);
- free(nm);
- free(ser);
- free(ds);
- size_t n = jarr_size(resp, "result.rows");
- if (n > 64)
- n = 64;
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.rows.%zu.account", i);
- char *acc = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.formula", i);
- char *form = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.description", i);
- char *rd = jstr_dup(resp, path);
- snprintf(tf.rows[i].account, sizeof tf.rows[i].account, "%s",
- acc ? acc : "");
- snprintf(tf.rows[i].formula, sizeof tf.rows[i].formula, "%s",
- form ? form : "");
- snprintf(tf.rows[i].text, sizeof tf.rows[i].text, "%s",
- rd ? rd : "");
- free(acc);
- free(form);
- free(rd);
- }
- tf.nrows = n > 0 ? (int)n : 1;
- free(resp);
- }
- int text_w = COLS - 57;
- if (text_w < 8)
- text_w = 8;
- struct tui_rt_col cols[4] = {
- { "Konto", 2, 6, TUI_RT_EDIT },
- { "Namn", 9, 24, TUI_RT_INFO },
- { "Formel", 34, 20, TUI_RT_EDIT },
- { "Radtext", 55, text_w, TUI_RT_EDIT },
- };
- tui_rt_init(&tf.rt, tf.nrows > 0 ? tf.nrows : 1, 64, 4, cols, 7, tpl_cell,
- tpl_info, &tf);
- tui_rt_set_footer(&tf.rt, tpl_footer);
- struct tui_form_field ff[3];
- memset(ff, 0, sizeof ff);
- ff[0].label = "Namn";
- ff[0].value = tf.name;
- ff[0].cap = sizeof tf.name;
- ff[0].kind = TUI_F_TEXT;
- ff[1].label = "Serie";
- ff[1].value = tf.series;
- ff[1].cap = sizeof tf.series;
- ff[1].kind = TUI_F_TEXT;
- ff[2].label = "Text";
- ff[2].value = tf.desc;
- ff[2].cap = sizeof tf.desc;
- ff[2].kind = TUI_F_TEXT;
- tui_rt_set_fields(&tf.rt, ff, 3);
- const char *title = load_name ? "Redigera mall" : "Ny mall";
- const char *hint = "Enter = ändra fält Tab = byta fält F5 = validera"
- " ^X = rensa rad ^Enter = spara Esc = avbryt";
- for (;;) {
- int rr = tui_rt_run(title, &tf.rt, hint);
- if (rr == -1)
- return 0;
- if (rr == -2) {
- tpl_save(&tf, 1);
- continue;
- }
- if (rr == -3 && tpl_save(&tf, 0))
- return 1;
- }
-}
-
-static void template_archive(struct app *a, const char *name)
-{
- char label[256];
- snprintf(label, sizeof label, "Arkivera '%s'? (j/n): ", name);
- char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, label, "n", 0) ? ansbuf : NULL;
- if (!ans || (ans[0] != 'j' && ans[0] != 'J'))
- return;
- 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", name);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *resp =
- client_rpc(&a->conn, "template.archive", a->session, a->org, args);
- free(args);
- if (resp && client_ok(resp))
- tui_message("Mall", "Mallen '%s' arkiverad.", name);
- else
- show_error("Kunde inte arkivera", resp);
- free(resp);
-}
-
-/* ------------------------------------------------------------------ */
-/* ingående balans (series IB) */
-/* ------------------------------------------------------------------ */
-
-struct tui_ibrow {
- char account[16];
- char amount[32];
- char text[64];
-};
-
-/* Loads the net IB per account for the active fiscal year. */
-/* Effective opening balances of the selected year: the year's IB vouchers
- plus all earlier movements, exactly what the reports show. */
-static int ib_load(struct app *a, char ***out_acc, int64_t **out_amt,
- int *out_n)
-{
- *out_acc = NULL;
- *out_amt = NULL;
- *out_n = 0;
- char args[128];
- snprintf(args, sizeof args,
- "{\"fiscal_year\":%lld,\"include_zero\":true}",
- (long long)a->fy);
- char *resp = client_rpc(&a->conn, "report.trial_balance", a->session,
- a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Ingående balans", resp);
- free(resp);
- return -1;
- }
- size_t n = jarr_size(resp, "result.accounts");
- char **accs = xcalloc(n ? n : 1, sizeof(char *));
- int64_t *amts = xcalloc(n ? n : 1, sizeof(int64_t));
- int count = 0;
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.accounts.%zu.ib_ore", i);
- int64_t ib = jint_val(resp, path, 0);
- if (ib == 0)
- continue;
- snprintf(path, sizeof path, "result.accounts.%zu.account", i);
- char *acc = jstr_dup(resp, path);
- if (!acc)
- continue;
- accs[count] = acc;
- amts[count] = ib;
- count++;
- }
- free(resp);
- *out_acc = accs;
- *out_amt = amts;
- *out_n = count;
- return 0;
-}
-
-static int64_t ib_old_lookup(char **accs, int64_t *amts, int n,
- const char *account)
-{
- for (int i = 0; i < n; i++)
- if (strcmp(accs[i], account) == 0)
- return amts[i];
- return 0;
-}
-
-struct ibform {
- struct app *a;
- struct tui_rt rt;
- struct tui_ibrow rows[64];
- char **old_acc;
- int64_t *old_amt;
- int nold;
-};
-
-static void ib_cell(void *ud, int row, int col, char **buf, size_t *cap)
-{
- struct ibform *ib = ud;
- struct tui_ibrow *r = &ib->rows[row];
- if (col == 0) {
- *buf = r->account;
- *cap = sizeof r->account;
- } else if (col == 2) {
- *buf = r->amount;
- *cap = sizeof r->amount;
- } else {
- *buf = r->text;
- *cap = sizeof r->text;
- }
-}
-
-static void ib_info(void *ud, int row, char *buf, size_t n)
-{
- struct ibform *ib = ud;
- const char *nm = acct_name(ib->a, ib->rows[row].account);
- snprintf(buf, n, "%s", nm ? nm : "");
-}
-
-static void ib_footer(void *ud, char *buf, size_t n)
-{
- (void)ud;
- snprintf(buf, n, "Positiva belopp = debet, negativa = kredit. Summan ska"
- " bli noll.");
-}
-
-static const char *ib_check(struct ibform *ib, int *used_out, char *msg,
- size_t msglen)
-{
- int64_t sum = 0;
- int used = 0;
- for (int i = 0; i < ib->rt.nrows; i++) {
- if (!ib->rows[i].account[0])
- continue;
- if (!acct_exists(ib->a, ib->rows[i].account)) {
- snprintf(msg, msglen, "Konto %.15s finns inte (rad %d).",
- ib->rows[i].account, i + 1);
- return msg;
- }
- double kr = 0;
- if (ib->rows[i].amount[0] &&
- !parse_x_double(ib->rows[i].amount, &kr)) {
- snprintf(msg, msglen, "Ogiltigt belopp på rad %d: '%.20s'.", i + 1,
- ib->rows[i].amount);
- return msg;
- }
- sum += (int64_t)llround(kr * 100.0);
- used++;
- }
- if (used == 0)
- return "Ange minst ett konto.";
- if (sum != 0) {
- snprintf(msg, msglen, "Summan måste bli noll (nu %lld öre).",
- (long long)sum);
- return msg;
- }
- *used_out = used;
- return NULL;
-}
-
-/* Posts the delta against the existing IB so nothing is ever edited. */
-static int ib_post_delta(struct ibform *ib)
-{
- struct app *a = ib->a;
- 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, "date", a->fy_start);
- yyjson_mut_obj_add_strcpy(d, o, "description", "Ingående balans");
- yyjson_mut_obj_add_strcpy(d, o, "series", "IB");
- char *ref = util_random_id("tui-", 8);
- yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref);
- free(ref);
- yyjson_mut_val *arr = yyjson_mut_arr(d);
- int posted = 0;
- for (int i = 0; i < ib->rt.nrows; i++) {
- if (!ib->rows[i].account[0])
- continue;
- double kr = 0;
- if (ib->rows[i].amount[0] &&
- !parse_x_double(ib->rows[i].amount, &kr))
- kr = 0;
- int64_t new_amt = (int64_t)llround(kr * 100.0);
- int64_t prev_amt =
- ib_old_lookup(ib->old_acc, ib->old_amt, ib->nold,
- ib->rows[i].account);
- int64_t delta = new_amt - prev_amt;
- if (delta == 0)
- continue;
- yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
- yyjson_mut_obj_add_strcpy(d, ro, "account", ib->rows[i].account);
- yyjson_mut_obj_add_int(d, ro, "debit_ore", delta > 0 ? delta : 0);
- yyjson_mut_obj_add_int(d, ro, "credit_ore", delta < 0 ? -delta : 0);
- if (ib->rows[i].text[0])
- yyjson_mut_obj_add_strcpy(d, ro, "description",
- ib->rows[i].text);
- posted++;
- }
- for (int i = 0; i < ib->nold; i++) {
- int still = 0;
- for (int j = 0; j < ib->rt.nrows; j++)
- if (strcmp(ib->rows[j].account, ib->old_acc[i]) == 0) {
- still = 1;
- break;
- }
- if (!still && ib->old_amt[i] != 0) {
- yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
- yyjson_mut_obj_add_strcpy(d, ro, "account", ib->old_acc[i]);
- yyjson_mut_obj_add_int(d, ro, "debit_ore",
- ib->old_amt[i] < 0 ? -ib->old_amt[i] : 0);
- yyjson_mut_obj_add_int(d, ro, "credit_ore",
- ib->old_amt[i] > 0 ? ib->old_amt[i] : 0);
- posted++;
- }
- }
- if (posted == 0) {
- yyjson_mut_doc_free(d);
- tui_message("Ingående balans", "Ingen ändring.");
- return 0;
- }
- yyjson_mut_obj_add_val(d, o, "rows", arr);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *resp =
- client_rpc(&a->conn, "voucher.post", a->session, a->org, args);
- free(args);
- if (resp && client_ok(resp)) {
- int64_t num = jint_val(resp, "result.number", 0);
- tui_message("Ingående balans",
- "Sparat som IB %lld (%d justerade rader).",
- (long long)num, posted);
- free(resp);
- return 1;
- }
- show_error("Kunde inte spara ingående balans", resp);
- free(resp);
- return 0;
-}
-
-static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold)
-{
- struct ibform ib;
- memset(&ib, 0, sizeof ib);
- ib.a = a;
- ib.old_acc = old_acc;
- ib.old_amt = old_amt;
- ib.nold = nold;
- if (nold > 63) {
- tui_message("Ingående balans",
- "För många konton (%d) för att redigera här.", nold);
- return 0;
- }
- for (int i = 0; i < nold && i < 63; i++) {
- snprintf(ib.rows[i].account, sizeof ib.rows[i].account, "%s",
- old_acc[i]);
- tui_kr_format(old_amt[i], ib.rows[i].amount,
- sizeof ib.rows[i].amount);
- }
- int text_w = COLS - 51;
- if (text_w < 6)
- text_w = 6;
- struct tui_rt_col cols[4] = {
- { "Konto", 2, 6, TUI_RT_EDIT },
- { "Namn", 9, 24, TUI_RT_INFO },
- { "Belopp", 34, 14, TUI_RT_EDIT },
- { "Text", 49, text_w, TUI_RT_EDIT },
- };
- tui_rt_init(&ib.rt, nold > 0 ? nold : 1, 64, 4, cols, 5, ib_cell, ib_info,
- &ib);
- tui_rt_set_footer(&ib.rt, ib_footer);
-
- for (;;) {
- int rr = tui_rt_run("Ingående balans", &ib.rt,
- "Tab = byta fält F5 = validera ^X = rensa rad"
- " ^Enter = spara Esc = avbryt");
- if (rr == -1)
- return 0;
- char msg[256];
- int used = 0;
- const char *problem = ib_check(&ib, &used, msg, sizeof msg);
- if (problem) {
- tui_message("Ingående balans", "%s", problem);
- continue;
- }
- if (rr == -2) {
- tui_message("Validering OK", "%d konton, summan är noll.", used);
- continue;
- }
- if (ib_post_delta(&ib))
- return 1;
- }
-}
-
-struct ibpager {
- struct app *a;
- char **accs;
- int64_t *amts;
- int n;
-};
-
-static int ib_view_key(void *ud, int ch)
-{
- struct ibpager *p = ud;
- if (ch == 'e' || ch == 'E' || ch == TUI_KEY_CTRL_N) {
- if (ib_form(p->a, p->accs, p->amts, p->n))
- return TUI_HOOK_REFRESH;
- return TUI_HOOK_STAY;
- }
- return TUI_HOOK_NONE;
-}
-
-/* The list is a read-only table with no selection: tui_pager shows the
- columns and the sum as plain text, and the e/^N keys run the editor. */
-static void ib_screen(struct app *a)
-{
- for (;;) {
- char **accs = NULL;
- int64_t *amts = NULL;
- int n = 0;
- if (ib_load(a, &accs, &amts, &n) != 0)
- return;
- struct buf t;
- buf_init(&t);
- buf_line(&t, "Räkenskapsår %s ingående balans (effektiv)\n\n",
- a->fy_label);
- buf_line(&t, "%-7s%-53s%s\n\n", "Konto", "Namn", "Belopp");
- int64_t sum = 0;
- for (int i = 0; i < n; i++) {
- sum += amts[i];
- char amount[32], nbuf[128];
- tui_kr_format(amts[i], amount, sizeof amount);
- const char *nm = acct_name(a, accs[i]);
- snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : "");
- tui_pad_field(nbuf, sizeof nbuf, 50);
- buf_line(&t, "%-6s %s %14s\n", accs[i], nbuf, amount);
- }
- if (n == 0)
- buf_line(&t, "Ingen ingående balans registrerad ännu.\n");
- char sumbuf[32];
- tui_kr_format(sum, sumbuf, sizeof sumbuf);
- buf_line(&t, "\nSumma: %s\n", sumbuf);
- buf_append(&t, "\0", 1);
- struct ibpager p = { a, accs, amts, n };
- int ret = tui_pager_hook("Ingående balans", (const char *)t.p,
- "e/^N = redigera", 0, ib_view_key, &p);
- buf_free(&t);
- for (int i = 0; i < n; i++)
- free(accs[i]);
- free(accs);
- free(amts);
- if (ret == 0)
- return;
- }
-}
-
-struct company_field {
- const char *key;
- const char *label;
- int numeric; /* 0 = text, 1 = month (1-12), 2 = unsigned integer */
-};
-
-static const struct company_field COMPANY_FIELDS[] = {
- { "name", "Företagsnamn", 0 },
- { "org_nr", "Organisationsnummer", 0 },
- { "vat_nr", "Momsregistreringsnummer", 0 },
- { "address", "Adress", 0 },
- { "postal_code", "Postnummer", 0 },
- { "city", "Ort", 0 },
- { "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 },
-};
-
-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);
- 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 ||
- strcmp(v, "year") == 0;
- if (strcmp(f->key, "framework") == 0)
- return strcmp(v, "K2") == 0 || strcmp(v, "K3") == 0;
- 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 = tui_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 ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, q, "n", 0) ? ansbuf : NULL;
- 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)) {
- tui_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[512] = "", titlebuf[512] = "";
- int ok_name = tui_prompt_into(namebuf, sizeof namebuf, "Namn: ",
- "", 0);
- int ok_title = ok_name && namebuf[0]
- ? tui_prompt_into(titlebuf, sizeof titlebuf,
- "Titel: ",
- "Styrelseledamot", 0)
- : 0;
- if (ok_name && namebuf[0] && ok_title && titlebuf[0]) {
- 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", titlebuf);
- 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))
- tui_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) {
- tui_message("Styrelseledamöter",
- "Endast ägare kan ändra styrelseledamöterna.");
- } else {
- char namebuf[512] = "", titlebuf[512] = "";
- int ok_name = tui_prompt_into(namebuf, sizeof namebuf,
- "Namn: ",
- names[s] ? names[s] : "", 0);
- int ok_title = ok_name && namebuf[0]
- ? tui_prompt_into(
- titlebuf, sizeof titlebuf, "Titel: ",
- titles[s] ? titles[s] : "", 0)
- : 0;
- if (ok_name && namebuf[0] && ok_title && titlebuf[0]) {
- 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", titlebuf);
- 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))
- tui_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;
- }
-}
-
-static void company_screen(struct app *a)
-{
- static int sel = 0;
- const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]);
- const int ninv = 2;
- static const char *const invkeys[2] = { "invoice_bankgiro",
- "invoice_our_ref" };
- static const char *const invlabels[2] = { "Bankgiro",
- "Vår referens (faktura)" };
- int owner = a->role[0] && strcmp(a->role, "owner") == 0;
- for (;;) {
- if (g_quit)
- return;
- char *resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}");
- if (!resp || !client_ok(resp)) {
- show_error("Företagsuppgifter", resp);
- free(resp);
- return;
- }
- char *vals[20];
- struct tui_form_field ff[20];
- memset(ff, 0, sizeof ff);
- for (int i = 0; i < nf; i++) {
- char path[64];
- vals[i] = xcalloc(512, 1);
- snprintf(path, sizeof path, "result.%s", COMPANY_FIELDS[i].key);
- if (COMPANY_FIELDS[i].numeric) {
- snprintf(vals[i], 512, "%lld",
- (long long)jint_val(resp, path, 0));
- } else {
- char *v = jstr_dup(resp, path);
- snprintf(vals[i], 512, "%s", v ? v : "");
- free(v);
- }
- ff[i].label = COMPANY_FIELDS[i].label;
- ff[i].value = vals[i];
- ff[i].cap = 512;
- ff[i].kind = TUI_F_TEXT;
- }
- free(resp);
- char *sresp =
- client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
- for (int i = 0; i < ninv; i++) {
- char path[64];
- int idx = nf + i;
- vals[idx] = xcalloc(512, 1);
- snprintf(path, sizeof path, "result.%s", invkeys[i]);
- char *v = sresp ? jstr_dup(sresp, path) : NULL;
- snprintf(vals[idx], 512, "%s", v ? v : "");
- free(v);
- ff[idx].label = invlabels[i];
- ff[idx].value = vals[idx];
- ff[idx].cap = 512;
- ff[idx].kind = TUI_F_TEXT;
- }
- free(sresp);
- ff[nf + ninv].label = "Styrelseledamöter";
- ff[nf + ninv].kind = TUI_F_ACTION;
- int r = tui_form_run("Företagsuppgifter", ff, nf + ninv + 1, owner,
- &sel);
- if (r >= 0 && r < nf) {
- if (!company_field_valid(&COMPANY_FIELDS[r], vals[r])) {
- tui_message("Ogiltigt värde", "%s", COMPANY_FIELDS[r].label);
- } else {
- yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
- yyjson_mut_val *o = yyjson_mut_obj(d);
- yyjson_mut_doc_set_root(d, o);
- if (COMPANY_FIELDS[r].numeric)
- yyjson_mut_obj_add_int(d, o, COMPANY_FIELDS[r].key,
- strtoll(vals[r], NULL, 10));
- else
- yyjson_mut_obj_add_strcpy(d, o, COMPANY_FIELDS[r].key,
- vals[r]);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *rr = client_rpc(&a->conn, "org.update", a->session,
- a->org, args);
- free(args);
- if (!rr || !client_ok(rr))
- show_error("Kunde inte spara", rr);
- free(rr);
- }
- } else if (r >= nf && r < nf + ninv) {
- 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, "key", invkeys[r - nf]);
- yyjson_mut_obj_add_strcpy(d, o, "value", vals[r]);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *rr = client_rpc(&a->conn, "settings.set", a->session,
- a->org, args);
- free(args);
- if (!rr || !client_ok(rr))
- show_error("Kunde inte spara", rr);
- free(rr);
- }
- if (r == nf + ninv)
- board_screen(a);
- for (int i = 0; i < nf + ninv; i++)
- free(vals[i]);
- if (r == TUI_FORM_BACK)
- return;
- }
-}
-
-static const char *const SETTING_KEYS[] = {
- "default_series",
- "attachment_dir",
- "invoice_receivable_account",
- "invoice_revenue_account",
- "smtp_host",
- "smtp_port",
- "smtp_user",
- "smtp_from",
- "smtp_reply_to",
- "smtp_security",
- "smtp_password",
-};
-static const char *const SETTING_LABELS[] = {
- "Standardserie",
- "Bilagornas mapp",
- "Fordringskonto",
- "Intäktskonto",
- "SMTP-server",
- "SMTP-port",
- "SMTP-användare",
- "SMTP-avsändare",
- "SMTP-svarsadress",
- "SMTP-säkerhet",
- "SMTP-lösenord",
-};
-static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" };
-
-static void settings_screen(struct app *a)
-{
- static int sel = 0;
- const int nset = (int)(sizeof SETTING_KEYS / sizeof SETTING_KEYS[0]);
- for (;;) {
- if (g_quit)
- return;
- char *resp =
- client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
- if (!resp || !client_ok(resp)) {
- show_error("Inställningar", resp);
- free(resp);
- return;
- }
- int pw_set = jbool_val(resp, "result.smtp_password_set", 0);
- char pwstatus[192];
- snprintf(pwstatus, sizeof pwstatus,
- "SMTP-lösenord (lämna tomt för oförändrat): %s",
- pw_set ? "satt" : "inte satt");
- char *vals[16];
- struct tui_form_field ff[16];
- memset(ff, 0, sizeof ff);
- int sec_choice = 0;
- for (int i = 0; i < nset; i++) {
- char path[64], *v;
- vals[i] = xcalloc(512, 1);
- snprintf(path, sizeof path, "result.%s", SETTING_KEYS[i]);
- v = jstr_dup(resp, path);
- snprintf(vals[i], 512, "%s", v ? v : "");
- free(v);
- const char *key = SETTING_KEYS[i];
- ff[i].label = SETTING_LABELS[i];
- ff[i].value = vals[i];
- ff[i].cap = 512;
- ff[i].kind = TUI_F_TEXT;
- if (strcmp(key, "smtp_security") == 0) {
- ff[i].kind = TUI_F_CHOICE;
- ff[i].choices = SECURITY_CHOICES;
- ff[i].nchoices = 3;
- ff[i].choice = &sec_choice;
- for (int k = 0; k < 3; k++)
- if (strcmp(vals[i], SECURITY_CHOICES[k]) == 0)
- sec_choice = k;
- } else if (strcmp(key, "smtp_password") == 0) {
- ff[i].mask = 1;
- }
- }
- free(resp);
- struct tui_form_action acts[1];
- memset(acts, 0, sizeof acts);
- acts[0].label = pwstatus;
- acts[0].enabled = -1; /* dim status row, not selectable */
- int r = tui_form_run_actions(
- "Inställningar", ff, nset, 1, acts, 1,
- "upp/ned/Home/End Enter = ändra F5 = uppdatera"
- " Esc/q = tillbaka ^C = avsluta",
- &sel);
- if (r >= 0 && r < nset) {
- const char *key = SETTING_KEYS[r];
- const char *val = vals[r];
- if (strcmp(key, "smtp_security") == 0)
- val = SECURITY_CHOICES[sec_choice];
- if (strcmp(key, "smtp_password") == 0 && !*vals[r]) {
- /* empty means "keep the stored secret" */
- } else {
- 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, "key", key);
- yyjson_mut_obj_add_strcpy(d, o, "value", val);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *rr = client_rpc(&a->conn, "settings.set", a->session,
- a->org, args);
- free(args);
- if (rr && client_ok(rr)) {
- snprintf(vals[r], 512, "%s", val);
- if (strcmp(key, "default_series") == 0)
- snprintf(a->default_series,
- sizeof a->default_series, "%s", val);
- else if (strcmp(key, "attachment_dir") == 0)
- snprintf(a->attachment_dir,
- sizeof a->attachment_dir, "%s", val);
- } else {
- show_error("Kunde inte spara inställningen", rr);
- }
- free(rr);
- }
- }
- for (int i = 0; i < nset; i++)
- free(vals[i]);
- if (r == TUI_FORM_BACK)
- return;
- }
-}
-
-/* ------------------------------------------------------------------ */
-/* invoicing: Fakturor, Kunder */
-/* ------------------------------------------------------------------ */
-
-static void today_iso(char *buf, size_t n)
-{
- time_t now = time(NULL);
- struct tm tm;
- localtime_r(&now, &tm);
- char tmp[64];
- snprintf(tmp, sizeof tmp, "%04d-%02d-%02d", tm.tm_year + 1900,
- tm.tm_mon + 1, tm.tm_mday);
- size_t len = strlen(tmp);
- if (len >= n)
- len = n ? n - 1 : 0;
- memcpy(buf, tmp, len);
- if (n)
- buf[len] = '\0';
-}
-
-/* Directory for generated invoice PDFs. */
-static void invoice_cache_dir(char *buf, size_t n)
-{
- const char *cache = getenv("XDG_CACHE_HOME");
- if (cache && *cache) {
- snprintf(buf, n, "%s/bokf", cache);
- return;
- }
- snprintf(buf, n, "/tmp");
-}
-
-/* Keep a customer-supplied name from escaping the directory. */
-static void safe_fname(const char *in, char *out, size_t n)
-{
- size_t o = 0;
- for (const char *p = in ? in : ""; *p && o + 1 < n; p++) {
- unsigned char c = (unsigned char)*p;
- if (c < 32 || c == '/')
- c = '-';
- out[o++] = (char)c;
- }
- out[o] = '\0';
-}
-
-static int write_pdf_b64(const char *b64, const char *path)
-{
- unsigned char *data = NULL;
- size_t len = 0;
- if (!b64 || util_b64_decode(b64, strlen(b64), &data, &len) != 0)
- return -1;
- FILE *fp = fopen(path, "wb");
- if (!fp) {
- free(data);
- return -1;
- }
- size_t wrote = fwrite(data, 1, len, fp);
- fclose(fp);
- free(data);
- return wrote == len ? 0 : -1;
-}
-
-/* Open a saved PDF with xdg-open when a desktop session is present;
- otherwise (or on fork failure) show where it was saved. */
-static void open_pdf(const char *path)
-{
- const char *display = getenv("DISPLAY");
- const char *wayland = getenv("WAYLAND_DISPLAY");
- if ((!display || !*display) && (!wayland || !*wayland)) {
- tui_message("Faktura PDF", "Sparad: %s", path);
- return;
- }
- pid_t pid = fork();
- if (pid == 0) {
- pid_t gc = fork();
- if (gc == 0) {
- setsid();
- execlp("xdg-open", "xdg-open", path, (char *)NULL);
- _exit(127);
- }
- _exit(gc < 0 ? 1 : 0);
- }
- if (pid > 0)
- waitpid(pid, NULL, 0);
- else
- tui_message("Faktura PDF", "Sparad: %s", path);
-}
-
-static void save_and_open_pdf(const char *b64, const char *filename)
-{
- char dir[512], path[700], safe[600];
- invoice_cache_dir(dir, sizeof dir);
- safe_fname(filename, safe, sizeof safe);
- if (snprintf(path, sizeof path, "%s/%s", dir, safe) >= (int)sizeof path) {
- tui_message("Faktura PDF", "Sökvägen blir för lång.");
- return;
- }
- config_mkdirs(path);
- if (write_pdf_b64(b64, path) != 0) {
- tui_message("Faktura PDF", "Kunde inte spara %s", path);
- return;
- }
- open_pdf(path);
-}
-
-static const char *invoice_status_sv(const char *s)
-{
- if (s && strcmp(s, "credited") == 0)
- return "krediterad";
- return "utfärdad";
-}
-
-static void qty_sv(int64_t milli, char *buf, size_t n)
-{
- int64_t whole = milli / 1000;
- int64_t frac = milli % 1000;
- char tmp[64];
- if (frac == 0) {
- snprintf(tmp, sizeof tmp, "%lld", (long long)whole);
- } else {
- if (frac < 0)
- frac = -frac;
- char fr[16];
- snprintf(fr, sizeof fr, "%03lld", (long long)frac);
- while (fr[0] && fr[strlen(fr) - 1] == '0')
- fr[strlen(fr) - 1] = '\0';
- snprintf(tmp, sizeof tmp, "%lld,%s", (long long)whole, fr);
- }
- size_t len = strlen(tmp);
- if (len >= n)
- len = n ? n - 1 : 0;
- memcpy(buf, tmp, len);
- if (n)
- buf[len] = '\0';
-}
-
-static const char *vat_label(const char *code)
-{
- if (!code)
- return "";
- if (strcmp(code, "25") == 0)
- return "25%";
- if (strcmp(code, "12") == 0)
- return "12%";
- if (strcmp(code, "6") == 0)
- return "6%";
- if (strcmp(code, "0") == 0)
- return "0%";
- if (strcmp(code, "rc") == 0)
- return "Omvänd moms";
- if (strcmp(code, "eu") == 0)
- return "EU";
- return code;
-}
-
-static const char *vat_code_of(const char *s)
-{
- if (!s || !*s)
- return "25";
- if (strcmp(s, "25") == 0 || strcmp(s, "25%") == 0)
- return "25";
- if (strcmp(s, "12") == 0 || strcmp(s, "12%") == 0)
- return "12";
- if (strcmp(s, "6") == 0 || strcmp(s, "6%") == 0)
- return "6";
- if (strcmp(s, "0") == 0 || strcmp(s, "0%") == 0)
- return "0";
- if (strcasecmp(s, "rc") == 0 || strcasecmp(s, "Omvänd moms") == 0)
- return "rc";
- if (strcasecmp(s, "eu") == 0 || strcasecmp(s, "EU") == 0)
- return "eu";
- return NULL;
-}
-
-static const char *unit_code_of(const char *s)
-{
- if (!s || !*s)
- return "st";
- if (strcasecmp(s, "st") == 0)
- return "st";
- if (strcasecmp(s, "tim") == 0)
- return "tim";
- if (strcasecmp(s, "dag") == 0)
- return "dag";
- if (strcasecmp(s, "km") == 0)
- return "km";
- return NULL;
-}
-
-static int unit_index(const char *s)
-{
- static const char *const opts[] = { "st", "tim", "dag", "km" };
- if (s && *s) {
- for (int i = 0; i < 4; i++)
- if (strcmp(s, opts[i]) == 0)
- return i;
- }
- return 0;
-}
-
-static int qty_is_valid(const char *s)
-{
- if (!s || !*s)
- return 0;
- int sep = 0, frac = 0, intd = 0, nonzero = 0;
- for (const char *p = s; *p; p++) {
- if (*p == ',' || *p == '.') {
- if (sep)
- return 0;
- sep = 1;
- continue;
- }
- if (*p < '0' || *p > '9')
- return 0;
- if (*p != '0')
- nonzero = 1;
- if (sep) {
- frac++;
- if (frac > 3)
- return 0;
- } else if (++intd > 15) {
- return 0;
- }
- }
- if (!intd || (sep && frac == 0))
- return 0;
- return nonzero;
-}
-
-/* ---------------------------- Kunder ------------------------------ */
-
-static int customer_save(struct app *a, int64_t id, const char *name,
- const char *address, const char *postal,
- const char *city, const char *vat, const char *email,
- const char *your_ref, const char *payment,
- const char *notes, int dry)
-{
- if (!name || !*name) {
- tui_message("Kund", "Namn krävs.");
- return 0;
- }
- if (!payment || !*payment) {
- tui_message("Kund", "Betalningsdagar krävs.");
- return 0;
- }
- for (const char *p = payment; *p; p++) {
- if (*p < '0' || *p > '9') {
- tui_message("Kund", "Betalningsdagar ska vara ett heltal.");
- return 0;
- }
- }
- yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
- yyjson_mut_val *o = yyjson_mut_obj(d);
- yyjson_mut_doc_set_root(d, o);
- if (id > 0)
- yyjson_mut_obj_add_int(d, o, "id", id);
- yyjson_mut_obj_add_strcpy(d, o, "name", name);
- yyjson_mut_obj_add_strcpy(d, o, "address", address);
- yyjson_mut_obj_add_strcpy(d, o, "postal_code", postal);
- yyjson_mut_obj_add_strcpy(d, o, "city", city);
- yyjson_mut_obj_add_strcpy(d, o, "vat_nr", vat);
- yyjson_mut_obj_add_strcpy(d, o, "email", email);
- yyjson_mut_obj_add_strcpy(d, o, "your_ref", your_ref);
- yyjson_mut_obj_add_int(d, o, "payment_days", strtoll(payment, NULL, 10));
- yyjson_mut_obj_add_strcpy(d, o, "notes", notes);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- const char *cmd = id > 0 ? "customer.update" : "customer.create";
- char *r = dry ? rpc_dry(a, cmd, args)
- : client_rpc(&a->conn, cmd, a->session, a->org, args);
- free(args);
- int ok = r && client_ok(r);
- if (ok)
- tui_message("Kund", dry ? "Uppgifterna är giltiga." : "Sparat.");
- else
- show_error(dry ? "Kunden kunde inte valideras" : "Kunde inte spara",
- r);
- free(r);
- return ok;
-}
-
-static void customer_form(struct app *a, int64_t id)
-{
- char name[256] = "", address[512] = "", postal[32] = "", city[128] = "";
- char vat[64] = "", email[256] = "", your_ref[128] = "";
- char payment[16] = "30", notes[2048] = "";
- if (id > 0) {
- char args[64];
- snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id);
- char *resp =
- client_rpc(&a->conn, "customer.get", a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Kund", resp);
- free(resp);
- return;
- }
- struct {
- const char *key;
- char *dst;
- size_t cap;
- } fields[] = {
- { "name", name, sizeof name },
- { "address", address, sizeof address },
- { "postal_code", postal, sizeof postal },
- { "city", city, sizeof city },
- { "vat_nr", vat, sizeof vat },
- { "email", email, sizeof email },
- { "your_ref", your_ref, sizeof your_ref },
- { "notes", notes, sizeof notes },
- };
- for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) {
- char path[64], *v;
- snprintf(path, sizeof path, "result.%s", fields[i].key);
- v = jstr_dup(resp, path);
- if (v) {
- snprintf(fields[i].dst, fields[i].cap, "%s", v);
- free(v);
- }
- }
- snprintf(payment, sizeof payment, "%lld",
- (long long)jint_val(resp, "result.payment_days", 30));
- free(resp);
- }
- const char *labels[] = { "Namn", "Adress",
- "Postnummer", "Ort",
- "Momsreg.nr", "E-post",
- "Er referens", "Betalningsdagar",
- "Anteckningar" };
- char *vals[] = { name, address, postal, city, vat,
- email, your_ref, payment, notes };
- size_t caps[] = { sizeof name, sizeof address, sizeof postal,
- sizeof city, sizeof vat, sizeof email,
- sizeof your_ref, sizeof payment, sizeof notes };
- struct tui_form_field ff[10];
- memset(ff, 0, sizeof ff);
- for (int i = 0; i < 9; i++) {
- ff[i].label = labels[i];
- ff[i].value = vals[i];
- ff[i].cap = caps[i];
- ff[i].kind = TUI_F_TEXT;
- }
- ff[9].label = "";
- ff[9].value = "Spara";
- ff[9].kind = TUI_F_ACTION;
- static int sel = 0;
- for (;;) {
- int r = tui_form_run(id > 0 ? "Kund" : "Ny kund", ff, 10, 1, &sel);
- if (r == TUI_FORM_BACK)
- return;
- if (r == TUI_FORM_REFRESH) {
- customer_save(a, id, name, address, postal, city, vat, email,
- your_ref, payment, notes, 1);
- continue;
- }
- if (r == TUI_FORM_SUBMIT || r == 9) {
- if (customer_save(a, id, name, address, postal, city, vat, email,
- your_ref, payment, notes, 0))
- return;
- }
- }
-}
-
-static void customer_archive_toggle(struct app *a, int64_t id, const char *name,
- int active)
-{
- if (!tui_confirm("Kund", active ? "Arkivera kund %s?" :
- "Återaktivera kund %s?",
- name ? name : ""))
- return;
- char args[128];
- snprintf(args, sizeof args, "{\"id\":%lld,\"active\":%s}", (long long)id,
- active ? "false" : "true");
- char *r =
- client_rpc(&a->conn, "customer.archive", a->session, a->org, args);
- if (r && client_ok(r))
- tui_message("Kund", active ? "Arkiverad." : "Återaktiverad.");
- else
- show_error("Kund", r);
- free(r);
-}
-
-static void customers_screen(struct app *a)
-{
- char **items = NULL;
- char **names = NULL;
- int64_t *ids = NULL;
- unsigned char *active = NULL;
- size_t n = 0;
- int fetch = 1;
- for (;;) {
- if (g_quit) {
- for (size_t i = 0; i < n; i++) {
- free(items[i]);
- free(names[i]);
- }
- free(items);
- free(names);
- free(ids);
- free(active);
- return;
- }
- if (fetch) {
- for (size_t i = 0; i < n; i++) {
- free(items[i]);
- free(names[i]);
- }
- free(items);
- free(names);
- free(ids);
- free(active);
- items = NULL;
- names = NULL;
- ids = NULL;
- active = NULL;
- n = 0;
- fetch = 0;
- char *resp = client_rpc(&a->conn, "customer.list", a->session,
- a->org, "{}");
- if (!resp || !client_ok(resp)) {
- show_error("Kunder", resp);
- free(resp);
- return;
- }
- n = jarr_size(resp, "result.items");
- items = xcalloc(n + 1, sizeof(char *));
- names = xcalloc(n + 1, sizeof(char *));
- ids = xcalloc(n ? n : 1, sizeof(int64_t));
- active = xcalloc(n + 1, 1);
- for (size_t i = 0; i < n; i++) {
- char path[64], line[768];
- snprintf(path, sizeof path, "result.items.%zu.id", i);
- ids[i] = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.active", i);
- active[i] = (unsigned char)jbool_val(resp, path, 1);
- snprintf(path, sizeof path, "result.items.%zu.name", i);
- char *name = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.postal_code", i);
- char *postal = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.city", i);
- char *city = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.vat_nr", i);
- char *vat = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.payment_days", i);
- int64_t days = jint_val(resp, path, 0);
- char nbuf[256], pbuf[256], vbuf[80];
- snprintf(nbuf, sizeof nbuf, "%s", name ? name : "");
- tui_pad_field(nbuf, sizeof nbuf, 30);
- snprintf(pbuf, sizeof pbuf, "%s %s", postal ? postal : "",
- city ? city : "");
- tui_pad_field(pbuf, sizeof pbuf, 30);
- snprintf(vbuf, sizeof vbuf, "%s", vat ? vat : "");
- tui_pad_field(vbuf, sizeof vbuf, 18);
- snprintf(line, sizeof line, "%s %s %s %3lld dagar %s", nbuf,
- pbuf, vbuf, (long long)days,
- active[i] ? "" : "(arkiverad)");
- items[i] = xstrdup(line);
- names[i] = xstrdup(name ? name : "");
- free(name);
- free(postal);
- free(city);
- free(vat);
- }
- free(resp);
- items[n] = xstrdup("+ Ny kund (^N)");
- }
- int start = 0;
- if (a->customer_sel) {
- for (size_t i = 0; i < n; i++)
- if (ids[i] == a->customer_sel) {
- start = (int)i;
- break;
- }
- }
- int cur = start;
- int sel = tui_select_list("Kunder", items, (int)n + 1, start, 1, &cur,
- 1, "arkivera/återaktivera", 0);
- if (cur >= 0 && (size_t)cur < n)
- a->customer_sel = ids[cur];
- if (sel == -1) {
- for (size_t i = 0; i < n; i++) {
- free(items[i]);
- free(names[i]);
- }
- free(items);
- free(names);
- free(ids);
- free(active);
- return;
- }
- if (sel == -2) {
- fetch = 1;
- continue;
- }
- if (sel == -6 && cur >= 0 && (size_t)cur < n) {
- customer_archive_toggle(a, ids[cur], names[cur],
- active[cur] != 0);
- fetch = 1;
- continue;
- }
- if (sel == -4 || (sel >= 0 && (size_t)sel == n)) {
- customer_form(a, 0);
- fetch = 1;
- continue;
- }
- if (sel >= 0 && (size_t)sel < n) {
- customer_form(a, ids[sel]);
- fetch = 1;
- continue;
- }
- }
-}
-
-/* --------------------------- Fakturor ----------------------------- */
-
-struct invctx {
- struct app *a;
- int64_t id;
- int64_t number;
- int64_t customer_id;
- char customer[256];
-};
-
-static void inv_detail_pdf(struct invctx *ctx)
-{
- char args[64];
- snprintf(args, sizeof args, "{\"id\":%lld}", (long long)ctx->id);
- char *resp = client_rpc(&ctx->a->conn, "invoice.pdf", ctx->a->session,
- ctx->a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Kunde inte hämta PDF", resp);
- free(resp);
- return;
- }
- char *b64 = jstr_dup(resp, "result.content_base64");
- if (b64) {
- char fname[800];
- snprintf(fname, sizeof fname, "Faktura %lld %s.pdf",
- (long long)ctx->number, ctx->customer);
- save_and_open_pdf(b64, fname);
- free(b64);
- }
- free(resp);
-}
-
-static void inv_detail_send(struct invctx *ctx)
-{
- struct app *a = ctx->a;
- char email[256] = "";
- if (ctx->customer_id > 0) {
- char cargs[64];
- snprintf(cargs, sizeof cargs, "{\"id\":%lld}",
- (long long)ctx->customer_id);
- char *c =
- client_rpc(&a->conn, "customer.get", a->session, a->org, cargs);
- if (c && client_ok(c)) {
- char *e = jstr_dup(c, "result.email");
- if (e) {
- snprintf(email, sizeof email, "%s", e);
- free(e);
- }
- }
- free(c);
- }
- if (!email[0]) {
- tui_message("Skicka faktura", "Kunden har ingen e-postadress.");
- return;
- }
- if (!tui_confirm("Skicka faktura", "Skicka faktura %lld till %s?",
- (long long)ctx->number, email))
- return;
- char args[64];
- snprintf(args, sizeof args, "{\"id\":%lld}", (long long)ctx->id);
- char *r =
- client_rpc(&a->conn, "invoice.send", a->session, a->org, args);
- if (r && client_ok(r)) {
- char *to = jstr_dup(r, "result.sent_to");
- tui_message("Skickat", "Faktura %lld skickad till %s.",
- (long long)ctx->number, to && *to ? to : email);
- free(to);
- } else {
- show_error("Skicka faktura", r);
- }
- free(r);
-}
-
-static int inv_key(void *ud, int ch)
-{
- struct invctx *ctx = ud;
- if (ch == 'p') {
- inv_detail_pdf(ctx);
- return TUI_HOOK_STAY;
- }
- if (ch == 's') {
- inv_detail_send(ctx);
- return TUI_HOOK_STAY;
- }
- return TUI_HOOK_NONE;
-}
-
-static void invoices_detail(struct app *a, int64_t id)
-{
- char args[64];
- snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id);
- for (;;) {
- char *resp =
- client_rpc(&a->conn, "invoice.get", a->session, a->org, args);
- if (!resp || !client_ok(resp)) {
- show_error("Faktura", resp);
- free(resp);
- return;
- }
- int64_t number = jint_val(resp, "result.number", 0);
- int64_t customer_id = jint_val(resp, "result.customer_id", 0);
- int64_t net = jint_val(resp, "result.net_ore", 0);
- int64_t vat = jint_val(resp, "result.vat_ore", 0);
- int64_t total = jint_val(resp, "result.total_ore", 0);
- char *cust = jstr_dup(resp, "result.customer_name");
- char *status = jstr_dup(resp, "result.status");
- char *date = jstr_dup(resp, "result.invoice_date");
- char *due = jstr_dup(resp, "result.due_date");
- char *delivery = jstr_dup(resp, "result.delivery_date");
- char *your_ref = jstr_dup(resp, "result.your_ref");
- char *our_ref = jstr_dup(resp, "result.our_ref");
- char *notes = jstr_dup(resp, "result.notes");
- char *ocr = jstr_dup(resp, "result.ocr");
- char *sent_at = jstr_dup(resp, "result.last_sent_at");
- char *sent_to = jstr_dup(resp, "result.last_sent_to");
- struct buf t;
- buf_init(&t);
- buf_line(&t, MK_HEAD "Faktura %lld %s\n", (long long)number,
- invoice_status_sv(status));
- buf_line(&t, MK_DIM "%s\n\n", cust ? cust : "");
- buf_line(&t, "Fakturadatum: %s\n", date ? date : "");
- buf_line(&t, "Förfallodatum: %s\n", due ? due : "");
- if (delivery && *delivery)
- buf_line(&t, "Leveransdatum: %s\n", delivery);
- if (your_ref && *your_ref)
- buf_line(&t, "Er referens: %s\n", your_ref);
- if (our_ref && *our_ref)
- buf_line(&t, "Vår referens: %s\n", our_ref);
- if (ocr && *ocr)
- buf_line(&t, "OCR: %s\n", ocr);
- if (sent_at && *sent_at)
- buf_line(&t, "Skickad: %s%s%s\n", sent_at,
- sent_to && *sent_to ? " till " : "",
- sent_to && *sent_to ? sent_to : "");
- if (notes && *notes)
- buf_line(&t, "\nFritext:\n%s\n", notes);
- buf_line(&t, "\n");
- buf_line(&t,
- MK_STICKY MK_HEAD
- "%-34s %8s %-6s %12s %-13s %-18s %12s\n",
- "Beskrivning", "Antal", "Enhet", "À-pris", "Moms", "Anm",
- "Belopp");
- size_t nrows = jarr_size(resp, "result.rows");
- for (size_t i = 0; i < nrows; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.rows.%zu.description", i);
- char *desc = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.quantity_milli", i);
- int64_t qm = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.rows.%zu.unit", i);
- char *unit = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.unit_price_ore", i);
- int64_t price = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.rows.%zu.vat_code", i);
- char *vat_code = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.note", i);
- char *note = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.rows.%zu.amount_ore", i);
- int64_t amount = jint_val(resp, path, 0);
- char dbuf[256], qbuf[24], qcol[24], ubuf[24], pcol[40],
- vbuf[24], nbuf[128], acol[40];
- snprintf(dbuf, sizeof dbuf, "%s", desc ? desc : "");
- tui_pad_field(dbuf, sizeof dbuf, 34);
- qty_sv(qm, qbuf, sizeof qbuf);
- tui_pad_hdr(qcol, sizeof qcol, 8, qbuf);
- snprintf(ubuf, sizeof ubuf, "%s", unit ? unit : "");
- tui_pad_field(ubuf, sizeof ubuf, 6);
- tui_amt_col(pcol, sizeof pcol, 12, price);
- snprintf(vbuf, sizeof vbuf, "%s", vat_label(vat_code));
- tui_pad_field(vbuf, sizeof vbuf, 13);
- snprintf(nbuf, sizeof nbuf, "%s", note ? note : "");
- tui_pad_field(nbuf, sizeof nbuf, 18);
- tui_amt_col(acol, sizeof acol, 12, amount);
- buf_line(&t, "%s %s %s %s %s %s %s\n", dbuf, qcol, ubuf, pcol,
- vbuf, nbuf, acol);
- free(desc);
- free(unit);
- free(vat_code);
- free(note);
- }
- char netc[40], vatc[40], totc[40];
- tui_amt_col(netc, sizeof netc, 14, net);
- tui_amt_col(vatc, sizeof vatc, 14, vat);
- tui_amt_col(totc, sizeof totc, 14, total);
- buf_line(&t, "\n%46s %14s\n", "Netto", netc);
- buf_line(&t, "%46s %14s\n", "Moms", vatc);
- buf_line(&t, MK_HEAD "%46s %14s\n", "Att betala", totc);
- buf_append(&t, "\0", 1);
- struct invctx ctx;
- memset(&ctx, 0, sizeof ctx);
- ctx.a = a;
- ctx.id = id;
- ctx.number = number;
- ctx.customer_id = customer_id;
- snprintf(ctx.customer, sizeof ctx.customer, "%s", cust ? cust : "");
- int ret = tui_pager_hook("Faktura", (const char *)t.p,
- "p = visa PDF s = skicka", 0, inv_key,
- &ctx);
- free(cust);
- free(status);
- free(date);
- free(due);
- free(delivery);
- free(your_ref);
- free(our_ref);
- free(notes);
- free(ocr);
- free(sent_at);
- free(sent_to);
- buf_free(&t);
- free(resp);
- if (ret != 1)
- return;
- }
-}
-
-#define IFORM_ROWS 64
-
-struct irow {
- char description[128];
- char quantity[16];
- char unit[8];
- char price[24];
- char vat[16];
- char note[64];
-};
-
-struct iform {
- struct app *a;
- struct tui_rt rt;
- struct irow rows[IFORM_ROWS];
- char date[16];
- char due[16];
- char delivery[16];
- char your_ref[64];
- char our_ref[64];
- char notes[256];
- char auto_due[16];
- char auto_ref[64];
- size_t ncust;
- int64_t *cust_ids;
- int *cust_days;
- char **cust_names;
- char **cust_emails;
- char **cust_refs;
- int cust_sel;
- int issued;
-};
-
-static void iform_customers_free(struct iform *f)
-{
- for (size_t i = 0; i < f->ncust; i++) {
- free(f->cust_names[i]);
- free(f->cust_emails[i]);
- free(f->cust_refs[i]);
- }
- free(f->cust_names);
- free(f->cust_emails);
- free(f->cust_refs);
- free(f->cust_ids);
- free(f->cust_days);
- f->cust_names = NULL;
- f->cust_emails = NULL;
- f->cust_refs = NULL;
- f->cust_ids = NULL;
- f->cust_days = NULL;
- f->ncust = 0;
-}
-
-static int iform_load_customers(struct iform *f)
-{
- char *resp = client_rpc(&f->a->conn, "customer.list", f->a->session,
- f->a->org, "{\"active_only\":true}");
- if (!resp || !client_ok(resp)) {
- show_error("Kunder", resp);
- free(resp);
- return -1;
- }
- size_t n = jarr_size(resp, "result.items");
- f->cust_ids = xcalloc(n ? n : 1, sizeof(int64_t));
- f->cust_days = xcalloc(n ? n : 1, sizeof(int));
- f->cust_names = xcalloc(n ? n : 1, sizeof(char *));
- f->cust_emails = xcalloc(n ? n : 1, sizeof(char *));
- f->cust_refs = xcalloc(n ? n : 1, sizeof(char *));
- for (size_t i = 0; i < n; i++) {
- char path[64];
- snprintf(path, sizeof path, "result.items.%zu.id", i);
- f->cust_ids[i] = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.payment_days", i);
- f->cust_days[i] = (int)jint_val(resp, path, 30);
- snprintf(path, sizeof path, "result.items.%zu.name", i);
- char *nm = jstr_dup(resp, path);
- f->cust_names[i] = nm ? nm : xstrdup("");
- snprintf(path, sizeof path, "result.items.%zu.email", i);
- char *em = jstr_dup(resp, path);
- f->cust_emails[i] = em ? em : xstrdup("");
- snprintf(path, sizeof path, "result.items.%zu.your_ref", i);
- char *rf = jstr_dup(resp, path);
- f->cust_refs[i] = rf ? rf : xstrdup("");
- }
- f->ncust = n;
- free(resp);
- return 0;
-}
-
-/* Re-apply the customer-derived defaults only while the user has not
- changed them (they still equal what the previous customer produced). */
-static void iform_customer_changed(struct iform *f)
-{
- if (f->ncust == 0 || f->cust_sel < 0 || (size_t)f->cust_sel >= f->ncust)
- return;
- char today[16], newdue[16];
- today_iso(today, sizeof today);
- util_date_add_days(today, f->cust_days[f->cust_sel], newdue,
- sizeof newdue);
- if (!f->due[0] || strcmp(f->due, f->auto_due) == 0)
- snprintf(f->due, sizeof f->due, "%s", newdue);
- snprintf(f->auto_due, sizeof f->auto_due, "%s", newdue);
- const char *ref = f->cust_refs[f->cust_sel];
- if (!f->your_ref[0] || strcmp(f->your_ref, f->auto_ref) == 0)
- snprintf(f->your_ref, sizeof f->your_ref, "%s", ref ? ref : "");
- snprintf(f->auto_ref, sizeof f->auto_ref, "%s", ref ? ref : "");
-}
-
-static void iform_cell(void *ud, int row, int col, char **buf, size_t *cap)
-{
- struct iform *f = ud;
- struct irow *r = &f->rows[row];
- if (col == 0) {
- *buf = r->description;
- *cap = sizeof r->description;
- } else if (col == 1) {
- *buf = r->quantity;
- *cap = sizeof r->quantity;
- } else if (col == 2) {
- *buf = r->unit;
- *cap = sizeof r->unit;
- } else if (col == 3) {
- *buf = r->price;
- *cap = sizeof r->price;
- } else if (col == 4) {
- *buf = r->vat;
- *cap = sizeof r->vat;
- } else {
- *buf = r->note;
- *cap = sizeof r->note;
- }
-}
-
-static const char *const UNIT_CHOICES[] = { "st", "tim", "dag", "km" };
-static const char *const VAT_LABELS[] = { "25%", "12%", "6%",
- "0%", "Omvänd moms", "EU" };
-static const char *const VAT_CODES[] = { "25", "12", "6", "0", "rc", "eu" };
-
-static int vat_index(const char *s)
-{
- const char *code = vat_code_of(s);
- if (!code)
- return 0;
- for (int i = 0; i < 6; i++)
- if (strcmp(code, VAT_CODES[i]) == 0)
- return i;
- return 0;
-}
-
-static int iform_key(void *ud, int ch)
-{
- struct iform *f = ud;
- struct tui_rt *rt = &f->rt;
- if (ch != '\n' && ch != '\r' && ch != KEY_ENTER)
- return TUI_HOOK_NONE;
- if (rt->focus == 0) {
- if (f->ncust == 0) {
- tui_message("Ny faktura", "Inga kunder. Skapa en under Register "
- "→ Kunder först.");
- return TUI_HOOK_STAY;
- }
- int c = tui_choice_prompt("Kund: ",
- (const char *const *)f->cust_names,
- (int)f->ncust, f->cust_sel);
- if (c >= 0) {
- int changed = c != f->cust_sel;
- f->cust_sel = c;
- if (changed)
- iform_customer_changed(f);
- tui_rt_normalize(rt);
- rt->focus = tui_rt_focus_step(0, rt->nfields, &rt->row, &rt->col,
- rt->nrows, rt->neditable, 1);
- }
- return TUI_HOOK_STAY;
- }
- if (rt->focus < 0 && rt->row >= 0 && rt->row < IFORM_ROWS) {
- struct irow *r = &f->rows[rt->row];
- if (rt->col == 2) {
- int c = tui_choice_prompt("Enhet: ", UNIT_CHOICES, 4,
- unit_index(r->unit));
- if (c >= 0)
- snprintf(r->unit, sizeof r->unit, "%s", UNIT_CHOICES[c]);
- return TUI_HOOK_STAY;
- }
- if (rt->col == 4) {
- int c = tui_choice_prompt("Moms: ", VAT_LABELS, 6,
- vat_index(r->vat));
- if (c >= 0)
- snprintf(r->vat, sizeof r->vat, "%s", VAT_LABELS[c]);
- return TUI_HOOK_STAY;
- }
- }
- return TUI_HOOK_NONE;
-}
-
-static char *iform_draft_args(struct iform *f, char *err, size_t errn)
-{
- if (f->ncust == 0 || f->cust_sel < 0 || (size_t)f->cust_sel >= f->ncust) {
- snprintf(err, errn, "Välj en kund.");
- return NULL;
- }
- if (!util_parse_iso_date(f->date)) {
- snprintf(err, errn, "Ogiltigt fakturadatum (ÅÅÅÅ-MM-DD).");
- return NULL;
- }
- if (!util_parse_iso_date(f->due)) {
- snprintf(err, errn, "Ogiltigt förfallodatum (ÅÅÅÅ-MM-DD).");
- return NULL;
- }
- if (f->delivery[0] && !util_parse_iso_date(f->delivery)) {
- snprintf(err, errn, "Ogiltigt leveransdatum (ÅÅÅÅ-MM-DD).");
- return NULL;
- }
- 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, "customer_id", f->cust_ids[f->cust_sel]);
- yyjson_mut_obj_add_strcpy(d, o, "invoice_date", f->date);
- yyjson_mut_obj_add_strcpy(d, o, "due_date", f->due);
- yyjson_mut_obj_add_strcpy(d, o, "delivery_date", f->delivery);
- yyjson_mut_obj_add_strcpy(d, o, "your_ref", f->your_ref);
- yyjson_mut_obj_add_strcpy(d, o, "our_ref", f->our_ref);
- yyjson_mut_obj_add_strcpy(d, o, "notes", f->notes);
- yyjson_mut_val *arr = yyjson_mut_arr(d);
- int used = 0;
- for (int i = 0; i < f->rt.nrows; i++) {
- struct irow *r = &f->rows[i];
- if (!r->description[0] && !r->quantity[0] && !r->unit[0] &&
- !r->price[0] && !r->vat[0] && !r->note[0])
- continue;
- if (!r->description[0]) {
- snprintf(err, errn, "Rad %d: beskrivning saknas.", i + 1);
- yyjson_mut_doc_free(d);
- return NULL;
- }
- const char *qty = r->quantity[0] ? r->quantity : "1";
- if (!qty_is_valid(qty)) {
- snprintf(err, errn,
- "Rad %d: antal måste vara ett positivt tal med högst "
- "3 decimaler.",
- i + 1);
- yyjson_mut_doc_free(d);
- return NULL;
- }
- int64_t price = 0;
- if (tui_parse_kr(r->price, &price) != 0) {
- snprintf(err, errn, "Rad %d: ogiltigt à-pris.", i + 1);
- yyjson_mut_doc_free(d);
- return NULL;
- }
- const char *unit = unit_code_of(r->unit);
- if (!unit) {
- snprintf(err, errn, "Rad %d: ogiltig enhet (st, tim, dag, km).",
- i + 1);
- yyjson_mut_doc_free(d);
- return NULL;
- }
- const char *vat = vat_code_of(r->vat);
- if (!vat) {
- snprintf(err, errn, "Rad %d: ogiltig momskod.", i + 1);
- yyjson_mut_doc_free(d);
- return NULL;
- }
- yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
- yyjson_mut_obj_add_strcpy(d, ro, "description", r->description);
- yyjson_mut_obj_add_strcpy(d, ro, "quantity", qty);
- yyjson_mut_obj_add_strcpy(d, ro, "unit", unit);
- yyjson_mut_obj_add_int(d, ro, "unit_price_ore", price);
- yyjson_mut_obj_add_strcpy(d, ro, "note", r->note);
- yyjson_mut_obj_add_strcpy(d, ro, "vat_code", vat);
- used++;
- }
- if (used == 0) {
- snprintf(err, errn, "Minst en rad krävs.");
- yyjson_mut_doc_free(d);
- return NULL;
- }
- yyjson_mut_obj_add_val(d, o, "rows", arr);
- char *out = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- return out;
-}
-
-static void iform_preview(struct iform *f)
-{
- char err[256];
- char *args = iform_draft_args(f, err, sizeof err);
- if (!args) {
- tui_message("Faktura", "%s", err);
- return;
- }
- char *resp = client_rpc(&f->a->conn, "invoice.preview", f->a->session,
- f->a->org, args);
- free(args);
- if (!resp || !client_ok(resp)) {
- show_error("Faktura", resp);
- free(resp);
- return;
- }
- char *b64 = jstr_dup(resp, "result.content_base64");
- if (b64) {
- save_and_open_pdf(b64, "Faktura förhandsvisning.pdf");
- free(b64);
- }
- free(resp);
-}
-
-static int64_t iform_issue(struct iform *f)
-{
- if (f->issued)
- return 0;
- char err[256];
- char *args = iform_draft_args(f, err, sizeof err);
- if (!args) {
- tui_message("Faktura", "%s", err);
- return 0;
- }
- char *resp = client_rpc(&f->a->conn, "invoice.issue", f->a->session,
- f->a->org, args);
- free(args);
- if (!resp || !client_ok(resp)) {
- show_error("Faktura", resp);
- free(resp);
- return 0;
- }
- int64_t id = jint_val(resp, "result.id", 0);
- int64_t number = jint_val(resp, "result.number", 0);
- free(resp);
- if (id <= 0)
- return 0;
- f->issued = 1;
- const char *email = f->cust_emails[f->cust_sel];
- if (email && *email) {
- if (tui_confirm("Skicka faktura", "Skicka faktura %lld till %s?",
- (long long)number, email)) {
- char args2[64];
- snprintf(args2, sizeof args2, "{\"id\":%lld}", (long long)id);
- char *r = client_rpc(&f->a->conn, "invoice.send", f->a->session,
- f->a->org, args2);
- if (r && client_ok(r)) {
- char *to = jstr_dup(r, "result.sent_to");
- tui_message("Skickat", "Faktura %lld skickad till %s.",
- (long long)number, to && *to ? to : email);
- free(to);
- } else {
- show_error("Skicka faktura", r);
- }
- free(r);
- }
- } else {
- tui_message("Faktura", "Faktura %lld skapad.", (long long)number);
- }
- return id;
-}
-
-static int64_t invoices_new(struct app *a)
-{
- struct iform f;
- memset(&f, 0, sizeof f);
- f.a = a;
- today_iso(f.date, sizeof f.date);
- snprintf(f.delivery, sizeof f.delivery, "%s", f.date);
- snprintf(f.due, sizeof f.due, "%s", f.date);
- snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due);
- if (iform_load_customers(&f) != 0) {
- iform_customers_free(&f);
- return 0;
- }
- char *resp =
- client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
- if (resp && client_ok(resp)) {
- char *v = jstr_dup(resp, "result.invoice_our_ref");
- if (v) {
- snprintf(f.our_ref, sizeof f.our_ref, "%s", v);
- free(v);
- }
- }
- free(resp);
- if (f.ncust > 0) {
- f.cust_sel = 0;
- iform_customer_changed(&f);
- }
- struct tui_rt_col cols[6] = {
- { "Beskrivning", 2, 34, TUI_RT_EDIT },
- { "Antal", 37, 8, TUI_RT_EDIT },
- { "Enhet", 46, 6, TUI_RT_EDIT },
- { "À-pris", 53, 14, TUI_RT_EDIT },
- { "Moms", 68, 14, TUI_RT_EDIT },
- { "Anm", 83, 32, TUI_RT_EDIT },
- };
- tui_rt_init(&f.rt, 1, IFORM_ROWS, 6, cols, 0, iform_cell, NULL, &f);
- tui_rt_set_key(&f.rt, iform_key);
- struct tui_form_field ff[7];
- memset(ff, 0, sizeof ff);
- ff[0].label = "Kund";
- ff[0].kind = TUI_F_CHOICE;
- ff[0].choices = (const char *const *)f.cust_names;
- ff[0].nchoices = (int)f.ncust;
- ff[0].choice = &f.cust_sel;
- ff[1].label = "Fakturadatum";
- ff[1].value = f.date;
- ff[1].cap = sizeof f.date;
- ff[1].kind = TUI_F_DATE;
- ff[2].label = "Förfallodatum";
- ff[2].value = f.due;
- ff[2].cap = sizeof f.due;
- ff[2].kind = TUI_F_DATE;
- ff[3].label = "Leveransdatum";
- ff[3].value = f.delivery;
- ff[3].cap = sizeof f.delivery;
- ff[3].kind = TUI_F_DATE;
- ff[4].label = "Er referens";
- ff[4].value = f.your_ref;
- ff[4].cap = sizeof f.your_ref;
- ff[4].kind = TUI_F_TEXT;
- ff[5].label = "Vår referens";
- ff[5].value = f.our_ref;
- ff[5].cap = sizeof f.our_ref;
- ff[5].kind = TUI_F_TEXT;
- ff[6].label = "Fritext";
- ff[6].value = f.notes;
- ff[6].cap = sizeof f.notes;
- ff[6].kind = TUI_F_TEXT;
- tui_rt_set_fields(&f.rt, ff, 7);
- const char *hint =
- "Enter = välj/ändra Tab = byta fält F5 = förhandsvisa"
- " ^Enter = utfärda Esc = avbryt";
- int64_t out = 0;
- for (;;) {
- int rr = tui_rt_run("Ny faktura", &f.rt, hint);
- if (rr == -1)
- break;
- if (rr == -2) {
- iform_preview(&f);
- continue;
- }
- if (rr == -3) {
- out = iform_issue(&f);
- if (out > 0)
- break;
- }
- }
- iform_customers_free(&f);
- return out;
-}
-
-static int invoices_key(void *ud, int ch)
-{
- struct app *a = ud;
- if (ch != 'n')
- return TUI_HOOK_NONE;
- char *resp = client_rpc(&a->conn, "invoice.sequence_get", a->session,
- a->org, "{}");
- char def[16] = "1";
- if (resp && client_ok(resp))
- snprintf(def, sizeof def, "%lld",
- (long long)jint_val(resp, "result.next_number", 1));
- free(resp);
- char buf[16] = "";
- if (!tui_prompt_into(buf, sizeof buf, "Nästa fakturanummer", def, 0))
- return TUI_HOOK_STAY;
- char args[64];
- snprintf(args, sizeof args, "{\"next_number\":%s}", buf);
- resp = client_rpc(&a->conn, "invoice.sequence_set", a->session, a->org,
- args);
- if (resp && client_ok(resp))
- tui_message("Fakturor", "Nästa fakturanummer: %s.", buf);
- else
- show_error("Fakturor", resp);
- free(resp);
- return TUI_HOOK_REFRESH;
-}
-
-static void invoices_screen(struct app *a)
-{
- char **items = NULL;
- int64_t *ids = NULL;
- size_t n = 0;
- int fetch = 1;
- for (;;) {
- if (g_quit) {
- for (size_t i = 0; i < n; i++)
- free(items[i]);
- free(items);
- free(ids);
- return;
- }
- if (fetch) {
- for (size_t i = 0; i < n; i++)
- free(items[i]);
- free(items);
- free(ids);
- items = NULL;
- ids = NULL;
- n = 0;
- fetch = 0;
- char *resp = client_rpc(&a->conn, "invoice.list", a->session,
- a->org, "{\"limit\":200}");
- if (!resp || !client_ok(resp)) {
- show_error("Fakturor", resp);
- free(resp);
- return;
- }
- n = jarr_size(resp, "result.items");
- items = xcalloc(n + 1, sizeof(char *));
- ids = xcalloc(n ? n : 1, sizeof(int64_t));
- for (size_t i = 0; i < n; i++) {
- char path[64], line[768];
- snprintf(path, sizeof path, "result.items.%zu.id", i);
- ids[i] = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.number", i);
- int64_t number = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.invoice_date", i);
- char *date = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.customer_name",
- i);
- char *cust = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.total_ore", i);
- int64_t total = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.status", i);
- char *status = jstr_dup(resp, path);
- char nbuf[32], cbuf[256], abuf[40];
- snprintf(nbuf, sizeof nbuf, "Faktura %lld", (long long)number);
- snprintf(cbuf, sizeof cbuf, "%s", cust ? cust : "");
- tui_pad_field(cbuf, sizeof cbuf, 28);
- tui_amt_col(abuf, sizeof abuf, 14, total);
- snprintf(line, sizeof line, "%s %s %s %s %s", nbuf,
- date ? date : "", cbuf, abuf,
- invoice_status_sv(status));
- items[i] = xstrdup(line);
- free(date);
- free(cust);
- free(status);
- }
- free(resp);
- items[n] = xstrdup("+ Ny faktura (^N)");
- }
- int start = 0;
- if (a->invoice_sel) {
- for (size_t i = 0; i < n; i++)
- if (ids[i] == a->invoice_sel) {
- start = (int)i;
- break;
- }
- }
- int cur = start;
- int sel = tui_select_list_hook("Fakturor", items, (int)n + 1, start, 1,
- &cur, 1, NULL, 0, invoices_key, a);
- if (cur >= 0 && (size_t)cur < n)
- a->invoice_sel = ids[cur];
- if (sel == -1) {
- for (size_t i = 0; i < n; i++)
- free(items[i]);
- free(items);
- free(ids);
- return;
- }
- if (sel == -2) {
- fetch = 1;
- continue;
- }
- if (sel == -4 || (sel >= 0 && (size_t)sel == n)) {
- int64_t nid = invoices_new(a);
- if (nid > 0)
- a->invoice_sel = nid;
- fetch = 1;
- continue;
- }
- if (sel >= 0 && (size_t)sel < n) {
- invoices_detail(a, ids[sel]);
- continue;
- }
- }
-}
-
-static void templates_screen(struct app *a)
-{
- int cursor = 0;
- for (;;) {
- if (g_quit)
- return;
- char *resp = client_rpc(&a->conn, "template.list", a->session, a->org,
- "{\"active_only\":true}");
- if (!resp || !client_ok(resp)) {
- show_error("Mallar", resp);
- free(resp);
- return;
- }
- size_t n = jarr_size(resp, "result.items");
- char **names = xcalloc(n + 1, sizeof(char *));
- char **lines = xcalloc(n + 1, sizeof(char *));
- for (size_t i = 0; i < n; i++) {
- char path[64], line[512];
- snprintf(path, sizeof path, "result.items.%zu.name", i);
- char *nm = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.series", i);
- char *ser = jstr_dup(resp, path);
- snprintf(path, sizeof path, "result.items.%zu.row_count", i);
- int64_t rc = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.description", i);
- char *ds = jstr_dup(resp, path);
- char nmbuf[128];
- snprintf(nmbuf, sizeof nmbuf, "%s", nm ? nm : "");
- tui_pad_field(nmbuf, sizeof nmbuf, 24);
- snprintf(line, sizeof line, "%s %-3s %2lld rader %s", nmbuf,
- ser ? ser : "", (long long)rc, ds ? ds : "");
- names[i] = xstrdup(nm ? nm : "");
- lines[i] = xstrdup(line);
- free(nm);
- free(ser);
- free(ds);
- }
- names[n] = xstrdup("");
- lines[n] = xstrdup("+ Ny mall (^N)");
- int s = tui_select_list("Mallar", lines, (int)n + 1, cursor, 1, &cursor, 1,
- n > 0 ? "arkivera" : NULL, 0);
- if (s == -1) {
- for (size_t i = 0; i <= n; i++) {
- free(names[i]);
- free(lines[i]);
- }
- free(names);
- free(lines);
- free(resp);
- return;
- }
- if (s == -6) {
- if (cursor >= 0 && (size_t)cursor < n)
- template_archive(a, names[cursor]);
- } else if (s == -4 || (s >= 0 && (size_t)s == n)) {
- template_form(a, NULL);
- } else if (s >= 0) {
- template_form(a, names[s]);
- }
- for (size_t i = 0; i <= n; i++) {
- free(names[i]);
- free(lines[i]);
- }
- free(names);
- free(lines);
- free(resp);
- if (s == -5)
- return;
- }
-}
-
-/* ------------------------------------------------------------------ */
-/* momsregler (the vat report's report_rules) */
-/* ------------------------------------------------------------------ */
-
-static const char *const RULE_TYPES[] = { "account", "range", "type" };
-#define RULE_NTYPES ((int)(sizeof RULE_TYPES / sizeof RULE_TYPES[0]))
-static const char *const RULE_SIGNS[] = { "+", "-" };
-#define RULE_NSIGNS ((int)(sizeof RULE_SIGNS / sizeof RULE_SIGNS[0]))
-
-struct rule {
- int64_t id;
- char box[16];
- char match_type[16];
- char pattern[128];
- int sign;
- int64_t sort_order;
+/* Screen names for ^R restoration and --screen. Only the top-level
+ views; sub-screens unwind to their parent. */
+struct scene {
+ const char *name;
+ void (*fn)(struct app *);
};
-/* F5 dry-runs; Ctrl+Enter creates/updates. Returns 1 when saved. */
-static int rule_save(struct app *a, int64_t id, const char *box, int mti,
- const char *pattern, int signi, const char *sort, int dry)
-{
- char *end = NULL;
- long long sort_order = strtoll(sort, &end, 10);
- if (!sort[0] || !end || *end) {
- tui_message("Momsregel", "Sortering måste vara ett heltal.");
- return 0;
- }
- yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
- yyjson_mut_val *o = yyjson_mut_obj(d);
- yyjson_mut_doc_set_root(d, o);
- if (id)
- yyjson_mut_obj_add_int(d, o, "id", id);
- yyjson_mut_obj_add_strcpy(d, o, "report", "vat");
- yyjson_mut_obj_add_strcpy(d, o, "box", box);
- yyjson_mut_obj_add_strcpy(d, o, "match_type", RULE_TYPES[mti]);
- yyjson_mut_obj_add_strcpy(d, o, "pattern", pattern);
- yyjson_mut_obj_add_int(d, o, "sign", signi == 1 ? -1 : 1);
- yyjson_mut_obj_add_int(d, o, "sort_order", (int64_t)sort_order);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- const char *cmd = id ? "report.rule_update" : "report.rule_create";
- char *resp = dry ? rpc_dry(a, cmd, args)
- : client_rpc(&a->conn, cmd, a->session, a->org, args);
- free(args);
- if (resp && client_ok(resp)) {
- if (dry) {
- tui_message("Validering OK", "Regeln är korrekt.");
- } else {
- tui_message("Momsregler", "Regeln sparad.");
- free(resp);
- return 1;
- }
- } else {
- show_error("Momsregler", resp);
- }
- free(resp);
- return 0;
-}
-
-static int rule_form(struct app *a, const struct rule *r)
-{
- static int sel = 0;
- char box[16] = "", pattern[128] = "", sort[16] = "10";
- int mti = 0, signi = 0;
- if (r) {
- snprintf(box, sizeof box, "%s", r->box);
- for (int i = 0; i < RULE_NTYPES; i++)
- if (strcmp(RULE_TYPES[i], r->match_type) == 0)
- mti = i;
- snprintf(pattern, sizeof pattern, "%s", r->pattern);
- signi = r->sign < 0 ? 1 : 0;
- snprintf(sort, sizeof sort, "%lld", (long long)r->sort_order);
- }
- struct tui_form_field ff[5];
- memset(ff, 0, sizeof ff);
- ff[0].label = "Ruta";
- ff[0].value = box;
- ff[0].cap = sizeof box;
- ff[0].kind = TUI_F_TEXT;
- ff[1].label = "Typ";
- ff[1].kind = TUI_F_CHOICE;
- ff[1].choices = RULE_TYPES;
- ff[1].nchoices = RULE_NTYPES;
- ff[1].choice = &mti;
- ff[2].label = "Mönster";
- ff[2].value = pattern;
- ff[2].cap = sizeof pattern;
- ff[2].kind = TUI_F_TEXT;
- ff[3].label = "Tecken";
- ff[3].kind = TUI_F_CHOICE;
- ff[3].choices = RULE_SIGNS;
- ff[3].nchoices = RULE_NSIGNS;
- ff[3].choice = &signi;
- ff[4].label = "Sortering";
- ff[4].value = sort;
- ff[4].cap = sizeof sort;
- ff[4].kind = TUI_F_TEXT;
- const char *title = r ? "Redigera momsregel" : "Ny momsregel";
- for (;;) {
- int rr = tui_form_run(title, ff, 5, 1, &sel);
- if (rr == TUI_FORM_BACK)
- return 0;
- if (rr == TUI_FORM_REFRESH) {
- rule_save(a, r ? r->id : 0, box, mti, pattern, signi, sort, 1);
- continue;
- }
- if (rr == TUI_FORM_SUBMIT) {
- if (rule_save(a, r ? r->id : 0, box, mti, pattern, signi, sort, 0))
- return 1;
- continue;
- }
- }
-}
-
-static void rule_delete(struct app *a, const struct rule *r)
-{
- if (!tui_confirm("Momsregler", "Ta bort regeln %s %s %s?", r->box,
- r->match_type, r->pattern))
- return;
- char args[64];
- snprintf(args, sizeof args, "{\"id\":%lld}", (long long)r->id);
- char *resp =
- client_rpc(&a->conn, "report.rule_delete", a->session, a->org, args);
- if (resp && client_ok(resp))
- tui_message("Momsregler", "Regeln borttagen.");
- else
- show_error("Momsregler", resp);
- free(resp);
-}
-
-static void rules_screen(struct app *a)
-{
- int cursor = 0;
- for (;;) {
- if (g_quit)
- return;
- char *resp = client_rpc(&a->conn, "report.rule_list", a->session,
- a->org, "{\"report\":\"vat\"}");
- if (!resp || !client_ok(resp)) {
- show_error("Momsregler", resp);
- free(resp);
- return;
- }
- size_t n = jarr_size(resp, "result.items");
- struct rule *rules = xcalloc(n ? n : 1, sizeof *rules);
- char **items = xcalloc(n + 1, sizeof(char *));
- for (size_t i = 0; i < n; i++) {
- char path[64], line[512], boxbuf[24], patbuf[96];
- snprintf(path, sizeof path, "result.items.%zu.id", i);
- rules[i].id = jint_val(resp, path, 0);
- snprintf(path, sizeof path, "result.items.%zu.box", i);
- char *sbox = jstr_dup(resp, path);
- snprintf(rules[i].box, sizeof rules[i].box, "%s",
- sbox ? sbox : "");
- free(sbox);
- snprintf(path, sizeof path, "result.items.%zu.match_type", i);
- char *mt = jstr_dup(resp, path);
- snprintf(rules[i].match_type, sizeof rules[i].match_type, "%s",
- mt ? mt : "");
- free(mt);
- snprintf(path, sizeof path, "result.items.%zu.pattern", i);
- char *pat = jstr_dup(resp, path);
- snprintf(rules[i].pattern, sizeof rules[i].pattern, "%s",
- pat ? pat : "");
- free(pat);
- snprintf(path, sizeof path, "result.items.%zu.sign", i);
- rules[i].sign = (int)jint_val(resp, path, 1);
- snprintf(path, sizeof path, "result.items.%zu.sort_order", i);
- rules[i].sort_order = jint_val(resp, path, 0);
-
- snprintf(boxbuf, sizeof boxbuf, "%s", rules[i].box);
- tui_pad_field(boxbuf, sizeof boxbuf, 6);
- snprintf(patbuf, sizeof patbuf, "%s", rules[i].pattern);
- tui_pad_field(patbuf, sizeof patbuf, 34);
- snprintf(line, sizeof line, "%s %-8s %s %-2s %6lld", boxbuf,
- rules[i].match_type, patbuf,
- rules[i].sign < 0 ? "-" : "+",
- (long long)rules[i].sort_order);
- items[i] = xstrdup(line);
- }
- items[n] = xstrdup("+ Ny regel (^N)");
- int s = tui_select_list("Momsregler", items, (int)n + 1, cursor, 1,
- &cursor, 1, n > 0 ? "ta bort" : NULL, 0);
- for (size_t i = 0; i <= n; i++)
- free(items[i]);
- free(items);
- free(resp);
- if (s == -1) {
- free(rules);
- return;
- }
- if (s == -6) {
- if (cursor >= 0 && (size_t)cursor < n)
- rule_delete(a, &rules[cursor]);
- } else if (s == -4 || (s >= 0 && (size_t)s == n)) {
- rule_form(a, NULL);
- } else if (s >= 0 && (size_t)s < n) {
- rule_form(a, &rules[s]);
- }
- free(rules);
- }
-}
-
-/* ------------------------------------------------------------------ */
-/* bokslut */
-/* ------------------------------------------------------------------ */
-
-/* client_rpc() cannot carry the protocol's top-level dry_run, so build
- the request by hand. */
-static char *rpc_dry(struct app *a, const char *cmd, const char *args)
-{
- size_t n = strlen(args) + strlen(cmd) + strlen(a->session) + 160;
- char *raw = xmalloc(n);
- snprintf(raw, n,
- "{\"v\":1,\"id\":\"tui\",\"cmd\":\"%s\",\"session\":\"%s\","
- "\"org\":%lld,\"dry_run\":true,\"args\":%s}",
- cmd, a->session, (long long)a->org, args);
- char *r = NULL;
- if (client_send_line(&a->conn, raw) == 0)
- r = client_read_line(&a->conn);
- free(raw);
- return r;
-}
-
-static void fmt_bokslut_plan(struct buf *t, yyjson_val *res)
-{
- yyjson_val *fy = yyjson_obj_get(res, "fiscal_year");
- if (fy)
- buf_line(t, MK_HEAD "Bokslutsplan %s %s - %s\n", vstr(fy, "label"),
- vstr(fy, "start_date"), vstr(fy, "end_date"));
- yyjson_val *vs = yyjson_obj_get(res, "vouchers");
- size_t n = yyjson_arr_size(vs);
- if (n == 0) {
- buf_line(t, "\nInget att bokföra.\n");
- return;
- }
- char pser[16] = "";
- long long pnum = 0;
- for (size_t i = 0; i < n; i++) {
- yyjson_val *v = yyjson_arr_get(vs, i);
- yyjson_val *pv = yyjson_obj_get(v, "voucher");
- const char *ser = vstr(pv, "series");
- long long num = (long long)vint(pv, "number");
- if (strcmp(ser, pser) != 0) {
- snprintf(pser, sizeof pser, "%s", ser);
- pnum = num;
- } else if (num <= pnum) {
- num = pnum + 1;
- }
- pnum = num;
- buf_line(t, "\n" MK_HEAD "%s%lld %s\n", pser, num,
- vstr(v, "description"));
- yyjson_val *rows = yyjson_obj_get(v, "rows");
- size_t nr = yyjson_arr_size(rows);
- for (size_t k = 0; k < nr; k++) {
- yyjson_val *r = yyjson_arr_get(rows, k);
- char a1[48], a2[48];
- tui_amt_col(a1, sizeof a1, 15, vint(r, "debit_ore"));
- tui_amt_col(a2, sizeof a2, 15, vint(r, "credit_ore"));
- buf_line(t, " %-5s %s %s\n", vstr(r, "account"), a1, a2);
- }
- }
-}
-
-static void bokslut_plan(struct app *a, const char *fond, const char *rate,
- int do_post)
-{
- int64_t fond_ore = 0, rate_ore = 0;
- if (tui_parse_kr(fond, &fond_ore) != 0) {
- tui_message("Bokslut", "Ogiltigt fondbelopp: %s", fond);
- return;
- }
- if (tui_parse_kr(rate, &rate_ore) != 0 || rate_ore < 0 || rate_ore > 10000) {
- tui_message("Bokslut", "Ogiltig skattesats: %s", rate);
- return;
- }
- char args[160];
- snprintf(args, sizeof args,
- "{\"fiscal_year\":%lld,\"periodiseringsfond_ore\":%lld,"
- "\"tax_rate\":%.2f}",
- (long long)a->fy, (long long)fond_ore,
- (double)rate_ore / 100.0);
- char *resp = rpc_dry(a, "bokslut.post", args);
- if (!resp || !client_ok(resp)) {
- show_error("Bokslut", resp);
- free(resp);
- return;
- }
- yyjson_doc *d = parse(resp);
- yyjson_val *res = d ? jget(yyjson_doc_get_root(d), "result") : NULL;
- struct buf t;
- buf_init(&t);
- if (res)
- fmt_bokslut_plan(&t, res);
- else
- buf_line(&t, "%s", resp);
- buf_append(&t, "", 1);
- tui_pager("Bokslut - plan (torrkörning)", (const char *)t.p, NULL);
- buf_free(&t);
- yyjson_doc_free(d);
- free(resp);
- if (!do_post)
- return;
- char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, "Bokför planen? (j/n): ", "n", 0) ? ansbuf : NULL;
- if (!ans || !(ans[0] == 'j' || ans[0] == 'J'))
- return;
- char *presp = client_rpc(&a->conn, "bokslut.post", a->session, a->org,
- args);
- if (!presp || !client_ok(presp)) {
- show_error("Bokslut", presp);
- free(presp);
- return;
- }
- yyjson_doc *pd = parse(presp);
- yyjson_val *pr = pd ? jget(yyjson_doc_get_root(pd), "result") : NULL;
- struct buf m;
- buf_init(&m);
- buf_line(&m, "Bokslutet är bokfört:");
- yyjson_val *vs = pr ? yyjson_obj_get(pr, "vouchers") : NULL;
- size_t n = yyjson_arr_size(vs);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *v = yyjson_arr_get(vs, i);
- yyjson_val *pv = yyjson_obj_get(v, "voucher");
- buf_line(&m, "\n %s%lld %s", vstr(pv, "series"),
- (long long)vint(pv, "number"), vstr(v, "description"));
- }
- tui_message("Bokslut", "%s", (const char *)m.p);
- buf_free(&m);
- yyjson_doc_free(pd);
- free(presp);
-}
-
-/* Bokslut: the year-end hub. The year's årsredovisning details (fields 0-5,
- saved one by one with fiscal_year.update) and the local periodiseringsfond
- and tax rate inputs plus a status row and the year-end actions, all in one
- view. New years inherit the stable fields. */
-static void bokslut_screen(struct app *a)
-{
- static int sel = 0;
- 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;
- char fond[64] = "0,00", rate[16] = "20,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("Bokslut", resp);
- free(resp);
- return;
- }
- char *fystat = jstr_dup(resp, "result.status");
- int closed = fystat && strcmp(fystat, "closed") == 0;
- free(fystat);
- char *vals[6];
- struct tui_form_field ff[8];
- int64_t div_ore = jint_val(resp, "result.dividend_ore", 0);
- memset(ff, 0, sizeof ff);
- for (int i = 0; i < nf; i++) {
- vals[i] = xcalloc(512, 1);
- if (i != 2) {
- char path[64], *v;
- snprintf(path, sizeof path, "result.%s", keys[i]);
- v = jstr_dup(resp, path);
- snprintf(vals[i], 512, "%s", v ? v : "");
- free(v);
- }
- ff[i].label = labels[i];
- ff[i].value = vals[i];
- ff[i].cap = 512;
- }
- free(resp);
-
- /* A closed year counts as posted; an open one only when the tax
- voucher that bokslut.post creates is already in the books. */
- int posted = closed;
- if (!closed) {
- char vargs[160];
- snprintf(vargs, sizeof vargs,
- "{\"fiscal_year\":%lld,"
- "\"text\":\"Skatt på årets resultat\",\"limit\":1}",
- (long long)a->fy);
- char *vresp = client_rpc(&a->conn, "voucher.list", a->session,
- a->org, vargs);
- if (vresp && client_ok(vresp))
- posted = jarr_size(vresp, "result.items") > 0;
- free(vresp);
- }
-
- char status_line[96];
- snprintf(status_line, sizeof status_line, "Bokslut bokfört: %s",
- posted ? "ja" : "nej");
- const char *reason = NULL;
- if (closed)
- reason = "året är stängt";
- else if (posted)
- reason = "bokslutet är redan bokfört";
- else if (!can_edit)
- reason = "du har inte behörighet";
- struct tui_form_action acts[5] = {
- { status_line, -1, NULL },
- { "Årsredovisning (K2)", 1, NULL },
- { "Inkomstdeklaration (INK2/SRU)", 1, NULL },
- { "Bokslutsplan (torrkörning)", 1, NULL },
- { "Bokför bokslut", reason ? 0 : 1, reason },
- };
-
- ff[0].kind = TUI_F_TEXT;
- ff[1].kind = TUI_F_DATE;
- ff[2].kind = TUI_F_AMOUNT;
- ff[2].ore = &div_ore;
- ff[3].kind = TUI_F_DATE;
- ff[4].kind = TUI_F_TEXT;
- ff[5].kind = TUI_F_TEXT;
- ff[6].label = "Periodiseringsfond (kr)";
- ff[6].value = fond;
- ff[6].cap = sizeof fond;
- ff[6].kind = TUI_F_TEXT;
- ff[7].label = "Skattesats (%)";
- ff[7].value = rate;
- ff[7].cap = sizeof rate;
- ff[7].kind = TUI_F_TEXT;
- tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför F5 = visa"
- " bokslutsplan ^Enter = bokför planen (frågar"
- " först) Esc/q = tillbaka ^C = avsluta");
- int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5,
- NULL, &sel);
- if (r >= 0 && r < nf) {
- 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 (r == 2)
- yyjson_mut_obj_add_int(d, o, "dividend_ore", div_ore);
- else
- yyjson_mut_obj_add_strcpy(d, o, keys[r], vals[r]);
- char *uargs = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *rr = client_rpc(&a->conn, "fiscal_year.update", a->session,
- a->org, uargs);
- free(uargs);
- if (!rr || !client_ok(rr))
- show_error("Kunde inte spara", rr);
- free(rr);
- }
- for (int i = 0; i < nf; i++)
- free(vals[i]);
- if (r == TUI_FORM_ACTION + 1) {
- arsredovisning_screen(a);
- continue;
- }
- if (r == TUI_FORM_ACTION + 2) {
- ink2_screen(a);
- continue;
- }
- if (r == TUI_FORM_ACTION + 3) {
- bokslut_plan(a, fond, rate, 0);
- continue;
- }
- if (r == TUI_FORM_ACTION + 4) {
- bokslut_plan(a, fond, rate, 1);
- continue;
- }
- if (r == TUI_FORM_REFRESH)
- bokslut_plan(a, fond, rate, 0);
- else if (r == TUI_FORM_SUBMIT)
- bokslut_plan(a, fond, rate, 1);
- else if (r == TUI_FORM_BACK)
- return;
- }
-}
-
-/* ------------------------------------------------------------------ */
-/* K2 årsredovisning (draft text) */
-/* ------------------------------------------------------------------ */
-
-/* Whole kronor with space thousands; rounded half away from zero. */
-
-/* Thousands of kronor for the flerårsöversikt. */
-static long long ar_tkr(int64_t ore)
-{
- return (long long)(ore >= 0 ? (ore + 50000) / 100000
- : (ore - 50000) / 100000);
-}
-
-#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];
+static const struct scene SCENES[] = {
+ { "dashboard", dashboard },
+ { "vouchers", vouchers_screen },
+ { "inbox", inbox_screen },
+ { "bank", bank_screen },
+ { "invoices", invoices_screen },
+ { "customers", customers_screen },
+ { "ib", ib_screen },
+ { "templates", templates_screen },
+ { "rules", rules_screen },
+ { "reports", reports_screen },
+ { "audit", audit_screen },
+ { "settings", settings_screen },
+ { "company", company_screen },
+ { "bokslut", bokslut_screen },
+ { "yearinfo", bokslut_screen }, /* ^R compatibility */
};
-/* 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;
- if (!res)
- return 0;
- for (int k = 0; k < 3; k++) {
- yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]);
- yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *row = yyjson_arr_get(arr, i);
- if (!acct_in(vstr(row, "account"), lo, hi))
- continue;
- int64_t ib = vint(row, "ib_ore");
- s += lo >= 2000 ? -ib : ib;
- }
- }
- return s;
-}
-
-static int64_t ar_bs_sum(yyjson_val *res, int lo, int hi)
-{
- int64_t s = 0;
- if (!res)
- return 0;
- for (int k = 0; k < 3; k++) {
- yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]);
- yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
- size_t n = yyjson_arr_size(arr);
- for (size_t i = 0; i < n; i++) {
- yyjson_val *row = yyjson_arr_get(arr, i);
- if (!acct_in(vstr(row, "account"), lo, hi))
- continue;
- int64_t ub = vint(row, "ub_ore");
- s += lo >= 2000 ? -ub : ub;
- }
- }
- return s;
-}
-
-/* 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)
-{
- 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;
- 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 (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;
- }
- 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 void ar_amt_col(char *buf, size_t n, int width, int64_t ore)
-{
- char tmp[40];
- if (n == 0)
- return;
- tui_kr0_format(ore, tmp, sizeof tmp);
- if ((size_t)width >= n)
- width = (int)n - 1;
- if (width < 1)
- width = 1;
- if (strlen(tmp) >= n)
- tmp[n - 1] = '\0';
- snprintf(buf, n, "%*s", width, tmp);
-}
-
-/* Sum and balance rows are derived figures and read as secondary. */
-static int ar_dim_label(const char *label)
-{
- return strncmp(label, "Summa", 5) == 0 || strcmp(label, "Totalt") == 0 ||
- strncmp(label, "Belopp vid", 10) == 0 ||
- strcmp(label, "Årets resultat") == 0 ||
- strcmp(label, "Resultat före skatt") == 0 ||
- strcmp(label, "Rörelseresultat") == 0 ||
- strcmp(label, "Resultat efter finansiella poster") == 0;
-}
-
-static void ar_row(struct buf *t, const char *label, int64_t cur, int64_t prev,
- int indent)
-{
- char nm[160], padded[200], a1[40], a2[40];
- snprintf(nm, sizeof nm, "%s%s", indent ? " " : "", label);
- tui_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 %s\n", ar_dim_label(label) ? MK_DIM : "", padded,
- a1, a2);
-}
-
-/* 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)
-{
- char nm[160], padded[200], c1[32], c2[32], c3[32], c4[32];
- snprintf(nm, sizeof nm, "%s%s", indent ? " " : "", label);
- tui_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 %s\n", ar_dim_label(label) ? MK_DIM : "",
- padded, c1, c2, c3, c4);
-}
-
-static void ar_wrap(struct buf *t, const char *text)
-{
- 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);
-}
-
-/* "2026-06-04" -> "4 juni 2026"; anything else becomes a placeholder. */
-static void ar_sv_date(const char *iso, char *out, size_t n)
-{
- static const char *const months[12] = {
- "januari", "februari", "mars", "april", "maj", "juni", "juli",
- "augusti", "september", "oktober", "november", "december"
- };
- 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,
- 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)
+void open_scene(struct app *a, const char *name)
{
- 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, MK_HEAD "Å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, "\n" MK_HEAD "Faststä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, "\n" MK_HEAD "Förvaltningsberättelse\n\nVerksamheten\n\n"
- "Allmä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, "\n" MK_HEAD "Väsentliga händelser under räkenskapsåret\n");
- 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" MK_HEAD "%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" MK_HEAD "%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, "\n" MK_HEAD "Resultatdisposition\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, "\n" MK_HEAD "Styrelsens yttrande över den föreslagna"
- " vinstutdelningen\n\n");
- if (shares > 0) {
- char per[48];
- tui_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, "\n" MK_HEAD "Resultaträkning\nSamtliga belopp anges i"
- " svenska kronor.\n\n");
- {
- 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
- 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, MK_HEAD "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, "\n" MK_HEAD "Rö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, "\n" MK_HEAD "Bokslutsdispositioner\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, "\n" MK_HEAD "Balansrä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, MK_HEAD "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, MK_HEAD "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, "\n" MK_HEAD "Eget 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, MK_HEAD "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, MK_HEAD "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, "\n" MK_HEAD "Noter\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, "\n" MK_HEAD "Not 2 Medelantal anställda\n");
- buf_line(t, "Medelantalet anställda under räkenskapsåret har uppgått"
- " till %s.\n", employees && *employees ? employees
- : "[antal]");
- if (notes && *notes) {
- buf_line(t, "\n" MK_HEAD "Not 3 Övriga upplysningar\n");
- ar_wrap(t, notes);
- }
-
- buf_line(t, "\n" MK_HEAD "Underskrifter\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");
-}
-
-/* The pager text carries style markers that must not reach the saved file. */
-static void strip_markup(const char *in, struct buf *out)
-{
- for (const char *p = in; *p;) {
- const char *nl = strchr(p, '\n');
- size_t len = nl ? (size_t)(nl - p) : strlen(p);
- if (len && p[0] >= TUI_MARK_HEADING && p[0] <= TUI_MARK_STICKY) {
- p++;
- len--;
- }
- buf_append(out, p, len);
- if (!nl)
- break;
- buf_append(out, "\n", 1);
- p = nl + 1;
- }
-}
-
-static void arsredovisning_save(struct app *a, const char *text)
-{
- const char *home = getenv("HOME");
- char def[512];
- snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : ".");
- struct stat sb;
- if (stat(def, &sb) != 0 || !S_ISDIR(sb.st_mode))
- snprintf(def, sizeof def, "%s", home && *home ? home : ".");
- char dirbuf[512]; char *dir = tui_prompt_into(dirbuf, sizeof dirbuf, "Spara årsredovisningen i: ", def, 0) ? dirbuf : NULL;
- if (!dir || !*dir)
- return;
- char full[640];
- if (dir[0] == '~' && (dir[1] == '/' || dir[1] == '\0'))
- snprintf(full, sizeof full, "%s%s", home ? home : "", dir + 1);
- else
- snprintf(full, sizeof full, "%s", dir);
- size_t fl = strlen(full);
- if (fl && full[fl - 1] != '/')
- snprintf(full + fl, sizeof full - fl, "/");
- 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 %s.txt", *name ? name : "utkast");
- struct buf clean;
- buf_init(&clean);
- strip_markup(text, &clean);
- FILE *f = fopen(full, "wb");
- size_t n = clean.len;
- if (!f || fwrite(clean.p, 1, n, f) != n) {
- if (f)
- fclose(f);
- buf_free(&clean);
- tui_message("Årsredovisning", "Kunde inte skriva %s: %s", full,
- strerror(errno));
- return;
- }
- fclose(f);
- buf_free(&clean);
- tui_message("Årsredovisning", "Sparat: %s\n\nFilen är ett utkast i textform"
- " att redigera vidare (t.ex. i Word).", full);
-}
-
-static void arsredovisning_screen(struct app *a)
-{
- for (;;) {
- char *fresp = client_rpc(&a->conn, "fiscal_year.list", a->session,
- a->org, "{}");
- if (!fresp || !client_ok(fresp)) {
- show_error("Årsredovisning", fresp);
- free(fresp);
+ snprintf(g_scene, sizeof g_scene, "%s", name);
+ for (size_t i = 0; i < sizeof SCENES / sizeof SCENES[0]; i++) {
+ if (strcmp(name, SCENES[i].name) == 0) {
+ SCENES[i].fn(a);
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 *bresp = client_rpc(&a->conn, "report.balance_sheet", a->session,
- 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;
- }
- 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 *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 (bs && nyears > 0)
- fmt_arsredovisning(&t, a, onr && *onr ? onr : "[org.nr]",
- 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);
- int again =
- tui_pager("Årsredovisning (K2)", (const char *)t.p, "s = spara");
- if (again == 2)
- arsredovisning_save(a, (const char *)t.p);
- buf_free(&t);
- free(onr);
- free(city);
- free(desc);
- yyjson_doc_free(bd);
- yyjson_doc_free(od);
- yyjson_doc_free(yd);
- free(bresp);
- free(oresp);
- free(yresp);
- free(mresp);
- if (again != 1 && again != 2)
- return;
}
}
-/* Screen names for ^R restoration and --screen. Only the top-level
- views; sub-screens unwind to their parent. */
-static void open_scene(struct app *a, const char *scene)
-{
- snprintf(g_scene, sizeof g_scene, "%s", scene);
- if (strcmp(scene, "vouchers") == 0)
- vouchers_screen(a);
- else if (strcmp(scene, "inbox") == 0)
- inbox_screen(a);
- else if (strcmp(scene, "bank") == 0)
- bank_screen(a);
- else if (strcmp(scene, "invoices") == 0)
- invoices_screen(a);
- else if (strcmp(scene, "customers") == 0)
- customers_screen(a);
- else if (strcmp(scene, "ib") == 0)
- ib_screen(a);
- else if (strcmp(scene, "templates") == 0)
- templates_screen(a);
- else if (strcmp(scene, "rules") == 0)
- rules_screen(a);
- else if (strcmp(scene, "reports") == 0)
- reports_screen(a);
- else if (strcmp(scene, "audit") == 0)
- audit_screen(a);
- else if (strcmp(scene, "settings") == 0)
- settings_screen(a);
- else if (strcmp(scene, "company") == 0)
- company_screen(a);
- else if (strcmp(scene, "bokslut") == 0 ||
- strcmp(scene, "yearinfo") == 0) /* yearinfo: ^R compatibility */
- bokslut_screen(a);
-}
-
static void do_reload(struct app *a, const char *self)
{
char orgbuf[32], fybuf[32];
@@ -7395,93 +87,6 @@ static void do_reload(struct app *a, const char *self)
execvp(argv[0], argv);
fprintf(stderr, "bokftui: kunde inte ladda om: %s\n", strerror(errno));
}
-
-static const char *const MAIN_ITEMS[] = {
- MK_SECTION "Bokföring",
- "Verifikat", "Underlag (inkorg)", "Bankavstämning",
- MK_SECTION "Fakturering",
- "Fakturor",
- MK_SECTION "Rapporter & bokslut",
- "Rapporter", "Bokslut",
- MK_SECTION "Register",
- "Mallar", "Kunder", "Momsregler", "Företagsuppgifter", "Inställningar",
- MK_SECTION "Räkenskapsår",
- "Ingående balans", "Räkenskapsår",
- MK_SECTION "Övrigt",
- "Revision", "Logga ut / avsluta",
-};
-
-static void dashboard(struct app *a)
-{
- static int last_sel = 0;
- for (;;) {
- if (g_reload)
- return;
- int sel = tui_menu("Bokf", MAIN_ITEMS,
- (int)(sizeof MAIN_ITEMS / sizeof MAIN_ITEMS[0]), 1,
- &last_sel);
- snprintf(g_scene, sizeof g_scene, "%s", "dashboard");
- if (g_quit || g_reload)
- return;
- if (sel == -4) {
- vouchers_new(a);
- continue;
- }
- if (sel == -5)
- return;
- if (sel < 0)
- continue; /* Esc/back at the top level never exits */
- switch (sel) {
- case 1:
- open_scene(a, "vouchers");
- break;
- case 2:
- open_scene(a, "inbox");
- break;
- case 3:
- open_scene(a, "bank");
- break;
- case 5:
- open_scene(a, "invoices");
- break;
- case 7:
- open_scene(a, "reports");
- break;
- case 8:
- open_scene(a, "bokslut");
- break;
- case 10:
- open_scene(a, "templates");
- break;
- case 11:
- open_scene(a, "customers");
- break;
- case 12:
- open_scene(a, "rules");
- break;
- case 13:
- open_scene(a, "company");
- break;
- case 14:
- open_scene(a, "settings");
- break;
- case 16:
- open_scene(a, "ib");
- break;
- case 17:
- select_fiscal_year(a);
- break;
- case 19:
- open_scene(a, "audit");
- break;
- case 20:
- return;
- default:
- break; /* section header */
- }
- }
-}
-
/* ------------------------------------------------------------------ */
/* login */
/* ------------------------------------------------------------------ */
@@ -7501,7 +106,7 @@ static void config_path(char *buf, size_t n)
home && *home ? home : ".");
}
-static void config_mkdirs(const char *path)
+void config_mkdirs(const char *path)
{
char tmp[640];
snprintf(tmp, sizeof tmp, "%s", path);
@@ -7556,40 +161,6 @@ static void config_save(const struct app *a)
chmod(path, 0600);
}
-/* Small append-only log for connection/reload problems. */
-static void tui_log(const char *fmt, ...)
- __attribute__((format(printf, 1, 2)));
-
-static void tui_log(const char *fmt, ...)
-{
- char path[600], dir[512];
- const char *cache = getenv("XDG_CACHE_HOME");
- if (cache && *cache)
- snprintf(dir, sizeof dir, "%s/bokf", cache);
- else {
- const char *home = getenv("HOME");
- snprintf(dir, sizeof dir, "%s/.cache/bokf", home && *home ? home : ".");
- }
- if (snprintf(path, sizeof path, "%s/tui.log", dir) >= (int)sizeof path)
- return;
- config_mkdirs(path);
- FILE *f = fopen(path, "a");
- if (!f)
- return;
- time_t now = time(NULL);
- struct tm tm;
- gmtime_r(&now, &tm);
- fprintf(f, "%04d-%02d-%02dT%02d:%02d:%02dZ ",
- tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour,
- tm.tm_min, tm.tm_sec);
- va_list ap;
- va_start(ap, fmt);
- vfprintf(f, fmt, ap);
- va_end(ap);
- fputc('\n', f);
- fclose(f);
-}
-
/* Connects and opens a session. Returns 0 on success, -2 when the
connection failed and -1 when the server rejected the login. */
static int try_login(struct app *a, const char *user, const char *pass,
diff --git a/clients/screens_accounts.c b/clients/screens_accounts.c
new file mode 100644
index 0000000..d6640f9
--- /dev/null
+++ b/clients/screens_accounts.c
@@ -0,0 +1,173 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+static const char *type_sv(const char *t)
+{
+ if (!t)
+ return "";
+ if (strcmp(t, "asset") == 0)
+ return "Tillgång";
+ if (strcmp(t, "liability") == 0)
+ return "Skuld";
+ if (strcmp(t, "equity") == 0)
+ return "Eget kapital";
+ if (strcmp(t, "revenue") == 0)
+ return "Intäkt";
+ if (strcmp(t, "expense") == 0)
+ return "Kostnad";
+ return t;
+}
+
+/* Moms treatment for an account: the stored vat_code when set, otherwise
+ derived from the BAS account name (which carries the rate). Accounts
+ whose treatment depends on the transaction stay blank. */
+static const char *vat_display(const char *name, const char *vat_code,
+ char *buf, size_t cap)
+{
+ if (vat_code && *vat_code) {
+ snprintf(buf, cap, "%s", vat_code);
+ return buf;
+ }
+ if (!name || !*name)
+ return "";
+ int pct = 0;
+ for (const char *p = name; *p;) {
+ if (*p >= '0' && *p <= '9') {
+ const char *q = p;
+ int v = 0;
+ while (*q >= '0' && *q <= '9') {
+ v = v * 10 + (*q - '0');
+ q++;
+ }
+ while (*q == ' ')
+ q++;
+ if (*q == '%') {
+ pct = v;
+ break;
+ }
+ p = q;
+ continue;
+ }
+ p++;
+ }
+ int in = strstr(name, "Ingående moms") != NULL;
+ int out = strstr(name, "Utgående moms") != NULL;
+ int reverse = strstr(name, "omvänd") != NULL;
+ if (in || out) {
+ if (pct)
+ snprintf(buf, cap, "%s %d%%", in ? "Ing" : "Utg", pct);
+ else
+ snprintf(buf, cap, "%s", in ? "Ing" : "Utg");
+ return buf;
+ }
+ if (pct) {
+ snprintf(buf, cap, "%d%%%s", pct, reverse ? " omv" : "");
+ return buf;
+ }
+ if (strstr(name, "momsfri") || strstr(name, "momsfritt") ||
+ strstr(name, "momsefri"))
+ return "Momsfri";
+ if (strstr(name, "Redovisningskonto för moms"))
+ return "Avräkning";
+ if (reverse)
+ return "Omvänd";
+ if (strstr(name, "moms"))
+ return "Moms";
+ return "";
+}
+
+/* Kontolista: every account with the details that apply to it. */
+void accounts_report(struct app *a)
+{
+ for (;;) {
+ char *resp = client_rpc(&a->conn, "account.list", a->session, a->org,
+ "{\"active_only\":false}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Kontolista", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ int name_w = COLS - 45;
+ if (name_w < 12)
+ name_w = 12;
+ struct buf text;
+ buf_init(&text);
+ char line[2048];
+ int hdr = snprintf(line, sizeof line,
+ MK_STICKY "%-6s %-*s %-12s %-5s %-6s %-8s\n",
+ "Konto", name_w, "Namn", "Typ", "Aktiv", "SRU",
+ "Moms");
+ buf_append(&text, line, (size_t)hdr);
+ int sep_w = 6 + 1 + name_w + 1 + 12 + 1 + 5 + 1 + 6 + 1 + 8;
+ for (int i = 0; i < sep_w && i < (int)sizeof line - 1; i++)
+ line[i] = '-';
+ line[sep_w < (int)sizeof line ? sep_w : (int)sizeof line - 1] = '\n';
+ buf_append(&text, line, (size_t)(sep_w < (int)sizeof line ? sep_w + 1
+ : (int)sizeof line));
+ int64_t active_count = 0;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.number", i);
+ char *number = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *name = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.type", i);
+ char *type = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.active", i);
+ int active = jbool_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.sru_code", i);
+ char *sru = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.vat_code", i);
+ char *vat = jstr_dup(resp, path);
+ char ncol[512];
+ char vatbuf[32];
+ char tibuf[32];
+ snprintf(ncol, sizeof ncol, "%s", name ? name : "");
+ tui_pad_field(ncol, sizeof ncol, name_w);
+ snprintf(tibuf, sizeof tibuf, "%s", type_sv(type));
+ tui_pad_field(tibuf, sizeof tibuf, 12);
+ snprintf(line, sizeof line, "%-6s %s %s %-5s %-6s %-8s\n",
+ number ? number : "", ncol, tibuf,
+ active ? "ja" : "nej", sru ? sru : "",
+ vat_display(name, vat, vatbuf, sizeof vatbuf));
+ buf_append(&text, line, strlen(line));
+ if (active)
+ active_count++;
+ free(number);
+ free(name);
+ free(type);
+ free(sru);
+ free(vat);
+ }
+ snprintf(line, sizeof line, "\n%lld konton, varav %lld aktiva.\n",
+ (long long)n, (long long)active_count);
+ buf_append(&text, line, strlen(line));
+ buf_append(&text, "\0", 1);
+ int again = tui_pager("Kontolista", (char *)text.p, NULL);
+ buf_free(&text);
+ free(resp);
+ if (!again)
+ return;
+ }
+}
diff --git a/clients/screens_attachments.c b/clients/screens_attachments.c
new file mode 100644
index 0000000..3eeb9e9
--- /dev/null
+++ b/clients/screens_attachments.c
@@ -0,0 +1,163 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+static void inbox_upload(struct app *a)
+{
+ char *path = file_browser(a, a->attachment_dir);
+ if (!path)
+ return;
+ struct stat sb;
+ if (stat(path, &sb) == 0 && (long)sb.st_size > a->max_attachment_bytes) {
+ tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.",
+ (double)sb.st_size / (1024 * 1024),
+ a->max_attachment_bytes / (1024 * 1024));
+ free(path);
+ return;
+ }
+ char *b64 = read_file_b64(path);
+ if (!b64) {
+ tui_message("Fel", "Kunde inte läsa %s", path);
+ free(path);
+ return;
+ }
+ const char *base = strrchr(path, '/');
+ base = base ? base + 1 : path;
+ 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, "filename", base);
+ yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ free(b64);
+ size_t reqlen = strlen(args) + 256;
+ char *raw = xmalloc(reqlen);
+ snprintf(raw, reqlen,
+ "{\"v\":1,\"id\":\"tui\",\"cmd\":\"attachment.put\","
+ "\"session\":\"%s\",\"org\":%lld,\"args\":%s}",
+ a->session, (long long)a->org, args);
+ free(args);
+ char *r = NULL;
+ if (client_send_line(&a->conn, raw) == 0)
+ r = client_read_line(&a->conn);
+ free(raw);
+ if (r && client_ok(r))
+ tui_message("Underlag", "Sparat.");
+ else
+ show_error("Kunde inte spara", r);
+ free(r);
+ free(path);
+}
+
+struct inboxctx {
+ struct app *a;
+ int64_t *ids;
+ size_t n;
+ int cursor;
+};
+
+static int inbox_key(void *ud, int ch)
+{
+ struct inboxctx *ctx = ud;
+ struct app *a = ctx->a;
+ int64_t sel_id = (ctx->cursor >= 0 && (size_t)ctx->cursor < ctx->n)
+ ? ctx->ids[ctx->cursor]
+ : 0;
+ if (ch == 'k' && sel_id) {
+ int64_t vid = pick_voucher(a);
+ if (vid) {
+ char kargs[64];
+ snprintf(kargs, sizeof kargs, "{\"id\":%lld,\"voucher_id\":%lld}",
+ (long long)sel_id, (long long)vid);
+ char *r = client_rpc(&a->conn, "attachment.link", a->session,
+ a->org, kargs);
+ if (r && client_ok(r))
+ tui_message("Underlag", "Kopplat till verifikatet.");
+ else
+ show_error("Kunde inte koppla", r);
+ free(r);
+ }
+ return TUI_HOOK_REFRESH;
+ }
+ if (ch == 'a' || ch == 'A') {
+ inbox_upload(a);
+ return TUI_HOOK_REFRESH;
+ }
+ return TUI_HOOK_NONE;
+}
+
+void inbox_screen(struct app *a)
+{
+ int cursor = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(&a->conn, "attachment.list", a->session, a->org,
+ "{\"unlinked\":true,\"limit\":200}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Underlag", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ char **items = xcalloc(n ? n : 1, sizeof(char *));
+ int64_t *ids = xcalloc(n ? 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.filename", i);
+ char *fn = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.size_bytes", i);
+ int64_t size = jint_val(resp, path, 0);
+ char fnbuf[256];
+ snprintf(fnbuf, sizeof fnbuf, "%s", fn ? fn : "");
+ tui_pad_field(fnbuf, sizeof fnbuf, 40);
+ snprintf(line, sizeof line, "%s %8lld byte", fnbuf,
+ (long long)size);
+ items[i] = xstrdup(line);
+ free(fn);
+ }
+ struct inboxctx ctx = { a, ids, n, cursor };
+ int sel = tui_select_list_hook("Underlag (inkorg)", items, (int)n,
+ cursor, 1, &cursor, 1, NULL, 0,
+ inbox_key, &ctx);
+ int64_t sel_id =
+ (sel >= 0 && (size_t)sel < n) ? ids[sel] : 0;
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ free(resp);
+ if (sel == -1)
+ return;
+ if (sel == -2)
+ continue;
+ if (sel == -4) {
+ inbox_upload(a);
+ continue;
+ }
+ if (sel_id)
+ attachment_download(a, sel_id);
+ }
+}
diff --git a/clients/screens_audit.c b/clients/screens_audit.c
new file mode 100644
index 0000000..8f54dd5
--- /dev/null
+++ b/clients/screens_audit.c
@@ -0,0 +1,132 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+static void audit_verify_run(struct app *a, int full)
+{
+ char *resp = client_rpc(&a->conn, "audit.verify", a->session, a->org,
+ full ? "{\"full\":true}" : "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Revision", resp);
+ free(resp);
+ return;
+ }
+ int64_t checked = jint_val(resp, "result.checked", 0);
+ int64_t vouchers = jint_val(resp, "result.vouchers_checked", 0);
+ int is_ok = jbool_val(resp, "result.ok", 0);
+ if (is_ok) {
+ if (full)
+ tui_message("Hashkedjor",
+ "Kedjorna är intakta.\n%lld verifikat och %lld "
+ "händelser kontrollerade.\n%lld underlag "
+ "hash-kontrollerade.",
+ (long long)vouchers, (long long)checked,
+ (long long)jint_val(resp, "result.attachments_checked",
+ 0));
+ else
+ tui_message("Hashkedjor",
+ "Kedjorna är intakta.\n%lld verifikat och %lld "
+ "händelser kontrollerade.",
+ (long long)vouchers, (long long)checked);
+ } else {
+ int64_t bad_v = jint_val(resp, "result.first_bad_voucher_id", 0);
+ int64_t bad_a = jint_val(resp, "result.first_bad_seq", 0);
+ int64_t unbal = jint_val(resp, "result.first_unbalanced_voucher_id", 0);
+ int64_t bad_att =
+ jint_val(resp, "result.first_bad_attachment_id", 0);
+ char detail[320] = "";
+ if (bad_v)
+ snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
+ "Första trasiga verifikat: %lld\n", (long long)bad_v);
+ if (bad_a)
+ snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
+ "Första trasiga händelse: %lld\n", (long long)bad_a);
+ if (unbal)
+ snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
+ "Första obalanserade verifikat: %lld\n",
+ (long long)unbal);
+ if (bad_att)
+ snprintf(detail + strlen(detail), sizeof detail - strlen(detail),
+ "Första trasiga underlag: %lld\n", (long long)bad_att);
+ tui_message("Hashkedjor", "KEDJORNA ÄR BRUTNA!\n%s", detail);
+ }
+ free(resp);
+}
+
+void audit_screen(struct app *a)
+{
+ static const char *const audit_items[] = { "Verifiera hashkedjan",
+ "Verifiera allt (inkl. underlag)",
+ "Senaste händelser" };
+ static int last_sel = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ int sel = tui_menu("Revision", audit_items, 3, 0, &last_sel);
+ if (sel < 0)
+ return;
+ if (sel == 0) {
+ audit_verify_run(a, 0);
+ } else if (sel == 1) {
+ audit_verify_run(a, 1);
+ } else {
+ for (;;) {
+ char *resp = client_rpc(&a->conn, "audit.list", a->session,
+ a->org, "{\"limit\":200}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Revision", resp);
+ free(resp);
+ break;
+ }
+ struct buf text;
+ buf_init(&text);
+ size_t n = jarr_size(resp, "result.items");
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.at", i);
+ char *at = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.action", i);
+ char *action = jstr_dup(resp, path);
+ snprintf(path, sizeof path,
+ "result.items.%zu.result_code", i);
+ char *code = jstr_dup(resp, path);
+ snprintf(path, sizeof path,
+ "result.items.%zu.actor_user_id", i);
+ int64_t actor = jint_val(resp, path, 0);
+ snprintf(line, sizeof line, "%s anv %lld %-22s %s\n",
+ at ? at : "", (long long)actor,
+ action ? action : "", code ? code : "");
+ buf_append(&text, line, strlen(line));
+ free(at);
+ free(action);
+ free(code);
+ }
+ buf_append(&text, "\0", 1);
+ int again = tui_pager("Behandlingshistorik", (char *)text.p, NULL);
+ buf_free(&text);
+ free(resp);
+ if (!again)
+ break;
+ }
+ }
+ }
+}
diff --git a/clients/screens_bank.c b/clients/screens_bank.c
new file mode 100644
index 0000000..63651bf
--- /dev/null
+++ b/clients/screens_bank.c
@@ -0,0 +1,368 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+/* ------------------------------------------------------------------ */
+/* bankavstämning */
+/* ------------------------------------------------------------------ */
+
+struct bank_match {
+ int64_t voucher_id;
+ char series[16];
+ int64_t number;
+};
+
+struct bank_sugg {
+ int64_t voucher_id;
+ char series[16];
+ int64_t number;
+ char date[16];
+ int64_t amount_ore;
+};
+
+struct bank_tx {
+ int64_t id;
+ char booked_at[16];
+ char text[256];
+ int64_t amount_ore;
+ struct bank_match *matches;
+ size_t nmatches;
+ struct bank_sugg *suggestions;
+ size_t nsuggestions;
+};
+
+static void bank_ver(char *buf, size_t n, const char *series, int64_t number)
+{
+ snprintf(buf, n, "%s%lld", series ? series : "", (long long)number);
+}
+
+/* Unmatched first, then newest booked_at first. */
+static int bank_tx_cmp(const void *pa, const void *pb)
+{
+ const struct bank_tx *x = pa, *y = pb;
+ int xm = x->nmatches > 0, ym = y->nmatches > 0;
+ if (xm != ym)
+ return xm - ym;
+ int c = strcmp(y->booked_at, x->booked_at);
+ if (c)
+ return c;
+ return x->id < y->id ? -1 : x->id > y->id;
+}
+
+static void bank_txs_free(struct bank_tx *txs, size_t n)
+{
+ for (size_t i = 0; i < n; i++) {
+ free(txs[i].matches);
+ free(txs[i].suggestions);
+ }
+ free(txs);
+}
+
+static void bank_tx_row(const struct bank_tx *t, char *line, size_t cap)
+{
+ char text[256], amt[48], tail[64] = "";
+ snprintf(text, sizeof text, "%s", t->text);
+ tui_pad_field(text, sizeof text, 32);
+ tui_amt_col(amt, sizeof amt, 13, t->amount_ore);
+ if (t->nmatches > 0) {
+ char ver[32];
+ bank_ver(ver, sizeof ver, t->matches[0].series,
+ t->matches[0].number);
+ if (t->nmatches > 1)
+ snprintf(tail, sizeof tail, "→ %s +%zu", ver, t->nmatches - 1);
+ else
+ snprintf(tail, sizeof tail, "→ %s", ver);
+ } else if (t->nsuggestions > 0) {
+ char ver[32];
+ bank_ver(ver, sizeof ver, t->suggestions[0].series,
+ t->suggestions[0].number);
+ snprintf(tail, sizeof tail, "(förslag %s)", ver);
+ }
+ snprintf(line, cap, "%s %s %s %s %s", t->nmatches > 0 ? "✓" : "·",
+ t->booked_at, text, amt, tail);
+}
+
+/* Enter on an unmatched row: suggest vouchers, or pick one freely. */
+static void bank_match_tx(struct app *a, const struct bank_tx *t)
+{
+ char **items = xcalloc(t->nsuggestions + 1, sizeof(char *));
+ for (size_t i = 0; i < t->nsuggestions; i++) {
+ const struct bank_sugg *s = &t->suggestions[i];
+ char ver[32], amt[48], diff[48], line[256];
+ bank_ver(ver, sizeof ver, s->series, s->number);
+ tui_amt_col(amt, sizeof amt, 13, s->amount_ore);
+ tui_amt_col(diff, sizeof diff, 13, t->amount_ore - s->amount_ore);
+ snprintf(line, sizeof line, "%s %-8s %s %s", s->date, ver, amt,
+ diff);
+ items[i] = xstrdup(line);
+ }
+ items[t->nsuggestions] = xstrdup("Välj annat verifikat…");
+ int sel = tui_select_list("Matcha transaktion", items,
+ (int)t->nsuggestions + 1, 0, 0, NULL, 0, NULL,
+ 0);
+ for (size_t i = 0; i <= t->nsuggestions; i++)
+ free(items[i]);
+ free(items);
+ if (sel < 0)
+ return;
+ int64_t vid;
+ if ((size_t)sel < t->nsuggestions) {
+ vid = t->suggestions[sel].voucher_id;
+ } else {
+ vid = pick_voucher(a);
+ if (!vid)
+ return;
+ }
+ char args[64];
+ snprintf(args, sizeof args,
+ "{\"transaction_id\":%lld,\"voucher_id\":%lld}",
+ (long long)t->id, (long long)vid);
+ char *resp = client_rpc(&a->conn, "bank.match", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Bankavstämning", resp);
+ free(resp);
+ return;
+ }
+ int64_t diff = jint_val(resp, "result.difference_ore", 0);
+ if (diff) {
+ char dbuf[48];
+ tui_kr_format(diff, dbuf, sizeof dbuf);
+ tui_message("Bankavstämning", "Matchat. Differens: %s", dbuf);
+ } else {
+ tui_message("Bankavstämning", "Matchat.");
+ }
+ free(resp);
+}
+
+static void bank_unmatch_tx(struct app *a, const struct bank_tx *t)
+{
+ if (t->nmatches == 0)
+ return;
+ if (t->nmatches == 1) {
+ if (!tui_confirm("Bankavstämning",
+ "Koppla bort transaktionen %s från verifikatet?",
+ t->text))
+ return;
+ } else if (!tui_confirm("Bankavstämning",
+ "Koppla bort transaktionen %s från %zu verifikat?",
+ t->text, t->nmatches)) {
+ return;
+ }
+ int ok = 1;
+ for (size_t i = 0; i < t->nmatches; i++) {
+ char args[64];
+ snprintf(args, sizeof args,
+ "{\"transaction_id\":%lld,\"voucher_id\":%lld}",
+ (long long)t->id, (long long)t->matches[i].voucher_id);
+ char *resp = client_rpc(&a->conn, "bank.unmatch", a->session, a->org,
+ args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Bankavstämning", resp);
+ ok = 0;
+ }
+ free(resp);
+ }
+ if (ok)
+ tui_message("Bankavstämning", "Matchningen borttagen.");
+}
+
+static void bank_import_file(struct app *a)
+{
+ char *path = file_browser(a, a->attachment_dir);
+ if (!path)
+ return;
+ char *b64 = read_file_b64(path);
+ if (!b64) {
+ tui_message("Fel", "Kunde inte läsa %s", path);
+ free(path);
+ return;
+ }
+ free(path);
+ 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, "format", "seb");
+ yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
+ yyjson_mut_obj_add_strcpy(d, o, "account", "1930");
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ free(b64);
+ if (!args)
+ return;
+ char *resp = rpc_dry(a, "bank.import", args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Bankavstämning", resp);
+ free(resp);
+ free(args);
+ return;
+ }
+ int64_t imported = jint_val(resp, "result.imported", 0);
+ int64_t duplicates = jint_val(resp, "result.duplicates", 0);
+ free(resp);
+ if (!tui_confirm("Bankavstämning",
+ "%lld transaktioner (%lld dubbletter). Importera?",
+ (long long)imported, (long long)duplicates)) {
+ free(args);
+ return;
+ }
+ resp = client_rpc(&a->conn, "bank.import", a->session, a->org, args);
+ free(args);
+ if (resp && client_ok(resp))
+ tui_message("Bankavstämning", "%lld transaktioner (%lld dubbletter).",
+ (long long)jint_val(resp, "result.imported", 0),
+ (long long)jint_val(resp, "result.duplicates", 0));
+ else
+ show_error("Bankavstämning", resp);
+ free(resp);
+}
+
+struct bankctx {
+ struct app *a;
+ struct bank_tx *txs;
+ size_t n;
+ int cursor;
+};
+
+static int bank_key(void *ud, int ch)
+{
+ struct bankctx *ctx = ud;
+ if (ch == 'a' || ch == 'A') {
+ bank_import_file(ctx->a);
+ return TUI_HOOK_REFRESH;
+ }
+ if ((ch == 'u' || ch == 'U') && ctx->cursor >= 0 &&
+ (size_t)ctx->cursor < ctx->n) {
+ bank_unmatch_tx(ctx->a, &ctx->txs[ctx->cursor]);
+ return TUI_HOOK_REFRESH;
+ }
+ return TUI_HOOK_NONE;
+}
+
+void bank_screen(struct app *a)
+{
+ int64_t keep_id = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(&a->conn, "bank.list", a->session, a->org,
+ "{\"status\":\"all\",\"limit\":500}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Bankavstämning", resp);
+ free(resp);
+ return;
+ }
+ yyjson_doc *doc = parse(resp);
+ yyjson_val *items =
+ doc ? jget(yyjson_doc_get_root(doc), "result.items") : NULL;
+ size_t n = items && yyjson_is_arr(items) ? yyjson_arr_size(items) : 0;
+ struct bank_tx *txs = xcalloc(n ? n : 1, sizeof *txs);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *it = yyjson_arr_get(items, i);
+ txs[i].id = vint(it, "id");
+ snprintf(txs[i].booked_at, sizeof txs[i].booked_at, "%s",
+ vstr(it, "booked_at"));
+ snprintf(txs[i].text, sizeof txs[i].text, "%s", vstr(it, "text"));
+ txs[i].amount_ore = vint(it, "amount_ore");
+ yyjson_val *ms = yyjson_obj_get(it, "matches");
+ size_t nm = ms && yyjson_is_arr(ms) ? yyjson_arr_size(ms) : 0;
+ if (nm > 0) {
+ txs[i].matches = xcalloc(nm, sizeof *txs[i].matches);
+ txs[i].nmatches = nm;
+ for (size_t k = 0; k < nm; k++) {
+ yyjson_val *m = yyjson_arr_get(ms, k);
+ txs[i].matches[k].voucher_id = vint(m, "voucher_id");
+ snprintf(txs[i].matches[k].series,
+ sizeof txs[i].matches[k].series, "%s",
+ vstr(m, "series"));
+ txs[i].matches[k].number = vint(m, "number");
+ }
+ }
+ yyjson_val *ss = yyjson_obj_get(it, "suggestions");
+ size_t ns = ss && yyjson_is_arr(ss) ? yyjson_arr_size(ss) : 0;
+ if (ns > 0) {
+ txs[i].suggestions = xcalloc(ns, sizeof *txs[i].suggestions);
+ txs[i].nsuggestions = ns;
+ for (size_t k = 0; k < ns; k++) {
+ yyjson_val *s = yyjson_arr_get(ss, k);
+ txs[i].suggestions[k].voucher_id = vint(s, "voucher_id");
+ snprintf(txs[i].suggestions[k].series,
+ sizeof txs[i].suggestions[k].series, "%s",
+ vstr(s, "series"));
+ txs[i].suggestions[k].number = vint(s, "number");
+ snprintf(txs[i].suggestions[k].date,
+ sizeof txs[i].suggestions[k].date, "%s",
+ vstr(s, "date"));
+ txs[i].suggestions[k].amount_ore = vint(s, "amount_ore");
+ }
+ }
+ }
+ qsort(txs, n, sizeof *txs, bank_tx_cmp);
+ int start = 0;
+ for (size_t i = 0; i < n; i++)
+ if (txs[i].id == keep_id)
+ start = (int)i;
+ int64_t unmatched = jint_val(resp, "result.summary.unmatched", -1);
+ if (unmatched < 0) {
+ unmatched = 0;
+ for (size_t i = 0; i < n; i++)
+ if (txs[i].nmatches == 0)
+ unmatched++;
+ }
+ char title[64];
+ snprintf(title, sizeof title, "Bankavstämning (%lld omatchade)",
+ (long long)unmatched);
+ size_t rows = n > 0 ? n : 1;
+ char **lines = xcalloc(rows, sizeof(char *));
+ if (n == 0) {
+ lines[0] = xstrdup("+ Importera bankfil (^N)");
+ } else {
+ for (size_t i = 0; i < n; i++) {
+ char line[512];
+ bank_tx_row(&txs[i], line, sizeof line);
+ lines[i] = xstrdup(line);
+ }
+ }
+ struct bankctx ctx = { a, txs, n, start };
+ int sel = tui_select_list_hook(title, lines, (int)rows, start, 1,
+ &ctx.cursor, 1, NULL, 0, bank_key,
+ &ctx);
+ for (size_t i = 0; i < rows; i++)
+ free(lines[i]);
+ free(lines);
+ if (sel == -1) {
+ bank_txs_free(txs, n);
+ free(resp);
+ yyjson_doc_free(doc);
+ return;
+ }
+ if (sel >= 0 && (size_t)sel < n) {
+ keep_id = txs[sel].id;
+ if (txs[sel].nmatches == 0)
+ bank_match_tx(a, &txs[sel]);
+ } else if (sel >= 0) {
+ bank_import_file(a);
+ }
+ bank_txs_free(txs, n);
+ free(resp);
+ yyjson_doc_free(doc);
+ }
+}
diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c
new file mode 100644
index 0000000..41649ce
--- /dev/null
+++ b/clients/screens_bokslut.c
@@ -0,0 +1,998 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+static void arsredovisning_screen(struct app *a);
+
+/* ------------------------------------------------------------------ */
+/* bokslut */
+/* ------------------------------------------------------------------ */
+
+
+static void fmt_bokslut_plan(struct buf *t, yyjson_val *res)
+{
+ yyjson_val *fy = yyjson_obj_get(res, "fiscal_year");
+ if (fy)
+ buf_line(t, MK_HEAD "Bokslutsplan %s %s - %s\n", vstr(fy, "label"),
+ vstr(fy, "start_date"), vstr(fy, "end_date"));
+ yyjson_val *vs = yyjson_obj_get(res, "vouchers");
+ size_t n = yyjson_arr_size(vs);
+ if (n == 0) {
+ buf_line(t, "\nInget att bokföra.\n");
+ return;
+ }
+ char pser[16] = "";
+ long long pnum = 0;
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *v = yyjson_arr_get(vs, i);
+ yyjson_val *pv = yyjson_obj_get(v, "voucher");
+ const char *ser = vstr(pv, "series");
+ long long num = (long long)vint(pv, "number");
+ if (strcmp(ser, pser) != 0) {
+ snprintf(pser, sizeof pser, "%s", ser);
+ pnum = num;
+ } else if (num <= pnum) {
+ num = pnum + 1;
+ }
+ pnum = num;
+ buf_line(t, "\n" MK_HEAD "%s%lld %s\n", pser, num,
+ vstr(v, "description"));
+ yyjson_val *rows = yyjson_obj_get(v, "rows");
+ size_t nr = yyjson_arr_size(rows);
+ for (size_t k = 0; k < nr; k++) {
+ yyjson_val *r = yyjson_arr_get(rows, k);
+ char a1[48], a2[48];
+ tui_amt_col(a1, sizeof a1, 15, vint(r, "debit_ore"));
+ tui_amt_col(a2, sizeof a2, 15, vint(r, "credit_ore"));
+ buf_line(t, " %-5s %s %s\n", vstr(r, "account"), a1, a2);
+ }
+ }
+}
+
+static void bokslut_plan(struct app *a, const char *fond, const char *rate,
+ int do_post)
+{
+ int64_t fond_ore = 0, rate_ore = 0;
+ if (tui_parse_kr(fond, &fond_ore) != 0) {
+ tui_message("Bokslut", "Ogiltigt fondbelopp: %s", fond);
+ return;
+ }
+ if (tui_parse_kr(rate, &rate_ore) != 0 || rate_ore < 0 || rate_ore > 10000) {
+ tui_message("Bokslut", "Ogiltig skattesats: %s", rate);
+ return;
+ }
+ char args[160];
+ snprintf(args, sizeof args,
+ "{\"fiscal_year\":%lld,\"periodiseringsfond_ore\":%lld,"
+ "\"tax_rate\":%.2f}",
+ (long long)a->fy, (long long)fond_ore,
+ (double)rate_ore / 100.0);
+ char *resp = rpc_dry(a, "bokslut.post", args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Bokslut", resp);
+ free(resp);
+ return;
+ }
+ yyjson_doc *d = parse(resp);
+ yyjson_val *res = d ? jget(yyjson_doc_get_root(d), "result") : NULL;
+ struct buf t;
+ buf_init(&t);
+ if (res)
+ fmt_bokslut_plan(&t, res);
+ else
+ buf_line(&t, "%s", resp);
+ buf_append(&t, "", 1);
+ tui_pager("Bokslut - plan (torrkörning)", (const char *)t.p, NULL);
+ buf_free(&t);
+ yyjson_doc_free(d);
+ free(resp);
+ if (!do_post)
+ return;
+ char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, "Bokför planen? (j/n): ", "n", 0) ? ansbuf : NULL;
+ if (!ans || !(ans[0] == 'j' || ans[0] == 'J'))
+ return;
+ char *presp = client_rpc(&a->conn, "bokslut.post", a->session, a->org,
+ args);
+ if (!presp || !client_ok(presp)) {
+ show_error("Bokslut", presp);
+ free(presp);
+ return;
+ }
+ yyjson_doc *pd = parse(presp);
+ yyjson_val *pr = pd ? jget(yyjson_doc_get_root(pd), "result") : NULL;
+ struct buf m;
+ buf_init(&m);
+ buf_line(&m, "Bokslutet är bokfört:");
+ yyjson_val *vs = pr ? yyjson_obj_get(pr, "vouchers") : NULL;
+ size_t n = yyjson_arr_size(vs);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *v = yyjson_arr_get(vs, i);
+ yyjson_val *pv = yyjson_obj_get(v, "voucher");
+ buf_line(&m, "\n %s%lld %s", vstr(pv, "series"),
+ (long long)vint(pv, "number"), vstr(v, "description"));
+ }
+ tui_message("Bokslut", "%s", (const char *)m.p);
+ buf_free(&m);
+ yyjson_doc_free(pd);
+ free(presp);
+}
+
+/* Bokslut: the year-end hub. The year's årsredovisning details (fields 0-5,
+ saved one by one with fiscal_year.update) and the local periodiseringsfond
+ and tax rate inputs plus a status row and the year-end actions, all in one
+ view. New years inherit the stable fields. */
+void bokslut_screen(struct app *a)
+{
+ static int sel = 0;
+ 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;
+ char fond[64] = "0,00", rate[16] = "20,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("Bokslut", resp);
+ free(resp);
+ return;
+ }
+ char *fystat = jstr_dup(resp, "result.status");
+ int closed = fystat && strcmp(fystat, "closed") == 0;
+ free(fystat);
+ char *vals[6];
+ struct tui_form_field ff[8];
+ int64_t div_ore = jint_val(resp, "result.dividend_ore", 0);
+ memset(ff, 0, sizeof ff);
+ for (int i = 0; i < nf; i++) {
+ vals[i] = xcalloc(512, 1);
+ if (i != 2) {
+ char path[64], *v;
+ snprintf(path, sizeof path, "result.%s", keys[i]);
+ v = jstr_dup(resp, path);
+ snprintf(vals[i], 512, "%s", v ? v : "");
+ free(v);
+ }
+ ff[i].label = labels[i];
+ ff[i].value = vals[i];
+ ff[i].cap = 512;
+ }
+ free(resp);
+
+ /* A closed year counts as posted; an open one only when the tax
+ voucher that bokslut.post creates is already in the books. */
+ int posted = closed;
+ if (!closed) {
+ char vargs[160];
+ snprintf(vargs, sizeof vargs,
+ "{\"fiscal_year\":%lld,"
+ "\"text\":\"Skatt på årets resultat\",\"limit\":1}",
+ (long long)a->fy);
+ char *vresp = client_rpc(&a->conn, "voucher.list", a->session,
+ a->org, vargs);
+ if (vresp && client_ok(vresp))
+ posted = jarr_size(vresp, "result.items") > 0;
+ free(vresp);
+ }
+
+ char status_line[96];
+ snprintf(status_line, sizeof status_line, "Bokslut bokfört: %s",
+ posted ? "ja" : "nej");
+ const char *reason = NULL;
+ if (closed)
+ reason = "året är stängt";
+ else if (posted)
+ reason = "bokslutet är redan bokfört";
+ else if (!can_edit)
+ reason = "du har inte behörighet";
+ struct tui_form_action acts[5] = {
+ { status_line, -1, NULL },
+ { "Årsredovisning (K2)", 1, NULL },
+ { "Inkomstdeklaration (INK2/SRU)", 1, NULL },
+ { "Bokslutsplan (torrkörning)", 1, NULL },
+ { "Bokför bokslut", reason ? 0 : 1, reason },
+ };
+
+ ff[0].kind = TUI_F_TEXT;
+ ff[1].kind = TUI_F_DATE;
+ ff[2].kind = TUI_F_AMOUNT;
+ ff[2].ore = &div_ore;
+ ff[3].kind = TUI_F_DATE;
+ ff[4].kind = TUI_F_TEXT;
+ ff[5].kind = TUI_F_TEXT;
+ ff[6].label = "Periodiseringsfond (kr)";
+ ff[6].value = fond;
+ ff[6].cap = sizeof fond;
+ ff[6].kind = TUI_F_TEXT;
+ ff[7].label = "Skattesats (%)";
+ ff[7].value = rate;
+ ff[7].cap = sizeof rate;
+ ff[7].kind = TUI_F_TEXT;
+ tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför F5 = visa"
+ " bokslutsplan ^Enter = bokför planen (frågar"
+ " först) Esc/q = tillbaka ^C = avsluta");
+ int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5,
+ NULL, &sel);
+ if (r >= 0 && r < nf) {
+ 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 (r == 2)
+ yyjson_mut_obj_add_int(d, o, "dividend_ore", div_ore);
+ else
+ yyjson_mut_obj_add_strcpy(d, o, keys[r], vals[r]);
+ char *uargs = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *rr = client_rpc(&a->conn, "fiscal_year.update", a->session,
+ a->org, uargs);
+ free(uargs);
+ if (!rr || !client_ok(rr))
+ show_error("Kunde inte spara", rr);
+ free(rr);
+ }
+ for (int i = 0; i < nf; i++)
+ free(vals[i]);
+ if (r == TUI_FORM_ACTION + 1) {
+ arsredovisning_screen(a);
+ continue;
+ }
+ if (r == TUI_FORM_ACTION + 2) {
+ ink2_screen(a);
+ continue;
+ }
+ if (r == TUI_FORM_ACTION + 3) {
+ bokslut_plan(a, fond, rate, 0);
+ continue;
+ }
+ if (r == TUI_FORM_ACTION + 4) {
+ bokslut_plan(a, fond, rate, 1);
+ continue;
+ }
+ if (r == TUI_FORM_REFRESH)
+ bokslut_plan(a, fond, rate, 0);
+ else if (r == TUI_FORM_SUBMIT)
+ bokslut_plan(a, fond, rate, 1);
+ else if (r == TUI_FORM_BACK)
+ return;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* K2 årsredovisning (draft text) */
+/* ------------------------------------------------------------------ */
+
+/* Whole kronor with space thousands; rounded half away from zero. */
+
+/* Thousands of kronor for the flerårsöversikt. */
+static long long ar_tkr(int64_t ore)
+{
+ return (long long)(ore >= 0 ? (ore + 50000) / 100000
+ : (ore - 50000) / 100000);
+}
+
+#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;
+ if (!res)
+ return 0;
+ for (int k = 0; k < 3; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ if (!acct_in(vstr(row, "account"), lo, hi))
+ continue;
+ int64_t ib = vint(row, "ib_ore");
+ s += lo >= 2000 ? -ib : ib;
+ }
+ }
+ return s;
+}
+
+static int64_t ar_bs_sum(yyjson_val *res, int lo, int hi)
+{
+ int64_t s = 0;
+ if (!res)
+ return 0;
+ for (int k = 0; k < 3; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ if (!acct_in(vstr(row, "account"), lo, hi))
+ continue;
+ int64_t ub = vint(row, "ub_ore");
+ s += lo >= 2000 ? -ub : ub;
+ }
+ }
+ return s;
+}
+
+/* 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)
+{
+ 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;
+ 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 (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;
+ }
+ 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 void ar_amt_col(char *buf, size_t n, int width, int64_t ore)
+{
+ char tmp[40];
+ if (n == 0)
+ return;
+ tui_kr0_format(ore, tmp, sizeof tmp);
+ if ((size_t)width >= n)
+ width = (int)n - 1;
+ if (width < 1)
+ width = 1;
+ if (strlen(tmp) >= n)
+ tmp[n - 1] = '\0';
+ snprintf(buf, n, "%*s", width, tmp);
+}
+
+/* Sum and balance rows are derived figures and read as secondary. */
+static int ar_dim_label(const char *label)
+{
+ return strncmp(label, "Summa", 5) == 0 || strcmp(label, "Totalt") == 0 ||
+ strncmp(label, "Belopp vid", 10) == 0 ||
+ strcmp(label, "Årets resultat") == 0 ||
+ strcmp(label, "Resultat före skatt") == 0 ||
+ strcmp(label, "Rörelseresultat") == 0 ||
+ strcmp(label, "Resultat efter finansiella poster") == 0;
+}
+
+static void ar_row(struct buf *t, const char *label, int64_t cur, int64_t prev,
+ int indent)
+{
+ char nm[160], padded[200], a1[40], a2[40];
+ snprintf(nm, sizeof nm, "%s%s", indent ? " " : "", label);
+ tui_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 %s\n", ar_dim_label(label) ? MK_DIM : "", padded,
+ a1, a2);
+}
+
+/* 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)
+{
+ char nm[160], padded[200], c1[32], c2[32], c3[32], c4[32];
+ snprintf(nm, sizeof nm, "%s%s", indent ? " " : "", label);
+ tui_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 %s\n", ar_dim_label(label) ? MK_DIM : "",
+ padded, c1, c2, c3, c4);
+}
+
+static void ar_wrap(struct buf *t, const char *text)
+{
+ 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);
+}
+
+/* "2026-06-04" -> "4 juni 2026"; anything else becomes a placeholder. */
+static void ar_sv_date(const char *iso, char *out, size_t n)
+{
+ static const char *const months[12] = {
+ "januari", "februari", "mars", "april", "maj", "juni", "juli",
+ "augusti", "september", "oktober", "november", "december"
+ };
+ 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,
+ 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)
+{
+ 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, MK_HEAD "Å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, "\n" MK_HEAD "Faststä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, "\n" MK_HEAD "Förvaltningsberättelse\n\nVerksamheten\n\n"
+ "Allmä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, "\n" MK_HEAD "Väsentliga händelser under räkenskapsåret\n");
+ 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" MK_HEAD "%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" MK_HEAD "%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, "\n" MK_HEAD "Resultatdisposition\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, "\n" MK_HEAD "Styrelsens yttrande över den föreslagna"
+ " vinstutdelningen\n\n");
+ if (shares > 0) {
+ char per[48];
+ tui_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, "\n" MK_HEAD "Resultaträkning\nSamtliga belopp anges i"
+ " svenska kronor.\n\n");
+ {
+ 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
+ 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, MK_HEAD "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, "\n" MK_HEAD "Rö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, "\n" MK_HEAD "Bokslutsdispositioner\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, "\n" MK_HEAD "Balansrä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, MK_HEAD "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, MK_HEAD "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, "\n" MK_HEAD "Eget 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, MK_HEAD "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, MK_HEAD "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, "\n" MK_HEAD "Noter\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, "\n" MK_HEAD "Not 2 Medelantal anställda\n");
+ buf_line(t, "Medelantalet anställda under räkenskapsåret har uppgått"
+ " till %s.\n", employees && *employees ? employees
+ : "[antal]");
+ if (notes && *notes) {
+ buf_line(t, "\n" MK_HEAD "Not 3 Övriga upplysningar\n");
+ ar_wrap(t, notes);
+ }
+
+ buf_line(t, "\n" MK_HEAD "Underskrifter\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");
+}
+
+/* The pager text carries style markers that must not reach the saved file. */
+static void strip_markup(const char *in, struct buf *out)
+{
+ for (const char *p = in; *p;) {
+ const char *nl = strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (len && p[0] >= TUI_MARK_HEADING && p[0] <= TUI_MARK_STICKY) {
+ p++;
+ len--;
+ }
+ buf_append(out, p, len);
+ if (!nl)
+ break;
+ buf_append(out, "\n", 1);
+ p = nl + 1;
+ }
+}
+
+static void arsredovisning_save(struct app *a, const char *text)
+{
+ const char *home = getenv("HOME");
+ char def[512];
+ snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : ".");
+ struct stat sb;
+ if (stat(def, &sb) != 0 || !S_ISDIR(sb.st_mode))
+ snprintf(def, sizeof def, "%s", home && *home ? home : ".");
+ char dirbuf[512]; char *dir = tui_prompt_into(dirbuf, sizeof dirbuf, "Spara årsredovisningen i: ", def, 0) ? dirbuf : NULL;
+ if (!dir || !*dir)
+ return;
+ char full[640];
+ if (dir[0] == '~' && (dir[1] == '/' || dir[1] == '\0'))
+ snprintf(full, sizeof full, "%s%s", home ? home : "", dir + 1);
+ else
+ snprintf(full, sizeof full, "%s", dir);
+ size_t fl = strlen(full);
+ if (fl && full[fl - 1] != '/')
+ snprintf(full + fl, sizeof full - fl, "/");
+ 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 %s.txt", *name ? name : "utkast");
+ struct buf clean;
+ buf_init(&clean);
+ strip_markup(text, &clean);
+ FILE *f = fopen(full, "wb");
+ size_t n = clean.len;
+ if (!f || fwrite(clean.p, 1, n, f) != n) {
+ if (f)
+ fclose(f);
+ buf_free(&clean);
+ tui_message("Årsredovisning", "Kunde inte skriva %s: %s", full,
+ strerror(errno));
+ return;
+ }
+ fclose(f);
+ buf_free(&clean);
+ tui_message("Årsredovisning", "Sparat: %s\n\nFilen är ett utkast i textform"
+ " att redigera vidare (t.ex. i Word).", full);
+}
+
+static void arsredovisning_screen(struct app *a)
+{
+ for (;;) {
+ 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 *bresp = client_rpc(&a->conn, "report.balance_sheet", a->session,
+ 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;
+ }
+ 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 *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 (bs && nyears > 0)
+ fmt_arsredovisning(&t, a, onr && *onr ? onr : "[org.nr]",
+ 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);
+ int again =
+ tui_pager("Årsredovisning (K2)", (const char *)t.p, "s = spara");
+ if (again == 2)
+ arsredovisning_save(a, (const char *)t.p);
+ buf_free(&t);
+ free(onr);
+ free(city);
+ free(desc);
+ yyjson_doc_free(bd);
+ yyjson_doc_free(od);
+ yyjson_doc_free(yd);
+ free(bresp);
+ free(oresp);
+ free(yresp);
+ free(mresp);
+ if (again != 1 && again != 2)
+ return;
+ }
+}
diff --git a/clients/screens_dashboard.c b/clients/screens_dashboard.c
new file mode 100644
index 0000000..0c133b2
--- /dev/null
+++ b/clients/screens_dashboard.c
@@ -0,0 +1,354 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+void update_status(struct app *a)
+{
+ char st[512], right[160];
+ snprintf(st, sizeof st, "%s | %s - %s | %s | %s", a->org_name,
+ a->fy_start, a->fy_end, a->role, a->username);
+ if (g_server_version[0])
+ snprintf(right, sizeof right, "tui %s srv %s", BOKF_VERSION,
+ g_server_version);
+ else
+ snprintf(right, sizeof right, "tui %s", BOKF_VERSION);
+ tui_set_status(st, right);
+}
+
+/* Create a new fiscal year. Returns the new id (>0) or 0. */
+static int64_t fy_new_form(struct app *a)
+{
+ static int sel = 0;
+ char label[32] = "", start[16] = "", end[16] = "";
+ char *resp =
+ client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ size_t n = jarr_size(resp, "result.items");
+ char max_end[16] = "";
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.end_date", i);
+ char *e = jstr_dup(resp, path);
+ if (e && strcmp(e, max_end) > 0)
+ snprintf(max_end, sizeof max_end, "%s", e);
+ free(e);
+ }
+ if (max_end[0])
+ util_date_add_days(max_end, 1, start, sizeof start);
+ }
+ free(resp);
+ if (!start[0]) {
+ time_t t = time(NULL);
+ struct tm tm;
+ gmtime_r(&t, &tm);
+ int y = tm.tm_year + 1900;
+ if (y < 1900 || y > 2200)
+ y = 2026;
+ snprintf(start, sizeof start, "%04d-01-01", y);
+ }
+ util_date_add_months(start, 12, end, sizeof end);
+ util_date_add_days(end, -1, end, sizeof end);
+ int sy = 0, sm = 0, sd = 0, ey = 0, em = 0, ed = 0;
+ util_date_parse(start, &sy, &sm, &sd);
+ util_date_parse(end, &ey, &em, &ed);
+ if (sm == 1 && sd == 1)
+ snprintf(label, sizeof label, "%d", ey);
+ else
+ snprintf(label, sizeof label, "%d/%d", sy, ey);
+
+ struct tui_form_field ff[3];
+ for (;;) {
+ memset(ff, 0, sizeof ff);
+ ff[0].label = "Etikett";
+ ff[0].value = label;
+ ff[0].cap = sizeof label;
+ ff[0].kind = TUI_F_TEXT;
+ ff[1].label = "Startdatum";
+ ff[1].value = start;
+ ff[1].cap = sizeof start;
+ ff[1].kind = TUI_F_DATE;
+ ff[2].label = "Slutdatum";
+ ff[2].value = end;
+ ff[2].cap = sizeof end;
+ ff[2].kind = TUI_F_DATE;
+ int r = tui_form_run("Nytt räkenskapsår", ff, 3, 1, &sel);
+ if (r == TUI_FORM_BACK)
+ return 0;
+ if (r != TUI_FORM_REFRESH && r != TUI_FORM_SUBMIT)
+ continue; /* a field was edited; keep the form up */
+ if (!label[0]) {
+ tui_message("Räkenskapsår", "Etiketten får inte vara tom.");
+ continue;
+ }
+ if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) {
+ tui_message("Räkenskapsår",
+ "Start- och slutdatum måste vara ÅÅÅÅ-MM-DD.");
+ continue;
+ }
+ if (strcmp(start, end) >= 0) {
+ tui_message("Räkenskapsår",
+ "Startdatum måste ligga före slutdatum.");
+ continue;
+ }
+ if (r == TUI_FORM_REFRESH) {
+ tui_message("Validering OK", "%s %s - %s", label, start, end);
+ 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_strcpy(d, o, "label", label);
+ yyjson_mut_obj_add_strcpy(d, o, "start_date", start);
+ yyjson_mut_obj_add_strcpy(d, o, "end_date", end);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *oresp = client_rpc(&a->conn, "fiscal_year.open", a->session,
+ a->org, args);
+ free(args);
+ if (oresp && client_ok(oresp)) {
+ int64_t id = jint_val(oresp, "result.id", 0);
+ tui_message("Räkenskapsår", "%s skapat.", label);
+ free(oresp);
+ return id;
+ }
+ show_error("Kunde inte skapa räkenskapsåret", oresp);
+ free(oresp);
+ }
+}
+
+/* Pick which fiscal year the screens work in. Shown as its real range so
+ broken fiscal years (not starting in January) are obvious. Ctrl+A closes
+ or reopens the highlighted year. */
+static void select_fiscal_year(struct app *a)
+{
+ int64_t prefer = a->fy;
+ int cursor = -1;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp =
+ client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Räkenskapsår", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ if (n == 0) {
+ free(resp);
+ int64_t id = fy_new_form(a);
+ if (id > 0)
+ prefer = id;
+ continue;
+ }
+ char **lines = xcalloc(n, sizeof(char *));
+ int64_t *ids = xcalloc(n, sizeof(int64_t));
+ int *closed = xcalloc(n, sizeof(int));
+ char (*ranges)[40] = xcalloc(n, sizeof *ranges);
+ char (*labels)[64] = xcalloc(n, sizeof *labels);
+ int sel_default = 0;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ ids[i] = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.label", i);
+ char *label = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.start_date", i);
+ char *sdate = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.end_date", i);
+ char *edate = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.status", i);
+ char *status = jstr_dup(resp, path);
+ snprintf(ranges[i], sizeof ranges[i], "%s - %s",
+ sdate ? sdate : "?", edate ? edate : "?");
+ snprintf(labels[i], sizeof labels[i], "%s", label ? label : "");
+ closed[i] = strcmp(status ? status : "", "closed") == 0;
+ char line[256], stbuf[16];
+ snprintf(stbuf, sizeof stbuf, "%s", closed[i] ? "stängd" : "öppen");
+ tui_pad_field(stbuf, sizeof stbuf, 6);
+ snprintf(line, sizeof line, "%-25s %s %s", ranges[i], stbuf,
+ labels[i]);
+ lines[i] = xstrdup(line);
+ if (ids[i] == prefer)
+ sel_default = (int)i;
+ free(label);
+ free(sdate);
+ free(edate);
+ free(status);
+ }
+ int start = cursor >= 0 ? cursor : sel_default;
+ if (start >= (int)n)
+ start = (int)n - 1;
+ int sel = tui_select_list("Räkenskapsår", lines, (int)n, start, 0,
+ &cursor, 1, NULL, 1);
+ for (size_t i = 0; i < n; i++)
+ free(lines[i]);
+ free(lines);
+ free(resp);
+ if (sel == -4) {
+ free(ids);
+ free(closed);
+ free(ranges);
+ free(labels);
+ int64_t id = fy_new_form(a);
+ if (id > 0)
+ prefer = id;
+ continue;
+ }
+ if (sel == -7) {
+ if (cursor >= 0 && (size_t)cursor < n) {
+ char label[192];
+ snprintf(label, sizeof label, "%s räkenskapsåret %s? (j/n): ",
+ closed[cursor] ? "Återöppna" : "Avsluta",
+ labels[cursor]);
+ char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, label, "n", 0) ? ansbuf : NULL;
+ if (ans && (ans[0] == 'j' || ans[0] == 'J')) {
+ char args[64];
+ snprintf(args, sizeof args,
+ "{\"id\":%lld,\"confirm\":true}",
+ (long long)ids[cursor]);
+ char *r = client_rpc(&a->conn,
+ closed[cursor]
+ ? "fiscal_year.reopen"
+ : "fiscal_year.close",
+ a->session, a->org, args);
+ if (r && client_ok(r))
+ tui_message("Räkenskapsår", "Räkenskapsåret %s är %s.",
+ labels[cursor],
+ closed[cursor] ? "återöppnat" : "avslutat");
+ else
+ show_error("Kunde inte ändra status", r);
+ free(r);
+ }
+ }
+ free(ids);
+ free(closed);
+ free(ranges);
+ free(labels);
+ continue;
+ }
+ if (sel < 0) {
+ free(ids);
+ free(closed);
+ free(ranges);
+ free(labels);
+ return;
+ }
+ a->fy = ids[sel];
+ snprintf(a->fy_label, sizeof a->fy_label, "%s", labels[sel]);
+ /* range is "start - end" */
+ snprintf(a->fy_start, sizeof a->fy_start, "%.10s", ranges[sel]);
+ snprintf(a->fy_end, sizeof a->fy_end, "%s", ranges[sel] + 13);
+ free(ids);
+ free(closed);
+ free(ranges);
+ free(labels);
+ a->voucher_sel = 0;
+ update_status(a);
+ return;
+ }
+}
+static const char *const MAIN_ITEMS[] = {
+ MK_SECTION "Bokföring",
+ "Verifikat", "Underlag (inkorg)", "Bankavstämning",
+ MK_SECTION "Fakturering",
+ "Fakturor",
+ MK_SECTION "Rapporter & bokslut",
+ "Rapporter", "Bokslut",
+ MK_SECTION "Register",
+ "Mallar", "Kunder", "Momsregler", "Företagsuppgifter", "Inställningar",
+ MK_SECTION "Räkenskapsår",
+ "Ingående balans", "Räkenskapsår",
+ MK_SECTION "Övrigt",
+ "Revision", "Logga ut / avsluta",
+};
+
+void dashboard(struct app *a)
+{
+ static int last_sel = 0;
+ for (;;) {
+ if (g_reload)
+ return;
+ int sel = tui_menu("Bokf", MAIN_ITEMS,
+ (int)(sizeof MAIN_ITEMS / sizeof MAIN_ITEMS[0]), 1,
+ &last_sel);
+ snprintf(g_scene, sizeof g_scene, "%s", "dashboard");
+ if (g_quit || g_reload)
+ return;
+ if (sel == -4) {
+ vouchers_new(a);
+ continue;
+ }
+ if (sel == -5)
+ return;
+ if (sel < 0)
+ continue; /* Esc/back at the top level never exits */
+ switch (sel) {
+ case 1:
+ open_scene(a, "vouchers");
+ break;
+ case 2:
+ open_scene(a, "inbox");
+ break;
+ case 3:
+ open_scene(a, "bank");
+ break;
+ case 5:
+ open_scene(a, "invoices");
+ break;
+ case 7:
+ open_scene(a, "reports");
+ break;
+ case 8:
+ open_scene(a, "bokslut");
+ break;
+ case 10:
+ open_scene(a, "templates");
+ break;
+ case 11:
+ open_scene(a, "customers");
+ break;
+ case 12:
+ open_scene(a, "rules");
+ break;
+ case 13:
+ open_scene(a, "company");
+ break;
+ case 14:
+ open_scene(a, "settings");
+ break;
+ case 16:
+ open_scene(a, "ib");
+ break;
+ case 17:
+ select_fiscal_year(a);
+ break;
+ case 19:
+ open_scene(a, "audit");
+ break;
+ case 20:
+ return;
+ default:
+ break; /* section header */
+ }
+ }
+}
diff --git a/clients/screens_ib.c b/clients/screens_ib.c
new file mode 100644
index 0000000..ca35562
--- /dev/null
+++ b/clients/screens_ib.c
@@ -0,0 +1,354 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+/* ------------------------------------------------------------------ */
+/* ingående balans (series IB) */
+/* ------------------------------------------------------------------ */
+
+struct tui_ibrow {
+ char account[16];
+ char amount[32];
+ char text[64];
+};
+
+/* Loads the net IB per account for the active fiscal year. */
+/* Effective opening balances of the selected year: the year's IB vouchers
+ plus all earlier movements, exactly what the reports show. */
+static int ib_load(struct app *a, char ***out_acc, int64_t **out_amt,
+ int *out_n)
+{
+ *out_acc = NULL;
+ *out_amt = NULL;
+ *out_n = 0;
+ char args[128];
+ snprintf(args, sizeof args,
+ "{\"fiscal_year\":%lld,\"include_zero\":true}",
+ (long long)a->fy);
+ char *resp = client_rpc(&a->conn, "report.trial_balance", a->session,
+ a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Ingående balans", resp);
+ free(resp);
+ return -1;
+ }
+ size_t n = jarr_size(resp, "result.accounts");
+ char **accs = xcalloc(n ? n : 1, sizeof(char *));
+ int64_t *amts = xcalloc(n ? n : 1, sizeof(int64_t));
+ int count = 0;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.accounts.%zu.ib_ore", i);
+ int64_t ib = jint_val(resp, path, 0);
+ if (ib == 0)
+ continue;
+ snprintf(path, sizeof path, "result.accounts.%zu.account", i);
+ char *acc = jstr_dup(resp, path);
+ if (!acc)
+ continue;
+ accs[count] = acc;
+ amts[count] = ib;
+ count++;
+ }
+ free(resp);
+ *out_acc = accs;
+ *out_amt = amts;
+ *out_n = count;
+ return 0;
+}
+
+static int64_t ib_old_lookup(char **accs, int64_t *amts, int n,
+ const char *account)
+{
+ for (int i = 0; i < n; i++)
+ if (strcmp(accs[i], account) == 0)
+ return amts[i];
+ return 0;
+}
+
+struct ibform {
+ struct app *a;
+ struct tui_rt rt;
+ struct tui_ibrow rows[64];
+ char **old_acc;
+ int64_t *old_amt;
+ int nold;
+};
+
+static void ib_cell(void *ud, int row, int col, char **buf, size_t *cap)
+{
+ struct ibform *ib = ud;
+ struct tui_ibrow *r = &ib->rows[row];
+ if (col == 0) {
+ *buf = r->account;
+ *cap = sizeof r->account;
+ } else if (col == 2) {
+ *buf = r->amount;
+ *cap = sizeof r->amount;
+ } else {
+ *buf = r->text;
+ *cap = sizeof r->text;
+ }
+}
+
+static void ib_info(void *ud, int row, char *buf, size_t n)
+{
+ struct ibform *ib = ud;
+ const char *nm = acct_name(ib->a, ib->rows[row].account);
+ snprintf(buf, n, "%s", nm ? nm : "");
+}
+
+static void ib_footer(void *ud, char *buf, size_t n)
+{
+ (void)ud;
+ snprintf(buf, n, "Positiva belopp = debet, negativa = kredit. Summan ska"
+ " bli noll.");
+}
+
+static const char *ib_check(struct ibform *ib, int *used_out, char *msg,
+ size_t msglen)
+{
+ int64_t sum = 0;
+ int used = 0;
+ for (int i = 0; i < ib->rt.nrows; i++) {
+ if (!ib->rows[i].account[0])
+ continue;
+ if (!acct_exists(ib->a, ib->rows[i].account)) {
+ snprintf(msg, msglen, "Konto %.15s finns inte (rad %d).",
+ ib->rows[i].account, i + 1);
+ return msg;
+ }
+ double kr = 0;
+ if (ib->rows[i].amount[0] &&
+ !parse_x_double(ib->rows[i].amount, &kr)) {
+ snprintf(msg, msglen, "Ogiltigt belopp på rad %d: '%.20s'.", i + 1,
+ ib->rows[i].amount);
+ return msg;
+ }
+ sum += (int64_t)llround(kr * 100.0);
+ used++;
+ }
+ if (used == 0)
+ return "Ange minst ett konto.";
+ if (sum != 0) {
+ snprintf(msg, msglen, "Summan måste bli noll (nu %lld öre).",
+ (long long)sum);
+ return msg;
+ }
+ *used_out = used;
+ return NULL;
+}
+
+/* Posts the delta against the existing IB so nothing is ever edited. */
+static int ib_post_delta(struct ibform *ib)
+{
+ struct app *a = ib->a;
+ 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, "date", a->fy_start);
+ yyjson_mut_obj_add_strcpy(d, o, "description", "Ingående balans");
+ yyjson_mut_obj_add_strcpy(d, o, "series", "IB");
+ char *ref = util_random_id("tui-", 8);
+ yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref);
+ free(ref);
+ yyjson_mut_val *arr = yyjson_mut_arr(d);
+ int posted = 0;
+ for (int i = 0; i < ib->rt.nrows; i++) {
+ if (!ib->rows[i].account[0])
+ continue;
+ double kr = 0;
+ if (ib->rows[i].amount[0] &&
+ !parse_x_double(ib->rows[i].amount, &kr))
+ kr = 0;
+ int64_t new_amt = (int64_t)llround(kr * 100.0);
+ int64_t prev_amt =
+ ib_old_lookup(ib->old_acc, ib->old_amt, ib->nold,
+ ib->rows[i].account);
+ int64_t delta = new_amt - prev_amt;
+ if (delta == 0)
+ continue;
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "account", ib->rows[i].account);
+ yyjson_mut_obj_add_int(d, ro, "debit_ore", delta > 0 ? delta : 0);
+ yyjson_mut_obj_add_int(d, ro, "credit_ore", delta < 0 ? -delta : 0);
+ if (ib->rows[i].text[0])
+ yyjson_mut_obj_add_strcpy(d, ro, "description",
+ ib->rows[i].text);
+ posted++;
+ }
+ for (int i = 0; i < ib->nold; i++) {
+ int still = 0;
+ for (int j = 0; j < ib->rt.nrows; j++)
+ if (strcmp(ib->rows[j].account, ib->old_acc[i]) == 0) {
+ still = 1;
+ break;
+ }
+ if (!still && ib->old_amt[i] != 0) {
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "account", ib->old_acc[i]);
+ yyjson_mut_obj_add_int(d, ro, "debit_ore",
+ ib->old_amt[i] < 0 ? -ib->old_amt[i] : 0);
+ yyjson_mut_obj_add_int(d, ro, "credit_ore",
+ ib->old_amt[i] > 0 ? ib->old_amt[i] : 0);
+ posted++;
+ }
+ }
+ if (posted == 0) {
+ yyjson_mut_doc_free(d);
+ tui_message("Ingående balans", "Ingen ändring.");
+ return 0;
+ }
+ yyjson_mut_obj_add_val(d, o, "rows", arr);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp =
+ client_rpc(&a->conn, "voucher.post", a->session, a->org, args);
+ free(args);
+ if (resp && client_ok(resp)) {
+ int64_t num = jint_val(resp, "result.number", 0);
+ tui_message("Ingående balans",
+ "Sparat som IB %lld (%d justerade rader).",
+ (long long)num, posted);
+ free(resp);
+ return 1;
+ }
+ show_error("Kunde inte spara ingående balans", resp);
+ free(resp);
+ return 0;
+}
+
+static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold)
+{
+ struct ibform ib;
+ memset(&ib, 0, sizeof ib);
+ ib.a = a;
+ ib.old_acc = old_acc;
+ ib.old_amt = old_amt;
+ ib.nold = nold;
+ if (nold > 63) {
+ tui_message("Ingående balans",
+ "För många konton (%d) för att redigera här.", nold);
+ return 0;
+ }
+ for (int i = 0; i < nold && i < 63; i++) {
+ snprintf(ib.rows[i].account, sizeof ib.rows[i].account, "%s",
+ old_acc[i]);
+ tui_kr_format(old_amt[i], ib.rows[i].amount,
+ sizeof ib.rows[i].amount);
+ }
+ int text_w = COLS - 51;
+ if (text_w < 6)
+ text_w = 6;
+ struct tui_rt_col cols[4] = {
+ { "Konto", 2, 6, TUI_RT_EDIT },
+ { "Namn", 9, 24, TUI_RT_INFO },
+ { "Belopp", 34, 14, TUI_RT_EDIT },
+ { "Text", 49, text_w, TUI_RT_EDIT },
+ };
+ tui_rt_init(&ib.rt, nold > 0 ? nold : 1, 64, 4, cols, 5, ib_cell, ib_info,
+ &ib);
+ tui_rt_set_footer(&ib.rt, ib_footer);
+
+ for (;;) {
+ int rr = tui_rt_run("Ingående balans", &ib.rt,
+ "Tab = byta fält F5 = validera ^X = rensa rad"
+ " ^Enter = spara Esc = avbryt");
+ if (rr == -1)
+ return 0;
+ char msg[256];
+ int used = 0;
+ const char *problem = ib_check(&ib, &used, msg, sizeof msg);
+ if (problem) {
+ tui_message("Ingående balans", "%s", problem);
+ continue;
+ }
+ if (rr == -2) {
+ tui_message("Validering OK", "%d konton, summan är noll.", used);
+ continue;
+ }
+ if (ib_post_delta(&ib))
+ return 1;
+ }
+}
+
+struct ibpager {
+ struct app *a;
+ char **accs;
+ int64_t *amts;
+ int n;
+};
+
+static int ib_view_key(void *ud, int ch)
+{
+ struct ibpager *p = ud;
+ if (ch == 'e' || ch == 'E' || ch == TUI_KEY_CTRL_N) {
+ if (ib_form(p->a, p->accs, p->amts, p->n))
+ return TUI_HOOK_REFRESH;
+ return TUI_HOOK_STAY;
+ }
+ return TUI_HOOK_NONE;
+}
+
+/* The list is a read-only table with no selection: tui_pager shows the
+ columns and the sum as plain text, and the e/^N keys run the editor. */
+void ib_screen(struct app *a)
+{
+ for (;;) {
+ char **accs = NULL;
+ int64_t *amts = NULL;
+ int n = 0;
+ if (ib_load(a, &accs, &amts, &n) != 0)
+ return;
+ struct buf t;
+ buf_init(&t);
+ buf_line(&t, "Räkenskapsår %s ingående balans (effektiv)\n\n",
+ a->fy_label);
+ buf_line(&t, "%-7s%-53s%s\n\n", "Konto", "Namn", "Belopp");
+ int64_t sum = 0;
+ for (int i = 0; i < n; i++) {
+ sum += amts[i];
+ char amount[32], nbuf[128];
+ tui_kr_format(amts[i], amount, sizeof amount);
+ const char *nm = acct_name(a, accs[i]);
+ snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : "");
+ tui_pad_field(nbuf, sizeof nbuf, 50);
+ buf_line(&t, "%-6s %s %14s\n", accs[i], nbuf, amount);
+ }
+ if (n == 0)
+ buf_line(&t, "Ingen ingående balans registrerad ännu.\n");
+ char sumbuf[32];
+ tui_kr_format(sum, sumbuf, sizeof sumbuf);
+ buf_line(&t, "\nSumma: %s\n", sumbuf);
+ buf_append(&t, "\0", 1);
+ struct ibpager p = { a, accs, amts, n };
+ int ret = tui_pager_hook("Ingående balans", (const char *)t.p,
+ "e/^N = redigera", 0, ib_view_key, &p);
+ buf_free(&t);
+ for (int i = 0; i < n; i++)
+ free(accs[i]);
+ free(accs);
+ free(amts);
+ if (ret == 0)
+ return;
+ }
+}
diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c
new file mode 100644
index 0000000..3782a5f
--- /dev/null
+++ b/clients/screens_invoices.c
@@ -0,0 +1,1324 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+/* ------------------------------------------------------------------ */
+/* invoicing: Fakturor, Kunder */
+/* ------------------------------------------------------------------ */
+
+static void today_iso(char *buf, size_t n)
+{
+ time_t now = time(NULL);
+ struct tm tm;
+ localtime_r(&now, &tm);
+ char tmp[64];
+ snprintf(tmp, sizeof tmp, "%04d-%02d-%02d", tm.tm_year + 1900,
+ tm.tm_mon + 1, tm.tm_mday);
+ size_t len = strlen(tmp);
+ if (len >= n)
+ len = n ? n - 1 : 0;
+ memcpy(buf, tmp, len);
+ if (n)
+ buf[len] = '\0';
+}
+
+/* Directory for generated invoice PDFs. */
+static void invoice_cache_dir(char *buf, size_t n)
+{
+ const char *cache = getenv("XDG_CACHE_HOME");
+ if (cache && *cache) {
+ snprintf(buf, n, "%s/bokf", cache);
+ return;
+ }
+ snprintf(buf, n, "/tmp");
+}
+
+/* Keep a customer-supplied name from escaping the directory. */
+static void safe_fname(const char *in, char *out, size_t n)
+{
+ size_t o = 0;
+ for (const char *p = in ? in : ""; *p && o + 1 < n; p++) {
+ unsigned char c = (unsigned char)*p;
+ if (c < 32 || c == '/')
+ c = '-';
+ out[o++] = (char)c;
+ }
+ out[o] = '\0';
+}
+
+static int write_pdf_b64(const char *b64, const char *path)
+{
+ unsigned char *data = NULL;
+ size_t len = 0;
+ if (!b64 || util_b64_decode(b64, strlen(b64), &data, &len) != 0)
+ return -1;
+ FILE *fp = fopen(path, "wb");
+ if (!fp) {
+ free(data);
+ return -1;
+ }
+ size_t wrote = fwrite(data, 1, len, fp);
+ fclose(fp);
+ free(data);
+ return wrote == len ? 0 : -1;
+}
+
+/* Open a saved PDF with xdg-open when a desktop session is present;
+ otherwise (or on fork failure) show where it was saved. */
+static void open_pdf(const char *path)
+{
+ const char *display = getenv("DISPLAY");
+ const char *wayland = getenv("WAYLAND_DISPLAY");
+ if ((!display || !*display) && (!wayland || !*wayland)) {
+ tui_message("Faktura PDF", "Sparad: %s", path);
+ return;
+ }
+ pid_t pid = fork();
+ if (pid == 0) {
+ pid_t gc = fork();
+ if (gc == 0) {
+ setsid();
+ execlp("xdg-open", "xdg-open", path, (char *)NULL);
+ _exit(127);
+ }
+ _exit(gc < 0 ? 1 : 0);
+ }
+ if (pid > 0)
+ waitpid(pid, NULL, 0);
+ else
+ tui_message("Faktura PDF", "Sparad: %s", path);
+}
+
+static void save_and_open_pdf(const char *b64, const char *filename)
+{
+ char dir[512], path[700], safe[600];
+ invoice_cache_dir(dir, sizeof dir);
+ safe_fname(filename, safe, sizeof safe);
+ if (snprintf(path, sizeof path, "%s/%s", dir, safe) >= (int)sizeof path) {
+ tui_message("Faktura PDF", "Sökvägen blir för lång.");
+ return;
+ }
+ config_mkdirs(path);
+ if (write_pdf_b64(b64, path) != 0) {
+ tui_message("Faktura PDF", "Kunde inte spara %s", path);
+ return;
+ }
+ open_pdf(path);
+}
+
+static const char *invoice_status_sv(const char *s)
+{
+ if (s && strcmp(s, "credited") == 0)
+ return "krediterad";
+ return "utfärdad";
+}
+
+static void qty_sv(int64_t milli, char *buf, size_t n)
+{
+ int64_t whole = milli / 1000;
+ int64_t frac = milli % 1000;
+ char tmp[64];
+ if (frac == 0) {
+ snprintf(tmp, sizeof tmp, "%lld", (long long)whole);
+ } else {
+ if (frac < 0)
+ frac = -frac;
+ char fr[16];
+ snprintf(fr, sizeof fr, "%03lld", (long long)frac);
+ while (fr[0] && fr[strlen(fr) - 1] == '0')
+ fr[strlen(fr) - 1] = '\0';
+ snprintf(tmp, sizeof tmp, "%lld,%s", (long long)whole, fr);
+ }
+ size_t len = strlen(tmp);
+ if (len >= n)
+ len = n ? n - 1 : 0;
+ memcpy(buf, tmp, len);
+ if (n)
+ buf[len] = '\0';
+}
+
+static const char *vat_label(const char *code)
+{
+ if (!code)
+ return "";
+ if (strcmp(code, "25") == 0)
+ return "25%";
+ if (strcmp(code, "12") == 0)
+ return "12%";
+ if (strcmp(code, "6") == 0)
+ return "6%";
+ if (strcmp(code, "0") == 0)
+ return "0%";
+ if (strcmp(code, "rc") == 0)
+ return "Omvänd moms";
+ if (strcmp(code, "eu") == 0)
+ return "EU";
+ return code;
+}
+
+static const char *vat_code_of(const char *s)
+{
+ if (!s || !*s)
+ return "25";
+ if (strcmp(s, "25") == 0 || strcmp(s, "25%") == 0)
+ return "25";
+ if (strcmp(s, "12") == 0 || strcmp(s, "12%") == 0)
+ return "12";
+ if (strcmp(s, "6") == 0 || strcmp(s, "6%") == 0)
+ return "6";
+ if (strcmp(s, "0") == 0 || strcmp(s, "0%") == 0)
+ return "0";
+ if (strcasecmp(s, "rc") == 0 || strcasecmp(s, "Omvänd moms") == 0)
+ return "rc";
+ if (strcasecmp(s, "eu") == 0 || strcasecmp(s, "EU") == 0)
+ return "eu";
+ return NULL;
+}
+
+static const char *unit_code_of(const char *s)
+{
+ if (!s || !*s)
+ return "st";
+ if (strcasecmp(s, "st") == 0)
+ return "st";
+ if (strcasecmp(s, "tim") == 0)
+ return "tim";
+ if (strcasecmp(s, "dag") == 0)
+ return "dag";
+ if (strcasecmp(s, "km") == 0)
+ return "km";
+ return NULL;
+}
+
+static int unit_index(const char *s)
+{
+ static const char *const opts[] = { "st", "tim", "dag", "km" };
+ if (s && *s) {
+ for (int i = 0; i < 4; i++)
+ if (strcmp(s, opts[i]) == 0)
+ return i;
+ }
+ return 0;
+}
+
+static int qty_is_valid(const char *s)
+{
+ if (!s || !*s)
+ return 0;
+ int sep = 0, frac = 0, intd = 0, nonzero = 0;
+ for (const char *p = s; *p; p++) {
+ if (*p == ',' || *p == '.') {
+ if (sep)
+ return 0;
+ sep = 1;
+ continue;
+ }
+ if (*p < '0' || *p > '9')
+ return 0;
+ if (*p != '0')
+ nonzero = 1;
+ if (sep) {
+ frac++;
+ if (frac > 3)
+ return 0;
+ } else if (++intd > 15) {
+ return 0;
+ }
+ }
+ if (!intd || (sep && frac == 0))
+ return 0;
+ return nonzero;
+}
+
+/* ---------------------------- Kunder ------------------------------ */
+
+static int customer_save(struct app *a, int64_t id, const char *name,
+ const char *address, const char *postal,
+ const char *city, const char *vat, const char *email,
+ const char *your_ref, const char *payment,
+ const char *notes, int dry)
+{
+ if (!name || !*name) {
+ tui_message("Kund", "Namn krävs.");
+ return 0;
+ }
+ if (!payment || !*payment) {
+ tui_message("Kund", "Betalningsdagar krävs.");
+ return 0;
+ }
+ for (const char *p = payment; *p; p++) {
+ if (*p < '0' || *p > '9') {
+ tui_message("Kund", "Betalningsdagar ska vara ett heltal.");
+ return 0;
+ }
+ }
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ if (id > 0)
+ yyjson_mut_obj_add_int(d, o, "id", id);
+ yyjson_mut_obj_add_strcpy(d, o, "name", name);
+ yyjson_mut_obj_add_strcpy(d, o, "address", address);
+ yyjson_mut_obj_add_strcpy(d, o, "postal_code", postal);
+ yyjson_mut_obj_add_strcpy(d, o, "city", city);
+ yyjson_mut_obj_add_strcpy(d, o, "vat_nr", vat);
+ yyjson_mut_obj_add_strcpy(d, o, "email", email);
+ yyjson_mut_obj_add_strcpy(d, o, "your_ref", your_ref);
+ yyjson_mut_obj_add_int(d, o, "payment_days", strtoll(payment, NULL, 10));
+ yyjson_mut_obj_add_strcpy(d, o, "notes", notes);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ const char *cmd = id > 0 ? "customer.update" : "customer.create";
+ char *r = dry ? rpc_dry(a, cmd, args)
+ : client_rpc(&a->conn, cmd, a->session, a->org, args);
+ free(args);
+ int ok = r && client_ok(r);
+ if (ok)
+ tui_message("Kund", dry ? "Uppgifterna är giltiga." : "Sparat.");
+ else
+ show_error(dry ? "Kunden kunde inte valideras" : "Kunde inte spara",
+ r);
+ free(r);
+ return ok;
+}
+
+static void customer_form(struct app *a, int64_t id)
+{
+ char name[256] = "", address[512] = "", postal[32] = "", city[128] = "";
+ char vat[64] = "", email[256] = "", your_ref[128] = "";
+ char payment[16] = "30", notes[2048] = "";
+ if (id > 0) {
+ char args[64];
+ snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id);
+ char *resp =
+ client_rpc(&a->conn, "customer.get", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Kund", resp);
+ free(resp);
+ return;
+ }
+ struct {
+ const char *key;
+ char *dst;
+ size_t cap;
+ } fields[] = {
+ { "name", name, sizeof name },
+ { "address", address, sizeof address },
+ { "postal_code", postal, sizeof postal },
+ { "city", city, sizeof city },
+ { "vat_nr", vat, sizeof vat },
+ { "email", email, sizeof email },
+ { "your_ref", your_ref, sizeof your_ref },
+ { "notes", notes, sizeof notes },
+ };
+ for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) {
+ char path[64], *v;
+ snprintf(path, sizeof path, "result.%s", fields[i].key);
+ v = jstr_dup(resp, path);
+ if (v) {
+ snprintf(fields[i].dst, fields[i].cap, "%s", v);
+ free(v);
+ }
+ }
+ snprintf(payment, sizeof payment, "%lld",
+ (long long)jint_val(resp, "result.payment_days", 30));
+ free(resp);
+ }
+ const char *labels[] = { "Namn", "Adress",
+ "Postnummer", "Ort",
+ "Momsreg.nr", "E-post",
+ "Er referens", "Betalningsdagar",
+ "Anteckningar" };
+ char *vals[] = { name, address, postal, city, vat,
+ email, your_ref, payment, notes };
+ size_t caps[] = { sizeof name, sizeof address, sizeof postal,
+ sizeof city, sizeof vat, sizeof email,
+ sizeof your_ref, sizeof payment, sizeof notes };
+ struct tui_form_field ff[10];
+ memset(ff, 0, sizeof ff);
+ for (int i = 0; i < 9; i++) {
+ ff[i].label = labels[i];
+ ff[i].value = vals[i];
+ ff[i].cap = caps[i];
+ ff[i].kind = TUI_F_TEXT;
+ }
+ ff[9].label = "";
+ ff[9].value = "Spara";
+ ff[9].kind = TUI_F_ACTION;
+ static int sel = 0;
+ for (;;) {
+ int r = tui_form_run(id > 0 ? "Kund" : "Ny kund", ff, 10, 1, &sel);
+ if (r == TUI_FORM_BACK)
+ return;
+ if (r == TUI_FORM_REFRESH) {
+ customer_save(a, id, name, address, postal, city, vat, email,
+ your_ref, payment, notes, 1);
+ continue;
+ }
+ if (r == TUI_FORM_SUBMIT || r == 9) {
+ if (customer_save(a, id, name, address, postal, city, vat, email,
+ your_ref, payment, notes, 0))
+ return;
+ }
+ }
+}
+
+static void customer_archive_toggle(struct app *a, int64_t id, const char *name,
+ int active)
+{
+ if (!tui_confirm("Kund", active ? "Arkivera kund %s?" :
+ "Återaktivera kund %s?",
+ name ? name : ""))
+ return;
+ char args[128];
+ snprintf(args, sizeof args, "{\"id\":%lld,\"active\":%s}", (long long)id,
+ active ? "false" : "true");
+ char *r =
+ client_rpc(&a->conn, "customer.archive", a->session, a->org, args);
+ if (r && client_ok(r))
+ tui_message("Kund", active ? "Arkiverad." : "Återaktiverad.");
+ else
+ show_error("Kund", r);
+ free(r);
+}
+
+void customers_screen(struct app *a)
+{
+ char **items = NULL;
+ char **names = NULL;
+ int64_t *ids = NULL;
+ unsigned char *active = NULL;
+ size_t n = 0;
+ int fetch = 1;
+ for (;;) {
+ if (g_quit) {
+ for (size_t i = 0; i < n; i++) {
+ free(items[i]);
+ free(names[i]);
+ }
+ free(items);
+ free(names);
+ free(ids);
+ free(active);
+ return;
+ }
+ if (fetch) {
+ for (size_t i = 0; i < n; i++) {
+ free(items[i]);
+ free(names[i]);
+ }
+ free(items);
+ free(names);
+ free(ids);
+ free(active);
+ items = NULL;
+ names = NULL;
+ ids = NULL;
+ active = NULL;
+ n = 0;
+ fetch = 0;
+ char *resp = client_rpc(&a->conn, "customer.list", a->session,
+ a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Kunder", resp);
+ free(resp);
+ return;
+ }
+ n = jarr_size(resp, "result.items");
+ items = xcalloc(n + 1, sizeof(char *));
+ names = xcalloc(n + 1, sizeof(char *));
+ ids = xcalloc(n ? n : 1, sizeof(int64_t));
+ active = xcalloc(n + 1, 1);
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[768];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ ids[i] = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.active", i);
+ active[i] = (unsigned char)jbool_val(resp, path, 1);
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *name = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.postal_code", i);
+ char *postal = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.city", i);
+ char *city = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.vat_nr", i);
+ char *vat = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.payment_days", i);
+ int64_t days = jint_val(resp, path, 0);
+ char nbuf[256], pbuf[256], vbuf[80];
+ snprintf(nbuf, sizeof nbuf, "%s", name ? name : "");
+ tui_pad_field(nbuf, sizeof nbuf, 30);
+ snprintf(pbuf, sizeof pbuf, "%s %s", postal ? postal : "",
+ city ? city : "");
+ tui_pad_field(pbuf, sizeof pbuf, 30);
+ snprintf(vbuf, sizeof vbuf, "%s", vat ? vat : "");
+ tui_pad_field(vbuf, sizeof vbuf, 18);
+ snprintf(line, sizeof line, "%s %s %s %3lld dagar %s", nbuf,
+ pbuf, vbuf, (long long)days,
+ active[i] ? "" : "(arkiverad)");
+ items[i] = xstrdup(line);
+ names[i] = xstrdup(name ? name : "");
+ free(name);
+ free(postal);
+ free(city);
+ free(vat);
+ }
+ free(resp);
+ items[n] = xstrdup("+ Ny kund (^N)");
+ }
+ int start = 0;
+ if (a->customer_sel) {
+ for (size_t i = 0; i < n; i++)
+ if (ids[i] == a->customer_sel) {
+ start = (int)i;
+ break;
+ }
+ }
+ int cur = start;
+ int sel = tui_select_list("Kunder", items, (int)n + 1, start, 1, &cur,
+ 1, "arkivera/återaktivera", 0);
+ if (cur >= 0 && (size_t)cur < n)
+ a->customer_sel = ids[cur];
+ if (sel == -1) {
+ for (size_t i = 0; i < n; i++) {
+ free(items[i]);
+ free(names[i]);
+ }
+ free(items);
+ free(names);
+ free(ids);
+ free(active);
+ return;
+ }
+ if (sel == -2) {
+ fetch = 1;
+ continue;
+ }
+ if (sel == -6 && cur >= 0 && (size_t)cur < n) {
+ customer_archive_toggle(a, ids[cur], names[cur],
+ active[cur] != 0);
+ fetch = 1;
+ continue;
+ }
+ if (sel == -4 || (sel >= 0 && (size_t)sel == n)) {
+ customer_form(a, 0);
+ fetch = 1;
+ continue;
+ }
+ if (sel >= 0 && (size_t)sel < n) {
+ customer_form(a, ids[sel]);
+ fetch = 1;
+ continue;
+ }
+ }
+}
+
+/* --------------------------- Fakturor ----------------------------- */
+
+struct invctx {
+ struct app *a;
+ int64_t id;
+ int64_t number;
+ int64_t customer_id;
+ char customer[256];
+};
+
+static void inv_detail_pdf(struct invctx *ctx)
+{
+ char args[64];
+ snprintf(args, sizeof args, "{\"id\":%lld}", (long long)ctx->id);
+ char *resp = client_rpc(&ctx->a->conn, "invoice.pdf", ctx->a->session,
+ ctx->a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Kunde inte hämta PDF", resp);
+ free(resp);
+ return;
+ }
+ char *b64 = jstr_dup(resp, "result.content_base64");
+ if (b64) {
+ char fname[800];
+ snprintf(fname, sizeof fname, "Faktura %lld %s.pdf",
+ (long long)ctx->number, ctx->customer);
+ save_and_open_pdf(b64, fname);
+ free(b64);
+ }
+ free(resp);
+}
+
+static void inv_detail_send(struct invctx *ctx)
+{
+ struct app *a = ctx->a;
+ char email[256] = "";
+ if (ctx->customer_id > 0) {
+ char cargs[64];
+ snprintf(cargs, sizeof cargs, "{\"id\":%lld}",
+ (long long)ctx->customer_id);
+ char *c =
+ client_rpc(&a->conn, "customer.get", a->session, a->org, cargs);
+ if (c && client_ok(c)) {
+ char *e = jstr_dup(c, "result.email");
+ if (e) {
+ snprintf(email, sizeof email, "%s", e);
+ free(e);
+ }
+ }
+ free(c);
+ }
+ if (!email[0]) {
+ tui_message("Skicka faktura", "Kunden har ingen e-postadress.");
+ return;
+ }
+ if (!tui_confirm("Skicka faktura", "Skicka faktura %lld till %s?",
+ (long long)ctx->number, email))
+ return;
+ char args[64];
+ snprintf(args, sizeof args, "{\"id\":%lld}", (long long)ctx->id);
+ char *r =
+ client_rpc(&a->conn, "invoice.send", a->session, a->org, args);
+ if (r && client_ok(r)) {
+ char *to = jstr_dup(r, "result.sent_to");
+ tui_message("Skickat", "Faktura %lld skickad till %s.",
+ (long long)ctx->number, to && *to ? to : email);
+ free(to);
+ } else {
+ show_error("Skicka faktura", r);
+ }
+ free(r);
+}
+
+static int inv_key(void *ud, int ch)
+{
+ struct invctx *ctx = ud;
+ if (ch == 'p') {
+ inv_detail_pdf(ctx);
+ return TUI_HOOK_STAY;
+ }
+ if (ch == 's') {
+ inv_detail_send(ctx);
+ return TUI_HOOK_STAY;
+ }
+ return TUI_HOOK_NONE;
+}
+
+static void invoices_detail(struct app *a, int64_t id)
+{
+ char args[64];
+ snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id);
+ for (;;) {
+ char *resp =
+ client_rpc(&a->conn, "invoice.get", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Faktura", resp);
+ free(resp);
+ return;
+ }
+ int64_t number = jint_val(resp, "result.number", 0);
+ int64_t customer_id = jint_val(resp, "result.customer_id", 0);
+ int64_t net = jint_val(resp, "result.net_ore", 0);
+ int64_t vat = jint_val(resp, "result.vat_ore", 0);
+ int64_t total = jint_val(resp, "result.total_ore", 0);
+ char *cust = jstr_dup(resp, "result.customer_name");
+ char *status = jstr_dup(resp, "result.status");
+ char *date = jstr_dup(resp, "result.invoice_date");
+ char *due = jstr_dup(resp, "result.due_date");
+ char *delivery = jstr_dup(resp, "result.delivery_date");
+ char *your_ref = jstr_dup(resp, "result.your_ref");
+ char *our_ref = jstr_dup(resp, "result.our_ref");
+ char *notes = jstr_dup(resp, "result.notes");
+ char *ocr = jstr_dup(resp, "result.ocr");
+ char *sent_at = jstr_dup(resp, "result.last_sent_at");
+ char *sent_to = jstr_dup(resp, "result.last_sent_to");
+ struct buf t;
+ buf_init(&t);
+ buf_line(&t, MK_HEAD "Faktura %lld %s\n", (long long)number,
+ invoice_status_sv(status));
+ buf_line(&t, MK_DIM "%s\n\n", cust ? cust : "");
+ buf_line(&t, "Fakturadatum: %s\n", date ? date : "");
+ buf_line(&t, "Förfallodatum: %s\n", due ? due : "");
+ if (delivery && *delivery)
+ buf_line(&t, "Leveransdatum: %s\n", delivery);
+ if (your_ref && *your_ref)
+ buf_line(&t, "Er referens: %s\n", your_ref);
+ if (our_ref && *our_ref)
+ buf_line(&t, "Vår referens: %s\n", our_ref);
+ if (ocr && *ocr)
+ buf_line(&t, "OCR: %s\n", ocr);
+ if (sent_at && *sent_at)
+ buf_line(&t, "Skickad: %s%s%s\n", sent_at,
+ sent_to && *sent_to ? " till " : "",
+ sent_to && *sent_to ? sent_to : "");
+ if (notes && *notes)
+ buf_line(&t, "\nFritext:\n%s\n", notes);
+ buf_line(&t, "\n");
+ buf_line(&t,
+ MK_STICKY MK_HEAD
+ "%-34s %8s %-6s %12s %-13s %-18s %12s\n",
+ "Beskrivning", "Antal", "Enhet", "À-pris", "Moms", "Anm",
+ "Belopp");
+ size_t nrows = jarr_size(resp, "result.rows");
+ for (size_t i = 0; i < nrows; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.rows.%zu.description", i);
+ char *desc = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.quantity_milli", i);
+ int64_t qm = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.rows.%zu.unit", i);
+ char *unit = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.unit_price_ore", i);
+ int64_t price = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.rows.%zu.vat_code", i);
+ char *vat_code = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.note", i);
+ char *note = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.amount_ore", i);
+ int64_t amount = jint_val(resp, path, 0);
+ char dbuf[256], qbuf[24], qcol[24], ubuf[24], pcol[40],
+ vbuf[24], nbuf[128], acol[40];
+ snprintf(dbuf, sizeof dbuf, "%s", desc ? desc : "");
+ tui_pad_field(dbuf, sizeof dbuf, 34);
+ qty_sv(qm, qbuf, sizeof qbuf);
+ tui_pad_hdr(qcol, sizeof qcol, 8, qbuf);
+ snprintf(ubuf, sizeof ubuf, "%s", unit ? unit : "");
+ tui_pad_field(ubuf, sizeof ubuf, 6);
+ tui_amt_col(pcol, sizeof pcol, 12, price);
+ snprintf(vbuf, sizeof vbuf, "%s", vat_label(vat_code));
+ tui_pad_field(vbuf, sizeof vbuf, 13);
+ snprintf(nbuf, sizeof nbuf, "%s", note ? note : "");
+ tui_pad_field(nbuf, sizeof nbuf, 18);
+ tui_amt_col(acol, sizeof acol, 12, amount);
+ buf_line(&t, "%s %s %s %s %s %s %s\n", dbuf, qcol, ubuf, pcol,
+ vbuf, nbuf, acol);
+ free(desc);
+ free(unit);
+ free(vat_code);
+ free(note);
+ }
+ char netc[40], vatc[40], totc[40];
+ tui_amt_col(netc, sizeof netc, 14, net);
+ tui_amt_col(vatc, sizeof vatc, 14, vat);
+ tui_amt_col(totc, sizeof totc, 14, total);
+ buf_line(&t, "\n%46s %14s\n", "Netto", netc);
+ buf_line(&t, "%46s %14s\n", "Moms", vatc);
+ buf_line(&t, MK_HEAD "%46s %14s\n", "Att betala", totc);
+ buf_append(&t, "\0", 1);
+ struct invctx ctx;
+ memset(&ctx, 0, sizeof ctx);
+ ctx.a = a;
+ ctx.id = id;
+ ctx.number = number;
+ ctx.customer_id = customer_id;
+ snprintf(ctx.customer, sizeof ctx.customer, "%s", cust ? cust : "");
+ int ret = tui_pager_hook("Faktura", (const char *)t.p,
+ "p = visa PDF s = skicka", 0, inv_key,
+ &ctx);
+ free(cust);
+ free(status);
+ free(date);
+ free(due);
+ free(delivery);
+ free(your_ref);
+ free(our_ref);
+ free(notes);
+ free(ocr);
+ free(sent_at);
+ free(sent_to);
+ buf_free(&t);
+ free(resp);
+ if (ret != 1)
+ return;
+ }
+}
+
+#define IFORM_ROWS 64
+
+struct irow {
+ char description[128];
+ char quantity[16];
+ char unit[8];
+ char price[24];
+ char vat[16];
+ char note[64];
+};
+
+struct iform {
+ struct app *a;
+ struct tui_rt rt;
+ struct irow rows[IFORM_ROWS];
+ char date[16];
+ char due[16];
+ char delivery[16];
+ char your_ref[64];
+ char our_ref[64];
+ char notes[256];
+ char auto_due[16];
+ char auto_ref[64];
+ size_t ncust;
+ int64_t *cust_ids;
+ int *cust_days;
+ char **cust_names;
+ char **cust_emails;
+ char **cust_refs;
+ int cust_sel;
+ int issued;
+};
+
+static void iform_customers_free(struct iform *f)
+{
+ for (size_t i = 0; i < f->ncust; i++) {
+ free(f->cust_names[i]);
+ free(f->cust_emails[i]);
+ free(f->cust_refs[i]);
+ }
+ free(f->cust_names);
+ free(f->cust_emails);
+ free(f->cust_refs);
+ free(f->cust_ids);
+ free(f->cust_days);
+ f->cust_names = NULL;
+ f->cust_emails = NULL;
+ f->cust_refs = NULL;
+ f->cust_ids = NULL;
+ f->cust_days = NULL;
+ f->ncust = 0;
+}
+
+static int iform_load_customers(struct iform *f)
+{
+ char *resp = client_rpc(&f->a->conn, "customer.list", f->a->session,
+ f->a->org, "{\"active_only\":true}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Kunder", resp);
+ free(resp);
+ return -1;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ f->cust_ids = xcalloc(n ? n : 1, sizeof(int64_t));
+ f->cust_days = xcalloc(n ? n : 1, sizeof(int));
+ f->cust_names = xcalloc(n ? n : 1, sizeof(char *));
+ f->cust_emails = xcalloc(n ? n : 1, sizeof(char *));
+ f->cust_refs = xcalloc(n ? n : 1, sizeof(char *));
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ f->cust_ids[i] = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.payment_days", i);
+ f->cust_days[i] = (int)jint_val(resp, path, 30);
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *nm = jstr_dup(resp, path);
+ f->cust_names[i] = nm ? nm : xstrdup("");
+ snprintf(path, sizeof path, "result.items.%zu.email", i);
+ char *em = jstr_dup(resp, path);
+ f->cust_emails[i] = em ? em : xstrdup("");
+ snprintf(path, sizeof path, "result.items.%zu.your_ref", i);
+ char *rf = jstr_dup(resp, path);
+ f->cust_refs[i] = rf ? rf : xstrdup("");
+ }
+ f->ncust = n;
+ free(resp);
+ return 0;
+}
+
+/* Re-apply the customer-derived defaults only while the user has not
+ changed them (they still equal what the previous customer produced). */
+static void iform_customer_changed(struct iform *f)
+{
+ if (f->ncust == 0 || f->cust_sel < 0 || (size_t)f->cust_sel >= f->ncust)
+ return;
+ char today[16], newdue[16];
+ today_iso(today, sizeof today);
+ util_date_add_days(today, f->cust_days[f->cust_sel], newdue,
+ sizeof newdue);
+ if (!f->due[0] || strcmp(f->due, f->auto_due) == 0)
+ snprintf(f->due, sizeof f->due, "%s", newdue);
+ snprintf(f->auto_due, sizeof f->auto_due, "%s", newdue);
+ const char *ref = f->cust_refs[f->cust_sel];
+ if (!f->your_ref[0] || strcmp(f->your_ref, f->auto_ref) == 0)
+ snprintf(f->your_ref, sizeof f->your_ref, "%s", ref ? ref : "");
+ snprintf(f->auto_ref, sizeof f->auto_ref, "%s", ref ? ref : "");
+}
+
+static void iform_cell(void *ud, int row, int col, char **buf, size_t *cap)
+{
+ struct iform *f = ud;
+ struct irow *r = &f->rows[row];
+ if (col == 0) {
+ *buf = r->description;
+ *cap = sizeof r->description;
+ } else if (col == 1) {
+ *buf = r->quantity;
+ *cap = sizeof r->quantity;
+ } else if (col == 2) {
+ *buf = r->unit;
+ *cap = sizeof r->unit;
+ } else if (col == 3) {
+ *buf = r->price;
+ *cap = sizeof r->price;
+ } else if (col == 4) {
+ *buf = r->vat;
+ *cap = sizeof r->vat;
+ } else {
+ *buf = r->note;
+ *cap = sizeof r->note;
+ }
+}
+
+static const char *const UNIT_CHOICES[] = { "st", "tim", "dag", "km" };
+static const char *const VAT_LABELS[] = { "25%", "12%", "6%",
+ "0%", "Omvänd moms", "EU" };
+static const char *const VAT_CODES[] = { "25", "12", "6", "0", "rc", "eu" };
+
+static int vat_index(const char *s)
+{
+ const char *code = vat_code_of(s);
+ if (!code)
+ return 0;
+ for (int i = 0; i < 6; i++)
+ if (strcmp(code, VAT_CODES[i]) == 0)
+ return i;
+ return 0;
+}
+
+static int iform_key(void *ud, int ch)
+{
+ struct iform *f = ud;
+ struct tui_rt *rt = &f->rt;
+ if (ch != '\n' && ch != '\r' && ch != KEY_ENTER)
+ return TUI_HOOK_NONE;
+ if (rt->focus == 0) {
+ if (f->ncust == 0) {
+ tui_message("Ny faktura", "Inga kunder. Skapa en under Register "
+ "→ Kunder först.");
+ return TUI_HOOK_STAY;
+ }
+ int c = tui_choice_prompt("Kund: ",
+ (const char *const *)f->cust_names,
+ (int)f->ncust, f->cust_sel);
+ if (c >= 0) {
+ int changed = c != f->cust_sel;
+ f->cust_sel = c;
+ if (changed)
+ iform_customer_changed(f);
+ tui_rt_normalize(rt);
+ rt->focus = tui_rt_focus_step(0, rt->nfields, &rt->row, &rt->col,
+ rt->nrows, rt->neditable, 1);
+ }
+ return TUI_HOOK_STAY;
+ }
+ if (rt->focus < 0 && rt->row >= 0 && rt->row < IFORM_ROWS) {
+ struct irow *r = &f->rows[rt->row];
+ if (rt->col == 2) {
+ int c = tui_choice_prompt("Enhet: ", UNIT_CHOICES, 4,
+ unit_index(r->unit));
+ if (c >= 0)
+ snprintf(r->unit, sizeof r->unit, "%s", UNIT_CHOICES[c]);
+ return TUI_HOOK_STAY;
+ }
+ if (rt->col == 4) {
+ int c = tui_choice_prompt("Moms: ", VAT_LABELS, 6,
+ vat_index(r->vat));
+ if (c >= 0)
+ snprintf(r->vat, sizeof r->vat, "%s", VAT_LABELS[c]);
+ return TUI_HOOK_STAY;
+ }
+ }
+ return TUI_HOOK_NONE;
+}
+
+static char *iform_draft_args(struct iform *f, char *err, size_t errn)
+{
+ if (f->ncust == 0 || f->cust_sel < 0 || (size_t)f->cust_sel >= f->ncust) {
+ snprintf(err, errn, "Välj en kund.");
+ return NULL;
+ }
+ if (!util_parse_iso_date(f->date)) {
+ snprintf(err, errn, "Ogiltigt fakturadatum (ÅÅÅÅ-MM-DD).");
+ return NULL;
+ }
+ if (!util_parse_iso_date(f->due)) {
+ snprintf(err, errn, "Ogiltigt förfallodatum (ÅÅÅÅ-MM-DD).");
+ return NULL;
+ }
+ if (f->delivery[0] && !util_parse_iso_date(f->delivery)) {
+ snprintf(err, errn, "Ogiltigt leveransdatum (ÅÅÅÅ-MM-DD).");
+ return NULL;
+ }
+ 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, "customer_id", f->cust_ids[f->cust_sel]);
+ yyjson_mut_obj_add_strcpy(d, o, "invoice_date", f->date);
+ yyjson_mut_obj_add_strcpy(d, o, "due_date", f->due);
+ yyjson_mut_obj_add_strcpy(d, o, "delivery_date", f->delivery);
+ yyjson_mut_obj_add_strcpy(d, o, "your_ref", f->your_ref);
+ yyjson_mut_obj_add_strcpy(d, o, "our_ref", f->our_ref);
+ yyjson_mut_obj_add_strcpy(d, o, "notes", f->notes);
+ yyjson_mut_val *arr = yyjson_mut_arr(d);
+ int used = 0;
+ for (int i = 0; i < f->rt.nrows; i++) {
+ struct irow *r = &f->rows[i];
+ if (!r->description[0] && !r->quantity[0] && !r->unit[0] &&
+ !r->price[0] && !r->vat[0] && !r->note[0])
+ continue;
+ if (!r->description[0]) {
+ snprintf(err, errn, "Rad %d: beskrivning saknas.", i + 1);
+ yyjson_mut_doc_free(d);
+ return NULL;
+ }
+ const char *qty = r->quantity[0] ? r->quantity : "1";
+ if (!qty_is_valid(qty)) {
+ snprintf(err, errn,
+ "Rad %d: antal måste vara ett positivt tal med högst "
+ "3 decimaler.",
+ i + 1);
+ yyjson_mut_doc_free(d);
+ return NULL;
+ }
+ int64_t price = 0;
+ if (tui_parse_kr(r->price, &price) != 0) {
+ snprintf(err, errn, "Rad %d: ogiltigt à-pris.", i + 1);
+ yyjson_mut_doc_free(d);
+ return NULL;
+ }
+ const char *unit = unit_code_of(r->unit);
+ if (!unit) {
+ snprintf(err, errn, "Rad %d: ogiltig enhet (st, tim, dag, km).",
+ i + 1);
+ yyjson_mut_doc_free(d);
+ return NULL;
+ }
+ const char *vat = vat_code_of(r->vat);
+ if (!vat) {
+ snprintf(err, errn, "Rad %d: ogiltig momskod.", i + 1);
+ yyjson_mut_doc_free(d);
+ return NULL;
+ }
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "description", r->description);
+ yyjson_mut_obj_add_strcpy(d, ro, "quantity", qty);
+ yyjson_mut_obj_add_strcpy(d, ro, "unit", unit);
+ yyjson_mut_obj_add_int(d, ro, "unit_price_ore", price);
+ yyjson_mut_obj_add_strcpy(d, ro, "note", r->note);
+ yyjson_mut_obj_add_strcpy(d, ro, "vat_code", vat);
+ used++;
+ }
+ if (used == 0) {
+ snprintf(err, errn, "Minst en rad krävs.");
+ yyjson_mut_doc_free(d);
+ return NULL;
+ }
+ yyjson_mut_obj_add_val(d, o, "rows", arr);
+ char *out = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ return out;
+}
+
+static void iform_preview(struct iform *f)
+{
+ char err[256];
+ char *args = iform_draft_args(f, err, sizeof err);
+ if (!args) {
+ tui_message("Faktura", "%s", err);
+ return;
+ }
+ char *resp = client_rpc(&f->a->conn, "invoice.preview", f->a->session,
+ f->a->org, args);
+ free(args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Faktura", resp);
+ free(resp);
+ return;
+ }
+ char *b64 = jstr_dup(resp, "result.content_base64");
+ if (b64) {
+ save_and_open_pdf(b64, "Faktura förhandsvisning.pdf");
+ free(b64);
+ }
+ free(resp);
+}
+
+static int64_t iform_issue(struct iform *f)
+{
+ if (f->issued)
+ return 0;
+ char err[256];
+ char *args = iform_draft_args(f, err, sizeof err);
+ if (!args) {
+ tui_message("Faktura", "%s", err);
+ return 0;
+ }
+ char *resp = client_rpc(&f->a->conn, "invoice.issue", f->a->session,
+ f->a->org, args);
+ free(args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Faktura", resp);
+ free(resp);
+ return 0;
+ }
+ int64_t id = jint_val(resp, "result.id", 0);
+ int64_t number = jint_val(resp, "result.number", 0);
+ free(resp);
+ if (id <= 0)
+ return 0;
+ f->issued = 1;
+ const char *email = f->cust_emails[f->cust_sel];
+ if (email && *email) {
+ if (tui_confirm("Skicka faktura", "Skicka faktura %lld till %s?",
+ (long long)number, email)) {
+ char args2[64];
+ snprintf(args2, sizeof args2, "{\"id\":%lld}", (long long)id);
+ char *r = client_rpc(&f->a->conn, "invoice.send", f->a->session,
+ f->a->org, args2);
+ if (r && client_ok(r)) {
+ char *to = jstr_dup(r, "result.sent_to");
+ tui_message("Skickat", "Faktura %lld skickad till %s.",
+ (long long)number, to && *to ? to : email);
+ free(to);
+ } else {
+ show_error("Skicka faktura", r);
+ }
+ free(r);
+ }
+ } else {
+ tui_message("Faktura", "Faktura %lld skapad.", (long long)number);
+ }
+ return id;
+}
+
+static int64_t invoices_new(struct app *a)
+{
+ struct iform f;
+ memset(&f, 0, sizeof f);
+ f.a = a;
+ today_iso(f.date, sizeof f.date);
+ snprintf(f.delivery, sizeof f.delivery, "%s", f.date);
+ snprintf(f.due, sizeof f.due, "%s", f.date);
+ snprintf(f.auto_due, sizeof f.auto_due, "%s", f.due);
+ if (iform_load_customers(&f) != 0) {
+ iform_customers_free(&f);
+ return 0;
+ }
+ char *resp =
+ client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ char *v = jstr_dup(resp, "result.invoice_our_ref");
+ if (v) {
+ snprintf(f.our_ref, sizeof f.our_ref, "%s", v);
+ free(v);
+ }
+ }
+ free(resp);
+ if (f.ncust > 0) {
+ f.cust_sel = 0;
+ iform_customer_changed(&f);
+ }
+ struct tui_rt_col cols[6] = {
+ { "Beskrivning", 2, 34, TUI_RT_EDIT },
+ { "Antal", 37, 8, TUI_RT_EDIT },
+ { "Enhet", 46, 6, TUI_RT_EDIT },
+ { "À-pris", 53, 14, TUI_RT_EDIT },
+ { "Moms", 68, 14, TUI_RT_EDIT },
+ { "Anm", 83, 32, TUI_RT_EDIT },
+ };
+ tui_rt_init(&f.rt, 1, IFORM_ROWS, 6, cols, 0, iform_cell, NULL, &f);
+ tui_rt_set_key(&f.rt, iform_key);
+ struct tui_form_field ff[7];
+ memset(ff, 0, sizeof ff);
+ ff[0].label = "Kund";
+ ff[0].kind = TUI_F_CHOICE;
+ ff[0].choices = (const char *const *)f.cust_names;
+ ff[0].nchoices = (int)f.ncust;
+ ff[0].choice = &f.cust_sel;
+ ff[1].label = "Fakturadatum";
+ ff[1].value = f.date;
+ ff[1].cap = sizeof f.date;
+ ff[1].kind = TUI_F_DATE;
+ ff[2].label = "Förfallodatum";
+ ff[2].value = f.due;
+ ff[2].cap = sizeof f.due;
+ ff[2].kind = TUI_F_DATE;
+ ff[3].label = "Leveransdatum";
+ ff[3].value = f.delivery;
+ ff[3].cap = sizeof f.delivery;
+ ff[3].kind = TUI_F_DATE;
+ ff[4].label = "Er referens";
+ ff[4].value = f.your_ref;
+ ff[4].cap = sizeof f.your_ref;
+ ff[4].kind = TUI_F_TEXT;
+ ff[5].label = "Vår referens";
+ ff[5].value = f.our_ref;
+ ff[5].cap = sizeof f.our_ref;
+ ff[5].kind = TUI_F_TEXT;
+ ff[6].label = "Fritext";
+ ff[6].value = f.notes;
+ ff[6].cap = sizeof f.notes;
+ ff[6].kind = TUI_F_TEXT;
+ tui_rt_set_fields(&f.rt, ff, 7);
+ const char *hint =
+ "Enter = välj/ändra Tab = byta fält F5 = förhandsvisa"
+ " ^Enter = utfärda Esc = avbryt";
+ int64_t out = 0;
+ for (;;) {
+ int rr = tui_rt_run("Ny faktura", &f.rt, hint);
+ if (rr == -1)
+ break;
+ if (rr == -2) {
+ iform_preview(&f);
+ continue;
+ }
+ if (rr == -3) {
+ out = iform_issue(&f);
+ if (out > 0)
+ break;
+ }
+ }
+ iform_customers_free(&f);
+ return out;
+}
+
+static int invoices_key(void *ud, int ch)
+{
+ struct app *a = ud;
+ if (ch != 'n')
+ return TUI_HOOK_NONE;
+ char *resp = client_rpc(&a->conn, "invoice.sequence_get", a->session,
+ a->org, "{}");
+ char def[16] = "1";
+ if (resp && client_ok(resp))
+ snprintf(def, sizeof def, "%lld",
+ (long long)jint_val(resp, "result.next_number", 1));
+ free(resp);
+ char buf[16] = "";
+ if (!tui_prompt_into(buf, sizeof buf, "Nästa fakturanummer", def, 0))
+ return TUI_HOOK_STAY;
+ char args[64];
+ snprintf(args, sizeof args, "{\"next_number\":%s}", buf);
+ resp = client_rpc(&a->conn, "invoice.sequence_set", a->session, a->org,
+ args);
+ if (resp && client_ok(resp))
+ tui_message("Fakturor", "Nästa fakturanummer: %s.", buf);
+ else
+ show_error("Fakturor", resp);
+ free(resp);
+ return TUI_HOOK_REFRESH;
+}
+
+void invoices_screen(struct app *a)
+{
+ char **items = NULL;
+ int64_t *ids = NULL;
+ size_t n = 0;
+ int fetch = 1;
+ for (;;) {
+ if (g_quit) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ return;
+ }
+ if (fetch) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ items = NULL;
+ ids = NULL;
+ n = 0;
+ fetch = 0;
+ char *resp = client_rpc(&a->conn, "invoice.list", a->session,
+ a->org, "{\"limit\":200}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Fakturor", resp);
+ free(resp);
+ return;
+ }
+ n = jarr_size(resp, "result.items");
+ items = xcalloc(n + 1, sizeof(char *));
+ ids = xcalloc(n ? n : 1, sizeof(int64_t));
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[768];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ ids[i] = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.number", i);
+ int64_t number = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.invoice_date", i);
+ char *date = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.customer_name",
+ i);
+ char *cust = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.total_ore", i);
+ int64_t total = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.status", i);
+ char *status = jstr_dup(resp, path);
+ char nbuf[32], cbuf[256], abuf[40];
+ snprintf(nbuf, sizeof nbuf, "Faktura %lld", (long long)number);
+ snprintf(cbuf, sizeof cbuf, "%s", cust ? cust : "");
+ tui_pad_field(cbuf, sizeof cbuf, 28);
+ tui_amt_col(abuf, sizeof abuf, 14, total);
+ snprintf(line, sizeof line, "%s %s %s %s %s", nbuf,
+ date ? date : "", cbuf, abuf,
+ invoice_status_sv(status));
+ items[i] = xstrdup(line);
+ free(date);
+ free(cust);
+ free(status);
+ }
+ free(resp);
+ items[n] = xstrdup("+ Ny faktura (^N)");
+ }
+ int start = 0;
+ if (a->invoice_sel) {
+ for (size_t i = 0; i < n; i++)
+ if (ids[i] == a->invoice_sel) {
+ start = (int)i;
+ break;
+ }
+ }
+ int cur = start;
+ int sel = tui_select_list_hook("Fakturor", items, (int)n + 1, start, 1,
+ &cur, 1, NULL, 0, invoices_key, a);
+ if (cur >= 0 && (size_t)cur < n)
+ a->invoice_sel = ids[cur];
+ if (sel == -1) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ return;
+ }
+ if (sel == -2) {
+ fetch = 1;
+ continue;
+ }
+ if (sel == -4 || (sel >= 0 && (size_t)sel == n)) {
+ int64_t nid = invoices_new(a);
+ if (nid > 0)
+ a->invoice_sel = nid;
+ fetch = 1;
+ continue;
+ }
+ if (sel >= 0 && (size_t)sel < n) {
+ invoices_detail(a, ids[sel]);
+ continue;
+ }
+ }
+}
diff --git a/clients/screens_reports.c b/clients/screens_reports.c
new file mode 100644
index 0000000..e6263a2
--- /dev/null
+++ b/clients/screens_reports.c
@@ -0,0 +1,1176 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+/* ------------------------------------------------------------------ */
+/* report formatting (Kapitas-like text tables) */
+/* ------------------------------------------------------------------ */
+/* prefix (8) + label (48) + amount columns */
+#define BS_RULE_W (8 + 48 + 4 * 14)
+#define IS_RULE_W (8 + 48 + 3 * 15)
+
+static void fmt_head(struct buf *t, const struct app *a, const char *title,
+ yyjson_val *res)
+{
+ yyjson_val *fy = yyjson_obj_get(res, "fiscal_year");
+ const char *start = vstr(fy, "start_date");
+ const char *end = vstr(fy, "end_date");
+ buf_line(t, MK_HEAD "%s\n%s\n\n", title, a->org_name);
+ buf_line(t, MK_DIM "Räkenskapsår: %s - %s\n", start, end);
+ buf_line(t, MK_DIM "Period: %s - %s\n\n", start, end);
+}
+/* Pad to `width` display columns without truncating (labels may be longer). */
+static void pad_min(char *buf, size_t n, int width)
+{
+ int w = tui_disp_width(buf);
+ size_t len = strlen(buf);
+ while (w < width && len + 1 < n) {
+ buf[len++] = ' ';
+ buf[len] = '\0';
+ w++;
+ }
+}
+
+/* Right-align `text` in `width` display columns (headers with å/ä/ö). */
+
+/* ---------------- balansräkning ---------------- */
+
+struct bs_sum {
+ int64_t ib, per, ub;
+};
+
+static void bs_col_head(struct buf *t, int sticky)
+{
+ buf_line(t, "%s %5s %-48s %14s %14s %14s %14s\n",
+ sticky ? MK_STICKY MK_HEAD : "", "", "", "Ing balans", "Ing saldo",
+ "Period", "Utg balans");
+ buf_rule(t, BS_RULE_W);
+}
+
+static int bs_rows(struct buf *t, yyjson_val *res, int lo, int hi,
+ struct bs_sum *sum)
+{
+ int found = 0;
+ for (int k = 0; k < 3; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ const char *acc = vstr(row, "account");
+ if (!acct_in(acc, lo, hi))
+ continue;
+ int64_t ib = vint(row, "ib_ore");
+ int64_t ub = vint(row, "ub_ore");
+ int64_t per = ub - ib;
+ if (t) {
+ char nm[128], a1[48], a2[48], a3[48], a4[48];
+ tui_pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ tui_amt_col(a1, sizeof a1, 14, ib);
+ tui_amt_col(a2, sizeof a2, 14, ib);
+ tui_amt_col(a3, sizeof a3, 14, per);
+ tui_amt_col(a4, sizeof a4, 14, ub);
+ buf_line(t, " %5s %s %s %s %s %s\n", acc, nm, a1, a2, a3, a4);
+ }
+ sum->ib += ib;
+ sum->per += per;
+ sum->ub += ub;
+ found = 1;
+ }
+ }
+ return found;
+}
+
+static void bs_sum_line(struct buf *t, const char *label,
+ const struct bs_sum *s)
+{
+ char lbl[64], a1[48], a2[48], a3[48], a4[48];
+ tui_pad_label(lbl, sizeof lbl, label, 48);
+ tui_amt_col(a1, sizeof a1, 14, s->ib);
+ tui_amt_col(a2, sizeof a2, 14, s->ib);
+ tui_amt_col(a3, sizeof a3, 14, s->per);
+ tui_amt_col(a4, sizeof a4, 14, s->ub);
+ buf_line(t, MK_DIM " %5s %s %s %s %s %s\n", "", lbl, a1, a2, a3, a4);
+}
+
+static int bs_group(struct buf *t, yyjson_val *res, const char *title,
+ const char *sumlabel, int lo, int hi, struct bs_sum *acc)
+{
+ struct bs_sum probe = { 0, 0, 0 };
+ if (!bs_rows(NULL, res, lo, hi, &probe))
+ return 0;
+ buf_line(t, MK_HEAD "%s\n", title);
+ struct bs_sum s = { 0, 0, 0 };
+ bs_rows(t, res, lo, hi, &s);
+ bs_sum_line(t, sumlabel, &s);
+ if (acc) {
+ acc->ib += s.ib;
+ acc->per += s.per;
+ acc->ub += s.ub;
+ }
+ return 1;
+}
+
+static void fmt_balans(struct buf *t, const struct app *a, yyjson_val *res)
+{
+ static const struct {
+ const char *title;
+ const char *sum;
+ int lo, hi;
+ } OMS[] = {
+ { "Varulager", "Summa varulager", 1400, 1499 },
+ { "Kortfristiga fordringar", "Summa kortfristiga fordringar", 1500,
+ 1699 },
+ { "Kortfristiga placeringar", "Summa kortfristiga placeringar", 1700,
+ 1899 },
+ { "Kassa och bank", "Summa kassa och bank", 1900, 1999 },
+ };
+ fmt_head(t, a, "Balansrapport", res);
+ struct bs_sum anl = { 0, 0, 0 }, oms = { 0, 0, 0 };
+ struct bs_sum eget = { 0, 0, 0 }, skuld = { 0, 0, 0 };
+ buf_line(t, MK_HEAD "TILLGÅNGAR\n");
+ bs_col_head(t, 1);
+ buf_line(t, "\n" MK_HEAD "Anläggningstillgångar\n");
+ bs_rows(t, res, 1000, 1399, &anl);
+ bs_sum_line(t, "Summa anläggningstillgångar", &anl);
+ buf_line(t, "\n" MK_HEAD "Omsättningstillgångar\n");
+ int printed = 0;
+ for (size_t i = 0; i < sizeof OMS / sizeof OMS[0]; i++) {
+ struct bs_sum probe = { 0, 0, 0 };
+ if (!bs_rows(NULL, res, OMS[i].lo, OMS[i].hi, &probe))
+ continue;
+ if (printed)
+ buf_line(t, "\n");
+ bs_group(t, res, OMS[i].title, OMS[i].sum, OMS[i].lo, OMS[i].hi,
+ &oms);
+ printed = 1;
+ }
+ bs_sum_line(t, "Summa omsättningstillgångar", &oms);
+ struct bs_sum as = { anl.ib + oms.ib, anl.per + oms.per, anl.ub + oms.ub };
+ buf_rule(t, BS_RULE_W);
+ bs_sum_line(t, "Summa tillgångar", &as);
+
+ buf_line(t, "\n" MK_HEAD "EGET KAPITAL OCH SKULDER\n");
+ bs_col_head(t, 0);
+ buf_line(t, "\n" MK_HEAD "Eget kapital\n");
+ bs_group(t, res, "Bundet eget kapital", "Summa bundet eget kapital", 2000,
+ 2090, &eget);
+ buf_line(t, "\n");
+ bs_group(t, res, "Fritt eget kapital", "Summa fritt eget kapital", 2091,
+ 2099, &eget);
+ bs_sum_line(t, "Summa eget kapital", &eget);
+ buf_line(t, "\n");
+ if (bs_group(t, res, "Långfristiga skulder", "Summa långfristiga skulder",
+ 2100, 2399, &skuld))
+ buf_line(t, "\n");
+ bs_group(t, res, "Kortfristiga skulder", "Summa kortfristiga skulder",
+ 2400, 2999, &skuld);
+ struct bs_sum es = { eget.ib + skuld.ib, eget.per + skuld.per,
+ eget.ub + skuld.ub };
+ buf_rule(t, BS_RULE_W);
+ bs_sum_line(t, "Summa eget kapital och skulder", &es);
+
+ yyjson_val *eq = yyjson_obj_get(res, "equity");
+ int64_t result = eq ? vint(eq, "result_ore") : 0;
+ char lbl[64], zero[48], a1[48];
+ tui_pad_label(lbl, sizeof lbl, "Beräknat resultat", 48);
+ tui_amt_col(zero, sizeof zero, 14, 0);
+ tui_amt_col(a1, sizeof a1, 14, result);
+ buf_line(t, "\n");
+ buf_rule(t, BS_RULE_W);
+ buf_line(t, MK_DIM " %5s %s %s %s %s %s\n", "", lbl, zero, zero, a1, a1);
+}
+/* ---------------- resultaträkning ---------------- */
+
+static const char *const IS_KEYS[2] = { "revenue", "expenses" };
+
+static int is_group_has(yyjson_val *res, const struct is_group *g)
+{
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++)
+ if (acct_in(vstr(yyjson_arr_get(arr, i), "account"), g->lo,
+ g->hi))
+ return 1;
+ }
+ return 0;
+}
+
+static int64_t is_prev_lookup(yyjson_val *prev, const char *account,
+ int negate)
+{
+ if (!prev)
+ return 0;
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(prev, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ if (strcmp(vstr(row, "account"), account) == 0) {
+ int64_t a = vint(row, "amount_ore");
+ return negate ? -a : a;
+ }
+ }
+ }
+ return 0;
+}
+
+static void is_summary(struct buf *t, const char *label, int64_t cur,
+ int64_t prev, int blank)
+{
+ char lbl[64], a1[48], a2[48];
+ if (blank)
+ buf_line(t, "\n");
+ tui_pad_label(lbl, sizeof lbl, label, 48);
+ tui_amt_col(a1, sizeof a1, 15, cur);
+ tui_amt_col(a2, sizeof a2, 15, prev);
+ buf_line(t, MK_DIM " %5s %s %s %s %s\n", "", lbl, a1, a1, a2);
+}
+
+static void is_print_group(struct buf *t, yyjson_val *res, yyjson_val *prev,
+ const struct is_group *gr, int64_t *cur,
+ int64_t *pre)
+{
+ int64_t sc = 0, sp = 0;
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ const char *acc = vstr(row, "account");
+ if (!acct_in(acc, gr->lo, gr->hi))
+ continue;
+ int64_t c = gr->rev ? vint(row, "amount_ore")
+ : -vint(row, "amount_ore");
+ int64_t p = is_prev_lookup(prev, acc, !gr->rev);
+ char nm[128], a1[48], a2[48];
+ tui_pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ tui_amt_col(a1, sizeof a1, 15, c);
+ tui_amt_col(a2, sizeof a2, 15, p);
+ buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2);
+ sc += c;
+ sp += p;
+ }
+ }
+ is_summary(t, "Summa", sc, sp, 0);
+ *cur = sc;
+ *pre = sp;
+}
+
+static int is_range_has(yyjson_val *res, int lo, int hi)
+{
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++)
+ if (acct_in(vstr(yyjson_arr_get(arr, i), "account"), lo, hi))
+ return 1;
+ }
+ return 0;
+}
+
+/* Rows in one account range, printed with the previous-year column; the
+ signed display sums are added to *sum / *sum_pre. */
+static void is_range_rows(struct buf *t, yyjson_val *res, yyjson_val *prev,
+ int lo, int hi, int64_t *sum, int64_t *sum_pre)
+{
+ for (int k = 0; k < 2; k++) {
+ yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]);
+ yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL;
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ const char *acc = vstr(row, "account");
+ if (!acct_in(acc, lo, hi))
+ continue;
+ int64_t c = k == 0 ? vint(row, "amount_ore")
+ : -vint(row, "amount_ore");
+ int64_t p = is_prev_lookup(prev, acc, k != 0);
+ char nm[128], a1[48], a2[48];
+ tui_pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ tui_amt_col(a1, sizeof a1, 15, c);
+ tui_amt_col(a2, sizeof a2, 15, p);
+ buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2);
+ *sum += c;
+ *sum_pre += p;
+ }
+ }
+}
+
+static void fmt_resultat(struct buf *t, const struct app *a, yyjson_val *res,
+ yyjson_val *prev)
+{
+ fmt_head(t, a, "Resultatrapport", res);
+ const char *sect = NULL;
+ int64_t rev = 0, rev_pre = 0, exp = 0, exp_pre = 0;
+ for (size_t g = 0; g < sizeof IS_GROUPS / sizeof IS_GROUPS[0]; g++) {
+ const struct is_group *gr = &IS_GROUPS[g];
+ if (!is_group_has(res, gr))
+ continue;
+ if (!sect || strcmp(sect, gr->section) != 0) {
+ if (sect) {
+ is_summary(t, "Summa rörelseintäkter, lagerförändringar m.m.",
+ rev, rev_pre, 0);
+ buf_line(t, "\n");
+ buf_line(t, MK_HEAD "%s\n", gr->section);
+ } else {
+ char lbl[80], h1[32], h2[32], h3[32];
+ tui_pad_label(lbl, sizeof lbl, gr->section, 48);
+ tui_pad_hdr(h1, sizeof h1, 15, "Period");
+ tui_pad_hdr(h2, sizeof h2, 15, "Ackumulerat");
+ tui_pad_hdr(h3, sizeof h3, 15, "Föreg. per.");
+ buf_line(t, MK_STICKY MK_HEAD " %5s %s %s %s %s\n", "", lbl,
+ h1, h2, h3);
+ buf_rule(t, IS_RULE_W);
+ buf_line(t, "\n");
+ buf_line(t, MK_HEAD "%s\n", gr->title);
+ }
+ sect = gr->section;
+ } else {
+ buf_line(t, "\n");
+ buf_line(t, MK_HEAD "%s\n", gr->title);
+ }
+ int64_t sc = 0, sp = 0;
+ is_print_group(t, res, prev, gr, &sc, &sp);
+ if (gr->rev) {
+ rev += sc;
+ rev_pre += sp;
+ } else {
+ exp += sc;
+ exp_pre += sp;
+ }
+ }
+ is_summary(t, "Summa rörelsekostnader", exp, exp_pre, 0);
+
+ int64_t fin = 0, fin_pre = 0, disp = 0, disp_pre = 0;
+ int64_t tax = 0, tax_pre = 0, booked = 0, booked_pre = 0;
+ int has_disp = is_range_has(res, 8800, 8899);
+ int has_tax = is_range_has(res, 8900, 8989);
+ int has_booked = is_range_has(res, 8999, 8999);
+ if (is_range_has(res, 8000, 8599)) {
+ buf_line(t, "\n" MK_HEAD "Finansiella poster\n");
+ is_range_rows(t, res, prev, 8000, 8599, &fin, &fin_pre);
+ is_summary(t, "Summa", fin, fin_pre, 0);
+ }
+ int64_t ror = rev + exp, ror_pre = rev_pre + exp_pre;
+ int64_t after = ror + fin, after_pre = ror_pre + fin_pre;
+ buf_line(t, "\n");
+ buf_rule(t, IS_RULE_W);
+ is_summary(t, "Rörelseresultat", ror, ror_pre, 0);
+ is_summary(t, "Resultat efter finansiella poster", after, after_pre, 1);
+ if (has_disp) {
+ buf_line(t, "\n" MK_HEAD "Bokslutsdispositioner\n");
+ is_range_rows(t, res, prev, 8800, 8899, &disp, &disp_pre);
+ is_summary(t, "Summa", disp, disp_pre, 0);
+ }
+ int64_t before_tax = after + disp, before_tax_pre = after_pre + disp_pre;
+ is_summary(t, "Resultat före skatt", before_tax, before_tax_pre, 1);
+ if (has_tax) {
+ buf_line(t, "\n" MK_HEAD "Skatt\n");
+ is_range_rows(t, res, prev, 8900, 8989, &tax, &tax_pre);
+ is_summary(t, "Summa", tax, tax_pre, 0);
+ }
+ int64_t ars = before_tax + tax, ars_pre = before_tax_pre + tax_pre;
+ buf_line(t, "\n");
+ buf_rule(t, IS_RULE_W);
+ is_summary(t, "Årets resultat", ars, ars_pre, 0);
+ if (has_booked) {
+ buf_line(t, "\n" MK_HEAD "Bokfört resultat\n");
+ is_range_rows(t, res, prev, 8999, 8999, &booked, &booked_pre);
+ is_summary(t, "Summa", booked, booked_pre, 0);
+ buf_line(t, "\n");
+ buf_rule(t, IS_RULE_W);
+ is_summary(t, "Ej bokfört resultat", ars + booked,
+ ars_pre + booked_pre, 0);
+ } else {
+ buf_line(t, "\n");
+ buf_rule(t, IS_RULE_W);
+ is_summary(t, "Ej bokfört resultat", ars, ars_pre, 0);
+ }
+}
+
+/* ---------------- saldobalans ---------------- */
+
+static void fmt_saldobalans(struct buf *t, const struct app *a,
+ yyjson_val *res)
+{
+ fmt_head(t, a, "Saldobalans", res);
+ char nmh[64], h1[32], h2[32], h3[32], h4[32];
+ tui_pad_label(nmh, sizeof nmh, "Benämning", 48);
+ tui_pad_hdr(h1, sizeof h1, 14, "Ingående");
+ tui_pad_hdr(h2, sizeof h2, 14, "Debet");
+ tui_pad_hdr(h3, sizeof h3, 14, "Kredit");
+ tui_pad_hdr(h4, sizeof h4, 14, "Utgående");
+ buf_line(t, MK_STICKY MK_HEAD " %5s %s %s %s %s %s\n", "Konto", nmh, h1,
+ h2, h3, h4);
+ buf_rule(t, BS_RULE_W);
+ yyjson_val *arr = yyjson_obj_get(res, "accounts");
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ char nm[128], a1[48], a2[48], a3[48], a4[48];
+ tui_pad_label(nm, sizeof nm, vstr(row, "name"), 48);
+ tui_amt_col(a1, sizeof a1, 14, vint(row, "ib_ore"));
+ tui_amt_col(a2, sizeof a2, 14, vint(row, "debit_ore"));
+ tui_amt_col(a3, sizeof a3, 14, vint(row, "credit_ore"));
+ tui_amt_col(a4, sizeof a4, 14, vint(row, "ub_ore"));
+ buf_line(t, " %5s %s %s %s %s %s\n", vstr(row, "account"), nm, a1,
+ a2, a3, a4);
+ }
+ yyjson_val *tot = yyjson_obj_get(res, "totals");
+ char lbl[64], a1[48], a2[48], a3[48], a4[48];
+ tui_pad_label(lbl, sizeof lbl, "Summa", 48);
+ tui_amt_col(a1, sizeof a1, 14, vint(tot, "ib_ore"));
+ tui_amt_col(a2, sizeof a2, 14, vint(tot, "debit_ore"));
+ tui_amt_col(a3, sizeof a3, 14, vint(tot, "credit_ore"));
+ tui_amt_col(a4, sizeof a4, 14, vint(tot, "ub_ore"));
+ buf_line(t, "\n");
+ buf_rule(t, BS_RULE_W);
+ buf_line(t, MK_DIM " %5s %s %s %s %s %s\n", "", lbl, a1, a2, a3, a4);
+}
+
+/* ---------------- momsdeklaration ---------------- */
+
+struct vat_row {
+ const char *section;
+ const char *box;
+ const char *label;
+};
+
+static const struct vat_row VAT_ROWS[] = {
+ { "A.", "05",
+ "Momspliktig försäljning som inte ingår i ruta 06, 07 eller 08" },
+ { "A.", "06", "Momspliktiga uttag" },
+ { "A.", "07", "Beskattningsunderlag vid vinstmarginalbeskattning" },
+ { "A.", "08", "Hyresinkomster vid frivillig beskattning" },
+ { "B.", "10", "Utgående moms 25%" },
+ { "B.", "11", "Utgående moms 12%" },
+ { "B.", "12", "Utgående moms 6%" },
+ { "C.", "20", "Inköp av varor från annat EU-land" },
+ { "C.", "21", "Inköp av tjänster från annat EU-land enligt huvudregeln" },
+ { "C.", "22", "Inköp av tjänster från land utanför EU" },
+ { "C.", "23", "Inköp av varor i Sverige som köparen är betalningsskyldig för" },
+ { "C.", "24", "Övriga inköp av tjänster i Sverige som köparen är betalningsskyldig för" },
+ { "D.", "30", "Utgående moms 25%" },
+ { "D.", "31", "Utgående moms 12%" },
+ { "D.", "32", "Utgående moms 6%" },
+ { "H.", "50", "Beskattningsunderlag vid import" },
+ { "I.", "60", "Utgående moms 25%" },
+ { "I.", "61", "Utgående moms 12%" },
+ { "I.", "62", "Utgående moms 6%" },
+ { "E.", "35", "Försäljning av varor till annat EU-land" },
+ { "E.", "36", "Försäljning av varor utanför EU" },
+ { "E.", "37", "Mellanmans inköp av varor vid trepartshandel" },
+ { "E.", "38", "Mellanmans försäljning av varor vid trepartshandel" },
+ { "E.", "39", "Försäljning av tjänster till beskattningsbar person i annat EU-land enligt huvudregeln" },
+ { "E.", "40", "Övrig försäljning av tjänster som tillhandahållits utomlands" },
+ { "E.", "41", "Försäljning när köparen är betalningsskyldig i Sverige" },
+ { "E.", "42", "Övrig försäljning m.m." },
+ { "F.", "48", "Ingående moms att dra av" },
+ { "G.", "49", "Moms att betala (+) eller att få tillbaka (-)" },
+};
+
+static const char *vat_section_title(const char *s)
+{
+ if (strcmp(s, "A.") == 0)
+ return "Momspliktig försäljning eller uttag exklusive moms";
+ if (strcmp(s, "B.") == 0)
+ return "Utgående moms på försäljning eller uttag i ruta 05 - 08";
+ if (strcmp(s, "C.") == 0)
+ return "Momspliktiga inköp vid omvänd skattskyldighet";
+ if (strcmp(s, "D.") == 0)
+ return "Utgående moms på inköp i ruta 20 - 24";
+ if (strcmp(s, "H.") == 0)
+ return "Import";
+ if (strcmp(s, "I.") == 0)
+ return "Utgående moms på import i ruta 50";
+ if (strcmp(s, "E.") == 0)
+ return "Försäljning m.m. som är undantagen från moms";
+ if (strcmp(s, "F.") == 0)
+ return "Ingående moms";
+ return "Moms att betala eller få tillbaka";
+}
+
+static void fmt_moms(struct buf *t, const struct app *a, yyjson_val *res)
+{
+ buf_line(t, MK_HEAD "Momsrapport\n\n%s\n\n", a->org_name);
+ buf_line(t, MK_DIM "Räkenskapsår: %s - %s\n", a->fy_start, a->fy_end);
+ buf_line(t, MK_DIM "Period: %s - %s\n", vstr(res, "from"),
+ vstr(res, "to"));
+ const char *sect = NULL;
+ yyjson_val *boxes = yyjson_obj_get(res, "boxes");
+ size_t n = yyjson_arr_size(boxes);
+ for (size_t i = 0; i < sizeof VAT_ROWS / sizeof VAT_ROWS[0]; i++) {
+ const struct vat_row *vr = &VAT_ROWS[i];
+ if (strcmp(vr->box, "49") == 0)
+ continue; /* last */
+ if (!sect || strcmp(sect, vr->section) != 0) {
+ sect = vr->section;
+ buf_line(t, "\n" MK_HEAD "%s %s\n", vr->section,
+ vat_section_title(vr->section));
+ }
+ int64_t ore = 0;
+ for (size_t j = 0; j < n; j++) {
+ yyjson_val *b = yyjson_arr_get(boxes, j);
+ if (strcmp(vstr(b, "box"), vr->box) == 0) {
+ ore = vint(b, "amount_ore");
+ break;
+ }
+ }
+ char amt[48] = "", lbl[192];
+ if (ore)
+ kr_whole(ore, amt, sizeof amt);
+ snprintf(lbl, sizeof lbl, "%s", vr->label);
+ pad_min(lbl, sizeof lbl, 66);
+ buf_line(t, "%s. %s %14s\n", vr->box, lbl, amt);
+ }
+ int64_t ore49 = 0;
+ for (size_t j = 0; j < n; j++) {
+ yyjson_val *b = yyjson_arr_get(boxes, j);
+ if (strcmp(vstr(b, "box"), "49") == 0) {
+ ore49 = vint(b, "amount_ore");
+ break;
+ }
+ }
+ buf_line(t, "\n" MK_HEAD "G. %s\n", vat_section_title("G."));
+ buf_rule(t, 87);
+ char amt49[48], lbl49[192];
+ kr_whole(ore49, amt49, sizeof amt49);
+ snprintf(lbl49, sizeof lbl49, "%s",
+ "Moms att betala (+) eller att få tillbaka (-)");
+ pad_min(lbl49, sizeof lbl49, 66);
+ buf_line(t, MK_DIM "49. %s %14s\n", lbl49, amt49);
+}
+static int64_t prev_fy_id(struct app *a)
+{
+ char *resp =
+ client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ free(resp);
+ return 0;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ int64_t best = 0;
+ char best_end[16] = "";
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.end_date", i);
+ char *e = jstr_dup(resp, path);
+ if (e && strcmp(e, a->fy_start) < 0 && strcmp(e, best_end) > 0) {
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ int64_t id = jint_val(resp, path, 0);
+ if (id) {
+ best = id;
+ snprintf(best_end, sizeof best_end, "%s", e);
+ }
+ }
+ free(e);
+ }
+ free(resp);
+ return best;
+}
+
+/* ---------------- huvudbok ---------------- */
+
+static void fmt_huvudbok(struct buf *t, const struct app *a, yyjson_val *res)
+{
+ fmt_head(t, a, "Huvudbok", res);
+ yyjson_val *lv = yyjson_obj_get(res, "last_voucher");
+ if (lv)
+ buf_line(t, MK_DIM "Senaste ver.: %s%lld\n\n", vstr(lv, "series"),
+ (long long)vint(lv, "number"));
+ buf_line(t, MK_STICKY MK_HEAD "%-8s %-58s %14s %14s %16s\n", "Konto", "",
+ "Debet", "Kredit", "Saldo");
+ buf_rule(t, 114);
+ yyjson_val *accs = yyjson_obj_get(res, "accounts");
+ size_t n = yyjson_arr_size(accs);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *ao = yyjson_arr_get(accs, i);
+ int64_t ib = vint(ao, "ib_ore");
+ int64_t d = vint(ao, "debit_ore");
+ int64_t c = vint(ao, "credit_ore");
+ buf_line(t, "\n" MK_HEAD "%s %s\n\n", vstr(ao, "account"),
+ vstr(ao, "name"));
+ char a1[48];
+ tui_amt_col(a1, sizeof a1, 16, ib);
+ char lbl[96];
+ tui_pad_label(lbl, sizeof lbl, "Ingående balans:", 59);
+ buf_line(t, MK_DIM " %s %14s %14s %16s\n", lbl, "", "", a1);
+ tui_pad_label(lbl, sizeof lbl, "Ingående saldo:", 59);
+ buf_line(t, MK_DIM " %s %14s %14s %16s\n", lbl, "", "", a1);
+ buf_line(t, "\n");
+ yyjson_val *rows = yyjson_obj_get(ao, "rows");
+ size_t nr = yyjson_arr_size(rows);
+ for (size_t k = 0; k < nr; k++) {
+ yyjson_val *ro = yyjson_arr_get(rows, k);
+ char ver[32], nm[256], dd[48], cc[48], deb[48], kre[48], sal[48];
+ snprintf(ver, sizeof ver, "%s%lld", vstr(ro, "series"),
+ (long long)vint(ro, "number"));
+ const char *txt = vstr(ro, "row_description");
+ if (!*txt)
+ txt = vstr(ro, "description");
+ tui_pad_label(nm, sizeof nm, txt, 40);
+ tui_amt_col(dd, sizeof dd, 14, vint(ro, "debit_ore"));
+ tui_amt_col(cc, sizeof cc, 14, vint(ro, "credit_ore"));
+ snprintf(deb, sizeof deb, "%s", vint(ro, "debit_ore") ? dd : "");
+ snprintf(kre, sizeof kre, "%s", vint(ro, "credit_ore") ? cc : "");
+ tui_amt_col(sal, sizeof sal, 16, vint(ro, "saldo_ore"));
+ buf_line(t, " %-7s %-10s %s %14s %14s %16s\n", ver,
+ vstr(ro, "date"), nm, deb, kre, sal);
+ }
+ char s1[48], s2[48], s3[48];
+ tui_amt_col(s1, sizeof s1, 14, d);
+ tui_amt_col(s2, sizeof s2, 14, c);
+ tui_amt_col(s3, sizeof s3, 16, d - c);
+ tui_pad_label(lbl, sizeof lbl, "Omslutning:", 59);
+ buf_line(t, MK_DIM " %s %14s %14s %16s\n", lbl, s1, s2, s3);
+ tui_amt_col(s3, sizeof s3, 16, vint(ao, "ub_ore"));
+ tui_pad_label(lbl, sizeof lbl, "Utgående saldo:", 59);
+ buf_line(t, MK_DIM " %s %14s %14s %16s\n", lbl, s1, s2, s3);
+ }
+}
+
+/* ---------------- verifikationslista ---------------- */
+
+static void fmt_verifikationslista(struct buf *t, const struct app *a,
+ yyjson_val *res)
+{
+ fmt_head(t, a, "Verifikationslista", res);
+ yyjson_val *lv = yyjson_obj_get(res, "last_voucher");
+ if (lv)
+ buf_line(t, MK_DIM "Senaste ver.: %s%lld\n\n", vstr(lv, "series"),
+ (long long)vint(lv, "number"));
+ char nmh[64];
+ tui_pad_label(nmh, sizeof nmh, "Benämning", 48);
+ buf_line(t, MK_STICKY MK_HEAD "%-8s %-11s %s %14s %14s\n", "Ver.",
+ "Datum/Konto", nmh, "Debet", "Kredit");
+ buf_rule(t, 98);
+ yyjson_val *vs = yyjson_obj_get(res, "vouchers");
+ size_t n = yyjson_arr_size(vs);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *vo = yyjson_arr_get(vs, i);
+ char ver[32];
+ snprintf(ver, sizeof ver, "%s%lld", vstr(vo, "series"),
+ (long long)vint(vo, "number"));
+ buf_line(t, MK_HEAD "%-8s %-11s %s\n", ver, vstr(vo, "date"),
+ vstr(vo, "description"));
+ yyjson_val *rows = yyjson_obj_get(vo, "rows");
+ size_t nr = yyjson_arr_size(rows);
+ for (size_t k = 0; k < nr; k++) {
+ yyjson_val *ro = yyjson_arr_get(rows, k);
+ char nm[256], dd[48], cc[48], deb[48], kre[48];
+ tui_pad_label(nm, sizeof nm, vstr(ro, "name"), 48);
+ tui_amt_col(dd, sizeof dd, 14, vint(ro, "debit_ore"));
+ tui_amt_col(cc, sizeof cc, 14, vint(ro, "credit_ore"));
+ snprintf(deb, sizeof deb, "%s", vint(ro, "debit_ore") ? dd : "");
+ snprintf(kre, sizeof kre, "%s", vint(ro, "credit_ore") ? cc : "");
+ buf_line(t, " %-10s %s %14s %14s\n", vstr(ro, "account"),
+ nm, deb, kre);
+ }
+ }
+ yyjson_val *tot = yyjson_obj_get(res, "totals");
+ char s1[48], s2[48];
+ tui_amt_col(s1, sizeof s1, 14, vint(tot, "debit_ore"));
+ tui_amt_col(s2, sizeof s2, 14, vint(tot, "credit_ore"));
+ buf_line(t, "\n" MK_DIM "%-69s %14s %14s\n", "Omslutning:", s1, s2);
+}
+
+static void eskd_save(struct app *a, const char *from, const char *to)
+{
+ const char *home = getenv("HOME");
+ char period[8], def[512], full[512];
+ snprintf(period, sizeof period, "%.4s%.2s", to, to + 5);
+ snprintf(def, sizeof def, "%s/eskd_%s.xml", home && *home ? home : ".",
+ period);
+ char pathbuf[512]; char *path = tui_prompt_into(pathbuf, sizeof pathbuf, "Spara eSKD-fil: ", def, 0) ? pathbuf : NULL;
+ if (!path || !*path)
+ return;
+ if (path[0] == '~' && (path[1] == '/' || path[1] == '\0'))
+ snprintf(full, sizeof full, "%s%s", home ? home : "", path + 1);
+ else
+ snprintf(full, sizeof full, "%s", path);
+ char args[64];
+ snprintf(args, sizeof args, "{\"from\":\"%s\",\"to\":\"%s\"}", from, to);
+ char *resp =
+ client_rpc(&a->conn, "report.vat_eskd", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Kunde inte skapa eSKD", resp);
+ free(resp);
+ return;
+ }
+ char *b64 = jstr_dup(resp, "result.content_base64");
+ unsigned char *data = NULL;
+ size_t n = 0;
+ if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) {
+ tui_message("eSKD", "Kunde inte avkoda filen från servern.");
+ } else {
+ FILE *f = fopen(full, "wb");
+ if (!f || fwrite(data, 1, n, f) != n) {
+ tui_message("eSKD", "Kunde inte skriva %s: %s", full, strerror(errno));
+ if (f)
+ fclose(f);
+ } else {
+ fclose(f);
+ tui_message("eSKD", "Sparat: %s\n(%zu byte, ISO-8859-1)", full, n);
+ }
+ }
+ free(data);
+ free(b64);
+ free(resp);
+}
+
+/* Official fältnamn (short) for the INK2/INK2R/INK2S codes; 2025P4. */
+static const struct {
+ const char *code;
+ const char *label;
+} SRU_LABELS[] = {
+ { "7011", "Räkenskapsårets början" },
+ { "7012", "Räkenskapsårets slut" },
+ { "7104", "1.1 Överskott av näringsverksamhet" },
+ { "7114", "1.2 Underskott av näringsverksamhet" },
+ { "7131", "1.3 Kreditinstituts underlag för riskskatt" },
+ { "7132", "1.4 Underlag för särskild löneskatt på pensionskostnader" },
+ { "7133", "1.5 Negativt underlag för särskild löneskatt på pensionskostnader" },
+ { "7153", "1.6 a Försäkringsföretag m.fl. samt avsatt till pensioner 15 %" },
+ { "7154", "1.6 b Utländska pensions- försäkringar 15 %" },
+ { "7155", "1.7 a Försäkringsföretag m.fl 30 %" },
+ { "7156", "1.7 b Utländska kapitalförsäkringar 30 %" },
+ { "80", "1.8 Småhus/ ägarlägenhet" },
+ { "93", "1.9 Hyreshus: bostäder" },
+ { "84", "1.10 Småhus/ägarlägenhet: tomtmark, byggnad under uppförande" },
+ { "86", "1.11 Hyreshus: tomtmark, bostäder under uppförande" },
+ { "95", "1.12 Hyreshus: lokaler" },
+ { "96", "1.13 Industrienhet och elproduktionsenhet: värme- kraftverk" },
+ { "97", "1.14 Elproduktionsenhet, vattenkraftverk" },
+ { "98", "1.15 Elproduktionsenhet, vindkraftverk" },
+ { "90", "Övriga upplysningar på bilaga" },
+ { "1582", "1.16 Förnybar el (kilowattimmar)" },
+ { "7011", "Räkenskapsårets början" },
+ { "7012", "Räkenskapsårets slut" },
+ { "7201", "2.1 Immateriella anläggningstillgångar Koncessioner, patent, licenser, varumärken, hyresrätter, goodwill och liknande rättigheter" },
+ { "7202", "2.2 Immateriella anläggningstillgångar Förskott avseende immateriella anläggningstillgångar" },
+ { "7214", "2.3 Materiella anläggningstillgångar Byggnader och mark" },
+ { "7215", "2.4 Materiella anläggningstillgångar Maskiner, inventarier och övriga materiella anläggningstillgångar" },
+ { "7216", "2.5 Materiella anläggningstillgångar Förbättringsutgifter på annans fastighet" },
+ { "7217", "2.6 Materiella anläggningstillgångar Pågående nyanläggningar och förskott avseende materiella anläggningstillgångar" },
+ { "7230", "2.7 Finansiella anläggningstillgångar Andelar i koncernföretag" },
+ { "7231", "2.8 Finansiella anläggningstillgångar Andelar i intresseföretag och gemensamt styrda företag" },
+ { "7233", "2.9 Ägarintressen i övriga företag och andra långfristiga värdepappersinnehav" },
+ { "7232", "2.10 Finansiella anläggningstillgångar Fordringar hos koncern-, intresse- och gemensamt styrda företag" },
+ { "7234", "2.11 Finansiella anläggningstillgångar Lån till delägare eller närstående" },
+ { "7235", "2.12 Fordringar hos övriga företag som det finns ett ägarintresse i och andra långfristiga fordringar" },
+ { "7241", "2.13 Varulager m.m. Råvaror och förnödenheter" },
+ { "7242", "2.14 Varulager Varor under tillverkning" },
+ { "7243", "2.15 Varulager Färdiga varor och handelsvaror" },
+ { "7244", "2.16 Varulager Övriga lagertillgångar" },
+ { "7245", "2.17 Varulager Pågående arbeten för annans räkning" },
+ { "7246", "2.18 Varulager Förskott till leverantörer" },
+ { "7251", "2.19 Kortfristiga fordringar Kundfordringar" },
+ { "7252", "2.20 Kortfristiga fordringar Fordringar hos koncern-, intresse- och gemensamt styrda företag" },
+ { "7261", "2.21 Fordringar hos övriga företag som det finns ett ägarintresse i och övriga fordringar" },
+ { "7262", "2.22 Kortfristiga fordringar Upparbetad men ej fakturerad intäkt" },
+ { "7263", "2.23 Kortfristiga fordringar Förutbetalda kostnader och upplupna intäkter" },
+ { "7270", "2.24 Kortfristiga placeringar Andelar i koncernföretag" },
+ { "7271", "2.25 Kortfristiga placeringar Övriga kortfristiga placeringar" },
+ { "7281", "2.26 Kassa och bank Kassa, bank och redovisningsmedel" },
+ { "7301", "2.27 Eget kapital Bundet eget kapital" },
+ { "7302", "2.28 Eget kapital Fritt eget kapital" },
+ { "7321", "2.29 Obeskattade reserver Periodiseringsfonder" },
+ { "7322", "2.30 Obeskattade reserver Ackumulerade överavskrivningar" },
+ { "7323", "2.31 Obeskattade reserver Övriga obeskattade reserver" },
+ { "7331", "2.32 Avsättningar Avsättningar för pensioner och liknande förpliktelser enligt lag (1967:531)..." },
+ { "7332", "2.33 Avsättningar Övriga avsättningar för pensioner och liknande förpliktelser" },
+ { "7333", "2.34 Avsättningar Övriga avsättningar" },
+ { "7350", "2.35 Långfristiga skulder Obligationslån" },
+ { "7351", "2.36 Långfristiga skulder Checkräkningskredit" },
+ { "7352", "2.37 Långfristiga skulder Övriga skulder till kreditinstitut" },
+ { "7353", "2.38 Långfristiga skulder Skulder till koncern-, intresse- och gemensamt styrda företag" },
+ { "7354", "2.39 Skulder till övriga företag som det finns ett ägarintresse i och övriga skulder" },
+ { "7360", "2.40 Kortfristiga skulder Checkräkningskredit" },
+ { "7361", "2.41 Kortfristiga skulder Övriga skulder till kreditinstitut" },
+ { "7362", "2.42 Kortfristiga skulder Förskott från kunder" },
+ { "7363", "2.43 Kortfristiga skulder Pågående arbeten för annans räkning" },
+ { "7364", "2.44 Kortfristiga skulder Fakturerad men ej upparbetad intäkt" },
+ { "7365", "2.45 Kortfristiga skulder Leverantörsskulder" },
+ { "7366", "2.46 Kortfristiga skulder Växelskulder" },
+ { "7367", "2.47 Kortfristiga skulder Skulder till koncern-, intresse- och gemensamt styrda företag" },
+ { "7369", "2.48 Skulder till övriga företag som det finns ett ägarintresse i och övriga skulder" },
+ { "7368", "2.49 Kortfristiga skulder Skattesskulder" },
+ { "7370", "2.50 Kortfristiga skulder Upplupna kostnader och förutbetalda intäkter" },
+ { "7410", "3.1 Nettoomsättning" },
+ { "7411", "3.2 Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning. +" },
+ { "7510", "3.2 Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning. -" },
+ { "7412", "3.3 Aktiverat arbete för egen räkning" },
+ { "7413", "3.4 Övriga rörelseintäkter" },
+ { "7511", "3.5 Råvaror och förnödenheter" },
+ { "7512", "3.6 Handelsvaror" },
+ { "7513", "3.7 Övriga externa kostnader" },
+ { "7514", "3.8 Personalkostnader" },
+ { "7515", "3.9 Av- och nedskrivningar av materiella och immateriella anläggningstillgångar" },
+ { "7516", "3.10 Nedskrivningar av omsättningstillgångar utöver normala nedskrivningar" },
+ { "7517", "3.11 Övriga rörelsekostnader" },
+ { "7414", "3.12 Resultat från andelar i koncernföretag +" },
+ { "7518", "3.12 Resultat från andelar i koncernföretag -" },
+ { "7415", "3.13 Resultat från andelar i intresseföretag och gemensamt styrda företag +" },
+ { "7519", "3.13 Resultat från andelar i intresseföretag och gemensamt styrda företag -" },
+ { "7423", "3.14 Resultat från övriga företag som det finns ett ägarintresse i +" },
+ { "7530", "3.14 Resultat från övriga företag som det finns ett ägarintresse i -" },
+ { "7416", "3.15 Resultat från övriga finansiella anläggningstillgångar +" },
+ { "7520", "3.15 Resultat från övriga finansiella anläggningstillgångar -" },
+ { "7417", "3.16 Övriga ränteintäkter och liknande resultatposter" },
+ { "7521", "3.17 Nedskrivning av finansiella anläggningstillgångar och kortfristiga placeringar" },
+ { "7522", "3.18 Räntekostnader och liknande resultatposter" },
+ { "7524", "3.19 Lämnade koncernbidrag" },
+ { "7419", "3.20 Mottagna koncernbidrag" },
+ { "7420", "3.21 Återföring av periodiseringsfond" },
+ { "7525", "3.22 Avsättning till periodiseringsfond" },
+ { "7421", "3.23 Förändring av överavskrivningar +" },
+ { "7526", "3.23 Förändring av överavskrivningar -" },
+ { "7422", "3.24 Övriga bokslutsdispositioner +" },
+ { "7527", "3.24 Övriga bokslutsdispositioner -" },
+ { "7528", "3.25 Skatt på årets resultat" },
+ { "7450", "3.26 Årets resultat, vinst (flyttas till p. 4.1)" },
+ { "7550", "3.27 Årets resultat, förlust (flyttas till p. 4.2)" },
+ { "7011", "Räkenskapsårets början" },
+ { "7012", "Räkenskapsårets slut" },
+ { "7650", "4.1 Årets resultat, vinst" },
+ { "7750", "4.2 Årets resultat, förlust" },
+ { "7651", "4.3a. Bokförda kostnader som inte ska dras av: a. Skatt på årets resultat" },
+ { "7652", "b. Bokförda kostnader som inte ska dras av: b. Nedskrivning av finansiella tillgångar" },
+ { "7653", "c. Bokförda kostnader som inte ska dras av: c. Andra bokförda kostnader" },
+ { "7751", "4.4a. Kostnader som ska dras av men som inte ingår i det redovisade resultatet: a. Lämnade koncernbidrag" },
+ { "7764", "b. Kostnader som ska dras av men som inte ingår i det redovisade resultatet: b. Andra ej bokförda kostnader" },
+ { "7752", "4.5a. Bokförda intäkter som inte ska tas upp: a. Ackordsvinster" },
+ { "7753", "b. Bokförda intäkter som inte ska tas upp: b. Utdelning" },
+ { "7754", "c. Bokförda intäkter som inte ska tas upp: c. Andra bokförda intäkter" },
+ { "7654", "4.6a. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet a. Beräknad schablonintäkt på periodiseringsfonder vid beskattningsårets ingång" },
+ { "7668", "b. Beräknad schablonintäkt på fondandelar ägda vid kalenderårets ingång" },
+ { "7655", "c. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: c. Mottagna koncernbidrag" },
+ { "7673", "d. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: d. Uppräknat belopp vid återföring av periodiseringsfond" },
+ { "7665", "e. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: e. Andra ej bokförda intäkter" },
+ { "7755", "4.7a. Avyttring av delägarrätter: a. Bokförd vinst" },
+ { "7656", "b. Avyttring av delägarrätter: b. Bokförd förlust" },
+ { "7756", "c. Avyttring av delägarrätter: c. Uppskov med kapitalvinst enligt blankett N4" },
+ { "7657", "d. Avyttring av delägarrätter: d. Återfört uppskov med kapitalvinst enligt blankett N4" },
+ { "7658", "e. Avyttring av delägarrätter: e. Kapitalvinst för beskattningsåret" },
+ { "7757", "f. Avyttring av delägarrätter: f. Kapitalförlust som ska dras av" },
+ { "7758", "4.8a. Andel i handelsbolag (inkl. avyttring): a. Bokförd intäkt/vinst" },
+ { "7659", "b. Andel i handelsbolag (inkl. avyttring): b. Skattemässigt överskott enligt N3B" },
+ { "7660", "c. Andel i handelsbolag (inkl. avyttring): c. Bokförd kostnad/förlust" },
+ { "7759", "d. Andel i handelsbolag (inkl. avyttring): d. Skattemässigt underskott enligt N3B" },
+ { "7666", "4.9 Skattemässig justering av bokfört resultat för avskrivningar på byggnader och annan fast egendom samt restvärdesavskrivning på maskiner och inventarier (+)" },
+ { "7765", "4.9 Skattemässig justering av bokfört resultat för avskrivningar på byggnader och annan fast egendom samt restvärdesavskrivning på maskiner och inventarier (-)" },
+ { "7661", "4.10 Skattemässig justering av bokfört resultat vid avyttring av näringsfastighet och näringsbostadsrätt" },
+ { "7760", "4.10 Skattemässig korrigering av bokfört resultat vid avyttring av näringsfastighet och näringsbostadsrätt: -" },
+ { "7761", "4.11 Skogs-/substansminskningsavdrag (specificeras på blankett N8)" },
+ { "7662", "4.12 Återföringar vid avyttring av fastighet t.ex. värdeminskningsavdrag, skogsavdrag och substansminskningsavdrag..." },
+ { "7663", "4.13 Andra skattemässiga justeringar av resultatet: +" },
+ { "7762", "4.13 Andra skattemässiga justeringar av resultatet: -" },
+ { "7763", "4.14a. Underskott: a. Outnyttjat underskott från föregående år" },
+ { "7671", "4.14b. Reduktion av outnyttjat underskott med hänsyn till beloppsspärr, ackord eller konkurs" },
+ { "7672", "4.14c. Reduktion av outnyttjat underskott med hänsyn till koncernbidragsspärr, fusionsspärr m.m. (beloppet ska också tas upp vid p. 1.2. på sid. 1)" },
+ { "7670", "4.15 Överskott (flyttas till p. 1.1 på sid. 1)" },
+ { "7770", "4.16 Underskott (flyttas till p. 1.2 på sid. 1)" },
+ { "8020", "4.17 Årets begärda och tidigare års medgivna värdeminskningsavdrag som finns vid beskattningsårets utgång avseende byggnader." },
+ { "8021", "4.18 Årets begärda och tidigare års medgivna värdeminskningsavdrag som finns vid beskattningsårets utgång avseende markanläggningar." },
+ { "8023", "4.19 Vid restvärdesavskrivning: återförda belopp för av- och nedskrivning, försäljning, utrangering" },
+ { "8026", "4.20 Lån från aktieägare (fysisk person) vid räkenskapsårets utgång" },
+ { "8022", "4.21 Pensionskostnader (som ingår i p. 3.8)" },
+ { "8028", "4.22 Koncernbidragsspärrat och fusionsspärrat underskott m.m. (frivillig uppgift)" },
+ { "8040", "Uppdragstagare (t.ex. redovisningskonsult) har biträtt vid upprättandet av årsredovisningen: Ja" },
+ { "8041", "Uppdragstagare (t.ex. redovisningskonsult) har biträtt vid upprättandet av årsredovisningen: Nej" },
+ { "8044", "Årsredovisningen har varit föremål för revision: Ja" },
+ { "8045", "Årsredovisningen har varit föremål för revision: Nej" },
+};
+
+static const char *sru_label(const char *code)
+{
+ for (size_t i = 0; i < sizeof SRU_LABELS / sizeof SRU_LABELS[0]; i++)
+ if (strcmp(SRU_LABELS[i].code, code) == 0)
+ return SRU_LABELS[i].label;
+ return code;
+}
+
+static void sru_rows(struct buf *t, yyjson_val *arr)
+{
+ size_t n = yyjson_arr_size(arr);
+ for (size_t i = 0; i < n; i++) {
+ yyjson_val *row = yyjson_arr_get(arr, i);
+ char lbl[256], amt[48];
+ tui_pad_label(lbl, sizeof lbl, sru_label(vstr(row, "code")), 66);
+ tui_kr_format(vint(row, "amount") * 100, amt, sizeof amt);
+ buf_line(t, "%s %14s\n", lbl, amt);
+ }
+}
+
+static void fmt_ink2(struct buf *t, const struct app *a, yyjson_val *res)
+{
+ buf_line(t, MK_HEAD "Inkomstdeklaration 2 (INK2)\n%s\n\n", a->org_name);
+ buf_line(t, MK_DIM "Räkenskapsår: %s - %s\n", vstr(res, "from"),
+ vstr(res, "to"));
+ buf_line(t, MK_DIM "Blankett: %s\n", vstr(res, "period"));
+ buf_line(t, "\n" MK_HEAD "INK2 - huvudblankett\n");
+ buf_rule(t, 81);
+ sru_rows(t, yyjson_obj_get(res, "ink2"));
+ buf_line(t, "\n" MK_HEAD "INK2R - räkenskapsschema\n");
+ buf_rule(t, 81);
+ sru_rows(t, yyjson_obj_get(res, "ink2r"));
+ buf_line(t, "\n" MK_HEAD "INK2S - skattemässiga justeringar\n");
+ buf_rule(t, 81);
+ sru_rows(t, yyjson_obj_get(res, "ink2s"));
+ yyjson_val *un = yyjson_obj_get(res, "unmapped");
+ size_t nu = yyjson_arr_size(un);
+ if (nu) {
+ buf_line(t, "\n" MK_HEAD "Konton utan SRU-mappning (måste åtgärdas):\n");
+ for (size_t i = 0; i < nu; i++) {
+ yyjson_val *row = yyjson_arr_get(un, i);
+ char amt[48];
+ tui_kr_format(vint(row, "amount_ore"), amt, sizeof amt);
+ buf_line(t, " %s %s\n", vstr(row, "account"), amt);
+ }
+ }
+}
+
+static void sru_save(struct app *a)
+{
+ const char *home = getenv("HOME");
+ char def[512];
+ snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : ".");
+ struct stat sb;
+ if (stat(def, &sb) != 0 || !S_ISDIR(sb.st_mode))
+ snprintf(def, sizeof def, "%s", home && *home ? home : ".");
+ char dirbuf[512]; char *dir = tui_prompt_into(dirbuf, sizeof dirbuf, "Spara SRU-filer i: ", def, 0) ? dirbuf : NULL;
+ if (!dir || !*dir)
+ return;
+ char full[512];
+ if (dir[0] == '~' && (dir[1] == '/' || dir[1] == '\0'))
+ snprintf(full, sizeof full, "%s%s", home ? home : "", dir + 1);
+ else
+ snprintf(full, sizeof full, "%s", dir);
+ char args[64];
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld}", (long long)a->fy);
+ char *resp = client_rpc(&a->conn, "sru.export", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Kunde inte skapa SRU-filerna", resp);
+ free(resp);
+ return;
+ }
+ const char *keys[2] = { "result.info.content_base64",
+ "result.blanketter.content_base64" };
+ const char *names[2] = { "INFO.SRU", "BLANKETTER.SRU" };
+ char saved[2][600] = { "", "" };
+ int ok = 0;
+ for (int i = 0; i < 2; i++) {
+ char *b64 = jstr_dup(resp, keys[i]);
+ unsigned char *data = NULL;
+ size_t n = 0;
+ if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) {
+ free(b64);
+ continue;
+ }
+ snprintf(saved[i], sizeof saved[i], "%s/%s", full, names[i]);
+ FILE *f = fopen(saved[i], "wb");
+ if (f && fwrite(data, 1, n, f) == n) {
+ fclose(f);
+ ok++;
+ } else {
+ if (f)
+ fclose(f);
+ tui_message("SRU", "Kunde inte skriva %s: %s", saved[i],
+ strerror(errno));
+ }
+ free(data);
+ free(b64);
+ }
+ free(resp);
+ if (ok == 2)
+ tui_message("SRU", "Sparat:\n%s\n%s\n\nLadda upp båda i Skatteverkets"
+ " filöverföring.",
+ saved[0], saved[1]);
+}
+
+void ink2_screen(struct app *a)
+{
+ char sargs[64];
+ snprintf(sargs, sizeof sargs, "{\"fiscal_year\":%lld}", (long long)a->fy);
+ for (;;) {
+ char *resp = client_rpc(&a->conn, "sru.export", a->session, a->org,
+ sargs);
+ if (!resp || !client_ok(resp)) {
+ show_error("Inkomstdeklaration", resp);
+ free(resp);
+ break;
+ }
+ yyjson_doc *sd = parse(resp);
+ yyjson_val *sres = sd ? jget(yyjson_doc_get_root(sd), "result") : NULL;
+ struct buf out;
+ buf_init(&out);
+ if (sres)
+ fmt_ink2(&out, a, sres);
+ else
+ buf_line(&out, "%s", resp);
+ buf_append(&out, "", 1);
+ int sagain = tui_pager("Rapport", (const char *)out.p, "s = spara SRU");
+ buf_free(&out);
+ yyjson_doc_free(sd);
+ free(resp);
+ if (sagain == 2)
+ sru_save(a);
+ if (!sagain)
+ break;
+ }
+}
+
+void reports_screen(struct app *a)
+{
+ static const char *const report_items[] = {
+ "Saldobalans (trial balance)",
+ "Resultaträkning",
+ "Balansräkning",
+ "Huvudbok",
+ "Verifikationslista",
+ "Momsdeklaration",
+ "Kontolista (alla konton)",
+ };
+ static int last_sel = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ int sel = tui_menu("Rapporter", report_items, 7, 0, &last_sel);
+ if (sel < 0)
+ return;
+ if (sel == 6) {
+ accounts_report(a);
+ continue;
+ }
+ char args[256];
+ char vat_from[16] = "", vat_to[16] = "";
+ const char *cmd = NULL;
+ switch (sel) {
+ case 0:
+ cmd = "report.trial_balance";
+ snprintf(args, sizeof args,
+ "{\"include_zero\":false,\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ break;
+ case 1:
+ cmd = "report.income_statement";
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ break;
+ case 2:
+ cmd = "report.balance_sheet";
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ break;
+ case 3:
+ cmd = "report.general_ledger";
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ break;
+ case 4:
+ cmd = "report.voucher_list";
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ break;
+ case 5: {
+ cmd = "report.vat";
+ char frombuf[16]; char *from = tui_date_prompt_into(frombuf, sizeof frombuf, "Från (YYYY-MM-DD): ", a->fy_start) ? frombuf : NULL;
+ if (!from)
+ continue;
+ char f[16];
+ snprintf(f, sizeof f, "%s", from);
+ char tobuf[16]; char *to = tui_date_prompt_into(tobuf, sizeof tobuf, "Till (YYYY-MM-DD): ", a->fy_end) ? tobuf : NULL;
+ if (!to)
+ continue;
+ snprintf(vat_from, sizeof vat_from, "%s", f);
+ snprintf(vat_to, sizeof vat_to, "%s", to);
+ snprintf(args, sizeof args,
+ "{\"from\":\"%s\",\"to\":\"%s\"}", f, to);
+ break;
+ }
+ }
+ for (;;) {
+ char *resp = client_rpc(&a->conn, cmd, a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Rapport", resp);
+ free(resp);
+ break;
+ }
+ yyjson_doc *d = parse(resp);
+ yyjson_val *res =
+ d ? jget(yyjson_doc_get_root(d), "result") : NULL;
+ struct buf out;
+ buf_init(&out);
+ if (!res) {
+ buf_line(&out, "%s", resp);
+ } else if (sel == 0) {
+ fmt_saldobalans(&out, a, res);
+ } else if (sel == 1) {
+ yyjson_doc *pd = NULL;
+ yyjson_val *prev = NULL;
+ int64_t pf = prev_fy_id(a);
+ if (pf) {
+ char pargs[64];
+ snprintf(pargs, sizeof pargs, "{\"fiscal_year\":%lld}",
+ (long long)pf);
+ char *presp = client_rpc(&a->conn,
+ "report.income_statement",
+ a->session, a->org, pargs);
+ if (presp && client_ok(presp)) {
+ pd = parse(presp);
+ prev = pd ? jget(yyjson_doc_get_root(pd), "result")
+ : NULL;
+ }
+ free(presp);
+ }
+ fmt_resultat(&out, a, res, prev);
+ yyjson_doc_free(pd);
+ } else if (sel == 2) {
+ fmt_balans(&out, a, res);
+ } else if (sel == 3) {
+ fmt_huvudbok(&out, a, res);
+ } else if (sel == 4) {
+ fmt_verifikationslista(&out, a, res);
+ } else {
+ fmt_moms(&out, a, res);
+ }
+ buf_append(&out, "", 1);
+ int again = tui_pager("Rapport", (const char *)out.p,
+ sel == 5 ? "s = spara eSKD" : NULL);
+ buf_free(&out);
+ yyjson_doc_free(d);
+ free(resp);
+ if (again == 2 && sel == 5) {
+ eskd_save(a, vat_from, vat_to);
+ break;
+ }
+ if (!again)
+ break;
+ }
+ }
+}
diff --git a/clients/screens_rules.c b/clients/screens_rules.c
new file mode 100644
index 0000000..19f898d
--- /dev/null
+++ b/clients/screens_rules.c
@@ -0,0 +1,226 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+/* ------------------------------------------------------------------ */
+/* momsregler (the vat report's report_rules) */
+/* ------------------------------------------------------------------ */
+
+static const char *const RULE_TYPES[] = { "account", "range", "type" };
+#define RULE_NTYPES ((int)(sizeof RULE_TYPES / sizeof RULE_TYPES[0]))
+static const char *const RULE_SIGNS[] = { "+", "-" };
+#define RULE_NSIGNS ((int)(sizeof RULE_SIGNS / sizeof RULE_SIGNS[0]))
+
+struct rule {
+ int64_t id;
+ char box[16];
+ char match_type[16];
+ char pattern[128];
+ int sign;
+ int64_t sort_order;
+};
+
+/* F5 dry-runs; Ctrl+Enter creates/updates. Returns 1 when saved. */
+static int rule_save(struct app *a, int64_t id, const char *box, int mti,
+ const char *pattern, int signi, const char *sort, int dry)
+{
+ char *end = NULL;
+ long long sort_order = strtoll(sort, &end, 10);
+ if (!sort[0] || !end || *end) {
+ tui_message("Momsregel", "Sortering måste vara ett heltal.");
+ return 0;
+ }
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ if (id)
+ yyjson_mut_obj_add_int(d, o, "id", id);
+ yyjson_mut_obj_add_strcpy(d, o, "report", "vat");
+ yyjson_mut_obj_add_strcpy(d, o, "box", box);
+ yyjson_mut_obj_add_strcpy(d, o, "match_type", RULE_TYPES[mti]);
+ yyjson_mut_obj_add_strcpy(d, o, "pattern", pattern);
+ yyjson_mut_obj_add_int(d, o, "sign", signi == 1 ? -1 : 1);
+ yyjson_mut_obj_add_int(d, o, "sort_order", (int64_t)sort_order);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ const char *cmd = id ? "report.rule_update" : "report.rule_create";
+ char *resp = dry ? rpc_dry(a, cmd, args)
+ : client_rpc(&a->conn, cmd, a->session, a->org, args);
+ free(args);
+ if (resp && client_ok(resp)) {
+ if (dry) {
+ tui_message("Validering OK", "Regeln är korrekt.");
+ } else {
+ tui_message("Momsregler", "Regeln sparad.");
+ free(resp);
+ return 1;
+ }
+ } else {
+ show_error("Momsregler", resp);
+ }
+ free(resp);
+ return 0;
+}
+
+static int rule_form(struct app *a, const struct rule *r)
+{
+ static int sel = 0;
+ char box[16] = "", pattern[128] = "", sort[16] = "10";
+ int mti = 0, signi = 0;
+ if (r) {
+ snprintf(box, sizeof box, "%s", r->box);
+ for (int i = 0; i < RULE_NTYPES; i++)
+ if (strcmp(RULE_TYPES[i], r->match_type) == 0)
+ mti = i;
+ snprintf(pattern, sizeof pattern, "%s", r->pattern);
+ signi = r->sign < 0 ? 1 : 0;
+ snprintf(sort, sizeof sort, "%lld", (long long)r->sort_order);
+ }
+ struct tui_form_field ff[5];
+ memset(ff, 0, sizeof ff);
+ ff[0].label = "Ruta";
+ ff[0].value = box;
+ ff[0].cap = sizeof box;
+ ff[0].kind = TUI_F_TEXT;
+ ff[1].label = "Typ";
+ ff[1].kind = TUI_F_CHOICE;
+ ff[1].choices = RULE_TYPES;
+ ff[1].nchoices = RULE_NTYPES;
+ ff[1].choice = &mti;
+ ff[2].label = "Mönster";
+ ff[2].value = pattern;
+ ff[2].cap = sizeof pattern;
+ ff[2].kind = TUI_F_TEXT;
+ ff[3].label = "Tecken";
+ ff[3].kind = TUI_F_CHOICE;
+ ff[3].choices = RULE_SIGNS;
+ ff[3].nchoices = RULE_NSIGNS;
+ ff[3].choice = &signi;
+ ff[4].label = "Sortering";
+ ff[4].value = sort;
+ ff[4].cap = sizeof sort;
+ ff[4].kind = TUI_F_TEXT;
+ const char *title = r ? "Redigera momsregel" : "Ny momsregel";
+ for (;;) {
+ int rr = tui_form_run(title, ff, 5, 1, &sel);
+ if (rr == TUI_FORM_BACK)
+ return 0;
+ if (rr == TUI_FORM_REFRESH) {
+ rule_save(a, r ? r->id : 0, box, mti, pattern, signi, sort, 1);
+ continue;
+ }
+ if (rr == TUI_FORM_SUBMIT) {
+ if (rule_save(a, r ? r->id : 0, box, mti, pattern, signi, sort, 0))
+ return 1;
+ continue;
+ }
+ }
+}
+
+static void rule_delete(struct app *a, const struct rule *r)
+{
+ if (!tui_confirm("Momsregler", "Ta bort regeln %s %s %s?", r->box,
+ r->match_type, r->pattern))
+ return;
+ char args[64];
+ snprintf(args, sizeof args, "{\"id\":%lld}", (long long)r->id);
+ char *resp =
+ client_rpc(&a->conn, "report.rule_delete", a->session, a->org, args);
+ if (resp && client_ok(resp))
+ tui_message("Momsregler", "Regeln borttagen.");
+ else
+ show_error("Momsregler", resp);
+ free(resp);
+}
+
+void rules_screen(struct app *a)
+{
+ int cursor = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(&a->conn, "report.rule_list", a->session,
+ a->org, "{\"report\":\"vat\"}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Momsregler", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ struct rule *rules = xcalloc(n ? n : 1, sizeof *rules);
+ char **items = xcalloc(n + 1, sizeof(char *));
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[512], boxbuf[24], patbuf[96];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ rules[i].id = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.box", i);
+ char *sbox = jstr_dup(resp, path);
+ snprintf(rules[i].box, sizeof rules[i].box, "%s",
+ sbox ? sbox : "");
+ free(sbox);
+ snprintf(path, sizeof path, "result.items.%zu.match_type", i);
+ char *mt = jstr_dup(resp, path);
+ snprintf(rules[i].match_type, sizeof rules[i].match_type, "%s",
+ mt ? mt : "");
+ free(mt);
+ snprintf(path, sizeof path, "result.items.%zu.pattern", i);
+ char *pat = jstr_dup(resp, path);
+ snprintf(rules[i].pattern, sizeof rules[i].pattern, "%s",
+ pat ? pat : "");
+ free(pat);
+ snprintf(path, sizeof path, "result.items.%zu.sign", i);
+ rules[i].sign = (int)jint_val(resp, path, 1);
+ snprintf(path, sizeof path, "result.items.%zu.sort_order", i);
+ rules[i].sort_order = jint_val(resp, path, 0);
+
+ snprintf(boxbuf, sizeof boxbuf, "%s", rules[i].box);
+ tui_pad_field(boxbuf, sizeof boxbuf, 6);
+ snprintf(patbuf, sizeof patbuf, "%s", rules[i].pattern);
+ tui_pad_field(patbuf, sizeof patbuf, 34);
+ snprintf(line, sizeof line, "%s %-8s %s %-2s %6lld", boxbuf,
+ rules[i].match_type, patbuf,
+ rules[i].sign < 0 ? "-" : "+",
+ (long long)rules[i].sort_order);
+ items[i] = xstrdup(line);
+ }
+ items[n] = xstrdup("+ Ny regel (^N)");
+ int s = tui_select_list("Momsregler", items, (int)n + 1, cursor, 1,
+ &cursor, 1, n > 0 ? "ta bort" : NULL, 0);
+ for (size_t i = 0; i <= n; i++)
+ free(items[i]);
+ free(items);
+ free(resp);
+ if (s == -1) {
+ free(rules);
+ return;
+ }
+ if (s == -6) {
+ if (cursor >= 0 && (size_t)cursor < n)
+ rule_delete(a, &rules[cursor]);
+ } else if (s == -4 || (s >= 0 && (size_t)s == n)) {
+ rule_form(a, NULL);
+ } else if (s >= 0 && (size_t)s < n) {
+ rule_form(a, &rules[s]);
+ }
+ free(rules);
+ }
+}
diff --git a/clients/screens_settings.c b/clients/screens_settings.c
new file mode 100644
index 0000000..6440150
--- /dev/null
+++ b/clients/screens_settings.c
@@ -0,0 +1,424 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+struct company_field {
+ const char *key;
+ const char *label;
+ int numeric; /* 0 = text, 1 = month (1-12), 2 = unsigned integer */
+};
+
+static const struct company_field COMPANY_FIELDS[] = {
+ { "name", "Företagsnamn", 0 },
+ { "org_nr", "Organisationsnummer", 0 },
+ { "vat_nr", "Momsregistreringsnummer", 0 },
+ { "address", "Adress", 0 },
+ { "postal_code", "Postnummer", 0 },
+ { "city", "Ort", 0 },
+ { "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 },
+};
+
+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);
+ 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 ||
+ strcmp(v, "year") == 0;
+ if (strcmp(f->key, "framework") == 0)
+ return strcmp(v, "K2") == 0 || strcmp(v, "K3") == 0;
+ 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 = tui_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 ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, q, "n", 0) ? ansbuf : NULL;
+ 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)) {
+ tui_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[512] = "", titlebuf[512] = "";
+ int ok_name = tui_prompt_into(namebuf, sizeof namebuf, "Namn: ",
+ "", 0);
+ int ok_title = ok_name && namebuf[0]
+ ? tui_prompt_into(titlebuf, sizeof titlebuf,
+ "Titel: ",
+ "Styrelseledamot", 0)
+ : 0;
+ if (ok_name && namebuf[0] && ok_title && titlebuf[0]) {
+ 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", titlebuf);
+ 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))
+ tui_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) {
+ tui_message("Styrelseledamöter",
+ "Endast ägare kan ändra styrelseledamöterna.");
+ } else {
+ char namebuf[512] = "", titlebuf[512] = "";
+ int ok_name = tui_prompt_into(namebuf, sizeof namebuf,
+ "Namn: ",
+ names[s] ? names[s] : "", 0);
+ int ok_title = ok_name && namebuf[0]
+ ? tui_prompt_into(
+ titlebuf, sizeof titlebuf, "Titel: ",
+ titles[s] ? titles[s] : "", 0)
+ : 0;
+ if (ok_name && namebuf[0] && ok_title && titlebuf[0]) {
+ 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", titlebuf);
+ 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))
+ tui_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;
+ }
+}
+
+void company_screen(struct app *a)
+{
+ static int sel = 0;
+ const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]);
+ const int ninv = 2;
+ static const char *const invkeys[2] = { "invoice_bankgiro",
+ "invoice_our_ref" };
+ static const char *const invlabels[2] = { "Bankgiro",
+ "Vår referens (faktura)" };
+ int owner = a->role[0] && strcmp(a->role, "owner") == 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Företagsuppgifter", resp);
+ free(resp);
+ return;
+ }
+ char *vals[20];
+ struct tui_form_field ff[20];
+ memset(ff, 0, sizeof ff);
+ for (int i = 0; i < nf; i++) {
+ char path[64];
+ vals[i] = xcalloc(512, 1);
+ snprintf(path, sizeof path, "result.%s", COMPANY_FIELDS[i].key);
+ if (COMPANY_FIELDS[i].numeric) {
+ snprintf(vals[i], 512, "%lld",
+ (long long)jint_val(resp, path, 0));
+ } else {
+ char *v = jstr_dup(resp, path);
+ snprintf(vals[i], 512, "%s", v ? v : "");
+ free(v);
+ }
+ ff[i].label = COMPANY_FIELDS[i].label;
+ ff[i].value = vals[i];
+ ff[i].cap = 512;
+ ff[i].kind = TUI_F_TEXT;
+ }
+ free(resp);
+ char *sresp =
+ client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
+ for (int i = 0; i < ninv; i++) {
+ char path[64];
+ int idx = nf + i;
+ vals[idx] = xcalloc(512, 1);
+ snprintf(path, sizeof path, "result.%s", invkeys[i]);
+ char *v = sresp ? jstr_dup(sresp, path) : NULL;
+ snprintf(vals[idx], 512, "%s", v ? v : "");
+ free(v);
+ ff[idx].label = invlabels[i];
+ ff[idx].value = vals[idx];
+ ff[idx].cap = 512;
+ ff[idx].kind = TUI_F_TEXT;
+ }
+ free(sresp);
+ ff[nf + ninv].label = "Styrelseledamöter";
+ ff[nf + ninv].kind = TUI_F_ACTION;
+ int r = tui_form_run("Företagsuppgifter", ff, nf + ninv + 1, owner,
+ &sel);
+ if (r >= 0 && r < nf) {
+ if (!company_field_valid(&COMPANY_FIELDS[r], vals[r])) {
+ tui_message("Ogiltigt värde", "%s", COMPANY_FIELDS[r].label);
+ } else {
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ if (COMPANY_FIELDS[r].numeric)
+ yyjson_mut_obj_add_int(d, o, COMPANY_FIELDS[r].key,
+ strtoll(vals[r], NULL, 10));
+ else
+ yyjson_mut_obj_add_strcpy(d, o, COMPANY_FIELDS[r].key,
+ vals[r]);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *rr = client_rpc(&a->conn, "org.update", a->session,
+ a->org, args);
+ free(args);
+ if (!rr || !client_ok(rr))
+ show_error("Kunde inte spara", rr);
+ free(rr);
+ }
+ } else if (r >= nf && r < nf + ninv) {
+ 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, "key", invkeys[r - nf]);
+ yyjson_mut_obj_add_strcpy(d, o, "value", vals[r]);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *rr = client_rpc(&a->conn, "settings.set", a->session,
+ a->org, args);
+ free(args);
+ if (!rr || !client_ok(rr))
+ show_error("Kunde inte spara", rr);
+ free(rr);
+ }
+ if (r == nf + ninv)
+ board_screen(a);
+ for (int i = 0; i < nf + ninv; i++)
+ free(vals[i]);
+ if (r == TUI_FORM_BACK)
+ return;
+ }
+}
+
+static const char *const SETTING_KEYS[] = {
+ "default_series",
+ "attachment_dir",
+ "invoice_receivable_account",
+ "invoice_revenue_account",
+ "smtp_host",
+ "smtp_port",
+ "smtp_user",
+ "smtp_from",
+ "smtp_reply_to",
+ "smtp_security",
+ "smtp_password",
+};
+static const char *const SETTING_LABELS[] = {
+ "Standardserie",
+ "Bilagornas mapp",
+ "Fordringskonto",
+ "Intäktskonto",
+ "SMTP-server",
+ "SMTP-port",
+ "SMTP-användare",
+ "SMTP-avsändare",
+ "SMTP-svarsadress",
+ "SMTP-säkerhet",
+ "SMTP-lösenord",
+};
+static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" };
+
+void settings_screen(struct app *a)
+{
+ static int sel = 0;
+ const int nset = (int)(sizeof SETTING_KEYS / sizeof SETTING_KEYS[0]);
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp =
+ client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Inställningar", resp);
+ free(resp);
+ return;
+ }
+ int pw_set = jbool_val(resp, "result.smtp_password_set", 0);
+ char pwstatus[192];
+ snprintf(pwstatus, sizeof pwstatus,
+ "SMTP-lösenord (lämna tomt för oförändrat): %s",
+ pw_set ? "satt" : "inte satt");
+ char *vals[16];
+ struct tui_form_field ff[16];
+ memset(ff, 0, sizeof ff);
+ int sec_choice = 0;
+ for (int i = 0; i < nset; i++) {
+ char path[64], *v;
+ vals[i] = xcalloc(512, 1);
+ snprintf(path, sizeof path, "result.%s", SETTING_KEYS[i]);
+ v = jstr_dup(resp, path);
+ snprintf(vals[i], 512, "%s", v ? v : "");
+ free(v);
+ const char *key = SETTING_KEYS[i];
+ ff[i].label = SETTING_LABELS[i];
+ ff[i].value = vals[i];
+ ff[i].cap = 512;
+ ff[i].kind = TUI_F_TEXT;
+ if (strcmp(key, "smtp_security") == 0) {
+ ff[i].kind = TUI_F_CHOICE;
+ ff[i].choices = SECURITY_CHOICES;
+ ff[i].nchoices = 3;
+ ff[i].choice = &sec_choice;
+ for (int k = 0; k < 3; k++)
+ if (strcmp(vals[i], SECURITY_CHOICES[k]) == 0)
+ sec_choice = k;
+ } else if (strcmp(key, "smtp_password") == 0) {
+ ff[i].mask = 1;
+ }
+ }
+ free(resp);
+ struct tui_form_action acts[1];
+ memset(acts, 0, sizeof acts);
+ acts[0].label = pwstatus;
+ acts[0].enabled = -1; /* dim status row, not selectable */
+ int r = tui_form_run_actions(
+ "Inställningar", ff, nset, 1, acts, 1,
+ "upp/ned/Home/End Enter = ändra F5 = uppdatera"
+ " Esc/q = tillbaka ^C = avsluta",
+ &sel);
+ if (r >= 0 && r < nset) {
+ const char *key = SETTING_KEYS[r];
+ const char *val = vals[r];
+ if (strcmp(key, "smtp_security") == 0)
+ val = SECURITY_CHOICES[sec_choice];
+ if (strcmp(key, "smtp_password") == 0 && !*vals[r]) {
+ /* empty means "keep the stored secret" */
+ } else {
+ 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, "key", key);
+ yyjson_mut_obj_add_strcpy(d, o, "value", val);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *rr = client_rpc(&a->conn, "settings.set", a->session,
+ a->org, args);
+ free(args);
+ if (rr && client_ok(rr)) {
+ snprintf(vals[r], 512, "%s", val);
+ if (strcmp(key, "default_series") == 0)
+ snprintf(a->default_series,
+ sizeof a->default_series, "%s", val);
+ else if (strcmp(key, "attachment_dir") == 0)
+ snprintf(a->attachment_dir,
+ sizeof a->attachment_dir, "%s", val);
+ } else {
+ show_error("Kunde inte spara inställningen", rr);
+ }
+ free(rr);
+ }
+ }
+ for (int i = 0; i < nset; i++)
+ free(vals[i]);
+ if (r == TUI_FORM_BACK)
+ return;
+ }
+}
diff --git a/clients/screens_templates.c b/clients/screens_templates.c
new file mode 100644
index 0000000..b2cd7ab
--- /dev/null
+++ b/clients/screens_templates.c
@@ -0,0 +1,343 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+struct tui_trow {
+ char account[16];
+ char formula[64];
+ char text[64];
+};
+
+struct tplform {
+ struct app *a;
+ struct tui_rt rt;
+ struct tui_trow rows[64];
+ int nrows;
+ char name[128];
+ char desc[256];
+ char series[16];
+ int64_t id;
+};
+
+static void tpl_cell(void *ud, int row, int col, char **buf, size_t *cap)
+{
+ struct tplform *tf = ud;
+ struct tui_trow *r = &tf->rows[row];
+ if (col == 0) {
+ *buf = r->account;
+ *cap = sizeof r->account;
+ } else if (col == 2) {
+ *buf = r->formula;
+ *cap = sizeof r->formula;
+ } else {
+ *buf = r->text;
+ *cap = sizeof r->text;
+ }
+}
+
+static void tpl_info(void *ud, int row, char *buf, size_t n)
+{
+ struct tplform *tf = ud;
+ const char *nm = acct_name(tf->a, tf->rows[row].account);
+ snprintf(buf, n, "%s", nm ? nm : "");
+}
+
+static void tpl_footer(void *ud, char *buf, size_t n)
+{
+ (void)ud;
+ snprintf(buf, n, "x = belopp i kronor. Positivt blir debet, negativt"
+ " kredit.");
+}
+
+/* F5 dry-runs; Ctrl+Enter creates/updates. Returns 1 when saved. */
+static int tpl_save(struct tplform *tf, int dry)
+{
+ const char *problem = NULL;
+ char msg[256] = "";
+ if (!tf->name[0])
+ problem = "Namn saknas.";
+ int used = 0;
+ for (int i = 0; i < tf->rt.nrows && !problem; i++) {
+ if (!tf->rows[i].account[0])
+ continue;
+ if (!acct_exists(tf->a, tf->rows[i].account)) {
+ snprintf(msg, sizeof msg, "Konto %.15s finns inte (rad %d).",
+ tf->rows[i].account, i + 1);
+ problem = msg;
+ break;
+ }
+ if (!tf->rows[i].formula[0] || !formula_valid(tf->rows[i].formula)) {
+ snprintf(msg, sizeof msg,
+ "Ogiltig formel på rad %d: '%.60s'.", i + 1,
+ tf->rows[i].formula);
+ problem = msg;
+ break;
+ }
+ used++;
+ }
+ if (!problem && used == 0)
+ problem = "Mallen behöver minst en rad.";
+ if (problem) {
+ tui_message("Mall", "%s", problem);
+ return 0;
+ }
+
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ if (tf->id)
+ yyjson_mut_obj_add_int(d, o, "id", tf->id);
+ yyjson_mut_obj_add_strcpy(d, o, "name", tf->name);
+ yyjson_mut_obj_add_strcpy(d, o, "series", tf->series);
+ yyjson_mut_obj_add_strcpy(d, o, "description", tf->desc);
+ yyjson_mut_val *arr = yyjson_mut_arr(d);
+ for (int i = 0; i < tf->rt.nrows; i++) {
+ if (!tf->rows[i].account[0])
+ continue;
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "account", tf->rows[i].account);
+ yyjson_mut_obj_add_strcpy(d, ro, "formula", tf->rows[i].formula);
+ if (tf->rows[i].text[0])
+ yyjson_mut_obj_add_strcpy(d, ro, "description",
+ tf->rows[i].text);
+ }
+ yyjson_mut_obj_add_val(d, o, "rows", arr);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ const char *cmd = tf->id ? "template.update" : "template.create";
+ char *resp = dry ? rpc_dry(tf->a, cmd, args)
+ : client_rpc(&tf->a->conn, cmd, tf->a->session,
+ tf->a->org, args);
+ free(args);
+ if (resp && client_ok(resp)) {
+ if (dry) {
+ tui_message("Validering OK", "Mallen är korrekt (%d rader).",
+ used);
+ } else {
+ tui_message("Mall", "Mallen '%s' sparad.", tf->name);
+ free(resp);
+ return 1;
+ }
+ } else {
+ show_error("Kunde inte spara mallen", resp);
+ }
+ free(resp);
+ return 0;
+}
+
+/* Template editor: a form, not a wizard. load_name == NULL creates a new
+ template, otherwise the named template is loaded and saved via
+ template.update. */
+static int template_form(struct app *a, const char *load_name)
+{
+ struct tplform tf;
+ memset(&tf, 0, sizeof tf);
+ tf.a = a;
+ snprintf(tf.series, sizeof tf.series, "%s", a->default_series);
+ if (load_name && *load_name) {
+ 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", load_name);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp =
+ client_rpc(&a->conn, "template.get", a->session, a->org, args);
+ free(args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Mall", resp);
+ free(resp);
+ return 0;
+ }
+ tf.id = jint_val(resp, "result.id", 0);
+ char *nm = jstr_dup(resp, "result.name");
+ char *ser = jstr_dup(resp, "result.series");
+ char *ds = jstr_dup(resp, "result.description");
+ if (nm)
+ snprintf(tf.name, sizeof tf.name, "%s", nm);
+ if (ser && *ser)
+ snprintf(tf.series, sizeof tf.series, "%s", ser);
+ if (ds)
+ snprintf(tf.desc, sizeof tf.desc, "%s", ds);
+ free(nm);
+ free(ser);
+ free(ds);
+ size_t n = jarr_size(resp, "result.rows");
+ if (n > 64)
+ n = 64;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.rows.%zu.account", i);
+ char *acc = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.formula", i);
+ char *form = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.description", i);
+ char *rd = jstr_dup(resp, path);
+ snprintf(tf.rows[i].account, sizeof tf.rows[i].account, "%s",
+ acc ? acc : "");
+ snprintf(tf.rows[i].formula, sizeof tf.rows[i].formula, "%s",
+ form ? form : "");
+ snprintf(tf.rows[i].text, sizeof tf.rows[i].text, "%s",
+ rd ? rd : "");
+ free(acc);
+ free(form);
+ free(rd);
+ }
+ tf.nrows = n > 0 ? (int)n : 1;
+ free(resp);
+ }
+ int text_w = COLS - 57;
+ if (text_w < 8)
+ text_w = 8;
+ struct tui_rt_col cols[4] = {
+ { "Konto", 2, 6, TUI_RT_EDIT },
+ { "Namn", 9, 24, TUI_RT_INFO },
+ { "Formel", 34, 20, TUI_RT_EDIT },
+ { "Radtext", 55, text_w, TUI_RT_EDIT },
+ };
+ tui_rt_init(&tf.rt, tf.nrows > 0 ? tf.nrows : 1, 64, 4, cols, 7, tpl_cell,
+ tpl_info, &tf);
+ tui_rt_set_footer(&tf.rt, tpl_footer);
+ struct tui_form_field ff[3];
+ memset(ff, 0, sizeof ff);
+ ff[0].label = "Namn";
+ ff[0].value = tf.name;
+ ff[0].cap = sizeof tf.name;
+ ff[0].kind = TUI_F_TEXT;
+ ff[1].label = "Serie";
+ ff[1].value = tf.series;
+ ff[1].cap = sizeof tf.series;
+ ff[1].kind = TUI_F_TEXT;
+ ff[2].label = "Text";
+ ff[2].value = tf.desc;
+ ff[2].cap = sizeof tf.desc;
+ ff[2].kind = TUI_F_TEXT;
+ tui_rt_set_fields(&tf.rt, ff, 3);
+ const char *title = load_name ? "Redigera mall" : "Ny mall";
+ const char *hint = "Enter = ändra fält Tab = byta fält F5 = validera"
+ " ^X = rensa rad ^Enter = spara Esc = avbryt";
+ for (;;) {
+ int rr = tui_rt_run(title, &tf.rt, hint);
+ if (rr == -1)
+ return 0;
+ if (rr == -2) {
+ tpl_save(&tf, 1);
+ continue;
+ }
+ if (rr == -3 && tpl_save(&tf, 0))
+ return 1;
+ }
+}
+
+static void template_archive(struct app *a, const char *name)
+{
+ char label[256];
+ snprintf(label, sizeof label, "Arkivera '%s'? (j/n): ", name);
+ char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, label, "n", 0) ? ansbuf : NULL;
+ if (!ans || (ans[0] != 'j' && ans[0] != 'J'))
+ return;
+ 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", name);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp =
+ client_rpc(&a->conn, "template.archive", a->session, a->org, args);
+ free(args);
+ if (resp && client_ok(resp))
+ tui_message("Mall", "Mallen '%s' arkiverad.", name);
+ else
+ show_error("Kunde inte arkivera", resp);
+ free(resp);
+}
+
+void templates_screen(struct app *a)
+{
+ int cursor = 0;
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(&a->conn, "template.list", a->session, a->org,
+ "{\"active_only\":true}");
+ if (!resp || !client_ok(resp)) {
+ show_error("Mallar", resp);
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ char **names = xcalloc(n + 1, sizeof(char *));
+ char **lines = xcalloc(n + 1, sizeof(char *));
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *nm = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.series", i);
+ char *ser = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.row_count", i);
+ int64_t rc = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.description", i);
+ char *ds = jstr_dup(resp, path);
+ char nmbuf[128];
+ snprintf(nmbuf, sizeof nmbuf, "%s", nm ? nm : "");
+ tui_pad_field(nmbuf, sizeof nmbuf, 24);
+ snprintf(line, sizeof line, "%s %-3s %2lld rader %s", nmbuf,
+ ser ? ser : "", (long long)rc, ds ? ds : "");
+ names[i] = xstrdup(nm ? nm : "");
+ lines[i] = xstrdup(line);
+ free(nm);
+ free(ser);
+ free(ds);
+ }
+ names[n] = xstrdup("");
+ lines[n] = xstrdup("+ Ny mall (^N)");
+ int s = tui_select_list("Mallar", lines, (int)n + 1, cursor, 1, &cursor, 1,
+ n > 0 ? "arkivera" : NULL, 0);
+ if (s == -1) {
+ for (size_t i = 0; i <= n; i++) {
+ free(names[i]);
+ free(lines[i]);
+ }
+ free(names);
+ free(lines);
+ free(resp);
+ return;
+ }
+ if (s == -6) {
+ if (cursor >= 0 && (size_t)cursor < n)
+ template_archive(a, names[cursor]);
+ } else if (s == -4 || (s >= 0 && (size_t)s == n)) {
+ template_form(a, NULL);
+ } else if (s >= 0) {
+ template_form(a, names[s]);
+ }
+ for (size_t i = 0; i <= n; i++) {
+ free(names[i]);
+ free(lines[i]);
+ }
+ free(names);
+ free(lines);
+ free(resp);
+ if (s == -5)
+ return;
+ }
+}
diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c
new file mode 100644
index 0000000..d87cfd2
--- /dev/null
+++ b/clients/screens_vouchers.c
@@ -0,0 +1,958 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+struct vdctx {
+ struct app *a;
+ int64_t id;
+ size_t natts;
+ const char *resp;
+ int corrected;
+ int want_refresh;
+ int posted_new;
+ int64_t posted_id;
+};
+
+static int vd_key(void *ud, int ch)
+{
+ struct vdctx *ctx = ud;
+ struct app *a = ctx->a;
+ if (ch == TUI_KEY_CTRL_N) {
+ int64_t nid = vouchers_new(a);
+ if (nid > 0) {
+ ctx->posted_id = nid;
+ ctx->posted_new = 1;
+ return TUI_HOOK_BACK;
+ }
+ return TUI_HOOK_STAY;
+ }
+ if (ch == 'c') {
+ char reasonbuf[512];
+ char *reason =
+ tui_prompt_into(reasonbuf, sizeof reasonbuf, "Beskriv rättelsen: ",
+ "", 0)
+ ? reasonbuf
+ : NULL;
+ if (reason && *reason) {
+ 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, "voucher", ctx->id);
+ yyjson_mut_obj_add_strcpy(d, o, "description", reason);
+ char *ref = util_random_id("tui-", 8);
+ yyjson_mut_obj_add_strcpy(d, o, "client_ref", ref);
+ char *cargs = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *r = client_rpc(&a->conn, "voucher.correct", a->session,
+ a->org, cargs);
+ if (r && client_ok(r)) {
+ int64_t new_id = jint_val(r, "result.id", 0);
+ tui_message("Rättat", "Ändringsverifikat %lld skapat",
+ (long long)new_id);
+ ctx->corrected = 1;
+ } else {
+ show_error("Kunde inte rätta", r);
+ }
+ free(r);
+ free(cargs);
+ free(ref);
+ }
+ return TUI_HOOK_BACK;
+ }
+ if (ch == 6) { /* ^F: attach a file to this voucher */
+ char *path = file_browser(a, a->attachment_dir);
+ if (path) {
+ struct stat sb;
+ if (stat(path, &sb) == 0 &&
+ (long)sb.st_size > a->max_attachment_bytes) {
+ tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.",
+ (double)sb.st_size / (1024 * 1024),
+ a->max_attachment_bytes / (1024 * 1024));
+ free(path);
+ } else {
+ char *b64 = read_file_b64(path);
+ if (!b64) {
+ tui_message("Fel", "Kunde inte läsa %s", path);
+ free(path);
+ } else {
+ const char *base = strrchr(path, '/');
+ base = base ? base + 1 : path;
+ 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, "filename", base);
+ yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
+ yyjson_mut_obj_add_int(d, o, "voucher_id", ctx->id);
+ char *fargs = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ free(b64);
+ free(path);
+ char *r = client_rpc(&a->conn, "attachment.put", a->session,
+ a->org, fargs);
+ if (r && client_ok(r))
+ tui_message("Underlag", "Bifogat.");
+ else
+ show_error("Kunde inte bifoga", r);
+ free(r);
+ free(fargs);
+ ctx->want_refresh = 1;
+ return TUI_HOOK_BACK;
+ }
+ }
+ }
+ return TUI_HOOK_STAY;
+ }
+ if (ch == 'f' && ctx->natts > 0) {
+ char **alines = xcalloc(ctx->natts, sizeof(char *));
+ int64_t *aids = xcalloc(ctx->natts, sizeof(int64_t));
+ for (size_t i = 0; i < ctx->natts; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.attachments.%zu.id", i);
+ aids[i] = jint_val(ctx->resp, path, 0);
+ snprintf(path, sizeof path, "result.attachments.%zu.filename", i);
+ char *fn = jstr_dup(ctx->resp, path);
+ alines[i] = xstrdup(fn ? fn : "(namnlös)");
+ free(fn);
+ }
+ int acursor = 0;
+ int asel = tui_select_list("Underlag", alines, (int)ctx->natts, 0, 0,
+ &acursor, 0, "ta bort", 0);
+ if (asel >= 0) {
+ attachment_download(a, aids[asel]);
+ } else if (asel == -6 && acursor >= 0 &&
+ (size_t)acursor < ctx->natts) {
+ char q[320];
+ snprintf(q, sizeof q, "Ta bort '%s' från verifikatet? (j/n): ",
+ alines[acursor]);
+ char ansbuf[512];
+ char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, q, "n", 0)
+ ? ansbuf
+ : NULL;
+ if (ans && (ans[0] == 'j' || ans[0] == 'J')) {
+ char uargs[64];
+ snprintf(uargs, sizeof uargs, "{\"id\":%lld,\"voucher_id\":%lld}",
+ (long long)aids[acursor], (long long)ctx->id);
+ char *r = client_rpc(&a->conn, "attachment.unlink", a->session,
+ a->org, uargs);
+ if (r && client_ok(r)) {
+ tui_message("Underlag", "Länken borttagen.");
+ ctx->want_refresh = 1;
+ for (size_t i = 0; i < ctx->natts; i++)
+ free(alines[i]);
+ free(alines);
+ free(aids);
+ return TUI_HOOK_REFRESH;
+ }
+ show_error("Kunde inte ta bort", r);
+ free(r);
+ }
+ }
+ for (size_t i = 0; i < ctx->natts; i++)
+ free(alines[i]);
+ free(alines);
+ free(aids);
+ return TUI_HOOK_STAY;
+ }
+ return TUI_HOOK_NONE;
+}
+
+static int voucher_detail(struct app *a, int64_t id, int64_t *out_new)
+{
+ if (out_new)
+ *out_new = 0;
+ char args[64];
+ snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id);
+ for (;;) {
+ char *resp =
+ client_rpc(&a->conn, "voucher.get", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Verifikat", resp);
+ free(resp);
+ return 0;
+ }
+ struct buf text;
+ buf_init(&text);
+ char *series = jstr_dup(resp, "result.series");
+ char *date = jstr_dup(resp, "result.date");
+ char *desc = jstr_dup(resp, "result.description");
+ char *hash = jstr_dup(resp, "result.hash");
+ int64_t number = jint_val(resp, "result.number", 0);
+ char line[1024];
+ snprintf(line, sizeof line, "%s%lld %s %s\n\n",
+ series ? series : "", (long long)number, date ? date : "",
+ desc ? desc : "");
+ buf_append(&text, line, strlen(line));
+ int hdr = snprintf(
+ line, sizeof line, MK_HEAD " %-6s %-34s D %12s K %12s\n",
+ "Konto", "Benämning", "Debet", "Kredit");
+ buf_append(&text, line, (size_t)hdr);
+ size_t nrows = jarr_size(resp, "result.rows");
+ for (size_t i = 0; i < nrows; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.rows.%zu.account", i);
+ char *acc = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.name", i);
+ char *name = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.debit_ore", i);
+ int64_t debit = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.rows.%zu.credit_ore", i);
+ int64_t credit = jint_val(resp, path, 0);
+ char d[32], c[32];
+ tui_kr_format(debit, d, sizeof d);
+ tui_kr_format(credit, c, sizeof c);
+ char acol[32], ncol[256];
+ snprintf(acol, sizeof acol, "%s", acc ? acc : "");
+ tui_pad_field(acol, sizeof acol, 6);
+ snprintf(ncol, sizeof ncol, "%s", name ? name : "");
+ tui_pad_field(ncol, sizeof ncol, 34);
+ snprintf(line, sizeof line, " %s %s D %12s K %12s\n", acol,
+ ncol, d, c);
+ buf_append(&text, line, strlen(line));
+ free(acc);
+ free(name);
+ }
+ size_t natts = jarr_size(resp, "result.attachments");
+ if (natts) {
+ buf_append(&text, "\n", 1);
+ line[0] = MK_RULE[0];
+ for (int i = 1; i <= 98; i++)
+ line[i] = '-';
+ line[99] = '\n';
+ buf_append(&text, line, 100);
+ buf_append(&text, MK_HEAD "Underlag:\n", 11);
+ for (size_t i = 0; i < natts; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.attachments.%zu.filename",
+ i);
+ char *fn = jstr_dup(resp, path);
+ snprintf(line, sizeof line, " %s\n", fn ? fn : "");
+ buf_append(&text, line, strlen(line));
+ free(fn);
+ }
+ }
+ if (hash) {
+ snprintf(line, sizeof line, "\nHash: %s\n", hash);
+ buf_append(&text, line, strlen(line));
+ }
+ int64_t corrects = jint_val(resp, "result.corrects_voucher_id", 0);
+ if (corrects) {
+ snprintf(line, sizeof line, "Rättar verifikat: %lld\n",
+ (long long)corrects);
+ buf_append(&text, line, strlen(line));
+ }
+ buf_append(&text, "\0", 1);
+
+ struct vdctx ctx;
+ memset(&ctx, 0, sizeof ctx);
+ ctx.a = a;
+ ctx.id = id;
+ ctx.natts = natts;
+ ctx.resp = resp;
+ char hint[256];
+ snprintf(hint, sizeof hint,
+ "^N = nytt verifikat c = rätta ^F = bifoga%s",
+ natts ? " f = underlag" : "");
+ int want_refresh = 0, corrected = 0;
+ int ret = tui_pager_hook("Verifikat", (const char *)text.p, hint, 0,
+ vd_key, &ctx);
+ if (ret == 1)
+ want_refresh = 1;
+ if (ctx.want_refresh)
+ want_refresh = 1;
+ corrected = ctx.corrected;
+ int posted_new = ctx.posted_new;
+ int64_t posted_id = ctx.posted_id;
+ free(series);
+ free(date);
+ free(desc);
+ free(hash);
+ buf_free(&text);
+ free(resp);
+ if (posted_new) {
+ if (out_new)
+ *out_new = posted_id;
+ return 2;
+ }
+ if (want_refresh)
+ continue;
+ return corrected;
+ }
+}
+
+struct tui_vrow {
+ char account[16];
+ char debit[32];
+ char credit[32];
+ char text[64];
+};
+
+struct acct_cache {
+ int loaded;
+ int64_t org;
+ char **numbers;
+ char **names;
+ size_t n;
+};
+
+static struct acct_cache g_accts;
+
+void acct_cache_free(void)
+{
+ for (size_t i = 0; i < g_accts.n; i++) {
+ free(g_accts.numbers[i]);
+ free(g_accts.names[i]);
+ }
+ free(g_accts.numbers);
+ free(g_accts.names);
+ memset(&g_accts, 0, sizeof g_accts);
+}
+
+static void acct_cache_load(struct app *a)
+{
+ if (g_accts.loaded && g_accts.org == a->org)
+ return;
+ acct_cache_free();
+ char *resp = client_rpc(&a->conn, "account.list", a->session, a->org,
+ "{\"active_only\":true}");
+ if (!resp || !client_ok(resp)) {
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ g_accts.numbers = xcalloc(n ? n : 1, sizeof(char *));
+ g_accts.names = xcalloc(n ? n : 1, sizeof(char *));
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.number", i);
+ char *number = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *name = jstr_dup(resp, path);
+ g_accts.numbers[i] = number ? number : xstrdup("");
+ g_accts.names[i] = name ? name : xstrdup("");
+ }
+ g_accts.n = n;
+ g_accts.org = a->org;
+ g_accts.loaded = 1;
+ free(resp);
+}
+
+const char *acct_name(struct app *a, const char *number)
+{
+ if (!number || !*number)
+ return NULL;
+ acct_cache_load(a);
+ for (size_t i = 0; i < g_accts.n; i++)
+ if (strcmp(g_accts.numbers[i], number) == 0)
+ return g_accts.names[i];
+ return NULL;
+}
+
+int acct_exists(struct app *a, const char *number)
+{
+ if (!number || !*number)
+ return 0;
+ acct_cache_load(a);
+ for (size_t i = 0; i < g_accts.n; i++)
+ if (strcmp(g_accts.numbers[i], number) == 0)
+ return 1;
+ return 0;
+}
+
+
+struct vform {
+ struct app *a;
+ struct tui_rt rt;
+ struct tui_vrow rows[64];
+ int64_t att_ids[32];
+ char att_names[32][80];
+ int natt;
+ char date[16];
+ char series[16];
+ char desc[256];
+ char client_ref[64];
+};
+
+static void vform_cell(void *ud, int row, int col, char **buf, size_t *cap)
+{
+ struct vform *vf = ud;
+ struct tui_vrow *r = &vf->rows[row];
+ if (col == 0) {
+ *buf = r->account;
+ *cap = sizeof r->account;
+ } else if (col == 2) {
+ *buf = r->debit;
+ *cap = sizeof r->debit;
+ } else if (col == 3) {
+ *buf = r->credit;
+ *cap = sizeof r->credit;
+ } else {
+ *buf = r->text;
+ *cap = sizeof r->text;
+ }
+}
+
+static void vform_info(void *ud, int row, char *buf, size_t n)
+{
+ struct vform *vf = ud;
+ const char *nm = acct_name(vf->a, vf->rows[row].account);
+ snprintf(buf, n, "%s", nm ? nm : "");
+}
+
+static void vform_footer(void *ud, char *buf, size_t n)
+{
+ struct vform *vf = ud;
+ int64_t sum_d = 0, sum_c = 0;
+ for (int i = 0; i < vf->rt.nrows; i++) {
+ int64_t v;
+ if (tui_parse_kr(vf->rows[i].debit, &v) == 0)
+ sum_d += v;
+ if (tui_parse_kr(vf->rows[i].credit, &v) == 0)
+ sum_c += v;
+ }
+ char d1[32], c1[32], diff[32], line[1024];
+ tui_kr_format(sum_d, d1, sizeof d1);
+ tui_kr_format(sum_c, c1, sizeof c1);
+ tui_kr_format(sum_d - sum_c, diff, sizeof diff);
+ int balanced = sum_d == sum_c;
+ struct buf t;
+ buf_init(&t);
+ if (vf->natt > 0) {
+ snprintf(line, sizeof line, "Bilagor: ");
+ for (int i = 0; i < vf->natt; i++) {
+ size_t used = strlen(line);
+ if (used + 1 >= sizeof line)
+ break;
+ snprintf(line + used, sizeof line - used, "%.79s%s",
+ vf->att_names[i], i + 1 < vf->natt ? ", " : "");
+ }
+ buf_append(&t, line, strlen(line));
+ buf_append(&t, "\n", 1);
+ }
+ snprintf(line, sizeof line, "Summa debet %12s kredit %12s %s", d1, c1,
+ balanced ? "I BALANS" : "DIFF");
+ buf_append(&t, line, strlen(line));
+ if (!balanced) {
+ snprintf(line, sizeof line, "\nDifferens: %s", diff);
+ buf_append(&t, line, strlen(line));
+ }
+ buf_append(&t, "\0", 1);
+ snprintf(buf, n, "%s", (char *)t.p);
+ buf_free(&t);
+}
+
+static void vform_attach(struct vform *vf)
+{
+ struct app *a = vf->a;
+ char *path = file_browser(a, a->attachment_dir);
+ if (!path)
+ return;
+ struct stat sb;
+ if (stat(path, &sb) == 0 &&
+ (long)sb.st_size > a->max_attachment_bytes) {
+ tui_message("Bilaga", "Filen är %.1f MB, max %ld MB.",
+ (double)sb.st_size / (1024 * 1024),
+ a->max_attachment_bytes / (1024 * 1024));
+ free(path);
+ return;
+ }
+ char *b64 = read_file_b64(path);
+ if (!b64) {
+ tui_message("Bilaga", "Kunde inte läsa %s", path);
+ free(path);
+ return;
+ }
+ if (vf->natt >= 32) {
+ tui_message("Bilaga", "Max 32 bilagor per verifikat.");
+ free(b64);
+ free(path);
+ return;
+ }
+ const char *base = strrchr(path, '/');
+ base = base ? base + 1 : path;
+ 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, "filename", base);
+ yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ size_t need = strlen(args) + 256;
+ char *raw = xmalloc(need);
+ snprintf(raw, need,
+ "{\"v\":1,\"id\":\"tui\",\"cmd\":\"attachment.put\","
+ "\"session\":\"%s\",\"org\":%lld,\"args\":%s}",
+ a->session, (long long)a->org, args);
+ free(args);
+ char *r = NULL;
+ if (client_send_line(&a->conn, raw) == 0)
+ r = client_read_line(&a->conn);
+ free(raw);
+ if (r && client_ok(r)) {
+ vf->att_ids[vf->natt] = jint_val(r, "result.id", 0);
+ snprintf(vf->att_names[vf->natt], sizeof vf->att_names[vf->natt],
+ "%s", base);
+ vf->natt++;
+ } else {
+ show_error("Kunde inte bifoga filen", r);
+ }
+ free(r);
+ free(b64);
+ free(path);
+}
+
+static void vform_template(struct vform *vf)
+{
+ struct app *a = vf->a;
+ char *lresp = client_rpc(&a->conn, "template.list", a->session, a->org,
+ "{\"active_only\":true}");
+ if (!lresp || !client_ok(lresp)) {
+ show_error("Mallar", lresp);
+ free(lresp);
+ return;
+ }
+ size_t ln = jarr_size(lresp, "result.items");
+ if (ln == 0) {
+ tui_message("Mallar",
+ "Inga mallar ännu. Skapa en under Mallar först.");
+ free(lresp);
+ return;
+ }
+ char **names = xcalloc(ln, sizeof(char *));
+ char **lines = xcalloc(ln, sizeof(char *));
+ for (size_t i = 0; i < ln; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.name", i);
+ char *nm = jstr_dup(lresp, path);
+ snprintf(path, sizeof path, "result.items.%zu.series", i);
+ char *ser = jstr_dup(lresp, path);
+ snprintf(path, sizeof path, "result.items.%zu.row_count", i);
+ int64_t rc = jint_val(lresp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.description", i);
+ char *ds = jstr_dup(lresp, path);
+ char nmbuf[128];
+ snprintf(nmbuf, sizeof nmbuf, "%s", nm ? nm : "");
+ tui_pad_field(nmbuf, sizeof nmbuf, 24);
+ snprintf(line, sizeof line, "%s %-3s %2lld rader %s", nmbuf,
+ ser ? ser : "", (long long)rc, ds ? ds : "");
+ names[i] = xstrdup(nm ? nm : "");
+ lines[i] = xstrdup(line);
+ free(nm);
+ free(ser);
+ free(ds);
+ }
+ int tsel = tui_select_list("Välj mall", lines, (int)ln, 0, 1, NULL, 0,
+ NULL, 0);
+ char tname[128] = "";
+ if (tsel >= 0)
+ snprintf(tname, sizeof tname, "%s", names[tsel]);
+ for (size_t i = 0; i < ln; i++) {
+ free(names[i]);
+ free(lines[i]);
+ }
+ free(names);
+ free(lines);
+ free(lresp);
+ if (tsel < 0)
+ return;
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *to = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, to);
+ yyjson_mut_obj_add_strcpy(d, to, "name", tname);
+ char *targs = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ char *resp =
+ client_rpc(&a->conn, "template.get", a->session, a->org, targs);
+ free(targs);
+ if (!resp || !client_ok(resp)) {
+ show_error("Mall", resp);
+ free(resp);
+ return;
+ }
+ char xsbuf[512];
+ char *xs = tui_prompt_into(xsbuf, sizeof xsbuf, "Belopp (x, kr): ", "",
+ 0)
+ ? xsbuf
+ : NULL;
+ double xv = 0;
+ if (xs && *xs && !parse_x_double(xs, &xv)) {
+ tui_message("Fel", "Ogiltigt belopp.");
+ free(resp);
+ return;
+ }
+ size_t n = jarr_size(resp, "result.rows");
+ char **accts = xcalloc(n ? n : 1, sizeof(char *));
+ char **forms = xcalloc(n ? n : 1, sizeof(char *));
+ char **descs = xcalloc(n ? n : 1, sizeof(char *));
+ struct template_row *in = xcalloc(n ? n : 1, sizeof *in);
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.rows.%zu.account", i);
+ accts[i] = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.formula", i);
+ forms[i] = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.rows.%zu.description", i);
+ descs[i] = jstr_dup(resp, path);
+ in[i].account = accts[i] ? accts[i] : "";
+ in[i].formula = forms[i] ? forms[i] : "";
+ in[i].description = descs[i] && *descs[i] ? descs[i] : NULL;
+ }
+ struct resolved_row *out = xcalloc(n ? n : 1, sizeof *out);
+ size_t on = 0;
+ char ferr[256] = "";
+ if (formula_resolve_rows(in, n, xv, out, &on, ferr, sizeof ferr) != 0) {
+ tui_message("Mall", "%s",
+ ferr[0] ? ferr : "kunde inte lösa mallen");
+ } else {
+ memset(vf->rows, 0, sizeof vf->rows);
+ int use = (int)(on > 64 ? 64 : on);
+ for (int i = 0; i < use; i++) {
+ snprintf(vf->rows[i].account, sizeof vf->rows[i].account, "%s",
+ out[i].account);
+ char tmp[32];
+ if (out[i].debit_ore) {
+ tui_kr_format(out[i].debit_ore, tmp, sizeof tmp);
+ snprintf(vf->rows[i].debit, sizeof vf->rows[i].debit, "%s",
+ tmp);
+ } else {
+ tui_kr_format(out[i].credit_ore, tmp, sizeof tmp);
+ snprintf(vf->rows[i].credit, sizeof vf->rows[i].credit, "%s",
+ tmp);
+ }
+ if (out[i].description[0])
+ snprintf(vf->rows[i].text, sizeof vf->rows[i].text, "%s",
+ out[i].description);
+ }
+ vf->rt.nrows = use > 0 ? use : 1;
+ char *tds = jstr_dup(resp, "result.description");
+ char *tser = jstr_dup(resp, "result.series");
+ if (!vf->desc[0] && tds && *tds)
+ replace_x_into(tds, xv, vf->desc, sizeof vf->desc);
+ if (tser && *tser)
+ snprintf(vf->series, sizeof vf->series, "%s", tser);
+ free(tds);
+ free(tser);
+ vf->rt.row = 0;
+ vf->rt.col = 0;
+ vf->rt.focus = -1;
+ vf->rt.fresh = 1;
+ vf->rt.pos = (size_t)-1;
+ tui_rt_normalize(&vf->rt);
+ }
+ free(out);
+ for (size_t i = 0; i < n; i++) {
+ free(accts[i]);
+ free(forms[i]);
+ free(descs[i]);
+ }
+ free(accts);
+ free(forms);
+ free(descs);
+ free(in);
+ free(resp);
+}
+
+/* Returns the posted voucher id, or 0 to stay in the form. */
+static int64_t vform_post(struct vform *vf, int dry)
+{
+ struct app *a = vf->a;
+ 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, "date", vf->date);
+ yyjson_mut_obj_add_strcpy(d, o, "description", vf->desc);
+ yyjson_mut_obj_add_strcpy(d, o, "series", vf->series);
+ yyjson_mut_obj_add_strcpy(d, o, "client_ref", vf->client_ref);
+ yyjson_mut_val *arr = yyjson_mut_arr(d);
+ for (int i = 0; i < vf->rt.nrows; i++) {
+ if (!vf->rows[i].account[0])
+ continue;
+ int64_t dv = 0, cv = 0;
+ if (tui_parse_kr(vf->rows[i].debit, &dv) != 0 ||
+ tui_parse_kr(vf->rows[i].credit, &cv) != 0) {
+ tui_message("Fel", "Ogiltigt belopp på rad %d", i + 1);
+ yyjson_mut_doc_free(d);
+ return 0;
+ }
+ yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr);
+ yyjson_mut_obj_add_strcpy(d, ro, "account", vf->rows[i].account);
+ yyjson_mut_obj_add_int(d, ro, "debit_ore", dv);
+ yyjson_mut_obj_add_int(d, ro, "credit_ore", cv);
+ if (vf->rows[i].text[0])
+ yyjson_mut_obj_add_strcpy(d, ro, "description",
+ vf->rows[i].text);
+ }
+ yyjson_mut_obj_add_val(d, o, "rows", arr);
+ if (vf->natt > 0) {
+ yyjson_mut_val *aa = yyjson_mut_arr(d);
+ for (int i = 0; i < vf->natt; i++)
+ yyjson_mut_arr_add_int(d, aa, vf->att_ids[i]);
+ yyjson_mut_obj_add_val(d, o, "attachment_ids", aa);
+ }
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ if (dry) {
+ char *raw = xmalloc(strlen(args) + 256);
+ sprintf(raw,
+ "{\"v\":1,\"id\":\"tui\",\"cmd\":\"voucher.post\","
+ "\"session\":\"%s\",\"org\":%lld,\"dry_run\":true,"
+ "\"args\":%s}",
+ a->session, (long long)a->org, args);
+ if (client_send_line(&a->conn, raw) == 0) {
+ char *r = client_read_line(&a->conn);
+ if (r && client_ok(r)) {
+ int64_t num = jint_val(r, "result.number", 0);
+ tui_message("Validering OK",
+ "Nummer %lld skulle tilldelas.", (long long)num);
+ } else {
+ show_error("Validering misslyckades", r);
+ }
+ free(r);
+ } else {
+ tui_message("Fel", "Kunde inte nå servern");
+ }
+ free(raw);
+ } else {
+ char *r = client_rpc(&a->conn, "voucher.post", a->session, a->org,
+ args);
+ if (r && client_ok(r)) {
+ int64_t id = jint_val(r, "result.id", 0);
+ int64_t num = jint_val(r, "result.number", 0);
+ char *ser = jstr_dup(r, "result.series");
+ int replayed = jbool_val(r, "result.replayed", 0);
+ tui_message("Bokfört", "%s%lld (id %lld)%s", ser ? ser : "",
+ (long long)num, (long long)id,
+ replayed ? " — redan bokfört (idempotent)" : "");
+ free(ser);
+ free(r);
+ free(args);
+ return id;
+ }
+ show_error("Kunde inte bokföra", r);
+ free(r);
+ }
+ free(args);
+ return 0;
+}
+
+static int vform_key(void *ud, int ch)
+{
+ struct vform *vf = ud;
+ if (ch == KEY_F(4)) {
+ vform_template(vf);
+ return TUI_HOOK_STAY;
+ }
+ if (ch == 6) {
+ vform_attach(vf);
+ return TUI_HOOK_STAY;
+ }
+ return TUI_HOOK_NONE;
+}
+
+int64_t vouchers_new(struct app *a)
+{
+ struct vform vf;
+ memset(&vf, 0, sizeof vf);
+ vf.a = a;
+ snprintf(vf.date, sizeof vf.date, "%s",
+ a->fy_start[0] ? a->fy_start : "2026-01-01");
+ snprintf(vf.series, sizeof vf.series, "%s", a->default_series);
+ char *ref = util_random_id("tui-", 8);
+ snprintf(vf.client_ref, sizeof vf.client_ref, "%s", ref);
+ free(ref);
+ int text_w = COLS - 60;
+ if (text_w < 8)
+ text_w = 8;
+ struct tui_rt_col cols[5] = {
+ { "Konto", 2, 6, TUI_RT_EDIT },
+ { "Namn", 9, 24, TUI_RT_INFO },
+ { "Debet", 34, 11, TUI_RT_EDIT },
+ { "Kredit", 46, 11, TUI_RT_EDIT },
+ { "Text", 58, text_w, TUI_RT_EDIT },
+ };
+ tui_rt_init(&vf.rt, 1, 64, 5, cols, 7, vform_cell, vform_info, &vf);
+ tui_rt_set_footer(&vf.rt, vform_footer);
+ tui_rt_set_key(&vf.rt, vform_key);
+ struct tui_form_field ff[3];
+ memset(ff, 0, sizeof ff);
+ ff[0].label = "Datum";
+ ff[0].value = vf.date;
+ ff[0].cap = sizeof vf.date;
+ ff[0].kind = TUI_F_DATE;
+ ff[1].label = "Serie";
+ ff[1].value = vf.series;
+ ff[1].cap = sizeof vf.series;
+ ff[1].kind = TUI_F_TEXT;
+ ff[2].label = "Text";
+ ff[2].value = vf.desc;
+ ff[2].cap = sizeof vf.desc;
+ ff[2].kind = TUI_F_TEXT;
+ tui_rt_set_fields(&vf.rt, ff, 3);
+ const char *hint = "Enter = ändra fält Tab = byta fält F4 = mall"
+ " ^F = bifoga fil F5 = validera ^X = rensa rad"
+ " ^Enter = bokför Esc = avbryt";
+ for (;;) {
+ int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint);
+ if (rr == -1)
+ return 0;
+ if (rr == -2) {
+ vform_post(&vf, 1);
+ continue;
+ }
+ if (rr == -3) {
+ int64_t id = vform_post(&vf, 0);
+ if (id > 0)
+ return id;
+ }
+ }
+}
+
+void vouchers_screen(struct app *a)
+{
+ char **items = NULL;
+ int64_t *ids = NULL;
+ size_t n = 0;
+ int fetch = 1;
+ for (;;) {
+ if (g_quit) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ return;
+ }
+ if (fetch) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ items = NULL;
+ ids = NULL;
+ n = 0;
+ fetch = 0;
+ char largs[96];
+ snprintf(largs, sizeof largs,
+ "{\"limit\":200,\"fiscal_year\":%lld}",
+ (long long)a->fy);
+ char *resp = client_rpc(&a->conn, "voucher.list", a->session, a->org,
+ largs);
+ if (!resp || !client_ok(resp)) {
+ show_error("Verifikat", resp);
+ free(resp);
+ return;
+ }
+ n = jarr_size(resp, "result.items");
+ items = xcalloc(n + 1, sizeof(char *));
+ ids = xcalloc(n ? n : 1, sizeof(int64_t));
+ char **idstr = xcalloc(n ? n : 1, sizeof(char *));
+ int idw = 4;
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ ids[i] = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.series", i);
+ char *ser = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.number", i);
+ int64_t number = jint_val(resp, path, 0);
+ char idbuf[32];
+ snprintf(idbuf, sizeof idbuf, "%s%lld", ser ? ser : "",
+ (long long)number);
+ idstr[i] = xstrdup(idbuf);
+ int l = (int)strlen(idbuf);
+ if (l > idw)
+ idw = l;
+ free(ser);
+ }
+ for (size_t i = 0; i < n; i++) {
+ char path[64], line[512];
+ snprintf(path, sizeof path, "result.items.%zu.date", i);
+ char *date = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.description", i);
+ char *desc = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.attachment_count",
+ i);
+ int atts = (int)jint_val(resp, path, 0);
+ snprintf(line, sizeof line, " %c %-*s %s %s",
+ atts ? 'x' : ' ', idw, idstr[i], date ? date : "",
+ desc ? desc : "");
+ items[i] = xstrdup(line);
+ free(date);
+ free(desc);
+ free(idstr[i]);
+ }
+ free(idstr);
+ free(resp);
+ items[n] = xstrdup("+ Nytt verifikat (^N)");
+ }
+ size_t total = n + 1;
+ int start = 0;
+ if (a->voucher_sel) {
+ for (size_t i = 0; i < n; i++)
+ if (ids[i] == a->voucher_sel) {
+ start = (int)i;
+ break;
+ }
+ }
+ int cur = start;
+ int sel = tui_select_list("Verifikat", items, (int)total, start, 1, &cur,
+ 1, NULL, 0);
+ if (cur >= 0 && (size_t)cur < n)
+ a->voucher_sel = ids[cur];
+ if (sel == -2) {
+ fetch = 1;
+ continue;
+ }
+ int64_t open_id = 0;
+ int want_new = (sel == -4);
+ if (sel >= 0) {
+ if ((size_t)sel == n)
+ want_new = 1;
+ else if ((size_t)sel < n)
+ open_id = ids[sel];
+ } else if (sel != -4) {
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ return;
+ }
+ if (want_new) {
+ open_id = vouchers_new(a);
+ if (open_id <= 0)
+ continue;
+ a->voucher_sel = open_id;
+ fetch = 1;
+ }
+ for (;;) {
+ int64_t nv = 0;
+ int act = voucher_detail(a, open_id, &nv);
+ if (act == 2) {
+ open_id = nv;
+ a->voucher_sel = nv;
+ fetch = 1;
+ continue;
+ }
+ if (act == 1)
+ fetch = 1;
+ break;
+ }
+ }
+}
diff --git a/clients/ui.c b/clients/ui.c
new file mode 100644
index 0000000..ff573c9
--- /dev/null
+++ b/clients/ui.c
@@ -0,0 +1,674 @@
+#include <dirent.h>
+#include <errno.h>
+#include <locale.h>
+#include <math.h>
+#include <ncursesw/ncurses.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "client.h"
+#include "tui.h"
+#include "formula.h"
+#include "util.h"
+#include "version.h"
+#include "yyjson.h"
+#include "ui.h"
+
+/* ^C quits the application; Esc is navigation only */
+int g_quit = 0;
+/* ^R re-execs the binary, keeping session, org, year and screen */
+int g_reload = 0;
+char g_scene[32] = "dashboard";
+char g_reload_scene[32];
+
+int ui_getch(void)
+{
+ int ch = getch();
+ if (ch == 3) { /* ^C */
+ g_quit = 1;
+ return 27; /* behave like "back" so screens unwind */
+ }
+ if (ch == 18) { /* ^R */
+ g_reload = 1;
+ snprintf(g_reload_scene, sizeof g_reload_scene, "%s", g_scene);
+ return 27;
+ }
+ return ch;
+}
+
+void request_quit(void)
+{
+ g_quit = 1;
+}
+/* ------------------------------------------------------------------ */
+/* json helpers */
+/* ------------------------------------------------------------------ */
+
+yyjson_val *jget(yyjson_val *root, const char *path)
+{
+ char tmp[128];
+ snprintf(tmp, sizeof tmp, "%s", path);
+ yyjson_val *v = root;
+ char *save = NULL;
+ for (char *tok = strtok_r(tmp, ".", &save); tok;
+ tok = strtok_r(NULL, ".", &save)) {
+ if (!v)
+ return NULL;
+ if (yyjson_is_obj(v))
+ v = yyjson_obj_get(v, tok);
+ else if (yyjson_is_arr(v)) {
+ char *end = NULL;
+ long idx = strtol(tok, &end, 10);
+ if (!end || *end)
+ return NULL;
+ v = yyjson_arr_get(v, (size_t)idx);
+ } else {
+ return NULL;
+ }
+ }
+ return v;
+}
+
+yyjson_doc *parse(const char *resp)
+{
+ return resp ? yyjson_read(resp, strlen(resp), 0) : NULL;
+}
+
+char *jstr_dup(const char *resp, const char *path)
+{
+ yyjson_doc *d = parse(resp);
+ yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
+ char *out = v && yyjson_is_str(v) ? xstrdup(yyjson_get_str(v)) : NULL;
+ yyjson_doc_free(d);
+ return out;
+}
+
+int64_t jint_val(const char *resp, const char *path, int64_t def)
+{
+ yyjson_doc *d = parse(resp);
+ yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
+ int64_t out = v && yyjson_is_int(v) ? yyjson_get_int(v) : def;
+ yyjson_doc_free(d);
+ return out;
+}
+
+size_t jarr_size(const char *resp, const char *path)
+{
+ yyjson_doc *d = parse(resp);
+ yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
+ size_t n = v && yyjson_is_arr(v) ? yyjson_arr_size(v) : 0;
+ yyjson_doc_free(d);
+ return n;
+}
+
+int jbool_val(const char *resp, const char *path, int def)
+{
+ yyjson_doc *d = parse(resp);
+ yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL;
+ int out = v && yyjson_is_bool(v) ? yyjson_get_bool(v) : def;
+ yyjson_doc_free(d);
+ return out;
+}
+
+/* ------------------------------------------------------------------ */
+/* ui primitives */
+/* ------------------------------------------------------------------ */
+
+char g_server_version[32];
+
+/* Whole kronor with space thousands, öre truncated like the blankett. */
+void kr_whole(int64_t ore, char *buf, size_t n)
+{
+ char tmp[48];
+ tui_kr_format((ore / 100) * 100, tmp, sizeof tmp);
+ char *comma = strchr(tmp, ',');
+ if (comma)
+ *comma = '\0';
+ snprintf(buf, n, "%s", tmp);
+}
+
+int parse_x_double(const char *s, double *out)
+{
+ if (!s)
+ return 0;
+ char buf[64];
+ size_t j = 0;
+ for (const char *p = s; *p && j < sizeof buf - 1; p++) {
+ if (*p == ' ' || *p == '\t')
+ continue;
+ buf[j++] = (*p == ',') ? '.' : *p;
+ }
+ buf[j] = '\0';
+ if (!j)
+ return 0;
+ char *end = NULL;
+ double v = strtod(buf, &end);
+ if (!end || *end)
+ return 0;
+ *out = v;
+ return 1;
+}
+
+void replace_x_into(const char *src, double x, char *dst, size_t cap)
+{
+ size_t o = 0;
+ for (const char *p = src; *p && o + 1 < cap;) {
+ if (p[0] == '{' && p[1] == 'x' && p[2] == '}') {
+ char tmp[64];
+ snprintf(tmp, sizeof tmp, "%.2f", x);
+ for (size_t k = 0; tmp[k] && o + 1 < cap; k++)
+ dst[o++] = tmp[k];
+ p += 3;
+ } else {
+ dst[o++] = *p++;
+ }
+ }
+ dst[o] = '\0';
+}
+
+/* Display width in terminal columns, counting UTF-8 lead bytes. Good enough
+ for Swedish text; combining marks are ignored. */
+
+
+/* ------------------------------------------------------------------ */
+/* app helpers */
+/* ------------------------------------------------------------------ */
+
+void app_refresh_context(struct app *a)
+{
+ char args[64];
+ snprintf(args, sizeof args, "{\"org\":%lld}", (long long)a->org);
+ char *resp = client_rpc(&a->conn, "session.use_org", a->session, 0, args);
+ free(resp);
+
+ resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ char *name = jstr_dup(resp, "result.name");
+ if (name) {
+ snprintf(a->org_name, sizeof a->org_name, "%s", name);
+ free(name);
+ }
+ }
+ free(resp);
+
+ resp = client_rpc(&a->conn, "org.list", a->session, 0, "{}");
+ if (resp) {
+ size_t n = jarr_size(resp, "result.items");
+ for (size_t i = 0; i < n; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.items.%zu.id", i);
+ if (jint_val(resp, path, -1) == a->org) {
+ snprintf(path, sizeof path, "result.items.%zu.role", i);
+ char *role = jstr_dup(resp, path);
+ if (role) {
+ snprintf(a->role, sizeof a->role, "%s", role);
+ free(role);
+ }
+ }
+ }
+ }
+ free(resp);
+
+ snprintf(a->default_series, sizeof a->default_series, "%s", "A");
+ a->attachment_dir[0] = '\0';
+ resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ char *ser = jstr_dup(resp, "result.default_series");
+ if (ser && *ser)
+ snprintf(a->default_series, sizeof a->default_series, "%s", ser);
+ free(ser);
+ char *dir = jstr_dup(resp, "result.attachment_dir");
+ if (dir && *dir)
+ snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", dir);
+ free(dir);
+ }
+ free(resp);
+
+ a->max_attachment_bytes = 10 * 1024 * 1024;
+ resp = client_rpc(&a->conn, "meta", NULL, 0, NULL);
+ if (resp && client_ok(resp)) {
+ char *ver = jstr_dup(resp, "result.version");
+ if (ver && *ver)
+ snprintf(g_server_version, sizeof g_server_version, "%s", ver);
+ free(ver);
+ int64_t v =
+ jint_val(resp, "result.limits.max_attachment_bytes", 0);
+ if (v > 0)
+ a->max_attachment_bytes = (long)v;
+ }
+ free(resp);
+
+ resp = client_rpc(&a->conn, "fiscal_year.get", a->session, a->org, "{}");
+ if (resp && client_ok(resp)) {
+ a->fy = jint_val(resp, "result.id", 0);
+ char *label = jstr_dup(resp, "result.label");
+ char *start = jstr_dup(resp, "result.start_date");
+ char *end = jstr_dup(resp, "result.end_date");
+ if (label)
+ snprintf(a->fy_label, sizeof a->fy_label, "%s", label);
+ if (start)
+ snprintf(a->fy_start, sizeof a->fy_start, "%s", start);
+ if (end)
+ snprintf(a->fy_end, sizeof a->fy_end, "%s", end);
+ free(label);
+ free(start);
+ free(end);
+ }
+ free(resp);
+}
+void show_error(const char *title, const char *resp)
+{
+ char *code = jstr_dup(resp, "error.code");
+ char *msg = jstr_dup(resp, "error.message");
+ if ((!code || !*code) && (!msg || !*msg)) {
+ tui_message(title, "%s", resp && *resp
+ ? resp
+ : "Inget svar från servern (kör daemonen?)");
+ } else {
+ tui_message(title, "%s: %s", code && *code ? code : "fel",
+ msg && *msg ? msg : "okänt fel");
+ }
+ free(code);
+ free(msg);
+}
+
+char *read_file_b64(const char *path)
+{
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return NULL;
+ struct buf b;
+ buf_init(&b);
+ unsigned char chunk[65536];
+ size_t rn;
+ while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0)
+ buf_append(&b, chunk, rn);
+ int bad = ferror(f);
+ fclose(f);
+ if (bad) {
+ buf_free(&b);
+ return NULL;
+ }
+ char *b64 = util_b64(b.p ? b.p : (const unsigned char *)"", b.len);
+ buf_free(&b);
+ return b64;
+}
+struct fentry {
+ char name[300];
+ int isdir;
+};
+
+static int fentry_cmp(const void *a, const void *b)
+{
+ const struct fentry *x = a;
+ const struct fentry *y = b;
+ if (x->isdir != y->isdir)
+ return y->isdir - x->isdir;
+ return strcasecmp(x->name, y->name);
+}
+
+/* Expands a leading ~ to $HOME (no ~user support). */
+static void expand_path(const char *in, char *out, size_t n)
+{
+ if (in && in[0] == '~' && (in[1] == '\0' || in[1] == '/')) {
+ const char *home = getenv("HOME");
+ if (home)
+ snprintf(out, n, "%s%s", home, in + 1);
+ else
+ snprintf(out, n, "%s", in[1] ? in + 2 : ".");
+ } else {
+ snprintf(out, n, "%s", in ? in : ".");
+ }
+}
+
+/* File browser for choosing underlag. Starts in start_dir or $HOME.
+ Returns a malloc'd path, or NULL on cancel. */
+char *file_browser(struct app *a, const char *start_dir)
+{
+ (void)a;
+ if (g_quit)
+ return NULL;
+ char dir[1024];
+ const char *start = (start_dir && *start_dir) ? start_dir : getenv("HOME");
+ expand_path(start, dir, sizeof dir);
+ if (!dir[0])
+ snprintf(dir, sizeof dir, ".");
+ for (;;) {
+ DIR *d = opendir(dir);
+ if (!d) {
+ tui_message("Filväljare", "Kan inte öppna %s", dir);
+ return NULL;
+ }
+ struct fentry *ents = NULL;
+ size_t n = 0, cap = 0;
+ struct dirent *de;
+ while ((de = readdir(d)) != NULL) {
+ if (strcmp(de->d_name, ".") == 0 ||
+ strcmp(de->d_name, "..") == 0)
+ continue; /* ".." is the first item in the list */
+ if (de->d_name[0] == '.')
+ continue; /* hidden files */
+ char full[1600];
+ snprintf(full, sizeof full, "%.1200s/%.299s", dir, de->d_name);
+ struct stat st;
+ if (stat(full, &st) != 0)
+ continue;
+ if (n == cap) {
+ cap = cap ? cap * 2 : 64;
+ ents = xrealloc(ents, cap * sizeof *ents);
+ }
+ snprintf(ents[n].name, sizeof ents[n].name, "%s", de->d_name);
+ ents[n].isdir = S_ISDIR(st.st_mode);
+ n++;
+ if (n >= 2000)
+ break;
+ }
+ closedir(d);
+ qsort(ents, n, sizeof *ents, fentry_cmp);
+ char **items = xcalloc(n + 1, sizeof(char *));
+ items[0] = xstrdup(".. (uppåt)");
+ for (size_t i = 0; i < n; i++) {
+ char line[340];
+ snprintf(line, sizeof line, "%s%s", ents[i].name,
+ ents[i].isdir ? "/" : "");
+ items[i + 1] = xstrdup(line);
+ }
+ char title[1200];
+ snprintf(title, sizeof title, "Välj fil — %.200s", dir);
+ int sel = tui_select_list(title, items, (int)n + 1, 0, 1, NULL, 0, NULL, 0);
+ for (size_t i = 0; i <= n; i++)
+ free(items[i]);
+ free(items);
+ if (sel < 0) {
+ free(ents);
+ return NULL;
+ }
+ if (sel == 0) {
+ char *slash = strrchr(dir, '/');
+ if (slash && slash != dir)
+ *slash = '\0';
+ else if (slash == dir)
+ dir[1] = '\0';
+ free(ents);
+ continue;
+ }
+ size_t idx = (size_t)sel - 1;
+ char full[1600];
+ snprintf(full, sizeof full, "%.1200s/%.299s", dir, ents[idx].name);
+ if (ents[idx].isdir) {
+ snprintf(dir, sizeof dir, "%.1023s", full);
+ free(ents);
+ continue;
+ }
+ free(ents);
+ return xstrdup(full);
+ }
+}
+
+/* Valid UTF-8 without NUL bytes: good enough to call a receipt text. */
+static int bytes_look_text(const unsigned char *p, size_t n)
+{
+ for (size_t i = 0; i < n;) {
+ unsigned char c = p[i];
+ if (c == 0)
+ return 0;
+ if (c < 0x80) {
+ i++;
+ continue;
+ }
+ int len;
+ if ((c & 0xE0) == 0xC0)
+ len = 2;
+ else if ((c & 0xF0) == 0xE0)
+ len = 3;
+ else if ((c & 0xF8) == 0xF0)
+ len = 4;
+ else
+ return 0;
+ if (i + (size_t)len > n)
+ return 0;
+ for (int k = 1; k < len; k++)
+ if ((p[i + k] & 0xC0) != 0x80)
+ return 0;
+ i += (size_t)len;
+ }
+ return 1;
+}
+
+/* Fetch one attachment, save it to a prompted path and verify its hash.
+ Text attachments are shown inline after saving. */
+void attachment_download(struct app *a, int64_t att_id)
+{
+ char args[64];
+ snprintf(args, sizeof args, "{\"id\":%lld}", (long long)att_id);
+ char *resp =
+ client_rpc(&a->conn, "attachment.get", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Kunde inte hämta underlaget", resp);
+ free(resp);
+ return;
+ }
+ char *fn = jstr_dup(resp, "result.filename");
+ char *mime = jstr_dup(resp, "result.mime");
+ char *sha = jstr_dup(resp, "result.sha256");
+ char *b64 = jstr_dup(resp, "result.content_base64");
+ unsigned char *data = NULL;
+ size_t n = 0;
+ if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) {
+ tui_message("Underlag", "Kunde inte avkoda innehållet.");
+ goto done;
+ }
+ const char *home = getenv("HOME");
+ char def[600], dl[512];
+ snprintf(dl, sizeof dl, "%s/Downloads", home && *home ? home : ".");
+ struct stat sb;
+ if (stat(dl, &sb) != 0 || !S_ISDIR(sb.st_mode))
+ snprintf(dl, sizeof dl, "%s", home && *home ? home : ".");
+ snprintf(def, sizeof def, "%s/%s", dl, fn && *fn ? fn : "underlag");
+ char pathbuf[512]; char *path = tui_prompt_into(pathbuf, sizeof pathbuf, "Spara underlag: ", def, 0) ? pathbuf : NULL;
+ if (!path || !*path)
+ goto done;
+ char full[600];
+ if (path[0] == '~' && (path[1] == '/' || path[1] == '\0'))
+ snprintf(full, sizeof full, "%s%s", home ? home : "", path + 1);
+ else
+ snprintf(full, sizeof full, "%s", path);
+ if (stat(full, &sb) == 0) {
+ char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, "Filen finns, skriv över? (j/n): ", "n", 0) ? ansbuf : NULL;
+ if (!ans || (ans[0] != 'j' && ans[0] != 'J'))
+ goto done;
+ }
+ FILE *f = fopen(full, "wb");
+ if (!f || fwrite(data, 1, n, f) != n) {
+ tui_message("Underlag", "Kunde inte skriva %s: %s", full, strerror(errno));
+ if (f)
+ fclose(f);
+ goto done;
+ }
+ fclose(f);
+ unsigned char raw[32];
+ char hex[65];
+ util_sha256(data, n, raw);
+ util_hex(raw, sizeof raw, hex);
+ if (sha && *sha && strcmp(hex, sha) != 0)
+ tui_message("Underlag", "VARNING: kontrollsumman stämmer inte.\nSparat: %s",
+ full);
+ else
+ tui_message("Underlag", "Sparat: %s\n(%zu byte)", full, n);
+ int is_text = (mime && strncmp(mime, "text/", 5) == 0) ||
+ (n > 0 && bytes_look_text(data, n));
+ if (is_text && n <= 256 * 1024) {
+ char *copy = xmalloc(n + 1);
+ memcpy(copy, data, n);
+ copy[n] = '\0';
+ tui_pager("Underlag", copy, NULL);
+ free(copy);
+ }
+done:
+ free(data);
+ free(b64);
+ free(sha);
+ free(mime);
+ free(fn);
+ free(resp);
+}
+const char *vstr(yyjson_val *o, const char *k)
+{
+ yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL;
+ return v && yyjson_is_str(v) ? yyjson_get_str(v) : "";
+}
+
+int64_t vint(yyjson_val *o, const char *k)
+{
+ yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL;
+ return v && yyjson_is_int(v) ? (int64_t)yyjson_get_int(v) : 0;
+}
+
+void buf_line(struct buf *b, const char *fmt, ...)
+{
+ char line[1024];
+ va_list ap;
+ va_start(ap, fmt);
+ int n = vsnprintf(line, sizeof line, fmt, ap);
+ va_end(ap);
+ if (n < 0)
+ return;
+ buf_append(b, line, (size_t)n < sizeof line ? (size_t)n : sizeof line - 1);
+}
+
+void buf_rule(struct buf *b, int width)
+{
+ char line[128];
+ if (width > (int)sizeof line - 3)
+ width = (int)sizeof line - 3;
+ if (width < 1)
+ return;
+ line[0] = MK_RULE[0];
+ memset(line + 1, '-', (size_t)width);
+ line[width + 1] = '\n';
+ buf_append(b, line, (size_t)width + 2);
+}
+int acct_in(const char *number, int lo, int hi)
+{
+ int n = atoi(number);
+ return n >= lo && n <= hi;
+}
+const char *const BS_KEYS[3] = { "assets", "equity", "liabilities" };
+
+const struct is_group IS_GROUPS[] = {
+ { "Rörelseintäkter, lagerförändringar m.m.", "Nettoomsättning", 3000, 3799,
+ 1 },
+ { "Rörelseintäkter, lagerförändringar m.m.", "Övriga rörelseintäkter",
+ 3800, 3999, 1 },
+ { "Rörelsekostnader", "Råvaror och förnödenheter", 4000, 4999, 0 },
+ { "Rörelsekostnader", "Övriga externa kostnader", 5000, 6999, 0 },
+ { "Rörelsekostnader", "Personalkostnader", 7000, 7699, 0 },
+ { "Rörelsekostnader", "Avskrivningar och nedskrivningar", 7700, 7899, 0 },
+ { "Rörelsekostnader", "Övriga rörelsekostnader", 7900, 7999, 0 },
+};
+
+/* Pick a voucher in the active fiscal year; returns its id or 0. */
+int64_t pick_voucher(struct app *a)
+{
+ char args[96];
+ snprintf(args, sizeof args, "{\"fiscal_year\":%lld,\"limit\":200}",
+ (long long)a->fy);
+ char *resp =
+ client_rpc(&a->conn, "voucher.list", a->session, a->org, args);
+ if (!resp || !client_ok(resp)) {
+ show_error("Verifikat", resp);
+ free(resp);
+ return 0;
+ }
+ size_t n = jarr_size(resp, "result.items");
+ if (n == 0) {
+ tui_message("Verifikat", "Inga verifikat i räkenskapsåret.");
+ free(resp);
+ return 0;
+ }
+ char **items = xcalloc(n, sizeof(char *));
+ int64_t *ids = xcalloc(n, sizeof(int64_t));
+ for (size_t i = 0; i < n; i++) {
+ char path[64], ver[32], 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.series", i);
+ char *series = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.number", i);
+ int64_t number = jint_val(resp, path, 0);
+ snprintf(path, sizeof path, "result.items.%zu.date", i);
+ char *date = jstr_dup(resp, path);
+ snprintf(path, sizeof path, "result.items.%zu.description", i);
+ char *desc = jstr_dup(resp, path);
+ snprintf(ver, sizeof ver, "%s%lld", series ? series : "",
+ (long long)number);
+ snprintf(line, sizeof line, "%-8s %-10s %s", ver, date ? date : "",
+ desc ? desc : "");
+ items[i] = xstrdup(line);
+ free(series);
+ free(date);
+ free(desc);
+ }
+ int sel = tui_select_list("Välj verifikat", items, (int)n, 0, 1, NULL, 0,
+ NULL, 0);
+ int64_t out = sel >= 0 ? ids[sel] : 0;
+ for (size_t i = 0; i < n; i++)
+ free(items[i]);
+ free(items);
+ free(ids);
+ free(resp);
+ return out;
+}
+/* client_rpc() cannot carry the protocol's top-level dry_run, so build
+ the request by hand. */
+char *rpc_dry(struct app *a, const char *cmd, const char *args)
+{
+ size_t n = strlen(args) + strlen(cmd) + strlen(a->session) + 160;
+ char *raw = xmalloc(n);
+ snprintf(raw, n,
+ "{\"v\":1,\"id\":\"tui\",\"cmd\":\"%s\",\"session\":\"%s\","
+ "\"org\":%lld,\"dry_run\":true,\"args\":%s}",
+ cmd, a->session, (long long)a->org, args);
+ char *r = NULL;
+ if (client_send_line(&a->conn, raw) == 0)
+ r = client_read_line(&a->conn);
+ free(raw);
+ return r;
+}
+/* Small append-only log for connection/reload problems. */
+void tui_log(const char *fmt, ...)
+{
+ char path[600], dir[512];
+ const char *cache = getenv("XDG_CACHE_HOME");
+ if (cache && *cache)
+ snprintf(dir, sizeof dir, "%s/bokf", cache);
+ else {
+ const char *home = getenv("HOME");
+ snprintf(dir, sizeof dir, "%s/.cache/bokf", home && *home ? home : ".");
+ }
+ if (snprintf(path, sizeof path, "%s/tui.log", dir) >= (int)sizeof path)
+ return;
+ config_mkdirs(path);
+ FILE *f = fopen(path, "a");
+ if (!f)
+ return;
+ time_t now = time(NULL);
+ struct tm tm;
+ gmtime_r(&now, &tm);
+ fprintf(f, "%04d-%02d-%02dT%02d:%02d:%02dZ ",
+ tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour,
+ tm.tm_min, tm.tm_sec);
+ va_list ap;
+ va_start(ap, fmt);
+ vfprintf(f, fmt, ap);
+ va_end(ap);
+ fputc('\n', f);
+ fclose(f);
+}
diff --git a/clients/ui.h b/clients/ui.h
new file mode 100644
index 0000000..1737259
--- /dev/null
+++ b/clients/ui.h
@@ -0,0 +1,125 @@
+#ifndef BOKF_UI_H
+#define BOKF_UI_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#include "client.h"
+#include "tui.h"
+#include "util.h"
+#include "yyjson.h"
+
+/* Shared UI state and helpers for the bokftui screens (screens_<group>.c).
+ The scene registry and main() live in bokftui.c. */
+
+/* universal "new/add" hotkey: ^N (0x0e), the de-facto Linux convention */
+#define KEY_CTRL_N 0x0e
+/* ^X clears the focused row in row editors */
+#define KEY_CTRL_X 0x18
+/* ^Enter saves/posts the form; sent via xterm modifyOtherKeys */
+#define KEY_CTRL_ENTER (KEY_MAX + 1)
+/* Pager markup: headings, dimmed derived lines and full-width rules. */
+#define MK_HEAD "\001"
+#define MK_DIM "\002"
+#define MK_RULE "\003"
+#define MK_STICKY "\004"
+/* Menu item prefix for a non-selectable section header. */
+#define MK_SECTION MK_HEAD
+
+struct app {
+ struct client_conn conn;
+ char socket[256];
+ char session[128];
+ char username[64];
+ char password[128]; /* typed or from BOKFD_PASSWORD, kept for ^R */
+ int64_t org;
+ char org_name[128];
+ char role[32];
+ int64_t fy;
+ int64_t voucher_sel; /* last selected voucher id in the list view */
+ int64_t invoice_sel; /* last selected invoice id in the list view */
+ int64_t customer_sel; /* last selected customer id in the list view */
+ char default_series[16];
+ char attachment_dir[256];
+ long max_attachment_bytes;
+ char fy_label[64];
+ char fy_start[16];
+ char fy_end[16];
+};
+
+/* ^C quits the application; Esc is navigation only */
+extern int g_quit;
+/* ^R re-execs the binary, keeping session, org, year and screen */
+extern int g_reload;
+extern char g_scene[32];
+extern char g_reload_scene[32];
+extern char g_server_version[32];
+
+/* json helpers */
+yyjson_val *jget(yyjson_val *root, const char *path);
+yyjson_doc *parse(const char *resp);
+char *jstr_dup(const char *resp, const char *path);
+int64_t jint_val(const char *resp, const char *path, int64_t def);
+size_t jarr_size(const char *resp, const char *path);
+int jbool_val(const char *resp, const char *path, int def);
+
+/* shared UI primitives */
+int ui_getch(void);
+void request_quit(void);
+void tui_log(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
+void show_error(const char *title, const char *resp);
+void kr_whole(int64_t ore, char *buf, size_t n);
+int parse_x_double(const char *s, double *out);
+void replace_x_into(const char *src, double x, char *dst, size_t cap);
+void app_refresh_context(struct app *a);
+char *rpc_dry(struct app *a, const char *cmd, const char *args);
+char *read_file_b64(const char *path);
+char *file_browser(struct app *a, const char *start_dir);
+void attachment_download(struct app *a, int64_t att_id);
+int64_t pick_voucher(struct app *a);
+
+/* generated-document helpers (reports, bokslut) */
+void buf_line(struct buf *b, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+void buf_rule(struct buf *b, int width);
+const char *vstr(yyjson_val *o, const char *k);
+int64_t vint(yyjson_val *o, const char *k);
+int acct_in(const char *number, int lo, int hi);
+extern const char *const BS_KEYS[3];
+struct is_group {
+ const char *section;
+ const char *title;
+ int lo, hi;
+ int rev;
+};
+#define IS_GROUPS_N 7
+extern const struct is_group IS_GROUPS[IS_GROUPS_N];
+
+/* defined in bokftui.c, used by screens */
+void open_scene(struct app *a, const char *name);
+void config_mkdirs(const char *path);
+
+/* screen entry points (scene registry) */
+void dashboard(struct app *a);
+void update_status(struct app *a);
+int64_t vouchers_new(struct app *a);
+void vouchers_screen(struct app *a);
+void acct_cache_free(void);
+const char *acct_name(struct app *a, const char *number);
+int acct_exists(struct app *a, const char *number);
+void accounts_report(struct app *a);
+void reports_screen(struct app *a);
+void ink2_screen(struct app *a);
+void rules_screen(struct app *a);
+void inbox_screen(struct app *a);
+void bank_screen(struct app *a);
+void audit_screen(struct app *a);
+void templates_screen(struct app *a);
+void ib_screen(struct app *a);
+void settings_screen(struct app *a);
+void company_screen(struct app *a);
+void invoices_screen(struct app *a);
+void customers_screen(struct app *a);
+void bokslut_screen(struct app *a);
+
+#endif
diff --git a/docs/STATE.md b/docs/STATE.md
index 8a1d433..b05689f 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -178,6 +178,15 @@ check.
so `describe` output is byte-identical, and
`scripts/check-consistency.sh` / `make check` scans `src/commands.c` and
every `src/cmd_*.c`.
+23. **TUI screen split (2026-09-20)**: the same idea for the TUI:
+ `clients/bokftui.c` keeps `main` (arg parsing, login, startup),
+ `config_path`/`config_mkdirs`, the Ctrl+R reload and the scene registry
+ (`struct scene SCENES[]` + `open_scene`); shared helpers and `struct app`
+ live in `clients/ui.[ch]`; each view group lives in
+ `clients/screens_<group>.c` (dashboard, vouchers, accounts, reports,
+ rules, attachments, bank, audit, templates, ib, bokslut, settings,
+ invoices). Pure move: no key, string or scene-name changes; the registry
+ keeps every name including the `yearinfo` alias.
## Pending decisions