handling if owner disconnects their stripe account
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user