Skip to content

Commit 5a113f9

Browse files
feat(billing): add signupGrant + oneShot config fields to Free plan (#3661)
* feat(billing): add signupGrant + oneShot config fields to Free plan Adds N2 one-shot signup grant fields to BillingPlan config (Task 1/7). - billing.development.config.js: Free plan signupGrant:500, oneShot:true - billing.config.zod.js (new): Zod schema for planDefinitions entries - billing.plan.service.js: getPlanFromConfig passes through new fields - Tests: planDefinitions unit tests + plan.service signupGrant passthrough * fix(billing): strengthen planDefinitions tests + safe ratios default - test: use actual devkit planIds (starter/pro/enterprise) so paid-plan signupGrant absence test cannot pass vacuously on missing plan IDs - zod: ratios default(() => ({})) to prevent shared-reference mutation * fix(billing): tighten signupGrant schema — positive() + co-presence refine - signupGrant uses .positive() (zero grant is a no-op, disallow it) - .refine() enforces signupGrant + oneShot must be defined together - tests: add zero/orphan-oneShot/orphan-signupGrant rejection cases * fix(billing): runtime co-presence guard for signupGrant+oneShot in plan service Addresses Phase 0 gate [high]: Zod schema refine() was test-only. Add logger.warn in getPlanFromConfig when signupGrant/oneShot are not both defined, catching misconfigured planDefinitions at runtime. Tests cover both asymmetric cases + the symmetric OK paths. * fix(billing): add getSignupGrant export + harden co-presence guard Phase 0 gate [critical]: export getSignupGrant(planId) helper from billing.plan.service.js Phase 0 gate [medium]: co-presence guard returns plan without partial fields on mismatch Phase 0 gate [low]: rename hasGrant/hasOneShot → hasSignupGrant/hasOneShotFlag for clarity Add tests: getSignupGrant returns 500 for free, undefined for pro/unknown; co-presence guard early-return drops lone signupGrant or oneShot * fix(billing): mock plan.service in quota test suite to prevent logger load billing.plan.service.js now imports logger at module load time. Logger reads config.log.fileLogger on init — absent from the quota test's minimal billing-only config fixture → TypeError on all 40 tests. Fix: add jest.unstable_mockModule for billing.plan.service.js in the first describe block (which never needed the real service anyway). The second describe block already mocked it at line 295.
1 parent 25db1be commit 5a113f9

6 files changed

Lines changed: 338 additions & 4 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import { z } from 'zod';
5+
6+
/**
7+
* @desc Zod schema for a single entry in config.billing.planDefinitions.
8+
*
9+
* Required fields:
10+
* planId — logical plan identifier (e.g. "free", "growth", "pro")
11+
* meterQuota — compute units per reset period (0 = no periodic quota)
12+
* ratios — feature multiplier map (e.g. { default: 1, autofix: 2 })
13+
*
14+
* Optional fields (N2 signup-grant):
15+
* signupGrant — one-time credit granted to fresh orgs at signup (positive integer).
16+
* Must be present when oneShot is set.
17+
* oneShot — when true the grant does not renew on weekly/monthly reset.
18+
* Must be present when signupGrant is set.
19+
* version — plan version string (YYYY.MM or v${N}); falls back to meter.ratioVersion
20+
*/
21+
const billingPlanDefinitionSchema = z
22+
.object({
23+
planId: z.string().min(1),
24+
meterQuota: z.number().int().nonnegative(),
25+
ratios: z.record(z.string(), z.number()).default(() => ({})),
26+
version: z.string().optional(),
27+
signupGrant: z.number().int().positive().optional(),
28+
oneShot: z.boolean().optional(),
29+
})
30+
.refine(
31+
(data) => (data.signupGrant !== undefined) === (data.oneShot !== undefined),
32+
{
33+
message:
34+
'signupGrant and oneShot must be defined together — set both or neither',
35+
path: ['signupGrant'],
36+
},
37+
);
38+
39+
export { billingPlanDefinitionSchema };

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,20 @@ const config = {
4747
/**
4848
* Plan definitions — DOWNSTREAM-OVERRIDE-REQUIRED for meter mode.
4949
* Used by BillingPlanService.ensureSeeded() at boot to upsert BillingPlan docs.
50-
* Array of objects: { planId, meterQuota: units/week, ratios: { featureKey: multiplier }, version? }.
50+
* Array of objects: { planId, meterQuota: units/week, ratios: { featureKey: multiplier }, version?, signupGrant?, oneShot? }.
5151
* billing.plans enum is derived at boot from planDefinitions.map(p => p.planId) — do NOT
5252
* declare billing.plans manually. This is the single source of truth for plan identifiers.
5353
*
5454
* version: optional — falls back to billing.meter.ratioVersion, then v${count + 1}.
5555
* Downstream projects that use YYYY.MM versioning should set this (or set ratioVersion).
5656
*/
5757
planDefinitions: [
58-
{ planId: 'free', meterQuota: 0, ratios: { default: 1 } },
58+
/**
59+
* signupGrant: one-time credit given to fresh orgs at signup (N2 feature).
60+
* oneShot: true = grant does not renew on weekly/monthly reset.
61+
* DOWNSTREAM-OVERRIDE: set meterQuota + signupGrant per project's actual unit economics.
62+
*/
63+
{ planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: { default: 1 } },
5964
{ planId: 'starter', meterQuota: 50000, ratios: { default: 1 } },
6065
{ planId: 'pro', meterQuota: 500000, ratios: { default: 1 } },
6166
{ planId: 'enterprise', meterQuota: 2000000, ratios: { default: 1 } },

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
* Module dependencies
33
*/
44
import config from '../../../config/index.js';
5+
import logger from '../../../lib/services/logger.js';
56

67
/**
78
* @desc Resolve a plan definition from config.billing.planDefinitions.
89
* Returns a plan-like object compatible with the previous DB-backed API.
910
*
1011
* @param {string} planId - The logical plan identifier (e.g. "pro").
11-
* @returns {Object|null} Plan object with meterQuota, ratios, version fields, or null.
12+
* @returns {Object|null} Plan object with meterQuota, ratios, version, and optional
13+
* signupGrant / oneShot fields (N2 signup-grant feature), or null.
1214
*/
1315
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
1416
const getPlanFromConfig = (planId) => {
@@ -21,12 +23,30 @@ const getPlanFromConfig = (planId) => {
2123
?? config?.billing?.meter?.ratioVersion
2224
?? 'v1';
2325

24-
return {
26+
const plan = {
2527
planId: def.planId,
2628
version,
2729
meterQuota: def.meterQuota ?? 0,
2830
ratios: def.ratios ?? {},
2931
};
32+
33+
// N2 signup-grant fields — only present when configured (optional on the def).
34+
// Guard: signupGrant and oneShot must be defined together (co-presence invariant).
35+
// This mirrors the Zod schema refine() and catches misconfigured planDefinitions at runtime.
36+
const hasSignupGrant = def.signupGrant !== undefined;
37+
const hasOneShotFlag = def.oneShot !== undefined;
38+
if (hasSignupGrant !== hasOneShotFlag) {
39+
logger.warn(
40+
'[billing.plan] signupGrant and oneShot must be defined together — check planDefinitions config',
41+
{ planId: def.planId, signupGrant: def.signupGrant, oneShot: def.oneShot },
42+
);
43+
// Do not attach partial/broken grant fields — return plan without them.
44+
return plan;
45+
}
46+
if (hasSignupGrant) plan.signupGrant = def.signupGrant;
47+
if (hasOneShotFlag) plan.oneShot = def.oneShot;
48+
49+
return plan;
3050
};
3151

3252
/**
@@ -57,7 +77,22 @@ const getPlanByVersion = (planId, version) => {
5777
return plan;
5878
};
5979

80+
/**
81+
* @desc Get the signup grant amount for a given planId (N2 Free Tier Grant feature).
82+
* Returns the signupGrant value if the plan definition has it, or undefined.
83+
* Callers must handle undefined as "no grant configured for this plan".
84+
*
85+
* @param {string} planId - The logical plan identifier (e.g. "free").
86+
* @returns {number|undefined} The signupGrant value, or undefined.
87+
*/
88+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
89+
const getSignupGrant = (planId) => {
90+
const plan = getPlanFromConfig(planId);
91+
return plan?.signupGrant;
92+
};
93+
6094
export default {
6195
getActivePlan,
6296
getPlanByVersion,
97+
getSignupGrant,
6398
};
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, it, beforeEach, afterEach, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for planDefinitions config shape — validates signupGrant / oneShot fields.
8+
*/
9+
describe('billing planDefinitions config:', () => {
10+
let config;
11+
12+
beforeEach(async () => {
13+
jest.resetModules();
14+
const mod = await import('../../../config/index.js');
15+
config = mod.default;
16+
});
17+
18+
afterEach(() => {
19+
jest.restoreAllMocks();
20+
});
21+
22+
describe('planDefinitions — Free signup grant', () => {
23+
it('Free plan has signupGrant: 500 and oneShot: true', () => {
24+
const definitions = config?.billing?.planDefinitions ?? [];
25+
const free = definitions.find((p) => p.planId === 'free');
26+
expect(free).toBeDefined();
27+
expect(free.signupGrant).toBe(500);
28+
expect(free.oneShot).toBe(true);
29+
expect(free.meterQuota).toBe(0);
30+
});
31+
32+
it('Paid plans (starter, pro, enterprise) do not have signupGrant', () => {
33+
const definitions = config?.billing?.planDefinitions ?? [];
34+
const paidPlanIds = ['starter', 'pro', 'enterprise'];
35+
for (const planId of paidPlanIds) {
36+
const plan = definitions.find((p) => p.planId === planId);
37+
// Assert the plan exists so this test cannot pass vacuously if IDs change
38+
expect(plan).toBeDefined();
39+
expect(plan.signupGrant).toBeUndefined();
40+
expect(plan.oneShot).toBeUndefined();
41+
}
42+
});
43+
44+
it('Free plan signupGrant is a positive integer (> 0)', () => {
45+
const definitions = config?.billing?.planDefinitions ?? [];
46+
const free = definitions.find((p) => p.planId === 'free');
47+
expect(free).toBeDefined();
48+
expect(Number.isInteger(free.signupGrant)).toBe(true);
49+
expect(free.signupGrant).toBeGreaterThan(0);
50+
});
51+
52+
it('Free plan oneShot is a boolean', () => {
53+
const definitions = config?.billing?.planDefinitions ?? [];
54+
const free = definitions.find((p) => p.planId === 'free');
55+
expect(free).toBeDefined();
56+
expect(typeof free.oneShot).toBe('boolean');
57+
});
58+
});
59+
60+
describe('planDefinitions Zod schema validation', () => {
61+
it('validates a plan entry with signupGrant and oneShot via billingPlanDefinitionSchema', async () => {
62+
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
63+
const result = billingPlanDefinitionSchema.safeParse({
64+
planId: 'free',
65+
meterQuota: 0,
66+
signupGrant: 500,
67+
oneShot: true,
68+
ratios: { default: 1 },
69+
});
70+
expect(result.success).toBe(true);
71+
});
72+
73+
it('validates a plan entry without signupGrant and oneShot (optional fields)', async () => {
74+
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
75+
const result = billingPlanDefinitionSchema.safeParse({
76+
planId: 'growth',
77+
meterQuota: 1600,
78+
ratios: { default: 1 },
79+
});
80+
expect(result.success).toBe(true);
81+
});
82+
83+
it('rejects negative signupGrant', async () => {
84+
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
85+
const result = billingPlanDefinitionSchema.safeParse({
86+
planId: 'free',
87+
meterQuota: 0,
88+
signupGrant: -1,
89+
oneShot: true,
90+
ratios: {},
91+
});
92+
expect(result.success).toBe(false);
93+
});
94+
95+
it('rejects zero signupGrant (meaningless no-op grant)', async () => {
96+
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
97+
const result = billingPlanDefinitionSchema.safeParse({
98+
planId: 'free',
99+
meterQuota: 0,
100+
signupGrant: 0,
101+
oneShot: true,
102+
ratios: {},
103+
});
104+
expect(result.success).toBe(false);
105+
});
106+
107+
it('rejects non-integer signupGrant', async () => {
108+
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
109+
const result = billingPlanDefinitionSchema.safeParse({
110+
planId: 'free',
111+
meterQuota: 0,
112+
signupGrant: 500.5,
113+
oneShot: true,
114+
ratios: {},
115+
});
116+
expect(result.success).toBe(false);
117+
});
118+
119+
it('rejects oneShot without signupGrant', async () => {
120+
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
121+
const result = billingPlanDefinitionSchema.safeParse({
122+
planId: 'free',
123+
meterQuota: 0,
124+
oneShot: true,
125+
ratios: {},
126+
});
127+
expect(result.success).toBe(false);
128+
});
129+
130+
it('rejects signupGrant without oneShot', async () => {
131+
const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js');
132+
const result = billingPlanDefinitionSchema.safeParse({
133+
planId: 'free',
134+
meterQuota: 0,
135+
signupGrant: 500,
136+
ratios: {},
137+
});
138+
expect(result.success).toBe(false);
139+
});
140+
});
141+
});

0 commit comments

Comments
 (0)