diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-21 09:56:01 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-21 09:56:01 +0200 |
| commit | 4488b6c2eb1a3ddf20e190ebd28953e4f3f01042 (patch) | |
| tree | b52812145147669d04f23a57d41b3f604104963f /docs | |
| parent | e692f0fbe16342195297048534ad1be227493107 (diff) | |
| download | bokf-4488b6c2eb1a3ddf20e190ebd28953e4f3f01042.tar.gz bokf-4488b6c2eb1a3ddf20e190ebd28953e4f3f01042.zip | |
payroll: lönebesked PDF and mail (schema v11)
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/PAYROLL.md | 35 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 38 | ||||
| -rw-r--r-- | docs/SCHEMA.md | 18 |
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; |
