gnubok

Salary runs

Payroll lifecycle — create, calculate, approve, mark paid, book, generate AGI XML.

Endpoints


GET /api/v1/companies/:companyId/salary-runs {#get-salary-runs-list}

salary-runs.list · scope payroll:read

List salary runs.

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.

Don't 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.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Example response

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

GET /api/v1/companies/:companyId/salary-runs/:id {#get-salary-runs-get}

salary-runs.get · scope payroll:read

Get a salary run.

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.

Don't use for: Per-employee breakdown (Phase 5 PR-1 does not expose the per-employee endpoint on v1; use the internal /api/salary/runs/{id} for that today). Salary journal report — use GET /reports/salary-journal in Phase 5 PR-3.

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.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no

Example response

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

POST /api/v1/companies/:companyId/salary-runs {#post-salary-runs-create}

salary-runs.create · scope payroll:write

Create a salary run.

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.

Don't 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.

Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes

Example request

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

Example response

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

POST /api/v1/companies/:companyId/salary-runs/:id/approve {#post-salary-runs-approve}

salary-runs.approve · scope payroll:write

Approve a reviewed salary 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.

Don't 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.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Example response

{
  "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-salary-runs-book}

salary-runs.book · scope payroll:write

Post the verifikationer for a paid salary 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 paidbooked 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.

Don't 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.

Risk: high · Idempotent: yes · Reversible: no · Dry-run supported: yes

Example response

{
  "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 {#post-salary-runs-calculate}

salary-runs.calculate · scope payroll:write

Calculate a draft salary run and advance it to review.

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.

Don't 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.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: yes

Example response

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

POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi {#post-salary-runs-generate-agi}

salary-runs.generate-agi · scope payroll:write

Generate the Skatteverket AGI XML for a salary run.

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

Don't 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.

Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: no

Example response

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

POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid {#post-salary-runs-mark-paid}

salary-runs.mark-paid · scope payroll:write

Mark an approved salary run as paid.

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.

Don't 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.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Example response

{
  "data": {
    "id": "run_a8f1…",
    "status": "paid",
    "paid_at": "2026-05-25T08:00:00Z"
  },
  "meta": {
    "request_id": "req_…",
    "api_version": "2026-05-12"
  }
}

PATCH /api/v1/companies/:companyId/salary-runs/:id {#patch-salary-runs-update}

salary-runs.update · scope payroll:write

Update a draft salary 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.

Don't 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.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Example request

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

Example response

{
  "data": {
    "id": "run_…",
    "payment_date": "2026-05-23",
    "status": "draft"
  }
}

DELETE /api/v1/companies/:companyId/salary-runs/:id {#delete-salary-runs-delete}

salary-runs.delete · scope payroll:write

Delete a draft salary 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.

Don't 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.

Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: yes

Example response

{
  "data": null
}