diff options
Diffstat (limited to 'clients/screens_payroll.c')
| -rw-r--r-- | clients/screens_payroll.c | 1261 |
1 files changed, 1261 insertions, 0 deletions
diff --git a/clients/screens_payroll.c b/clients/screens_payroll.c new file mode 100644 index 0000000..23d765f --- /dev/null +++ b/clients/screens_payroll.c @@ -0,0 +1,1261 @@ +#include <dirent.h> +#include <errno.h> +#include <locale.h> +#include <math.h> +#include <ncursesw/ncurses.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <sys/stat.h> +#include <sys/wait.h> +#include <time.h> +#include <unistd.h> + +#include "client.h" +#include "tui.h" +#include "formula.h" +#include "util.h" +#include "version.h" +#include "yyjson.h" +#include "ui.h" + +/* ------------------------------------------------------------------ */ +/* 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) + pdf_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 (^Enter/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 ^Enter/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); + } +} |
