diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-20 22:00:01 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-20 22:00:01 +0200 |
| commit | 10a535ace098144980d0ffd1593f6384c39cc221 (patch) | |
| tree | 53a7c6c5026d479c4b6d5fd4e5b341474cd190fa | |
| parent | 3a5a6d3b04dc4e17a42225afb243eecb3502ead6 (diff) | |
tui: split bokftui into screen files
| -rw-r--r-- | AGENTS.md | 4 | ||||
| -rw-r--r-- | Makefile | 14 | ||||
| -rw-r--r-- | clients/bokftui.c | 7485 | ||||
| -rw-r--r-- | clients/screens_accounts.c | 173 | ||||
| -rw-r--r-- | clients/screens_attachments.c | 163 | ||||
| -rw-r--r-- | clients/screens_audit.c | 132 | ||||
| -rw-r--r-- | clients/screens_bank.c | 368 | ||||
| -rw-r--r-- | clients/screens_bokslut.c | 998 | ||||
| -rw-r--r-- | clients/screens_dashboard.c | 354 | ||||
| -rw-r--r-- | clients/screens_ib.c | 354 | ||||
| -rw-r--r-- | clients/screens_invoices.c | 1324 | ||||
| -rw-r--r-- | clients/screens_reports.c | 1176 | ||||
| -rw-r--r-- | clients/screens_rules.c | 226 | ||||
| -rw-r--r-- | clients/screens_settings.c | 424 | ||||
| -rw-r--r-- | clients/screens_templates.c | 343 | ||||
| -rw-r--r-- | clients/screens_vouchers.c | 958 | ||||
| -rw-r--r-- | clients/ui.c | 674 | ||||
| -rw-r--r-- | clients/ui.h | 125 | ||||
| -rw-r--r-- | docs/STATE.md | 9 |
19 files changed, 7846 insertions, 7458 deletions
@@ -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 @@ -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 |
