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 Alert
+
+ +
+

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 - Manual Review Required
+
+ +
+

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

+
    +
  1. Review the dispute details in Stripe Dashboard
  2. +
  3. Assess whether to pursue clawback from owner's future payouts
  4. +
  5. Document the decision and reasoning
  6. +
  7. If clawback approved, contact owner before deducting
  8. +
+
+ +

+ 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. +

+
+ + +
+ +