aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/DECISIONS.md70
-rw-r--r--docs/INVOICING.md38
-rw-r--r--docs/PAYROLL.md8
-rw-r--r--docs/PROTOCOL.md115
-rw-r--r--docs/SCHEMA.md12
-rw-r--r--docs/STATE.md160
-rw-r--r--docs/TUI-GUIDELINES.md131
7 files changed, 461 insertions, 73 deletions
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index a108f48..5499610 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -201,7 +201,65 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21).
employed now but everything is multi-employee; no semester accrual;
tax tables from day one; manual step-buttons (Bokför, Lönebesked,
AGI-underlag, Betala skatt & avgifter); bank phase 3 (`bank_rule.*`) is
- dropped.
+ dropped.
+25. **Per-feature voucher series (2026-09-21)**: the single standardserie
+ setting is replaced by one setting per feature that posts vouchers:
+ `series_voucher` (manual vouchers and new templates, default `A`,
+ falling back to the legacy `default_series`), `series_invoice` (`F`),
+ `series_payroll` (`L`), `series_bokslut` (`Å`) and `series_ib` (`IB`).
+ All are editable (Bolaget → Verifikationsserier), 1–8 characters with
+ no control characters. The manual voucher form remembers the last
+ series it posted as `series_voucher`. Reports and SIE treat both the
+ configured `series_ib` and the historical `IB` series as ingående
+ balans, so old books keep working. "Nästa fakturanummer" is editable
+ in Bolaget → Fakturauppgifter (owner only).
+26. **Invoicing follow-ups (2026-09-21)**: schema v12 adds
+ `invoice_rows.is_text` and `invoices.paid_date`/`payment_voucher_id`.
+ Invoices accept **text rows** (`"text": true`): description only, no
+ amount, excluded from totals and the posting voucher; at least one
+ priced row is required. The invoice detail gets `u = duplicera` (same
+ customer, rows and references, dates reset to today, due = today +
+ payment days) and `b = kvittera betalning`, which prefills the ordinary
+ voucher form (D `bank_account`, K `invoice_receivable_account`, both
+ editable, underlag attachable) and, after posting, calls
+ `invoice.pay`, which requires the voucher to credit the receivable with
+ exactly the invoice total. Partial payments are out of scope. Lists
+ show `betald <datum>`. SMTP: `smtp_from`/`smtp_reply_to` are validated
+ as e-mail addresses (settings.set and mail config), with the sender's
+ display name taken from the org name.
+27. **Document header (2026-09-22)**: the invoice header bar no longer
+ draws the `MAKANDRA AB` Comfortaa outline; it prints the organization
+ name (`orgs.name`) in Helvetica-Bold, scaled down and truncated with
+ `...` if it would reach the `FAKTURA` wordmark, which stays. The shared
+ setting `document_header_color` (`#rrggbb`, default `#314c59`) colours
+ the header bar of **every** generated document — currently the invoice
+ and the lönebesked; `settings.set` refuses other values and the
+ renderers fall back to the default if a stored value is invalid. The
+ lönebesked prints the employer name in its header too, so the
+ `MAKANDRA AB` outlines are unused at runtime (kept in the generated
+ header). Images remain out of scope.
+28. **TUI interaction model (2026-09-22, design)**: settled in the UX
+ session; the spec is `TUI-GUIDELINES.md` "Interaction model" and is not
+ implemented yet. Two focus modes: the navigation keys (`Tab`, arrows,
+ `Home`/`End`, `PgUp`/`PgDn`) only move focus/selection/scroll and never
+ mutate data, while an active field keeps caret semantics. `Enter`
+ activates the focused item only when it owns an action (menu item,
+ action row, opening list row); a plain report has none, so `Enter` does
+ nothing there — it never saves a whole form, deletes or posts. Entities
+ have one of three lifecycles: **register** (explicit `Spara` after
+ validation; drafts live in memory and in
+ `$XDG_CACHE_HOME/bokf/drafts.json`, marked `<UTKAST>`, deletable from
+ the list and from the editor), **document** (the form is the draft,
+ explicit post, immutable) and **settings** (explicit `Spara`; the
+ per-field autosave is dropped). Every savable form ends with a `Spara`
+ (or `Posta`) action row; commit is blocked while invalid and a server
+ error keeps the draft. Screens declare actions in one ordered
+ `struct tui_action` list that drives the `F2` menu, the accelerator keys
+ and the footer hints; `F2` only, no `§`; `Ctrl+Enter` is dropped as a
+ commit key (`F9` and the save action row remain). **Kunder pilot
+ implemented 2026-09-22** (`clients/drafts.[ch]`, `<UTKAST>`, the `Spara`
+ row, the `F2` menu, delete from list and editor); the other screens
+ follow.
## Completed work formerly listed under "Pending decisions"
@@ -275,7 +333,15 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21).
statement (Kapitas 2022-2026). **Decided 2026-09-19: no importer change.**
Locked years stay locked and the source's closings stay in the books; the
årsredovisning export flags incomplete jämförelsetal for those years and
- points to the previous year's annual report.
+ points to the previous year's annual report. **Amended 2026-09-22**: the
+ income statement (and therefore INK2/SRU and the TUI resultatrapport) now
+ skips the same "Stäng ..." vouchers when they are SIE-imported, so a year
+ being declared shows its real figures; the balance sheet keeps them (the
+ result sits in 2099 and must not be counted twice). The source's own
+ #IB/#UB corrections that the vouchers do not reproduce (e.g. Makandra's FY
+ 2022/2023 result transfer is 17 857.63 kr short of that year's P&L) remain
+ a data divergence: the derived balance sheet is off by that amount until a
+ correction is booked in the current year.
11. ~~SIE import only into an empty fiscal year; consider broader import.~~
Chronological multi-year import works (CRLF, `#RAR 0`, zero rows, `#IB`
rule handled); each year must still target an empty fiscal year. Note:
diff --git a/docs/INVOICING.md b/docs/INVOICING.md
index 7c6ce2e..de584cf 100644
--- a/docs/INVOICING.md
+++ b/docs/INVOICING.md
@@ -14,7 +14,8 @@ the generated PDF reproduces the existing document.
- Customer register (name, address, momsreg.nr, e-mail, er referens,
payment terms), owner-editable and audited.
- One-page invoice document generated by bokfd, visually matching the
- existing Google Sheets export (same grid, colours and wordmark).
+ existing Google Sheets export (same grid, colours and `FAKTURA` wordmark;
+ the header shows the organization name).
- A configurable, always-increasing invoice number series per org, plus an
OCR reference that Bankgiro accepts.
- Issue in one action: number + PDF (stored as an immutable attachment) +
@@ -49,13 +50,19 @@ Colours:
| Body text | `#314c59` |
| Header/table text on the bar | `#ffffff` |
+The header bar's colour is the shared document setting
+`document_header_color` (`#rrggbb`, default `#314c59`, also used by the
+lönebesked).
+
Fonts:
- Body: Helvetica (PDF base-14, metrically compatible with Arial); no
embedding.
-- Wordmark `MAKANDRA AB` and `FAKTURA`: Comfortaa Bold (SIL OFL) as
- pre-generated vector outlines, drawn as filled paths. No font file or
- TrueType machinery at runtime.
+- Wordmark `FAKTURA`: Comfortaa Bold (SIL OFL) as pre-generated vector
+ outlines, drawn as filled paths; no font file or TrueType machinery at
+ runtime. The header's left side prints the organization `name` in
+ Helvetica-Bold, scaled down and truncated with `...` if it would reach
+ `FAKTURA`.
Grid (points, origin top-left; refined against the originals in
`tests/` golden comparisons):
@@ -63,7 +70,7 @@ Grid (points, origin top-left; refined against the originals in
| Element | x | y |
|---|---|---|
| Header bar (x 17.3–577.7) | 17.3 | 53.3–75.7 |
-| Wordmark `MAKANDRA AB` (ink left/baseline) | 21.74 | 69.14 |
+| Header name (org `name`, ink left/baseline) | 21.74 | 69.14 |
| `FAKTURA` (ink right/baseline) | 576.87 | 69.14 |
| Info labels (bold 7.285 pt), right-aligned | 113.98 | 101.11 + 14.71/row |
| Info values (9.107 pt), left-aligned | 118.87 | same rows |
@@ -206,6 +213,8 @@ CREATE TABLE invoices (
CHECK (status IN ('issued','credited')),
document_id INTEGER,
voucher_id INTEGER,
+ paid_date TEXT NOT NULL DEFAULT '',
+ payment_voucher_id INTEGER,
last_sent_at TEXT,
last_sent_to TEXT,
created_at TEXT NOT NULL,
@@ -214,7 +223,9 @@ CREATE TABLE invoices (
UNIQUE (org_id, number),
FOREIGN KEY (org_id, customer_id) REFERENCES customers(org_id, id),
FOREIGN KEY (org_id, document_id) REFERENCES attachments(org_id, id),
- FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id)
+ FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id),
+ FOREIGN KEY (org_id, payment_voucher_id)
+ REFERENCES vouchers(org_id, id)
) STRICT;
CREATE TABLE invoice_rows (
@@ -232,6 +243,7 @@ CREATE TABLE invoice_rows (
vat_code TEXT NOT NULL DEFAULT '25'
CHECK (vat_code IN ('25','12','6','0','rc','eu')),
account TEXT NOT NULL DEFAULT '',
+ is_text INTEGER NOT NULL DEFAULT 0,
UNIQUE (org_id, id),
UNIQUE (org_id, invoice_id, line_no),
FOREIGN KEY (org_id, invoice_id) REFERENCES invoices(org_id, id)
@@ -240,7 +252,19 @@ CREATE TABLE invoice_rows (
`vouchers.source` gains `invoice` (and later `credit`): the CHECK constraint
must be widened. `invoice_rows` are written once at issue; `invoices` only
-changes `status`, `last_sent_*` and (later) credit links.
+changes `status`, `last_sent_*`, `paid_date`/`payment_voucher_id` and (later)
+credit links. Schema v12 adds `invoice_rows.is_text` and the two payment
+columns with forward `ALTER TABLE`s; the composite foreign key on
+`payment_voucher_id` exists in fresh databases only (SQLite cannot add one
+later), and `invoice.pay` validates the reference in code either way.
+
+A **text row** (`is_text`) is a free-text line in the table: only
+`description` is meaningful, it has no quantity, unit, price or VAT and
+contributes nothing to the totals or the posting voucher. It renders in the
+description column only. Every invoice still needs at least one priced row.
+When an invoice is marked **paid** (`invoice.pay`), `paid_date` is the
+payment voucher's date and `payment_voucher_id` links it; partial payments
+are not modelled.
Customer seed: Andra bygg AB (Solna, SE559232855201, Eric Lejeby, 30),
NZ Bygg AB (Bromma, SE559264837101, Valentyne Schnelle, 30),
diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md
index b78aeba..ebf9272 100644
--- a/docs/PAYROLL.md
+++ b/docs/PAYROLL.md
@@ -216,9 +216,11 @@ the base is the gross.
## 6. Documents and TUI
- **Lönebesked** (wave 2, done): `payroll.payslip` renders one A4 page per
- employee and run with the invoice's visual language — dark `#314c59`
- header bar, employer and employee blocks, gross, preliminary tax (shown
- negative), net and the employer-contribution note; the personnummer is
+ employee and run with the invoice's visual language — the employer name in
+ a header bar in the shared document colour `document_header_color`
+ (default `#314c59`), employer and employee blocks, gross, preliminary tax
+ (shown negative), net and the employer-contribution note; the personnummer
+ is
masked except the last four. `payroll.payslip_mail` stores the PDF as an
`application/pdf` attachment on the run's voucher, links it with
`voucher_attachments` and e-mails it through the org's `smtp_*` settings
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 76197d9..0b69d53 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -374,26 +374,36 @@ resolved rows in a dry run.
| Command | Args | Notes |
|---|---|---|
| `settings.get` | — | effective org settings (defaults included); secret values replaced by `_set` flags |
-| `settings.set` | `key`, `value?` | known keys: `default_series`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account`, `invoice_bankgiro`, `invoice_our_ref`, `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security`, `smtp_password` |
+| `settings.set` | `key`, `value?` | known keys: `default_series`, `series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut`, `series_ib`, `attachment_dir`, `bank_account`, `invoice_receivable_account`, `invoice_revenue_account`, `invoice_bankgiro`, `invoice_our_ref`, `document_header_color`, `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security`, `smtp_password` |
-`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 accepted for
+`series_voucher`, `series_invoice`, `series_payroll`, `series_bokslut` and
+`series_ib` (1–8 characters, e.g. `A`, `V-`, `Å`) are the number series each
+feature posts into: manual vouchers, `invoice.issue`, payroll runs and
+payments, `bokslut.post` and ingående balans. They default to `A`, `F`, `L`,
+`Å` and `IB`; `series_voucher` also falls back to the legacy
+`default_series` (1–8 characters) when unset, and new templates default to
+it. Reports and SIE count both the configured `series_ib` and the
+historical `IB` series as ingående balans. `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
default revenue account of invoice postings, digits only, up to 10
-characters. Verification ids are the concatenation of series and number
+characters. `document_header_color` (`#rrggbb`, default `#314c59`) is the
+background colour of the generated documents' header bars (invoice and
+lönebesked); the invoice prints the org name on it. Verification ids are the
+concatenation of series and number
(`V-8`), and series are free-form: only an unbroken numbering per series is
required.
`smtp_host` (up to 255 characters, no control characters), `smtp_user` (up to
-255), `smtp_from` and `smtp_reply_to` (up to 254), `smtp_port` (digits,
-1–65535) and `smtp_security` (`starttls`, `tls` or `plain`, default
-`starttls` when unset) configure the outgoing mail used when invoices are
-sent.
+255), `smtp_from` and `smtp_reply_to` (up to 254) must be e-mail addresses
+(one `@`, no spaces), `smtp_port` (digits, 1–65535) and `smtp_security`
+(`starttls`, `tls` or `plain`, default `starttls` when unset) configure the
+outgoing mail used when invoices are sent. The sender's display name is the
+organization name; `smtp_from` is the address.
`smtp_password` is a secret setting. `settings.set` encrypts the value with
AES-256-GCM under the key in the `BOKFD_SECRET_KEY` environment variable (32
@@ -441,7 +451,18 @@ linked to that voucher and each item carries that `voucher_id`.
| `sru.export` | `fiscal_year`, `adjustments?`, `submitter?`, `assisted?`, `audited?`, `ignore_unmapped?` | `INFO.SRU` + `BLANKETTER.SRU` (ISO-8859-1, base64) |
All reports are pure reads, respect locks, and return JSON rows. Amounts are
-öre. `report.general_ledger` (huvudbok) returns account blocks:
+öre. IB (ingående balans) for a balance account (asset, liability, equity) is
+all earlier history, including earlier years' `IB` vouchers, plus this
+year's `IB` vouchers; a P&L account (revenue, expense) restarts at zero at
+every fiscal-year start, so its IB is only, with a narrowed `from`, the
+year's movements before it; `IB` rows on P&L accounts (the balancing side of
+an opening-balance adjustment) are ignored.
+`report.income_statement` (and therefore `sru.export` and the TUI
+resultatrapport) ignores the source system's `"Stäng ..."` closing vouchers
+in SIE-imported years (`source:"sie_import"`), where the P&L accounts are
+closed straight to 2099 and would otherwise net to zero; the TUI
+årsredovisning uses the same rule. `report.general_ledger` (huvudbok)
+returns account blocks:
`{"fiscal_year","from","to","last_voucher":{...},"accounts":[{"account",
"name","ib_ore","debit_ore","credit_ore","ub_ore","rows":[{"series",
"number","date","description","row_description","debit_ore","credit_ore",
@@ -472,7 +493,10 @@ like the blankett; box 48 positive as filed) and returns `{"org_nr",
bytes are ISO-8859-1, so write them verbatim to a `.xml` file. Both cover
the whole fiscal year; the ledger's
period can be narrowed with `from`/`to`. `report.vat` returns `{"from","to","boxes":[{"box":"05","label":"...","amount_ore":...}],"note"}`.
-Rules sharing a box are summed into a single entry. `box 49` is the sum of
+Rules sharing a box are summed into a single entry. The momsomföring
+itself (any voucher with a row on 2650) and SIE-imported `"Stäng ..."` year
+closings are left out, so a period that includes its own VAT settlement
+still reports the underlying boxes. `box 49` is the sum of
the moms boxes (`10`,`11`,`12`,`30`,`31`,`32`,`48`,`60`,`61`,`62`), so box 48
is signed like the blankett (ingående moms negative); underlag boxes do not
change what is payable.
@@ -509,7 +533,8 @@ support `dry_run`, which validates without writing.
| `sie.import` | `content_base64` or `path`, `dry_run?` | one file per call; creates missing accounts and posts #VER as `source:"sie_import"`; only into an empty org fiscal year; `#IB` becomes an `IB` voucher when the year has no earlier history, otherwise the earlier vouchers carry the balances |
SIE 4 files are written in CP437 with PC8 format, `#SIETYP 4`, `#FNR`, `#ORGNR`,
-`#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. Import is the migration path
+`#KONTO`, `#IB`, `#UB`, `#RES`, `#VER`, `#TRANS`. `#IB`/`#UB` are written for
+balance accounts and `#RES` for P&L accounts, with IB as in §7.6. Import is the migration path
from Fortnox/Visma/BL and must be dry-run first; it reports exactly what would
be created.
@@ -585,10 +610,11 @@ removes one link and is a `NOT_FOUND` when it does not exist. Both mutate
| `invoice.sequence_set` | `next_number` (owner) | `next_number` |
| `invoice.preview` | draft (below) | `content_base64`, `number`, `ocr`, `net_ore`, `vat_ore`, `total_ore` |
| `invoice.issue` | draft, `dry_run?` | `id`, `number`, `ocr`, `document_id`, `voucher_id`, totals |
-| `invoice.get` | `id` | header, `rows[]`, `document_id`, `voucher_id`, `last_sent_at`, `last_sent_to` |
-| `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first |
+| `invoice.get` | `id` | header, `rows[]`, `document_id`, `voucher_id`, `paid_date`, `payment_voucher_id`, `last_sent_at`, `last_sent_to` |
+| `invoice.list` | `customer_id?`, `status?` (`issued`/`credited`), `limit?` | `items[]`, newest first, with `paid_date` |
| `invoice.pdf` | `id` | stored PDF as `content_base64` |
| `invoice.send` | `id`, `to?` | `id`, `sent_to`, `at`; `dry_run` returns `to`, `subject` |
+| `invoice.pay` | `id`, `voucher_id`, `dry_run?` | `id`, `number`, `paid_date`, `payment_voucher_id`, `voucher_series`, `voucher_number` |
The draft object is the argument set shared by `invoice.preview` and
`invoice.issue`:
@@ -611,6 +637,13 @@ accounts are `ACCOUNT_NOT_FOUND`/`ACCOUNT_INACTIVE`. The customer must exist
and be active (`NOT_FOUND`). A draft whose rows do not fit the single page is
rejected with `TOO_LARGE`.
+A row with `"text": true` is a free-text line: only `description` is used,
+it has no quantity, unit, price or VAT, contributes nothing to the totals
+and only prints its description in the document. Every draft still needs at
+least one priced row (`INVALID_ARGS` otherwise, "invoice total must be
+greater than zero"). `invoice.get` and `invoice.list` return rows with
+`is_text`.
+
Numbering is a per-org, global series: `invoice_sequence.next_number` starts
at 1, is set by the owner and is incremented by exactly one per issued
invoice. The OCR reference is the number followed by its MOD10 (Luhn) check
@@ -629,7 +662,9 @@ is the idempotency key).
`invoice.sequence_set` and `invoice.issue` are audited; `invoice.issue`
supports `dry_run`, which validates and renders but takes no number and
writes nothing. `invoice.pdf` returns the stored document as base64
-(`JVBERi0` after decoding is the PDF magic). When the setting
+(`JVBERi0` after decoding is the PDF magic). The document's header bar
+prints the organization `name` and is coloured by `document_header_color`
+(default `#314c59`; invalid stored values fall back to it). When the setting
`invoice_bankgiro` is present it is printed in the document's Bankgiro field;
`invoice_our_ref` (up to 64 characters) prefills the invoice form's "Vår
referens".
@@ -647,6 +682,15 @@ updated and the `invoice.send` audit entry stores `{id,to,subject}` only.
`dry_run` validates configuration, recipient and stored document and returns
the recipient and subject without sending or updating anything.
+`invoice.pay` links a payment voucher (created by the client, normally from
+the TUI's **Kvittera betalning** action, which prefills debit `bank_account`
+and credit `invoice_receivable_account` in the ordinary voucher form) and
+stamps `paid_date` with the voucher's date. The voucher must credit the
+`invoice_receivable_account` (default `1510`) with exactly the invoice
+total, else `INVALID_ARGS`; an already paid invoice and a `credited` one are
+rejected (`CONFLICT` and `INVALID_ARGS`). `dry_run` validates without
+writing, and the command is audited.
+
### 7.11 Anställda (employees)
The employee register. `personal_no` is checked for shape (10 or 12 digits,
@@ -732,7 +776,8 @@ given date (default: today) and sets the run's `status` to `paid` with
`payment_voucher_id`; paying twice is a `CONFLICT`.
`payroll.payslip` renders one A4 lönebesked for an employee line of a posted
-run: the employer header and footer, the employee name, the masked
+run: the employer name in the header bar (coloured by
+`document_header_color`), the employer footer, the employee name, the masked
personnummer (`********-1234`, or `********` when the key is unavailable),
period, pay date, tax table and column, then Bruttolön, the negative
Preliminärskatt, a rule and Nettolön, and the note `Arbetsgivaravgifter
@@ -857,6 +902,7 @@ Args: `name:type(values)[!][=default]`, `!` = required.
| `invoice.list` | viewer | yes | no | no | `customer_id:int`, `status:enum(issued\|credited)`, `limit:int=200` |
| `invoice.pdf` | viewer | yes | no | no | `id:int!` |
| `invoice.send` | bookkeeper | yes | yes | yes | `id:int!`, `to:string` |
+| `invoice.pay` | bookkeeper | yes | yes | yes | `id:int!`, `voucher_id:int!` |
| `employee.list` | viewer | yes | no | no | `active_only:bool` |
| `employee.get` | viewer | yes | no | no | `id:int!` |
| `employee.create` | bookkeeper | yes | yes | yes | `name:string!`, `personal_no:string!`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `email:string`, `salary_account:string`, `monthly_salary_ore:int=0`, `tax_table:int=30`, `tax_column:int=1` |
@@ -902,7 +948,10 @@ commands. Implemented screens (0.1.0-dev):
- **Mallar** — list, create and edit templates in the same form style as
vouchers (Tab, dynamic rows, F7 clear row, F5 validate, F9 save); archive
keeps the template but hides it from the list.
-- **Underlag** — inbox of unlinked attachments; `a` uploads a file.
+- **Underlag** — inbox of unlinked attachments; `a` uploads a file and
+ Enter opens **Granska** (text in a pager, other files in the desktop
+ viewer) or **Ladda ned…**; the voucher detail's underlag list (`f`) works
+ the same way.
- **Bankavstämning** — imported bank transactions (`bank.import`) matched
against vouchers on the bank account, with suggestions; Enter matches the
suggested voucher (or picks another), `u` unmatches, `a` imports a SEB CSV.
@@ -912,16 +961,21 @@ commands. Implemented screens (0.1.0-dev):
returns to the list. A failed auto-match keeps the posted voucher and
shows the server error.
- **Fakturor** — invoice list (`invoice.list`, newest first) with number,
- date, customer, total and status (`utfärdad`/`krediterad`). Ctrl+N opens
- the form, Enter the detail. The form has the customer picker, invoice/due
- (due defaults from the customer's payment days) and delivery dates, er/var
- referens and rows (beskrivning, antal, enhet, à-pris, moms, anm); `F5`
- previews the real PDF (`invoice.preview`, nothing stored, no number
- consumed), `Ctrl+Enter` issues (`invoice.issue`) and then asks
- "Skicka faktura <nr> till <e-post>?". The detail shows header and rows;
- `p` fetches the stored PDF (`invoice.pdf`) and `s` sends it
- (`invoice.send`). In the list, `n` sets the next invoice number
- (`invoice.sequence_get`/`sequence_set`, owner-only).
+ date, customer, total and status (`utfärdad`/`krediterad`/`betald
+ <datum>`). Ctrl+N opens the form, Enter the detail. The form has the
+ customer picker, invoice/due (due defaults from the customer's payment
+ days) and delivery dates, er/var referens and rows (beskrivning, antal,
+ enhet, à-pris, moms, anm); a row with only beskrivning is a free-text line
+ (`text` rows, no amount). `F5` previews the real PDF (`invoice.preview`,
+ nothing stored, no number consumed), `F9` issues (`invoice.issue`) and
+ then asks "Skicka faktura <nr> till <e-post>?". The detail shows header
+ and rows and offers `p = visa PDF` (`invoice.pdf`), `s = skicka`
+ (`invoice.send`), `u = duplicera` (a new draft with the same rows and
+ today's dates) and, on unpaid invoices, `b = kvittera betalning`: a
+ prefilled payment voucher (debit `bank_account`, credit
+ `invoice_receivable_account`) is opened in the ordinary voucher form and,
+ once posted, linked with `invoice.pay`. In the list, `n` sets the next
+ invoice number (`invoice.sequence_get`/`sequence_set`, owner-only).
- **Kunder** — the customer register (name, address, postal code, city, VAT
number, e-mail, your reference, payment days, notes). Ctrl+N creates,
Enter edits (F5 validates with a dry run, Ctrl+Enter saves), `d`
@@ -963,10 +1017,11 @@ commands. Implemented screens (0.1.0-dev):
- **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
+ place by owners, others see it read-only; **Fakturauppgifter** (nästa
+ fakturanummer (owner), fordringskonto, intäktskonto, bankgiro, vår
+ referens); **Verifikationsserier** (the per-feature series); and
**E-post (SMTP)** (host, port, user, sender, reply-to, security,
- password), both `settings.set` and open to bookkeepers;
+ password), the last three `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
diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md
index 9818e1c..9199691 100644
--- a/docs/SCHEMA.md
+++ b/docs/SCHEMA.md
@@ -560,12 +560,12 @@ another voucher is posted in between) — clients must not persist it.
## 12. Migrations and versioning
- `meta(key TEXT PRIMARY KEY, value TEXT)` holds `schema_version` (integer)
- and `created_at`. Current version: **11** (v11 adds the employee e-mail,
- v10 adds the payroll tables and the `payroll`/`payroll_tax` voucher
- sources, v9 adds the invoicing tables and `invoice`, v8 the two bank
- reconciliation tables, v7 makes attachments append-only, v3 replaces the
- seeded moms rules with the corrected mapping; v2 adds the two template
- tables).
+ and `created_at`. Current version: **12** (v12 adds invoice text rows and
+ the invoice payment link, v11 the employee e-mail, v10 the payroll tables
+ and the `payroll`/`payroll_tax` voucher sources, v9 the invoicing tables
+ and `invoice`, v8 the two bank reconciliation tables, v7 makes attachments
+ append-only, v3 replaces the seeded moms rules with the corrected mapping;
+ v2 adds the two template tables).
- Migrations are forward-only, applied automatically at daemon start, each in
one transaction. Before the first migration statement a consistent
`VACUUM INTO` snapshot is written to
diff --git a/docs/STATE.md b/docs/STATE.md
index cf2efc8..f481524 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -12,9 +12,22 @@ filing/year-end work remains. TUI is usable and exercised by `make test-pty`
lönebesked); `make test` covers the server/protocol/ledger, the TUI widget
unit tests and the docs consistency check.
-## Resume here (2026-09-21)
+## Resume here (2026-09-22)
-- **Deployed**: `v0.1.58`, healthy on `nas` (Alpine runtime, static aarch64
+- **Deklaration 2025/2026 (org 2, 2026-09-22)**: the imported year is closed
+ by the source's `Stäng intäktskonton/kostnadskonton`, so the resultatrapport
+ (and the INK2/SRU derived from it) showed 0. `report.income_statement` now
+ skips those vouchers (deployed in `v0.1.65`), which gives
+ **241 817,48 kr** resultat efter skatt för 2025/2026. In the TUI the
+ "Bokfört resultat" section is absent for such years (the close goes straight
+ to 2099). Two **data drifts** (since resolved, see the v0.1.66 entry):
+ the imported FY 2022/2023 result transfer is **17 857,63 kr** short of that
+ year's P&L (its tax was never booked as an expense), so the derived balance
+ sheet does not balance by that amount; and 2099 is 1,07 kr off the P&L
+ result. Locked years stay locked — a correction belongs in the open year
+ (2026/2027). The owner can now run Bokslutshubben → Inkomstdeklaration.
+
+- **Deployed**: `v0.1.66`, 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`). `v0.1.55` brought the payroll
server waves (schema v11: employees, tax tables, lönebesked) and the
@@ -22,9 +35,87 @@ unit tests and the docs consistency check.
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). 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`.
+ Skatteverket fetch, SMTP, static clients); `v0.1.59` adds the keyboard
+ protocols (though gnome-terminal/VTE cannot send Ctrl+Enter); `v0.1.60`
+ the per-feature voucher series (Bolaget → Verifikationsserier, IB
+ configurable with legacy `IB` still recognized), Nästa fakturanummer in
+ Fakturauppgifter and `F9`-only save hints; `v0.1.61` lets underlag be
+ granskade (text in a pager, other files in the desktop viewer) or
+ nedladdade from the voucher detail and the inbox; `v0.1.62` brings invoice
+ **text rows**, `u = duplicera`, `b = kvittera betalning` (`invoice.pay`
+ links a payment voucher) and rejects name-like `smtp_from` values
+ (schema v12: `invoice_rows.is_text`, `invoices.paid_date`/
+ `payment_voucher_id`); `v0.1.63` prints the organization name in the
+ invoice and lönebesked headers and adds the shared document colour setting
+ `document_header_color`; `v0.1.64` is the TUI interaction-model spec (docs
+ only, no runtime change); `v0.1.65` carries earlier IB vouchers forward,
+ restarts P&L at each year, skips imported `Stäng` closings in the
+ income statement and the momsomföring in the VAT report; `v0.1.66`
+ ignores IB rows on P&L accounts. The live database migrated v9 → v11 on the v0.1.55
+ startup and v11 → v12 on the v0.1.62 startup, each with the automatic
+ pre-migration snapshot in `var/db/backup/`. `main` and the tags are pushed
+ to `nas`.
+- **Invoices (2026-09-22)**: the follow-ups are done and deployed in
+ `v0.1.62` — free-text rows, `u = duplicera`, `b = kvittera betalning`
+ (`invoice.pay`, strict receivable check) and `smtp_from` address
+ validation. `test_core` has `invoice_extras`; the pty suite has
+ `invoice-duplicate` and `invoice-pay`. Remaining invoice work is in the
+ backlog (credit notes, per-row account, kundreskontra, partial payments).
+- **Document header (2026-09-22, deployed in `v0.1.63`)**: the invoice
+ header bar prints the organization name instead of the hard-coded
+ `MAKANDRA AB` outline (scaled down and truncated with `...` if long).
+ The shared document setting `document_header_color` (`#rrggbb`, default
+ `#314c59`, editable in Bolaget → Fakturauppgifter) colours the header bar
+ of both the invoice and the lönebesked, and the lönebesked prints the
+ employer name there too (it previously used the Comfortaa wordmark). The
+ invoice's `FAKTURA` outline is unchanged; the `MAKANDRA AB` outlines in
+ `src/wordmark.h` are now unused at runtime.
+- **TUI interaction model (2026-09-22)**: the UX session settled two focus
+ modes, three entity lifecycles, explicit `Spara` in every savable form and
+ `<UTKAST>` drafts persisted to `$XDG_CACHE_HOME/bokf/drafts.json`
+ (deletable from both the list and the editor), plus one `tui_action`
+ registry per screen behind `F2` (no `§`). The **Kunder pilot is
+ implemented** (`clients/drafts.[ch]`, `tui_action_menu()`,
+ `tui_action_hint()`, `<UTKAST>`, `Spara` row, F2 menu, delete draft) with
+ unit tests, the pty scenarios `customer-draft`/`customer-draft-save` and a
+ green `make gate`. It is in `main` (`07f5b14`), pushed and part of `v0.1.65`.
+ **Next session**: get the human's Ctrl+R test feedback (Bolaget → Kunder:
+ Ctrl+N, type, Esc, F2, Spara, Radera utkast) and then continue the
+ rollout per backlog item 17: the other registers, explicit `Spara` in the
+ settings forms, `tui_rt` action menus. Review points kept in
+ `TUI-GUIDELINES.md`: drafts of encrypted fields (an employee's
+ personnummer) and `Enter` on action rows. Spec in `TUI-GUIDELINES.md`
+ "Interaction model"; decisions in `DECISIONS.md` #28.
+- **IB carry-forward (2026-09-22, deployed in `v0.1.65`)**:
+ reports and `sie.export` carry earlier years' `IB` vouchers into the
+ opening balance (Makandra's 2021 IB voucher held aktiekapital 2081/1940,
+ which vanished from every later year) and restart P&L accounts at each
+ fiscal-year start (they used to accumulate since 2021). SIE writes
+ `#IB`/`#UB` only for balance accounts and `#RES` only for P&L. Makandra's
+ opening balances now show the 17 857,63 kr 2022/23 gap openly (balance
+ accounts' IB sums to that instead of 0). Merged together with
+ `eff/imported-closings`, whose "Stäng" skip now applies only to
+ `sie_import` vouchers. `test_core` has `ib_carry` and `imported_closings`.
+- **Momsrapport (2026-09-22, deployed in `v0.1.65`)**:
+ `report.vat` (and `report.vat_eskd`) skips the momsomföring (vouchers with
+ a 2650 row) and SIE-imported `Stäng` closings. Makandra's 2025/26 report
+ was all zeros because V107 (Momsdeklaration) and V109 were counted; the
+ rules themselves were fine. `test_core` has `vat_settlement`.
+- **IB rows on P&L accounts (2026-09-22, deployed in `v0.1.66`)**: reports
+ ignore `IB` rows on P&L accounts (the balancing side of an IB delta);
+ `bokfctl --dry-run` sets the request's `dry_run`. Makandra data, booked by
+ the owner: V 25 in 2021/22 (8910/2512 17 857,63 — the tax voucher missing
+ from the source's SIE export), IB 2 (2512 D / 8999 K 17 857,63) and IB 3
+ (2081 D / 1940 K 25 000) in 2026/27. Every year now balances, 2026/27 opens
+ exactly on the filed 2025/26 UB; only öre diffs remain in history (2512
+ 1,63, 2099 1,07, moms 0,56). 2021/22 was reopened for V 25 and must be
+ closed again by the owner.
+- **Mail configuration**: Makandra AB (org 2) has **no** `smtp_*` settings
+ in bokf, so `invoice.send` there is `SMTP_NOT_CONFIGURED` (fine if
+ invoices are sent elsewhere — set them up when wanted). Mock AB (org 1)
+ has host/port/user/password but its stored `smtp_from` is the name
+ "Anders Bergsten": change it to an e-mail address in **Bolaget → E-post
+ (SMTP)** before sending; the new validation refuses names on save.
- **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
@@ -34,15 +125,16 @@ unit tests and the docs consistency check.
- **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
+ avgifter), Anställda under Bolaget 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
+ (Företagsuppgifter, Fakturauppgifter with Nästa fakturanummer,
+ **Verifikationsserier**, 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`
@@ -76,20 +168,28 @@ None open. Completed items that used to be listed here are archived in
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.
+14. ~~Invoice text rows, duplicate and payment registration~~ done in
+ `v0.1.62` (schema v12). Remaining invoice follow-ups when needed:
+ credit notes (`invoice.credit`), per-row account in the invoice form,
+ kundreskontra view, partial payments.
15. `make test-pty` speed if it ever exceeds ~60 s: parallelise the
independent scenarios (each has its own rig) and add `--only` symmetry;
measure first.
16. Test fixtures (`t_fresh_org()`) + one test file per domain
(`tests/core_<domain>.c`) so `--only` stops cascading; pilot with one
domain.
+17. Interaction-model rollout (spec: `TUI-GUIDELINES.md` "Interaction
+ model", decisions #28): **Kunder done** 2026-09-22 (widget layer
+ `tui_action`/`F2`, `clients/drafts.[ch]`, drafts/`<UTKAST>`/`Spara`,
+ pty scenarios), awaiting the human's Ctrl+R test feedback. Remaining:
+ the other register screens, explicit `Spara` in the settings forms
+ (replacing per-field autosave), and `tui_rt` action menus.
Original entries for the struck items are in `docs/DECISIONS.md`.
## Environment / how to run
-- **Deployed**: `scripts/deploy.sh` (latest `v0.1.58`, healthy on nas).
+- **Deployed**: `scripts/deploy.sh` (latest `v0.1.66`, 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
@@ -132,6 +232,10 @@ Original entries for the struck items are in `docs/DECISIONS.md`.
## Known caveats
+- Saving with Ctrl+Enter needs a terminal that speaks xterm
+ `modifyOtherKeys` level 2 or the Kitty keyboard protocol; gnome-terminal/VTE
+ sends neither, so the hints advertise `F9`, which works everywhere.
+
- Developer tooling: the `g_cmd_<domain>[]` tables in `src/cmd_*.c` carry
declarative argument schemas (`CMD_ARGS`); `describe` emits them and the
dispatcher validates before the handler runs. `make check` (part of
@@ -145,24 +249,34 @@ Original entries for the struck items are in `docs/DECISIONS.md`.
- Never commit unless the human asks.
- SQLite files must not be backed up live with restic; use
`backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots.
-- Schema version is 11 (v3 moms rules; v4/v6 year info; v5 org
+- Schema version is 12 (v3 moms rules; v4/v6 year info; v5 org
description/shares + board members; v7 attachments append-only triggers;
v8 bank reconciliation; v9 invoicing + widened `vouchers.source` with a
table rebuild; v10 payroll + `payroll`/`payroll_tax` sources, same
- rebuild; v11 `employees.email`); forward migrations are in `db.c`.
+ rebuild; v11 `employees.email`; v12 `invoice_rows.is_text` and
+ `invoices.paid_date`/`payment_voucher_id`); forward migrations are in
+ `db.c`.
## Makandra driftstatus (org 2)
- **Org**: Makandra AB, org 2. Räkenskapsår (id): 2022=3, 2023=4, 2024=5,
2025=6, 2026=7, **2027=2 (öppet, aktuellt)**. Bokslut/AR/deklaration görs
för det år som är valt i sessionen.
-- **FK2027**: importerade Kapitas-böcker + 28 bokförda verifikat (V21–V48)
- för bank/skatt maj–sep 2026, samt V49 som makulerar en dubblett (V20).
- 1930 stämmer mot banken utom **CDON 2 409 kr** (väntar på kvittots del
- 2–4; bokförs när det kommer). 1630 = 40 721 (exakt enligt Skatteverket).
-- **Underlag**: 279 attachment i org 2 (alla historikdokument + insamlade
- underlag). Bank-/SKV-utdrag ligger i `~/Makandra AB/{bank,skatteverket}`
- (Syncthing), källkorpus i `~/Downloads/Makandra AB-…/Bokföring/`.
+- **FK2027**: importerade Kapitas-böcker + 30 bokförda verifikat (V21–V50)
+ för bank/skatt maj–sep 2026: V49 makulerar en dubblett (V20) och V50 är
+ Hetzner-förskottet (nedan). 1930 stämmer mot banken utom **CDON
+ 2 409 kr** (väntar på kvittots del 2–4; bokförs när det kommer) och
+ Hetzner-kortköpet 2026-09-21 som ännu inte är importerat. 1630 = 40 721
+ (exakt enligt Skatteverket).
+- **Hetzner-förskott (2026-09-22)**: V50, datum 2026-09-21 (bankens
+ bokföringsdatum), "Förskott Hetzner 100 EUR": **D 1790** 1 156,39 /
+ **K 1930** 1 156,39, med båda PDF:erna som underlag. Matchas mot banken
+ när nästa SEB-fil (efter 2026-09-18) importeras. Förbrukningen bokförs
+ när Hetzner-fakturorna kommer (se reglerna nedan).
+- **Underlag**: 309 attachment i org 2 (alla historikdokument + insamlade
+ underlag, inkl. de två Hetzner-PDF:erna). Bank-/SKV-utdrag ligger i
+ `~/Makandra AB/{bank,skatteverket}` (Syncthing), källkorpus i
+ `~/Downloads/Makandra AB-…/Bokföring/`.
- **Stängning**: 2022–2026 ska stängas av ägaren via **Räkenskapsår** i
TUI:n; låt FK2027 vara öppen till nästa bokslut.
- **Deklaration**: FK2026 är deklarerad av revisorn. FK2027 deklareras
@@ -176,7 +290,11 @@ Original entries for the struck items are in `docs/DECISIONS.md`.
historikårens P&L nettar noll pga källsystemets stängningar (AR hoppar
över "Stäng"-verifikat i flerårsöversikten); utdelning bokförs vid stämman
med mallen **Utdelning** (D 2099/K 2898); pappersoriginal finns i fysisk
- pärm (får refereras i efterhand, även i stängda år).
+ pärm (får refereras i efterhand, även i stängda år); leverantörsförskott
+ och förbrukningssaldo (Hetzner) bokförs som **D 1790/K 1930** vid
+ påfyllning och **D 6540 + D 2645 / K 2614 / K 1790** vid förbrukning;
+ kortköp bokförs på **bankens bokföringsdatum** (lättare att härleda och
+ matcha vid import).
- **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) och TUI-sektionen Lön är klara, men
diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md
index 190a5c5..1c201aa 100644
--- a/docs/TUI-GUIDELINES.md
+++ b/docs/TUI-GUIDELINES.md
@@ -4,6 +4,124 @@ Rules for the ncurses client so every view behaves the same. When in doubt,
copy the behaviour of the voucher list / voucher form; they are the reference
implementations. Inspired by Midnight Commander, htop, mutt and calcurse.
+The **Interaction model** section is the agreed target (settled 2026-09-22,
+`DECISIONS.md` #28). The **Kunder** pilot is implemented: drafts in
+`$XDG_CACHE_HOME/bokf/drafts.json`, `<UTKAST>` marking, the explicit `Spara`
+row, the `F2` action menu and draft deletion work there. Every other screen
+still follows the sections below; the interaction model wins where they
+conflict as each screen moves over.
+
+## Interaction model (target, 2026-09-22)
+
+Every entity has **one lifecycle**, every form **one commit gesture**, and
+every action **one declaration**. Nothing is written to the backend
+implicitly.
+
+### Two focus modes
+
+The cursor is always in one of two modes:
+
+- **Navigation** — `Tab`/`Shift-Tab`, arrows, `Home`/`End`, `PgUp`/`PgDn`
+ only move focus, selection or the viewport. They never mutate data, never
+ save and never run an action.
+- **Editing** — the focused field is reverse video with a caret. Inside a
+ field, `←`/`→`/`Home`/`End` move the caret, `Backspace`/`Del`/`Ctrl+U`
+ edit the text and `Up`/`Down` leave the field. `Enter` commits the field
+ and advances; `Esc` restores it (the scratch-copy semantics stay).
+
+`Enter` activates the focused item **when that item owns an action** (a menu
+item, an action row, a list row that opens a detail). A report, a pager or a
+blank area has no such item, so `Enter` does nothing there. `Enter` never
+saves a whole form, never deletes and never posts.
+
+### Entity lifecycle
+
+| Class | Draft | Commit event | `Esc`/`q` | Examples |
+|---|---|---|---|---|
+| **Register** | in memory + local draft file | explicit `Spara` (the save action row, `F9`) after validation | back; the draft stays | customers, employees, templates, momsregler, org data |
+| **Document** | the form is the draft | explicit `Posta` (the save action row, `F9`); immutable once written | back; confirm only when dirty | verifikat, fakturor, löneruns, bokslut |
+| **Settings** | an edit buffer until `Spara` | explicit `Spara` | back | faktura/SMTP/serie-inställningar |
+
+- Every savable form ends with a visible commit action row (`Spara`, or
+ `Posta` for documents); `Enter` on the focused row commits (it is an item
+ with an action), `F9` is the accelerator.
+- `Ctrl+Enter` is **dropped** as a commit key: gnome-terminal/VTE cannot
+ send it. The save action row and `F9` are the only commit gestures, which
+ makes every terminal behave the same.
+- Commit is blocked while the data is invalid: the first invalid field is
+ focused and its error shown. The draft is untouched. A server error at
+ commit keeps the draft as well; only a successful command removes it.
+- `Ctrl+R` (reload) and `Ctrl+C` never flush drafts to the backend; the
+ client-side draft file makes them survive both, so no work is lost.
+
+### Drafts
+
+- Every non-committed edit is a **draft**, held in memory and mirrored to
+ `$XDG_CACHE_HOME/bokf/drafts.json` (mode 0600, atomic replace) on every
+ change. This is the "nothing is ever lost" guarantee — `Ctrl+R`, a crash
+ and `Ctrl+C` included.
+- A draft is keyed by `(org, entity, id)`; a new entity gets a temporary id
+ and shows up in its list immediately.
+- Drafts are marked `<UTKAST>` directly after the row number in lists
+ (`3. <UTKAST> Namn`) and in the editor's frame title (`Kund <UTKAST>`).
+- **Delete draft** is an action both on the list row (via `F2`) and inside
+ the editor, with a confirmation. It removes the memory and file draft and
+ never touches the backend. A successful commit removes the draft too.
+- Drafts are client-local and never synced; another client sees the last
+ committed value.
+- When the backend entity is gone at commit time (`NOT_FOUND`), the editor
+ offers "spara som ny" or "radera utkast".
+- Review point: a draft of an encrypted field (an employee's personnummer)
+ puts plaintext in the cache file. 0600 is the same protection as the
+ Bitwarden session file; decide whether such fields are excluded from
+ drafts.
+
+### Actions and the `F2` menu
+
+Screens declare actions, never keys:
+
+```c
+struct tui_action {
+ const char *id; /* stable, e.g. "customer.archive" */
+ const char *label; /* Swedish UI text */
+ int key; /* accelerator; 0 = menu only */
+ int enabled; /* 1 runnable, 0 dimmed with a reason, -1 heading */
+ const char *reason; /* why a disabled action is dim */
+};
+```
+
+- One ordered action list per context drives everything: `F2` opens the
+ `Åtgärder` menu, the same list dispatches the accelerator keys and builds
+ the footer hint. A key can no longer exist outside the registry.
+- The menu is sectioned: **Aktuell rad** (item actions), **Skärmen** (save,
+ delete draft, attach, …), **Globalt** (`F5` uppdatera, `Ctrl+R` ladda om,
+ …). Destructive actions are last and still ask for confirmation.
+- `Enter` in the menu runs the highlighted action; disabled actions are dim
+ with their reason (as in `tui_form_action` today); `Esc` closes. The
+ actions that complex forms hide behind hotkeys today live here unchanged.
+- The footer shows at most the two or three most important contextual
+ actions plus `F2 = fler`. Universal navigation keys (`Tab`, arrows,
+ `PgUp`/`PgDn`, `Home`/`End`) are not repeated there.
+- The session's key decisions: letter accelerators stay, `F9` is the only
+ commit key (`Ctrl+Enter` is dropped) and `F2` only — no `§` binding (it is
+ not reliably encodable across terminals).
+
+### Implementation status
+
+1. Done: `struct tui_action`, `tui_action_menu()` and `tui_action_hint()` in
+ `clients/tui.[ch]`, unit-tested in `tests/test_tui.c`.
+2. Partly done: the Kunder list builds its `F2` actions in a key hook and
+ appends `F2 = åtgärder` to the footer via `tui_list_hint_extra()`; other
+ lists have no actions yet, and `tui_rt` still uses its own key branches.
+3. Done for Kunder: `clients/drafts.[ch]` (JSON store, atomic 0600 write,
+ temporary ids, `<UTKAST>` marking, delete action), unit-tested.
+4. Done for Kunder: draft/`Spara` model with the `Spara` action row,
+ `F2` menu and draft deletion from both the list and the editor. The
+ other register screens and the settings forms (explicit `Spara`, no
+ per-field autosave) are next.
+5. Done for Kunder: pty scenarios `customer-draft` and
+ `customer-draft-save` cover create, edit, `<UTKAST>`, delete and save.
+
## Session start
After login the org picker ("Välj organisation att representera") is always
@@ -29,11 +147,12 @@ there.
| `a` | Add/upload (Underlag) |
| `c` | Correct (voucher detail) |
| `d` | Delete/arkivera the selected row (only where the action exists; asks for confirmation) |
-| `f` | Voucher detail: list the voucher's underlag — Enter fetches, `d` removes the link (asks first). Underlag: Enter fetches |
+| `f` | Voucher detail: list the voucher's underlag — Enter opens Granska (text in a pager, PDFs/images in the desktop viewer) or Ladda ned…, `d` removes the link (asks first). Underlag: Enter does the same |
+| `u` / `b` | Faktura detail: `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid |
| `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` level 2 or the Kitty keyboard protocol; terminals that send neither keep `F9` working, and the hints show `^Enter/F9` |
+| `Ctrl+Enter` | Save/post the current form. Needs xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol (xterm, kitty, foot, WezTerm); gnome-terminal/VTE sends neither, so the hints advertise `F9`, which works everywhere. The interaction model drops `Ctrl+Enter` entirely — don't add it to new views |
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 +162,7 @@ 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` (Bolaget → Fakturauppgifter) for new
+ using the org's `series_voucher` (Bolaget → Verifikationsserier) 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.
@@ -235,10 +354,14 @@ only place that touches ncurses. Rules:
## Adding a view — checklist
1. Data comes from public protocol commands only.
-2. Wrap the screen in `frame()`/`hints()`; return `Esc`/`q` to the parent.
+2. Wrap the screen in `tui_frame()` and let the widgets carry the footer
+ hints; return `Esc`/`q` to the parent.
3. Use `tui_menu`/`tui_select_list` instead of writing a new loop; pass
`allow_new`/`allow_refresh` so the universal keys apply.
4. Forms use the shared editor (`tui_edit_field`, `tui_prompt_into`,
`tui_date_prompt_into`, `tui_amount_prompt_into`) and the row helpers.
5. Support `F5` if the data can change elsewhere.
6. Update `PROTOCOL.md` §8 and this file if you add a new key or interaction.
+7. Declare the screen's actions in one `tui_action` list (once the
+ interaction model is implemented); dispatch, the `F2` menu and the footer
+ hint all read that list, so a key cannot exist without a visible action.