summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-21 11:45:54 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-21 11:45:54 +0200
commite1dd7112e0346914710566660625da0f6b866e8a (patch)
treec4b0d5c7f6f62c1c4c38245e7c001b3fd4b03e74
parent182f0a592b6fe97fc1ff87add227f9d52a4ef902 (diff)
downloadbokf-e1dd7112e0346914710566660625da0f6b866e8a.tar.gz
bokf-e1dd7112e0346914710566660625da0f6b866e8a.zip
tui: payroll screens, Företag/System menu and settings splitv0.1.55
-rw-r--r--Makefile2
-rw-r--r--clients/bokftui.c11
-rw-r--r--clients/screens_bokslut.c5
-rw-r--r--clients/screens_dashboard.c52
-rw-r--r--clients/screens_invoices.c86
-rw-r--r--clients/screens_payroll.c1260
-rw-r--r--clients/screens_settings.c348
-rw-r--r--clients/ui.c85
-rw-r--r--clients/ui.h9
-rw-r--r--docs/PAYROLL.md19
-rw-r--r--docs/PROTOCOL.md43
-rw-r--r--docs/STATE.md49
-rw-r--r--docs/TUI-GUIDELINES.md3
-rwxr-xr-xscripts/tui-golden.py101
14 files changed, 1758 insertions, 315 deletions
diff --git a/Makefile b/Makefile
index ba8ed02..6b246a3 100644
--- a/Makefile
+++ b/Makefile
@@ -53,7 +53,7 @@ TUI_SCREENS = clients/screens_dashboard.c clients/screens_vouchers.c \
clients/screens_bank.c clients/screens_audit.c \
clients/screens_templates.c clients/screens_ib.c \
clients/screens_settings.c clients/screens_invoices.c \
- clients/screens_bokslut.c
+ clients/screens_bokslut.c clients/screens_payroll.c
TUI_SCREEN_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(TUI_SCREENS))
TUI_SCREEN_DEP = $(TUI_SCREEN_OBJ:.o=.d)
diff --git a/clients/bokftui.c b/clients/bokftui.c
index 84d1a18..a047ef5 100644
--- a/clients/bokftui.c
+++ b/clients/bokftui.c
@@ -35,13 +35,17 @@ static const struct scene SCENES[] = {
{ "bank", bank_screen },
{ "invoices", invoices_screen },
{ "customers", customers_screen },
+ { "payroll", payroll_screen },
+ { "employees", employees_screen },
+ { "tax_tables", payroll_tax_tables_screen },
{ "ib", ib_screen },
{ "templates", templates_screen },
{ "rules", rules_screen },
{ "reports", reports_screen },
{ "audit", audit_screen },
- { "settings", settings_screen },
{ "company", company_screen },
+ { "settings", company_screen }, /* ^R/--screen compatibility */
+ { "system", system_settings_screen },
{ "bokslut", bokslut_screen },
{ "yearinfo", bokslut_screen }, /* ^R compatibility */
};
@@ -329,8 +333,9 @@ static void usage(FILE *f)
" --session ID reuse an open session (or BOKFD_SESSION)\n"
" --screen NAME start in a view: dashboard, vouchers, inbox,\n"
" bank, ib, templates, rules, reports, bokslut\n"
- " (yearinfo is an alias), audit, settings,\n"
- " company, invoices, customers\n"
+ " (yearinfo is an alias), audit, company,\n"
+ " invoices, customers, payroll, employees,\n"
+ " tax_tables, system (settings is an alias)\n"
" --version\n");
}
diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c
index 41649ce..df47196 100644
--- a/clients/screens_bokslut.c
+++ b/clients/screens_bokslut.c
@@ -560,7 +560,7 @@ static void fmt_arsredovisning(struct buf *t, const struct app *a,
ar_wrap(t, description);
else
buf_line(t, "[Kort beskrivning av verksamheten saknas — fyll i under"
- " Företagsuppgifter.]\n");
+ " Bolaget.]\n");
buf_line(t, "\nBolaget har sitt säte i %s.\n", city);
buf_line(t, "\n" MK_HEAD "Väsentliga händelser under räkenskapsåret\n");
if (events && *events)
@@ -796,8 +796,7 @@ static void fmt_arsredovisning(struct buf *t, const struct app *a,
if (board_text && *board_text)
buf_line(t, "%s", board_text);
else
- buf_line(t, "[Lägg till styrelseledamöter under"
- " Företagsuppgifter.]\n");
+ buf_line(t, "[Lägg till styrelseledamöter under Bolaget.]\n");
}
/* The pager text carries style markers that must not reach the saved file. */
diff --git a/clients/screens_dashboard.c b/clients/screens_dashboard.c
index 0c133b2..a386dae 100644
--- a/clients/screens_dashboard.c
+++ b/clients/screens_dashboard.c
@@ -269,17 +269,20 @@ static void select_fiscal_year(struct app *a)
}
static const char *const MAIN_ITEMS[] = {
MK_SECTION "Bokföring",
- "Verifikat", "Underlag (inkorg)", "Bankavstämning",
+ "Verifikat", "Underlag (inkorg)", "Bankavstämning", "Mallar",
MK_SECTION "Fakturering",
"Fakturor",
+ MK_SECTION "Lön",
+ "Lönekörningar",
MK_SECTION "Rapporter & bokslut",
"Rapporter", "Bokslut",
- MK_SECTION "Register",
- "Mallar", "Kunder", "Momsregler", "Företagsuppgifter", "Inställningar",
+ MK_SECTION "Företag",
+ "Anställda", "Kunder", "Bolaget", "Momsregler",
+ MK_SECTION "System",
+ "Skattetabeller", "Revision", "Systeminställningar",
MK_SECTION "Räkenskapsår",
"Ingående balans", "Räkenskapsår",
- MK_SECTION "Övrigt",
- "Revision", "Logga ut / avsluta",
+ "Logga ut / avsluta",
};
void dashboard(struct app *a)
@@ -312,40 +315,49 @@ void dashboard(struct app *a)
case 3:
open_scene(a, "bank");
break;
- case 5:
- open_scene(a, "invoices");
+ case 4:
+ open_scene(a, "templates");
break;
- case 7:
- open_scene(a, "reports");
+ case 6:
+ open_scene(a, "invoices");
break;
case 8:
- open_scene(a, "bokslut");
+ open_scene(a, "payroll");
break;
case 10:
- open_scene(a, "templates");
+ open_scene(a, "reports");
break;
case 11:
- open_scene(a, "customers");
- break;
- case 12:
- open_scene(a, "rules");
+ open_scene(a, "bokslut");
break;
case 13:
- open_scene(a, "company");
+ open_scene(a, "employees");
break;
case 14:
- open_scene(a, "settings");
+ open_scene(a, "customers");
+ break;
+ case 15:
+ open_scene(a, "company");
break;
case 16:
- open_scene(a, "ib");
+ open_scene(a, "rules");
break;
- case 17:
- select_fiscal_year(a);
+ case 18:
+ open_scene(a, "tax_tables");
break;
case 19:
open_scene(a, "audit");
break;
case 20:
+ open_scene(a, "system");
+ break;
+ case 22:
+ open_scene(a, "ib");
+ break;
+ case 23:
+ select_fiscal_year(a);
+ break;
+ case 24:
return;
default:
break; /* section header */
diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c
index 3782a5f..b8cc8dd 100644
--- a/clients/screens_invoices.c
+++ b/clients/screens_invoices.c
@@ -41,89 +41,7 @@ static void today_iso(char *buf, size_t n)
buf[len] = '\0';
}
-/* Directory for generated invoice PDFs. */
-static void invoice_cache_dir(char *buf, size_t n)
-{
- const char *cache = getenv("XDG_CACHE_HOME");
- if (cache && *cache) {
- snprintf(buf, n, "%s/bokf", cache);
- return;
- }
- snprintf(buf, n, "/tmp");
-}
-
-/* Keep a customer-supplied name from escaping the directory. */
-static void safe_fname(const char *in, char *out, size_t n)
-{
- size_t o = 0;
- for (const char *p = in ? in : ""; *p && o + 1 < n; p++) {
- unsigned char c = (unsigned char)*p;
- if (c < 32 || c == '/')
- c = '-';
- out[o++] = (char)c;
- }
- out[o] = '\0';
-}
-
-static int write_pdf_b64(const char *b64, const char *path)
-{
- unsigned char *data = NULL;
- size_t len = 0;
- if (!b64 || util_b64_decode(b64, strlen(b64), &data, &len) != 0)
- return -1;
- FILE *fp = fopen(path, "wb");
- if (!fp) {
- free(data);
- return -1;
- }
- size_t wrote = fwrite(data, 1, len, fp);
- fclose(fp);
- free(data);
- return wrote == len ? 0 : -1;
-}
-/* Open a saved PDF with xdg-open when a desktop session is present;
- otherwise (or on fork failure) show where it was saved. */
-static void open_pdf(const char *path)
-{
- const char *display = getenv("DISPLAY");
- const char *wayland = getenv("WAYLAND_DISPLAY");
- if ((!display || !*display) && (!wayland || !*wayland)) {
- tui_message("Faktura PDF", "Sparad: %s", path);
- return;
- }
- pid_t pid = fork();
- if (pid == 0) {
- pid_t gc = fork();
- if (gc == 0) {
- setsid();
- execlp("xdg-open", "xdg-open", path, (char *)NULL);
- _exit(127);
- }
- _exit(gc < 0 ? 1 : 0);
- }
- if (pid > 0)
- waitpid(pid, NULL, 0);
- else
- tui_message("Faktura PDF", "Sparad: %s", path);
-}
-
-static void save_and_open_pdf(const char *b64, const char *filename)
-{
- char dir[512], path[700], safe[600];
- invoice_cache_dir(dir, sizeof dir);
- safe_fname(filename, safe, sizeof safe);
- if (snprintf(path, sizeof path, "%s/%s", dir, safe) >= (int)sizeof path) {
- tui_message("Faktura PDF", "Sökvägen blir för lång.");
- return;
- }
- config_mkdirs(path);
- if (write_pdf_b64(b64, path) != 0) {
- tui_message("Faktura PDF", "Kunde inte spara %s", path);
- return;
- }
- open_pdf(path);
-}
static const char *invoice_status_sv(const char *s)
{
@@ -558,7 +476,7 @@ static void inv_detail_pdf(struct invctx *ctx)
char fname[800];
snprintf(fname, sizeof fname, "Faktura %lld %s.pdf",
(long long)ctx->number, ctx->customer);
- save_and_open_pdf(b64, fname);
+ pdf_save_and_open(b64, fname, "Faktura PDF");
free(b64);
}
free(resp);
@@ -1049,7 +967,7 @@ static void iform_preview(struct iform *f)
}
char *b64 = jstr_dup(resp, "result.content_base64");
if (b64) {
- save_and_open_pdf(b64, "Faktura förhandsvisning.pdf");
+ pdf_save_and_open(b64, "Faktura förhandsvisning.pdf", "Faktura PDF");
free(b64);
}
free(resp);
diff --git a/clients/screens_payroll.c b/clients/screens_payroll.c
new file mode 100644
index 0000000..9bfaf38
--- /dev/null
+++ b/clients/screens_payroll.c
@@ -0,0 +1,1260 @@
+#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)", 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 = 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);
+ }
+}
diff --git a/clients/screens_settings.c b/clients/screens_settings.c
index 6440150..c6a46c5 100644
--- a/clients/screens_settings.c
+++ b/clients/screens_settings.c
@@ -193,22 +193,181 @@ static void board_screen(struct app *a)
}
}
+struct setting_field {
+ const char *key;
+ const char *label;
+ const char *const *choices;
+ int nchoices;
+ int mask;
+};
+
+static void setting_save(struct app *a, const struct setting_field *f,
+ const char *value, const char *title)
+{
+ if (f->mask && !*value)
+ return; /* empty secret means keep the stored one */
+ 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", f->key);
+ yyjson_mut_obj_add_strcpy(d, o, "value", value);
+ char *args = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ if (!args)
+ return;
+ char *rr = client_rpc(&a->conn, "settings.set", a->session, a->org, args);
+ free(args);
+ if (rr && client_ok(rr)) {
+ if (strcmp(f->key, "default_series") == 0)
+ snprintf(a->default_series, sizeof a->default_series, "%s",
+ value);
+ else if (strcmp(f->key, "attachment_dir") == 0)
+ snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s",
+ value);
+ } else {
+ show_error(title, rr);
+ }
+ free(rr);
+}
+
+static void settings_form(struct app *a, const char *title,
+ const struct setting_field *fields, int n,
+ int can_edit, int show_password_status, int *focus)
+{
+ for (;;) {
+ if (g_quit)
+ return;
+ char *resp = client_rpc(&a->conn, "settings.get", a->session, a->org,
+ "{}");
+ if (!resp || !client_ok(resp)) {
+ show_error(title, resp);
+ free(resp);
+ return;
+ }
+ char *vals[8];
+ int choice[8];
+ struct tui_form_field ff[8];
+ memset(ff, 0, sizeof ff);
+ for (int i = 0; i < n; i++) {
+ char path[64];
+ vals[i] = xcalloc(512, 1);
+ snprintf(path, sizeof path, "result.%s", fields[i].key);
+ char *v = jstr_dup(resp, path);
+ snprintf(vals[i], 512, "%s", v ? v : "");
+ free(v);
+ ff[i].label = fields[i].label;
+ ff[i].value = vals[i];
+ ff[i].cap = 512;
+ ff[i].kind = TUI_F_TEXT;
+ ff[i].mask = fields[i].mask;
+ choice[i] = 0;
+ if (fields[i].choices) {
+ ff[i].kind = TUI_F_CHOICE;
+ ff[i].choices = fields[i].choices;
+ ff[i].nchoices = fields[i].nchoices;
+ ff[i].choice = &choice[i];
+ for (int k = 0; k < fields[i].nchoices; k++)
+ if (strcmp(vals[i], fields[i].choices[k]) == 0)
+ choice[i] = k;
+ }
+ }
+ char pwstatus[192];
+ struct tui_form_action acts[1];
+ memset(acts, 0, sizeof acts);
+ if (show_password_status) {
+ int pw_set = jbool_val(resp, "result.smtp_password_set", 0);
+ snprintf(pwstatus, sizeof pwstatus,
+ "SMTP-lösenord (lämna tomt för oförändrat): %s",
+ pw_set ? "satt" : "inte satt");
+ acts[0].label = pwstatus;
+ acts[0].enabled = -1;
+ }
+ free(resp);
+ tui_form_hint("upp/ned/Home/End Enter = ändra F5 = uppdatera"
+ " Esc/q = tillbaka ^C = avsluta");
+ int r = tui_form_run_actions(title, ff, n, can_edit, acts,
+ show_password_status ? 1 : 0, NULL,
+ focus);
+ if (r >= 0 && r < n) {
+ const char *val = vals[r];
+ if (fields[r].choices)
+ val = fields[r].choices[choice[r]];
+ setting_save(a, &fields[r], val, title);
+ }
+ for (int i = 0; i < n; i++)
+ free(vals[i]);
+ if (r == TUI_FORM_BACK)
+ return;
+ }
+}
+
+static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" };
+
+static const struct setting_field INVOICE_SETTINGS[] = {
+ { "default_series", "Standardserie", NULL, 0, 0 },
+ { "invoice_receivable_account", "Fordringskonto", NULL, 0, 0 },
+ { "invoice_revenue_account", "Intäktskonto", NULL, 0, 0 },
+ { "invoice_bankgiro", "Bankgiro", NULL, 0, 0 },
+ { "invoice_our_ref", "Vår referens (faktura)", NULL, 0, 0 },
+};
+
+static const struct setting_field SMTP_SETTINGS[] = {
+ { "smtp_host", "SMTP-server", NULL, 0, 0 },
+ { "smtp_port", "SMTP-port", NULL, 0, 0 },
+ { "smtp_user", "SMTP-användare", NULL, 0, 0 },
+ { "smtp_from", "SMTP-avsändare", NULL, 0, 0 },
+ { "smtp_reply_to", "SMTP-svarsadress", NULL, 0, 0 },
+ { "smtp_security", "SMTP-säkerhet", SECURITY_CHOICES, 3, 0 },
+ { "smtp_password", "SMTP-lösenord", NULL, 0, 1 },
+};
+
+static const struct setting_field SYSTEM_SETTINGS[] = {
+ { "attachment_dir", "Bilagornas mapp", NULL, 0, 0 },
+};
+
+static int settings_can_write(struct app *a)
+{
+ return a->role[0] && (strcmp(a->role, "owner") == 0 ||
+ strcmp(a->role, "bookkeeper") == 0);
+}
+
+static void invoice_settings_screen(struct app *a)
+{
+ static int sel = 0;
+ settings_form(a, "Fakturauppgifter", INVOICE_SETTINGS,
+ (int)(sizeof INVOICE_SETTINGS / sizeof INVOICE_SETTINGS[0]),
+ settings_can_write(a), 0, &sel);
+}
+
+static void smtp_settings_screen(struct app *a)
+{
+ static int sel = 0;
+ settings_form(a, "E-post (SMTP)", SMTP_SETTINGS,
+ (int)(sizeof SMTP_SETTINGS / sizeof SMTP_SETTINGS[0]),
+ settings_can_write(a), 1, &sel);
+}
+
+void system_settings_screen(struct app *a)
+{
+ static int sel = 0;
+ settings_form(a, "Systeminställningar", SYSTEM_SETTINGS,
+ (int)(sizeof SYSTEM_SETTINGS / sizeof SYSTEM_SETTINGS[0]),
+ settings_can_write(a), 0, &sel);
+}
+
void company_screen(struct app *a)
{
static int sel = 0;
const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]);
- const int ninv = 2;
- static const char *const invkeys[2] = { "invoice_bankgiro",
- "invoice_our_ref" };
- static const char *const invlabels[2] = { "Bankgiro",
- "Vår referens (faktura)" };
int owner = a->role[0] && strcmp(a->role, "owner") == 0;
+ int writer = settings_can_write(a);
+ const char *settings_reason = writer ? NULL : "du har inte behörighet";
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);
+ show_error("Bolaget", resp);
free(resp);
return;
}
@@ -233,26 +392,15 @@ void company_screen(struct app *a)
ff[i].kind = TUI_F_TEXT;
}
free(resp);
- char *sresp =
- client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
- for (int i = 0; i < ninv; i++) {
- char path[64];
- int idx = nf + i;
- vals[idx] = xcalloc(512, 1);
- snprintf(path, sizeof path, "result.%s", invkeys[i]);
- char *v = sresp ? jstr_dup(sresp, path) : NULL;
- snprintf(vals[idx], 512, "%s", v ? v : "");
- free(v);
- ff[idx].label = invlabels[i];
- ff[idx].value = vals[idx];
- ff[idx].cap = 512;
- ff[idx].kind = TUI_F_TEXT;
- }
- free(sresp);
- ff[nf + ninv].label = "Styrelseledamöter";
- ff[nf + ninv].kind = TUI_F_ACTION;
- int r = tui_form_run("Företagsuppgifter", ff, nf + ninv + 1, owner,
- &sel);
+ struct tui_form_action acts[3] = {
+ { "Fakturauppgifter…", writer ? 1 : 0, settings_reason },
+ { "E-post (SMTP)…", writer ? 1 : 0, settings_reason },
+ { "Styrelseledamöter", 1, NULL },
+ };
+ tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför"
+ " F5 = uppdatera Esc/q = tillbaka ^C = avsluta");
+ int r = tui_form_run_actions("Bolaget", ff, nf, owner, acts, 3, NULL,
+ &sel);
if (r >= 0 && r < nf) {
if (!company_field_valid(&COMPANY_FIELDS[r], vals[r])) {
tui_message("Ogiltigt värde", "%s", COMPANY_FIELDS[r].label);
@@ -275,150 +423,18 @@ void company_screen(struct app *a)
show_error("Kunde inte spara", rr);
free(rr);
}
- } else if (r >= nf && r < nf + ninv) {
- 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", invkeys[r - nf]);
- yyjson_mut_obj_add_strcpy(d, o, "value", vals[r]);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *rr = client_rpc(&a->conn, "settings.set", a->session,
- a->org, args);
- free(args);
- if (!rr || !client_ok(rr))
- show_error("Kunde inte spara", rr);
- free(rr);
}
- if (r == nf + ninv)
- board_screen(a);
- for (int i = 0; i < nf + ninv; i++)
+ for (int i = 0; i < nf; i++)
free(vals[i]);
- if (r == TUI_FORM_BACK)
+ if (r == TUI_FORM_ACTION + 0)
+ invoice_settings_screen(a);
+ else if (r == TUI_FORM_ACTION + 1)
+ smtp_settings_screen(a);
+ else if (r == TUI_FORM_ACTION + 2)
+ board_screen(a);
+ else if (r == TUI_FORM_BACK)
return;
}
}
-static const char *const SETTING_KEYS[] = {
- "default_series",
- "attachment_dir",
- "invoice_receivable_account",
- "invoice_revenue_account",
- "smtp_host",
- "smtp_port",
- "smtp_user",
- "smtp_from",
- "smtp_reply_to",
- "smtp_security",
- "smtp_password",
-};
-static const char *const SETTING_LABELS[] = {
- "Standardserie",
- "Bilagornas mapp",
- "Fordringskonto",
- "Intäktskonto",
- "SMTP-server",
- "SMTP-port",
- "SMTP-användare",
- "SMTP-avsändare",
- "SMTP-svarsadress",
- "SMTP-säkerhet",
- "SMTP-lösenord",
-};
-static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" };
-void settings_screen(struct app *a)
-{
- static int sel = 0;
- const int nset = (int)(sizeof SETTING_KEYS / sizeof SETTING_KEYS[0]);
- for (;;) {
- if (g_quit)
- return;
- char *resp =
- client_rpc(&a->conn, "settings.get", a->session, a->org, "{}");
- if (!resp || !client_ok(resp)) {
- show_error("Inställningar", resp);
- free(resp);
- return;
- }
- int pw_set = jbool_val(resp, "result.smtp_password_set", 0);
- char pwstatus[192];
- snprintf(pwstatus, sizeof pwstatus,
- "SMTP-lösenord (lämna tomt för oförändrat): %s",
- pw_set ? "satt" : "inte satt");
- char *vals[16];
- struct tui_form_field ff[16];
- memset(ff, 0, sizeof ff);
- int sec_choice = 0;
- for (int i = 0; i < nset; i++) {
- char path[64], *v;
- vals[i] = xcalloc(512, 1);
- snprintf(path, sizeof path, "result.%s", SETTING_KEYS[i]);
- v = jstr_dup(resp, path);
- snprintf(vals[i], 512, "%s", v ? v : "");
- free(v);
- const char *key = SETTING_KEYS[i];
- ff[i].label = SETTING_LABELS[i];
- ff[i].value = vals[i];
- ff[i].cap = 512;
- ff[i].kind = TUI_F_TEXT;
- if (strcmp(key, "smtp_security") == 0) {
- ff[i].kind = TUI_F_CHOICE;
- ff[i].choices = SECURITY_CHOICES;
- ff[i].nchoices = 3;
- ff[i].choice = &sec_choice;
- for (int k = 0; k < 3; k++)
- if (strcmp(vals[i], SECURITY_CHOICES[k]) == 0)
- sec_choice = k;
- } else if (strcmp(key, "smtp_password") == 0) {
- ff[i].mask = 1;
- }
- }
- free(resp);
- struct tui_form_action acts[1];
- memset(acts, 0, sizeof acts);
- acts[0].label = pwstatus;
- acts[0].enabled = -1; /* dim status row, not selectable */
- int r = tui_form_run_actions(
- "Inställningar", ff, nset, 1, acts, 1,
- "upp/ned/Home/End Enter = ändra F5 = uppdatera"
- " Esc/q = tillbaka ^C = avsluta",
- &sel);
- if (r >= 0 && r < nset) {
- const char *key = SETTING_KEYS[r];
- const char *val = vals[r];
- if (strcmp(key, "smtp_security") == 0)
- val = SECURITY_CHOICES[sec_choice];
- if (strcmp(key, "smtp_password") == 0 && !*vals[r]) {
- /* empty means "keep the stored secret" */
- } else {
- yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
- yyjson_mut_val *o = yyjson_mut_obj(d);
- yyjson_mut_doc_set_root(d, o);
- yyjson_mut_obj_add_strcpy(d, o, "key", key);
- yyjson_mut_obj_add_strcpy(d, o, "value", val);
- char *args = yyjson_mut_write(d, 0, NULL);
- yyjson_mut_doc_free(d);
- char *rr = client_rpc(&a->conn, "settings.set", a->session,
- a->org, args);
- free(args);
- if (rr && client_ok(rr)) {
- snprintf(vals[r], 512, "%s", val);
- if (strcmp(key, "default_series") == 0)
- snprintf(a->default_series,
- sizeof a->default_series, "%s", val);
- else if (strcmp(key, "attachment_dir") == 0)
- snprintf(a->attachment_dir,
- sizeof a->attachment_dir, "%s", val);
- } else {
- show_error("Kunde inte spara inställningen", rr);
- }
- free(rr);
- }
- }
- for (int i = 0; i < nset; i++)
- free(vals[i]);
- if (r == TUI_FORM_BACK)
- return;
- }
-}
diff --git a/clients/ui.c b/clients/ui.c
index ff573c9..a95ce44 100644
--- a/clients/ui.c
+++ b/clients/ui.c
@@ -442,6 +442,91 @@ static int bytes_look_text(const unsigned char *p, size_t n)
return 1;
}
+/* Directory for generated PDFs. */
+static void pdf_cache_dir(char *buf, size_t n)
+{
+ const char *cache = getenv("XDG_CACHE_HOME");
+ if (cache && *cache) {
+ snprintf(buf, n, "%s/bokf", cache);
+ return;
+ }
+ snprintf(buf, n, "/tmp");
+}
+
+/* Keep a user-supplied name from escaping the directory. */
+static void safe_fname(const char *in, char *out, size_t n)
+{
+ size_t o = 0;
+ for (const char *p = in ? in : ""; *p && o + 1 < n; p++) {
+ unsigned char c = (unsigned char)*p;
+ if (c < 32 || c == '/')
+ c = '-';
+ out[o++] = (char)c;
+ }
+ out[o] = '\0';
+}
+
+static int write_pdf_b64(const char *b64, const char *path)
+{
+ unsigned char *data = NULL;
+ size_t len = 0;
+ if (!b64 || util_b64_decode(b64, strlen(b64), &data, &len) != 0)
+ return -1;
+ FILE *fp = fopen(path, "wb");
+ if (!fp) {
+ free(data);
+ return -1;
+ }
+ size_t wrote = fwrite(data, 1, len, fp);
+ fclose(fp);
+ free(data);
+ return wrote == len ? 0 : -1;
+}
+
+/* Open a saved PDF with xdg-open when a desktop session is present;
+ otherwise (or on fork failure) show where it was saved. */
+static void open_pdf(const char *path, const char *title)
+{
+ const char *display = getenv("DISPLAY");
+ const char *wayland = getenv("WAYLAND_DISPLAY");
+ if ((!display || !*display) && (!wayland || !*wayland)) {
+ tui_message(title, "Sparad: %s", path);
+ return;
+ }
+ pid_t pid = fork();
+ if (pid == 0) {
+ pid_t gc = fork();
+ if (gc == 0) {
+ setsid();
+ execlp("xdg-open", "xdg-open", path, (char *)NULL);
+ _exit(127);
+ }
+ _exit(gc < 0 ? 1 : 0);
+ }
+ if (pid > 0)
+ waitpid(pid, NULL, 0);
+ else
+ tui_message(title, "Sparad: %s", path);
+}
+
+void pdf_save_and_open(const char *b64, const char *filename,
+ const char *title)
+{
+ char dir[512], path[700], safe[600];
+ pdf_cache_dir(dir, sizeof dir);
+ safe_fname(filename, safe, sizeof safe);
+ if (snprintf(path, sizeof path, "%s/%s", dir, safe) >= (int)sizeof path) {
+ tui_message(title, "Sökvägen blir för lång.");
+ return;
+ }
+ config_mkdirs(path);
+ if (write_pdf_b64(b64, path) != 0) {
+ tui_message(title, "Kunde inte spara %s", path);
+ return;
+ }
+ open_pdf(path, title);
+}
+
/* Fetch one attachment, save it to a prompted path and verify its hash.
Text attachments are shown inline after saving. */
void attachment_download(struct app *a, int64_t att_id)
diff --git a/clients/ui.h b/clients/ui.h
index d10f680..2b93106 100644
--- a/clients/ui.h
+++ b/clients/ui.h
@@ -39,6 +39,8 @@ struct app {
int64_t voucher_sel; /* last selected voucher id in the list view */
int64_t invoice_sel; /* last selected invoice id in the list view */
int64_t customer_sel; /* last selected customer id in the list view */
+ int64_t employee_sel; /* last selected employee id in the list view */
+ int64_t payroll_sel; /* last selected payroll run id in the list view */
char default_series[16];
char attachment_dir[256];
long max_attachment_bytes;
@@ -77,6 +79,8 @@ char *read_file_b64(const char *path);
char *file_browser(struct app *a, const char *start_dir);
void attachment_download(struct app *a, int64_t att_id);
int64_t pick_voucher(struct app *a);
+void pdf_save_and_open(const char *b64, const char *filename,
+ const char *title);
/* generated-document helpers (reports, bokslut) */
void buf_line(struct buf *b, const char *fmt, ...)
@@ -125,10 +129,13 @@ void bank_screen(struct app *a);
void audit_screen(struct app *a);
void templates_screen(struct app *a);
void ib_screen(struct app *a);
-void settings_screen(struct app *a);
void company_screen(struct app *a);
+void system_settings_screen(struct app *a);
void invoices_screen(struct app *a);
void customers_screen(struct app *a);
void bokslut_screen(struct app *a);
+void employees_screen(struct app *a);
+void payroll_screen(struct app *a);
+void payroll_tax_tables_screen(struct app *a);
#endif
diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md
index b821754..23e7fb1 100644
--- a/docs/PAYROLL.md
+++ b/docs/PAYROLL.md
@@ -5,7 +5,7 @@ Status: Design draft · 2026-09-21 · License: GPL-3.0-or-later
Payroll for a small Swedish AB that runs monthly salaries from the books.
Wave 1 is the engine and the mandatory steps; the employee is the owner but
the schema and commands are multi-employee from the start. Wave 2 (the
-lönebesked PDF and its delivery) is done; wave 3 is the TUI.
+lönebesked PDF and its delivery) and wave 3 (the TUI) are done.
## 1. Scope
@@ -227,12 +227,19 @@ the base is the gross.
SMTP configuration `SMTP_NOT_CONFIGURED` and a failed delivery
`SMTP_FAILED` (the stored attachment stays linked). `dry_run` validates,
renders and stores nothing. See `PROTOCOL.md` §7.12.
-- **TUI — Lön** (new section after Fakturering): `Lönekörningar` (list,
- Ctrl+N for a new run), `Anställda` under Register, and a **Skattetabeller**
- action/status that fetches and warns.
+- **TUI — Lön** (done, `clients/screens_payroll.c`): `Lönekörningar` (list,
+ Ctrl+N for a new run) under Lön, `Anställda` under Företag, and a
+ **Skattetabeller** screen (System, since the tables are national) with
+ status, the Skatteverket fetch and the offline file import.
- The run screen shows the preview (F5), posts with Ctrl+Enter after
- confirmation, then offers the buttons **Lönebesked**, **AGI-underlag** and
- **Betala skatt & avgifter** as manual steps.
+ confirmation, then offers the buttons **Lönebesked** (saves/opens the
+ PDF), **AGI-underlag** (`payroll.agi` in a pager, owner only) and
+ **Betala skatt & avgifter** (`payroll.pay_tax` with a date prompt) as
+ manual steps. Steps that do not apply yet are dimmed with the reason.
+ Posting and paying show the resulting voucher as `V-<n>`.
+- The list title warns when the current year's tax tables are missing; a
+ new run is refused with a pointer to Anställda when nobody has a monthly
+ salary.
## 7. Testing
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index ebce8bf..01f5dd1 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -925,6 +925,26 @@ commands. Implemented screens (0.1.0-dev):
number, e-mail, your reference, payment days, notes). Ctrl+N creates,
Enter edits (F5 validates with a dry run, Ctrl+Enter saves), `d`
archives/reactivates.
+- **Lönekörningar** — the monthly payroll runs (`payroll.run_list`, newest
+ period first) with status (`bokförd`/`betald`), gross and net. Ctrl+N
+ starts a new run (period YYYY-MM and pay date, defaulting into the
+ selected fiscal year) when an active employee with a monthly salary
+ exists, Enter opens an existing run. The run screen shows the status and
+ totals, `F5` previews (`payroll.run_preview` for a new run, the stored
+ lines for a posted one), `Ctrl+Enter` posts after confirmation
+ (`payroll.run_post`, showing the voucher), and the manual steps are the
+ action rows **Lönebesked** (PDF via `payroll.payslip`), **AGI-underlag**
+ (`payroll.agi`, owner only) and **Betala skatt & avgifter**
+ (`payroll.pay_tax`); a disabled step names its reason. The list title
+ warns when the current year's tax tables are missing.
+- **Anställda** — the employee register (name, masked personnummer, salary,
+ tax table/column, e-mail). Ctrl+N creates, Enter edits (an empty
+ personnummer keeps the stored one), `d` archives/reactivates; F5
+ validates with a dry run and Ctrl+Enter saves.
+- **Skattetabeller** (System) — stored tax table years, the current year's
+ status, fetch time and source. Owners fetch Skatteverket's official
+ monthly table (`payroll.tax_tables_fetch`) or import a file offline
+ (`payroll.tax_tables_import`).
- **Rapporter** — saldobalans, resultaträkning, balansräkning, moms,
inkomstdeklaration (INK2/SRU), årsredovisning (K2 text draft) and
kontolista (all accounts with type, moms treatment, SRU and status).
@@ -939,18 +959,23 @@ commands. Implemented screens (0.1.0-dev):
events, AGM and payment dates, proposed dividend, employees, other notes),
edited per field; a new fiscal year inherits the stable fields. The
årsredovisning draft reads them without prompting.
-- **Företagsuppgifter** — the org record (name, org number, VAT number,
- address, e-mail, phone, moms period, framework, fiscal-year start month),
- plus the invoice bankgiro and "vår referens"; editable in place, only
- owners can save, others see it read-only. The accounting accounts and the
- SMTP transport live in **Inställningar**.
-- **Revision** — chain verification and behandlingshistorik.
+- **Bolaget** — the org record (name, org number, VAT number, address,
+ e-mail, phone, moms period, framework, fiscal-year start month), editable
+ in place by owners; others see it read-only. The sub-forms
+ **Fakturauppgifter…** (standardserie, fordringskonto, intäktskonto,
+ bankgiro, vår referens) and **E-post (SMTP)…** (host, port, user, sender,
+ reply-to, security, password) are `settings.set` and open to bookkeepers,
+ and **Styrelseledamöter** lists the board.
+- **Systeminställningar** (System) — bilagornas mapp, the file browser's
+ start directory.
+- **Revision** (System) — chain verification and behandlingshistorik.
**Ctrl+N is the universal "add" key**: it starts a new verifikat from the
dashboard, the voucher list and the voucher detail view; a new mall from the
-Mallar menu; a new fiscal year from the year picker; and maps to the editor
-in Ingående balans and to file upload in Underlag. F5 is the universal
-refresh. Hints show the keys per screen.
+Mallar menu; a new customer or employee from their registers; a new payroll
+run from Lönekörningar; a new fiscal year from the year picker; and maps to
+the editor in Ingående balans and to file upload in Underlag. F5 is the
+universal refresh. Hints show the keys per screen.
**Ctrl+R reloads the client in place** (for hot-reloading after a rebuild):
it re-execs the installed binary with `--org`, `--fy` and `--screen NAME`
diff --git a/docs/STATE.md b/docs/STATE.md
index 4425b10..767ed7c 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -6,29 +6,39 @@ Read with `AGENTS.md` (rules) and `docs/TUI-GUIDELINES.md` (UI conventions).
## Status
Working self-hosted bookkeeping system, not production-proven. Backend ledger
-core is complete, as are invoicing and the payroll engine; filing/year-end
-work and the payroll TUI remain. TUI is usable and exercised by
-`make test-pty` (golden screen-text scenarios); `make test` covers the
-server/protocol/ledger, the TUI widget unit tests and the docs consistency
-check.
+core is complete, as are invoicing, the payroll engine and the payroll TUI;
+filing/year-end work remains. TUI is usable and exercised by `make test-pty`
+(golden screen-text scenarios, now including the payroll run and the
+lönebesked); `make test` covers the server/protocol/ledger, the TUI widget
+unit tests and the docs consistency check.
## Resume here (2026-09-21)
- **Deployed**: `v0.1.54`, healthy on `nas` (Alpine runtime, static aarch64
binaries cross-compiled on this machine — ~20 s, the host only assembles
the image; image 33.7 MB, no `libssl3`). `main` is ahead of the deployment:
- payroll (schema v11), the query helpers and the mail fixes are **not
- deployed** yet — deploy before the human tries payroll in the TUI.
-- **Next task**: the **Lön** TUI section (wave 3) per `docs/PAYROLL.md` §6:
- `Lönekörningar` (list + Ctrl+N), `Anställda` under Register, a
- `Skattetabeller` fetch/status action, and the run screen with F5 preview,
- Ctrl+Enter post and the manual buttons Bokför, Lönebesked, AGI-underlag,
- Betala skatt & avgifter.
-- **Follow-ups from the payroll waves**: fold `payroll.settings_get/set` into
+ payroll (schema v11), the query helpers, the mail fixes and the payroll
+ TUI are **not deployed** yet — deploy before the human tries payroll on
+ `nas`.
+- **Next tasks (payroll follow-ups)**: fold `payroll.settings_get/set` into
`settings.get/set` (cmd_settings.c was busy during wave 1); settle the
over-80k % rule (SKV 433 leaves it ambiguous — currently a clear
- `INVALID_ARGS`); move `invoice.send` to the shared `src/mail.c`; add a pty
- scenario for the payslip.
+ `INVALID_ARGS`); move `invoice.send` to the shared `src/mail.c`. The TUI
+ has no screen for the payroll account/rate settings yet (they are
+ defaults and can stay server-side until the fold).
+- **Done in the TUI wave**: `clients/screens_payroll.c` with Lönekörningar
+ (list + Ctrl+N, run screen with F5 preview, Ctrl+Enter post after
+ confirmation and the action rows Lönebesked, AGI-underlag, Betala skatt &
+ avgifter), Anställda under Företag and the Skattetabeller
+ fetch/import/status screen under System. A pty scenario posts a run and
+ fetches its lönebesked, so the payslip PDF path is exercised end to end.
+- **Menyn (2026-09-21)**: dashboarden har sektionerna Bokföring (med
+ Mallar), Fakturering, Lön, Rapporter & bokslut, Företag (Anställda,
+ Kunder, Bolaget, Momsregler) och System (Skattetabeller, Revision,
+ Systeminställningar). Inställningar är borttagen: standardserie,
+ fordrings-/intäktskonto, bankgiro, vår referens och SMTP ligger nu under
+ Bolaget (sub-formulären Fakturauppgifter och E-post), bilagornas mapp
+ under System. `--screen settings` är kvar som alias för Bolaget.
- **Already merged on main since the last deploy**: payroll schema v10/v11 +
employees/tax tables/runs/payslip (`e692f0f`, `4488b6c`), `mail.c` +
`smtp_reply_to` (`e25a8c9`), query helpers `db_row_json`/`req_list`/
@@ -60,8 +70,9 @@ None open. Completed items that used to be listed here are archived in
11. ~~SIE import only into an empty fiscal year~~ chronological multi-year
import works; each year must still target an empty fiscal year.
12. TUI polish: horizontal scrolling in long text fields, bracketed paste.
-13. Payroll TUI (wave 3) and the payroll follow-ups listed under "Resume
- here"; payroll is server-complete but has no employee registered yet.
+13. ~~Payroll TUI (wave 3)~~ done (`clients/screens_payroll.c`); the
+ remaining payroll follow-ups are listed under "Resume here". No
+ employee is registered yet in the real orgs.
14. Invoice follow-ups when needed: credit notes (`invoice.credit`), per-row
account in the invoice form, kundreskontra view.
15. `make test-pty` speed if it ever exceeds ~60 s: parallelise the
@@ -165,9 +176,9 @@ Original entries for the struck items are in `docs/DECISIONS.md`.
pärm (får refereras i efterhand, även i stängda år).
- **Lön (2026-09-21)**: bara ägaren är anställd, men schema och kommandon är
fleranvändarklara. Lönemotorn (schema v11: anställda, skattetabeller,
- lönekörningar, lönebesked + mejl) är klar server-side; TUI:t saknas och
+ lönekörningar, lönebesked + mejl) och TUI-sektionen Lön är klara, men
**ingen anställd är registrerad än** (personnummer, lön, skattetabell och
- kolumn fylls i under Anställda). Konton enligt historiken:
+ kolumn fylls i under Register → Anställda). Konton enligt historiken:
7210/7510/2710/2731/1630. Skattetabellerna hämtas med en knapp från
Skatteverket (varning när året är gammalt), personnumret är krypterat i
databasen. Ingen semesteruppbokning; arbetsgivardeklarationen (AGI) görs
diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md
index 46982ed..7e0d404 100644
--- a/docs/TUI-GUIDELINES.md
+++ b/docs/TUI-GUIDELINES.md
@@ -43,7 +43,8 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width.
- Rows are numbered `NN. text`, right-aligned so 2- and 3-digit numbers line up.
- Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`),
- using the org's `default_series` (Inställningar) for new vouchers.
+ using the org's `default_series` (Bolaget → Fakturauppgifter) for new
+ vouchers.
- The last row may be an action (e.g. `+ Nytt verifikat (Ctrl+N)`); selecting it
runs the action instead of opening a detail view.
- Selection memory: lists remember the selected row by identity (voucher id),
diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py
index 25cfdc3..fbcfc57 100755
--- a/scripts/tui-golden.py
+++ b/scripts/tui-golden.py
@@ -56,6 +56,7 @@ KEYS = {
"ctrlc": "\x03",
"ctrln": "\x0e",
"ctrlenter": "\x1b[27;5;13~",
+ "f5": "\x1b[15~",
}
# {org_name} {org_nr} {fy_label} {fy_start} {fy_end} are substituted at run
@@ -64,7 +65,8 @@ SCENARIOS = [
{
"name": "dashboard",
"screen": "dashboard",
- "expect": ["{org_name}", "{fy_label}", "{fy_start}", "{fy_end}"],
+ "expect": ["{org_name}", "{fy_label}", "{fy_start}", "{fy_end}",
+ "Företag", "Bolaget"],
},
{
"name": "audit-ok",
@@ -211,6 +213,78 @@ SCENARIOS = [
},
],
},
+ {
+ "name": "employees",
+ "screen": "employees",
+ "expect": ["Anställda", "Testanställd", "Ny anställd"],
+ },
+ {
+ "name": "company",
+ "screen": "company",
+ "expect": ["Bolaget", "Fakturauppgifter", "E-post (SMTP)",
+ "Styrelseledamöter", "Test AB"],
+ "steps": [
+ {
+ "keys": ["end", "up", "enter"],
+ "expect": ["E-post (SMTP)", "SMTP-server",
+ "lämna tomt för oförändrat"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["Bolaget", "Fakturauppgifter"],
+ },
+ ],
+ },
+ {
+ "name": "system",
+ "screen": "system",
+ "expect": ["Systeminställningar", "Bilagornas mapp"],
+ },
+ {
+ "name": "tax-tables",
+ "screen": "tax_tables",
+ "expect": ["Skattetabeller", "Status: skattetabeller för",
+ "Hämta från Skatteverket"],
+ },
+ {
+ "name": "payroll",
+ "screen": "payroll",
+ "expect": ["Lönekörningar", "Ny lönekörning"],
+ },
+ {
+ "name": "payroll-run",
+ "screen": "payroll",
+ "steps": [
+ {
+ "keys": ["ctrln"],
+ "expect": ["Ny lönekörning", "Period", "Bokför körning"],
+ },
+ {
+ "keys": ["f5"],
+ "expect": ["Testanställd", "30 000,00", "Avgifter"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["Ny lönekörning", "Bokför körning"],
+ },
+ {
+ "keys": ["ctrlenter"],
+ "expect": ["Bokför lönekörningen"],
+ },
+ {
+ "keys": ["j"],
+ "expect": ["Lönekörningen är bokförd"],
+ },
+ {
+ "keys": ["enter"],
+ "expect": ["Status: bokförd", "Lönebesked (PDF)"],
+ },
+ {
+ "keys": ["down", "down", "down", "down", "enter"],
+ "expect": ["Sparad", "Lönebesked"],
+ },
+ ],
+ },
]
# --------------------------------------------------------------------------
@@ -739,6 +813,24 @@ def setup_rig(repo, tmp, env):
"unit_price_ore": 100000,
"vat_code": "25"}],
})], env)
+ # Payroll rig: one B record covering the employee's salary, and one
+ # employee, so the Lön screens have data to show.
+ table_line = "30B30 1 80000 7000 7000 7000 7000 7000 7000\n"
+ run_checked([str(bind / "bokfctl"), "--socket", str(sock),
+ "--user", "admin", "--password", env["BOKFD_PASSWORD"],
+ "--org", str(org_id), "payroll.tax_tables_import",
+ json.dumps({"year": start.year,
+ "content_base64": base64.b64encode(
+ table_line.encode("utf-8")).decode()})], env)
+ run_checked([str(bind / "bokfctl"), "--socket", str(sock),
+ "--user", "admin", "--password", env["BOKFD_PASSWORD"],
+ "--org", str(org_id), "employee.create",
+ json.dumps({"name": "Testanställd",
+ "personal_no": "19800101-1234",
+ "monthly_salary_ore": 3000000,
+ "tax_table": 30,
+ "tax_column": 1,
+ "email": "test@example.com"})], env)
return daemon, sock, org_id, fy
@@ -763,9 +855,14 @@ def main(argv):
tmp = Path(tempfile.mkdtemp(prefix="bokf-golden-", dir="/tmp"))
password = "golden-" + os.urandom(6).hex()
env = dict(os.environ)
- env.update({"BOKFD_PASSWORD": password})
+ env.update({"BOKFD_PASSWORD": password,
+ "BOKFD_SECRET_KEY": os.urandom(32).hex()})
env.pop("BOKFD_SESSION", None)
env.pop("BOKFD_TOKEN", None)
+ # keep generated PDFs from opening a window: the TUI then reports where
+ # it saved the file, which is what the scenarios assert on
+ env.pop("DISPLAY", None)
+ env.pop("WAYLAND_DISPLAY", None)
daemon = None
failures = 0