Skip to content

Commit 21b30ac

Browse files
refactor(billing): extract BillingQuotaService.assertCanExecute from requireQuota middleware (#3748)
Promote-up from Trawl: extract the quota/meter enforcement logic from billing.requireQuota.js into a dedicated BillingQuotaService so the enforcement path can be shared with any caller (not just the HTTP middleware), and tested independently. - New: modules/billing/services/billing.quota.service.js Exports assertCanExecute({ orgId, organization, user, resource, action }) with full dual-mode support (legacy quota + meter mode), admin bypass, meterExempt bypass, fail-closed statuses, past_due grace period, and plan-not-configured 503 guard. - Refactor: modules/billing/middlewares/billing.requireQuota.js Now a thin wrapper: calls assertCanExecute, maps AppError status codes to HTTP responses, sets res.locals.billingDegraded on degraded mode. Behavior is unchanged — pure structural refactor. - New: modules/billing/tests/billing.quota.service.unit.tests.js Direct service-layer unit tests (bypasses / meter mode / legacy mode). - Updated: modules/billing/tests/billing.quota.unit.tests.js Adapted to mock BillingQuotaService at the service boundary instead of the middleware's former inline dependencies. All scenario coverage kept. Pure refactor. Trawl has been running this extraction in production for weeks. After merge + /update-stack, Trawl's billing.requireQuota.js becomes byte-compatible with Devkit's. All downstreams inherit the service-layer extraction on their next /update-stack.
1 parent 8393489 commit 21b30ac

4 files changed

Lines changed: 838 additions & 468 deletions

File tree

modules/billing/middlewares/billing.requireQuota.js

Lines changed: 30 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
11
/**
22
* Module dependencies
33
*/
4-
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
5-
import BillingUsageService from '../services/billing.usage.service.js';
6-
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
7-
import BillingPlanService from '../services/billing.plan.service.js';
8-
9-
import { activeStatuses } from '../lib/constants.js';
10-
import { getDefaultPlanId, getGracePeriodDays } from '../lib/billing.constants.js';
11-
import config from '../../../config/index.js';
4+
import BillingQuotaService from '../services/billing.quota.service.js';
125
import responses from '../../../lib/helpers/responses.js';
136

147
/**
158
* Returns Express middleware that gates access based on plan quotas.
169
*
17-
* Dual mode:
10+
* Dual mode — both enforced via `BillingQuotaService.assertCanExecute`:
1811
* - When `config.billing.meterMode === false` (default): legacy quota logic.
1912
* Reads limits from `config.billing.quotas[plan][resource][action]` and
2013
* compares against the current month's usage via BillingUsageService.
@@ -46,140 +39,41 @@ function requireQuota(resource, action) {
4639
return responses.error(res, 403, 'Forbidden', 'Organization context is required to check quota')();
4740
}
4841

49-
// Bypass for admin users or meter-exempt organizations
50-
if (req.user?.roles?.includes('admin')) return next();
51-
if (req.organization.meterExempt === true) return next();
52-
5342
try {
54-
// ── Meter mode (meterMode: true) ──────────────────────────────────────
55-
if (config.billing?.meterMode === true) {
56-
const orgId = req.organization._id.toString();
57-
58-
// ── Degraded-mode gate (past_due grace period) ─────────────────────
59-
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
60-
61-
// Fail-closed statuses: paused, unpaid, incomplete_expired, incomplete, canceled → always route to free quota.
62-
// These statuses fire as customer.subscription.updated (status field changes), so they
63-
// arrive here while the subscription doc may still hold a paid-tier meterQuota.
64-
// Routes to default/free-plan quota to prevent paid-quota bleed-through on lapsed/failed subscriptions.
65-
const failClosedStatuses = ['paused', 'unpaid', 'incomplete_expired', 'incomplete', 'canceled'];
66-
if (subscription && failClosedStatuses.includes(subscription.status)) {
67-
const planId = getDefaultPlanId();
68-
const freePlan = BillingPlanService.getActivePlan(planId);
69-
if (!freePlan) {
70-
return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')({
71-
type: 'PLAN_NOT_CONFIGURED',
72-
planId,
73-
});
74-
}
75-
const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgId);
76-
const remaining = (freePlan.meterQuota ?? 0) + extrasBalance;
77-
if (remaining <= 0) {
78-
return responses.error(res, 402, 'Payment Required', 'Meter exhausted')({
79-
type: 'METER_EXHAUSTED',
80-
meterUsed: 0,
81-
meterQuota: freePlan.meterQuota ?? 0,
82-
extrasRemaining: extrasBalance,
83-
packsAvailable: config.billing?.packs ?? [],
84-
upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans',
85-
});
86-
}
87-
return next();
88-
}
89-
90-
if (subscription?.status === 'past_due' && subscription.pastDueSince != null) {
91-
const gracePeriodMs = getGracePeriodDays() * 24 * 60 * 60 * 1000;
92-
const elapsed = Date.now() - new Date(subscription.pastDueSince).getTime();
93-
if (elapsed >= gracePeriodMs) {
94-
return responses.error(res, 402, 'Payment Required', 'Subscription past due, please update payment')({
95-
type: 'PAYMENT_PAST_DUE',
96-
message: 'Subscription past due, please update payment',
97-
subscriptionStatus: 'past_due',
98-
});
99-
}
100-
// Within grace period — mark degraded for downstream awareness but allow through
101-
res.locals.billingDegraded = true;
102-
}
103-
104-
const usage = await BillingUsageService.getMeter(orgId);
105-
const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgId);
106-
107-
let meterUsed;
108-
let meterQuota;
109-
110-
if (!usage) {
111-
// No BillingUsage doc yet (new user / first scrap of the week).
112-
// Don't create the doc here — let incrementMeter do it on first attribution.
113-
// Fall back to the plan quota so first-run requests are not blocked.
114-
// Reuse the `subscription` already fetched by the degraded-mode gate above.
115-
const planId = subscription?.plan ?? getDefaultPlanId();
116-
const activePlan = BillingPlanService.getActivePlan(planId);
43+
const orgId = req.organization._id.toString();
44+
const { degraded } = await BillingQuotaService.assertCanExecute({
45+
orgId,
46+
organization: req.organization,
47+
user: req.user,
48+
resource,
49+
action,
50+
});
51+
52+
if (degraded) {
53+
res.locals.billingDegraded = true;
54+
}
11755

118-
// Plan missing (seeding / version bump in progress) → fail safe with 503
119-
// rather than defaulting to meterQuota=0 which would surface as 402 METER_EXHAUSTED.
120-
if (activePlan === null || activePlan === undefined) {
121-
return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')({
122-
type: 'PLAN_NOT_CONFIGURED',
123-
planId,
124-
});
125-
}
56+
return next();
57+
} catch (err) {
58+
// Map AppError status codes to HTTP responses matching previous behavior.
59+
// Extract denial details from the AppError (may be array or object).
60+
const details = Array.isArray(err.details) ? err.details[0] : err.details;
12661

127-
meterUsed = 0;
128-
meterQuota = activePlan.meterQuota ?? 0;
129-
} else {
130-
meterUsed = usage.meterUsed ?? 0;
131-
meterQuota = usage.meterQuota ?? 0;
62+
if (err.status === 402) {
63+
if (details?.type === 'PAYMENT_PAST_DUE') {
64+
return responses.error(res, 402, 'Payment Required', 'Subscription past due, please update payment')(details);
13265
}
133-
134-
const remaining = (meterQuota - meterUsed) + extrasBalance;
135-
136-
if (remaining <= 0) {
137-
return responses.error(res, 402, 'Payment Required', 'Meter exhausted')({
138-
type: 'METER_EXHAUSTED',
139-
meterUsed,
140-
meterQuota,
141-
extrasRemaining: extrasBalance,
142-
packsAvailable: config.billing?.packs ?? [],
143-
upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans',
144-
});
66+
if (details?.type === 'METER_EXHAUSTED') {
67+
return responses.error(res, 402, 'Payment Required', 'Meter exhausted')(details);
14568
}
146-
147-
return next();
69+
return responses.error(res, 402, 'Payment Required', err.message)(details);
14870
}
149-
150-
// ── Legacy mode (meterMode: false, default) ───────────────────────────
151-
// Determine current plan — default to free when subscription is missing or inactive
152-
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
153-
const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? 'free' : (subscription.plan || 'free');
154-
155-
// Look up quota limit from config
156-
const quotas = config.billing?.quotas;
157-
const limit = quotas?.[plan]?.[resource]?.[action];
158-
159-
// If no quota is configured for this plan/resource/action, allow through
160-
if (limit === undefined || limit === null) return next();
161-
162-
// Infinity means unlimited — skip usage check
163-
if (limit === Infinity) return next();
164-
165-
// Check current usage
166-
const usage = await BillingUsageService.get(req.organization._id.toString());
167-
const counterKey = `${resource}_${action}`;
168-
const current = usage.counters[counterKey] || 0;
169-
170-
if (current >= limit) {
171-
return responses.error(res, 429, 'Quota exceeded', 'You have reached the usage limit for this resource')({
172-
type: 'QUOTA_EXCEEDED',
173-
resource,
174-
action,
175-
limit,
176-
current,
177-
upgradeUrl: config.billing?.upgradeUrl || '/billing/plans',
178-
});
71+
if (err.status === 429) {
72+
return responses.error(res, 429, 'Quota exceeded', 'You have reached the usage limit for this resource')(details);
73+
}
74+
if (err.status === 503) {
75+
return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')(details);
17976
}
180-
181-
return next();
182-
} catch (err) {
18377
return next(err);
18478
}
18579
};
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Billing quota assertion service.
3+
*
4+
* Single source of truth for quota/meter enforcement logic extracted from
5+
* `billing.requireQuota.js`. Exposes `assertCanExecute` so both the HTTP
6+
* middleware and any other caller (e.g. background workers, tool handlers)
7+
* share the exact same enforcement path — no divergence, no bypass.
8+
*
9+
* Dual mode (mirrors `billing.requireQuota.js` exactly):
10+
* - `meterMode === false` (default): legacy quota — reads counters from
11+
* `config.billing.quotas[plan][resource][action]` via BillingUsageService.
12+
* - `meterMode === true`: meter gate — past_due grace period + meterQuota
13+
* + extrasBalance. Fail-closed for paused/unpaid/incomplete/canceled.
14+
*
15+
* On denial throws an `AppError` with a `status` property matching the HTTP
16+
* status that `billing.requireQuota.js` would have returned (402 / 429 / 503).
17+
* Callers can inspect `err.status` to produce the right protocol-level response.
18+
*
19+
* @module billing.quota.service
20+
*/
21+
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';
22+
import BillingUsageService from './billing.usage.service.js';
23+
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
24+
import BillingPlanService from './billing.plan.service.js';
25+
26+
import { activeStatuses } from '../lib/constants.js';
27+
import { getDefaultPlanId, getGracePeriodDays } from '../lib/billing.constants.js';
28+
import config from '../../../config/index.js';
29+
import AppError from '../../../lib/helpers/AppError.js';
30+
31+
/**
32+
* @typedef {Object} QuotaDenyReason
33+
* @property {string} type - Machine-readable denial type (METER_EXHAUSTED, PAYMENT_PAST_DUE, QUOTA_EXCEEDED, PLAN_NOT_CONFIGURED)
34+
* @property {number} status - HTTP-equivalent status code (402, 429, 503)
35+
* @property {Object} [details] - Additional metadata (meterUsed, meterQuota, etc.)
36+
*/
37+
38+
/**
39+
* @desc Assert that an organization may execute a metered action.
40+
*
41+
* Mirrors the exact enforcement tree in `billing.requireQuota.js`:
42+
* 1. Admin bypass (user.roles includes 'admin') → allow
43+
* 2. meterExempt org → allow
44+
* 3. meterMode === true → meter gate (fail-closed + past_due grace + exhaustion)
45+
* 4. meterMode === false → legacy quota gate (plan counter)
46+
*
47+
* @param {Object} opts
48+
* @param {string|Object} opts.orgId - Organization _id (string or ObjectId)
49+
* @param {Object} [opts.organization] - Organization document (for meterExempt check)
50+
* @param {Object} [opts.user] - Authenticated user (for admin bypass)
51+
* @param {string} opts.resource - Quota resource name (e.g. 'scraps')
52+
* @param {string} opts.action - Quota action name (e.g. 'execute')
53+
* @returns {Promise<{degraded: boolean}>} Resolves with `{ degraded }` when access is allowed.
54+
* `degraded` is true when past_due within grace period (informational).
55+
* @throws {AppError} with `.status` set to 402 / 429 / 503 on denial.
56+
*/
57+
async function assertCanExecute({ orgId, organization, user, resource, action }) {
58+
// ── Admin bypass ──────────────────────────────────────────────────────────
59+
if (user?.roles?.includes('admin')) return { degraded: false };
60+
61+
// ── meterExempt bypass ────────────────────────────────────────────────────
62+
if (organization?.meterExempt === true) return { degraded: false };
63+
64+
const orgIdStr = String(orgId);
65+
66+
// ── Meter mode ────────────────────────────────────────────────────────────
67+
if (config.billing?.meterMode === true) {
68+
const subscription = await SubscriptionRepository.findByOrganization(orgIdStr);
69+
70+
// Fail-closed statuses → route to free plan quota
71+
const failClosedStatuses = ['paused', 'unpaid', 'incomplete_expired', 'incomplete', 'canceled'];
72+
if (subscription && failClosedStatuses.includes(subscription.status)) {
73+
const planId = getDefaultPlanId();
74+
const freePlan = BillingPlanService.getActivePlan(planId);
75+
if (!freePlan) {
76+
throw new AppError('Billing plan configuration is temporarily unavailable', {
77+
status: 503,
78+
details: { type: 'PLAN_NOT_CONFIGURED', planId },
79+
});
80+
}
81+
const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgIdStr);
82+
const remaining = (freePlan.meterQuota ?? 0) + extrasBalance;
83+
if (remaining <= 0) {
84+
throw new AppError('Meter exhausted', {
85+
status: 402,
86+
details: {
87+
type: 'METER_EXHAUSTED',
88+
meterUsed: 0,
89+
meterQuota: freePlan.meterQuota ?? 0,
90+
extrasRemaining: extrasBalance,
91+
packsAvailable: config.billing?.packs ?? [],
92+
upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans',
93+
},
94+
});
95+
}
96+
return { degraded: false };
97+
}
98+
99+
// past_due grace period gate
100+
let degraded = false;
101+
if (subscription?.status === 'past_due' && subscription.pastDueSince != null) {
102+
const gracePeriodMs = getGracePeriodDays() * 24 * 60 * 60 * 1000;
103+
const elapsed = Date.now() - new Date(subscription.pastDueSince).getTime();
104+
if (elapsed >= gracePeriodMs) {
105+
throw new AppError('Subscription past due, please update payment', {
106+
status: 402,
107+
details: {
108+
type: 'PAYMENT_PAST_DUE',
109+
message: 'Subscription past due, please update payment',
110+
subscriptionStatus: 'past_due',
111+
},
112+
});
113+
}
114+
// Within grace period — mark degraded for informational purposes
115+
degraded = true;
116+
}
117+
118+
const usage = await BillingUsageService.getMeter(orgIdStr);
119+
const extrasBalance = await BillingExtraBalanceRepository.getBalance(orgIdStr);
120+
121+
let meterUsed;
122+
let meterQuota;
123+
124+
if (!usage) {
125+
// No BillingUsage doc yet — fall back to plan quota
126+
const planId = subscription?.plan ?? getDefaultPlanId();
127+
const activePlan = BillingPlanService.getActivePlan(planId);
128+
if (activePlan === null || activePlan === undefined) {
129+
throw new AppError('Billing plan configuration is temporarily unavailable', {
130+
status: 503,
131+
details: { type: 'PLAN_NOT_CONFIGURED', planId },
132+
});
133+
}
134+
meterUsed = 0;
135+
meterQuota = activePlan.meterQuota ?? 0;
136+
} else {
137+
meterUsed = usage.meterUsed ?? 0;
138+
meterQuota = usage.meterQuota ?? 0;
139+
}
140+
141+
const remaining = (meterQuota - meterUsed) + extrasBalance;
142+
if (remaining <= 0) {
143+
throw new AppError('Meter exhausted', {
144+
status: 402,
145+
details: {
146+
type: 'METER_EXHAUSTED',
147+
meterUsed,
148+
meterQuota,
149+
extrasRemaining: extrasBalance,
150+
packsAvailable: config.billing?.packs ?? [],
151+
upgradeUrl: config.billing?.upgradeUrl ?? '/billing/plans',
152+
},
153+
});
154+
}
155+
156+
return { degraded };
157+
}
158+
159+
// ── Legacy mode ───────────────────────────────────────────────────────────
160+
const subscription = await SubscriptionRepository.findByOrganization(orgIdStr);
161+
const plan = (!subscription || !activeStatuses.includes(subscription.status))
162+
? 'free'
163+
: (subscription.plan || 'free');
164+
165+
const quotas = config.billing?.quotas;
166+
const limit = quotas?.[plan]?.[resource]?.[action];
167+
168+
// No quota configured for this plan/resource/action → allow
169+
if (limit === undefined || limit === null) return { degraded: false };
170+
171+
// Infinity means unlimited
172+
if (limit === Infinity) return { degraded: false };
173+
174+
const usage = await BillingUsageService.get(orgIdStr);
175+
const counterKey = `${resource}_${action}`;
176+
const current = usage.counters[counterKey] || 0;
177+
178+
if (current >= limit) {
179+
throw new AppError('You have reached the usage limit for this resource', {
180+
status: 429,
181+
details: {
182+
type: 'QUOTA_EXCEEDED',
183+
resource,
184+
action,
185+
limit,
186+
current,
187+
upgradeUrl: config.billing?.upgradeUrl || '/billing/plans',
188+
},
189+
});
190+
}
191+
192+
return { degraded: false };
193+
}
194+
195+
export { assertCanExecute };
196+
export default { assertCanExecute };

0 commit comments

Comments
 (0)