Skip to content

Commit 2e079e9

Browse files
fix(billing): validate webhook plan metadata against known enum values (#3284)
The checkout.session.completed webhook was trusting metadata.plan blindly, which caused 500s when Stripe product IDs leaked into that field. Add validatePlan() helper that checks against config.billing.plans and apply it to both handleCheckoutCompleted and resolvePlan. Invalid values now fall back to 'free' instead of propagating to the database. Also adds 14 integration tests covering all four webhook handlers.
1 parent 8b69820 commit 2e079e9

2 files changed

Lines changed: 311 additions & 2 deletions

File tree

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ import billingEvents from '../lib/events.js';
99

1010
const Organization = mongoose.model('Organization');
1111

12+
/**
13+
* Valid plan names from config (immutable set for O(1) lookups).
14+
*/
15+
const validPlans = new Set(config.billing?.plans || ['free', 'starter', 'pro', 'enterprise']);
16+
17+
/**
18+
* @desc Validate that a plan name is a known enum value.
19+
* @param {string} plan - The plan name to validate.
20+
* @returns {string|null} The plan name if valid, null otherwise.
21+
*/
22+
const validatePlan = (plan) => (validPlans.has(plan) ? plan : null);
23+
1224
/**
1325
* Plan rank lookup — higher index means higher-tier plan.
1426
* Used to determine upgrade vs downgrade.
@@ -24,7 +36,8 @@ const planRanks = Object.fromEntries((config.billing?.plans || []).map((p, i) =>
2436
*/
2537
const resolvePlan = (subscription) => {
2638
const item = subscription.items?.data?.[0];
27-
return item?.price?.metadata?.planId || item?.plan?.metadata?.planId || 'free';
39+
const raw = item?.price?.metadata?.planId || item?.plan?.metadata?.planId;
40+
return validatePlan(raw) || 'free';
2841
};
2942

3043
/**
@@ -46,7 +59,7 @@ const syncOrganizationPlan = async (organizationId, plan) => {
4659
const handleCheckoutCompleted = async (session) => {
4760
const { customer: stripeCustomerId, subscription: stripeSubscriptionId, metadata } = session;
4861
let organizationId = metadata?.organizationId;
49-
const plan = metadata?.plan || 'free';
62+
const plan = validatePlan(metadata?.plan) || 'free';
5063

5164
// Fallback: resolve organizationId from stripeCustomerId if metadata is missing
5265
if (!organizationId) {
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach } from '@jest/globals';
5+
6+
/**
7+
* Integration tests for billing webhook service
8+
*/
9+
describe('Billing webhook integration tests:', () => {
10+
let WebhookService;
11+
let mockSubscriptionRepository;
12+
let mockOrganizationModel;
13+
14+
const orgId = '507f1f77bcf86cd799439011';
15+
const subId = '607f1f77bcf86cd799439022';
16+
17+
beforeEach(async () => {
18+
jest.resetModules();
19+
20+
mockSubscriptionRepository = {
21+
findByOrganization: jest.fn(),
22+
findByStripeCustomerId: jest.fn(),
23+
findByStripeSubscriptionId: jest.fn(),
24+
create: jest.fn(),
25+
update: jest.fn(),
26+
};
27+
28+
mockOrganizationModel = {
29+
findByIdAndUpdate: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }),
30+
};
31+
32+
jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
33+
default: mockSubscriptionRepository,
34+
}));
35+
36+
jest.unstable_mockModule('mongoose', () => {
37+
const actualTypes = {
38+
ObjectId: {
39+
isValid: (id) => /^[a-f\d]{24}$/i.test(id),
40+
},
41+
};
42+
return {
43+
default: {
44+
Types: actualTypes,
45+
model: (name) => {
46+
if (name === 'Organization') return mockOrganizationModel;
47+
return {};
48+
},
49+
},
50+
};
51+
});
52+
53+
jest.unstable_mockModule('../../../config/index.js', () => ({
54+
default: {
55+
billing: {
56+
plans: ['free', 'starter', 'pro', 'enterprise'],
57+
},
58+
},
59+
}));
60+
61+
jest.unstable_mockModule('../lib/events.js', () => ({
62+
default: { emit: jest.fn() },
63+
}));
64+
65+
const mod = await import('../services/billing.webhook.service.js');
66+
WebhookService = mod.default;
67+
});
68+
69+
afterEach(() => {
70+
jest.restoreAllMocks();
71+
});
72+
73+
describe('handleCheckoutCompleted', () => {
74+
test('should update existing subscription with valid metadata plan', async () => {
75+
const existing = { _id: subId, organization: orgId };
76+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(existing);
77+
mockSubscriptionRepository.update.mockResolvedValue({});
78+
79+
await WebhookService.handleCheckoutCompleted({
80+
customer: 'cus_123',
81+
subscription: 'sub_456',
82+
metadata: { organizationId: orgId, plan: 'pro' },
83+
});
84+
85+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(
86+
expect.objectContaining({ _id: subId, plan: 'pro', status: 'active' }),
87+
);
88+
expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith(
89+
orgId, { plan: 'pro' }, { runValidators: true },
90+
);
91+
});
92+
93+
test('should create subscription when none exists', async () => {
94+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
95+
mockSubscriptionRepository.create.mockResolvedValue({});
96+
97+
await WebhookService.handleCheckoutCompleted({
98+
customer: 'cus_123',
99+
subscription: 'sub_456',
100+
metadata: { organizationId: orgId, plan: 'starter' },
101+
});
102+
103+
expect(mockSubscriptionRepository.create).toHaveBeenCalledWith(
104+
expect.objectContaining({
105+
organization: orgId,
106+
stripeCustomerId: 'cus_123',
107+
stripeSubscriptionId: 'sub_456',
108+
plan: 'starter',
109+
status: 'active',
110+
}),
111+
);
112+
});
113+
114+
test('should fall back to free when metadata plan is invalid (e.g. Stripe product ID)', async () => {
115+
const existing = { _id: subId, organization: orgId };
116+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(existing);
117+
mockSubscriptionRepository.update.mockResolvedValue({});
118+
119+
await WebhookService.handleCheckoutCompleted({
120+
customer: 'cus_123',
121+
subscription: 'sub_456',
122+
metadata: { organizationId: orgId, plan: 'prod_ABC123xyz' },
123+
});
124+
125+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(
126+
expect.objectContaining({ plan: 'free' }),
127+
);
128+
});
129+
130+
test('should fall back to free when metadata plan is missing', async () => {
131+
const existing = { _id: subId, organization: orgId };
132+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(existing);
133+
mockSubscriptionRepository.update.mockResolvedValue({});
134+
135+
await WebhookService.handleCheckoutCompleted({
136+
customer: 'cus_123',
137+
subscription: 'sub_456',
138+
metadata: { organizationId: orgId },
139+
});
140+
141+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(
142+
expect.objectContaining({ plan: 'free' }),
143+
);
144+
});
145+
146+
test('should handle missing organizationId by resolving from stripeCustomerId', async () => {
147+
const existing = { _id: subId, organization: orgId };
148+
mockSubscriptionRepository.findByStripeCustomerId.mockResolvedValue(existing);
149+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(existing);
150+
mockSubscriptionRepository.update.mockResolvedValue({});
151+
152+
await WebhookService.handleCheckoutCompleted({
153+
customer: 'cus_123',
154+
subscription: 'sub_456',
155+
metadata: { plan: 'pro' },
156+
});
157+
158+
expect(mockSubscriptionRepository.findByStripeCustomerId).toHaveBeenCalledWith('cus_123');
159+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(
160+
expect.objectContaining({ plan: 'pro', status: 'active' }),
161+
);
162+
});
163+
164+
test('should return early when organizationId cannot be resolved', async () => {
165+
mockSubscriptionRepository.findByStripeCustomerId.mockResolvedValue(null);
166+
167+
await WebhookService.handleCheckoutCompleted({
168+
customer: 'cus_123',
169+
subscription: 'sub_456',
170+
metadata: {},
171+
});
172+
173+
expect(mockSubscriptionRepository.update).not.toHaveBeenCalled();
174+
expect(mockSubscriptionRepository.create).not.toHaveBeenCalled();
175+
});
176+
});
177+
178+
describe('handleSubscriptionUpdated', () => {
179+
test('should update plan, status, currentPeriodEnd, cancelAtPeriodEnd', async () => {
180+
const existing = { _id: subId, organization: orgId };
181+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
182+
mockSubscriptionRepository.update.mockResolvedValue({});
183+
184+
const periodEnd = Math.floor(Date.now() / 1000) + 86400;
185+
186+
await WebhookService.handleSubscriptionUpdated(
187+
{
188+
id: 'sub_456',
189+
status: 'active',
190+
current_period_end: periodEnd,
191+
cancel_at_period_end: true,
192+
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
193+
},
194+
{ data: {} },
195+
);
196+
197+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(
198+
expect.objectContaining({
199+
_id: subId,
200+
plan: 'pro',
201+
status: 'active',
202+
currentPeriodEnd: new Date(periodEnd * 1000),
203+
cancelAtPeriodEnd: true,
204+
}),
205+
);
206+
expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith(
207+
orgId, { plan: 'pro' }, { runValidators: true },
208+
);
209+
});
210+
211+
test('should return early when subscription not found', async () => {
212+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(null);
213+
214+
await WebhookService.handleSubscriptionUpdated(
215+
{ id: 'sub_unknown', items: { data: [] } },
216+
{ data: {} },
217+
);
218+
219+
expect(mockSubscriptionRepository.update).not.toHaveBeenCalled();
220+
});
221+
222+
test('should fall back to free when plan metadata is invalid', async () => {
223+
const existing = { _id: subId, organization: orgId };
224+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
225+
mockSubscriptionRepository.update.mockResolvedValue({});
226+
227+
await WebhookService.handleSubscriptionUpdated(
228+
{
229+
id: 'sub_456',
230+
status: 'active',
231+
current_period_end: 1700000000,
232+
cancel_at_period_end: false,
233+
items: { data: [{ price: { metadata: { planId: 'prod_INVALID' } } }] },
234+
},
235+
{ data: {} },
236+
);
237+
238+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(
239+
expect.objectContaining({ plan: 'free' }),
240+
);
241+
});
242+
});
243+
244+
describe('handleSubscriptionDeleted', () => {
245+
test('should reset plan to free and status to canceled', async () => {
246+
const existing = { _id: subId, organization: orgId };
247+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
248+
mockSubscriptionRepository.update.mockResolvedValue({});
249+
250+
await WebhookService.handleSubscriptionDeleted({ id: 'sub_456' });
251+
252+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(
253+
expect.objectContaining({ _id: subId, plan: 'free', status: 'canceled' }),
254+
);
255+
expect(mockOrganizationModel.findByIdAndUpdate).toHaveBeenCalledWith(
256+
orgId, { plan: 'free' }, { runValidators: true },
257+
);
258+
});
259+
260+
test('should return early when subscription not found', async () => {
261+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(null);
262+
263+
await WebhookService.handleSubscriptionDeleted({ id: 'sub_unknown' });
264+
265+
expect(mockSubscriptionRepository.update).not.toHaveBeenCalled();
266+
});
267+
});
268+
269+
describe('handleInvoicePaymentFailed', () => {
270+
test('should set status to past_due', async () => {
271+
const existing = { _id: subId, organization: orgId };
272+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
273+
mockSubscriptionRepository.update.mockResolvedValue({});
274+
275+
await WebhookService.handleInvoicePaymentFailed({ subscription: 'sub_456' });
276+
277+
expect(mockSubscriptionRepository.update).toHaveBeenCalledWith(
278+
expect.objectContaining({ _id: subId, status: 'past_due' }),
279+
);
280+
});
281+
282+
test('should return early when no subscription ID in invoice', async () => {
283+
await WebhookService.handleInvoicePaymentFailed({ subscription: null });
284+
285+
expect(mockSubscriptionRepository.findByStripeSubscriptionId).not.toHaveBeenCalled();
286+
});
287+
288+
test('should return early when subscription not found', async () => {
289+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(null);
290+
291+
await WebhookService.handleInvoicePaymentFailed({ subscription: 'sub_unknown' });
292+
293+
expect(mockSubscriptionRepository.update).not.toHaveBeenCalled();
294+
});
295+
});
296+
});

0 commit comments

Comments
 (0)