-
-
Notifications
You must be signed in to change notification settings - Fork 11
feat(billing): add requireQuota middleware for plan-based limits #3275
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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(); | ||
PierreBrisorgueil marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.