591 lines
17 KiB
JavaScript
591 lines
17 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,
|
|
});
|
|
});
|
|
|
|
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 name fallback when firstName is not available", async () => {
|
|
const mockUser = {
|
|
id: 1,
|
|
email: "owner@test.com",
|
|
firstName: null,
|
|
name: "Full Name",
|
|
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: "Full Name",
|
|
})
|
|
);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
});
|