# bokf — invoicing (fakturering) Status: Design draft · 2026-09-20 · License: GPL-3.0-or-later This chapter is the design for bokf's own invoicing: customer register, invoice documents, numbering/OCR, posting and e-mail. It replaces the current Google Sheets export. The customer must not notice the change, so the generated PDF reproduces the existing document. ## 1. Scope **In (MVP)** - 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). - 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) + posted voucher (D 1510 / K 3xxx + 26xx) + invoice↔voucher link, atomic. - Preview before sending: the real PDF is generated and opened locally without storing it or consuming the number. - E-mail the PDF to the customer over SMTP with TLS, with a "send again" action and send history. - Payment matching through the existing bank reconciliation (`bank_matches`): an invoice is paid when the payment voucher is matched to the bank transaction. **Out (now, later)** - Multi-page invoices (single page; too many rows is an error until a later version). - Credit notes (`invoice.credit`), reminders/interest, Peppol/e-invoice, OCR/giro avi, foreign currency, articles/price lists (a line's account and price are typed or defaulted), recurring invoices (a later `invoice.repeat`). - Drafts stored on the server: the form is the draft. ## 2. The document (layout spec) A4 (595 × 842 pt), one page, no images. Measured from the existing PDFs (Google Sheets export, Fonts: Arial + Comfortaa-Bold for the wordmark). Colours: | Use | Colour | |---|---| | Header bar, table header, accent | `#314c59` (slate) | | Body text | `#314c59` | | Header/table text on the bar | `#ffffff` | 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. Grid (points, origin top-left; refined against the originals in `tests/` golden comparisons): | 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 | | `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 | | Dröjsmålsränta (3 fixed lines) | 118.87 | 174.03 + 10.47/line | | Fakturaadress block (name always first slot) | 370.35 | 115.82 + 14.71/line | | Table header bar (x 17.3–577.7) | 17.3 | 242.43–257.85 | | Table columns: left edges | 20.10, 118.87 | baseline 253.81 | | Table columns: right edges | 365.45, 433.39, 504.15, 574.89 | — | | First table row | 20.10/118.87 | 268.53, 14.71/grid row | | Summary left labels / values, right-aligned | 329.76 / 365.45 | 518.60, 533.31, 548.03, 569.04, 583.75 | | Rule above summary (0.70 pt, black) | 17.3–577.7 | 507.57 | | Rule above footer (0.70 pt, black) | 17.3–577.7 | 621.76 | | Summary right labels / values, right-aligned | 504.17 / 574.89 | same rows | | `OCR` row | 504.15 / 574.89 | 598.46 | | `Summa att betala SEK` (bold) | 504.17 / 574.89 | 615.24 | | Footer headings (bold 6.375 pt) | 20.10, 118.87, 263.88 | 642.47 | | Footer values (9.107 pt) | 20.10, 118.87, 334.63 | 656.60, 671.31, 686.02 | | Page number | 567.43 | 819.80 (9.75 pt) | Table rows: the first row's baseline is 268.53, every grid row is 14.71 pt. A row's `description` lines print one grid row apart, except that the first extra line starts one blank grid row lower (29.42 pt): the original sheets use that gap before the `Period ...` line. The next invoice row starts `(description lines + 1)` grid rows below the current one. The left and right summary blocks are anchored at fixed positions, not reflowed after the table. Fields, in the original's order (note: the original says `Betalningsvilkor`, kept for fidelity): - Left: `Fakturanummer`, `Fakturadatum`, `Leveransdatum`, `Ert momsreg.nr`, `Betalningsvilkor`, `Dröjsmålsränta` (three fixed lines), `Vår referens`, `Er referens`. - Right (`Fakturaadress`): customer name + `\n`-separated address lines. - Table: `Art.nr | Beskrivning | Antal | á-pris | Anm | Belopp`. A row's `description` may contain a second line (e.g. `Period 2022-01-25 tom 2022-02-25`) which is printed under the row. - Summary: `Momsunderlag` per rate (`Moms 25%`, `Moms 12%`, `Moms 6%`, `Momsfritt`; omvänd skattskyldighet lands in `Momsfritt`), `Belopp före moms`, `Total moms`, `Öresutjämning`, `Förfallodatum`, `Bankgiro`, `OCR`, `Summa att betala SEK`. - Footer: `Adress` (org name, address), `Kontakt` (phone, e-mail), `Orgnr`, `Momsregnr`, `Godkänd för F-skatt`. Amounts: table cells and the summary's plain kronor cells print no thousands separator and no decimals when the amount is whole, otherwise a comma decimal (e.g. `73200`, `12,50`). Only `Summa att betala SEK` is grouped and always two decimals: `91 500,00`. A single trailing page number `1`. ## 3. Numbering and OCR - `invoice_sequence(org_id, next_number)` in the database, one row per org. Configurable by the owner (`invoice.sequence_get` / `invoice.sequence_set`), **global across fiscal years**, never reused, incremented by exactly one per issued invoice (inside the issue transaction). - The first production value is set to `17761` (last used number `17760`, found in the books). - `OCR = number + MOD10 check digit` (Luhn). No customer number; the series is unbroken and the reference unique. Bankgiro accepts MOD10. - The number is consumed at **issue**, not at send; a failed e-mail does not free it. Send again never changes it. ## 4. Lifecycle ``` form (draft in the TUI) └─ F5 invoice.preview → PDF + number + OCR, nothing stored └─ Ctrl+Enter invoice.issue → number + PDF attachment + voucher + links, all in one transaction then a confirm: invoice.send ``` - `issued` is immutable. Corrections are credit notes (later version); a wrong e-mail address can be corrected on the customer and the invoice re-sent. - `credited` (later) references the original. - "Paid" is derived: the issue voucher posts D 1510; the payment voucher (D 1930 / K 1510) is matched to the bank transaction by `bank.match`; a reskontra view sums the 1510 legs per invoice. ## 5. Accounting - Default bank/receivable account `1510` (setting `invoice_receivable_account`). - Revenue account per line, default from setting `invoice_revenue_account` (`3001`), overridable per row. - VAT per row via `vat_code`: `25`, `12`, `6`, `0`, `rc` (omvänd skattskyldighet, no VAT on the invoice, `Momsfritt` in the summary) and `eu` (EU sale, `Momsfritt`). - Voucher: D 1510 total; per Vat-rate K 26xx (2610/2620/2630); K revenue accounts; `source:"invoice"` (schema CHECK is extended in v9) and a `corrects_voucher_id`-style link is not used — the `invoices` row links to the voucher. ## 6. Schema v9 (draft) ```sql CREATE TABLE customers ( org_id INTEGER NOT NULL REFERENCES orgs(id), id INTEGER PRIMARY KEY, name TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', postal_code TEXT NOT NULL DEFAULT '', city TEXT NOT NULL DEFAULT '', country TEXT NOT NULL DEFAULT 'SE', vat_nr TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', your_ref TEXT NOT NULL DEFAULT '', payment_days INTEGER NOT NULL DEFAULT 30 CHECK (payment_days >= 0), notes TEXT NOT NULL DEFAULT '', active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)), created_at TEXT NOT NULL, updated_at TEXT, UNIQUE (org_id, id), UNIQUE (org_id, name) ) STRICT; CREATE TABLE invoice_sequence ( org_id INTEGER NOT NULL PRIMARY KEY REFERENCES orgs(id), next_number INTEGER NOT NULL CHECK (next_number > 0) ) STRICT; CREATE TABLE invoices ( org_id INTEGER NOT NULL REFERENCES orgs(id), id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, number INTEGER NOT NULL CHECK (number > 0), ocr TEXT NOT NULL, invoice_date TEXT NOT NULL, due_date TEXT NOT NULL, delivery_date TEXT NOT NULL DEFAULT '', your_ref TEXT NOT NULL DEFAULT '', our_ref TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '', net_ore INTEGER NOT NULL, vat_ore INTEGER NOT NULL, total_ore INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'issued' CHECK (status IN ('issued','credited')), document_id INTEGER, voucher_id INTEGER, last_sent_at TEXT, last_sent_to TEXT, created_at TEXT NOT NULL, created_by INTEGER NOT NULL REFERENCES users(id), UNIQUE (org_id, id), 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) ) STRICT; CREATE TABLE invoice_rows ( org_id INTEGER NOT NULL, id INTEGER PRIMARY KEY, invoice_id INTEGER NOT NULL, line_no INTEGER NOT NULL, article_no TEXT NOT NULL DEFAULT '', description TEXT NOT NULL, quantity_milli INTEGER NOT NULL CHECK (quantity_milli > 0), unit TEXT NOT NULL DEFAULT 'st', unit_price_ore INTEGER NOT NULL, amount_ore INTEGER NOT NULL, note TEXT NOT NULL DEFAULT '', vat_code TEXT NOT NULL DEFAULT '25' CHECK (vat_code IN ('25','12','6','0','rc','eu')), account TEXT NOT NULL DEFAULT '', UNIQUE (org_id, id), UNIQUE (org_id, invoice_id, line_no), FOREIGN KEY (org_id, invoice_id) REFERENCES invoices(org_id, id) ) STRICT; ``` `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. Customer seed: Andra bygg AB (Solna, SE559232855201, Eric Lejeby, 30), NZ Bygg AB (Bromma, SE559264837101, Valentyne Schnelle, 30), SolidLift Holding AB (Tyresö, SE556740113701, Lars Gustafsson, 30), SafeLine Sweden AB (Tyresö, SE556513071201, Mattias Gyllenros, 14). ## 7. Commands | Command | Args | Result | |---|---|---| | `customer.list` | `active_only?` | items | | `customer.get` | `id` | one row | | `customer.create` | `name` req; address fields, `vat_nr`, `email`, `your_ref`, `payment_days`, `notes` | created customer | | `customer.update` | `id` req + any field | effective row | | `customer.archive` | `id`, `active` | archived/reactivated | | `invoice.sequence_get` | — | `next_number` | | `invoice.sequence_set` | `next_number` (owner) | `next_number` | | `invoice.preview` | draft (below) | `content_base64` (PDF), `number`, `ocr`, totals; nothing stored | | `invoice.issue` | draft + `dry_run?` | `id`, `number`, `ocr`, `document_id`, `voucher_id`, totals | | `invoice.get` | `id` | header, rows, document/voucher ids, last send | | `invoice.list` | `customer_id?`, `status?`, `limit` | items | | `invoice.send` | `id`, `to?`, `dry_run?` | `sent_to`, `at` | | `invoice.pdf` | `id` | stored PDF as base64 (for preview/resend) | Draft object (rows in the form's order, amounts computed by the server): ```json {"customer_id":2,"invoice_date":"2026-09-20","due_date":"2026-10-20", "delivery_date":"2026-09-20","your_ref":"Lars","our_ref":"Anders Betts", "notes":"", "rows":[{"description":"Utvecklingsarbete","quantity":"61", "unit":"tim","unit_price_ore":120000,"note":"","vat_code":"25", "account":"3001"}]} ``` Permissions: reads need `read`; `customer.*`, `invoice.issue/send` and `invoice.sequence_set` are `PERM_OWNER`/`PERM_WRITE` mutations with `dry_run` and audit (`customer.create/update/archive`, `invoice.issue`, `invoice.send`, `invoice.sequence_set`). ## 8. E-mail (SMTP) - Direct SMTP over TLS with OpenSSL (already linked): implicit TLS (465) and STARTTLS (587) with AUTH PLAIN/LOGIN, from `settings`. - Settings: `smtp_host`, `smtp_port`, `smtp_user`, `smtp_from`, `smtp_reply_to`, `smtp_security` (`starttls|tls|plain`) and the secret `smtp_password`. - Secret storage: AES-256-GCM with a 32-byte key from `BOKFD_SECRET_KEY` (base64/hex). Stored value is `enc:v1::`; `settings.get` never returns secret values (reports `set:true`), and `settings.set` redacts the value in the audit entry. Missing key → clear error when setting the password or sending. - Message: `From: Makandra AB <...>`, `To:` customer e-mail (or `to` override), `Subject: Faktura `, short Swedish body, the stored PDF as `Faktura .pdf`. Failures map to a stable error code and leave the invoice issued; `invoice.send` can be retried. ## 9. TUI New section **Fakturering** (after Bokföring): `Fakturor` now, `Kundreskontra` with the payment view later. Customers are master data and live under **Register** (`Kunder`), per the menu rule; the invoice form's customer picker can create a customer inline later. - **Fakturor**: list with number, date, customer, total, status (`utfärdad`, `betald` when the 1510 legs are matched). Ctrl+N opens the form; Enter shows the invoice detail with `p` = visa PDF, `s` = skicka (igen). - **Fakturaformuläret** (fields): kund (väljs), fakturadatum, förfallodatum (från kundens villkor), leveransdatum, er/var referens, rader (beskrivning, antal, enhet, à-pris, anm/moms, konto), fritext. F5 = `invoice.preview` → PDF öppnas i systemets läsare (annars sparas den och sökvägen visas). Ctrl+Enter = `invoice.issue`, följt av bekräftelserutan "Skicka faktura till ?". - **Kunder** (Register): list/form för kundregistret. ## 10. Testing - Unit: number/OCR (MOD10 vectors), totals per VAT code, parser/validator, sequence atomicity, secret encryption round-trip + redaction. - Document golden test: render a fixed invoice to PDF and compare page rasters against the stored original (`tests/`), pixel diff within a small tolerance; catch layout regressions. - pty golden scenario: create a customer, fill the form, F5 preview, issue, and assert the list shows the number and `utfärdad`. - SMTP: send to a local test server (or a capture socket) in the suite; the live provider is configured by the operator.