Files
rentall-app/backend/tests/unit/services/stripeWebhookService.test.js
2026-01-18 19:18:35 -05:00

797 lines
24 KiB
JavaScript

// 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([]);
});
});
});