// 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(), sendAccountDisconnectedEmail: 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(); }); }); describe("handleAccountDeauthorized", () => { it("should return missing_account_id when accountId is not provided", async () => { const result = await StripeWebhookService.handleAccountDeauthorized(null); expect(result.processed).toBe(false); expect(result.reason).toBe("missing_account_id"); }); it("should return missing_account_id for undefined accountId", async () => { const result = await StripeWebhookService.handleAccountDeauthorized(undefined); expect(result.processed).toBe(false); expect(result.reason).toBe("missing_account_id"); }); it("should return user_not_found when account does not match any user", async () => { User.findOne.mockResolvedValue(null); const result = await StripeWebhookService.handleAccountDeauthorized("acct_unknown"); expect(result.processed).toBe(false); expect(result.reason).toBe("user_not_found"); expect(User.findOne).toHaveBeenCalledWith({ where: { stripeConnectedAccountId: "acct_unknown" }, }); }); it("should clear Stripe connection fields when account is deauthorized", async () => { const mockUser = { id: 1, email: "owner@test.com", firstName: "Test", name: "Test Owner", stripeConnectedAccountId: "acct_123", stripePayoutsEnabled: true, update: jest.fn().mockResolvedValue(true), }; User.findOne.mockResolvedValue(mockUser); Rental.findAll.mockResolvedValue([]); emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ success: true, }); const result = await StripeWebhookService.handleAccountDeauthorized("acct_123"); expect(result.processed).toBe(true); expect(result.userId).toBe(1); expect(mockUser.update).toHaveBeenCalledWith({ stripeConnectedAccountId: null, stripePayoutsEnabled: false, stripeDisabledReason: null, stripeRequirementsCurrentlyDue: [], stripeRequirementsPastDue: [], stripeRequirementsLastUpdated: null, }); }); it("should log warning when there are pending payouts", async () => { const mockUser = { id: 1, email: "owner@test.com", firstName: "Test", update: jest.fn().mockResolvedValue(true), }; const mockRentals = [{ id: 100 }, { id: 101 }]; User.findOne.mockResolvedValue(mockUser); Rental.findAll.mockResolvedValue(mockRentals); emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ success: true, }); const result = await StripeWebhookService.handleAccountDeauthorized("acct_123"); expect(result.pendingPayoutsCount).toBe(2); expect(Rental.findAll).toHaveBeenCalledWith({ where: { ownerId: 1, payoutStatus: "pending", }, }); }); it("should send notification email to owner", async () => { const mockUser = { id: 1, email: "owner@test.com", firstName: "Test", update: jest.fn().mockResolvedValue(true), }; User.findOne.mockResolvedValue(mockUser); Rental.findAll.mockResolvedValue([]); emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ success: true, }); const result = await StripeWebhookService.handleAccountDeauthorized("acct_123"); expect(result.notificationSent).toBe(true); expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith( "owner@test.com", { ownerName: "Test", hasPendingPayouts: false, pendingPayoutCount: 0, } ); }); it("should send notification with pending payout info when there are pending payouts", async () => { const mockUser = { id: 1, email: "owner@test.com", firstName: "Owner", update: jest.fn().mockResolvedValue(true), }; const mockRentals = [{ id: 100 }, { id: 101 }, { id: 102 }]; User.findOne.mockResolvedValue(mockUser); Rental.findAll.mockResolvedValue(mockRentals); emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ success: true, }); await StripeWebhookService.handleAccountDeauthorized("acct_123"); expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith( "owner@test.com", { ownerName: "Owner", hasPendingPayouts: true, pendingPayoutCount: 3, } ); }); it("should use lastName fallback when firstName is not available", async () => { const mockUser = { id: 1, email: "owner@test.com", firstName: null, lastName: "Smith", update: jest.fn().mockResolvedValue(true), }; User.findOne.mockResolvedValue(mockUser); Rental.findAll.mockResolvedValue([]); emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ success: true, }); await StripeWebhookService.handleAccountDeauthorized("acct_123"); expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith( "owner@test.com", expect.objectContaining({ ownerName: "Smith", }) ); }); it("should handle email sending failure gracefully", async () => { const mockUser = { id: 1, email: "owner@test.com", firstName: "Test", update: jest.fn().mockResolvedValue(true), }; User.findOne.mockResolvedValue(mockUser); Rental.findAll.mockResolvedValue([]); emailServices.payment.sendAccountDisconnectedEmail.mockRejectedValue( new Error("Email service down") ); const result = await StripeWebhookService.handleAccountDeauthorized("acct_123"); // Should still mark as processed even if notification fails expect(result.processed).toBe(true); expect(result.notificationSent).toBe(false); expect(mockUser.update).toHaveBeenCalled(); }); it("should throw error when database update fails", async () => { const mockUser = { id: 1, email: "owner@test.com", firstName: "Test", update: jest.fn().mockRejectedValue(new Error("DB error")), }; User.findOne.mockResolvedValue(mockUser); await expect( StripeWebhookService.handleAccountDeauthorized("acct_123") ).rejects.toThrow("DB error"); }); }); });