Skip to content

Commit b257c87

Browse files
feat(billing): plan source-of-truth + plan seed + meterExempt Zod (PR-N6) (#3555)
* feat(billing): plan source-of-truth + plan seed + meterExempt Zod (PR-N6) P0 + P1.7 unblockers for activating meterMode in prod. - P0.1: read planId from Subscription.plan of org instead of config.billing.plans[0] (which always returned 'free'). Fallback now configurable via billing.defaultPlan. - P0.2: BillingPlanService.ensureSeeded() upserts BillingPlan docs from config.billing.planDefinitions at boot. Idempotent. No-op when meterMode=false. - P0.3: add meterExempt to organizations Zod schema (was on Mongoose only, silently dropped on PUT/PATCH). - P1.7: declare billing.upgradeUrl + billing.defaultPlan in default config (already used by middleware with hardcoded fallback). * fix(billing): lean subscription lookup + E11000-safe ensureSeeded + fail-fast on seed error Review fixes for PR-N6 (Codacy/Copilot threads): - billing.subscription.repository: add findPlan() — lean projection {plan:1}, no populate, for hot-path meter attribution and weekly reset (avoids org join cost on every incrementMeter). - billing.usage.service + billing.reset.service: switch from findByOrganization to findPlan. - billing.plan.service.ensureSeeded: derive version from count() instead of hardcoding 'v1' (avoids E11000 when an inactive v1 record exists). Wrap create in try-catch: E11000 from concurrent pod startup races is absorbed as a skip, not fatal. - billing.init: re-throw ensureSeeded failure when meterMode=true — prevents deploy from succeeding in a broken state where all quota resolutions return 0. - Tests: add coverage for count>0 version derivation, E11000 race skip, non-E11000 propagation. Update billing.usage.service, billing.reset.service and cron weeklyReset tests to mock findPlan instead of findByOrganization. * test(billing): add coverage for billing.init fail-fast + findPlan guard - Add billing.init.unit.tests.js: 4 tests covering ensureSeeded integration (called at init, swallowed when meterMode=false, re-throws when meterMode=true, logs info on seeded>0) - Add findPlan describe block to billing.subscription.repository.unit.tests.js: 3 tests covering valid ID, invalid ID guard (no DB call), and null on no match Closes codecov/project regression introduced by 9e71c74. * fix(billing): add enterprise to planDefinitions CodeRabbit flagged that billing.plans enum includes 'enterprise' but planDefinitions did not — bootstrap seeding would skip enterprise and downstream resolution would fall back to meterQuota=0 for enterprise orgs.
1 parent ec17597 commit b257c87

15 files changed

Lines changed: 540 additions & 12 deletions

modules/billing/billing.init.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import config from '../../config/index.js';
55
import AnalyticsService from '../../lib/services/analytics.js';
66
import billingEvents from './lib/events.js';
7+
import BillingPlanService from './services/billing.plan.service.js';
78

89
/**
910
* Billing module initialisation.
@@ -29,4 +30,17 @@ export default async (app) => {
2930
AnalyticsService.groupIdentify('company', String(organizationId), { plan: newPlan });
3031
} catch (_) { /* analytics must not break billing flow */ }
3132
});
33+
34+
try {
35+
const { seeded, skipped } = await BillingPlanService.ensureSeeded();
36+
if (seeded > 0) {
37+
console.info(`[billing] seeded ${seeded} plan(s) from config.billing.planDefinitions (skipped ${skipped} already active)`);
38+
}
39+
} catch (err) {
40+
console.error('[billing] ensureSeeded failed:', err);
41+
// Fail fast when meterMode is enabled: a seeding failure means quota resolution
42+
// will return 0 for all plans, silently gating all metered operations.
43+
// Surfacing the crash here prevents a deploy from succeeding in a broken state.
44+
if (config?.billing?.meterMode) throw err;
45+
}
3246
};

modules/billing/config/billing.development.config.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,27 @@ const config = {
3131
* When false, all meter code paths are no-ops; legacy behavior unchanged.
3232
*/
3333
meterMode: false,
34+
/**
35+
* Default plan used when an organization has no subscription. Configurable per project.
36+
*/
37+
defaultPlan: 'free',
38+
/**
39+
* URL the front-end should redirect users to when quota is exhausted or past_due.
40+
* Used by middleware error responses (METER_EXHAUSTED, QUOTA_EXCEEDED).
41+
*/
42+
upgradeUrl: '/billing/plans',
43+
/**
44+
* Plan definitions — DOWNSTREAM-OVERRIDE-REQUIRED for meter mode.
45+
* Used by BillingPlanService.ensureSeeded() at boot to upsert BillingPlan docs.
46+
* Each entry: { meterQuota: units/week, ratios: { featureKey: multiplier } }.
47+
* Plans listed here must also exist in billing.plans enum.
48+
*/
49+
planDefinitions: {
50+
free: { meterQuota: 0, ratios: { default: 1 } },
51+
starter: { meterQuota: 50000, ratios: { default: 1 } },
52+
pro: { meterQuota: 500000, ratios: { default: 1 } },
53+
enterprise: { meterQuota: 2000000, ratios: { default: 1 } },
54+
},
3455
/**
3556
* Meter unit parameters — downstream projects must override with their
3657
* actual unit economics before enabling meterMode in production.

modules/billing/repositories/billing.subscription.repository.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ const findByStripeSubscriptionId = (stripeSubscriptionId) => {
109109
return Subscription.findOne({ stripeSubscriptionId: normalized }).populate(defaultPopulate).exec();
110110
};
111111

112+
/**
113+
* @function findPlan
114+
* @description Lean lookup that returns only the `plan` field for a given organization.
115+
* Used on hot paths (meter attribution, weekly reset) where only the plan
116+
* identifier is needed — avoids the full populate overhead of findByOrganization.
117+
* @param {String} organizationId - The organization ID.
118+
* @returns {Promise<{plan: string}|null>} A lean plain object with just `plan`, or null.
119+
*/
120+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
121+
const findPlan = (organizationId) => {
122+
if (!mongoose.Types.ObjectId.isValid(organizationId)) return null;
123+
return Subscription.findOne({ organization: organizationId }, { plan: 1 }).lean().exec();
124+
};
125+
112126
/**
113127
* @function findAllDueForReset
114128
* @description Fetch active/trialing subscriptions whose currentPeriodStart falls
@@ -173,6 +187,7 @@ export default {
173187
update,
174188
remove,
175189
findByOrganization,
190+
findPlan,
176191
findByStripeCustomerId,
177192
findByStripeSubscriptionId,
178193
findAllDueForReset,

modules/billing/services/billing.plan.service.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Module dependencies
33
*/
4+
import config from '../../../config/index.js';
45
import BillingPlanRepository from '../repositories/billing.plan.repository.js';
56

67
/**
@@ -161,10 +162,70 @@ const bumpVersionWithRetry = async (planId, fields, { maxAttempts = 3 } = {}) =>
161162
throw lastErr;
162163
};
163164

165+
/**
166+
* @function ensureSeeded
167+
* @description Upsert BillingPlan docs from config.billing.planDefinitions.
168+
* For each configured planId, ensures an active plan exists.
169+
* No-op when meter mode is disabled. Idempotent on re-run.
170+
*
171+
* Race / E11000 safety: version is derived from the total count of
172+
* existing docs for the planId (same strategy as bumpVersion). A
173+
* try-catch on create absorbs E11000 from concurrent multi-pod
174+
* startup races — the losing pod simply increments skipped.
175+
*
176+
* @returns {Promise<{seeded: number, skipped: number}>} Seed summary.
177+
*/
178+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
179+
const ensureSeeded = async () => {
180+
if (!config?.billing?.meterMode) return { seeded: 0, skipped: 0 };
181+
182+
const definitions = config?.billing?.planDefinitions ?? {};
183+
let seeded = 0;
184+
let skipped = 0;
185+
186+
for (const [planId, def] of Object.entries(definitions)) {
187+
const existing = await BillingPlanRepository.findActive(planId);
188+
if (existing) {
189+
skipped += 1;
190+
continue;
191+
}
192+
193+
try {
194+
// Derive version from total count so that an inactive v1 record does not
195+
// collide with the insert (same approach as bumpVersion to avoid hardcoding 'v1').
196+
const total = await BillingPlanRepository.count(planId);
197+
const version = `v${total + 1}`;
198+
199+
await BillingPlanRepository.create({
200+
planId,
201+
version,
202+
meterQuota: def.meterQuota ?? 0,
203+
ratios: def.ratios ?? { default: 1 },
204+
effectiveFrom: new Date(),
205+
effectiveUntil: null,
206+
active: true,
207+
});
208+
cache.delete(planId);
209+
seeded += 1;
210+
} catch (err) {
211+
// E11000: concurrent pod beat us to the insert — treat as skip, not fatal.
212+
const isE11000 = err.code === 11000 || (err.message && err.message.includes('E11000'));
213+
if (isE11000) {
214+
skipped += 1;
215+
continue;
216+
}
217+
throw err;
218+
}
219+
}
220+
221+
return { seeded, skipped };
222+
};
223+
164224
export default {
165225
getActivePlan,
166226
getPlanByVersion,
167227
bumpVersion,
168228
bumpVersionWithRetry,
229+
ensureSeeded,
169230
invalidateCache,
170231
};

modules/billing/services/billing.reset.service.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ const resetWeek = async (orgId, periodStart) => {
5050
// Delegates to repository — no mongoose import in service layer.
5151
await BillingUsageRepository.archiveOtherWeeks(orgId, newWeekKey, now);
5252

53-
// Step 2 — Fetch the active plan to snapshot quota/planVersion.
54-
const planId = config?.billing?.plans?.[0] ?? 'pro';
53+
// Step 2 — Fetch the active plan to snapshot quota/planVersion — lean projection (plan only, no populate).
54+
const subscription = await BillingSubscriptionRepository.findPlan(orgId);
55+
const planId = subscription?.plan ?? config?.billing?.defaultPlan ?? 'free';
5556
const activePlan = await BillingPlanService.getActivePlan(planId);
5657
const meterQuota = activePlan?.meterQuota ?? 0;
5758
const planVersion = activePlan?.version ?? null;

modules/billing/services/billing.usage.service.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import config from '../../../config/index.js';
55
import UsageRepository from '../repositories/billing.usage.repository.js';
6+
import BillingSubscriptionRepository from '../repositories/billing.subscription.repository.js';
67
import BillingPlanService from './billing.plan.service.js';
78

89
/**
@@ -104,8 +105,9 @@ const incrementMeter = async (organizationId, units, breakdown, idempotencyKey)
104105
const weekKey = currentWeekKey();
105106
const monthKey = currentMonth();
106107

107-
// Fetch active plan for quota snapshot
108-
const planId = config?.billing?.plans?.[0] ?? 'pro';
108+
// Fetch active plan for quota snapshot — lean projection (plan field only, no populate)
109+
const subscription = await BillingSubscriptionRepository.findPlan(organizationId);
110+
const planId = subscription?.plan ?? config?.billing?.defaultPlan ?? 'free';
109111
const activePlan = await BillingPlanService.getActivePlan(planId);
110112
const meterQuota = activePlan?.meterQuota ?? 0;
111113
const planVersion = activePlan?.version ?? null;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for billing.init ensureSeeded integration.
8+
*/
9+
describe('billing.init ensureSeeded unit tests:', () => {
10+
let billingInit;
11+
let mockBillingPlanService;
12+
let mockConfig;
13+
14+
const mockApp = {};
15+
16+
beforeEach(async () => {
17+
jest.resetModules();
18+
19+
mockConfig = {
20+
billing: {
21+
meterMode: false,
22+
packs: [],
23+
},
24+
};
25+
26+
mockBillingPlanService = {
27+
ensureSeeded: jest.fn().mockResolvedValue({ seeded: 0, skipped: 0 }),
28+
};
29+
30+
jest.unstable_mockModule('../../../config/index.js', () => ({
31+
default: mockConfig,
32+
}));
33+
34+
jest.unstable_mockModule('../services/billing.plan.service.js', () => ({
35+
default: mockBillingPlanService,
36+
}));
37+
38+
// Stub analytics and events to avoid side effects
39+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
40+
default: { groupIdentify: jest.fn() },
41+
}));
42+
43+
jest.unstable_mockModule('../lib/events.js', () => ({
44+
default: { on: jest.fn(), emit: jest.fn() },
45+
}));
46+
47+
const mod = await import('../billing.init.js');
48+
billingInit = mod.default;
49+
});
50+
51+
afterEach(() => {
52+
jest.restoreAllMocks();
53+
});
54+
55+
test('ensureSeeded is called at init when meterMode=false', async () => {
56+
await billingInit(mockApp);
57+
expect(mockBillingPlanService.ensureSeeded).toHaveBeenCalledTimes(1);
58+
});
59+
60+
test('ensureSeeded failure is swallowed when meterMode=false', async () => {
61+
mockBillingPlanService.ensureSeeded.mockRejectedValue(new Error('DB error'));
62+
63+
// Should not throw — meterMode=false means graceful degradation
64+
await expect(billingInit(mockApp)).resolves.toBeUndefined();
65+
});
66+
67+
test('ensureSeeded failure re-throws when meterMode=true (fail-fast)', async () => {
68+
mockConfig.billing.meterMode = true;
69+
mockBillingPlanService.ensureSeeded.mockRejectedValue(new Error('seed failure'));
70+
71+
await expect(billingInit(mockApp)).rejects.toThrow('seed failure');
72+
});
73+
74+
test('ensureSeeded success with seeded>0 logs info and resolves', async () => {
75+
mockBillingPlanService.ensureSeeded.mockResolvedValue({ seeded: 2, skipped: 1 });
76+
const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
77+
78+
await billingInit(mockApp);
79+
80+
expect(infoSpy).toHaveBeenCalledWith(
81+
expect.stringContaining('seeded 2 plan(s)'),
82+
);
83+
});
84+
});

0 commit comments

Comments
 (0)