idempotency for stripe transfer, refund, charge
This commit is contained in:
@@ -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") {
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user