handling case where payout failed and webhook event not received

This commit is contained in:
jackiettran
2026-01-08 15:27:02 -05:00
parent 65b7574be2
commit 5248c3dc39
7 changed files with 1237 additions and 29 deletions

View 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();
});
});
});

View File

@@ -0,0 +1,162 @@
const {
PAYOUT_FAILURE_MESSAGES,
DEFAULT_PAYOUT_FAILURE,
getPayoutFailureMessage,
} = require("../../../utils/payoutErrors");
describe("Payout Errors Utility", () => {
describe("PAYOUT_FAILURE_MESSAGES", () => {
const requiredProperties = ["message", "action", "requiresBankUpdate"];
const allFailureCodes = [
"account_closed",
"account_frozen",
"bank_account_restricted",
"bank_ownership_changed",
"could_not_process",
"debit_not_authorized",
"declined",
"insufficient_funds",
"invalid_account_number",
"incorrect_account_holder_name",
"incorrect_account_holder_type",
"invalid_currency",
"no_account",
"unsupported_card",
];
test.each(allFailureCodes)("%s exists in PAYOUT_FAILURE_MESSAGES", (code) => {
expect(PAYOUT_FAILURE_MESSAGES).toHaveProperty(code);
});
test.each(allFailureCodes)("%s has all required properties", (code) => {
const failureInfo = PAYOUT_FAILURE_MESSAGES[code];
for (const prop of requiredProperties) {
expect(failureInfo).toHaveProperty(prop);
}
});
test.each(allFailureCodes)("%s has non-empty message and action", (code) => {
const failureInfo = PAYOUT_FAILURE_MESSAGES[code];
expect(failureInfo.message.length).toBeGreaterThan(0);
expect(failureInfo.action.length).toBeGreaterThan(0);
});
test.each(allFailureCodes)("%s has boolean requiresBankUpdate", (code) => {
const failureInfo = PAYOUT_FAILURE_MESSAGES[code];
expect(typeof failureInfo.requiresBankUpdate).toBe("boolean");
});
// Codes that require bank account update
const codesRequiringBankUpdate = [
"account_closed",
"bank_account_restricted",
"bank_ownership_changed",
"insufficient_funds",
"invalid_account_number",
"incorrect_account_holder_name",
"incorrect_account_holder_type",
"invalid_currency",
"no_account",
"unsupported_card",
];
test.each(codesRequiringBankUpdate)(
"%s requires bank update",
(code) => {
expect(PAYOUT_FAILURE_MESSAGES[code].requiresBankUpdate).toBe(true);
}
);
// Codes that don't require bank account update (temporary issues)
const temporaryCodes = [
"account_frozen",
"could_not_process",
"debit_not_authorized",
"declined",
];
test.each(temporaryCodes)(
"%s does not require bank update (temporary issue)",
(code) => {
expect(PAYOUT_FAILURE_MESSAGES[code].requiresBankUpdate).toBe(false);
}
);
});
describe("DEFAULT_PAYOUT_FAILURE", () => {
test("has all required properties", () => {
expect(DEFAULT_PAYOUT_FAILURE).toHaveProperty("message");
expect(DEFAULT_PAYOUT_FAILURE).toHaveProperty("action");
expect(DEFAULT_PAYOUT_FAILURE).toHaveProperty("requiresBankUpdate");
});
test("has non-empty message and action", () => {
expect(DEFAULT_PAYOUT_FAILURE.message.length).toBeGreaterThan(0);
expect(DEFAULT_PAYOUT_FAILURE.action.length).toBeGreaterThan(0);
});
test("defaults to requiring bank update", () => {
expect(DEFAULT_PAYOUT_FAILURE.requiresBankUpdate).toBe(true);
});
});
describe("getPayoutFailureMessage", () => {
test("returns correct message for known failure code", () => {
const result = getPayoutFailureMessage("account_closed");
expect(result.message).toBe("Your bank account has been closed.");
expect(result.action).toBe(
"Please update your bank account in your payout settings."
);
expect(result.requiresBankUpdate).toBe(true);
});
test("returns correct message for account_frozen", () => {
const result = getPayoutFailureMessage("account_frozen");
expect(result.message).toBe("Your bank account is frozen.");
expect(result.action).toContain("contact your bank");
expect(result.requiresBankUpdate).toBe(false);
});
test("returns correct message for invalid_account_number", () => {
const result = getPayoutFailureMessage("invalid_account_number");
expect(result.message).toContain("invalid");
expect(result.requiresBankUpdate).toBe(true);
});
test("returns correct message for could_not_process", () => {
const result = getPayoutFailureMessage("could_not_process");
expect(result.message).toContain("could not process");
expect(result.action).toContain("retry");
expect(result.requiresBankUpdate).toBe(false);
});
test("returns default message for unknown failure code", () => {
const result = getPayoutFailureMessage("unknown_code_xyz");
expect(result).toEqual(DEFAULT_PAYOUT_FAILURE);
});
test("returns default message for null failure code", () => {
const result = getPayoutFailureMessage(null);
expect(result).toEqual(DEFAULT_PAYOUT_FAILURE);
});
test("returns default message for undefined failure code", () => {
const result = getPayoutFailureMessage(undefined);
expect(result).toEqual(DEFAULT_PAYOUT_FAILURE);
});
test("returns default message for empty string failure code", () => {
const result = getPayoutFailureMessage("");
expect(result).toEqual(DEFAULT_PAYOUT_FAILURE);
});
});
});