From 39be42277a112187f3521b91892d2d4383cba5d7 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 11 May 2026 19:14:32 +0200 Subject: [PATCH 1/6] 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 --- modules/billing/config/billing.config.zod.js | 28 +++++ .../config/billing.development.config.js | 9 +- .../billing/services/billing.plan.service.js | 11 +- ...lling.config.planDefinitions.unit.tests.js | 101 ++++++++++++++++++ .../tests/billing.plan.service.unit.tests.js | 18 ++++ 5 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 modules/billing/config/billing.config.zod.js create mode 100644 modules/billing/tests/billing.config.planDefinitions.unit.tests.js diff --git a/modules/billing/config/billing.config.zod.js b/modules/billing/config/billing.config.zod.js new file mode 100644 index 000000000..551871150 --- /dev/null +++ b/modules/billing/config/billing.config.zod.js @@ -0,0 +1,28 @@ +/** + * Module dependencies + */ +import { z } from 'zod'; + +/** + * @desc Zod schema for a single entry in config.billing.planDefinitions. + * + * Required fields: + * planId — logical plan identifier (e.g. "free", "growth", "pro") + * meterQuota — compute units per reset period (0 = no periodic quota) + * ratios — feature multiplier map (e.g. { default: 1, autofix: 2 }) + * + * Optional fields (N2 signup-grant): + * signupGrant — one-time credit amount given on org creation (non-negative integer) + * oneShot — when true the grant does not renew on weekly/monthly reset + * version — plan version string (YYYY.MM or v${N}); falls back to meter.ratioVersion + */ +const billingPlanDefinitionSchema = z.object({ + planId: z.string().min(1), + meterQuota: z.number().int().nonnegative(), + ratios: z.record(z.string(), z.number()).default({}), + version: z.string().optional(), + signupGrant: z.number().int().nonnegative().optional(), + oneShot: z.boolean().optional(), +}); + +export { billingPlanDefinitionSchema }; diff --git a/modules/billing/config/billing.development.config.js b/modules/billing/config/billing.development.config.js index a7233358e..964b65ffe 100644 --- a/modules/billing/config/billing.development.config.js +++ b/modules/billing/config/billing.development.config.js @@ -47,7 +47,7 @@ const config = { /** * Plan definitions — DOWNSTREAM-OVERRIDE-REQUIRED for meter mode. * Used by BillingPlanService.ensureSeeded() at boot to upsert BillingPlan docs. - * Array of objects: { planId, meterQuota: units/week, ratios: { featureKey: multiplier }, version? }. + * Array of objects: { planId, meterQuota: units/week, ratios: { featureKey: multiplier }, version?, signupGrant?, oneShot? }. * billing.plans enum is derived at boot from planDefinitions.map(p => p.planId) — do NOT * declare billing.plans manually. This is the single source of truth for plan identifiers. * @@ -55,7 +55,12 @@ const config = { * Downstream projects that use YYYY.MM versioning should set this (or set ratioVersion). */ planDefinitions: [ - { planId: 'free', meterQuota: 0, ratios: { default: 1 } }, + /** + * signupGrant: one-time credit given to fresh orgs at signup (N2 feature). + * oneShot: true = grant does not renew on weekly/monthly reset. + * DOWNSTREAM-OVERRIDE: set meterQuota + signupGrant per project's actual unit economics. + */ + { planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: { default: 1 } }, { planId: 'starter', meterQuota: 50000, ratios: { default: 1 } }, { planId: 'pro', meterQuota: 500000, ratios: { default: 1 } }, { planId: 'enterprise', meterQuota: 2000000, ratios: { default: 1 } }, diff --git a/modules/billing/services/billing.plan.service.js b/modules/billing/services/billing.plan.service.js index a85a9e2cb..2c0d698a1 100644 --- a/modules/billing/services/billing.plan.service.js +++ b/modules/billing/services/billing.plan.service.js @@ -8,7 +8,8 @@ import config from '../../../config/index.js'; * Returns a plan-like object compatible with the previous DB-backed API. * * @param {string} planId - The logical plan identifier (e.g. "pro"). - * @returns {Object|null} Plan object with meterQuota, ratios, version fields, or null. + * @returns {Object|null} Plan object with meterQuota, ratios, version, and optional + * signupGrant / oneShot fields (N2 signup-grant feature), or null. */ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik const getPlanFromConfig = (planId) => { @@ -21,12 +22,18 @@ const getPlanFromConfig = (planId) => { ?? config?.billing?.meter?.ratioVersion ?? 'v1'; - return { + const plan = { planId: def.planId, version, meterQuota: def.meterQuota ?? 0, ratios: def.ratios ?? {}, }; + + // N2 signup-grant fields — only present when configured (optional on the def) + if (def.signupGrant !== undefined) plan.signupGrant = def.signupGrant; + if (def.oneShot !== undefined) plan.oneShot = def.oneShot; + + return plan; }; /** diff --git a/modules/billing/tests/billing.config.planDefinitions.unit.tests.js b/modules/billing/tests/billing.config.planDefinitions.unit.tests.js new file mode 100644 index 000000000..0a3c6463f --- /dev/null +++ b/modules/billing/tests/billing.config.planDefinitions.unit.tests.js @@ -0,0 +1,101 @@ +/** + * Module dependencies. + */ +import { jest, describe, it, beforeEach, afterEach, expect } from '@jest/globals'; + +/** + * Unit tests for planDefinitions config shape — validates signupGrant / oneShot fields. + */ +describe('billing planDefinitions config:', () => { + let config; + + beforeEach(async () => { + jest.resetModules(); + const mod = await import('../../../config/index.js'); + config = mod.default; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('planDefinitions — Free signup grant', () => { + it('Free plan has signupGrant: 500 and oneShot: true', () => { + const definitions = config?.billing?.planDefinitions ?? []; + const free = definitions.find((p) => p.planId === 'free'); + expect(free).toBeDefined(); + expect(free.signupGrant).toBe(500); + expect(free.oneShot).toBe(true); + expect(free.meterQuota).toBe(0); + }); + + it('Growth and Pro plans do not have signupGrant', () => { + const definitions = config?.billing?.planDefinitions ?? []; + const growth = definitions.find((p) => p.planId === 'growth'); + const pro = definitions.find((p) => p.planId === 'pro'); + if (growth) expect(growth.signupGrant).toBeUndefined(); + if (pro) expect(pro.signupGrant).toBeUndefined(); + }); + + it('Free plan signupGrant is a non-negative integer', () => { + const definitions = config?.billing?.planDefinitions ?? []; + const free = definitions.find((p) => p.planId === 'free'); + expect(free).toBeDefined(); + expect(Number.isInteger(free.signupGrant)).toBe(true); + expect(free.signupGrant).toBeGreaterThanOrEqual(0); + }); + + it('Free plan oneShot is a boolean', () => { + const definitions = config?.billing?.planDefinitions ?? []; + const free = definitions.find((p) => p.planId === 'free'); + expect(free).toBeDefined(); + expect(typeof free.oneShot).toBe('boolean'); + }); + }); + + describe('planDefinitions Zod schema validation', () => { + it('validates a plan entry with signupGrant and oneShot via billingPlanDefinitionSchema', async () => { + const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js'); + const result = billingPlanDefinitionSchema.safeParse({ + planId: 'free', + meterQuota: 0, + signupGrant: 500, + oneShot: true, + ratios: { default: 1 }, + }); + expect(result.success).toBe(true); + }); + + it('validates a plan entry without signupGrant and oneShot (optional fields)', async () => { + const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js'); + const result = billingPlanDefinitionSchema.safeParse({ + planId: 'growth', + meterQuota: 1600, + ratios: { default: 1 }, + }); + expect(result.success).toBe(true); + }); + + it('rejects negative signupGrant', async () => { + const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js'); + const result = billingPlanDefinitionSchema.safeParse({ + planId: 'free', + meterQuota: 0, + signupGrant: -1, + ratios: {}, + }); + expect(result.success).toBe(false); + }); + + it('rejects non-integer signupGrant', async () => { + const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js'); + const result = billingPlanDefinitionSchema.safeParse({ + planId: 'free', + meterQuota: 0, + signupGrant: 500.5, + ratios: {}, + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/modules/billing/tests/billing.plan.service.unit.tests.js b/modules/billing/tests/billing.plan.service.unit.tests.js index 7d14fd8ec..1a8851914 100644 --- a/modules/billing/tests/billing.plan.service.unit.tests.js +++ b/modules/billing/tests/billing.plan.service.unit.tests.js @@ -83,6 +83,24 @@ describe('BillingPlanService unit tests:', () => { expect(result).not.toBeInstanceOf(Promise); expect(typeof result).toBe('object'); }); + + test('passes through signupGrant when present on plan definition', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: {} }, + ]; + const result = BillingPlanService.getActivePlan('free'); + expect(result.signupGrant).toBe(500); + expect(result.oneShot).toBe(true); + }); + + test('does not include signupGrant on plans without the field', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'growth', meterQuota: 1600, ratios: {} }, + ]; + const result = BillingPlanService.getActivePlan('growth'); + expect(result.signupGrant).toBeUndefined(); + expect(result.oneShot).toBeUndefined(); + }); }); describe('getPlanByVersion', () => { From 446b6907923bbe5e1054d86bf863fc06fe840f05 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 11 May 2026 19:18:08 +0200 Subject: [PATCH 2/6] 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 --- modules/billing/config/billing.config.zod.js | 2 +- .../billing.config.planDefinitions.unit.tests.js | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/modules/billing/config/billing.config.zod.js b/modules/billing/config/billing.config.zod.js index 551871150..df3ee1d74 100644 --- a/modules/billing/config/billing.config.zod.js +++ b/modules/billing/config/billing.config.zod.js @@ -19,7 +19,7 @@ import { z } from 'zod'; const billingPlanDefinitionSchema = z.object({ planId: z.string().min(1), meterQuota: z.number().int().nonnegative(), - ratios: z.record(z.string(), z.number()).default({}), + ratios: z.record(z.string(), z.number()).default(() => ({})), version: z.string().optional(), signupGrant: z.number().int().nonnegative().optional(), oneShot: z.boolean().optional(), diff --git a/modules/billing/tests/billing.config.planDefinitions.unit.tests.js b/modules/billing/tests/billing.config.planDefinitions.unit.tests.js index 0a3c6463f..3c87ad469 100644 --- a/modules/billing/tests/billing.config.planDefinitions.unit.tests.js +++ b/modules/billing/tests/billing.config.planDefinitions.unit.tests.js @@ -29,12 +29,16 @@ describe('billing planDefinitions config:', () => { expect(free.meterQuota).toBe(0); }); - it('Growth and Pro plans do not have signupGrant', () => { + it('Paid plans (starter, pro, enterprise) do not have signupGrant', () => { const definitions = config?.billing?.planDefinitions ?? []; - const growth = definitions.find((p) => p.planId === 'growth'); - const pro = definitions.find((p) => p.planId === 'pro'); - if (growth) expect(growth.signupGrant).toBeUndefined(); - if (pro) expect(pro.signupGrant).toBeUndefined(); + const paidPlanIds = ['starter', 'pro', 'enterprise']; + for (const planId of paidPlanIds) { + const plan = definitions.find((p) => p.planId === planId); + // Assert the plan exists so this test cannot pass vacuously if IDs change + expect(plan).toBeDefined(); + expect(plan.signupGrant).toBeUndefined(); + expect(plan.oneShot).toBeUndefined(); + } }); it('Free plan signupGrant is a non-negative integer', () => { From 753aba2f34416d8337f028961d27d2e2954b78c9 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 11 May 2026 19:29:06 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(billing):=20tighten=20signupGrant=20sch?= =?UTF-8?q?ema=20=E2=80=94=20positive()=20+=20co-presence=20refine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- modules/billing/config/billing.config.zod.js | 31 +++++++++----- ...lling.config.planDefinitions.unit.tests.js | 40 ++++++++++++++++++- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/modules/billing/config/billing.config.zod.js b/modules/billing/config/billing.config.zod.js index df3ee1d74..bc619d38d 100644 --- a/modules/billing/config/billing.config.zod.js +++ b/modules/billing/config/billing.config.zod.js @@ -12,17 +12,28 @@ import { z } from 'zod'; * ratios — feature multiplier map (e.g. { default: 1, autofix: 2 }) * * Optional fields (N2 signup-grant): - * signupGrant — one-time credit amount given on org creation (non-negative integer) - * oneShot — when true the grant does not renew on weekly/monthly reset + * signupGrant — one-time credit granted to fresh orgs at signup (positive integer). + * Must be present when oneShot is set. + * oneShot — when true the grant does not renew on weekly/monthly reset. + * Must be present when signupGrant is set. * version — plan version string (YYYY.MM or v${N}); falls back to meter.ratioVersion */ -const billingPlanDefinitionSchema = z.object({ - planId: z.string().min(1), - meterQuota: z.number().int().nonnegative(), - ratios: z.record(z.string(), z.number()).default(() => ({})), - version: z.string().optional(), - signupGrant: z.number().int().nonnegative().optional(), - oneShot: z.boolean().optional(), -}); +const billingPlanDefinitionSchema = z + .object({ + planId: z.string().min(1), + meterQuota: z.number().int().nonnegative(), + ratios: z.record(z.string(), z.number()).default(() => ({})), + version: z.string().optional(), + signupGrant: z.number().int().positive().optional(), + oneShot: z.boolean().optional(), + }) + .refine( + (data) => (data.signupGrant !== undefined) === (data.oneShot !== undefined), + { + message: + 'signupGrant and oneShot must be defined together — set both or neither', + path: ['signupGrant'], + }, + ); export { billingPlanDefinitionSchema }; diff --git a/modules/billing/tests/billing.config.planDefinitions.unit.tests.js b/modules/billing/tests/billing.config.planDefinitions.unit.tests.js index 3c87ad469..b4c1daa61 100644 --- a/modules/billing/tests/billing.config.planDefinitions.unit.tests.js +++ b/modules/billing/tests/billing.config.planDefinitions.unit.tests.js @@ -41,12 +41,12 @@ describe('billing planDefinitions config:', () => { } }); - it('Free plan signupGrant is a non-negative integer', () => { + it('Free plan signupGrant is a positive integer (> 0)', () => { const definitions = config?.billing?.planDefinitions ?? []; const free = definitions.find((p) => p.planId === 'free'); expect(free).toBeDefined(); expect(Number.isInteger(free.signupGrant)).toBe(true); - expect(free.signupGrant).toBeGreaterThanOrEqual(0); + expect(free.signupGrant).toBeGreaterThan(0); }); it('Free plan oneShot is a boolean', () => { @@ -86,6 +86,19 @@ describe('billing planDefinitions config:', () => { planId: 'free', meterQuota: 0, signupGrant: -1, + oneShot: true, + ratios: {}, + }); + expect(result.success).toBe(false); + }); + + it('rejects zero signupGrant (meaningless no-op grant)', async () => { + const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js'); + const result = billingPlanDefinitionSchema.safeParse({ + planId: 'free', + meterQuota: 0, + signupGrant: 0, + oneShot: true, ratios: {}, }); expect(result.success).toBe(false); @@ -97,6 +110,29 @@ describe('billing planDefinitions config:', () => { planId: 'free', meterQuota: 0, signupGrant: 500.5, + oneShot: true, + ratios: {}, + }); + expect(result.success).toBe(false); + }); + + it('rejects oneShot without signupGrant', async () => { + const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js'); + const result = billingPlanDefinitionSchema.safeParse({ + planId: 'free', + meterQuota: 0, + oneShot: true, + ratios: {}, + }); + expect(result.success).toBe(false); + }); + + it('rejects signupGrant without oneShot', async () => { + const { billingPlanDefinitionSchema } = await import('../config/billing.config.zod.js'); + const result = billingPlanDefinitionSchema.safeParse({ + planId: 'free', + meterQuota: 0, + signupGrant: 500, ratios: {}, }); expect(result.success).toBe(false); From 1718c8a424ff7284454935d21f32095ad5238987 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 11 May 2026 19:39:58 +0200 Subject: [PATCH 4/6] 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. --- .../billing/services/billing.plan.service.js | 17 +++++-- .../tests/billing.plan.service.unit.tests.js | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/modules/billing/services/billing.plan.service.js b/modules/billing/services/billing.plan.service.js index 2c0d698a1..a348be27a 100644 --- a/modules/billing/services/billing.plan.service.js +++ b/modules/billing/services/billing.plan.service.js @@ -2,6 +2,7 @@ * Module dependencies */ import config from '../../../config/index.js'; +import logger from '../../../lib/services/logger.js'; /** * @desc Resolve a plan definition from config.billing.planDefinitions. @@ -29,9 +30,19 @@ const getPlanFromConfig = (planId) => { ratios: def.ratios ?? {}, }; - // N2 signup-grant fields — only present when configured (optional on the def) - if (def.signupGrant !== undefined) plan.signupGrant = def.signupGrant; - if (def.oneShot !== undefined) plan.oneShot = def.oneShot; + // N2 signup-grant fields — only present when configured (optional on the def). + // Guard: signupGrant and oneShot must be defined together (co-presence invariant). + // This mirrors the Zod schema refine() and catches misconfigured planDefinitions at runtime. + const hasGrant = def.signupGrant !== undefined; + const hasOneShot = def.oneShot !== undefined; + if (hasGrant !== hasOneShot) { + logger.warn( + '[billing.plan] signupGrant and oneShot must be defined together — check planDefinitions config', + { planId: def.planId, signupGrant: def.signupGrant, oneShot: def.oneShot }, + ); + } + if (hasGrant) plan.signupGrant = def.signupGrant; + if (hasOneShot) plan.oneShot = def.oneShot; return plan; }; diff --git a/modules/billing/tests/billing.plan.service.unit.tests.js b/modules/billing/tests/billing.plan.service.unit.tests.js index 1a8851914..f8e7174e6 100644 --- a/modules/billing/tests/billing.plan.service.unit.tests.js +++ b/modules/billing/tests/billing.plan.service.unit.tests.js @@ -9,6 +9,7 @@ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globa describe('BillingPlanService unit tests:', () => { let BillingPlanService; let mockConfig; + let mockLogger; beforeEach(async () => { jest.resetModules(); @@ -27,9 +28,14 @@ describe('BillingPlanService unit tests:', () => { }, }; + mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig, })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: mockLogger, + })); const mod = await import('../services/billing.plan.service.js'); BillingPlanService = mod.default; @@ -101,6 +107,44 @@ describe('BillingPlanService unit tests:', () => { expect(result.signupGrant).toBeUndefined(); expect(result.oneShot).toBeUndefined(); }); + + test('warns when signupGrant is set without oneShot (co-presence invariant)', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, ratios: {} }, + ]; + BillingPlanService.getActivePlan('free'); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('signupGrant and oneShot must be defined together'), + expect.objectContaining({ planId: 'free' }), + ); + }); + + test('warns when oneShot is set without signupGrant (co-presence invariant)', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, oneShot: true, ratios: {} }, + ]; + BillingPlanService.getActivePlan('free'); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('signupGrant and oneShot must be defined together'), + expect.objectContaining({ planId: 'free' }), + ); + }); + + test('does not warn when both signupGrant and oneShot are set', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: {} }, + ]; + BillingPlanService.getActivePlan('free'); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + test('does not warn when neither signupGrant nor oneShot are set', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'pro', meterQuota: 8000, ratios: {} }, + ]; + BillingPlanService.getActivePlan('pro'); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); }); describe('getPlanByVersion', () => { From 3f25337f46a16d01efe740bda22a03f062d16241 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 11 May 2026 20:06:01 +0200 Subject: [PATCH 5/6] fix(billing): add getSignupGrant export + harden co-presence guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../billing/services/billing.plan.service.js | 27 +++++++++--- .../tests/billing.plan.service.unit.tests.js | 41 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/modules/billing/services/billing.plan.service.js b/modules/billing/services/billing.plan.service.js index a348be27a..0dca51331 100644 --- a/modules/billing/services/billing.plan.service.js +++ b/modules/billing/services/billing.plan.service.js @@ -33,16 +33,18 @@ const getPlanFromConfig = (planId) => { // N2 signup-grant fields — only present when configured (optional on the def). // Guard: signupGrant and oneShot must be defined together (co-presence invariant). // This mirrors the Zod schema refine() and catches misconfigured planDefinitions at runtime. - const hasGrant = def.signupGrant !== undefined; - const hasOneShot = def.oneShot !== undefined; - if (hasGrant !== hasOneShot) { + const hasSignupGrant = def.signupGrant !== undefined; + const hasOneShotFlag = def.oneShot !== undefined; + if (hasSignupGrant !== hasOneShotFlag) { logger.warn( '[billing.plan] signupGrant and oneShot must be defined together — check planDefinitions config', { planId: def.planId, signupGrant: def.signupGrant, oneShot: def.oneShot }, ); + // Do not attach partial/broken grant fields — return plan without them. + return plan; } - if (hasGrant) plan.signupGrant = def.signupGrant; - if (hasOneShot) plan.oneShot = def.oneShot; + if (hasSignupGrant) plan.signupGrant = def.signupGrant; + if (hasOneShotFlag) plan.oneShot = def.oneShot; return plan; }; @@ -75,7 +77,22 @@ const getPlanByVersion = (planId, version) => { return plan; }; +/** + * @desc Get the signup grant amount for a given planId (N2 Free Tier Grant feature). + * Returns the signupGrant value if the plan definition has it, or undefined. + * Callers must handle undefined as "no grant configured for this plan". + * + * @param {string} planId - The logical plan identifier (e.g. "free"). + * @returns {number|undefined} The signupGrant value, or undefined. + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const getSignupGrant = (planId) => { + const plan = getPlanFromConfig(planId); + return plan?.signupGrant; +}; + export default { getActivePlan, getPlanByVersion, + getSignupGrant, }; diff --git a/modules/billing/tests/billing.plan.service.unit.tests.js b/modules/billing/tests/billing.plan.service.unit.tests.js index f8e7174e6..ffb312e2f 100644 --- a/modules/billing/tests/billing.plan.service.unit.tests.js +++ b/modules/billing/tests/billing.plan.service.unit.tests.js @@ -145,6 +145,47 @@ describe('BillingPlanService unit tests:', () => { BillingPlanService.getActivePlan('pro'); expect(mockLogger.warn).not.toHaveBeenCalled(); }); + + test('does not attach lone signupGrant when oneShot is missing (co-presence guard early return)', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, ratios: {} }, + ]; + const result = BillingPlanService.getActivePlan('free'); + expect(result.signupGrant).toBeUndefined(); + expect(result.oneShot).toBeUndefined(); + }); + + test('does not attach lone oneShot when signupGrant is missing (co-presence guard early return)', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, oneShot: true, ratios: {} }, + ]; + const result = BillingPlanService.getActivePlan('free'); + expect(result.signupGrant).toBeUndefined(); + expect(result.oneShot).toBeUndefined(); + }); + }); + + describe('getSignupGrant', () => { + test('returns signupGrant for a plan with both signupGrant and oneShot set', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'free', meterQuota: 0, signupGrant: 500, oneShot: true, ratios: {} }, + ]; + const grant = BillingPlanService.getSignupGrant('free'); + expect(grant).toBe(500); + }); + + test('returns undefined for plans without signupGrant', () => { + mockConfig.billing.planDefinitions = [ + { planId: 'pro', meterQuota: 8000, ratios: {} }, + ]; + const grant = BillingPlanService.getSignupGrant('pro'); + expect(grant).toBeUndefined(); + }); + + test('returns undefined for unknown planId', () => { + const grant = BillingPlanService.getSignupGrant('nonexistent'); + expect(grant).toBeUndefined(); + }); }); describe('getPlanByVersion', () => { From 2fbbfd214572bb505cb5ed372a0b79d272b3435e Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 11 May 2026 20:15:20 +0200 Subject: [PATCH 6/6] fix(billing): mock plan.service in quota test suite to prevent logger load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- modules/billing/tests/billing.quota.unit.tests.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modules/billing/tests/billing.quota.unit.tests.js b/modules/billing/tests/billing.quota.unit.tests.js index 3fa0c54e0..fa17b2828 100644 --- a/modules/billing/tests/billing.quota.unit.tests.js +++ b/modules/billing/tests/billing.quota.unit.tests.js @@ -48,6 +48,17 @@ describe('requireQuota middleware:', () => { default: mockConfig, })); + // billing.plan.service.js imports logger at module load time; the logger reads + // config.log.fileLogger which is absent from this suite's minimal config fixture. + // Mock the service to avoid loading the real logger with an incomplete config. + jest.unstable_mockModule('../services/billing.plan.service.js', () => ({ + default: { + getActivePlan: jest.fn().mockReturnValue(null), + getPlanByVersion: jest.fn().mockReturnValue(null), + getSignupGrant: jest.fn().mockReturnValue(undefined), + }, + })); + const mod = await import('../middlewares/billing.requireQuota.js'); requireQuota = mod.default;