diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 155 | ||||
| -rw-r--r-- | clients/bokftui.c | 19 | ||||
| -rw-r--r-- | clients/client.c | 3 | ||||
| -rw-r--r-- | clients/screens_bokslut.c | 7 | ||||
| -rw-r--r-- | clients/screens_dashboard.c | 57 | ||||
| -rw-r--r-- | clients/screens_ib.c | 2 | ||||
| -rw-r--r-- | clients/screens_invoices.c | 88 | ||||
| -rw-r--r-- | clients/screens_payroll.c | 1261 | ||||
| -rw-r--r-- | clients/screens_settings.c | 371 | ||||
| -rw-r--r-- | clients/screens_templates.c | 2 | ||||
| -rw-r--r-- | clients/screens_vouchers.c | 2 | ||||
| -rw-r--r-- | clients/tui.c | 35 | ||||
| -rw-r--r-- | clients/ui.c | 104 | ||||
| -rw-r--r-- | clients/ui.h | 10 | ||||
| -rw-r--r-- | docs/PAYROLL.md | 19 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 52 | ||||
| -rw-r--r-- | docs/STATE.md | 70 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 17 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 202 | ||||
| -rw-r--r-- | src/smtp.c | 3 | ||||
| -rw-r--r-- | src/tax_table.c | 3 | ||||
| -rw-r--r-- | src/tls_ca.h | 31 |
24 files changed, 2154 insertions, 362 deletions
@@ -14,3 +14,4 @@ dropzone/ var/ .env build-gate*/ +__pycache__/ @@ -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) @@ -3,13 +3,61 @@ Self-hosted bookkeeping for Swedish organizations. One C daemon, one SQLite file, one JSON API shared by the CLI, the ncurses UI and LLM agents. -Status: **0.1.0-dev — walking skeleton.** Not production ready. See +Status: **0.1.0-dev — working, not production-proven.** See `docs/COMPLIANCE.md` §9. +## Features + +Everything below lives in the daemon and one SQLite file; every feature is a +JSON command in `docs/PROTOCOL.md`, so the TUI, the CLI and an AI agent can +do the same things. + +**Bookkeeping** +- BAS chart of accounts (K2 and K3 variants), per organization; multi-org with + owner/bookkeeper/viewer roles. +- Verifikat with unbroken numbering per series and fiscal year, balanced + rows in integer öre, ändringsverifikat for corrections — nothing is ever + edited or deleted. +- Konteringsmallar: reusable templates with a small formula language + (`x*0.25`, `-x`), applied at posting time. +- Underlag: attachments stored in the database (SHA-256 verified, + append-only), an inbox for unlinked documents. +- Fiscal years, period locks, ingående balans, year-end close. + +**Reports and filing** +- Saldobalans, resultatrapport, balansrapport, huvudbok, + verifikationslista — Kapitas-style tables in the TUI, JSON on the API. +- Momsrapport ruta för ruta with per-org editable rules; eSKD XML export + for the momsdeklaration. +- Bokslut: automated year-end postings (avskrivningar, periodiseringsfond, + skatt, resultatdisposition) from a dry-run plan. +- Årsredovisning K2 text draft and INK2/SRU files (INK2, INK2R, INK2S). +- SIE 4 import and export (CP437), multi-year history import. + +**Invoicing, bank and payroll** +- Customer register; invoices rendered as PDF (own renderer, no + dependencies), OCR numbers, issue = number + PDF + voucher in one + transaction, e-mail over SMTP with the password encrypted at rest. +- Bank reconciliation: import SEB CSV statements, match against vouchers, + create prefilled vouchers from unmatched transactions. +- Payroll engine: employees, Skatteverket tax tables, monthly runs with + preview/post, payslips (PDF + e-mail) and AGI underlag — API and TUI + complete (Lönekörningar, Anställda, Skattetabeller). + +**Compliance and operations** (`docs/COMPLIANCE.md`) +- SHA-256 hash chains over vouchers and the audit log; `audit.verify` + recomputes them and re-hashes attachments. +- Every mutation is audited; secrets never reach the log. +- `backup.snapshot` (`VACUUM INTO`) for consistent backups; pre-migration + snapshots before schema upgrades. +- Unix socket, optional TLS listener with certificate reload, Argon2id + passwords, scoped revocable API tokens, login rate limiting. + ## Build -Requirements: Linux, a C11 compiler, GNU make, and libncursesw for the TUI. -No other system libraries — SQLite, yyjson, SHA-256 and Argon2 are vendored. +Requirements: Linux, a C11 compiler, GNU make, libncursesw for the TUI and +OpenSSL (`libssl-dev`) for TLS and SMTP. SQLite, yyjson, SHA-256 and Argon2 +are vendored. ```sh make -j$(nproc) @@ -28,7 +76,9 @@ Produces `build/bokfd` (daemon), `build/bokfctl` (CLI) and `build/bokftui` ```sh ./build/bokfd init --db /var/lib/bokfd/bokfd.db --user admin -BOKFD_PASSWORD=secret ./build/bokfd init ... # non-interactive +# non-interactive, same command with the password in the environment: +BOKFD_PASSWORD=secret ./build/bokfd init --db /var/lib/bokfd/bokfd.db \ + --user admin ./build/bokfd --db /var/lib/bokfd/bokfd.db --socket /run/bokfd/bokfd.sock ``` @@ -43,11 +93,108 @@ BOKFD_USER=admin BOKFD_PASSWORD=secret \ ./build/bokfctl --socket /run/bokfd/bokfd.sock org.create '{"name":"AB Ett","org_nr":"5560123456"}' ``` +## Run the backend with Docker + +The image carries `bokfd` and `bokfctl` only (Alpine, ~30 MB). It is built +from the checkout; there is no registry. The TUI is built on the machine you +sit at and talks to the container over TLS. + +```sh +# 1. Image +docker build -t bokf:dev . + +# 2. A directory for compose.yaml, .env and the state (var/db, var/run) +mkdir -p ~/bokf && cp compose.yaml ~/bokf/ && cd ~/bokf +printf 'BOKF_IMAGE=bokf\nBOKF_TAG=dev\nLEGO_DOMAIN=localhost\n' > .env + +# 3. A certificate. Self-signed is fine on a LAN; compose.yaml also has a +# lego sidecar for Let's Encrypt via DNS-01 (see docs/DEPLOY.md). +mkdir -p var/db/certs/certificates +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ + -days 3650 -subj /CN=localhost -addext subjectAltName=DNS:localhost \ + -keyout var/db/certs/certificates/localhost.key \ + -out var/db/certs/certificates/localhost.crt + +# 4. Create the database and the admin user, then start +docker compose run --rm -e BOKFD_PASSWORD=secret123 bokfd init --user admin +docker compose up -d bokfd +docker compose ps # wait for "healthy" +``` + +Then from any machine that trusts the certificate: + +```sh +export BOKFD_SOCKET=tls:localhost:8788 \ + BOKFD_TLS_CA=~/bokf/var/db/certs/certificates/localhost.crt +BOKFD_USER=admin BOKFD_PASSWORD=secret123 ./build/bokfctl health +BOKFD_USER=admin BOKFD_PASSWORD=secret123 ./build/bokftui +``` + +`init` refuses to run twice. State lives in `var/db` (database, `backup/`, +`export/`, `certs/`) and `var/run` (the Unix socket, for clients on the same +host). Set `BOKFD_SECRET_KEY` in `.env` (32 random bytes, hex or base64) +before storing SMTP passwords or employee personnummer (payroll needs it). +Use `LEGO_DOMAIN`, `LEGO_EMAIL`, +`INWX_USERNAME`/`INWX_PASSWORD` for a real certificate, and +`scripts/deploy.sh` to ship releases to a remote host over SSH — both in +`docs/DEPLOY.md`. + +## Working with an AI agent + +bokf is designed so that an LLM agent can do the bookkeeping, with a human +approving what matters: + +- **One protocol, self-describing.** `describe` returns every command with + its permission, argument schema and defaults; `agent.instructions` returns + the workflow rules in Markdown. An agent needs no other documentation. +- **Safe by construction.** Every mutation supports `dry_run:true`, and + postings carry an idempotent `client_ref`; the ledger is append-only, so a + wrong posting is corrected by a new voucher, never hidden. Locked periods + and closed years are hard stops the agent cannot override. +- **Least privilege.** Give the agent a scoped API token (`read`, `write`, + never `admin`), bound to one organization, shown once and revocable. Owner + actions (closing a year in the TUI, token administration in `bokfctl`) + stay with the human. +- **Fully audited.** Every call the agent makes is in the hash-chained audit + log with the token that made it. + +Setup is one command per agent: + +```sh +BOKFD_USER=admin BOKFD_PASSWORD=… ./build/bokfctl --org 1 token.create \ + '{"label":"claude","scopes":["read","write"]}' +``` + +Point the agent at the socket (or `tls:host:8788`) and the token, tell it to +fetch `agent.instructions` first, and prompts like these are enough: + +- *"Post the September invoices from `~/underlag/2026-09/` — attach each PDF, + use the `Försäljning 25 %` template, dry-run everything and show me the + list before posting."* +- *"Import `seb-2026-09.csv`, match what you can against existing vouchers, + and prepare — but don't post — vouchers for the rest."* +- *"Give me the momsrapport for Q3, explain any box that changed more than + 20 % from Q2, and save the eSKD file."* +- *"Run the October payroll preview for all employees and list the + differences from September."* + +`bokfctl` speaks the same NDJSON protocol from shell scripts and MCP-style +tool wrappers alike: `bokfctl <command> '<json args>'`. + +The same applies to developing bokf. `AGENTS.md` holds the invariants and +the recipe for adding a command; `make check` fails when code and +`docs/PROTOCOL.md` drift; `make gate` is the pre-push bar. Most of the +project was written by agents working from short briefs. + ## Documentation - `docs/PROTOCOL.md` — transport, auth, command reference - `docs/SCHEMA.md` — database schema, invariants, hash chains - `docs/COMPLIANCE.md` — BFL/BFNAR mapping and operator duties +- `docs/DEPLOY.md` — Docker, TLS certificates, deploying to a remote host +- `docs/INVOICING.md`, `docs/PAYROLL.md` — design notes for those modules +- `docs/TUI-GUIDELINES.md` — keys, widgets and conventions for the TUI +- `AGENTS.md` — how to work on the code (for humans and agents) ## License diff --git a/clients/bokftui.c b/clients/bokftui.c index 84d1a18..69d6690 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_screen }, { "bokslut", bokslut_screen }, { "yearinfo", bokslut_screen }, /* ^R compatibility */ }; @@ -142,11 +146,14 @@ static void config_load(struct app *a) snprintf(a->socket, sizeof a->socket, "%s", val); else if (strcmp(line, "user") == 0 && !a->username[0]) snprintf(a->username, sizeof a->username, "%s", val); + else if (strcmp(line, "attachment_dir") == 0 && + !a->attachment_dir[0]) + snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", val); } fclose(f); } -static void config_save(const struct app *a) +void config_save(struct app *a) { char path[512]; config_path(path, sizeof path); @@ -157,6 +164,8 @@ static void config_save(const struct app *a) fprintf(f, "server=%s\n", a->socket); if (a->username[0]) fprintf(f, "user=%s\n", a->username); + if (a->attachment_dir[0]) + fprintf(f, "attachment_dir=%s\n", a->attachment_dir); fclose(f); chmod(path, 0600); } @@ -329,13 +338,15 @@ 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"); } static void reset_modify_keys(void) { + putp("\033[<u"); putp("\033[>4m"); fflush(stdout); } diff --git a/clients/client.c b/clients/client.c index fd119f1..30f2a67 100644 --- a/clients/client.c +++ b/clients/client.c @@ -1,4 +1,5 @@ #include "client.h" +#include "tls_ca.h" #include <arpa/inet.h> #include <errno.h> @@ -126,7 +127,7 @@ static int tls_connect_addr(const char *addrport, struct client_conn *out) } SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); - SSL_CTX_set_default_verify_paths(ctx); + tls_load_default_cas(ctx); const char *ca = getenv("BOKFD_TLS_CA"); if (ca && *ca && SSL_CTX_load_verify_locations(ctx, ca, NULL) != 1) { set_tls_error("TLS CA file"); diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c index 41649ce..3f69982 100644 --- a/clients/screens_bokslut.c +++ b/clients/screens_bokslut.c @@ -242,7 +242,7 @@ void bokslut_screen(struct app *a) ff[7].cap = sizeof rate; ff[7].kind = TUI_F_TEXT; tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför F5 = visa" - " bokslutsplan ^Enter = bokför planen (frågar" + " bokslutsplan ^Enter/F9 = bokför planen (frågar" " först) Esc/q = tillbaka ^C = avsluta"); int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5, NULL, &sel); @@ -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..83b4f6a 100644 --- a/clients/screens_dashboard.c +++ b/clients/screens_dashboard.c @@ -268,18 +268,13 @@ static void select_fiscal_year(struct app *a) } } static const char *const MAIN_ITEMS[] = { - MK_SECTION "Bokföring", - "Verifikat", "Underlag (inkorg)", "Bankavstämning", - MK_SECTION "Fakturering", + "Verifikat", "Underlag (inkorg)", "Bankavstämning", "Mallar", "Fakturor", - MK_SECTION "Rapporter & bokslut", + "Lönekörningar", "Rapporter", "Bokslut", - MK_SECTION "Register", - "Mallar", "Kunder", "Momsregler", "Företagsuppgifter", "Inställningar", - MK_SECTION "Räkenskapsår", + "Bolaget", "System", "Ingående balans", "Räkenskapsår", - MK_SECTION "Övrigt", - "Revision", "Logga ut / avsluta", + "Logga ut / avsluta", }; void dashboard(struct app *a) @@ -303,52 +298,46 @@ void dashboard(struct app *a) if (sel < 0) continue; /* Esc/back at the top level never exits */ switch (sel) { - case 1: + case 0: open_scene(a, "vouchers"); break; - case 2: + case 1: open_scene(a, "inbox"); break; - case 3: + case 2: open_scene(a, "bank"); break; - case 5: + case 3: + open_scene(a, "templates"); + break; + case 4: open_scene(a, "invoices"); break; - case 7: + case 5: + open_scene(a, "payroll"); + break; + case 6: open_scene(a, "reports"); break; - case 8: + case 7: open_scene(a, "bokslut"); break; - case 10: - open_scene(a, "templates"); - break; - case 11: - open_scene(a, "customers"); - break; - case 12: - open_scene(a, "rules"); - break; - case 13: + case 8: open_scene(a, "company"); break; - case 14: - open_scene(a, "settings"); + case 9: + open_scene(a, "system"); break; - case 16: + case 10: open_scene(a, "ib"); break; - case 17: + case 11: select_fiscal_year(a); break; - case 19: - open_scene(a, "audit"); - break; - case 20: + case 12: return; default: - break; /* section header */ + break; } } } diff --git a/clients/screens_ib.c b/clients/screens_ib.c index ca35562..8950667 100644 --- a/clients/screens_ib.c +++ b/clients/screens_ib.c @@ -272,7 +272,7 @@ static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold) for (;;) { int rr = tui_rt_run("Ingående balans", &ib.rt, "Tab = byta fält F5 = validera ^X = rensa rad" - " ^Enter = spara Esc = avbryt"); + " ^Enter/F9 = spara Esc = avbryt"); if (rr == -1) return 0; char msg[256]; diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c index 3782a5f..ca59eef 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); @@ -1174,7 +1092,7 @@ static int64_t invoices_new(struct app *a) tui_rt_set_fields(&f.rt, ff, 7); const char *hint = "Enter = välj/ändra Tab = byta fält F5 = förhandsvisa" - " ^Enter = utfärda Esc = avbryt"; + " ^Enter/F9 = utfärda Esc = avbryt"; int64_t out = 0; for (;;) { int rr = tui_rt_run("Ny faktura", &f.rt, hint); 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); + } +} diff --git a/clients/screens_settings.c b/clients/screens_settings.c index 6440150..8a31620 100644 --- a/clients/screens_settings.c +++ b/clients/screens_settings.c @@ -193,15 +193,183 @@ static void board_screen(struct app *a) } } -void company_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 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); +} + +static const char *const SYSTEM_ITEMS[] = { + "Skattetabeller", + "Revision", +}; + +void system_screen(struct app *a) +{ + static int sel = 0; + for (;;) { + if (g_quit) + return; + int r = tui_menu("System", SYSTEM_ITEMS, + (int)(sizeof SYSTEM_ITEMS / sizeof SYSTEM_ITEMS[0]), + 0, &sel); + if (r < 0) + return; + if (r == 0) + payroll_tax_tables_screen(a); + else if (r == 1) + audit_screen(a); + } +} + +static void company_data_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; for (;;) { if (g_quit) @@ -233,26 +401,7 @@ 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); + int r = tui_form_run("Företagsuppgifter", ff, nf, owner, &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 +424,62 @@ 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) 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 COMPANY_ITEMS[] = { + "Företagsuppgifter", + "Fakturauppgifter", + "E-post (SMTP)", + "Styrelseledamöter", + MK_SECTION "Register", + "Anställda", + "Kunder", + "Momsregler", }; -static const char *const SECURITY_CHOICES[] = { "starttls", "tls", "plain" }; -void settings_screen(struct app *a) +void company_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); + int r = tui_menu("Bolaget", COMPANY_ITEMS, + (int)(sizeof COMPANY_ITEMS / sizeof COMPANY_ITEMS[0]), + 0, &sel); + if (r < 0) return; + switch (r) { + case 0: + company_data_screen(a); + break; + case 1: + invoice_settings_screen(a); + break; + case 2: + smtp_settings_screen(a); + break; + case 3: + board_screen(a); + break; + case 5: + employees_screen(a); + break; + case 6: + customers_screen(a); + break; + case 7: + rules_screen(a); + break; + default: + break; } - 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/screens_templates.c b/clients/screens_templates.c index b2cd7ab..5777742 100644 --- a/clients/screens_templates.c +++ b/clients/screens_templates.c @@ -234,7 +234,7 @@ static int template_form(struct app *a, const char *load_name) tui_rt_set_fields(&tf.rt, ff, 3); const char *title = load_name ? "Redigera mall" : "Ny mall"; const char *hint = "Enter = ändra fält Tab = byta fält F5 = validera" - " ^X = rensa rad ^Enter = spara Esc = avbryt"; + " ^X = rensa rad ^Enter/F9 = spara Esc = avbryt"; for (;;) { int rr = tui_rt_run(title, &tf.rt, hint); if (rr == -1) diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c index fedffe9..5e1b006 100644 --- a/clients/screens_vouchers.c +++ b/clients/screens_vouchers.c @@ -836,7 +836,7 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p) tui_rt_normalize(&vf.rt); const char *hint = "Enter = ändra fält Tab = byta fält F4 = mall" " ^F = bifoga fil F5 = validera ^X = rensa rad" - " ^Enter = bokför Esc = avbryt"; + " ^Enter/F9 = bokför Esc = avbryt"; for (;;) { int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint); if (rr == -1) diff --git a/clients/tui.c b/clients/tui.c index 031fad0..30761a8 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -159,9 +159,34 @@ int tui_sticky_split(char **lines, int n, char **head, int *nhead) void tui_keys_setup(void) { - /* ^Enter has no control code; enable xterm modifyOtherKeys and bind the - sequence it produces. Terminals without it ignore the request. */ + /* ^Enter has no control code; enable xterm modifyOtherKeys level 2 (level + 1 leaves control+Enter alone) and the Kitty keyboard protocol, then bind + the sequences they produce. Terminals with neither keep F9 working. */ + putp("\033[>4;2m"); + putp("\033[>1u"); + fflush(stdout); define_key("\033[27;5;13~", TUI_KEY_CTRL_ENTER); + define_key("\033[13;5u", TUI_KEY_CTRL_ENTER); + define_key("\033[27;2;9~", KEY_BTAB); + define_key("\033[9;2u", KEY_BTAB); + define_key("\033[27u", 27); + define_key("\033[27;1u", 27); + define_key("\033[27;5;97~", 1); + define_key("\033[97;5u", 1); + define_key("\033[27;5;99~", 3); + define_key("\033[99;5u", 3); + define_key("\033[27;5;101~", 5); + define_key("\033[101;5u", 5); + define_key("\033[27;5;102~", 6); + define_key("\033[102;5u", 6); + define_key("\033[27;5;110~", 14); + define_key("\033[110;5u", 14); + define_key("\033[27;5;114~", 18); + define_key("\033[114;5u", 18); + define_key("\033[27;5;117~", 21); + define_key("\033[117;5u", 21); + define_key("\033[27;5;120~", 24); + define_key("\033[120;5u", 24); /* Home/End arrive as CSI or SS3 depending on the terminal */ define_key("\033[1~", KEY_HOME); define_key("\033OH", KEY_HOME); @@ -184,8 +209,6 @@ void tui_keys_setup(void) init_pair(TUI_CP_RULE, COLOR_BLUE, -1); init_pair(TUI_CP_STATUS, COLOR_CYAN, -1); } - putp("\033[>4;1m"); - fflush(stdout); } @@ -1592,7 +1615,7 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, h = na > 0 ? (can_edit ? "upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = uppdatera ^Enter = spara" + " F5 = uppdatera ^Enter/F9 = spara" " Esc/q = tillbaka ^C = avsluta" : "upp/ned/Tab = flytta Enter = utför" " F5 = uppdatera Esc/q = tillbaka" @@ -1600,7 +1623,7 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, " (endast behöriga kan ändra)") : (can_edit ? "upp/ned/Home/End Enter = ändra" - " F5 = uppdatera ^Enter = spara" + " F5 = uppdatera ^Enter/F9 = spara" " Esc/q = tillbaka ^C = avsluta" : "upp/ned/Home/End F5 = uppdatera" " Esc/q = tillbaka ^C = avsluta" diff --git a/clients/ui.c b/clients/ui.c index ff573c9..ca6906f 100644 --- a/clients/ui.c +++ b/clients/ui.c @@ -217,17 +217,12 @@ void app_refresh_context(struct app *a) free(resp); snprintf(a->default_series, sizeof a->default_series, "%s", "A"); - a->attachment_dir[0] = '\0'; resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { char *ser = jstr_dup(resp, "result.default_series"); if (ser && *ser) snprintf(a->default_series, sizeof a->default_series, "%s", ser); free(ser); - char *dir = jstr_dup(resp, "result.attachment_dir"); - if (dir && *dir) - snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", dir); - free(dir); } free(resp); @@ -332,14 +327,18 @@ static void expand_path(const char *in, char *out, size_t n) Returns a malloc'd path, or NULL on cancel. */ char *file_browser(struct app *a, const char *start_dir) { - (void)a; if (g_quit) return NULL; char dir[1024]; - const char *start = (start_dir && *start_dir) ? start_dir : getenv("HOME"); + const char *home = getenv("HOME"); + const char *start = (start_dir && *start_dir) ? start_dir : home; expand_path(start, dir, sizeof dir); - if (!dir[0]) - snprintf(dir, sizeof dir, "."); + struct stat sb; + if (!dir[0] || stat(dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) { + expand_path(home && *home ? home : ".", dir, sizeof dir); + if (!dir[0]) + snprintf(dir, sizeof dir, "."); + } for (;;) { DIR *d = opendir(dir); if (!d) { @@ -408,6 +407,8 @@ char *file_browser(struct app *a, const char *start_dir) continue; } free(ents); + snprintf(a->attachment_dir, sizeof a->attachment_dir, "%.255s", dir); + config_save(a); return xstrdup(full); } } @@ -442,6 +443,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..b37eb4f 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, ...) @@ -98,6 +102,7 @@ extern const struct is_group IS_GROUPS[IS_GROUPS_N]; /* defined in bokftui.c, used by screens */ void open_scene(struct app *a, const char *name); void config_mkdirs(const char *path); +void config_save(struct app *a); /* Optional prefill for the new-voucher form (bank reconciliation). */ struct voucher_prefill { @@ -125,10 +130,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 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); +void system_screen(struct app *a); #endif diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md index b821754..b78aeba 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 Bolaget, and a + **Skattetabeller** screen (via 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..76197d9 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -378,8 +378,9 @@ resolved rows in a dry run. `default_series` (1–8 characters, e.g. `A`, `V-`, `A `) is used when `voucher.post` carries no `series` and as the default series for new -templates. `attachment_dir` (a path, up to 255 characters) is the folder the -TUI file browser opens in when attaching underlag. `bank_account` (digits +templates. `attachment_dir` (a path, up to 255 characters) is accepted for +compatibility, but the TUI file browser now remembers the directory of the +last picked attachment client-side. `bank_account` (digits only, up to 10 characters, default `1930`) is the account `bank.import` uses when the request carries no `account`. `invoice_receivable_account` (default `1510`) and `invoice_revenue_account` (default `3001`) are the receivable and @@ -925,6 +926,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** (via **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 +960,27 @@ 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 dashboard's hub for the org's master data: + **Företagsuppgifter** (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; **Fakturauppgifter** + (standardserie, fordringskonto, intäktskonto, bankgiro, vår referens) and + **E-post (SMTP)** (host, port, user, sender, reply-to, security, + password), both `settings.set` and open to bookkeepers; + **Styrelseledamöter**; and the registers **Anställda**, **Kunder** and + **Momsregler**. +- **System** — the hub with **Skattetabeller** and **Revision**. The file + browser starts in the directory of the last picked attachment (remembered + in the client's `tui.conf`) and falls back to `$HOME` when it is gone. +- **Revision** (via **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..929ddf1 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -6,34 +6,49 @@ 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 +- **Deployed**: `v0.1.59`, 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 + the image; image 33.7 MB, no `libssl3`). `v0.1.55` brought the payroll + server waves (schema v11: employees, tax tables, lönebesked) and the + payroll TUI; `v0.1.56` the **Bolaget** hub; `v0.1.57` the flat main menu + without section headings, the **System** hub (Skattetabeller, Revision) + and the client-side remembered attachment directory; `v0.1.58` loads the + system CA bundle explicitly so the static binaries can verify TLS (the + Skatteverket fetch, SMTP, static clients); `v0.1.59` fixes form saving on + modern terminals (modifyOtherKeys level 2, Kitty keyboard protocol, and + `^Enter/F9` in the hints). The live database migrated v9 → v11 on the + v0.1.55 startup with the automatic pre-migration snapshot in + `var/db/backup/`. `main` and the tags are pushed to `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. -- **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`/ - `req_exec` (`b1956aa`), and a Makefile fix that tracks the test binaries' - `.d` files (a stale `smtp_check.o` hung `make test`). + `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. +- **Menu (2026-09-21)**: the dashboard is one flat list without section + headings: Verifikat, Underlag, Bankavstämning, Mallar, Fakturor, + Lönekörningar, Rapporter, Bokslut, Bolaget, System, Ingående balans, + Räkenskapsår, Logga ut. **Bolaget** is the master-data hub + (Företagsuppgifter, Fakturauppgifter, E-post (SMTP), Styrelseledamöter + and the registers Anställda, Kunder, Momsregler); **System** holds + Skattetabeller and Revision. Inställningar is gone, and bilagornas mapp + is no longer a setting: the file browser remembers the last pick + directory in `tui.conf` and falls back to `$HOME`. `--screen settings` + remains an alias for Bolaget. ## Open decisions @@ -60,8 +75,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 @@ -75,7 +91,7 @@ Original entries for the struck items are in `docs/DECISIONS.md`. ## Environment / how to run -- **Deployed**: `scripts/deploy.sh` (latest `v0.1.54`, healthy on nas). +- **Deployed**: `scripts/deploy.sh` (latest `v0.1.59`, healthy on nas). Live daemon `tls:bokf.makandra.eu:8788`, token `~/.config/bokf/migration-token` (scopes `read,write`; owner-only actions like closing years must be done by the human in the TUI). Git remote @@ -165,9 +181,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..190a5c5 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -33,7 +33,7 @@ there. | `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) | | `k` | Underlag: link the highlighted attachment to a voucher picked from a list | | `Ctrl+X` | Clear the current row — only inside row editors (never "new") | -| `Ctrl+Enter` | Save/post the current form. Enabled via xterm `modifyOtherKeys`; terminals that cannot send it keep `F9` working | +| `Ctrl+Enter` | Save/post the current form. Enabled via xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol; terminals that send neither keep `F9` working, and the hints show `^Enter/F9` | Every screen prints its keys in the footer via `hints()`. If a key exists, the footer shows it; if the footer shows it, the key works. Control keys are @@ -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), @@ -134,11 +135,13 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. `BACK`, `REFRESH` or `SUBMIT` (the widget returns as on `Esc`/`F5`/ `Ctrl+Enter`). Hooks exist on `tui_form_run_hook`, `tui_rt_set_key`, `tui_pager_hook` and `tui_select_list_hook`. -- File browser (`file_browser`): starts in the org's `attachment_dir` (or - `$HOME`; a leading `~` is expanded to `$HOME`), `.. (uppåt)` is the first row, directories sort first with a - trailing `/`, hidden files are skipped. Enter enters a directory or picks a - file; `Esc` cancels. Selected files are uploaded immediately as unlinked - underlag and linked when the voucher is posted. +- File browser (`file_browser`): starts in the directory where the last + attachment was picked (remembered in `tui.conf`), falling back to `$HOME` + when it is gone (a leading `~` is expanded to `$HOME`), `.. (uppåt)` is + the first row, directories sort first with a trailing `/`, hidden files + are skipped. Enter enters a directory or picks a file; `Esc` cancels. + Picking a file remembers its directory. Selected files are uploaded + immediately as unlinked underlag and linked when the voucher is posted. ## Messages diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index 25cfdc3..f5d5b7f 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -56,6 +56,9 @@ KEYS = { "ctrlc": "\x03", "ctrln": "\x0e", "ctrlenter": "\x1b[27;5;13~", + "ctrlenter-kitty": "\x1b[13;5u", + "f5": "\x1b[15~", + "f9": "\x1b[20~", } # {org_name} {org_nr} {fy_label} {fy_start} {fy_end} are substituted at run @@ -64,7 +67,8 @@ SCENARIOS = [ { "name": "dashboard", "screen": "dashboard", - "expect": ["{org_name}", "{fy_label}", "{fy_start}", "{fy_end}"], + "expect": ["{org_name}", "{fy_label}", "{fy_start}", "{fy_end}", + "Bolaget", "System", "Räkenskapsår"], }, { "name": "audit-ok", @@ -118,6 +122,21 @@ SCENARIOS = [ ], }, { + "name": "inbox-file-browser", + "screen": "inbox", + "expect": ["Underlag (inkorg)"], + "steps": [ + { + "keys": ["a"], + "expect": ["Välj fil — /"], + }, + { + "keys": ["esc"], + "expect": ["Underlag (inkorg)"], + }, + ], + }, + { "name": "bank-new-voucher", "screen": "bank", "expect": ["Bankavstämning", "GOLDEN INSÄTTNING", "CDON"], @@ -211,6 +230,156 @@ SCENARIOS = [ }, ], }, + { + "name": "employees", + "screen": "employees", + "expect": ["Anställda", "Testanställd", "Ny anställd"], + }, + { + "name": "employee-new-f9", + "screen": "employees", + "steps": [ + { + "keys": ["ctrln"], + "expect": ["Ny anställd", "Personnummer"], + }, + { + "keys": ["enter", "Testperson", "enter"], + "expect": ["Ny anställd", "Testperson"], + }, + { + "keys": ["down", "enter", "19900101-1234", "enter"], + "expect": ["19900101-1234"], + }, + { + "keys": ["f9"], + "expect": ["Anställd skapad."], + }, + { + "keys": ["enter"], + "expect": ["Anställda", "Testperson"], + }, + ], + }, + { + "name": "employee-new-kitty", + "screen": "employees", + "steps": [ + { + "keys": ["ctrln"], + "expect": ["Ny anställd", "Personnummer"], + }, + { + "keys": ["enter", "Kitty Person", "enter"], + "expect": ["Kitty Person"], + }, + { + "keys": ["down", "enter", "19900202-5678", "enter"], + "expect": ["19900202-5678"], + }, + { + "keys": ["ctrlenter-kitty"], + "expect": ["Anställd skapad."], + }, + { + "keys": ["enter"], + "expect": ["Anställda", "Kitty Person"], + }, + ], + }, + { + "name": "company", + "screen": "company", + "expect": ["Bolaget", "Företagsuppgifter", "Fakturauppgifter", + "E-post (SMTP)", "Anställda", "Kunder", "Momsregler"], + "steps": [ + { + "keys": ["3"], + "expect": ["E-post (SMTP)", "SMTP-server", + "lämna tomt för oförändrat"], + }, + { + "keys": ["esc"], + "expect": ["Bolaget", "Företagsuppgifter"], + }, + { + "keys": ["5"], + "expect": ["Anställda", "Testanställd"], + }, + { + "keys": ["esc"], + "expect": ["Bolaget", "Kunder"], + }, + ], + }, + { + "name": "system", + "screen": "system", + "expect": ["System", "Skattetabeller", "Revision"], + "steps": [ + { + "keys": ["1"], + "expect": ["Skattetabeller", "Status: skattetabeller för"], + }, + { + "keys": ["esc"], + "expect": ["System", "Revision"], + }, + { + "keys": ["2"], + "expect": ["Revision", "Verifiera hashkedjan"], + }, + { + "keys": ["esc"], + "expect": ["System", "Skattetabeller"], + }, + ], + }, + { + "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 +908,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 +950,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 @@ -809,6 +1001,12 @@ def main(argv): report_failure(name, missing, "", before) failures += 1 continue + if (b"\x1b[>4;2m" not in app.raw or + b"\x1b[>1u" not in app.raw): + report_failure(name, ["keyboard protocol enable " + "sequence"], "", before) + failures += 1 + continue current = before for step in sc.get("steps", []): keys = resolve_keys(step["keys"]) @@ -18,6 +18,7 @@ #include <openssl/ssl.h> #include <openssl/x509.h> +#include "tls_ca.h" #include "util.h" #define SMTP_TIMEOUT_SEC 30 @@ -345,7 +346,7 @@ static int tls_start(struct smtp_conn *c, const char *host, char *err, SSL_CTX_set_options(c->ctx, SSL_OP_NO_COMPRESSION | SSL_OP_NO_RENEGOTIATION); SSL_CTX_set_verify(c->ctx, SSL_VERIFY_PEER, NULL); - if (SSL_CTX_set_default_verify_paths(c->ctx) != 1) { + if (tls_load_default_cas(c->ctx) != 1) { set_err(err, errlen, "smtp: cannot load system CA certificates"); return -1; } diff --git a/src/tax_table.c b/src/tax_table.c index 1cf40c1..6405a70 100644 --- a/src/tax_table.c +++ b/src/tax_table.c @@ -18,6 +18,7 @@ #include <openssl/x509.h> #include "db.h" +#include "tls_ca.h" #include "version.h" #define TT_LINE_LEN 49 @@ -478,7 +479,7 @@ static int tt_get_once(const struct tt_url *u, struct buf *body, int *status, SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); SSL_CTX_set_options(ctx, SSL_OP_NO_COMPRESSION | SSL_OP_NO_RENEGOTIATION); SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL); - if (SSL_CTX_set_default_verify_paths(ctx) != 1) { + if (tls_load_default_cas(ctx) != 1) { set_err(err, "cannot load system CA certificates"); goto done; } diff --git a/src/tls_ca.h b/src/tls_ca.h new file mode 100644 index 0000000..3b31345 --- /dev/null +++ b/src/tls_ca.h @@ -0,0 +1,31 @@ +#ifndef BOKF_TLS_CA_H +#define BOKF_TLS_CA_H + +#include <openssl/ssl.h> + +/* Load the system trust store. A statically linked OpenSSL keeps the build + machine's compiled-in directory (e.g. Debian's /usr/lib/ssl), which may + not exist where the binary runs, so the common bundle locations are also + tried explicitly. Returns 1 when any store was loaded. */ +static inline int tls_load_default_cas(SSL_CTX *ctx) +{ + int ok = SSL_CTX_set_default_verify_paths(ctx) == 1; + static const char *const files[] = { + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + NULL, + }; + static const char *const dirs[] = { + "/etc/ssl/certs", + NULL, + }; + for (int i = 0; files[i]; i++) + if (SSL_CTX_load_verify_locations(ctx, files[i], NULL) == 1) + ok = 1; + for (int i = 0; dirs[i]; i++) + if (SSL_CTX_load_verify_locations(ctx, NULL, dirs[i]) == 1) + ok = 1; + return ok; +} + +#endif |
