|
1 | 1 | /** |
2 | 2 | * Module dependencies |
3 | 3 | */ |
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'; |
12 | 5 | import responses from '../../../lib/helpers/responses.js'; |
13 | 6 |
|
14 | 7 | /** |
15 | 8 | * Returns Express middleware that gates access based on plan quotas. |
16 | 9 | * |
17 | | - * Dual mode: |
| 10 | + * Dual mode — both enforced via `BillingQuotaService.assertCanExecute`: |
18 | 11 | * - When `config.billing.meterMode === false` (default): legacy quota logic. |
19 | 12 | * Reads limits from `config.billing.quotas[plan][resource][action]` and |
20 | 13 | * compares against the current month's usage via BillingUsageService. |
@@ -46,140 +39,41 @@ function requireQuota(resource, action) { |
46 | 39 | return responses.error(res, 403, 'Forbidden', 'Organization context is required to check quota')(); |
47 | 40 | } |
48 | 41 |
|
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 | | - |
53 | 42 | 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 | + } |
117 | 55 |
|
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; |
126 | 61 |
|
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); |
132 | 65 | } |
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); |
145 | 68 | } |
146 | | - |
147 | | - return next(); |
| 69 | + return responses.error(res, 402, 'Payment Required', err.message)(details); |
148 | 70 | } |
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); |
179 | 76 | } |
180 | | - |
181 | | - return next(); |
182 | | - } catch (err) { |
183 | 77 | return next(err); |
184 | 78 | } |
185 | 79 | }; |
|
0 commit comments