Skip to content

Commit 0005b45

Browse files
fix(billing): treat incomplete subscriptions as fail-closed (#3624)
* fix(billing): treat incomplete subscriptions as fail-closed for quota Adds 'incomplete' to failClosedStatuses in requireQuota meter-mode gate. Previously, a status='incomplete' sub (initial payment failed, ~24h Stripe window) fell through to the meter check, which read the paid plan quota from subscription.plan when no BillingUsage doc existed — giving users full paid resources for free. Now routes to free-plan quota like paused/unpaid/incomplete_expired. V8 audit C2. * fix(billing): add canceled to fail-closed statuses + pin getMeter short-circuit CodeRabbit pass 1 (PR #3624): - Add 'canceled' to failClosedStatuses in meter mode — mirrors legacy-mode behavior (activeStatuses check) so canceled subs can't bleed paid quota - Update comment: list all 5 statuses, clarify "routes to default/free-plan quota" - Add getMeter.not.toHaveBeenCalled() assertion to pin fail-closed short-circuit - Remove hard-coded quota number reference from test comment
1 parent ddb0285 commit 0005b45

2 files changed

Lines changed: 30 additions & 3 deletions

File tree

modules/billing/middlewares/billing.requireQuota.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,11 @@ function requireQuota(resource, action) {
5858
// ── Degraded-mode gate (past_due grace period) ─────────────────────
5959
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
6060

61-
// Fail-closed statuses: paused, unpaid, incomplete_expired → always route to free quota.
61+
// Fail-closed statuses: paused, unpaid, incomplete_expired, incomplete, canceled → always route to free quota.
6262
// These statuses fire as customer.subscription.updated (status field changes), so they
6363
// arrive here while the subscription doc may still hold a paid-tier meterQuota.
64-
// Resetting to zero prevents paid-quota bleed-through on lapsed subscriptions.
65-
const failClosedStatuses = ['paused', 'unpaid', 'incomplete_expired'];
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'];
6666
if (subscription && failClosedStatuses.includes(subscription.status)) {
6767
const planId = getDefaultPlanId();
6868
const freePlan = BillingPlanService.getActivePlan(planId);

modules/billing/tests/billing.quota.unit.tests.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,5 +594,32 @@ describe('requireQuota middleware:', () => {
594594
const errData = JSON.parse(payload.error);
595595
expect(errData.type).toBe('METER_EXHAUSTED');
596596
});
597+
598+
// ── V8 audit C2: incomplete subscription must be fail-closed ─────────────
599+
600+
test('V8-C2: incomplete subscription + no BillingUsage doc → 402 METER_EXHAUSTED with free quota (not pro)', async () => {
601+
// Org has status='incomplete' (initial payment failed, Stripe ~24h auto-cancel window).
602+
// No BillingUsage doc exists yet for this brand-new subscription.
603+
// The paid plan (pro) must NOT bleed through — fail-closed branch routes to free plan.
604+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'incomplete' });
605+
mockBillingUsageService.getMeter.mockResolvedValue(null);
606+
mockBillingExtraBalanceRepository.getBalance.mockResolvedValue(0);
607+
mockBillingPlanService.getActivePlan.mockReturnValue({ meterQuota: 0, version: 'v1' });
608+
609+
await requireQuota('scraps', 'create')(req, res, next);
610+
611+
expect(next).not.toHaveBeenCalled();
612+
expect(res.status).toHaveBeenCalledWith(402);
613+
const payload = res.json.mock.calls[0][0];
614+
const errData = JSON.parse(payload.error);
615+
expect(errData.type).toBe('METER_EXHAUSTED');
616+
// Free plan quota (0), not the pro paid quota
617+
expect(errData.meterQuota).toBe(0);
618+
// getActivePlan called with the free/default plan id, not 'pro'
619+
expect(mockBillingPlanService.getActivePlan).toHaveBeenCalledWith('free');
620+
expect(mockBillingPlanService.getActivePlan).not.toHaveBeenCalledWith('pro');
621+
// Fail-closed branch must short-circuit before the meter check
622+
expect(mockBillingUsageService.getMeter).not.toHaveBeenCalled();
623+
});
597624
});
598625
});

0 commit comments

Comments
 (0)