Skip to content

Commit 09cccde

Browse files
fix(billing): resolve plan via priceId map, not price.metadata.planId (#3742)
Port trawl's #1250 fix upstream: `customer.subscription.updated` was reading `price.metadata.planId` which is empty in real Stripe webhook payloads (planId lives on the Product, not the Price). Every paid org's plan was silently reset to `free` on every Stripe subscription update event. Fix: - Add `buildPriceIdToPlanMap()` — builds `Map<priceId, planName>` from `config.stripe.prices` at boot (monthly + annual per plan) - Rewrite `resolvePlan()` — looks up plan via priceId map first; falls back to `price.metadata.planId` for legacy test fixtures; final fallback `free` + warn - Sync `cancelAt`/`cancelAtPeriodEnd` on `customer.subscription.updated` — writes the fields added by #3741 so the UI can show pending cancellation dates - Use priceId map in `previousPlan` detection (plan change block in updated handler) - Strip trawl-specific `analytics.capture('subscription_changed')` calls (not ported) Also ports `retryWithBackoff` + `isNonTransientStripeError` patterns (already in devkit from a prior PR — kept identical to trawl). Adds 20 unit tests covering: priceId map resolution, annual/monthly variants, unknown priceId fallback to free, legacy metadata fallback, priority of map over metadata, cancelAt unix→Date conversion, null cancel clear, absent field guard, handleSubscriptionCreated race safety (E11000 idempotency), and Dashboard subscription creation path. Closes #3742
1 parent 889626a commit 09cccde

2 files changed

Lines changed: 548 additions & 5 deletions

File tree

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

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,45 @@ const validatePlan = (plan) => {
5757
*/
5858
const planRanks = Object.fromEntries((config.billing?.plans || []).map((p, i) => [p, i]));
5959

60+
/**
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.
65+
*
66+
* Fix: resolvePlan no longer reads price.metadata (always empty in real Stripe webhook payloads).
67+
*/
68+
const buildPriceIdToPlanMap = () => {
69+
const map = {};
70+
const stripePrices = config.stripe?.prices || {};
71+
for (const [planId, intervals] of Object.entries(stripePrices)) {
72+
if (!validPlans.has(planId)) continue;
73+
if (intervals.monthly) map[intervals.monthly] = planId;
74+
if (intervals.annual) map[intervals.annual] = planId;
75+
}
76+
return map;
77+
};
78+
const priceIdToPlan = buildPriceIdToPlanMap();
79+
6080
/**
6181
* @description Resolve the plan name from a Stripe subscription object.
62-
* In webhook payloads, price.product is typically a string ID (not expanded),
63-
* so we check price.metadata first, then fall back to plan.metadata.
82+
* Strategy (most-specific first):
83+
* 1. config price-ID map (price_xxx → planId) — robust, no metadata dependency.
84+
* 2. price.metadata.planId legacy fallback (works only if metadata was explicitly set).
85+
* 3. plan.metadata.planId further legacy fallback.
86+
* 4. 'free' when nothing resolves.
6487
* @param {Object} subscription - Stripe subscription object
6588
* @returns {string} plan name
6689
*/
6790
const resolvePlan = (subscription) => {
6891
const item = subscription.items?.data?.[0];
69-
const raw = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
70-
return validatePlan(raw) || 'free';
92+
const priceId = item?.price?.id;
93+
if (priceId && priceIdToPlan[priceId]) {
94+
return priceIdToPlan[priceId];
95+
}
96+
// Legacy fallback: price metadata set explicitly (e.g. test fixtures or manual Stripe edits)
97+
const rawMeta = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
98+
return validatePlan(rawMeta) || 'free';
7199
};
72100

73101
/**
@@ -386,6 +414,18 @@ const handleSubscriptionUpdated = async (subscription, event) => {
386414
status: subscription.status,
387415
};
388416
if (newPeriodStart) fields.currentPeriodStart = newPeriodStart;
417+
// Pending cancellation — record cancel_at_period_end flag and cancel_at date
418+
// so the UI can show the user when their plan actually ends instead of treating
419+
// it as an immediate downgrade. Does NOT change `plan` — subscription stays on
420+
// the current plan until cancel_at.
421+
if (typeof subscription.cancel_at_period_end === 'boolean') {
422+
fields.cancelAtPeriodEnd = subscription.cancel_at_period_end;
423+
}
424+
if (subscription.cancel_at) {
425+
fields.cancelAt = new Date(subscription.cancel_at * 1000);
426+
} else if (subscription.cancel_at === null) {
427+
fields.cancelAt = null;
428+
}
389429

390430
const updated = await SubscriptionRepository.updateIfEventNewer(String(existing._id), event.created, event.id, fields, 'subscription');
391431
if (!updated) {
@@ -423,7 +463,9 @@ const handleSubscriptionUpdated = async (subscription, event) => {
423463
const previousItems = event?.data?.previous_attributes?.items?.data;
424464
let planChangeResetTriggered = false;
425465
if (previousItems) {
426-
const previousPlan = previousItems[0]?.price?.metadata?.planId
466+
const previousPriceId = previousItems[0]?.price?.id;
467+
const previousPlan = (previousPriceId && priceIdToPlan[previousPriceId])
468+
|| previousItems[0]?.price?.metadata?.planId
427469
|| previousItems[0]?.plan?.metadata?.planId
428470
|| null;
429471
if (previousPlan && previousPlan !== newPlan) {

0 commit comments

Comments
 (0)