Skip to content

Commit 5968959

Browse files
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.
1 parent ec29f0e commit 5968959

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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, { reason } = {}) => {
22+
if (typeof stripeChargeId !== 'string' || stripeChargeId.trim() === '') {
23+
throw new Error('invalid argument: stripeChargeId must be a non-empty string');
24+
}
25+
if (amountCents !== undefined) {
26+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
27+
throw new Error('invalid argument: amountCents must be a positive integer');
28+
}
29+
}
30+
31+
const stripe = getStripe();
32+
if (!stripe) throw new Error('Stripe is not configured');
33+
34+
const idempotencyKey = `refund_${stripeChargeId}_${amountCents ?? 'full'}`;
35+
36+
const params = {
37+
charge: stripeChargeId,
38+
reason: reason || 'requested_by_customer',
39+
};
40+
if (amountCents !== undefined) {
41+
params.amount = amountCents;
42+
}
43+
44+
return stripe.refunds.create(params, { idempotencyKey });
45+
};
46+
47+
export default {
48+
refundCharge,
49+
};
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)