What “recovery-first” means in code
A failed charge entersDunningService.handleFailure(invoiceId, failureCode, rail). From there, every decision is made by DunningStrategy.decide() — a pure function in @duro/billing that takes the failure context and returns an action, never just an error.
This diagram is decide(), branch for branch. Every leaf is a DunningDecision { action, nextAttemptAt, rail, reason }. The reason string is human-readable and surfaces on the merchant’s recovery dashboard and in the dunning email. Note the ordering: insufficient_funds is handled first and short-circuits before the attempt-budget check.
Step 1 — classify why it failed
You cannot retry intelligently if you don’t know what went wrong.FailureCode.classify() maps the raw gateway code (and its numeric aliases) into a category:
Two predicates ride on the category:
requiresNewCard() (expired/unsupported → don’t retry, ask the customer) and isHardDecline() (stolen/lost/fraud → stop hammering the card, switch rails).
Step 2 - insufficient funds retries on the wallet
insufficient_funds (which also covers insufficient_balance and code 51) is the most common failure, and it is handled before every other branch: decide() returns a retry on the wallet rail with an exponential (doubling) backoff.
- Retrying on the wallet means the next attempt draws from the customer’s funded wallet balance if they’ve topped it up, instead of hammering the same empty card. This is the payoff of wallet-first billing: a broke card at renewal is not a dead end when there is a balance to fall back to.
- The interval widens each attempt (1h, 2h, 4h, 8h…) up to a 7-day cap, worth re-checking a broke account on a growing schedule rather than a fixed clock.
Duro also ships a Nigeria-native
PaydayWindow helper (PAYDAY_ANCHOR_DAY = 28, with an early-month grace window on the 1st-3rd, nextPayday() → the upcoming 28th at 09:00 UTC). It encodes the idea that a salaried customer’s money lands around payday. It is a standalone primitive in @duro/billing; the current decide() uses the wallet + exponential-backoff path above for insufficient funds. paydayAware remains a per-merchant setting and is stored on the schedule.Step 3 — rail fallback
If the card is the problem (hard decline), the answer isn’t a better-timed card retry — it’s a different rail. The same customer who can’t pay by card can almost always pay another way.RAIL_FALLBACK = [ussd, transfer, virtual_account, direct_debit]. On a hard decline, the strategy advances to the next rail in the chain and the next attempt charges there. The merchant configures both the order and which rails are enabled. The recovery dashboard visualises this as a relay — card handing off to the rail the customer actually has.
Step 4 — the retry schedule
For transient failures (processor error, do-not-honour after the first card retry, hard declines on an alternate rail), the schedule is a curve of fixed offsets, in hours from the schedule’s creation:nextAttemptAt(base, attemptsMade, offsets) returns base + offsets[attemptsMade], or null once the offsets are exhausted, which the strategy reads as “give up.” Both the offsets and the max-attempts are per-merchant settings (retryOffsetsHours, maxAttempts), so a business can make recovery as patient or as aggressive as it likes.
Insufficient-funds retries are the exception: rather than the fixed curve, they use a doubling exponential backoff (exponentialBackoff(now, attemptsMade) → 2^(attempts-1) hours, capped at 168h/7d), because a broke account is worth re-checking on a widening interval rather than on a fixed clock.
The recovery state machine
A failing invoice gets exactly oneDunningSchedule, and it walks its own small state machine in lockstep with the subscription:
On recovery (recovered), the service does the thing that keeps billing-period accounting honest: it advances the subscription’s currentPeriodStart/End to the recovered invoice’s period, transitions past_due → active, and emits both subscription_recovered and subscription_payment_recovered. The customer who paid late ends up exactly where a customer who paid on time would — no skipped period, no double charge.
On exhaustion, the invoice becomes uncollectible, the subscription transitions past_due → unpaid, and dunning stops. The money is written off, visibly, on the dashboard.
The recovery ledger
Every schedule contributes to a liveRecoverySummary the merchant sees as their hero metric:
recoveredRevenue and atRiskRevenue are summed in the database (a relation-filtered SUM over invoices), not by loading rows into Node — so the dashboard stays cheap as volume grows. This is the number that replaces “total revenue” at the top of the merchant’s screen: what you almost lost, and got back.
Every knob is the merchant’s
The whole engine reads from a per-tenantStoreSettings row, so recovery behaviour is configured, not hard-coded:
A change to these flows straight into the next decision — verified live: flipping
dunningEnabled off pauses retries and the scanner skips the schedule; custom offsets and max-attempts drive the schedule that’s written.
Next: payday & rails for the worker mechanics, then money in.