163 lines
4.8 KiB
JavaScript
163 lines
4.8 KiB
JavaScript
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: dispute.status,
|
|
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,
|
|
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;
|