From 3042a9007f605d420d57f4b48e6b8785a917870b Mon Sep 17 00:00:00 2001
From: jackiettran <41605212+jackiettran@users.noreply.github.com>
Date: Thu, 8 Jan 2026 17:23:55 -0500
Subject: [PATCH] handling stripe disputes/chargeback where renter disputes the
charge through their credit card company or bank
---
...0108000001-add-on-hold-to-payout-status.js | 18 +
...0108000002-add-dispute-fields-to-rental.js | 57 +++
backend/models/Rental.js | 39 +-
backend/routes/stripeWebhooks.js | 13 +
backend/services/disputeService.js | 162 ++++++++
.../services/email/core/TemplateManager.js | 3 +
.../email/domain/PaymentEmailService.js | 115 ++++++
.../templates/emails/disputeAlertToAdmin.html | 354 +++++++++++++++++
.../emails/disputeLostAlertToAdmin.html | 359 ++++++++++++++++++
9 files changed, 1119 insertions(+), 1 deletion(-)
create mode 100644 backend/migrations/20260108000001-add-on-hold-to-payout-status.js
create mode 100644 backend/migrations/20260108000002-add-dispute-fields-to-rental.js
create mode 100644 backend/services/disputeService.js
create mode 100644 backend/templates/emails/disputeAlertToAdmin.html
create mode 100644 backend/templates/emails/disputeLostAlertToAdmin.html
diff --git a/backend/migrations/20260108000001-add-on-hold-to-payout-status.js b/backend/migrations/20260108000001-add-on-hold-to-payout-status.js
new file mode 100644
index 0000000..4e074cc
--- /dev/null
+++ b/backend/migrations/20260108000001-add-on-hold-to-payout-status.js
@@ -0,0 +1,18 @@
+"use strict";
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ // Add 'on_hold' to the existing payoutStatus enum
+ await queryInterface.sequelize.query(`
+ ALTER TYPE "enum_Rentals_payoutStatus" ADD VALUE IF NOT EXISTS 'on_hold';
+ `);
+ },
+
+ down: async (queryInterface, Sequelize) => {
+ // Note: PostgreSQL doesn't support removing enum values directly
+ // This would require recreating the enum type
+ console.log(
+ "Cannot remove enum value - manual intervention required if rollback needed"
+ );
+ },
+};
diff --git a/backend/migrations/20260108000002-add-dispute-fields-to-rental.js b/backend/migrations/20260108000002-add-dispute-fields-to-rental.js
new file mode 100644
index 0000000..3f343ee
--- /dev/null
+++ b/backend/migrations/20260108000002-add-dispute-fields-to-rental.js
@@ -0,0 +1,57 @@
+"use strict";
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn("Rentals", "stripeDisputeStatus", {
+ type: Sequelize.ENUM("open", "won", "lost", "warning_closed"),
+ allowNull: true,
+ });
+ await queryInterface.addColumn("Rentals", "stripeDisputeId", {
+ type: Sequelize.STRING,
+ allowNull: true,
+ });
+ await queryInterface.addColumn("Rentals", "stripeDisputeReason", {
+ type: Sequelize.STRING,
+ allowNull: true,
+ });
+ await queryInterface.addColumn("Rentals", "stripeDisputeAmount", {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ });
+ await queryInterface.addColumn("Rentals", "stripeDisputeCreatedAt", {
+ type: Sequelize.DATE,
+ allowNull: true,
+ });
+ await queryInterface.addColumn("Rentals", "stripeDisputeEvidenceDueBy", {
+ type: Sequelize.DATE,
+ allowNull: true,
+ });
+ await queryInterface.addColumn("Rentals", "stripeDisputeClosedAt", {
+ type: Sequelize.DATE,
+ allowNull: true,
+ });
+ await queryInterface.addColumn("Rentals", "stripeDisputeLost", {
+ type: Sequelize.BOOLEAN,
+ defaultValue: false,
+ });
+ await queryInterface.addColumn("Rentals", "stripeDisputeLostAmount", {
+ type: Sequelize.INTEGER,
+ allowNull: true,
+ });
+ },
+
+ down: async (queryInterface) => {
+ await queryInterface.removeColumn("Rentals", "stripeDisputeStatus");
+ await queryInterface.removeColumn("Rentals", "stripeDisputeId");
+ await queryInterface.removeColumn("Rentals", "stripeDisputeReason");
+ await queryInterface.removeColumn("Rentals", "stripeDisputeAmount");
+ await queryInterface.removeColumn("Rentals", "stripeDisputeCreatedAt");
+ await queryInterface.removeColumn("Rentals", "stripeDisputeEvidenceDueBy");
+ await queryInterface.removeColumn("Rentals", "stripeDisputeClosedAt");
+ await queryInterface.removeColumn("Rentals", "stripeDisputeLost");
+ await queryInterface.removeColumn("Rentals", "stripeDisputeLostAmount");
+ await queryInterface.sequelize.query(
+ 'DROP TYPE IF EXISTS "enum_Rentals_stripeDisputeStatus";'
+ );
+ },
+};
diff --git a/backend/models/Rental.js b/backend/models/Rental.js
index 5851990..1feeb86 100644
--- a/backend/models/Rental.js
+++ b/backend/models/Rental.js
@@ -71,7 +71,7 @@ const Rental = sequelize.define("Rental", {
allowNull: false,
},
payoutStatus: {
- type: DataTypes.ENUM("pending", "completed", "failed"),
+ type: DataTypes.ENUM("pending", "completed", "failed", "on_hold"),
allowNull: true,
},
payoutProcessedAt: {
@@ -94,6 +94,43 @@ const Rental = sequelize.define("Rental", {
bankDepositFailureCode: {
type: DataTypes.STRING,
},
+ // Dispute tracking fields (for tracking Stripe payment disputes/chargebacks)
+ stripeDisputeStatus: {
+ type: DataTypes.ENUM("open", "won", "lost", "warning_closed"),
+ allowNull: true,
+ },
+ stripeDisputeId: {
+ type: DataTypes.STRING,
+ allowNull: true,
+ },
+ stripeDisputeReason: {
+ type: DataTypes.STRING,
+ allowNull: true,
+ },
+ stripeDisputeAmount: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ },
+ stripeDisputeCreatedAt: {
+ type: DataTypes.DATE,
+ allowNull: true,
+ },
+ stripeDisputeEvidenceDueBy: {
+ type: DataTypes.DATE,
+ allowNull: true,
+ },
+ stripeDisputeClosedAt: {
+ type: DataTypes.DATE,
+ allowNull: true,
+ },
+ stripeDisputeLost: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ },
+ stripeDisputeLostAmount: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ },
// Refund tracking fields
refundAmount: {
type: DataTypes.DECIMAL(10, 2),
diff --git a/backend/routes/stripeWebhooks.js b/backend/routes/stripeWebhooks.js
index 0e74446..b6b75b7 100644
--- a/backend/routes/stripeWebhooks.js
+++ b/backend/routes/stripeWebhooks.js
@@ -1,5 +1,6 @@
const express = require("express");
const StripeWebhookService = require("../services/stripeWebhookService");
+const DisputeService = require("../services/disputeService");
const logger = require("../utils/logger");
const router = express.Router();
@@ -70,6 +71,18 @@ router.post("/", async (req, res) => {
);
break;
+ case "charge.dispute.created":
+ // Renter disputed a charge with their bank
+ await DisputeService.handleDisputeCreated(event.data.object);
+ break;
+
+ case "charge.dispute.closed":
+ case "charge.dispute.funds_reinstated":
+ case "charge.dispute.funds_withdrawn":
+ // Dispute was resolved (won, lost, or warning closed)
+ await DisputeService.handleDisputeClosed(event.data.object);
+ break;
+
default:
logger.info("Unhandled webhook event type", { type: event.type });
}
diff --git a/backend/services/disputeService.js b/backend/services/disputeService.js
new file mode 100644
index 0000000..ffe0a29
--- /dev/null
+++ b/backend/services/disputeService.js
@@ -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;
diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js
index dec4e03..5bf2e3c 100644
--- a/backend/services/email/core/TemplateManager.js
+++ b/backend/services/email/core/TemplateManager.js
@@ -104,6 +104,9 @@ class TemplateManager {
"forumCommentDeletionToAuthor.html",
"paymentDeclinedToRenter.html",
"paymentMethodUpdatedToOwner.html",
+ "payoutFailedToOwner.html",
+ "disputeAlertToAdmin.html",
+ "disputeLostAlertToAdmin.html",
"userBannedNotification.html",
];
diff --git a/backend/services/email/domain/PaymentEmailService.js b/backend/services/email/domain/PaymentEmailService.js
index 7f45737..9a1682c 100644
--- a/backend/services/email/domain/PaymentEmailService.js
+++ b/backend/services/email/domain/PaymentEmailService.js
@@ -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;
diff --git a/backend/templates/emails/disputeAlertToAdmin.html b/backend/templates/emails/disputeAlertToAdmin.html
new file mode 100644
index 0000000..cb1f97c
--- /dev/null
+++ b/backend/templates/emails/disputeAlertToAdmin.html
@@ -0,0 +1,354 @@
+
+
+
+
+
+
+ Payment Dispute Alert - Village Share
+
+
+
+
+
+
+
+
Payment Dispute Opened
+
+
+ A payment dispute has been filed that requires immediate attention.
+ Evidence must be submitted before the deadline to contest this
+ dispute.
+
+
+
+
Disputed Amount
+
${{amount}}
+
Funds withdrawn from platform balance
+
+
+
+
Evidence Due By
+
{{evidenceDueBy}}
+
+
+
Dispute Details
+
+
+ | Rental ID |
+ {{rentalId}} |
+
+
+ | Item |
+ {{itemName}} |
+
+
+ | Dispute Reason |
+ {{reason}} |
+
+
+
+
Parties Involved
+
+
Renter: {{renterName}} ({{renterEmail}})
+
Owner: {{ownerName}} ({{ownerEmail}})
+
+
+
+
Action Required:
+
+ Log into the Stripe Dashboard to review and respond to this dispute.
+ Submit evidence such as rental agreements, communication logs,
+ delivery confirmation, and condition check photos before the
+ deadline.
+
+
+
+
+
+
+
+
diff --git a/backend/templates/emails/disputeLostAlertToAdmin.html b/backend/templates/emails/disputeLostAlertToAdmin.html
new file mode 100644
index 0000000..3502250
--- /dev/null
+++ b/backend/templates/emails/disputeLostAlertToAdmin.html
@@ -0,0 +1,359 @@
+
+
+
+
+
+
+ Dispute Lost - Manual Review Required - Village Share
+
+
+
+
+
+
+
+
Dispute Lost - Owner Already Paid
+
+
+
+ MANUAL REVIEW REQUIRED: The platform has lost this dispute, but the
+ owner has already received their payout.
+
+
+
+
+
Platform Loss
+
${{amount}}
+
Dispute lost - funds returned to renter
+
+
+
Financial Impact
+
+
+ | Rental ID |
+ {{rentalId}} |
+
+
+ | Disputed Amount Lost |
+ ${{amount}} |
+
+
+ | Owner Payout (already sent) |
+ ${{ownerPayoutAmount}} |
+
+
+
+
Owner Information
+
+
Name: {{ownerName}}
+
Email: {{ownerEmail}}
+
+
+
+
Recommended Actions
+
+ - Review the dispute details in Stripe Dashboard
+ - Assess whether to pursue clawback from owner's future payouts
+ - Document the decision and reasoning
+ - If clawback approved, contact owner before deducting
+
+
+
+
+ This alert was sent because the dispute was lost after the owner had
+ already received their bank deposit. Manual review is required to
+ determine next steps.
+
+
+
+
+
+
+