Skip to content

Commit a1341f2

Browse files
fix(billing): address CodeRabbit review — error handling, schema, pagination, naming
- Use 500 instead of 422 for Stripe failures to avoid leaking internals - Fix SubscriptionUpdate to not inject defaults on partial updates - Remove create-on-missing-id fallback from repository update method - Normalize stripeCustomerId before querying - Add JSDoc to route registrar and rename to billing.routes.js (convention) - Switch to Stripe autoPagingToArray for full pagination support - Document single-price-per-interval expectation - Add cache TTL refresh test
1 parent fc6c49f commit a1341f2

7 files changed

Lines changed: 109 additions & 68 deletions

File tree

modules/billing/controllers/billing.plans.controller.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const getPlans = async (req, res) => {
1616
const plans = await BillingPlansService.getPlans();
1717
responses.success(res, 'billing plans')(plans);
1818
} catch (err) {
19-
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
19+
responses.error(res, 500, 'Internal Server Error', 'Failed to retrieve billing plans')(err);
2020
}
2121
};
2222

modules/billing/models/subscription.schema.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,31 @@ const Subscription = z.object({
2929
cancelAtPeriodEnd: z.boolean().default(false),
3030
});
3131

32-
const SubscriptionUpdate = Subscription.partial();
32+
/**
33+
* Update schema without defaults to avoid populating unspecified fields during PATCH
34+
*/
35+
const SubscriptionCore = z.object({
36+
organization: z
37+
.string()
38+
.trim()
39+
.regex(objectIdRegex, 'organization must be a valid ObjectId'),
40+
stripeCustomerId: z
41+
.string()
42+
.trim()
43+
.optional()
44+
.transform((val) => (val === '' ? undefined : val)),
45+
stripeSubscriptionId: z
46+
.string()
47+
.trim()
48+
.optional()
49+
.transform((val) => (val === '' ? undefined : val)),
50+
plan: z.enum(['free', 'starter', 'pro']),
51+
status: z.enum(['active', 'past_due', 'canceled', 'trialing', 'incomplete']),
52+
currentPeriodEnd: z.coerce.date().nullable().optional(),
53+
cancelAtPeriodEnd: z.boolean(),
54+
});
55+
56+
const SubscriptionUpdate = SubscriptionCore.partial();
3357

3458
export default {
3559
Subscription,

modules/billing/repositories/subscription.repository.js

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,12 @@ const get = (id) => {
5454
*/
5555
const update = (subscription) => {
5656
const id = resolveId(subscription);
57-
if (id && mongoose.Types.ObjectId.isValid(id)) {
58-
// eslint-disable-next-line no-unused-vars
59-
const { _id, id: _virtualId, ...payload } = subscription;
60-
return Subscription.findByIdAndUpdate(id, payload, { returnDocument: 'after', runValidators: true })
61-
.populate(defaultPopulate)
62-
.exec();
63-
}
64-
return new Subscription(subscription).save().then((doc) => doc.populate(defaultPopulate));
57+
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
58+
// eslint-disable-next-line no-unused-vars
59+
const { _id, id: _virtualId, ...payload } = subscription;
60+
return Subscription.findByIdAndUpdate(id, payload, { returnDocument: 'after', runValidators: true })
61+
.populate(defaultPopulate)
62+
.exec();
6563
};
6664

6765
/**
@@ -94,8 +92,9 @@ const findByOrganization = (organizationId) => {
9492
* @returns {Promise<Object|null>} A promise resolving to the retrieved subscription or null.
9593
*/
9694
const findByStripeCustomerId = (stripeCustomerId) => {
97-
if (!stripeCustomerId || !stripeCustomerId.trim()) return null;
98-
return Subscription.findOne({ stripeCustomerId }).populate(defaultPopulate).exec();
95+
const normalized = stripeCustomerId?.trim();
96+
if (!normalized) return null;
97+
return Subscription.findOne({ stripeCustomerId: normalized }).populate(defaultPopulate).exec();
9998
};
10099

101100
export default {
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import billing from '../controllers/billing.plans.controller.js';
55

66
/**
7-
* Routes
7+
* @desc Register billing routes
8+
* @param {Object} app - Express application instance
9+
* @returns {void}
810
*/
911
export default (app) => {
1012
// plans (public)

modules/billing/services/billing.plans.service.js

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,30 +46,26 @@ const getStripe = () => {
4646
* @returns {Promise<Array>} sorted array of plan objects
4747
*/
4848
const fetchPlansFromStripe = async (stripe) => {
49-
const products = await stripe.products.list({
50-
active: true,
51-
limit: 100,
52-
});
53-
54-
const prices = await stripe.prices.list({
55-
active: true,
56-
limit: 100,
57-
});
49+
const [products, prices] = await Promise.all([
50+
stripe.products.list({ active: true }).autoPagingToArray({ limit: 1000 }),
51+
stripe.prices.list({ active: true }).autoPagingToArray({ limit: 1000 }),
52+
]);
5853

5954
const pricesByProduct = {};
60-
for (const price of prices.data) {
55+
for (const price of prices) {
6156
if (!pricesByProduct[price.product]) pricesByProduct[price.product] = [];
6257
pricesByProduct[price.product].push(price);
6358
}
6459

65-
const plans = products.data.map((product) => {
60+
const plans = products.map((product) => {
6661
const productPrices = pricesByProduct[product.id] || [];
6762

6863
let monthlyPrice = 0;
6964
let annualPrice = 0;
7065
let stripePriceMonthly = null;
7166
let stripePriceAnnual = null;
7267

68+
// Expects one active price per interval per product; last match wins if duplicates exist
7369
for (const price of productPrices) {
7470
const amount = typeof price.unit_amount === 'number' ? price.unit_amount : 0;
7571
if (price.recurring?.interval === 'month') {

modules/billing/tests/billing.plans.unit.tests.js

Lines changed: 54 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
*/
44
import { jest, beforeEach, afterEach } from '@jest/globals';
55

6+
/**
7+
* Helper to create a mock Stripe list result with autoPagingToArray
8+
*/
9+
const mockListResult = (data) => ({
10+
autoPagingToArray: jest.fn().mockResolvedValue(data),
11+
});
12+
613
/**
714
* Unit tests for billing plans service
815
*/
@@ -11,39 +18,27 @@ describe('Billing plans service unit tests:', () => {
1118
let mockConfig;
1219
let mockStripeInstance;
1320

14-
const mockProducts = {
15-
data: [
16-
{
17-
id: 'prod_pro',
18-
name: 'Pro',
19-
metadata: { planId: 'pro' },
20-
},
21-
{
22-
id: 'prod_starter',
23-
name: 'Starter',
24-
metadata: { planId: 'starter' },
25-
},
26-
],
27-
};
28-
29-
const mockAllPrices = {
30-
data: [
31-
{ product: 'prod_starter', recurring: { interval: 'month' }, unit_amount: 900, id: 'price_starter_m' },
32-
{ product: 'prod_starter', recurring: { interval: 'year' }, unit_amount: 9000, id: 'price_starter_y' },
33-
{ product: 'prod_pro', recurring: { interval: 'month' }, unit_amount: 2900, id: 'price_pro_m' },
34-
{ product: 'prod_pro', recurring: { interval: 'year' }, unit_amount: 29000, id: 'price_pro_y' },
35-
],
36-
};
21+
const productsData = [
22+
{ id: 'prod_pro', name: 'Pro', metadata: { planId: 'pro' } },
23+
{ id: 'prod_starter', name: 'Starter', metadata: { planId: 'starter' } },
24+
];
25+
26+
const pricesData = [
27+
{ product: 'prod_starter', recurring: { interval: 'month' }, unit_amount: 900, id: 'price_starter_m' },
28+
{ product: 'prod_starter', recurring: { interval: 'year' }, unit_amount: 9000, id: 'price_starter_y' },
29+
{ product: 'prod_pro', recurring: { interval: 'month' }, unit_amount: 2900, id: 'price_pro_m' },
30+
{ product: 'prod_pro', recurring: { interval: 'year' }, unit_amount: 29000, id: 'price_pro_y' },
31+
];
3732

3833
beforeEach(async () => {
3934
jest.resetModules();
4035

4136
mockStripeInstance = {
4237
products: {
43-
list: jest.fn().mockResolvedValue(mockProducts),
38+
list: jest.fn().mockReturnValue(mockListResult(productsData)),
4439
},
4540
prices: {
46-
list: jest.fn().mockResolvedValue(mockAllPrices),
41+
list: jest.fn().mockReturnValue(mockListResult(pricesData)),
4742
},
4843
};
4944

@@ -122,10 +117,8 @@ describe('Billing plans service unit tests:', () => {
122117
});
123118

124119
test('should fall back to product id when metadata planId is missing', async () => {
125-
mockStripeInstance.products.list.mockResolvedValue({
126-
data: [{ id: 'prod_basic', name: 'Basic', metadata: {} }],
127-
});
128-
mockStripeInstance.prices.list.mockResolvedValue({ data: [] });
120+
mockStripeInstance.products.list.mockReturnValue(mockListResult([{ id: 'prod_basic', name: 'Basic', metadata: {} }]));
121+
mockStripeInstance.prices.list.mockReturnValue(mockListResult([]));
129122

130123
const mod = await import('../services/billing.plans.service.js');
131124
BillingPlansService = mod.default;
@@ -145,12 +138,12 @@ describe('Billing plans service unit tests:', () => {
145138
});
146139

147140
test('should handle prices without recurring interval', async () => {
148-
mockStripeInstance.products.list.mockResolvedValue({
149-
data: [{ id: 'prod_one', name: 'OneTime', metadata: { planId: 'one' } }],
150-
});
151-
mockStripeInstance.prices.list.mockResolvedValue({
152-
data: [{ product: 'prod_one', unit_amount: 500, id: 'price_one' }],
153-
});
141+
mockStripeInstance.products.list.mockReturnValue(
142+
mockListResult([{ id: 'prod_one', name: 'OneTime', metadata: { planId: 'one' } }]),
143+
);
144+
mockStripeInstance.prices.list.mockReturnValue(
145+
mockListResult([{ product: 'prod_one', unit_amount: 500, id: 'price_one' }]),
146+
);
154147

155148
const mod = await import('../services/billing.plans.service.js');
156149
BillingPlansService = mod.default;
@@ -163,12 +156,12 @@ describe('Billing plans service unit tests:', () => {
163156
});
164157

165158
test('should handle null unit_amount gracefully', async () => {
166-
mockStripeInstance.products.list.mockResolvedValue({
167-
data: [{ id: 'prod_metered', name: 'Metered', metadata: { planId: 'metered' } }],
168-
});
169-
mockStripeInstance.prices.list.mockResolvedValue({
170-
data: [{ product: 'prod_metered', recurring: { interval: 'month' }, unit_amount: null, id: 'price_metered' }],
171-
});
159+
mockStripeInstance.products.list.mockReturnValue(
160+
mockListResult([{ id: 'prod_metered', name: 'Metered', metadata: { planId: 'metered' } }]),
161+
);
162+
mockStripeInstance.prices.list.mockReturnValue(
163+
mockListResult([{ product: 'prod_metered', recurring: { interval: 'month' }, unit_amount: null, id: 'price_metered' }]),
164+
);
172165

173166
const mod = await import('../services/billing.plans.service.js');
174167
BillingPlansService = mod.default;
@@ -178,14 +171,14 @@ describe('Billing plans service unit tests:', () => {
178171
expect(plans[0].stripePriceMonthly).toBe('price_metered');
179172
});
180173

181-
test('should fetch products and prices with limit 100', async () => {
174+
test('should use autoPagingToArray for products and prices', async () => {
182175
const mod = await import('../services/billing.plans.service.js');
183176
BillingPlansService = mod.default;
184177

185178
await BillingPlansService.getPlans();
186179

187-
expect(mockStripeInstance.products.list).toHaveBeenCalledWith({ active: true, limit: 100 });
188-
expect(mockStripeInstance.prices.list).toHaveBeenCalledWith({ active: true, limit: 100 });
180+
expect(mockStripeInstance.products.list).toHaveBeenCalledWith({ active: true });
181+
expect(mockStripeInstance.prices.list).toHaveBeenCalledWith({ active: true });
189182
});
190183

191184
test('should only make one prices.list call regardless of product count', async () => {
@@ -196,4 +189,21 @@ describe('Billing plans service unit tests:', () => {
196189

197190
expect(mockStripeInstance.prices.list).toHaveBeenCalledTimes(1);
198191
});
192+
193+
test('should refresh cache after TTL expires', async () => {
194+
const mod = await import('../services/billing.plans.service.js');
195+
BillingPlansService = mod.default;
196+
197+
await BillingPlansService.getPlans();
198+
199+
// Advance time past TTL (1 hour)
200+
const originalDateNow = Date.now;
201+
Date.now = jest.fn().mockReturnValue(originalDateNow() + 61 * 60 * 1000);
202+
203+
await BillingPlansService.getPlans();
204+
205+
expect(mockStripeInstance.products.list).toHaveBeenCalledTimes(2);
206+
207+
Date.now = originalDateNow;
208+
});
199209
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,5 +173,15 @@ describe('Billing unit tests:', () => {
173173
expect(result.data.stripeSubscriptionId).toBe('sub_xyz789');
174174
done();
175175
});
176+
177+
test('should not inject defaults in SubscriptionUpdate partial', (done) => {
178+
const update = { plan: 'pro' };
179+
const result = schema.SubscriptionUpdate.safeParse(update);
180+
expect(result.error).toBeFalsy();
181+
expect(result.data.plan).toBe('pro');
182+
expect(result.data.status).toBeUndefined();
183+
expect(result.data.cancelAtPeriodEnd).toBeUndefined();
184+
done();
185+
});
176186
});
177187
});

0 commit comments

Comments
 (0)