State machine — validated transitions
M11 InvoiceStateService enforces per-doc-type transition tables. Each transition stamps a lifecycle column with user + timestamp; some require a written reason. Status auto-flips happen via cron (overdue) or service hooks (paid, partially_paid). Direct API calls bypassing the state machine are rejected with 422.
Tax invoice transitions
| From | To | Trigger | Lifecycle stamp |
|---|---|---|---|
| — | draft | create | created_at + by |
| draft | sent | approve + send | approved_at, sent_at + by |
| sent | partially_paid | payment allocation < balance | — |
| sent / partially_paid | paid | balance reaches 0 | paid_in_full_at |
| sent / partially_paid | overdue | cron · due_date < today & balance > 0 | overdue_flagged_at |
| overdue | partially_paid / paid | payment received | — |
| any (except paid/written_off) | cancelled | manual + reason > 50 chars | cancelled_at + by + reason |
| any (except paid/cancelled) | written_off | partner action + reason | written_off_at + by + reason |
Proforma transitions
| — | draft | create | created_at + by |
| draft | sent | send to client | sent_at + by |
| sent | converted | convert to tax invoice | converted_at + by · stamps converted_to_invoice_id |
| any | cancelled | manual + reason | cancelled_at + by + reason |
Credit + debit note transitions
CNs and DNs follow the tax-invoice state machine but are simpler — no overdue logic (CNs reduce balance, DNs are usually paid as part of next settlement). Standard draft → sent. Cancel allowed.
Mandatory-reason fields
Two transitions require a written reason captured in their dedicated column:
Why was this invoice cancelled? "Client withdrew engagement on D45 · refund of 30% retainer issued via CN/2026/0007 · per partner agreement."
Why was this AR balance written off? "Client liquidated · OAAA confirmed · provision created in FY 2025 audit · per partner approval dated 12 Apr 2026."
Cron — auto-overdue flagging
InvoiceStateService::markOverdueDue() runs nightly. For each tax_invoice where:
- status IN (sent, partially_paid)
- balance_due > 0
- due_date < CURDATE()
Status auto-flips to overdue. Stamps overdue_flagged_at. Notification to manager + accountant. Triggers dunning chain (see chapter 10).
Why a state machine matters
Without a state machine, you'd have:
- "Paid" invoices that someone manually flipped back to draft (revenue recognition fraud risk)
- Cancelled invoices being re-edited (audit trail broken)
- Random transitions in audit log without context
- No way to query "show me everything that became overdue in the last 30 days"
The state machine makes every transition deliberate, audited, reversible only through controlled paths. This is what an external auditor (or the OTA) wants to see.
Side-effects per transition
| Transition | Side effects |
|---|---|
| draft → sent | PDF cache rebuild · M17 email queued (template invoice.sent) · audit log · client notification |
| sent → partially_paid | Balance recompute · invoice.balance_due updated · client billing card refresh |
| partially_paid → paid | paid_in_full_at stamped · M17 email queued (template payment.received) · audit log · receipt PDF generated · revenue recognition complete |
| sent → overdue | overdue_flagged_at stamped · M17 dunning email queued · partner notification · AR aging recompute |
| any → cancelled | If linked CNs exist, validate · audit log with reason · clear from active AR list · M17 notification to client (template invoice.cancelled) |
| proforma → converted | New tax invoice draft created · lines cloned · lineage stamped · advance auto-allocation if applicable · proforma now read-only |
Step-by-step — handling a cancellation
Identify the invoice
From M11 → invoice list → filter by client. Find the one to cancel.
Click "Cancel" action
Modal opens with mandatory reason field (min 50 chars).
Write the reason
Be specific. "Client withdrew engagement on 12-Apr-2026 due to scope change. Per partner agreement, 30% retainer refunded via CN/2026/0007. No further work performed."
Confirm cancel
Service validates: status not in (paid, written_off, cancelled). If any payments allocated, reject with "Allocate to credit note first". If clean, transition fires.
Side effects
cancelled_at + by + reason stamped. M17 email queued to client (template invoice.cancelled). Audit log entry. AR list updated.
If a refund is needed
Issue a CN against the cancelled invoice's parent (or the cancellation itself, depending on your policy). The CN flows independently.
Create a draft tax invoice → try directly setting status='paid' via the URL or curl call. The InvoiceStateService rejects with 422: "Invalid transition: draft → paid (must walk through sent first)". The state machine is enforced at the service layer, not just the UI dropdown.
Never bypass the state machine via direct DB updates. The next code change that adds a side-effect to a transition will assume the side-effect always fires; a manual UPDATE skips that. Stick with the service even when scripting.
The cron runs nightly. If you need to flag overdue immediately (e.g. day after a deadline), run php bin/cron-m11-overdue.php manually. Idempotent — running multiple times only adds the flag once. Useful when a client is being chased same-day after due date.