Skip to content

Commit 6ec833e

Browse files
fix(billing): re-apply lost C.2 review nits (warn-on-unknown-priceId + 4 hardening)
5 Copilot review findings from PR #3743 (#1250 P0) were silently lost because a controller-side fix commit was never pushed to origin before the squash-merge. Re-applied fixes: 1. JSDoc for buildPriceIdToPlanMap: accurate description with @returns tag 2. resolvePlan: warn log when priceId not in map and metadata empty (operationally critical — same silent-free-downgrade shape as the P0 it was meant to prevent) 3. cancel_at truthy check → typeof === 'number' (prevents cancel_at=0 being skipped) 4. previousPlan: validatePlan() before comparison (rejects stale/invalid metadata values) 5. Test comment: corrects misleading "retryWithBackoff setTimeout suppression" claim Refs: #3743, plan 2026-05-30-trawl-devkit-perfect-alignment.md
1 parent 1230870 commit 6ec833e

2 files changed

Lines changed: 27 additions & 9 deletions

File tree

modules/billing/services/billing.webhook.service.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,15 @@ const validatePlan = (plan) => {
5858
const planRanks = Object.fromEntries((config.billing?.plans || []).map((p, i) => [p, i]));
5959

6060
/**
61-
* Reverse-map from Stripe price ID → plan name, built from config.stripe.prices at boot.
62-
* config.stripe.prices = { growth: { monthly: 'price_xxx', annual: 'price_yyy' }, pro: { ... } }
63-
* This avoids relying on price.metadata.planId (empty on Stripe webhook payloads — planId
64-
* lives on the Product, not the Price) and avoids a Stripe API call per webhook.
61+
* Build a reverse-map from Stripe price ID → plan name, sourced from `config.stripe.prices`
62+
* at module load. Shape: `{ growth: { monthly: 'price_xxx', annual: 'price_yyy' }, pro: {...} }`.
6563
*
66-
* Fix: resolvePlan no longer reads price.metadata (always empty in real Stripe webhook payloads).
64+
* Why: `price.metadata.planId` is empty on real Stripe webhook payloads — `planId` lives on
65+
* the Product, not the Price exposed by `customer.subscription.updated`. The reverse-map gives
66+
* a robust priceId→plan lookup without an extra Stripe API call per webhook. `resolvePlan`
67+
* keeps `price.metadata.planId` as a legacy fallback (test fixtures, manual Stripe edits).
68+
*
69+
* @returns {Record<string, string>} priceId → planId map (built once at module init)
6770
*/
6871
const buildPriceIdToPlanMap = () => {
6972
const map = {};
@@ -95,7 +98,17 @@ const resolvePlan = (subscription) => {
9598
}
9699
// Legacy fallback: price metadata set explicitly (e.g. test fixtures or manual Stripe edits)
97100
const rawMeta = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
98-
return validatePlan(rawMeta) || 'free';
101+
const fromMeta = validatePlan(rawMeta);
102+
if (fromMeta) return fromMeta;
103+
// Last-resort fallback — warn so misconfigured config.stripe.prices is visible
104+
// (otherwise this silently downgrades paid orgs to 'free', which is the exact bug #1250 fixed).
105+
if (priceId) {
106+
logger.warn('[billing.webhook] resolvePlan: priceId not in priceIdToPlan map and metadata empty — falling back to free', {
107+
priceId,
108+
stripeSubscriptionId: subscription?.id,
109+
});
110+
}
111+
return 'free';
99112
};
100113

101114
/**
@@ -421,7 +434,7 @@ const handleSubscriptionUpdated = async (subscription, event) => {
421434
if (typeof subscription.cancel_at_period_end === 'boolean') {
422435
fields.cancelAtPeriodEnd = subscription.cancel_at_period_end;
423436
}
424-
if (subscription.cancel_at) {
437+
if (typeof subscription.cancel_at === 'number') {
425438
fields.cancelAt = new Date(subscription.cancel_at * 1000);
426439
} else if (subscription.cancel_at === null) {
427440
fields.cancelAt = null;
@@ -464,10 +477,13 @@ const handleSubscriptionUpdated = async (subscription, event) => {
464477
let planChangeResetTriggered = false;
465478
if (previousItems) {
466479
const previousPriceId = previousItems[0]?.price?.id;
467-
const previousPlan = (previousPriceId && priceIdToPlan[previousPriceId])
480+
const previousRaw = (previousPriceId && priceIdToPlan[previousPriceId])
468481
|| previousItems[0]?.price?.metadata?.planId
469482
|| previousItems[0]?.plan?.metadata?.planId
470483
|| null;
484+
// Validate against the canonical plan enum — legacy metadata can carry stale or invalid
485+
// values that would otherwise emit plan.changed + trigger forceRotateForPlanChange with junk.
486+
const previousPlan = validatePlan(previousRaw);
471487
if (previousPlan && previousPlan !== newPlan) {
472488
const prevRank = planRanks[previousPlan];
473489
const newRank = planRanks[newPlan];

modules/billing/tests/billing.webhook.priceid-map.unit.tests.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ const makeBaseSetup = (configOverride = {}) => {
6868
model: () => ({}),
6969
},
7070
}));
71-
// Suppress retryWithBackoff delays in tests (replace setTimeout with immediate resolve)
71+
// Mock the failed-backfill repository so the retry recorder is a no-op in unit tests
72+
// (does not suppress the retryWithBackoff setTimeout delays themselves — tests that exercise
73+
// retry pathways still pay the real delay, so keep retry attempt counts low in fixtures).
7274
jest.unstable_mockModule('../repositories/billing.failedBackfill.repository.js', () => ({
7375
default: { record: jest.fn().mockResolvedValue({}) },
7476
}));

0 commit comments

Comments
 (0)