#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "client.h" #include "tui.h" #include "formula.h" #include "util.h" #include "version.h" #include "yyjson.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]); 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[16]; struct tui_form_field ff[16]; 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); ff[nf].label = "Styrelseledamöter"; ff[nf].kind = TUI_F_ACTION; int r = tui_form_run("Företagsuppgifter", ff, nf + 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); } } if (r == nf) board_screen(a); for (int i = 0; i < nf; i++) free(vals[i]); if (r == TUI_FORM_BACK) return; } } static const char *const SETTING_KEYS[] = { "default_series", "attachment_dir", "invoice_bankgiro", "invoice_our_ref", "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", "Bankgiro", "Vår referens", "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; }; /* 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]; }; /* 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; } } /* 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]; char *argv[12]; int n = 0; snprintf(orgbuf, sizeof orgbuf, "%lld", (long long)a->org); snprintf(fybuf, sizeof fybuf, "%lld", (long long)a->fy); argv[n++] = (char *)(self && *self ? self : "bokftui"); argv[n++] = "--socket"; argv[n++] = a->socket; argv[n++] = "--org"; argv[n++] = orgbuf; if (a->fy > 0) { argv[n++] = "--fy"; argv[n++] = fybuf; } argv[n++] = "--screen"; argv[n++] = g_reload_scene[0] ? g_reload_scene : g_scene; argv[n] = NULL; setenv("BOKFD_SESSION", a->session, 1); if (a->password[0]) setenv("BOKFD_PASSWORD", a->password, 1); if (a->username[0]) setenv("BOKFD_USER", a->username, 1); tui_log("reload: screen=%s org=%lld fy=%lld", g_reload_scene, (long long)a->org, (long long)a->fy); endwin(); 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 */ /* ------------------------------------------------------------------ */ /* The login screen remembers server and user (never the password) in $XDG_CONFIG_HOME/bokf/tui.conf, defaulting to ~/.config/bokf/tui.conf. */ static void config_path(char *buf, size_t n) { const char *xdg = getenv("XDG_CONFIG_HOME"); if (xdg && *xdg) { snprintf(buf, n, "%s/bokf/tui.conf", xdg); return; } const char *home = getenv("HOME"); snprintf(buf, n, "%s/.config/bokf/tui.conf", home && *home ? home : "."); } static void config_mkdirs(const char *path) { char tmp[640]; snprintf(tmp, sizeof tmp, "%s", path); if (strlen(tmp) >= sizeof tmp) return; for (char *p = tmp + 1; *p; p++) { if (*p != '/') continue; *p = '\0'; mkdir(tmp, 0700); *p = '/'; } } static void config_load(struct app *a) { char path[512]; config_path(path, sizeof path); FILE *f = fopen(path, "r"); if (!f) return; char line[512]; while (fgets(line, sizeof line, f)) { char *nl = strchr(line, '\n'); if (nl) *nl = '\0'; char *eq = strchr(line, '='); if (!eq) continue; *eq = '\0'; const char *val = util_str_trim(eq + 1); if (strcmp(line, "server") == 0 && !a->socket[0]) snprintf(a->socket, sizeof a->socket, "%s", val); else if (strcmp(line, "user") == 0 && !a->username[0]) snprintf(a->username, sizeof a->username, "%s", val); } fclose(f); } static void config_save(const struct app *a) { char path[512]; config_path(path, sizeof path); config_mkdirs(path); FILE *f = fopen(path, "w"); if (!f) return; fprintf(f, "server=%s\n", a->socket); if (a->username[0]) fprintf(f, "user=%s\n", a->username); fclose(f); 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, const char *token, char **err_out) { *err_out = NULL; if (client_connect(a->socket, &a->conn) != 0) { *err_out = xstrdup(client_last_error()); return -2; } char *session = NULL; int rc; if (token && *token) rc = client_token_login(&a->conn, token, &session, err_out); else rc = client_login(&a->conn, user, pass, &session, err_out); if (rc != 0) { client_close(&a->conn); return -1; } snprintf(a->session, sizeof a->session, "%s", session); if (user && *user) { /* callers may pass a->username itself; snprintf with overlapping source and destination is undefined and yields "" on glibc */ char ubuf[64]; snprintf(ubuf, sizeof ubuf, "%s", user); snprintf(a->username, sizeof a->username, "%s", ubuf); } free(session); if (token && *token) { char *resp = client_rpc(&a->conn, "session.whoami", a->session, 0, NULL); char *u = jstr_dup(resp, "result.user.username"); if (u && *u) snprintf(a->username, sizeof a->username, "%s", u); free(u); free(resp); } return 0; } static int login_screen(struct app *a) { static int sel = 0; char socket_path[256], user[64], pass[128]; snprintf(socket_path, sizeof socket_path, "%s", a->socket); const char *env_pass = getenv("BOKFD_PASSWORD"); snprintf(user, sizeof user, "%s", a->username); snprintf(pass, sizeof pass, "%s", env_pass ? env_pass : ""); struct tui_form_field ff[4]; memset(ff, 0, sizeof ff); ff[0].label = "Server"; ff[0].value = socket_path; ff[0].cap = sizeof socket_path; ff[0].kind = TUI_F_TEXT; ff[1].label = "Användare"; ff[1].value = user; ff[1].cap = sizeof user; ff[1].kind = TUI_F_TEXT; ff[2].label = "Lösenord"; ff[2].value = pass; ff[2].cap = sizeof pass; ff[2].kind = TUI_F_TEXT; ff[2].mask = 1; ff[3].label = ""; ff[3].value = "Logga in"; ff[3].kind = TUI_F_ACTION; for (;;) { int r = tui_form_run("bokf — inloggning", ff, 4, 1, &sel); if (r == TUI_FORM_BACK) { if (g_quit) { endwin(); return -1; } continue; /* Esc never exits here */ } if (r != TUI_FORM_SUBMIT && r != 3) continue; /* attempt login */ snprintf(a->socket, sizeof a->socket, "%s", socket_path); char *err = NULL; int rc = try_login(a, user, pass, NULL, &err); if (rc == -2) { tui_message("Fel", "Kunde inte ansluta till %s: %s", a->socket, err ? err : "okänt fel"); free(err); continue; } if (rc != 0) { if (err && err[0] == '{') { char *code = jstr_dup(err, "error.code"); char *msg = jstr_dup(err, "error.message"); tui_message("Inloggning misslyckades", "%s: %s", code ? code : "fel", msg ? msg : "okänt fel"); free(code); free(msg); } else { tui_message("Inloggning misslyckades", "%s", err ? err : "transportfel"); } free(err); continue; } snprintf(a->password, sizeof a->password, "%s", pass); config_save(a); return 0; } } static int select_org(struct app *a) { char *resp = client_rpc(&a->conn, "session.list_orgs", a->session, 0, "{}"); if (!resp || !client_ok(resp)) { show_error("Organisationer", resp); free(resp); return -1; } size_t n = jarr_size(resp, "result.items"); if (n == 0) { tui_message("Organisationer", "Inga organisationer. Skapa en med bokfctl org.create."); free(resp); return -1; } 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]; snprintf(path, sizeof path, "result.items.%zu.name", i); char *name = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.role", i); char *role = jstr_dup(resp, path); char nbuf[128], line[256]; snprintf(nbuf, sizeof nbuf, "%s", name ? name : ""); tui_pad_field(nbuf, sizeof nbuf, 30); snprintf(line, sizeof line, "%s (%s)", nbuf, role ? role : ""); items[i] = xstrdup(line); snprintf(path, sizeof path, "result.items.%zu.id", i); ids[i] = jint_val(resp, path, 0); free(name); free(role); } int sel = tui_select_list("Välj organisation att representera", items, (int)n, 0, 0, NULL, 0, NULL, 0); for (size_t i = 0; i < n; i++) free(items[i]); free(items); free(resp); if (sel < 0) return -1; a->org = ids[sel]; free(ids); return 0; } static void usage(FILE *f) { fprintf(f, "usage: bokftui [options]\n" " --socket TARGET unix path, tcp:host:port or tls:host:port\n" " --user NAME prefill username\n" " --org ID select org directly\n" " --fy ID select fiscal year directly\n" " --session ID reuse an open session (or BOKFD_SESSION)\n" " --screen NAME start in a view: dashboard, vouchers, inbox,\n" " bank, ib, templates, rules, reports, bokslut\n" " (yearinfo is an alias), audit, settings,\n" " company, invoices, customers\n" " --version\n"); } static void reset_modify_keys(void) { putp("\033[>4m"); fflush(stdout); } int main(int argc, char **argv) { struct app app; memset(&app, 0, sizeof app); app.conn.fd = -1; const char *self = argc > 0 ? argv[0] : "bokftui"; const char *cli_socket = NULL, *cli_user = NULL, *cli_session = NULL; const char *screen_arg = NULL; int64_t want_org = 0, want_fy = 0; for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--socket") == 0 && i + 1 < argc) cli_socket = argv[++i]; else if (strcmp(argv[i], "--user") == 0 && i + 1 < argc) cli_user = argv[++i]; else if (strcmp(argv[i], "--session") == 0 && i + 1 < argc) cli_session = argv[++i]; else if (strcmp(argv[i], "--screen") == 0 && i + 1 < argc) screen_arg = argv[++i]; else if (strcmp(argv[i], "--fy") == 0 && i + 1 < argc) want_fy = strtoll(argv[++i], NULL, 10); else if (strcmp(argv[i], "--org") == 0 && i + 1 < argc) { want_org = strtoll(argv[++i], NULL, 10); } else if (strcmp(argv[i], "--version") == 0) { printf("bokftui %s\n", BOKF_VERSION); return 0; } else if (strcmp(argv[i], "--help") == 0) { usage(stdout); return 0; } else { usage(stderr); return 2; } } const char *env_socket = getenv("BOKFD_SOCKET"); const char *env_user = getenv("BOKFD_USER"); if (cli_socket) snprintf(app.socket, sizeof app.socket, "%s", cli_socket); else if (env_socket) snprintf(app.socket, sizeof app.socket, "%s", env_socket); if (cli_user) snprintf(app.username, sizeof app.username, "%s", cli_user); else if (env_user) snprintf(app.username, sizeof app.username, "%s", env_user); config_load(&app); if (!app.socket[0]) snprintf(app.socket, sizeof app.socket, "%s", "/run/bokfd/bokfd.sock"); setlocale(LC_ALL, ""); initscr(); set_escdelay(100); cbreak(); noecho(); keypad(stdscr, TRUE); tui_keys_setup(); tui_set_input(ui_getch); tui_set_screen(tui_frame, tui_hints); tui_set_quit(request_quit); atexit(reset_modify_keys); curs_set(1); const char *env_session = getenv("BOKFD_SESSION"); const char *session_arg = cli_session ? cli_session : env_session; const char *token = getenv("BOKFD_TOKEN"); const char *env_pass = getenv("BOKFD_PASSWORD"); int have_auto = (session_arg && *session_arg) || (token && *token) || (app.username[0] && env_pass && *env_pass); int stale_session = 0; char *auto_err = NULL; if (have_auto) { tui_log("auto-login start (session=%d token=%d password=%d)", session_arg && *session_arg ? 1 : 0, token && *token ? 1 : 0, app.username[0] && env_pass && *env_pass ? 1 : 0); for (int i = 0; i < 60 && !app.session[0]; i++) { if (i > 0) { char msg[128]; snprintf(msg, sizeof msg, "Återansluter till servern... (%ds)", i); tui_set_status(msg, NULL); tui_frame("bokf — ansluter"); tui_redraw(); sleep(1); } if (session_arg && *session_arg) { struct client_conn c; if (client_connect(app.socket, &c) != 0) continue; char *resp = client_rpc(&c, "session.whoami", session_arg, 0, NULL); if (resp && client_ok(resp)) { app.conn = c; snprintf(app.session, sizeof app.session, "%s", session_arg); free(resp); tui_log("auto-login: reused session"); break; } if (!resp) { free(resp); client_close(&c); continue; } free(resp); client_close(&c); stale_session = 1; session_arg = NULL; tui_log("auto-login: session expired"); } if (token && *token) { char *err = NULL; int rc = try_login(&app, NULL, NULL, token, &err); if (rc == 0) { tui_log("auto-login: token ok"); break; } token = NULL; if (rc == -2) { free(err); continue; } tui_log("auto-login: token failed: %s", err ? err : "?"); free(auto_err); auto_err = err; } if (app.username[0] && env_pass && *env_pass) { char *err = NULL; int rc = try_login(&app, app.username, env_pass, NULL, &err); if (rc == 0) { snprintf(app.password, sizeof app.password, "%s", env_pass); tui_log("auto-login: password ok"); break; } env_pass = NULL; if (rc == -2) { free(err); continue; } tui_log("auto-login: password failed: %s", err ? err : "?"); free(auto_err); auto_err = err; } break; } if (!app.session[0]) tui_log("auto-login: giving up, login screen"); } if (!app.session[0]) { if (auto_err) { char *code = jstr_dup(auto_err, "error.code"); char *msg = jstr_dup(auto_err, "error.message"); tui_message("Återanslutning misslyckades", "%s: %s", code ? code : "fel", msg ? msg : "okänt fel"); free(code); free(msg); } else if (stale_session) { tui_message("Session", "Sessionen har gått ut, logga in igen."); } } free(auto_err); if (!app.session[0] && login_screen(&app) != 0) { if (g_reload) do_reload(&app, self); endwin(); return 0; } curs_set(0); if (want_org > 0) { app.org = want_org; } else if (select_org(&app) != 0) { if (g_reload) do_reload(&app, self); endwin(); return 0; } app_refresh_context(&app); if (want_fy > 0) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)want_fy); char *resp = client_rpc(&app.conn, "fiscal_year.get", app.session, app.org, args); if (resp && client_ok(resp)) { app.fy = want_fy; 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(app.fy_label, sizeof app.fy_label, "%s", label); if (start) snprintf(app.fy_start, sizeof app.fy_start, "%s", start); if (end) snprintf(app.fy_end, sizeof app.fy_end, "%s", end); free(label); free(start); free(end); } free(resp); } update_status(&app); if (screen_arg && *screen_arg && strcmp(screen_arg, "dashboard") != 0) open_scene(&app, screen_arg); dashboard(&app); if (g_reload) do_reload(&app, self); client_rpc(&app.conn, "session.close", app.session, 0, "{}"); acct_cache_free(); client_close(&app.conn); endwin(); return 0; }