Skip to content

Commit 6df6d8c

Browse files
fix(billing): resolve Stripe plan via the priceId map in admin sync and reconcile
resolveStripePlan in billing.admin.service.js and billing.reconcile.service.js read only price/plan.metadata.planId and fell back to 'free' — the exact defect #1250 fixed in the webhook handler via a priceId→planId map built from config.stripe.prices, never propagated here. Real Stripe Price objects carry no metadata.planId, so: - syncOrgFromStripe (a DB write) could silently downgrade a paying org to free on the very tool ops runs to resolve a billing divergence. - runReconciliation (log-only) emitted a false planMismatch alert for every paid org, drowning real divergences. Extracted the webhook's resolvePlan/buildPriceIdToPlanMap into a shared modules/billing/lib/billing.planResolver.js so all three call sites resolve via ONE implementation. The shared resolver returns null (never 'free') when unresolved; the admin path now ABORTS the write (409) instead of guessing, and the reconcile path skips the plan comparison instead of alerting. Replaced the unrealistic metadata.planId-only test fixtures with priceId-based ones matching real Stripe payloads, and added regression coverage: a paid subscription with no metadata.planId resolves correctly in both admin and reconcile, and admin sync never writes 'free' for it. Closes #3964 Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup
1 parent 8c3b208 commit 6df6d8c

11 files changed

Lines changed: 455 additions & 86 deletions

ERRORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ Use this file as a compact memory of recurring AI mistakes.
3030
- [2026-05-31] billing/stripe: reading `price.metadata.planId` in `customer.subscription.updated` webhook handler -> field is EMPTY in real Stripe webhook payloads (planId lives on the Product, not the Price); use a `priceId → plan` map built at boot from `config.stripe.prices` instead; see pierreb-devkit/Node#3742
3131
- [2026-06-04] repository: top-level `const Foo = mongoose.model('Foo')` in a repository file -> this is evaluated at import time; safe in an HTTP server (loadModels() runs first) but silently crashes standalone scripts (crons, migrations) with `MissingSchemaError` when import order differs; tests miss it because jest mocks intercept the module entirely; fix = lazy getter `const Foo = () => mongoose.model('Foo')` (call sites: `Foo().find(...)`) or dynamic import after `loadModels()` in the entrypoint; see pierreb-devkit/Node#3789
3232
- [2026-06-15] deps/audit: leaving `npm audit` advisories unaddressed on the assumption they need a major bump -> run `npm audit fix` (never `--force`) first; the runtime-tree DoS/ReDoS items (`qs`, `path-to-regexp`, `brace-expansion`) all fixed via in-range bumps, no residual. These are DoS-class but NOT attacker-reachable in this stack: Express route patterns are static (no user-controlled `path-to-regexp` input) and `qs`/`brace-expansion` only parse server-side query strings under fixed code paths — still bump them to keep the tree clean and avoid scanner noise.
33+
- [2026-07-16] billing/stripe: the #3742 priceId-map fix (`resolvePlan`/`buildPriceIdToPlanMap`) was applied only to the webhook handler; `billing.admin.service.js` `resolveStripePlan` (admin force-sync, a DB WRITE path) and `billing.reconcile.service.js` `resolveStripePlan` (LOG-ONLY divergence check) kept their own copies reading only `metadata.planId` -> a paid org's Stripe subscription (which never carries `metadata.planId`) resolved to `'free'` on admin sync (silently downgrading a paying org) and produced a false `planMismatch` alert on every reconcile run; fix = one shared resolver (`modules/billing/lib/billing.planResolver.js`) used by all three call sites, and a null-unresolved sentinel (never guess `'free'`) so the admin write path ABORTS instead of downgrading and the reconcile log path skips the comparison instead of alerting; see pierreb-devkit/Node#3964

modules/billing/controllers/billing.admin.controller.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ const adminSyncFromStripe = async (req, res) => {
139139
return responses.success(res, 'subscription synced from Stripe')(result);
140140
} catch (err) {
141141
const status = err.status ?? 500;
142-
const title = status === 404 ? 'Not Found' : status === 422 ? 'Unprocessable Entity' : status === 502 ? 'Bad Gateway' : 'Internal Server Error';
142+
const title = status === 404 ? 'Not Found'
143+
: status === 422 ? 'Unprocessable Entity'
144+
: status === 409 ? 'Conflict'
145+
: status === 502 ? 'Bad Gateway'
146+
: 'Internal Server Error';
143147
return responses.error(res, status, title, 'Failed to sync from Stripe')(err);
144148
}
145149
};
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import config from '../../../config/index.js';
5+
import logger from '../../../lib/services/logger.js';
6+
7+
/**
8+
* Valid plan names from config (immutable set for O(1) lookups).
9+
*/
10+
const validPlans = new Set(config.billing?.plans || ['free', 'starter', 'pro', 'enterprise']);
11+
12+
/**
13+
* Build a reverse-map from Stripe price ID → plan name, sourced from `config.stripe.prices`
14+
* at module load. Shape: `{ starter: { monthly: 'price_xxx', annual: 'price_yyy' }, pro: {...} }`.
15+
*
16+
* Why: `price.metadata.planId` is empty on real Stripe payloads — `planId` lives on the
17+
* Product, not the Price exposed by webhook/subscription objects. The reverse-map gives a
18+
* robust priceId→plan lookup without an extra Stripe API call.
19+
*
20+
* Shared by the webhook handler, the admin force-sync tool, and the reconcile cron so all
21+
* three resolve identically (#3964 / #1250 — the admin + reconcile copies had drifted into
22+
* a metadata-only resolver that silently fell back to 'free' for every real subscription).
23+
*
24+
* @returns {Record<string, string>} priceId → planId map (built once at module init)
25+
*/
26+
export const buildPriceIdToPlanMap = () => {
27+
const map = {};
28+
const stripePrices = config.stripe?.prices || {};
29+
for (const [planId, intervals] of Object.entries(stripePrices)) {
30+
if (!validPlans.has(planId)) continue;
31+
if (intervals?.monthly) map[intervals.monthly] = planId;
32+
if (intervals?.annual) map[intervals.annual] = planId;
33+
}
34+
return map;
35+
};
36+
37+
const priceIdToPlan = buildPriceIdToPlanMap();
38+
39+
/**
40+
* @description Look up the plan for a raw Stripe price ID (no subscription object needed).
41+
* Used when only a bare price ID is available (e.g. reading `previous_attributes` on a
42+
* webhook diff, where the previous line item is a partial object, not a full subscription).
43+
* @param {string|undefined} priceId - Stripe price ID (price_xxx).
44+
* @returns {string|undefined} plan name, or undefined when the price ID is not mapped.
45+
*/
46+
export const lookupPlanByPriceId = (priceId) => (priceId ? priceIdToPlan[priceId] : undefined);
47+
48+
/**
49+
* @description Resolve the plan name from a Stripe subscription object.
50+
* Strategy (most-specific first):
51+
* 1. config priceId map (price_xxx → planId) — robust, no metadata dependency.
52+
* 2. price.metadata.planId / plan.metadata.planId legacy fallback (test fixtures, manual
53+
* Stripe edits) — validated against the known plan enum.
54+
* 3. `null` when nothing resolves — deliberately NOT 'free'. Silently downgrading an
55+
* unresolvable paid subscription to 'free' is the exact defect this resolver replaces;
56+
* the caller decides the safe behavior for its context (log-only vs a DB write).
57+
* @param {Object} subscription - Stripe subscription object.
58+
* @param {Object} [opts]
59+
* @param {string} [opts.logPrefix] - Log tag for the caller module (e.g. '[billing.admin]').
60+
* @returns {string|null} plan name, or null when unresolved.
61+
*/
62+
export const resolvePlanFromSubscription = (subscription, { logPrefix = '[billing]' } = {}) => {
63+
const item = subscription?.items?.data?.[0];
64+
const priceId = item?.price?.id;
65+
if (priceId && priceIdToPlan[priceId]) {
66+
return priceIdToPlan[priceId];
67+
}
68+
69+
// Legacy fallback: price/plan metadata set explicitly (e.g. test fixtures or manual Stripe edits).
70+
const rawMeta = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
71+
if (rawMeta) {
72+
if (validPlans.has(rawMeta)) return rawMeta;
73+
logger.warn(`${logPrefix} resolvePlanFromSubscription: unrecognized planId in metadata`, {
74+
raw: rawMeta,
75+
validPlans: [...validPlans],
76+
});
77+
return null;
78+
}
79+
80+
// Nothing resolved — warn so a misconfigured config.stripe.prices (or an unmapped plan
81+
// such as a manually-sold enterprise price) is visible instead of silently downgrading.
82+
if (priceId) {
83+
logger.warn(`${logPrefix} resolvePlanFromSubscription: priceId not in priceIdToPlan map and no metadata`, {
84+
priceId,
85+
stripeSubscriptionId: subscription?.id,
86+
});
87+
}
88+
return null;
89+
};
90+
91+
export default { buildPriceIdToPlanMap, resolvePlanFromSubscription, lookupPlanByPriceId };

modules/billing/services/billing.admin.service.js

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/**
22
* Module dependencies
33
*/
4-
import config from '../../../config/index.js';
54
import getStripe from '../lib/stripe.js';
65
import logger from '../../../lib/services/logger.js';
76
import { bumpEventMarkers } from '../lib/billing.markerBump.js';
@@ -11,11 +10,7 @@ import OrganizationRepository from '../../organizations/repositories/organizatio
1110
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
1211
import BillingWebhookService from './billing.webhook.service.js';
1312
import { getDollarsToUnitRatio } from '../lib/billing.constants.js';
14-
15-
/**
16-
* Valid plan names from config (immutable set for O(1) lookups).
17-
*/
18-
const validPlans = new Set(config.billing?.plans || ['free', 'starter', 'pro', 'enterprise']);
13+
import { resolvePlanFromSubscription } from '../lib/billing.planResolver.js';
1914

2015
/**
2116
* @function getCustomerStatus
@@ -93,6 +88,29 @@ const syncOrgFromStripe = async (orgId) => {
9388

9489
const stripeSub = await stripe.subscriptions.retrieve(existing.stripeSubscriptionId);
9590
const newPlan = resolveStripePlan(stripeSub);
91+
92+
// Safety guard (#3964): never silently downgrade a paying org to 'free'. If the Stripe
93+
// subscription's price doesn't map via config.stripe.prices AND carries no valid
94+
// metadata.planId, the plan is genuinely unresolvable (misconfigured price map, or an
95+
// unmapped price such as a manually-sold enterprise plan) — abort the write entirely
96+
// rather than guess. Ops must fix the config.stripe.prices mapping (or set the plan via
97+
// bumpOrgPlan) before retrying sync.
98+
if (!newPlan) {
99+
logger.error('[billing.admin] syncOrgFromStripe — cannot resolve plan from Stripe subscription, aborting sync to avoid downgrading org', {
100+
orgId,
101+
stripeSubscriptionId: existing.stripeSubscriptionId,
102+
stripePriceId: stripeSub.items?.data?.[0]?.price?.id ?? null,
103+
currentDbPlan: existing.plan,
104+
});
105+
throw Object.assign(
106+
new Error(
107+
`cannot resolve plan for Stripe subscription ${existing.stripeSubscriptionId} (orgId=${orgId}) — `
108+
+ 'no priceId→plan mapping and no valid metadata.planId; aborting sync to avoid corrupting the org\'s plan',
109+
),
110+
{ status: 409 },
111+
);
112+
}
113+
96114
const newStatus = stripeSub.status;
97115
// Stripe API ≥ 2025-08-27 moved current_period_start to items.data[0].
98116
// Read from items first, fall back to top-level for older API versions (mirrors webhook handler).
@@ -373,17 +391,19 @@ const creditDisputeReinstated = async (chargeId, amountCents, reason, refundRequ
373391

374392
/**
375393
* @function resolveStripePlan
376-
* @description Resolve the plan name from a Stripe subscription object.
377-
* Same heuristic as billing.webhook.service.js (price.metadata.planId first).
394+
* @description Resolve the plan name from a Stripe subscription object, via the shared
395+
* priceId→plan resolver (`../lib/billing.planResolver.js` — also used by the
396+
* webhook handler and the reconcile cron; #3964/#1250).
397+
* Unlike the webhook resolver, this does NOT fall back to 'free' when
398+
* unresolved — `syncOrgFromStripe` is a direct DB write on the admin escape-hatch
399+
* path, and silently downgrading an unresolvable paid subscription to 'free' is
400+
* the exact defect this fixes. Returns `null` so the caller can abort instead.
378401
* @param {Object} subscription - Stripe subscription object.
379-
* @returns {string} plan name.
402+
* @returns {string|null} plan name, or null when unresolved (caller must abort the write).
380403
*/
381404
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
382-
const resolveStripePlan = (subscription) => {
383-
const item = subscription.items?.data?.[0];
384-
const raw = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
385-
return validPlans.has(raw) ? raw : 'free';
386-
};
405+
const resolveStripePlan = (subscription) =>
406+
resolvePlanFromSubscription(subscription, { logPrefix: '[billing.admin]' });
387407

388408
/**
389409
* @function dispatchWebhookEvent

modules/billing/services/billing.reconcile.service.js

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import getStripe from '../lib/stripe.js';
66
import logger from '../../../lib/services/logger.js';
77
import billingEvents from '../lib/events.js';
88
import { currentWeekKey, weekStartDate } from '../lib/billing.isoWeek.js';
9+
import { resolvePlanFromSubscription } from '../lib/billing.planResolver.js';
910

1011
/**
1112
* Page size for the reconciliation cursor — fetch in batches to avoid long-running queries.
@@ -17,23 +18,20 @@ const RECONCILE_PAGE_SIZE = 100;
1718
*/
1819
const RECONCILE_STATUSES = ['active', 'past_due', 'trialing', 'unpaid'];
1920

20-
/**
21-
* Valid plan names from config.
22-
*/
23-
const validPlans = new Set(config.billing?.plans || ['free', 'starter', 'pro', 'enterprise']);
24-
2521
/**
2622
* @function resolveStripePlan
27-
* @description Resolve the plan name from a Stripe subscription object.
23+
* @description Resolve the plan name from a Stripe subscription object, via the shared
24+
* priceId→plan resolver (`../lib/billing.planResolver.js` — also used by the
25+
* webhook handler and the admin force-sync tool; #3964/#1250). Returns `null`
26+
* when unresolved (LOG-ONLY caller — never guesses 'free', which previously
27+
* produced a false `planMismatch` divergence alert for every paid org whose
28+
* price lacked `metadata.planId`, i.e. every real Stripe subscription).
2829
* @param {Object} subscription - Stripe subscription object.
29-
* @returns {string} plan name.
30+
* @returns {string|null} plan name, or null when unresolved.
3031
*/
3132
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
32-
const resolveStripePlan = (subscription) => {
33-
const item = subscription.items?.data?.[0];
34-
const raw = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
35-
return validPlans.has(raw) ? raw : 'free';
36-
};
33+
const resolveStripePlan = (subscription) =>
34+
resolvePlanFromSubscription(subscription, { logPrefix: '[billing.reconcile]' });
3735

3836
/**
3937
* @function runReconciliation
@@ -193,7 +191,19 @@ const _reconcileOne = async (stripe, sub) => {
193191
const stripePlan = resolveStripePlan(stripeSub);
194192

195193
const statusMismatch = sub.status !== stripeStatus;
196-
const planMismatch = sub.plan !== stripePlan;
194+
// stripePlan === null means the price is unresolvable (no priceId mapping, no valid
195+
// metadata) — skip the plan comparison rather than risk a false divergence against a
196+
// guessed 'free'. Log distinctly so a misconfigured config.stripe.prices stays visible.
197+
if (stripePlan === null) {
198+
logger.warn('[billing.reconcile] cannot resolve Stripe plan — skipping plan comparison for this subscription', {
199+
organizationId: orgId,
200+
subscriptionId: String(sub._id),
201+
stripeSubscriptionId: subId,
202+
stripePriceId: stripeSub.items?.data?.[0]?.price?.id ?? null,
203+
dbPlan: sub.plan,
204+
});
205+
}
206+
const planMismatch = stripePlan !== null && sub.plan !== stripePlan;
197207

198208
// Check meter↔extras divergence for this org (Opus C1 detection layer).
199209
// This runs regardless of status/plan match — a healthy subscription can still

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

Lines changed: 9 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import billingEvents from '../lib/events.js';
1616
import { SENTINEL_PENDING } from '../lib/billing.constants.js';
1717
import { retryWithBackoff } from '../lib/billing.retry.js';
1818
import { isNonTransientStripeError } from '../lib/billing.stripe-errors.js';
19+
import { resolvePlanFromSubscription, lookupPlanByPriceId } from '../lib/billing.planResolver.js';
1920
import AnalyticsService from '../../../lib/services/analytics.js';
2021

2122
/**
@@ -58,61 +59,18 @@ const validatePlan = (plan) => {
5859
*/
5960
const planRanks = Object.fromEntries((config.billing?.plans || []).map((p, i) => [p, i]));
6061

61-
/**
62-
* Build a reverse-map from Stripe price ID → plan name, sourced from `config.stripe.prices`
63-
* at module load. Shape: `{ growth: { monthly: 'price_xxx', annual: 'price_yyy' }, pro: {...} }`.
64-
*
65-
* Why: `price.metadata.planId` is empty on real Stripe webhook payloads — `planId` lives on
66-
* the Product, not the Price exposed by `customer.subscription.updated`. The reverse-map gives
67-
* a robust priceId→plan lookup without an extra Stripe API call per webhook. `resolvePlan`
68-
* keeps `price.metadata.planId` as a legacy fallback (test fixtures, manual Stripe edits).
69-
*
70-
* @returns {Record<string, string>} priceId → planId map (built once at module init)
71-
*/
72-
const buildPriceIdToPlanMap = () => {
73-
const map = {};
74-
const stripePrices = config.stripe?.prices || {};
75-
for (const [planId, intervals] of Object.entries(stripePrices)) {
76-
if (!validPlans.has(planId)) continue;
77-
if (intervals.monthly) map[intervals.monthly] = planId;
78-
if (intervals.annual) map[intervals.annual] = planId;
79-
}
80-
return map;
81-
};
82-
const priceIdToPlan = buildPriceIdToPlanMap();
83-
8462
/**
8563
* @description Resolve the plan name from a Stripe subscription object.
86-
* Strategy (most-specific first):
87-
* 1. config price-ID map (price_xxx → planId) — robust, no metadata dependency.
88-
* 2. price.metadata.planId legacy fallback (works only if metadata was explicitly set).
89-
* 3. plan.metadata.planId further legacy fallback.
90-
* 4. 'free' when nothing resolves.
64+
* Delegates to the shared priceId→plan resolver (see `../lib/billing.planResolver.js`,
65+
* also used by the admin force-sync tool and the reconcile cron — #3964/#1250). Strategy
66+
* (most-specific first): config priceId map → price/plan.metadata.planId legacy fallback
67+
* 'free' when nothing resolves (webhook-specific last resort: an event still needs a plan
68+
* written; unlike the admin/reconcile call sites, there is no "abort" option mid-webhook).
9169
* @param {Object} subscription - Stripe subscription object
9270
* @returns {string} plan name
9371
*/
94-
const resolvePlan = (subscription) => {
95-
const item = subscription.items?.data?.[0];
96-
const priceId = item?.price?.id;
97-
if (priceId && priceIdToPlan[priceId]) {
98-
return priceIdToPlan[priceId];
99-
}
100-
// Legacy fallback: price metadata set explicitly (e.g. test fixtures or manual Stripe edits)
101-
const rawMeta = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
102-
const fromMeta = validatePlan(rawMeta);
103-
if (fromMeta) return fromMeta;
104-
// Last-resort fallback — warn only when metadata is also absent so misconfigured
105-
// config.stripe.prices is visible (otherwise this silently downgrades paid orgs to 'free',
106-
// which is the exact bug #1250 fixed). When metadata is present but invalid, validatePlan()
107-
// above already emitted an "unrecognized planId" warning — no double-warn needed.
108-
if (priceId && !rawMeta) {
109-
logger.warn('[billing.webhook] resolvePlan: priceId not in priceIdToPlan map and no metadata — falling back to free', {
110-
priceId,
111-
stripeSubscriptionId: subscription?.id,
112-
});
113-
}
114-
return 'free';
115-
};
72+
const resolvePlan = (subscription) =>
73+
resolvePlanFromSubscription(subscription, { logPrefix: '[billing.webhook]' }) ?? 'free';
11674

11775
/**
11876
* @description Sync the organization plan field to match the subscription plan.
@@ -480,7 +438,7 @@ const handleSubscriptionUpdated = async (subscription, event) => {
480438
let planChangeResetTriggered = false;
481439
if (previousItems) {
482440
const previousPriceId = previousItems[0]?.price?.id;
483-
const previousRaw = (previousPriceId && priceIdToPlan[previousPriceId])
441+
const previousRaw = lookupPlanByPriceId(previousPriceId)
484442
|| previousItems[0]?.price?.metadata?.planId
485443
|| previousItems[0]?.plan?.metadata?.planId
486444
|| null;

0 commit comments

Comments
 (0)