AAuditPro Suite· Finance manual
Finance manual State machine

Tax invoice transitions

FromToTriggerLifecycle stamp
draftcreatecreated_at + by
draftsentapprove + sendapproved_at, sent_at + by
sentpartially_paidpayment allocation < balance
sent / partially_paidpaidbalance reaches 0paid_in_full_at
sent / partially_paidoverduecron · due_date < today & balance > 0overdue_flagged_at
overduepartially_paid / paidpayment received
any (except paid/written_off)cancelledmanual + reason > 50 charscancelled_at + by + reason
any (except paid/cancelled)written_offpartner action + reasonwritten_off_at + by + reason

Proforma transitions

draftcreatecreated_at + by
draftsentsend to clientsent_at + by
sentconvertedconvert to tax invoiceconverted_at + by · stamps converted_to_invoice_id
anycancelledmanual + reasoncancelled_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:

!Cancel
cancelled_reason · min 50 chars

Why was this invoice cancelled? "Client withdrew engagement on D45 · refund of 30% retainer issued via CN/2026/0007 · per partner agreement."

!Write-off
written_off_reason · min 50 chars · partner only

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 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:

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

TransitionSide effects
draft → sentPDF cache rebuild · M17 email queued (template invoice.sent) · audit log · client notification
sent → partially_paidBalance recompute · invoice.balance_due updated · client billing card refresh
partially_paid → paidpaid_in_full_at stamped · M17 email queued (template payment.received) · audit log · receipt PDF generated · revenue recognition complete
sent → overdueoverdue_flagged_at stamped · M17 dunning email queued · partner notification · AR aging recompute
any → cancelledIf linked CNs exist, validate · audit log with reason · clear from active AR list · M17 notification to client (template invoice.cancelled)
proforma → convertedNew tax invoice draft created · lines cloned · lineage stamped · advance auto-allocation if applicable · proforma now read-only

Step-by-step — handling a cancellation

  1. Identify the invoice

    From M11 → invoice list → filter by client. Find the one to cancel.

  2. Click "Cancel" action

    Modal opens with mandatory reason field (min 50 chars).

  3. 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."

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

  5. Side effects

    cancelled_at + by + reason stamped. M17 email queued to client (template invoice.cancelled). Audit log entry. AR list updated.

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

Try this

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.

Watch out

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.

Tip — bulk overdue flagging

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.