Renewals & overage
Scope: what the billing schedulers do once a day — expiring unpaid subscriptions, charging Paymob renewals off-session, and billing accrued click overage. How money first starts moving is architecture; the invoice-keyed idempotency primitive referenced throughout is defined there.
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (
src/billing/schedulers/billing-scheduler.ts,src/billing/services/overage-billing.service.ts,src/billing/config/plans.config.ts,src/entitlements/entitlements.service.ts,src/click-tracking/click-tracking.service.ts).
The cron trio
BillingScheduler runs three jobs, each EVERY_DAY_AT_MIDNIGHT (server-local):
- expireOverdueSubscriptions — Stripe + safety-net expiry,
- renewDuePaymobSubscriptions — the MOTO renewal engine,
- billAccruedOverage — the overage sweep (delegates to
OverageBillingService).
The expiry sweep excludes Paymob subscriptions on purpose — the renewal cron is the sole owner of the Paymob lifecycle; otherwise both jobs firing at 00:00 could race and a due-for-renewal sub would be expired instead of renewed. Every job isolates failures per subscription (one tx per sub, each independently caught) and re-checks status/period inside the transaction so a webhook that renewed at 23:59:59 isn't clobbered by the 00:00:00 cron.
Expiry sweep (Stripe + safety net)
ACTIVE/PAST_DUE subscriptions with currentPeriodEnd < now and gateway ≠
PAYMOB → downgradeToFree: status EXPIRED, org plan mirrored to STARTER.
currentPeriodEnd is mirrored from the gateway — no +30-day date math.
Paymob MOTO renewal engine
Paymob has no native recurring subscriptions. The first checkout is 3DS
on-session; the card-token callback saves a PaymentCardToken. Each day,
every due ACTIVE Paymob sub (period ended) resolves to:
| Condition | Outcome |
|---|---|
cancelAtPeriodEnd | Downgrade to STARTER, no charge |
| No default card token | Downgrade to STARTER (no dunning) |
| No org OWNER (corrupt state) | Downgrade to STARTER |
| MOTO charge throws or declines | Downgrade to STARTER immediately |
| Charge succeeds | Grant via runInvoicePaidTransaction keyed on the Paymob txn id |
On success the period becomes now + PLAN_DURATION_DAYS[plan] (30 days for
the paid tiers), a PAID invoice lands with billingReason: 'subscription_cycle', billedBy is the OWNER, and a scheduled plan change
is applied (clearScheduledPlan: true) — all inside the invoice-keyed
idempotency boundary, so the cron's grant and the TRANSACTION callback's are
interchangeable replays of each other. Two deliberate edges:
- Money moved → the entitlement lands. After a successful charge the
grant is not re-checked against terminal status; a cancel that raced the
charge keeps its
cancelAtPeriodEndand downgrades on the next sweep, but a paid-for cycle is never swallowed. - Success without a txn id (degenerate gateway response) → no idempotency key exists, so no invoice row is safe; the period is extended directly (so the customer isn't re-charged tomorrow) with a loud error log.
Overage sweep
EntitlementsService.recordUsage meters every click of an overage-policy
org (only clicks_per_month, only plans whose policy is overage) into an
org-anchored UsageLedger row (eventType: 'click',
referenceType: 'ClickEvent') — fire-and-forget from the click-tracking
path with an isolated catch; the ledger never blocks or fails a click.
ENTERPRISE's quotas use the MAX_SAFE_INTEGER sentinel, so nothing is ever
billable there — its rows are written off.
The sweep (OverageBillingService.sweepOverageWindows) selects ended
periods (currentPeriodEnd < now) whose org still has uninvoiced rows:
- Window: rows with
invoicedAt: null AND createdAt ≤ currentPeriodEnd. TheinvoicedAtmarking itself is the double-charge guard — a row is marked the moment its charge lands (or is written off) and never re-enters the selection. Stragglers from older failed passes are swept with the current window; the plan limit is subtracted once per pass, so a merged pass can only undercharge, never double-charge. Stripe calls additionally carry a window-stable idempotency key (org + period bounds). - Charge model:
billable = max(0, units − plan clicks_per_month limit)— the ledger records every click of a paid org, so the limit is subtracted once. Stripe → a pending invoice item swept onto the next renewal invoice (no local Invoice row; the renewal webhook records it when paid). Paymob → an immediate MOTO charge on the default saved card + a PAID invoice keyed on the txn id (the MOTO callback and this sweep are interchangeable replays). - Minimum-charge write-off: a charge under 1.00 currency unit (100 minor units) is written off — rows marked invoiced, not charged.
- Failure policy: an overage failure never downgrades or blocks the
subscription (unlike renewal failure) — rows stay
invoicedAt: nulland the next daily sweep retries. Missing preconditions (no Stripe customer, no default card, no OWNER, no configured rate) also stay unbilled for retry. Orgs with usage but no subscription row are skipped defensively with a warning.