1161 lines
33 KiB
JavaScript
1161 lines
33 KiB
JavaScript
// Mock Stripe SDK
|
|
const mockStripeCheckoutSessionsRetrieve = jest.fn();
|
|
const mockStripeAccountsCreate = jest.fn();
|
|
const mockStripeAccountsRetrieve = jest.fn();
|
|
const mockStripeAccountLinksCreate = jest.fn();
|
|
const mockStripeTransfersCreate = jest.fn();
|
|
const mockStripeRefundsCreate = jest.fn();
|
|
const mockStripeRefundsRetrieve = jest.fn();
|
|
const mockStripePaymentIntentsCreate = jest.fn();
|
|
const mockStripeCustomersCreate = jest.fn();
|
|
const mockStripeCheckoutSessionsCreate = jest.fn();
|
|
|
|
jest.mock('stripe', () => {
|
|
return jest.fn(() => ({
|
|
checkout: {
|
|
sessions: {
|
|
retrieve: mockStripeCheckoutSessionsRetrieve,
|
|
create: mockStripeCheckoutSessionsCreate
|
|
}
|
|
},
|
|
accounts: {
|
|
create: mockStripeAccountsCreate,
|
|
retrieve: mockStripeAccountsRetrieve
|
|
},
|
|
accountLinks: {
|
|
create: mockStripeAccountLinksCreate
|
|
},
|
|
transfers: {
|
|
create: mockStripeTransfersCreate
|
|
},
|
|
refunds: {
|
|
create: mockStripeRefundsCreate,
|
|
retrieve: mockStripeRefundsRetrieve
|
|
},
|
|
paymentIntents: {
|
|
create: mockStripePaymentIntentsCreate
|
|
},
|
|
customers: {
|
|
create: mockStripeCustomersCreate
|
|
}
|
|
}));
|
|
});
|
|
|
|
const mockLoggerError = jest.fn();
|
|
jest.mock('../../../utils/logger', () => ({
|
|
error: mockLoggerError,
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
withRequestId: jest.fn(() => ({
|
|
error: jest.fn(),
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
})),
|
|
}));
|
|
|
|
const StripeService = require('../../../services/stripeService');
|
|
|
|
describe('StripeService', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Set environment variables for tests
|
|
process.env.FRONTEND_URL = 'http://localhost:3000';
|
|
});
|
|
|
|
describe('getCheckoutSession', () => {
|
|
it('should retrieve checkout session successfully', async () => {
|
|
const mockSession = {
|
|
id: 'cs_123456789',
|
|
status: 'complete',
|
|
setup_intent: {
|
|
id: 'seti_123456789',
|
|
payment_method: {
|
|
id: 'pm_123456789',
|
|
type: 'card'
|
|
}
|
|
}
|
|
};
|
|
|
|
mockStripeCheckoutSessionsRetrieve.mockResolvedValue(mockSession);
|
|
|
|
const result = await StripeService.getCheckoutSession('cs_123456789');
|
|
|
|
expect(mockStripeCheckoutSessionsRetrieve).toHaveBeenCalledWith('cs_123456789', {
|
|
expand: ['setup_intent', 'setup_intent.payment_method']
|
|
});
|
|
expect(result).toEqual(mockSession);
|
|
});
|
|
|
|
it('should handle checkout session retrieval errors', async () => {
|
|
const stripeError = new Error('Session not found');
|
|
mockStripeCheckoutSessionsRetrieve.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.getCheckoutSession('invalid_session'))
|
|
.rejects.toThrow('Session not found');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error retrieving checkout session',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle missing session ID', async () => {
|
|
const stripeError = new Error('Invalid session ID');
|
|
mockStripeCheckoutSessionsRetrieve.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.getCheckoutSession(null))
|
|
.rejects.toThrow('Invalid session ID');
|
|
});
|
|
});
|
|
|
|
describe('createConnectedAccount', () => {
|
|
it('should create connected account with default country', async () => {
|
|
const mockAccount = {
|
|
id: 'acct_123456789',
|
|
type: 'express',
|
|
email: 'test@example.com',
|
|
country: 'US',
|
|
capabilities: {
|
|
transfers: { status: 'pending' }
|
|
}
|
|
};
|
|
|
|
mockStripeAccountsCreate.mockResolvedValue(mockAccount);
|
|
|
|
const result = await StripeService.createConnectedAccount({
|
|
email: 'test@example.com'
|
|
});
|
|
|
|
expect(mockStripeAccountsCreate).toHaveBeenCalledWith({
|
|
type: 'express',
|
|
email: 'test@example.com',
|
|
country: 'US',
|
|
capabilities: {
|
|
transfers: { requested: true }
|
|
}
|
|
});
|
|
expect(result).toEqual(mockAccount);
|
|
});
|
|
|
|
it('should create connected account with custom country', async () => {
|
|
const mockAccount = {
|
|
id: 'acct_123456789',
|
|
type: 'express',
|
|
email: 'test@example.com',
|
|
country: 'CA',
|
|
capabilities: {
|
|
transfers: { status: 'pending' }
|
|
}
|
|
};
|
|
|
|
mockStripeAccountsCreate.mockResolvedValue(mockAccount);
|
|
|
|
const result = await StripeService.createConnectedAccount({
|
|
email: 'test@example.com',
|
|
country: 'CA'
|
|
});
|
|
|
|
expect(mockStripeAccountsCreate).toHaveBeenCalledWith({
|
|
type: 'express',
|
|
email: 'test@example.com',
|
|
country: 'CA',
|
|
capabilities: {
|
|
transfers: { requested: true }
|
|
}
|
|
});
|
|
expect(result).toEqual(mockAccount);
|
|
});
|
|
|
|
it('should handle connected account creation errors', async () => {
|
|
const stripeError = new Error('Invalid email address');
|
|
mockStripeAccountsCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createConnectedAccount({
|
|
email: 'invalid-email'
|
|
})).rejects.toThrow('Invalid email address');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error creating connected account',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle missing email parameter', async () => {
|
|
const stripeError = new Error('Email is required');
|
|
mockStripeAccountsCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createConnectedAccount({}))
|
|
.rejects.toThrow('Email is required');
|
|
});
|
|
});
|
|
|
|
describe('createAccountLink', () => {
|
|
it('should create account link successfully', async () => {
|
|
const mockAccountLink = {
|
|
object: 'account_link',
|
|
url: 'https://connect.stripe.com/setup/e/acct_123456789',
|
|
created: Date.now(),
|
|
expires_at: Date.now() + 3600
|
|
};
|
|
|
|
mockStripeAccountLinksCreate.mockResolvedValue(mockAccountLink);
|
|
|
|
const result = await StripeService.createAccountLink(
|
|
'acct_123456789',
|
|
'http://localhost:3000/refresh',
|
|
'http://localhost:3000/return'
|
|
);
|
|
|
|
expect(mockStripeAccountLinksCreate).toHaveBeenCalledWith({
|
|
account: 'acct_123456789',
|
|
refresh_url: 'http://localhost:3000/refresh',
|
|
return_url: 'http://localhost:3000/return',
|
|
type: 'account_onboarding'
|
|
});
|
|
expect(result).toEqual(mockAccountLink);
|
|
});
|
|
|
|
it('should handle account link creation errors', async () => {
|
|
const stripeError = new Error('Account not found');
|
|
mockStripeAccountLinksCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createAccountLink(
|
|
'invalid_account',
|
|
'http://localhost:3000/refresh',
|
|
'http://localhost:3000/return'
|
|
)).rejects.toThrow('Account not found');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error creating account link',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle invalid URLs', async () => {
|
|
const stripeError = new Error('Invalid URL format');
|
|
mockStripeAccountLinksCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createAccountLink(
|
|
'acct_123456789',
|
|
'invalid-url',
|
|
'invalid-url'
|
|
)).rejects.toThrow('Invalid URL format');
|
|
});
|
|
});
|
|
|
|
describe('getAccountStatus', () => {
|
|
it('should retrieve account status successfully', async () => {
|
|
const mockAccount = {
|
|
id: 'acct_123456789',
|
|
details_submitted: true,
|
|
payouts_enabled: true,
|
|
capabilities: {
|
|
transfers: { status: 'active' }
|
|
},
|
|
requirements: {
|
|
pending_verification: [],
|
|
currently_due: [],
|
|
past_due: []
|
|
},
|
|
other_field: 'should_be_filtered_out'
|
|
};
|
|
|
|
mockStripeAccountsRetrieve.mockResolvedValue(mockAccount);
|
|
|
|
const result = await StripeService.getAccountStatus('acct_123456789');
|
|
|
|
expect(mockStripeAccountsRetrieve).toHaveBeenCalledWith('acct_123456789');
|
|
expect(result).toEqual({
|
|
id: 'acct_123456789',
|
|
details_submitted: true,
|
|
payouts_enabled: true,
|
|
capabilities: {
|
|
transfers: { status: 'active' }
|
|
},
|
|
requirements: {
|
|
pending_verification: [],
|
|
currently_due: [],
|
|
past_due: []
|
|
}
|
|
});
|
|
});
|
|
|
|
it('should handle account status retrieval errors', async () => {
|
|
const stripeError = new Error('Account not found');
|
|
mockStripeAccountsRetrieve.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.getAccountStatus('invalid_account'))
|
|
.rejects.toThrow('Account not found');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error retrieving account status',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle accounts with incomplete data', async () => {
|
|
const mockAccount = {
|
|
id: 'acct_123456789',
|
|
details_submitted: false,
|
|
payouts_enabled: false,
|
|
capabilities: null,
|
|
requirements: null
|
|
};
|
|
|
|
mockStripeAccountsRetrieve.mockResolvedValue(mockAccount);
|
|
|
|
const result = await StripeService.getAccountStatus('acct_123456789');
|
|
|
|
expect(result).toEqual({
|
|
id: 'acct_123456789',
|
|
details_submitted: false,
|
|
payouts_enabled: false,
|
|
capabilities: null,
|
|
requirements: null
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createTransfer', () => {
|
|
it('should create transfer with default currency and idempotency key', async () => {
|
|
const mockTransfer = {
|
|
id: 'tr_123456789',
|
|
amount: 5000, // $50.00 in cents
|
|
currency: 'usd',
|
|
destination: 'acct_123456789',
|
|
metadata: {
|
|
rentalId: '1',
|
|
ownerId: '2'
|
|
}
|
|
};
|
|
|
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
|
|
|
const result = await StripeService.createTransfer({
|
|
amount: 50.00,
|
|
destination: 'acct_123456789',
|
|
metadata: {
|
|
rentalId: '1',
|
|
ownerId: '2'
|
|
}
|
|
});
|
|
|
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
|
|
{
|
|
amount: 5000, // Converted to cents
|
|
currency: 'usd',
|
|
destination: 'acct_123456789',
|
|
metadata: {
|
|
rentalId: '1',
|
|
ownerId: '2'
|
|
}
|
|
},
|
|
{ idempotencyKey: 'transfer_rental_1' }
|
|
);
|
|
expect(result).toEqual(mockTransfer);
|
|
});
|
|
|
|
it('should create transfer with custom currency and no idempotency key when no rentalId', async () => {
|
|
const mockTransfer = {
|
|
id: 'tr_123456789',
|
|
amount: 5000,
|
|
currency: 'eur',
|
|
destination: 'acct_123456789',
|
|
metadata: {}
|
|
};
|
|
|
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
|
|
|
const result = await StripeService.createTransfer({
|
|
amount: 50.00,
|
|
currency: 'eur',
|
|
destination: 'acct_123456789'
|
|
});
|
|
|
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
|
|
{
|
|
amount: 5000,
|
|
currency: 'eur',
|
|
destination: 'acct_123456789',
|
|
metadata: {}
|
|
},
|
|
undefined
|
|
);
|
|
expect(result).toEqual(mockTransfer);
|
|
});
|
|
|
|
it('should handle decimal amounts correctly', async () => {
|
|
const mockTransfer = {
|
|
id: 'tr_123456789',
|
|
amount: 12534, // $125.34 in cents
|
|
currency: 'usd',
|
|
destination: 'acct_123456789',
|
|
metadata: {}
|
|
};
|
|
|
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
|
|
|
await StripeService.createTransfer({
|
|
amount: 125.34,
|
|
destination: 'acct_123456789'
|
|
});
|
|
|
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
|
|
{
|
|
amount: 12534, // Properly converted to cents
|
|
currency: 'usd',
|
|
destination: 'acct_123456789',
|
|
metadata: {}
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle transfer creation errors', async () => {
|
|
const stripeError = new Error('Insufficient funds');
|
|
mockStripeTransfersCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createTransfer({
|
|
amount: 50.00,
|
|
destination: 'acct_123456789'
|
|
})).rejects.toThrow('Insufficient funds');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error creating transfer',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle rounding for very small amounts', async () => {
|
|
const mockTransfer = {
|
|
id: 'tr_123456789',
|
|
amount: 1, // $0.005 rounded to 1 cent
|
|
currency: 'usd',
|
|
destination: 'acct_123456789',
|
|
metadata: {}
|
|
};
|
|
|
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
|
|
|
await StripeService.createTransfer({
|
|
amount: 0.005, // Should round to 1 cent
|
|
destination: 'acct_123456789'
|
|
});
|
|
|
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
|
|
{
|
|
amount: 1,
|
|
currency: 'usd',
|
|
destination: 'acct_123456789',
|
|
metadata: {}
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createRefund', () => {
|
|
it('should create refund with default parameters and idempotency key', async () => {
|
|
const mockRefund = {
|
|
id: 're_123456789',
|
|
amount: 5000, // $50.00 in cents
|
|
payment_intent: 'pi_123456789',
|
|
reason: 'requested_by_customer',
|
|
status: 'succeeded',
|
|
metadata: {
|
|
rentalId: '1'
|
|
}
|
|
};
|
|
|
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
|
|
|
const result = await StripeService.createRefund({
|
|
paymentIntentId: 'pi_123456789',
|
|
amount: 50.00,
|
|
metadata: {
|
|
rentalId: '1'
|
|
}
|
|
});
|
|
|
|
expect(mockStripeRefundsCreate).toHaveBeenCalledWith(
|
|
{
|
|
payment_intent: 'pi_123456789',
|
|
amount: 5000, // Converted to cents
|
|
metadata: {
|
|
rentalId: '1'
|
|
},
|
|
reason: 'requested_by_customer'
|
|
},
|
|
{ idempotencyKey: 'refund_rental_1_5000' }
|
|
);
|
|
expect(result).toEqual(mockRefund);
|
|
});
|
|
|
|
it('should create refund with custom reason and no idempotency key when no rentalId', async () => {
|
|
const mockRefund = {
|
|
id: 're_123456789',
|
|
amount: 10000,
|
|
payment_intent: 'pi_123456789',
|
|
reason: 'fraudulent',
|
|
status: 'succeeded',
|
|
metadata: {}
|
|
};
|
|
|
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
|
|
|
const result = await StripeService.createRefund({
|
|
paymentIntentId: 'pi_123456789',
|
|
amount: 100.00,
|
|
reason: 'fraudulent'
|
|
});
|
|
|
|
expect(mockStripeRefundsCreate).toHaveBeenCalledWith(
|
|
{
|
|
payment_intent: 'pi_123456789',
|
|
amount: 10000,
|
|
metadata: {},
|
|
reason: 'fraudulent'
|
|
},
|
|
undefined
|
|
);
|
|
expect(result).toEqual(mockRefund);
|
|
});
|
|
|
|
it('should handle decimal amounts correctly', async () => {
|
|
const mockRefund = {
|
|
id: 're_123456789',
|
|
amount: 12534, // $125.34 in cents
|
|
payment_intent: 'pi_123456789',
|
|
reason: 'requested_by_customer',
|
|
status: 'succeeded',
|
|
metadata: {}
|
|
};
|
|
|
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
|
|
|
await StripeService.createRefund({
|
|
paymentIntentId: 'pi_123456789',
|
|
amount: 125.34
|
|
});
|
|
|
|
expect(mockStripeRefundsCreate).toHaveBeenCalledWith(
|
|
{
|
|
payment_intent: 'pi_123456789',
|
|
amount: 12534, // Properly converted to cents
|
|
metadata: {},
|
|
reason: 'requested_by_customer'
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle refund creation errors', async () => {
|
|
const stripeError = new Error('Payment intent not found');
|
|
mockStripeRefundsCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createRefund({
|
|
paymentIntentId: 'pi_invalid',
|
|
amount: 50.00
|
|
})).rejects.toThrow('Payment intent not found');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error creating refund',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle partial refund scenarios', async () => {
|
|
const mockRefund = {
|
|
id: 're_123456789',
|
|
amount: 2500, // Partial refund of $25.00
|
|
payment_intent: 'pi_123456789',
|
|
reason: 'requested_by_customer',
|
|
status: 'succeeded',
|
|
metadata: {
|
|
type: 'partial'
|
|
}
|
|
};
|
|
|
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
|
|
|
const result = await StripeService.createRefund({
|
|
paymentIntentId: 'pi_123456789',
|
|
amount: 25.00,
|
|
metadata: {
|
|
type: 'partial'
|
|
}
|
|
});
|
|
|
|
expect(result.amount).toBe(2500);
|
|
expect(result.metadata.type).toBe('partial');
|
|
});
|
|
});
|
|
|
|
describe('getRefund', () => {
|
|
it('should retrieve refund successfully', async () => {
|
|
const mockRefund = {
|
|
id: 're_123456789',
|
|
amount: 5000,
|
|
payment_intent: 'pi_123456789',
|
|
reason: 'requested_by_customer',
|
|
status: 'succeeded',
|
|
created: Date.now()
|
|
};
|
|
|
|
mockStripeRefundsRetrieve.mockResolvedValue(mockRefund);
|
|
|
|
const result = await StripeService.getRefund('re_123456789');
|
|
|
|
expect(mockStripeRefundsRetrieve).toHaveBeenCalledWith('re_123456789');
|
|
expect(result).toEqual(mockRefund);
|
|
});
|
|
|
|
it('should handle refund retrieval errors', async () => {
|
|
const stripeError = new Error('Refund not found');
|
|
mockStripeRefundsRetrieve.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.getRefund('re_invalid'))
|
|
.rejects.toThrow('Refund not found');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error retrieving refund',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle null refund ID', async () => {
|
|
const stripeError = new Error('Invalid refund ID');
|
|
mockStripeRefundsRetrieve.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.getRefund(null))
|
|
.rejects.toThrow('Invalid refund ID');
|
|
});
|
|
});
|
|
|
|
describe('chargePaymentMethod', () => {
|
|
it('should charge payment method successfully with idempotency key', async () => {
|
|
const mockPaymentIntent = {
|
|
id: 'pi_123456789',
|
|
status: 'succeeded',
|
|
client_secret: 'pi_123456789_secret_test',
|
|
amount: 5000,
|
|
currency: 'usd',
|
|
created: 1234567890,
|
|
latest_charge: {
|
|
payment_method_details: {
|
|
type: 'card',
|
|
card: {
|
|
brand: 'visa',
|
|
last4: '4242'
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
|
|
|
const result = await StripeService.chargePaymentMethod(
|
|
'pm_123456789',
|
|
50.00,
|
|
'cus_123456789',
|
|
{ rentalId: '1' }
|
|
);
|
|
|
|
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
|
|
{
|
|
amount: 5000, // Converted to cents
|
|
currency: 'usd',
|
|
payment_method: 'pm_123456789',
|
|
customer: 'cus_123456789',
|
|
confirm: true,
|
|
off_session: true,
|
|
return_url: 'http://localhost:3000/complete-payment',
|
|
metadata: { rentalId: '1' },
|
|
expand: ['latest_charge.payment_method_details']
|
|
},
|
|
{ idempotencyKey: 'charge_rental_1' }
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
paymentIntentId: 'pi_123456789',
|
|
status: 'succeeded',
|
|
clientSecret: 'pi_123456789_secret_test',
|
|
paymentMethod: {
|
|
type: 'card',
|
|
brand: 'visa',
|
|
last4: '4242'
|
|
},
|
|
chargedAt: new Date(1234567890 * 1000),
|
|
amountCharged: 50.00
|
|
});
|
|
});
|
|
|
|
it('should handle payment method charge errors', async () => {
|
|
const stripeError = new Error('Payment method declined');
|
|
mockStripePaymentIntentsCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.chargePaymentMethod(
|
|
'pm_invalid',
|
|
50.00,
|
|
'cus_123456789'
|
|
)).rejects.toThrow('The payment could not be processed.');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Payment failed',
|
|
expect.objectContaining({
|
|
code: expect.any(String),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use default frontend URL when not set and no idempotency key without rentalId', async () => {
|
|
delete process.env.FRONTEND_URL;
|
|
|
|
const mockPaymentIntent = {
|
|
id: 'pi_123456789',
|
|
status: 'succeeded',
|
|
client_secret: 'pi_123456789_secret_test'
|
|
};
|
|
|
|
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
|
|
|
await StripeService.chargePaymentMethod(
|
|
'pm_123456789',
|
|
50.00,
|
|
'cus_123456789'
|
|
);
|
|
|
|
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
return_url: 'http://localhost:3000/complete-payment'
|
|
}),
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle decimal amounts correctly', async () => {
|
|
const mockPaymentIntent = {
|
|
id: 'pi_123456789',
|
|
status: 'succeeded',
|
|
client_secret: 'pi_123456789_secret_test'
|
|
};
|
|
|
|
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
|
|
|
await StripeService.chargePaymentMethod(
|
|
'pm_123456789',
|
|
125.34,
|
|
'cus_123456789'
|
|
);
|
|
|
|
expect(mockStripePaymentIntentsCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
amount: 12534 // Properly converted to cents
|
|
}),
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle payment requiring authentication', async () => {
|
|
const mockPaymentIntent = {
|
|
id: 'pi_123456789',
|
|
status: 'requires_action',
|
|
client_secret: 'pi_123456789_secret_test',
|
|
next_action: {
|
|
type: 'use_stripe_sdk'
|
|
}
|
|
};
|
|
|
|
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
|
|
|
|
const result = await StripeService.chargePaymentMethod(
|
|
'pm_123456789',
|
|
50.00,
|
|
'cus_123456789'
|
|
);
|
|
|
|
expect(result.status).toBe('requires_action');
|
|
expect(result.clientSecret).toBe('pi_123456789_secret_test');
|
|
});
|
|
});
|
|
|
|
describe('createCustomer', () => {
|
|
it('should create customer successfully', async () => {
|
|
const mockCustomer = {
|
|
id: 'cus_123456789',
|
|
email: 'test@example.com',
|
|
name: 'John Doe',
|
|
metadata: {
|
|
userId: '123'
|
|
},
|
|
created: Date.now()
|
|
};
|
|
|
|
mockStripeCustomersCreate.mockResolvedValue(mockCustomer);
|
|
|
|
const result = await StripeService.createCustomer({
|
|
email: 'test@example.com',
|
|
name: 'John Doe',
|
|
metadata: {
|
|
userId: '123'
|
|
}
|
|
});
|
|
|
|
expect(mockStripeCustomersCreate).toHaveBeenCalledWith({
|
|
email: 'test@example.com',
|
|
name: 'John Doe',
|
|
metadata: {
|
|
userId: '123'
|
|
}
|
|
});
|
|
expect(result).toEqual(mockCustomer);
|
|
});
|
|
|
|
it('should create customer with minimal data', async () => {
|
|
const mockCustomer = {
|
|
id: 'cus_123456789',
|
|
email: 'test@example.com',
|
|
name: null,
|
|
metadata: {}
|
|
};
|
|
|
|
mockStripeCustomersCreate.mockResolvedValue(mockCustomer);
|
|
|
|
const result = await StripeService.createCustomer({
|
|
email: 'test@example.com'
|
|
});
|
|
|
|
expect(mockStripeCustomersCreate).toHaveBeenCalledWith({
|
|
email: 'test@example.com',
|
|
name: undefined,
|
|
metadata: {}
|
|
});
|
|
expect(result).toEqual(mockCustomer);
|
|
});
|
|
|
|
it('should handle customer creation errors', async () => {
|
|
const stripeError = new Error('Invalid email format');
|
|
mockStripeCustomersCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createCustomer({
|
|
email: 'invalid-email'
|
|
})).rejects.toThrow('Invalid email format');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error creating customer',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle duplicate customer errors', async () => {
|
|
const stripeError = new Error('Customer already exists');
|
|
mockStripeCustomersCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createCustomer({
|
|
email: 'existing@example.com',
|
|
name: 'Existing User'
|
|
})).rejects.toThrow('Customer already exists');
|
|
});
|
|
});
|
|
|
|
describe('createSetupCheckoutSession', () => {
|
|
it('should create setup checkout session successfully', async () => {
|
|
const mockSession = {
|
|
id: 'cs_123456789',
|
|
url: null,
|
|
client_secret: 'cs_123456789_secret_test',
|
|
customer: 'cus_123456789',
|
|
mode: 'setup',
|
|
ui_mode: 'embedded',
|
|
metadata: {
|
|
type: 'payment_method_setup',
|
|
userId: '123'
|
|
}
|
|
};
|
|
|
|
mockStripeCheckoutSessionsCreate.mockResolvedValue(mockSession);
|
|
|
|
const result = await StripeService.createSetupCheckoutSession({
|
|
customerId: 'cus_123456789',
|
|
metadata: {
|
|
userId: '123'
|
|
}
|
|
});
|
|
|
|
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
|
|
customer: 'cus_123456789',
|
|
payment_method_types: ['card', 'link'],
|
|
mode: 'setup',
|
|
ui_mode: 'embedded',
|
|
redirect_on_completion: 'never',
|
|
payment_method_options: {
|
|
card: {
|
|
request_three_d_secure: 'any',
|
|
},
|
|
},
|
|
metadata: {
|
|
type: 'payment_method_setup',
|
|
userId: '123'
|
|
}
|
|
});
|
|
expect(result).toEqual(mockSession);
|
|
});
|
|
|
|
it('should create setup checkout session with minimal data', async () => {
|
|
const mockSession = {
|
|
id: 'cs_123456789',
|
|
url: null,
|
|
client_secret: 'cs_123456789_secret_test',
|
|
customer: 'cus_123456789',
|
|
mode: 'setup',
|
|
ui_mode: 'embedded',
|
|
metadata: {
|
|
type: 'payment_method_setup'
|
|
}
|
|
};
|
|
|
|
mockStripeCheckoutSessionsCreate.mockResolvedValue(mockSession);
|
|
|
|
const result = await StripeService.createSetupCheckoutSession({
|
|
customerId: 'cus_123456789'
|
|
});
|
|
|
|
expect(mockStripeCheckoutSessionsCreate).toHaveBeenCalledWith({
|
|
customer: 'cus_123456789',
|
|
payment_method_types: ['card', 'link'],
|
|
mode: 'setup',
|
|
ui_mode: 'embedded',
|
|
redirect_on_completion: 'never',
|
|
payment_method_options: {
|
|
card: {
|
|
request_three_d_secure: 'any',
|
|
},
|
|
},
|
|
metadata: {
|
|
type: 'payment_method_setup'
|
|
}
|
|
});
|
|
expect(result).toEqual(mockSession);
|
|
});
|
|
|
|
it('should handle setup checkout session creation errors', async () => {
|
|
const stripeError = new Error('Customer not found');
|
|
mockStripeCheckoutSessionsCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createSetupCheckoutSession({
|
|
customerId: 'cus_invalid'
|
|
})).rejects.toThrow('Customer not found');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error creating setup checkout session',
|
|
expect.objectContaining({
|
|
error: stripeError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle missing customer ID', async () => {
|
|
const stripeError = new Error('Customer ID is required');
|
|
mockStripeCheckoutSessionsCreate.mockRejectedValue(stripeError);
|
|
|
|
await expect(StripeService.createSetupCheckoutSession({}))
|
|
.rejects.toThrow('Customer ID is required');
|
|
});
|
|
});
|
|
|
|
describe('Error handling and edge cases', () => {
|
|
it('should handle very large monetary amounts', async () => {
|
|
const mockTransfer = {
|
|
id: 'tr_123456789',
|
|
amount: 99999999, // $999,999.99 in cents
|
|
currency: 'usd',
|
|
destination: 'acct_123456789',
|
|
metadata: {}
|
|
};
|
|
|
|
mockStripeTransfersCreate.mockResolvedValue(mockTransfer);
|
|
|
|
await StripeService.createTransfer({
|
|
amount: 999999.99,
|
|
destination: 'acct_123456789'
|
|
});
|
|
|
|
expect(mockStripeTransfersCreate).toHaveBeenCalledWith(
|
|
{
|
|
amount: 99999999,
|
|
currency: 'usd',
|
|
destination: 'acct_123456789',
|
|
metadata: {}
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle zero amounts', async () => {
|
|
const mockRefund = {
|
|
id: 're_123456789',
|
|
amount: 0,
|
|
payment_intent: 'pi_123456789',
|
|
reason: 'requested_by_customer',
|
|
status: 'succeeded',
|
|
metadata: {}
|
|
};
|
|
|
|
mockStripeRefundsCreate.mockResolvedValue(mockRefund);
|
|
|
|
await StripeService.createRefund({
|
|
paymentIntentId: 'pi_123456789',
|
|
amount: 0
|
|
});
|
|
|
|
expect(mockStripeRefundsCreate).toHaveBeenCalledWith(
|
|
{
|
|
payment_intent: 'pi_123456789',
|
|
amount: 0,
|
|
metadata: {},
|
|
reason: 'requested_by_customer'
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle network timeout errors', async () => {
|
|
const timeoutError = new Error('Request timeout');
|
|
timeoutError.type = 'StripeConnectionError';
|
|
mockStripeTransfersCreate.mockRejectedValue(timeoutError);
|
|
|
|
await expect(StripeService.createTransfer({
|
|
amount: 50.00,
|
|
destination: 'acct_123456789'
|
|
})).rejects.toThrow('Request timeout');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error creating transfer',
|
|
expect.objectContaining({
|
|
error: timeoutError.message,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle API key errors', async () => {
|
|
const apiKeyError = new Error('Invalid API key');
|
|
apiKeyError.type = 'StripeAuthenticationError';
|
|
mockStripeCustomersCreate.mockRejectedValue(apiKeyError);
|
|
|
|
await expect(StripeService.createCustomer({
|
|
email: 'test@example.com'
|
|
})).rejects.toThrow('Invalid API key');
|
|
|
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
'Error creating customer',
|
|
expect.objectContaining({
|
|
error: apiKeyError.message,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
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' }
|
|
);
|
|
});
|
|
});
|
|
}); |