Skip to content

Commit fc6c49f

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 a1c6542 commit fc6c49f

4 files changed

Lines changed: 98 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,

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,5 +121,57 @@ describe('Billing unit tests:', () => {
121121
expect(result.data.plan).toBe('starter');
122122
done();
123123
});
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+
});
124176
});
125177
});

0 commit comments

Comments
 (0)