#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 void bokslut_screen(struct app *a); static void arsredovisning_screen(struct app *a); static char *rpc_dry(struct app *a, const char *cmd, const char *args); static int64_t vouchers_new(struct app *a); static void 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; int prev = 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) { curs_set(prev == ERR ? 0 : prev); return buf; } if (ch == 27) { curs_set(prev == ERR ? 0 : prev); 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, const char *archive_action, 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], dbuf[48] = ""; if (archive_action) snprintf(dbuf, sizeof dbuf, " d = %s", archive_action); 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" : "", dbuf, 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 (archive_action && (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, NULL, 1); for (size_t i = 0; i < n; i++) free(lines[i]); free(lines); free(resp); if (sel == -4) { free(ids); free(closed); free(ranges); free(labels); int64_t id = fy_new_form(a); if (id > 0) prefer = id; continue; } if (sel == -7) { if (cursor >= 0 && (size_t)cursor < n) { char label[192]; snprintf(label, sizeof label, "%s räkenskapsåret %s? (j/n): ", closed[cursor] ? "Återöppna" : "Avsluta", labels[cursor]); char *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, NULL, 0); for (size_t i = 0; i <= n; i++) free(items[i]); free(items); if (sel < 0) { free(ents); return NULL; } if (sel == 0) { char *slash = strrchr(dir, '/'); if (slash && slash != dir) *slash = '\0'; else if (slash == dir) dir[1] = '\0'; free(ents); continue; } size_t idx = (size_t)sel - 1; char full[1600]; snprintf(full, sizeof full, "%.1200s/%.299s", dir, ents[idx].name); if (ents[idx].isdir) { snprintf(dir, sizeof dir, "%.1023s", full); free(ents); continue; } free(ents); return xstrdup(full); } } /* Valid UTF-8 without NUL bytes: good enough to call a receipt text. */ static int bytes_look_text(const unsigned char *p, size_t n) { for (size_t i = 0; i < n;) { unsigned char c = p[i]; if (c == 0) return 0; if (c < 0x80) { i++; continue; } int len; if ((c & 0xE0) == 0xC0) len = 2; else if ((c & 0xF0) == 0xE0) len = 3; else if ((c & 0xF8) == 0xF0) len = 4; else return 0; if (i + (size_t)len > n) return 0; for (int k = 1; k < len; k++) if ((p[i + k] & 0xC0) != 0x80) return 0; i += (size_t)len; } return 1; } /* Fetch one attachment, save it to a prompted path and verify its hash. Text attachments are shown inline after saving. */ static void attachment_download(struct app *a, int64_t att_id) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)att_id); char *resp = client_rpc(&a->conn, "attachment.get", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Kunde inte hämta underlaget", resp); free(resp); return; } char *fn = jstr_dup(resp, "result.filename"); char *mime = jstr_dup(resp, "result.mime"); char *sha = jstr_dup(resp, "result.sha256"); char *b64 = jstr_dup(resp, "result.content_base64"); unsigned char *data = NULL; size_t n = 0; if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { message("Underlag", "Kunde inte avkoda innehållet."); goto done; } const char *home = getenv("HOME"); char def[600], dl[512]; snprintf(dl, sizeof dl, "%s/Downloads", home && *home ? home : "."); struct stat sb; if (stat(dl, &sb) != 0 || !S_ISDIR(sb.st_mode)) snprintf(dl, sizeof dl, "%s", home && *home ? home : "."); snprintf(def, sizeof def, "%s/%s", dl, fn && *fn ? fn : "underlag"); char *path = prompt("Spara underlag: ", def, 0); if (!path || !*path) goto done; char full[600]; if (path[0] == '~' && (path[1] == '/' || path[1] == '\0')) snprintf(full, sizeof full, "%s%s", home ? home : "", path + 1); else snprintf(full, sizeof full, "%s", path); if (stat(full, &sb) == 0) { char *ans = prompt("Filen finns, skriv över? (j/n): ", "n", 0); if (!ans || (ans[0] != 'j' && ans[0] != 'J')) goto done; } FILE *f = fopen(full, "wb"); if (!f || fwrite(data, 1, n, f) != n) { message("Underlag", "Kunde inte skriva %s: %s", full, strerror(errno)); if (f) fclose(f); goto done; } fclose(f); unsigned char raw[32]; char hex[65]; util_sha256(data, n, raw); util_hex(raw, sizeof raw, hex); if (sha && *sha && strcmp(hex, sha) != 0) message("Underlag", "VARNING: kontrollsumman stämmer inte.\nSparat: %s", full); else message("Underlag", "Sparat: %s\n(%zu byte)", full, n); int is_text = (mime && strncmp(mime, "text/", 5) == 0) || (n > 0 && bytes_look_text(data, n)); if (is_text && n <= 256 * 1024) { char *copy = xmalloc(n + 1); memcpy(copy, data, n); copy[n] = '\0'; text_view("Underlag", copy, NULL); free(copy); } done: free(data); free(b64); free(sha); free(mime); free(fn); free(resp); } 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) { char hint[256]; snprintf(hint, sizeof hint, "upp/ned F5 = uppdatera ^N = nytt verifikat c =" " rätta ^F = bifoga%s Esc/q = tillbaka", natts ? " f = underlag" : ""); frame("Verifikat"); for (int i = 0; i < view && top + i < n; i++) mvaddnstr(2 + i, 2, lines[top + i], COLS - 4); hints(hint); 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 == 6) { /* ^F: attach a file to this voucher */ char *path = file_browser(a, a->attachment_dir); if (path) { struct stat sb; if (stat(path, &sb) == 0 && (long)sb.st_size > a->max_attachment_bytes) { message("Bilaga", "Filen är %.1f MB, max %ld MB.", (double)sb.st_size / (1024 * 1024), a->max_attachment_bytes / (1024 * 1024)); free(path); } else { char *b64 = read_file_b64(path); if (!b64) { message("Fel", "Kunde inte läsa %s", path); free(path); } else { const char *base = strrchr(path, '/'); base = base ? base + 1 : path; yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); yyjson_mut_doc_set_root(d, o); yyjson_mut_obj_add_strcpy(d, o, "filename", base); yyjson_mut_obj_add_strcpy(d, o, "content_base64", b64); yyjson_mut_obj_add_int(d, o, "voucher_id", id); char *fargs = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); free(b64); free(path); char *r = client_rpc(&a->conn, "attachment.put", a->session, a->org, fargs); if (r && client_ok(r)) message("Underlag", "Bifogat."); else show_error("Kunde inte bifoga", r); free(r); free(fargs); want_refresh = 1; stop = 1; } } } } else if (ch == 'f' && natts > 0) { char **alines = xcalloc(natts, sizeof(char *)); int64_t *aids = xcalloc(natts, sizeof(int64_t)); for (size_t i = 0; i < natts; i++) { char path[64]; snprintf(path, sizeof path, "result.attachments.%zu.id", i); aids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.attachments.%zu.filename", i); char *fn = jstr_dup(resp, path); alines[i] = xstrdup(fn ? fn : "(namnlös)"); free(fn); } int acursor = 0; int asel = select_list("Underlag", alines, (int)natts, 0, 0, &acursor, 0, "ta bort", 0); if (asel >= 0) attachment_download(a, aids[asel]); else if (asel == -6 && acursor >= 0 && (size_t)acursor < natts) { char q[320]; snprintf(q, sizeof q, "Ta bort '%s' från verifikatet? (j/n): ", alines[acursor]); char *ans = prompt(q, "n", 0); if (ans && (ans[0] == 'j' || ans[0] == 'J')) { char uargs[64]; snprintf(uargs, sizeof uargs, "{\"id\":%lld,\"voucher_id\":%lld}", (long long)aids[acursor], (long long)id); char *r = client_rpc(&a->conn, "attachment.unlink", a->session, a->org, uargs); if (r && client_ok(r)) { message("Underlag", "Länken borttagen."); want_refresh = 1; stop = 1; } else { show_error("Kunde inte ta bort", r); } free(r); } } for (size_t i = 0; i < natts; i++) free(alines[i]); free(alines); free(aids); } 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, NULL, 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(path, sizeof path, "result.items.%zu.attachment_count", i); int atts = (int)jint_val(resp, path, 0); snprintf(line, sizeof line, " %c %-*s %s %s", atts ? 'x' : ' ', idw, idstr[i], date ? date : "", desc ? desc : ""); items[i] = xstrdup(line); free(date); free(desc); free(idstr[i]); } free(idstr); free(resp); items[n] = xstrdup("+ Nytt verifikat (^N)"); } size_t total = n + 1; int start = 0; if (a->voucher_sel) { for (size_t i = 0; i < n; i++) if (ids[i] == a->voucher_sel) { start = (int)i; break; } } int cur = start; int sel = select_list("Verifikat", items, (int)total, start, 1, &cur, 1, NULL, 0); if (cur >= 0 && (size_t)cur < n) a->voucher_sel = ids[cur]; if (sel == -2) { fetch = 1; continue; } int64_t open_id = 0; int want_new = (sel == -4); if (sel >= 0) { if ((size_t)sel == n) want_new = 1; else if ((size_t)sel < n) open_id = ids[sel]; } else if (sel != -4) { for (size_t i = 0; i < n; i++) free(items[i]); free(items); free(ids); return; } if (want_new) { open_id = vouchers_new(a); if (open_id <= 0) continue; a->voucher_sel = open_id; fetch = 1; } for (;;) { int64_t nv = 0; int act = voucher_detail(a, open_id, &nv); if (act == 2) { open_id = nv; a->voucher_sel = nv; fetch = 1; continue; } if (act == 1) fetch = 1; break; } } } static const char *type_sv(const char *t) { if (!t) return ""; if (strcmp(t, "asset") == 0) return "Tillgång"; if (strcmp(t, "liability") == 0) return "Skuld"; if (strcmp(t, "equity") == 0) return "Eget kapital"; if (strcmp(t, "revenue") == 0) return "Intäkt"; if (strcmp(t, "expense") == 0) return "Kostnad"; return t; } /* Moms treatment for an account: the stored vat_code when set, otherwise derived from the BAS account name (which carries the rate). Accounts whose treatment depends on the transaction stay blank. */ static const char *vat_display(const char *name, const char *vat_code, char *buf, size_t cap) { if (vat_code && *vat_code) { snprintf(buf, cap, "%s", vat_code); return buf; } if (!name || !*name) return ""; int pct = 0; for (const char *p = name; *p;) { if (*p >= '0' && *p <= '9') { const char *q = p; int v = 0; while (*q >= '0' && *q <= '9') { v = v * 10 + (*q - '0'); q++; } while (*q == ' ') q++; if (*q == '%') { pct = v; break; } p = q; continue; } p++; } int in = strstr(name, "Ingående moms") != NULL; int out = strstr(name, "Utgående moms") != NULL; int reverse = strstr(name, "omvänd") != NULL; if (in || out) { if (pct) snprintf(buf, cap, "%s %d%%", in ? "Ing" : "Utg", pct); else snprintf(buf, cap, "%s", in ? "Ing" : "Utg"); return buf; } if (pct) { snprintf(buf, cap, "%d%%%s", pct, reverse ? " omv" : ""); return buf; } if (strstr(name, "momsfri") || strstr(name, "momsfritt") || strstr(name, "momsefri")) return "Momsfri"; if (strstr(name, "Redovisningskonto för moms")) return "Avräkning"; if (reverse) return "Omvänd"; if (strstr(name, "moms")) return "Moms"; return ""; } /* Kontolista: every account with the details that apply to it. */ static void accounts_report(struct app *a) { for (;;) { char *resp = client_rpc(&a->conn, "account.list", a->session, a->org, "{\"active_only\":false}"); if (!resp || !client_ok(resp)) { show_error("Kontolista", resp); free(resp); return; } size_t n = jarr_size(resp, "result.items"); int name_w = COLS - 45; if (name_w < 12) name_w = 12; struct buf text; buf_init(&text); char line[2048]; int hdr = snprintf(line, sizeof line, "%-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 int is_range_has(yyjson_val *res, int lo, int hi) { for (int k = 0; k < 2; k++) { yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]); yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; size_t n = yyjson_arr_size(arr); for (size_t i = 0; i < n; i++) if (acct_in(vstr(yyjson_arr_get(arr, i), "account"), lo, hi)) return 1; } return 0; } /* Rows in one account range, printed with the previous-year column; the signed display sums are added to *sum / *sum_pre. */ static void is_range_rows(struct buf *t, yyjson_val *res, yyjson_val *prev, int lo, int hi, int64_t *sum, int64_t *sum_pre) { for (int k = 0; k < 2; k++) { yyjson_val *sec = yyjson_obj_get(res, IS_KEYS[k]); yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; size_t n = yyjson_arr_size(arr); for (size_t i = 0; i < n; i++) { yyjson_val *row = yyjson_arr_get(arr, i); const char *acc = vstr(row, "account"); if (!acct_in(acc, lo, hi)) continue; int64_t c = k == 0 ? vint(row, "amount_ore") : -vint(row, "amount_ore"); int64_t p = is_prev_lookup(prev, acc, k != 0); char nm[128], a1[48], a2[48]; 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); *sum += c; *sum_pre += p; } } } static void fmt_resultat(struct buf *t, const struct app *a, yyjson_val *res, yyjson_val *prev) { fmt_head(t, a, "Resultatrapport", res); const char *sect = NULL; int64_t rev = 0, rev_pre = 0, exp = 0, exp_pre = 0; for (size_t g = 0; g < sizeof IS_GROUPS / sizeof IS_GROUPS[0]; g++) { const struct is_group *gr = &IS_GROUPS[g]; if (!is_group_has(res, gr)) continue; if (!sect || strcmp(sect, gr->section) != 0) { if (sect) { is_summary(t, "Summa rörelseintäkter, lagerförändringar m.m.", rev, rev_pre, 0); buf_line(t, "\n"); buf_line(t, "%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); int64_t fin = 0, fin_pre = 0, disp = 0, disp_pre = 0; int64_t tax = 0, tax_pre = 0, booked = 0, booked_pre = 0; int has_disp = is_range_has(res, 8800, 8899); int has_tax = is_range_has(res, 8900, 8989); int has_booked = is_range_has(res, 8999, 8999); if (is_range_has(res, 8000, 8599)) { buf_line(t, "\nFinansiella poster\n"); is_range_rows(t, res, prev, 8000, 8599, &fin, &fin_pre); is_summary(t, "Summa", fin, fin_pre, 0); } int64_t ror = rev + exp, ror_pre = rev_pre + exp_pre; int64_t after = ror + fin, after_pre = ror_pre + fin_pre; buf_line(t, "\n"); buf_rule(t, IS_RULE_W); is_summary(t, "Rörelseresultat", ror, ror_pre, 0); is_summary(t, "Resultat efter finansiella poster", after, after_pre, 1); if (has_disp) { buf_line(t, "\nBokslutsdispositioner\n"); is_range_rows(t, res, prev, 8800, 8899, &disp, &disp_pre); is_summary(t, "Summa", disp, disp_pre, 0); } int64_t before_tax = after + disp, before_tax_pre = after_pre + disp_pre; is_summary(t, "Resultat före skatt", before_tax, before_tax_pre, 1); if (has_tax) { buf_line(t, "\nSkatt\n"); is_range_rows(t, res, prev, 8900, 8989, &tax, &tax_pre); is_summary(t, "Summa", tax, tax_pre, 0); } int64_t ars = before_tax + tax, ars_pre = before_tax_pre + tax_pre; buf_line(t, "\n"); buf_rule(t, IS_RULE_W); is_summary(t, "Årets resultat", ars, ars_pre, 0); if (has_booked) { buf_line(t, "\nBokfört resultat\n"); is_range_rows(t, res, prev, 8999, 8999, &booked, &booked_pre); is_summary(t, "Summa", booked, booked_pre, 0); buf_line(t, "\n"); buf_rule(t, IS_RULE_W); is_summary(t, "Ej bokfört resultat", ars + booked, ars_pre + booked_pre, 0); } else { buf_line(t, "\n"); buf_rule(t, IS_RULE_W); is_summary(t, "Ej bokfört resultat", ars, ars_pre, 0); } } /* ---------------- saldobalans ---------------- */ static void fmt_saldobalans(struct buf *t, const struct app *a, yyjson_val *res) { fmt_head(t, a, "Saldobalans", res); char nmh[64], h1[32], h2[32], h3[32], h4[32]; 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); } /* Official fältnamn (short) for the INK2/INK2R/INK2S codes; 2025P4. */ static const struct { const char *code; const char *label; } SRU_LABELS[] = { { "7011", "Räkenskapsårets början" }, { "7012", "Räkenskapsårets slut" }, { "7104", "1.1 Överskott av näringsverksamhet" }, { "7114", "1.2 Underskott av näringsverksamhet" }, { "7131", "1.3 Kreditinstituts underlag för riskskatt" }, { "7132", "1.4 Underlag för särskild löneskatt på pensionskostnader" }, { "7133", "1.5 Negativt underlag för särskild löneskatt på pensionskostnader" }, { "7153", "1.6 a Försäkringsföretag m.fl. samt avsatt till pensioner 15 %" }, { "7154", "1.6 b Utländska pensions- försäkringar 15 %" }, { "7155", "1.7 a Försäkringsföretag m.fl 30 %" }, { "7156", "1.7 b Utländska kapitalförsäkringar 30 %" }, { "80", "1.8 Småhus/ ägarlägenhet" }, { "93", "1.9 Hyreshus: bostäder" }, { "84", "1.10 Småhus/ägarlägenhet: tomtmark, byggnad under uppförande" }, { "86", "1.11 Hyreshus: tomtmark, bostäder under uppförande" }, { "95", "1.12 Hyreshus: lokaler" }, { "96", "1.13 Industrienhet och elproduktionsenhet: värme- kraftverk" }, { "97", "1.14 Elproduktionsenhet, vattenkraftverk" }, { "98", "1.15 Elproduktionsenhet, vindkraftverk" }, { "90", "Övriga upplysningar på bilaga" }, { "1582", "1.16 Förnybar el (kilowattimmar)" }, { "7011", "Räkenskapsårets början" }, { "7012", "Räkenskapsårets slut" }, { "7201", "2.1 Immateriella anläggningstillgångar Koncessioner, patent, licenser, varumärken, hyresrätter, goodwill och liknande rättigheter" }, { "7202", "2.2 Immateriella anläggningstillgångar Förskott avseende immateriella anläggningstillgångar" }, { "7214", "2.3 Materiella anläggningstillgångar Byggnader och mark" }, { "7215", "2.4 Materiella anläggningstillgångar Maskiner, inventarier och övriga materiella anläggningstillgångar" }, { "7216", "2.5 Materiella anläggningstillgångar Förbättringsutgifter på annans fastighet" }, { "7217", "2.6 Materiella anläggningstillgångar Pågående nyanläggningar och förskott avseende materiella anläggningstillgångar" }, { "7230", "2.7 Finansiella anläggningstillgångar Andelar i koncernföretag" }, { "7231", "2.8 Finansiella anläggningstillgångar Andelar i intresseföretag och gemensamt styrda företag" }, { "7233", "2.9 Ägarintressen i övriga företag och andra långfristiga värdepappersinnehav" }, { "7232", "2.10 Finansiella anläggningstillgångar Fordringar hos koncern-, intresse- och gemensamt styrda företag" }, { "7234", "2.11 Finansiella anläggningstillgångar Lån till delägare eller närstående" }, { "7235", "2.12 Fordringar hos övriga företag som det finns ett ägarintresse i och andra långfristiga fordringar" }, { "7241", "2.13 Varulager m.m. Råvaror och förnödenheter" }, { "7242", "2.14 Varulager Varor under tillverkning" }, { "7243", "2.15 Varulager Färdiga varor och handelsvaror" }, { "7244", "2.16 Varulager Övriga lagertillgångar" }, { "7245", "2.17 Varulager Pågående arbeten för annans räkning" }, { "7246", "2.18 Varulager Förskott till leverantörer" }, { "7251", "2.19 Kortfristiga fordringar Kundfordringar" }, { "7252", "2.20 Kortfristiga fordringar Fordringar hos koncern-, intresse- och gemensamt styrda företag" }, { "7261", "2.21 Fordringar hos övriga företag som det finns ett ägarintresse i och övriga fordringar" }, { "7262", "2.22 Kortfristiga fordringar Upparbetad men ej fakturerad intäkt" }, { "7263", "2.23 Kortfristiga fordringar Förutbetalda kostnader och upplupna intäkter" }, { "7270", "2.24 Kortfristiga placeringar Andelar i koncernföretag" }, { "7271", "2.25 Kortfristiga placeringar Övriga kortfristiga placeringar" }, { "7281", "2.26 Kassa och bank Kassa, bank och redovisningsmedel" }, { "7301", "2.27 Eget kapital Bundet eget kapital" }, { "7302", "2.28 Eget kapital Fritt eget kapital" }, { "7321", "2.29 Obeskattade reserver Periodiseringsfonder" }, { "7322", "2.30 Obeskattade reserver Ackumulerade överavskrivningar" }, { "7323", "2.31 Obeskattade reserver Övriga obeskattade reserver" }, { "7331", "2.32 Avsättningar Avsättningar för pensioner och liknande förpliktelser enligt lag (1967:531)..." }, { "7332", "2.33 Avsättningar Övriga avsättningar för pensioner och liknande förpliktelser" }, { "7333", "2.34 Avsättningar Övriga avsättningar" }, { "7350", "2.35 Långfristiga skulder Obligationslån" }, { "7351", "2.36 Långfristiga skulder Checkräkningskredit" }, { "7352", "2.37 Långfristiga skulder Övriga skulder till kreditinstitut" }, { "7353", "2.38 Långfristiga skulder Skulder till koncern-, intresse- och gemensamt styrda företag" }, { "7354", "2.39 Skulder till övriga företag som det finns ett ägarintresse i och övriga skulder" }, { "7360", "2.40 Kortfristiga skulder Checkräkningskredit" }, { "7361", "2.41 Kortfristiga skulder Övriga skulder till kreditinstitut" }, { "7362", "2.42 Kortfristiga skulder Förskott från kunder" }, { "7363", "2.43 Kortfristiga skulder Pågående arbeten för annans räkning" }, { "7364", "2.44 Kortfristiga skulder Fakturerad men ej upparbetad intäkt" }, { "7365", "2.45 Kortfristiga skulder Leverantörsskulder" }, { "7366", "2.46 Kortfristiga skulder Växelskulder" }, { "7367", "2.47 Kortfristiga skulder Skulder till koncern-, intresse- och gemensamt styrda företag" }, { "7369", "2.48 Skulder till övriga företag som det finns ett ägarintresse i och övriga skulder" }, { "7368", "2.49 Kortfristiga skulder Skattesskulder" }, { "7370", "2.50 Kortfristiga skulder Upplupna kostnader och förutbetalda intäkter" }, { "7410", "3.1 Nettoomsättning" }, { "7411", "3.2 Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning. +" }, { "7510", "3.2 Förändring av lager av produkter i arbete, färdiga varor och pågående arbete för annans räkning. -" }, { "7412", "3.3 Aktiverat arbete för egen räkning" }, { "7413", "3.4 Övriga rörelseintäkter" }, { "7511", "3.5 Råvaror och förnödenheter" }, { "7512", "3.6 Handelsvaror" }, { "7513", "3.7 Övriga externa kostnader" }, { "7514", "3.8 Personalkostnader" }, { "7515", "3.9 Av- och nedskrivningar av materiella och immateriella anläggningstillgångar" }, { "7516", "3.10 Nedskrivningar av omsättningstillgångar utöver normala nedskrivningar" }, { "7517", "3.11 Övriga rörelsekostnader" }, { "7414", "3.12 Resultat från andelar i koncernföretag +" }, { "7518", "3.12 Resultat från andelar i koncernföretag -" }, { "7415", "3.13 Resultat från andelar i intresseföretag och gemensamt styrda företag +" }, { "7519", "3.13 Resultat från andelar i intresseföretag och gemensamt styrda företag -" }, { "7423", "3.14 Resultat från övriga företag som det finns ett ägarintresse i +" }, { "7530", "3.14 Resultat från övriga företag som det finns ett ägarintresse i -" }, { "7416", "3.15 Resultat från övriga finansiella anläggningstillgångar +" }, { "7520", "3.15 Resultat från övriga finansiella anläggningstillgångar -" }, { "7417", "3.16 Övriga ränteintäkter och liknande resultatposter" }, { "7521", "3.17 Nedskrivning av finansiella anläggningstillgångar och kortfristiga placeringar" }, { "7522", "3.18 Räntekostnader och liknande resultatposter" }, { "7524", "3.19 Lämnade koncernbidrag" }, { "7419", "3.20 Mottagna koncernbidrag" }, { "7420", "3.21 Återföring av periodiseringsfond" }, { "7525", "3.22 Avsättning till periodiseringsfond" }, { "7421", "3.23 Förändring av överavskrivningar +" }, { "7526", "3.23 Förändring av överavskrivningar -" }, { "7422", "3.24 Övriga bokslutsdispositioner +" }, { "7527", "3.24 Övriga bokslutsdispositioner -" }, { "7528", "3.25 Skatt på årets resultat" }, { "7450", "3.26 Årets resultat, vinst (flyttas till p. 4.1)" }, { "7550", "3.27 Årets resultat, förlust (flyttas till p. 4.2)" }, { "7011", "Räkenskapsårets början" }, { "7012", "Räkenskapsårets slut" }, { "7650", "4.1 Årets resultat, vinst" }, { "7750", "4.2 Årets resultat, förlust" }, { "7651", "4.3a. Bokförda kostnader som inte ska dras av: a. Skatt på årets resultat" }, { "7652", "b. Bokförda kostnader som inte ska dras av: b. Nedskrivning av finansiella tillgångar" }, { "7653", "c. Bokförda kostnader som inte ska dras av: c. Andra bokförda kostnader" }, { "7751", "4.4a. Kostnader som ska dras av men som inte ingår i det redovisade resultatet: a. Lämnade koncernbidrag" }, { "7764", "b. Kostnader som ska dras av men som inte ingår i det redovisade resultatet: b. Andra ej bokförda kostnader" }, { "7752", "4.5a. Bokförda intäkter som inte ska tas upp: a. Ackordsvinster" }, { "7753", "b. Bokförda intäkter som inte ska tas upp: b. Utdelning" }, { "7754", "c. Bokförda intäkter som inte ska tas upp: c. Andra bokförda intäkter" }, { "7654", "4.6a. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet a. Beräknad schablonintäkt på periodiseringsfonder vid beskattningsårets ingång" }, { "7668", "b. Beräknad schablonintäkt på fondandelar ägda vid kalenderårets ingång" }, { "7655", "c. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: c. Mottagna koncernbidrag" }, { "7673", "d. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: d. Uppräknat belopp vid återföring av periodiseringsfond" }, { "7665", "e. Intäkter som ska tas upp men som inte ingår i det redovisade resultatet: e. Andra ej bokförda intäkter" }, { "7755", "4.7a. Avyttring av delägarrätter: a. Bokförd vinst" }, { "7656", "b. Avyttring av delägarrätter: b. Bokförd förlust" }, { "7756", "c. Avyttring av delägarrätter: c. Uppskov med kapitalvinst enligt blankett N4" }, { "7657", "d. Avyttring av delägarrätter: d. Återfört uppskov med kapitalvinst enligt blankett N4" }, { "7658", "e. Avyttring av delägarrätter: e. Kapitalvinst för beskattningsåret" }, { "7757", "f. Avyttring av delägarrätter: f. Kapitalförlust som ska dras av" }, { "7758", "4.8a. Andel i handelsbolag (inkl. avyttring): a. Bokförd intäkt/vinst" }, { "7659", "b. Andel i handelsbolag (inkl. avyttring): b. Skattemässigt överskott enligt N3B" }, { "7660", "c. Andel i handelsbolag (inkl. avyttring): c. Bokförd kostnad/förlust" }, { "7759", "d. Andel i handelsbolag (inkl. avyttring): d. Skattemässigt underskott enligt N3B" }, { "7666", "4.9 Skattemässig justering av bokfört resultat för avskrivningar på byggnader och annan fast egendom samt restvärdesavskrivning på maskiner och inventarier (+)" }, { "7765", "4.9 Skattemässig justering av bokfört resultat för avskrivningar på byggnader och annan fast egendom samt restvärdesavskrivning på maskiner och inventarier (-)" }, { "7661", "4.10 Skattemässig justering av bokfört resultat vid avyttring av näringsfastighet och näringsbostadsrätt" }, { "7760", "4.10 Skattemässig korrigering av bokfört resultat vid avyttring av näringsfastighet och näringsbostadsrätt: -" }, { "7761", "4.11 Skogs-/substansminskningsavdrag (specificeras på blankett N8)" }, { "7662", "4.12 Återföringar vid avyttring av fastighet t.ex. värdeminskningsavdrag, skogsavdrag och substansminskningsavdrag..." }, { "7663", "4.13 Andra skattemässiga justeringar av resultatet: +" }, { "7762", "4.13 Andra skattemässiga justeringar av resultatet: -" }, { "7763", "4.14a. Underskott: a. Outnyttjat underskott från föregående år" }, { "7671", "4.14b. Reduktion av outnyttjat underskott med hänsyn till beloppsspärr, ackord eller konkurs" }, { "7672", "4.14c. Reduktion av outnyttjat underskott med hänsyn till koncernbidragsspärr, fusionsspärr m.m. (beloppet ska också tas upp vid p. 1.2. på sid. 1)" }, { "7670", "4.15 Överskott (flyttas till p. 1.1 på sid. 1)" }, { "7770", "4.16 Underskott (flyttas till p. 1.2 på sid. 1)" }, { "8020", "4.17 Årets begärda och tidigare års medgivna värdeminskningsavdrag som finns vid beskattningsårets utgång avseende byggnader." }, { "8021", "4.18 Årets begärda och tidigare års medgivna värdeminskningsavdrag som finns vid beskattningsårets utgång avseende markanläggningar." }, { "8023", "4.19 Vid restvärdesavskrivning: återförda belopp för av- och nedskrivning, försäljning, utrangering" }, { "8026", "4.20 Lån från aktieägare (fysisk person) vid räkenskapsårets utgång" }, { "8022", "4.21 Pensionskostnader (som ingår i p. 3.8)" }, { "8028", "4.22 Koncernbidragsspärrat och fusionsspärrat underskott m.m. (frivillig uppgift)" }, { "8040", "Uppdragstagare (t.ex. redovisningskonsult) har biträtt vid upprättandet av årsredovisningen: Ja" }, { "8041", "Uppdragstagare (t.ex. redovisningskonsult) har biträtt vid upprättandet av årsredovisningen: Nej" }, { "8044", "Årsredovisningen har varit föremål för revision: Ja" }, { "8045", "Årsredovisningen har varit föremål för revision: Nej" }, }; static const char *sru_label(const char *code) { for (size_t i = 0; i < sizeof SRU_LABELS / sizeof SRU_LABELS[0]; i++) if (strcmp(SRU_LABELS[i].code, code) == 0) return SRU_LABELS[i].label; return code; } static void sru_rows(struct buf *t, yyjson_val *arr) { size_t n = yyjson_arr_size(arr); for (size_t i = 0; i < n; i++) { yyjson_val *row = yyjson_arr_get(arr, i); char lbl[256], amt[48]; pad_label(lbl, sizeof lbl, sru_label(vstr(row, "code")), 66); kr_format(vint(row, "amount") * 100, amt, sizeof amt); buf_line(t, "%s %14s\n", lbl, amt); } } static void fmt_ink2(struct buf *t, const struct app *a, yyjson_val *res) { buf_line(t, "Inkomstdeklaration 2 (INK2)\n%s\n\n", a->org_name); buf_line(t, "Räkenskapsår: %s - %s\n", vstr(res, "from"), vstr(res, "to")); buf_line(t, "Blankett: %s\n", vstr(res, "period")); buf_line(t, "\nINK2 - huvudblankett\n"); buf_rule(t, 81); sru_rows(t, yyjson_obj_get(res, "ink2")); buf_line(t, "\nINK2R - räkenskapsschema\n"); buf_rule(t, 81); sru_rows(t, yyjson_obj_get(res, "ink2r")); buf_line(t, "\nINK2S - skattemässiga justeringar\n"); buf_rule(t, 81); sru_rows(t, yyjson_obj_get(res, "ink2s")); yyjson_val *un = yyjson_obj_get(res, "unmapped"); size_t nu = yyjson_arr_size(un); if (nu) { buf_line(t, "\nKonton utan SRU-mappning (måste åtgärdas):\n"); for (size_t i = 0; i < nu; i++) { yyjson_val *row = yyjson_arr_get(un, i); char amt[48]; kr_format(vint(row, "amount_ore"), amt, sizeof amt); buf_line(t, " %s %s\n", vstr(row, "account"), amt); } } } static void sru_save(struct app *a) { const char *home = getenv("HOME"); char def[512]; snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : "."); struct stat sb; if (stat(def, &sb) != 0 || !S_ISDIR(sb.st_mode)) snprintf(def, sizeof def, "%s", home && *home ? home : "."); char *dir = prompt("Spara SRU-filer i: ", def, 0); if (!dir || !*dir) return; char full[512]; if (dir[0] == '~' && (dir[1] == '/' || dir[1] == '\0')) snprintf(full, sizeof full, "%s%s", home ? home : "", dir + 1); else snprintf(full, sizeof full, "%s", dir); char args[64]; snprintf(args, sizeof args, "{\"fiscal_year\":%lld}", (long long)a->fy); char *resp = client_rpc(&a->conn, "sru.export", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Kunde inte skapa SRU-filerna", resp); free(resp); return; } const char *keys[2] = { "result.info.content_base64", "result.blanketter.content_base64" }; const char *names[2] = { "INFO.SRU", "BLANKETTER.SRU" }; char saved[2][600] = { "", "" }; int ok = 0; for (int i = 0; i < 2; i++) { char *b64 = jstr_dup(resp, keys[i]); unsigned char *data = NULL; size_t n = 0; if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { free(b64); continue; } snprintf(saved[i], sizeof saved[i], "%s/%s", full, names[i]); FILE *f = fopen(saved[i], "wb"); if (f && fwrite(data, 1, n, f) == n) { fclose(f); ok++; } else { if (f) fclose(f); message("SRU", "Kunde inte skriva %s: %s", saved[i], strerror(errno)); } free(data); free(b64); } free(resp); if (ok == 2) message("SRU", "Sparat:\n%s\n%s\n\nLadda upp båda i Skatteverkets" " filöverföring.", saved[0], saved[1]); } static void reports_screen(struct app *a) { static const char *const report_items[] = { "Saldobalans (trial balance)", "Resultaträkning", "Balansräkning", "Huvudbok", "Verifikationslista", "Momsdeklaration", "Inkomstdeklaration (INK2/SRU)", "Årsredovisning (K2)", "Kontolista (alla konton)", }; static int last_sel = 0; for (;;) { if (g_quit) return; int sel = menu("Rapporter", report_items, 9, 0, &last_sel); if (sel < 0) return; if (sel == 6) { char sargs[64]; snprintf(sargs, sizeof sargs, "{\"fiscal_year\":%lld}", (long long)a->fy); for (;;) { char *resp = client_rpc(&a->conn, "sru.export", a->session, a->org, sargs); if (!resp || !client_ok(resp)) { show_error("Inkomstdeklaration", resp); free(resp); break; } yyjson_doc *sd = parse(resp); yyjson_val *sres = sd ? jget(yyjson_doc_get_root(sd), "result") : NULL; struct buf out; buf_init(&out); if (sres) fmt_ink2(&out, a, sres); else buf_line(&out, "%s", resp); buf_append(&out, "", 1); int sagain = text_view("Rapport", (const char *)out.p, "s = spara SRU"); buf_free(&out); yyjson_doc_free(sd); free(resp); if (sagain == 2) sru_save(a); if (!sagain) break; } continue; } if (sel == 7) { arsredovisning_screen(a); continue; } if (sel == 8) { 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; } } } /* Pick a voucher in the active fiscal year; returns its id or 0. */ static int64_t pick_voucher(struct app *a) { char args[96]; snprintf(args, sizeof args, "{\"fiscal_year\":%lld,\"limit\":200}", (long long)a->fy); char *resp = client_rpc(&a->conn, "voucher.list", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Verifikat", resp); free(resp); return 0; } size_t n = jarr_size(resp, "result.items"); if (n == 0) { message("Verifikat", "Inga verifikat i räkenskapsåret."); free(resp); return 0; } char **items = xcalloc(n, sizeof(char *)); int64_t *ids = xcalloc(n, sizeof(int64_t)); for (size_t i = 0; i < n; i++) { char path[64], ver[32], line[512]; snprintf(path, sizeof path, "result.items.%zu.id", i); ids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.series", i); char *series = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.number", i); int64_t number = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.date", i); char *date = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.description", i); char *desc = jstr_dup(resp, path); snprintf(ver, sizeof ver, "%s%lld", series ? series : "", (long long)number); snprintf(line, sizeof line, "%-8s %-10s %s", ver, date ? date : "", desc ? desc : ""); items[i] = xstrdup(line); free(series); free(date); free(desc); } int sel = select_list("Välj verifikat", items, (int)n, 0, 1, NULL, 0, NULL, 0); int64_t out = sel >= 0 ? ids[sel] : 0; for (size_t i = 0; i < n; i++) free(items[i]); free(items); free(ids); free(resp); return out; } static void inbox_screen(struct app *a) { int top = 0, sel = 0; for (;;) { if (g_quit) return; char *resp = client_rpc(&a->conn, "attachment.list", a->session, a->org, "{\"unlinked\":true,\"limit\":200}"); if (!resp || !client_ok(resp)) { show_error("Underlag", resp); free(resp); return; } size_t n = jarr_size(resp, "result.items"); char **items = xcalloc(n ? n : 1, sizeof(char *)); int64_t *ids = xcalloc(n ? n : 1, sizeof(int64_t)); for (size_t i = 0; i < n; i++) { char path[64], line[512]; snprintf(path, sizeof path, "result.items.%zu.id", i); ids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.filename", i); char *fn = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.size_bytes", i); int64_t size = jint_val(resp, path, 0); char fnbuf[256]; snprintf(fnbuf, sizeof fnbuf, "%s", fn ? fn : ""); 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 (n == 0) sel = 0; else if (sel >= (int)n) sel = (int)n - 1; if (sel < top) top = sel; if (sel >= top + view) top = sel - view + 1; 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++) { int y = 2 + (int)(i - (size_t)top); if ((int)i == sel) attron(A_REVERSE); mvaddnstr(y, 2, items[i], COLS - 4); if ((int)i == sel) attroff(A_REVERSE); } if (n == 0) mvaddstr(2, 2, "Inga obokförda underlag."); hints("Enter = hämta k = koppla till verifikat a/^N = lägg till" " fil PgUp/PgDn, Home/End rullar F5 = uppdatera Esc/q =" " tillbaka"); refresh(); int ch = ui_getch(); int64_t sel_id = (sel >= 0 && (size_t)sel < n) ? ids[sel] : 0; for (size_t i = 0; i < n; i++) free(items[i]); free(items); free(ids); free(resp); if (ch == 27 || ch == 'q') return; if ((ch == '\n' || ch == '\r' || ch == KEY_ENTER) && sel_id) attachment_download(a, sel_id); else if (ch == KEY_DOWN && sel + 1 < (int)n) sel++; else if (ch == KEY_UP && sel > 0) sel--; else if (ch == KEY_NPAGE) sel = sel + view < (int)n ? sel + view : (int)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 > 0 ? (int)n - 1 : 0; else if (ch == 'k' && sel_id) { int64_t vid = pick_voucher(a); if (vid) { char kargs[64]; snprintf(kargs, sizeof kargs, "{\"id\":%lld,\"voucher_id\":%lld}", (long long)sel_id, (long long)vid); char *r = client_rpc(&a->conn, "attachment.link", a->session, a->org, kargs); if (r && client_ok(r)) message("Underlag", "Kopplat till verifikatet."); else show_error("Kunde inte koppla", r); free(r); } } 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); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); const char *cmd = tpl_id ? "template.update" : "template.create"; char *resp = ch == KEY_F(5) ? rpc_dry(a, cmd, args) : 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; /* 0 = text, 1 = month (1-12), 2 = unsigned integer */ }; static const struct company_field COMPANY_FIELDS[] = { { "name", "Företagsnamn", 0 }, { "org_nr", "Organisationsnummer", 0 }, { "vat_nr", "Momsregistreringsnummer", 0 }, { "address", "Adress", 0 }, { "postal_code", "Postnummer", 0 }, { "city", "Ort", 0 }, { "country", "Land", 0 }, { "email", "E-post", 0 }, { "phone", "Telefon", 0 }, { "description", "Verksamhetsbeskrivning", 0 }, { "shares", "Antal aktier", 2 }, { "moms_period", "Momsperiod (month/quarter/year)", 0 }, { "framework", "Regelverk (K2/K3)", 0 }, { "fiscal_year_start_month", "Startmånad räkenskapsår (1-12)", 1 }, }; static int company_field_valid(const struct company_field *f, const char *v) { if (f->numeric) { char *end = NULL; long n = strtol(v, &end, 10); if (!end || *end != '\0') return 0; return f->numeric == 2 ? n >= 0 : (n >= 1 && n <= 12); } if (strcmp(f->key, "moms_period") == 0) return strcmp(v, "month") == 0 || strcmp(v, "quarter") == 0 || strcmp(v, "year") == 0; if (strcmp(f->key, "framework") == 0) return strcmp(v, "K2") == 0 || strcmp(v, "K3") == 0; return 1; } /* Board members shown in the årsredovisning (name + title). */ static void board_screen(struct app *a) { int owner = a->role[0] && strcmp(a->role, "owner") == 0; int cursor = 0; for (;;) { if (g_quit) return; char *resp = client_rpc(&a->conn, "board.list", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Styrelseledamöter", resp); free(resp); return; } size_t n = jarr_size(resp, "result.items"); int rows = (int)n + (owner ? 1 : 0); char **lines = xcalloc(rows ? rows : 1, sizeof(char *)); char **names = xcalloc(n + 1, sizeof(char *)); char **titles = xcalloc(n + 1, sizeof(char *)); int64_t *ids = xcalloc(n + 1, sizeof(int64_t)); for (size_t i = 0; i < n; i++) { char path[64], line[512]; snprintf(path, sizeof path, "result.items.%zu.id", i); ids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.name", i); names[i] = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.title", i); titles[i] = jstr_dup(resp, path); snprintf(line, sizeof line, "%s %s", titles[i] ? titles[i] : "", names[i] ? names[i] : ""); lines[i] = xstrdup(line); } if (owner) lines[n] = xstrdup("+ Ny ledamot (^N)"); int s = select_list("Styrelseledamöter", lines, rows, cursor, 1, &cursor, owner ? 1 : 0, owner && n > 0 ? "ta bort" : NULL, 0); if (s == -6 && cursor >= 0 && (size_t)cursor < n) { char q[320]; snprintf(q, sizeof q, "Ta bort '%s'? (j/n): ", lines[cursor]); char *ans = prompt(q, "n", 0); if (ans && (ans[0] == 'j' || ans[0] == 'J')) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)ids[cursor]); char *r = client_rpc(&a->conn, "board.remove", a->session, a->org, args); if (r && client_ok(r)) { message("Styrelseledamöter", "Borttagen."); } else { show_error("Kunde inte ta bort", r); } free(r); } } else if (s == -4 || (s >= 0 && owner && (size_t)s == n)) { char namebuf[256] = ""; char *name = prompt("Namn: ", "", 0); if (name && *name) snprintf(namebuf, sizeof namebuf, "%s", name); char *title = namebuf[0] ? prompt("Titel: ", "Styrelseledamot", 0) : NULL; if (namebuf[0] && title && *title) { yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); yyjson_mut_doc_set_root(d, o); yyjson_mut_obj_add_strcpy(d, o, "name", namebuf); yyjson_mut_obj_add_strcpy(d, o, "title", title); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *r = client_rpc(&a->conn, "board.add", a->session, a->org, args); free(args); if (r && client_ok(r)) message("Styrelseledamöter", "Tillagd."); else show_error("Kunde inte lägga till", r); free(r); } } else if (s >= 0 && (size_t)s < n) { if (!owner) { message("Styrelseledamöter", "Endast ägare kan ändra styrelseledamöterna."); } else { char namebuf[256] = ""; char *name = prompt("Namn: ", names[s] ? names[s] : "", 0); if (name && *name) snprintf(namebuf, sizeof namebuf, "%s", name); char *title = namebuf[0] ? prompt("Titel: ", titles[s] ? titles[s] : "", 0) : NULL; if (namebuf[0] && title && *title) { yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); yyjson_mut_doc_set_root(d, o); yyjson_mut_obj_add_int(d, o, "id", ids[s]); yyjson_mut_obj_add_strcpy(d, o, "name", namebuf); yyjson_mut_obj_add_strcpy(d, o, "title", title); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *r = client_rpc(&a->conn, "board.update", a->session, a->org, args); free(args); if (r && client_ok(r)) message("Styrelseledamöter", "Sparad."); else show_error("Kunde inte spara", r); free(r); } } } int back = s == -1; for (int i = 0; i < rows; i++) free(lines[i]); free(lines); for (size_t i = 0; i < n; i++) { free(names[i]); free(titles[i]); } free(names); free(titles); free(ids); free(resp); if (back) return; } } /* "Information om året": the per-year årsredovisning details. New years inherit the stable fields, so only what changed needs editing. */ static void yearinfo_screen(struct app *a) { static const char *const labels[6] = { "Väsentliga händelser", "Årsstämma (YYYY-MM-DD)", "Föreslagen utdelning (kr)", "Utdelningens datum (YYYY-MM-DD)", "Medelantal anställda", "Övriga noter (t.ex. revisor)", }; static const char *const keys[6] = { "events", "agm_date", "dividend_ore", "dividend_date", "employees", "notes" }; const int nf = 6; int can_edit = strcmp(a->role, "owner") == 0 || strcmp(a->role, "bookkeeper") == 0; for (;;) { if (g_quit) return; char args[48]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)a->fy); char *resp = client_rpc(&a->conn, "fiscal_year.get", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Information om året", resp); free(resp); return; } char *vals[6]; vals[0] = jstr_dup(resp, "result.events"); vals[1] = jstr_dup(resp, "result.agm_date"); char divbuf[48]; kr_format(jint_val(resp, "result.dividend_ore", 0), divbuf, sizeof divbuf); vals[2] = xstrdup(divbuf); vals[3] = jstr_dup(resp, "result.dividend_date"); vals[4] = jstr_dup(resp, "result.employees"); vals[5] = jstr_dup(resp, "result.notes"); for (int i = 0; i < nf; i++) if (!vals[i]) vals[i] = xstrdup(""); free(resp); int sel = 0; int back = 0; for (;;) { frame("Information om året"); attron(A_BOLD); mvaddstr(3, 2, "Uppgift"); mvaddstr(3, 34, "Värde"); attroff(A_BOLD); for (int i = 0; i < nf; i++) { char lbl[128], line[640]; snprintf(lbl, sizeof lbl, "%s", labels[i]); pad_field(lbl, sizeof lbl, 31); snprintf(line, sizeof line, "%s %s", lbl, vals[i]); if (i == sel) { attron(A_REVERSE); mvaddnstr(5 + i, 2, line, COLS - 4); attroff(A_REVERSE); } else { if (!can_edit) attron(A_DIM); mvaddnstr(5 + i, 2, line, COLS - 4); if (!can_edit) attroff(A_DIM); } } hints(can_edit ? "upp/ned Enter = ändra F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta" : "upp/ned F5 = uppdatera Esc/q = tillbaka" " ^C = avsluta (endast ägare/redovisare)"); refresh(); int ch = ui_getch(); if (ch == KEY_UP && sel > 0) sel--; else if (ch == KEY_DOWN && sel < nf - 1) sel++; else if (ch == KEY_F(5)) break; /* reload */ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { if (!can_edit) { message("Information om året", "Endast ägare eller redovisare kan ändra."); continue; } char label[160]; snprintf(label, sizeof label, "%s: ", labels[sel]); char *v = prompt(label, vals[sel], 0); if (!v) continue; yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); yyjson_mut_doc_set_root(d, o); yyjson_mut_obj_add_int(d, o, "id", a->fy); if (sel == 2) { int64_t ore = 0; if (parse_kr(v, &ore) != 0) { message("Ogiltigt värde", "%s", labels[sel]); yyjson_mut_doc_free(d); continue; } yyjson_mut_obj_add_int(d, o, keys[sel], ore); } else { if ((sel == 1 || sel == 3) && *v && !util_parse_iso_date(v)) { message("Ogiltigt datum", "%s", v); yyjson_mut_doc_free(d); continue; } yyjson_mut_obj_add_strcpy(d, o, keys[sel], v); } char *uargs = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *r = client_rpc(&a->conn, "fiscal_year.update", a->session, a->org, uargs); free(uargs); if (!r || !client_ok(r)) show_error("Kunde inte spara", r); free(r); break; /* reload from the server */ } else if (ch == 27 || ch == 'q') { back = 1; break; } } for (int i = 0; i < nf; i++) free(vals[i]); if (back) return; } } static void company_screen(struct app *a) { const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]); 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]; if (i < nf) { snprintf(lbl, sizeof lbl, "%s", COMPANY_FIELDS[i].label); pad_field(lbl, sizeof lbl, 31); snprintf(line, sizeof line, "%s %s", lbl, vals[i]); } else { snprintf(lbl, sizeof lbl, "Styrelseledamöter"); pad_field(lbl, sizeof lbl, 31); snprintf(line, sizeof line, "%s (lista)", lbl); } if (i == sel) { attron(A_REVERSE); mvaddnstr(5 + i, 2, line, COLS - 4); attroff(A_REVERSE); } else { if (!owner && i < nf) attron(A_DIM); mvaddnstr(5 + i, 2, line, COLS - 4); if (!owner && i < nf) 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) sel++; else if (ch == KEY_F(5)) break; /* reload */ else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { if (sel == nf) { if (!owner) { message("Företagsuppgifter", "Endast ägare kan ändra" " styrelseledamöterna."); continue; } board_screen(a); break; /* reload */ } if (!owner) { message("Företagsuppgifter", "Endast ägare kan ändra företagsuppgifterna."); 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 ? "arkivera" : NULL, 0); if (s == -1) { for (size_t i = 0; i <= n; i++) { free(names[i]); free(lines[i]); } free(names); free(lines); free(resp); return; } if (s == -6) { if (cursor >= 0 && (size_t)cursor < n) template_archive(a, names[cursor]); } else if (s == -4 || (s >= 0 && (size_t)s == n)) { template_form(a, NULL); } else if (s >= 0) { template_form(a, names[s]); } for (size_t i = 0; i <= n; i++) { free(names[i]); free(lines[i]); } free(names); free(lines); free(resp); if (s == -5) return; } } /* ------------------------------------------------------------------ */ /* bokslut */ /* ------------------------------------------------------------------ */ /* client_rpc() cannot carry the protocol's top-level dry_run, so build the request by hand. */ static char *rpc_dry(struct app *a, const char *cmd, const char *args) { size_t n = strlen(args) + strlen(cmd) + strlen(a->session) + 160; char *raw = xmalloc(n); snprintf(raw, n, "{\"v\":1,\"id\":\"tui\",\"cmd\":\"%s\",\"session\":\"%s\"," "\"org\":%lld,\"dry_run\":true,\"args\":%s}", cmd, a->session, (long long)a->org, args); char *r = NULL; if (client_send_line(&a->conn, raw) == 0) r = client_read_line(&a->conn); free(raw); return r; } static void fmt_bokslut_plan(struct buf *t, yyjson_val *res) { yyjson_val *fy = yyjson_obj_get(res, "fiscal_year"); if (fy) buf_line(t, "Bokslutsplan %s %s - %s\n", vstr(fy, "label"), vstr(fy, "start_date"), vstr(fy, "end_date")); yyjson_val *vs = yyjson_obj_get(res, "vouchers"); size_t n = yyjson_arr_size(vs); if (n == 0) { buf_line(t, "\nInget att bokföra.\n"); return; } char pser[16] = ""; long long pnum = 0; for (size_t i = 0; i < n; i++) { yyjson_val *v = yyjson_arr_get(vs, i); yyjson_val *pv = yyjson_obj_get(v, "voucher"); const char *ser = vstr(pv, "series"); long long num = (long long)vint(pv, "number"); if (strcmp(ser, pser) != 0) { snprintf(pser, sizeof pser, "%s", ser); pnum = num; } else if (num <= pnum) { num = pnum + 1; } pnum = num; buf_line(t, "\n%s%lld %s\n", pser, num, vstr(v, "description")); yyjson_val *rows = yyjson_obj_get(v, "rows"); size_t nr = yyjson_arr_size(rows); for (size_t k = 0; k < nr; k++) { yyjson_val *r = yyjson_arr_get(rows, k); char a1[48], a2[48]; amt_col(a1, sizeof a1, 15, vint(r, "debit_ore")); amt_col(a2, sizeof a2, 15, vint(r, "credit_ore")); buf_line(t, " %-5s %s %s\n", vstr(r, "account"), a1, a2); } } } static void bokslut_plan(struct app *a, const char *fond, const char *rate, int do_post) { int64_t fond_ore = 0, rate_ore = 0; if (parse_kr(fond, &fond_ore) != 0) { message("Bokslut", "Ogiltigt fondbelopp: %s", fond); return; } if (parse_kr(rate, &rate_ore) != 0 || rate_ore < 0 || rate_ore > 10000) { message("Bokslut", "Ogiltig skattesats: %s", rate); return; } char args[160]; snprintf(args, sizeof args, "{\"fiscal_year\":%lld,\"periodiseringsfond_ore\":%lld," "\"tax_rate\":%.2f}", (long long)a->fy, (long long)fond_ore, (double)rate_ore / 100.0); char *resp = rpc_dry(a, "bokslut.post", args); if (!resp || !client_ok(resp)) { show_error("Bokslut", resp); free(resp); return; } yyjson_doc *d = parse(resp); yyjson_val *res = d ? jget(yyjson_doc_get_root(d), "result") : NULL; struct buf t; buf_init(&t); if (res) fmt_bokslut_plan(&t, res); else buf_line(&t, "%s", resp); buf_append(&t, "", 1); text_view("Bokslut - plan (torrkörning)", (const char *)t.p, NULL); buf_free(&t); yyjson_doc_free(d); free(resp); if (!do_post) return; char *ans = prompt("Bokför planen? (j/n): ", "n", 0); if (!ans || !(ans[0] == 'j' || ans[0] == 'J')) return; char *presp = client_rpc(&a->conn, "bokslut.post", a->session, a->org, args); if (!presp || !client_ok(presp)) { show_error("Bokslut", presp); free(presp); return; } yyjson_doc *pd = parse(presp); yyjson_val *pr = pd ? jget(yyjson_doc_get_root(pd), "result") : NULL; struct buf m; buf_init(&m); buf_line(&m, "Bokslutet är bokfört:"); yyjson_val *vs = pr ? yyjson_obj_get(pr, "vouchers") : NULL; size_t n = yyjson_arr_size(vs); for (size_t i = 0; i < n; i++) { yyjson_val *v = yyjson_arr_get(vs, i); yyjson_val *pv = yyjson_obj_get(v, "voucher"); buf_line(&m, "\n %s%lld %s", vstr(pv, "series"), (long long)vint(pv, "number"), vstr(v, "description")); } message("Bokslut", "%s", (const char *)m.p); buf_free(&m); yyjson_doc_free(pd); free(presp); } static void bokslut_screen(struct app *a) { char fond[32] = "0,00"; char rate[16] = "20,6"; int sel = 0; for (;;) { if (g_quit) return; frame("Bokslut"); attron(A_BOLD); mvaddstr(4, 2, "Fält"); mvaddstr(4, 34, "Värde"); attroff(A_BOLD); static const char *const labels[2] = { "Periodiseringsfond (kr)", "Skattesats (%)" }; const char *vals[2] = { fond, rate }; for (int i = 0; i < 2; i++) { char lbl[64], line[128]; snprintf(lbl, sizeof lbl, "%s", labels[i]); pad_field(lbl, sizeof lbl, 31); snprintf(line, sizeof line, "%s %s", lbl, vals[i]); if (i == sel) attron(A_REVERSE); mvaddnstr(6 + i, 2, line, COLS - 4); if (i == sel) attroff(A_REVERSE); } mvaddstr(9, 2, "Fondbelopp i kr, tomt = ingen avsättning."); mvaddstr(10, 2, "Skattesatsen tillämpas på resultat efter" " bokslutsdispositioner."); hints("upp/ned Enter = ändra F5 = visa plan ^Enter = bokför" " Esc/q = tillbaka ^C = avsluta"); refresh(); int ch = ui_getch(); if (ch == KEY_UP && sel > 0) sel--; else if (ch == KEY_DOWN && sel < 1) sel++; else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { char label[80]; snprintf(label, sizeof label, "%s: ", labels[sel]); char *v = prompt(label, vals[sel], 0); if (!v) continue; int64_t ore = 0; if (parse_kr(v, &ore) != 0 || (sel == 1 && (ore < 0 || ore > 10000))) { message("Bokslut", "Ogiltigt värde: %s", v); continue; } if (sel == 0) snprintf(fond, sizeof fond, "%s", v); else snprintf(rate, sizeof rate, "%s", v); } else if (ch == KEY_F(5)) { bokslut_plan(a, fond, rate, 0); } else if (ch == KEY_CTRL_ENTER || ch == KEY_F(9)) { bokslut_plan(a, fond, rate, 1); } else if (ch == 27 || ch == 'q') { return; } } } /* ------------------------------------------------------------------ */ /* K2 årsredovisning (draft text) */ /* ------------------------------------------------------------------ */ /* Whole kronor with space thousands; rounded half away from zero. */ static void kr0_format(int64_t ore, char *buf, size_t n) { long long v = (long long)(ore >= 0 ? (ore + 50) / 100 : (ore - 50) / 100); int neg = v < 0; if (neg) v = -v; char digits[32], out[40]; snprintf(digits, sizeof digits, "%lld", v); size_t o = 0, len = strlen(digits); for (size_t i = 0; i < len && o + 2 < sizeof out; i++) { if (i > 0 && (len - i) % 3 == 0) out[o++] = ' '; out[o++] = digits[i]; } out[o] = '\0'; snprintf(buf, n, "%s%s", neg ? "-" : "", out); } /* Thousands of kronor for the flerårsöversikt. */ static long long ar_tkr(int64_t ore) { return (long long)(ore >= 0 ? (ore + 50000) / 100000 : (ore - 50000) / 100000); } #define AR_YEARS 5 #define AR_GROUPS 7 struct ar_year { char label[8]; char from[16], to[16]; int64_t netto, ror, res_fin, skatt, res, disp; int64_t fin_in, fin_out; int64_t groups[AR_GROUPS]; }; /* Balance-sheet amounts are shown the way an annual report reads them: credit balances (equity, liabilities) as positive numbers. */ static int64_t ar_bs_ib_sum(yyjson_val *res, int lo, int hi) { int64_t s = 0; if (!res) return 0; for (int k = 0; k < 3; k++) { yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]); yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; size_t n = yyjson_arr_size(arr); for (size_t i = 0; i < n; i++) { yyjson_val *row = yyjson_arr_get(arr, i); if (!acct_in(vstr(row, "account"), lo, hi)) continue; int64_t ib = vint(row, "ib_ore"); s += lo >= 2000 ? -ib : ib; } } return s; } static int64_t ar_bs_sum(yyjson_val *res, int lo, int hi) { int64_t s = 0; if (!res) return 0; for (int k = 0; k < 3; k++) { yyjson_val *sec = yyjson_obj_get(res, BS_KEYS[k]); yyjson_val *arr = sec ? yyjson_obj_get(sec, "accounts") : NULL; size_t n = yyjson_arr_size(arr); for (size_t i = 0; i < n; i++) { yyjson_val *row = yyjson_arr_get(arr, i); if (!acct_in(vstr(row, "account"), lo, hi)) continue; int64_t ub = vint(row, "ub_ore"); s += lo >= 2000 ? -ub : ub; } } return s; } /* One year's income statement from its general ledger. Vouchers whose description starts with "Stäng" are the source system's closings in imported history years and are skipped, so those years show their real figures instead of a zeroed P&L. */ static void ar_load_year(struct ar_year *y, yyjson_val *gl) { memset(y, 0, sizeof *y); yyjson_val *fy = yyjson_obj_get(gl, "fiscal_year"); snprintf(y->label, sizeof y->label, "%.4s", vstr(fy, "start_date")); snprintf(y->from, sizeof y->from, "%s", vstr(fy, "start_date")); snprintf(y->to, sizeof y->to, "%s", vstr(fy, "end_date")); yyjson_val *accts = yyjson_obj_get(gl, "accounts"); size_t na = yyjson_arr_size(accts); for (size_t i = 0; i < na; i++) { yyjson_val *acct = yyjson_arr_get(accts, i); const char *num = vstr(acct, "account"); int n = atoi(num); if (n < 3000 || n > 8999) continue; int64_t net = 0; yyjson_val *rows = yyjson_obj_get(acct, "rows"); size_t nr = yyjson_arr_size(rows); for (size_t k = 0; k < nr; k++) { yyjson_val *row = yyjson_arr_get(rows, k); if (strncmp(vstr(row, "description"), "Stäng", 7) == 0) continue; net += vint(row, "debit_ore") - vint(row, "credit_ore"); } int64_t disp = -net; int done = 0; for (size_t g = 0; g < AR_GROUPS; g++) { if (acct_in(num, IS_GROUPS[g].lo, IS_GROUPS[g].hi)) { y->groups[g] += disp; done = 1; break; } } if (done) continue; if (n >= 8000 && n <= 8399) y->fin_in += disp; else if (n >= 8400 && n <= 8599) y->fin_out += disp; else if (n >= 8800 && n <= 8899) y->disp += disp; else if (n >= 8900 && n <= 8989) y->skatt += disp; } int64_t rev = 0, cost = 0; for (size_t g = 0; g < AR_GROUPS; g++) if (IS_GROUPS[g].rev) rev += y->groups[g]; else cost += y->groups[g]; y->netto = y->groups[0]; y->ror = rev + cost; y->res_fin = y->ror + y->fin_in + y->fin_out; y->res = y->res_fin + y->disp + y->skatt; } static void ar_amt_col(char *buf, size_t n, int width, int64_t ore) { char tmp[40]; kr0_format(ore, tmp, sizeof tmp); snprintf(buf, n, "%*s", width, tmp); } static void ar_row(struct buf *t, const char *label, int64_t cur, int64_t prev, int indent) { char nm[160], padded[200], a1[40], a2[40]; snprintf(nm, sizeof nm, "%s%s", indent ? " " : "", label); pad_label(padded, sizeof padded, nm, 48); ar_amt_col(a1, sizeof a1, 16, cur); ar_amt_col(a2, sizeof a2, 16, prev); buf_line(t, "%s %s %s\n", padded, a1, a2); } /* Equity table: four amount columns. */ static void ar_eq_row(struct buf *t, const char *label, int64_t ak, int64_t br, int64_t ar, int indent) { char nm[160], padded[200], c1[32], c2[32], c3[32], c4[32]; snprintf(nm, sizeof nm, "%s%s", indent ? " " : "", label); pad_label(padded, sizeof padded, nm, 34); ar_amt_col(c1, sizeof c1, 12, ak); ar_amt_col(c2, sizeof c2, 14, br); ar_amt_col(c3, sizeof c3, 14, ar); ar_amt_col(c4, sizeof c4, 14, ak + br + ar); buf_line(t, "%s %s %s %s %s\n", padded, c1, c2, c3, c4); } static void ar_wrap(struct buf *t, const char *text) { char line[128] = ""; const char *p = text; while (*p) { while (*p == ' ' || *p == '\n' || *p == '\t') p++; if (!*p) break; size_t n = strcspn(p, " \n\t"); size_t ll = strlen(line); if (ll + (ll ? 1 : 0) + n > 76) { buf_line(t, "%s\n", line); ll = 0; line[0] = '\0'; } if (ll) { line[ll++] = ' '; line[ll] = '\0'; } if (n > sizeof line - 1 - ll) n = sizeof line - 1 - ll; memcpy(line + ll, p, n); line[ll + n] = '\0'; p += n; } if (line[0]) buf_line(t, "%s\n", line); } /* "2026-06-04" -> "4 juni 2026"; anything else becomes a placeholder. */ static void ar_sv_date(const char *iso, char *out, size_t n) { static const char *const months[12] = { "januari", "februari", "mars", "april", "maj", "juni", "juli", "augusti", "september", "oktober", "november", "december" }; int y = 0, m = 0, d = 0; if (!iso || strlen(iso) != 10 || sscanf(iso, "%d-%d-%d", &y, &m, &d) != 3 || m < 1 || m > 12 || d < 1 || d > 31) { snprintf(out, n, "[datum]"); return; } snprintf(out, n, "%d %s %d", d, months[m - 1], y); } static void fmt_arsredovisning(struct buf *t, const struct app *a, const char *org_nr, const char *city, const char *description, int64_t shares, yyjson_val *fyj, const char *board_text, const char *first_board, yyjson_val *bs, struct ar_year *years, int nyears) { struct ar_year *cur = &years[nyears - 1]; struct ar_year *prev = nyears > 1 ? &years[nyears - 2] : NULL; int64_t ars = cur->res; int64_t prev_ars = prev ? prev->res : 0; const char *events = vstr(fyj, "events"); const char *employees = vstr(fyj, "employees"); const char *notes = vstr(fyj, "notes"); int64_t dividend = vint(fyj, "dividend_ore"); char agm_sv[64], pay_sv[64]; ar_sv_date(vstr(fyj, "agm_date"), agm_sv, sizeof agm_sv); ar_sv_date(vstr(fyj, "dividend_date"), pay_sv, sizeof pay_sv); buf_line(t, "ÅRSREDOVISNING\n\n%s\nOrg nr %s\n\n", a->org_name, org_nr); buf_line(t, "Årsredovisning för räkenskapsåret %s - %s\n\n", a->fy_start, a->fy_end); buf_line(t, "Styrelsen avger följande årsredovisning.\n\n"); buf_line(t, "Om inte annat särskilt anges, redovisas alla belopp i hela" " kronor.\n"); buf_line(t, "\nFastställelseintyg\n\n"); buf_line(t, "Undertecknad styrelseledamot i %s intygar, dels att denna" " kopia av\nårsredovisningen stämmer överens med originalet," " dels att resultaträkningen\noch balansräkningen har" " fastställts på årsstämma den %s. Årsstämman\nbeslutade" " att godkänna styrelsens förslag till hur vinsten ska" " fördelas.\n\n", a->org_name, agm_sv); buf_line(t, "%s %s\n\nNamnteckning:" " ___________________________\n\nNamnförtydligande: %s\n", city, agm_sv, first_board); buf_line(t, "\nFörvaltningsberättelse\n\nVerksamheten\n\nAllmänt om" " verksamheten\n"); if (description && *description) ar_wrap(t, description); else buf_line(t, "[Kort beskrivning av verksamheten saknas — fyll i under" " Företagsuppgifter.]\n"); buf_line(t, "\nBolaget har sitt säte i %s.\n", city); buf_line(t, "\nVäsentliga händelser under räkenskapsåret\n"); if (events && *events) ar_wrap(t, events); else buf_line(t, "[Beskriv väsentliga händelser.]\n"); if (nyears > 1) { char row[512]; int off = snprintf(row, sizeof row, "%-38s", "Flerårsöversikt"); for (int i = 0; i < nyears; i++) off += snprintf(row + off, sizeof row - (size_t)off, "%9s", years[i].label); buf_line(t, "\n%s\n\n", row); off = snprintf(row, sizeof row, "%-38s", "Nettoomsättning, tkr"); for (int i = 0; i < nyears; i++) off += snprintf(row + off, sizeof row - (size_t)off, "%9lld", ar_tkr(years[i].netto)); buf_line(t, "%s\n", row); off = snprintf(row, sizeof row, "%-38s", "Resultat efter finansiella poster, tkr"); for (int i = 0; i < nyears; i++) off += snprintf(row + off, sizeof row - (size_t)off, "%9lld", ar_tkr(years[i].res_fin)); buf_line(t, "%s\n", row); } int64_t ak_ib = ar_bs_ib_sum(bs, 2081, 2089); int64_t ak_ub = ar_bs_sum(bs, 2081, 2089); int64_t br_ib = ar_bs_ib_sum(bs, 2091, 2098); int64_t br_ub = ar_bs_sum(bs, 2091, 2098); int64_t movement = br_ub - br_ib; int64_t paid_div = prev_ars - movement; if (paid_div < 0) paid_div = 0; int64_t balanced = prev_ars - paid_div; if (prev) { char row[512]; int off = snprintf(row, sizeof row, "%-34s", "Förändringar i eget" " kapital"); off += snprintf(row + off, sizeof row - (size_t)off, "%12s", "Aktiekapital"); off += snprintf(row + off, sizeof row - (size_t)off, "%14s", "Balanserat"); off += snprintf(row + off, sizeof row - (size_t)off, "%14s", "Årets"); off += snprintf(row + off, sizeof row - (size_t)off, "%14s", "Totalt"); buf_line(t, "\n%s\n", row); buf_line(t, "%s\n", " resultat" " resultat"); int has_disp = movement != 0; int64_t ar_ib = has_disp ? prev_ars : 0; ar_eq_row(t, "Belopp vid årets ingång", ak_ib, br_ib, ar_ib, 0); if (has_disp) { buf_line(t, "Resultatdisposition enligt årsstämman\n"); ar_eq_row(t, "Utdelning", 0, -paid_div, 0, 1); ar_eq_row(t, "Balanseras i ny räkning", 0, balanced, -prev_ars, 1); } ar_eq_row(t, "Årets resultat", 0, 0, ars, 0); ar_eq_row(t, "Belopp vid årets utgång", ak_ub, br_ub, ars, 0); } int64_t dispon = br_ub + ars; buf_line(t, "\nResultatdisposition\n\nTill årsstämmans förfogande står" " följande vinstmedel:\n"); ar_row(t, "Balanserat resultat", br_ub, 0, 1); ar_row(t, "Årets resultat", ars, 0, 1); ar_row(t, "Totalt", dispon, 0, 1); buf_line(t, "\nStyrelsen föreslår att vinstmedlen disponeras enligt" " följande:\n"); ar_row(t, "Utdelning till ägarna", dividend, 0, 1); ar_row(t, "Balanseras i ny räkning", dispon - dividend, 0, 1); ar_row(t, "Totalt", dispon, 0, 1); if (dividend > 0) { buf_line(t, "\nStyrelsens yttrande över den föreslagna" " vinstutdelningen\n\n"); if (shares > 0) { char per[48]; kr_format((dividend + shares / 2) / shares, per, sizeof per); buf_line(t, "Utdelning per aktie: %s kr. Datum för utdelning är" " %s.\n\n", per, pay_sv); } else { buf_line(t, "Datum för utdelning är %s.\n\n", pay_sv); } buf_line(t, "Likviditeten i bolaget bedöms kunna upprätthållas på en" " likaledes betryggande nivå.\n\n"); buf_line(t, "Styrelsens uppfattning är att den föreslagna utdelningen" " ej hindrar bolaget\nfrån att fullgöra sina förpliktelser" " på kort och lång sikt, ej heller att fullgöra\nerforderliga" " investeringar. Den föreslagna utdelningen kan därmed" " försvaras med\nhänsyn till vad som anförs i ABL 17 kap." " 3 § 2 st. (försiktighetsregeln).\n"); } buf_line(t, "\nResultaträkning\nSamtliga belopp anges i svenska" " kronor.\n\n"); { char h1[40], h2[40], row[512]; snprintf(h1, sizeof h1, "%s - %s", cur->from, cur->to); if (prev) snprintf(h2, sizeof h2, "%s - %s", prev->from, prev->to); else h2[0] = '\0'; int off = snprintf(row, sizeof row, "%-48s", ""); off += snprintf(row + off, sizeof row - (size_t)off, "%16s", h1); if (h2[0]) off += snprintf(row + off, sizeof row - (size_t)off, "%17s", h2); buf_line(t, "%s\n", row); } buf_line(t, "Rörelseintäkter, lagerförändringar m.m.\n"); ar_row(t, "Nettoomsättning", cur->groups[0], prev ? prev->groups[0] : 0, 1); int64_t rev_cur = cur->groups[0] + cur->groups[1]; int64_t rev_pre = prev ? prev->groups[0] + prev->groups[1] : 0; ar_row(t, "Summa Rörelseintäkter, lagerförändringar m.m.", rev_cur, rev_pre, 1); buf_line(t, "\nRörelsekostnader\n"); int64_t cost_cur = 0, cost_pre = 0; for (size_t g = 2; g < AR_GROUPS; g++) { int64_t c = cur->groups[g], p = prev ? prev->groups[g] : 0; if (c || p) ar_row(t, IS_GROUPS[g].title, c, p, 1); cost_cur += c; cost_pre += p; } ar_row(t, "Summa Rörelsens kostnader", cost_cur, cost_pre, 1); buf_line(t, "\n"); ar_row(t, "Rörelseresultat", cur->ror, prev ? prev->ror : 0, 0); if (cur->fin_in || (prev && prev->fin_in)) ar_row(t, "Ränteintäkter", cur->fin_in, prev ? prev->fin_in : 0, 0); if (cur->fin_out || (prev && prev->fin_out)) ar_row(t, "Räntekostnader", cur->fin_out, prev ? prev->fin_out : 0, 0); ar_row(t, "Resultat efter finansiella poster", cur->res_fin, prev ? prev->res_fin : 0, 0); int64_t btax = cur->res_fin + cur->disp; int64_t btax_pre = prev ? prev->res_fin + prev->disp : 0; if (cur->disp || (prev && prev->disp)) { buf_line(t, "\nBokslutsdispositioner\n"); ar_row(t, "Summa", cur->disp, prev ? prev->disp : 0, 1); } ar_row(t, "Resultat före skatt", btax, btax_pre, 0); if (cur->skatt || (prev && prev->skatt)) ar_row(t, "Skatt på årets resultat", cur->skatt, prev ? prev->skatt : 0, 0); buf_line(t, "\n"); ar_row(t, "Årets resultat", ars, prev_ars, 0); buf_line(t, "\nBalansräkning\n\n"); { char h1[40], h2[40], row[256]; snprintf(h1, sizeof h1, "%s", cur->to); if (prev) snprintf(h2, sizeof h2, "%s", prev->to); else h2[0] = '\0'; int off = snprintf(row, sizeof row, "%-48s", ""); off += snprintf(row + off, sizeof row - (size_t)off, "%16s", h1); if (h2[0]) off += snprintf(row + off, sizeof row - (size_t)off, "%17s", h2); buf_line(t, "%s\n", row); } buf_line(t, "Tillgångar\nAnläggningstillgångar\n"); ar_row(t, "Summa anläggningstillgångar", ar_bs_sum(bs, 1000, 1399), ar_bs_ib_sum(bs, 1000, 1399), 1); buf_line(t, "Omsättningstillgångar\n"); int64_t v = ar_bs_sum(bs, 1400, 1499), vp = ar_bs_ib_sum(bs, 1400, 1499); if (v || vp) ar_row(t, "Varulager", v, vp, 0); ar_row(t, "Kortfristiga fordringar", ar_bs_sum(bs, 1500, 1699), ar_bs_ib_sum(bs, 1500, 1699), 0); v = ar_bs_sum(bs, 1700, 1899); vp = ar_bs_ib_sum(bs, 1700, 1899); if (v || vp) ar_row(t, "Kortfristiga placeringar", v, vp, 0); ar_row(t, "Kassa och bank", ar_bs_sum(bs, 1900, 1999), ar_bs_ib_sum(bs, 1900, 1999), 0); ar_row(t, "Summa tillgångar", ar_bs_sum(bs, 1000, 1999), ar_bs_ib_sum(bs, 1000, 1999), 0); buf_line(t, "\nEget kapital och skulder\nBundet eget kapital\n"); ar_row(t, "Aktiekapital", ak_ub, ak_ib, 1); ar_row(t, "Summa bundet eget kapital", ar_bs_sum(bs, 2000, 2090), ar_bs_ib_sum(bs, 2000, 2090), 1); buf_line(t, "Fritt eget kapital\n"); ar_row(t, "Balanserat resultat", br_ub, br_ib, 1); ar_row(t, "Årets resultat", ars, prev_ars, 1); ar_row(t, "Summa fritt eget kapital", ar_bs_sum(bs, 2091, 2098) + ars, ar_bs_ib_sum(bs, 2091, 2098) + prev_ars, 1); ar_row(t, "Summa eget kapital", ar_bs_sum(bs, 2000, 2090) + ar_bs_sum(bs, 2091, 2098) + ars, ar_bs_ib_sum(bs, 2000, 2090) + ar_bs_ib_sum(bs, 2091, 2098) + prev_ars, 0); buf_line(t, "\n"); ar_row(t, "Obeskattade reserver", ar_bs_sum(bs, 2100, 2199), ar_bs_ib_sum(bs, 2100, 2199), 0); ar_row(t, "Avsättningar", ar_bs_sum(bs, 2200, 2299), ar_bs_ib_sum(bs, 2200, 2299), 0); buf_line(t, "Kortfristiga skulder\n"); ar_row(t, "Skatteskulder", ar_bs_sum(bs, 2500, 2599), ar_bs_ib_sum(bs, 2500, 2599), 1); ar_row(t, "Mervärdesskatt", ar_bs_sum(bs, 2600, 2699), ar_bs_ib_sum(bs, 2600, 2699), 1); ar_row(t, "Personalskatter och sociala avgifter", ar_bs_sum(bs, 2700, 2799), ar_bs_ib_sum(bs, 2700, 2799), 1); ar_row(t, "Övriga kortfristiga skulder", ar_bs_sum(bs, 2800, 2999) + ar_bs_sum(bs, 2400, 2499), ar_bs_ib_sum(bs, 2800, 2999) + ar_bs_ib_sum(bs, 2400, 2499), 1); int64_t skuld = ar_bs_sum(bs, 2400, 2999); int64_t skuld_p = ar_bs_ib_sum(bs, 2400, 2999); ar_row(t, "Summa kortfristiga skulder", skuld, skuld_p, 1); ar_row(t, "Summa eget kapital, avsättningar och skulder", ar_bs_sum(bs, 2000, 2999) + ars, ar_bs_ib_sum(bs, 2000, 2999) + prev_ars, 0); buf_line(t, "\nNoter\n\nNot 1 Redovisnings- och värderingsprinciper\n"); buf_line(t, "Årsredovisningen är upprättad i enlighet med" " årsredovisningslagen och\nBFNAR 2016:10 Årsredovisning i" " mindre företag.\n"); buf_line(t, "\nNot 2 Medelantal anställda\n"); buf_line(t, "Medelantalet anställda under räkenskapsåret har uppgått" " till %s.\n", employees && *employees ? employees : "[antal]"); if (notes && *notes) { buf_line(t, "\nNot 3 Övriga upplysningar\n"); ar_wrap(t, notes); } buf_line(t, "\nUnderskrifter\n\n%s %s\n\n", city, agm_sv); if (board_text && *board_text) buf_line(t, "%s", board_text); else buf_line(t, "[Lägg till styrelseledamöter under" " Företagsuppgifter.]\n"); } static void arsredovisning_save(struct app *a, const char *text) { const char *home = getenv("HOME"); char def[512]; snprintf(def, sizeof def, "%s/Downloads", home && *home ? home : "."); struct stat sb; if (stat(def, &sb) != 0 || !S_ISDIR(sb.st_mode)) snprintf(def, sizeof def, "%s", home && *home ? home : "."); char *dir = prompt("Spara årsredovisningen i: ", def, 0); if (!dir || !*dir) return; char full[640]; if (dir[0] == '~' && (dir[1] == '/' || dir[1] == '\0')) snprintf(full, sizeof full, "%s%s", home ? home : "", dir + 1); else snprintf(full, sizeof full, "%s", dir); size_t fl = strlen(full); if (fl && full[fl - 1] != '/') snprintf(full + fl, sizeof full - fl, "/"); char name[80]; snprintf(name, sizeof name, "%s", a->fy_label); for (char *p = name; *p; p++) if (*p == '/') *p = '-'; snprintf(full + strlen(full), sizeof full - strlen(full), "Årsredovisning %s.txt", *name ? name : "utkast"); FILE *f = fopen(full, "wb"); size_t n = strlen(text); if (!f || fwrite(text, 1, n, f) != n) { if (f) fclose(f); message("Årsredovisning", "Kunde inte skriva %s: %s", full, strerror(errno)); return; } fclose(f); message("Årsredovisning", "Sparat: %s\n\nFilen är ett utkast i textform" " att redigera vidare (t.ex. i Word).", full); } static void arsredovisning_screen(struct app *a) { for (;;) { char *fresp = client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}"); if (!fresp || !client_ok(fresp)) { show_error("Årsredovisning", fresp); free(fresp); return; } struct ar_year years[AR_YEARS]; int nyears = 0; { yyjson_doc *fd = parse(fresp); yyjson_val *items = fd ? jget(yyjson_doc_get_root(fd), "result.items") : NULL; size_t n = yyjson_arr_size(items); int idx = -1; for (size_t i = 0; i < n; i++) { yyjson_val *it = yyjson_arr_get(items, i); if (vint(it, "id") == a->fy) idx = (int)i; } if (idx < 0) idx = (int)n - 1; int start = idx - (AR_YEARS - 1); if (start < 0) start = 0; for (int i = start; i <= idx && nyears < AR_YEARS; i++) { yyjson_val *it = yyjson_arr_get(items, (size_t)i); char gargs[64]; snprintf(gargs, sizeof gargs, "{\"fiscal_year\":%lld}", (long long)vint(it, "id")); char *gresp = client_rpc(&a->conn, "report.general_ledger", a->session, a->org, gargs); if (!gresp || !client_ok(gresp)) { show_error("Årsredovisning", gresp); free(gresp); yyjson_doc_free(fd); free(fresp); return; } yyjson_doc *gd = parse(gresp); yyjson_val *g = gd ? jget(yyjson_doc_get_root(gd), "result") : NULL; if (g) ar_load_year(&years[nyears++], g); yyjson_doc_free(gd); free(gresp); } yyjson_doc_free(fd); } free(fresp); char bargs[64]; snprintf(bargs, sizeof bargs, "{\"fiscal_year\":%lld}", (long long)a->fy); char *bresp = client_rpc(&a->conn, "report.balance_sheet", a->session, a->org, bargs); char *oresp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}"); char *yresp = client_rpc(&a->conn, "fiscal_year.get", a->session, a->org, "{}"); char *mresp = client_rpc(&a->conn, "board.list", a->session, a->org, "{}"); if (!bresp || !client_ok(bresp) || !oresp || !client_ok(oresp) || !yresp || !client_ok(yresp) || !mresp || !client_ok(mresp) || nyears == 0) { show_error("Årsredovisning", bresp && !client_ok(bresp) ? bresp : oresp); free(bresp); free(oresp); free(yresp); free(mresp); return; } yyjson_doc *bd = parse(bresp), *od = parse(oresp), *yd = parse(yresp); yyjson_val *bs = bd ? jget(yyjson_doc_get_root(bd), "result") : NULL; yyjson_val *org = od ? jget(yyjson_doc_get_root(od), "result") : NULL; yyjson_val *fyj = yd ? jget(yyjson_doc_get_root(yd), "result") : NULL; char *onr = org ? jstr_dup(oresp, "result.org_nr") : NULL; char *city = org ? jstr_dup(oresp, "result.city") : NULL; char *desc = org ? jstr_dup(oresp, "result.description") : NULL; int64_t shares = org ? jint_val(oresp, "result.shares", 0) : 0; char board_text[1024] = ""; char first_board[128] = "[namn]"; size_t nb = jarr_size(mresp, "result.items"); for (size_t i = 0; i < nb && strlen(board_text) < 900; i++) { char path[64]; snprintf(path, sizeof path, "result.items.%zu.title", i); char *ti = jstr_dup(mresp, path); snprintf(path, sizeof path, "result.items.%zu.name", i); char *nm = jstr_dup(mresp, path); char line[256]; snprintf(line, sizeof line, "%s %s\n\n", ti ? ti : "", nm ? nm : ""); strncat(board_text, line, sizeof board_text - strlen(board_text) - 1); if (i == 0) snprintf(first_board, sizeof first_board, "%s", nm && *nm ? nm : "[namn]"); free(ti); free(nm); } struct buf t; buf_init(&t); if (bs && nyears > 0) fmt_arsredovisning(&t, a, onr && *onr ? onr : "[org.nr]", city && *city ? city : "[Ort]", desc ? desc : "", shares, fyj, board_text, first_board, bs, years, nyears); else buf_line(&t, "Kunde inte läsa rapporterna."); buf_append(&t, "", 1); int again = text_view("Årsredovisning (K2)", (const char *)t.p, "s = spara"); if (again == 2) arsredovisning_save(a, (const char *)t.p); buf_free(&t); free(onr); free(city); free(desc); yyjson_doc_free(bd); yyjson_doc_free(od); yyjson_doc_free(yd); free(bresp); free(oresp); free(yresp); free(mresp); if (again != 1 && again != 2) return; } } /* Screen names for ^R restoration and --screen. Only the top-level views; sub-screens unwind to their parent. */ static void open_scene(struct app *a, const char *scene) { snprintf(g_scene, sizeof g_scene, "%s", scene); if (strcmp(scene, "vouchers") == 0) vouchers_screen(a); else if (strcmp(scene, "inbox") == 0) inbox_screen(a); else if (strcmp(scene, "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); else if (strcmp(scene, "bokslut") == 0) bokslut_screen(a); else if (strcmp(scene, "yearinfo") == 0) yearinfo_screen(a); } static void do_reload(struct app *a, const char *self) { 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", "Bokslut", "Information om året", "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, 12, 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: open_scene(a, "bokslut"); break; case 9: open_scene(a, "yearinfo"); break; case 10: select_fiscal_year(a); break; case 11: 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, NULL, 0); for (size_t i = 0; i < n; i++) free(items[i]); free(items); free(resp); if (sel < 0) return -1; a->org = ids[sel]; free(ids); return 0; } static void usage(FILE *f) { fprintf(f, "usage: bokftui [options]\n" " --socket TARGET unix path, tcp:host:port or tls:host:port\n" " --user NAME prefill username\n" " --org ID select org directly\n" " --fy ID select fiscal year directly\n" " --session ID reuse an open session (or BOKFD_SESSION)\n" " --screen NAME start in a view: dashboard, vouchers, inbox,\n" " ib, templates, reports, bokslut, yearinfo,\n" " 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; }