handling case where payout failed and webhook event not received
This commit is contained in:
392
backend/tests/unit/services/stripeWebhookService.test.js
Normal file
392
backend/tests/unit/services/stripeWebhookService.test.js
Normal file
@@ -0,0 +1,392 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user