summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-21 09:56:01 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-21 09:56:01 +0200
commit4488b6c2eb1a3ddf20e190ebd28953e4f3f01042 (patch)
treeb52812145147669d04f23a57d41b3f604104963f /docs
parente692f0fbe16342195297048534ad1be227493107 (diff)
downloadbokf-4488b6c2eb1a3ddf20e190ebd28953e4f3f01042.tar.gz
bokf-4488b6c2eb1a3ddf20e190ebd28953e4f3f01042.zip
payroll: lönebesked PDF and mail (schema v11)
Diffstat (limited to 'docs')
-rw-r--r--docs/PAYROLL.md35
-rw-r--r--docs/PROTOCOL.md38
-rw-r--r--docs/SCHEMA.md18
3 files changed, 72 insertions, 19 deletions
diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md
index ffb0eee..b821754 100644
--- a/docs/PAYROLL.md
+++ b/docs/PAYROLL.md
@@ -4,7 +4,8 @@ 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.
+the schema and commands are multi-employee from the start. Wave 2 (the
+lönebesked PDF and its delivery) is done; wave 3 is the TUI.
## 1. Scope
@@ -23,6 +24,15 @@ the schema and commands are multi-employee from the start.
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).
@@ -46,7 +56,7 @@ the account is a per-employee setting. Settings: `payroll_salary_account`
(`7510`), `payroll_avgift_liability` (`2731`), `payroll_tax_payment_account`
(`1630`), `payroll_avgift_rate_bp` (default `3142`, basis points).
-## 3. Schema v10
+## 3. Schema v10 (v11 adds the employee e-mail)
```sql
CREATE TABLE employees (
@@ -65,6 +75,7 @@ CREATE TABLE employees (
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;
@@ -129,7 +140,9 @@ CREATE TABLE tax_table_meta (
```
`vouchers.source` allows `'payroll'` and `'payroll_tax'` (widened CHECK in
-v10; the same table rebuild as v9).
+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
@@ -202,10 +215,18 @@ the base is the gross.
## 6. Documents and TUI
-- **Lönebesked** (wave 2): one PDF per employee and run, stored as an
- attachment and mailable with the existing SMTP path. Layout: employer,
- employee (personnummer masked except the last four), period, pay date,
- gross, tax, net, vacation note omitted, payment account.
+- **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** (new section after Fakturering): `Lönekörningar` (list,
Ctrl+N for a new run), `Anställda` under Register, and a **Skattetabeller**
action/status that fetches and warns.
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 9a2a3c4..ebce8bf 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -111,7 +111,7 @@ Scopes on a token can narrow but never widen the user's role.
| `bank.import`, `bank.match`, `bank.unmatch` | | ● | ● | |
| `sie.import`, `account.create`, `account.update` | | ● | ● | |
| `period.lock`, `fiscal_year.open/close/reopen`, `org.update` | | | ● | |
-| `employee.create/update/archive`, `payroll.run_post`, `payroll.pay_tax`, `payroll.settings_set` | | ● | ● | |
+| `employee.create/update/archive`, `payroll.run_post`, `payroll.pay_tax`, `payroll.payslip_mail`, `payroll.settings_set` | | ● | ● | |
| `payroll.agi` (decrypted personnummer), `payroll.tax_tables_fetch/import` | | | ● | |
| `org.member_*`, `token.create` for others | | | ● | |
| `user.create`, any org | | | | ● |
@@ -659,13 +659,15 @@ missing, reads return an all-masked `********` instead of failing.
|---|---|---|
| `employee.list` | `active_only?` | `items[]` ordered by name, personnummer masked |
| `employee.get` | `id` | one employee, personnummer masked |
-| `employee.create` | `name`, `personal_no`, `address?`, `postal_code?`, `city?`, `bank_account?`, `salary_account?`, `monthly_salary_ore?`, `tax_table?`, `tax_column?` | the employee |
+| `employee.create` | `name`, `personal_no`, `address?`, `postal_code?`, `city?`, `bank_account?`, `email?`, `salary_account?`, `monthly_salary_ore?`, `tax_table?`, `tax_column?` | the employee |
| `employee.update` | `id` plus any field (merged) | the effective employee |
| `employee.archive` | `id`, `active` | `id`, `active` |
`salary_account` (digits only) defaults to the `payroll_salary_account`
setting (default `7210`), `tax_table` is 29–42 and `tax_column` is 1–6.
-Creating a second employee with the same personnummer is a `CONFLICT`. All
+`email` (at most 254 characters, no control characters) is the recipient for
+`payroll.payslip_mail`. Creating a second employee with the same
+personnummer is a `CONFLICT`. All
mutations support `dry_run` and are audited (`employee.create`,
`employee.update`, `employee.archive`); the audit entry redacts the
personnummer.
@@ -681,6 +683,8 @@ personnummer.
| `payroll.run_post` | `period`, `pay_date`, `dry_run?` | `id`, `voucher_id`, totals; posts the run voucher |
| `payroll.run_list` | `limit?` | runs, newest period first |
| `payroll.run_get` | `id` | run with `lines[]` |
+| `payroll.payslip` | `run_id`, `employee_id?` | one employee's payslip PDF as `content_base64`, `filename` |
+| `payroll.payslip_mail` | `run_id`, `employee_id?`, `to?` | sends the payslip; `sent_to`, `at`, `attachment_id`, `filename` |
| `payroll.agi` | `period` | per-employee AGI underlag incl. the personnummer in clear (owner) |
| `payroll.pay_tax` | `run_id`, `date?`, `dry_run?` | payment voucher; marks the run `paid` |
| `payroll.settings_get` | — | effective payroll settings (defaults included) |
@@ -726,6 +730,28 @@ only command that reveals the number. `payroll.pay_tax` books
given date (default: today) and sets the run's `status` to `paid` with
`payment_voucher_id`; paying twice is a `CONFLICT`.
+`payroll.payslip` renders one A4 lönebesked for an employee line of a posted
+run: the employer header and footer, the employee name, the masked
+personnummer (`********-1234`, or `********` when the key is unavailable),
+period, pay date, tax table and column, then Bruttolön, the negative
+Preliminärskatt, a rule and Nettolön, and the note `Arbetsgivaravgifter
+<rate> % betalas av arbetsgivaren.` (from `payroll_avgift_rate_bp`). The
+personnummer is never written in clear. `employee_id` may be omitted only
+when the run has a single line; an unknown run, employee or line is
+`NOT_FOUND`. Nothing is stored.
+
+`payroll.payslip_mail` renders the same PDF, stores it as an
+`application/pdf` attachment named `Lönebesked <period> <name>.pdf`, links
+it to the run's voucher with `voucher_attachments` (the run must have one;
+otherwise `INTERNAL`) and mails it with subject `Lönebesked <period>` and a
+short Swedish body with the net amount. The recipient is `to`, or the
+employee's `email`; an empty address is `INVALID_ARGS`. SMTP configuration
+follows `invoice.send`; missing configuration is `SMTP_NOT_CONFIGURED` and a
+failed delivery `SMTP_FAILED` with the stored attachment left linked. A
+successful send is audited as `payroll.payslip_mail`. `dry_run` checks the
+recipient and the configuration, renders the PDF and returns the recipient
+and file name without storing or sending anything.
+
The settings `payroll_salary_account` (`7210`, the default for new
employees), `payroll_tax_account` (`2710`), `payroll_avgift_account`
(`7510`), `payroll_avgift_liability` (`2731`),
@@ -832,8 +858,8 @@ Args: `name:type(values)[!][=default]`, `!` = required.
| `invoice.send` | bookkeeper | yes | yes | yes | `id:int!`, `to:string` |
| `employee.list` | viewer | yes | no | no | `active_only:bool` |
| `employee.get` | viewer | yes | no | no | `id:int!` |
-| `employee.create` | bookkeeper | yes | yes | yes | `name:string!`, `personal_no:string!`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `salary_account:string`, `monthly_salary_ore:int=0`, `tax_table:int=30`, `tax_column:int=1` |
-| `employee.update` | bookkeeper | yes | yes | yes | `id:int!`, `name:string`, `personal_no:string`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `salary_account:string`, `monthly_salary_ore:int`, `tax_table:int`, `tax_column:int`, `active:bool` |
+| `employee.create` | bookkeeper | yes | yes | yes | `name:string!`, `personal_no:string!`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `email:string`, `salary_account:string`, `monthly_salary_ore:int=0`, `tax_table:int=30`, `tax_column:int=1` |
+| `employee.update` | bookkeeper | yes | yes | yes | `id:int!`, `name:string`, `personal_no:string`, `address:string`, `postal_code:string`, `city:string`, `bank_account:string`, `email:string`, `salary_account:string`, `monthly_salary_ore:int`, `tax_table:int`, `tax_column:int`, `active:bool` |
| `employee.archive` | bookkeeper | yes | yes | yes | `id:int!`, `active:bool!` |
| `payroll.tax_tables_fetch` | owner | yes | yes | yes | `year:int` |
| `payroll.tax_tables_import` | owner | yes | yes | yes | `year:int!`, `content_base64:string!` |
@@ -842,6 +868,8 @@ Args: `name:type(values)[!][=default]`, `!` = required.
| `payroll.run_post` | bookkeeper | yes | yes | yes | `period:string!`, `pay_date:date!` |
| `payroll.run_list` | viewer | yes | no | no | `limit:int=100` |
| `payroll.run_get` | viewer | yes | no | no | `id:int!` |
+| `payroll.payslip` | viewer | yes | no | no | `run_id:int!`, `employee_id:int` |
+| `payroll.payslip_mail` | bookkeeper | yes | yes | yes | `run_id:int!`, `employee_id:int`, `to:string` |
| `payroll.agi` | owner | yes | no | no | `period:string!` |
| `payroll.pay_tax` | bookkeeper | yes | yes | yes | `run_id:int!`, `date:date` |
| `payroll.settings_get` | viewer | yes | no | no | — |
diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md
index f2e93ac..9818e1c 100644
--- a/docs/SCHEMA.md
+++ b/docs/SCHEMA.md
@@ -560,11 +560,12 @@ another voucher is posted in between) — clients must not persist it.
## 12. Migrations and versioning
- `meta(key TEXT PRIMARY KEY, value TEXT)` holds `schema_version` (integer)
- and `created_at`. Current version: **10** (v10 adds the payroll tables and
- the `payroll`/`payroll_tax` voucher sources, v9 adds the invoicing tables
- and `invoice`, v8 the two bank reconciliation tables, v7 makes attachments
- append-only, v3 replaces the seeded moms rules with the corrected mapping;
- v2 adds the two template tables).
+ and `created_at`. Current version: **11** (v11 adds the employee e-mail,
+ v10 adds the payroll tables and the `payroll`/`payroll_tax` voucher
+ sources, v9 adds the invoicing tables and `invoice`, v8 the two bank
+ reconciliation tables, v7 makes attachments append-only, v3 replaces the
+ seeded moms rules with the corrected mapping; v2 adds the two template
+ tables).
- Migrations are forward-only, applied automatically at daemon start, each in
one transaction. Before the first migration statement a consistent
`VACUUM INTO` snapshot is written to
@@ -599,7 +600,7 @@ change afterwards. The DDL and field semantics are in `docs/INVOICING.md`
rebuilding the table in the v9 migration (foreign keys are disabled for the
migration and `PRAGMA foreign_key_check` runs before they are re-enabled).
-## 15. Payroll (schema v10)
+## 15. Payroll (schema v10, employees.e-mail v11)
The employee register and the monthly runs. `personal_no_enc` holds the
AES-256-GCM envelope (`enc:v1:<nonce>:<ciphertext>`, `src/secret.c`) under
@@ -609,7 +610,9 @@ money is in the immutable voucher of each run. `vouchers.source` gained
`'payroll'` (the monthly run) and `'payroll_tax'` (the payment to the tax
account); widening that CHECK required rebuilding the table in the v10
migration, exactly like v9 (foreign keys are disabled for the migration and
-`PRAGMA foreign_key_check` runs before they are re-enabled).
+`PRAGMA foreign_key_check` runs before they are re-enabled). v11 adds the
+employee `email` (`ALTER TABLE`, fresh databases already carry it) used as
+the default lönebesked recipient.
```sql
CREATE TABLE employees (
@@ -628,6 +631,7 @@ CREATE TABLE employees (
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT,
+ email TEXT NOT NULL DEFAULT '',
UNIQUE (org_id, id)
) STRICT;