Invoice send: gates and guards

An invoice send passes three deliberate gates: a staged propose leg, a confirm leg, and an approve leg that runs a jurisdiction validator as a hard block. A separate duplicate guard refuses to stage a same-total, same-period invoice against a live sibling — it deliberately ignores paid siblings, which is why legitimate catch-up billing is not blocked.

The three legs

Invoice sends are deliberately not one-shot. Three separate calls, each with a distinct job:

LegCallWhat it doesWhat it does not do
1propose_send_invoiceCreates a pending operation, resolves recipients, renders the PDF and email preview, runs the duplicate guardSend anything. Change the invoice
2confirm_pending_operationMoves the operation to staged, awaiting operator approvalSend anything
3approve_send_invoiceRuns 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_invoiceconfirm_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_abn
  • missing_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.

Related

Last updated 2026-08-28