v5.38.0 4 June 2026 Improvement

Enforced invoice lifecycle

The invoice lifecycle the documentation describes and the lifecycle the kernel enforces are now the same object.

Enforced transition table. Invoice status changes now follow a strict table at both the service and the database: draft can become sent or cancelled; sent can become partial, paid, or written_off; partial can become paid or written_off. paid, cancelled, and written_off are terminal. Cancellation applies to drafts only — an issued invoice is a tax document and is closed via written_off instead. Illegal transitions, including setting an invoice to its current status, return a 400 naming the rejected pair.

Status endpoint surface. The status endpoint now accepts sent, cancelled, and written_off. paid and partial are payment-derived — they are set automatically by the payment endpoint and can no longer be set by hand, so an invoice’s status and its recorded payments always agree. A new credited status enters the vocabulary, reserved for the forthcoming credit-note mechanism.

Payment guards. Payments now apply only to issued, open invoices. Paying a draft returns a 400 telling you to issue the invoice first; paying a terminal invoice returns 400; overpayment remains rejected as before.

Honest 404s. Operations against a missing invoice (status change, edit, delete, payment) now return the 404 the documentation has always declared, instead of a silent success.

First-issue timestamp. sentAt now records the first issue of an invoice and is never restamped by re-sends or later transitions. Existing issued invoices missing the timestamp have been backfilled from their audit history.

Cancelled invoices cannot be emailed. Emailing a cancelled invoice returns a 400 before any email is sent.

This release tightens the API contract ahead of launch: the status enum shrink and the payment guards are stricter than the previous behaviour, and pre-launch is the moment to make them so. Requests that previously relied on transitions outside the table will now receive a 400 with the exact reason.