diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 155 | ||||
| -rw-r--r-- | clients/bokftui.c | 10 | ||||
| -rw-r--r-- | clients/client.c | 3 | ||||
| -rw-r--r-- | clients/screens_bokslut.c | 2 | ||||
| -rw-r--r-- | clients/screens_dashboard.c | 53 | ||||
| -rw-r--r-- | clients/screens_ib.c | 2 | ||||
| -rw-r--r-- | clients/screens_invoices.c | 2 | ||||
| -rw-r--r-- | clients/screens_payroll.c | 5 | ||||
| -rw-r--r-- | clients/screens_settings.c | 97 | ||||
| -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 | 19 | ||||
| -rw-r--r-- | clients/ui.h | 3 | ||||
| -rw-r--r-- | docs/PAYROLL.md | 6 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 33 | ||||
| -rw-r--r-- | docs/STATE.md | 37 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 14 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 113 | ||||
| -rw-r--r-- | src/smtp.c | 3 | ||||
| -rw-r--r-- | src/tax_table.c | 3 | ||||
| -rw-r--r-- | src/tls_ca.h | 31 |
23 files changed, 492 insertions, 139 deletions
@@ -14,3 +14,4 @@ dropzone/ var/ .env build-gate*/ +__pycache__/ @@ -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 a047ef5..69d6690 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -45,7 +45,7 @@ static const struct scene SCENES[] = { { "audit", audit_screen }, { "company", company_screen }, { "settings", company_screen }, /* ^R/--screen compatibility */ - { "system", system_settings_screen }, + { "system", system_screen }, { "bokslut", bokslut_screen }, { "yearinfo", bokslut_screen }, /* ^R compatibility */ }; @@ -146,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); @@ -161,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); } @@ -341,6 +346,7 @@ static void usage(FILE *f) 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 df47196..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); diff --git a/clients/screens_dashboard.c b/clients/screens_dashboard.c index a386dae..83b4f6a 100644 --- a/clients/screens_dashboard.c +++ b/clients/screens_dashboard.c @@ -268,19 +268,11 @@ static void select_fiscal_year(struct app *a) } } static const char *const MAIN_ITEMS[] = { - MK_SECTION "Bokföring", "Verifikat", "Underlag (inkorg)", "Bankavstämning", "Mallar", - MK_SECTION "Fakturering", "Fakturor", - MK_SECTION "Lön", "Lönekörningar", - MK_SECTION "Rapporter & bokslut", "Rapporter", "Bokslut", - MK_SECTION "Företag", - "Anställda", "Kunder", "Bolaget", "Momsregler", - MK_SECTION "System", - "Skattetabeller", "Revision", "Systeminställningar", - MK_SECTION "Räkenskapsår", + "Bolaget", "System", "Ingående balans", "Räkenskapsår", "Logga ut / avsluta", }; @@ -306,61 +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 4: + case 3: open_scene(a, "templates"); break; - case 6: + case 4: open_scene(a, "invoices"); break; - case 8: + case 5: open_scene(a, "payroll"); break; - case 10: + case 6: open_scene(a, "reports"); break; - case 11: + case 7: open_scene(a, "bokslut"); break; - case 13: - open_scene(a, "employees"); - break; - case 14: - open_scene(a, "customers"); - break; - case 15: + case 8: open_scene(a, "company"); break; - case 16: - open_scene(a, "rules"); - break; - case 18: - open_scene(a, "tax_tables"); - break; - case 19: - open_scene(a, "audit"); - break; - case 20: + case 9: open_scene(a, "system"); break; - case 22: + case 10: open_scene(a, "ib"); break; - case 23: + case 11: select_fiscal_year(a); break; - case 24: + 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 b8cc8dd..ca59eef 100644 --- a/clients/screens_invoices.c +++ b/clients/screens_invoices.c @@ -1092,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 index 9bfaf38..23d765f 100644 --- a/clients/screens_payroll.c +++ b/clients/screens_payroll.c @@ -1101,7 +1101,8 @@ static int64_t payroll_run_screen(struct app *a, int64_t id) { status, -1, NULL }, { totals, -1, NULL }, { "Förhandsvisa (F5)", 1, NULL }, - { "Bokför körning (^Enter)", post_reason ? 0 : 1, post_reason }, + { "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 }, @@ -1123,7 +1124,7 @@ static int64_t payroll_run_screen(struct app *a, int64_t id) snprintf(title, sizeof title, "Ny lönekörning"); int editable = can_write && !posted; tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = förhandsvisa ^Enter = bokför" + " 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); diff --git a/clients/screens_settings.c b/clients/screens_settings.c index c6a46c5..8a31620 100644 --- a/clients/screens_settings.c +++ b/clients/screens_settings.c @@ -321,10 +321,6 @@ static const struct setting_field SMTP_SETTINGS[] = { { "smtp_password", "SMTP-lösenord", NULL, 0, 1 }, }; -static const struct setting_field SYSTEM_SETTINGS[] = { - { "attachment_dir", "Bilagornas mapp", NULL, 0, 0 }, -}; - static int settings_can_write(struct app *a) { return a->role[0] && (strcmp(a->role, "owner") == 0 || @@ -347,27 +343,40 @@ static void smtp_settings_screen(struct app *a) settings_can_write(a), 1, &sel); } -void system_settings_screen(struct app *a) +static const char *const SYSTEM_ITEMS[] = { + "Skattetabeller", + "Revision", +}; + +void system_screen(struct app *a) { static int sel = 0; - settings_form(a, "Systeminställningar", SYSTEM_SETTINGS, - (int)(sizeof SYSTEM_SETTINGS / sizeof SYSTEM_SETTINGS[0]), - settings_can_write(a), 0, &sel); + 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); + } } -void company_screen(struct app *a) +static void company_data_screen(struct app *a) { static int sel = 0; const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]); int owner = a->role[0] && strcmp(a->role, "owner") == 0; - int writer = settings_can_write(a); - const char *settings_reason = writer ? NULL : "du har inte behörighet"; for (;;) { if (g_quit) return; char *resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}"); if (!resp || !client_ok(resp)) { - show_error("Bolaget", resp); + show_error("Företagsuppgifter", resp); free(resp); return; } @@ -392,15 +401,7 @@ void company_screen(struct app *a) ff[i].kind = TUI_F_TEXT; } free(resp); - struct tui_form_action acts[3] = { - { "Fakturauppgifter…", writer ? 1 : 0, settings_reason }, - { "E-post (SMTP)…", writer ? 1 : 0, settings_reason }, - { "Styrelseledamöter", 1, NULL }, - }; - tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = uppdatera Esc/q = tillbaka ^C = avsluta"); - int r = tui_form_run_actions("Bolaget", ff, nf, owner, acts, 3, NULL, - &sel); + 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); @@ -426,14 +427,58 @@ void company_screen(struct app *a) } for (int i = 0; i < nf; i++) free(vals[i]); - if (r == TUI_FORM_ACTION + 0) + if (r == TUI_FORM_BACK) + return; + } +} + +static const char *const COMPANY_ITEMS[] = { + "Företagsuppgifter", + "Fakturauppgifter", + "E-post (SMTP)", + "Styrelseledamöter", + MK_SECTION "Register", + "Anställda", + "Kunder", + "Momsregler", +}; + +void company_screen(struct app *a) +{ + static int sel = 0; + for (;;) { + if (g_quit) + return; + 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); - else if (r == TUI_FORM_ACTION + 1) + break; + case 2: smtp_settings_screen(a); - else if (r == TUI_FORM_ACTION + 2) + break; + case 3: board_screen(a); - else if (r == TUI_FORM_BACK) - return; + break; + case 5: + employees_screen(a); + break; + case 6: + customers_screen(a); + break; + case 7: + rules_screen(a); + break; + default: + break; + } } } 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 a95ce44..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); } } diff --git a/clients/ui.h b/clients/ui.h index 2b93106..b37eb4f 100644 --- a/clients/ui.h +++ b/clients/ui.h @@ -102,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 { @@ -130,12 +131,12 @@ void audit_screen(struct app *a); void templates_screen(struct app *a); void ib_screen(struct app *a); void company_screen(struct app *a); -void system_settings_screen(struct app *a); void invoices_screen(struct app *a); void customers_screen(struct app *a); void bokslut_screen(struct app *a); void employees_screen(struct app *a); void payroll_screen(struct app *a); void payroll_tax_tables_screen(struct app *a); +void system_screen(struct app *a); #endif diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md index 23e7fb1..b78aeba 100644 --- a/docs/PAYROLL.md +++ b/docs/PAYROLL.md @@ -228,9 +228,9 @@ the base is the gross. `SMTP_FAILED` (the stored attachment stays linked). `dry_run` validates, renders and stores nothing. See `PROTOCOL.md` §7.12. - **TUI — Lön** (done, `clients/screens_payroll.c`): `Lönekörningar` (list, - Ctrl+N for a new run) under Lön, `Anställda` under Företag, and a - **Skattetabeller** screen (System, since the tables are national) with - status, the Skatteverket fetch and the offline file import. + 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** (saves/opens the PDF), **AGI-underlag** (`payroll.agi` in a pager, owner only) and diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 01f5dd1..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 @@ -941,8 +942,8 @@ commands. Implemented screens (0.1.0-dev): tax table/column, e-mail). Ctrl+N creates, Enter edits (an empty personnummer keeps the stored one), `d` archives/reactivates; F5 validates with a dry run and Ctrl+Enter saves. -- **Skattetabeller** (System) — stored tax table years, the current year's - status, fetch time and source. Owners fetch Skatteverket's official +- **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, @@ -959,16 +960,20 @@ 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. -- **Bolaget** — the org record (name, org number, VAT number, address, - e-mail, phone, moms period, framework, fiscal-year start month), editable - in place by owners; others see it read-only. The sub-forms - **Fakturauppgifter…** (standardserie, fordringskonto, intäktskonto, - bankgiro, vår referens) and **E-post (SMTP)…** (host, port, user, sender, - reply-to, security, password) are `settings.set` and open to bookkeepers, - and **Styrelseledamöter** lists the board. -- **Systeminställningar** (System) — bilagornas mapp, the file browser's - start directory. -- **Revision** (System) — chain verification and behandlingshistorik. +- **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 diff --git a/docs/STATE.md b/docs/STATE.md index bd38963..929ddf1 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -14,13 +14,19 @@ unit tests and the docs consistency check. ## Resume here (2026-09-21) -- **Deployed**: `v0.1.55`, 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`). It brings the payroll server - waves (schema v11: employees, tax tables, lönebesked) and the payroll - TUI with the Företag/System menu; the live database migrated v9 → v11 on - startup with the automatic pre-migration snapshot in `var/db/backup/`. - `main` and the tag `v0.1.55` are pushed to `nas`. + 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 @@ -33,13 +39,16 @@ unit tests and the docs consistency check. 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 has the sections Bokföring (with - Mallar), Fakturering, Lön, Rapporter & bokslut, Företag (Anställda, - Kunder, Bolaget, Momsregler) and System (Skattetabeller, Revision, - Systeminställningar). Inställningar is gone: standardserie, - fordrings-/intäktskonto, bankgiro, vår referens and SMTP now live under - Bolaget (the Fakturauppgifter and E-post sub-forms) and bilagornas mapp - under System. `--screen settings` remains an alias for Bolaget. +- **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 @@ -82,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.55`, 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 diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index 7e0d404..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 @@ -135,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 fbcfc57..f5d5b7f 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -56,7 +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 @@ -66,7 +68,7 @@ SCENARIOS = [ "name": "dashboard", "screen": "dashboard", "expect": ["{org_name}", "{fy_label}", "{fy_start}", "{fy_end}", - "Företag", "Bolaget"], + "Bolaget", "System", "Räkenskapsår"], }, { "name": "audit-ok", @@ -120,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"], @@ -219,26 +236,104 @@ SCENARIOS = [ "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", "Fakturauppgifter", "E-post (SMTP)", - "Styrelseledamöter", "Test AB"], + "expect": ["Bolaget", "Företagsuppgifter", "Fakturauppgifter", + "E-post (SMTP)", "Anställda", "Kunder", "Momsregler"], "steps": [ { - "keys": ["end", "up", "enter"], + "keys": ["3"], "expect": ["E-post (SMTP)", "SMTP-server", "lämna tomt för oförändrat"], }, { "keys": ["esc"], - "expect": ["Bolaget", "Fakturauppgifter"], + "expect": ["Bolaget", "Företagsuppgifter"], + }, + { + "keys": ["5"], + "expect": ["Anställda", "Testanställd"], + }, + { + "keys": ["esc"], + "expect": ["Bolaget", "Kunder"], }, ], }, { "name": "system", "screen": "system", - "expect": ["Systeminställningar", "Bilagornas mapp"], + "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", @@ -906,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 |
