diff options
Diffstat (limited to 'docs/INVOICING.md')
| -rw-r--r-- | docs/INVOICING.md | 310 |
1 files changed, 310 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. |
