Skip to content

Commit cf4763f

Browse files
feat(billing): add generic BillingUsage model for quota tracking (#3273)
* feat(billing): add generic BillingUsage model for quota tracking * 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 fc34ae8 commit cf4763f

5 files changed

Lines changed: 340 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
const Schema = mongoose.Schema;
7+
8+
/**
9+
* Data Model Mongoose
10+
*/
11+
const UsageMongoose = new Schema(
12+
{
13+
organizationId: {
14+
type: Schema.ObjectId,
15+
ref: 'Organization',
16+
required: true,
17+
},
18+
month: {
19+
type: String,
20+
required: true,
21+
index: true,
22+
},
23+
counters: {
24+
type: Schema.Types.Mixed,
25+
default: () => ({}),
26+
},
27+
},
28+
{
29+
timestamps: true,
30+
},
31+
);
32+
33+
UsageMongoose.index({ organizationId: 1, month: 1 }, { unique: true });
34+
35+
/**
36+
* Returns the hex string representation of the document ObjectId.
37+
* @returns {string} Hex string of the ObjectId.
38+
*/
39+
function addID() {
40+
return this._id.toHexString();
41+
}
42+
43+
/**
44+
* Model configuration
45+
*/
46+
UsageMongoose.virtual('id').get(addID);
47+
// Ensure virtual fields are serialised.
48+
UsageMongoose.set('toJSON', {
49+
virtuals: true,
50+
});
51+
52+
mongoose.model('BillingUsage', UsageMongoose);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import { z } from 'zod';
5+
6+
/**
7+
* Data Schema
8+
*/
9+
const objectIdRegex = /^[a-f\d]{24}$/i;
10+
11+
const BillingUsage = z.object({
12+
organizationId: z.string().trim().regex(objectIdRegex, 'organizationId must be a valid ObjectId'),
13+
month: z.string().trim().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format'),
14+
counters: z.record(z.string(), z.number()).default(() => ({})),
15+
});
16+
17+
export default {
18+
BillingUsage,
19+
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
const BillingUsage = mongoose.model('BillingUsage');
7+
8+
const SAFE_KEY_RE = /^[a-zA-Z0-9_-]+$/;
9+
10+
/**
11+
* @function get
12+
* @description Fetch a single usage document by organizationId and month.
13+
* @param {String} organizationId - The organization ID.
14+
* @param {String} month - The month in YYYY-MM format.
15+
* @returns {Promise<Object|null>} The usage document or null.
16+
*/
17+
const get = (organizationId, month) => {
18+
if (!mongoose.Types.ObjectId.isValid(organizationId)) return null;
19+
return BillingUsage.findOne({ organizationId, month }).exec();
20+
};
21+
22+
/**
23+
* @function increment
24+
* @description Atomically increment a counter key for the given org+month, with upsert.
25+
* @param {String} organizationId - The organization ID.
26+
* @param {String} month - The month in YYYY-MM format.
27+
* @param {String} key - The counter key to increment (e.g. 'executions').
28+
* @param {Number} amount - The amount to increment by.
29+
* @returns {Promise<Object>} The updated usage document.
30+
*/
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+
};
51+
52+
/**
53+
* @function reset
54+
* @description Reset all counters to an empty object for the given org+month.
55+
* @param {String} organizationId - The organization ID.
56+
* @param {String} month - The month in YYYY-MM format.
57+
* @returns {Promise<Object|null>} The updated usage document or null.
58+
*/
59+
const reset = (organizationId, month) => {
60+
if (!mongoose.Types.ObjectId.isValid(organizationId)) return null;
61+
return BillingUsage.findOneAndUpdate(
62+
{ organizationId, month },
63+
{ $set: { counters: {} } },
64+
{ returnDocument: 'after', runValidators: true },
65+
).exec();
66+
};
67+
68+
export default {
69+
get,
70+
increment,
71+
reset,
72+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import UsageRepository from '../repositories/billing.usage.repository.js';
5+
6+
/**
7+
* Compute the current month string in YYYY-MM format.
8+
* @returns {String} e.g. '2026-03'
9+
*/
10+
const currentMonth = () => {
11+
const now = new Date();
12+
const year = now.getUTCFullYear();
13+
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
14+
return `${year}-${month}`;
15+
};
16+
17+
/**
18+
* @desc Increment a usage counter for the given organization (current month).
19+
* @param {String} organizationId - The organization ID.
20+
* @param {String} key - The counter key to increment.
21+
* @param {Number} amount - The amount to increment by.
22+
* @returns {Promise<Object>} The updated usage document.
23+
*/
24+
const increment = (organizationId, key, amount) => UsageRepository.increment(organizationId, currentMonth(), key, amount);
25+
26+
/**
27+
* @desc Get usage for the given organization (current month).
28+
* @param {String} organizationId - The organization ID.
29+
* @returns {Promise<Object>} The usage document or an object with empty counters.
30+
*/
31+
const get = async (organizationId) => {
32+
const month = currentMonth();
33+
const usage = await UsageRepository.get(organizationId, month);
34+
return usage || { organizationId, month, counters: {} };
35+
};
36+
37+
/**
38+
* @desc Reset usage counters for the given organization (current month).
39+
* @param {String} organizationId - The organization ID.
40+
* @returns {Promise<Object|null>} The updated usage document or null.
41+
*/
42+
const reset = (organizationId) => UsageRepository.reset(organizationId, currentMonth());
43+
44+
export default {
45+
increment,
46+
get,
47+
reset,
48+
};
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';
5+
import schema from '../models/billing.usage.schema.js';
6+
7+
/**
8+
* Unit tests
9+
*/
10+
describe('BillingUsage unit tests:', () => {
11+
describe('Schema validation', () => {
12+
let usage;
13+
14+
beforeEach(() => {
15+
usage = {
16+
organizationId: '507f1f77bcf86cd799439011',
17+
month: '2026-03',
18+
counters: { executions: 10 },
19+
};
20+
});
21+
22+
test('should be valid with correct data', () => {
23+
const result = schema.BillingUsage.safeParse(usage);
24+
expect(result.error).toBeFalsy();
25+
expect(result.data.organizationId).toBe('507f1f77bcf86cd799439011');
26+
expect(result.data.month).toBe('2026-03');
27+
expect(result.data.counters.executions).toBe(10);
28+
});
29+
30+
test('should show error when organizationId is missing', () => {
31+
usage.organizationId = '';
32+
const result = schema.BillingUsage.safeParse(usage);
33+
expect(result.error).toBeDefined();
34+
});
35+
36+
test('should show error when organizationId is invalid', () => {
37+
usage.organizationId = 'not-valid';
38+
const result = schema.BillingUsage.safeParse(usage);
39+
expect(result.error).toBeDefined();
40+
});
41+
42+
test('should show error when month format is invalid', () => {
43+
usage.month = '2026/03';
44+
const result = schema.BillingUsage.safeParse(usage);
45+
expect(result.error).toBeDefined();
46+
});
47+
48+
test('should show error when month is missing', () => {
49+
delete usage.month;
50+
const result = schema.BillingUsage.safeParse(usage);
51+
expect(result.error).toBeDefined();
52+
});
53+
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+
66+
test('should default counters to empty object', () => {
67+
delete usage.counters;
68+
const result = schema.BillingUsage.safeParse(usage);
69+
expect(result.error).toBeFalsy();
70+
expect(result.data.counters).toEqual({});
71+
});
72+
});
73+
74+
describe('Service layer', () => {
75+
let BillingUsageService;
76+
let mockUsageRepository;
77+
78+
const orgId = '507f1f77bcf86cd799439011';
79+
80+
beforeEach(async () => {
81+
jest.resetModules();
82+
83+
mockUsageRepository = {
84+
get: jest.fn(),
85+
increment: jest.fn(),
86+
reset: jest.fn(),
87+
};
88+
89+
jest.unstable_mockModule('../repositories/billing.usage.repository.js', () => ({
90+
default: mockUsageRepository,
91+
}));
92+
93+
const mod = await import('../services/billing.usage.service.js');
94+
BillingUsageService = mod.default;
95+
});
96+
97+
afterEach(() => {
98+
jest.restoreAllMocks();
99+
});
100+
101+
test('increment should create document on first call (upsert)', async () => {
102+
const created = { organizationId: orgId, month: '2026-03', counters: { executions: 1 } };
103+
mockUsageRepository.increment.mockResolvedValue(created);
104+
105+
const result = await BillingUsageService.increment(orgId, 'executions', 1);
106+
107+
expect(mockUsageRepository.increment).toHaveBeenCalledWith(orgId, expect.stringMatching(/^\d{4}-\d{2}$/), 'executions', 1);
108+
expect(result.counters.executions).toBe(1);
109+
});
110+
111+
test('increment should atomically increase counter', async () => {
112+
const updated = { organizationId: orgId, month: '2026-03', counters: { executions: 5 } };
113+
mockUsageRepository.increment.mockResolvedValue(updated);
114+
115+
const result = await BillingUsageService.increment(orgId, 'executions', 3);
116+
117+
expect(result.counters.executions).toBe(5);
118+
});
119+
120+
test('get should return empty counters for new org/month', async () => {
121+
mockUsageRepository.get.mockResolvedValue(null);
122+
123+
const result = await BillingUsageService.get(orgId);
124+
125+
expect(result.counters).toEqual({});
126+
expect(result.organizationId).toBe(orgId);
127+
});
128+
129+
test('get should return existing usage document', async () => {
130+
const existing = { organizationId: orgId, month: '2026-03', counters: { executions: 42, aiCalls: 5 } };
131+
mockUsageRepository.get.mockResolvedValue(existing);
132+
133+
const result = await BillingUsageService.get(orgId);
134+
135+
expect(result.counters.executions).toBe(42);
136+
expect(result.counters.aiCalls).toBe(5);
137+
});
138+
139+
test('reset should clear counters', async () => {
140+
const resetDoc = { organizationId: orgId, month: '2026-03', counters: {} };
141+
mockUsageRepository.reset.mockResolvedValue(resetDoc);
142+
143+
const result = await BillingUsageService.reset(orgId);
144+
145+
expect(mockUsageRepository.reset).toHaveBeenCalledWith(orgId, expect.stringMatching(/^\d{4}-\d{2}$/));
146+
expect(result.counters).toEqual({});
147+
});
148+
});
149+
});

0 commit comments

Comments
 (0)