Skip to content

Commit 031f8e1

Browse files
refactor(billing): planDefinitions array shape — single source of truth (#3560) (#3562)
* refactor(billing): planDefinitions array shape — single source of truth Convert planDefinitions to array of self-contained { planId, ... } objects. Derive billing.plans enum at boot in config/index.js with backward-compat shim (deprecation warning). Add meterMode-gated boot validator on Subscription.distinct('plan'). 5 new shim tests + 2 new validator tests + 5 ensureSeeded mocks updated. Migration doc added. Closes #3560 * fix(billing): key-wins planId precedence in normalizePlanDefinitions; add regression test - { ...def, planId } ensures the object key is always authoritative (was { planId, ...def }) - Guard null/undefined def with `?? {}` to prevent spread throwing - Filter non-string/empty planIds when deriving config.billing.plans enum - Update JSDoc @returns to accurately reflect passthrough behavior for non-object values - Add regression test: object key beats nested def.planId on conflict
1 parent 77e895e commit 031f8e1

8 files changed

Lines changed: 319 additions & 40 deletions

File tree

config/index.js

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,43 @@ const initGlobalConfig = async () => {
199199
return conf;
200200
};
201201

202+
/**
203+
* Normalize billing.planDefinitions to the canonical array-of-objects shape.
204+
*
205+
* Accepts either:
206+
* - Array (new shape): returned as-is.
207+
* - Plain object (legacy shape): converted to array and a deprecation warning is emitted.
208+
* The object key is always authoritative as planId; any nested planId value is overridden.
209+
*
210+
* Safe when planDefinitions is missing/null — returns the input unchanged.
211+
* Non-object, non-array values (e.g. strings) are returned unchanged.
212+
*
213+
* @param {Array|Object|null|undefined} planDefinitions
214+
* @returns {Array|Object|null|undefined} Array form when convertible; original value otherwise.
215+
*/
216+
const normalizePlanDefinitions = (planDefinitions) => {
217+
if (!planDefinitions) return planDefinitions;
218+
if (Array.isArray(planDefinitions)) return planDefinitions;
219+
if (typeof planDefinitions === 'object') {
220+
console.warn(
221+
'[billing] planDefinitions object shape is deprecated, switch to array — see docs/migrations/2026-05-01-billing-plan-definitions-array.md. Will be removed ~2026-07.',
222+
);
223+
return Object.entries(planDefinitions).map(([planId, def]) => ({ ...(def ?? {}), planId }));
224+
}
225+
return planDefinitions;
226+
};
227+
202228
const config = await initGlobalConfig();
203-
export { deepMerge, assertSafeEnv };
229+
230+
// Post-merge billing normalization: shim legacy planDefinitions + derive plans enum.
231+
if (config.billing?.planDefinitions != null) {
232+
config.billing.planDefinitions = normalizePlanDefinitions(config.billing.planDefinitions);
233+
if (Array.isArray(config.billing.planDefinitions)) {
234+
config.billing.plans = config.billing.planDefinitions
235+
.map((p) => p.planId)
236+
.filter((id) => typeof id === 'string' && id.length > 0);
237+
}
238+
}
239+
240+
export { deepMerge, assertSafeEnv, normalizePlanDefinitions };
204241
export default config;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Migration: billing.planDefinitions array shape (2026-05-01)
2+
3+
## Why
4+
5+
During PR-N6 (#3555), the `enterprise` plan was present in `billing.plans` but missing from `billing.planDefinitions`. This silent divergence would have caused `ensureSeeded()` to skip seeding the enterprise plan, resulting in a `null` return from `getActivePlan('enterprise')` and a 503 for all metered enterprise users.
6+
7+
The root cause: `plans` (enum array) and `planDefinitions` (definition map) were two separate declarations that had to be kept in sync manually. The fix is to make `planDefinitions` the single source of truth — `plans` is now derived at boot.
8+
9+
## Before / After
10+
11+
**Before (object-keyed — deprecated):**
12+
13+
```js
14+
billing: {
15+
plans: ['free', 'starter', 'pro', 'enterprise'], // must match planDefinitions keys — drift risk
16+
planDefinitions: {
17+
free: { meterQuota: 0, ratios: { default: 1 } },
18+
starter: { meterQuota: 50000, ratios: { default: 1 } },
19+
pro: { meterQuota: 500000, ratios: { default: 1 } },
20+
enterprise: { meterQuota: 2000000, ratios: { default: 1 } },
21+
},
22+
}
23+
```
24+
25+
**After (array — canonical):**
26+
27+
```js
28+
billing: {
29+
// plans: derived at boot from planDefinitions — do NOT declare manually
30+
planDefinitions: [
31+
{ planId: 'free', meterQuota: 0, ratios: { default: 1 } },
32+
{ planId: 'starter', meterQuota: 50000, ratios: { default: 1 } },
33+
{ planId: 'pro', meterQuota: 500000, ratios: { default: 1 } },
34+
{ planId: 'enterprise', meterQuota: 2000000, ratios: { default: 1 } },
35+
],
36+
}
37+
```
38+
39+
`config.billing.plans` is still available at runtime (derived by `config/index.js`) — `z.enum(config.billing.plans)` and all Zod/Mongoose schemas keep working unchanged.
40+
41+
## Downstream migration steps
42+
43+
For each downstream project that overrides `billing.planDefinitions` (e.g. `modules/billing/config/billing.{project}.config.js`):
44+
45+
1. Pull the latest devkit: `npm run update-stack` (or merge the upstream devkit branch).
46+
2. Open `modules/billing/config/billing.{project}.config.js`.
47+
3. Convert `planDefinitions` from object to array form (see Before/After above). Remove the `plans: [...]` line — leaving it is harmless (the derivation in `config/index.js` runs after deepMerge and overwrites it), but remove it to avoid confusion.
48+
4. Run unit tests: `NODE_ENV={project} npm run test:unit -- billing`.
49+
5. Smoke test the plans endpoint: `curl https://api.{project}.example.com/api/billing/plans`.
50+
51+
No database migration is required — the `BillingPlan` collection is unchanged.
52+
53+
## Validation
54+
55+
After migration, confirm:
56+
57+
- Boot logs show **no** `[billing] planDefinitions object shape is deprecated` warning (shim did not trigger).
58+
- `NODE_ENV={project} npm run test:unit -- billing` passes.
59+
- `[billing] Subscription.plan value "..." not in planDefinitions` warning is **absent** in boot logs (no orphaned subscriptions).
60+
61+
## Shim removal timeline
62+
63+
The backward-compat shim in `config/index.js` converts legacy object-keyed `planDefinitions` to array form with a `console.warn`. It will be removed **~2026-07-01**, contingent on:
64+
65+
- Zero deprecation warnings observed in any downstream prod log for ≥ 30 days.
66+
- All downstream projects confirmed migrated to array form.
67+
68+
Track removal in a follow-up issue once all downstreams are migrated.

modules/billing/billing.init.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Module dependencies
33
*/
4+
import mongoose from 'mongoose';
45
import config from '../../config/index.js';
56
import AnalyticsService from '../../lib/services/analytics.js';
67
import billingEvents from './lib/events.js';
@@ -43,4 +44,22 @@ export default async (app) => {
4344
// Surfacing the crash here prevents a deploy from succeeding in a broken state.
4445
if (config?.billing?.meterMode) throw err;
4546
}
47+
48+
// Boot validator: warn on orphaned Subscription.plan values (meterMode only).
49+
// Runs after ensureSeeded so the plan catalog is up to date.
50+
// Never crashes boot — wrapped in try/catch.
51+
if (config?.billing?.meterMode) {
52+
try {
53+
const Subscription = mongoose.model('Subscription');
54+
const knownPlans = new Set(config.billing.plans ?? []);
55+
const distinctPlans = await Subscription.distinct('plan');
56+
for (const plan of distinctPlans) {
57+
if (!knownPlans.has(plan)) {
58+
console.warn(`[billing] Subscription.plan value "${plan}" not in planDefinitions — orphaned plan, may resolve quota=0`);
59+
}
60+
}
61+
} catch (_err) {
62+
// Validator failure must NOT crash boot (e.g. model not yet registered at early init)
63+
}
64+
}
4665
};

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ const config = {
66
},
77
billing: {
88
activated: true,
9-
// Plans available for subscriptions — extend as needed
10-
plans: ['free', 'starter', 'pro', 'enterprise'],
119
// Quotas — downstream projects override these per plan:
1210
// quotas: {
1311
// free: { documents: { create: 10, export: 50 } },
@@ -49,15 +47,16 @@ const config = {
4947
/**
5048
* Plan definitions — DOWNSTREAM-OVERRIDE-REQUIRED for meter mode.
5149
* Used by BillingPlanService.ensureSeeded() at boot to upsert BillingPlan docs.
52-
* Each entry: { meterQuota: units/week, ratios: { featureKey: multiplier } }.
53-
* Plans listed here must also exist in billing.plans enum.
50+
* Array of objects: { planId, meterQuota: units/week, ratios: { featureKey: multiplier } }.
51+
* billing.plans enum is derived at boot from planDefinitions.map(p => p.planId) — do NOT
52+
* declare billing.plans manually. This is the single source of truth for plan identifiers.
5453
*/
55-
planDefinitions: {
56-
free: { meterQuota: 0, ratios: { default: 1 } },
57-
starter: { meterQuota: 50000, ratios: { default: 1 } },
58-
pro: { meterQuota: 500000, ratios: { default: 1 } },
59-
enterprise: { meterQuota: 2000000, ratios: { default: 1 } },
60-
},
54+
planDefinitions: [
55+
{ planId: 'free', meterQuota: 0, ratios: { default: 1 } },
56+
{ planId: 'starter', meterQuota: 50000, ratios: { default: 1 } },
57+
{ planId: 'pro', meterQuota: 500000, ratios: { default: 1 } },
58+
{ planId: 'enterprise', meterQuota: 2000000, ratios: { default: 1 } },
59+
],
6160
/**
6261
* Meter unit parameters — downstream projects must override with their
6362
* actual unit economics before enabling meterMode in production.

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,14 @@ const bumpVersionWithRetry = async (planId, fields, { maxAttempts = 3 } = {}) =>
165165
/**
166166
* @function ensureSeeded
167167
* @description Upsert BillingPlan docs from config.billing.planDefinitions.
168-
* For each configured planId, ensures an active plan exists.
168+
* For each configured plan entry, ensures an active plan exists.
169169
* No-op when meter mode is disabled. Idempotent on re-run.
170170
*
171+
* Accepts the canonical array-of-objects shape:
172+
* [{ planId, meterQuota, ratios }, ...].
173+
* The legacy object shape is normalized upstream in config/index.js
174+
* before this service is ever called.
175+
*
171176
* Race / E11000 safety: version is derived from the total count of
172177
* existing docs for the planId (same strategy as bumpVersion). A
173178
* try-catch on create absorbs E11000 from concurrent multi-pod
@@ -179,11 +184,12 @@ const bumpVersionWithRetry = async (planId, fields, { maxAttempts = 3 } = {}) =>
179184
const ensureSeeded = async () => {
180185
if (!config?.billing?.meterMode) return { seeded: 0, skipped: 0 };
181186

182-
const definitions = config?.billing?.planDefinitions ?? {};
187+
const definitions = config?.billing?.planDefinitions ?? [];
183188
let seeded = 0;
184189
let skipped = 0;
185190

186-
for (const [planId, def] of Object.entries(definitions)) {
191+
for (const def of definitions) {
192+
const { planId, ...planDef } = def;
187193
const existing = await BillingPlanRepository.findActive(planId);
188194
if (existing) {
189195
skipped += 1;
@@ -199,8 +205,8 @@ const ensureSeeded = async () => {
199205
await BillingPlanRepository.create({
200206
planId,
201207
version,
202-
meterQuota: def.meterQuota ?? 0,
203-
ratios: def.ratios ?? { default: 1 },
208+
meterQuota: planDef.meterQuota ?? 0,
209+
ratios: planDef.ratios ?? { default: 1 },
204210
effectiveFrom: new Date(),
205211
effectiveUntil: null,
206212
active: true,

modules/billing/tests/billing.init.unit.tests.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals';
55

66
/**
7-
* Unit tests for billing.init ensureSeeded integration.
7+
* Unit tests for billing.init ensureSeeded integration and boot validator.
88
*/
9-
describe('billing.init ensureSeeded unit tests:', () => {
9+
describe('billing.init unit tests:', () => {
1010
let billingInit;
1111
let mockBillingPlanService;
1212
let mockConfig;
13+
let mockDistinct;
14+
let mockMongoose;
1315

1416
const mockApp = {};
1517

@@ -27,6 +29,11 @@ describe('billing.init ensureSeeded unit tests:', () => {
2729
ensureSeeded: jest.fn().mockResolvedValue({ seeded: 0, skipped: 0 }),
2830
};
2931

32+
mockDistinct = jest.fn().mockResolvedValue([]);
33+
mockMongoose = {
34+
model: jest.fn().mockReturnValue({ distinct: mockDistinct }),
35+
};
36+
3037
jest.unstable_mockModule('../../../config/index.js', () => ({
3138
default: mockConfig,
3239
}));
@@ -44,6 +51,10 @@ describe('billing.init ensureSeeded unit tests:', () => {
4451
default: { on: jest.fn(), emit: jest.fn() },
4552
}));
4653

54+
jest.unstable_mockModule('mongoose', () => ({
55+
default: mockMongoose,
56+
}));
57+
4758
const mod = await import('../billing.init.js');
4859
billingInit = mod.default;
4960
});
@@ -81,4 +92,37 @@ describe('billing.init ensureSeeded unit tests:', () => {
8192
expect.stringContaining('seeded 2 plan(s)'),
8293
);
8394
});
95+
96+
test('boot validator warns on orphaned Subscription.plan values when meterMode=true', async () => {
97+
mockConfig.billing.meterMode = true;
98+
mockConfig.billing.plans = ['free', 'starter', 'pro'];
99+
100+
// Stub distinct to return a known plan + an orphaned plan
101+
mockDistinct.mockResolvedValue(['free', 'legacy_plan']);
102+
103+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
104+
105+
await billingInit(mockApp);
106+
107+
expect(mockDistinct).toHaveBeenCalledWith('plan');
108+
expect(warnSpy).toHaveBeenCalledWith(
109+
expect.stringContaining('"legacy_plan" not in planDefinitions'),
110+
);
111+
// Known plan 'free' must NOT trigger a warning
112+
const warnings = warnSpy.mock.calls.map((c) => c[0]);
113+
expect(warnings.some((w) => w.includes('"free"'))).toBe(false);
114+
});
115+
116+
test('boot validator failure does not crash boot', async () => {
117+
mockConfig.billing.meterMode = true;
118+
mockConfig.billing.plans = ['free'];
119+
120+
// Subscription model throws (e.g. not yet registered at early init)
121+
mockMongoose.model.mockImplementation(() => {
122+
throw new Error('model not registered');
123+
});
124+
125+
// Must resolve without throwing
126+
await expect(billingInit(mockApp)).resolves.toBeUndefined();
127+
});
84128
});

modules/billing/tests/billing.plan.ensureSeeded.unit.tests.js

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('BillingPlanService.ensureSeeded unit tests:', () => {
1717
mockConfig = {
1818
billing: {
1919
meterMode: true,
20-
planDefinitions: {},
20+
planDefinitions: [],
2121
},
2222
};
2323

@@ -55,7 +55,7 @@ describe('BillingPlanService.ensureSeeded unit tests:', () => {
5555
expect(mockBillingPlanRepository.create).not.toHaveBeenCalled();
5656
});
5757

58-
test('meterMode=true with empty planDefinitions returns zeroes', async () => {
58+
test('meterMode=true with empty planDefinitions array returns zeroes', async () => {
5959
const result = await BillingPlanService.ensureSeeded();
6060

6161
expect(result).toEqual({ seeded: 0, skipped: 0 });
@@ -64,11 +64,11 @@ describe('BillingPlanService.ensureSeeded unit tests:', () => {
6464
});
6565

6666
test('meterMode=true with 3 planDefinitions and none existing seeds 3 plans', async () => {
67-
mockConfig.billing.planDefinitions = {
68-
free: { meterQuota: 0, ratios: { default: 1 } },
69-
starter: { meterQuota: 50000, ratios: { default: 1 } },
70-
pro: { meterQuota: 500000, ratios: { default: 2 } },
71-
};
67+
mockConfig.billing.planDefinitions = [
68+
{ planId: 'free', meterQuota: 0, ratios: { default: 1 } },
69+
{ planId: 'starter', meterQuota: 50000, ratios: { default: 1 } },
70+
{ planId: 'pro', meterQuota: 500000, ratios: { default: 2 } },
71+
];
7272
mockBillingPlanRepository.findActive.mockResolvedValue(null);
7373
// count=0 → version 'v1' for each plan
7474
mockBillingPlanRepository.count.mockResolvedValue(0);
@@ -87,11 +87,11 @@ describe('BillingPlanService.ensureSeeded unit tests:', () => {
8787
});
8888

8989
test('meterMode=true with 3 planDefinitions and 1 existing seeds 2 and skips 1', async () => {
90-
mockConfig.billing.planDefinitions = {
91-
free: { meterQuota: 0, ratios: { default: 1 } },
92-
starter: { meterQuota: 50000, ratios: { default: 1 } },
93-
pro: { meterQuota: 500000, ratios: { default: 1 } },
94-
};
90+
mockConfig.billing.planDefinitions = [
91+
{ planId: 'free', meterQuota: 0, ratios: { default: 1 } },
92+
{ planId: 'starter', meterQuota: 50000, ratios: { default: 1 } },
93+
{ planId: 'pro', meterQuota: 500000, ratios: { default: 1 } },
94+
];
9595
mockBillingPlanRepository.findActive
9696
.mockResolvedValueOnce({ planId: 'free', version: 'v1' })
9797
.mockResolvedValueOnce(null)
@@ -114,9 +114,9 @@ describe('BillingPlanService.ensureSeeded unit tests:', () => {
114114
});
115115

116116
test('count > 0 yields next version string correctly', async () => {
117-
mockConfig.billing.planDefinitions = {
118-
pro: { meterQuota: 500000, ratios: { default: 1 } },
119-
};
117+
mockConfig.billing.planDefinitions = [
118+
{ planId: 'pro', meterQuota: 500000, ratios: { default: 1 } },
119+
];
120120
mockBillingPlanRepository.findActive.mockResolvedValue(null);
121121
// Existing inactive v1 is present (total count = 1)
122122
mockBillingPlanRepository.count.mockResolvedValue(1);
@@ -131,10 +131,10 @@ describe('BillingPlanService.ensureSeeded unit tests:', () => {
131131
});
132132

133133
test('E11000 on create is absorbed as a skip (concurrent pod race)', async () => {
134-
mockConfig.billing.planDefinitions = {
135-
free: { meterQuota: 0, ratios: { default: 1 } },
136-
pro: { meterQuota: 500000, ratios: { default: 1 } },
137-
};
134+
mockConfig.billing.planDefinitions = [
135+
{ planId: 'free', meterQuota: 0, ratios: { default: 1 } },
136+
{ planId: 'pro', meterQuota: 500000, ratios: { default: 1 } },
137+
];
138138
mockBillingPlanRepository.findActive.mockResolvedValue(null);
139139
mockBillingPlanRepository.count.mockResolvedValue(0);
140140
// First create throws E11000; second succeeds
@@ -147,9 +147,9 @@ describe('BillingPlanService.ensureSeeded unit tests:', () => {
147147
});
148148

149149
test('non-E11000 error on create propagates and aborts', async () => {
150-
mockConfig.billing.planDefinitions = {
151-
pro: { meterQuota: 500000, ratios: { default: 1 } },
152-
};
150+
mockConfig.billing.planDefinitions = [
151+
{ planId: 'pro', meterQuota: 500000, ratios: { default: 1 } },
152+
];
153153
mockBillingPlanRepository.findActive.mockResolvedValue(null);
154154
mockBillingPlanRepository.count.mockResolvedValue(0);
155155
mockBillingPlanRepository.create.mockRejectedValue(new Error('DB connection lost'));

0 commit comments

Comments
 (0)