Skip to content

Commit e4edf4f

Browse files
feat(billing): emit plan.changed event on subscription plan change
Adds a billing event emitter that downstream projects can subscribe to for handling plan upgrades/downgrades. The webhook service now detects plan changes from Stripe's previous_attributes and emits plan.changed with organizationId, previousPlan, newPlan, subscription, and isDowngrade.
1 parent 0eede6e commit e4edf4f

5 files changed

Lines changed: 265 additions & 6 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const handleWebhook = async (req, res) => {
3131
await BillingWebhookService.handleCheckoutCompleted(event.data.object);
3232
break;
3333
case 'customer.subscription.updated':
34-
await BillingWebhookService.handleSubscriptionUpdated(event.data.object);
34+
await BillingWebhookService.handleSubscriptionUpdated(event.data.object, event);
3535
break;
3636
case 'customer.subscription.deleted':
3737
await BillingWebhookService.handleSubscriptionDeleted(event.data.object);

modules/billing/lib/events.js

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 { EventEmitter } from 'events';
5+
6+
/**
7+
* Singleton event emitter for billing events.
8+
*
9+
* Events:
10+
* - `plan.changed` — emitted when a subscription's plan changes
11+
* Payload: { organizationId, previousPlan, newPlan, subscription, isDowngrade }
12+
*/
13+
const billingEvents = new EventEmitter();
14+
15+
export default billingEvents;

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@
33
*/
44
import mongoose from 'mongoose';
55

6+
import config from '../../../config/index.js';
67
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
8+
import billingEvents from '../lib/events.js';
79

810
const Organization = mongoose.model('Organization');
911

12+
/**
13+
* Plan rank lookup — higher index means higher-tier plan.
14+
* Used to determine upgrade vs downgrade.
15+
*/
16+
const planRanks = Object.fromEntries(config.billing.plans.map((p, i) => [p, i]));
17+
1018
/**
1119
* @desc Resolve the plan name from a Stripe subscription object.
1220
* In webhook payloads, price.product is typically a string ID (not expanded),
@@ -75,20 +83,39 @@ const handleCheckoutCompleted = async (session) => {
7583
* @param {Object} subscription - Stripe subscription object
7684
* @returns {Promise<void>}
7785
*/
78-
const handleSubscriptionUpdated = async (subscription) => {
86+
const handleSubscriptionUpdated = async (subscription, event) => {
7987
const existing = await SubscriptionRepository.findByStripeSubscriptionId(subscription.id);
8088
if (!existing) return;
8189

82-
const plan = resolvePlan(subscription);
90+
const newPlan = resolvePlan(subscription);
8391
await SubscriptionRepository.update({
8492
_id: existing._id,
85-
plan,
93+
plan: newPlan,
8694
status: subscription.status,
8795
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
8896
cancelAtPeriodEnd: subscription.cancel_at_period_end,
8997
});
9098

91-
await syncOrganizationPlan(existing.organization?._id || existing.organization, plan);
99+
const organizationId = String(existing.organization?._id || existing.organization);
100+
await syncOrganizationPlan(organizationId, newPlan);
101+
102+
// Detect plan change from previous_attributes and emit event
103+
const previousItems = event?.data?.previous_attributes?.items?.data;
104+
if (previousItems) {
105+
const previousPlan = previousItems[0]?.price?.metadata?.planId
106+
|| previousItems[0]?.plan?.metadata?.planId
107+
|| null;
108+
if (previousPlan && previousPlan !== newPlan) {
109+
const isDowngrade = (planRanks[previousPlan] ?? -1) > (planRanks[newPlan] ?? -1);
110+
billingEvents.emit('plan.changed', {
111+
organizationId,
112+
previousPlan,
113+
newPlan,
114+
subscription,
115+
isDowngrade,
116+
});
117+
}
118+
}
92119
};
93120

94121
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ describe('Billing webhook controller unit tests:', () => {
113113
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
114114
await BillingWebhookController.handleWebhook(req, res);
115115

116-
expect(mockBillingWebhookService.handleSubscriptionUpdated).toHaveBeenCalledWith({ id: 'sub_123' });
116+
expect(mockBillingWebhookService.handleSubscriptionUpdated).toHaveBeenCalledWith({ id: 'sub_123' }, eventData);
117117
expect(res.status).toHaveBeenCalledWith(200);
118118
});
119119

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for billing plan.changed event
8+
*/
9+
describe('Billing plan.changed event unit tests:', () => {
10+
let BillingWebhookService;
11+
let billingEvents;
12+
let mockSubscriptionRepository;
13+
14+
const orgId = '507f1f77bcf86cd799439011';
15+
const subId = '507f1f77bcf86cd799439022';
16+
17+
beforeEach(async () => {
18+
jest.resetModules();
19+
20+
mockSubscriptionRepository = {
21+
findByOrganization: jest.fn(),
22+
findByStripeSubscriptionId: jest.fn(),
23+
findByStripeCustomerId: jest.fn(),
24+
create: jest.fn(),
25+
update: jest.fn(),
26+
};
27+
28+
jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
29+
default: mockSubscriptionRepository,
30+
}));
31+
32+
jest.unstable_mockModule('mongoose', () => ({
33+
default: {
34+
model: jest.fn().mockReturnValue({
35+
findByIdAndUpdate: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }),
36+
}),
37+
Types: { ObjectId: { isValid: jest.fn().mockReturnValue(true) } },
38+
},
39+
}));
40+
41+
// Import service and events from the same module context
42+
const mod = await import('../services/billing.webhook.service.js');
43+
BillingWebhookService = mod.default;
44+
const eventsModule = await import('../lib/events.js');
45+
billingEvents = eventsModule.default;
46+
});
47+
48+
afterEach(() => {
49+
billingEvents.removeAllListeners('plan.changed');
50+
jest.restoreAllMocks();
51+
});
52+
53+
test('should emit plan.changed event when plan changes (downgrade)', async () => {
54+
const existing = { _id: subId, organization: { _id: orgId } };
55+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
56+
mockSubscriptionRepository.update.mockResolvedValue({});
57+
58+
const handler = jest.fn();
59+
billingEvents.on('plan.changed', handler);
60+
61+
const subscription = {
62+
id: 'sub_456',
63+
status: 'active',
64+
current_period_end: 1700000000,
65+
cancel_at_period_end: false,
66+
items: { data: [{ price: { metadata: { planId: 'starter' } } }] },
67+
};
68+
69+
const event = {
70+
data: {
71+
object: subscription,
72+
previous_attributes: {
73+
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
74+
},
75+
},
76+
};
77+
78+
await BillingWebhookService.handleSubscriptionUpdated(subscription, event);
79+
80+
expect(handler).toHaveBeenCalledTimes(1);
81+
expect(handler).toHaveBeenCalledWith({
82+
organizationId: orgId,
83+
previousPlan: 'pro',
84+
newPlan: 'starter',
85+
subscription,
86+
isDowngrade: true,
87+
});
88+
});
89+
90+
test('should not emit plan.changed event when plan has not changed', async () => {
91+
const existing = { _id: subId, organization: { _id: orgId } };
92+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
93+
mockSubscriptionRepository.update.mockResolvedValue({});
94+
95+
const handler = jest.fn();
96+
billingEvents.on('plan.changed', handler);
97+
98+
const subscription = {
99+
id: 'sub_456',
100+
status: 'active',
101+
current_period_end: 1700000000,
102+
cancel_at_period_end: false,
103+
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
104+
};
105+
106+
// No previous_attributes means no plan change
107+
const event = { data: { object: subscription } };
108+
109+
await BillingWebhookService.handleSubscriptionUpdated(subscription, event);
110+
111+
expect(handler).not.toHaveBeenCalled();
112+
});
113+
114+
test('should detect upgrade (isDowngrade = false) when moving to higher plan', async () => {
115+
const existing = { _id: subId, organization: orgId };
116+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
117+
mockSubscriptionRepository.update.mockResolvedValue({});
118+
119+
const handler = jest.fn();
120+
billingEvents.on('plan.changed', handler);
121+
122+
const subscription = {
123+
id: 'sub_456',
124+
status: 'active',
125+
current_period_end: 1700000000,
126+
cancel_at_period_end: false,
127+
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
128+
};
129+
130+
const event = {
131+
data: {
132+
object: subscription,
133+
previous_attributes: {
134+
items: { data: [{ price: { metadata: { planId: 'starter' } } }] },
135+
},
136+
},
137+
};
138+
139+
await BillingWebhookService.handleSubscriptionUpdated(subscription, event);
140+
141+
expect(handler).toHaveBeenCalledTimes(1);
142+
expect(handler).toHaveBeenCalledWith(
143+
expect.objectContaining({
144+
previousPlan: 'starter',
145+
newPlan: 'pro',
146+
isDowngrade: false,
147+
}),
148+
);
149+
});
150+
151+
test('should detect downgrade from enterprise to free', async () => {
152+
const existing = { _id: subId, organization: orgId };
153+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
154+
mockSubscriptionRepository.update.mockResolvedValue({});
155+
156+
const handler = jest.fn();
157+
billingEvents.on('plan.changed', handler);
158+
159+
const subscription = {
160+
id: 'sub_456',
161+
status: 'active',
162+
current_period_end: 1700000000,
163+
cancel_at_period_end: false,
164+
items: { data: [{ price: { metadata: { planId: 'free' } } }] },
165+
};
166+
167+
const event = {
168+
data: {
169+
object: subscription,
170+
previous_attributes: {
171+
items: { data: [{ price: { metadata: { planId: 'enterprise' } } }] },
172+
},
173+
},
174+
};
175+
176+
await BillingWebhookService.handleSubscriptionUpdated(subscription, event);
177+
178+
expect(handler).toHaveBeenCalledTimes(1);
179+
expect(handler).toHaveBeenCalledWith(
180+
expect.objectContaining({
181+
previousPlan: 'enterprise',
182+
newPlan: 'free',
183+
isDowngrade: true,
184+
}),
185+
);
186+
});
187+
188+
test('should not emit when previous_attributes has items but same plan', async () => {
189+
const existing = { _id: subId, organization: orgId };
190+
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
191+
mockSubscriptionRepository.update.mockResolvedValue({});
192+
193+
const handler = jest.fn();
194+
billingEvents.on('plan.changed', handler);
195+
196+
const subscription = {
197+
id: 'sub_456',
198+
status: 'active',
199+
current_period_end: 1700000000,
200+
cancel_at_period_end: false,
201+
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
202+
};
203+
204+
const event = {
205+
data: {
206+
object: subscription,
207+
previous_attributes: {
208+
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
209+
},
210+
},
211+
};
212+
213+
await BillingWebhookService.handleSubscriptionUpdated(subscription, event);
214+
215+
expect(handler).not.toHaveBeenCalled();
216+
});
217+
});

0 commit comments

Comments
 (0)