Skip to content

Commit 0fe2056

Browse files
fix(billing): add checkout metadata, harden webhook and customer creation (#3265)
* fix(billing): add checkout metadata, harden webhook and customer creation - Add organizationId and plan metadata to Stripe checkout sessions (#3259) - Wrap SubscriptionRepository.create in try/catch for duplicate key race condition (#3260) - Fallback to stripeCustomerId lookup when checkout metadata is missing (#3261) - Remove Stripe error details from webhook signature failure response (#3262) Closes #3259, closes #3260, closes #3261, closes #3262 * fix(billing): address review feedback on metadata and fallback - Use planId instead of name for checkout metadata plan field - Fix webhook fallback to extract organization._id from populated doc - Use destructured stripeCustomerId for consistency in fallback - Update test mocks to reflect populated organization shape
1 parent d529d7a commit 0fe2056

6 files changed

Lines changed: 60 additions & 11 deletions

File tree

modules/billing/controllers/billing.webhook.controller.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const handleWebhook = async (req, res) => {
3939
try {
4040
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
4141
} catch (err) {
42-
return res.status(400).json({ error: `Webhook signature verification failed: ${err.message}` });
42+
return res.status(400).json({ error: 'Webhook signature verification failed' });
4343
}
4444

4545
try {

modules/billing/services/billing.service.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,37 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
8585
stripeCustomerId: customer.id,
8686
});
8787
} else {
88-
subscription = await SubscriptionRepository.create({
89-
organization: organization._id,
90-
stripeCustomerId: customer.id,
91-
});
88+
try {
89+
subscription = await SubscriptionRepository.create({
90+
organization: organization._id,
91+
stripeCustomerId: customer.id,
92+
});
93+
} catch (err) {
94+
if (err.code === 11000) {
95+
subscription = await SubscriptionRepository.findByOrganization(organization._id);
96+
} else {
97+
throw err;
98+
}
99+
}
92100
}
93101
// Re-read to handle race: if another request already set stripeCustomerId, use that
94102
const latest = await SubscriptionRepository.findByOrganization(organization._id);
95103
if (latest?.stripeCustomerId) subscription = latest;
96104
}
97105

106+
// Derive plan name from priceId for checkout metadata
107+
const matchedPlan = plans.find((p) => p.stripePriceMonthly === priceId || p.stripePriceAnnual === priceId);
108+
98109
const session = await stripe.checkout.sessions.create({
99110
customer: subscription.stripeCustomerId,
100111
mode: 'subscription',
101112
line_items: [{ price: priceId, quantity: 1 }],
102113
success_url: successUrl,
103114
cancel_url: cancelUrl,
115+
metadata: {
116+
organizationId: String(organization._id),
117+
plan: matchedPlan?.planId || 'free',
118+
},
104119
});
105120

106121
return session.url;

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ const syncOrganizationPlan = async (organizationId, plan) => {
3737
*/
3838
const handleCheckoutCompleted = async (session) => {
3939
const { customer: stripeCustomerId, subscription: stripeSubscriptionId, metadata } = session;
40-
const organizationId = metadata?.organizationId;
40+
let organizationId = metadata?.organizationId;
4141
const plan = metadata?.plan || 'free';
4242

43+
// Fallback: resolve organizationId from stripeCustomerId if metadata is missing
44+
if (!organizationId) {
45+
const sub = await SubscriptionRepository.findByStripeCustomerId(stripeCustomerId);
46+
if (sub) organizationId = String(sub.organization?._id || sub.organization);
47+
}
48+
4349
if (!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) return;
4450

4551
const existing = await SubscriptionRepository.findByOrganization(organizationId);

modules/billing/tests/billing.checkout.unit.tests.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ describe('Billing service unit tests:', () => {
1515
const orgId = '507f1f77bcf86cd799439011';
1616
const mockOrganization = { _id: orgId, name: 'Test Org' };
1717
const validPlans = [
18-
{ planId: 'starter', stripePriceMonthly: 'price_starter_m', stripePriceAnnual: 'price_starter_y' },
19-
{ planId: 'pro', stripePriceMonthly: 'price_pro_m', stripePriceAnnual: 'price_pro_y' },
18+
{ planId: 'starter', name: 'Starter', stripePriceMonthly: 'price_starter_m', stripePriceAnnual: 'price_starter_y' },
19+
{ planId: 'pro', name: 'Pro', stripePriceMonthly: 'price_pro_m', stripePriceAnnual: 'price_pro_y' },
2020
];
2121

2222
beforeEach(async () => {
@@ -204,6 +204,10 @@ describe('Billing service unit tests:', () => {
204204
line_items: [{ price: 'price_starter_m', quantity: 1 }],
205205
success_url: 'http://ok',
206206
cancel_url: 'http://cancel',
207+
metadata: {
208+
organizationId: orgId,
209+
plan: 'starter',
210+
},
207211
});
208212
});
209213
});

modules/billing/tests/billing.controller.unit.tests.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('Billing webhook controller unit tests:', () => {
7777
await BillingWebhookController.handleWebhook(req, res);
7878

7979
expect(res.status).toHaveBeenCalledWith(400);
80-
expect(res.json).toHaveBeenCalledWith({ error: 'Webhook signature verification failed: bad signature' });
80+
expect(res.json).toHaveBeenCalledWith({ error: 'Webhook signature verification failed' });
8181
});
8282

8383
test('should handle checkout.session.completed event', async () => {

modules/billing/tests/billing.service.unit.tests.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe('Billing webhook service unit tests:', () => {
2020
mockSubscriptionRepository = {
2121
findByOrganization: jest.fn(),
2222
findByStripeSubscriptionId: jest.fn(),
23+
findByStripeCustomerId: jest.fn(),
2324
create: jest.fn(),
2425
update: jest.fn(),
2526
};
@@ -108,18 +109,41 @@ describe('Billing webhook service unit tests:', () => {
108109
);
109110
});
110111

111-
test('should return early when organizationId is missing', async () => {
112+
test('should return early when organizationId is missing and no customer fallback', async () => {
113+
mockSubscriptionRepository.findByStripeCustomerId.mockResolvedValue(null);
114+
112115
const mod = await import('../services/billing.webhook.service.js');
113116
BillingWebhookService = mod.default;
114117

115118
await BillingWebhookService.handleCheckoutCompleted({
116-
customer: 'cus_123',
119+
customer: 'cus_unknown',
117120
subscription: 'sub_456',
118121
metadata: {},
119122
});
120123

124+
expect(mockSubscriptionRepository.findByStripeCustomerId).toHaveBeenCalledWith('cus_unknown');
121125
expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled();
122126
});
127+
128+
test('should fallback to stripeCustomerId when organizationId is missing in metadata', async () => {
129+
mockSubscriptionRepository.findByStripeCustomerId.mockResolvedValue({ organization: { _id: orgId } });
130+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
131+
mockSubscriptionRepository.create.mockResolvedValue({});
132+
133+
const mod = await import('../services/billing.webhook.service.js');
134+
BillingWebhookService = mod.default;
135+
136+
await BillingWebhookService.handleCheckoutCompleted({
137+
customer: 'cus_123',
138+
subscription: 'sub_456',
139+
metadata: {},
140+
});
141+
142+
expect(mockSubscriptionRepository.findByStripeCustomerId).toHaveBeenCalledWith('cus_123');
143+
expect(mockSubscriptionRepository.create).toHaveBeenCalledWith(
144+
expect.objectContaining({ organization: orgId }),
145+
);
146+
});
123147
});
124148

125149
describe('handleSubscriptionUpdated', () => {

0 commit comments

Comments
 (0)