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

# The Renewal Engine

> The loop that turns the crank: scan for due subscriptions, charge, advance — or hand the failure to recovery. The algorithm, step by step.

This is the loop that makes Duro a *billing* system rather than a CRUD app over subscriptions. It runs in the worker, on a timer, with no caller. Two pieces: a **scanner** that finds work, and a **service** that does one subscription's worth of it.

## The scan/process split

Every background job in Duro follows the same shape, and for the same reason: a single cross-tenant scan would be a long transaction holding work hostage. Instead the scan is cheap and only *enqueues*; the heavy lifting is a per-item job that can fail, retry, and scale independently.

```mermaid theme={null}
flowchart LR
    TIMER["repeatable 'scan'<br/>every 60s"] --> SCANNER["BillingScanner.scan(now)"]
    SCANNER --> Q1["data('test').subscription<br/>where status in (active, trialing)<br/>and currentPeriodEnd ≤ now"]
    SCANNER --> Q2["data('live').subscription<br/>…same…"]
    Q1 & Q2 --> ENQ["enqueue one 'renew' job<br/>per subscription<br/>jobId = billing_mode_subId"]
    ENQ --> PROC["BillingProcessor → BillingService.renew(id)"]
```

The `jobId = billing_{mode}_{subscriptionId}` is a **dedup key**: if the scan fires again before a renewal finishes, BullMQ collapses the duplicate. And because the scan filters on `status in (active, trialing)`, a subscription that's already `past_due` (in dunning) is invisible to it — recovery owns that subscription until it resolves.

## `renew()` — one subscription, decided

This is the core algorithm. Read it as a decision tree; it's implemented as straight-line guards.

```mermaid theme={null}
flowchart TD
    START["renew(subscriptionId)"] --> LOAD["load subscription"]
    LOAD --> REN{"status active<br/>or trialing?"}
    REN -->|"no"| SKIP["skip — not billable"]
    REN -->|"yes"| DUE{"currentPeriodEnd<br/>≤ now?"}
    DUE -->|"no"| SKIP
    DUE -->|"yes"| CAPE{"cancelAtPeriodEnd?"}
    CAPE -->|"yes"| CANCEL["transition cancel →<br/>canceled. done."]
    CAPE -->|"no"| SCHED["apply scheduledChange<br/>(plan change at period end)"]
    SCHED --> LIMIT{"maxCycles reached?"}
    LIMIT -->|"yes"| EXPIRE["transition reach_limit →<br/>expired. done."]
    LIMIT -->|"no"| INV["create invoice for<br/>next period (idempotent key)"]
    INV --> CHARGE["gateway.charge(amount, rail)"]
    CHARGE --> OK{"succeeded?"}
    OK -->|"yes"| ADVANCE["invoice → paid<br/>advance period<br/>cyclesCompleted += 1<br/>if trialing → activate<br/>emit payment_success"]
    OK -->|"no"| DUNNING["DunningService.handleFailure(<br/>invoice, code, rail)<br/>→ schedule retries, past_due"]
    ADVANCE --> RELIMIT{"now at maxCycles?"}
    RELIMIT -->|"yes"| EXPIRE
    RELIMIT -->|"no"| DONE["active, next period set"]
```

Each branch is a real return value (`charged`, `dunning`, `canceled`, `expired`, `skipped`), which the worker logs and which the integration suite asserts on.

### The period math

The invoice always covers the **next** period, and the subscription only advances on success:

* `periodStart = currentPeriodEnd` (the moment the old period ended)
* `periodEnd = BillingCycle.advance(periodStart, plan.interval, plan.intervalCount)`

On a successful charge, the subscription's `currentPeriodStart/End` jump to `[periodStart, periodEnd]`. On failure they *don't move* — the subscription sits in `past_due` at the old period while dunning works the invoice. When dunning eventually wins, [it advances the period to the invoice's period](/billing/dunning#recover) — so a recovered subscription lands exactly where a first-try success would have, with no double-billing and no skipped period.

### Trial → active is just the first renewal

There's no special "trial expiry" job. A trialing subscription has `currentPeriodEnd = trialEndsAt`. When that passes, the renewal scan picks it up like any other due subscription, charges the first real payment, and on success runs `activate` to move `trialing → active`. One code path, fewer edge cases.

## Interval math, with month-end clamping

`BillingCycle.advance` handles seven intervals, normalising everything through hours, days, or calendar-months:

| Interval   | Advance by           |
| ---------- | -------------------- |
| `hour`     | `+n hours`           |
| `day`      | `+n days`            |
| `week`     | `+7n days`           |
| `month`    | `+n calendar months` |
| `quarter`  | `+3n months`         |
| `biannual` | `+6n months`         |
| `year`     | `+12n months`        |

Month arithmetic **clamps to the last valid day**: a subscription that renews on Jan 31 renews next on Feb 28 (or 29), not an invalid Feb 31 that silently rolls to March. This is the kind of detail that produces a support ticket six months in if you get it wrong, so it has its own unit tests.

## maxCycles, scheduledChange, cancelAtPeriodEnd

The renewal engine is also where deferred intentions get applied — the things a merchant set up earlier that only take effect at a period boundary:

<CardGroup cols={3}>
  <Card title="maxCycles" icon="stop">
    A plan can bill a fixed number of times (a 12-month installment, say). After the Nth successful charge the subscription transitions to `expired` and falls out of every future scan.
  </Card>

  <Card title="scheduledChange" icon="arrow-right-arrow-left">
    A plan change requested mid-cycle is stored as `scheduledChange` and applied at renewal — the new period bills on the new plan, emitting `subscription_plan_changed`.
  </Card>

  <Card title="cancelAtPeriodEnd" icon="hourglass-end">
    "Cancel at the end of what they paid for." The flag is set immediately; the renewal engine honours it by cancelling instead of charging when the period rolls.
  </Card>
</CardGroup>

## Where it runs, and how it's proven

The engine lives in `BillingService` (`@duro/merchant-api`) — pure orchestration over `RepositoryContext` and the `ChargeGateway`. The worker's `BillingProcessor` is a thin shell that builds the context and calls `renew()`. Because the service is decoupled from the queue, the **integration suite drives it directly against a real Postgres** — all six branches (charge+advance, trial activation, fail→dunning, cancel-at-period-end, maxCycles→expire, not-due skip) run on every push to CI.

Next: [proration](/billing/proration) for mid-cycle plan changes, then the chapter that matters most — [recovery](/billing/dunning).
