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();
|
||||
});
|
||||
});
|
||||
});
|
||||
162
backend/tests/unit/utils/payoutErrors.test.js
Normal file
162
backend/tests/unit/utils/payoutErrors.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user