handling case where payout failed and webhook event not received
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
// 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,
|
||||
});
|
||||
|
||||
|
||||
366
backend/templates/emails/payoutFailedToOwner.html
Normal file
366
backend/templates/emails/payoutFailedToOwner.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
102
backend/utils/payoutErrors.js
Normal file
102
backend/utils/payoutErrors.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user