/** * @fileoverview Plan quotas and usage recording. * @module services/entitlementService */ import { config } from '../config.js'; import { AppError } from '../errors.js'; import { getTenant, resolveLimits } from '../store/tenants.js'; import { getUsage, incrementUsage } from '../store/usage.js'; import { createDebugger } from '../debug/logger.js'; const log = createDebugger('billing'); function isUnlimited(value) { return value === null || value === undefined; } function quotaExceeded({ limit, used, allowOverage }) { if (isUnlimited(limit)) return false; if (used < limit) return false; return !allowOverage; } /** * Ensure tenant may perform an action under plan limits. * * @param {string} tenantId * @param {'timestamp'|'verify'|'batch_timestamp'} action * @param {{ amount?: number }} [options] * @returns {{ tenant: object, limits: object, usage: object }} */ export function checkEntitlement(tenantId, action, { amount = 1 } = {}) { const tenant = getTenant(tenantId); if (!tenant) { throw new AppError('Unknown tenant', { status: 401, code: 'UNAUTHORIZED' }); } const limits = resolveLimits(tenant); const usage = getUsage(tenantId); log.debug('checkEntitlement', { tenantId, action, plan: tenant.plan, amount, timestampsUsed: usage.timestamps, timestampsLimit: limits.timestamps, }); if (action === 'batch_timestamp') { if (!limits.batch) { throw new AppError('Batch timestamps require a paid plan', { status: 403, code: 'PLAN_UPGRADE_REQUIRED', details: { upgradeUrl: config.upgradeUrl }, }); } if (!isUnlimited(limits.batchMaxItems) && amount > limits.batchMaxItems) { throw new AppError(`Batch size exceeds plan limit of ${limits.batchMaxItems}`, { status: 403, code: 'BATCH_LIMIT_EXCEEDED', details: { upgradeUrl: config.upgradeUrl, limit: limits.batchMaxItems }, }); } } if (action === 'timestamp') { if ( quotaExceeded({ limit: limits.timestamps, used: usage.timestamps, allowOverage: limits.allowOverage, }) ) { const err = new AppError('Monthly timestamp quota exceeded', { status: 402, code: 'QUOTA_EXCEEDED', details: { limit: limits.timestamps, used: usage.timestamps, period: 'monthly', upgradeUrl: config.upgradeUrl, }, }); throw err; } } if (action === 'verify') { if ( quotaExceeded({ limit: limits.verifications, used: usage.verifications, allowOverage: limits.allowOverage, }) ) { throw new AppError('Monthly verification quota exceeded', { status: 402, code: 'QUOTA_EXCEEDED', details: { limit: limits.verifications, used: usage.verifications, period: 'monthly', upgradeUrl: config.upgradeUrl, }, }); } } return { tenant, limits, usage }; } /** * Record usage after a successful action. * * @param {string} tenantId * @param {string} action * @param {{ amount?: number }} [options] * @returns {void} */ export function recordUsage(tenantId, action, { amount = 1 } = {}) { const tenant = getTenant(tenantId); if (!tenant) return; const limits = resolveLimits(tenant); const usage = getUsage(tenantId); if (action === 'timestamp') { if ( !isUnlimited(limits.timestamps) && usage.timestamps >= limits.timestamps && limits.allowOverage ) { incrementUsage(tenantId, 'overage.timestamps', amount); } incrementUsage(tenantId, 'timestamps', amount); } if (action === 'verify') { if ( !isUnlimited(limits.verifications) && usage.verifications >= limits.verifications && limits.allowOverage ) { incrementUsage(tenantId, 'overage.verifications', amount); } incrementUsage(tenantId, 'verifications', amount); } if (action === 'status') { incrementUsage(tenantId, 'statusChecks', amount); } if (action === 'batch_timestamp') { incrementUsage(tenantId, 'batchTimestamps', amount); incrementUsage(tenantId, 'timestamps', amount); } log.debug('recordUsage', { tenantId, action, amount }); }