Files
accounted/skills/accounted-api/references/salary-runs.md
T
MattssonandClaude Fable 5 f43a6653f1 feat(salary): update_salary_run MCP tool and editable draft payment date (#2041)
* feat(salary): update_salary_run MCP tool and editable draft payment date

payment_date drives the booking entry date but was only editable via the
v1 PATCH. Close the gap on both remaining surfaces:

- New staged MCP write tool gnubok_update_salary_run (search-only
  catalog; tools/list budget is at zero headroom) accepting the exact
  v1 PATCH field set: payment_date, voucher_series, notes. Draft-only
  with the same optimistic lock semantics, via a new shared service
  lib/salary/update-run.ts used by both the staging preflight and the
  commit executor.
- Run header UI: payment date on a draft run is now an inline date
  input (prefilled, committed on blur/Enter, snaps back on failure),
  saved through the existing internal PATCH. Read-only once not draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): op-type migration, calc invalidation on date change, scanner compliance

Consolidated CI + review fix pass for #2041:

- pg-real: add 'update_salary_run' to pending_operations_operation_type_check
  (wholesale re-create, NOT VALID + VALIDATE pair, mirroring 20260828160000/1).
- Swedish accounting review: a payment_date change on a draft run now clears
  every roster row's calculation_breakdown (shared service and internal PATCH
  alike), so both book preflights refuse the run until a recalculation has run
  against the new date; skatteavdrag and the AGI redovisningsperiod follow the
  payment month. Staging preview exposes invalidates_calculation and the next
  hint states the clearing.
- no-phantom-columns: literal select strings in update-run.ts; ceiling +1 with
  a documented reason for the inherent patch-shaped UPDATE payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): close skeptic findings on payment_date editing

Skeptic round 1 refuted two paths; both closed:

- Retry idempotency (correctness): the calculation_breakdown clear was
  gated on new-date-differs-from-stored, so a retry after a partial
  failure (header committed, clear failed) compared against the already
  updated date and skipped the clear forever, leaving a stale
  calculation bookable. The clear is now gated on payment_date being
  SUPPLIED, on all three surfaces (shared service, internal PATCH, v1
  PATCH: the v1 route previously had no clear at all and bypassed the
  invariant).
- Kontantprincipen (compliance): AGI derives its redovisningsperiod
  from period_year/period_month while the verifikat books on
  payment_date, so a cross-month payment_date change could book salary
  in one month and declare it in another. All three edit surfaces now
  refuse a payment_date outside the run's period month with the new
  structured error SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD; the UI date
  input is min/max-bounded to the period month.
- The internal PATCH update is now optimistic-locked on status='draft'
  (races return 400 instead of silently writing), matching the v1 PATCH
  and the shared service, and the clear cannot fire for a run that left
  draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): carry book_skattekonto op types through the constraint re-create

The sibling migration 20260830130000 (merged from main) re-created
pending_operations_operation_type_check with book_skattekonto_row and
book_skattekonto_rows. This branch's 20260830150000 sorts after it and
re-creates the constraint wholesale, so its list must be that migration's
superset or the two values would be silently revoked at apply time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): value-validate internal PATCH and grandfather out-of-period dates

Two skeptic follow-ups:

- The internal PATCH now validates values, not just keys: JSON body must
  be an object, payment_date must be ISO (shared ISO_DATE_RE),
  voucher_series a single A-Z letter, notes a string of max 2000 chars
  or null: the same rules as the v1 UpdateSalaryRunSchema, so nothing
  unvalidated can reach the DB through the whitelist.
- Creation does not (yet) couple payment_date to the period month, so a
  legally created out-of-period date must stay correctable. All three
  edit surfaces now allow day adjustments within the run's CURRENT
  payment month as well as the period month (grandfather clause); no
  move can introduce a new wrong month.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): resolve migration version collision with delete_draft_invoice

Main's delete_draft_invoice PR landed on the same 20260830150000/150001
versions and also re-creates pending_operations_operation_type_check.
Rename this branch's pair to 20260830160000/160001 (applies last) and
carry delete_draft_invoice through the wholesale re-create so nothing is
silently revoked. Final list = sibling's list + update_salary_run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* docs(salary): regenerate accounted-api skill for the new PATCH pitfalls

apiskill:check byte-compares the generated skill against the registry;
the two pitfalls added to the v1 salary-runs PATCH endpoint made
references/salary-runs.md stale and failed Core Build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:43:23 +02:00

1274 lines
46 KiB
Markdown

<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
# Salary runs endpoints
Swedish payroll runs: create -> calculate -> approve -> book/mark-paid -> generate-agi (arbetsgivardeklaration), with per-employee payslips and draft-only line edits.
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors)
are in SKILL.md and are not repeated per endpoint.
### `GET /api/v1/companies/{companyId}/salary-runs`
**List salary runs.**
`scope:payroll:read · risk:low · idempotent`
Returns salary runs in created-first order with their lifecycle status (draft|review|approved|paid|booked|corrected) and denormalised totals. Filters: ?period_year=YYYY, ?status=draft.
**Use when:** You need an overview of payroll activity: for building a list view, finding the current open run, or resolving a salary_run_id before invoking a lifecycle verb.
**Do not use for:** Per-employee details (those live on the detail endpoint). Salary journal report (use GET /reports/salary-journal in Phase 5 PR-3).
**Pitfalls:**
- A company has at most one salary run per (period_year, period_month). The unique constraint is at the DB layer.
- Totals are denormalised: they are 0 until POST /calculate runs.
- `corrected` status is reached via the internal /correct route (not yet exposed on v1): Phase 5 PR-1 ships create/calculate/approve/mark-paid/book/generate-agi only.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
Response `200`:
```ts
{
data: { id: string, period_year: number, period_month: number, payment_date: string, status: "draft" | "review" | "approved" | "paid" | "booked" | "corrected", voucher_series: string, total_gross: number, total_tax: number, total_net: number, total_avgifter: number, total_employer_cost: number, agi_generated_at: string, agi_submitted_at: string, approved_at: string, paid_at: string, booked_at: string, created_at: string }[],
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": [
{
"id": "run_a8f1…",
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"status": "draft",
"voucher_series": "A",
"total_gross": 0,
"total_tax": 0,
"total_net": 0,
"total_avgifter": 0,
"total_employer_cost": 0
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs`
**Create a salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run · reversible`
Creates a draft salary run for the given period (period_year, period_month). The run starts empty: add employees via the internal /salary/runs/{id}/employees endpoints, then POST /salary-runs/{id}/calculate. Requires Idempotency-Key. Dry-runnable.
**Use when:** You are starting a new month's payroll. Use dry-run first to validate the period + voucher_series choice without committing.
**Do not use for:** Adding employees to an existing run (that is a separate surface: see internal /salary/runs/{id}/employees for Phase 5 PR-1; promoting it to v1 is deferred to a follow-up).
**Pitfalls:**
- Idempotency-Key is mandatory.
- Duplicate (period_year, period_month) for the same company returns 409 SALARY_RUN_DUPLICATE_PERIOD.
- period_month is 1-12. The DB CHECK enforces this: a 0 or 13 returns 400 VALIDATION_ERROR before reaching the DB.
- voucher_series defaults to "A". If the company uses a dedicated salary voucher series, set it explicitly.
- A newly-created run has no employees: :calculate without employees returns 400 SALARY_RUN_NO_EMPLOYEES.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
Request body:
```ts
{
period_year: number,
period_month: number,
payment_date: string,
voucher_series?: string,
notes?: string
}
```
Example request:
```json
{
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"voucher_series": "L"
}
```
Response `200`:
```ts
{
data: {
id: string,
period_year: number,
period_month: number,
payment_date: string,
status: "draft" | "review" | "approved" | "paid" | "booked" | "corrected",
voucher_series: string,
total_gross: number,
total_tax: number,
total_net: number,
total_avgifter: number,
total_employer_cost: number,
agi_generated_at: string,
agi_submitted_at: string,
approved_at: string,
paid_at: string,
booked_at: string,
created_at: string,
notes: string,
calculation_params?: unknown,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"status": "draft",
"voucher_series": "L"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/salary-runs/{id}`
**Get a salary run.**
`scope:payroll:read · risk:low · idempotent`
Returns the salary run's lifecycle state, denormalised totals (gross/tax/net/avgifter/vacation/employer_cost), and references to the journal entries it produced (once :book has run).
**Use when:** You have a salary_run_id and need its current status: typically to decide which lifecycle verb to call next, or to display the run header in a UI.
**Do not use for:** Per-employee breakdown: use GET /salary-runs/{id}/employees (list) or /salary-runs/{id}/employees/{employeeId} (payslip detail). Salary journal report: use GET /reports/salary-journal.
**Pitfalls:**
- salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table.
- total_* fields are 0 until POST /calculate has run.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: {
id: string,
period_year: number,
period_month: number,
payment_date: string,
status: "draft" | "review" | "approved" | "paid" | "booked" | "corrected",
voucher_series: string,
total_gross: number,
total_tax: number,
total_net: number,
total_avgifter: number,
total_vacation_accrual: number,
total_employer_cost: number,
salary_entry_id: string,
avgifter_entry_id: string,
vacation_entry_id: string,
agi_generated_at: string,
agi_submitted_at: string,
calculation_params?: unknown,
approved_by: string,
approved_at: string,
paid_at: string,
booked_at: string,
booked_by: string,
notes: string,
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"period_year": 2026,
"period_month": 5,
"payment_date": "2026-05-25",
"status": "approved",
"total_gross": 105000,
"total_tax": -28500,
"total_net": 76500,
"total_avgifter": 32991,
"total_employer_cost": 137991
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/salary-runs/{id}`
**Update a draft salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run`
Updates payment_date, voucher_series, or notes on a draft salary run. ONLY allowed when status === "draft": once :calculate has advanced the run to review, these fields are frozen because they feed into the verifikation that :book will eventually post.
**Use when:** You created a draft, then noticed payment_date should be different (e.g. moved from the 25th to the 23rd) before running :calculate.
**Do not use for:** Changing period_year / period_month (immutable: DELETE the draft and create a new one). Modifying employees in the run (not in v1 PR-1 scope).
**Pitfalls:**
- Returns 400 SALARY_RUN_PATCH_NOT_DRAFT if status !== "draft".
- period_year + period_month are immutable post-create.
- payment_date must stay within the run's period month (400 SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD otherwise): the AGI is declared per payment month. A run whose current payment date already sits outside the period month may still be day-adjusted within that same month.
- Supplying payment_date clears every roster row's calculation_breakdown, so an already-calculated run must be recalculated before :approve/:book.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Request body:
```ts
{ payment_date?: string, voucher_series?: string, notes?: string }
```
Example request:
```json
{
"payment_date": "2026-05-23"
}
```
Response `200`:
```ts
{
data: {
id: string,
period_year: number,
period_month: number,
payment_date: string,
status: "draft" | "review" | "approved" | "paid" | "booked" | "corrected",
voucher_series: string,
total_gross: number,
total_tax: number,
total_net: number,
total_avgifter: number,
total_vacation_accrual: number,
total_employer_cost: number,
salary_entry_id: string,
avgifter_entry_id: string,
vacation_entry_id: string,
agi_generated_at: string,
agi_submitted_at: string,
calculation_params?: unknown,
approved_by: string,
approved_at: string,
paid_at: string,
booked_at: string,
booked_by: string,
notes: string,
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"id": "run_…",
"payment_date": "2026-05-23",
"status": "draft"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}`
**Delete a draft salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run`
Hard-deletes a salary run. ONLY allowed when status === "draft": once the run has calculated numbers or posted a verifikation, BFL 5 kap immutability applies and storno is the only correction path. CASCADE deletes salary_run_employees and salary_line_items.
**Use when:** You created a run by mistake or want to recreate it with different period_month. Only draft runs can be deleted.
**Do not use for:** Reverting a booked run (use the internal /correct flow; v1 promotion deferred). Hiding a run from listings (no soft-delete on this table: drafts are truly removed).
**Pitfalls:**
- Returns 400 SALARY_RUN_DELETE_NOT_DRAFT for any status other than draft.
- Hard delete: the salary_run_employees + salary_line_items rows cascade away.
- Idempotent in the absent-row sense: DELETE on a non-existent id returns 404 SALARY_RUN_NOT_FOUND rather than re-emitting a deletion event.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `204`.
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/approve`
**Approve a reviewed salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run`
Advances a salary run from `review` to `approved` after validating every employee has the data required for the payment step (bank account + clearing number for the bank transfer) and the booking step (`calculation_breakdown` proves `:calculate` ran). Records the approving user + timestamp. Strict-mode: validation errors return a complete list rather than failing on the first one.
**Use when:** You have a salary run in `review` status and want to authorize it for payment. This is the human (or agent) signoff step before money moves; the verifikation is still pending and won't exist until `:book` runs.
**Do not use for:** Posting journal entries (use `:book` after `:mark-paid`). Reverting an approval (the lifecycle has no `:unapprove`: call `:correct` once the run is booked if you need to undo).
**Pitfalls:**
- Run must be in `review`: non-`review` runs return 400 SALARY_RUN_APPROVE_NOT_REVIEW.
- Every employee on the run needs a `clearing_number` + `bank_account_number`. Missing bank details return 400 SALARY_RUN_APPROVE_VALIDATION_FAILED with the per-employee list.
- Every employee on the run needs `calculation_breakdown` populated. If you skipped `:calculate` somehow, approve fails.
- Employees without email get a non-blocking warning (lönebesked can't be sent automatically).
- No period-lock check here: that lives on `:book` where the verifikation is posted. An agent can approve a run whose payment date falls in a now-locked period; `:book` will later refuse.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: { id: string, status: "approved", approved_at: string, approved_by: string, warnings: string[] },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"status": "approved",
"approved_at": "2026-05-14T12:00:00Z",
"approved_by": "user_b73c…",
"warnings": [
"Anna Andersson: E-post saknas, lönebesked kan inte skickas"
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/book`
**Post the verifikationer for a paid salary run.**
`scope:payroll:write · risk:high · idempotent · dry-run`
Creates 2-4 journal entries (1: salary brutto/tax/net; 2: arbetsgivaravgifter; 3 if applicable: semesterlöneskuld accrual; 4 if applicable: pension + SLP from löneväxling), then advances status `paid` → `booked` with all the entry IDs recorded on the salary_runs row. Strict-mode: any engine failure aborts BEFORE the status flip: the run stays in `paid` so the caller can fix the cause (locked period, missing BAS account, etc.) and retry.
**Use when:** You've marked a salary run as paid and want to post the BFL-required verifikationer. This is the final lifecycle verb before AGI generation; after :book, the run can no longer be edited and corrections must use the (forthcoming) `:correct` verb.
**Do not use for:** Posting salary entries outside the salary-run lifecycle (use POST /journal-entries directly). Re-booking an already-booked run (returns 400 SALARY_RUN_BOOK_NOT_PAID).
**Pitfalls:**
- Run must be in `paid`: non-`paid` runs return 400 SALARY_RUN_BOOK_NOT_PAID.
- payment_date must fall in an open fiscal period: locked period returns 400 PERIOD_LOCKED with `fiscal_period_id` and a hint of what unlock action is needed.
- BFL 5 kap immutability: once `:book` succeeds the verifikationer cannot be edited or deleted. Corrections require `:correct` (Phase 5 PR-3) which does a storno-then-rebook.
- The salary verifikation is the primary one; its voucher_number appears in the response audit block. The avgifter, vacation, and pension entries get separate voucher numbers (returned as `entry_ids`).
- Strict-mode: if the engine fails partway, the salary_runs row stays in `paid`. There is no "partial booking": the engine either commits all entries or the entire booking fails.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: {
id: string,
status: "booked",
booked_at: string,
booked_by: string,
salary_entry_id: string,
avgifter_entry_id: string,
vacation_entry_id: string,
pension_entry_id: string,
entry_ids: string[]
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"status": "booked",
"booked_at": "2026-05-26T09:15:00Z",
"booked_by": "user_b73c…",
"salary_entry_id": "je_salary…",
"avgifter_entry_id": "je_avg…",
"vacation_entry_id": "je_vac…",
"pension_entry_id": null,
"entry_ids": [
"je_salary…",
"je_avg…",
"je_vac…"
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"audit": {
"voucher_number": "L2026-0023",
"voucher_url": "/api/v1/companies/.../journal-entries/je_salary…",
"immutable_at": "2026-05-26T09:15:00Z"
}
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate`
**Calculate a draft salary run and advance it to review.**
`scope:payroll:write · risk:medium · idempotent · dry-run`
Runs the per-employee payroll calculation (tax withholding, employer contributions, vacation accrual) for every employee on a draft run, persists the line items + run totals + calculation_params snapshot, then promotes status from draft to review in a single atomic verb. Returns the updated run plus a `warnings` array surfacing non-blocking issues (Skatteverket tax-table fallback, läkarintyg day-8 transition, Försäkringskassan day-15 transition, F-skatt not-verified employees). Strict-mode: any failure (validation, tax-table unavailable, DB error) aborts before the status flip: the run stays in draft.
**Use when:** You have a draft salary run with employees added and want to compute the numbers + freeze them for approval. This is the first lifecycle verb after creating a run.
**Do not use for:** re-running a salary run already in review or later (only `draft` is accepted: call POST :correct in Phase 5 PR-3 once that ships to revise a booked run). Adding employees to the run (that surface is not yet on v1; use the dashboard).
**Pitfalls:**
- Run must be in `draft` status: calculate on a non-draft run returns 400 SALARY_RUN_CALCULATE_NOT_DRAFT.
- Salary run must have at least one employee: empty runs return 400 SALARY_RUN_NO_EMPLOYEES.
- If Skatteverket's tax-table API is down and local fallback is missing the required table, calculate returns 503 SALARY_RUN_TAX_TABLE_MISSING. Retry is safe; the operation is idempotent at the helper level.
- F-skatt "not_verified" employees produce a non-blocking warning; an integrator should treat the warning as a hard signal that withholding will be wrong until F-skatt is verified.
- Warnings about tax-table fallback or läkarintyg / FK day-15 transitions are non-blocking; the run still advances to review. Surface them to a human reviewer before calling :approve.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: {
id: string,
status: "review",
period_year: number,
period_month: number,
total_gross: number,
total_tax: number,
total_net: number,
total_avgifter: number,
total_employer_cost: number,
warnings: string[]
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"status": "review",
"period_year": 2026,
"period_month": 5,
"total_gross": 105000,
"total_tax": 28500,
"total_net": 76500,
"total_avgifter": 32991,
"total_employer_cost": 137991,
"warnings": [
"Läkarintyg krävs från och med dag 8: Anna Andersson. Kontrollera att läkarintyg finns innan lönekörningen godkänns."
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/salary-runs/{id}/employees`
**List per-employee results of a salary run.**
`scope:payroll:read · risk:low · idempotent`
Returns one row per employee in the run with the calculated aggregates: gross salary, tax withheld, net pay, arbetsgivaravgifter, vacation accrual, and absence day counts. All aggregate fields are 0 until POST /calculate has run. Cursor pagination on (created_at, id).
**Use when:** You need the per-employee outcome of a run: to review before approval, to reconcile against an external system, or to pick an employee_id for the payslip drill-in.
**Do not use for:** Payslip line items or the step-by-step calculation breakdown: use GET /salary-runs/{id}/employees/{employeeId}. The employee master record: use GET /employees/{id}.
**Pitfalls:**
- Aggregates are 0 until POST /calculate has advanced the run to review.
- tax_withheld_override / avgifter_amount_override are review-stage manual adjustments; the effective value is COALESCE(override, calculated).
- personnummer is masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee detail endpoint returns the full value.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: { salary_run_employee_id: string, employee_id: string, first_name: string, last_name: string, personnummer_masked: string, salary_type: string, employment_degree: number, monthly_salary: number, hours_worked: number, gross_salary: number, taxable_income: number, tax_withheld: number, tax_withheld_override: number, net_salary: number, avgifter_basis: number, avgifter_amount: number, avgifter_amount_override: number, avgifter_category: string, vacation_accrual: number, sick_days: number, vab_days: number, parental_days: number, vacation_days_taken: number, created_at: string, updated_at: string }[],
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": [
{
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"salary_type": "monthly",
"gross_salary": 35000,
"tax_withheld": -8200,
"net_salary": 26800,
"avgifter_amount": 10997
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/employees`
**Add an employee to a draft salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run · reversible`
Attaches an active employee to a draft run: snapshots their pay configuration (salary, degree, tax table) onto the run and seeds the base salary line (Grundlön/Timlön). For hourly employees, pass hours_worked.
**Use when:** The run was created without this employee (e.g. hired after the run was drafted), or you create runs empty and attach employees one by one from an external system.
**Do not use for:** Changing an attached employee's pay for this month (internal per-run PATCH; not on v1). Re-attaching after removal is fine: the snapshot is retaken.
**Pitfalls:**
- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.
- Attaching twice returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE.
- The snapshot freezes salary/degree/tax-table at attach time: later employee edits do not flow into this run.
- Inactive (soft-deleted) employees cannot be attached: 404 EMPLOYEE_NOT_FOUND.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Request body:
```ts
{ employee_id: string, hours_worked?: number }
```
Example request:
```json
{
"employee_id": "emp_77b2…"
}
```
Response `200`:
```ts
{
data: {
salary_run_employee_id: string,
employee_id: string,
salary_type: string,
employment_degree: number,
monthly_salary: number,
hours_worked: number,
tax_table_number: number,
tax_column: number
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"salary_type": "monthly",
"monthly_salary": 35000
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}`
**Get one employee's payslip in a salary run.**
`scope:payroll:read · risk:low · idempotent`
Returns the full payslip for one employee in a run: gross/tax/net aggregates, arbetsgivaravgifter with category, vacation accrual, YTD accumulators, every payslip line item (grundlön, tillägg, avdrag, förmåner), and the step-by-step calculation_breakdown recorded by the engine.
**Use when:** You need to verify how a specific employee's pay was computed: reviewing a run before approval, answering "why is the tax this amount", or rendering a payslip in an external system.
**Do not use for:** The rendered PDF payslip: use GET /salary-runs/{id}/payslips/{employeeId}/pdf. Editing line items: POST/PATCH/DELETE on the lines endpoints.
**Pitfalls:**
- calculation_breakdown is null and aggregates are 0 until POST /calculate has run.
- line_items include engine-derived rows (absence, benefits) that are regenerated on every :calculate; manual rows survive recalculation.
- The effective tax is COALESCE(tax_withheld_override, tax_withheld); same for avgifter overrides.
- personnummer is masked here (GDPR Art.5(1)(c)); GET /employees/{id} is the identity drill-in.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
| `employeeId` | path | `string` | yes | |
Response `200`:
```ts
{
data: {
salary_run_employee_id: string,
salary_run_id: string,
employee_id: string,
first_name: string,
last_name: string,
personnummer_masked: string,
salary_type: string,
employment_degree: number,
monthly_salary: number,
hours_worked: number,
gross_salary: number,
gross_deductions: number,
benefit_values: number,
taxable_income: number,
tax_withheld: number,
tax_withheld_override: number,
net_deductions: number,
net_salary: number,
avgifter_rate: number,
avgifter_basis: number,
avgifter_amount: number,
avgifter_basis_override: number,
avgifter_amount_override: number,
avgifter_category: string,
override_reason: string,
vacation_accrual: number,
vacation_accrual_avgifter: number,
tax_table_number: number,
tax_column: number,
tax_table_year: number,
sick_days: number,
vab_days: number,
parental_days: number,
vacation_days_taken: number,
ytd_gross: number,
ytd_tax: number,
ytd_net: number,
calculation_breakdown?: unknown,
line_items: { salary_line_item_id: string, item_type: string, description: string, quantity: number, unit_price: number, amount: number, is_taxable: boolean, is_avgift_basis: boolean, is_vacation_basis: boolean, is_gross_deduction: boolean, is_net_deduction: boolean, account_number: string, sort_order: number }[],
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"first_name": "Anna",
"last_name": "Andersson",
"personnummer_masked": "YYYYMMDDXXXX",
"gross_salary": 35000,
"tax_withheld": -8200,
"net_salary": 26800,
"line_items": [
{
"salary_line_item_id": "sli_31c9…",
"item_type": "monthly_salary",
"description": "Grundlön",
"amount": 35000
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}`
**Set this run's base salary for one employee.**
`scope:payroll:write · risk:medium · idempotent · dry-run · reversible`
Sets the per-run base salary (salary_run_employees.monthly_salary) that the calculation engine reads for this run. The employee master record is untouched, so each month's gross can differ from the employee's standard pay (variable owner salary). Draft-only; 0 is a valid nollkörning.
**Use when:** The employee's pay this month differs from their configured fixed salary: owners taking salary by need and capacity, one-off adjustments, or a deliberate zero month.
**Do not use for:** Changing the employee's standard salary going forward: PATCH /employees/{id}. Editing individual payslip lines (tillägg/avdrag): the lines endpoints. Tax/avgifter overrides in review: not exposed on v1 yet.
**Pitfalls:**
- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.
- Run POST /calculate afterwards: gross, tax and totals reflect the new salary only after recalculation.
- Do NOT edit the monthly_salary line item instead: recalculation rebuilds base salary lines from this per-run value.
- For hourly employees the value is stored but gross derives from hours worked; the salary_type field in the response tells you which applies.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
| `employeeId` | path | `string` | yes | |
Request body:
```ts
{ monthly_salary: number }
```
Example request:
```json
{
"monthly_salary": 45000
}
```
Response `200`:
```ts
{
data: {
salary_run_employee_id: string,
employee_id: string,
salary_type: string,
employment_degree: number,
previous_monthly_salary: number,
monthly_salary: number
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"salary_run_employee_id": "sre_a8f1…",
"employee_id": "emp_77b2…",
"salary_type": "monthly",
"employment_degree": 100,
"previous_monthly_salary": 30000,
"monthly_salary": 45000
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}`
**Remove an employee from a draft salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run · reversible`
Detaches the employee from the run and cascades away their payslip line items. Draft-only. The employee master record is untouched: this only affects the run roster.
**Use when:** An employee should not be paid this period (unpaid leave the whole month, employment ended) but was auto-added when the run was created.
**Do not use for:** Deactivating the employee entirely: DELETE /employees/{id} (soft-delete). Zero-salary months: keep them in the run with a 0 base instead if you want a nollkörning on record.
**Pitfalls:**
- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.
- Cascade-deletes the employee's line items in this run, including manual ones.
- Re-attaching later retakes the pay snapshot from the employee master.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
| `employeeId` | path | `string` | yes | |
Response `204`.
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}/lines`
**Add a payslip line to an employee in a draft salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run · reversible`
Creates a salary_line_items row (bonus, overtime, gross/net deduction, benefit, traktamente, ...) for one employee in a draft run. account_number auto-resolves from item_type when omitted. Amounts are rounded to whole öre.
**Use when:** You need to add a one-off pay component before calculating: a bonus, an expense reimbursement, a union fee, or a manual correction line.
**Do not use for:** Editing the base monthly salary (PATCH the run-employee via the internal surface; not on v1 yet). Absence: register absence days instead (PUT /employees/{id}/absence); the engine derives sick/VAB lines itself.
**Pitfalls:**
- Draft-only: returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
- Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.
- Engine-derived lines (absence, benefits) are regenerated on every :calculate; manual lines survive.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
| `employeeId` | path | `string` | yes | |
Request body:
```ts
{
item_type: "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other",
description: string,
quantity?: number,
unit_price?: number,
amount: number,
is_taxable?: boolean,
is_avgift_basis?: boolean,
is_vacation_basis?: boolean,
is_gross_deduction?: boolean,
is_net_deduction?: boolean,
account_number?: string,
sort_order?: number
}
```
Example request:
```json
{
"item_type": "bonus",
"description": "Kvartalsbonus Q2",
"amount": 5000
}
```
Response `200`:
```ts
{
data: {
salary_line_item_id: string,
salary_run_employee_id: string,
item_type: string,
description: string,
quantity: number,
unit_price: number,
amount: number,
is_taxable: boolean,
is_avgift_basis: boolean,
is_vacation_basis: boolean,
is_gross_deduction: boolean,
is_net_deduction: boolean,
account_number: string,
sort_order: number
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"salary_line_item_id": "sli_31c9…",
"item_type": "bonus",
"description": "Kvartalsbonus Q2",
"amount": 5000,
"account_number": "7210"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/generate-agi`
**Generate the Skatteverket AGI XML for a salary run.**
`scope:payroll:write · risk:medium · idempotent`
Generates the arbetsgivardeklaration-på-individnivå XML for the run (HU section + per-employee IU + Frånvarouppgift for VAB/parental), upserts the agi_declarations row (correction-aware), stamps salary_runs.agi_generated_at, emits `agi.generated`, and auto-completes the `arbetsgivardeklaration` deadline. Returns the XML as a string field in the v1 envelope: agents extract `data.xml` and forward to Skatteverket directly (Mina Sidor upload or via a connected extension).
**Use when:** You've reviewed (or approved / paid / booked) a salary run and need to file AGI with Skatteverket. The Skatteverket filing deadline is the 12th of the following month (17th in Jan / Aug for companies ≤40 MSEK turnover).
**Do not use for:** Submitting the AGI to Skatteverket: this endpoint only generates and persists the XML. Submission is a separate flow via the (optional) `skatteverket` extension.
**Pitfalls:**
- Run status must be one of review, approved, paid, booked, corrected: `draft` returns 400 AGI_GENERATE_NOT_BOOKABLE.
- Generating AGI from a `review`-status run risks submitting figures that will change at `:approve`. The dashboard allows this for flexibility; agents should prefer `approved+` unless an early-warning workflow specifically wants the preview.
- Subsequent calls for the same period UPDATE the agi_declarations row (is_correction=true) and overwrite the XML. The FK570 specifikationsnummer stays consistent per employee: different number = new record per Skatteverket spec.
- AGI_INCOMPLETE_DATA returns 400 when company contact info is missing (org_number, contact name, phone, email). Fix via /settings/company before retrying.
- The XML content is räkenskapsinformation: BFL 7 kap retention applies. The agi_declarations row is never auto-deleted.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: {
agi_declaration_id: string,
period_year: number,
period_month: number,
employee_count: number,
is_correction: boolean,
totals: { totalTax: number, totalAvgifterBasis: number, totalAvgifterAmount: number, totalSjuklonekostnad: number, avgifterByCategory: Record<string, { basis: number, amount: number }> },
xml: string,
xml_filename: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"agi_declaration_id": "agi_a8f1…",
"period_year": 2026,
"period_month": 5,
"employee_count": 3,
"is_correction": false,
"totals": {
"totalTax": 28500,
"totalAvgifterBasis": 105000,
"totalAvgifterAmount": 32991,
"totalSjuklonekostnad": 0,
"avgifterByCategory": {
"standard": {
"basis": 105000,
"amount": 32991
}
}
},
"xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Skatteverket omrade=\"Arbetsgivardeklaration\">…</Skatteverket>",
"xml_filename": "AGI_5566778899_202605.xml"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `PATCH /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}`
**Update a payslip line in a draft salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run · reversible`
Updates fields on a salary_line_items row (amount, description, quantity, unit_price, flags, account_number) while the run is a draft. Amounts are rounded to whole öre.
**Use when:** You spotted a wrong amount or description on a manual line before calculating: fix it in place instead of delete + recreate.
**Do not use for:** Post-calculation tax/avgifter adjustments (review-stage overrides are not on v1). Engine-derived lines (absence/benefits): they are regenerated by :calculate, so edits are overwritten.
**Pitfalls:**
- Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
- A lineId that belongs to a different run returns 404 SALARY_LINE_NOT_FOUND.
- Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
| `lineId` | path | `string` | yes | |
Request body:
```ts
{
item_type?: "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other",
description?: string,
quantity?: number,
unit_price?: number,
amount?: number,
is_taxable?: boolean,
is_avgift_basis?: boolean,
is_vacation_basis?: boolean,
is_gross_deduction?: boolean,
is_net_deduction?: boolean,
account_number?: string,
sort_order?: number
}
```
Example request:
```json
{
"amount": 5500
}
```
Response `200`:
```ts
{
data: {
salary_line_item_id: string,
salary_run_employee_id: string,
item_type: string,
description: string,
quantity: number,
unit_price: number,
amount: number,
is_taxable: boolean,
is_avgift_basis: boolean,
is_vacation_basis: boolean,
is_gross_deduction: boolean,
is_net_deduction: boolean,
account_number: string,
sort_order: number
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"salary_line_item_id": "sli_31c9…",
"amount": 5500
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}`
**Delete a payslip line from a draft salary run.**
`scope:payroll:write · risk:low · idempotent · dry-run`
Removes a salary_line_items row while the run is a draft. Engine-derived lines (absence, benefits) reappear on the next :calculate; delete the underlying absence/benefit record instead.
**Use when:** A manual line (bonus, deduction) was added by mistake and the run has not been calculated/advanced yet.
**Do not use for:** Removing an employee from the run entirely: DELETE /salary-runs/{id}/employees/{employeeId}. Suppressing engine-derived lines: fix the source data (absence days, benefits).
**Pitfalls:**
- Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.
- Deleting an engine-derived line is futile: :calculate regenerates it from source data.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
| `lineId` | path | `string` | yes | |
Response `204`.
---
### `POST /api/v1/companies/{companyId}/salary-runs/{id}/mark-paid`
**Mark an approved salary run as paid.**
`scope:payroll:write · risk:low · idempotent · dry-run`
Advances a salary run from `approved` to `paid` and stamps `paid_at`. This is the state-change verb after the bank transfer (or autogiro file) has been processed; it does NOT initiate payment, and does NOT post journal entries (use `:book` after this for that).
**Use when:** You've confirmed the salary payment hit employee bank accounts and want to advance the run's lifecycle so `:book` can post the verifikation.
**Do not use for:** Initiating the actual bank transfer (the v1 API does not yet expose payment-file generation; use the dashboard's payment-file endpoints). Posting journal entries (use `:book`). Reverting a paid run (no `:unpaid` exists: call `:correct` once booked if you need to undo).
**Pitfalls:**
- Run must be in `approved`: non-`approved` runs return 400 SALARY_RUN_MARK_PAID_NOT_APPROVED.
- paid_at is set server-side to the current UTC timestamp; the API does not accept a body-supplied date to keep BFL audit clean.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: { id: string, status: "paid", paid_at: string },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"id": "run_a8f1…",
"status": "paid",
"paid_at": "2026-05-25T08:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf`
**Download one employee's payslip as PDF.**
`scope:payroll:read · risk:low · idempotent`
Returns the rendered payslip (lönespecifikation) as application/pdf, byte-equivalent to the dashboard download. Content-Disposition is attachment with a filename derived from the period and employee name.
**Use when:** You need the payslip document itself: archiving, forwarding to the employee outside the Accounted send flow, or attaching to an external HR system.
**Do not use for:** The payslip DATA (amounts, line items): use GET /salary-runs/{id}/employees/{employeeId}, which is cheaper and structured. Emailing payslips to employees: the send flow is internal-only today.
**Pitfalls:**
- The PDF renders whatever the run currently holds: for a draft run that has not been calculated, amounts are 0.
- PDF rendering takes a few hundred milliseconds; cache on the client if requesting repeatedly.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
| `employeeId` | path | `string` | yes | |
Response `200` (`application/pdf`).