handling stripe disputes/chargeback where renter disputes the charge through their credit card company or bank
This commit is contained in:
162
backend/services/disputeService.js
Normal file
162
backend/services/disputeService.js
Normal file
@@ -0,0 +1,162 @@
|
||||
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;
|
||||
@@ -104,6 +104,9 @@ class TemplateManager {
|
||||
"forumCommentDeletionToAuthor.html",
|
||||
"paymentDeclinedToRenter.html",
|
||||
"paymentMethodUpdatedToOwner.html",
|
||||
"payoutFailedToOwner.html",
|
||||
"disputeAlertToAdmin.html",
|
||||
"disputeLostAlertToAdmin.html",
|
||||
"userBannedNotification.html",
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const EmailClient = require("../core/EmailClient");
|
||||
const TemplateManager = require("../core/TemplateManager");
|
||||
const { formatEmailDate } = require("../core/emailUtils");
|
||||
|
||||
/**
|
||||
* PaymentEmailService handles payment-related emails
|
||||
@@ -171,6 +172,120 @@ class PaymentEmailService {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send dispute alert to platform admin
|
||||
* Called when a new dispute is opened
|
||||
* @param {Object} disputeData - Dispute information
|
||||
* @param {string} disputeData.rentalId - Rental ID
|
||||
* @param {number} disputeData.amount - Disputed amount in dollars
|
||||
* @param {string} disputeData.reason - Stripe dispute reason code
|
||||
* @param {Date} disputeData.evidenceDueBy - Evidence submission deadline
|
||||
* @param {string} disputeData.renterEmail - Renter's email
|
||||
* @param {string} disputeData.renterName - Renter's name
|
||||
* @param {string} disputeData.ownerEmail - Owner's email
|
||||
* @param {string} disputeData.ownerName - Owner's name
|
||||
* @param {string} disputeData.itemName - Item name
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
async sendDisputeAlertEmail(disputeData) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const variables = {
|
||||
rentalId: disputeData.rentalId,
|
||||
itemName: disputeData.itemName || "Unknown Item",
|
||||
amount: disputeData.amount.toFixed(2),
|
||||
reason: this.formatDisputeReason(disputeData.reason),
|
||||
evidenceDueBy: formatEmailDate(disputeData.evidenceDueBy),
|
||||
renterName: disputeData.renterName || "Unknown",
|
||||
renterEmail: disputeData.renterEmail || "Unknown",
|
||||
ownerName: disputeData.ownerName || "Unknown",
|
||||
ownerEmail: disputeData.ownerEmail || "Unknown",
|
||||
};
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"disputeAlertToAdmin",
|
||||
variables
|
||||
);
|
||||
|
||||
// Send to admin email (configure in env)
|
||||
const adminEmail = process.env.ADMIN_EMAIL || process.env.SES_FROM_EMAIL;
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
adminEmail,
|
||||
`URGENT: Payment Dispute - Rental #${disputeData.rentalId}`,
|
||||
htmlContent
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send dispute alert email:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send alert when dispute is lost and owner was already paid
|
||||
* Flags for manual review to decide on potential clawback
|
||||
* @param {Object} disputeData - Dispute information
|
||||
* @param {string} disputeData.rentalId - Rental ID
|
||||
* @param {number} disputeData.amount - Lost dispute amount in dollars
|
||||
* @param {number} disputeData.ownerPayoutAmount - Amount already paid to owner
|
||||
* @param {string} disputeData.ownerEmail - Owner's email
|
||||
* @param {string} disputeData.ownerName - Owner's name
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
async sendDisputeLostAlertEmail(disputeData) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const variables = {
|
||||
rentalId: disputeData.rentalId,
|
||||
amount: disputeData.amount.toFixed(2),
|
||||
ownerPayoutAmount: parseFloat(disputeData.ownerPayoutAmount || 0).toFixed(2),
|
||||
ownerName: disputeData.ownerName || "Unknown",
|
||||
ownerEmail: disputeData.ownerEmail || "Unknown",
|
||||
};
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"disputeLostAlertToAdmin",
|
||||
variables
|
||||
);
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL || process.env.SES_FROM_EMAIL;
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
adminEmail,
|
||||
`ACTION REQUIRED: Dispute Lost - Owner Already Paid - Rental #${disputeData.rentalId}`,
|
||||
htmlContent
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send dispute lost alert email:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Stripe dispute reason codes to human-readable text
|
||||
* @param {string} reason - Stripe dispute reason code
|
||||
* @returns {string} Human-readable reason
|
||||
*/
|
||||
formatDisputeReason(reason) {
|
||||
const reasonMap = {
|
||||
duplicate: "Duplicate charge",
|
||||
fraudulent: "Fraudulent transaction",
|
||||
subscription_canceled: "Subscription canceled",
|
||||
product_unacceptable: "Product unacceptable",
|
||||
product_not_received: "Product not received",
|
||||
unrecognized: "Unrecognized charge",
|
||||
credit_not_processed: "Credit not processed",
|
||||
general: "General dispute",
|
||||
};
|
||||
return reasonMap[reason] || reason || "Unknown reason";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PaymentEmailService;
|
||||
|
||||
Reference in New Issue
Block a user