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..0e75d2955 --- /dev/null +++ b/lib/services/tests/billing-pricing-constants.contract.unit.tests.js @@ -0,0 +1,26 @@ +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 the safe default 0.0.0', () => { + expect(PRICING_VERSION).toBe('0.0.0'); + }); + test('PLAN_QUOTAS is the safe default { free: 0 }', () => { + expect(PLAN_QUOTAS).toEqual({ free: 0 }); + }); + test('RATIOS is the safe default empty object', () => { + expect(RATIOS).toEqual({}); + }); + test('STRIPE_PRICE_CENTS is the safe default empty object', () => { + expect(STRIPE_PRICE_CENTS).toEqual({}); + }); + 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 new file mode 100644 index 000000000..2f71f00d3 --- /dev/null +++ b/modules/billing/services/billing.refund.service.js @@ -0,0 +1,53 @@ +/** + * 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, 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'); + } + 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.pricing.config.wiring.unit.tests.js b/modules/billing/tests/billing.pricing.config.wiring.unit.tests.js new file mode 100644 index 000000000..64253425a --- /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 defaults from '../../../config/defaults/development.config.js'; + +describe('config.billing.pricing wiring (development.config.js):', () => { + test('exposes PRICING_VERSION, PLAN_QUOTAS, RATIOS, STRIPE_PRICE_CENTS, STRIPE_PACK_CENTS', () => { + 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 defaults.billing.pricing.PLAN_QUOTAS.free).toBe('number'); + }); +}); 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(); + }); +});