Skip to content

Commit f588da1

Browse files
fix(billing): address review feedback on BillingUsage model
- Validate organizationId in increment(), sanitize counter key against injection - Handle E11000 duplicate key race on concurrent upserts (retry once) - Add runValidators to reset() for consistency with increment() - Tighten month regex to reject invalid months (00, 13) - Use UTC for currentMonth() to avoid timezone-dependent boundaries - Capture month once in get() to prevent cross-boundary inconsistency - Remove redundant individual index on organizationId (compound covers it) - Add missing Jest globals imports in test file - Add tests for semantically invalid month values
1 parent d2d42b2 commit f588da1

5 files changed

Lines changed: 42 additions & 13 deletions

File tree

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ const UsageMongoose = new Schema(
1414
type: Schema.ObjectId,
1515
ref: 'Organization',
1616
required: true,
17-
index: true,
1817
},
1918
month: {
2019
type: String,

modules/billing/models/billing.usage.schema.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const objectIdRegex = /^[a-f\d]{24}$/i;
1010

1111
const BillingUsage = z.object({
1212
organizationId: z.string().trim().regex(objectIdRegex, 'organizationId must be a valid ObjectId'),
13-
month: z.string().trim().regex(/^\d{4}-\d{2}$/, 'month must be in YYYY-MM format'),
13+
month: z.string().trim().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format'),
1414
counters: z.record(z.string(), z.number()).default(() => ({})),
1515
});
1616

modules/billing/repositories/billing.usage.repository.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import mongoose from 'mongoose';
55

66
const BillingUsage = mongoose.model('BillingUsage');
77

8+
const SAFE_KEY_RE = /^[a-zA-Z0-9_-]+$/;
9+
810
/**
911
* @function get
1012
* @description Fetch a single usage document by organizationId and month.
@@ -26,11 +28,26 @@ const get = (organizationId, month) => {
2628
* @param {Number} amount - The amount to increment by.
2729
* @returns {Promise<Object>} The updated usage document.
2830
*/
29-
const increment = (organizationId, month, key, amount) => BillingUsage.findOneAndUpdate(
30-
{ organizationId, month },
31-
{ $inc: { [`counters.${key}`]: amount } },
32-
{ upsert: true, returnDocument: 'after', runValidators: true },
33-
).exec();
31+
const increment = async (organizationId, month, key, amount) => {
32+
if (!mongoose.Types.ObjectId.isValid(organizationId)) return null;
33+
if (!SAFE_KEY_RE.test(key)) throw new Error(`Invalid counter key: ${key}`);
34+
try {
35+
return await BillingUsage.findOneAndUpdate(
36+
{ organizationId, month },
37+
{ $inc: { [`counters.${key}`]: amount } },
38+
{ upsert: true, returnDocument: 'after', runValidators: true },
39+
).exec();
40+
} catch (err) {
41+
if (err.code === 11000) {
42+
return BillingUsage.findOneAndUpdate(
43+
{ organizationId, month },
44+
{ $inc: { [`counters.${key}`]: amount } },
45+
{ returnDocument: 'after', runValidators: true },
46+
).exec();
47+
}
48+
throw err;
49+
}
50+
};
3451

3552
/**
3653
* @function reset
@@ -44,7 +61,7 @@ const reset = (organizationId, month) => {
4461
return BillingUsage.findOneAndUpdate(
4562
{ organizationId, month },
4663
{ $set: { counters: {} } },
47-
{ returnDocument: 'after' },
64+
{ returnDocument: 'after', runValidators: true },
4865
).exec();
4966
};
5067

modules/billing/services/billing.usage.service.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import UsageRepository from '../repositories/billing.usage.repository.js';
99
*/
1010
const currentMonth = () => {
1111
const now = new Date();
12-
const year = now.getFullYear();
13-
const month = String(now.getMonth() + 1).padStart(2, '0');
12+
const year = now.getUTCFullYear();
13+
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
1414
return `${year}-${month}`;
1515
};
1616

@@ -29,8 +29,9 @@ const increment = (organizationId, key, amount) => UsageRepository.increment(org
2929
* @returns {Promise<Object>} The usage document or an object with empty counters.
3030
*/
3131
const get = async (organizationId) => {
32-
const usage = await UsageRepository.get(organizationId, currentMonth());
33-
return usage || { organizationId, month: currentMonth(), counters: {} };
32+
const month = currentMonth();
33+
const usage = await UsageRepository.get(organizationId, month);
34+
return usage || { organizationId, month, counters: {} };
3435
};
3536

3637
/**

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Module dependencies.
33
*/
4-
import { jest, beforeEach, afterEach } from '@jest/globals';
4+
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';
55
import schema from '../models/billing.usage.schema.js';
66

77
/**
@@ -51,6 +51,18 @@ describe('BillingUsage unit tests:', () => {
5151
expect(result.error).toBeDefined();
5252
});
5353

54+
test('should reject semantically invalid month 2026-00', () => {
55+
usage.month = '2026-00';
56+
const result = schema.BillingUsage.safeParse(usage);
57+
expect(result.error).toBeDefined();
58+
});
59+
60+
test('should reject semantically invalid month 2026-13', () => {
61+
usage.month = '2026-13';
62+
const result = schema.BillingUsage.safeParse(usage);
63+
expect(result.error).toBeDefined();
64+
});
65+
5466
test('should default counters to empty object', () => {
5567
delete usage.counters;
5668
const result = schema.BillingUsage.safeParse(usage);

0 commit comments

Comments
 (0)