Skip to content

Commit 6237160

Browse files
fix(billing): replace dot separator with underscore in counter keys
requireQuota middleware and getUsage controller built counter keys with dots (resource.action) which are rejected by SAFE_KEY_RE in the usage repository, causing 422 errors on quota-checked endpoints. Switch to underscore separator to match the allowed pattern. Closes #3350
1 parent 25c7209 commit 6237160

5 files changed

Lines changed: 18 additions & 18 deletions

File tree

modules/billing/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Increment counters after successful operations:
4141
import BillingUsageService from '../billing/services/billing.usage.service.js';
4242

4343
// After creating a document
44-
await BillingUsageService.increment(organizationId, 'documents.create', 1);
44+
await BillingUsageService.increment(organizationId, 'documents_create', 1);
4545
```
4646

4747
### Usage Endpoint

modules/billing/controllers/billing.controller.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ const getUsage = async (req, res) => {
8282
for (const resource of Object.keys(planQuotas)) {
8383
for (const action of Object.keys(planQuotas[resource])) {
8484
const rawLimit = planQuotas[resource][action];
85-
limits[`${resource}.${action}`] = Number.isFinite(rawLimit) ? rawLimit : null;
85+
limits[`${resource}_${action}`] = Number.isFinite(rawLimit) ? rawLimit : null;
8686
}
8787
}
8888
}

modules/billing/middlewares/billing.requireQuota.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function requireQuota(resource, action) {
5454

5555
// Check current usage
5656
const usage = await BillingUsageService.get(req.organization._id.toString());
57-
const counterKey = `${resource}.${action}`;
57+
const counterKey = `${resource}_${action}`;
5858
const current = usage.counters[counterKey] || 0;
5959

6060
if (current >= limit) {

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('requireQuota middleware:', () => {
6969

7070
test('should allow request when under quota', async () => {
7171
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
72-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 1 } });
72+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 1 } });
7373

7474
await requireQuota('scraps', 'create')(req, res, next);
7575

@@ -79,7 +79,7 @@ describe('requireQuota middleware:', () => {
7979

8080
test('should return 429 when at quota limit', async () => {
8181
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
82-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
82+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } });
8383

8484
await requireQuota('scraps', 'create')(req, res, next);
8585

@@ -95,7 +95,7 @@ describe('requireQuota middleware:', () => {
9595

9696
test('should return 429 when over quota limit', async () => {
9797
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
98-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 5 } });
98+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 5 } });
9999

100100
await requireQuota('scraps', 'create')(req, res, next);
101101

@@ -105,7 +105,7 @@ describe('requireQuota middleware:', () => {
105105

106106
test('should treat missing subscription as free plan', async () => {
107107
mockSubscriptionRepository.findByOrganization.mockResolvedValue(null);
108-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
108+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } });
109109

110110
await requireQuota('scraps', 'create')(req, res, next);
111111

@@ -121,7 +121,7 @@ describe('requireQuota middleware:', () => {
121121
'should treat %s subscription as free plan',
122122
async (status) => {
123123
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status });
124-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 3 } });
124+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 3 } });
125125

126126
await requireQuota('scraps', 'create')(req, res, next);
127127

@@ -145,7 +145,7 @@ describe('requireQuota middleware:', () => {
145145

146146
test('should return correct error payload with upgradeUrl', async () => {
147147
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'free', status: 'active' });
148-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.execute': 100 } });
148+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_execute': 100 } });
149149

150150
await requireQuota('scraps', 'execute')(req, res, next);
151151

@@ -178,7 +178,7 @@ describe('requireQuota middleware:', () => {
178178

179179
test('should use subscription plan when status is trialing', async () => {
180180
mockSubscriptionRepository.findByOrganization.mockResolvedValue({ plan: 'starter', status: 'trialing' });
181-
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps.create': 15 } });
181+
mockBillingUsageService.get.mockResolvedValue({ counters: { 'scraps_create': 15 } });
182182

183183
await requireQuota('scraps', 'create')(req, res, next);
184184

modules/billing/tests/billing.usage.endpoint.unit.tests.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe('Billing usage endpoint unit tests:', () => {
6363

6464
test('should return usage and limits for active subscription', async () => {
6565
mockBillingService.getSubscription.mockResolvedValue({ plan: 'starter', status: 'active' });
66-
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents.create': 5, 'requests.execute': 42 } });
66+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents_create': 5, 'requests_execute': 42 } });
6767

6868
const req = { organization: { _id: orgId } };
6969
await billingController.getUsage(req, res);
@@ -74,8 +74,8 @@ describe('Billing usage endpoint unit tests:', () => {
7474
message: 'billing usage',
7575
data: expect.objectContaining({
7676
plan: 'starter',
77-
usage: { 'documents.create': 5, 'requests.execute': 42 },
78-
limits: { 'documents.create': 20, 'requests.execute': 2000 },
77+
usage: { 'documents_create': 5, 'requests_execute': 42 },
78+
limits: { 'documents_create': 20, 'requests_execute': 2000 },
7979
}),
8080
}));
8181
});
@@ -92,7 +92,7 @@ describe('Billing usage endpoint unit tests:', () => {
9292
type: 'success',
9393
data: expect.objectContaining({
9494
plan: 'free',
95-
limits: { 'documents.create': 5, 'requests.execute': 100 },
95+
limits: { 'documents_create': 5, 'requests_execute': 100 },
9696
}),
9797
}));
9898
});
@@ -106,7 +106,7 @@ describe('Billing usage endpoint unit tests:', () => {
106106
'paused',
107107
])('should return free plan when subscription status is %s', async (status) => {
108108
mockBillingService.getSubscription.mockResolvedValue({ plan: 'starter', status });
109-
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents.create': 2 } });
109+
mockBillingUsageService.get.mockResolvedValue({ month: '2026-03', counters: { 'documents_create': 2 } });
110110

111111
const req = { organization: { _id: orgId } };
112112
await billingController.getUsage(req, res);
@@ -115,7 +115,7 @@ describe('Billing usage endpoint unit tests:', () => {
115115
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
116116
data: expect.objectContaining({
117117
plan: 'free',
118-
limits: { 'documents.create': 5, 'requests.execute': 100 },
118+
limits: { 'documents_create': 5, 'requests_execute': 100 },
119119
}),
120120
}));
121121
});
@@ -131,7 +131,7 @@ describe('Billing usage endpoint unit tests:', () => {
131131
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
132132
data: expect.objectContaining({
133133
plan: 'starter',
134-
limits: { 'documents.create': 20, 'requests.execute': 2000 },
134+
limits: { 'documents_create': 20, 'requests_execute': 2000 },
135135
}),
136136
}));
137137
});
@@ -163,7 +163,7 @@ describe('Billing usage endpoint unit tests:', () => {
163163
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
164164
data: expect.objectContaining({
165165
plan: 'pro',
166-
limits: { 'documents.create': null, 'requests.execute': null },
166+
limits: { 'documents_create': null, 'requests_execute': null },
167167
}),
168168
}));
169169
});

0 commit comments

Comments
 (0)