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
|
# bokf — payroll (lön)
Status: Design draft · 2026-09-21 · License: GPL-3.0-or-later
Payroll for a small Swedish AB that runs monthly salaries from the books.
Wave 1 is the engine and the mandatory steps; the employee is the owner but
the schema and commands are multi-employee from the start. Wave 2 (the
lönebesked PDF and its delivery) and wave 3 (the TUI) are done.
## 1. Scope
**In (wave 1)**
- Employee register: name, personnummer (encrypted at rest), address,
bank account, employment (monthly salary), salary account, tax table and
column.
- Tax tables from Skatteverket: fetch, parse and store the official monthly
tables, with a staleness check and a one-button refresh.
- One monthly payroll run per period: gross, tax, employer contributions,
net, posting as a voucher.
- AGI underlag: the field values per employee and period for the manual
declaration on skatteverket.se.
- Payment steps as buttons: pay salaries (the run voucher) and pay tax +
contributions to the tax account (its own voucher), matched by the bank
reconciliation.
**In (wave 2, done)**
- Employee e-mail (`employees.email`, schema v11) as the default lönebesked
recipient.
- Lönebesked: one A4 PDF per employee and run, rendered with the invoice's
visual language, stored as an attachment on the run's voucher and mailable
with the existing SMTP path (`payroll.payslip`,
`payroll.payslip_mail`). See §6.
**Out (later)**
- Semester/vacation accrual and vacation pay (skipped for now).
- AGI XML/filing, pension, benefits, foreign employees, växa-stöd,
studiesocialt, löneväxling.
## 2. Accounts
Taken from the org's own imported history (2022–2026): 7210 for the owner's
salary, 7510 för arbetsgivaravgifter, 2710 personalskatt and 2731 avräkning.
| Item | Debit | Credit |
|---|---|---|
| Lönekörning | 7210 löner (per employee, default) | 2710 personalskatt, 1930 nettolön |
| Arbetsgivaravgifter | 7510 (31.42 %) | 2731 avräkning sociala avgifter |
| Betalning till skattekontot | 2710 + 2731 | 1630 skattekontot |
7010 is for kollektivanställda, 7210 för tjänstemän (what this org has used);
the account is a per-employee setting. Settings: `payroll_salary_account`
(default `7210`), `payroll_tax_account` (`2710`), `payroll_avgift_account`
(`7510`), `payroll_avgift_liability` (`2731`), `payroll_tax_payment_account`
(`1630`), `payroll_avgift_rate_bp` (default `3142`, basis points).
## 3. Schema v10 (v11 adds the employee e-mail)
```sql
CREATE TABLE employees (
org_id INTEGER NOT NULL REFERENCES orgs(id),
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
personal_no_enc TEXT NOT NULL, -- enc:v1:... (AES-256-GCM)
address TEXT NOT NULL DEFAULT '',
postal_code TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
bank_account TEXT NOT NULL DEFAULT '',
salary_account TEXT NOT NULL DEFAULT '7210',
monthly_salary_ore INTEGER NOT NULL DEFAULT 0 CHECK (monthly_salary_ore >= 0),
tax_table INTEGER NOT NULL DEFAULT 30 CHECK (tax_table BETWEEN 29 AND 42),
tax_column INTEGER NOT NULL DEFAULT 1 CHECK (tax_column BETWEEN 1 AND 6),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT,
email TEXT NOT NULL DEFAULT '', -- v11, lönebesked recipient
UNIQUE (org_id, id)
) STRICT;
CREATE TABLE payroll_runs (
org_id INTEGER NOT NULL REFERENCES orgs(id),
id INTEGER PRIMARY KEY,
fiscal_year_id INTEGER NOT NULL,
period TEXT NOT NULL, -- YYYY-MM
pay_date TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'posted'
CHECK (status IN ('posted','paid')),
gross_ore INTEGER NOT NULL,
tax_ore INTEGER NOT NULL,
avgifter_ore INTEGER NOT NULL,
net_ore INTEGER NOT NULL,
voucher_id INTEGER,
payment_voucher_id INTEGER,
created_at TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id),
UNIQUE (org_id, id),
UNIQUE (org_id, period, pay_date),
FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_years(org_id, id),
FOREIGN KEY (org_id, voucher_id) REFERENCES vouchers(org_id, id),
FOREIGN KEY (org_id, payment_voucher_id) REFERENCES vouchers(org_id, id)
) STRICT;
CREATE TABLE payroll_run_lines (
org_id INTEGER NOT NULL,
id INTEGER PRIMARY KEY,
run_id INTEGER NOT NULL,
employee_id INTEGER NOT NULL,
gross_ore INTEGER NOT NULL,
tax_ore INTEGER NOT NULL,
avgifter_ore INTEGER NOT NULL,
net_ore INTEGER NOT NULL,
tax_table INTEGER NOT NULL,
tax_column INTEGER NOT NULL,
UNIQUE (org_id, id),
UNIQUE (org_id, run_id, employee_id),
FOREIGN KEY (org_id, run_id) REFERENCES payroll_runs(org_id, id),
FOREIGN KEY (org_id, employee_id) REFERENCES employees(org_id, id)
) STRICT;
-- National reference data, not tenant data: no org_id.
CREATE TABLE tax_tables (
in_year INTEGER NOT NULL,
table_no INTEGER NOT NULL,
column_no INTEGER NOT NULL,
income_from_ore INTEGER NOT NULL,
income_to_ore INTEGER, -- NULL = open-ended top range
tax_ore INTEGER NOT NULL, -- whole kronor for the range
pct INTEGER, -- top range percentage (x100)
PRIMARY KEY (in_year, table_no, column_no, income_from_ore)
) STRICT;
CREATE TABLE tax_table_meta (
in_year INTEGER PRIMARY KEY,
source_url TEXT NOT NULL,
sha256 BLOB NOT NULL CHECK (length(sha256) = 32),
fetched_at TEXT NOT NULL
) STRICT;
```
`vouchers.source` allows `'payroll'` and `'payroll_tax'` (widened CHECK in
v10; the same table rebuild as v9). v11 adds `employees.email` in a forward
migration (`ALTER TABLE`); `employee.create/update` accept it (at most 254
characters, no control characters) and `employee.list/get` return it.
## 4. Tax tables
Skatteverket publishes the complete monthly table as a fixed-width UTF-8
(BOM) TXT per income year, linked from "Teknisk beskrivning för
skattetabeller". 2026: `allmanna-tabeller-manad.txt`, ~389 kB, 7,965 lines.
Records (verified against the 2026 file):
```
30B29 2001 2100 150 0 150 0 150 2
30%29 80001 82200 30 30 26 27 35 35
```
- `30B<nn>`: monthly table `nn` (29–42), income range in whole kronor, then
the tax for columns 1–6 in whole kronor.
- `30%<nn>`: the same tables above 80,000 kr/month; the columns are
percentages (hundredths) that apply to the part of the income above the
table's base, per SKV 433 (the technical description, fetched as PDF).
- The tax in the B records already includes grundavdrag, jobbskatteavdrag,
public service fee and burial fee; use it as the withholding.
Commands:
- `payroll.tax_tables_fetch {year?}` (owner, audited): HTTPS GETs the
technical page, finds that year's `allmanna-tabeller-manad.txt`, downloads
and parses it, replaces the year's rows in one transaction and stores the
source URL + SHA-256 in `tax_table_meta`. Missing year defaults to the
current calendar year.
- `payroll.tax_tables_import {year, content_base64}` (owner, audited): the
same parse from a supplied file (offline/air-gapped fallback).
- `payroll.tax_tables_status`: `{"stored_years":[...],"current_year":N,
"stale":bool,"fetched_at":...,"source_url":...}`. The TUI shows a warning
and the fetch button when `stale`.
The HTTPS GET uses OpenSSL directly (same stack as the SMTP client) with
system trust; the page URL is a constant, the file link is discovered by
matching `allmanna-tabeller-manad.txt` for the requested year.
## 5. Commands (wave 1)
| Command | Args | Result |
|---|---|---|
| `employee.list` | `active_only?` | items (personnummer masked) |
| `employee.get` | `id` | one employee (personnummer masked) |
| `employee.create` | `name`, `personal_no`, salary/account/table fields | created employee |
| `employee.update` | `id` + any field | effective employee |
| `employee.archive` | `id`, `active` | archived/reactivated |
| `payroll.tax_tables_fetch` | `year?` | `stored`, `rows`, `source_url`, `sha256` |
| `payroll.tax_tables_import` | `year`, `content_base64` | `stored`, `rows` |
| `payroll.tax_tables_status` | — | stored/current year, `stale` |
| `payroll.run_preview` | `period` (YYYY-MM) | per-employee gross/tax/avgifter/net and totals; nothing written |
| `payroll.run_post` | `period`, `pay_date`, `dry_run?` | run id, voucher id, totals |
| `payroll.run_list` | `limit?` | runs |
| `payroll.run_get` | `id` | run + lines |
| `payroll.agi` | `period` | per-employee AGI field values for the manual declaration |
| `payroll.pay_tax` | `run_id`, `date?`, `dry_run?` | payment voucher (D 2710 + D 2731, K 1630) |
`run_post` takes each active employee's `monthly_salary_ore` as gross (hourly
employees are out of scope in wave 1), withholds the table tax for their
table/column and books the two legs above in one transaction with
`source:"payroll"`. Taxation below 1,000 kr/year is not withheld (SKV rule)
and is handled at the run level. `pay_tax` books the payment with
`source:"payroll_tax"` and marks the run paid; the bank reconciliation
matches the bank leg to 1630 as usual.
The employer contribution rate is a setting (`payroll_avgift_rate_bp`,
default 3142) so växa-stöd/regional reductions can be handled manually later;
the base is the gross.
## 6. Documents and TUI
- **Lönebesked** (wave 2, done): `payroll.payslip` renders one A4 page per
employee and run with the invoice's visual language — dark `#314c59`
header bar, employer and employee blocks, gross, preliminary tax (shown
negative), net and the employer-contribution note; the personnummer is
masked except the last four. `payroll.payslip_mail` stores the PDF as an
`application/pdf` attachment on the run's voucher, links it with
`voucher_attachments` and e-mails it through the org's `smtp_*` settings
to the employee's `email` (subject `Lönebesked <period>`, a short Swedish
body with the net amount). A missing address is `INVALID_ARGS`, missing
SMTP configuration `SMTP_NOT_CONFIGURED` and a failed delivery
`SMTP_FAILED` (the stored attachment stays linked). `dry_run` validates,
renders and stores nothing. See `PROTOCOL.md` §7.12.
- **TUI — Lön** (done, `clients/screens_payroll.c`): `Lönekörningar` (list,
Ctrl+N for a new run) under Lön, `Anställda` under Företag, and a
**Skattetabeller** screen (System, since the tables are national) with
status, the Skatteverket fetch and the offline file import.
- The run screen shows the preview (F5), posts with Ctrl+Enter after
confirmation, then offers the buttons **Lönebesked** (saves/opens the
PDF), **AGI-underlag** (`payroll.agi` in a pager, owner only) and
**Betala skatt & avgifter** (`payroll.pay_tax` with a date prompt) as
manual steps. Steps that do not apply yet are dimmed with the reason.
Posting and paying show the resulting voucher as `V-<n>`.
- The list title warns when the current year's tax tables are missing; a
new run is refused with a pointer to Anställda when nobody has a monthly
salary.
## 7. Testing
- Parser: fixture lines from the real 2026 file (B and % records), off-by-one
ranges, BOM handling; tax lookup at range boundaries; the 80,000 kr/month
boundary and the top-range percentage formula per SKV 433.
- Run posting: two employees, correct gross/tax/avgifter/net, balanced
voucher with the configured accounts, `source:"payroll"`, AGI JSON values,
pay_tax voucher and paid status; stale-table warning.
- Personnummer: encrypted at rest, masked in responses, decrypt for the AGI
export only.
|