const { Rental, User, Item } = require("../models"); const emailServices = require("./email"); const logger = require("../utils/logger"); class DisputeService { /** * Handle charge.dispute.created webhook * Called when a renter disputes a charge with their bank * @param {Object} dispute - The Stripe dispute object from the webhook * @returns {Object} Processing result */ static async handleDisputeCreated(dispute) { const paymentIntentId = dispute.payment_intent; logger.info("Processing dispute.created webhook", { disputeId: dispute.id, paymentIntentId, reason: dispute.reason, amount: dispute.amount, }); const rental = await Rental.findOne({ where: { stripePaymentIntentId: paymentIntentId }, include: [ { model: User, as: "owner" }, { model: User, as: "renter" }, { model: Item, as: "item" }, ], }); if (!rental) { logger.warn("Dispute received for unknown rental", { paymentIntentId, disputeId: dispute.id, }); return { processed: false, reason: "rental_not_found" }; } // Update rental with dispute info await rental.update({ stripeDisputeStatus: "open", stripeDisputeId: dispute.id, stripeDisputeReason: dispute.reason, stripeDisputeAmount: dispute.amount, stripeDisputeCreatedAt: new Date(dispute.created * 1000), stripeDisputeEvidenceDueBy: new Date( dispute.evidence_details.due_by * 1000 ), }); // Pause payout if not yet deposited to owner's bank if (rental.bankDepositStatus !== "paid") { await rental.update({ payoutStatus: "on_hold" }); logger.info("Payout placed on hold due to dispute", { rentalId: rental.id, }); } // Send admin notification await emailServices.payment.sendDisputeAlertEmail({ rentalId: rental.id, amount: dispute.amount / 100, reason: dispute.reason, evidenceDueBy: new Date(dispute.evidence_details.due_by * 1000), renterEmail: rental.renter?.email, renterName: rental.renter?.firstName, ownerEmail: rental.owner?.email, ownerName: rental.owner?.firstName, itemName: rental.item?.name, }); logger.warn("Dispute created for rental", { rentalId: rental.id, disputeId: dispute.id, reason: dispute.reason, evidenceDueBy: dispute.evidence_details.due_by, }); return { processed: true, rentalId: rental.id }; } /** * Handle dispute closed events (won, lost, or warning_closed) * Called for: charge.dispute.closed, charge.dispute.funds_reinstated, charge.dispute.funds_withdrawn * @param {Object} dispute - The Stripe dispute object from the webhook * @returns {Object} Processing result */ static async handleDisputeClosed(dispute) { logger.info("Processing dispute closed webhook", { disputeId: dispute.id, status: dispute.status, }); const rental = await Rental.findOne({ where: { stripeDisputeId: dispute.id }, include: [{ model: User, as: "owner" }], }); if (!rental) { logger.warn("Dispute closed for unknown rental", { disputeId: dispute.id, }); return { processed: false, reason: "rental_not_found" }; } const won = dispute.status === "won"; await rental.update({ stripeDisputeStatus: dispute.status, // 'won', 'lost', 'warning_closed' stripeDisputeClosedAt: new Date(), }); // If we won the dispute, resume payout if it was on hold if (won && rental.payoutStatus === "on_hold") { await rental.update({ payoutStatus: "pending" }); logger.info("Payout resumed after winning dispute", { rentalId: rental.id, }); } // If we lost, record the loss amount if (!won && dispute.status === "lost") { await rental.update({ stripeDisputeLost: true, stripeDisputeLostAmount: dispute.amount, }); logger.warn("Dispute lost", { rentalId: rental.id, amount: dispute.amount, }); // If owner was already paid, flag for manual review if (rental.bankDepositStatus === "paid") { await emailServices.payment.sendDisputeLostAlertEmail({ rentalId: rental.id, amount: dispute.amount / 100, ownerAlreadyPaid: true, ownerPayoutAmount: rental.payoutAmount, ownerEmail: rental.owner?.email, ownerName: rental.owner?.firstName, }); logger.warn( "Dispute lost - owner already paid, flagged for manual review", { rentalId: rental.id, payoutAmount: rental.payoutAmount, } ); } } logger.info("Dispute closed", { rentalId: rental.id, disputeId: dispute.id, outcome: dispute.status, }); return { processed: true, won, rentalId: rental.id }; } } module.exports = DisputeService;