// Mock dependencies - define mocks inline to avoid hoisting issues jest.mock('../../../models', () => ({ Rental: { findAll: jest.fn(), update: jest.fn() }, User: jest.fn(), Item: jest.fn() })); jest.mock('../../../services/stripeService', () => ({ createTransfer: jest.fn() })); jest.mock('sequelize', () => ({ Op: { not: 'not' } })); const mockLoggerError = jest.fn(); const mockLoggerInfo = jest.fn(); jest.mock('../../../utils/logger', () => ({ error: mockLoggerError, info: mockLoggerInfo, warn: jest.fn(), withRequestId: jest.fn(() => ({ error: jest.fn(), info: jest.fn(), warn: jest.fn(), })), })); const PayoutService = require('../../../services/payoutService'); const { Rental, User, Item } = require('../../../models'); const StripeService = require('../../../services/stripeService'); // Get references to mocks after importing const mockRentalFindAll = Rental.findAll; const mockRentalUpdate = Rental.update; const mockUserModel = User; const mockItemModel = Item; const mockCreateTransfer = StripeService.createTransfer; describe('PayoutService', () => { let consoleSpy; beforeEach(() => { jest.clearAllMocks(); consoleSpy = jest.spyOn(console, 'log').mockImplementation(); }); afterEach(() => { consoleSpy.mockRestore(); }); describe('getEligiblePayouts', () => { it('should return eligible rentals for payout', async () => { const mockRentals = [ { id: 1, status: 'completed', paymentStatus: 'paid', payoutStatus: 'pending', owner: { id: 1, stripeConnectedAccountId: 'acct_123' } }, { id: 2, status: 'completed', paymentStatus: 'paid', payoutStatus: 'pending', owner: { id: 2, stripeConnectedAccountId: 'acct_456' } } ]; mockRentalFindAll.mockResolvedValue(mockRentals); const result = await PayoutService.getEligiblePayouts(); expect(mockRentalFindAll).toHaveBeenCalledWith({ where: { status: 'completed', paymentStatus: 'paid', payoutStatus: 'pending' }, include: [ { model: mockUserModel, as: 'owner', where: { stripeConnectedAccountId: { 'not': null }, stripePayoutsEnabled: true } }, { model: mockItemModel, as: 'item' } ] }); expect(result).toEqual(mockRentals); }); it('should handle database errors', async () => { const dbError = new Error('Database connection failed'); mockRentalFindAll.mockRejectedValue(dbError); await expect(PayoutService.getEligiblePayouts()).rejects.toThrow('Database connection failed'); expect(mockLoggerError).toHaveBeenCalledWith('Error getting eligible payouts', expect.objectContaining({ error: dbError.message })); }); it('should return empty array when no eligible rentals found', async () => { mockRentalFindAll.mockResolvedValue([]); const result = await PayoutService.getEligiblePayouts(); expect(result).toEqual([]); }); }); describe('processRentalPayout', () => { let mockRental; beforeEach(() => { mockRental = { id: 1, ownerId: 2, payoutStatus: 'pending', payoutAmount: 9500, // $95.00 totalAmount: 10000, // $100.00 platformFee: 500, // $5.00 startDateTime: new Date('2023-01-01T10:00:00Z'), endDateTime: new Date('2023-01-02T10:00:00Z'), owner: { id: 2, stripeConnectedAccountId: 'acct_123' }, update: jest.fn().mockResolvedValue(true) }; }); describe('Validation', () => { it('should throw error when owner has no connected Stripe account', async () => { mockRental.owner.stripeConnectedAccountId = null; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Owner does not have a connected Stripe account'); }); it('should throw error when owner is missing', async () => { mockRental.owner = null; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Owner does not have a connected Stripe account'); }); it('should throw error when payout already processed', async () => { mockRental.payoutStatus = 'completed'; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Rental payout has already been processed'); }); it('should throw error when payout amount is invalid', async () => { mockRental.payoutAmount = 0; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Invalid payout amount'); }); it('should throw error when payout amount is negative', async () => { mockRental.payoutAmount = -100; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Invalid payout amount'); }); it('should throw error when payout amount is null', async () => { mockRental.payoutAmount = null; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Invalid payout amount'); }); }); describe('Successful processing', () => { beforeEach(() => { mockCreateTransfer.mockResolvedValue({ id: 'tr_123456789', amount: 9500, destination: 'acct_123' }); }); it('should successfully process a rental payout', async () => { const result = await PayoutService.processRentalPayout(mockRental); // Verify Stripe transfer creation expect(mockCreateTransfer).toHaveBeenCalledWith({ amount: 9500, destination: 'acct_123', metadata: { rentalId: 1, ownerId: 2, totalAmount: '10000', platformFee: '500', startDateTime: '2023-01-01T10:00:00.000Z', endDateTime: '2023-01-02T10:00:00.000Z' } }); // Verify status update to completed expect(mockRental.update).toHaveBeenCalledWith({ payoutStatus: 'completed', payoutProcessedAt: expect.any(Date), stripeTransferId: 'tr_123456789' }); // Verify success log expect(consoleSpy).toHaveBeenCalledWith( 'Payout completed for rental 1: $9500 to acct_123' ); // Verify return value expect(result).toEqual({ success: true, transferId: 'tr_123456789', amount: 9500 }); }); it('should handle successful payout with different amounts', async () => { mockRental.payoutAmount = 15000; mockRental.totalAmount = 16000; mockRental.platformFee = 1000; mockCreateTransfer.mockResolvedValue({ id: 'tr_987654321', amount: 15000, destination: 'acct_123' }); const result = await PayoutService.processRentalPayout(mockRental); expect(mockCreateTransfer).toHaveBeenCalledWith({ amount: 15000, destination: 'acct_123', metadata: expect.objectContaining({ totalAmount: '16000', platformFee: '1000' }) }); expect(result.amount).toBe(15000); expect(result.transferId).toBe('tr_987654321'); }); }); describe('Error handling', () => { it('should handle Stripe transfer creation errors', async () => { const stripeError = new Error('Stripe transfer failed'); mockCreateTransfer.mockRejectedValue(stripeError); await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Stripe transfer failed'); // Verify failure status was set expect(mockRental.update).toHaveBeenCalledWith({ payoutStatus: 'failed' }); expect(mockLoggerError).toHaveBeenCalledWith( 'Error processing payout for rental', expect.objectContaining({ rentalId: 1 }) ); }); it('should handle database update errors during processing', async () => { // Stripe succeeds but database update fails mockCreateTransfer.mockResolvedValue({ id: 'tr_123456789', amount: 9500 }); const dbError = new Error('Database update failed'); mockRental.update.mockRejectedValueOnce(dbError); await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Database update failed'); expect(mockLoggerError).toHaveBeenCalledWith( 'Error processing payout for rental', expect.objectContaining({ rentalId: 1 }) ); }); it('should handle database update errors during completion', async () => { mockCreateTransfer.mockResolvedValue({ id: 'tr_123456789', amount: 9500 }); const dbError = new Error('Database completion update failed'); mockRental.update.mockRejectedValueOnce(dbError); await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Database completion update failed'); expect(mockCreateTransfer).toHaveBeenCalled(); expect(mockLoggerError).toHaveBeenCalledWith( 'Error processing payout for rental', expect.objectContaining({ rentalId: 1 }) ); }); it('should handle failure status update errors gracefully', async () => { const stripeError = new Error('Stripe transfer failed'); const updateError = new Error('Update failed status failed'); mockCreateTransfer.mockRejectedValue(stripeError); mockRental.update.mockRejectedValueOnce(updateError); // The service will throw the update error since it happens in the catch block await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Update failed status failed'); // Should still attempt to update to failed status expect(mockRental.update).toHaveBeenCalledWith({ payoutStatus: 'failed' }); }); }); }); describe('processAllEligiblePayouts', () => { beforeEach(() => { jest.spyOn(PayoutService, 'getEligiblePayouts'); jest.spyOn(PayoutService, 'processRentalPayout'); }); afterEach(() => { PayoutService.getEligiblePayouts.mockRestore(); PayoutService.processRentalPayout.mockRestore(); }); it('should process all eligible payouts successfully', async () => { const mockRentals = [ { id: 1, payoutAmount: 9500 }, { id: 2, payoutAmount: 7500 } ]; PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals); PayoutService.processRentalPayout .mockResolvedValueOnce({ success: true, transferId: 'tr_123', amount: 9500 }) .mockResolvedValueOnce({ success: true, transferId: 'tr_456', amount: 7500 }); const result = await PayoutService.processAllEligiblePayouts(); expect(consoleSpy).toHaveBeenCalledWith('Found 2 eligible rentals for payout'); expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 0 failed'); expect(result).toEqual({ successful: [ { rentalId: 1, amount: 9500, transferId: 'tr_123' }, { rentalId: 2, amount: 7500, transferId: 'tr_456' } ], failed: [], totalProcessed: 2 }); }); it('should handle mixed success and failure results', async () => { const mockRentals = [ { id: 1, payoutAmount: 9500 }, { id: 2, payoutAmount: 7500 }, { id: 3, payoutAmount: 12000 } ]; PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals); PayoutService.processRentalPayout .mockResolvedValueOnce({ success: true, transferId: 'tr_123', amount: 9500 }) .mockRejectedValueOnce(new Error('Stripe account suspended')) .mockResolvedValueOnce({ success: true, transferId: 'tr_789', amount: 12000 }); const result = await PayoutService.processAllEligiblePayouts(); expect(consoleSpy).toHaveBeenCalledWith('Found 3 eligible rentals for payout'); expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 1 failed'); expect(result).toEqual({ successful: [ { rentalId: 1, amount: 9500, transferId: 'tr_123' }, { rentalId: 3, amount: 12000, transferId: 'tr_789' } ], failed: [ { rentalId: 2, error: 'Stripe account suspended' } ], totalProcessed: 3 }); }); it('should handle no eligible payouts', async () => { PayoutService.getEligiblePayouts.mockResolvedValue([]); const result = await PayoutService.processAllEligiblePayouts(); expect(consoleSpy).toHaveBeenCalledWith('Found 0 eligible rentals for payout'); expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 0 successful, 0 failed'); expect(result).toEqual({ successful: [], failed: [], totalProcessed: 0 }); expect(PayoutService.processRentalPayout).not.toHaveBeenCalled(); }); it('should handle errors in getEligiblePayouts', async () => { const dbError = new Error('Database connection failed'); PayoutService.getEligiblePayouts.mockRejectedValue(dbError); await expect(PayoutService.processAllEligiblePayouts()) .rejects.toThrow('Database connection failed'); expect(mockLoggerError).toHaveBeenCalledWith( 'Error processing all eligible payouts', expect.objectContaining({ error: dbError.message }) ); }); it('should handle all payouts failing', async () => { const mockRentals = [ { id: 1, payoutAmount: 9500 }, { id: 2, payoutAmount: 7500 } ]; PayoutService.getEligiblePayouts.mockResolvedValue(mockRentals); PayoutService.processRentalPayout .mockRejectedValueOnce(new Error('Transfer failed')) .mockRejectedValueOnce(new Error('Account not found')); const result = await PayoutService.processAllEligiblePayouts(); expect(result).toEqual({ successful: [], failed: [ { rentalId: 1, error: 'Transfer failed' }, { rentalId: 2, error: 'Account not found' } ], totalProcessed: 2 }); }); }); describe('retryFailedPayouts', () => { beforeEach(() => { jest.spyOn(PayoutService, 'processRentalPayout'); }); afterEach(() => { PayoutService.processRentalPayout.mockRestore(); }); it('should retry failed payouts successfully', async () => { const mockFailedRentals = [ { id: 1, payoutAmount: 9500, update: jest.fn().mockResolvedValue(true) }, { id: 2, payoutAmount: 7500, update: jest.fn().mockResolvedValue(true) } ]; mockRentalFindAll.mockResolvedValue(mockFailedRentals); PayoutService.processRentalPayout .mockResolvedValueOnce({ success: true, transferId: 'tr_retry_123', amount: 9500 }) .mockResolvedValueOnce({ success: true, transferId: 'tr_retry_456', amount: 7500 }); const result = await PayoutService.retryFailedPayouts(); // Verify query for failed rentals expect(mockRentalFindAll).toHaveBeenCalledWith({ where: { status: 'completed', paymentStatus: 'paid', payoutStatus: 'failed' }, include: [ { model: mockUserModel, as: 'owner', where: { stripeConnectedAccountId: { 'not': null }, stripePayoutsEnabled: true } }, { model: mockItemModel, as: 'item' } ] }); // Verify status reset to pending expect(mockFailedRentals[0].update).toHaveBeenCalledWith({ payoutStatus: 'pending' }); expect(mockFailedRentals[1].update).toHaveBeenCalledWith({ payoutStatus: 'pending' }); // Verify processing attempts expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockFailedRentals[0]); expect(PayoutService.processRentalPayout).toHaveBeenCalledWith(mockFailedRentals[1]); // Verify logs expect(consoleSpy).toHaveBeenCalledWith('Found 2 failed payouts to retry'); expect(consoleSpy).toHaveBeenCalledWith('Retry processing complete: 2 successful, 0 failed'); // Verify result expect(result).toEqual({ successful: [ { rentalId: 1, amount: 9500, transferId: 'tr_retry_123' }, { rentalId: 2, amount: 7500, transferId: 'tr_retry_456' } ], failed: [], totalProcessed: 2 }); }); it('should handle mixed retry results', async () => { const mockFailedRentals = [ { id: 1, payoutAmount: 9500, update: jest.fn().mockResolvedValue(true) }, { id: 2, payoutAmount: 7500, update: jest.fn().mockResolvedValue(true) } ]; mockRentalFindAll.mockResolvedValue(mockFailedRentals); PayoutService.processRentalPayout .mockResolvedValueOnce({ success: true, transferId: 'tr_retry_123', amount: 9500 }) .mockRejectedValueOnce(new Error('Still failing')); const result = await PayoutService.retryFailedPayouts(); expect(result).toEqual({ successful: [ { rentalId: 1, amount: 9500, transferId: 'tr_retry_123' } ], failed: [ { rentalId: 2, error: 'Still failing' } ], totalProcessed: 2 }); }); it('should handle no failed payouts to retry', async () => { mockRentalFindAll.mockResolvedValue([]); const result = await PayoutService.retryFailedPayouts(); expect(consoleSpy).toHaveBeenCalledWith('Found 0 failed payouts to retry'); expect(consoleSpy).toHaveBeenCalledWith('Retry processing complete: 0 successful, 0 failed'); expect(result).toEqual({ successful: [], failed: [], totalProcessed: 0 }); expect(PayoutService.processRentalPayout).not.toHaveBeenCalled(); }); it('should handle errors in finding failed rentals', async () => { const dbError = new Error('Database query failed'); mockRentalFindAll.mockRejectedValue(dbError); await expect(PayoutService.retryFailedPayouts()) .rejects.toThrow('Database query failed'); expect(mockLoggerError).toHaveBeenCalledWith( 'Error retrying failed payouts', expect.objectContaining({ error: dbError.message }) ); }); it('should handle status reset errors', async () => { const mockFailedRentals = [ { id: 1, payoutAmount: 9500, update: jest.fn().mockRejectedValue(new Error('Status reset failed')) } ]; mockRentalFindAll.mockResolvedValue(mockFailedRentals); const result = await PayoutService.retryFailedPayouts(); expect(result.failed).toEqual([ { rentalId: 1, error: 'Status reset failed' } ]); expect(PayoutService.processRentalPayout).not.toHaveBeenCalled(); }); }); describe('Error logging', () => { it('should log errors with rental context in processRentalPayout', async () => { const mockRental = { id: 123, payoutStatus: 'pending', payoutAmount: 9500, owner: { stripeConnectedAccountId: 'acct_123' }, update: jest.fn().mockRejectedValue(new Error('Update failed')) }; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Update failed'); expect(mockLoggerError).toHaveBeenCalledWith( 'Error processing payout for rental', expect.objectContaining({ rentalId: 123 }) ); }); it('should log aggregate results in processAllEligiblePayouts', async () => { jest.spyOn(PayoutService, 'getEligiblePayouts').mockResolvedValue([ { id: 1 }, { id: 2 }, { id: 3 } ]); jest.spyOn(PayoutService, 'processRentalPayout') .mockResolvedValueOnce({ amount: 100, transferId: 'tr_1' }) .mockRejectedValueOnce(new Error('Failed')) .mockResolvedValueOnce({ amount: 300, transferId: 'tr_3' }); await PayoutService.processAllEligiblePayouts(); expect(consoleSpy).toHaveBeenCalledWith('Found 3 eligible rentals for payout'); expect(consoleSpy).toHaveBeenCalledWith('Payout processing complete: 2 successful, 1 failed'); PayoutService.getEligiblePayouts.mockRestore(); PayoutService.processRentalPayout.mockRestore(); }); }); describe('Edge cases', () => { it('should handle rental with undefined owner', async () => { const mockRental = { id: 1, payoutStatus: 'pending', payoutAmount: 9500, owner: undefined, update: jest.fn() }; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Owner does not have a connected Stripe account'); }); it('should handle rental with empty string Stripe account ID', async () => { const mockRental = { id: 1, payoutStatus: 'pending', payoutAmount: 9500, owner: { stripeConnectedAccountId: '' }, update: jest.fn() }; await expect(PayoutService.processRentalPayout(mockRental)) .rejects.toThrow('Owner does not have a connected Stripe account'); }); it('should handle very large payout amounts', async () => { const mockRental = { id: 1, ownerId: 2, payoutStatus: 'pending', payoutAmount: 999999999, // Very large amount totalAmount: 1000000000, platformFee: 1, startDateTime: new Date('2023-01-01T10:00:00Z'), endDateTime: new Date('2023-01-02T10:00:00Z'), owner: { stripeConnectedAccountId: 'acct_123' }, update: jest.fn().mockResolvedValue(true) }; mockCreateTransfer.mockResolvedValue({ id: 'tr_large_amount', amount: 999999999 }); const result = await PayoutService.processRentalPayout(mockRental); expect(mockCreateTransfer).toHaveBeenCalledWith({ amount: 999999999, destination: 'acct_123', metadata: expect.objectContaining({ totalAmount: '1000000000', platformFee: '1' }) }); expect(result.amount).toBe(999999999); }); }); });