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
77 changes: 77 additions & 0 deletions modules/billing/middlewares/billing.requireQuota.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Module dependencies
*/
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
import BillingUsageService from '../services/billing.usage.service.js';

import config from '../../../config/index.js';
import responses from '../../../lib/helpers/responses.js';

/**
* Returns Express middleware that gates access based on plan quotas.
* Reads limits from `config.billing.quotas[plan][resource][action]` and
* compares against the current month's usage via BillingUsageService.
*
* When no quota is configured for the plan/resource/action combination
* (limit is `null` or `undefined`), the request is allowed through.
* When the limit is `Infinity`, the request is also allowed without
* checking usage (unlimited plan).
*
* Expects `req.organization` to be set by resolveOrganization upstream.
*
* @param {string} resource - The quota resource name (e.g. 'scraps').
* @param {string} action - The quota action name (e.g. 'create', 'execute').
* @returns {Function} Express middleware function.
*/
function requireQuota(resource, action) {
/**
* Enforce quota for a resource/action and block requests when limit is reached.
* @param {import('express').Request} req - Express request object.
* @param {import('express').Response} res - Express response object.
* @param {import('express').NextFunction} next - Express next callback.
* @returns {Promise<void>} Resolves when middleware handling completes.
*/
return async function requireQuotaMiddleware(req, res, next) {
if (!req.organization) {
return responses.error(res, 403, 'Forbidden', 'Organization context is required to check quota')();
}

try {
// Determine current plan — default to free when no subscription or past_due
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
const plan = (!subscription || subscription.status === 'past_due') ? 'free' : (subscription.plan || 'free');

// Look up quota limit from config
const quotas = config.billing?.quotas;
const limit = quotas?.[plan]?.[resource]?.[action];

// If no quota is configured for this plan/resource/action, allow through
if (limit === undefined || limit === null) return next();

// Infinity means unlimited — skip usage check
if (limit === Infinity) return next();

// Check current usage
const usage = await BillingUsageService.get(req.organization._id.toString());
const counterKey = `${resource}.${action}`;
const current = usage.counters[counterKey] || 0;

if (current >= limit) {
return responses.error(res, 429, 'Quota exceeded', 'You have reached the usage limit for this resource')({
type: 'QUOTA_EXCEEDED',
resource,
action,
limit,
current,
upgradeUrl: config.billing?.upgradeUrl || '/billing/plans',
});
}

return next();
} catch (err) {
return next(err);
}
};
}

export default requireQuota;
184 changes: 184 additions & 0 deletions modules/billing/tests/billing.quota.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Module dependencies.
*/
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';

/**
* Unit tests
*/
describe('requireQuota middleware:', () => {
let requireQuota;
let mockSubscriptionRepository;
let mockBillingUsageService;
let mockConfig;
let req;
let res;
let next;

beforeEach(async () => {
jest.resetModules();

mockSubscriptionRepository = {
findByOrganization: jest.fn(),
};

mockBillingUsageService = {
get: jest.fn(),
};

mockConfig = {
billing: {
quotas: {
free: { scraps: { create: 3, execute: 100 } },
starter: { scraps: { create: 20, execute: 2000 } },
pro: { scraps: { create: Infinity, execute: Infinity } },
},
},
};

jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({
default: mockSubscriptionRepository,
}));

jest.unstable_mockModule('../services/billing.usage.service.js', () => ({
default: mockBillingUsageService,
}));

jest.unstable_mockModule('../../../config/index.js', () => ({
default: mockConfig,
}));

const mod = await import('../middlewares/billing.requireQuota.js');
requireQuota = mod.default;

req = {
organization: { _id: '507f1f77bcf86cd799439011' },
};

res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};

next = jest.fn();
});

afterEach(() => {
jest.restoreAllMocks();
});

test('should allow request when under quota', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 1 } });

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

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

test('should return 429 when at quota limit', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
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',
message: 'Quota exceeded',
code: 429,
status: 429,
}));
});

test('should return 429 when over quota limit', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 5 } });

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

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

test('should treat missing subscription as free plan', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
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 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('should allow unlimited (Infinity) without checking usage', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' });

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

expect(next).toHaveBeenCalled();
expect(mockBillingUsageService.get).not.toHaveBeenCalled();
});

test('should return correct error payload with upgradeUrl', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.execute': 100 } });

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

expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'Quota exceeded',
code: 429,
status: 429,
description: 'You have reached the usage limit for this resource',
}));
});

test('should return 403 when organization context is missing', async () => {
req.organization = undefined;

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

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

test('should allow through when no quota is configured for resource', async () => {
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });

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

expect(next).toHaveBeenCalled();
});

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

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

expect(next).toHaveBeenCalled();
});
});
Loading