1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
|
# 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` 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 <nr> till <e-post>?".
- **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.
|