#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "client.h" #include "tui.h" #include "formula.h" #include "util.h" #include "version.h" #include "yyjson.h" #include "ui.h" /* ------------------------------------------------------------------ */ /* lön: Lönekörningar, Anställda, Skattetabeller */ /* ------------------------------------------------------------------ */ static void payroll_today(char *buf, size_t n) { time_t now = time(NULL); struct tm tm; localtime_r(&now, &tm); char tmp[64]; snprintf(tmp, sizeof tmp, "%04d-%02d-%02d", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday); size_t len = strlen(tmp); if (len >= n) len = n ? n - 1 : 0; memcpy(buf, tmp, len); if (n) buf[len] = '\0'; } static int payroll_can_write(struct app *a) { return strcmp(a->role, "owner") == 0 || strcmp(a->role, "bookkeeper") == 0; } static int payroll_is_owner(struct app *a) { return strcmp(a->role, "owner") == 0; } static int payroll_digits(const char *s) { if (!s || !*s) return 0; for (const char *p = s; *p; p++) if (*p < '0' || *p > '9') return 0; return 1; } static int payroll_parse_int(const char *s, int lo, int hi, int *out) { if (!s || !*s) return -1; char *end = NULL; long v = strtol(s, &end, 10); if (!end || *end || v < lo || v > hi) return -1; *out = (int)v; return 0; } static const char *payroll_status_sv(const char *s) { return s && strcmp(s, "paid") == 0 ? "betald" : "bokförd"; } static void payroll_voucher_ver(struct app *a, int64_t vid, char *buf, size_t n) { buf[0] = '\0'; if (vid <= 0) return; char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)vid); char *resp = client_rpc(&a->conn, "voucher.get", a->session, a->org, args); if (resp && client_ok(resp)) { char *ser = jstr_dup(resp, "result.series"); snprintf(buf, n, "%s%lld", ser ? ser : "", (long long)jint_val(resp, "result.number", 0)); free(ser); } free(resp); } static void payroll_copy(char *dst, size_t n, const char *resp, const char *path) { char *v = jstr_dup(resp, path); snprintf(dst, n, "%s", v ? v : ""); free(v); } static int payroll_period_valid(const char *p) { if (!p || strlen(p) != 7 || p[4] != '-') return 0; for (int i = 0; i < 7; i++) { if (i == 4) continue; if (p[i] < '0' || p[i] > '9') return 0; } int m = (p[5] - '0') * 10 + (p[6] - '0'); return m >= 1 && m <= 12; } /* ------------------------------------------------------------------ */ /* Anställda (employee register) */ /* ------------------------------------------------------------------ */ struct employee_form { int64_t id; int is_new; char name[200]; char personal_no[16]; char address[512]; char postal_code[32]; char city[128]; char bank_account[64]; char email[256]; char salary_account[16]; char tax_table[8]; char tax_column[8]; int64_t monthly_salary_ore; }; static char *employee_form_args(const struct employee_form *f, char *err, size_t errn) { if (!f->name[0]) { snprintf(err, errn, "Namn saknas."); return NULL; } if (f->is_new && !f->personal_no[0]) { snprintf(err, errn, "Personnummer saknas."); return NULL; } int table = 0, column = 0; if (payroll_parse_int(f->tax_table, 29, 42, &table) != 0) { snprintf(err, errn, "Skattetabell måste vara 29–42."); return NULL; } if (payroll_parse_int(f->tax_column, 1, 6, &column) != 0) { snprintf(err, errn, "Kolumn måste vara 1–6."); return NULL; } if (f->monthly_salary_ore < 0) { snprintf(err, errn, "Månadslönen kan inte vara negativ."); return NULL; } if (f->salary_account[0] && !payroll_digits(f->salary_account)) { snprintf(err, errn, "Lönekontot får bara innehålla siffror."); return NULL; } yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); yyjson_mut_doc_set_root(d, o); if (!f->is_new) yyjson_mut_obj_add_int(d, o, "id", f->id); yyjson_mut_obj_add_strcpy(d, o, "name", f->name); if (f->personal_no[0]) yyjson_mut_obj_add_strcpy(d, o, "personal_no", f->personal_no); yyjson_mut_obj_add_strcpy(d, o, "address", f->address); yyjson_mut_obj_add_strcpy(d, o, "postal_code", f->postal_code); yyjson_mut_obj_add_strcpy(d, o, "city", f->city); yyjson_mut_obj_add_strcpy(d, o, "bank_account", f->bank_account); yyjson_mut_obj_add_strcpy(d, o, "email", f->email); if (f->salary_account[0]) yyjson_mut_obj_add_strcpy(d, o, "salary_account", f->salary_account); yyjson_mut_obj_add_int(d, o, "monthly_salary_ore", f->monthly_salary_ore); yyjson_mut_obj_add_int(d, o, "tax_table", table); yyjson_mut_obj_add_int(d, o, "tax_column", column); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); return args; } static void employee_form(struct app *a, int64_t id) { static int sel = 0; struct employee_form f; memset(&f, 0, sizeof f); f.id = id; f.is_new = id <= 0; snprintf(f.tax_table, sizeof f.tax_table, "30"); snprintf(f.tax_column, sizeof f.tax_column, "1"); if (!f.is_new) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id); char *resp = client_rpc(&a->conn, "employee.get", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Anställd", resp); free(resp); return; } payroll_copy(f.name, sizeof f.name, resp, "result.name"); payroll_copy(f.personal_no, sizeof f.personal_no, resp, "result.personal_no"); payroll_copy(f.address, sizeof f.address, resp, "result.address"); payroll_copy(f.postal_code, sizeof f.postal_code, resp, "result.postal_code"); payroll_copy(f.city, sizeof f.city, resp, "result.city"); payroll_copy(f.bank_account, sizeof f.bank_account, resp, "result.bank_account"); payroll_copy(f.email, sizeof f.email, resp, "result.email"); payroll_copy(f.salary_account, sizeof f.salary_account, resp, "result.salary_account"); snprintf(f.tax_table, sizeof f.tax_table, "%lld", (long long)jint_val(resp, "result.tax_table", 30)); snprintf(f.tax_column, sizeof f.tax_column, "%lld", (long long)jint_val(resp, "result.tax_column", 1)); f.monthly_salary_ore = jint_val(resp, "result.monthly_salary_ore", 0); f.personal_no[0] = '\0'; free(resp); } for (;;) { if (g_quit) return; struct tui_form_field ff[11]; memset(ff, 0, sizeof ff); int i = 0; ff[i].label = "Namn"; ff[i].value = f.name; ff[i].cap = sizeof f.name; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = f.is_new ? "Personnummer" : "Personnummer (tomt = oförändrat)"; ff[i].value = f.personal_no; ff[i].cap = sizeof f.personal_no; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = "Adress"; ff[i].value = f.address; ff[i].cap = sizeof f.address; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = "Postnummer"; ff[i].value = f.postal_code; ff[i].cap = sizeof f.postal_code; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = "Ort"; ff[i].value = f.city; ff[i].cap = sizeof f.city; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = "Bankkonto (nettolön)"; ff[i].value = f.bank_account; ff[i].cap = sizeof f.bank_account; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = "E-post (lönebesked)"; ff[i].value = f.email; ff[i].cap = sizeof f.email; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = "Lönekonto"; ff[i].value = f.salary_account; ff[i].cap = sizeof f.salary_account; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = "Månadslön (kr)"; ff[i].kind = TUI_F_AMOUNT; ff[i].ore = &f.monthly_salary_ore; i++; ff[i].label = "Skattetabell (29–42)"; ff[i].value = f.tax_table; ff[i].cap = sizeof f.tax_table; ff[i].kind = TUI_F_TEXT; i++; ff[i].label = "Kolumn (1–6)"; ff[i].value = f.tax_column; ff[i].cap = sizeof f.tax_column; ff[i].kind = TUI_F_TEXT; i++; const char *title = f.is_new ? "Ny anställd" : "Anställd"; int r = tui_form_run(title, ff, i, 1, &sel); if (r == TUI_FORM_BACK) return; if (r != TUI_FORM_REFRESH && r != TUI_FORM_SUBMIT) continue; char err[256]; char *args = employee_form_args(&f, err, sizeof err); if (!args) { tui_message(title, "%s", err); continue; } const char *cmd = f.is_new ? "employee.create" : "employee.update"; char *resp = r == TUI_FORM_REFRESH ? rpc_dry(a, cmd, args) : client_rpc(&a->conn, cmd, a->session, a->org, args); free(args); if (r == TUI_FORM_REFRESH) { if (resp && client_ok(resp)) tui_message(title, "Validering OK."); else show_error(title, resp); free(resp); continue; } if (!resp || !client_ok(resp)) { show_error("Kunde inte spara", resp); free(resp); continue; } free(resp); tui_message(title, f.is_new ? "Anställd skapad." : "Sparad."); return; } } static void employee_archive(struct app *a, int64_t id, const char *name, int active) { if (!tui_confirm("Anställd", active ? "Arkivera %s?" : "Återaktivera %s?", name ? name : "")) return; char args[128]; snprintf(args, sizeof args, "{\"id\":%lld,\"active\":%s}", (long long)id, active ? "false" : "true"); char *r = client_rpc(&a->conn, "employee.archive", a->session, a->org, args); if (r && client_ok(r)) tui_message("Anställd", active ? "Arkiverad." : "Återaktiverad."); else show_error("Anställd", r); free(r); } void employees_screen(struct app *a) { for (;;) { if (g_quit) return; char *resp = client_rpc(&a->conn, "employee.list", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Anställda", resp); free(resp); return; } size_t n = jarr_size(resp, "result.items"); char **lines = xcalloc(n + 1, sizeof(char *)); char **names = xcalloc(n + 1, sizeof(char *)); int64_t *ids = xcalloc(n + 1, sizeof(int64_t)); unsigned char *active = xcalloc(n + 1, 1); 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.name", i); names[i] = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.personal_no", i); char *pn = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.monthly_salary_ore", i); int64_t salary = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.tax_table", i); int64_t table = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.tax_column", i); int64_t column = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.email", i); char *email = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.active", i); active[i] = (unsigned char)jbool_val(resp, path, 1); char nbuf[224], pbuf[24], abuf[32], tbuf[16], ebuf[256]; snprintf(nbuf, sizeof nbuf, "%s", names[i] ? names[i] : ""); tui_pad_field(nbuf, sizeof nbuf, 24); snprintf(pbuf, sizeof pbuf, "%s", pn ? pn : ""); tui_pad_field(pbuf, sizeof pbuf, 15); tui_amt_col(abuf, sizeof abuf, 12, salary); snprintf(tbuf, sizeof tbuf, "%lld/%lld", (long long)table, (long long)column); snprintf(ebuf, sizeof ebuf, "%s", email ? email : ""); char line[600]; snprintf(line, sizeof line, "%s %s %s %s %s%s", nbuf, pbuf, abuf, tbuf, ebuf, active[i] ? "" : " (arkiverad)"); lines[i] = xstrdup(line); free(pn); free(email); } free(resp); size_t start = 0; for (size_t i = 0; i < n; i++) if (ids[i] == a->employee_sel) { start = i; break; } lines[n] = xstrdup("+ Ny anställd (^N)"); int cur = (int)start; int sel = tui_select_list("Anställda", lines, (int)n + 1, cur, 1, &cur, 1, "arkivera/återaktivera", 0); if (cur >= 0 && (size_t)cur < n) a->employee_sel = ids[cur]; if (sel == -1) { for (size_t i = 0; i <= n; i++) { free(lines[i]); free(names[i]); } free(lines); free(names); free(ids); free(active); return; } if (sel == -6 && cur >= 0 && (size_t)cur < n) { employee_archive(a, ids[cur], names[cur], active[cur]); } else if (sel == -4 || (sel >= 0 && (size_t)sel == n)) { employee_form(a, 0); } else if (sel >= 0 && (size_t)sel < n) { employee_form(a, ids[sel]); } for (size_t i = 0; i <= n; i++) { free(lines[i]); free(names[i]); } free(lines); free(names); free(ids); free(active); } } /* ------------------------------------------------------------------ */ /* Skattetabeller */ /* ------------------------------------------------------------------ */ static void tax_tables_fetch(struct app *a, int year) { if (!tui_confirm("Skattetabeller", "Hämta och ersätta skattetabellerna för %d från" " Skatteverket?", year)) return; char args[48]; snprintf(args, sizeof args, "{\"year\":%d}", year); char *resp = client_rpc(&a->conn, "payroll.tax_tables_fetch", a->session, a->org, args); if (resp && client_ok(resp)) { tui_message("Skattetabeller", "%lld rader för %lld sparade.", (long long)jint_val(resp, "result.rows", 0), (long long)jint_val(resp, "result.year", 0)); } else { show_error("Kunde inte hämta skattetabellerna", resp); } free(resp); } static void tax_tables_import(struct app *a, int year) { char yearbuf[8]; snprintf(yearbuf, sizeof yearbuf, "%d", year); char *yb = tui_prompt_into(yearbuf, sizeof yearbuf, "Inkomstår för filen: ", yearbuf, 0) ? yearbuf : NULL; if (!yb || !*yb) return; char *end = NULL; long y = strtol(yb, &end, 10); if (!end || *end || y < 2000 || y > 9999) { tui_message("Skattetabeller", "Ogiltigt inkomstår."); return; } char *path = file_browser(a, a->attachment_dir); if (!path) return; char *b64 = read_file_b64(path); if (!b64) { tui_message("Skattetabeller", "Kunde inte läsa %s", path); free(path); return; } free(path); yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); yyjson_mut_doc_set_root(d, o); yyjson_mut_obj_add_int(d, o, "year", y); 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); if (!args) return; char *resp = client_rpc(&a->conn, "payroll.tax_tables_import", a->session, a->org, args); free(args); if (resp && client_ok(resp)) { tui_message("Skattetabeller", "%lld rader för %lld importerade.", (long long)jint_val(resp, "result.rows", 0), (long long)jint_val(resp, "result.year", 0)); } else { show_error("Kunde inte importera skattetabellerna", resp); } free(resp); } void payroll_tax_tables_screen(struct app *a) { static int sel = 4; int owner = payroll_is_owner(a); const char *reason = owner ? NULL : "kräver ägarbehörighet"; for (;;) { if (g_quit) return; char *resp = client_rpc(&a->conn, "payroll.tax_tables_status", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { show_error("Skattetabeller", resp); free(resp); return; } int current_year = (int)jint_val(resp, "result.current_year", 0); int stale = jbool_val(resp, "result.stale", 1); char years[256] = "inga"; size_t ny = jarr_size(resp, "result.stored_years"); if (ny > 0) { years[0] = '\0'; size_t off = 0; for (size_t i = 0; i < ny; i++) { char path[48]; snprintf(path, sizeof path, "result.stored_years.%zu", i); int64_t y = jint_val(resp, path, 0); size_t left = sizeof years - off; int w = snprintf(years + off, left, "%s%lld", i ? ", " : "", (long long)y); if (w < 0 || (size_t)w >= left) break; off += (size_t)w; } } char current[8]; snprintf(current, sizeof current, "%d", current_year); char fetched[64] = "—"; char *fa = jstr_dup(resp, "result.fetched_at"); if (fa && *fa) snprintf(fetched, sizeof fetched, "%s", fa); free(fa); char url[1024] = "—"; char *su = jstr_dup(resp, "result.source_url"); if (su && *su) snprintf(url, sizeof url, "%s", su); free(su); char status[160]; if (stale) snprintf(status, sizeof status, "Status: skattetabeller för %d saknas", current_year); else snprintf(status, sizeof status, "Status: skattetabeller för %d finns", current_year); free(resp); struct tui_form_field ff[4]; memset(ff, 0, sizeof ff); ff[0].label = "Aktuellt inkomstår"; ff[0].value = current; ff[0].cap = sizeof current; ff[0].kind = TUI_F_TEXT; ff[1].label = "Hämtade år"; ff[1].value = years; ff[1].cap = sizeof years; ff[1].kind = TUI_F_TEXT; ff[2].label = "Senast hämtad"; ff[2].value = fetched; ff[2].cap = sizeof fetched; ff[2].kind = TUI_F_TEXT; ff[3].label = "Källa"; ff[3].value = url; ff[3].cap = sizeof url; ff[3].kind = TUI_F_TEXT; struct tui_form_action acts[3]; acts[0].label = status; acts[0].enabled = -1; acts[0].disabled_reason = NULL; acts[1].label = "Hämta från Skatteverket"; acts[1].enabled = owner ? 1 : 0; acts[1].disabled_reason = reason; acts[2].label = "Importera tabellfil (offline)…"; acts[2].enabled = owner ? 1 : 0; acts[2].disabled_reason = reason; int r = tui_form_run_actions( "Skattetabeller", ff, 4, 0, acts, 3, "upp/ned/Tab = flytta Enter = utför F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta", &sel); if (r == TUI_FORM_ACTION + 1) tax_tables_fetch(a, current_year); else if (r == TUI_FORM_ACTION + 2) tax_tables_import(a, current_year); else if (r == TUI_FORM_BACK) return; } } /* ------------------------------------------------------------------ */ /* Lönekörningar */ /* ------------------------------------------------------------------ */ struct payroll_run { struct app *a; int64_t id; char period[16]; char pay_date[16]; char status[16]; int64_t gross_ore; int64_t tax_ore; int64_t avgifter_ore; int64_t net_ore; int lines; }; static void payroll_run_defaults(struct app *a, char *period, size_t pn, char *pay_date, size_t dn) { char today[16]; payroll_today(today, sizeof today); if (a->fy_start[0] && a->fy_end[0] && strcmp(today, a->fy_start) >= 0 && strcmp(today, a->fy_end) <= 0) { snprintf(period, pn, "%.7s", today); snprintf(pay_date, dn, "%s", today); return; } if (a->fy_start[0] && strlen(a->fy_start) >= 7) { snprintf(period, pn, "%.7s", a->fy_start); snprintf(pay_date, dn, "%s", a->fy_start); return; } snprintf(period, pn, "%.7s", today); snprintf(pay_date, dn, "%s", today); } static int payroll_run_load(struct payroll_run *r) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)r->id); char *resp = client_rpc(&r->a->conn, "payroll.run_get", r->a->session, r->a->org, args); if (!resp || !client_ok(resp)) { show_error("Lönekörning", resp); free(resp); return -1; } payroll_copy(r->period, sizeof r->period, resp, "result.period"); payroll_copy(r->pay_date, sizeof r->pay_date, resp, "result.pay_date"); payroll_copy(r->status, sizeof r->status, resp, "result.status"); r->gross_ore = jint_val(resp, "result.gross_ore", 0); r->tax_ore = jint_val(resp, "result.tax_ore", 0); r->avgifter_ore = jint_val(resp, "result.avgifter_ore", 0); r->net_ore = jint_val(resp, "result.net_ore", 0); r->lines = (int)jarr_size(resp, "result.lines"); free(resp); return 0; } static void payroll_fmt_lines(struct buf *t, yyjson_val *items) { buf_line(t, "%-22s %-6s %13s %13s %13s %13s\n", "Namn", "Tabell", "Brutto", "Skatt", "Avgifter", "Netto"); size_t n = yyjson_arr_size(items); for (size_t i = 0; i < n; i++) { yyjson_val *it = yyjson_arr_get(items, i); char name[224], tab[16], g[32], tx[32], av[32], net[32]; snprintf(name, sizeof name, "%s", vstr(it, "name")); tui_pad_field(name, sizeof name, 22); snprintf(tab, sizeof tab, "%lld/%lld", (long long)vint(it, "tax_table"), (long long)vint(it, "tax_column")); tui_pad_field(tab, sizeof tab, 6); tui_amt_col(g, sizeof g, 13, vint(it, "gross_ore")); tui_amt_col(tx, sizeof tx, 13, vint(it, "tax_ore")); tui_amt_col(av, sizeof av, 13, vint(it, "avgifter_ore")); tui_amt_col(net, sizeof net, 13, vint(it, "net_ore")); buf_line(t, "%s %s %s %s %s %s\n", name, tab, g, tx, av, net); } } static void payroll_fmt_total_line(struct buf *t, int64_t gross, int64_t tax, int64_t avgifter, int64_t net) { char name[32] = "Summa", g[32], tx[32], av[32], nb[32]; tui_pad_field(name, sizeof name, 22); tui_amt_col(g, sizeof g, 13, gross); tui_amt_col(tx, sizeof tx, 13, tax); tui_amt_col(av, sizeof av, 13, avgifter); tui_amt_col(nb, sizeof nb, 13, net); buf_rule(t, 80); buf_line(t, MK_DIM "%s %-6s %s %s %s %s\n", name, "", g, tx, av, nb); } static void payroll_fmt_totals(struct buf *t, yyjson_val *totals) { if (!totals) return; payroll_fmt_total_line(t, vint(totals, "gross_ore"), vint(totals, "tax_ore"), vint(totals, "avgifter_ore"), vint(totals, "net_ore")); } static void payroll_run_preview(struct payroll_run *r) { if (!payroll_period_valid(r->period)) { tui_message("Lönekörning", "Perioden måste vara ÅÅÅÅ-MM."); return; } char *args = NULL; const char *cmd; if (r->id > 0) { char idargs[64]; snprintf(idargs, sizeof idargs, "{\"id\":%lld}", (long long)r->id); args = xstrdup(idargs); cmd = "payroll.run_get"; } else { yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); yyjson_mut_doc_set_root(d, o); yyjson_mut_obj_add_strcpy(d, o, "period", r->period); args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); cmd = "payroll.run_preview"; } if (!args) return; char *resp = client_rpc(&r->a->conn, cmd, r->a->session, r->a->org, args); free(args); if (!resp || !client_ok(resp)) { show_error("Lönekörning", resp); free(resp); return; } yyjson_doc *pd = parse(resp); yyjson_val *res = pd ? jget(yyjson_doc_get_root(pd), "result") : NULL; struct buf t; buf_init(&t); if (!res) { buf_line(&t, "%s\n", resp); } else { if (r->id > 0) { buf_line(&t, MK_HEAD "Lönekörning %s — %s\n", r->period, payroll_status_sv(r->status)); buf_line(&t, MK_DIM "Betalningsdatum %s\n", r->pay_date); } else { buf_line(&t, MK_HEAD "Lönekörning %s — förhandsvisning (tabellår %lld)\n", r->period, (long long)vint(res, "tax_year")); } buf_line(&t, "\n"); payroll_fmt_lines(&t, yyjson_obj_get(res, "items")); if (r->id > 0) payroll_fmt_total_line(&t, r->gross_ore, r->tax_ore, r->avgifter_ore, r->net_ore); else payroll_fmt_totals(&t, yyjson_obj_get(res, "totals")); yyjson_val *accts = yyjson_obj_get(res, "accounts"); if (accts && yyjson_is_obj(accts)) { int64_t rate = vint(res, "avgift_rate_bp"); buf_line(&t, "\n" MK_DIM "Konton: skatt %s, avgifter %s (%.2f %%), avräkning %s," " netto %s, skattekonto %s.\n", vstr(accts, "tax"), vstr(accts, "avgift"), (double)rate / 100.0, vstr(accts, "liability"), vstr(accts, "net"), vstr(accts, "payment")); } } buf_append(&t, "", 1); char title[64]; snprintf(title, sizeof title, "Lönekörning %s", r->period); tui_pager(title, (const char *)t.p, NULL); buf_free(&t); yyjson_doc_free(pd); free(resp); } static void payroll_run_post(struct payroll_run *r) { if (!payroll_period_valid(r->period)) { tui_message("Lönekörning", "Perioden måste vara ÅÅÅÅ-MM."); return; } if (!util_parse_iso_date(r->pay_date)) { tui_message("Lönekörning", "Betalningsdatum måste vara ÅÅÅÅ-MM-DD."); return; } if (!tui_confirm("Lönekörning", "Bokför lönekörningen %s med betaldatum %s?", r->period, r->pay_date)) 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, "period", r->period); yyjson_mut_obj_add_strcpy(d, o, "pay_date", r->pay_date); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); if (!args) return; char *resp = client_rpc(&r->a->conn, "payroll.run_post", r->a->session, r->a->org, args); free(args); if (!resp || !client_ok(resp)) { show_error("Kunde inte bokföra lönekörningen", resp); free(resp); return; } r->id = jint_val(resp, "result.id", 0); char ver[32]; payroll_voucher_ver(r->a, jint_val(resp, "result.voucher_id", 0), ver, sizeof ver); free(resp); char msg[96]; if (ver[0]) snprintf(msg, sizeof msg, "Lönekörningen är bokförd (%s).", ver); else snprintf(msg, sizeof msg, "Lönekörningen är bokförd."); tui_message("Lönekörning", "%s", msg); } static void payroll_run_payslip(struct payroll_run *r) { char idargs[64]; snprintf(idargs, sizeof idargs, "{\"id\":%lld}", (long long)r->id); char *resp = client_rpc(&r->a->conn, "payroll.run_get", r->a->session, r->a->org, idargs); if (!resp || !client_ok(resp)) { show_error("Lönebesked", resp); free(resp); return; } yyjson_doc *d = parse(resp); yyjson_val *lines = d ? jget(yyjson_doc_get_root(d), "result.lines") : NULL; size_t n = yyjson_arr_size(lines); int64_t employee_id = 0; if (n == 1) { employee_id = vint(yyjson_arr_get(lines, 0), "employee_id"); } else if (n > 1) { char **names = xcalloc(n, sizeof(char *)); int64_t *ids = xcalloc(n, sizeof(int64_t)); for (size_t i = 0; i < n; i++) { yyjson_val *it = yyjson_arr_get(lines, i); names[i] = xstrdup(vstr(it, "name")); ids[i] = vint(it, "employee_id"); } int sel = tui_choice_prompt("Välj anställd: ", (const char *const *)names, (int)n, 0); if (sel >= 0) employee_id = ids[sel]; for (size_t i = 0; i < n; i++) free(names[i]); free(names); free(ids); } free(resp); yyjson_doc_free(d); if (n == 0) { tui_message("Lönebesked", "Körningen har inga anställda."); return; } if (employee_id <= 0) return; yyjson_mut_doc *ad = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(ad); yyjson_mut_doc_set_root(ad, o); yyjson_mut_obj_add_int(ad, o, "run_id", r->id); yyjson_mut_obj_add_int(ad, o, "employee_id", employee_id); char *args = yyjson_mut_write(ad, 0, NULL); yyjson_mut_doc_free(ad); if (!args) return; resp = client_rpc(&r->a->conn, "payroll.payslip", r->a->session, r->a->org, args); free(args); if (!resp || !client_ok(resp)) { show_error("Kunde inte hämta lönebeskedet", resp); free(resp); return; } char *b64 = jstr_dup(resp, "result.content_base64"); char *fname = jstr_dup(resp, "result.filename"); free(resp); if (b64 && fname) file_save_and_open(b64, fname, "Lönebesked"); free(b64); free(fname); } static void payroll_run_agi(struct payroll_run *r) { if (!payroll_is_owner(r->a)) { tui_message("AGI-underlag", "Kräver ägarbehörighet."); 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, "period", r->period); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *resp = client_rpc(&r->a->conn, "payroll.agi", r->a->session, r->a->org, args); free(args); if (!resp || !client_ok(resp)) { show_error("AGI-underlag", resp); free(resp); return; } yyjson_doc *pd = parse(resp); yyjson_val *res = pd ? jget(yyjson_doc_get_root(pd), "result") : NULL; struct buf t; buf_init(&t); if (!res) { buf_line(&t, "%s\n", resp); } else { buf_line(&t, MK_HEAD "Arbetsgivardeklaration (AGI) %s\n", vstr(res, "period")); buf_line(&t, MK_DIM "Betalningsdatum %s\n", vstr(res, "pay_date")); buf_line(&t, "\n"); buf_line(&t, "%-22s %-15s %-6s %13s %13s %13s %13s\n", "Namn", "Personnummer", "Tabell", "Brutto", "Skatt", "Avgifter", "Netto"); yyjson_val *items = yyjson_obj_get(res, "items"); size_t n = yyjson_arr_size(items); for (size_t i = 0; i < n; i++) { yyjson_val *it = yyjson_arr_get(items, i); char name[224], pn[24], tab[16], g[32], tx[32], av[32], net[32]; snprintf(name, sizeof name, "%s", vstr(it, "name")); tui_pad_field(name, sizeof name, 22); snprintf(pn, sizeof pn, "%s", vstr(it, "personal_no")); tui_pad_field(pn, sizeof pn, 15); snprintf(tab, sizeof tab, "%lld/%lld", (long long)vint(it, "tax_table"), (long long)vint(it, "tax_column")); tui_pad_field(tab, sizeof tab, 6); tui_amt_col(g, sizeof g, 13, vint(it, "gross_ore")); tui_amt_col(tx, sizeof tx, 13, vint(it, "tax_ore")); tui_amt_col(av, sizeof av, 13, vint(it, "avgifter_ore")); tui_amt_col(net, sizeof net, 13, vint(it, "net_ore")); buf_line(&t, "%s %s %s %s %s %s %s\n", name, pn, tab, g, tx, av, net); } payroll_fmt_totals(&t, yyjson_obj_get(res, "totals")); buf_line(&t, "\n" MK_DIM "Underlaget fylls i manuellt på skatteverket.se.\n"); } buf_append(&t, "", 1); tui_pager("AGI-underlag", (const char *)t.p, NULL); buf_free(&t); yyjson_doc_free(pd); free(resp); } static void payroll_run_pay_tax(struct payroll_run *r) { int64_t total = r->tax_ore + r->avgifter_ore; if (total <= 0) { tui_message("Betala skatt & avgifter", "Inget att betala."); return; } char tb[32], sb[32], ab[32]; tui_kr_format(total, tb, sizeof tb); tui_kr_format(r->tax_ore, sb, sizeof sb); tui_kr_format(r->avgifter_ore, ab, sizeof ab); if (!tui_confirm("Betala skatt & avgifter", "Betala %s för %s (skatt %s + avgifter %s)?", tb, r->period, sb, ab)) return; char date[16]; payroll_today(date, sizeof date); if (!tui_date_prompt_into(date, sizeof date, "Betalningsdatum: ", date)) 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_int(d, o, "run_id", r->id); yyjson_mut_obj_add_strcpy(d, o, "date", date); char *args = yyjson_mut_write(d, 0, NULL); yyjson_mut_doc_free(d); char *resp = client_rpc(&r->a->conn, "payroll.pay_tax", r->a->session, r->a->org, args); free(args); if (!resp || !client_ok(resp)) { show_error("Kunde inte betala", resp); free(resp); return; } char ver[32]; payroll_voucher_ver(r->a, jint_val(resp, "result.payment_voucher_id", 0), ver, sizeof ver); free(resp); if (ver[0]) tui_message("Betala skatt & avgifter", "Betalningen bokförd (%s).", ver); else tui_message("Betala skatt & avgifter", "Betalningen bokförd."); } static int payroll_salary_count(struct app *a) { char *resp = client_rpc(&a->conn, "employee.list", a->session, a->org, "{\"active_only\":true}"); if (!resp || !client_ok(resp)) { free(resp); return 0; } size_t n = jarr_size(resp, "result.items"); int count = 0; for (size_t i = 0; i < n; i++) { char path[64]; snprintf(path, sizeof path, "result.items.%zu.monthly_salary_ore", i); if (jint_val(resp, path, 0) > 0) count++; } free(resp); return count; } static int64_t payroll_run_screen(struct app *a, int64_t id) { static int focus = 0; struct payroll_run r; memset(&r, 0, sizeof r); r.a = a; r.id = id; payroll_run_defaults(a, r.period, sizeof r.period, r.pay_date, sizeof r.pay_date); int can_write = payroll_can_write(a); int owner = payroll_is_owner(a); for (;;) { if (g_quit) return r.id; int posted = r.id > 0; int paid = 0; if (posted) { if (payroll_run_load(&r) != 0) return r.id; paid = strcmp(r.status, "paid") == 0; } char status[160]; if (!posted) snprintf(status, sizeof status, "Status: inte bokförd"); else if (paid) snprintf(status, sizeof status, "Status: betald"); else snprintf(status, sizeof status, "Status: bokförd %s", r.pay_date); char totals[192]; if (posted) { char g[32], tx[32], av[32], net[32]; tui_kr_format(r.gross_ore, g, sizeof g); tui_kr_format(r.tax_ore, tx, sizeof tx); tui_kr_format(r.avgifter_ore, av, sizeof av); tui_kr_format(r.net_ore, net, sizeof net); snprintf(totals, sizeof totals, "Brutto %s Skatt %s Avgifter %s Netto %s", g, tx, av, net); } else { int count = payroll_salary_count(a); snprintf(totals, sizeof totals, "%d anställd%s med månadslön", count, count == 1 ? "" : "a"); } const char *post_reason = NULL; if (!can_write) post_reason = "du har inte behörighet"; else if (posted) post_reason = "körningen är redan bokförd"; const char *payslip_reason = posted ? NULL : "körningen är inte bokförd än"; const char *agi_reason = NULL; if (!owner) agi_reason = "kräver ägarbehörighet"; else if (!posted) agi_reason = "körningen är inte bokförd än"; const char *pay_reason = NULL; if (!can_write) pay_reason = "du har inte behörighet"; else if (!posted) pay_reason = "körningen är inte bokförd än"; else if (paid) pay_reason = "redan betald"; else if (r.tax_ore + r.avgifter_ore <= 0) pay_reason = "inget att betala"; struct tui_form_action acts[7] = { { status, -1, NULL }, { totals, -1, NULL }, { "Förhandsvisa (F5)", 1, NULL }, { "Bokför körning (F9)", post_reason ? 0 : 1, post_reason }, { "Lönebesked (PDF)", payslip_reason ? 0 : 1, payslip_reason }, { "AGI-underlag", agi_reason ? 0 : 1, agi_reason }, { "Betala skatt & avgifter", pay_reason ? 0 : 1, pay_reason }, }; struct tui_form_field ff[2]; memset(ff, 0, sizeof ff); ff[0].label = "Period (ÅÅÅÅ-MM)"; ff[0].value = r.period; ff[0].cap = sizeof r.period; ff[0].kind = TUI_F_TEXT; ff[1].label = "Betalningsdatum"; ff[1].value = r.pay_date; ff[1].cap = sizeof r.pay_date; ff[1].kind = TUI_F_DATE; char title[64]; if (posted) snprintf(title, sizeof title, "Lönekörning %s", r.period); else snprintf(title, sizeof title, "Ny lönekörning"); int editable = can_write && !posted; tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför" " F5 = förhandsvisa F9 = bokför" " Esc/q = tillbaka ^C = avsluta"); int ret = tui_form_run_actions(title, ff, 2, editable, acts, 7, NULL, &focus); if (ret == TUI_FORM_BACK) return r.id; if (ret == TUI_FORM_REFRESH || ret == TUI_FORM_ACTION + 2) payroll_run_preview(&r); else if ((ret == TUI_FORM_SUBMIT || ret == TUI_FORM_ACTION + 3) && !posted) payroll_run_post(&r); else if (ret == TUI_FORM_ACTION + 4) payroll_run_payslip(&r); else if (ret == TUI_FORM_ACTION + 5) payroll_run_agi(&r); else if (ret == TUI_FORM_ACTION + 6) payroll_run_pay_tax(&r); } return r.id; } void payroll_screen(struct app *a) { int64_t keep_id = a->payroll_sel; for (;;) { if (g_quit) return; char *sresp = client_rpc(&a->conn, "payroll.tax_tables_status", a->session, a->org, "{}"); int stale = 0; int year = 0; if (sresp && client_ok(sresp)) { stale = jbool_val(sresp, "result.stale", 0); year = (int)jint_val(sresp, "result.current_year", 0); } free(sresp); char *resp = client_rpc(&a->conn, "payroll.run_list", a->session, a->org, "{\"limit\":200}"); if (!resp || !client_ok(resp)) { show_error("Lönekörningar", resp); free(resp); return; } size_t n = jarr_size(resp, "result.items"); char **lines = 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]; snprintf(path, sizeof path, "result.items.%zu.id", i); ids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.period", i); char *period = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.pay_date", i); char *pay_date = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.status", i); char *status = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.gross_ore", i); int64_t gross = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.net_ore", i); int64_t net = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.line_count", i); int64_t lc = jint_val(resp, path, 0); char sbuf[16], gbuf[32], nbuf[32], line[256]; snprintf(sbuf, sizeof sbuf, "%s", payroll_status_sv(status ? status : "posted")); tui_pad_field(sbuf, sizeof sbuf, 7); tui_amt_col(gbuf, sizeof gbuf, 13, gross); tui_amt_col(nbuf, sizeof nbuf, 13, net); snprintf(line, sizeof line, "%s %s %s %s %s %lld anst.", period ? period : "", pay_date ? pay_date : "", sbuf, gbuf, nbuf, (long long)lc); lines[i] = xstrdup(line); free(period); free(pay_date); free(status); } free(resp); size_t start = 0; for (size_t i = 0; i < n; i++) if (ids[i] == keep_id) { start = i; break; } lines[n] = xstrdup("+ Ny lönekörning (^N)"); char title[160]; if (stale) snprintf(title, sizeof title, "Lönekörningar — skattetabeller för %d saknas (se" " Skattetabeller)", year); else snprintf(title, sizeof title, "Lönekörningar"); int cur = (int)start; int sel = tui_select_list(title, lines, (int)n + 1, cur, 1, &cur, 1, NULL, 0); if (cur >= 0 && (size_t)cur < n) a->payroll_sel = ids[cur]; if (sel == -1) { for (size_t i = 0; i <= n; i++) free(lines[i]); free(lines); free(ids); return; } if (sel == -4 || (sel >= 0 && (size_t)sel == n)) { if (payroll_salary_count(a) == 0) { tui_message("Lönekörningar", "Inga aktiva anställda med månadslön. Lägg upp" " dem under Register → Anställda."); } else { int64_t rid = payroll_run_screen(a, 0); if (rid > 0) { keep_id = rid; a->payroll_sel = rid; } } for (size_t i = 0; i <= n; i++) free(lines[i]); free(lines); free(ids); continue; } if (sel >= 0 && (size_t)sel < n) { int64_t rid = payroll_run_screen(a, ids[sel]); if (rid > 0) { keep_id = rid; a->payroll_sel = rid; } } for (size_t i = 0; i <= n; i++) free(lines[i]); free(lines); free(ids); } }