Skip to content

Commit 2e25408

Browse files
feat(billing): ship pricing constants contract + refundCharge service (#3775)
* 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. * feat(billing): add refundCharge service with deterministic idempotency key 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. * fix(billing): address 3 CodeRabbit findings on pricing contract + refund service 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.
1 parent 0a2b3b4 commit 2e25408

6 files changed

Lines changed: 207 additions & 0 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Billing pricing constants — devkit-shipped export contract with safe defaults.
3+
*
4+
* Downstreams override the VALUES (not the contract) by setting `billing.pricing.*`
5+
* in their `config/defaults/<project>.config.js`. The global `config` exposes the
6+
* merged result at `config.billing.pricing`. Importers should always read through
7+
* `config.billing.pricing.PLAN_QUOTAS` etc., NOT from this file directly — that
8+
* way downstream values win the glob-merge.
9+
*
10+
* Why ship from devkit:
11+
* - Every downstream running billing wants the same export shape.
12+
* - Migrations + contract tests + costs service all benefit from a single import path.
13+
* - Trawl had this file at `modules/billing/config/billing.pricing.constants.js` with
14+
* 6+ importers — promoted upstream in plan `2026-06-02-trawl-billing-residual-cleanup.md`.
15+
*
16+
* @module billing.pricing.constants
17+
*/
18+
19+
/** @type {string} YYYY.MM pricing version (e.g. '2026.05'). Default 0.0.0 = unset. */
20+
export const PRICING_VERSION = '0.0.0';
21+
22+
/** @type {Record<string, number>} Weekly meter quota in compute units per plan. */
23+
export const PLAN_QUOTAS = { free: 0 };
24+
25+
/** @type {Record<string, number>} Compute unit multipliers per feature key. */
26+
export const RATIOS = {};
27+
28+
/** @type {Record<string, { monthly: number, annual: number }>} Stripe price cents per plan. */
29+
export const STRIPE_PRICE_CENTS = {};
30+
31+
/** @type {Record<string, number>} Stripe price cents per extras pack. */
32+
export const STRIPE_PACK_CENTS = {};

config/defaults/development.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import {
2+
PRICING_VERSION,
3+
PLAN_QUOTAS,
4+
RATIOS,
5+
STRIPE_PRICE_CENTS,
6+
STRIPE_PACK_CENTS,
7+
} from './billing.pricing.constants.js';
8+
19
const config = {
210
app: {
311
title: 'Devkit Node - Development Environment',
@@ -142,6 +150,16 @@ const config = {
142150
},
143151
},
144152
},
153+
billing: {
154+
/**
155+
* Pricing constants contract — safe devkit defaults.
156+
* Downstream projects override these values in their own config file.
157+
* These values are also directly importable from
158+
* `config/defaults/billing.pricing.constants.js` for migrations and
159+
* standalone tooling that cannot import the full config object.
160+
*/
161+
pricing: { PRICING_VERSION, PLAN_QUOTAS, RATIOS, STRIPE_PRICE_CENTS, STRIPE_PACK_CENTS },
162+
},
145163
};
146164

147165
export default config;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
import {
3+
PRICING_VERSION,
4+
PLAN_QUOTAS,
5+
RATIOS,
6+
STRIPE_PRICE_CENTS,
7+
STRIPE_PACK_CENTS,
8+
} from '../../../config/defaults/billing.pricing.constants.js';
9+
10+
describe('billing.pricing.constants — devkit contract:', () => {
11+
test('PRICING_VERSION is the safe default 0.0.0', () => {
12+
expect(PRICING_VERSION).toBe('0.0.0');
13+
});
14+
test('PLAN_QUOTAS is the safe default { free: 0 }', () => {
15+
expect(PLAN_QUOTAS).toEqual({ free: 0 });
16+
});
17+
test('RATIOS is the safe default empty object', () => {
18+
expect(RATIOS).toEqual({});
19+
});
20+
test('STRIPE_PRICE_CENTS is the safe default empty object', () => {
21+
expect(STRIPE_PRICE_CENTS).toEqual({});
22+
});
23+
test('STRIPE_PACK_CENTS is the safe default empty object', () => {
24+
expect(STRIPE_PACK_CENTS).toEqual({});
25+
});
26+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import getStripe from '../lib/stripe.js';
5+
6+
/**
7+
* @function refundCharge
8+
* @description Initiate a Stripe refund for a given charge.
9+
* Wraps stripe.refunds.create with a deterministic idempotency key so
10+
* retries on network failures never create duplicate refunds.
11+
* The actual ledger debit happens via the `charge.refunded` webhook
12+
* (single source of truth) — this service ONLY initiates the Stripe refund.
13+
*
14+
* @param {string} stripeChargeId - Stripe charge ID (ch_xxx). Must be non-empty.
15+
* @param {number|undefined} [amountCents] - Amount to refund in cents. Omit for full refund. Must be > 0 if provided.
16+
* @param {Object} [options={}] - Optional Stripe refund options.
17+
* @param {string} [options.reason] - Optional Stripe refund reason.
18+
* @returns {Promise<Object>} The Stripe refund object.
19+
*/
20+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
21+
const refundCharge = async (stripeChargeId, amountCents, options = {}) => {
22+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
23+
throw new Error('invalid argument: options must be a plain object');
24+
}
25+
const { reason } = options;
26+
if (typeof stripeChargeId !== 'string' || stripeChargeId.trim() === '') {
27+
throw new Error('invalid argument: stripeChargeId must be a non-empty string');
28+
}
29+
if (amountCents !== undefined) {
30+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
31+
throw new Error('invalid argument: amountCents must be a positive integer');
32+
}
33+
}
34+
35+
const stripe = getStripe();
36+
if (!stripe) throw new Error('Stripe is not configured');
37+
38+
const idempotencyKey = `refund_${stripeChargeId}_${amountCents ?? 'full'}`;
39+
40+
const params = {
41+
charge: stripeChargeId,
42+
reason: reason || 'requested_by_customer',
43+
};
44+
if (amountCents !== undefined) {
45+
params.amount = amountCents;
46+
}
47+
48+
return stripe.refunds.create(params, { idempotencyKey });
49+
};
50+
51+
export default {
52+
refundCharge,
53+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
import defaults from '../../../config/defaults/development.config.js';
3+
4+
describe('config.billing.pricing wiring (development.config.js):', () => {
5+
test('exposes PRICING_VERSION, PLAN_QUOTAS, RATIOS, STRIPE_PRICE_CENTS, STRIPE_PACK_CENTS', () => {
6+
expect(defaults.billing).toBeDefined();
7+
expect(defaults.billing.pricing).toBeDefined();
8+
expect(defaults.billing.pricing.PRICING_VERSION).toBeDefined();
9+
expect(defaults.billing.pricing.PLAN_QUOTAS).toBeDefined();
10+
expect(defaults.billing.pricing.RATIOS).toBeDefined();
11+
expect(defaults.billing.pricing.STRIPE_PRICE_CENTS).toBeDefined();
12+
expect(defaults.billing.pricing.STRIPE_PACK_CENTS).toBeDefined();
13+
});
14+
test('PLAN_QUOTAS has at least `free` numeric entry by default', () => {
15+
expect(typeof defaults.billing.pricing.PLAN_QUOTAS.free).toBe('number');
16+
});
17+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2+
3+
describe('billing.refund.service — refundCharge:', () => {
4+
let RefundService;
5+
let mockStripe;
6+
let mockRefundsCreate;
7+
8+
beforeEach(async () => {
9+
jest.resetModules();
10+
mockRefundsCreate = jest.fn().mockResolvedValue({ id: 're_test_123', status: 'succeeded' });
11+
mockStripe = { refunds: { create: mockRefundsCreate } };
12+
jest.unstable_mockModule('../lib/stripe.js', () => ({
13+
default: jest.fn().mockReturnValue(mockStripe),
14+
}));
15+
({ default: RefundService } = await import('../services/billing.refund.service.js'));
16+
});
17+
18+
test('initiates a full refund when amountCents omitted', async () => {
19+
const result = await RefundService.refundCharge('ch_test_123');
20+
expect(mockRefundsCreate).toHaveBeenCalledTimes(1);
21+
const callArgs = mockRefundsCreate.mock.calls[0][0];
22+
expect(callArgs.charge).toBe('ch_test_123');
23+
expect(callArgs.amount).toBeUndefined();
24+
expect(result.id).toBe('re_test_123');
25+
});
26+
27+
test('uses partial amount when amountCents provided', async () => {
28+
await RefundService.refundCharge('ch_test_456', 5000);
29+
expect(mockRefundsCreate).toHaveBeenCalledWith(expect.objectContaining({
30+
charge: 'ch_test_456',
31+
amount: 5000,
32+
}), expect.any(Object));
33+
});
34+
35+
test('passes deterministic idempotency key derived from charge+amount', async () => {
36+
await RefundService.refundCharge('ch_idem_test', 1000);
37+
const idemArg = mockRefundsCreate.mock.calls[0][1];
38+
expect(idemArg).toBeDefined();
39+
expect(idemArg.idempotencyKey).toBeDefined();
40+
// Same charge+amount → same key (deterministic)
41+
await RefundService.refundCharge('ch_idem_test', 1000);
42+
const idemArg2 = mockRefundsCreate.mock.calls[1][1];
43+
expect(idemArg2.idempotencyKey).toBe(idemArg.idempotencyKey);
44+
});
45+
46+
test('passes reason when provided', async () => {
47+
await RefundService.refundCharge('ch_test', undefined, { reason: 'requested_by_customer' });
48+
expect(mockRefundsCreate).toHaveBeenCalledWith(expect.objectContaining({
49+
reason: 'requested_by_customer',
50+
}), expect.any(Object));
51+
});
52+
53+
test('throws when stripeChargeId is empty', async () => {
54+
await expect(RefundService.refundCharge('')).rejects.toThrow();
55+
});
56+
57+
test('throws when amountCents is 0 or negative', async () => {
58+
await expect(RefundService.refundCharge('ch_x', 0)).rejects.toThrow();
59+
await expect(RefundService.refundCharge('ch_x', -100)).rejects.toThrow();
60+
});
61+
});

0 commit comments

Comments
 (0)