ADR-019: Stripe re-connect flushes stuck tax invoices
Status: Accepted Date: 2026-05-04
Summary
Section titled “Summary”When an operator connects Stripe (POST /v1/settings/stripe), Hardcount retries tax on every draft invoice stuck on provider_not_configured or provider_auth in that mode, because connecting means the credentials are fixed. The response returns the count as retries_queued. A background goroutine with a 5-minute limit runs each retry through Service.RetryTax, which also finalizes invoices the engine made (ADR-017). There is no durable queue: if the process stops mid-run, connecting again or a per-invoice Retry tax finishes the job. Status: active.
Context
Section titled “Context”Two earlier ADRs frame this:
-
ADR-017 (background tax-retry reconciler) auto-retries invoices stuck on transient codes (
provider_outage,unknown) via an exponential backoff schedule. It deliberately excludes operator-action codes (provider_auth,provider_not_configured,customer_data_invalid,jurisdiction_unsupported) — auto-retrying without operator acknowledgement would burn provider quota on permanently-failing invoices. -
ADR-018 added a Retry advance button so operators recover from a stuck test-clock catchup without delete-and-rebuild.
Between those, one gap remained: when an operator (re)connects
Stripe in Settings → Payments — the explicit signal “I just fixed
the credentials” — invoices that were stuck on
provider_not_configured or provider_auth for that
(tenant, livemode) had no automatic recovery. The operator had to
click Retry tax on every stuck invoice individually. Discovered
2026-05-04 by an operator who connected test-mode Stripe and then
asked “should retry tax happen automatically now or wait?” —
answer was “neither, you have to click each.”
Industry parallel: Stripe Connect replays queued events when an account reconnects. Lago / Recurly do the equivalent — the re-connect IS the operator signal, so the system should catch up on its own.
Decision
Section titled “Decision”When tenantstripe.Service.Connect succeeds (verify returns 200
from Stripe), the service:
- Synchronously counts invoices for that (tenantID, livemode)
matching
status='draft' AND tax_status IN ('pending', 'failed') AND tax_error_code IN ('provider_not_configured', 'provider_auth'). The count is included in the Connect HTTP response asretries_queuedso the dashboard renders “Retrying N stuck invoices in the background”. - Fans out a goroutine to actually run the retries. The
goroutine builds a fresh
context.Background() + WithTenantID + WithLivemode + 5-min timeout(per the ctx-attribute audit memory: every ctx-detached worker pins all relevant attrs, not just livemode). Callsinvoice.Service.RetryProviderConfigErrors, which iterates the stuck rows and invokes existingService.RetryTaxper row. RetryTax already includes the auto-finalize chain from ADR-017 — so engine-generated invoices that recompute clean move fromdraft→finalizedin the same goroutine pass.
The retry path goes through the existing single source of truth
(Service.RetryTax); this ADR is purely a new trigger, no new
recompute logic.
Scope of the retry
Section titled “Scope of the retry”Only provider_not_configured and provider_auth — both Stripe-
configuration codes that fresh credentials directly resolve.
customer_data_invalid and jurisdiction_unsupported need
different operator actions (edit billing profile / register with
Stripe Tax) and don’t belong on the Connect path.
No retry-attempt cap
Section titled “No retry-attempt cap”The 8-attempt cap on the background reconciler exists to bound provider-quota burn on permanently-transient errors. Connect- triggered retry is a one-shot human signal — if the new credentials still don’t work, the per-invoice retry surfaces a fresh error code and the operator sees that immediately. A cap would be redundant.
Sync vs async
Section titled “Sync vs async”Async goroutine, returning fast from Connect. A synchronous fan- out would block the Connect HTTP response on potentially many sequential Stripe Tax round-trips (~200ms each); 100 stuck invoices × 200ms = 20-second spinner. Bad UX. The 5-min wall- clock timeout on the goroutine bounds resource use; in-flight retries past that exit cleanly and remaining rows can still be recovered by an operator-driven Retry tax click per invoice.
No durable retry queue
Section titled “No durable retry queue”If the process dies mid-fan-out, in-flight retries are lost. Two recovery paths exist without durable persistence:
- The operator re-clicks Connect (idempotent — fan-out re-runs against any rows still stuck).
- The operator clicks Retry tax on individual invoices.
Adding a durable queue would be belt-and-suspenders (per the relevant memory) for a low-frequency, operator-initiated event.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Operator’s mental model matches: “I fixed the underlying issue → the system catches up.” No per-invoice clicking.
- Frontend gets immediate feedback (“Retrying 3 stuck invoices in the background.”) so the operator knows recovery is in flight.
- Single source of truth: retry logic stays in
Service.RetryTax. This ADR is a trigger, not a parallel implementation. - The auto-finalize chain (ADR-017) means engine invoices flip from draft → finalized as part of the same fan-out — no second pass needed.
Negative
Section titled “Negative”- A flaky network during the goroutine’s run leaves some rows stuck. Acceptable: re-Connect fixes it; Retry tax per-invoice also fixes it.
- Connect now has a per-tenant query (
COUNT) before responding. Sub-millisecond on the indexed predicate; negligible. - Race vs operator manual click: both can target the same row.
UpdateTaxAtomiclocksFOR UPDATE, so one wins; the loser gets a 409 and aborts that one row. Net: each row resolves exactly once.
Compatibility
Section titled “Compatibility”- API:
POST /v1/settings/striperesponse shape gains aretries_queuedinteger field. Existing clients that ignore unknown fields work unchanged. - Frontend: Settings → Payments connect toast renders “Retrying N stuck invoices in the background.” when N > 0.
- No schema change.
- Backwards-compatible with deployed binaries — the field is
additive on the response and the goroutine is gated by
SetStuckRetrierbeing wired (no-op if not).
tenantstripe.Service.SetStuckRetrieris the production wiring; the no-op fallback when nil keeps narrow unit tests free of cross-package imports.invoice.Service.CountProviderConfigErrorsexists alongsideRetryProviderConfigErrorsso Connect can populate the response without waiting for the goroutine. Both back to the sameStore.ListProviderConfigErrorsquery — count is justlen(rows).- The 5-min
PostConnectRetryTimeoutcovers ~1500 invoices at Stripe Tax’s typical ~200ms/call latency. Far beyond expected per-tenant volumes.