# Monthly payroll cycles

A payroll cycle covers one tenant and one monthly period. The V1 journey supports one company
officer per tenant and the current month or M-1 for writes. Older revisions remain readable.
Approval and finalization do **not** initiate payment or file a DSN.
The new calculate, operation, abandon and document routes are currently closed by
`MANAGED_PAYROLL_V1_ENABLED` and return `404 payroll_journey_unavailable` until Verso
explicitly activates them. Existing cycle, variables, preview and approval routes remain served.

Use the Gateway paths below, prefixed with
`/partners/{partnerId}/tenants/{tenantId}/payroll-cycles`. The period uses `YYYY-MM`.
The API key must belong to the partner and tenant; a different partner or tenant cannot read or
change the cycle. Keep each response's `ETag` for the next write's `If-Match` header.

## Enter and calculate

1. `PUT /{period}` creates the cycle or returns its current revision.
2. `PUT /{period}/variables` with `If-Match` sends `remuneration` in euros (including zero) and
   `dateDeVersement` as an explicitly confirmed `YYYY-MM-DD` payment date. OpsService records the
   write intent before sending it to PayrollEngine; the engine decides whether the date and
   payroll input are admissible.
3. `POST /{period}/calculate` with `If-Match` and a stable `Idempotency-Key` returns `202 Accepted`
   and an operation URL. This starts one persisted **real** payroll calculation, not a forecast.
   Repeating the same key for the same request returns the same operation. Poll
   `GET /{period}/operations/{operationId}` until `completed`, `failed` or
   `reconcile_required`; `202` does not mean the calculation succeeded.
4. `GET /{period}` is the source of truth. A completed calculation exposes a `candidate` with
   `id`, job and result-set IDs, and a `result` for that exact candidate. The result contains
   `outcome`, `warnings`, `blockers` and `persons` (one person in V1). Each person has gross,
   employee and employer contributions, net deductions, net before tax, net social, PAS,
   net payable, employer cost and the native wage-type details. These amounts are copied from
   the engine's result set; missing or ambiguous native values are `null`. In FR.Salaire 2026,
   the classic bulletin builds net before tax from native net payable (WT 7025) plus native PAS
   (WT 5100, mirrored by WT 7005). Verso requires the two PAS values to agree before exposing
   this amount. Net social is the separate WT 6000. The `result` is absent until an exact
   candidate is ready.

The cycle states are `collecting`, `preview_ready`, `approved`, `finalized`, `filed`, `accepted`
and `correction_required`. The operation has its own `status` and `phase`, separate from the
cycle state. A `failed` or `reconcile_required` operation exposes a `blocker` code. When the
effect of a lost engine response cannot be identified uniquely, OpsService requires operator
reconciliation and never sends the native command again blindly. A competing calculation or
decision returns `409 payroll_operation_collision` with the active operation ID.

## Approve the exact result

After reviewing `result` and the `candidate.id`, send
`POST /{period}/approval` with the latest `If-Match` and body
`{"approvalKey":"stable-key","candidateId":"the-reviewed-id"}`. The response is `202`
with an operation URL. Approval is bound to that candidate, input generation, exact engine job
and result set. The worker finalizes that same job without recalculating. Poll the operation and
then read the cycle; `finalized` is distinct from `approved` and from filing.

If the variables or relevant tenant inputs change, the old candidate becomes invalid and a new
calculation and review are required. An old `ETag` yields `412`; an old candidate yields a
conflict. Before approval, including after an input has invalidated a draft,
`POST /{period}/abandon` with `If-Match` and body
`{"candidateId":"...","idempotencyKey":"stable-key"}` explicitly abandons the exact draft.
After finalization, a change creates a new revision for correction; the prior result and
documents are not overwritten. The first input on an empty M-1 cycle remains revision 1;
changing an existing input after cutoff or approval creates the next revision.
When a prior month is corrected while the following month is still open, each input remains
scoped to its own month. Resending the following month's unchanged variables is an idempotent
read of its existing write. A calculation can be requested immediately after a confirmed write;
Ops chooses an engine evaluation instant that includes that write.

For a legacy simulation or draft that has no Ops candidate, use
`POST /{period}/drafts/{jobId}/abort` with the latest `If-Match` and a stable UUID
`requestKey`. Supply `reasonCode` as `simulation_discarded`, `draft_replaced` or
`operator_recovery`. This queues an audited operation for that **exact** native `Draft`; poll
the returned operation URL until `completed` before requesting a fresh calculation. A repeated
key returns the same operation. An approved, foreign, non-Draft or candidate-owned job is
refused. An ambiguous response keeps the payroll reserved until Ops verifies native history.
This route is off by default (`Ops__DraftAbortEnabled=false`) until Verso completes its
database, writer and clock checks. Older drafts outside M/M−1 require the authenticated Ops
recovery path; the partner window is unchanged.

`POST /{period}/preview` remains a compatibility readiness check for legacy cycles. A managed
candidate uses `calculate` plus `GET`; preview cannot select a different native result. Legacy
approval without `candidateId` remains confined to cycles with no managed candidate. New
integrations should always send `candidateId`.

For an existing legacy cycle, read its ETag, call `POST /{period}/preview` with `If-Match`, and
check `state=preview_ready`, `preview.pdfProof=ready`, empty `preview.blockers`, and the exact
`payrunJobId` and `resultSetId`. Then call `GET /{period}/payslip` to obtain a temporary private
PDF URL. Its response includes the beneficiary, period, revision, job, result set and checksum.
Download the URL and compare the PDF with that identity. `documentStatus=preview` means the
partner has not approved it; `approved_preview` means the same legacy preview was approved,
but it is not a managed `final` document. If the PDF is not ready, the API returns `409
payslip_not_ready` with `cycleState`, the last `preview` including its blockers, and a
`nextAction` (`set_cycle_variables`, `prepare_preview`, `wait_for_payrun`,
`resolve_preview_blockers` or `contact_verso` when rendering is disabled or failed). Do not
attempt to open a missing URL. A genuine unknown cycle still returns `404`.

## Documents

Generate or retry a document with
`POST /{period}/revisions/{revision}/candidates/{candidateId}/documents/{variant}` and read it
with `GET` on the same path. `variant` is `draft` or `final`. The draft is optional and visibly
marked **BROUILLON — À VALIDER**. Approval does not require it. The final document is available
only after finalization; it has a different identity and no draft mark. In sandbox it carries
**TEST — SANS VALEUR**. Document generation failure can be retried without recalculating or
refinalizing payroll.

Each document response has its own status, checksum, retention deadline and a temporary private
read URL when ready. Call `GET` again to renew an expired URL. The document retains its identity
and storage version independently of the URL. Sandbox documents are retained for 180 days;
staging and preproduction use configured retention periods, and production can be configured
without automatic expiration. Once retention expires, PDF and manifest versions and document
metadata are purged; reads and generation return `410 document_retention_expired`. Earlier
revisions remain addressable by their original revision and candidate IDs during retention.

## Limits and recovery

`GET` and operation polling remain authoritative even if a client misses a response. When Verso
has configured an optional HTTPS destination for your partner and environment, it sends
`payroll_cycle.updated` after useful changes: calculation ready/failed/requiring reconciliation,
operator reconciliation resolved,
candidate invalidation or abandonment, finalization, and document ready/failed. There is no
subscription URL in a calculation request. The signal contains references only, so always
re-read the cycle or operation with your API key. Notifications can arrive twice or out of order.
If no destination is configured, polling alone provides the complete journey.

For example, a receiver may see:

```json
{"eventId":"msg_…","schemaVersion":"1","type":"payroll_cycle.updated","occurredAt":"2026-09-27T12:00:00Z","data":{"environment":"sandbox","partnerId":"42","tenantId":"7","period":"2026-09","revision":1,"change":"calculation.ready","candidateId":"…","operationId":123,"documentId":null,"resource":"/partners/42/tenants/7/payroll-cycles/2026-09"}}
```

The sender includes `webhook-id` (stable event ID), `webhook-timestamp` (Unix seconds for this
attempt) and `webhook-signature` (`v1,` plus base64 HMAC-SHA256). Verify the signature over the
raw bytes `webhook-id.webhook-timestamp.body`, reject stale timestamps, persist the ID before
returning `2xx`, then call the authenticated GET. During secret rotation the signature header can
contain two space-separated signatures. See the [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md).
Verso retries transient non-2xx or lost responses with jittered backoff for up to 24 hours, at most one
hour between attempts. After exhaustion, Verso Ops can audit and replay the same event ID and
body; the attempt timestamp and signature change. An unavailable webhook does not cancel or
repeat payroll. HTTP 410 suspends the delivery for operator investigation.

A variable write that may have succeeded downstream is exposed
as `409 variable_write_reconciliation_required`, with `variableWrite.id`, status and
`reconciliationRequired: true` on the cycle. Do not retry that write until Ops has reconciled
the frozen intent against engine history. The same principle applies to ambiguous calculation
and finalization effects.

The V1 write window is M/M-1. A later managed month must use only earlier finalized results:
`prior_month_not_finalized` refuses a calculation started too soon. If a later month is
already finalized, R1 returns `later_finalized_month_requires_operator_replay` for an earlier
correction. Verso Ops must inspect the downstream jobs and explicitly replay their correction
revisions in order; no finalized month is changed automatically. The internal operator route
checks the exact finalized job/result list and opens new, empty revisions; each month needs
a fresh input, calculation and approval backed by recorded partner consent. It is disabled
by default and does not expand the partner M/M−1 window. Inactive or offboarded tenants cannot start partner payroll
mutations. The native write guard remains active for an existing managed payroll even when the
managed journey flag is closed; native writes require the Ops policy lookup and fail closed if
that lookup is unavailable. Verso's legal control and DSN transmission remain separate Ops workflows.
