Skip to content

Commit 2708fed

Browse files
fix(billing): address review feedback on Subscription model
- Validate organization as ObjectId format in Zod schema - Normalize empty Stripe IDs to undefined to prevent sparse index issues - Fix id/_id inconsistency in update/remove repository methods - Add defensive guard in findByStripeCustomerId for falsy input - Update JSDoc @return to @returns and add Promise return types - Add unit tests for ObjectId validation and Stripe ID normalization
1 parent 539b9bf commit 2708fed

4 files changed

Lines changed: 223 additions & 17 deletions

File tree

modules/billing/models/subscription.model.mongoose.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ const SubscriptionMongoose = new Schema(
5050
);
5151

5252
/**
53-
* @desc Function to add id (+ _id) to all objects
54-
* @return {String} hex string of the ObjectId
53+
* Returns the hex string representation of the document ObjectId.
54+
* @returns {string} Hex string of the ObjectId.
5555
*/
5656
function addID() {
5757
return this._id.toHexString();

modules/billing/models/subscription.schema.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,23 @@ import { z } from 'zod';
66
/**
77
* Data Schema
88
*/
9+
const objectIdRegex = /^[a-f\d]{24}$/i;
10+
911
const Subscription = z.object({
10-
organization: z.string().trim().min(1),
11-
stripeCustomerId: z.string().trim().optional(),
12-
stripeSubscriptionId: z.string().trim().optional(),
12+
organization: z
13+
.string()
14+
.trim()
15+
.regex(objectIdRegex, 'organization must be a valid ObjectId'),
16+
stripeCustomerId: z
17+
.string()
18+
.trim()
19+
.optional()
20+
.transform((val) => (val === '' ? undefined : val)),
21+
stripeSubscriptionId: z
22+
.string()
23+
.trim()
24+
.optional()
25+
.transform((val) => (val === '' ? undefined : val)),
1326
plan: z.enum(['free', 'starter', 'pro']).default('free'),
1427
status: z.enum(['active', 'past_due', 'canceled', 'trialing', 'incomplete']).default('active'),
1528
currentPeriodEnd: z.coerce.date().nullable().optional(),

modules/billing/repositories/subscription.repository.js

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,34 @@ const defaultPopulate = [
1212
},
1313
];
1414

15+
/**
16+
* Resolves the identifier from a subscription object or raw id.
17+
* @param {Object|string} value - The subscription object or id string.
18+
* @returns {string|undefined} The resolved id.
19+
*/
20+
const resolveId = (value) => value?._id || value?.id || value;
21+
1522
/**
1623
* @function list
1724
* @description Data access operation to fetch all subscriptions from the database with an optional filter.
1825
* @param {Object} [filter] - Optional filter to apply to the query.
19-
* @returns {Array} An array of subscriptions.
26+
* @returns {Promise<Array>} A promise resolving to an array of subscriptions.
2027
*/
2128
const list = (filter) => Subscription.find(filter).populate(defaultPopulate).sort('-createdAt').exec();
2229

2330
/**
2431
* @function create
2532
* @description Data access operation to create a new subscription in the database.
2633
* @param {Object} subscription - The subscription object to create.
27-
* @returns {Object} The created subscription.
34+
* @returns {Promise<Object>} A promise resolving to the created subscription.
2835
*/
2936
const create = (subscription) => new Subscription(subscription).save().then((doc) => doc.populate(defaultPopulate));
3037

3138
/**
3239
* @function get
3340
* @description Data access operation to fetch a single subscription by its ID.
3441
* @param {String} id - The ID of the subscription to fetch.
35-
* @returns {Object} The retrieved subscription or null if the ID is not valid.
42+
* @returns {Promise<Object|null>} A promise resolving to the retrieved subscription or null if the ID is not valid.
3643
*/
3744
const get = (id) => {
3845
if (!mongoose.Types.ObjectId.isValid(id)) return null;
@@ -43,11 +50,14 @@ const get = (id) => {
4350
* @function update
4451
* @description Data access operation to update an existing subscription in the database.
4552
* @param {Object} subscription - The subscription object containing the updated details.
46-
* @returns {Object} The updated subscription.
53+
* @returns {Promise<Object>} A promise resolving to the updated subscription.
4754
*/
4855
const update = (subscription) => {
49-
if (subscription._id) {
50-
return Subscription.findByIdAndUpdate(subscription._id, subscription, { returnDocument: 'after', runValidators: true })
56+
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 })
5161
.populate(defaultPopulate)
5262
.exec();
5363
}
@@ -58,15 +68,19 @@ const update = (subscription) => {
5868
* @function remove
5969
* @description Data access operation to delete a single subscription by its ID.
6070
* @param {Object} subscription - The subscription object to delete.
61-
* @returns {Object} A confirmation of the deletion.
71+
* @returns {Promise<Object|null>} A promise resolving to a confirmation of the deletion or null if invalid.
6272
*/
63-
const remove = (subscription) => Subscription.deleteOne({ _id: subscription.id }).exec();
73+
const remove = (subscription) => {
74+
const id = resolveId(subscription);
75+
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
76+
return Subscription.deleteOne({ _id: id }).exec();
77+
};
6478

6579
/**
6680
* @function findByOrganization
6781
* @description Data access operation to fetch a subscription by organization ID.
6882
* @param {String} organizationId - The organization ID.
69-
* @returns {Object} The retrieved subscription or null.
83+
* @returns {Promise<Object|null>} A promise resolving to the retrieved subscription or null.
7084
*/
7185
const findByOrganization = (organizationId) => {
7286
if (!mongoose.Types.ObjectId.isValid(organizationId)) return null;
@@ -77,10 +91,12 @@ const findByOrganization = (organizationId) => {
7791
* @function findByStripeCustomerId
7892
* @description Data access operation to fetch a subscription by Stripe customer ID.
7993
* @param {String} stripeCustomerId - The Stripe customer ID.
80-
* @returns {Object} The retrieved subscription or null.
94+
* @returns {Promise<Object|null>} A promise resolving to the retrieved subscription or null.
8195
*/
82-
const findByStripeCustomerId = (stripeCustomerId) =>
83-
Subscription.findOne({ stripeCustomerId }).populate(defaultPopulate).exec();
96+
const findByStripeCustomerId = (stripeCustomerId) => {
97+
if (!stripeCustomerId || !stripeCustomerId.trim()) return null;
98+
return Subscription.findOne({ stripeCustomerId }).populate(defaultPopulate).exec();
99+
};
84100

85101
export default {
86102
list,
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import schema from '../models/subscription.schema.js';
5+
6+
/**
7+
* Unit tests
8+
*/
9+
describe('Billing unit tests:', () => {
10+
describe('Subscription schema', () => {
11+
let subscription;
12+
13+
beforeEach(() => {
14+
subscription = {
15+
organization: '507f1f77bcf86cd799439011',
16+
plan: 'free',
17+
status: 'active',
18+
};
19+
});
20+
21+
test('should be valid a subscription example without problems', (done) => {
22+
const result = schema.Subscription.safeParse(subscription);
23+
expect(typeof result).toBe('object');
24+
expect(result.error).toBeFalsy();
25+
done();
26+
});
27+
28+
test('should be able to show an error when trying a schema without organization', (done) => {
29+
subscription.organization = '';
30+
31+
const result = schema.Subscription.safeParse(subscription);
32+
expect(typeof result).toBe('object');
33+
expect(result.error).toBeDefined();
34+
done();
35+
});
36+
37+
test('should be valid with all optional fields', (done) => {
38+
subscription.stripeCustomerId = 'cus_123';
39+
subscription.stripeSubscriptionId = 'sub_456';
40+
subscription.currentPeriodEnd = '2026-12-31T00:00:00.000Z';
41+
subscription.cancelAtPeriodEnd = true;
42+
43+
const result = schema.Subscription.safeParse(subscription);
44+
expect(typeof result).toBe('object');
45+
expect(result.error).toBeFalsy();
46+
expect(result.data.cancelAtPeriodEnd).toBe(true);
47+
done();
48+
});
49+
50+
test('should default plan to free', (done) => {
51+
delete subscription.plan;
52+
53+
const result = schema.Subscription.safeParse(subscription);
54+
expect(result.error).toBeFalsy();
55+
expect(result.data.plan).toBe('free');
56+
done();
57+
});
58+
59+
test('should default status to active', (done) => {
60+
delete subscription.status;
61+
62+
const result = schema.Subscription.safeParse(subscription);
63+
expect(result.error).toBeFalsy();
64+
expect(result.data.status).toBe('active');
65+
done();
66+
});
67+
68+
test('should reject invalid plan value', (done) => {
69+
subscription.plan = 'invalid';
70+
71+
const result = schema.Subscription.safeParse(subscription);
72+
expect(result.error).toBeDefined();
73+
done();
74+
});
75+
76+
test('should reject invalid status value', (done) => {
77+
subscription.status = 'invalid';
78+
79+
const result = schema.Subscription.safeParse(subscription);
80+
expect(result.error).toBeDefined();
81+
done();
82+
});
83+
84+
test('should accept all valid plan values', (done) => {
85+
for (const plan of ['free', 'starter', 'pro']) {
86+
subscription.plan = plan;
87+
const result = schema.Subscription.safeParse(subscription);
88+
expect(result.error).toBeFalsy();
89+
}
90+
done();
91+
});
92+
93+
test('should accept all valid status values', (done) => {
94+
for (const status of ['active', 'past_due', 'canceled', 'trialing', 'incomplete']) {
95+
subscription.status = status;
96+
const result = schema.Subscription.safeParse(subscription);
97+
expect(result.error).toBeFalsy();
98+
}
99+
done();
100+
});
101+
102+
test('should default cancelAtPeriodEnd to false', (done) => {
103+
const result = schema.Subscription.safeParse(subscription);
104+
expect(result.error).toBeFalsy();
105+
expect(result.data.cancelAtPeriodEnd).toBe(false);
106+
done();
107+
});
108+
109+
test('should strip unknown fields with SubscriptionUpdate', (done) => {
110+
const update = { plan: 'pro', unknown: 'field' };
111+
const result = schema.SubscriptionUpdate.safeParse(update);
112+
expect(result.error).toBeFalsy();
113+
expect(result.data?.unknown).toBeUndefined();
114+
done();
115+
});
116+
117+
test('should allow partial updates with SubscriptionUpdate', (done) => {
118+
const update = { plan: 'starter' };
119+
const result = schema.SubscriptionUpdate.safeParse(update);
120+
expect(result.error).toBeFalsy();
121+
expect(result.data.plan).toBe('starter');
122+
done();
123+
});
124+
125+
test('should reject invalid ObjectId for organization', (done) => {
126+
subscription.organization = 'not-an-objectid';
127+
128+
const result = schema.Subscription.safeParse(subscription);
129+
expect(result.error).toBeDefined();
130+
done();
131+
});
132+
133+
test('should accept valid ObjectId for organization', (done) => {
134+
subscription.organization = '507f1f77bcf86cd799439011';
135+
136+
const result = schema.Subscription.safeParse(subscription);
137+
expect(result.error).toBeFalsy();
138+
done();
139+
});
140+
141+
test('should normalize empty stripeCustomerId to undefined', (done) => {
142+
subscription.stripeCustomerId = '';
143+
144+
const result = schema.Subscription.safeParse(subscription);
145+
expect(result.error).toBeFalsy();
146+
expect(result.data.stripeCustomerId).toBeUndefined();
147+
done();
148+
});
149+
150+
test('should normalize empty stripeSubscriptionId to undefined', (done) => {
151+
subscription.stripeSubscriptionId = '';
152+
153+
const result = schema.Subscription.safeParse(subscription);
154+
expect(result.error).toBeFalsy();
155+
expect(result.data.stripeSubscriptionId).toBeUndefined();
156+
done();
157+
});
158+
159+
test('should preserve valid stripeCustomerId', (done) => {
160+
subscription.stripeCustomerId = 'cus_abc123';
161+
162+
const result = schema.Subscription.safeParse(subscription);
163+
expect(result.error).toBeFalsy();
164+
expect(result.data.stripeCustomerId).toBe('cus_abc123');
165+
done();
166+
});
167+
168+
test('should preserve valid stripeSubscriptionId', (done) => {
169+
subscription.stripeSubscriptionId = 'sub_xyz789';
170+
171+
const result = schema.Subscription.safeParse(subscription);
172+
expect(result.error).toBeFalsy();
173+
expect(result.data.stripeSubscriptionId).toBe('sub_xyz789');
174+
done();
175+
});
176+
});
177+
});

0 commit comments

Comments
 (0)