Files
rentall-app/backend/tests/unit/services/stripeService.test.js
2026-01-19 19:22:01 -05:00

1684 lines
50 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();
const mockStripeAccountSessionsCreate = jest.fn();
const mockStripePaymentMethodsRetrieve = jest.fn();
jest.mock('stripe', () => {
return jest.fn(() => ({
checkout: {
sessions: {
retrieve: mockStripeCheckoutSessionsRetrieve,
create: mockStripeCheckoutSessionsCreate
}
},
accounts: {
create: mockStripeAccountsCreate,
retrieve: mockStripeAccountsRetrieve
},
accountLinks: {
create: mockStripeAccountLinksCreate
},
accountSessions: {
create: mockStripeAccountSessionsCreate
},
transfers: {
create: mockStripeTransfersCreate
},
refunds: {
create: mockStripeRefundsCreate,
retrieve: mockStripeRefundsRetrieve
},
paymentIntents: {
create: mockStripePaymentIntentsCreate
},
customers: {
create: mockStripeCustomersCreate
},
paymentMethods: {
retrieve: mockStripePaymentMethodsRetrieve
}
}));
});
const mockLoggerError = jest.fn();
const mockLoggerWarn = jest.fn();
const mockLoggerInfo = jest.fn();
jest.mock('../../../utils/logger', () => ({
error: mockLoggerError,
info: mockLoggerInfo,
warn: mockLoggerWarn,
withRequestId: jest.fn(() => ({
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
})),
}));
// Mock User model
const mockUserFindOne = jest.fn();
const mockUserUpdate = jest.fn();
jest.mock('../../../models', () => ({
User: {
findOne: mockUserFindOne
}
}));
// Mock email services
const mockSendAccountDisconnectedEmail = jest.fn();
jest.mock('../../../services/email', () => ({
payment: {
sendAccountDisconnectedEmail: mockSendAccountDisconnectedEmail
}
}));
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' }
);
});
});
describe('createAccountSession', () => {
it('should create account session successfully', async () => {
const mockSession = {
object: 'account_session',
client_secret: 'acct_sess_secret_123',
expires_at: Date.now() + 3600
};
mockStripeAccountSessionsCreate.mockResolvedValue(mockSession);
const result = await StripeService.createAccountSession('acct_123456789');
expect(mockStripeAccountSessionsCreate).toHaveBeenCalledWith({
account: 'acct_123456789',
components: {
account_onboarding: { enabled: true }
}
});
expect(result).toEqual(mockSession);
});
it('should handle account session creation errors', async () => {
const stripeError = new Error('Account not found');
mockStripeAccountSessionsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createAccountSession('invalid_account'))
.rejects.toThrow('Account not found');
expect(mockLoggerError).toHaveBeenCalledWith(
'Error creating account session',
expect.objectContaining({
error: stripeError.message,
})
);
});
it('should handle invalid account ID', async () => {
const stripeError = new Error('Invalid account ID format');
mockStripeAccountSessionsCreate.mockRejectedValue(stripeError);
await expect(StripeService.createAccountSession(null))
.rejects.toThrow('Invalid account ID format');
});
});
describe('getPaymentMethod', () => {
it('should retrieve payment method successfully', async () => {
const mockPaymentMethod = {
id: 'pm_123456789',
type: 'card',
card: {
brand: 'visa',
last4: '4242',
exp_month: 12,
exp_year: 2025
},
customer: 'cus_123456789'
};
mockStripePaymentMethodsRetrieve.mockResolvedValue(mockPaymentMethod);
const result = await StripeService.getPaymentMethod('pm_123456789');
expect(mockStripePaymentMethodsRetrieve).toHaveBeenCalledWith('pm_123456789');
expect(result).toEqual(mockPaymentMethod);
});
it('should handle payment method retrieval errors', async () => {
const stripeError = new Error('Payment method not found');
mockStripePaymentMethodsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getPaymentMethod('pm_invalid'))
.rejects.toThrow('Payment method not found');
expect(mockLoggerError).toHaveBeenCalledWith(
'Error retrieving payment method',
expect.objectContaining({
error: stripeError.message,
paymentMethodId: 'pm_invalid'
})
);
});
it('should handle null payment method ID', async () => {
const stripeError = new Error('Invalid payment method ID');
mockStripePaymentMethodsRetrieve.mockRejectedValue(stripeError);
await expect(StripeService.getPaymentMethod(null))
.rejects.toThrow('Invalid payment method ID');
});
});
describe('isAccountDisconnectedError', () => {
it('should return true for account_invalid error code', () => {
const error = { code: 'account_invalid', message: 'Account is invalid' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for platform_api_key_expired error code', () => {
const error = { code: 'platform_api_key_expired', message: 'API key expired' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for error message containing "cannot transfer"', () => {
const error = { code: 'some_code', message: 'You cannot transfer to this account' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for error message containing "not connected"', () => {
const error = { code: 'some_code', message: 'This account is not connected to your platform' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for error message containing "no longer connected"', () => {
const error = { code: 'some_code', message: 'This account is no longer connected' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return true for error message containing "account has been deauthorized"', () => {
const error = { code: 'some_code', message: 'The account has been deauthorized' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(true);
});
it('should return false for unrelated error codes', () => {
const error = { code: 'card_declined', message: 'Card was declined' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(false);
});
it('should return false for unrelated error messages', () => {
const error = { code: 'some_code', message: 'Insufficient funds in account' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(false);
});
it('should handle error with no message', () => {
const error = { code: 'some_code' };
expect(StripeService.isAccountDisconnectedError(error)).toBe(false);
});
it('should handle error with undefined message', () => {
const error = { code: 'some_code', message: undefined };
expect(StripeService.isAccountDisconnectedError(error)).toBe(false);
});
});
describe('handleDisconnectedAccount', () => {
beforeEach(() => {
mockUserFindOne.mockReset();
mockSendAccountDisconnectedEmail.mockReset();
});
it('should clear user stripe connection data', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockResolvedValue(true);
await StripeService.handleDisconnectedAccount('acct_123456789');
expect(mockUserFindOne).toHaveBeenCalledWith({
where: { stripeConnectedAccountId: 'acct_123456789' }
});
expect(mockUserUpdate).toHaveBeenCalledWith({
stripeConnectedAccountId: null,
stripePayoutsEnabled: false
});
});
it('should send account disconnected email', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockResolvedValue(true);
await StripeService.handleDisconnectedAccount('acct_123456789');
expect(mockSendAccountDisconnectedEmail).toHaveBeenCalledWith('test@example.com', {
ownerName: 'John',
hasPendingPayouts: true,
pendingPayoutCount: 1
});
});
it('should do nothing when user not found', async () => {
mockUserFindOne.mockResolvedValue(null);
await StripeService.handleDisconnectedAccount('acct_nonexistent');
expect(mockUserFindOne).toHaveBeenCalledWith({
where: { stripeConnectedAccountId: 'acct_nonexistent' }
});
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockSendAccountDisconnectedEmail).not.toHaveBeenCalled();
});
it('should handle email sending errors gracefully', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockRejectedValue(new Error('Email service down'));
// Should not throw
await expect(StripeService.handleDisconnectedAccount('acct_123456789'))
.resolves.not.toThrow();
// Should have logged the error
expect(mockLoggerError).toHaveBeenCalledWith(
'Failed to clean up disconnected account',
expect.objectContaining({
accountId: 'acct_123456789'
})
);
});
it('should handle user update errors gracefully', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
update: jest.fn().mockRejectedValue(new Error('Database error'))
};
mockUserFindOne.mockResolvedValue(mockUser);
// Should not throw
await expect(StripeService.handleDisconnectedAccount('acct_123456789'))
.resolves.not.toThrow();
expect(mockLoggerError).toHaveBeenCalledWith(
'Failed to clean up disconnected account',
expect.objectContaining({
accountId: 'acct_123456789'
})
);
});
it('should use lastName as fallback when firstName is not available', async () => {
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: null,
lastName: 'Doe',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockResolvedValue(true);
await StripeService.handleDisconnectedAccount('acct_123456789');
expect(mockSendAccountDisconnectedEmail).toHaveBeenCalledWith('test@example.com', {
ownerName: 'Doe',
hasPendingPayouts: true,
pendingPayoutCount: 1
});
});
});
describe('createTransfer - disconnected account handling', () => {
beforeEach(() => {
mockUserFindOne.mockReset();
mockSendAccountDisconnectedEmail.mockReset();
mockLoggerWarn.mockReset();
});
it('should call handleDisconnectedAccount when account_invalid error occurs', async () => {
const disconnectedError = new Error('The account has been deauthorized');
disconnectedError.code = 'account_invalid';
mockStripeTransfersCreate.mockRejectedValue(disconnectedError);
const mockUser = {
id: 123,
email: 'test@example.com',
firstName: 'John',
update: mockUserUpdate.mockResolvedValue(true)
};
mockUserFindOne.mockResolvedValue(mockUser);
mockSendAccountDisconnectedEmail.mockResolvedValue(true);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_disconnected'
})).rejects.toThrow('The account has been deauthorized');
// Wait for async handleDisconnectedAccount to complete
await new Promise(resolve => setTimeout(resolve, 10));
expect(mockLoggerWarn).toHaveBeenCalledWith(
'Transfer failed - account appears disconnected',
expect.objectContaining({
destination: 'acct_disconnected',
errorCode: 'account_invalid'
})
);
});
it('should still throw the original error after cleanup', async () => {
const disconnectedError = new Error('Cannot transfer to this account');
disconnectedError.code = 'account_invalid';
mockStripeTransfersCreate.mockRejectedValue(disconnectedError);
mockUserFindOne.mockResolvedValue(null);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_disconnected'
})).rejects.toThrow('Cannot transfer to this account');
});
it('should log warning for disconnected account errors', async () => {
const disconnectedError = new Error('This account is no longer connected');
disconnectedError.code = 'some_error';
disconnectedError.type = 'StripeInvalidRequestError';
mockStripeTransfersCreate.mockRejectedValue(disconnectedError);
mockUserFindOne.mockResolvedValue(null);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_disconnected'
})).rejects.toThrow('This account is no longer connected');
expect(mockLoggerWarn).toHaveBeenCalledWith(
'Transfer failed - account appears disconnected',
expect.objectContaining({
destination: 'acct_disconnected'
})
);
});
it('should not call handleDisconnectedAccount for non-disconnection errors', async () => {
const normalError = new Error('Insufficient balance');
normalError.code = 'insufficient_balance';
mockStripeTransfersCreate.mockRejectedValue(normalError);
await expect(StripeService.createTransfer({
amount: 50.00,
destination: 'acct_123'
})).rejects.toThrow('Insufficient balance');
expect(mockLoggerWarn).not.toHaveBeenCalledWith(
'Transfer failed - account appears disconnected',
expect.any(Object)
);
});
});
describe('chargePaymentMethod - additional cases', () => {
it('should handle authentication_required error and return requires_action', async () => {
const authError = new Error('Authentication required');
authError.code = 'authentication_required';
authError.payment_intent = {
id: 'pi_requires_auth',
client_secret: 'pi_requires_auth_secret'
};
mockStripePaymentIntentsCreate.mockRejectedValue(authError);
const result = await StripeService.chargePaymentMethod(
'pm_123',
50.00,
'cus_123'
);
expect(result.status).toBe('requires_action');
expect(result.requiresAction).toBe(true);
expect(result.paymentIntentId).toBe('pi_requires_auth');
expect(result.clientSecret).toBe('pi_requires_auth_secret');
});
it('should handle us_bank_account payment method type', async () => {
const mockPaymentIntent = {
id: 'pi_bank',
status: 'succeeded',
client_secret: 'pi_bank_secret',
created: Date.now() / 1000,
latest_charge: {
payment_method_details: {
type: 'us_bank_account',
us_bank_account: {
last4: '6789',
bank_name: 'Test Bank'
}
}
}
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_bank_123',
50.00,
'cus_123'
);
expect(result.status).toBe('succeeded');
expect(result.paymentMethod).toEqual({
type: 'bank',
brand: 'bank_account',
last4: '6789'
});
});
it('should handle unknown payment method type', async () => {
const mockPaymentIntent = {
id: 'pi_unknown',
status: 'succeeded',
client_secret: 'pi_unknown_secret',
created: Date.now() / 1000,
latest_charge: {
payment_method_details: {
type: 'crypto_wallet'
}
}
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_crypto_123',
50.00,
'cus_123'
);
expect(result.status).toBe('succeeded');
expect(result.paymentMethod).toEqual({
type: 'crypto_wallet',
brand: 'crypto_wallet',
last4: null
});
});
it('should handle payment with no charge details', async () => {
const mockPaymentIntent = {
id: 'pi_no_charge',
status: 'succeeded',
client_secret: 'pi_no_charge_secret',
created: Date.now() / 1000,
latest_charge: null
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_123',
50.00,
'cus_123'
);
expect(result.status).toBe('succeeded');
expect(result.paymentMethod).toBeNull();
});
it('should handle card with missing details gracefully', async () => {
const mockPaymentIntent = {
id: 'pi_card_no_details',
status: 'succeeded',
client_secret: 'pi_card_secret',
created: Date.now() / 1000,
latest_charge: {
payment_method_details: {
type: 'card',
card: {} // Missing brand and last4
}
}
};
mockStripePaymentIntentsCreate.mockResolvedValue(mockPaymentIntent);
const result = await StripeService.chargePaymentMethod(
'pm_123',
50.00,
'cus_123'
);
expect(result.status).toBe('succeeded');
expect(result.paymentMethod).toEqual({
type: 'card',
brand: 'card',
last4: '****'
});
});
});
});