Skip to content

Commit 4ee7e48

Browse files
feat(billing): add requireQuota middleware for plan-based limits (#3275)
* feat(billing): add requireQuota middleware for plan-based limits * fix(billing): address review feedback on requireQuota middleware - Rename billing.quota.middleware.js → billing.requireQuota.js for naming consistency with billing.requirePlan.js - Add JSDoc to inner middleware function per repository coding guidelines - Document bypass cases (null/undefined limit, Infinity) in outer JSDoc - Use responses.error envelope for 429 quota response instead of raw JSON - Update test assertions to match standardized response envelope
1 parent cf4763f commit 4ee7e48

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
5+
import BillingUsageService from '../services/billing.usage.service.js';
6+
7+
import config from '../../../config/index.js';
8+
import responses from '../../../lib/helpers/responses.js';
9+
10+
/**
11+
* Returns Express middleware that gates access based on plan quotas.
12+
* Reads limits from `config.billing.quotas[plan][resource][action]` and
13+
* compares against the current month's usage via BillingUsageService.
14+
*
15+
* When no quota is configured for the plan/resource/action combination
16+
* (limit is `null` or `undefined`), the request is allowed through.
17+
* When the limit is `Infinity`, the request is also allowed without
18+
* checking usage (unlimited plan).
19+
*
20+
* Expects `req.organization` to be set by resolveOrganization upstream.
21+
*
22+
* @param {string} resource - The quota resource name (e.g. 'scraps').
23+
* @param {string} action - The quota action name (e.g. 'create', 'execute').
24+
* @returns {Function} Express middleware function.
25+
*/
26+
function requireQuota(resource, action) {
27+
/**
28+
* Enforce quota for a resource/action and block requests when limit is reached.
29+
* @param {import('express').Request} req - Express request object.
30+
* @param {import('express').Response} res - Express response object.
31+
* @param {import('express').NextFunction} next - Express next callback.
32+
* @returns {Promise<void>} Resolves when middleware handling completes.
33+
*/
34+
return async function requireQuotaMiddleware(req, res, next) {
35+
if (!req.organization) {
36+
return responses.error(res, 403, 'Forbidden', 'Organization context is required to check quota')();
37+
}
38+
39+
try {
40+
// Determine current plan — default to free when no subscription or past_due
41+
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
42+
const plan = (!subscription || subscription.status === 'past_due') ? 'free' : (subscription.plan || 'free');
43+
44+
// Look up quota limit from config
45+
const quotas = config.billing?.quotas;
46+
const limit = quotas?.[plan]?.[resource]?.[action];
47+
48+
// If no quota is configured for this plan/resource/action, allow through
49+
if (limit === undefined || limit === null) return next();
50+
51+
// Infinity means unlimited — skip usage check
52+
if (limit === Infinity) return next();
53+
54+
// Check current usage
55+
const usage = await BillingUsageService.get(req.organization._id.toString());
56+
const counterKey = `${resource}.${action}`;
57+
const current = usage.counters[counterKey] || 0;
58+
59+
if (current >= limit) {
60+
return responses.error(res, 429, 'Quota exceeded', 'You have reached the usage limit for this resource')({
61+
type: 'QUOTA_EXCEEDED',
62+
resource,
63+
action,
64+
limit,
65+
current,
66+
upgradeUrl: config.billing?.upgradeUrl || '/billing/plans',
67+
});
68+
}
69+
70+
return next();
71+
} catch (err) {
72+
return next(err);
73+
}
74+
};
75+
}
76+
77+
export default requireQuota;
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests
8+
*/
9+
describe('requireQuota middleware:', () => {
10+
let requireQuota;
11+
let mockSubscriptionRepository;
12+
let mockBillingUsageService;
13+
let mockConfig;
14+
let req;
15+
let res;
16+
let next;
17+
18+
beforeEach(async () => {
19+
jest.resetModules();
20+
21+
mockSubscriptionRepository = {
22+
findByOrganization: jest.fn(),
23+
};
24+
25+
mockBillingUsageService = {
26+
get: jest.fn(),
27+
};
28+
29+
mockConfig = {
30+
billing: {
31+
quotas: {
32+
free: { scraps: { create: 3, execute: 100 } },
33+
starter: { scraps: { create: 20, execute: 2000 } },
34+
pro: { scraps: { create: Infinity, execute: Infinity } },
35+
},
36+
},
37+
};
38+
39+
jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
40+
default: mockSubscriptionRepository,
41+
}));
42+
43+
jest.unstable_mockModule('../services/billing.usage.service.js', () => ({
44+
default: mockBillingUsageService,
45+
}));
46+
47+
jest.unstable_mockModule('../../../config/index.js', () => ({
48+
default: mockConfig,
49+
}));
50+
51+
const mod = await import('../middlewares/billing.requireQuota.js');
52+
requireQuota = mod.default;
53+
54+
req = {
55+
organization: { _id: '507f1f77bcf86cd799439011' },
56+
};
57+
58+
res = {
59+
status: jest.fn().mockReturnThis(),
60+
json: jest.fn().mockReturnThis(),
61+
};
62+
63+
next = jest.fn();
64+
});
65+
66+
afterEach(() => {
67+
jest.restoreAllMocks();
68+
});
69+
70+
test('should allow request when under quota', async () => {
71+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
72+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 1 } });
73+
74+
await requireQuota('scraps', 'create')(req, res, next);
75+
76+
expect(next).toHaveBeenCalled();
77+
expect(res.status).not.toHaveBeenCalled();
78+
});
79+
80+
test('should return 429 when at quota limit', async () => {
81+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
82+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
83+
84+
await requireQuota('scraps', 'create')(req, res, next);
85+
86+
expect(next).not.toHaveBeenCalled();
87+
expect(res.status).toHaveBeenCalledWith(429);
88+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
89+
type: 'error',
90+
message: 'Quota exceeded',
91+
code: 429,
92+
status: 429,
93+
}));
94+
});
95+
96+
test('should return 429 when over quota limit', async () => {
97+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
98+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 5 } });
99+
100+
await requireQuota('scraps', 'create')(req, res, next);
101+
102+
expect(next).not.toHaveBeenCalled();
103+
expect(res.status).toHaveBeenCalledWith(429);
104+
});
105+
106+
test('should treat missing subscription as free plan', async () => {
107+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
108+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
109+
110+
await requireQuota('scraps', 'create')(req, res, next);
111+
112+
expect(next).not.toHaveBeenCalled();
113+
expect(res.status).toHaveBeenCalledWith(429);
114+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
115+
type: 'error',
116+
code: 429,
117+
}));
118+
});
119+
120+
test('should treat past_due subscription as free plan', async () => {
121+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'past_due' });
122+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
123+
124+
await requireQuota('scraps', 'create')(req, res, next);
125+
126+
expect(next).not.toHaveBeenCalled();
127+
expect(res.status).toHaveBeenCalledWith(429);
128+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
129+
type: 'error',
130+
code: 429,
131+
}));
132+
});
133+
134+
test('should allow unlimited (Infinity) without checking usage', async () => {
135+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' });
136+
137+
await requireQuota('scraps', 'create')(req, res, next);
138+
139+
expect(next).toHaveBeenCalled();
140+
expect(mockBillingUsageService.get).not.toHaveBeenCalled();
141+
});
142+
143+
test('should return correct error payload with upgradeUrl', async () => {
144+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
145+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.execute': 100 } });
146+
147+
await requireQuota('scraps', 'execute')(req, res, next);
148+
149+
expect(res.status).toHaveBeenCalledWith(429);
150+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
151+
type: 'error',
152+
message: 'Quota exceeded',
153+
code: 429,
154+
status: 429,
155+
description: 'You have reached the usage limit for this resource',
156+
}));
157+
});
158+
159+
test('should return 403 when organization context is missing', async () => {
160+
req.organization = undefined;
161+
162+
await requireQuota('scraps', 'create')(req, res, next);
163+
164+
expect(next).not.toHaveBeenCalled();
165+
expect(res.status).toHaveBeenCalledWith(403);
166+
});
167+
168+
test('should allow through when no quota is configured for resource', async () => {
169+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
170+
171+
await requireQuota('unknownResource', 'create')(req, res, next);
172+
173+
expect(next).toHaveBeenCalled();
174+
});
175+
176+
test('should treat zero usage as under quota', async () => {
177+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
178+
mockBillingUsageService.get.mockResolvedValue({ counters: {} });
179+
180+
await requireQuota('scraps', 'create')(req, res, next);
181+
182+
expect(next).toHaveBeenCalled();
183+
});
184+
});

0 commit comments

Comments
 (0)