Concorbit HelpAll guides →

Autopay

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:

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

  1. Builds the Stripe client via BillingStripeClientFactory::forTenantBilling (the connected account is baked in, no platform-key fallback).

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

  3. On success, finalises via StripeConnectService::finalizeInvoiceFromIntent, which records the settlement through PaymentLedgerService (idempotent on the PaymentIntent id) and flips the invoice to paid if the new ledger sum meets the total.

  4. 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 numberRetry 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 paid on save; there is nothing to charge.

For recovery of an autopay invoice that was blocked because of a connected-account rotation, see Operator runbook.