Skip to content

Commit f17f27e

Browse files
feat(billing): add requireQuota middleware for plan-based limits
1 parent cf4763f commit f17f27e

2 files changed

Lines changed: 249 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
* Expects `req.organization` to be set by resolveOrganization upstream.
16+
*
17+
* @param {string} resource - The quota resource name (e.g. 'scraps').
18+
* @param {string} action - The quota action name (e.g. 'create', 'execute').
19+
* @returns {Function} Express middleware function.
20+
*/
21+
function requireQuota(resource, action) {
22+
return async function requireQuotaMiddleware(req, res, next) {
23+
if (!req.organization) {
24+
return responses.error(res, 403, 'Forbidden', 'Organization context is required to check quota')();
25+
}
26+
27+
try {
28+
// Determine current plan — default to free when no subscription or past_due
29+
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
30+
const plan = (!subscription || subscription.status === 'past_due') ? 'free' : (subscription.plan || 'free');
31+
32+
// Look up quota limit from config
33+
const quotas = config.billing?.quotas;
34+
const limit = quotas?.[plan]?.[resource]?.[action];
35+
36+
// If no quota is configured for this plan/resource/action, allow through
37+
if (limit === undefined || limit === null) return next();
38+
39+
// Infinity means unlimited — skip usage check
40+
if (limit === Infinity) return next();
41+
42+
// Check current usage
43+
const usage = await BillingUsageService.get(req.organization._id.toString());
44+
const counterKey = `${resource}.${action}`;
45+
const current = usage.counters[counterKey] || 0;
46+
47+
if (current >= limit) {
48+
return res.status(429).json({
49+
type: 'QUOTA_EXCEEDED',
50+
resource,
51+
action,
52+
limit,
53+
current,
54+
upgradeUrl: config.billing?.upgradeUrl || '/billing/plans',
55+
});
56+
}
57+
58+
return next();
59+
} catch (err) {
60+
return next(err);
61+
}
62+
};
63+
}
64+
65+
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.quota.middleware.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: 'QUOTA_EXCEEDED',
90+
resource: 'scraps',
91+
action: 'create',
92+
limit: 3,
93+
current: 3,
94+
}));
95+
});
96+
97+
test('should return 429 when over quota limit', async () => {
98+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
99+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 5 } });
100+
101+
await requireQuota('scraps', 'create')(req, res, next);
102+
103+
expect(next).not.toHaveBeenCalled();
104+
expect(res.status).toHaveBeenCalledWith(429);
105+
});
106+
107+
test('should treat missing subscription as free plan', async () => {
108+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
109+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
110+
111+
await requireQuota('scraps', 'create')(req, res, next);
112+
113+
expect(next).not.toHaveBeenCalled();
114+
expect(res.status).toHaveBeenCalledWith(429);
115+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
116+
limit: 3,
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+
limit: 3,
130+
}));
131+
});
132+
133+
test('should allow unlimited (Infinity) without checking usage', async () => {
134+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' });
135+
136+
await requireQuota('scraps', 'create')(req, res, next);
137+
138+
expect(next).toHaveBeenCalled();
139+
expect(mockBillingUsageService.get).not.toHaveBeenCalled();
140+
});
141+
142+
test('should return correct error payload with upgradeUrl', async () => {
143+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
144+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.execute': 100 } });
145+
146+
await requireQuota('scraps', 'execute')(req, res, next);
147+
148+
expect(res.status).toHaveBeenCalledWith(429);
149+
expect(res.json).toHaveBeenCalledWith({
150+
type: 'QUOTA_EXCEEDED',
151+
resource: 'scraps',
152+
action: 'execute',
153+
limit: 100,
154+
current: 100,
155+
upgradeUrl: '/billing/plans',
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)