aboutsummaryrefslogtreecommitdiff
path: root/docs/INVOICING.md
blob: f509997d74f73f79c57dba3a30e366f9289329b7 (plain)
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
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# 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:<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.