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"); 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. * Triggers payouts for owner when payouts_enabled becomes true. * @param {Object} account - The Stripe account object from the webhook * @returns {Object} - { processed, payoutsTriggered, payoutResults } */ static async handleAccountUpdated(account) { const accountId = account.id; const payoutsEnabled = account.payouts_enabled; logger.info("Processing account.updated webhook", { accountId, payoutsEnabled, chargesEnabled: account.charges_enabled, detailsSubmitted: account.details_submitted, }); // 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" }; } const previousPayoutsEnabled = user.stripePayoutsEnabled; // Update user's payouts_enabled status await user.update({ stripePayoutsEnabled: payoutsEnabled }); logger.info("Updated user stripePayoutsEnabled", { userId: user.id, accountId, previousPayoutsEnabled, newPayoutsEnabled: payoutsEnabled, }); // 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, }); const result = await this.processPayoutsForOwner(user.id); return { processed: true, payoutsTriggered: true, payoutResults: result, }; } return { processed: true, payoutsTriggered: false }; } /** * 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. * @param {Object} payout - The Stripe payout object * @param {string} connectedAccountId - The connected account ID (from event.account) * @returns {Object} - { processed, rentalsUpdated } */ 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 }; } // 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, }); return { processed: true, rentalsUpdated: updatedCount }; } catch (error) { logger.error("Error processing payout.failed webhook", { payoutId: payout.id, connectedAccountId, error: error.message, stack: error.stack, }); throw error; } } } module.exports = StripeWebhookService;