handling if owner disconnects their stripe account

This commit is contained in:
jackiettran
2026-01-08 17:49:02 -05:00
parent 3042a9007f
commit 8585633907
6 changed files with 741 additions and 0 deletions

View File

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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.

View 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">&#9888;</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>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

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