---
title: "Delete Payroll Run"
method: DELETE
path: "/companies/{company_id}/payroll/runs/{run_id}"
tags: ["payroll"]
---

# Delete Payroll Run

`DELETE /companies/{company_id}/payroll/runs/{run_id}`

## Request

- Base URL: `https://app.zeno-works.ch` (the document declares no server; this is the origin it was published from)
- URL: `https://app.zeno-works.ch/companies/{company_id}/payroll/runs/{run_id}`
- Auth: none declared

Discard a ``draft`` or ``calculated`` run — 422 from ``posted`` onwards.

ZCT-540 — the one payroll entity with no reachable delete. ``runs_service``
has shipped :func:`delete_run` since SP6 (R5 iii) and the whole codebase
reasons about it — ZET-154's post gate exists to make "post N, then delete
N−1" impossible *by construction* — but no route ever exposed it, so no MCP
operation existed either (the tool surface mirrors this API 1:1). That left
the product telling the truth about the only two exits and offering neither:
a year-level operation refuses with *"N unposted run(s) overlap … — post or
delete them before issuing"* (:mod:`invoiage_core.payroll.reports.inclusion`
⇒ :func:`payroll.lohnausweis.service.issue`), while a draft created out of
period order can no longer be posted at all — ``ytd_basis_out_of_order``
correctly refuses to calculate it (ZET-153), and that guard is not the
defect. One abandoned draft therefore blocked Lohnausweis issuance for the
rest of the year, with the only escape being to reverse and redo every later
run.

**Nothing is orphaned.** ``delete_run`` releases the SP9 expense-claim feed
markers via the ``inputs_discarded`` hook, clears results, QST details, run
lines and the snapshot, and the two child tables the clear does not name
(``payroll_payslip``, ``payroll_payment_batch``) are ``ON DELETE CASCADE``
and cannot exist before ``posted`` anyway. **No posted figure can move**:
only ``draft``/``calculated`` runs are deletable, and ZET-154's
``_refuse_unfinalized_ytd_basis`` refuses to post a run while any
contributor to its year-to-date basis is still unposted, so a deletable run
is never a contributor to an immutable one.

Deliberately *not* the ticket's second suggestion (let year-level operations
ignore empty drafts): an unposted run genuinely does make a year incomplete,
the same partition drives the declarations and the reconciliation, and
"ignore it if it looks empty" would hide a run someone still means to
calculate. The exit the message already promises is the one that was missing.

**The deletion is audited.** ``delete_run`` appends a ``payroll_master_audit``
row (``entity_type="run"``, ``action=deleted``) inside the same transaction,
carrying who, when, which run and which period, plus the lifecycle stamps the
header held — ``approved → calculated → DELETE`` is reachable, so the run that
vanished may have been approved by someone (owner ruling 2026-09-03; this
replaces the interim ``log.info``, which is not what an audit reads). It is not
a GeBüV matter — a draft/calculated run has ``journal_entry_id IS NULL``, so
nothing was ever booked, unlike ZET-220's posted entry — but it is a payroll-audit
one, and this route is the first thing to make it reachable, over MCP included.

⚠️ The trail is written but not yet *readable over the API*: the only route over
``payroll_master_audit`` is ``GET …/employees/{employee_id}/audit``, and a run row
is not employee-scoped (``employee_id IS NULL``). It is a durable, queryable row
where there was none; a read surface for it is a separate ticket.

## Path parameters

- `company_id` string, required
- `run_id` string, required

## Headers

- `authorization` string, nullable

## Cookies

- `access_token` string, nullable

## Response `200`

Successful Response

- PayrollOkResult
  - `ok` boolean

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/zeno-works/apis/zeno-api.md) · [All operations](https://skmtc.dev/zeno-works/apis/zeno-api/llms.txt) · [OpenAPI document](https://skmtc.dev/zeno-works/apis/zeno-api/revisions/1eb93dde146c?raw)
