diff --git a/modules/billing/middlewares/billing.requireQuota.js b/modules/billing/middlewares/billing.requireQuota.js index 18a1b0e52..bb1512a25 100644 --- a/modules/billing/middlewares/billing.requireQuota.js +++ b/modules/billing/middlewares/billing.requireQuota.js @@ -1,20 +1,13 @@ /** * Module dependencies */ -import SubscriptionRepository from '../repositories/billing.subscription.repository.js'; -import BillingUsageService from '../services/billing.usage.service.js'; -import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js'; -import BillingPlanService from '../services/billing.plan.service.js'; - -import { activeStatuses } from '../lib/constants.js'; -import { getDefaultPlanId, getGracePeriodDays } from '../lib/billing.constants.js'; -import config from '../../../config/index.js'; +import BillingQuotaService from '../services/billing.quota.service.js'; import responses from '../../../lib/helpers/responses.js'; /** * Returns Express middleware that gates access based on plan quotas. * - * Dual mode: + * Dual mode — both enforced via `BillingQuotaService.assertCanExecute`: * - When `config.billing.meterMode === false` (default): legacy quota logic. * Reads limits from `config.billing.quotas[plan][resource][action]` and * compares against the current month's usage via BillingUsageService. @@ -46,140 +39,44 @@ function requireQuota(resource, action) { return responses.error(res, 403, 'Forbidden', 'Organization context is required to check quota')(); } - // Bypass for admin users or meter-exempt organizations - if (req.user?.roles?.includes('admin')) return next(); - if (req.organization.meterExempt === true) return next(); - try { - // ── Meter mode (meterMode: true) ────────────────────────────────────── - if (config.billing?.meterMode === true) { - const orgId = req.organization._id.toString(); - - // ── Degraded-mode gate (past_due grace period) ───────────────────── - const subscription = await SubscriptionRepository.findByOrganization(req.organization._id); - - // Fail-closed statuses: paused, unpaid, incomplete_expired, incomplete, canceled → always route to free quota. - // These statuses fire as customer.subscription.updated (status field changes), so they - // arrive here while the subscription doc may still hold a paid-tier meterQuota. - // Routes to default/free-plan quota to prevent paid-quota bleed-through on lapsed/failed subscriptions. - const failClosedStatuses = ['paused', 'unpaid', 'incomplete_expired', 'incomplete', 'canceled']; - if (subscription && failClosedStatuses.includes(subscription.status)) { - const planId = getDefaultPlanId(); - const freePlan = BillingPlanService.getActivePlan(planId); - if (!freePlan) { - return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')({ - type: 'PLAN_NOT_CONFIGURED', - planId, - }); - } - const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgId); - const remaining = (freePlan.meterQuota ?? 0) + extrasBalance; - if (remaining <= 0) { - return responses.error(res, 402, 'Payment Required', 'Meter exhausted')({ - type: 'METER_EXHAUSTED', - meterUsed: 0, - meterQuota: freePlan.meterQuota ?? 0, - extrasRemaining: extrasBalance, - packsAvailable: config.billing?.packs ?? [], - upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans', - }); - } - return next(); - } - - if (subscription?.status === 'past_due' && subscription.pastDueSince != null) { - const gracePeriodMs = getGracePeriodDays() * 24 * 60 * 60 * 1000; - const elapsed = Date.now() - new Date(subscription.pastDueSince).getTime(); - if (elapsed >= gracePeriodMs) { - return responses.error(res, 402, 'Payment Required', 'Subscription past due, please update payment')({ - type: 'PAYMENT_PAST_DUE', - message: 'Subscription past due, please update payment', - subscriptionStatus: 'past_due', - }); - } - // Within grace period — mark degraded for downstream awareness but allow through - res.locals.billingDegraded = true; - } - - const usage = await BillingUsageService.getMeter(orgId); - const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgId); - - let meterUsed; - let meterQuota; - - if (!usage) { - // No BillingUsage doc yet (new user / first scrap of the week). - // Don't create the doc here — let incrementMeter do it on first attribution. - // Fall back to the plan quota so first-run requests are not blocked. - // Reuse the `subscription` already fetched by the degraded-mode gate above. - const planId = subscription?.plan ?? getDefaultPlanId(); - const activePlan = BillingPlanService.getActivePlan(planId); + const orgId = req.organization._id.toString(); + const { degraded } = await BillingQuotaService.assertCanExecute({ + orgId, + organization: req.organization, + user: req.user, + resource, + action, + }); + + if (degraded) { + res.locals.billingDegraded = true; + } - // Plan missing (seeding / version bump in progress) → fail safe with 503 - // rather than defaulting to meterQuota=0 which would surface as 402 METER_EXHAUSTED. - if (activePlan === null || activePlan === undefined) { - return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')({ - type: 'PLAN_NOT_CONFIGURED', - planId, - }); - } + return next(); + } catch (err) { + // Map AppError status codes to HTTP responses matching previous behavior. + // Extract denial details from the AppError (may be array or object). + const details = Array.isArray(err.details) ? err.details[0] : err.details; - meterUsed = 0; - meterQuota = activePlan.meterQuota ?? 0; - } else { - meterUsed = usage.meterUsed ?? 0; - meterQuota = usage.meterQuota ?? 0; + if (err.status === 402) { + if (details?.type === 'PAYMENT_PAST_DUE') { + return responses.error(res, 402, 'Payment Required', 'Subscription past due, please update payment')(details); } - - const remaining = (meterQuota - meterUsed) + extrasBalance; - - if (remaining <= 0) { - return responses.error(res, 402, 'Payment Required', 'Meter exhausted')({ - type: 'METER_EXHAUSTED', - meterUsed, - meterQuota, - extrasRemaining: extrasBalance, - packsAvailable: config.billing?.packs ?? [], - upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans', - }); + if (details?.type === 'METER_EXHAUSTED') { + return responses.error(res, 402, 'Payment Required', 'Meter exhausted')(details); } - - return next(); + // Defensive: an unknown 402 sub-type would leak err.message verbatim. + // The service only throws known types today, so send the generic phrase + // instead — any future 402 type must be mapped explicitly above. + return responses.error(res, 402, 'Payment Required', 'Payment required')(details); } - - // ── Legacy mode (meterMode: false, default) ─────────────────────────── - // Determine current plan — default to free when subscription is missing or inactive - const subscription = await SubscriptionRepository.findByOrganization(req.organization._id); - const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? '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', - }); + if (err.status === 429) { + return responses.error(res, 429, 'Quota exceeded', 'You have reached the usage limit for this resource')(details); + } + if (err.status === 503) { + return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')(details); } - - return next(); - } catch (err) { return next(err); } }; diff --git a/modules/billing/services/billing.quota.service.js b/modules/billing/services/billing.quota.service.js new file mode 100644 index 000000000..90d3c5654 --- /dev/null +++ b/modules/billing/services/billing.quota.service.js @@ -0,0 +1,196 @@ +/** + * Billing quota assertion service. + * + * Single source of truth for quota/meter enforcement logic extracted from + * `billing.requireQuota.js`. Exposes `assertCanExecute` so both the HTTP + * middleware and any other caller (e.g. background workers, tool handlers) + * share the exact same enforcement path — no divergence, no bypass. + * + * Dual mode (mirrors `billing.requireQuota.js` exactly): + * - `meterMode === false` (default): legacy quota — reads counters from + * `config.billing.quotas[plan][resource][action]` via BillingUsageService. + * - `meterMode === true`: meter gate — past_due grace period + meterQuota + * + extrasBalance. Fail-closed for paused/unpaid/incomplete/canceled. + * + * On denial throws an `AppError` with a `status` property matching the HTTP + * status that `billing.requireQuota.js` would have returned (402 / 429 / 503). + * Callers can inspect `err.status` to produce the right protocol-level response. + * + * @module billing.quota.service + */ +import SubscriptionRepository from '../repositories/billing.subscription.repository.js'; +import BillingUsageService from './billing.usage.service.js'; +import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js'; +import BillingPlanService from './billing.plan.service.js'; + +import { activeStatuses } from '../lib/constants.js'; +import { getDefaultPlanId, getGracePeriodDays } from '../lib/billing.constants.js'; +import config from '../../../config/index.js'; +import AppError from '../../../lib/helpers/AppError.js'; + +/** + * @typedef {Object} QuotaDenyReason + * @property {string} type - Machine-readable denial type (METER_EXHAUSTED, PAYMENT_PAST_DUE, QUOTA_EXCEEDED, PLAN_NOT_CONFIGURED) + * @property {number} status - HTTP-equivalent status code (402, 429, 503) + * @property {Object} [details] - Additional metadata (meterUsed, meterQuota, etc.) + */ + +/** + * @desc Assert that an organization may execute a metered action. + * + * Mirrors the exact enforcement tree in `billing.requireQuota.js`: + * 1. Admin bypass (user.roles includes 'admin') → allow + * 2. meterExempt org → allow + * 3. meterMode === true → meter gate (fail-closed + past_due grace + exhaustion) + * 4. meterMode === false → legacy quota gate (plan counter) + * + * @param {Object} opts + * @param {string|Object} opts.orgId - Organization _id (string or ObjectId) + * @param {Object} [opts.organization] - Organization document (for meterExempt check) + * @param {Object} [opts.user] - Authenticated user (for admin bypass) + * @param {string} opts.resource - Quota resource name (e.g. 'scraps') + * @param {string} opts.action - Quota action name (e.g. 'execute') + * @returns {Promise<{degraded: boolean}>} Resolves with `{ degraded }` when access is allowed. + * `degraded` is true when past_due within grace period (informational). + * @throws {AppError} with `.status` set to 402 / 429 / 503 on denial. + */ +async function assertCanExecute({ orgId, organization, user, resource, action }) { + // ── Admin bypass ────────────────────────────────────────────────────────── + if (user?.roles?.includes('admin')) return { degraded: false }; + + // ── meterExempt bypass ──────────────────────────────────────────────────── + if (organization?.meterExempt === true) return { degraded: false }; + + const orgIdStr = String(orgId); + + // ── Meter mode ──────────────────────────────────────────────────────────── + if (config.billing?.meterMode === true) { + const subscription = await SubscriptionRepository.findByOrganization(orgIdStr); + + // Fail-closed statuses → route to free plan quota + const failClosedStatuses = ['paused', 'unpaid', 'incomplete_expired', 'incomplete', 'canceled']; + if (subscription && failClosedStatuses.includes(subscription.status)) { + const planId = getDefaultPlanId(); + const freePlan = BillingPlanService.getActivePlan(planId); + if (!freePlan) { + throw new AppError('Billing plan configuration is temporarily unavailable', { + status: 503, + details: { type: 'PLAN_NOT_CONFIGURED', planId }, + }); + } + const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgIdStr); + const remaining = (freePlan.meterQuota ?? 0) + extrasBalance; + if (remaining <= 0) { + throw new AppError('Meter exhausted', { + status: 402, + details: { + type: 'METER_EXHAUSTED', + meterUsed: 0, + meterQuota: freePlan.meterQuota ?? 0, + extrasRemaining: extrasBalance, + packsAvailable: config.billing?.packs ?? [], + upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans', + }, + }); + } + return { degraded: false }; + } + + // past_due grace period gate + let degraded = false; + if (subscription?.status === 'past_due' && subscription.pastDueSince != null) { + const gracePeriodMs = getGracePeriodDays() * 24 * 60 * 60 * 1000; + const elapsed = Date.now() - new Date(subscription.pastDueSince).getTime(); + if (elapsed >= gracePeriodMs) { + throw new AppError('Subscription past due, please update payment', { + status: 402, + details: { + type: 'PAYMENT_PAST_DUE', + message: 'Subscription past due, please update payment', + subscriptionStatus: 'past_due', + }, + }); + } + // Within grace period — mark degraded for informational purposes + degraded = true; + } + + const usage = await BillingUsageService.getMeter(orgIdStr); + const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgIdStr); + + let meterUsed; + let meterQuota; + + if (!usage) { + // No BillingUsage doc yet — fall back to plan quota + const planId = subscription?.plan ?? getDefaultPlanId(); + const activePlan = BillingPlanService.getActivePlan(planId); + if (!activePlan) { + throw new AppError('Billing plan configuration is temporarily unavailable', { + status: 503, + details: { type: 'PLAN_NOT_CONFIGURED', planId }, + }); + } + meterUsed = 0; + meterQuota = activePlan.meterQuota ?? 0; + } else { + meterUsed = usage.meterUsed ?? 0; + meterQuota = usage.meterQuota ?? 0; + } + + const remaining = (meterQuota - meterUsed) + extrasBalance; + if (remaining <= 0) { + throw new AppError('Meter exhausted', { + status: 402, + details: { + type: 'METER_EXHAUSTED', + meterUsed, + meterQuota, + extrasRemaining: extrasBalance, + packsAvailable: config.billing?.packs ?? [], + upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans', + }, + }); + } + + return { degraded }; + } + + // ── Legacy mode ─────────────────────────────────────────────────────────── + const subscription = await SubscriptionRepository.findByOrganization(orgIdStr); + const plan = (!subscription || !activeStatuses.includes(subscription.status)) + ? 'free' + : (subscription.plan || 'free'); + + const quotas = config.billing?.quotas; + const limit = quotas?.[plan]?.[resource]?.[action]; + + // No quota configured for this plan/resource/action → allow + if (limit === undefined || limit === null) return { degraded: false }; + + // Infinity means unlimited + if (limit === Infinity) return { degraded: false }; + + const usage = await BillingUsageService.get(orgIdStr); + const counterKey = `${resource}_${action}`; + const current = usage.counters[counterKey] || 0; + + if (current >= limit) { + throw new AppError('You have reached the usage limit for this resource', { + status: 429, + details: { + type: 'QUOTA_EXCEEDED', + resource, + action, + limit, + current, + upgradeUrl: config.billing?.upgradeUrl || '/billing/plans', + }, + }); + } + + return { degraded: false }; +} + +export { assertCanExecute }; +export default { assertCanExecute }; diff --git a/modules/billing/tests/billing.quota.service.unit.tests.js b/modules/billing/tests/billing.quota.service.unit.tests.js new file mode 100644 index 000000000..fa8727532 --- /dev/null +++ b/modules/billing/tests/billing.quota.service.unit.tests.js @@ -0,0 +1,351 @@ +/** + * Unit tests for billing.quota.service.assertCanExecute + * + * The service is the single enforcement source used by: + * - billing.requireQuota middleware (HTTP path) + * - Any other caller sharing the quota enforcement path (e.g. background workers) + * + * Coverage mirrors billing.quota.unit.tests.js for the middleware but + * exercises the service layer directly. Key scenarios: + * - Admin bypass → resolves { degraded: false } + * - meterExempt org bypass → resolves { degraded: false } + * - Meter mode: exhausted → throws AppError status 402 METER_EXHAUSTED + * - Meter mode: past_due grace elapsed → throws AppError status 402 PAYMENT_PAST_DUE + * - Meter mode: past_due within grace → resolves { degraded: true } + * - Meter mode: fail-closed statuses (paused/canceled/etc.) → 402 METER_EXHAUSTED + * - Meter mode: no usage doc + plan fallback (allow / deny / 503 PLAN_NOT_CONFIGURED) + * - Legacy mode: under quota → allow; at/over limit → throws 429 QUOTA_EXCEEDED + * - Legacy mode: Infinity → allow; no quota configured → allow + */ +import { describe, test, expect, jest } from '@jest/globals'; + +/* ── shared mocks ── */ +let mockSubscriptionRepository; +let mockBillingUsageService; +let mockBillingExtraBalanceRepository; +let mockBillingPlanService; +let mockConfig; + +const setupMocks = async (configOverrides = {}) => { + jest.resetModules(); + + mockSubscriptionRepository = { findByOrganization: jest.fn() }; + mockBillingUsageService = { get: jest.fn(), getMeter: jest.fn() }; + mockBillingExtraBalanceRepository = { getBalance: jest.fn() }; + mockBillingPlanService = { getActivePlan: jest.fn() }; + + mockConfig = { + billing: { + meterMode: false, + quotas: { + free: { scraps: { execute: 100, create: 3 } }, + starter: { scraps: { execute: 2000, create: 20 } }, + pro: { scraps: { execute: Infinity, create: Infinity } }, + }, + packs: [], + upgradeUrl: '/billing/plans', + defaultPlan: 'free', + ...configOverrides, + }, + }; + + jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({ + default: mockSubscriptionRepository, + })); + jest.unstable_mockModule('../services/billing.usage.service.js', () => ({ + default: mockBillingUsageService, + })); + jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ + default: mockBillingExtraBalanceRepository, + })); + jest.unstable_mockModule('../services/billing.plan.service.js', () => ({ + default: mockBillingPlanService, + })); + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: mockConfig, + })); + + const mod = await import('../services/billing.quota.service.js'); + return mod.assertCanExecute; +}; + +const ORG_ID = '507f1f77bcf86cd799439011'; +const BASE_ORG = { _id: ORG_ID }; + +/* ===================================================================== + * Admin & meterExempt bypasses + * ===================================================================== */ + +describe('assertCanExecute — bypasses', () => { + test('resolves { degraded: false } for admin user (no DB calls)', async () => { + const assertCanExecute = await setupMocks(); + const result = await assertCanExecute({ + orgId: ORG_ID, + organization: BASE_ORG, + user: { roles: ['admin'] }, + resource: 'scraps', + action: 'execute', + }); + expect(result).toEqual({ degraded: false }); + expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled(); + expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled(); + expect(mockBillingUsageService.get).not.toHaveBeenCalled(); + }); + + test('resolves { degraded: false } for meterExempt org (no DB calls)', async () => { + const assertCanExecute = await setupMocks(); + const result = await assertCanExecute({ + orgId: ORG_ID, + organization: { ...BASE_ORG, meterExempt: true }, + user: { roles: ['user'] }, + resource: 'scraps', + action: 'execute', + }); + expect(result).toEqual({ degraded: false }); + expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled(); + }); + + test('does NOT bypass for meterExempt: false', async () => { + const assertCanExecute = await setupMocks(); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); + mockBillingUsageService.get.mockResolvedValue({ counters: { scraps_execute: 50 } }); + + const result = await assertCanExecute({ + orgId: ORG_ID, + organization: { ...BASE_ORG, meterExempt: false }, + user: { roles: ['user'] }, + resource: 'scraps', + action: 'execute', + }); + expect(result).toEqual({ degraded: false }); + expect(mockBillingUsageService.get).toHaveBeenCalled(); + }); +}); + +/* ===================================================================== + * Meter mode + * ===================================================================== */ + +describe('assertCanExecute — meter mode (meterMode: true)', () => { + const meterConfig = { + meterMode: true, + quotas: {}, + packs: [{ packId: 'pack_500k', meterUnits: 500000 }], + upgradeUrl: '/billing/plans', + defaultPlan: 'free', + }; + + test('resolves when remaining quota > 0', async () => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'active', pastDueSince: null }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 1000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + const result = await assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + }); + expect(result).toEqual({ degraded: false }); + }); + + test('resolves when quota exhausted but extras cover it', async () => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'active', pastDueSince: null }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(500000); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).resolves.toEqual({ degraded: false }); + }); + + test('throws AppError status 402 METER_EXHAUSTED when meter is exhausted', async () => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'active', pastDueSince: null }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).rejects.toMatchObject({ status: 402, message: 'Meter exhausted' }); + }); + + test('thrown error includes METER_EXHAUSTED details', async () => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'active', pastDueSince: null }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 6000, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + let caught; + try { + await assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + }); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); + expect(caught.status).toBe(402); + // AppError.details is the array-or-object passed to the constructor + const details = Array.isArray(caught.details) ? caught.details[0] : caught.details; + expect(details.type).toBe('METER_EXHAUSTED'); + expect(details.meterUsed).toBe(6000); + expect(details.meterQuota).toBe(5000); + expect(details.extrasRemaining).toBe(0); + }); + + test('throws 402 PAYMENT_PAST_DUE when past_due grace elapsed', async () => { + const assertCanExecute = await setupMocks(meterConfig); + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'past_due', pastDueSince: tenDaysAgo }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).rejects.toMatchObject({ status: 402 }); + }); + + test('resolves { degraded: true } when past_due within grace period', async () => { + const assertCanExecute = await setupMocks(meterConfig); + const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'past_due', pastDueSince: fiveDaysAgo }); + mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + const result = await assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + }); + expect(result).toEqual({ degraded: true }); + }); + + test.each(['paused', 'unpaid', 'incomplete_expired', 'incomplete', 'canceled'])( + 'fail-closed: %s subscription with free quota=0 → 402 METER_EXHAUSTED', + async (status) => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status }); + mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 0, version: 'v1' }); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).rejects.toMatchObject({ status: 402 }); + }, + ); + + test('fail-closed: plan not configured → 503 PLAN_NOT_CONFIGURED', async () => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'canceled' }); + mockBillingPlanService.getActivePlan.mockReturnValue(null); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).rejects.toMatchObject({ status: 503 }); + }); + + test('no BillingUsage doc + plan quota > 0 → allow (first run path)', async () => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); + mockBillingUsageService.getMeter.mockResolvedValue(null); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 10, version: 'v1' }); + + const result = await assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + }); + expect(result).toEqual({ degraded: false }); + }); + + test('no BillingUsage doc + plan null → 503 PLAN_NOT_CONFIGURED', async () => { + const assertCanExecute = await setupMocks(meterConfig); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); + mockBillingUsageService.getMeter.mockResolvedValue(null); + mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + mockBillingPlanService.getActivePlan.mockReturnValue(null); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).rejects.toMatchObject({ status: 503 }); + }); +}); + +/* ===================================================================== + * Legacy mode + * ===================================================================== */ + +describe('assertCanExecute — legacy mode (meterMode: false)', () => { + test('resolves when under quota', async () => { + const assertCanExecute = await setupMocks(); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); + mockBillingUsageService.get.mockResolvedValue({ counters: { scraps_execute: 50 } }); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).resolves.toEqual({ degraded: false }); + }); + + test('throws 429 QUOTA_EXCEEDED when at limit', async () => { + const assertCanExecute = await setupMocks(); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); + mockBillingUsageService.get.mockResolvedValue({ counters: { scraps_execute: 100 } }); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).rejects.toMatchObject({ status: 429 }); + }); + + test('Infinity quota → resolves without usage check', async () => { + const assertCanExecute = await setupMocks(); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' }); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).resolves.toEqual({ degraded: false }); + expect(mockBillingUsageService.get).not.toHaveBeenCalled(); + }); + + test('no quota configured → resolves (allow through)', async () => { + const assertCanExecute = await setupMocks(); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'unknownResource', action: 'execute', + })).resolves.toEqual({ degraded: false }); + }); + + test('missing subscription → treated as free plan', async () => { + const assertCanExecute = await setupMocks(); + mockSubscriptionRepository.findByOrganization.mockResolvedValue(null); + mockBillingUsageService.get.mockResolvedValue({ counters: { scraps_execute: 100 } }); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).rejects.toMatchObject({ status: 429 }); + }); + + test('starter plan with higher quota → resolves when under starter limit', async () => { + const assertCanExecute = await setupMocks(); + mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'active' }); + mockBillingUsageService.get.mockResolvedValue({ counters: { scraps_execute: 1500 } }); + + await expect(assertCanExecute({ + orgId: ORG_ID, organization: BASE_ORG, user: { roles: ['user'] }, + resource: 'scraps', action: 'execute', + })).resolves.toEqual({ degraded: false }); + }); +}); diff --git a/modules/billing/tests/billing.quota.unit.tests.js b/modules/billing/tests/billing.quota.unit.tests.js index fa17b2828..e62fb0b0d 100644 --- a/modules/billing/tests/billing.quota.unit.tests.js +++ b/modules/billing/tests/billing.quota.unit.tests.js @@ -2,15 +2,18 @@ * Module dependencies. */ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; +import AppError from '../../../lib/helpers/AppError.js'; /** - * Unit tests + * Unit tests for the requireQuota middleware. + * + * The middleware is now a thin wrapper around BillingQuotaService.assertCanExecute. + * Tests here verify the HTTP response mapping (status codes, payloads, next() calls) + * by mocking assertCanExecute at the service boundary. */ describe('requireQuota middleware:', () => { let requireQuota; - let mockSubscriptionRepository; - let mockBillingUsageService; - let mockConfig; + let mockBillingQuotaService; let req; let res; let next; @@ -18,45 +21,12 @@ describe('requireQuota middleware:', () => { beforeEach(async () => { jest.resetModules(); - mockSubscriptionRepository = { - findByOrganization: jest.fn(), + mockBillingQuotaService = { + assertCanExecute: 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, - })); - - // billing.plan.service.js imports logger at module load time; the logger reads - // config.log.fileLogger which is absent from this suite's minimal config fixture. - // Mock the service to avoid loading the real logger with an incomplete config. - jest.unstable_mockModule('../services/billing.plan.service.js', () => ({ - default: { - getActivePlan: jest.fn().mockReturnValue(null), - getPlanByVersion: jest.fn().mockReturnValue(null), - getSignupGrant: jest.fn().mockReturnValue(undefined), - }, + jest.unstable_mockModule('../services/billing.quota.service.js', () => ({ + default: mockBillingQuotaService, })); const mod = await import('../middlewares/billing.requireQuota.js'); @@ -69,6 +39,7 @@ describe('requireQuota middleware:', () => { res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), + locals: {}, }; next = jest.fn(); @@ -78,9 +49,59 @@ describe('requireQuota middleware:', () => { jest.restoreAllMocks(); }); - test('should allow request when under quota', async () => { - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); - mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 1 } }); + // ── Organization guard ──────────────────────────────────────────────────── + + 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); + }); + + // ── Allow paths ─────────────────────────────────────────────────────────── + + test('should allow request when assertCanExecute resolves { degraded: false }', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(res.locals.billingDegraded).toBeUndefined(); + }); + + test('should allow request and set billingDegraded when assertCanExecute resolves { degraded: true }', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: true }); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.locals.billingDegraded).toBe(true); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should pass correct params to assertCanExecute', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); + req.user = { roles: ['user'] }; + + await requireQuota('scraps', 'execute')(req, res, next); + + expect(mockBillingQuotaService.assertCanExecute).toHaveBeenCalledWith({ + orgId: '507f1f77bcf86cd799439011', + organization: req.organization, + user: req.user, + resource: 'scraps', + action: 'execute', + }); + }); + + // ── Admin bypass ────────────────────────────────────────────────────────── + + test('should bypass quota for admin user (legacy mode)', async () => { + req.user = { roles: ['admin'] }; + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); @@ -88,9 +109,57 @@ describe('requireQuota middleware:', () => { expect(res.status).not.toHaveBeenCalled(); }); + test('should NOT bypass quota for non-admin user (legacy mode)', async () => { + req.user = { roles: ['user'] }; + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('You have reached the usage limit for this resource', { + status: 429, + details: { type: 'QUOTA_EXCEEDED', resource: 'scraps', action: 'create', limit: 3, current: 3 }, + }), + ); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(429); + }); + + // ── meterExempt bypass ──────────────────────────────────────────────────── + + test('should bypass quota for meterExempt organization', async () => { + req.organization = { _id: '507f1f77bcf86cd799439011', meterExempt: true }; + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should NOT bypass quota when meterExempt is false', async () => { + req.organization = { _id: '507f1f77bcf86cd799439011', meterExempt: false }; + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('You have reached the usage limit for this resource', { + status: 429, + details: { type: 'QUOTA_EXCEEDED', resource: 'scraps', action: 'create', limit: 3, current: 3 }, + }), + ); + + await requireQuota('scraps', 'create')(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(429); + }); + + // ── Legacy mode denial (429 QUOTA_EXCEEDED) ─────────────────────────────── + test('should return 429 when at quota limit', async () => { - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); - mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } }); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('You have reached the usage limit for this resource', { + status: 429, + details: { type: 'QUOTA_EXCEEDED', resource: 'scraps', action: 'create', limit: 3, current: 3 }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); @@ -105,8 +174,12 @@ describe('requireQuota middleware:', () => { }); test('should return 429 when over quota limit', async () => { - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); - mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 5 } }); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('You have reached the usage limit for this resource', { + status: 429, + details: { type: 'QUOTA_EXCEEDED', resource: 'scraps', action: 'create', limit: 3, current: 5 }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); @@ -114,9 +187,13 @@ describe('requireQuota middleware:', () => { 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 } }); + test('should treat missing subscription as free plan (service resolves)', async () => { + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('You have reached the usage limit for this resource', { + status: 429, + details: { type: 'QUOTA_EXCEEDED', resource: 'scraps', action: 'create', limit: 3, current: 3 }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); @@ -129,10 +206,14 @@ describe('requireQuota middleware:', () => { }); 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 } }); + 'should handle %s subscription denial from service', + async () => { + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('You have reached the usage limit for this resource', { + status: 429, + details: { type: 'QUOTA_EXCEEDED', resource: 'scraps', action: 'create', limit: 3, current: 3 }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); @@ -145,18 +226,21 @@ describe('requireQuota middleware:', () => { }, ); - test('should allow unlimited (Infinity) without checking usage', async () => { - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' }); + test('should allow unlimited (Infinity) without checking usage (service resolves)', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); 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 } }); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('You have reached the usage limit for this resource', { + status: 429, + details: { type: 'QUOTA_EXCEEDED', resource: 'scraps', action: 'execute', limit: 100, current: 100, upgradeUrl: '/billing/plans' }, + }), + ); await requireQuota('scraps', 'execute')(req, res, next); @@ -170,26 +254,16 @@ describe('requireQuota middleware:', () => { })); }); - 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' }); + test('should allow through when no quota is configured for resource (service resolves)', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('unknownResource', 'create')(req, res, next); 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 } }); + test('should use subscription plan when status is trialing (service resolves)', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); @@ -197,127 +271,19 @@ describe('requireQuota middleware:', () => { 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: {} }); + test('should treat zero usage as under quota (service resolves)', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); expect(next).toHaveBeenCalled(); }); - // ── Admin bypass ────────────────────────────────────────────────────────── - - test('should bypass quota for admin user (legacy mode)', async () => { - req.user = { roles: ['admin'] }; - // No subscription/usage lookups needed — should short-circuit - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); - mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } }); - - await requireQuota('scraps', 'create')(req, res, next); - - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - // Short-circuit: no downstream quota lookups - expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled(); - expect(mockBillingUsageService.get).not.toHaveBeenCalled(); - }); - - test('should NOT bypass quota for non-admin user (legacy mode)', async () => { - req.user = { roles: ['user'] }; - 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); - }); - - test('should bypass quota for meterExempt organization', async () => { - req.organization = { _id: '507f1f77bcf86cd799439011', meterExempt: true }; - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); - mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } }); - - await requireQuota('scraps', 'create')(req, res, next); - - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - // Short-circuit: no downstream quota lookups - expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled(); - expect(mockBillingUsageService.get).not.toHaveBeenCalled(); - }); - - test('should NOT bypass quota when meterExempt is false', async () => { - req.organization = { _id: '507f1f77bcf86cd799439011', meterExempt: false }; - 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); - }); - // ── Meter mode (meterMode: true) ─────────────────────────────────────────── describe('meter mode (meterMode: true)', () => { - let mockBillingExtraBalanceRepository; - let mockBillingPlanService; - - beforeEach(async () => { - jest.resetModules(); - - mockConfig = { - billing: { - meterMode: true, - quotas: {}, - packs: [{ packId: 'pack_500k', meterUnits: 500000 }], - upgradeUrl: '/billing/plans', - defaultPlan: 'free', - }, - }; - - mockBillingUsageService = { - get: jest.fn(), - getMeter: jest.fn(), - }; - - mockBillingExtraBalanceRepository = { - getBalance: jest.fn(), - }; - - mockBillingPlanService = { - getActivePlan: jest.fn(), - }; - - jest.unstable_mockModule('../repositories/billing.subscription.repository.js', () => ({ - default: mockSubscriptionRepository, - })); - - jest.unstable_mockModule('../services/billing.usage.service.js', () => ({ - default: mockBillingUsageService, - })); - - jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ - default: mockBillingExtraBalanceRepository, - })); - - jest.unstable_mockModule('../services/billing.plan.service.js', () => ({ - default: mockBillingPlanService, - })); - - jest.unstable_mockModule('../../../config/index.js', () => ({ - default: mockConfig, - })); - - const mod = await import('../middlewares/billing.requireQuota.js'); - requireQuota = mod.default; - }); - test('should call next() when remaining quota > 0 (meter not exhausted)', async () => { - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 1000, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); @@ -326,8 +292,7 @@ describe('requireQuota middleware:', () => { }); test('should call next() when plan quota exhausted but extras balance covers it', async () => { - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(500000); + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); @@ -335,8 +300,19 @@ describe('requireQuota middleware:', () => { }); test('should return 402 when meterUsed >= meterQuota and extras balance is 0', async () => { - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Meter exhausted', { + status: 402, + details: { + type: 'METER_EXHAUSTED', + meterUsed: 5000, + meterQuota: 5000, + extrasRemaining: 0, + packsAvailable: [{ packId: 'pack_500k', meterUnits: 500000 }], + upgradeUrl: '/billing/plans', + }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); @@ -349,15 +325,25 @@ describe('requireQuota middleware:', () => { }); test('should include METER_EXHAUSTED payload with pack info in 402 response', async () => { - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 6000, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Meter exhausted', { + status: 402, + details: { + type: 'METER_EXHAUSTED', + meterUsed: 6000, + meterQuota: 5000, + extrasRemaining: 0, + packsAvailable: [{ packId: 'pack_500k', meterUnits: 500000 }], + upgradeUrl: '/billing/plans', + }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); expect(res.status).toHaveBeenCalledWith(402); const payload = res.json.mock.calls[0][0]; expect(payload.description).toBe('Meter exhausted'); - // The error object contains the metadata const errData = JSON.parse(payload.error); expect(errData.type).toBe('METER_EXHAUSTED'); expect(errData.meterUsed).toBe(6000); @@ -367,15 +353,15 @@ describe('requireQuota middleware:', () => { }); test('should return 402 when meter doc is null and plan quota is 0 (no extras)', async () => { - // No BillingUsage doc yet; plan has meterQuota=0 (e.g. unconfigured plan) - mockBillingUsageService.getMeter.mockResolvedValue(null); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); - mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 0, version: 'v1' }); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Meter exhausted', { + status: 402, + details: { type: 'METER_EXHAUSTED', meterUsed: 0, meterQuota: 0, extrasRemaining: 0, packsAvailable: [], upgradeUrl: '/billing/plans' }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); - // meterUsed=0, meterQuota=0 → remaining = (0-0)+0 = 0 → 402 expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(402); }); @@ -383,11 +369,12 @@ describe('requireQuota middleware:', () => { // ── Fix #3569: lazy fallback for new-user first scrap ───────────────────── test('fix #3575: no meter doc + getActivePlan returns null — returns 503 PLAN_NOT_CONFIGURED (not 402)', async () => { - // getActivePlan returns null during seeding / version bump — must NOT surface as 402 METER_EXHAUSTED. - mockBillingUsageService.getMeter.mockResolvedValue(null); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); - mockBillingPlanService.getActivePlan.mockReturnValue(null); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Billing plan configuration is temporarily unavailable', { + status: 503, + details: { type: 'PLAN_NOT_CONFIGURED', planId: 'free' }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); @@ -399,83 +386,52 @@ describe('requireQuota middleware:', () => { }); test('fix #3569: new Free user — 1st scrap passes when plan meterQuota > 0', async () => { - // No BillingUsage doc yet (getMeter returns null) — first scrap of the week. - // Middleware should fall back to plan quota instead of blocking with 402. - mockBillingUsageService.getMeter.mockResolvedValue(null); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' }); - mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 10, version: 'v1' }); + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); - // meterUsed=0, meterQuota=10 → remaining = 10 > 0 → allow expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); }); test('fix #3569: new Pro user — 1st scrap passes with full pro quota', async () => { - mockBillingUsageService.getMeter.mockResolvedValue(null); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' }); - mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 200000, version: 'v1' }); + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); - expect(mockBillingPlanService.getActivePlan).toHaveBeenCalledWith('pro'); }); - test('fix #3569: no meter doc + no subscription — falls back to defaultPlan quota', async () => { - mockBillingUsageService.getMeter.mockResolvedValue(null); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - mockSubscriptionRepository.findByOrganization.mockResolvedValue(null); - mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 5, version: 'v1' }); + test('fix #3569: no meter doc + no subscription — falls back to defaultPlan quota (service resolves)', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); - // Falls back to config.billing.defaultPlan = 'free' - expect(mockBillingPlanService.getActivePlan).toHaveBeenCalledWith('free'); expect(next).toHaveBeenCalled(); }); - test('fix #3569: no meter doc — does NOT write a BillingUsage doc (incrementMeter creates it)', async () => { - // The middleware must never write the usage doc — only incrementMeter should create it. - mockBillingUsageService.getMeter.mockResolvedValue(null); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' }); - mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 200000, version: 'v1' }); + test('fix #3569: no meter doc — does NOT write a BillingUsage doc (service boundary handles it)', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); - // incrementMeter and upsert should never be called by the middleware - expect(mockBillingUsageService.getMeter).toHaveBeenCalledTimes(1); - // get (legacy path) should not be called at all in meter mode - expect(mockBillingUsageService.get).not.toHaveBeenCalled(); + expect(mockBillingQuotaService.assertCanExecute).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalled(); }); - test('should call SubscriptionRepository in meter mode (degraded-mode gate)', async () => { - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ status: 'active', pastDueSince: null }); - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + test('should call assertCanExecute in meter mode (degraded-mode gate)', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); - expect(mockSubscriptionRepository.findByOrganization).toHaveBeenCalled(); + expect(mockBillingQuotaService.assertCanExecute).toHaveBeenCalled(); expect(next).toHaveBeenCalled(); }); test('should allow through with billingDegraded flag when past_due within grace period (J+5)', async () => { - const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ - status: 'past_due', - pastDueSince: fiveDaysAgo, - }); - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - - res.locals = {}; + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: true }); + await requireQuota('scraps', 'create')(req, res, next); expect(next).toHaveBeenCalled(); @@ -483,17 +439,14 @@ describe('requireQuota middleware:', () => { expect(res.status).not.toHaveBeenCalledWith(402); }); - test('grace period reads from config — custom gracePeriodDays=3 blocks at J+4', async () => { - mockConfig.billing.gracePeriodDays = 3; - const fourDaysAgo = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ - status: 'past_due', - pastDueSince: fourDaysAgo, - }); - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - - res.locals = {}; + test('grace period reads from config — custom gracePeriodDays=3 blocks at J+4 (service throws PAYMENT_PAST_DUE)', async () => { + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Subscription past due, please update payment', { + status: 402, + details: { type: 'PAYMENT_PAST_DUE', message: 'Subscription past due, please update payment', subscriptionStatus: 'past_due' }, + }), + ); + await requireQuota('scraps', 'create')(req, res, next); expect(next).not.toHaveBeenCalled(); @@ -504,15 +457,13 @@ describe('requireQuota middleware:', () => { }); test('should return 402 PAYMENT_PAST_DUE when past_due and grace period elapsed (J+10)', async () => { - const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ - status: 'past_due', - pastDueSince: tenDaysAgo, - }); - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - - res.locals = {}; + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Subscription past due, please update payment', { + status: 402, + details: { type: 'PAYMENT_PAST_DUE', message: 'Subscription past due, please update payment', subscriptionStatus: 'past_due' }, + }), + ); + await requireQuota('scraps', 'create')(req, res, next); expect(next).not.toHaveBeenCalled(); @@ -523,15 +474,9 @@ describe('requireQuota middleware:', () => { expect(errData.subscriptionStatus).toBe('past_due'); }); - test('should NOT block past_due with no pastDueSince set (legacy/missing field)', async () => { - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ - status: 'past_due', - pastDueSince: null, - }); - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + test('should NOT block past_due with no pastDueSince set (service resolves)', async () => { + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); - res.locals = {}; await requireQuota('scraps', 'create')(req, res, next); expect(next).toHaveBeenCalled(); @@ -539,16 +484,13 @@ describe('requireQuota middleware:', () => { }); test('should return 402 PAYMENT_PAST_DUE at exactly the 7-day boundary', async () => { - // Exactly 7 days ago → elapsed >= gracePeriodMs → block - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000 - 1); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ - status: 'past_due', - pastDueSince: sevenDaysAgo, - }); - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 100, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - - res.locals = {}; + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Subscription past due, please update payment', { + status: 402, + details: { type: 'PAYMENT_PAST_DUE', subscriptionStatus: 'past_due' }, + }), + ); + await requireQuota('scraps', 'create')(req, res, next); expect(next).not.toHaveBeenCalled(); @@ -557,46 +499,32 @@ describe('requireQuota middleware:', () => { test('should bypass meter quota for admin user', async () => { req.user = { roles: ['admin'] }; - // getMeter would return exhausted, but admin should bypass - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); - // Short-circuit: no downstream meter/subscription lookups - expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled(); - expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled(); - expect(mockBillingExtraBalanceRepository.getBalance).not.toHaveBeenCalled(); }); test('should bypass meter quota for meterExempt organization', async () => { req.organization = { _id: '507f1f77bcf86cd799439011', meterExempt: true }; - // getMeter would return exhausted, but exempt org should bypass - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); + mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); await requireQuota('scraps', 'create')(req, res, next); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); - // Short-circuit: no downstream meter/subscription lookups - expect(mockSubscriptionRepository.findByOrganization).not.toHaveBeenCalled(); - expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled(); - expect(mockBillingExtraBalanceRepository.getBalance).not.toHaveBeenCalled(); }); test('degraded J+5: still blocks if meter is exhausted', async () => { - const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ - status: 'past_due', - pastDueSince: fiveDaysAgo, - }); - mockBillingUsageService.getMeter.mockResolvedValue({ meterUsed: 5000, meterQuota: 5000 }); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - - res.locals = {}; + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Meter exhausted', { + status: 402, + details: { type: 'METER_EXHAUSTED', meterUsed: 5000, meterQuota: 5000, extrasRemaining: 0, packsAvailable: [], upgradeUrl: '/billing/plans' }, + }), + ); + await requireQuota('scraps', 'create')(req, res, next); expect(next).not.toHaveBeenCalled(); @@ -609,13 +537,19 @@ describe('requireQuota middleware:', () => { // ── V8 audit C2: incomplete subscription must be fail-closed ───────────── test('V8-C2: incomplete subscription + no BillingUsage doc → 402 METER_EXHAUSTED with free quota (not pro)', async () => { - // Org has status='incomplete' (initial payment failed, Stripe ~24h auto-cancel window). - // No BillingUsage doc exists yet for this brand-new subscription. - // The paid plan (pro) must NOT bleed through — fail-closed branch routes to free plan. - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'incomplete' }); - mockBillingUsageService.getMeter.mockResolvedValue(null); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 0, version: 'v1' }); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Meter exhausted', { + status: 402, + details: { + type: 'METER_EXHAUSTED', + meterUsed: 0, + meterQuota: 0, + extrasRemaining: 0, + packsAvailable: [], + upgradeUrl: '/billing/plans', + }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); @@ -624,22 +558,23 @@ describe('requireQuota middleware:', () => { const payload = res.json.mock.calls[0][0]; const errData = JSON.parse(payload.error); expect(errData.type).toBe('METER_EXHAUSTED'); - // Free plan quota (0), not the pro paid quota expect(errData.meterQuota).toBe(0); - // getActivePlan called with the free/default plan id, not 'pro' - expect(mockBillingPlanService.getActivePlan).toHaveBeenCalledWith('free'); - expect(mockBillingPlanService.getActivePlan).not.toHaveBeenCalledWith('pro'); - // Fail-closed branch must short-circuit before the meter check - expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled(); }); test('V8-C2b: canceled subscription in meter mode → 402 METER_EXHAUSTED with free quota (not paid)', async () => { - // Org has status='canceled' (subscription ended). Paid plan must NOT bleed through. - // Fail-closed branch routes to free plan, same as incomplete. - mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'canceled' }); - mockBillingUsageService.getMeter.mockResolvedValue(null); - mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0); - mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 0, version: 'v1' }); + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Meter exhausted', { + status: 402, + details: { + type: 'METER_EXHAUSTED', + meterUsed: 0, + meterQuota: 0, + extrasRemaining: 0, + packsAvailable: [], + upgradeUrl: '/billing/plans', + }, + }), + ); await requireQuota('scraps', 'create')(req, res, next); @@ -648,13 +583,7 @@ describe('requireQuota middleware:', () => { const payload = res.json.mock.calls[0][0]; const errData = JSON.parse(payload.error); expect(errData.type).toBe('METER_EXHAUSTED'); - // Free plan quota (0), not the pro paid quota expect(errData.meterQuota).toBe(0); - // getActivePlan called with the free/default plan id, not 'pro' - expect(mockBillingPlanService.getActivePlan).toHaveBeenCalledWith('free'); - expect(mockBillingPlanService.getActivePlan).not.toHaveBeenCalledWith('pro'); - // Fail-closed branch must short-circuit before the meter check - expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled(); }); }); });