Skip to content

Commit 3ccb712

Browse files
feat(billing): add GET /api/billing/usage endpoint
1 parent 0f5347e commit 3ccb712

5 files changed

Lines changed: 248 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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
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 SubscriptionRepository from '../repositories/billing.subscription.repository.js';
8+
import BillingUsageService from '../services/billing.usage.service.js';
69

710
/**
811
* @desc Endpoint to create a Stripe Checkout session
@@ -55,8 +58,53 @@ const getSubscription = async (req, res) => {
5558
}
5659
};
5760

61+
/**
62+
* @desc Endpoint to get billing usage for the current organization
63+
* @param {Object} req - Express request object
64+
* @param {Object} res - Express response object
65+
* @returns {Promise<void>}
66+
*/
67+
const getUsage = async (req, res) => {
68+
try {
69+
// Determine current plan
70+
const subscription = await SubscriptionRepository.findByOrganization(req.organization._id);
71+
const plan = (!subscription || subscription.status === 'past_due') ? 'free' : (subscription.plan || 'free');
72+
73+
// Get usage counters
74+
const usage = await BillingUsageService.get(req.organization._id.toString());
75+
76+
// Compute current month UTC for period
77+
const now = new Date();
78+
const year = now.getUTCFullYear();
79+
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
80+
const period = `${year}-${month}`;
81+
82+
// Flatten quotas config into { "resource.action": limit } format
83+
const quotas = config.billing?.quotas;
84+
let limits = {};
85+
if (quotas?.[plan]) {
86+
const planQuotas = quotas[plan];
87+
for (const resource of Object.keys(planQuotas)) {
88+
for (const action of Object.keys(planQuotas[resource])) {
89+
limits[`${resource}.${action}`] = planQuotas[resource][action];
90+
}
91+
}
92+
}
93+
94+
responses.success(res, 'billing usage')({
95+
plan,
96+
period,
97+
usage: usage.counters || {},
98+
limits,
99+
});
100+
} catch (err) {
101+
responses.error(res, 500, 'Internal Server Error', 'Failed to retrieve billing usage')(err);
102+
}
103+
};
104+
58105
export default {
59106
checkout,
60107
portal,
61108
getSubscription,
109+
getUsage,
62110
};

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: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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 mockSubscriptionRepository;
12+
let mockBillingUsageService;
13+
let mockConfig;
14+
let res;
15+
16+
const orgId = '507f1f77bcf86cd799439011';
17+
18+
beforeEach(async () => {
19+
jest.resetModules();
20+
21+
mockSubscriptionRepository = {
22+
findByOrganization: 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('../repositories/billing.subscription.repository.js', () => ({
40+
default: mockSubscriptionRepository,
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+
// Mock BillingService to avoid stripe dependency
52+
jest.unstable_mockModule('../services/billing.service.js', () => ({
53+
default: {},
54+
}));
55+
56+
const mod = await import('../controllers/billing.controller.js');
57+
billingController = mod.default;
58+
59+
res = {
60+
status: jest.fn().mockReturnThis(),
61+
json: jest.fn().mockReturnThis(),
62+
};
63+
});
64+
65+
afterEach(() => {
66+
jest.restoreAllMocks();
67+
});
68+
69+
test('should return usage and limits for active subscription', async () => {
70+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'active' });
71+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'documents.create': 5, 'requests.execute': 42 } });
72+
73+
const req = { organization: { _id: orgId } };
74+
await billingController.getUsage(req, res);
75+
76+
expect(res.status).toHaveBeenCalledWith(200);
77+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
78+
type: 'success',
79+
message: 'billing usage',
80+
data: expect.objectContaining({
81+
plan: 'starter',
82+
usage: { 'documents.create': 5, 'requests.execute': 42 },
83+
limits: { 'documents.create': 20, 'requests.execute': 2000 },
84+
}),
85+
}));
86+
});
87+
88+
test('should return free plan when no subscription', async () => {
89+
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
90+
mockBillingUsageService.get.mockResolvedValue({ counters: {} });
91+
92+
const req = { organization: { _id: orgId } };
93+
await billingController.getUsage(req, res);
94+
95+
expect(res.status).toHaveBeenCalledWith(200);
96+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
97+
type: 'success',
98+
data: expect.objectContaining({
99+
plan: 'free',
100+
limits: { 'documents.create': 5, 'requests.execute': 100 },
101+
}),
102+
}));
103+
});
104+
105+
test('should return free plan when subscription is past_due', async () => {
106+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'past_due' });
107+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'documents.create': 2 } });
108+
109+
const req = { organization: { _id: orgId } };
110+
await billingController.getUsage(req, res);
111+
112+
expect(res.status).toHaveBeenCalledWith(200);
113+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
114+
data: expect.objectContaining({
115+
plan: 'free',
116+
limits: { 'documents.create': 5, 'requests.execute': 100 },
117+
}),
118+
}));
119+
});
120+
121+
test('should return empty limits when plan has no quota config', async () => {
122+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'enterprise', status: 'active' });
123+
mockBillingUsageService.get.mockResolvedValue({ counters: {} });
124+
125+
const req = { organization: { _id: orgId } };
126+
await billingController.getUsage(req, res);
127+
128+
expect(res.status).toHaveBeenCalledWith(200);
129+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
130+
data: expect.objectContaining({
131+
plan: 'enterprise',
132+
limits: {},
133+
}),
134+
}));
135+
});
136+
137+
test('should return correct flattened limits format', async () => {
138+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'pro', status: 'active' });
139+
mockBillingUsageService.get.mockResolvedValue({ counters: {} });
140+
141+
const req = { organization: { _id: orgId } };
142+
await billingController.getUsage(req, res);
143+
144+
expect(res.status).toHaveBeenCalledWith(200);
145+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
146+
data: expect.objectContaining({
147+
plan: 'pro',
148+
limits: { 'documents.create': Infinity, 'requests.execute': Infinity },
149+
}),
150+
}));
151+
});
152+
153+
test('should return empty counters for new org (no usage yet)', async () => {
154+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'active' });
155+
mockBillingUsageService.get.mockResolvedValue({ counters: {} });
156+
157+
const req = { organization: { _id: orgId } };
158+
await billingController.getUsage(req, res);
159+
160+
expect(res.status).toHaveBeenCalledWith(200);
161+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
162+
data: expect.objectContaining({
163+
usage: {},
164+
}),
165+
}));
166+
});
167+
168+
test('should return correct period in YYYY-MM format', async () => {
169+
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
170+
mockBillingUsageService.get.mockResolvedValue({ counters: {} });
171+
172+
const req = { organization: { _id: orgId } };
173+
await billingController.getUsage(req, res);
174+
175+
expect(res.status).toHaveBeenCalledWith(200);
176+
const { data } = res.json.mock.calls[0][0];
177+
expect(data.period).toMatch(/^\d{4}-\d{2}$/);
178+
});
179+
180+
test('should return 500 when an error occurs', async () => {
181+
mockSubscriptionRepository.findByOrganization.mockRejectedValue(new Error('DB error'));
182+
183+
const req = { organization: { _id: orgId } };
184+
await billingController.getUsage(req, res);
185+
186+
expect(res.status).toHaveBeenCalledWith(500);
187+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
188+
type: 'error',
189+
message: 'Internal Server Error',
190+
}));
191+
});
192+
});

0 commit comments

Comments
 (0)