Skip to main content

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):

  1. expireOverdueSubscriptions — Stripe + safety-net expiry,
  2. renewDuePaymobSubscriptions — the MOTO renewal engine,
  3. 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:

ConditionOutcome
cancelAtPeriodEndDowngrade to STARTER, no charge
No default card tokenDowngrade to STARTER (no dunning)
No org OWNER (corrupt state)Downgrade to STARTER
MOTO charge throws or declinesDowngrade to STARTER immediately
Charge succeedsGrant 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 cancelAtPeriodEnd and 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. The invoicedAt marking 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: null and 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.