handling if owner disconnects their stripe account
This commit is contained in:
@@ -71,6 +71,11 @@ router.post("/", async (req, res) => {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "account.application.deauthorized":
|
||||||
|
// Owner disconnected their Stripe account from our platform
|
||||||
|
await StripeWebhookService.handleAccountDeauthorized(event.account);
|
||||||
|
break;
|
||||||
|
|
||||||
case "charge.dispute.created":
|
case "charge.dispute.created":
|
||||||
// Renter disputed a charge with their bank
|
// Renter disputed a charge with their bank
|
||||||
await DisputeService.handleDisputeCreated(event.data.object);
|
await DisputeService.handleDisputeCreated(event.data.object);
|
||||||
|
|||||||
@@ -173,6 +173,46 @@ class PaymentEmailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification when owner disconnects their Stripe account
|
||||||
|
* @param {string} ownerEmail - Owner's email address
|
||||||
|
* @param {Object} params - Email parameters
|
||||||
|
* @param {string} params.ownerName - Owner's name
|
||||||
|
* @param {boolean} params.hasPendingPayouts - Whether there are pending payouts
|
||||||
|
* @param {number} params.pendingPayoutCount - Number of pending payouts
|
||||||
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async sendAccountDisconnectedEmail(ownerEmail, params) {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { ownerName, hasPendingPayouts, pendingPayoutCount } = params;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
ownerName: ownerName || "there",
|
||||||
|
hasPendingPayouts: hasPendingPayouts || false,
|
||||||
|
pendingPayoutCount: pendingPayoutCount || 0,
|
||||||
|
reconnectUrl: `${process.env.FRONTEND_URL}/settings/payouts`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
|
"accountDisconnectedToOwner",
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.emailClient.sendEmail(
|
||||||
|
ownerEmail,
|
||||||
|
"Your payout account has been disconnected - Village Share",
|
||||||
|
htmlContent
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send account disconnected email:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send dispute alert to platform admin
|
* Send dispute alert to platform admin
|
||||||
* Called when a new dispute is opened
|
* Called when a new dispute is opened
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
||||||
|
const { User } = require("../models");
|
||||||
|
const emailServices = require("./email");
|
||||||
|
|
||||||
class StripeService {
|
class StripeService {
|
||||||
static async getCheckoutSession(sessionId) {
|
static async getCheckoutSession(sessionId) {
|
||||||
@@ -111,6 +113,23 @@ class StripeService {
|
|||||||
|
|
||||||
return transfer;
|
return transfer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Check if this is a disconnected account error (fallback for missed webhooks)
|
||||||
|
if (this.isAccountDisconnectedError(error)) {
|
||||||
|
logger.warn("Transfer failed - account appears disconnected", {
|
||||||
|
destination,
|
||||||
|
errorCode: error.code,
|
||||||
|
errorType: error.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up stale connection data asynchronously (don't block the error)
|
||||||
|
this.handleDisconnectedAccount(destination).catch((cleanupError) => {
|
||||||
|
logger.error("Failed to clean up disconnected account", {
|
||||||
|
destination,
|
||||||
|
error: cleanupError.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.error("Error creating transfer", {
|
logger.error("Error creating transfer", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
@@ -119,6 +138,77 @@ class StripeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error indicates the connected account is disconnected.
|
||||||
|
* Used as fallback detection when webhook was missed.
|
||||||
|
* @param {Error} error - Stripe error object
|
||||||
|
* @returns {boolean} - True if error indicates disconnected account
|
||||||
|
*/
|
||||||
|
static isAccountDisconnectedError(error) {
|
||||||
|
// Stripe returns these error codes when account is disconnected or invalid
|
||||||
|
const disconnectedCodes = ["account_invalid", "platform_api_key_expired"];
|
||||||
|
|
||||||
|
// Error messages that indicate disconnection
|
||||||
|
const disconnectedMessages = [
|
||||||
|
"cannot transfer",
|
||||||
|
"not connected",
|
||||||
|
"no longer connected",
|
||||||
|
"account has been deauthorized",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (disconnectedCodes.includes(error.code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (error.message || "").toLowerCase();
|
||||||
|
return disconnectedMessages.some((msg) => message.includes(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle disconnected account - cleanup and notify.
|
||||||
|
* Called as fallback when webhook was missed.
|
||||||
|
* @param {string} accountId - The disconnected Stripe account ID
|
||||||
|
*/
|
||||||
|
static async handleDisconnectedAccount(accountId) {
|
||||||
|
try {
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { stripeConnectedAccountId: accountId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("Cleaning up disconnected account (webhook likely missed)", {
|
||||||
|
userId: user.id,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear connection
|
||||||
|
await user.update({
|
||||||
|
stripeConnectedAccountId: null,
|
||||||
|
stripePayoutsEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
await emailServices.payment.sendAccountDisconnectedEmail(user.email, {
|
||||||
|
ownerName: user.firstName || user.name,
|
||||||
|
hasPendingPayouts: true, // We're in a transfer, so there's at least one
|
||||||
|
pendingPayoutCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Sent account disconnected notification (fallback)", {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.error("Failed to clean up disconnected account", {
|
||||||
|
accountId,
|
||||||
|
error: cleanupError.message,
|
||||||
|
});
|
||||||
|
// Don't throw - let original error propagate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async createRefund({
|
static async createRefund({
|
||||||
paymentIntentId,
|
paymentIntentId,
|
||||||
amount,
|
amount,
|
||||||
|
|||||||
@@ -338,6 +338,93 @@ class StripeWebhookService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle account.application.deauthorized webhook event.
|
||||||
|
* Triggered when an owner disconnects their Stripe account from our platform.
|
||||||
|
* @param {string} accountId - The connected account ID that was deauthorized
|
||||||
|
* @returns {Object} - { processed, userId, pendingPayoutsCount, notificationSent }
|
||||||
|
*/
|
||||||
|
static async handleAccountDeauthorized(accountId) {
|
||||||
|
logger.warn("Processing account.application.deauthorized webhook", {
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accountId) {
|
||||||
|
logger.warn("account.application.deauthorized webhook missing account ID");
|
||||||
|
return { processed: false, reason: "missing_account_id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find the user by their connected account ID
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { stripeConnectedAccountId: accountId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn("No user found for deauthorized Stripe account", { accountId });
|
||||||
|
return { processed: false, reason: "user_not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Stripe connection fields
|
||||||
|
await user.update({
|
||||||
|
stripeConnectedAccountId: null,
|
||||||
|
stripePayoutsEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Cleared Stripe connection for deauthorized account", {
|
||||||
|
userId: user.id,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for pending payouts that will now fail
|
||||||
|
const pendingRentals = await Rental.findAll({
|
||||||
|
where: {
|
||||||
|
ownerId: user.id,
|
||||||
|
payoutStatus: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingRentals.length > 0) {
|
||||||
|
logger.warn("Owner disconnected account with pending payouts", {
|
||||||
|
userId: user.id,
|
||||||
|
pendingCount: pendingRentals.length,
|
||||||
|
pendingRentalIds: pendingRentals.map((r) => r.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification email
|
||||||
|
let notificationSent = false;
|
||||||
|
try {
|
||||||
|
await emailServices.payment.sendAccountDisconnectedEmail(user.email, {
|
||||||
|
ownerName: user.firstName || user.name,
|
||||||
|
hasPendingPayouts: pendingRentals.length > 0,
|
||||||
|
pendingPayoutCount: pendingRentals.length,
|
||||||
|
});
|
||||||
|
notificationSent = true;
|
||||||
|
logger.info("Sent account disconnected notification", { userId: user.id });
|
||||||
|
} catch (emailError) {
|
||||||
|
logger.error("Failed to send account disconnected notification", {
|
||||||
|
userId: user.id,
|
||||||
|
error: emailError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
processed: true,
|
||||||
|
userId: user.id,
|
||||||
|
pendingPayoutsCount: pendingRentals.length,
|
||||||
|
notificationSent,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error processing account.application.deauthorized webhook", {
|
||||||
|
accountId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 payout.paid or payout.failed webhooks were missed.
|
* This handles cases where payout.paid or payout.failed webhooks were missed.
|
||||||
|
|||||||
321
backend/templates/emails/accountDisconnectedToOwner.html
Normal file
321
backend/templates/emails/accountDisconnectedToOwner.html
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<!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 Account Disconnected - 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-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">Village Share</div>
|
||||||
|
<div class="tagline">Payout Account Disconnected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Hi {{ownerName}},</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Your Stripe payout account has been disconnected from Village Share.
|
||||||
|
This means we are no longer able to send your earnings to your bank
|
||||||
|
account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="warning-display">
|
||||||
|
<div class="warning-label">Account Status</div>
|
||||||
|
<div class="warning-icon">⚠</div>
|
||||||
|
<div class="warning-subtitle">Payout account disconnected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if hasPendingPayouts}}
|
||||||
|
<div class="alert-box">
|
||||||
|
<p><strong>Important:</strong></p>
|
||||||
|
<p>
|
||||||
|
You have {{pendingPayoutCount}} pending
|
||||||
|
payout{{#if (gt pendingPayoutCount 1)}}s{{/if}} that cannot be
|
||||||
|
processed until you reconnect your payout account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<h2>What This Means</h2>
|
||||||
|
<p>
|
||||||
|
Without a connected payout account, you will not be able to receive
|
||||||
|
earnings from rentals. Your listings will remain active, but payments
|
||||||
|
cannot be deposited to your bank account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>What To Do</h2>
|
||||||
|
<p>
|
||||||
|
If you disconnected your account by mistake, or would like to continue
|
||||||
|
receiving payouts, please reconnect your Stripe account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="text-align: center">
|
||||||
|
<a href="{{reconnectUrl}}" class="button">Reconnect Payout Account</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Need help?</strong></p>
|
||||||
|
<p>
|
||||||
|
If you're having trouble reconnecting your account, or didn't
|
||||||
|
disconnect it yourself, please contact our support team right away.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We're here to help you continue earning on Village Share. Don't
|
||||||
|
hesitate to reach out if you have any questions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Village Share</strong></p>
|
||||||
|
<p>
|
||||||
|
This is an important notification about your payout account. You
|
||||||
|
received this message because your Stripe account was disconnected
|
||||||
|
from our platform.
|
||||||
|
</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>
|
||||||
@@ -38,6 +38,7 @@ jest.mock("../../../utils/logger", () => ({
|
|||||||
jest.mock("../../../services/email", () => ({
|
jest.mock("../../../services/email", () => ({
|
||||||
payment: {
|
payment: {
|
||||||
sendPayoutFailedNotification: jest.fn(),
|
sendPayoutFailedNotification: jest.fn(),
|
||||||
|
sendAccountDisconnectedEmail: jest.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -389,4 +390,201 @@ describe("StripeWebhookService", () => {
|
|||||||
expect(mockRental.update).toHaveBeenCalled();
|
expect(mockRental.update).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleAccountDeauthorized", () => {
|
||||||
|
it("should return missing_account_id when accountId is not provided", async () => {
|
||||||
|
const result = await StripeWebhookService.handleAccountDeauthorized(null);
|
||||||
|
|
||||||
|
expect(result.processed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_account_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return missing_account_id for undefined accountId", async () => {
|
||||||
|
const result = await StripeWebhookService.handleAccountDeauthorized(undefined);
|
||||||
|
|
||||||
|
expect(result.processed).toBe(false);
|
||||||
|
expect(result.reason).toBe("missing_account_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return user_not_found when account does not match any user", async () => {
|
||||||
|
User.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await StripeWebhookService.handleAccountDeauthorized("acct_unknown");
|
||||||
|
|
||||||
|
expect(result.processed).toBe(false);
|
||||||
|
expect(result.reason).toBe("user_not_found");
|
||||||
|
expect(User.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { stripeConnectedAccountId: "acct_unknown" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear Stripe connection fields when account is deauthorized", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "owner@test.com",
|
||||||
|
firstName: "Test",
|
||||||
|
name: "Test Owner",
|
||||||
|
stripeConnectedAccountId: "acct_123",
|
||||||
|
stripePayoutsEnabled: true,
|
||||||
|
update: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
Rental.findAll.mockResolvedValue([]);
|
||||||
|
emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await StripeWebhookService.handleAccountDeauthorized("acct_123");
|
||||||
|
|
||||||
|
expect(result.processed).toBe(true);
|
||||||
|
expect(result.userId).toBe(1);
|
||||||
|
expect(mockUser.update).toHaveBeenCalledWith({
|
||||||
|
stripeConnectedAccountId: null,
|
||||||
|
stripePayoutsEnabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log warning when there are pending payouts", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "owner@test.com",
|
||||||
|
firstName: "Test",
|
||||||
|
update: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
const mockRentals = [{ id: 100 }, { id: 101 }];
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
Rental.findAll.mockResolvedValue(mockRentals);
|
||||||
|
emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await StripeWebhookService.handleAccountDeauthorized("acct_123");
|
||||||
|
|
||||||
|
expect(result.pendingPayoutsCount).toBe(2);
|
||||||
|
expect(Rental.findAll).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
ownerId: 1,
|
||||||
|
payoutStatus: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send notification email to owner", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "owner@test.com",
|
||||||
|
firstName: "Test",
|
||||||
|
update: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
Rental.findAll.mockResolvedValue([]);
|
||||||
|
emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await StripeWebhookService.handleAccountDeauthorized("acct_123");
|
||||||
|
|
||||||
|
expect(result.notificationSent).toBe(true);
|
||||||
|
expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith(
|
||||||
|
"owner@test.com",
|
||||||
|
{
|
||||||
|
ownerName: "Test",
|
||||||
|
hasPendingPayouts: false,
|
||||||
|
pendingPayoutCount: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send notification with pending payout info when there are pending payouts", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "owner@test.com",
|
||||||
|
firstName: "Owner",
|
||||||
|
update: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
const mockRentals = [{ id: 100 }, { id: 101 }, { id: 102 }];
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
Rental.findAll.mockResolvedValue(mockRentals);
|
||||||
|
emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await StripeWebhookService.handleAccountDeauthorized("acct_123");
|
||||||
|
|
||||||
|
expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith(
|
||||||
|
"owner@test.com",
|
||||||
|
{
|
||||||
|
ownerName: "Owner",
|
||||||
|
hasPendingPayouts: true,
|
||||||
|
pendingPayoutCount: 3,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use name fallback when firstName is not available", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "owner@test.com",
|
||||||
|
firstName: null,
|
||||||
|
name: "Full Name",
|
||||||
|
update: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
Rental.findAll.mockResolvedValue([]);
|
||||||
|
emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await StripeWebhookService.handleAccountDeauthorized("acct_123");
|
||||||
|
|
||||||
|
expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith(
|
||||||
|
"owner@test.com",
|
||||||
|
expect.objectContaining({
|
||||||
|
ownerName: "Full Name",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle email sending failure gracefully", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "owner@test.com",
|
||||||
|
firstName: "Test",
|
||||||
|
update: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
Rental.findAll.mockResolvedValue([]);
|
||||||
|
emailServices.payment.sendAccountDisconnectedEmail.mockRejectedValue(
|
||||||
|
new Error("Email service down")
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await StripeWebhookService.handleAccountDeauthorized("acct_123");
|
||||||
|
|
||||||
|
// Should still mark as processed even if notification fails
|
||||||
|
expect(result.processed).toBe(true);
|
||||||
|
expect(result.notificationSent).toBe(false);
|
||||||
|
expect(mockUser.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when database update fails", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
email: "owner@test.com",
|
||||||
|
firstName: "Test",
|
||||||
|
update: jest.fn().mockRejectedValue(new Error("DB error")),
|
||||||
|
};
|
||||||
|
|
||||||
|
User.findOne.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
StripeWebhookService.handleAccountDeauthorized("acct_123")
|
||||||
|
).rejects.toThrow("DB error");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user