The three legs
An invoice send that an operator approves is 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.
The other caller: a send with no operator leg
The three legs above are the operator's chain, and they are not the only way an invoice is sent. A client can be set to invoice on completion, and that path sends with no pending operation and no approval leg at all — marking the visit complete is the only human act in it. A builder who reads the table above and assumes a staged operation always exists will be wrong for those clients.
What the two paths share, and what they do not:
| Operator chain | Send on completion | |
|---|---|---|
| Pending operation | Yes, legs 1–3 | None |
| Who approves | The operator, at leg 3 | Nobody; the client's setting is the standing approval |
| Jurisdiction validator | Runs, hard | Runs, hard — the same gate |
| Duplicate-invoice guard | Runs at propose time | Not on this path; see below |
| Recipient resolution | receives_invoices contacts, then billing_email | Identical |
invoice.sent | Emitted | Emitted |
The validator is the part to be precise about. It is one gate with one entry point, and the approval-free path does not get a gentler version of it — a send with nobody watching is the last place you would want a weaker tax check. What differs is only what happens on a refusal. There is no operator at the other end to hand a structured failure to, so nothing is sent, the visit is left waiting for a person with the reason recorded against it, and the client is never emailed an invoice that would have failed validation.
The propose-time duplicate guard is absent here because there is no propose leg to run it in. What protects this path is further upstream and stricter: one live invoice per visit. A completed visit that already carries an invoice cannot acquire a second one, so a re-delivered completion event — or a double-tapped button in the field — yields the first invoice again rather than raising another.
Which clients are on this path, and the three-state setting that decides it, is in can it handle residential and commercial clients. The link such an invoice carries has to outlive the send, which constrains which link it can be — see a payment link you can store.
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 — once the invoice total crosses the threshold (thresholdCents, 100000 = $1,000.00) — that the invoice identifies the buyer. The ATO's rule is the buyer's identity or ABN, so a named buyer with no ABN (a private homeowner) passes: the identity is the client's legal name, which the invoice prints as Bill To. The gate refuses only when there is neither a recipient ABN nor a buyer name. A business that wants an ABN specifically can switch on finance.ato.require_recipient_abn_above_threshold. 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.
Note that missing_supplier_abn is a property of the sending business, not of any one invoice: until the tenant's own ABN is on file, this gate refuses every send they attempt, through either path. It is the first thing to check when sends fail across the board rather than on one document.
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.