> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# How Dunning Runs

> The mechanics behind the strategy — how a failed charge actually becomes a scheduled, executed, re-decided retry across both modes.

The [recovery engine](/billing/dunning) page covered *what* gets decided. This page covers *how* the worker executes it — the scan, the retry job, and the re-decision loop.

## Two moving parts

```mermaid theme={null}
flowchart LR
    subgraph timer["repeatable scan · every 60s"]
        DS["DunningScanner.scan(now)"]
    end
    DS --> Q["data(mode).dunning_schedules<br/>where state = scheduled<br/>and nextAttemptAt ≤ now<br/>(both modes)"]
    Q --> ENQ["enqueue 'process' job per schedule<br/>jobId = dunning_mode_scheduleId"]
    ENQ --> PROC["DunningProcessor"]
    PROC --> PD["DunningService.processDue(scheduleId)"]
```

The scanner is a cross-tenant sweep of *due* schedules — `state = scheduled AND nextAttemptAt ≤ now` — across both schemas. It only enqueues; the retry itself is a separate job so a slow gateway call never holds the scan open.

## `processDue` — execute one retry, then re-decide

```mermaid theme={null}
flowchart TD
    START["processDue(scheduleId)"] --> GUARD{"state == scheduled?"}
    GUARD -->|"no"| RET["return — someone else<br/>is handling it"]
    GUARD -->|"yes"| FLIGHT["set state = in_flight"]
    FLIGHT --> CHARGE["gateway.charge on schedule.rail"]
    CHARGE --> REC["record PaymentAttempt<br/>(with customerId)"]
    REC --> OK{"succeeded?"}
    OK -->|"yes"| RECOVER["recover():<br/>invoice paid · advance period ·<br/>past_due → active ·<br/>emit recovered events"]
    OK -->|"no"| ADVANCE["advance():<br/>attemptsMade += 1<br/>DunningStrategy.decide(…) again"]
    ADVANCE --> NEXT{"new action"}
    NEXT -->|"exhaust"| EXH["invoice uncollectible ·<br/>past_due → unpaid"]
    NEXT -->|"request_card_update"| PAUSE["state = paused ·<br/>emit payment_action_required"]
    NEXT -->|"retry / switch_rail / payday"| RESCHED["state = scheduled ·<br/>set rail + nextAttemptAt"]
```

The crucial property: **the strategy is consulted on every attempt, not just the first.** A failure that started as `insufficient_funds` and waited for payday might, on the payday retry, come back as a `do_not_honor` — and the engine re-decides from scratch with the new code, the new attempt count, and the schedule's stored `paydayAware`/offsets. Recovery is adaptive, not a fixed script.

## The `in_flight` guard prevents double-charges

Setting `state = in_flight` before charging, and the `state == scheduled` guard at the top, mean two workers that somehow grab the same schedule can't both charge it. The first claims it (`scheduled → in_flight`); the second sees `in_flight` and returns. Combined with the BullMQ `jobId` dedup, a retry executes at most once.

## Manual recovery

Merchants don't only wait for the timer. The dashboard's Recovery Command Center can force a retry now:

```mermaid theme={null}
sequenceDiagram
    participant M as Merchant
    participant API as POST /v1/recovery/{invoiceId}/retry
    participant S as DunningService
    M->>API: retry this invoice now
    API->>S: reset schedule to scheduled, nextAttemptAt = now
    API->>S: processDue(scheduleId)
    S-->>API: { state, attemptsMade }
    API-->>M: recovered / advanced / exhausted
```

Same `processDue` path — the manual button and the timer converge on one code path, so they can never diverge in behaviour.

## What the merchant sees

The whole thing renders as a live operations board:

* **Three columns** — *at risk* → *recovering* → *recovered / lost* — each card a failing subscription with its failure code, attempt N/5, chosen rail, and next action.
* **A per-card timeline** — retries plotted on a calendar, the rail-switch relay drawn out, payday waits marked "⏳ waiting for the 28th."
* **The funnel** — `byFailureCode` showing exactly what's killing this merchant's revenue, so they can fix the upstream cause.

All of it is fed by the `RecoverySummary` and the `events` table — no separate analytics pipeline, just reads over the operational data.

Next: [money in →](/payments/checkout).
