Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
40 changes: 32 additions & 8 deletions modules/billing/middlewares/billing.requirePlan.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@
*/
import SubscriptionRepository from '../repositories/billing.subscription.repository.js';

import responses from '../../../lib/helpers/responses.js';
/**
* 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 +6 to +11

/**
* Returns Express middleware that gates access based on subscription plan.
* This middleware is orthogonal to CASL — CASL gates by role, requirePlan
* gates by subscription plan.
* Returns Express middleware that gates access based on subscription plan AND status.
* Orthogonal to CASL — CASL gates by role, requirePlan gates by subscription plan.
*
* A plan is only effective when subscription.status ∈ {active, trialing}.
* canceled / past_due / unpaid / incomplete are treated as 'free'.
*
* Response shape on deny matches the downstream trawl_node contract (top-level errorCode):
* { type: 'error', message: 'Forbidden', code: 403, status: 403,
* errorCode: 'PLAN_REQUIRED', description, requiredPlans: string[], currentPlan: string }
*
* Expects `req.organization` to be set by resolveOrganization upstream.
*
Expand All @@ -18,17 +29,30 @@ import responses from '../../../lib/helpers/responses.js';
function requirePlan(...plans) {
return async function requirePlanMiddleware(req, res, next) {
if (!req.organization) {
return responses.error(res, 403, 'Forbidden', 'Organization context is required to check subscription plan')();
return res.status(403).json({
type: 'error',
message: 'Forbidden',
code: 403,
status: 403,
errorCode: 'ORG_CONTEXT_REQUIRED',
description: 'Organization context is required to check subscription plan',
});
}

try {
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 43 to 46
if (plans.includes(currentPlan)) return next();

return responses.error(res, 403, 'Forbidden', 'Your current plan does not allow access to this resource')({
type: 'PLAN_REQUIRED',
return res.status(403).json({
type: 'error',
message: 'Forbidden',
code: 403,
status: 403,
errorCode: 'PLAN_REQUIRED',
description: 'Your current plan does not allow access to this resource',
requiredPlans: plans,
currentPlan,
});
Expand Down
176 changes: 171 additions & 5 deletions modules/billing/tests/billing.requirePlan.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('requirePlan middleware unit tests:', () => {
});

test('should call next when subscription plan matches the required plan', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'pro' });
mockFindByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' });

const middleware = requirePlan('pro');
const req = mockReq();
Expand All @@ -60,7 +60,7 @@ describe('requirePlan middleware unit tests:', () => {
});

test('should call next when subscription plan is in multiple allowed plans', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'starter' });
mockFindByOrganization.mockResolvedValue({ plan: 'starter', status: 'active' });

const middleware = requirePlan('starter', 'pro', 'enterprise');
const req = mockReq();
Expand All @@ -74,7 +74,7 @@ describe('requirePlan middleware unit tests:', () => {
});

test('should return 403 when subscription plan does not match', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'free' });
mockFindByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });

const middleware = requirePlan('pro', 'enterprise');
const req = mockReq();
Expand All @@ -85,7 +85,9 @@ describe('requirePlan middleware unit tests:', () => {

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'Forbidden' }));
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'Forbidden', errorCode: 'PLAN_REQUIRED' }),
);
});

test('should default to free plan when no subscription exists', async () => {
Expand Down Expand Up @@ -126,7 +128,171 @@ describe('requirePlan middleware unit tests:', () => {

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'Forbidden' }));
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'Forbidden', errorCode: 'ORG_CONTEXT_REQUIRED' }),
);
expect(mockFindByOrganization).not.toHaveBeenCalled();
});
});

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;
}
Comment on lines +138 to +153

beforeEach(() => {
jest.clearAllMocks();
});

test('treats a canceled growth subscription as free (denies access)', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'growth', status: 'canceled' });

const req = mockReq();
const res = mockRes();
const next = jest.fn();

await requirePlan('growth', 'pro')(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
const body = res.json.mock.calls[0][0];
expect(body.currentPlan).toBe('free');
});

test('treats a past_due growth subscription as free (denies access)', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'growth', status: 'past_due' });

const req = mockReq();
const res = mockRes();
const next = jest.fn();

await requirePlan('growth')(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
const body = res.json.mock.calls[0][0];
expect(body.currentPlan).toBe('free');
});

test('treats unpaid subscription as free (denies access)', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'pro', status: 'unpaid' });

const req = mockReq();
const res = mockRes();
const next = jest.fn();

await requirePlan('pro')(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
const body = res.json.mock.calls[0][0];
expect(body.currentPlan).toBe('free');
});

test('treats incomplete subscription as free (denies access)', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'pro', status: 'incomplete' });

const req = mockReq();
const res = mockRes();
const next = jest.fn();

await requirePlan('pro')(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
const body = res.json.mock.calls[0][0];
expect(body.currentPlan).toBe('free');
});

test('passes an active growth subscription', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'growth', status: 'active' });

const req = mockReq();
const res = mockRes();
const next = jest.fn();

await requirePlan('growth')(req, res, next);

expect(next).toHaveBeenCalledWith();
expect(res.status).not.toHaveBeenCalled();
});

test('passes a trialing pro subscription', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'pro', status: 'trialing' });

const req = mockReq();
const res = mockRes();
const next = jest.fn();

await requirePlan('pro')(req, res, next);

expect(next).toHaveBeenCalledWith();
expect(res.status).not.toHaveBeenCalled();
});
});

describe('requirePlan — response shape (top-level errorCode):', () => {
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;
}

beforeEach(() => {
jest.clearAllMocks();
});

test('returns top-level errorCode, requiredPlans, currentPlan on PLAN_REQUIRED', async () => {
mockFindByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });

const req = mockReq();
const res = mockRes();
const next = jest.fn();

await requirePlan('growth', 'pro')(req, res, next);

const body = res.json.mock.calls[0][0];
expect(body.errorCode).toBe('PLAN_REQUIRED');
expect(body.requiredPlans).toEqual(['growth', 'pro']);
expect(body.currentPlan).toBe('free');
expect(body.type).toBe('error');
expect(body.message).toBe('Forbidden');
expect(body.code).toBe(403);
expect(body.status).toBe(403);
});

test('returns ORG_CONTEXT_REQUIRED errorCode when req.organization is absent', async () => {
const req = mockReq({ organization: undefined });
const res = mockRes();
const next = jest.fn();

await requirePlan('growth')(req, res, next);

const body = res.json.mock.calls[0][0];
expect(body.errorCode).toBe('ORG_CONTEXT_REQUIRED');
expect(res.status).toHaveBeenCalledWith(403);
expect(body.type).toBe('error');
});
});
Loading