Skip to content

Commit 0c2efb2

Browse files
feat(billing): add GET /api/billing/usage endpoint (#3277)
* feat(billing): add GET /api/billing/usage endpoint * fix(billing): address review feedback on usage endpoint - Move subscription lookup behind BillingService (service-layer rule) - Remove SubscriptionRepository import from controller - Use usage.month for period instead of recomputing (avoid drift) - Normalize Infinity quotas to null for JSON-safe serialization - Update tests to match new service boundary and null limits * fix(billing): use status allowlist for plan resolution in usage endpoint - Replace past_due-only check with active/trialing allowlist - All other statuses (canceled, unpaid, incomplete, etc.) now map to free - Add test.each for all non-active statuses + trialing test
1 parent 0f5347e commit 0c2efb2

5 files changed

Lines changed: 262 additions & 0 deletions

File tree

lib/middlewares/policy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const deriveSubjectType = (routePath) => {
195195
if (routePath === '/api/billing/checkout') return 'BillingCheckout';
196196
if (routePath === '/api/billing/portal') return 'BillingPortal';
197197
if (routePath === '/api/billing/subscription') return 'BillingSubscription';
198+
if (routePath === '/api/billing/usage') return 'BillingUsage';
198199
if (routePath === '/api/billing/plans') return 'BillingPlans';
199200
if (routePath.startsWith('/api/tasks')) return 'Task';
200201
if (routePath.startsWith('/api/uploads')) return 'Upload';

modules/billing/controllers/billing.controller.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/**
22
* Module dependencies
33
*/
4+
import config from '../../../config/index.js';
45
import responses from '../../../lib/helpers/responses.js';
56
import BillingService from '../services/billing.service.js';
7+
import BillingUsageService from '../services/billing.usage.service.js';
68

79
/**
810
* @desc Endpoint to create a Stripe Checkout session
@@ -55,8 +57,50 @@ const getSubscription = async (req, res) => {
5557
}
5658
};
5759

60+
/**
61+
* @desc Endpoint to get billing usage for the current organization
62+
* @param {Object} req - Express request object
63+
* @param {Object} res - Express response object
64+
* @returns {Promise<void>}
65+
*/
66+
const getUsage = async (req, res) => {
67+
try {
68+
// Determine current plan via service layer
69+
const subscription = await BillingService.getSubscription(req.organization._id);
70+
const activeStatuses = ['active', 'trialing'];
71+
const plan = (!subscription || !activeStatuses.includes(subscription.status)) ? 'free' : (subscription.plan || 'free');
72+
73+
// Get usage counters (includes month field)
74+
const usage = await BillingUsageService.get(req.organization._id.toString());
75+
76+
// Flatten quotas config into { "resource.action": limit } format
77+
// Normalize Infinity to null for JSON-safe serialization
78+
const quotas = config.billing?.quotas;
79+
let limits = {};
80+
if (quotas?.[plan]) {
81+
const planQuotas = quotas[plan];
82+
for (const resource of Object.keys(planQuotas)) {
83+
for (const action of Object.keys(planQuotas[resource])) {
84+
const rawLimit = planQuotas[resource][action];
85+
limits[`${resource}.${action}`] = Number.isFinite(rawLimit) ? rawLimit : null;
86+
}
87+
}
88+
}
89+
90+
responses.success(res, 'billing usage')({
91+
plan,
92+
period: usage.month,
93+
usage: usage.counters || {},
94+
limits,
95+
});
96+
} catch (err) {
97+
responses.error(res, 500, 'Internal Server Error', 'Failed to retrieve billing usage')(err);
98+
}
99+
};
100+
58101
export default {
59102
checkout,
60103
portal,
61104
getSubscription,
105+
getUsage,
62106
};

modules/billing/policies/billing.policy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function billingAbilities(user, membership, { can, cannot }) {
2424
can('create', 'BillingCheckout');
2525
can('create', 'BillingPortal');
2626
can('read', 'BillingSubscription');
27+
can('read', 'BillingUsage');
2728
}
2829

2930
/**

modules/billing/routes/billing.routes.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,10 @@ export default (app) => {
3636
.route('/api/billing/subscription')
3737
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
3838
.get(billing.getSubscription);
39+
40+
// usage
41+
app
42+
.route('/api/billing/usage')
43+
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
44+
.get(billing.getUsage);
3945
};
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for billing usage endpoint (getUsage controller)
8+
*/
9+
describe('Billing usage endpoint unit tests:', () => {
10+
let billingController;
11+
let mockBillingService;
12+
let mockBillingUsageService;
13+
let mockConfig;
14+
let res;
15+
16+
const orgId = '507f1f77bcf86cd799439011';
17+
18+
beforeEach(async () => {
19+
jest.resetModules();
20+
21+
mockBillingService = {
22+
getSubscription: jest.fn(),
23+
};
24+
25+
mockBillingUsageService = {
26+
get: jest.fn(),
27+
};
28+
29+
mockConfig = {
30+
billing: {
31+
quotas: {
32+
free: { documents: { create: 5 }, requests: { execute: 100 } },
33+
starter: { documents: { create: 20 }, requests: { execute: 2000 } },
34+
pro: { documents: { create: Infinity }, requests: { execute: Infinity } },
35+
},
36+
},
37+
};
38+
39+
jest.unstable_mockModule('../services/billing.service.js', () => ({
40+
default: mockBillingService,
41+
}));
42+
43+
jest.unstable_mockModule('../services/billing.usage.service.js', () => ({
44+
default: mockBillingUsageService,
45+
}));
46+
47+
jest.unstable_mockModule('../../../config/index.js', () => ({
48+
default: mockConfig,
49+
}));
50+
51+
const mod = await import('../controllers/billing.controller.js');
52+
billingController = mod.default;
53+
54+
res = {
55+
status: jest.fn().mockReturnThis(),
56+
json: jest.fn().mockReturnThis(),
57+
};
58+
});
59+
60+
afterEach(() => {
61+
jest.restoreAllMocks();
62+
});
63+
64+
test('should return usage and limits for active subscription', async () => {
65+
mockBillingService.getSubscription.mockResolvedValue({ plan: 'starter', status: 'active' });
66+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents.create': 5, 'requests.execute': 42 } });
67+
68+
const req = { organization: { _id: orgId } };
69+
await billingController.getUsage(req, res);
70+
71+
expect(res.status).toHaveBeenCalledWith(200);
72+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
73+
type: 'success',
74+
message: 'billing usage',
75+
data: expect.objectContaining({
76+
plan: 'starter',
77+
usage: { 'documents.create': 5, 'requests.execute': 42 },
78+
limits: { 'documents.create': 20, 'requests.execute': 2000 },
79+
}),
80+
}));
81+
});
82+
83+
test('should return free plan when no subscription', async () => {
84+
mockBillingService.getSubscription.mockResolvedValue(null);
85+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: {} });
86+
87+
const req = { organization: { _id: orgId } };
88+
await billingController.getUsage(req, res);
89+
90+
expect(res.status).toHaveBeenCalledWith(200);
91+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
92+
type: 'success',
93+
data: expect.objectContaining({
94+
plan: 'free',
95+
limits: { 'documents.create': 5, 'requests.execute': 100 },
96+
}),
97+
}));
98+
});
99+
100+
test.each([
101+
'past_due',
102+
'canceled',
103+
'unpaid',
104+
'incomplete',
105+
'incomplete_expired',
106+
'paused',
107+
])('should return free plan when subscription status is %s', async (status) => {
108+
mockBillingService.getSubscription.mockResolvedValue({ plan: 'starter', status });
109+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents.create': 2 } });
110+
111+
const req = { organization: { _id: orgId } };
112+
await billingController.getUsage(req, res);
113+
114+
expect(res.status).toHaveBeenCalledWith(200);
115+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
116+
data: expect.objectContaining({
117+
plan: 'free',
118+
limits: { 'documents.create': 5, 'requests.execute': 100 },
119+
}),
120+
}));
121+
});
122+
123+
test('should return paid plan when subscription status is trialing', async () => {
124+
mockBillingService.getSubscription.mockResolvedValue({ plan: 'starter', status: 'trialing' });
125+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: {} });
126+
127+
const req = { organization: { _id: orgId } };
128+
await billingController.getUsage(req, res);
129+
130+
expect(res.status).toHaveBeenCalledWith(200);
131+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
132+
data: expect.objectContaining({
133+
plan: 'starter',
134+
limits: { 'documents.create': 20, 'requests.execute': 2000 },
135+
}),
136+
}));
137+
});
138+
139+
test('should return empty limits when plan has no quota config', async () => {
140+
mockBillingService.getSubscription.mockResolvedValue({ plan: 'enterprise', status: 'active' });
141+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: {} });
142+
143+
const req = { organization: { _id: orgId } };
144+
await billingController.getUsage(req, res);
145+
146+
expect(res.status).toHaveBeenCalledWith(200);
147+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
148+
data: expect.objectContaining({
149+
plan: 'enterprise',
150+
limits: {},
151+
}),
152+
}));
153+
});
154+
155+
test('should return correct flattened limits format', async () => {
156+
mockBillingService.getSubscription.mockResolvedValue({ plan: 'pro', status: 'active' });
157+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: {} });
158+
159+
const req = { organization: { _id: orgId } };
160+
await billingController.getUsage(req, res);
161+
162+
expect(res.status).toHaveBeenCalledWith(200);
163+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
164+
data: expect.objectContaining({
165+
plan: 'pro',
166+
limits: { 'documents.create': null, 'requests.execute': null },
167+
}),
168+
}));
169+
});
170+
171+
test('should return empty counters for new org (no usage yet)', async () => {
172+
mockBillingService.getSubscription.mockResolvedValue({ plan: 'starter', status: 'active' });
173+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: {} });
174+
175+
const req = { organization: { _id: orgId } };
176+
await billingController.getUsage(req, res);
177+
178+
expect(res.status).toHaveBeenCalledWith(200);
179+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
180+
data: expect.objectContaining({
181+
usage: {},
182+
}),
183+
}));
184+
});
185+
186+
test('should return period from usage month field', async () => {
187+
mockBillingService.getSubscription.mockResolvedValue({ plan: 'free', status: 'active' });
188+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: {} });
189+
190+
const req = { organization: { _id: orgId } };
191+
await billingController.getUsage(req, res);
192+
193+
expect(res.status).toHaveBeenCalledWith(200);
194+
const { data } = res.json.mock.calls[0][0];
195+
expect(data.period).toBe('2026-03');
196+
});
197+
198+
test('should return 500 when an error occurs', async () => {
199+
mockBillingService.getSubscription.mockRejectedValue(new Error('DB error'));
200+
201+
const req = { organization: { _id: orgId } };
202+
await billingController.getUsage(req, res);
203+
204+
expect(res.status).toHaveBeenCalledWith(500);
205+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
206+
type: 'error',
207+
message: 'Internal Server Error',
208+
}));
209+
});
210+
});

0 commit comments

Comments
 (0)