Skip to content

Commit 319a675

Browse files
fix(billing): stop the webhook from forcing free on an unresolvable price (#3974)
* fix(billing): stop the webhook from forcing free on an unresolvable price customer.subscription.{updated,created} and invoice.payment_succeeded resolved an unmappable Stripe price (no config.stripe.prices entry AND no valid metadata.planId — e.g. a manually-sold enterprise price) to a hardcoded 'free' fallback, silently downgrading a paying org on the next webhook delivery. #3964 fixed the same defect on the admin (409 abort) and reconcile (skip-comparison) call sites but explicitly left the webhook's fallback in place, since a webhook cannot abort mid-flight. resolvePlan now retains the org/subscription's already-loaded current plan on an unresolvable price instead of forcing 'free', and emits billing.webhook.plan_unresolved (mirrors the billing.refund.unresolved alert pattern already used in this file) so ops can review. Only a subscription with zero prior plan reference anywhere (a genuinely new org/customer) still defaults to 'free' — the least-harmful default for a fresh signup. A genuinely-free priceId/metadata mapping still resolves 'free' normally, unaffected. Closes #3970 Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup * fix(billing): wire the plan_unresolved ops alert listener billing.webhook.plan_unresolved (#3970) was emitted from billing.webhook.service.js but had no billingEvents.on() listener — EventEmitter.emit silently no-ops with zero listeners, so the manual-review alert never reached the ops/ntfy channel. Registers the listener in billing.init.js mirroring the billing.refund.unresolved / billing.reconciliation.divergence pattern exactly (logger.error with ntfyPriority: 4 — same "manual review" priority as those two siblings). Adds the matching unit test case. Claude-Session: https://claude.ai/code/session_01WfNC8bt1TgL4AsiYgCEGup
1 parent 82b5cf0 commit 319a675

8 files changed

Lines changed: 280 additions & 23 deletions

ERRORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@ Use this file as a compact memory of recurring AI mistakes.
3434
- [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
3535
- [2026-07-16] architecture: `users.service.js remove()`'s sole-owner cascade called `OrganizationsRepository.remove()` directly instead of `organizations.crud.service.js#remove()`, silently skipping `runOrganizationRemovedHandlers` (the `onOrganizationRemoved` seam `modules/tasks/tasks.init.js` registers for org-scoped task cleanup) -> deleting an org must always route through the owning module's SERVICE method, never straight to its Repository, even for a "bare" cascade delete from another module — a direct-repository shortcut silently bypasses any registered removal hook; a stale cross-test fixture in `tasks.integration.tests.js` (an orphaned-but-still-existing task reused across unrelated test cases) was inadvertently relying on this bug and needed a properly isolated fixture once the cascade actually cleaned up; see pierreb-devkit/Node#3965
3636
- [2026-07-17] error handling: shared helpers (`lib/helpers/mailer/index.js#sendMail`, `modules/audit/services/audit.service.js#log`) wrapped the failing call in their own try/catch, logged a single generic line, and resolved `null` -> every caller's own context-rich `.catch()` (action/userId/orgId) became dead code on a real outage, since the promise never rejected; a central helper must let the underlying call's rejection propagate and let each CALL SITE own the "never break the main flow" `.catch()` with its own context — never swallow centrally just because most callers currently attach a catch; see pierreb-devkit/Node#3966
37+
- [2026-07-17] billing/stripe: #3964 fixed the admin (409 abort) and reconcile (skip-comparison) call sites of the shared plan resolver but left `billing.webhook.service.js resolvePlan`'s `?? 'free'` fallback in place -> a webhook for an existing paid org whose Stripe price is unresolvable (unmapped `priceId`, no valid `metadata.planId` — e.g. a manually-sold enterprise price) silently downgraded the org to `'free'` on the next `customer.subscription.updated`/`.created`/`invoice.payment_succeeded` event; a webhook cannot 409-abort mid-flight like the admin path, so the fix instead retains the org's/subscription's already-loaded `.plan` (never a hardcoded `'free'`) and emits `billing.webhook.plan_unresolved` for manual review — only a brand-new subscription with zero prior plan reference (no DB row anywhere for that org) still defaults to `'free'`; see pierreb-devkit/Node#3970

modules/billing/billing.init.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,22 @@ export default async (app) => {
234234
});
235235
});
236236

237+
// billing.webhook.plan_unresolved — priority 4 (high): webhook couldn't resolve a Stripe
238+
// subscription price to a plan; the write retained the last-known plan instead of forcing
239+
// 'free' (#3970), and this alert is the manual-review half of that guard.
240+
billingEvents.on('billing.webhook.plan_unresolved', (payload) => {
241+
const { organizationId, eventId, stripeSubscriptionId, priceId, retainedPlan, hadKnownPlan } = payload;
242+
logger.error('[billing.init] ALERT: webhook plan unresolved — manual review required', {
243+
organizationId,
244+
eventId,
245+
stripeSubscriptionId,
246+
priceId,
247+
retainedPlan,
248+
hadKnownPlan,
249+
ntfyPriority: 4,
250+
});
251+
});
252+
237253
// Prevent accidental crash if any future code emits 'error' with no listener
238254
// (Node default behaviour: throws if no 'error' listener is registered).
239255
// Registered here (after config is ready) so events.js stays config-free and importable without ordering hazards.

modules/billing/lib/events.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import { EventEmitter } from 'events';
1818
* - `billing.refund.unresolved` — emitted when a charge.refunded event cannot be correlated
1919
* to a known session/org (missing metadata on charge AND PaymentIntent, or ambiguous pack).
2020
* Payload: { chargeId, paymentIntentId, refundAmount } | { reason, orgId, stripeSessionId, amountRefundedCents }
21+
* - `billing.webhook.plan_unresolved` — emitted when a webhook's Stripe subscription price
22+
* cannot be resolved to a plan (unmapped priceId AND no valid metadata.planId) — the write
23+
* retains the last-known plan (or 'free' for a genuinely new org) instead of forcing 'free'.
24+
* Payload: { organizationId, stripeSubscriptionId, priceId, retainedPlan, hadKnownPlan, eventId }
2125
*
2226
* NOTE: The 'error' event listener is registered in billing.init.js (after config is ready)
2327
* to avoid module-load-time config reads in this low-level singleton.

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

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,55 @@ const validatePlan = (plan) => {
6060
const planRanks = Object.fromEntries((config.billing?.plans || []).map((p, i) => [p, i]));
6161

6262
/**
63-
* @description Resolve the plan name from a Stripe subscription object.
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).
63+
* @description Resolve the plan name from a Stripe subscription object for a webhook write path.
64+
* Delegates to the shared priceId→plan resolver (see `../lib/billing.planResolver.js`, also used
65+
* by the admin force-sync tool and the reconcile cron — #3964/#1250). Strategy (most-specific
66+
* first): config priceId map → price/plan.metadata.planId legacy fallback → unresolvable.
67+
*
68+
* On unresolvable (unmapped priceId AND no valid metadata.planId — e.g. a manually-sold
69+
* enterprise price, or a misconfigured `config.stripe.prices`), this does NOT force 'free'.
70+
* Unlike the admin tool (aborts the write with a 409) or the reconcile cron (skips the plan
71+
* comparison, log-only), a webhook cannot abort mid-flight — an event still needs a plan
72+
* written. Instead it retains `fallbackPlan` (the org/subscription's last-known plan, passed by
73+
* the caller from the already-loaded DB doc) so an ambiguous price never silently downgrades a
74+
* paying org, and emits an alert for manual review (mirrors the `billing.refund.unresolved`
75+
* alert pattern used elsewhere in this file). When `fallbackPlan` is itself unknown (no prior
76+
* plan reference — a genuinely brand-new subscription/org), defaults to 'free': the
77+
* least-harmful default for a fresh signup, an ops/config error either way, and still surfaced
78+
* via the alert (#3970).
6979
* @param {Object} subscription - Stripe subscription object
80+
* @param {string|null} [fallbackPlan] - Last-known plan to retain on ambiguity; null/undefined → 'free'.
81+
* @param {Object} [ctx={}] - Alert context merged into the log/emit payload (e.g. organizationId, eventId).
7082
* @returns {string} plan name
7183
*/
72-
const resolvePlan = (subscription) =>
73-
resolvePlanFromSubscription(subscription, { logPrefix: '[billing.webhook]' }) ?? 'free';
84+
const resolvePlan = (subscription, fallbackPlan = null, ctx = {}) => {
85+
const resolved = resolvePlanFromSubscription(subscription, { logPrefix: '[billing.webhook]' });
86+
if (resolved !== null) return resolved;
87+
88+
const hadKnownPlan = fallbackPlan !== null && fallbackPlan !== undefined;
89+
const retainedPlan = hadKnownPlan ? fallbackPlan : 'free';
90+
const priceId = subscription?.items?.data?.[0]?.price?.id ?? null;
91+
92+
logger.error(
93+
'[billing.webhook] resolvePlan — unresolvable price, NOT forcing free (retaining last-known plan)',
94+
{ ...ctx, stripeSubscriptionId: subscription?.id, priceId, retainedPlan, hadKnownPlan },
95+
);
96+
try {
97+
billingEvents.emit('billing.webhook.plan_unresolved', {
98+
...ctx,
99+
stripeSubscriptionId: subscription?.id,
100+
priceId,
101+
retainedPlan,
102+
hadKnownPlan,
103+
});
104+
} catch (evtErr) {
105+
logger.error('[billing.webhook] billing.webhook.plan_unresolved listener error (non-fatal)', {
106+
error: evtErr?.message ?? String(evtErr),
107+
stack: evtErr?.stack,
108+
});
109+
}
110+
return retainedPlan;
111+
};
74112

75113
/**
76114
* @description Sync the organization plan field to match the subscription plan.
@@ -369,7 +407,10 @@ const handleSubscriptionUpdated = async (subscription, event) => {
369407
const existing = await SubscriptionRepository.findByStripeSubscriptionId(subscription.id);
370408
if (!existing) return;
371409

372-
const newPlan = resolvePlan(subscription);
410+
// Hoisted so both resolvePlan's alert context and the rest of the handler share one value.
411+
const organizationId = String(existing.organization?._id || existing.organization);
412+
413+
const newPlan = resolvePlan(subscription, existing.plan, { organizationId, eventId: event?.id });
373414
// Stripe API ≥ 2025-08-27 moved current_period_start/end to items.data[0].
374415
// Read from items first, fall back to top-level for older API versions.
375416
const rawPeriodStart = subscription.items?.data?.[0]?.current_period_start ?? subscription.current_period_start;
@@ -407,7 +448,6 @@ const handleSubscriptionUpdated = async (subscription, event) => {
407448
return;
408449
}
409450

410-
const organizationId = String(existing.organization?._id || existing.organization);
411451
await syncOrganizationPlan(organizationId, effectivePlan);
412452

413453
if (isIncompleteExpired) {
@@ -633,13 +673,18 @@ const handleInvoicePaymentSucceeded = async (invoice, event) => {
633673
const isPastDue = existing.pastDueSince !== null && existing.pastDueSince !== undefined;
634674
const fields = isPastDue ? { pastDueSince: null, status: 'active' } : {};
635675

676+
// Hoisted so both resolvePlan's alert context and the rest of the handler share one value.
677+
const organizationId = String(existing.organization?._id || existing.organization);
678+
636679
let resolvedPlan = null;
637680
if (isPastDue) {
638681
try {
639682
const stripe = getStripe();
640683
if (!stripe) throw new Error('Stripe not configured');
641684
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId);
642-
resolvedPlan = resolvePlan(stripeSub);
685+
// Restore path: an unresolvable price here must NOT re-write 'free' over a dunning-downgraded
686+
// sub — retain existing.plan (the last-known plan) and alert instead (#3970).
687+
resolvedPlan = resolvePlan(stripeSub, existing.plan, { organizationId, stripeSubscriptionId, eventId: event?.id });
643688
fields.plan = resolvedPlan;
644689
} catch (stripeErr) {
645690
logger.warn('[billing.webhook] invoice.payment_succeeded — Stripe re-fetch failed, plan not restored', {
@@ -649,8 +694,6 @@ const handleInvoicePaymentSucceeded = async (invoice, event) => {
649694
}
650695
}
651696

652-
const organizationId = String(existing.organization?._id || existing.organization);
653-
654697
const updated = await SubscriptionRepository.updateIfEventNewer(
655698
String(existing._id),
656699
event.created,
@@ -1120,7 +1163,6 @@ const handleChargeDisputeFundsWithdrawn = async (dispute, event) => {
11201163
*/
11211164
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
11221165
const handleSubscriptionCreated = async (subscription, event) => {
1123-
const newPlan = resolvePlan(subscription);
11241166
const rawPeriodStart = subscription.items?.data?.[0]?.current_period_start ?? subscription.current_period_start;
11251167
const newPeriodStart = rawPeriodStart ? new Date(rawPeriodStart * 1000) : undefined;
11261168

@@ -1145,6 +1187,10 @@ const handleSubscriptionCreated = async (subscription, event) => {
11451187
}
11461188

11471189
const organizationId = String(orgSub.organization?._id || orgSub.organization);
1190+
// orgSub was found via stripeCustomerId, so the org already carries a plan (e.g. a Dashboard
1191+
// second-subscription edge case) — retain it on an unresolvable price rather than defaulting
1192+
// to 'free', since syncOrganizationPlan below writes the shared org-level plan field (#3970).
1193+
const newPlan = resolvePlan(subscription, orgSub.plan, { organizationId, eventId: event?.id });
11481194
logger.info('[billing.webhook] subscription.created — creating DB row for Dashboard/Payment Link subscription', {
11491195
stripeSubscriptionId: subscription.id,
11501196
organizationId,
@@ -1179,6 +1225,9 @@ const handleSubscriptionCreated = async (subscription, event) => {
11791225
return;
11801226
}
11811227

1228+
const organizationId = String(existing.organization?._id || existing.organization);
1229+
const newPlan = resolvePlan(subscription, existing.plan, { organizationId, eventId: event?.id });
1230+
11821231
const fields = {
11831232
plan: newPlan,
11841233
status: subscription.status,
@@ -1197,7 +1246,6 @@ const handleSubscriptionCreated = async (subscription, event) => {
11971246
return;
11981247
}
11991248

1200-
const organizationId = String(existing.organization?._id || existing.organization);
12011249
await syncOrganizationPlan(organizationId, newPlan);
12021250
};
12031251

modules/billing/tests/billing.init.ops-listeners.unit.tests.js

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { jest, describe, test, afterEach, expect } from '@jest/globals';
1111
* - billing.dispute.lost (priority 5)
1212
* - billing.refund.unresolved (priority 4)
1313
* - billing.reconciliation.divergence (priority 4)
14+
* - billing.webhook.plan_unresolved (priority 4)
1415
* - error event listener on billingEvents singleton
1516
*
1617
* Each listener must:
@@ -90,6 +91,7 @@ describe('billing.init ops-listeners unit tests:', () => {
9091
realBillingEvents.removeAllListeners('billing.dispute.lost');
9192
realBillingEvents.removeAllListeners('billing.refund.unresolved');
9293
realBillingEvents.removeAllListeners('billing.reconciliation.divergence');
94+
realBillingEvents.removeAllListeners('billing.webhook.plan_unresolved');
9395
realBillingEvents.removeAllListeners('error');
9496
}
9597
jest.restoreAllMocks();
@@ -249,6 +251,55 @@ describe('billing.init ops-listeners unit tests:', () => {
249251
});
250252
});
251253

254+
// ---------------------------------------------------------------------------
255+
// billing.webhook.plan_unresolved
256+
// ---------------------------------------------------------------------------
257+
describe('billing.webhook.plan_unresolved listener:', () => {
258+
test('fires logger.error with full payload spread + ntfyPriority 4', async () => {
259+
await setup();
260+
261+
const payload = {
262+
organizationId: '507f1f77bcf86cd799439055',
263+
eventId: 'evt_plan_unresolved',
264+
stripeSubscriptionId: 'sub_live_bbb',
265+
priceId: 'price_unmapped',
266+
retainedPlan: 'growth',
267+
hadKnownPlan: true,
268+
};
269+
270+
realBillingEvents.emit('billing.webhook.plan_unresolved', payload);
271+
await new Promise((resolve) => setImmediate(resolve));
272+
273+
expect(mockLogger.error).toHaveBeenCalledWith(
274+
'[billing.init] ALERT: webhook plan unresolved — manual review required',
275+
expect.objectContaining({
276+
organizationId: '507f1f77bcf86cd799439055',
277+
eventId: 'evt_plan_unresolved',
278+
stripeSubscriptionId: 'sub_live_bbb',
279+
priceId: 'price_unmapped',
280+
retainedPlan: 'growth',
281+
hadKnownPlan: true,
282+
ntfyPriority: 4,
283+
}),
284+
);
285+
});
286+
287+
test('plan_unresolved: emitting the event does not throw synchronously', async () => {
288+
await setup();
289+
290+
const payload = {
291+
organizationId: 'org',
292+
eventId: 'evt_ok',
293+
stripeSubscriptionId: 'sub_ok',
294+
priceId: 'price_ok',
295+
retainedPlan: 'free',
296+
hadKnownPlan: false,
297+
};
298+
expect(() => realBillingEvents.emit('billing.webhook.plan_unresolved', payload)).not.toThrow();
299+
await new Promise((resolve) => setImmediate(resolve));
300+
});
301+
});
302+
252303
// ---------------------------------------------------------------------------
253304
// Multiple listeners all fire (sanity)
254305
// ---------------------------------------------------------------------------
@@ -259,11 +310,12 @@ describe('billing.init ops-listeners unit tests:', () => {
259310
realBillingEvents.emit('billing.dispute.lost', { disputeId: 'dp_b', chargeId: 'ch_b', organizationId: 'o', stripeSessionId: 'cs', amount: 200 });
260311
realBillingEvents.emit('billing.refund.unresolved', { chargeId: 'ch_c' });
261312
realBillingEvents.emit('billing.reconciliation.divergence', { organizationId: 'o', subscriptionId: 's', stripeSubscriptionId: 'sub_x', db: {}, stripe: {}, statusMismatch: true, planMismatch: false });
313+
realBillingEvents.emit('billing.webhook.plan_unresolved', { organizationId: 'o', eventId: 'evt_d', stripeSubscriptionId: 'sub_y', priceId: 'price_y', retainedPlan: 'free', hadKnownPlan: false });
262314

263315
await new Promise((resolve) => setImmediate(resolve));
264316

265-
// 4 listeners, 1 logger.error call each = 4 total
266-
expect(mockLogger.error).toHaveBeenCalledTimes(4);
317+
// 5 listeners, 1 logger.error call each = 5 total
318+
expect(mockLogger.error).toHaveBeenCalledTimes(5);
267319
});
268320

269321
// ---------------------------------------------------------------------------

modules/billing/tests/billing.webhook.integration.tests.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,11 @@ describe('Billing webhook integration tests:', () => {
297297
expect(mockOrganizationRepository.setPlan).not.toHaveBeenCalled();
298298
});
299299

300-
test('should fall back to free when plan metadata is invalid', async () => {
301-
const existing = { _id: subId, organization: orgId };
300+
test('#3970: retains current plan (does NOT force free) when plan metadata is invalid', async () => {
301+
// Existing paid org — an unrecognized metadata.planId is unresolvable, must not downgrade.
302+
const existing = { _id: subId, organization: orgId, plan: 'starter' };
302303
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
304+
const { default: billingEvents } = await import('../lib/events.js');
303305

304306
await WebhookService.handleSubscriptionUpdated(
305307
{
@@ -316,9 +318,14 @@ describe('Billing webhook integration tests:', () => {
316318
subId,
317319
expect.any(Number),
318320
expect.any(String),
319-
expect.objectContaining({ plan: 'free' }),
321+
expect.objectContaining({ plan: 'starter' }),
320322
'subscription',
321323
);
324+
expect(mockOrganizationRepository.setPlan).toHaveBeenCalledWith(orgId, 'starter');
325+
expect(billingEvents.emit).toHaveBeenCalledWith(
326+
'billing.webhook.plan_unresolved',
327+
expect.objectContaining({ organizationId: orgId, retainedPlan: 'starter', hadKnownPlan: true }),
328+
);
322329
});
323330
});
324331

0 commit comments

Comments
 (0)