diff --git a/modules/billing/middlewares/billing.requireQuota.js b/modules/billing/middlewares/billing.requireQuota.js new file mode 100644 index 00000000..dc312f7a --- /dev/null +++ b/modules/billing/middlewares/billing.requireQuota.js @@ -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} 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; diff --git a/modules/billing/tests/billing.quota.unit.tests.js b/modules/billing/tests/billing.quota.unit.tests.js new file mode 100644 index 00000000..5778c8ec --- /dev/null +++ b/modules/billing/tests/billing.quota.unit.tests.js @@ -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(); + }); +});