Skip to content

Commit 596fcae

Browse files
feat(billing): add Stripe webhook endpoint and handler (#3256)
* feat(billing): add Stripe webhook endpoint and event handlers Add POST /api/billing/webhook pre-parser route with raw body parsing, signature verification, and handlers for checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, and invoice.payment_failed events. Closes #3243 * test(billing): add unit tests for webhook service and controller Cover all webhook event handlers (checkout.session.completed, subscription.updated, subscription.deleted, invoice.payment_failed) and controller endpoint to meet global coverage thresholds. * fix(billing): address review feedback — validate IDs, fix resolvePlan, add error logging - Validate organizationId with ObjectId.isValid before DB operations - Enable runValidators on Organization plan updates - Fix resolvePlan to use price.metadata (not price.product.metadata) since Stripe webhook payloads return product as string ID - Fix JSDoc return type to Promise<void> - Add console.error logging for webhook handler failures - Update tests to match new behavior
1 parent 6a16c9a commit 596fcae

6 files changed

Lines changed: 697 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import Stripe from 'stripe';
5+
6+
import config from '../../../config/index.js';
7+
import BillingWebhookService from '../services/billing.webhook.service.js';
8+
9+
/**
10+
* Lazily instantiated Stripe client
11+
*/
12+
let stripeClient = null;
13+
14+
/**
15+
* @desc Get or create the Stripe client instance
16+
* @returns {Object|null} Stripe client or null if not configured
17+
*/
18+
const getStripe = () => {
19+
if (stripeClient) return stripeClient;
20+
if (!config.stripe?.secretKey) return null;
21+
stripeClient = new Stripe(config.stripe.secretKey);
22+
return stripeClient;
23+
};
24+
25+
/**
26+
* @desc Endpoint to handle Stripe webhook events
27+
* @param {Object} req - Express request object
28+
* @param {Object} res - Express response object
29+
* @returns {Promise<void>}
30+
*/
31+
const handleWebhook = async (req, res) => {
32+
const stripe = getStripe();
33+
if (!stripe) return res.status(400).json({ error: 'Stripe is not configured' });
34+
35+
const sig = req.headers['stripe-signature'];
36+
const { webhookSecret } = config.stripe;
37+
38+
let event;
39+
try {
40+
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
41+
} catch (err) {
42+
return res.status(400).json({ error: `Webhook signature verification failed: ${err.message}` });
43+
}
44+
45+
try {
46+
switch (event.type) {
47+
case 'checkout.session.completed':
48+
await BillingWebhookService.handleCheckoutCompleted(event.data.object);
49+
break;
50+
case 'customer.subscription.updated':
51+
await BillingWebhookService.handleSubscriptionUpdated(event.data.object);
52+
break;
53+
case 'customer.subscription.deleted':
54+
await BillingWebhookService.handleSubscriptionDeleted(event.data.object);
55+
break;
56+
case 'invoice.payment_failed':
57+
await BillingWebhookService.handleInvoicePaymentFailed(event.data.object);
58+
break;
59+
default:
60+
break;
61+
}
62+
return res.status(200).json({ received: true });
63+
} catch (err) {
64+
console.error('Stripe webhook handler error:', err);
65+
return res.status(500).json({ error: 'Webhook handler failed' });
66+
}
67+
};
68+
69+
export default {
70+
handleWebhook,
71+
};

modules/billing/repositories/billing.subscription.repository.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ const findByStripeCustomerId = (stripeCustomerId) => {
9797
return Subscription.findOne({ stripeCustomerId: normalized }).populate(defaultPopulate).exec();
9898
};
9999

100+
/**
101+
* @function findByStripeSubscriptionId
102+
* @description Data access operation to fetch a subscription by Stripe subscription ID.
103+
* @param {String} stripeSubscriptionId - The Stripe subscription ID.
104+
* @returns {Promise<Object|null>} A promise resolving to the retrieved subscription or null.
105+
*/
106+
const findByStripeSubscriptionId = (stripeSubscriptionId) => {
107+
const normalized = stripeSubscriptionId?.trim();
108+
if (!normalized) return null;
109+
return Subscription.findOne({ stripeSubscriptionId: normalized }).populate(defaultPopulate).exec();
110+
};
111+
100112
export default {
101113
list,
102114
create,
@@ -105,4 +117,5 @@ export default {
105117
remove,
106118
findByOrganization,
107119
findByStripeCustomerId,
120+
findByStripeSubscriptionId,
108121
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import express from 'express';
5+
6+
import webhook from '../controllers/billing.webhook.controller.js';
7+
8+
/**
9+
* @desc Register billing pre-parser routes (mounted before body parsing and CSRF)
10+
* @param {Object} app - Express application instance
11+
* @returns {void}
12+
*/
13+
export default (app) => {
14+
app.route('/api/billing/webhook').post(express.raw({ type: 'application/json' }), webhook.handleWebhook);
15+
};
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
7+
8+
const Organization = mongoose.model('Organization');
9+
10+
/**
11+
* @desc Resolve the plan name from a Stripe subscription object.
12+
* In webhook payloads, price.product is typically a string ID (not expanded),
13+
* so we check price.metadata first, then fall back to plan.metadata.
14+
* @param {Object} subscription - Stripe subscription object
15+
* @returns {string} plan name
16+
*/
17+
const resolvePlan = (subscription) => {
18+
const item = subscription.items?.data?.[0];
19+
return item?.price?.metadata?.planId || item?.plan?.metadata?.planId || 'free';
20+
};
21+
22+
/**
23+
* @desc Sync the organization plan field to match the subscription plan
24+
* @param {String} organizationId - Organization document ID
25+
* @param {String} plan - Plan name to set
26+
* @returns {Promise<void>}
27+
*/
28+
const syncOrganizationPlan = async (organizationId, plan) => {
29+
if (!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) return;
30+
await Organization.findByIdAndUpdate(organizationId, { plan }, { runValidators: true }).exec();
31+
};
32+
33+
/**
34+
* @desc Handle checkout.session.completed event — create or update subscription
35+
* @param {Object} session - Stripe checkout session object
36+
* @returns {Promise<void>}
37+
*/
38+
const handleCheckoutCompleted = async (session) => {
39+
const { customer: stripeCustomerId, subscription: stripeSubscriptionId, metadata } = session;
40+
const organizationId = metadata?.organizationId;
41+
const plan = metadata?.plan || 'free';
42+
43+
if (!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) return;
44+
45+
const existing = await SubscriptionRepository.findByOrganization(organizationId);
46+
if (existing) {
47+
await SubscriptionRepository.update({
48+
_id: existing._id,
49+
stripeCustomerId,
50+
stripeSubscriptionId,
51+
plan,
52+
status: 'active',
53+
});
54+
} else {
55+
await SubscriptionRepository.create({
56+
organization: organizationId,
57+
stripeCustomerId,
58+
stripeSubscriptionId,
59+
plan,
60+
status: 'active',
61+
});
62+
}
63+
64+
await syncOrganizationPlan(organizationId, plan);
65+
};
66+
67+
/**
68+
* @desc Handle customer.subscription.updated event — sync subscription state
69+
* @param {Object} subscription - Stripe subscription object
70+
* @returns {Promise<void>}
71+
*/
72+
const handleSubscriptionUpdated = async (subscription) => {
73+
const existing = await SubscriptionRepository.findByStripeSubscriptionId(subscription.id);
74+
if (!existing) return;
75+
76+
const plan = resolvePlan(subscription);
77+
await SubscriptionRepository.update({
78+
_id: existing._id,
79+
plan,
80+
status: subscription.status,
81+
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
82+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
83+
});
84+
85+
await syncOrganizationPlan(existing.organization?._id || existing.organization, plan);
86+
};
87+
88+
/**
89+
* @desc Handle customer.subscription.deleted event — cancel subscription
90+
* @param {Object} subscription - Stripe subscription object
91+
* @returns {Promise<void>}
92+
*/
93+
const handleSubscriptionDeleted = async (subscription) => {
94+
const existing = await SubscriptionRepository.findByStripeSubscriptionId(subscription.id);
95+
if (!existing) return;
96+
97+
await SubscriptionRepository.update({
98+
_id: existing._id,
99+
plan: 'free',
100+
status: 'canceled',
101+
});
102+
103+
await syncOrganizationPlan(existing.organization?._id || existing.organization, 'free');
104+
};
105+
106+
/**
107+
* @desc Handle invoice.payment_failed event — mark subscription as past_due
108+
* @param {Object} invoice - Stripe invoice object
109+
* @returns {Promise<void>}
110+
*/
111+
const handleInvoicePaymentFailed = async (invoice) => {
112+
const { subscription: stripeSubscriptionId } = invoice;
113+
if (!stripeSubscriptionId) return;
114+
115+
const existing = await SubscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId);
116+
if (!existing) return;
117+
118+
await SubscriptionRepository.update({
119+
_id: existing._id,
120+
status: 'past_due',
121+
});
122+
};
123+
124+
export default {
125+
handleCheckoutCompleted,
126+
handleSubscriptionUpdated,
127+
handleSubscriptionDeleted,
128+
handleInvoicePaymentFailed,
129+
};

0 commit comments

Comments
 (0)