From ec29f0e08ea47ecad1a87f3fb3ad6517668f8ec1 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 2 Jun 2026 11:09:08 +0200 Subject: [PATCH 1/3] feat(billing): ship pricing constants contract + safe defaults in config/defaults/ Promote billing.pricing.constants shape to devkit (Task 1a of billing-residual-cleanup plan). Adds config/defaults/billing.pricing.constants.js with the export contract + safe defaults (PRICING_VERSION='0.0.0', PLAN_QUOTAS={free:0}, RATIOS={}, STRIPE_PRICE_CENTS={}, STRIPE_PACK_CENTS={}). Wires into development.config.js under billing.pricing so importers can read via config.billing.pricing.*. Downstream projects override values (not the shape) in their own project config; devkit ships the contract only. --- config/defaults/billing.pricing.constants.js | 32 +++++++++++++++++++ config/defaults/development.config.js | 18 +++++++++++ ...g-pricing-constants.contract.unit.tests.js | 28 ++++++++++++++++ ...illing.pricing.config.wiring.unit.tests.js | 17 ++++++++++ 4 files changed, 95 insertions(+) create mode 100644 config/defaults/billing.pricing.constants.js create mode 100644 lib/services/tests/billing-pricing-constants.contract.unit.tests.js create mode 100644 modules/billing/tests/billing.pricing.config.wiring.unit.tests.js diff --git a/config/defaults/billing.pricing.constants.js b/config/defaults/billing.pricing.constants.js new file mode 100644 index 000000000..bd7aa1336 --- /dev/null +++ b/config/defaults/billing.pricing.constants.js @@ -0,0 +1,32 @@ +/** + * Billing pricing constants — devkit-shipped export contract with safe defaults. + * + * Downstreams override the VALUES (not the contract) by setting `billing.pricing.*` + * in their `config/defaults/.config.js`. The global `config` exposes the + * merged result at `config.billing.pricing`. Importers should always read through + * `config.billing.pricing.PLAN_QUOTAS` etc., NOT from this file directly — that + * way downstream values win the glob-merge. + * + * Why ship from devkit: + * - Every downstream running billing wants the same export shape. + * - Migrations + contract tests + costs service all benefit from a single import path. + * - Trawl had this file at `modules/billing/config/billing.pricing.constants.js` with + * 6+ importers — promoted upstream in plan `2026-06-02-trawl-billing-residual-cleanup.md`. + * + * @module billing.pricing.constants + */ + +/** @type {string} YYYY.MM pricing version (e.g. '2026.05'). Default 0.0.0 = unset. */ +export const PRICING_VERSION = '0.0.0'; + +/** @type {Record} Weekly meter quota in compute units per plan. */ +export const PLAN_QUOTAS = { free: 0 }; + +/** @type {Record} Compute unit multipliers per feature key. */ +export const RATIOS = {}; + +/** @type {Record} Stripe price cents per plan. */ +export const STRIPE_PRICE_CENTS = {}; + +/** @type {Record} Stripe price cents per extras pack. */ +export const STRIPE_PACK_CENTS = {}; diff --git a/config/defaults/development.config.js b/config/defaults/development.config.js index 0e100b216..79676b493 100644 --- a/config/defaults/development.config.js +++ b/config/defaults/development.config.js @@ -1,3 +1,11 @@ +import { + PRICING_VERSION, + PLAN_QUOTAS, + RATIOS, + STRIPE_PRICE_CENTS, + STRIPE_PACK_CENTS, +} from './billing.pricing.constants.js'; + const config = { app: { title: 'Devkit Node - Development Environment', @@ -142,6 +150,16 @@ const config = { }, }, }, + billing: { + /** + * Pricing constants contract — safe devkit defaults. + * Downstream projects override these values in their own config file. + * These values are also directly importable from + * `config/defaults/billing.pricing.constants.js` for migrations and + * standalone tooling that cannot import the full config object. + */ + pricing: { PRICING_VERSION, PLAN_QUOTAS, RATIOS, STRIPE_PRICE_CENTS, STRIPE_PACK_CENTS }, + }, }; export default config; diff --git a/lib/services/tests/billing-pricing-constants.contract.unit.tests.js b/lib/services/tests/billing-pricing-constants.contract.unit.tests.js new file mode 100644 index 000000000..03e4be278 --- /dev/null +++ b/lib/services/tests/billing-pricing-constants.contract.unit.tests.js @@ -0,0 +1,28 @@ +import { describe, test, expect } from '@jest/globals'; +import { + PRICING_VERSION, + PLAN_QUOTAS, + RATIOS, + STRIPE_PRICE_CENTS, + STRIPE_PACK_CENTS, +} from '../../../config/defaults/billing.pricing.constants.js'; + +describe('billing.pricing.constants — devkit contract:', () => { + test('PRICING_VERSION is a non-empty string with YYYY.MM shape (or 0.0.0 default)', () => { + expect(typeof PRICING_VERSION).toBe('string'); + expect(PRICING_VERSION).toMatch(/^\d+\.\d+(\.\d+)?$/); + }); + test('PLAN_QUOTAS is an object with at least `free` key (numeric)', () => { + expect(typeof PLAN_QUOTAS).toBe('object'); + expect(typeof PLAN_QUOTAS.free).toBe('number'); + }); + test('RATIOS is an object (may be empty by default)', () => { + expect(typeof RATIOS).toBe('object'); + }); + test('STRIPE_PRICE_CENTS is an object (may be empty by default)', () => { + expect(typeof STRIPE_PRICE_CENTS).toBe('object'); + }); + test('STRIPE_PACK_CENTS is an object (may be empty by default)', () => { + expect(typeof STRIPE_PACK_CENTS).toBe('object'); + }); +}); diff --git a/modules/billing/tests/billing.pricing.config.wiring.unit.tests.js b/modules/billing/tests/billing.pricing.config.wiring.unit.tests.js new file mode 100644 index 000000000..9acd03adf --- /dev/null +++ b/modules/billing/tests/billing.pricing.config.wiring.unit.tests.js @@ -0,0 +1,17 @@ +import { describe, test, expect } from '@jest/globals'; +import config from '../../../config/index.js'; + +describe('config.billing.pricing wiring:', () => { + test('exposes PRICING_VERSION, PLAN_QUOTAS, RATIOS, STRIPE_PRICE_CENTS, STRIPE_PACK_CENTS', () => { + expect(config.billing).toBeDefined(); + expect(config.billing.pricing).toBeDefined(); + expect(config.billing.pricing.PRICING_VERSION).toBeDefined(); + expect(config.billing.pricing.PLAN_QUOTAS).toBeDefined(); + expect(config.billing.pricing.RATIOS).toBeDefined(); + expect(config.billing.pricing.STRIPE_PRICE_CENTS).toBeDefined(); + expect(config.billing.pricing.STRIPE_PACK_CENTS).toBeDefined(); + }); + test('PLAN_QUOTAS has at least `free` numeric entry by default', () => { + expect(typeof config.billing.pricing.PLAN_QUOTAS.free).toBe('number'); + }); +}); From 596895991d316c8c0bfb893bfd572845df5547e5 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 2 Jun 2026 11:09:17 +0200 Subject: [PATCH 2/3] feat(billing): add refundCharge service with deterministic idempotency key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote billing.refund.service.js from trawl to devkit (Task 3a of billing-residual-cleanup plan). Wraps stripe.refunds.create with a deterministic idempotency key (refund_{chargeId}_{amount|full}) so retries on network failures never create duplicate refunds. Ledger debit happens via the charge.refunded webhook (single source of truth) — this service only initiates the Stripe refund. Full + partial refund, reason passthrough, empty chargeId and non-positive amount guards all covered by 6 unit test cases. --- .../services/billing.refund.service.js | 49 +++++++++++++++ ....refund.service.refundCharge.unit.tests.js | 61 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 modules/billing/services/billing.refund.service.js create mode 100644 modules/billing/tests/billing.refund.service.refundCharge.unit.tests.js diff --git a/modules/billing/services/billing.refund.service.js b/modules/billing/services/billing.refund.service.js new file mode 100644 index 000000000..e757a8eb6 --- /dev/null +++ b/modules/billing/services/billing.refund.service.js @@ -0,0 +1,49 @@ +/** + * Module dependencies + */ +import getStripe from '../lib/stripe.js'; + +/** + * @function refundCharge + * @description Initiate a Stripe refund for a given charge. + * Wraps stripe.refunds.create with a deterministic idempotency key so + * retries on network failures never create duplicate refunds. + * The actual ledger debit happens via the `charge.refunded` webhook + * (single source of truth) — this service ONLY initiates the Stripe refund. + * + * @param {string} stripeChargeId - Stripe charge ID (ch_xxx). Must be non-empty. + * @param {number|undefined} [amountCents] - Amount to refund in cents. Omit for full refund. Must be > 0 if provided. + * @param {Object} [options={}] - Optional Stripe refund options. + * @param {string} [options.reason] - Optional Stripe refund reason. + * @returns {Promise} The Stripe refund object. + */ +// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik +const refundCharge = async (stripeChargeId, amountCents, { reason } = {}) => { + if (typeof stripeChargeId !== 'string' || stripeChargeId.trim() === '') { + throw new Error('invalid argument: stripeChargeId must be a non-empty string'); + } + if (amountCents !== undefined) { + if (!Number.isInteger(amountCents) || amountCents <= 0) { + throw new Error('invalid argument: amountCents must be a positive integer'); + } + } + + const stripe = getStripe(); + if (!stripe) throw new Error('Stripe is not configured'); + + const idempotencyKey = `refund_${stripeChargeId}_${amountCents ?? 'full'}`; + + const params = { + charge: stripeChargeId, + reason: reason || 'requested_by_customer', + }; + if (amountCents !== undefined) { + params.amount = amountCents; + } + + return stripe.refunds.create(params, { idempotencyKey }); +}; + +export default { + refundCharge, +}; diff --git a/modules/billing/tests/billing.refund.service.refundCharge.unit.tests.js b/modules/billing/tests/billing.refund.service.refundCharge.unit.tests.js new file mode 100644 index 000000000..ec195f5b8 --- /dev/null +++ b/modules/billing/tests/billing.refund.service.refundCharge.unit.tests.js @@ -0,0 +1,61 @@ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +describe('billing.refund.service — refundCharge:', () => { + let RefundService; + let mockStripe; + let mockRefundsCreate; + + beforeEach(async () => { + jest.resetModules(); + mockRefundsCreate = jest.fn().mockResolvedValue({ id: 're_test_123', status: 'succeeded' }); + mockStripe = { refunds: { create: mockRefundsCreate } }; + jest.unstable_mockModule('../lib/stripe.js', () => ({ + default: jest.fn().mockReturnValue(mockStripe), + })); + ({ default: RefundService } = await import('../services/billing.refund.service.js')); + }); + + test('initiates a full refund when amountCents omitted', async () => { + const result = await RefundService.refundCharge('ch_test_123'); + expect(mockRefundsCreate).toHaveBeenCalledTimes(1); + const callArgs = mockRefundsCreate.mock.calls[0][0]; + expect(callArgs.charge).toBe('ch_test_123'); + expect(callArgs.amount).toBeUndefined(); + expect(result.id).toBe('re_test_123'); + }); + + test('uses partial amount when amountCents provided', async () => { + await RefundService.refundCharge('ch_test_456', 5000); + expect(mockRefundsCreate).toHaveBeenCalledWith(expect.objectContaining({ + charge: 'ch_test_456', + amount: 5000, + }), expect.any(Object)); + }); + + test('passes deterministic idempotency key derived from charge+amount', async () => { + await RefundService.refundCharge('ch_idem_test', 1000); + const idemArg = mockRefundsCreate.mock.calls[0][1]; + expect(idemArg).toBeDefined(); + expect(idemArg.idempotencyKey).toBeDefined(); + // Same charge+amount → same key (deterministic) + await RefundService.refundCharge('ch_idem_test', 1000); + const idemArg2 = mockRefundsCreate.mock.calls[1][1]; + expect(idemArg2.idempotencyKey).toBe(idemArg.idempotencyKey); + }); + + test('passes reason when provided', async () => { + await RefundService.refundCharge('ch_test', undefined, { reason: 'requested_by_customer' }); + expect(mockRefundsCreate).toHaveBeenCalledWith(expect.objectContaining({ + reason: 'requested_by_customer', + }), expect.any(Object)); + }); + + test('throws when stripeChargeId is empty', async () => { + await expect(RefundService.refundCharge('')).rejects.toThrow(); + }); + + test('throws when amountCents is 0 or negative', async () => { + await expect(RefundService.refundCharge('ch_x', 0)).rejects.toThrow(); + await expect(RefundService.refundCharge('ch_x', -100)).rejects.toThrow(); + }); +}); From f719b3c7943ea10410c34cddad53508e86be9c8d Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 2 Jun 2026 11:19:13 +0200 Subject: [PATCH 3/3] fix(billing): address 3 CodeRabbit findings on pricing contract + refund service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR1: pin exact safe defaults in contract test (toEqual instead of typeof) so regressions that populate RATIOS/STRIPE_PRICE_CENTS/STRIPE_PACK_CENTS by default are caught. CR2: guard options param in refundCharge before destructuring — null arg caused raw TypeError; now throws a clear validation error. CR3: wiring test imports development.config.js directly (not config/index.js) so it deterministically targets the new billing.pricing block in the defaults file, not whichever env the full pipeline resolves. --- ...g-pricing-constants.contract.unit.tests.js | 22 +++++++++---------- .../services/billing.refund.service.js | 6 ++++- ...illing.pricing.config.wiring.unit.tests.js | 20 ++++++++--------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/lib/services/tests/billing-pricing-constants.contract.unit.tests.js b/lib/services/tests/billing-pricing-constants.contract.unit.tests.js index 03e4be278..0e75d2955 100644 --- a/lib/services/tests/billing-pricing-constants.contract.unit.tests.js +++ b/lib/services/tests/billing-pricing-constants.contract.unit.tests.js @@ -8,21 +8,19 @@ import { } from '../../../config/defaults/billing.pricing.constants.js'; describe('billing.pricing.constants — devkit contract:', () => { - test('PRICING_VERSION is a non-empty string with YYYY.MM shape (or 0.0.0 default)', () => { - expect(typeof PRICING_VERSION).toBe('string'); - expect(PRICING_VERSION).toMatch(/^\d+\.\d+(\.\d+)?$/); + test('PRICING_VERSION is the safe default 0.0.0', () => { + expect(PRICING_VERSION).toBe('0.0.0'); }); - test('PLAN_QUOTAS is an object with at least `free` key (numeric)', () => { - expect(typeof PLAN_QUOTAS).toBe('object'); - expect(typeof PLAN_QUOTAS.free).toBe('number'); + test('PLAN_QUOTAS is the safe default { free: 0 }', () => { + expect(PLAN_QUOTAS).toEqual({ free: 0 }); }); - test('RATIOS is an object (may be empty by default)', () => { - expect(typeof RATIOS).toBe('object'); + test('RATIOS is the safe default empty object', () => { + expect(RATIOS).toEqual({}); }); - test('STRIPE_PRICE_CENTS is an object (may be empty by default)', () => { - expect(typeof STRIPE_PRICE_CENTS).toBe('object'); + test('STRIPE_PRICE_CENTS is the safe default empty object', () => { + expect(STRIPE_PRICE_CENTS).toEqual({}); }); - test('STRIPE_PACK_CENTS is an object (may be empty by default)', () => { - expect(typeof STRIPE_PACK_CENTS).toBe('object'); + test('STRIPE_PACK_CENTS is the safe default empty object', () => { + expect(STRIPE_PACK_CENTS).toEqual({}); }); }); diff --git a/modules/billing/services/billing.refund.service.js b/modules/billing/services/billing.refund.service.js index e757a8eb6..2f71f00d3 100644 --- a/modules/billing/services/billing.refund.service.js +++ b/modules/billing/services/billing.refund.service.js @@ -18,7 +18,11 @@ import getStripe from '../lib/stripe.js'; * @returns {Promise} The Stripe refund object. */ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik -const refundCharge = async (stripeChargeId, amountCents, { reason } = {}) => { +const refundCharge = async (stripeChargeId, amountCents, options = {}) => { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw new Error('invalid argument: options must be a plain object'); + } + const { reason } = options; if (typeof stripeChargeId !== 'string' || stripeChargeId.trim() === '') { throw new Error('invalid argument: stripeChargeId must be a non-empty string'); } diff --git a/modules/billing/tests/billing.pricing.config.wiring.unit.tests.js b/modules/billing/tests/billing.pricing.config.wiring.unit.tests.js index 9acd03adf..64253425a 100644 --- a/modules/billing/tests/billing.pricing.config.wiring.unit.tests.js +++ b/modules/billing/tests/billing.pricing.config.wiring.unit.tests.js @@ -1,17 +1,17 @@ import { describe, test, expect } from '@jest/globals'; -import config from '../../../config/index.js'; +import defaults from '../../../config/defaults/development.config.js'; -describe('config.billing.pricing wiring:', () => { +describe('config.billing.pricing wiring (development.config.js):', () => { test('exposes PRICING_VERSION, PLAN_QUOTAS, RATIOS, STRIPE_PRICE_CENTS, STRIPE_PACK_CENTS', () => { - expect(config.billing).toBeDefined(); - expect(config.billing.pricing).toBeDefined(); - expect(config.billing.pricing.PRICING_VERSION).toBeDefined(); - expect(config.billing.pricing.PLAN_QUOTAS).toBeDefined(); - expect(config.billing.pricing.RATIOS).toBeDefined(); - expect(config.billing.pricing.STRIPE_PRICE_CENTS).toBeDefined(); - expect(config.billing.pricing.STRIPE_PACK_CENTS).toBeDefined(); + expect(defaults.billing).toBeDefined(); + expect(defaults.billing.pricing).toBeDefined(); + expect(defaults.billing.pricing.PRICING_VERSION).toBeDefined(); + expect(defaults.billing.pricing.PLAN_QUOTAS).toBeDefined(); + expect(defaults.billing.pricing.RATIOS).toBeDefined(); + expect(defaults.billing.pricing.STRIPE_PRICE_CENTS).toBeDefined(); + expect(defaults.billing.pricing.STRIPE_PACK_CENTS).toBeDefined(); }); test('PLAN_QUOTAS has at least `free` numeric entry by default', () => { - expect(typeof config.billing.pricing.PLAN_QUOTAS.free).toBe('number'); + expect(typeof defaults.billing.pricing.PLAN_QUOTAS.free).toBe('number'); }); });