Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/billing/controllers/billing.webhook.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const handleWebhook = async (req, res) => {
await BillingWebhookService.handleCheckoutCompleted(event.data.object);
break;
case 'customer.subscription.updated':
await BillingWebhookService.handleSubscriptionUpdated(event.data.object);
await BillingWebhookService.handleSubscriptionUpdated(event.data.object, event);
break;
case 'customer.subscription.deleted':
await BillingWebhookService.handleSubscriptionDeleted(event.data.object);
Expand Down
15 changes: 15 additions & 0 deletions modules/billing/lib/events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Module dependencies
*/
import { EventEmitter } from 'events';

/**
* Singleton event emitter for billing events.
*
* Events:
* - `plan.changed` — emitted when a subscription's plan changes
* Payload: { organizationId, previousPlan, newPlan, subscription, isDowngrade }
*/
const billingEvents = new EventEmitter();

export default billingEvents;
35 changes: 31 additions & 4 deletions modules/billing/services/billing.webhook.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@
*/
import mongoose from 'mongoose';

import config from '../../../config/index.js';
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
import billingEvents from '../lib/events.js';

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

/**
* Plan rank lookup — higher index means higher-tier plan.
* Used to determine upgrade vs downgrade.
*/
const planRanks = Object.fromEntries((config.billing?.plans || []).map((p, i) => [p, i]));

/**
* @desc Resolve the plan name from a Stripe subscription object.
* In webhook payloads, price.product is typically a string ID (not expanded),
Expand Down Expand Up @@ -75,20 +83,39 @@ const handleCheckoutCompleted = async (session) => {
* @param {Object} subscription - Stripe subscription object
Comment thread
PierreBrisorgueil marked this conversation as resolved.
* @returns {Promise<void>}
*/
const handleSubscriptionUpdated = async (subscription) => {
const handleSubscriptionUpdated = async (subscription, event) => {
const existing = await SubscriptionRepository.findByStripeSubscriptionId(subscription.id);
if (!existing) return;

const plan = resolvePlan(subscription);
const newPlan = resolvePlan(subscription);
await SubscriptionRepository.update({
_id: existing._id,
plan,
plan: newPlan,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});

await syncOrganizationPlan(existing.organization?._id || existing.organization, plan);
const organizationId = String(existing.organization?._id || existing.organization);
await syncOrganizationPlan(organizationId, newPlan);

// Detect plan change from previous_attributes and emit event
const previousItems = event?.data?.previous_attributes?.items?.data;
if (previousItems) {
const previousPlan = previousItems[0]?.price?.metadata?.planId
|| previousItems[0]?.plan?.metadata?.planId
|| null;
if (previousPlan && previousPlan !== newPlan) {
const isDowngrade = (planRanks[previousPlan] ?? -1) > (planRanks[newPlan] ?? -1);
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
billingEvents.emit('plan.changed', {
organizationId,
previousPlan,
newPlan,
subscription,
isDowngrade,
});
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
}
}
};

/**
Expand Down
2 changes: 1 addition & 1 deletion modules/billing/tests/billing.controller.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe('Billing webhook controller unit tests:', () => {
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
await BillingWebhookController.handleWebhook(req, res);

expect(mockBillingWebhookService.handleSubscriptionUpdated).toHaveBeenCalledWith({ id: 'sub_123' });
expect(mockBillingWebhookService.handleSubscriptionUpdated).toHaveBeenCalledWith({ id: 'sub_123' }, eventData);
expect(res.status).toHaveBeenCalledWith(200);
});

Expand Down
225 changes: 225 additions & 0 deletions modules/billing/tests/billing.events.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* Module dependencies.
*/
import { jest, beforeEach, afterEach } from '@jest/globals';

/**
* Unit tests for billing plan.changed event
*/
describe('Billing plan.changed event unit tests:', () => {
let BillingWebhookService;
let billingEvents;
let mockSubscriptionRepository;

const orgId = '507f1f77bcf86cd799439011';
const subId = '507f1f77bcf86cd799439022';

beforeEach(async () => {
jest.resetModules();

mockSubscriptionRepository = {
findByOrganization: jest.fn(),
findByStripeSubscriptionId: jest.fn(),
findByStripeCustomerId: jest.fn(),
create: jest.fn(),
update: jest.fn(),
};

jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
default: mockSubscriptionRepository,
}));

jest.unstable_mockModule('mongoose', () => ({
default: {
model: jest.fn().mockReturnValue({
findByIdAndUpdate: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue({}) }),
}),
Types: { ObjectId: { isValid: jest.fn().mockReturnValue(true) } },
},
}));

jest.unstable_mockModule('../../../config/index.js', () => ({
default: {
billing: {
plans: ['free', 'starter', 'pro', 'enterprise'],
},
},
}));

// Import service and events from the same module context
const mod = await import('../services/billing.webhook.service.js');
BillingWebhookService = mod.default;
const eventsModule = await import('../lib/events.js');
billingEvents = eventsModule.default;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

afterEach(() => {
billingEvents.removeAllListeners('plan.changed');
jest.restoreAllMocks();
});

test('should emit plan.changed event when plan changes (downgrade)', async () => {
const existing = { _id: subId, organization: { _id: orgId } };
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockSubscriptionRepository.update.mockResolvedValue({});

const handler = jest.fn();
billingEvents.on('plan.changed', handler);

const subscription = {
id: 'sub_456',
status: 'active',
current_period_end: 1700000000,
cancel_at_period_end: false,
items: { data: [{ price: { metadata: { planId: 'starter' } } }] },
};

const event = {
data: {
object: subscription,
previous_attributes: {
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
},
},
};

await BillingWebhookService.handleSubscriptionUpdated(subscription, event);

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
organizationId: orgId,
previousPlan: 'pro',
newPlan: 'starter',
subscription,
isDowngrade: true,
});
});

test('should not emit plan.changed event when plan has not changed', async () => {
const existing = { _id: subId, organization: { _id: orgId } };
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockSubscriptionRepository.update.mockResolvedValue({});

const handler = jest.fn();
billingEvents.on('plan.changed', handler);

const subscription = {
id: 'sub_456',
status: 'active',
current_period_end: 1700000000,
cancel_at_period_end: false,
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
};

// No previous_attributes means no plan change
const event = { data: { object: subscription } };

await BillingWebhookService.handleSubscriptionUpdated(subscription, event);

expect(handler).not.toHaveBeenCalled();
});

test('should detect upgrade (isDowngrade = false) when moving to higher plan', async () => {
const existing = { _id: subId, organization: orgId };
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockSubscriptionRepository.update.mockResolvedValue({});

const handler = jest.fn();
billingEvents.on('plan.changed', handler);

const subscription = {
id: 'sub_456',
status: 'active',
current_period_end: 1700000000,
cancel_at_period_end: false,
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
};

const event = {
data: {
object: subscription,
previous_attributes: {
items: { data: [{ price: { metadata: { planId: 'starter' } } }] },
},
},
};

await BillingWebhookService.handleSubscriptionUpdated(subscription, event);

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
previousPlan: 'starter',
newPlan: 'pro',
isDowngrade: false,
}),
);
});

test('should detect downgrade from enterprise to free', async () => {
const existing = { _id: subId, organization: orgId };
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockSubscriptionRepository.update.mockResolvedValue({});

const handler = jest.fn();
billingEvents.on('plan.changed', handler);

const subscription = {
id: 'sub_456',
status: 'active',
current_period_end: 1700000000,
cancel_at_period_end: false,
items: { data: [{ price: { metadata: { planId: 'free' } } }] },
};

const event = {
data: {
object: subscription,
previous_attributes: {
items: { data: [{ price: { metadata: { planId: 'enterprise' } } }] },
},
},
};

await BillingWebhookService.handleSubscriptionUpdated(subscription, event);

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
previousPlan: 'enterprise',
newPlan: 'free',
isDowngrade: true,
}),
);
});

test('should not emit when previous_attributes has items but same plan', async () => {
const existing = { _id: subId, organization: orgId };
mockSubscriptionRepository.findByStripeSubscriptionId.mockResolvedValue(existing);
mockSubscriptionRepository.update.mockResolvedValue({});

const handler = jest.fn();
billingEvents.on('plan.changed', handler);

const subscription = {
id: 'sub_456',
status: 'active',
current_period_end: 1700000000,
cancel_at_period_end: false,
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
};

const event = {
data: {
object: subscription,
previous_attributes: {
items: { data: [{ price: { metadata: { planId: 'pro' } } }] },
},
},
};

await BillingWebhookService.handleSubscriptionUpdated(subscription, event);

expect(handler).not.toHaveBeenCalled();
});
});
Loading