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

46 KiB

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:

{
  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:

{
  "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:

{
  period_year: number,
  period_month: number,
  payment_date: string,
  voucher_series?: string,
  notes?: string
}

Example request:

{
  "period_year": 2026,
  "period_month": 5,
  "payment_date": "2026-05-25",
  "voucher_series": "L"
}

Response 200:

{
  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:

{
  "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:

{
  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:

{
  "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:

{ payment_date?: string, voucher_series?: string, notes?: string }

Example request:

{
  "payment_date": "2026-05-23"
}

Response 200:

{
  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:

{
  "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:

{
  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:

{
  "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:

{
  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:

{
  "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:

{
  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:

{
  "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:

{
  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:

{
  "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:

{ employee_id: string, hours_worked?: number }

Example request:

{
  "employee_id": "emp_77b2…"
}

Response 200:

{
  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:

{
  "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:

{
  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:

{
  "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:

{ monthly_salary: number }

Example request:

{
  "monthly_salary": 45000
}

Response 200:

{
  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:

{
  "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:

{
  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:

{
  "item_type": "bonus",
  "description": "Kvartalsbonus Q2",
  "amount": 5000
}

Response 200:

{
  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:

{
  "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:

{
  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:

{
  "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:

{
  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:

{
  "amount": 5500
}

Response 200:

{
  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:

{
  "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:

{
  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:

{
  "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).