Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/billing/controllers/billing.controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Module dependencies
*/
import { activeStatuses } from '../lib/constants.js';
import config from '../../../config/index.js';
import responses from '../../../lib/helpers/responses.js';
import BillingService from '../services/billing.service.js';
Expand Down Expand Up @@ -67,7 +68,6 @@ const getUsage = async (req, res) => {
try {
// Determine current plan via service layer
const subscription = await BillingService.getSubscription(req.organization._id);
const activeStatuses = ['active', 'trialing'];
const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? 'free' : (subscription.plan || 'free');

// Get usage counters (includes month field)
Expand Down
5 changes: 5 additions & 0 deletions modules/billing/lib/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Stripe subscription statuses that indicate an active subscription.
* Any status not in this list is treated as inactive (falls back to free plan).
*/
export const activeStatuses = ['active', 'trialing'];
5 changes: 3 additions & 2 deletions modules/billing/middlewares/billing.requireQuota.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
import BillingUsageService from '../services/billing.usage.service.js';

import { activeStatuses } from '../lib/constants.js';
import config from '../../../config/index.js';
import responses from '../../../lib/helpers/responses.js';

Expand Down Expand Up @@ -37,9 +38,9 @@ function requireQuota(resource, action) {
}

try {
// Determine current plan — default to free when no subscription or past_due
// Determine current plan — default to free when subscription is missing or inactive
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
const plan = (!subscription || subscription.status === 'past_due') ? 'free' : (subscription.plan || 'free');
const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? 'free' : (subscription.plan || 'free');
Comment thread
PierreBrisorgueil marked this conversation as resolved.

// Look up quota limit from config
const quotas = config.billing?.quotas;
Expand Down
39 changes: 26 additions & 13 deletions modules/billing/tests/billing.quota.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,22 @@ describe('requireQuota middleware:', () => {
}));
});

test('should treat past_due subscription as free plan', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'past_due' });
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });

await requireQuota('scraps', 'create')(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
code: 429,
}));
});
test.each(['past_due', 'canceled', 'unpaid', 'incomplete', 'incomplete_expired', 'paused'])(
'should treat %s subscription as free plan',
async (status) => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status });
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });

await requireQuota('scraps', 'create')(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
code: 429,
}));
},
);

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

test('should use subscription plan when status is trialing', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'trialing' });
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 15 } });

await requireQuota('scraps', 'create')(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

test('should treat zero usage as under quota', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
mockBillingUsageService.get.mockResolvedValue({ counters: {} });
Expand Down
Loading