Autopay
Autopay is the automatic off-session collection of an unpaid invoice against a contact's saved default card. It powers both recurring agreements and one-off-card invoices, on the same code path. The eligibility rules are deliberately strict, an invoice never silently auto-charges when a precondition is unclear.
This page covers what makes an invoice eligible, the dunning schedule when a charge fails, and how the per-invoice retry budget works.
Eligibility
AutopayService::isEligibleForAutopay returns true only when all of the following hold:
| Check | Why |
|---|---|
| The invoice is unpaid and not voided. | A paid or voided invoice has nothing to collect. |
| billing_collection_mode is recurring_card_subscription or one_off_card. | The mode is the frozen creation-time stamp. invoice_on_terms and unstamped (null) invoices never auto-charge. |
| Tenant::canAcceptPayments() is true. | The Stripe Connect account is connected and charges_enabled. |
| The invoice has no open dispute. | A dispute means the bank is asking for the money back; autopay must not push more charges through. |
| The contact has autopay_enabled = true. | The customer-side opt-in (set automatically when they save a card; surfaced in the portal). |
| The contact has an active default PaymentMethod on the current connected account. | The orphan guard. A PM stamped against an old stripe_account_id is refused. |
| The default PM is not expired. | An expired card cannot be charged. |
The fail-closed posture on billing_collection_mode is deliberate. An unstamped (legacy) invoice never auto-charges by default; an explicit collection-mode stamp is required.
The charge path
When all gates pass and the invoice's autopay_attempted flag is false, AutopayService::charge:
Builds the Stripe client via
BillingStripeClientFactory::forTenantBilling(the connected account is baked in, no platform-key fallback).Creates an off-session PaymentIntent against the contact's Stripe customer on the connected account, with the saved PaymentMethod and
confirm=true,off_session=true.On success, finalises via
StripeConnectService::finalizeInvoiceFromIntent, which records the settlement throughPaymentLedgerService(idempotent on the PaymentIntent id) and flips the invoice topaidif the new ledger sum meets the total.On a card decline (
CardException), records the failure on the invoice (autopay_error,dunning_attempts++,last_failure_at) and dispatches the Autopay failed email to the contact.
Successful autopay sends the Autopay successful email with the amount and invoice number.
The dunning schedule
When autopay fails, DunningService::processRetries runs each day and retries the invoice on a fixed schedule:
| Failure number | Retry on |
|---|---|
| 1 (the original autopay attempt) | day 0 |
| 2 (first dunning retry) | day 1 after the first failure |
| 3 (second dunning retry) | day 3 after the first failure |
| 4 (third and final dunning retry) | day 7 after the first failure |
After the day-7 retry fails the schedule is exhausted. The invoice is left unpaid, dunning stops, and the contact has the Autopay failed email link to update their card in the portal.
A cancelled subscription's last invoice does not retry. An expired subscription does not freeze its other invoices' retries, the budget lives per-invoice.
The per-invoice retry budget
invoices.dunning_attempts is the retry counter, per invoice, not per subscription. The decision to retry is "have dunning_attempts days elapsed since the first failure, and is that count less than the schedule length?" This means:
Each invoice has an independent retry budget. A bad period for one customer's card does not collapse retries for another invoice.
A new card saved by the contact does not automatically reset the counter; the next scheduled retry runs against the new default PM. Setting the counter back to zero would re-open the budget and is reserved for the operator runbook.
The day-1 retry no longer skips. Earlier versions had an off-by-one that meant the first dunning attempt landed on day 3; that is fixed.
Dispute freeze
When charge.dispute.created or charge.dispute.warning_needs_response lands for an invoice's PaymentIntent, the dispute workflow records the freeze and autopay refuses for that invoice. See Disputes for the full lifecycle. While the dispute is open, retries are paused; the schedule does not advance.
If the dispute is won, autopay re-eligibility resumes (the freeze drops). If lost, no further autopay runs; the money has been moved by charge.refunded.
Portal self-service
Contacts can manage their autopay preference through the client portal:
View saved payment methods (card brand, last 4, expiry).
Add a card via the inline Payment Element.
Set a default payment method.
Toggle autopay on or off.
Saving a card flips autopay_enabled to true automatically as the consent UX point. Removing the default card without setting a new one will pause autopay on the next attempt (no active default PM means the eligibility check fails closed).
When autopay does not apply
Invoices on credit terms (
invoice_on_terms). Customers on terms settle by manual payment, the portal pay-now button, or by recording a manual payment in the staff UI.AT-governed and QB-governed invoices. AT and QB own their paid state. concorbit's autopay refuses on the row.
Disputed invoices. Frozen until the dispute closes.
£0 invoices. Auto-promoted to
paidon save; there is nothing to charge.
For recovery of an autopay invoice that was blocked because of a connected-account rotation, see Operator runbook.