summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 14:41:23 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 14:41:23 +0200
commit9abb72570954b01e5a2a402dbfa29cc33afdf321 (patch)
treee8bc3b22e47847273abd3f945647d1c58be9bce4
parent3375c7c9cae3b53358655de638b8b632c6becea6 (diff)
downloadbokf-9abb72570954b01e5a2a402dbfa29cc33afdf321.tar.gz
bokf-9abb72570954b01e5a2a402dbfa29cc33afdf321.zip
invoicing: design chapter with layout, numbering and SMTP
-rw-r--r--docs/INVOICING.md310
-rw-r--r--docs/STATE.md13
2 files changed, 323 insertions, 0 deletions
diff --git a/docs/INVOICING.md b/docs/INVOICING.md
new file mode 100644
index 0000000..c04ffe3
--- /dev/null
+++ b/docs/INVOICING.md
@@ -0,0 +1,310 @@
+# 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 (full width) | 0 | 48–80 |
+| Wordmark | 20 | 55 |
+| `FAKTURA` (right-aligned) | 577 | 55 |
+| Label column (right-aligned at 114) | 57–114 | 96 + 14.7/row |
+| Value column | 178 | same rows |
+| Fakturaadress block | 556 | 142 + 22/line |
+| Table header | 20 | 247 |
+| Table columns | 20, 119, 343, 409, 484, 544 | — |
+| Summary block | 282 / 445 | 514 + 14.7/row |
+| Bankgiro / OCR | 475 | 579 / 592 |
+| `Summa att betala SEK` | 407 | 609 |
+| Footer | 20, 119, 264 | 638 + 14.7/row |
+| Page number | 577 | 820 (right) |
+
+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 print kronor with no decimals when whole, otherwise
+two; the summary prints `14 400,00` (space thousands separator, comma
+decimal). 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:<nonce_b64>:<ciphertext_b64>`;
+ `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 <number>`, short Swedish body, the stored PDF
+ as `Faktura <number> <customer>.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`, `Kunder`.
+
+- **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).
+- **Nytt verifikat-formulärets fakturavariant** (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 <nr> till <e-post>?".
+- **Kunder**: list/form for the customer register, under Fakturering (the
+ invoice flow needs them close by; master data rule in STATE 20 puts them
+ here for now).
+
+## 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.
diff --git a/docs/STATE.md b/docs/STATE.md
index 8ba7ba6..97d0e8d 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -135,6 +135,19 @@ check.
voucher and stay in the list. Later: `bank_rule.*` pattern suggestions
and "skapa verifikat från transaktion". TUI screen **Bankavstämning**
under Bokföring.
+20. **Menu IA (2026-09-20)**: one top-level section per workflow domain
+ (Bokföring, Fakturering, Lön, …), Register owns master data, Rapporter
+ owns read-only output, Räkenskapsår its own, and a feature adds at most
+ one section. Sections stay stable; screens come and go inside them.
+21. **Invoicing (2026-09-20, design)**: `docs/INVOICING.md` is the chapter.
+ bokf owns invoices end to end: customer register, a global
+ always-increasing number series in `invoice_sequence` (start 17761),
+ `OCR = number + MOD10`, a one-page PDF reproducing the Google Sheets
+ original (Helvetica + Comfortaa outlines, no new deps), issue = number +
+ PDF attachment + voucher + link in one transaction, F5 preview of the
+ real PDF without consuming the number, and SMTP sending with the
+ password encrypted in the DB (AES-256-GCM, `BOKFD_SECRET_KEY`).
+ Implementation waves: schema v9 + PDF, SMTP, TUI (Fakturering).
## Pending decisions