What β split the Stripe subscription-updated webhook handler into named sub-handlers.
Why β it is 161 lines at cyclomatic complexity 71, the heaviest function in the stack, with six concerns interleaved and coupled through a shared mutable flag. It is on the billing hot path, so it is also the function where a subtle mistake is most expensive.
Scope β modules/billing/services/billing.webhook.service.js:419-579 (verified on master by AST parse). Internal decomposition only; the module's exported surface does not change.
This issue deliberately contains no code sketch. Two review rounds found that pre-written sketches for this function dropped a fallback argument and a try/catch. Read the function and derive the split.
Invariants that must survive β verify each against the code before you finish:
resolvePlan is called with three arguments: the subscription, the existing plan as a fallback, and a context object. On an unresolvable price it returns the last-known plan, only defaulting to free when there is none, and stamps context into an alert plus a plan-unresolved event. Calling it with the subscription alone downgrades a paying organization to the free plan on any price-map miss β a money-visible regression. The organization id must stay hoisted before that call, as it is today.
- The meter rotation runs inside the "previous plan differs from new plan" guard. Hoisting it out fires a rotation on every plain renewal.
- The rotation is wrapped in a try/catch whose catch body also clears the reset-triggered flag so the period-rollover fallback still runs. Preserve both the catch and the flag reset. An escaping throw here causes a webhook retry storm.
- When period and plan change in the same event, the period rollover must still run to archive the previous period.
Definition of done: existing webhook suites pass with unchanged counts, plus tests proving (a) a plain renewal does not trigger a plan-change rotation, and (b) a rotation failure still resolves the webhook and still runs the rollover.
Automerge: no
Scope: validated 2026-07-28
Created via /dev:issue
Baseline: line numbers and counts here were verified against master @ c8049d69 (2026-07-28). Master moves β re-verify against current master before starting; the invariants above are what must hold regardless of where the code has drifted to.
What β split the Stripe subscription-updated webhook handler into named sub-handlers.
Why β it is 161 lines at cyclomatic complexity 71, the heaviest function in the stack, with six concerns interleaved and coupled through a shared mutable flag. It is on the billing hot path, so it is also the function where a subtle mistake is most expensive.
Scope β
modules/billing/services/billing.webhook.service.js:419-579(verified on master by AST parse). Internal decomposition only; the module's exported surface does not change.This issue deliberately contains no code sketch. Two review rounds found that pre-written sketches for this function dropped a fallback argument and a try/catch. Read the function and derive the split.
Invariants that must survive β verify each against the code before you finish:
resolvePlanis called with three arguments: the subscription, the existing plan as a fallback, and a context object. On an unresolvable price it returns the last-known plan, only defaulting to free when there is none, and stamps context into an alert plus a plan-unresolved event. Calling it with the subscription alone downgrades a paying organization to the free plan on any price-map miss β a money-visible regression. The organization id must stay hoisted before that call, as it is today.Definition of done: existing webhook suites pass with unchanged counts, plus tests proving (a) a plain renewal does not trigger a plan-change rotation, and (b) a rotation failure still resolves the webhook and still runs the rollover.
Automerge: no
Scope: validated 2026-07-28
Created via /dev:issue
Baseline: line numbers and counts here were verified against
master @ c8049d69(2026-07-28). Master moves β re-verify against current master before starting; the invariants above are what must hold regardless of where the code has drifted to.