Skip to main content
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. 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. 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 — 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:
IntervalAdvance 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:

maxCycles

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.

scheduledChange

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.

cancelAtPeriodEnd

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

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 for mid-cycle plan changes, then the chapter that matters most — recovery.