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

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