Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 30 additions & 136 deletions modules/billing/middlewares/billing.requireQuota.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -46,140 +39,41 @@ 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();
return responses.error(res, 402, 'Payment Required', err.message)(details);
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
}

// ── 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);
}
};
Expand Down
196 changes: 196 additions & 0 deletions modules/billing/services/billing.quota.service.js
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
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 === null || activePlan === undefined) {
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 };
Loading
Loading