Skip to content

Commit 6df1249

Browse files
fix(billing): use active/trialing status allowlist in requireQuota middleware (#3280)
* fix(billing): use active/trialing status allowlist in requireQuota middleware * fix(billing): extract activeStatuses constant and use test.each for inactive statuses - Hoist activeStatuses to shared lib/constants.js to avoid duplication between requireQuota middleware and billing controller - Collapse individual inactive-status test cases into test.each table for easier maintenance and extensibility
1 parent 0c2efb2 commit 6df1249

4 files changed

Lines changed: 35 additions & 16 deletions

File tree

modules/billing/controllers/billing.controller.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Module dependencies
33
*/
4+
import { activeStatuses } from '../lib/constants.js';
45
import config from '../../../config/index.js';
56
import responses from '../../../lib/helpers/responses.js';
67
import BillingService from '../services/billing.service.js';
@@ -67,7 +68,6 @@ const getUsage = async (req, res) => {
6768
try {
6869
// Determine current plan via service layer
6970
const subscription = await BillingService.getSubscription(req.organization._id);
70-
const activeStatuses = ['active', 'trialing'];
7171
const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? 'free' : (subscription.plan || 'free');
7272

7373
// Get usage counters (includes month field)

modules/billing/lib/constants.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Stripe subscription statuses that indicate an active subscription.
3+
* Any status not in this list is treated as inactive (falls back to free plan).
4+
*/
5+
export const activeStatuses = ['active', 'trialing'];

modules/billing/middlewares/billing.requireQuota.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
55
import BillingUsageService from '../services/billing.usage.service.js';
66

7+
import { activeStatuses } from '../lib/constants.js';
78
import config from '../../../config/index.js';
89
import responses from '../../../lib/helpers/responses.js';
910

@@ -37,9 +38,9 @@ function requireQuota(resource, action) {
3738
}
3839

3940
try {
40-
// Determine current plan — default to free when no subscription or past_due
41+
// Determine current plan — default to free when subscription is missing or inactive
4142
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
42-
const plan = (!subscription || subscription.status === 'past_due') ? 'free' : (subscription.plan || 'free');
43+
const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? 'free' : (subscription.plan || 'free');
4344

4445
// Look up quota limit from config
4546
const quotas = config.billing?.quotas;

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

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -117,19 +117,22 @@ describe('requireQuota middleware:', () => {
117117
}));
118118
});
119119

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-
});
120+
test.each(['past_due', 'canceled', 'unpaid', 'incomplete', 'incomplete_expired', 'paused'])(
121+
'should treat %s subscription as free plan',
122+
async (status) => {
123+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status });
124+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
125+
126+
await requireQuota('scraps', 'create')(req, res, next);
127+
128+
expect(next).not.toHaveBeenCalled();
129+
expect(res.status).toHaveBeenCalledWith(429);
130+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
131+
type: 'error',
132+
code: 429,
133+
}));
134+
},
135+
);
133136

134137
test('should allow unlimited (Infinity) without checking usage', async () => {
135138
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' });
@@ -173,6 +176,16 @@ describe('requireQuota middleware:', () => {
173176
expect(next).toHaveBeenCalled();
174177
});
175178

179+
test('should use subscription plan when status is trialing', async () => {
180+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'trialing' });
181+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 15 } });
182+
183+
await requireQuota('scraps', 'create')(req, res, next);
184+
185+
expect(next).toHaveBeenCalled();
186+
expect(res.status).not.toHaveBeenCalled();
187+
});
188+
176189
test('should treat zero usage as under quota', async () => {
177190
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
178191
mockBillingUsageService.get.mockResolvedValue({ counters: {} });

0 commit comments

Comments
 (0)