handling stripe disputes/chargeback where renter disputes the charge through their credit card company or bank
This commit is contained in:
@@ -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"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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";'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -71,7 +71,7 @@ const Rental = sequelize.define("Rental", {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
payoutStatus: {
|
payoutStatus: {
|
||||||
type: DataTypes.ENUM("pending", "completed", "failed"),
|
type: DataTypes.ENUM("pending", "completed", "failed", "on_hold"),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
payoutProcessedAt: {
|
payoutProcessedAt: {
|
||||||
@@ -94,6 +94,43 @@ const Rental = sequelize.define("Rental", {
|
|||||||
bankDepositFailureCode: {
|
bankDepositFailureCode: {
|
||||||
type: DataTypes.STRING,
|
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
|
// Refund tracking fields
|
||||||
refundAmount: {
|
refundAmount: {
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const StripeWebhookService = require("../services/stripeWebhookService");
|
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||||
|
const DisputeService = require("../services/disputeService");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -70,6 +71,18 @@ router.post("/", async (req, res) => {
|
|||||||
);
|
);
|
||||||
break;
|
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:
|
default:
|
||||||
logger.info("Unhandled webhook event type", { type: event.type });
|
logger.info("Unhandled webhook event type", { type: event.type });
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"forumCommentDeletionToAuthor.html",
|
||||||
"paymentDeclinedToRenter.html",
|
"paymentDeclinedToRenter.html",
|
||||||
"paymentMethodUpdatedToOwner.html",
|
"paymentMethodUpdatedToOwner.html",
|
||||||
|
"payoutFailedToOwner.html",
|
||||||
|
"disputeAlertToAdmin.html",
|
||||||
|
"disputeLostAlertToAdmin.html",
|
||||||
"userBannedNotification.html",
|
"userBannedNotification.html",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const EmailClient = require("../core/EmailClient");
|
const EmailClient = require("../core/EmailClient");
|
||||||
const TemplateManager = require("../core/TemplateManager");
|
const TemplateManager = require("../core/TemplateManager");
|
||||||
|
const { formatEmailDate } = require("../core/emailUtils");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PaymentEmailService handles payment-related emails
|
* PaymentEmailService handles payment-related emails
|
||||||
@@ -171,6 +172,120 @@ class PaymentEmailService {
|
|||||||
return { success: false, error: error.message };
|
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;
|
module.exports = PaymentEmailService;
|
||||||
|
|||||||
354
backend/templates/emails/disputeAlertToAdmin.html
Normal file
354
backend/templates/emails/disputeAlertToAdmin.html
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>Payment Dispute Alert - Village Share</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles */
|
||||||
|
body,
|
||||||
|
table,
|
||||||
|
td,
|
||||||
|
p,
|
||||||
|
a,
|
||||||
|
li,
|
||||||
|
blockquote {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #f8d7da;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 30px 0 15px 0;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert box */
|
||||||
|
.alert-box {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-box p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-box p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning display */
|
||||||
|
.warning-display {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-label {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-amount {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-subtitle {
|
||||||
|
color: #f8d7da;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deadline display */
|
||||||
|
.deadline-display {
|
||||||
|
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deadline-label {
|
||||||
|
color: #212529;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deadline-date {
|
||||||
|
color: #212529;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info table */
|
||||||
|
.info-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th,
|
||||||
|
.info-table td {
|
||||||
|
padding: 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table td {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.content,
|
||||||
|
.footer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-amount {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th,
|
||||||
|
.info-table td {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">Village Share</div>
|
||||||
|
<div class="tagline">Payment Dispute Alert</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h1>Payment Dispute Opened</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A payment dispute has been filed that requires immediate attention.
|
||||||
|
Evidence must be submitted before the deadline to contest this
|
||||||
|
dispute.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="warning-display">
|
||||||
|
<div class="warning-label">Disputed Amount</div>
|
||||||
|
<div class="warning-amount">${{amount}}</div>
|
||||||
|
<div class="warning-subtitle">Funds withdrawn from platform balance</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deadline-display">
|
||||||
|
<div class="deadline-label">Evidence Due By</div>
|
||||||
|
<div class="deadline-date">{{evidenceDueBy}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Dispute Details</h2>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr>
|
||||||
|
<th>Rental ID</th>
|
||||||
|
<td>{{rentalId}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<td>{{itemName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Dispute Reason</th>
|
||||||
|
<td>{{reason}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Parties Involved</h2>
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Renter:</strong> {{renterName}} ({{renterEmail}})</p>
|
||||||
|
<p><strong>Owner:</strong> {{ownerName}} ({{ownerEmail}})</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert-box">
|
||||||
|
<p><strong>Action Required:</strong></p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Village Share - Admin Alert</strong></p>
|
||||||
|
<p>
|
||||||
|
This is an automated notification about a payment dispute requiring
|
||||||
|
immediate attention.
|
||||||
|
</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
359
backend/templates/emails/disputeLostAlertToAdmin.html
Normal file
359
backend/templates/emails/disputeLostAlertToAdmin.html
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>Dispute Lost - Manual Review Required - Village Share</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles */
|
||||||
|
body,
|
||||||
|
table,
|
||||||
|
td,
|
||||||
|
p,
|
||||||
|
a,
|
||||||
|
li,
|
||||||
|
blockquote {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #721c24 0%, #491217 100%);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #f8d7da;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 30px 0 15px 0;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #6c757d;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Critical alert box */
|
||||||
|
.critical-alert {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 2px solid #dc3545;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-alert p {
|
||||||
|
margin: 0;
|
||||||
|
color: #721c24;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loss display */
|
||||||
|
.loss-display {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loss-label {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loss-amount {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loss-subtitle {
|
||||||
|
color: #f8d7da;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info box */
|
||||||
|
.info-box {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info table */
|
||||||
|
.info-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th,
|
||||||
|
.info-table td {
|
||||||
|
padding: 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table td {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action list */
|
||||||
|
.action-list {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list h3 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #004085;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list li {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-list li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.content,
|
||||||
|
.footer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loss-amount {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th,
|
||||||
|
.info-table td {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">Village Share</div>
|
||||||
|
<div class="tagline">Dispute Lost - Manual Review Required</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h1>Dispute Lost - Owner Already Paid</h1>
|
||||||
|
|
||||||
|
<div class="critical-alert">
|
||||||
|
<p>
|
||||||
|
MANUAL REVIEW REQUIRED: The platform has lost this dispute, but the
|
||||||
|
owner has already received their payout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loss-display">
|
||||||
|
<div class="loss-label">Platform Loss</div>
|
||||||
|
<div class="loss-amount">${{amount}}</div>
|
||||||
|
<div class="loss-subtitle">Dispute lost - funds returned to renter</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Financial Impact</h2>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr>
|
||||||
|
<th>Rental ID</th>
|
||||||
|
<td>{{rentalId}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Disputed Amount Lost</th>
|
||||||
|
<td style="color: #dc3545"><strong>${{amount}}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Owner Payout (already sent)</th>
|
||||||
|
<td>${{ownerPayoutAmount}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Owner Information</h2>
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Name:</strong> {{ownerName}}</p>
|
||||||
|
<p><strong>Email:</strong> {{ownerEmail}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-list">
|
||||||
|
<h3>Recommended Actions</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Review the dispute details in Stripe Dashboard</li>
|
||||||
|
<li>Assess whether to pursue clawback from owner's future payouts</li>
|
||||||
|
<li>Document the decision and reasoning</li>
|
||||||
|
<li>If clawback approved, contact owner before deducting</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #6c757d; font-size: 14px">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Village Share - Admin Alert</strong></p>
|
||||||
|
<p>
|
||||||
|
This is an automated notification about a dispute loss requiring
|
||||||
|
manual review.
|
||||||
|
</p>
|
||||||
|
<p>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user