handling if owner disconnects their stripe account
This commit is contained in:
@@ -71,6 +71,11 @@ router.post("/", async (req, res) => {
|
||||
);
|
||||
break;
|
||||
|
||||
case "account.application.deauthorized":
|
||||
// Owner disconnected their Stripe account from our platform
|
||||
await StripeWebhookService.handleAccountDeauthorized(event.account);
|
||||
break;
|
||||
|
||||
case "charge.dispute.created":
|
||||
// Renter disputed a charge with their bank
|
||||
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
|
||||
* Called when a new dispute is opened
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const logger = require("../utils/logger");
|
||||
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
||||
const { User } = require("../models");
|
||||
const emailServices = require("./email");
|
||||
|
||||
class StripeService {
|
||||
static async getCheckoutSession(sessionId) {
|
||||
@@ -111,6 +113,23 @@ class StripeService {
|
||||
|
||||
return transfer;
|
||||
} 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", {
|
||||
error: error.message,
|
||||
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({
|
||||
paymentIntentId,
|
||||
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.
|
||||
* 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", () => ({
|
||||
payment: {
|
||||
sendPayoutFailedNotification: jest.fn(),
|
||||
sendAccountDisconnectedEmail: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -389,4 +390,201 @@ describe("StripeWebhookService", () => {
|
||||
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