The three legs
Invoice sends are deliberately not one-shot. Three separate calls, each with a distinct job:
| Leg | Call | What it does | What it does not do |
|---|---|---|---|
| 1 | propose_send_invoice | Creates a pending operation, resolves recipients, renders the PDF and email preview, runs the duplicate guard | Send anything. Change the invoice |
| 2 | confirm_pending_operation | Moves the operation to staged, awaiting operator approval | Send anything |
| 3 | approve_send_invoice | Runs the jurisdiction validator, transitions the invoice to sent, dispatches the email, emits invoice.sent | — |
The invoice stays in draft and remains fully editable until leg 3 succeeds. Between legs 1 and 3 you can personalise the email — see Personalise a staged send.
Resends are two-leg, not three. propose_resend_invoice → confirm_pending_operation delivers immediately, because the original send was already operator-approved. A resend is pure re-delivery: no status change, no number change, no new postings.
Gate 1 — the duplicate-invoice guard (leg 1)
Runs at propose time. It refuses to stage when another invoice for the same client has an equal total and an overlapping job or service period, inside a window set by finance.invoicing.duplicate_window_days (default 14).
The critical detail is which siblings it considers: only live invoices — draft, sent, or overdue. A paid or voided sibling is invisible to the guard.
That asymmetry is correct, and it is what makes catch-up billing work. A recurring monthly client legitimately produces identical totals month after month. Once last month's invoice is paid, this month's identical invoice stages without complaint. The guard is aimed at the actual failure mode — issuing the same charge twice while the first one is still outstanding — not at "two invoices look alike".
When it does fire, the response names the conflicting invoice. Re-call with override_duplicate_check: true only after confirming the repeat billing is intentional.
Gate 2 — the jurisdiction validator (leg 3)
Runs at approve time, as a hard gate. This is deliberate: the validator inspects the invoice in its final state, after any edits made while it sat staged.
For the AU locale it checks supplier ABN presence, and recipient ABN presence once the invoice total crosses the threshold (thresholdCents, 100000 = $1,000.00). Below the threshold a missing recipient ABN is fine and the send proceeds.
Failure is returned as a structured result, not thrown — read ok: false and the code:
missing_supplier_abnmissing_recipient_abn_above_threshold
On failure the operation is marked validation_failed, invoice.ato_validation_failed is emitted, and nothing is sent.
The recipient ABN resolves in a fixed order: the invoice header's buyer_abn, then the client's ABN. get_invoice exposes the outcome as effective_recipient_abn plus recipient_abn_source ("header" or "client"), so a compliance check needs no PDF render.
Recipient resolution
propose_send_invoice returns a recipients array with email, name, and resolved_from for each address. Resolution prefers contacts flagged receives_invoices and falls back to the client's billing_email.
This array is the exact fan-out leg 3 will email. Reading it is the cheapest available check against a misconfigured contact — and during a data migration it is often the first place a placeholder billing address surfaces.
Expiry
Pending operations expire via finance.sends.pending_operation_ttl_minutes (default 60, clamped 5–240). An expired operation dispatched nothing and mutated nothing.
What a successful approve returns
{
"invoice_number": "INV-...",
"sent_at": "...",
"message_id": "...",
"send_ok": true,
"send_error": null,
"recipients": ["..."],
"validation": { "ok": true, "jurisdiction": "AU", "thresholdCents": 100000 }
}
send_ok: true with a message_id is the assertion worth checking in a batch run. validation.ok alone means the invoice was allowed to send, not that it did.
If the client holds available credit at send time, the response also carries credits_on_account_before_send and a credit_hint. Credits auto-apply asynchronously on the invoice.sent event, so the send response cannot report the result — re-read with get_invoice to see whether the invoice became partially_paid or paid.