Skip to content

fix(billing): requirePlan gates on subscription.status + top-level errorCode response shape#3682

Merged
PierreBrisorgueil merged 1 commit into
masterfrom
fix/requireplan-status-filter
May 20, 2026
Merged

fix(billing): requirePlan gates on subscription.status + top-level errorCode response shape#3682
PierreBrisorgueil merged 1 commit into
masterfrom
fix/requireplan-status-filter

Conversation

@PierreBrisorgueil
Copy link
Copy Markdown
Contributor

Closes #3679.

What — two drifts fixed in one PR

1. Logic gap: subscription.status not checked (pre-existing)

requirePlan previously gated only on subscription?.plan. A growth subscription with status: 'canceled', past_due', 'unpaid', or 'incomplete' still passed the check — the user kept access after their subscription lapsed.

Fix: introduce ACTIVE_STATUSES = Set(['active', 'trialing']). Plan is only effective when subscription.status ∈ ACTIVE_STATUSES. Everything else resolves currentPlan to 'free'.

This was pre-existing but harmless in devkit (middleware had zero route usages) — became a real gate when trawl_node #1183 wired it on apiKeys / webhooks / org-invites.

2. Response-shape divergence: upstreams the trawl_node #1183 patch

trawl_node #1183 patched the response to top-level errorCode to make it consumable by the Vue client:

{
  "type": "error",
  "message": "Forbidden",
  "code": 403,
  "status": 403,
  "errorCode": "PLAN_REQUIRED",
  "description": "Your current plan does not allow access to this resource",
  "requiredPlans": ["growth", "pro"],
  "currentPlan": "free"
}

That patch lived only in trawl_node; the next /update-stack --theirs would have silently wiped it (see feedback_update_stack_theirs_wipes_patches.md). This PR upstreams the shape to devkit so the contract is safe on future stack updates.

ORG_CONTEXT_REQUIRED follows the same top-level shape for consistency.

Decision (locked)

Breaking change note

This is a breaking response-shape change for any consumer reading body.type === 'PLAN_REQUIRED' (legacy nested key). The old shape used responses.error which wrapped meta inside the object differently. trawl_node already moved to errorCode in #1183. devkit has zero other consumers today. The responses.js import is removed.

Verification

  • Lint: ESLint: No issues found
  • Tests: 1468/1468 pass (99 suites) — includes 14 tests in the billing.requirePlan suite (6 baseline updated for new shape + 8 new covering status gating and response-shape assertions)

…rorCode

Closes #3679. Two related drifts fixed in one change:

1. Logic: canceled / past_due / unpaid / incomplete subscriptions no longer
   pass requirePlan('growth') just because subscription.plan === 'growth'.
   Only status ∈ {active, trialing} counts; everything else falls back to
   'free' plan resolution. Pre-existing gap — middleware had zero route
   usages in devkit until trawl_node wired it on apiKeys / webhooks /
   org-invites for plan-entitlement enforcement.

2. Response shape: surfaces { type: 'error', message: 'Forbidden', code: 403,
   status: 403, errorCode: 'PLAN_REQUIRED', description, requiredPlans,
   currentPlan } at the top level of the JSON body (was: nested
   { type: 'PLAN_REQUIRED' } under responses.error meta). Upstreams the
   existing trawl_node #1183 patch so /update-stack --theirs no longer
   silently wipes the downstream contract.

ORG_CONTEXT_REQUIRED also surfaces at top level for consistency.
14 unit tests total (6 baseline updated for new shape + 8 new for status gating
and response-shape assertions). Drops responses.js import (no longer needed).
Copilot AI review requested due to automatic review settings May 20, 2026 10:57
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Warning

Rate limit exceeded

@PierreBrisorgueil has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 20 minutes and 23 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 25e45a3c-aad3-43f1-9264-a79d4fbd3275

📥 Commits

Reviewing files that changed from the base of the PR and between 5e166d7 and a17b8e5.

📒 Files selected for processing (2)
  • modules/billing/middlewares/billing.requirePlan.js
  • modules/billing/tests/billing.requirePlan.unit.tests.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/requireplan-status-filter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.61%. Comparing base (5e166d7) to head (a17b8e5).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #3682   +/-   ##
=======================================
  Coverage   89.60%   89.61%           
=======================================
  Files         139      139           
  Lines        4734     4736    +2     
  Branches     1481     1482    +1     
=======================================
+ Hits         4242     4244    +2     
  Misses        385      385           
  Partials      107      107           
Flag Coverage Δ
integration 59.60% <0.00%> (-0.03%) ⬇️
unit 65.83% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5e166d7...a17b8e5. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR tightens billing entitlement enforcement by updating requirePlan to consider subscription status (not just plan) and by standardizing the 403 error response payload to a top-level errorCode contract used by downstream clients.

Changes:

  • Gate requirePlan on subscription.status ∈ {'active','trialing'}; otherwise treat the effective plan as free.
  • Change 403 deny responses to include top-level errorCode (PLAN_REQUIRED, ORG_CONTEXT_REQUIRED) plus requiredPlans/currentPlan.
  • Update and expand unit tests to cover status gating and the new response shape.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
modules/billing/middlewares/billing.requirePlan.js Adds status-based plan effectiveness and returns standardized top-level errorCode response envelopes.
modules/billing/tests/billing.requirePlan.unit.tests.js Updates existing assertions and adds new unit coverage for status gating and response payload shape.

Comment on lines +6 to +11
/**
* Statuses considered "active" for plan-entitlement checks.
* canceled / past_due / unpaid / incomplete fall back to 'free' resolution.
* @type {Set<string>}
*/
const ACTIVE_STATUSES = new Set(['active', 'trialing']);
Comment on lines 43 to 46
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
const currentPlan = subscription?.plan || 'free';
const isEffective = subscription && ACTIVE_STATUSES.has(subscription.status);
const currentPlan = isEffective ? subscription.plan : 'free';

Comment on lines +138 to +153
describe('requirePlan — subscription.status gating:', () => {
const fakeOrgId = new mongoose.Types.ObjectId();

function mockReq(overrides = {}) {
return {
organization: { _id: fakeOrgId, name: 'Test Org' },
...overrides,
};
}

function mockRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
@PierreBrisorgueil PierreBrisorgueil merged commit fd393ac into master May 20, 2026
12 checks passed
@PierreBrisorgueil PierreBrisorgueil deleted the fix/requireplan-status-filter branch May 20, 2026 11:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

billing: requirePlan gates on subscription.plan but not subscription.status (canceled plan still passes)

2 participants