#include #include #include #include #include #include #include #include #include #include #include #include #include #include "client.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) /* ^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; } 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 */ 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_status[512]; static char g_server_version[32]; static int disp_width(const char *s); static void tui_log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); static void show_error(const char *title, const char *resp); static int64_t vouchers_new(struct app *a); static void acct_cache_free(void); static void acct_cache_load(struct app *a); static const char *acct_name(struct app *a, const char *number); static int acct_exists(struct app *a, const char *number); /* ---- shared line editor used by every form and prompt ---- */ struct ledit { char *buf; size_t cap; size_t len; size_t pos; /* caret position in bytes */ }; static size_t disp_width_n(const char *s, size_t n) { size_t w = 0; for (size_t i = 0; i < n && s[i]; i++) if (((unsigned char)s[i] & 0xC0) != 0x80) w++; return w; } static void le_init(struct ledit *e, char *buf, size_t cap) { e->buf = buf; e->cap = cap; e->len = strlen(buf); e->pos = e->len; } static void le_clear(struct ledit *e) { e->len = 0; e->pos = 0; e->buf[0] = '\0'; } static void le_insert(struct ledit *e, unsigned char ch) { if (e->len + 1 >= e->cap) return; memmove(e->buf + e->pos + 1, e->buf + e->pos, e->len - e->pos + 1); e->buf[e->pos] = (char)ch; e->pos++; e->len++; } static void le_backspace(struct ledit *e) { if (e->pos == 0) return; memmove(e->buf + e->pos - 1, e->buf + e->pos, e->len - e->pos + 1); e->pos--; e->len--; } static void le_delete(struct ledit *e) { if (e->pos >= e->len) return; memmove(e->buf + e->pos, e->buf + e->pos + 1, e->len - e->pos); e->len--; } /* Consumes the key when it is an editing key; returns 1 then. */ static int le_key(struct ledit *e, int ch) { if (ch == KEY_LEFT) { if (e->pos > 0) { e->pos--; while (e->pos > 0 && ((unsigned char)e->buf[e->pos] & 0xC0) == 0x80) e->pos--; } return 1; } if (ch == KEY_RIGHT) { if (e->pos < e->len) { e->pos++; while (e->pos < e->len && ((unsigned char)e->buf[e->pos] & 0xC0) == 0x80) e->pos++; } return 1; } if (ch == KEY_HOME || ch == 1) { /* Ctrl+A */ e->pos = 0; return 1; } if (ch == KEY_END || ch == 5) { /* Ctrl+E */ e->pos = e->len; return 1; } if (ch == KEY_DC) { le_delete(e); return 1; } if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { le_backspace(e); return 1; } if (ch == 21) { /* ^U */ le_clear(e); return 1; } if (ch >= 32 && ch < 256) { le_insert(e, (unsigned char)ch); return 1; } return 0; } /* Date field: only digits are accepted, dashes are inserted automatically ("20260215" -> "2026-02-15"). The caret is tracked in digit space and reported back as a byte offset so forms render it like any field. */ static int date_field_edit(char *buf, size_t cap, size_t *pos, int ch, int *fresh) { char d[9]; size_t n = 0; for (const char *q = buf; *q && n < 8; q++) if (*q >= '0' && *q <= '9') d[n++] = *q; size_t ci = n; /* caret in digit space; byte pos -> digit index */ if (*pos != (size_t)-1) { size_t seen = 0; for (size_t i = 0; i < *pos && buf[i]; i++) if (buf[i] >= '0' && buf[i] <= '9') seen++; ci = seen; } if (*fresh) { int editing = ch == KEY_BACKSPACE || ch == 127 || ch == 8 || ch == KEY_DC || (ch >= '0' && ch <= '9'); int moving = ch == KEY_LEFT || ch == KEY_RIGHT || ch == KEY_HOME || ch == KEY_END || ch == 1 || ch == 5; if (editing || moving) { if (editing) { n = 0; ci = 0; } *fresh = 0; /* movement keeps the date and moves the caret */ } } if (ch >= '0' && ch <= '9') { if (ci > n) ci = n; if (n < 8) { for (size_t i = n; i > ci; i--) d[i] = d[i - 1]; d[ci] = (char)ch; n++; } else { /* full field: overwrite the digit at the caret */ if (ci > 7) ci = 7; d[ci] = (char)ch; } ci++; } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { if (ci > 0) { for (size_t i = ci - 1; i + 1 < n; i++) d[i] = d[i + 1]; n--; ci--; } } else if (ch == KEY_DC) { if (ci < n) { for (size_t i = ci; i + 1 < n; i++) d[i] = d[i + 1]; n--; } } else if (ch == KEY_LEFT) { if (ci > 0) ci--; } else if (ch == KEY_RIGHT) { if (ci < n) ci++; } else if (ch == KEY_HOME || ch == 1) { ci = 0; } else if (ch == KEY_END || ch == 5) { ci = n; } else if (ch == 21) { n = 0; ci = 0; *fresh = 0; } else { return 0; /* not a date editing key */ } /* rebuild "YYYY-MM-DD" (partial while typing) */ char out[16]; size_t o = 0; for (size_t i = 0; i < n; i++) { if (i == 4 || i == 6) out[o++] = '-'; out[o++] = d[i]; } out[o] = '\0'; snprintf(buf, cap, "%s", out); size_t byte = ci; if (n > 4 && ci >= 4) byte++; if (n > 6 && ci >= 6) byte++; if (byte > strlen(buf)) byte = strlen(buf); *pos = byte; return 1; } /* Prompt for a date with the date editor. Returns a static buffer or NULL on Esc. */ static char *date_prompt(const char *label, const char *def) { static char buf[16]; snprintf(buf, sizeof buf, "%s", def ? def : ""); int y = LINES - 3; attron(A_BOLD); mvaddstr(y, 2, label); attroff(A_BOLD); size_t pos = (size_t)-1; int fresh = 1; curs_set(1); for (;;) { move(y, (int)strlen(label) + 3); clrtoeol(); addnstr(buf, (int)sizeof buf); clrtoeol(); size_t slen = strlen(buf); size_t cp = pos == (size_t)-1 || pos > slen ? slen : pos; move(y, (int)strlen(label) + 3 + (int)disp_width_n(buf, cp)); refresh(); int ch = ui_getch(); if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) return buf; if (ch == 27) return NULL; date_field_edit(buf, sizeof buf, &pos, ch, &fresh); } } /* Applies a key to a form field. *pos is the caret, (size_t)-1 = at the end. When *fresh is set, the first editing key replaces the content. */ static int field_edit(char *buf, size_t cap, size_t *pos, int ch, int *fresh) { struct ledit e; le_init(&e, buf, cap); if (*pos != (size_t)-1 && *pos <= e.len) e.pos = *pos; if (*fresh) { int editing = ch == KEY_BACKSPACE || ch == 127 || ch == 8 || ch == KEY_DC || (ch >= 32 && ch < 256); int moving = ch == KEY_LEFT || ch == KEY_RIGHT || ch == KEY_HOME || ch == KEY_END || ch == 1 || ch == 5; if (editing || moving) { if (editing) le_clear(&e); *fresh = 0; /* movement keeps the content and moves the caret */ } } int handled = le_key(&e, ch); *pos = e.pos; return handled; } static void frame(const char *title) { erase(); attron(A_BOLD); box(stdscr, 0, 0); mvaddstr(0, 2, title); attroff(A_BOLD); if (g_status[0]) { attron(A_DIM); mvaddnstr(1, 2, g_status, COLS - 4); char ver[96]; if (g_server_version[0]) snprintf(ver, sizeof ver, "tui %s srv %s", BOKF_VERSION, g_server_version); else snprintf(ver, sizeof ver, "tui %s", BOKF_VERSION); int x = COLS - 3 - disp_width(ver); if (x > disp_width(g_status) + 6) mvaddstr(1, x, ver); attroff(A_DIM); } } static void hints(const char *s) { char line[512]; snprintf(line, sizeof line, "%s ^R = ladda om", s); attron(A_DIM); mvaddstr(LINES - 1, 2, line); attroff(A_DIM); clrtoeol(); } static void message(const char *title, const char *fmt, ...) __attribute__((format(printf, 2, 3))); static void message(const char *title, const char *fmt, ...) { char text[1024]; va_list ap; va_start(ap, fmt); vsnprintf(text, sizeof text, fmt, ap); va_end(ap); int h = 5, w = (int)strlen(text) + 4; if (w > COLS - 4) w = COLS - 4; if (w < 30) w = 30; WINDOW *win = newwin(h, w, (LINES - h) / 2, (COLS - w) / 2); box(win, 0, 0); wattron(win, A_BOLD); mvwaddnstr(win, 0, 2, title, w - 4); wattroff(win, A_BOLD); mvwaddnstr(win, 2, 2, text, w - 4); mvwaddstr(win, h - 1, w - 12, " tryck Enter"); wrefresh(win); nodelay(stdscr, FALSE); timeout(-1); for (;;) { int ch = wgetch(win); if (ch == 3) { g_quit = 1; break; } if (ch == '\n' || ch == '\r' || ch == ' ' || ch == 27 || ch == KEY_ENTER) break; } delwin(win); touchwin(stdscr); refresh(); } /* Line editor inside the current window at (y,x) with fixed label already drawn. Returns 1 on Enter, 2 on Tab, 3 on Shift-Tab, 0 on Esc. */ static int edit_field(int y, int x, char *buf, size_t cap, int mask) { size_t pos = (size_t)-1; int fresh = 1; for (;;) { move(y, x); if (mask) { size_t len = strlen(buf); for (size_t i = 0; i < len; i++) addch('*'); } else { addnstr(buf, (int)cap); } clrtoeol(); size_t len = strlen(buf); size_t cp = pos == (size_t)-1 || pos > len ? len : pos; move(y, x + (mask ? (int)cp : (int)disp_width_n(buf, cp))); refresh(); int ch = ui_getch(); if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) return 1; if (ch == 27) return 0; if (ch == '\t') return 2; if (ch == KEY_BTAB) return 3; field_edit(buf, cap, &pos, ch, &fresh); } } static char *prompt(const char *label, const char *def, int mask) { static char buf[512]; snprintf(buf, sizeof buf, "%s", def ? def : ""); int y = LINES - 3; attron(A_BOLD); mvaddstr(y, 2, label); attroff(A_BOLD); move(y, (int)strlen(label) + 3); clrtoeol(); refresh(); int prev = curs_set(1); int rc = edit_field(y, (int)strlen(label) + 3, buf, sizeof buf, mask); curs_set(prev == ERR ? 0 : prev); if (rc == 0) return NULL; return buf; } static int text_view(const char *title, const char *text, const char *action_hint) { int ret = 0; int nlines = 0; for (const char *p = text; *p; p++) if (*p == '\n') nlines++; nlines += 1; char **lines = xcalloc(nlines ? nlines : 1, sizeof(char *)); int n = 0; char *copy = xstrdup(text); for (char *p = copy;;) { char *nl = strchr(p, '\n'); if (nl) *nl = '\0'; lines[n++] = p; if (!nl) break; p = nl + 1; } int top = 0; int view = LINES - 4; for (;;) { frame(title); for (int i = 0; i < view; i++) { move(2 + i, 2); clrtoeol(); if (top + i < n) mvaddnstr(2 + i, 2, lines[top + i], COLS - 4); } char hint[256]; if (action_hint) snprintf(hint, sizeof hint, "piltangenter/PgUp/PgDn rullar F5 = uppdatera %s " "q = tillbaka", action_hint); else snprintf(hint, sizeof hint, "piltangenter/PgUp/PgDn rullar F5 = uppdatera " "q = tillbaka"); hints(hint); refresh(); int ch = ui_getch(); if (ch == 'q' || ch == 27) break; if (ch == KEY_F(5)) { ret = 1; break; } if (ch == 's' && action_hint) { ret = 2; break; } if (ch == KEY_DOWN && top + view < n) top++; else if (ch == KEY_UP && top > 0) top--; else if (ch == KEY_NPAGE) top += view; else if (ch == KEY_PPAGE) top -= view; else if (ch == KEY_HOME) top = 0; else if (ch == KEY_END) top = n > view ? n - view : 0; if (top + view > n) top = n > view ? n - view : 0; if (top < 0) top = 0; } free(copy); free(lines); return ret; } static int menu(const char *title, const char *const *items, int n, int allow_new, int *cursor) { int sel = (cursor && *cursor >= 0 && *cursor < n) ? *cursor : 0; int top = 0; char gotobuf[12] = ""; int goto_active = 0; for (;;) { if (cursor) *cursor = sel; int view = (LINES - 4) / 2; if (view < 1) view = 1; if (sel < top) top = sel; if (sel >= top + view) top = sel - view + 1; frame(title); for (int i = 0; i < view && top + i < n; i++) { int idx = top + i; char line[512]; snprintf(line, sizeof line, "%2d. %s", idx + 1, items[idx]); if (idx == sel) attron(A_REVERSE); mvaddnstr(3 + i * 2, 4, line, COLS - 6); if (idx == sel) attroff(A_REVERSE); } if (goto_active) { move(LINES - 2, 2); attron(A_BOLD); printw("Gå till nummer: %s", gotobuf); attroff(A_BOLD); clrtoeol(); } else { move(LINES - 2, 2); clrtoeol(); } hints(allow_new ? "upp/ned, 1-9 = snabbval, g = gå till, PgUp/PgDn," " Home/End, Enter ^N = ny Esc/q = tillbaka" " ^C = avsluta" : "upp/ned, 1-9 = snabbval, g = gå till, PgUp/PgDn," " Home/End, Enter Esc/q = tillbaka ^C = avsluta"); refresh(); int ch = ui_getch(); if (goto_active) { if (ch >= '0' && ch <= '9' && strlen(gotobuf) < 9) { size_t gl = strlen(gotobuf); gotobuf[gl] = (char)ch; gotobuf[gl + 1] = '\0'; int v = atoi(gotobuf); if (v >= 1 && v <= n) sel = v - 1; } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { size_t gl = strlen(gotobuf); if (gl) gotobuf[gl - 1] = '\0'; if (gotobuf[0]) { int v = atoi(gotobuf); if (v >= 1 && v <= n) sel = v - 1; } } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER || ch == 27 || ch == 'g' || ch == 'G') { goto_active = 0; gotobuf[0] = '\0'; } continue; } if (ch == KEY_UP && sel > 0) sel--; else if (ch == KEY_DOWN && sel < n - 1) sel++; else if (ch == KEY_NPAGE) sel = sel + view < n ? sel + view : n - 1; else if (ch == KEY_PPAGE) sel = sel - view > 0 ? sel - view : 0; else if (ch == KEY_HOME) sel = 0; else if (ch == KEY_END) sel = n - 1; else if (ch >= '1' && ch <= '9' && ch - '1' < n) { sel = ch - '1'; if (cursor) *cursor = sel; return sel; } else if (ch == 'g' || ch == 'G') { goto_active = 1; gotobuf[0] = '\0'; } else if (ch == KEY_CTRL_N && allow_new) return -4; else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) return sel; else if (ch == 27 || ch == 'q') return g_quit ? -5 : -1; } } static int select_list(const char *title, char **items, int n, int start, int allow_refresh, int *cursor, int allow_new, int allow_archive, int allow_toggle) { int sel = start; if (sel < 0) sel = 0; if (n > 0 && sel >= n) sel = n - 1; int top = 0; int view = LINES - 4; char gotobuf[12] = ""; int goto_active = 0; for (;;) { if (cursor) *cursor = sel; frame(title); if (sel < top) top = sel; if (sel >= top + view) top = sel - view + 1; int numw = 1; for (int tmp = n; tmp >= 10; tmp /= 10) numw++; if (numw > 9) numw = 9; for (int i = 0; i < view && top + i < n; i++) { char line[1024]; snprintf(line, sizeof line, "%*d. %s", numw, top + i + 1, items[top + i]); if (top + i == sel) attron(A_REVERSE); mvaddnstr(2 + i, 2, line, COLS - 4); if (top + i == sel) attroff(A_REVERSE); } if (goto_active) { move(LINES - 2, 2); attron(A_BOLD); printw("Gå till rad: %s", gotobuf); attroff(A_BOLD); clrtoeol(); } else { move(LINES - 2, 2); clrtoeol(); } { char hint[256]; snprintf(hint, sizeof hint, "upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End," " Enter%s%s%s%s Esc/q = tillbaka", allow_refresh ? " F5 = uppdatera" : "", allow_new ? " ^N = ny" : "", allow_archive ? " d = arkivera" : "", allow_toggle ? " ^A = öppna/stäng" : ""); snprintf(hint + strlen(hint), sizeof hint - strlen(hint), " ^C = avsluta"); hints(hint); } refresh(); int ch = ui_getch(); if (goto_active) { if (ch >= '0' && ch <= '9' && strlen(gotobuf) < 9) { size_t gl = strlen(gotobuf); gotobuf[gl] = (char)ch; gotobuf[gl + 1] = '\0'; int v = atoi(gotobuf); if (v >= 1 && v <= n) sel = v - 1; } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { size_t gl = strlen(gotobuf); if (gl) gotobuf[gl - 1] = '\0'; if (gotobuf[0]) { int v = atoi(gotobuf); if (v >= 1 && v <= n) sel = v - 1; } } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER || ch == 27 || ch == 'g' || ch == 'G') { goto_active = 0; gotobuf[0] = '\0'; } continue; } if (ch == KEY_UP && sel > 0) sel--; else if (ch == KEY_DOWN && sel < n - 1) sel++; else if (ch == KEY_NPAGE) sel = sel + view < n ? sel + view : n - 1; else if (ch == KEY_PPAGE) sel = sel - view > 0 ? sel - view : 0; else if (ch == KEY_HOME) sel = 0; else if (ch == KEY_END) sel = n - 1; else if (ch >= '1' && ch <= '9') { int idx = ch - '1'; if (idx < n) sel = idx; /* goto row, Enter opens */ } else if (ch == 'g' || ch == 'G') { goto_active = 1; gotobuf[0] = '\0'; } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) return sel; else if (allow_refresh && ch == KEY_F(5)) return -2; else if (allow_new && ch == KEY_CTRL_N) return -4; else if (allow_archive && (ch == 'd' || ch == 'D') && n > 0) return -6; else if (allow_toggle && ch == 1 && n > 0) return -7; else if (ch == 27 || ch == 'q') return g_quit ? -5 : -1; } } static int parse_kr(const char *s, int64_t *out) { if (!s || !*s) { *out = 0; return 0; } int64_t whole = 0, frac = 0; int fd = 0, seen_dot = 0; for (const char *p = s; *p; p++) { if (*p == ' ' || *p == '\t') continue; if (*p == '.' || *p == ',') { if (seen_dot) return -1; seen_dot = 1; continue; } if (*p < '0' || *p > '9') return -1; if (!seen_dot) { whole = whole * 10 + (*p - '0'); } else { if (fd >= 2) return -1; frac = frac * 10 + (*p - '0'); fd++; } } if (fd == 1) frac *= 10; *out = whole * 100 + frac; return 0; } static void kr_format(int64_t ore, char *buf, size_t n) { long long v = (long long)ore; int neg = v < 0; if (neg) v = -v; char digits[32]; snprintf(digits, sizeof digits, "%lld", v / 100); char out[28]; size_t o = 0; size_t len = strlen(digits); for (size_t i = 0; i < len && o + 2 < sizeof out; i++) { if (i > 0 && (len - i) % 3 == 0) out[o++] = ' '; out[o++] = digits[i]; } out[o] = '\0'; snprintf(buf, n, "%s%s,%02lld", neg ? "-" : "", out, v % 100); } /* 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]; 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. */ static int disp_width(const char *s) { int w = 0; for (const unsigned char *p = (const unsigned char *)s; *p; p++) if ((*p & 0xC0) != 0x80) w++; return w; } static void pad_field(char *buf, size_t cap, int width) { size_t slen = strlen(buf); int w = 0; size_t i = 0; while (i < slen && buf[i]) { unsigned char c = (unsigned char)buf[i]; size_t len = 1; if ((c & 0xE0) == 0xC0) len = 2; else if ((c & 0xF0) == 0xE0) len = 3; else if ((c & 0xF8) == 0xF0) len = 4; if (i + len > slen || w + 1 > width) break; w++; i += len; } buf[i] = '\0'; while (w < width && strlen(buf) + 1 < cap) { strcat(buf, " "); w++; } } /* ------------------------------------------------------------------ */ /* 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) { snprintf(g_status, sizeof g_status, "%s | %s - %s | %s | %s", a->org_name, a->fy_start, a->fy_end, a->role, a->username); } /* Create a new fiscal year. Returns the new id (>0) or 0. */ static int64_t fy_new_form(struct app *a) { 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); int field = 0; int field_fresh = 1; size_t field_pos = (size_t)-1; curs_set(1); for (;;) { frame("Nytt räkenskapsår"); int caret_y = -1, caret_x = -1; attron(A_BOLD); mvaddstr(4, 4, "Etikett"); mvaddstr(6, 4, "Startdatum"); mvaddstr(8, 4, "Slutdatum"); attroff(A_BOLD); { const char *vals[3] = { label, start, end }; int ys[3] = { 4, 6, 8 }; for (int k = 0; k < 3; k++) { char padded[64]; snprintf(padded, sizeof padded, "%-24s", vals[k]); if (field == k) { attron(A_REVERSE); caret_y = ys[k]; size_t slen = strlen(vals[k]); size_t cp = (field_fresh || field_pos == (size_t)-1 || field_pos > slen) ? slen : field_pos; caret_x = 16 + (int)disp_width_n(vals[k], cp); } mvaddstr(ys[k], 16, padded); if (field == k) attroff(A_REVERSE); } } hints("Tab = byta fält F5 = validera ^Enter = skapa Esc = avbryt"); if (caret_y >= 0) move(caret_y, caret_x); refresh(); int ch = ui_getch(); if (ch == 27) { curs_set(0); return 0; } if (ch == '\t') { field = (field + 1) % 3; field_fresh = 1; continue; } if (ch == KEY_BTAB) { field = (field + 2) % 3; field_fresh = 1; continue; } if (ch == KEY_F(5) || ch == KEY_CTRL_ENTER || ch == KEY_F(9)) { if (!label[0]) { message("Räkenskapsår", "Etiketten får inte vara tom."); continue; } if (!util_parse_iso_date(start) || !util_parse_iso_date(end)) { message("Räkenskapsår", "Start- och slutdatum måste vara ÅÅÅÅ-MM-DD."); continue; } if (strcmp(start, end) >= 0) { message("Räkenskapsår", "Startdatum måste ligga före slutdatum."); continue; } if (ch == KEY_F(5)) { 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); resp = client_rpc(&a->conn, "fiscal_year.open", a->session, a->org, args); free(args); if (resp && client_ok(resp)) { int64_t id = jint_val(resp, "result.id", 0); message("Räkenskapsår", "%s skapat.", label); free(resp); curs_set(0); return id; } show_error("Kunde inte skapa räkenskapsåret", resp); free(resp); continue; } char *buf = field == 0 ? label : (field == 1 ? start : end); size_t cap = field == 0 ? sizeof label : (field == 1 ? sizeof start : sizeof end); if (field == 0) field_edit(buf, cap, &field_pos, ch, &field_fresh); else date_field_edit(buf, cap, &field_pos, ch, &field_fresh); } } /* 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"); 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 = select_list("Räkenskapsår", lines, (int)n, start, 0, &cursor, 1, 0, 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 *ans = prompt(label, "n", 0); 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)) 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)) { message(title, "%s", resp && *resp ? resp : "Inget svar från servern (kör daemonen?)"); } else { 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) { 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 = select_list(title, items, (int)n + 1, 0, 1, NULL, 0, 0, 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); } } 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)); 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]; kr_format(debit, d, sizeof d); kr_format(credit, c, sizeof c); char acol[32], ncol[256]; snprintf(acol, sizeof acol, "%s", acc ? acc : ""); pad_field(acol, sizeof acol, 6); snprintf(ncol, sizeof ncol, "%s", name ? name : ""); 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, "\nUnderlag:\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); int nlines = 0; for (size_t i = 0; i < text.len && text.p[i]; i++) if (text.p[i] == '\n') nlines++; char **lines = xcalloc(nlines + 2, sizeof(char *)); int n = 0; char *copy = xstrdup((char *)text.p); for (char *p = copy;;) { char *nl = strchr(p, '\n'); if (nl) *nl = '\0'; lines[n++] = p; if (!nl) break; p = nl + 1; } int top = 0, view = LINES - 4; int want_refresh = 0, corrected = 0, stop = 0, posted_new = 0; int64_t posted_id = 0; while (!stop) { frame("Verifikat"); for (int i = 0; i < view && top + i < n; i++) mvaddnstr(2 + i, 2, lines[top + i], COLS - 4); hints("upp/ned F5 = uppdatera ^N = nytt verifikat c =" " rätta Esc/q = tillbaka"); refresh(); int ch = ui_getch(); if (ch == 'q' || ch == 27) { stop = 1; } else if (ch == KEY_F(5)) { want_refresh = 1; stop = 1; } else if (ch == KEY_CTRL_N) { int64_t nid = vouchers_new(a); if (nid > 0) { posted_id = nid; posted_new = 1; stop = 1; } } else if (ch == 'c') { char *reason = prompt("Beskriv rättelsen: ", "", 0); 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", 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); message("Rättat", "Ändringsverifikat %lld skapat", (long long)new_id); corrected = 1; } else { show_error("Kunde inte rätta", r); } free(r); free(cargs); free(ref); } stop = 1; } else if (ch == KEY_DOWN && top + view < n) { top++; } else if (ch == KEY_UP && top > 0) { top--; } else if (ch == KEY_NPAGE) { top += view; } else if (ch == KEY_PPAGE) { top -= view; } if (top + view > n) top = n > view ? n - view : 0; if (top < 0) top = 0; } free(copy); free(lines); 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]; }; static int row_has_content(const struct tui_vrow *r) { return r->account[0] || r->debit[0] || r->credit[0] || r->text[0]; } /* Keeps exactly one empty trailing row: appends one when the last row has content, and collapses an empty row that is followed by another empty row (the row the user just cleared). Adjusts *field so the selection stays on the same logical cell. */ static void normalize_rows(struct tui_vrow *rows, int *nrows, int *field) { while (*nrows < 64 && row_has_content(&rows[*nrows - 1])) { memset(&rows[*nrows], 0, sizeof rows[0]); (*nrows)++; } int i = 0; while (i < *nrows - 1) { if (!row_has_content(&rows[i]) && !row_has_content(&rows[i + 1])) { int fr = *field >= 3 ? (*field - 3) / 4 : -1; if (fr > i) *field -= 4; memmove(&rows[i], &rows[i + 1], (size_t)(*nrows - i - 1) * sizeof rows[0]); memset(&rows[*nrows - 1], 0, sizeof rows[0]); (*nrows)--; continue; } i++; } if (*nrows < 1) { *nrows = 1; memset(&rows[0], 0, sizeof rows[0]); if (*field >= 3) *field = 3; } } 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; } static int64_t vouchers_new(struct app *a) { static struct tui_vrow rows[64]; char date[16], desc[256], series[16], client_ref[64]; int64_t att_ids[32]; char att_names[32][80]; int natt = 0; curs_set(1); memset(rows, 0, sizeof rows); snprintf(date, sizeof date, "%s", a->fy_start[0] ? a->fy_start : "2026-01-01"); desc[0] = '\0'; snprintf(series, sizeof series, "%s", a->default_series); snprintf(client_ref, sizeof client_ref, "%s", util_random_id("tui-", 8)); int nrows = 1; int field = 0; int field_fresh = 1; size_t field_pos = (size_t)-1; int nfields = 3 + 4 * nrows; const int x_account = 2; const int name_w = 24; const int x_name = 9; const int x_debit = 34; const int x_credit = 46; const int x_text = 58; int text_w = COLS - x_text - 2; if (text_w < 8) text_w = 8; for (;;) { frame("Nytt verifikat"); int caret_y = -1, caret_x = -1; attron(A_BOLD); mvaddstr(2, 2, "Datum"); mvaddstr(2, 22, "Serie"); mvaddstr(4, 2, "Text"); attroff(A_BOLD); { /* field order is 0 = date, 1 = series, 2 = text */ const char *hvals[3] = { date, series, desc }; int hx[3] = { 9, 29, 9 }; int hy[3] = { 2, 2, 4 }; int hw[3] = { 10, 8, COLS - 12 }; for (int k = 0; k < 3; k++) { int w = hw[k] > 1 ? hw[k] : 1; char padded[512]; snprintf(padded, sizeof padded, "%s", hvals[k]); pad_field(padded, sizeof padded, w); if (field == k) { attron(A_REVERSE); caret_y = hy[k]; size_t slen = strlen(hvals[k]); size_t cp = (field_fresh || field_pos == (size_t)-1 || field_pos > slen) ? slen : field_pos; int cw = (int)disp_width_n(hvals[k], cp); caret_x = hx[k] + (cw > w ? w : cw); } mvaddstr(hy[k], hx[k], padded); if (field == k) attroff(A_REVERSE); } } attron(A_BOLD); mvaddstr(6, x_account, "Konto"); mvaddstr(6, x_name, "Namn"); mvaddstr(6, x_debit, "Debet"); mvaddstr(6, x_credit, "Kredit"); mvaddstr(6, x_text, "Text"); attroff(A_BOLD); int64_t sum_d = 0, sum_c = 0; for (int i = 0; i < nrows; i++) { int y = 7 + i; int64_t v; if (parse_kr(rows[i].debit, &v) == 0) sum_d += v; if (parse_kr(rows[i].credit, &v) == 0) sum_c += v; int sel_ci = -1; if (field >= 3) { int ri = (field - 3) / 4, ci = (field - 3) % 4; if (ri == i) sel_ci = ci; } const char *vals[4] = { rows[i].account, rows[i].debit, rows[i].credit, rows[i].text }; int xs[4] = { x_account, x_debit, x_credit, x_text }; int ws[4] = { 6, 11, 11, text_w }; for (int k = 0; k < 4; k++) { int w = ws[k] > 1 ? ws[k] : 1; char padded[512]; snprintf(padded, sizeof padded, "%s", vals[k]); pad_field(padded, sizeof padded, w); if (k == sel_ci) { attron(A_REVERSE); caret_y = y; size_t slen = strlen(vals[k]); size_t cp = (field_fresh || field_pos == (size_t)-1 || field_pos > slen) ? slen : field_pos; int cw = (int)disp_width_n(vals[k], cp); caret_x = xs[k] + (cw > w ? w : cw); } mvaddstr(y, xs[k], padded); if (k == sel_ci) attroff(A_REVERSE); } { char nbuf[512]; const char *nm = acct_name(a, rows[i].account); snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : ""); pad_field(nbuf, sizeof nbuf, name_w); attron(A_DIM); mvaddstr(y, x_name, nbuf); attroff(A_DIM); } } char d1[32], c1[32], diff[32]; kr_format(sum_d, d1, sizeof d1); kr_format(sum_c, c1, sizeof c1); kr_format(sum_d - sum_c, diff, sizeof diff); int balanced = sum_d == sum_c; move(7 + nrows, 2); clrtoeol(); if (natt > 0) { char aline[4096] = "Bilagor: "; for (int i = 0; i < natt; i++) { size_t used = strlen(aline); if (used + 1 >= sizeof aline) break; snprintf(aline + used, sizeof aline - used, "%.79s%s", att_names[i], i + 1 < natt ? ", " : ""); } attron(A_DIM); mvaddnstr(7 + nrows, 2, aline, COLS - 4); attroff(A_DIM); } attron(A_BOLD); mvprintw(7 + nrows + 1, 2, "Summa debet %12s kredit %12s ", d1, c1); mvprintw(7 + nrows + 1, 58, "%s", balanced ? "I BALANS" : "DIFF"); attroff(A_BOLD); if (!balanced) mvprintw(7 + nrows + 2, 2, "Differens: %s", diff); hints("F4 = mall ^F = bifoga fil Tab = byta fält F5 = validera" " ^X = rensa rad ^Enter = bokför Esc = avbryt"); if (caret_y >= 0) move(caret_y, caret_x); refresh(); int ch = ui_getch(); if (ch == 27) { curs_set(0); return 0; } if (ch == '\t') { field = (field + 1) % nfields; field_fresh = 1; continue; } if (ch == KEY_BTAB) { field = (field + nfields - 1) % nfields; field_fresh = 1; continue; } if (ch == KEY_UP) { if (field >= 3) { int ci = (field - 3) % 4; int ri = (field - 3) / 4; if (ri > 0) { field = 3 + (ri - 1) * 4 + ci; field_fresh = 1; } } continue; } if (ch == KEY_DOWN) { if (field >= 3) { int ci = (field - 3) % 4; int ri = (field - 3) / 4; if (ri + 1 < nrows) { field = 3 + (ri + 1) * 4 + ci; field_fresh = 1; } } continue; } if (ch == KEY_F(4)) { char tname[128] = ""; 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); continue; } size_t ln = jarr_size(lresp, "result.items"); if (ln == 0) { message("Mallar", "Inga mallar ännu. Skapa en under Mallar först."); free(lresp); continue; } 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 : ""); 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 = select_list("Välj mall", lines, (int)ln, 0, 1, NULL, 0, 0, 0); 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) continue; { char tbuf[128]; snprintf(tbuf, sizeof tbuf, "%s", tname); 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", tbuf); 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); } else { char *xs = prompt("Belopp (x, kr): ", "", 0); double xv = 0; if (xs && *xs && !parse_x_double(xs, &xv)) { message("Fel", "Ogiltigt belopp."); free(resp); continue; } 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) { message("Mall", "%s", ferr[0] ? ferr : "kunde inte lösa mallen"); } else { memset(rows, 0, sizeof rows); int use = (int)(on > 64 ? 64 : on); for (int i = 0; i < use; i++) { snprintf(rows[i].account, sizeof rows[i].account, "%s", out[i].account); char tmp[32]; if (out[i].debit_ore) { kr_format(out[i].debit_ore, tmp, sizeof tmp); snprintf(rows[i].debit, sizeof rows[i].debit, "%s", tmp); } else { kr_format(out[i].credit_ore, tmp, sizeof tmp); snprintf(rows[i].credit, sizeof rows[i].credit, "%s", tmp); } if (out[i].description[0]) snprintf(rows[i].text, sizeof rows[i].text, "%s", out[i].description); } nrows = use > 0 ? use : 1; char *tds = jstr_dup(resp, "result.description"); char *tser = jstr_dup(resp, "result.series"); if (!desc[0] && tds && *tds) replace_x_into(tds, xv, desc, sizeof desc); if (tser && *tser) snprintf(series, sizeof series, "%s", tser); free(tds); free(tser); field = 3; field_fresh = 1; normalize_rows(rows, &nrows, &field); nfields = 3 + 4 * nrows; } 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); } } continue; } if (ch == 6) { /* ^F: attach a file */ 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) { message("Bilaga", "Filen är %.1f MB, max %ld MB.", (double)sb.st_size / (1024 * 1024), a->max_attachment_bytes / (1024 * 1024)); free(path); continue; } char *b64 = read_file_b64(path); if (!b64) { message("Bilaga", "Kunde inte läsa %s", path); } else if (natt >= 32) { message("Bilaga", "Max 32 bilagor per verifikat."); } 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); 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)) { att_ids[natt] = jint_val(r, "result.id", 0); snprintf(att_names[natt], sizeof att_names[natt], "%s", base); natt++; } else { show_error("Kunde inte bifoga filen", r); } free(r); } free(b64); free(path); } continue; } if (ch == KEY_CTRL_X) { if (field >= 3) { int ri = (field - 3) / 4; if (ri < nrows) { memset(&rows[ri], 0, sizeof rows[ri]); normalize_rows(rows, &nrows, &field); nfields = 3 + 4 * nrows; field_fresh = 1; } } continue; } /* build args for validate/post */ int do_post = (ch == KEY_CTRL_ENTER || ch == KEY_F(9)); int do_check = (ch == KEY_F(5)); if (do_post || do_check) { 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", date); yyjson_mut_obj_add_strcpy(d, o, "description", desc); yyjson_mut_obj_add_strcpy(d, o, "series", series); yyjson_mut_obj_add_strcpy(d, o, "client_ref", client_ref); yyjson_mut_val *arr = yyjson_mut_arr(d); for (int i = 0; i < nrows; i++) { if (!rows[i].account[0]) continue; int64_t dv = 0, cv = 0; if (parse_kr(rows[i].debit, &dv) != 0 || parse_kr(rows[i].credit, &cv) != 0) { message("Fel", "Ogiltigt belopp på rad %d", i + 1); yyjson_mut_doc_free(d); goto next_key; } yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); yyjson_mut_obj_add_strcpy(d, ro, "account", rows[i].account); yyjson_mut_obj_add_int(d, ro, "debit_ore", dv); yyjson_mut_obj_add_int(d, ro, "credit_ore", cv); if (rows[i].text[0]) yyjson_mut_obj_add_strcpy(d, ro, "description", rows[i].text); } yyjson_mut_obj_add_val(d, o, "rows", arr); if (natt > 0) { yyjson_mut_val *aa = yyjson_mut_arr(d); for (int i = 0; i < natt; i++) yyjson_mut_arr_add_int(d, aa, 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 (do_check) { 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); message("Validering OK", "Nummer %lld skulle tilldelas.", (long long)num); } else { show_error("Validering misslyckades", r); } free(r); } else { 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); 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); curs_set(0); return id; } show_error("Kunde inte bokföra", r); free(r); } free(args); next_key: continue; } /* text input into current field */ char *buf = NULL; size_t cap = 0; if (field == 0) { buf = date; cap = sizeof date; } else if (field == 1) { buf = series; cap = sizeof series; } else if (field == 2) { buf = desc; cap = sizeof desc; } else { int ri = (field - 3) / 4, ci = (field - 3) % 4; if (ri < nrows) { switch (ci) { case 0: buf = rows[ri].account; cap = sizeof rows[ri].account; break; case 1: buf = rows[ri].debit; cap = sizeof rows[ri].debit; break; case 2: buf = rows[ri].credit; cap = sizeof rows[ri].credit; break; default: buf = rows[ri].text; cap = sizeof rows[ri].text; break; } } } if (!buf) continue; /* first keystroke in a freshly focused field replaces its content (so a prefilled date can be typed over directly) */ if (field == 0) date_field_edit(buf, cap, &field_pos, ch, &field_fresh); else field_edit(buf, cap, &field_pos, ch, &field_fresh); normalize_rows(rows, &nrows, &field); nfields = 3 + 4 * nrows; } } 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(line, sizeof line, "%s %-*s %s", date ? date : "", idw, idstr[i], 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 = select_list("Verifikat", items, (int)total, start, 1, &cur, 1, 0, 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, "%-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 : ""); pad_field(ncol, sizeof ncol, name_w); snprintf(tibuf, sizeof tibuf, "%s", type_sv(type)); 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 = text_view("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 - 2) width = (int)sizeof line - 2; if (width < 1) return; memset(line, '-', (size_t)width); line[width] = '\n'; buf_append(b, line, (size_t)width + 1); } /* 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, "%s\n%s\n\n", title, a->org_name); buf_line(t, "Räkenskapsår: %s - %s\n", start, end); buf_line(t, "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 = 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 å/ä/ö). */ static void pad_hdr(char *buf, size_t n, int width, const char *text) { int w = disp_width(text); int pad = width > w ? width - w : 0; snprintf(buf, n, "%*s%s", pad, "", text); } static void pad_label(char *buf, size_t n, const char *text, int width) { snprintf(buf, n, "%s", text); pad_field(buf, n, width); } static void amt_col(char *buf, size_t n, int width, int64_t ore) { char a[48]; kr_format(ore, a, sizeof a); snprintf(buf, n, "%*s", width, a); } /* ---------------- 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) { buf_line(t, " %5s %-48s %14s %14s %14s %14s\n", "", "", "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]; pad_label(nm, sizeof nm, vstr(row, "name"), 48); amt_col(a1, sizeof a1, 14, ib); amt_col(a2, sizeof a2, 14, ib); amt_col(a3, sizeof a3, 14, per); 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]; pad_label(lbl, sizeof lbl, label, 48); amt_col(a1, sizeof a1, 14, s->ib); amt_col(a2, sizeof a2, 14, s->ib); amt_col(a3, sizeof a3, 14, s->per); amt_col(a4, sizeof a4, 14, s->ub); buf_line(t, " %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, "%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, "TILLGÅNGAR\n"); bs_col_head(t); buf_line(t, "\nAnläggningstillgångar\n"); bs_rows(t, res, 1000, 1399, &anl); bs_sum_line(t, "Summa anläggningstillgångar", &anl); buf_line(t, "\nOmsä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, "\nEGET KAPITAL OCH SKULDER\n"); bs_col_head(t); buf_line(t, "\nEget 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]; pad_label(lbl, sizeof lbl, "Beräknat resultat", 48); amt_col(zero, sizeof zero, 14, 0); amt_col(a1, sizeof a1, 14, result); buf_line(t, "\n"); buf_rule(t, BS_RULE_W); buf_line(t, " %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"); pad_label(lbl, sizeof lbl, label, 48); amt_col(a1, sizeof a1, 15, cur); amt_col(a2, sizeof a2, 15, prev); buf_line(t, " %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]; pad_label(nm, sizeof nm, vstr(row, "name"), 48); amt_col(a1, sizeof a1, 15, c); 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 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, "%s\n", gr->section); } else { char lbl[80], h1[32], h2[32], h3[32]; pad_label(lbl, sizeof lbl, gr->section, 48); pad_hdr(h1, sizeof h1, 15, "Period"); pad_hdr(h2, sizeof h2, 15, "Ackumulerat"); pad_hdr(h3, sizeof h3, 15, "Föreg. per."); buf_line(t, " %5s %s %s %s %s\n", "", lbl, h1, h2, h3); buf_rule(t, IS_RULE_W); buf_line(t, "\n"); buf_line(t, "%s\n", gr->title); } sect = gr->section; } else { buf_line(t, "\n"); buf_line(t, "%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); /* finansiella poster */ int64_t fin = 0, fin_pre = 0, booked = 0, booked_pre = 0; int has_fin = 0, has_booked = 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, 8000, 8899)) has_fin = 1; else if (acct_in(acc, 8900, 8999)) has_booked = 1; } } if (has_fin) { buf_line(t, "\nFinansiella poster\n"); 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, 8000, 8899)) 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]; pad_label(nm, sizeof nm, vstr(row, "name"), 48); amt_col(a1, sizeof a1, 15, c); amt_col(a2, sizeof a2, 15, p); buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2); fin += c; fin_pre += p; } } 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); is_summary(t, "Resultat före skatt", after, after_pre, 1); buf_line(t, "\n"); buf_rule(t, IS_RULE_W); is_summary(t, "Årets resultat", after, after_pre, 0); if (has_booked) { buf_line(t, "Bokfört resultat\n"); 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, 8900, 8999)) continue; int64_t c = -vint(row, "amount_ore"); int64_t p = is_prev_lookup(prev, acc, 1); char nm[128], a1[48], a2[48]; pad_label(nm, sizeof nm, vstr(row, "name"), 48); amt_col(a1, sizeof a1, 15, c); amt_col(a2, sizeof a2, 15, p); buf_line(t, " %5s %s %s %s %s\n", acc, nm, a1, a1, a2); booked += c; booked_pre += p; } } 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", after + booked, after_pre + booked_pre, 0); } else { buf_line(t, "\n"); buf_rule(t, IS_RULE_W); is_summary(t, "Ej bokfört resultat", after, after_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]; pad_label(nmh, sizeof nmh, "Benämning", 48); pad_hdr(h1, sizeof h1, 14, "Ingående"); pad_hdr(h2, sizeof h2, 14, "Debet"); pad_hdr(h3, sizeof h3, 14, "Kredit"); pad_hdr(h4, sizeof h4, 14, "Utgående"); buf_line(t, " %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]; pad_label(nm, sizeof nm, vstr(row, "name"), 48); amt_col(a1, sizeof a1, 14, vint(row, "ib_ore")); amt_col(a2, sizeof a2, 14, vint(row, "debit_ore")); amt_col(a3, sizeof a3, 14, vint(row, "credit_ore")); 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]; pad_label(lbl, sizeof lbl, "Summa", 48); amt_col(a1, sizeof a1, 14, vint(tot, "ib_ore")); amt_col(a2, sizeof a2, 14, vint(tot, "debit_ore")); amt_col(a3, sizeof a3, 14, vint(tot, "credit_ore")); amt_col(a4, sizeof a4, 14, vint(tot, "ub_ore")); buf_line(t, "\n"); buf_rule(t, BS_RULE_W); buf_line(t, " %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, "Momsrapport\n\n%s\n\n", a->org_name); buf_line(t, "Räkenskapsår: %s - %s\n", a->fy_start, a->fy_end); buf_line(t, "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%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, "\nG. %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, "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, "Senaste ver.: %s%lld\n\n", vstr(lv, "series"), (long long)vint(lv, "number")); buf_line(t, "%-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%s %s\n\n", vstr(ao, "account"), vstr(ao, "name")); char a1[48]; amt_col(a1, sizeof a1, 16, ib); char lbl[96]; pad_label(lbl, sizeof lbl, "Ingående balans:", 59); buf_line(t, " %s %14s %14s %16s\n", lbl, "", "", a1); pad_label(lbl, sizeof lbl, "Ingående saldo:", 59); buf_line(t, " %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"); pad_label(nm, sizeof nm, txt, 40); amt_col(dd, sizeof dd, 14, vint(ro, "debit_ore")); 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 : ""); 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]; amt_col(s1, sizeof s1, 14, d); amt_col(s2, sizeof s2, 14, c); amt_col(s3, sizeof s3, 16, d - c); pad_label(lbl, sizeof lbl, "Omslutning:", 59); buf_line(t, " %s %14s %14s %16s\n", lbl, s1, s2, s3); amt_col(s3, sizeof s3, 16, vint(ao, "ub_ore")); pad_label(lbl, sizeof lbl, "Utgående saldo:", 59); buf_line(t, " %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, "Senaste ver.: %s%lld\n\n", vstr(lv, "series"), (long long)vint(lv, "number")); char nmh[64]; pad_label(nmh, sizeof nmh, "Benämning", 48); buf_line(t, "%-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, "%-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]; pad_label(nm, sizeof nm, vstr(ro, "name"), 48); amt_col(dd, sizeof dd, 14, vint(ro, "debit_ore")); 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]; amt_col(s1, sizeof s1, 14, vint(tot, "debit_ore")); amt_col(s2, sizeof s2, 14, vint(tot, "credit_ore")); buf_line(t, "\n%-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 *path = prompt("Spara eSKD-fil: ", def, 0); 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) { message("eSKD", "Kunde inte avkoda filen från servern."); } else { FILE *f = fopen(full, "wb"); if (!f || fwrite(data, 1, n, f) != n) { message("eSKD", "Kunde inte skriva %s: %s", full, strerror(errno)); if (f) fclose(f); } else { fclose(f); message("eSKD", "Sparat: %s\n(%zu byte, ISO-8859-1)", full, n); } } free(data); free(b64); free(resp); } 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 = 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 *from = date_prompt("Från (YYYY-MM-DD): ", a->fy_start); if (!from) continue; char f[16]; snprintf(f, sizeof f, "%s", from); char *to = date_prompt("Till (YYYY-MM-DD): ", a->fy_end); 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 = text_view("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; } } } static void inbox_screen(struct app *a) { int top = 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 *)); for (size_t i = 0; i < n; i++) { char path[64], line[512]; 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 : ""); pad_field(fnbuf, sizeof fnbuf, 40); snprintf(line, sizeof line, "%s %8lld byte", fnbuf, (long long)size); items[i] = xstrdup(line); free(fn); } int view = LINES - 4; if (top > (int)n - view) top = (int)n - view; if (top < 0) top = 0; frame("Underlag (inkorg)"); for (size_t i = (size_t)top; i < n && (int)(i - (size_t)top) < view; i++) mvaddnstr(2 + (int)(i - (size_t)top), 2, items[i], COLS - 4); if (n == 0) mvaddstr(2, 2, "Inga obokförda underlag."); hints("a/^N = lägg till fil PgUp/PgDn, Home/End rullar F5 =" " uppdatera Esc/q = tillbaka"); refresh(); int ch = ui_getch(); for (size_t i = 0; i < n; i++) free(items[i]); free(items); free(resp); if (ch == 27 || ch == 'q') return; if (ch == KEY_DOWN && top + view < (int)n) top++; else if (ch == KEY_UP && top > 0) top--; else if (ch == KEY_NPAGE) top += view; else if (ch == KEY_PPAGE) top -= view; else if (ch == KEY_HOME) top = 0; else if (ch == KEY_END) top = (int)n > view ? (int)n - view : 0; else if (ch == 'a' || ch == KEY_CTRL_N) { char *path = file_browser(a, a->attachment_dir); if (!path) continue; struct stat sb; if (stat(path, &sb) == 0 && (long)sb.st_size > a->max_attachment_bytes) { message("Bilaga", "Filen är %.1f MB, max %ld MB.", (double)sb.st_size / (1024 * 1024), a->max_attachment_bytes / (1024 * 1024)); free(path); continue; } char *b64 = read_file_b64(path); if (!b64) { message("Fel", "Kunde inte läsa %s", path); continue; } 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)) message("Underlag", "Sparat."); else show_error("Kunde inte spara", r); free(r); } } } static void audit_screen(struct app *a) { static const char *const audit_items[] = { "Verifiera hashkedjan", "Senaste händelser" }; static int last_sel = 0; for (;;) { if (g_quit) return; int sel = menu("Revision", audit_items, 2, 0, &last_sel); if (sel < 0) return; if (sel == 0) { char *resp = client_rpc(&a->conn, "audit.verify", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Revision", resp); free(resp); continue; } int64_t checked = jint_val(resp, "result.checked", 0); int is_ok = jbool_val(resp, "result.ok", 0); message("Hashkedja", "%s\n%d händelser kontrollerade.", is_ok ? "Kedjan är intakt." : "KEDJAN ÄR BRUTEN!", (int)checked); free(resp); } 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 = text_view("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]; }; static int trow_has_content(const struct tui_trow *r) { return r->account[0] || r->formula[0] || r->text[0]; } static void trow_normalize(struct tui_trow *rows, int *nrows, int *field) { while (*nrows < 64 && trow_has_content(&rows[*nrows - 1])) { memset(&rows[*nrows], 0, sizeof rows[0]); (*nrows)++; } int i = 0; while (i < *nrows - 1) { if (!trow_has_content(&rows[i]) && !trow_has_content(&rows[i + 1])) { int fr = *field >= 3 ? (*field - 3) / 3 : -1; if (fr > i) *field -= 3; memmove(&rows[i], &rows[i + 1], (size_t)(*nrows - i - 1) * sizeof rows[0]); memset(&rows[*nrows - 1], 0, sizeof rows[0]); (*nrows)--; continue; } i++; } if (*nrows < 1) { *nrows = 1; memset(&rows[0], 0, sizeof rows[0]); if (*field >= 3) *field = 3; } } /* Template editor: a form, not a wizard. 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) { static struct tui_trow rows[64]; char tname[128] = "", tdesc[256] = ""; char series[16]; snprintf(series, sizeof series, "%s", a->default_series); int64_t tpl_id = 0; curs_set(1); memset(rows, 0, sizeof rows); int nrows = 1; 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); curs_set(0); return 0; } tpl_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(tname, sizeof tname, "%s", nm); if (ser && *ser) snprintf(series, sizeof series, "%s", ser); if (ds) snprintf(tdesc, sizeof tdesc, "%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(rows[i].account, sizeof rows[i].account, "%s", acc ? acc : ""); snprintf(rows[i].formula, sizeof rows[i].formula, "%s", form ? form : ""); snprintf(rows[i].text, sizeof rows[i].text, "%s", rd ? rd : ""); free(acc); free(form); free(rd); } nrows = n > 0 ? (int)n : 1; free(resp); } int field = 0; int field_fresh = 1; size_t field_pos = (size_t)-1; int nfields = 3 + 3 * nrows; const int x_account = 2; const int name_w = 24; const int x_name = 9; const int x_formula = 34; const int form_w = 20; const int x_text = 55; int text_w = COLS - x_text - 2; if (text_w < 8) text_w = 8; for (;;) { frame(load_name ? "Redigera mall" : "Ny mall"); int caret_y = -1, caret_x = -1; attron(A_BOLD); mvaddstr(2, 2, "Namn"); mvaddstr(2, 46, "Serie"); mvaddstr(4, 2, "Text"); attroff(A_BOLD); { const char *hvals[3] = { tname, series, tdesc }; int hx[3] = { 9, 53, 9 }; int hy[3] = { 2, 2, 4 }; int hw[3] = { 34, 8, COLS - 12 }; for (int k = 0; k < 3; k++) { int w = hw[k] > 1 ? hw[k] : 1; char padded[512]; snprintf(padded, sizeof padded, "%s", hvals[k]); pad_field(padded, sizeof padded, w); if (field == k) { attron(A_REVERSE); caret_y = hy[k]; size_t slen = strlen(hvals[k]); size_t cp = (field_fresh || field_pos == (size_t)-1 || field_pos > slen) ? slen : field_pos; int cw = (int)disp_width_n(hvals[k], cp); caret_x = hx[k] + (cw > w ? w : cw); } mvaddstr(hy[k], hx[k], padded); if (field == k) attroff(A_REVERSE); } } attron(A_BOLD); mvaddstr(6, x_account, "Konto"); mvaddstr(6, x_name, "Namn"); mvaddstr(6, x_formula, "Formel"); mvaddstr(6, x_text, "Radtext"); attroff(A_BOLD); for (int i = 0; i < nrows; i++) { int y = 7 + i; int sel_ci = -1; if (field >= 3) { int ri = (field - 3) / 3, ci = (field - 3) % 3; if (ri == i) sel_ci = ci; } const char *vals[3] = { rows[i].account, rows[i].formula, rows[i].text }; int xs[3] = { x_account, x_formula, x_text }; int ws[3] = { 6, form_w, text_w }; for (int k = 0; k < 3; k++) { int w = ws[k] > 1 ? ws[k] : 1; char padded[512]; snprintf(padded, sizeof padded, "%s", vals[k]); pad_field(padded, sizeof padded, w); if (k == sel_ci) { attron(A_REVERSE); caret_y = y; size_t slen = strlen(vals[k]); size_t cp = (field_fresh || field_pos == (size_t)-1 || field_pos > slen) ? slen : field_pos; int cw = (int)disp_width_n(vals[k], cp); caret_x = xs[k] + (cw > w ? w : cw); } mvaddstr(y, xs[k], padded); if (k == sel_ci) attroff(A_REVERSE); } { char nbuf[512]; const char *nm = acct_name(a, rows[i].account); snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : ""); pad_field(nbuf, sizeof nbuf, name_w); attron(A_DIM); mvaddstr(y, x_name, nbuf); attroff(A_DIM); } } move(7 + nrows, 2); clrtoeol(); attron(A_DIM); mvaddnstr(7 + nrows, 2, "x = belopp i kronor. Positivt blir debet, negativt kredit.", COLS - 4); attroff(A_DIM); hints("Tab = byta fält F5 = validera ^X = rensa rad ^Enter = spara Esc = avbryt"); if (caret_y >= 0) move(caret_y, caret_x); refresh(); int ch = ui_getch(); if (ch == 27) { curs_set(0); return 0; } if (ch == ' ') { field = (field + 1) % nfields; field_fresh = 1; continue; } if (ch == KEY_BTAB) { field = (field + nfields - 1) % nfields; field_fresh = 1; continue; } if (ch == KEY_UP) { if (field >= 3) { int ci = (field - 3) % 3; int ri = (field - 3) / 3; if (ri > 0) { field = 3 + (ri - 1) * 3 + ci; field_fresh = 1; } } continue; } if (ch == KEY_DOWN) { if (field >= 3) { int ci = (field - 3) % 3; int ri = (field - 3) / 3; if (ri + 1 < nrows) { field = 3 + (ri + 1) * 3 + ci; field_fresh = 1; } } continue; } if (ch == KEY_CTRL_X) { if (field >= 3) { int ri = (field - 3) / 3; if (ri < nrows) { memset(&rows[ri], 0, sizeof rows[ri]); trow_normalize(rows, &nrows, &field); nfields = 3 + 3 * nrows; field_fresh = 1; } } continue; } /* validation and save */ if (ch == KEY_F(5) || ch == KEY_CTRL_ENTER || ch == KEY_F(9)) { const char *problem = NULL; char msg[256] = ""; if (!tname[0]) { problem = "Namn saknas."; } int used = 0; for (int i = 0; i < nrows && !problem; i++) { if (!rows[i].account[0]) continue; if (!acct_exists(a, rows[i].account)) { snprintf(msg, sizeof msg, "Konto %.15s finns inte (rad %d).", rows[i].account, i + 1); problem = msg; break; } if (!rows[i].formula[0] || !formula_valid(rows[i].formula)) { snprintf(msg, sizeof msg, "Ogiltig formel på rad %d: '%.60s'.", i + 1, rows[i].formula); problem = msg; break; } used++; } if (!problem && used == 0) problem = "Mallen behöver minst en rad."; if (problem) { message("Mall", "%s", problem); 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); if (tpl_id) yyjson_mut_obj_add_int(d, o, "id", tpl_id); yyjson_mut_obj_add_strcpy(d, o, "name", tname); yyjson_mut_obj_add_strcpy(d, o, "series", series); yyjson_mut_obj_add_strcpy(d, o, "description", tdesc); yyjson_mut_val *arr = yyjson_mut_arr(d); for (int i = 0; i < nrows; i++) { if (!rows[i].account[0]) continue; yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); yyjson_mut_obj_add_strcpy(d, ro, "account", rows[i].account); yyjson_mut_obj_add_strcpy(d, ro, "formula", rows[i].formula); if (rows[i].text[0]) yyjson_mut_obj_add_strcpy(d, ro, "description", rows[i].text); } yyjson_mut_obj_add_val(d, o, "rows", arr); if (ch == KEY_F(5)) yyjson_mut_obj_add_bool(d, o, "dry_run", true); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); const char *cmd = tpl_id ? "template.update" : "template.create"; char *resp = client_rpc(&a->conn, cmd, a->session, a->org, args); free(args); if (resp && client_ok(resp)) { if (ch == KEY_F(5)) { message("Validering OK", "Mallen är korrekt (%d rader).", used); } else { message("Mall", "Mallen '%s' sparad.", tname); free(resp); curs_set(0); return 1; } } else { show_error("Kunde inte spara mallen", resp); } free(resp); continue; } char *buf = NULL; size_t cap = 0; if (field == 0) { buf = tname; cap = sizeof tname; } else if (field == 1) { buf = series; cap = sizeof series; } else if (field == 2) { buf = tdesc; cap = sizeof tdesc; } else { int ri = (field - 3) / 3, ci = (field - 3) % 3; if (ri < nrows) { switch (ci) { case 0: buf = rows[ri].account; cap = sizeof rows[ri].account; break; case 1: buf = rows[ri].formula; cap = sizeof rows[ri].formula; break; default: buf = rows[ri].text; cap = sizeof rows[ri].text; break; } } } if (!buf) continue; field_edit(buf, cap, &field_pos, ch, &field_fresh); trow_normalize(rows, &nrows, &field); nfields = 3 + 3 * nrows; } } static void template_archive(struct app *a, const char *name) { char label[256]; snprintf(label, sizeof label, "Arkivera '%s'? (j/n): ", name); char *ans = prompt(label, "n", 0); 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)) 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]; }; static int ibrow_has_content(const struct tui_ibrow *r) { return r->account[0] || r->amount[0] || r->text[0]; } static void ibrow_normalize(struct tui_ibrow *rows, int *nrows, int *field) { while (*nrows < 64 && ibrow_has_content(&rows[*nrows - 1])) { memset(&rows[*nrows], 0, sizeof rows[0]); (*nrows)++; } int i = 0; while (i < *nrows - 1) { if (!ibrow_has_content(&rows[i]) && !ibrow_has_content(&rows[i + 1])) { int fr = *field / 3; if (fr > i) *field -= 3; memmove(&rows[i], &rows[i + 1], (size_t)(*nrows - i - 1) * sizeof rows[0]); memset(&rows[*nrows - 1], 0, sizeof rows[0]); (*nrows)--; continue; } i++; } if (*nrows < 1) { *nrows = 1; memset(&rows[0], 0, sizeof rows[0]); if (*field >= 3) *field = 0; } } /* 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; } static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold) { static struct tui_ibrow rows[64]; if (nold > 63) { message("Ingående balans", "För många konton (%d) för att redigera här.", nold); return 0; } memset(rows, 0, sizeof rows); for (int i = 0; i < nold && i < 63; i++) { snprintf(rows[i].account, sizeof rows[i].account, "%s", old_acc[i]); kr_format(old_amt[i], rows[i].amount, sizeof rows[i].amount); } int nrows = nold > 0 ? nold : 1; int field = 0; int field_fresh = 1; size_t field_pos = (size_t)-1; int nfields = 3 * nrows; const int x_account = 2; const int name_w = 24; const int x_name = 9; const int x_amount = 34; const int amount_w = 14; const int x_text = 49; int text_w = COLS - x_text - 2; if (text_w < 6) text_w = 6; curs_set(1); for (;;) { frame("Ingående balans"); int caret_y = -1, caret_x = -1; attron(A_BOLD); mvaddstr(2, 2, "Positiva belopp = debet, negativa = kredit. Summan ska bli" " noll."); attroff(A_BOLD); attron(A_BOLD); mvaddstr(4, x_account, "Konto"); mvaddstr(4, x_name, "Namn"); mvaddstr(4, x_amount, "Belopp"); mvaddstr(4, x_text, "Text"); attroff(A_BOLD); for (int i = 0; i < nrows; i++) { int y = 5 + i; int sel_ci = -1; if (field >= 0 && field < nfields) { int ri = field / 3, ci = field % 3; if (ri == i) sel_ci = ci; } const char *vals[3] = { rows[i].account, rows[i].amount, rows[i].text }; int xs[3] = { x_account, x_amount, x_text }; int ws[3] = { 6, amount_w, text_w }; for (int k = 0; k < 3; k++) { int w = ws[k] > 1 ? ws[k] : 1; char padded[512]; snprintf(padded, sizeof padded, "%s", vals[k]); pad_field(padded, sizeof padded, w); if (k == sel_ci) { attron(A_REVERSE); caret_y = y; size_t slen = strlen(vals[k]); size_t cp = (field_fresh || field_pos == (size_t)-1 || field_pos > slen) ? slen : field_pos; int cw = (int)disp_width_n(vals[k], cp); caret_x = xs[k] + (cw > w ? w : cw); } mvaddstr(y, xs[k], padded); if (k == sel_ci) attroff(A_REVERSE); } { char nbuf[512]; const char *nm = acct_name(a, rows[i].account); snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : ""); pad_field(nbuf, sizeof nbuf, name_w); attron(A_DIM); mvaddstr(y, x_name, nbuf); attroff(A_DIM); } } hints("Tab = byta fält F5 = validera ^X = rensa rad ^Enter = spara Esc = avbryt"); if (caret_y >= 0) move(caret_y, caret_x); refresh(); int ch = ui_getch(); if (ch == 27) { curs_set(0); return 0; } if (ch == '\t') { field = (field + 1) % nfields; field_fresh = 1; continue; } if (ch == KEY_BTAB) { field = (field + nfields - 1) % nfields; field_fresh = 1; continue; } if (ch == KEY_UP) { int ci = field % 3, ri = field / 3; if (ri > 0) { field = (ri - 1) * 3 + ci; field_fresh = 1; } continue; } if (ch == KEY_DOWN) { int ci = field % 3, ri = field / 3; if (ri + 1 < nrows) { field = (ri + 1) * 3 + ci; field_fresh = 1; } continue; } if (ch == KEY_CTRL_X) { int ri = field / 3; if (ri < nrows) { memset(&rows[ri], 0, sizeof rows[ri]); ibrow_normalize(rows, &nrows, &field); nfields = 3 * nrows; field_fresh = 1; } continue; } if (ch == KEY_F(5) || ch == KEY_CTRL_ENTER || ch == KEY_F(9)) { const char *problem = NULL; char msg[256] = ""; int64_t sum = 0; int used = 0; for (int i = 0; i < nrows && !problem; i++) { if (!rows[i].account[0]) continue; if (!acct_exists(a, rows[i].account)) { snprintf(msg, sizeof msg, "Konto %.15s finns inte (rad %d).", rows[i].account, i + 1); problem = msg; break; } double kr = 0; if (rows[i].amount[0] && !parse_x_double(rows[i].amount, &kr)) { snprintf(msg, sizeof msg, "Ogiltigt belopp på rad %d: '%.20s'.", i + 1, rows[i].amount); problem = msg; break; } sum += (int64_t)llround(kr * 100.0); used++; } if (!problem && used == 0) problem = "Ange minst ett konto."; if (!problem && sum != 0) { snprintf(msg, sizeof msg, "Summan måste bli noll (nu %lld öre).", (long long)sum); problem = msg; } if (problem) { message("Ingående balans", "%s", problem); continue; } if (ch == KEY_F(5)) { message("Validering OK", "%d konton, summan är noll.", used); continue; } /* delta against the existing IB so nothing is ever edited */ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); yyjson_mut_doc_set_root(d, o); char datebuf[16]; snprintf(datebuf, sizeof datebuf, "%s", a->fy_start); yyjson_mut_obj_add_strcpy(d, o, "date", datebuf); 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 < nrows; i++) { if (!rows[i].account[0]) continue; double kr = 0; if (rows[i].amount[0] && !parse_x_double(rows[i].amount, &kr)) kr = 0; int64_t new_amt = (int64_t)llround(kr * 100.0); int64_t prev_amt = ib_old_lookup(old_acc, old_amt, nold, 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", 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 (rows[i].text[0]) yyjson_mut_obj_add_strcpy(d, ro, "description", rows[i].text); posted++; } /* accounts that were in the old IB but removed now */ for (int i = 0; i < nold; i++) { int still = 0; for (int j = 0; j < nrows; j++) if (strcmp(rows[j].account, old_acc[i]) == 0) { still = 1; break; } if (!still && old_amt[i] != 0) { yyjson_mut_val *ro = yyjson_mut_arr_add_obj(d, arr); yyjson_mut_obj_add_strcpy(d, ro, "account", old_acc[i]); yyjson_mut_obj_add_int(d, ro, "debit_ore", old_amt[i] < 0 ? -old_amt[i] : 0); yyjson_mut_obj_add_int(d, ro, "credit_ore", old_amt[i] > 0 ? old_amt[i] : 0); posted++; } } if (posted == 0) { yyjson_mut_doc_free(d); message("Ingående balans", "Ingen ändring."); continue; } 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); message("Ingående balans", "Sparat som IB %lld (%d justerade rader).", (long long)num, posted); free(resp); curs_set(0); return 1; } show_error("Kunde inte spara ingående balans", resp); free(resp); continue; } char *buf = NULL; size_t cap = 0; int ri = field / 3, ci = field % 3; if (ri < nrows) { switch (ci) { case 0: buf = rows[ri].account; cap = sizeof rows[ri].account; break; case 1: buf = rows[ri].amount; cap = sizeof rows[ri].amount; break; default: buf = rows[ri].text; cap = sizeof rows[ri].text; break; } } if (!buf) continue; field_edit(buf, cap, &field_pos, ch, &field_fresh); ibrow_normalize(rows, &nrows, &field); nfields = 3 * nrows; } } static void ib_screen(struct app *a) { char **accs = NULL; int64_t *amts = NULL; int n = 0; int reload = 1; int top = 0; for (;;) { if (reload) { for (int i = 0; i < n; i++) free(accs[i]); free(accs); free(amts); accs = NULL; amts = NULL; n = 0; top = 0; reload = 0; if (ib_load(a, &accs, &amts, &n) != 0) return; } int view = LINES - 10; if (view < 1) view = 1; if (top > n - view) top = n - view; if (top < 0) top = 0; frame("Ingående balans"); attron(A_BOLD); mvprintw(2, 2, "Räkenskapsår %s ingående balans (effektiv)", a->fy_label); mvaddstr(4, 2, "Konto"); mvaddstr(4, 9, "Namn"); mvaddstr(4, 62, "Belopp"); attroff(A_BOLD); int64_t sum = 0; for (int i = 0; i < n; i++) sum += amts[i]; if (n == 0) { mvaddstr(6, 2, "Ingen ingående balans registrerad ännu."); } for (int i = 0; i < view && top + i < n; i++) { int idx = top + i; char amount[32]; kr_format(amts[idx], amount, sizeof amount); const char *nm = acct_name(a, accs[idx]); char nbuf[128]; snprintf(nbuf, sizeof nbuf, "%s", nm ? nm : ""); pad_field(nbuf, sizeof nbuf, 50); mvprintw(6 + i, 2, "%-6s %s %14s", accs[idx], nbuf, amount); } char sumbuf[32]; kr_format(sum, sumbuf, sizeof sumbuf); attron(A_BOLD); mvprintw(LINES - 4, 2, "Summa: %s", sumbuf); attroff(A_BOLD); hints("e/^N = redigera PgUp/PgDn, Home/End rullar F5 =" " uppdatera Esc/q = tillbaka"); refresh(); int ch = ui_getch(); if (ch == 'e' || ch == 'E' || ch == KEY_CTRL_N) { if (ib_form(a, accs, amts, n)) reload = 1; continue; } if (ch == KEY_DOWN && top + view < n) top++; else if (ch == KEY_UP && top > 0) top--; else if (ch == KEY_NPAGE) top += view; else if (ch == KEY_PPAGE) top -= view; else if (ch == KEY_HOME) top = 0; else if (ch == KEY_END) top = n > view ? n - view : 0; else if (ch == KEY_F(5)) { reload = 1; continue; } else if (ch == 27 || ch == 'q') { for (int i = 0; i < n; i++) free(accs[i]); free(accs); free(amts); return; } } } struct company_field { const char *key; const char *label; int numeric; }; 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 }, { "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); return end && *end == '\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; } static void company_screen(struct app *a) { 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 = xcalloc(nf, sizeof(char *)); for (int i = 0; i < nf; i++) { char path[64]; snprintf(path, sizeof path, "result.%s", COMPANY_FIELDS[i].key); if (COMPANY_FIELDS[i].numeric) { char tmp[32]; snprintf(tmp, sizeof tmp, "%lld", (long long)jint_val(resp, path, 0)); vals[i] = xstrdup(tmp); } else { char *v = jstr_dup(resp, path); vals[i] = v ? v : xstrdup(""); } } free(resp); int sel = 0; int back = 0; for (;;) { frame("Företagsuppgifter"); attron(A_BOLD); mvaddstr(3, 2, "Uppgift"); mvaddstr(3, 34, "Värde"); attroff(A_BOLD); for (int i = 0; i < nf; i++) { char lbl[128], line[512]; snprintf(lbl, sizeof lbl, "%s", COMPANY_FIELDS[i].label); pad_field(lbl, sizeof lbl, 31); snprintf(line, sizeof line, "%s %s", lbl, vals[i]); if (i == sel) { attron(A_REVERSE); mvaddnstr(5 + i, 2, line, COLS - 4); attroff(A_REVERSE); } else { if (!owner) attron(A_DIM); mvaddnstr(5 + i, 2, line, COLS - 4); if (!owner) attroff(A_DIM); } } hints(owner ? "upp/ned Enter = ändra F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta" : "upp/ned F5 = uppdatera Esc/q = tillbaka" " ^C = avsluta (endast ägare kan ändra)"); refresh(); int ch = ui_getch(); if (ch == KEY_UP && sel > 0) sel--; else if (ch == KEY_DOWN && sel < nf - 1) sel++; else if (ch == KEY_F(5)) break; /* reload */ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { if (!owner) { message("Företagsuppgifter", "Endast ägare kan ändra företagsuppgifterna."); continue; } char label[160]; snprintf(label, sizeof label, "%s: ", COMPANY_FIELDS[sel].label); char *val = prompt(label, vals[sel], 0); if (!val) continue; if (!company_field_valid(&COMPANY_FIELDS[sel], val)) { message("Ogiltigt värde", "%s", COMPANY_FIELDS[sel].label); 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); if (COMPANY_FIELDS[sel].numeric) yyjson_mut_obj_add_int(d, o, COMPANY_FIELDS[sel].key, strtoll(val, NULL, 10)); else yyjson_mut_obj_add_strcpy(d, o, COMPANY_FIELDS[sel].key, val); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *r = client_rpc(&a->conn, "org.update", a->session, a->org, args); free(args); if (!r || !client_ok(r)) show_error("Kunde inte spara", r); free(r); break; /* reload from the server */ } else if (ch == 27 || ch == 'q') { back = 1; break; } } for (int i = 0; i < nf; i++) free(vals[i]); free(vals); if (back || g_quit) return; } } static const char *const SETTING_KEYS[] = { "default_series", "attachment_dir" }; static const char *const SETTING_LABELS[] = { "Standardserie", "Bilagornas mapp" }; static void settings_screen(struct app *a) { 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; } char **vals = xcalloc(nset, sizeof(char *)); for (int i = 0; i < nset; i++) { char path[64]; snprintf(path, sizeof path, "result.%s", SETTING_KEYS[i]); char *v = jstr_dup(resp, path); vals[i] = v ? v : xstrdup(""); } free(resp); int sel = 0; for (;;) { frame("Inställningar"); attron(A_BOLD); mvaddstr(4, 2, "Inställning"); mvaddstr(4, 29, "Värde"); attroff(A_BOLD); for (int i = 0; i < nset; i++) { char lbl[128], line[512]; snprintf(lbl, sizeof lbl, "%s", SETTING_LABELS[i]); pad_field(lbl, sizeof lbl, 26); snprintf(line, sizeof line, "%s %s", lbl, vals[i]); if (i == sel) attron(A_REVERSE); mvaddnstr(6 + i, 2, line, COLS - 4); if (i == sel) attroff(A_REVERSE); } hints("upp/ned Enter = ändra F5 = uppdatera Esc/q = tillbaka" " ^C = avsluta"); refresh(); int ch = ui_getch(); if (ch == KEY_UP && sel > 0) sel--; else if (ch == KEY_DOWN && sel < nset - 1) sel++; else if (ch == KEY_F(5)) break; /* reload */ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { char label[128]; snprintf(label, sizeof label, "%s: ", SETTING_LABELS[sel]); char *val = prompt(label, vals[sel], 0); if (val) { 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", SETTING_KEYS[sel]); yyjson_mut_obj_add_strcpy(d, o, "value", val); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *r = client_rpc(&a->conn, "settings.set", a->session, a->org, args); free(args); if (r && client_ok(r)) { char *nv = jstr_dup(r, "result.value"); if (nv) { free(vals[sel]); vals[sel] = nv; if (strcmp(SETTING_KEYS[sel], "default_series") == 0) snprintf(a->default_series, sizeof a->default_series, "%s", nv); else if (strcmp(SETTING_KEYS[sel], "attachment_dir") == 0) snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", nv); } } else { show_error("Kunde inte spara inställningen", r); } free(r); } } else if (ch == 27 || ch == 'q') { for (int i = 0; i < nset; i++) free(vals[i]); free(vals); return; } } for (int i = 0; i < nset; i++) free(vals[i]); free(vals); } } 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 : ""); 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 = select_list("Mallar", lines, (int)n + 1, cursor, 1, &cursor, 1, n > 0, 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; } } /* 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, "ib") == 0) ib_screen(a); else if (strcmp(scene, "templates") == 0) templates_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); } 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[] = { "Verifikat", "Underlag (inkorg)", "Ingående balans", "Mallar", "Rapporter", "Revision", "Inställningar", "Företagsuppgifter", "Räkenskapsår", "Logga ut / avsluta", }; static void dashboard(struct app *a) { static int last_sel = 0; for (;;) { if (g_reload) return; int sel = menu("Bokf", MAIN_ITEMS, 10, 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 0: open_scene(a, "vouchers"); break; case 1: open_scene(a, "inbox"); break; case 2: open_scene(a, "ib"); break; case 3: open_scene(a, "templates"); break; case 4: open_scene(a, "reports"); break; case 5: open_scene(a, "audit"); break; case 6: open_scene(a, "settings"); break; case 7: open_scene(a, "company"); break; case 8: select_fiscal_year(a); break; case 9: return; } } } /* ------------------------------------------------------------------ */ /* 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) { 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 : ""); int field = 3; /* 0 server, 1 user, 2 password, 3 = Logga in button */ for (;;) { frame("bokf — inloggning"); attron(A_BOLD); mvaddstr(4, 4, "Server"); mvaddstr(6, 4, "Användare"); mvaddstr(8, 4, "Lösenord"); attroff(A_BOLD); mvaddstr(4, 16, socket_path); mvaddstr(6, 16, user); { char stars[128]; size_t n = strlen(pass); if (n > sizeof stars - 1) n = sizeof stars - 1; memset(stars, '*', n); stars[n] = '\0'; mvaddstr(8, 16, stars); } { const char *btn = " Logga in "; int bw = disp_width(btn); int bx = (COLS - bw) / 2; if (bx < 2) bx = 2; if (field == 3) { attron(A_REVERSE | A_BOLD); curs_set(0); } mvaddstr(11, bx, btn); if (field == 3) attroff(A_REVERSE | A_BOLD); } hints("Tab = byta fält/knapp Enter = logga in ^C = avsluta"); refresh(); if (field == 3) { int ch = ui_getch(); if (ch == 27) { if (g_quit) { endwin(); return -1; } continue; /* Esc never exits here */ } if (ch == '\t') { field = 0; curs_set(1); continue; } if (ch == KEY_BTAB) { field = 2; curs_set(1); continue; } if (ch != '\n' && ch != '\r' && ch != KEY_ENTER && ch != ' ') continue; /* fall through to the login attempt */ } else { int y = 4 + field * 2; int rc = edit_field(y, 16, field == 0 ? socket_path : (field == 1 ? user : pass), field == 0 ? sizeof socket_path : (field == 1 ? sizeof user : sizeof pass), field == 2); if (rc == 0) { if (g_quit) { endwin(); return -1; } continue; } if (rc == 2) { field = (field + 1) % 4; continue; } if (rc == 3) { field = (field + 3) % 4; continue; } if (field < 2) { field++; continue; } /* field 2: Enter submits */ } /* 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) { 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"); message("Inloggning misslyckades", "%s: %s", code ? code : "fel", msg ? msg : "okänt fel"); free(code); free(msg); } else { 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) { 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 : ""); 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 = select_list("Välj organisation att representera", items, (int)n, 0, 0, NULL, 0, 0, 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" " ib, templates, reports, audit, settings, company\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); /* ^Enter has no control code; enable xterm modifyOtherKeys and bind the sequence it produces. Terminals without it ignore the request. */ define_key("\033[27;5;13~", KEY_CTRL_ENTER); putp("\033[>4;1m"); fflush(stdout); 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) { mvprintw(LINES / 2, 2, "Återansluter till servern... (%ds)", i); refresh(); 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"); message("Återanslutning misslyckades", "%s: %s", code ? code : "fel", msg ? msg : "okänt fel"); free(code); free(msg); } else if (stale_session) { 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; }