idempotency for stripe transfer, refund, charge

This commit is contained in:
jackiettran
2026-01-09 14:14:49 -05:00
parent e2e32f7632
commit 8aea3c38ed
2 changed files with 253 additions and 112 deletions

View File

@@ -104,12 +104,20 @@ class StripeService {
metadata = {}, metadata = {},
}) { }) {
try { try {
const transfer = await stripe.transfers.create({ // Generate idempotency key from rental ID to prevent duplicate transfers
amount: Math.round(amount * 100), // Convert to cents const idempotencyKey = metadata?.rentalId
currency, ? `transfer_rental_${metadata.rentalId}`
destination, : undefined;
metadata,
}); const transfer = await stripe.transfers.create(
{
amount: Math.round(amount * 100), // Convert to cents
currency,
destination,
metadata,
},
idempotencyKey ? { idempotencyKey } : undefined
);
return transfer; return transfer;
} catch (error) { } catch (error) {
@@ -216,12 +224,20 @@ class StripeService {
reason = "requested_by_customer", reason = "requested_by_customer",
}) { }) {
try { try {
const refund = await stripe.refunds.create({ // Generate idempotency key - include amount to allow multiple partial refunds
payment_intent: paymentIntentId, const idempotencyKey = metadata?.rentalId
amount: Math.round(amount * 100), // Convert to cents ? `refund_rental_${metadata.rentalId}_${Math.round(amount * 100)}`
metadata, : undefined;
reason,
}); const refund = await stripe.refunds.create(
{
payment_intent: paymentIntentId,
amount: Math.round(amount * 100), // Convert to cents
metadata,
reason,
},
idempotencyKey ? { idempotencyKey } : undefined
);
return refund; return refund;
} catch (error) { } catch (error) {
@@ -252,20 +268,28 @@ class StripeService {
metadata = {} metadata = {}
) { ) {
try { try {
// Generate idempotency key to prevent duplicate charges for same rental
const idempotencyKey = metadata?.rentalId
? `charge_rental_${metadata.rentalId}`
: undefined;
// Create a payment intent with the stored payment method // Create a payment intent with the stored payment method
const paymentIntent = await stripe.paymentIntents.create({ const paymentIntent = await stripe.paymentIntents.create(
amount: Math.round(amount * 100), // Convert to cents {
currency: "usd", amount: Math.round(amount * 100), // Convert to cents
payment_method: paymentMethodId, currency: "usd",
customer: customerId, // Include customer ID payment_method: paymentMethodId,
confirm: true, // Automatically confirm the payment customer: customerId, // Include customer ID
off_session: true, // Indicate this is an off-session payment confirm: true, // Automatically confirm the payment
return_url: `${ off_session: true, // Indicate this is an off-session payment
process.env.FRONTEND_URL || "http://localhost:3000" return_url: `${
}/complete-payment`, process.env.FRONTEND_URL || "http://localhost:3000"
metadata, }/complete-payment`,
expand: ["latest_charge.payment_method_details"], // Expand to get payment method details metadata,
}); expand: ["latest_charge.payment_method_details"], // Expand to get payment method details
},
idempotencyKey ? { idempotencyKey } : undefined
);
// Check if additional authentication is required // Check if additional authentication is required
if (paymentIntent.status === "requires_action") { if (paymentIntent.status === "requires_action") {

View File

@@ -317,7 +317,7 @@ describe('StripeService', () => {
}); });
describe('createTransfer', () => { describe('createTransfer', () => {
it('should create transfer with default currency', async () => { it('should create transfer with default currency and idempotency key', async () => {
const mockTransfer = { const mockTransfer = {
id: 'tr_123456789', id: 'tr_123456789',
amount: 5000, // $50.00 in cents amount: 5000, // $50.00 in cents
@@ -340,19 +340,22 @@ describe('StripeService', () => {
} }
}); });
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({ expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
amount: 5000, // Converted to cents {
currency: 'usd', amount: 5000, // Converted to cents
destination: 'acct_123456789', currency: 'usd',
metadata: { destination: 'acct_123456789',
rentalId: '1', metadata: {
ownerId: '2' rentalId: '1',
} ownerId: '2'
}); }
},
{ idempotencyKey: 'transfer_rental_1' }
);
expect(result).toEqual(mockTransfer); expect(result).toEqual(mockTransfer);
}); });
it('should create transfer with custom currency', async () => { it('should create transfer with custom currency and no idempotency key when no rentalId', async () => {
const mockTransfer = { const mockTransfer = {
id: 'tr_123456789', id: 'tr_123456789',
amount: 5000, amount: 5000,
@@ -369,12 +372,15 @@ describe('StripeService', () => {
destination: 'acct_123456789' destination: 'acct_123456789'
}); });
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({ expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
amount: 5000, {
currency: 'eur', amount: 5000,
destination: 'acct_123456789', currency: 'eur',
metadata: {} destination: 'acct_123456789',
}); metadata: {}
},
undefined
);
expect(result).toEqual(mockTransfer); expect(result).toEqual(mockTransfer);
}); });
@@ -394,12 +400,15 @@ describe('StripeService', () => {
destination: 'acct_123456789' destination: 'acct_123456789'
}); });
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({ expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
amount: 12534, // Properly converted to cents {
currency: 'usd', amount: 12534, // Properly converted to cents
destination: 'acct_123456789', currency: 'usd',
metadata: {} destination: 'acct_123456789',
}); metadata: {}
},
undefined
);
}); });
it('should handle transfer creation errors', async () => { it('should handle transfer creation errors', async () => {
@@ -433,17 +442,20 @@ describe('StripeService', () => {
destination: 'acct_123456789' destination: 'acct_123456789'
}); });
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({ expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
amount: 1, {
currency: 'usd', amount: 1,
destination: 'acct_123456789', currency: 'usd',
metadata: {} destination: 'acct_123456789',
}); metadata: {}
},
undefined
);
}); });
}); });
describe('createRefund', () => { describe('createRefund', () => {
it('should create refund with default parameters', async () => { it('should create refund with default parameters and idempotency key', async () => {
const mockRefund = { const mockRefund = {
id: 're_123456789', id: 're_123456789',
amount: 5000, // $50.00 in cents amount: 5000, // $50.00 in cents
@@ -465,18 +477,21 @@ describe('StripeService', () => {
} }
}); });
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({ expect(mockStripeRefundsCreate).toHaveBeenCalledWith(
payment_intent: 'pi_123456789', {
amount: 5000, // Converted to cents payment_intent: 'pi_123456789',
metadata: { amount: 5000, // Converted to cents
rentalId: '1' metadata: {
rentalId: '1'
},
reason: 'requested_by_customer'
}, },
reason: 'requested_by_customer' { idempotencyKey: 'refund_rental_1_5000' }
}); );
expect(result).toEqual(mockRefund); expect(result).toEqual(mockRefund);
}); });
it('should create refund with custom reason', async () => { it('should create refund with custom reason and no idempotency key when no rentalId', async () => {
const mockRefund = { const mockRefund = {
id: 're_123456789', id: 're_123456789',
amount: 10000, amount: 10000,
@@ -494,12 +509,15 @@ describe('StripeService', () => {
reason: 'fraudulent' reason: 'fraudulent'
}); });
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({ expect(mockStripeRefundsCreate).toHaveBeenCalledWith(
payment_intent: 'pi_123456789', {
amount: 10000, payment_intent: 'pi_123456789',
metadata: {}, amount: 10000,
reason: 'fraudulent' metadata: {},
}); reason: 'fraudulent'
},
undefined
);
expect(result).toEqual(mockRefund); expect(result).toEqual(mockRefund);
}); });
@@ -520,12 +538,15 @@ describe('StripeService', () => {
amount: 125.34 amount: 125.34
}); });
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({ expect(mockStripeRefundsCreate).toHaveBeenCalledWith(
payment_intent: 'pi_123456789', {
amount: 12534, // Properly converted to cents payment_intent: 'pi_123456789',
metadata: {}, amount: 12534, // Properly converted to cents
reason: 'requested_by_customer' metadata: {},
}); reason: 'requested_by_customer'
},
undefined
);
}); });
it('should handle refund creation errors', async () => { it('should handle refund creation errors', async () => {
@@ -612,7 +633,7 @@ describe('StripeService', () => {
}); });
describe('chargePaymentMethod', () => { describe('chargePaymentMethod', () => {
it('should charge payment method successfully', async () => { it('should charge payment method successfully with idempotency key', async () => {
const mockPaymentIntent = { const mockPaymentIntent = {
id: 'pi_123456789', id: 'pi_123456789',
status: 'succeeded', status: 'succeeded',
@@ -620,16 +641,14 @@ describe('StripeService', () => {
amount: 5000, amount: 5000,
currency: 'usd', currency: 'usd',
created: 1234567890, created: 1234567890,
charges: { latest_charge: {
data: [{ payment_method_details: {
payment_method_details: { type: 'card',
type: 'card', card: {
card: { brand: 'visa',
brand: 'visa', last4: '4242'
last4: '4242'
}
} }
}] }
} }
}; };
@@ -642,17 +661,20 @@ describe('StripeService', () => {
{ rentalId: '1' } { rentalId: '1' }
); );
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith({ expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
amount: 5000, // Converted to cents {
currency: 'usd', amount: 5000, // Converted to cents
payment_method: 'pm_123456789', currency: 'usd',
customer: 'cus_123456789', payment_method: 'pm_123456789',
confirm: true, customer: 'cus_123456789',
off_session: true, confirm: true,
return_url: 'http://localhost:3000/payment-complete', off_session: true,
metadata: { rentalId: '1' }, return_url: 'http://localhost:3000/complete-payment',
expand: ['charges.data.payment_method_details'] metadata: { rentalId: '1' },
}); expand: ['latest_charge.payment_method_details']
},
{ idempotencyKey: 'charge_rental_1' }
);
expect(result).toEqual({ expect(result).toEqual({
paymentIntentId: 'pi_123456789', paymentIntentId: 'pi_123456789',
@@ -684,7 +706,7 @@ describe('StripeService', () => {
); );
}); });
it('should use default frontend URL when not set', async () => { it('should use default frontend URL when not set and no idempotency key without rentalId', async () => {
delete process.env.FRONTEND_URL; delete process.env.FRONTEND_URL;
const mockPaymentIntent = { const mockPaymentIntent = {
@@ -703,8 +725,9 @@ describe('StripeService', () => {
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith( expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
return_url: 'http://localhost:3000/payment-complete' return_url: 'http://localhost:3000/complete-payment'
}) }),
undefined
); );
}); });
@@ -726,7 +749,8 @@ describe('StripeService', () => {
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith( expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
amount: 12534 // Properly converted to cents amount: 12534 // Properly converted to cents
}) }),
undefined
); );
}); });
@@ -942,12 +966,15 @@ describe('StripeService', () => {
destination: 'acct_123456789' destination: 'acct_123456789'
}); });
expect(mockStripeTransfersCreate).toHaveBeenCalledWith({ expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
amount: 99999999, {
currency: 'usd', amount: 99999999,
destination: 'acct_123456789', currency: 'usd',
metadata: {} destination: 'acct_123456789',
}); metadata: {}
},
undefined
);
}); });
it('should handle zero amounts', async () => { it('should handle zero amounts', async () => {
@@ -967,12 +994,15 @@ describe('StripeService', () => {
amount: 0 amount: 0
}); });
expect(mockStripeRefundsCreate).toHaveBeenCalledWith({ expect(mockStripeRefundsCreate).toHaveBeenCalledWith(
payment_intent: 'pi_123456789', {
amount: 0, payment_intent: 'pi_123456789',
metadata: {}, amount: 0,
reason: 'requested_by_customer' metadata: {},
}); reason: 'requested_by_customer'
},
undefined
);
}); });
it('should handle network timeout errors', async () => { it('should handle network timeout errors', async () => {
@@ -1006,4 +1036,91 @@ describe('StripeService', () => {
); );
}); });
}); });
describe('Idempotency key generation', () => {
it('should generate different refund idempotency keys for different amounts on same rental', async () => {
mockStripeRefundsCreate.mockResolvedValue({ id: 're_123' });
// First refund - $30
await StripeService.createRefund({
paymentIntentId: 'pi_123',
amount: 30.00,
metadata: { rentalId: 'rental-uuid-123' }
});
// Second refund - $20
await StripeService.createRefund({
paymentIntentId: 'pi_123',
amount: 20.00,
metadata: { rentalId: 'rental-uuid-123' }
});
// Verify different idempotency keys were used
expect(mockStripeRefundsCreate).toHaveBeenNthCalledWith(
1,
expect.any(Object),
{ idempotencyKey: 'refund_rental_rental-uuid-123_3000' }
);
expect(mockStripeRefundsCreate).toHaveBeenNthCalledWith(
2,
expect.any(Object),
{ idempotencyKey: 'refund_rental_rental-uuid-123_2000' }
);
});
it('should generate consistent transfer idempotency key for same rental', async () => {
mockStripeTransfersCreate.mockResolvedValue({ id: 'tr_123' });
const rentalId = 'rental-uuid-456';
// Call twice with same rental
await StripeService.createTransfer({
amount: 100.00,
destination: 'acct_123',
metadata: { rentalId }
});
await StripeService.createTransfer({
amount: 100.00,
destination: 'acct_123',
metadata: { rentalId }
});
// Both should have the same idempotency key
expect(mockStripeTransfersCreate).toHaveBeenNthCalledWith(
1,
expect.any(Object),
{ idempotencyKey: 'transfer_rental_rental-uuid-456' }
);
expect(mockStripeTransfersCreate).toHaveBeenNthCalledWith(
2,
expect.any(Object),
{ idempotencyKey: 'transfer_rental_rental-uuid-456' }
);
});
it('should generate consistent charge idempotency key for same rental', async () => {
mockStripePaymentIntentsCreate.mockResolvedValue({
id: 'pi_123',
status: 'succeeded',
client_secret: 'secret',
created: Date.now() / 1000,
latest_charge: null
});
const rentalId = 'rental-uuid-789';
await StripeService.chargePaymentMethod(
'pm_123',
100.00,
'cus_123',
{ rentalId }
);
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
expect.any(Object),
{ idempotencyKey: 'charge_rental_rental-uuid-789' }
);
});
});
}); });