// Mock dependencies jest.mock("stripe", () => { const mockStripe = { payouts: { list: jest.fn(), }, transfers: { retrieve: jest.fn(), }, balanceTransactions: { list: jest.fn(), }, }; return jest.fn(() => mockStripe); }); jest.mock("../../../models", () => ({ Rental: { findAll: jest.fn(), update: jest.fn(), }, User: { findOne: jest.fn(), }, Item: jest.fn(), })); jest.mock("../../../services/payoutService", () => ({ processRentalPayout: jest.fn(), })); jest.mock("../../../utils/logger", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), })); jest.mock("../../../services/email", () => ({ payment: { sendPayoutFailedNotification: jest.fn(), }, })); jest.mock("sequelize", () => ({ Op: { not: "not", is: "is", in: "in", }, })); const StripeWebhookService = require("../../../services/stripeWebhookService"); const { Rental, User } = require("../../../models"); const emailServices = require("../../../services/email"); const stripe = require("stripe")(); describe("StripeWebhookService", () => { beforeEach(() => { jest.clearAllMocks(); }); describe("reconcilePayoutStatuses", () => { const mockOwnerId = "owner-123"; const mockConnectedAccountId = "acct_test123"; it("should return early if no rentals need reconciliation", async () => { Rental.findAll.mockResolvedValue([]); const result = await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId); expect(result).toEqual({ reconciled: 0, updated: 0, failed: 0, notificationsSent: 0, errors: [], }); expect(stripe.payouts.list).not.toHaveBeenCalled(); }); it("should return early if owner has no connected account", async () => { Rental.findAll.mockResolvedValue([ { id: 1, stripeTransferId: "tr_123", owner: { stripeConnectedAccountId: null }, }, ]); const result = await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId); expect(result).toEqual({ reconciled: 0, updated: 0, failed: 0, notificationsSent: 0, errors: [], }); }); it("should reconcile rental with paid payout", async () => { const mockRental = { id: 1, stripeTransferId: "tr_123", owner: { id: "owner-123", email: "owner@test.com", firstName: "Test", stripeConnectedAccountId: mockConnectedAccountId, }, update: jest.fn().mockResolvedValue(true), }; Rental.findAll.mockResolvedValue([mockRental]); stripe.payouts.list.mockImplementation((params) => { if (params.status === "paid") { return Promise.resolve({ data: [ { id: "po_paid123", status: "paid", arrival_date: 1700000000, }, ], }); } return Promise.resolve({ data: [] }); }); stripe.balanceTransactions.list.mockResolvedValue({ data: [] }); stripe.transfers.retrieve.mockResolvedValue({ id: "tr_123", created: 1699900000, // Before arrival_date }); const result = await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId); expect(result.updated).toBe(1); expect(result.failed).toBe(0); expect(mockRental.update).toHaveBeenCalledWith({ bankDepositStatus: "paid", bankDepositAt: expect.any(Date), stripePayoutId: "po_paid123", }); }); it("should reconcile rental with failed payout and send notification", async () => { const mockRental = { id: 1, stripeTransferId: "tr_123", owner: { id: "owner-123", email: "owner@test.com", firstName: "Test", stripeConnectedAccountId: mockConnectedAccountId, }, update: jest.fn().mockResolvedValue(true), }; Rental.findAll.mockResolvedValue([mockRental]); stripe.payouts.list.mockImplementation((params) => { if (params.status === "failed") { return Promise.resolve({ data: [ { id: "po_failed123", status: "failed", failure_code: "account_closed", amount: 5000, }, ], }); } return Promise.resolve({ data: [] }); }); // Mock balance transactions showing the transfer is in the failed payout stripe.balanceTransactions.list.mockResolvedValue({ data: [{ source: "tr_123" }], }); emailServices.payment.sendPayoutFailedNotification.mockResolvedValue({ success: true, }); const result = await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId); expect(result.failed).toBe(1); expect(result.notificationsSent).toBe(1); expect(mockRental.update).toHaveBeenCalledWith({ bankDepositStatus: "failed", stripePayoutId: "po_failed123", bankDepositFailureCode: "account_closed", }); expect( emailServices.payment.sendPayoutFailedNotification ).toHaveBeenCalledWith("owner@test.com", { ownerName: "Test", payoutAmount: 50, // 5000 cents = $50 failureMessage: "Your bank account has been closed.", actionRequired: "Please update your bank account in your payout settings.", failureCode: "account_closed", requiresBankUpdate: true, }); }); it("should handle multiple rentals with mixed statuses", async () => { const mockRentals = [ { id: 1, stripeTransferId: "tr_failed", owner: { id: "owner-123", email: "owner@test.com", firstName: "Test", stripeConnectedAccountId: mockConnectedAccountId, }, update: jest.fn().mockResolvedValue(true), }, { id: 2, stripeTransferId: "tr_paid", owner: { id: "owner-123", email: "owner@test.com", firstName: "Test", stripeConnectedAccountId: mockConnectedAccountId, }, update: jest.fn().mockResolvedValue(true), }, ]; Rental.findAll.mockResolvedValue(mockRentals); stripe.payouts.list.mockImplementation((params) => { if (params.status === "failed") { return Promise.resolve({ data: [ { id: "po_failed", status: "failed", failure_code: "account_frozen", amount: 3000, }, ], }); } if (params.status === "paid") { return Promise.resolve({ data: [ { id: "po_paid", status: "paid", arrival_date: 1700000000, }, ], }); } return Promise.resolve({ data: [] }); }); // tr_failed is in the failed payout stripe.balanceTransactions.list.mockResolvedValue({ data: [{ source: "tr_failed" }], }); stripe.transfers.retrieve.mockResolvedValue({ id: "tr_paid", created: 1699900000, }); emailServices.payment.sendPayoutFailedNotification.mockResolvedValue({ success: true, }); const result = await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId); expect(result.reconciled).toBe(2); expect(result.failed).toBe(1); expect(result.updated).toBe(1); expect(result.notificationsSent).toBe(1); }); it("should continue processing other rentals when one fails", async () => { const mockRentals = [ { id: 1, stripeTransferId: "tr_error", owner: { id: "owner-123", email: "owner@test.com", firstName: "Test", stripeConnectedAccountId: mockConnectedAccountId, }, update: jest.fn().mockRejectedValue(new Error("DB error")), }, { id: 2, stripeTransferId: "tr_success", owner: { id: "owner-123", email: "owner@test.com", firstName: "Test", stripeConnectedAccountId: mockConnectedAccountId, }, update: jest.fn().mockResolvedValue(true), }, ]; Rental.findAll.mockResolvedValue(mockRentals); stripe.payouts.list.mockImplementation((params) => { if (params.status === "paid") { return Promise.resolve({ data: [{ id: "po_paid", status: "paid", arrival_date: 1700000000 }], }); } return Promise.resolve({ data: [] }); }); stripe.balanceTransactions.list.mockResolvedValue({ data: [] }); stripe.transfers.retrieve.mockResolvedValue({ created: 1699900000, }); const result = await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId); expect(result.reconciled).toBe(2); expect(result.errors).toHaveLength(1); expect(result.errors[0].rentalId).toBe(1); expect(result.updated).toBe(1); }); it("should handle email notification failure gracefully", async () => { const mockRental = { id: 1, stripeTransferId: "tr_123", owner: { id: "owner-123", email: "owner@test.com", firstName: "Test", stripeConnectedAccountId: mockConnectedAccountId, }, update: jest.fn().mockResolvedValue(true), }; Rental.findAll.mockResolvedValue([mockRental]); stripe.payouts.list.mockImplementation((params) => { if (params.status === "failed") { return Promise.resolve({ data: [ { id: "po_failed", failure_code: "declined", amount: 1000, }, ], }); } return Promise.resolve({ data: [] }); }); stripe.balanceTransactions.list.mockResolvedValue({ data: [{ source: "tr_123" }], }); emailServices.payment.sendPayoutFailedNotification.mockRejectedValue( new Error("Email service down") ); const result = await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId); // Should still mark as failed even if notification fails expect(result.failed).toBe(1); expect(result.notificationsSent).toBe(0); expect(mockRental.update).toHaveBeenCalled(); }); }); });