summaryrefslogtreecommitdiff
path: root/docs/PROTOCOL.md
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/PROTOCOL.md
parente692f0fbe16342195297048534ad1be227493107 (diff)
downloadbokf-4488b6c2eb1a3ddf20e190ebd28953e4f3f01042.tar.gz
bokf-4488b6c2eb1a3ddf20e190ebd28953e4f3f01042.zip
payroll: lönebesked PDF and mail (schema v11)
Diffstat (limited to 'docs/PROTOCOL.md')
-rw-r--r--docs/PROTOCOL.md38
1 files changed, 33 insertions, 5 deletions
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 | — |