const { Rental, User, Item } = require("../models"); const StripeService = require("./stripeService"); const emailServices = require("./email"); const logger = require("../utils/logger"); const { Op } = require("sequelize"); class PayoutService { /** * Attempt to process payout for a single rental immediately after completion. * Checks if owner's Stripe account has payouts enabled before attempting. * @param {string} rentalId - The rental ID to process * @returns {Object} - { attempted, success, reason, transferId, amount } */ static async triggerPayoutOnCompletion(rentalId) { try { const rental = await Rental.findByPk(rentalId, { include: [ { model: User, as: "owner", attributes: ["id", "email", "firstName", "lastName", "stripeConnectedAccountId", "stripePayoutsEnabled"], }, { model: Item, as: "item" }, ], }); if (!rental) { logger.warn("Rental not found for payout trigger", { rentalId }); return { attempted: false, success: false, reason: "rental_not_found" }; } // Check eligibility conditions if (rental.paymentStatus !== "paid") { logger.info("Payout skipped: payment not paid", { rentalId, paymentStatus: rental.paymentStatus }); return { attempted: false, success: false, reason: "payment_not_paid" }; } if (rental.payoutStatus !== "pending") { logger.info("Payout skipped: payout not pending", { rentalId, payoutStatus: rental.payoutStatus }); return { attempted: false, success: false, reason: "payout_not_pending" }; } if (!rental.owner?.stripeConnectedAccountId) { logger.info("Payout skipped: owner has no Stripe account", { rentalId, ownerId: rental.ownerId }); return { attempted: false, success: false, reason: "no_stripe_account" }; } // Check if owner has payouts enabled (onboarding complete) if (!rental.owner.stripePayoutsEnabled) { logger.info("Payout deferred: owner payouts not enabled, will process when onboarding completes", { rentalId, ownerId: rental.ownerId, }); return { attempted: false, success: false, reason: "payouts_not_enabled" }; } // Attempt the payout const result = await this.processRentalPayout(rental); logger.info("Payout triggered successfully on completion", { rentalId, transferId: result.transferId, amount: result.amount, }); return { attempted: true, success: true, transferId: result.transferId, amount: result.amount, }; } catch (error) { logger.error("Error triggering payout on completion", { error: error.message, stack: error.stack, rentalId, }); // Payout marked as failed by processRentalPayout, will be retried by daily retry job return { attempted: true, success: false, reason: "payout_failed", error: error.message, }; } } static async getEligiblePayouts() { try { const eligibleRentals = await Rental.findAll({ where: { status: "completed", paymentStatus: "paid", payoutStatus: "pending", }, include: [ { model: User, as: "owner", where: { stripeConnectedAccountId: { [Op.not]: null, }, stripePayoutsEnabled: true, }, }, { model: Item, as: "item", }, ], }); return eligibleRentals; } catch (error) { logger.error("Error getting eligible payouts", { error: error.message, stack: error.stack }); throw error; } } static async processRentalPayout(rental) { try { if (!rental.owner || !rental.owner.stripeConnectedAccountId) { throw new Error("Owner does not have a connected Stripe account"); } if (rental.payoutStatus !== "pending") { throw new Error("Rental payout has already been processed"); } if (!rental.payoutAmount || rental.payoutAmount <= 0) { throw new Error("Invalid payout amount"); } // Create Stripe transfer const transfer = await StripeService.createTransfer({ amount: rental.payoutAmount, destination: rental.owner.stripeConnectedAccountId, metadata: { rentalId: rental.id, ownerId: rental.ownerId, totalAmount: rental.totalAmount.toString(), platformFee: rental.platformFee.toString(), startDateTime: rental.startDateTime.toISOString(), endDateTime: rental.endDateTime.toISOString(), }, }); // Update rental with successful payout await rental.update({ payoutStatus: "completed", payoutProcessedAt: new Date(), stripeTransferId: transfer.id, }); console.log( `Payout completed for rental ${rental.id}: $${rental.payoutAmount} to ${rental.owner.stripeConnectedAccountId}` ); // Send payout notification email to owner try { await emailServices.rentalFlow.sendPayoutReceivedEmail(rental.owner, rental); logger.info("Payout notification email sent to owner", { rentalId: rental.id, ownerId: rental.ownerId }); } catch (emailError) { // Log error but don't fail the payout logger.error("Failed to send payout notification email", { error: emailError.message, stack: emailError.stack, rentalId: rental.id, ownerId: rental.ownerId }); } return { success: true, transferId: transfer.id, amount: rental.payoutAmount, }; } catch (error) { logger.error("Error processing payout for rental", { error: error.message, stack: error.stack, rentalId: rental.id }); // Update status to failed await rental.update({ payoutStatus: "failed", }); throw error; } } static async processAllEligiblePayouts() { try { const eligibleRentals = await this.getEligiblePayouts(); console.log( `Found ${eligibleRentals.length} eligible rentals for payout` ); const results = { successful: [], failed: [], totalProcessed: eligibleRentals.length, }; for (const rental of eligibleRentals) { try { const result = await this.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, }); } } console.log( `Payout processing complete: ${results.successful.length} successful, ${results.failed.length} failed` ); return results; } catch (error) { logger.error("Error processing all eligible payouts", { error: error.message, stack: error.stack }); throw error; } } static async retryFailedPayouts() { try { const failedRentals = await Rental.findAll({ where: { status: "completed", paymentStatus: "paid", payoutStatus: "failed", }, include: [ { model: User, as: "owner", where: { stripeConnectedAccountId: { [Op.not]: null, }, stripePayoutsEnabled: true, }, }, { model: Item, as: "item", }, ], }); console.log(`Found ${failedRentals.length} failed payouts to retry`); const results = { successful: [], failed: [], totalProcessed: failedRentals.length, }; for (const rental of failedRentals) { try { // Reset to pending before retrying await rental.update({ payoutStatus: "pending" }); const result = await this.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, }); } } console.log( `Retry processing complete: ${results.successful.length} successful, ${results.failed.length} failed` ); return results; } catch (error) { logger.error("Error retrying failed payouts", { error: error.message, stack: error.stack }); throw error; } } } module.exports = PayoutService;