const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); const { User, Rental, Item } = require("../models"); const PayoutService = require("./payoutService"); const logger = require("../utils/logger"); const { Op } = require("sequelize"); const { getPayoutFailureMessage } = require("../utils/payoutErrors"); const emailServices = require("./email"); class StripeWebhookService { /** * Verify webhook signature and construct event */ static constructEvent(rawBody, signature, webhookSecret) { return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); } /** * Handle account.updated webhook event. * Tracks requirements, triggers payouts when enabled, and notifies when disabled. * @param {Object} account - The Stripe account object from the webhook * @returns {Object} - { processed, payoutsTriggered, payoutResults, notificationSent } */ static async handleAccountUpdated(account) { const accountId = account.id; const payoutsEnabled = account.payouts_enabled; const requirements = account.requirements || {}; logger.info("Processing account.updated webhook", { accountId, payoutsEnabled, chargesEnabled: account.charges_enabled, detailsSubmitted: account.details_submitted, currentlyDue: requirements.currently_due?.length || 0, pastDue: requirements.past_due?.length || 0, disabledReason: requirements.disabled_reason, }); // Find user with this Stripe account const user = await User.findOne({ where: { stripeConnectedAccountId: accountId }, }); if (!user) { logger.warn("No user found for Stripe account", { accountId }); return { processed: false, reason: "user_not_found" }; } // Store previous state before update const previousPayoutsEnabled = user.stripePayoutsEnabled; // Update user with all account status fields await user.update({ stripePayoutsEnabled: payoutsEnabled, stripeRequirementsCurrentlyDue: requirements.currently_due || [], stripeRequirementsPastDue: requirements.past_due || [], stripeDisabledReason: requirements.disabled_reason || null, stripeRequirementsLastUpdated: new Date(), }); logger.info("Updated user Stripe account status", { userId: user.id, accountId, previousPayoutsEnabled, newPayoutsEnabled: payoutsEnabled, currentlyDue: requirements.currently_due?.length || 0, pastDue: requirements.past_due?.length || 0, }); const result = { processed: true, payoutsTriggered: false, notificationSent: false, }; // If payouts just became enabled (false -> true), process pending payouts if (payoutsEnabled && !previousPayoutsEnabled) { logger.info("Payouts enabled for user, processing pending payouts", { userId: user.id, accountId, }); result.payoutsTriggered = true; result.payoutResults = await this.processPayoutsForOwner(user.id); } // If payouts just became disabled (true -> false), notify the owner if (!payoutsEnabled && previousPayoutsEnabled) { logger.warn("Payouts disabled for user", { userId: user.id, accountId, disabledReason: requirements.disabled_reason, currentlyDue: requirements.currently_due, }); try { const disabledReason = this.formatDisabledReason(requirements.disabled_reason); await emailServices.payment.sendPayoutsDisabledEmail(user.email, { ownerName: user.firstName || user.name, disabledReason, }); result.notificationSent = true; logger.info("Sent payouts disabled notification to owner", { userId: user.id, accountId, disabledReason: requirements.disabled_reason, }); } catch (emailError) { logger.error("Failed to send payouts disabled notification", { userId: user.id, accountId, error: emailError.message, }); } } return result; } /** * Format Stripe disabled_reason code to user-friendly message. * @param {string} reason - Stripe disabled_reason code * @returns {string} User-friendly message */ static formatDisabledReason(reason) { const reasonMap = { "requirements.past_due": "Some required information is past due and must be provided to continue receiving payouts.", "requirements.pending_verification": "Your submitted information is being verified. This usually takes a few minutes.", listed: "Your account has been listed for review due to potential policy concerns.", platform_paused: "Payouts have been temporarily paused by the platform.", rejected_fraud: "Your account was flagged for potential fraudulent activity.", rejected_listed: "Your account has been rejected due to policy concerns.", rejected_terms_of_service: "Your account was rejected due to a terms of service violation.", rejected_other: "Your account was rejected. Please contact support for more information.", under_review: "Your account is under review. We'll notify you when the review is complete.", }; return reasonMap[reason] || "Additional verification is required for your account."; } /** * Process all eligible payouts for a specific owner. * Called when owner completes Stripe onboarding. * @param {string} ownerId - The owner's user ID * @returns {Object} - { successful, failed, totalProcessed } */ static async processPayoutsForOwner(ownerId) { const eligibleRentals = await Rental.findAll({ where: { ownerId, status: "completed", paymentStatus: "paid", payoutStatus: "pending", }, include: [ { model: User, as: "owner", where: { stripeConnectedAccountId: { [Op.not]: null }, stripePayoutsEnabled: true, }, }, { model: Item, as: "item" }, ], }); logger.info("Found eligible rentals for owner payout", { ownerId, count: eligibleRentals.length, }); const results = { successful: [], failed: [], totalProcessed: eligibleRentals.length, }; for (const rental of eligibleRentals) { try { const result = await PayoutService.processRentalPayout(rental); results.successful.push({ rentalId: rental.id, amount: result.amount, transferId: result.transferId, }); } catch (error) { results.failed.push({ rentalId: rental.id, error: error.message, }); } } logger.info("Processed payouts for owner", { ownerId, successful: results.successful.length, failed: results.failed.length, }); return results; } /** * Handle payout.paid webhook event. * Updates rentals when funds are deposited to owner's bank account. * @param {Object} payout - The Stripe payout object * @param {string} connectedAccountId - The connected account ID (from event.account) * @returns {Object} - { processed, rentalsUpdated } */ static async handlePayoutPaid(payout, connectedAccountId) { logger.info("Processing payout.paid webhook", { payoutId: payout.id, connectedAccountId, amount: payout.amount, arrivalDate: payout.arrival_date, }); if (!connectedAccountId) { logger.warn("payout.paid webhook missing connected account ID", { payoutId: payout.id, }); return { processed: false, reason: "missing_account_id" }; } try { // Fetch balance transactions included in this payout // Filter by type 'transfer' to get only our platform transfers const balanceTransactions = await stripe.balanceTransactions.list( { payout: payout.id, type: "transfer", limit: 100, }, { stripeAccount: connectedAccountId } ); // Extract transfer IDs from balance transactions // The 'source' field contains the transfer ID const transferIds = balanceTransactions.data .map((bt) => bt.source) .filter(Boolean); if (transferIds.length === 0) { logger.info("No transfer balance transactions in payout", { payoutId: payout.id, connectedAccountId, }); return { processed: true, rentalsUpdated: 0 }; } logger.info("Found transfers in payout", { payoutId: payout.id, transferCount: transferIds.length, transferIds, }); // Update all rentals with matching stripeTransferId const [updatedCount] = await Rental.update( { bankDepositStatus: "paid", bankDepositAt: new Date(payout.arrival_date * 1000), stripePayoutId: payout.id, }, { where: { stripeTransferId: { [Op.in]: transferIds }, }, } ); logger.info("Updated rentals with bank deposit status", { payoutId: payout.id, rentalsUpdated: updatedCount, }); return { processed: true, rentalsUpdated: updatedCount }; } catch (error) { logger.error("Error processing payout.paid webhook", { payoutId: payout.id, connectedAccountId, error: error.message, stack: error.stack, }); throw error; } } /** * Handle payout.failed webhook event. * Updates rentals when bank deposit fails and notifies the owner. * @param {Object} payout - The Stripe payout object * @param {string} connectedAccountId - The connected account ID (from event.account) * @returns {Object} - { processed, rentalsUpdated, notificationSent } */ static async handlePayoutFailed(payout, connectedAccountId) { logger.info("Processing payout.failed webhook", { payoutId: payout.id, connectedAccountId, failureCode: payout.failure_code, failureMessage: payout.failure_message, }); if (!connectedAccountId) { logger.warn("payout.failed webhook missing connected account ID", { payoutId: payout.id, }); return { processed: false, reason: "missing_account_id" }; } try { // Fetch balance transactions included in this payout const balanceTransactions = await stripe.balanceTransactions.list( { payout: payout.id, type: "transfer", limit: 100, }, { stripeAccount: connectedAccountId } ); const transferIds = balanceTransactions.data .map((bt) => bt.source) .filter(Boolean); if (transferIds.length === 0) { logger.info("No transfer balance transactions in failed payout", { payoutId: payout.id, connectedAccountId, }); return { processed: true, rentalsUpdated: 0, notificationSent: false }; } // Update all rentals with matching stripeTransferId const [updatedCount] = await Rental.update( { bankDepositStatus: "failed", stripePayoutId: payout.id, bankDepositFailureCode: payout.failure_code || "unknown", }, { where: { stripeTransferId: { [Op.in]: transferIds }, }, } ); logger.warn("Updated rentals with failed bank deposit status", { payoutId: payout.id, rentalsUpdated: updatedCount, failureCode: payout.failure_code, }); // Find owner and send notification const user = await User.findOne({ where: { stripeConnectedAccountId: connectedAccountId }, }); let notificationSent = false; if (user) { // Get user-friendly message const failureInfo = getPayoutFailureMessage(payout.failure_code); try { await emailServices.payment.sendPayoutFailedNotification(user.email, { ownerName: user.firstName || user.name, payoutAmount: payout.amount / 100, failureMessage: failureInfo.message, actionRequired: failureInfo.action, failureCode: payout.failure_code || "unknown", requiresBankUpdate: failureInfo.requiresBankUpdate, }); notificationSent = true; logger.info("Sent payout failed notification to owner", { userId: user.id, payoutId: payout.id, failureCode: payout.failure_code, }); } catch (emailError) { logger.error("Failed to send payout failed notification", { userId: user.id, payoutId: payout.id, error: emailError.message, }); } } else { logger.warn("No user found for connected account", { connectedAccountId, payoutId: payout.id, }); } return { processed: true, rentalsUpdated: updatedCount, notificationSent }; } catch (error) { logger.error("Error processing payout.failed webhook", { payoutId: payout.id, connectedAccountId, error: error.message, stack: error.stack, }); throw error; } } /** * Handle payout.canceled webhook event. * Stripe can cancel payouts if: * - They are manually canceled via Dashboard/API before processing * - The connected account is deactivated * - Risk review cancels the payout * @param {Object} payout - The Stripe payout object * @param {string} connectedAccountId - The connected account ID * @returns {Object} - { processed, rentalsUpdated } */ static async handlePayoutCanceled(payout, connectedAccountId) { logger.info("Processing payout.canceled webhook", { payoutId: payout.id, connectedAccountId, }); if (!connectedAccountId) { logger.warn("payout.canceled webhook missing connected account ID", { payoutId: payout.id, }); return { processed: false, reason: "missing_account_id" }; } try { // Retrieve balance transactions to find associated transfers const balanceTransactions = await stripe.balanceTransactions.list( { payout: payout.id, type: "transfer", limit: 100, }, { stripeAccount: connectedAccountId } ); const transferIds = balanceTransactions.data .map((bt) => bt.source) .filter(Boolean); if (transferIds.length === 0) { logger.info("No transfers found for canceled payout", { payoutId: payout.id, }); return { processed: true, rentalsUpdated: 0 }; } // Update all rentals associated with this payout const [updatedCount] = await Rental.update( { bankDepositStatus: "canceled", stripePayoutId: payout.id, }, { where: { stripeTransferId: { [Op.in]: transferIds }, }, } ); logger.info("Updated rentals for canceled payout", { payoutId: payout.id, rentalsUpdated: updatedCount, }); return { processed: true, rentalsUpdated: updatedCount }; } catch (error) { logger.error("Error processing payout.canceled webhook", { payoutId: payout.id, connectedAccountId, error: error.message, stack: error.stack, }); throw error; } } /** * 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, stripeRequirementsCurrentlyDue: [], stripeRequirementsPastDue: [], stripeDisabledReason: null, stripeRequirementsLastUpdated: null, }); 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, payout.failed, or payout.canceled webhooks were missed. * * Checks paid, failed, and canceled payouts to ensure accurate status tracking. * * @param {string} ownerId - The owner's user ID * @returns {Object} - { reconciled, updated, failed, notificationsSent, errors } */ static async reconcilePayoutStatuses(ownerId) { const results = { reconciled: 0, updated: 0, failed: 0, notificationsSent: 0, errors: [], }; try { // Find rentals that need reconciliation const rentalsToReconcile = await Rental.findAll({ where: { ownerId, payoutStatus: "completed", stripeTransferId: { [Op.not]: null }, bankDepositStatus: { [Op.is]: null }, }, include: [ { model: User, as: "owner", attributes: ["id", "email", "firstName", "name", "stripeConnectedAccountId"], }, ], }); if (rentalsToReconcile.length === 0) { return results; } logger.info("Reconciling payout statuses", { ownerId, rentalsCount: rentalsToReconcile.length, }); // Get the connected account ID (same for all rentals of this owner) const connectedAccountId = rentalsToReconcile[0].owner?.stripeConnectedAccountId; if (!connectedAccountId) { logger.warn("Owner has no connected account ID", { ownerId }); return results; } // Fetch recent paid, failed, and canceled payouts const [paidPayouts, failedPayouts, canceledPayouts] = await Promise.all([ stripe.payouts.list( { status: "paid", limit: 20 }, { stripeAccount: connectedAccountId } ), stripe.payouts.list( { status: "failed", limit: 20 }, { stripeAccount: connectedAccountId } ), stripe.payouts.list( { status: "canceled", limit: 20 }, { stripeAccount: connectedAccountId } ), ]); // Build a map of transfer IDs to failed payouts for quick lookup const failedPayoutTransferMap = new Map(); for (const payout of failedPayouts.data) { try { const balanceTransactions = await stripe.balanceTransactions.list( { payout: payout.id, type: "transfer", limit: 100 }, { stripeAccount: connectedAccountId } ); for (const bt of balanceTransactions.data) { if (bt.source) { failedPayoutTransferMap.set(bt.source, payout); } } } catch (btError) { logger.warn("Error fetching balance transactions for failed payout", { payoutId: payout.id, error: btError.message, }); } } // Build a map of transfer IDs to canceled payouts for quick lookup const canceledPayoutTransferMap = new Map(); for (const payout of canceledPayouts.data) { try { const balanceTransactions = await stripe.balanceTransactions.list( { payout: payout.id, type: "transfer", limit: 100 }, { stripeAccount: connectedAccountId } ); for (const bt of balanceTransactions.data) { if (bt.source) { canceledPayoutTransferMap.set(bt.source, payout); } } } catch (btError) { logger.warn("Error fetching balance transactions for canceled payout", { payoutId: payout.id, error: btError.message, }); } } const owner = rentalsToReconcile[0].owner; for (const rental of rentalsToReconcile) { results.reconciled++; try { // First check if this transfer is in a failed payout const failedPayout = failedPayoutTransferMap.get(rental.stripeTransferId); if (failedPayout) { // Update rental with failed status await rental.update({ bankDepositStatus: "failed", stripePayoutId: failedPayout.id, bankDepositFailureCode: failedPayout.failure_code || "unknown", }); results.failed++; logger.warn("Reconciled rental with failed payout", { rentalId: rental.id, payoutId: failedPayout.id, failureCode: failedPayout.failure_code, }); // Send failure notification if (owner?.email) { try { const failureInfo = getPayoutFailureMessage(failedPayout.failure_code); await emailServices.payment.sendPayoutFailedNotification(owner.email, { ownerName: owner.firstName || owner.name, payoutAmount: failedPayout.amount / 100, failureMessage: failureInfo.message, actionRequired: failureInfo.action, failureCode: failedPayout.failure_code || "unknown", requiresBankUpdate: failureInfo.requiresBankUpdate, }); results.notificationsSent++; logger.info("Sent reconciled payout failure notification", { userId: owner.id, rentalId: rental.id, payoutId: failedPayout.id, }); } catch (emailError) { logger.error("Failed to send reconciled payout failure notification", { userId: owner.id, rentalId: rental.id, error: emailError.message, }); } } continue; // Move to next rental } // Check if this transfer is in a canceled payout const canceledPayout = canceledPayoutTransferMap.get(rental.stripeTransferId); if (canceledPayout) { await rental.update({ bankDepositStatus: "canceled", stripePayoutId: canceledPayout.id, }); results.canceled = (results.canceled || 0) + 1; logger.info("Reconciled rental with canceled payout", { rentalId: rental.id, payoutId: canceledPayout.id, }); continue; // Move to next rental } // Check for paid payout const transfer = await stripe.transfers.retrieve(rental.stripeTransferId); const matchingPaidPayout = paidPayouts.data.find( (payout) => payout.arrival_date >= transfer.created ); if (matchingPaidPayout) { await rental.update({ bankDepositStatus: "paid", bankDepositAt: new Date(matchingPaidPayout.arrival_date * 1000), stripePayoutId: matchingPaidPayout.id, }); results.updated++; logger.info("Reconciled rental payout status to paid", { rentalId: rental.id, payoutId: matchingPaidPayout.id, arrivalDate: matchingPaidPayout.arrival_date, }); } } catch (rentalError) { results.errors.push({ rentalId: rental.id, error: rentalError.message, }); logger.error("Error reconciling rental payout status", { rentalId: rental.id, error: rentalError.message, }); } } logger.info("Payout reconciliation complete", { ownerId, reconciled: results.reconciled, updated: results.updated, failed: results.failed, canceled: results.canceled || 0, notificationsSent: results.notificationsSent, errors: results.errors.length, }); return results; } catch (error) { logger.error("Error in reconcilePayoutStatuses", { ownerId, error: error.message, stack: error.stack, }); throw error; } } } module.exports = StripeWebhookService;