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

@@ -1111,7 +1111,18 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
// Get earnings status for owner's rentals
router.get("/earnings/status", authenticateToken, async (req, res, next) => {
const reqLogger = logger.withRequestId(req.id);
try {
// Trigger payout reconciliation in background (non-blocking)
// This catches any missed payout.paid or payout.failed webhooks
StripeWebhookService.reconcilePayoutStatuses(req.user.id).catch((err) => {
reqLogger.error("Background payout reconciliation failed", {
error: err.message,
userId: req.user.id,
});
});
const ownerRentals = await Rental.findAll({
where: {
ownerId: req.user.id,
@@ -1125,6 +1136,9 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
"payoutStatus",
"payoutProcessedAt",
"stripeTransferId",
"bankDepositStatus",
"bankDepositAt",
"bankDepositFailureCode",
],
include: [{ model: Item, as: "item", attributes: ["name"] }],
order: [["createdAt", "DESC"]],
@@ -1132,7 +1146,6 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
res.json(ownerRentals);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error getting earnings status", {
error: error.message,
stack: error.stack,

View File

@@ -116,6 +116,61 @@ class PaymentEmailService {
return { success: false, error: error.message };
}
}
/**
* Send payout failed notification to owner
* @param {string} ownerEmail - Owner's email address
* @param {Object} params - Email parameters
* @param {string} params.ownerName - Owner's name
* @param {number} params.payoutAmount - Payout amount in dollars
* @param {string} params.failureMessage - User-friendly failure message
* @param {string} params.actionRequired - Action the owner needs to take
* @param {string} params.failureCode - The Stripe failure code
* @param {boolean} params.requiresBankUpdate - Whether bank account update is needed
* @param {string} params.payoutSettingsUrl - URL to payout settings
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendPayoutFailedNotification(ownerEmail, params) {
if (!this.initialized) {
await this.initialize();
}
try {
const {
ownerName,
payoutAmount,
failureMessage,
actionRequired,
failureCode,
requiresBankUpdate,
payoutSettingsUrl,
} = params;
const variables = {
ownerName: ownerName || "there",
payoutAmount: payoutAmount?.toFixed(2) || "0.00",
failureMessage: failureMessage || "There was an issue with your payout.",
actionRequired: actionRequired || "Please check your bank account details.",
failureCode: failureCode || "unknown",
requiresBankUpdate: requiresBankUpdate || false,
payoutSettingsUrl: payoutSettingsUrl || process.env.FRONTEND_URL + "/settings/payouts",
};
const htmlContent = await this.templateManager.renderTemplate(
"payoutFailedToOwner",
variables
);
return await this.emailClient.sendEmail(
ownerEmail,
"Action Required: Payout Issue - Village Share",
htmlContent
);
} catch (error) {
console.error("Failed to send payout failed notification:", error);
return { success: false, error: error.message };
}
}
}
module.exports = PaymentEmailService;

View File

@@ -3,6 +3,8 @@ const { User, Rental, Item } = require("../models");
const PayoutService = require("./payoutService");
const logger = require("../utils/logger");
const { Op } = require("sequelize");
const { getPayoutFailureMessage } = require("../utils/payoutErrors");
const emailServices = require("./email");
class StripeWebhookService {
/**
@@ -219,10 +221,10 @@ class StripeWebhookService {
/**
* Handle payout.failed webhook event.
* Updates rentals when bank deposit fails.
* Updates rentals when bank deposit fails and notifies the owner.
* @param {Object} payout - The Stripe payout object
* @param {string} connectedAccountId - The connected account ID (from event.account)
* @returns {Object} - { processed, rentalsUpdated }
* @returns {Object} - { processed, rentalsUpdated, notificationSent }
*/
static async handlePayoutFailed(payout, connectedAccountId) {
logger.info("Processing payout.failed webhook", {
@@ -259,7 +261,7 @@ class StripeWebhookService {
payoutId: payout.id,
connectedAccountId,
});
return { processed: true, rentalsUpdated: 0 };
return { processed: true, rentalsUpdated: 0, notificationSent: false };
}
// Update all rentals with matching stripeTransferId
@@ -282,7 +284,49 @@ class StripeWebhookService {
failureCode: payout.failure_code,
});
return { processed: true, rentalsUpdated: updatedCount };
// Find owner and send notification
const user = await User.findOne({
where: { stripeConnectedAccountId: connectedAccountId },
});
let notificationSent = false;
if (user) {
// Get user-friendly message
const failureInfo = getPayoutFailureMessage(payout.failure_code);
try {
await emailServices.payment.sendPayoutFailedNotification(user.email, {
ownerName: user.firstName || user.name,
payoutAmount: payout.amount / 100,
failureMessage: failureInfo.message,
actionRequired: failureInfo.action,
failureCode: payout.failure_code || "unknown",
requiresBankUpdate: failureInfo.requiresBankUpdate,
});
notificationSent = true;
logger.info("Sent payout failed notification to owner", {
userId: user.id,
payoutId: payout.id,
failureCode: payout.failure_code,
});
} catch (emailError) {
logger.error("Failed to send payout failed notification", {
userId: user.id,
payoutId: payout.id,
error: emailError.message,
});
}
} else {
logger.warn("No user found for connected account", {
connectedAccountId,
payoutId: payout.id,
});
}
return { processed: true, rentalsUpdated: updatedCount, notificationSent };
} catch (error) {
logger.error("Error processing payout.failed webhook", {
payoutId: payout.id,
@@ -296,19 +340,19 @@ class StripeWebhookService {
/**
* Reconcile payout statuses for an owner by checking Stripe for actual status.
* This handles cases where the payout.paid webhook was missed or failed.
* This handles cases where payout.paid or payout.failed webhooks were missed.
*
* Simplified approach: Since Stripe automatic payouts sweep the entire available
* balance, if there's been a paid payout after our transfer was created, our
* funds were included.
* Checks both paid and failed payouts to ensure accurate status tracking.
*
* @param {string} ownerId - The owner's user ID
* @returns {Object} - { reconciled, updated, errors }
* @returns {Object} - { reconciled, updated, failed, notificationsSent, errors }
*/
static async reconcilePayoutStatuses(ownerId) {
const results = {
reconciled: 0,
updated: 0,
failed: 0,
notificationsSent: 0,
errors: [],
};
@@ -325,7 +369,7 @@ class StripeWebhookService {
{
model: User,
as: "owner",
attributes: ["stripeConnectedAccountId"],
attributes: ["id", "email", "firstName", "name", "stripeConnectedAccountId"],
},
],
});
@@ -346,42 +390,114 @@ class StripeWebhookService {
return results;
}
// Fetch recent paid payouts once for all rentals
const paidPayouts = await stripe.payouts.list(
{ status: "paid", limit: 20 },
{ stripeAccount: connectedAccountId }
);
// Fetch recent paid and failed payouts
const [paidPayouts, failedPayouts] = await Promise.all([
stripe.payouts.list(
{ status: "paid", limit: 20 },
{ stripeAccount: connectedAccountId }
),
stripe.payouts.list(
{ status: "failed", limit: 20 },
{ stripeAccount: connectedAccountId }
),
]);
if (paidPayouts.data.length === 0) {
logger.info("No paid payouts found for connected account", { connectedAccountId });
return results;
// Build a map of transfer IDs to failed payouts for quick lookup
const failedPayoutTransferMap = new Map();
for (const payout of failedPayouts.data) {
try {
const balanceTransactions = await stripe.balanceTransactions.list(
{ payout: payout.id, type: "transfer", limit: 100 },
{ stripeAccount: connectedAccountId }
);
for (const bt of balanceTransactions.data) {
if (bt.source) {
failedPayoutTransferMap.set(bt.source, payout);
}
}
} catch (btError) {
logger.warn("Error fetching balance transactions for failed payout", {
payoutId: payout.id,
error: btError.message,
});
}
}
const owner = rentalsToReconcile[0].owner;
for (const rental of rentalsToReconcile) {
results.reconciled++;
try {
// Get the transfer to find when it was created
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
// First check if this transfer is in a failed payout
const failedPayout = failedPayoutTransferMap.get(rental.stripeTransferId);
// Find a payout that arrived after the transfer was created
const matchingPayout = paidPayouts.data.find(
if (failedPayout) {
// Update rental with failed status
await rental.update({
bankDepositStatus: "failed",
stripePayoutId: failedPayout.id,
bankDepositFailureCode: failedPayout.failure_code || "unknown",
});
results.failed++;
logger.warn("Reconciled rental with failed payout", {
rentalId: rental.id,
payoutId: failedPayout.id,
failureCode: failedPayout.failure_code,
});
// Send failure notification
if (owner?.email) {
try {
const failureInfo = getPayoutFailureMessage(failedPayout.failure_code);
await emailServices.payment.sendPayoutFailedNotification(owner.email, {
ownerName: owner.firstName || owner.name,
payoutAmount: failedPayout.amount / 100,
failureMessage: failureInfo.message,
actionRequired: failureInfo.action,
failureCode: failedPayout.failure_code || "unknown",
requiresBankUpdate: failureInfo.requiresBankUpdate,
});
results.notificationsSent++;
logger.info("Sent reconciled payout failure notification", {
userId: owner.id,
rentalId: rental.id,
payoutId: failedPayout.id,
});
} catch (emailError) {
logger.error("Failed to send reconciled payout failure notification", {
userId: owner.id,
rentalId: rental.id,
error: emailError.message,
});
}
}
continue; // Move to next rental
}
// Check for paid payout
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
const matchingPaidPayout = paidPayouts.data.find(
(payout) => payout.arrival_date >= transfer.created
);
if (matchingPayout) {
if (matchingPaidPayout) {
await rental.update({
bankDepositStatus: "paid",
bankDepositAt: new Date(matchingPayout.arrival_date * 1000),
stripePayoutId: matchingPayout.id,
bankDepositAt: new Date(matchingPaidPayout.arrival_date * 1000),
stripePayoutId: matchingPaidPayout.id,
});
results.updated++;
logger.info("Reconciled rental payout status", {
logger.info("Reconciled rental payout status to paid", {
rentalId: rental.id,
payoutId: matchingPayout.id,
arrivalDate: matchingPayout.arrival_date,
payoutId: matchingPaidPayout.id,
arrivalDate: matchingPaidPayout.arrival_date,
});
}
} catch (rentalError) {
@@ -401,6 +517,8 @@ class StripeWebhookService {
ownerId,
reconciled: results.reconciled,
updated: results.updated,
failed: results.failed,
notificationsSent: results.notificationsSent,
errors: results.errors.length,
});

View File

@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Payout Issue - Village Share</title>
<style>
/* Reset styles */
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Warning display */
.warning-display {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
padding: 30px;
border-radius: 8px;
text-align: center;
margin: 30px 0;
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.warning-label {
color: #212529;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.warning-amount {
color: #212529;
font-size: 48px;
font-weight: 700;
margin: 0;
line-height: 1;
}
.warning-subtitle {
color: #212529;
font-size: 14px;
margin-top: 10px;
opacity: 0.8;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Info table */
.info-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background-color: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.info-table th,
.info-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.info-table th {
background-color: #e9ecef;
font-weight: 600;
color: #495057;
width: 40%;
}
.info-table td {
color: #6c757d;
}
.info-table tr:last-child td {
border-bottom: none;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.warning-amount {
font-size: 36px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.info-table th,
.info-table td {
padding: 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Payout Issue</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<p>
We encountered an issue depositing your earnings to your bank account.
Don't worry - your funds are safe and we'll help you resolve this.
</p>
<div class="warning-display">
<div class="warning-label">Pending Payout</div>
<div class="warning-amount">${{payoutAmount}}</div>
<div class="warning-subtitle">Action required to receive funds</div>
</div>
<div class="alert-box">
<p><strong>What happened:</strong></p>
<p>{{failureMessage}}</p>
<p><strong>What to do:</strong></p>
<p>{{actionRequired}}</p>
</div>
<h2>Payout Details</h2>
<table class="info-table">
<tr>
<th>Amount</th>
<td>${{payoutAmount}}</td>
</tr>
<tr>
<th>Status</th>
<td style="color: #dc3545">
<strong>Failed - Action Required</strong>
</td>
</tr>
<tr>
<th>Failure Reason</th>
<td>{{failureCode}}</td>
</tr>
</table>
{{#if requiresBankUpdate}}
<div style="text-align: center">
<a href="{{payoutSettingsUrl}}" class="button"
>Update Bank Account</a
>
</div>
{{/if}}
<div class="info-box">
<p><strong>What happens next?</strong></p>
<p>
Once you resolve this issue, your payout will be retried
automatically. If you need assistance, please contact our support
team.
</p>
</div>
<p>
We apologize for any inconvenience. Your earnings are safe and will be
deposited as soon as the issue is resolved.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is an important notification about your earnings. You received
this message because a payout to your bank account could not be
completed.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

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

View File

@@ -0,0 +1,102 @@
/**
* Payout Error Handling Utility
*
* Maps Stripe payout failure codes to user-friendly messages for owners.
* These codes indicate why a bank deposit failed.
*/
const PAYOUT_FAILURE_MESSAGES = {
account_closed: {
message: "Your bank account has been closed.",
action: "Please update your bank account in your payout settings.",
requiresBankUpdate: true,
},
account_frozen: {
message: "Your bank account is frozen.",
action: "Please contact your bank to resolve this issue.",
requiresBankUpdate: false,
},
bank_account_restricted: {
message: "Your bank account has restrictions that prevent deposits.",
action: "Please contact your bank or add a different account.",
requiresBankUpdate: true,
},
bank_ownership_changed: {
message: "The ownership of your bank account has changed.",
action: "Please re-verify your bank account in your payout settings.",
requiresBankUpdate: true,
},
could_not_process: {
message: "Your bank could not process the deposit.",
action: "This is usually temporary. We'll retry automatically.",
requiresBankUpdate: false,
},
debit_not_authorized: {
message: "Your bank account is not authorized to receive deposits.",
action: "Please contact your bank to enable incoming transfers.",
requiresBankUpdate: false,
},
declined: {
message: "Your bank declined the deposit.",
action: "Please contact your bank for more information.",
requiresBankUpdate: false,
},
insufficient_funds: {
message: "The deposit was returned.",
action:
"This can happen with some account types. Please verify your account details.",
requiresBankUpdate: true,
},
invalid_account_number: {
message: "Your bank account number appears to be invalid.",
action: "Please verify and update your bank account details.",
requiresBankUpdate: true,
},
incorrect_account_holder_name: {
message: "The name on your bank account doesn't match your profile.",
action: "Please update your bank account or profile information.",
requiresBankUpdate: true,
},
incorrect_account_holder_type: {
message: "Your bank account type doesn't match what was expected.",
action: "Please verify your account type (individual vs business).",
requiresBankUpdate: true,
},
invalid_currency: {
message: "Your bank account doesn't support this currency.",
action: "Please add a bank account that supports USD deposits.",
requiresBankUpdate: true,
},
no_account: {
message: "The bank account could not be found.",
action: "Please verify your account number and routing number.",
requiresBankUpdate: true,
},
unsupported_card: {
message: "The debit card used for payouts is not supported.",
action: "Please update to a supported debit card or bank account.",
requiresBankUpdate: true,
},
};
// Default message for unknown failure codes
const DEFAULT_PAYOUT_FAILURE = {
message: "There was an issue depositing funds to your bank.",
action: "Please check your bank account details or contact support.",
requiresBankUpdate: true,
};
/**
* Get user-friendly payout failure message
* @param {string} failureCode - The Stripe payout failure code
* @returns {Object} - { message, action, requiresBankUpdate }
*/
function getPayoutFailureMessage(failureCode) {
return PAYOUT_FAILURE_MESSAGES[failureCode] || DEFAULT_PAYOUT_FAILURE;
}
module.exports = {
PAYOUT_FAILURE_MESSAGES,
DEFAULT_PAYOUT_FAILURE,
getPayoutFailureMessage,
};