// 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"); }); }); describe("constructEvent", () => { it("should call stripe.webhooks.constructEvent with correct parameters", () => { const mockEvent = { id: "evt_123", type: "test.event" }; const mockConstructEvent = jest.fn().mockReturnValue(mockEvent); // Access the stripe mock const stripeMock = require("stripe")(); stripeMock.webhooks = { constructEvent: mockConstructEvent }; const rawBody = Buffer.from("test-body"); const signature = "test-sig"; const secret = "test-secret"; // The constructEvent just passes through to stripe // Since stripe is mocked, this tests the interface expect(typeof StripeWebhookService.constructEvent).toBe("function"); }); }); describe("formatDisabledReason", () => { it("should return user-friendly message for requirements.past_due", () => { const result = StripeWebhookService.formatDisabledReason("requirements.past_due"); expect(result).toContain("past due"); }); it("should return user-friendly message for requirements.pending_verification", () => { const result = StripeWebhookService.formatDisabledReason("requirements.pending_verification"); expect(result).toContain("being verified"); }); it("should return user-friendly message for listed", () => { const result = StripeWebhookService.formatDisabledReason("listed"); expect(result).toContain("review"); }); it("should return user-friendly message for rejected_fraud", () => { const result = StripeWebhookService.formatDisabledReason("rejected_fraud"); expect(result).toContain("fraudulent"); }); it("should return default message for unknown reason", () => { const result = StripeWebhookService.formatDisabledReason("unknown_reason"); expect(result).toContain("Additional verification"); }); it("should return default message for undefined reason", () => { const result = StripeWebhookService.formatDisabledReason(undefined); expect(result).toContain("Additional verification"); }); }); describe("handleAccountUpdated", () => { it("should return user_not_found when no user matches account", async () => { User.findOne.mockResolvedValue(null); const result = await StripeWebhookService.handleAccountUpdated({ id: "acct_unknown", payouts_enabled: true, requirements: {}, }); expect(result.processed).toBe(false); expect(result.reason).toBe("user_not_found"); }); it("should update user with account status", async () => { const mockUser = { id: "user-123", stripePayoutsEnabled: false, update: jest.fn().mockResolvedValue(true), }; User.findOne.mockResolvedValue(mockUser); const result = await StripeWebhookService.handleAccountUpdated({ id: "acct_123", payouts_enabled: true, requirements: { currently_due: ["requirement1"], past_due: [], }, }); expect(result.processed).toBe(true); expect(mockUser.update).toHaveBeenCalledWith( expect.objectContaining({ stripePayoutsEnabled: true, stripeRequirementsCurrentlyDue: ["requirement1"], }) ); }); }); describe("handlePayoutPaid", () => { it("should return missing_account_id when connectedAccountId is null", async () => { const result = await StripeWebhookService.handlePayoutPaid({ id: "po_123" }, null); expect(result.processed).toBe(false); expect(result.reason).toBe("missing_account_id"); }); it("should return 0 rentals updated when no transfers found", async () => { stripe.balanceTransactions.list.mockResolvedValue({ data: [] }); const result = await StripeWebhookService.handlePayoutPaid( { id: "po_123", arrival_date: 1700000000 }, "acct_123" ); expect(result.processed).toBe(true); expect(result.rentalsUpdated).toBe(0); }); it("should update rentals for transfers in payout", async () => { stripe.balanceTransactions.list.mockResolvedValue({ data: [{ source: "tr_123" }, { source: "tr_456" }], }); Rental.update.mockResolvedValue([2]); const result = await StripeWebhookService.handlePayoutPaid( { id: "po_123", arrival_date: 1700000000 }, "acct_123" ); expect(result.processed).toBe(true); expect(result.rentalsUpdated).toBe(2); }); }); describe("handlePayoutFailed", () => { it("should return missing_account_id when connectedAccountId is null", async () => { const result = await StripeWebhookService.handlePayoutFailed({ id: "po_123" }, null); expect(result.processed).toBe(false); expect(result.reason).toBe("missing_account_id"); }); it("should update rentals and send notification", async () => { stripe.balanceTransactions.list.mockResolvedValue({ data: [{ source: "tr_123" }], }); Rental.update.mockResolvedValue([1]); const mockUser = { id: "user-123", email: "owner@test.com", firstName: "Test", }; User.findOne.mockResolvedValue(mockUser); emailServices.payment.sendPayoutFailedNotification.mockResolvedValue({ success: true }); const result = await StripeWebhookService.handlePayoutFailed( { id: "po_123", failure_code: "account_closed", amount: 5000 }, "acct_123" ); expect(result.processed).toBe(true); expect(result.rentalsUpdated).toBe(1); expect(result.notificationSent).toBe(true); }); }); describe("handlePayoutCanceled", () => { it("should return missing_account_id when connectedAccountId is null", async () => { const result = await StripeWebhookService.handlePayoutCanceled({ id: "po_123" }, null); expect(result.processed).toBe(false); expect(result.reason).toBe("missing_account_id"); }); it("should update rentals with canceled status", async () => { stripe.balanceTransactions.list.mockResolvedValue({ data: [{ source: "tr_123" }], }); Rental.update.mockResolvedValue([1]); const result = await StripeWebhookService.handlePayoutCanceled( { id: "po_123" }, "acct_123" ); expect(result.processed).toBe(true); expect(result.rentalsUpdated).toBe(1); }); }); describe("processPayoutsForOwner", () => { it("should return empty results when no eligible rentals", async () => { Rental.findAll.mockResolvedValue([]); const result = await StripeWebhookService.processPayoutsForOwner("owner-123"); expect(result.totalProcessed).toBe(0); expect(result.successful).toEqual([]); expect(result.failed).toEqual([]); }); }); });