// 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: '****' }); }); }); });