aboutsummaryrefslogtreecommitdiff
path: root/docs/PAYROLL.md
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-21 08:50:11 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-21 08:50:11 +0200
commit5ba2cfc29a0f8d3628a0fa7de2d04e4e39edf69a (patch)
tree1842d0db64502c85d7dfcf826ba854f3c0bc3ae7 /docs/PAYROLL.md
parentfcbfd1c23ed91082fcbb77a7337567a1b9cedd91 (diff)
downloadbokf-5ba2cfc29a0f8d3628a0fa7de2d04e4e39edf69a.tar.gz
bokf-5ba2cfc29a0f8d3628a0fa7de2d04e4e39edf69a.zip
payroll: design chapter
Diffstat (limited to 'docs/PAYROLL.md')
-rw-r--r--docs/PAYROLL.md225
1 files changed, 225 insertions, 0 deletions
diff --git a/docs/PAYROLL.md b/docs/PAYROLL.md
new file mode 100644
index 0000000..ffb0eee
--- /dev/null
+++ b/docs/PAYROLL.md
@@ -0,0 +1,225 @@
+# 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.
+
+## 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.
+
+**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
+
+```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,
+ 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).
+
+## 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): 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.
+- **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.
+- The run screen shows the preview (F5), posts with Ctrl+Enter after
+ confirmation, then offers the buttons **Lönebesked**, **AGI-underlag** and
+ **Betala skatt & avgifter** as manual steps.
+
+## 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.