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
|
// Get earnings status for owner's rentals
|
||||||
router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
|
||||||
try {
|
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({
|
const ownerRentals = await Rental.findAll({
|
||||||
where: {
|
where: {
|
||||||
ownerId: req.user.id,
|
ownerId: req.user.id,
|
||||||
@@ -1125,6 +1136,9 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
|||||||
"payoutStatus",
|
"payoutStatus",
|
||||||
"payoutProcessedAt",
|
"payoutProcessedAt",
|
||||||
"stripeTransferId",
|
"stripeTransferId",
|
||||||
|
"bankDepositStatus",
|
||||||
|
"bankDepositAt",
|
||||||
|
"bankDepositFailureCode",
|
||||||
],
|
],
|
||||||
include: [{ model: Item, as: "item", attributes: ["name"] }],
|
include: [{ model: Item, as: "item", attributes: ["name"] }],
|
||||||
order: [["createdAt", "DESC"]],
|
order: [["createdAt", "DESC"]],
|
||||||
@@ -1132,7 +1146,6 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
|
|||||||
|
|
||||||
res.json(ownerRentals);
|
res.json(ownerRentals);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Error getting earnings status", {
|
reqLogger.error("Error getting earnings status", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
|
|||||||
@@ -116,6 +116,61 @@ class PaymentEmailService {
|
|||||||
return { success: false, error: error.message };
|
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;
|
module.exports = PaymentEmailService;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const { User, Rental, Item } = require("../models");
|
|||||||
const PayoutService = require("./payoutService");
|
const PayoutService = require("./payoutService");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const { Op } = require("sequelize");
|
const { Op } = require("sequelize");
|
||||||
|
const { getPayoutFailureMessage } = require("../utils/payoutErrors");
|
||||||
|
const emailServices = require("./email");
|
||||||
|
|
||||||
class StripeWebhookService {
|
class StripeWebhookService {
|
||||||
/**
|
/**
|
||||||
@@ -219,10 +221,10 @@ class StripeWebhookService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle payout.failed webhook event.
|
* 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 {Object} payout - The Stripe payout object
|
||||||
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||||
* @returns {Object} - { processed, rentalsUpdated }
|
* @returns {Object} - { processed, rentalsUpdated, notificationSent }
|
||||||
*/
|
*/
|
||||||
static async handlePayoutFailed(payout, connectedAccountId) {
|
static async handlePayoutFailed(payout, connectedAccountId) {
|
||||||
logger.info("Processing payout.failed webhook", {
|
logger.info("Processing payout.failed webhook", {
|
||||||
@@ -259,7 +261,7 @@ class StripeWebhookService {
|
|||||||
payoutId: payout.id,
|
payoutId: payout.id,
|
||||||
connectedAccountId,
|
connectedAccountId,
|
||||||
});
|
});
|
||||||
return { processed: true, rentalsUpdated: 0 };
|
return { processed: true, rentalsUpdated: 0, notificationSent: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update all rentals with matching stripeTransferId
|
// Update all rentals with matching stripeTransferId
|
||||||
@@ -282,7 +284,49 @@ class StripeWebhookService {
|
|||||||
failureCode: payout.failure_code,
|
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) {
|
} catch (error) {
|
||||||
logger.error("Error processing payout.failed webhook", {
|
logger.error("Error processing payout.failed webhook", {
|
||||||
payoutId: payout.id,
|
payoutId: payout.id,
|
||||||
@@ -296,19 +340,19 @@ class StripeWebhookService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile payout statuses for an owner by checking Stripe for actual status.
|
* 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
|
* Checks both paid and failed payouts to ensure accurate status tracking.
|
||||||
* balance, if there's been a paid payout after our transfer was created, our
|
|
||||||
* funds were included.
|
|
||||||
*
|
*
|
||||||
* @param {string} ownerId - The owner's user ID
|
* @param {string} ownerId - The owner's user ID
|
||||||
* @returns {Object} - { reconciled, updated, errors }
|
* @returns {Object} - { reconciled, updated, failed, notificationsSent, errors }
|
||||||
*/
|
*/
|
||||||
static async reconcilePayoutStatuses(ownerId) {
|
static async reconcilePayoutStatuses(ownerId) {
|
||||||
const results = {
|
const results = {
|
||||||
reconciled: 0,
|
reconciled: 0,
|
||||||
updated: 0,
|
updated: 0,
|
||||||
|
failed: 0,
|
||||||
|
notificationsSent: 0,
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -325,7 +369,7 @@ class StripeWebhookService {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["stripeConnectedAccountId"],
|
attributes: ["id", "email", "firstName", "name", "stripeConnectedAccountId"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -346,42 +390,114 @@ class StripeWebhookService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recent paid payouts once for all rentals
|
// Fetch recent paid and failed payouts
|
||||||
const paidPayouts = await stripe.payouts.list(
|
const [paidPayouts, failedPayouts] = await Promise.all([
|
||||||
|
stripe.payouts.list(
|
||||||
{ status: "paid", limit: 20 },
|
{ status: "paid", limit: 20 },
|
||||||
{ stripeAccount: connectedAccountId }
|
{ stripeAccount: connectedAccountId }
|
||||||
);
|
),
|
||||||
|
stripe.payouts.list(
|
||||||
|
{ status: "failed", limit: 20 },
|
||||||
|
{ stripeAccount: connectedAccountId }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (paidPayouts.data.length === 0) {
|
// Build a map of transfer IDs to failed payouts for quick lookup
|
||||||
logger.info("No paid payouts found for connected account", { connectedAccountId });
|
const failedPayoutTransferMap = new Map();
|
||||||
return results;
|
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) {
|
for (const rental of rentalsToReconcile) {
|
||||||
results.reconciled++;
|
results.reconciled++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the transfer to find when it was created
|
// First check if this transfer is in a failed payout
|
||||||
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
|
const failedPayout = failedPayoutTransferMap.get(rental.stripeTransferId);
|
||||||
|
|
||||||
// Find a payout that arrived after the transfer was created
|
if (failedPayout) {
|
||||||
const matchingPayout = paidPayouts.data.find(
|
// 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
|
(payout) => payout.arrival_date >= transfer.created
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingPayout) {
|
if (matchingPaidPayout) {
|
||||||
await rental.update({
|
await rental.update({
|
||||||
bankDepositStatus: "paid",
|
bankDepositStatus: "paid",
|
||||||
bankDepositAt: new Date(matchingPayout.arrival_date * 1000),
|
bankDepositAt: new Date(matchingPaidPayout.arrival_date * 1000),
|
||||||
stripePayoutId: matchingPayout.id,
|
stripePayoutId: matchingPaidPayout.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
results.updated++;
|
results.updated++;
|
||||||
|
|
||||||
logger.info("Reconciled rental payout status", {
|
logger.info("Reconciled rental payout status to paid", {
|
||||||
rentalId: rental.id,
|
rentalId: rental.id,
|
||||||
payoutId: matchingPayout.id,
|
payoutId: matchingPaidPayout.id,
|
||||||
arrivalDate: matchingPayout.arrival_date,
|
arrivalDate: matchingPaidPayout.arrival_date,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (rentalError) {
|
} catch (rentalError) {
|
||||||
@@ -401,6 +517,8 @@ class StripeWebhookService {
|
|||||||
ownerId,
|
ownerId,
|
||||||
reconciled: results.reconciled,
|
reconciled: results.reconciled,
|
||||||
updated: results.updated,
|
updated: results.updated,
|
||||||
|
failed: results.failed,
|
||||||
|
notificationsSent: results.notificationsSent,
|
||||||
errors: results.errors.length,
|
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