failed payment method handling

This commit is contained in:
jackiettran
2026-01-06 16:13:58 -05:00
parent ec84b8354e
commit 28c0b4976d
14 changed files with 1639 additions and 17 deletions

View File

@@ -0,0 +1,30 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
// Add paymentFailedNotifiedAt - tracks when owner notified renter about failed payment
await queryInterface.addColumn("Rentals", "paymentFailedNotifiedAt", {
type: Sequelize.DATE,
allowNull: true,
});
// Add paymentMethodUpdatedAt - tracks last payment method update for rate limiting
await queryInterface.addColumn("Rentals", "paymentMethodUpdatedAt", {
type: Sequelize.DATE,
allowNull: true,
});
// Add paymentMethodUpdateCount - count of updates within time window for rate limiting
await queryInterface.addColumn("Rentals", "paymentMethodUpdateCount", {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: 0,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn("Rentals", "paymentMethodUpdateCount");
await queryInterface.removeColumn("Rentals", "paymentMethodUpdatedAt");
await queryInterface.removeColumn("Rentals", "paymentFailedNotifiedAt");
},
};

View File

@@ -131,6 +131,18 @@ const Rental = sequelize.define("Rental", {
chargedAt: { chargedAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
}, },
// Payment failure notification tracking
paymentFailedNotifiedAt: {
type: DataTypes.DATE,
},
// Payment method update rate limiting
paymentMethodUpdatedAt: {
type: DataTypes.DATE,
},
paymentMethodUpdateCount: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
deliveryMethod: { deliveryMethod: {
type: DataTypes.ENUM("pickup", "delivery"), type: DataTypes.ENUM("pickup", "delivery"),
defaultValue: "pickup", defaultValue: "pickup",

View File

@@ -11,8 +11,11 @@ const RefundService = require("../services/refundService");
const LateReturnService = require("../services/lateReturnService"); const LateReturnService = require("../services/lateReturnService");
const PayoutService = require("../services/payoutService"); const PayoutService = require("../services/payoutService");
const DamageAssessmentService = require("../services/damageAssessmentService"); const DamageAssessmentService = require("../services/damageAssessmentService");
const StripeWebhookService = require("../services/stripeWebhookService");
const StripeService = require("../services/stripeService");
const emailServices = require("../services/email"); const emailServices = require("../services/email");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const { PaymentError } = require("../utils/stripeErrors");
const { validateS3Keys } = require("../utils/s3KeyValidator"); const { validateS3Keys } = require("../utils/s3KeyValidator");
const { IMAGE_LIMITS } = require("../config/imageLimits"); const { IMAGE_LIMITS } = require("../config/imageLimits");
const { isActive, getEffectiveStatus } = require("../utils/rentalStatus"); const { isActive, getEffectiveStatus } = require("../utils/rentalStatus");
@@ -106,6 +109,19 @@ router.get("/renting", authenticateToken, async (req, res) => {
router.get("/owning", authenticateToken, async (req, res) => { router.get("/owning", authenticateToken, async (req, res) => {
try { try {
// Reconcile payout statuses with Stripe before returning data
// This handles cases where webhooks were missed
try {
await StripeWebhookService.reconcilePayoutStatuses(req.user.id);
} catch (reconcileError) {
// Log but don't fail the request - still return rentals
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error reconciling payout statuses", {
error: reconcileError.message,
userId: req.user.id,
});
}
const rentals = await Rental.findAll({ const rentals = await Rental.findAll({
where: { ownerId: req.user.id }, where: { ownerId: req.user.id },
// Remove explicit attributes to let Sequelize handle missing columns gracefully // Remove explicit attributes to let Sequelize handle missing columns gracefully
@@ -236,12 +252,16 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
const now = new Date(); const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
if (rentalStartDateTime < fiveMinutesAgo) { if (rentalStartDateTime < fiveMinutesAgo) {
return res.status(400).json({ error: "Start date cannot be in the past" }); return res
.status(400)
.json({ error: "Start date cannot be in the past" });
} }
// Validate end date/time is after start date/time // Validate end date/time is after start date/time
if (rentalEndDateTime <= rentalStartDateTime) { if (rentalEndDateTime <= rentalStartDateTime) {
return res.status(400).json({ error: "End date/time must be after start date/time" }); return res
.status(400)
.json({ error: "End date/time must be after start date/time" });
} }
// Calculate rental cost using duration calculator // Calculate rental cost using duration calculator
@@ -403,12 +423,24 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"], attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeConnectedAccountId",
],
}, },
{ {
model: User, model: User,
as: "renter", as: "renter",
attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"], attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeCustomerId",
],
}, },
], ],
}); });
@@ -477,7 +509,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"], attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeConnectedAccountId",
],
}, },
{ {
model: User, model: User,
@@ -559,14 +597,55 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Payment failed during approval", { reqLogger.error("Payment failed during approval", {
error: paymentError.message, error: paymentError.message,
code: paymentError.code,
stack: paymentError.stack, stack: paymentError.stack,
rentalId: req.params.id, rentalId: req.params.id,
userId: req.user.id, userId: req.user.id,
}); });
// Keep rental as pending, but inform of payment failure
return res.status(400).json({ // Determine the renter-facing message
error: "Payment failed during approval", const renterMessage =
details: paymentError.message, paymentError instanceof PaymentError
? paymentError.renterMessage
: "Your payment could not be processed. Please try a different payment method.";
// Track payment failure timestamp
await rental.update({ paymentFailedNotifiedAt: new Date() });
// Auto-send payment declined email to renter
try {
await emailServices.payment.sendPaymentDeclinedNotification(
rental.renter.email,
{
renterFirstName: rental.renter.firstName,
itemName: rental.item.name,
declineReason: renterMessage,
rentalId: rental.id,
}
);
reqLogger.info("Payment declined email auto-sent to renter", {
rentalId: rental.id,
renterId: rental.renterId,
});
} catch (emailError) {
reqLogger.error("Failed to send payment declined email", {
error: emailError.message,
rentalId: rental.id,
});
}
// Keep rental as pending, inform owner of payment failure
const ownerMessage =
paymentError instanceof PaymentError
? paymentError.ownerMessage
: "The payment could not be processed.";
return res.status(402).json({
error: "payment_failed",
code: paymentError.code || "unknown_error",
ownerMessage,
renterMessage,
rentalId: rental.id,
}); });
} }
} else { } else {
@@ -581,7 +660,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"], attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeConnectedAccountId",
],
}, },
{ {
model: User, model: User,
@@ -942,7 +1027,9 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
const now = new Date(); const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
if (rentalStartDateTime < fiveMinutesAgo) { if (rentalStartDateTime < fiveMinutesAgo) {
return res.status(400).json({ error: "Start date cannot be in the past" }); return res
.status(400)
.json({ error: "Start date cannot be in the past" });
} }
// Validate date range // Validate date range
@@ -1195,7 +1282,13 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"], attributes: [
"id",
"firstName",
"lastName",
"email",
"stripeConnectedAccountId",
],
}, },
{ {
model: User, model: User,
@@ -1411,4 +1504,152 @@ router.post("/:id/report-damage", authenticateToken, async (req, res, next) => {
} }
}); });
// PUT /rentals/:id/payment-method - Renter updates payment method for pending rental
router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
try {
const rentalId = req.params.id;
const { stripePaymentMethodId } = req.body;
if (!stripePaymentMethodId) {
return res.status(400).json({ error: "Payment method ID is required" });
}
const rental = await Rental.findByPk(rentalId, {
include: [
{ model: Item, as: "item" },
{
model: User,
as: "owner",
attributes: ["id", "firstName", "email"],
},
],
});
if (!rental) {
return res.status(404).json({ error: "Rental not found" });
}
// Only renter can update payment method
if (rental.renterId !== req.user.id) {
return res
.status(403)
.json({ error: "Only the renter can update the payment method" });
}
// Only for pending rentals with pending payment
if (rental.status !== "pending" || rental.paymentStatus !== "pending") {
return res.status(400).json({
error: "Can only update payment method for pending rentals",
});
}
// Verify payment method belongs to renter's Stripe customer
const renter = await User.findByPk(req.user.id);
if (!renter.stripeCustomerId) {
return res
.status(400)
.json({ error: "No Stripe customer account found" });
}
let paymentMethod;
try {
paymentMethod = await StripeService.getPaymentMethod(
stripePaymentMethodId
);
} catch {
return res.status(400).json({ error: "Invalid payment method" });
}
if (paymentMethod.customer !== renter.stripeCustomerId) {
return res
.status(403)
.json({ error: "Payment method does not belong to this account" });
}
// Rate limiting: Max 3 updates per rental per hour
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
if (
rental.paymentMethodUpdatedAt &&
rental.paymentMethodUpdatedAt > oneHourAgo
) {
const updateCount = rental.paymentMethodUpdateCount || 0;
if (updateCount >= 3) {
return res.status(429).json({
error: "Too many payment method updates. Please try again later.",
});
}
}
// Store old payment method for audit log
const oldPaymentMethodId = rental.stripePaymentMethodId;
// Atomic update with status check (prevents race condition)
const [updateCount] = await Rental.update(
{
stripePaymentMethodId,
paymentMethodUpdatedAt: new Date(),
paymentMethodUpdateCount:
rental.paymentMethodUpdatedAt > oneHourAgo
? (rental.paymentMethodUpdateCount || 0) + 1
: 1,
},
{
where: {
id: rentalId,
status: "pending",
paymentStatus: "pending",
},
}
);
if (updateCount === 0) {
return res.status(409).json({
error: "Rental status changed. Please refresh and try again.",
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Payment method updated", {
rentalId,
userId: req.user.id,
oldPaymentMethodId,
newPaymentMethodId: stripePaymentMethodId,
});
// Optionally notify owner that payment method was updated
try {
await emailServices.payment.sendPaymentMethodUpdatedNotification(
rental.owner.email,
{
ownerFirstName: rental.owner.firstName,
itemName: rental.item.name,
rentalId: rental.id,
approvalUrl: `${process.env.FRONTEND_URL}/rentals/${rentalId}`,
}
);
} catch (emailError) {
// Don't fail the request if email fails
reqLogger.error("Failed to send payment method updated notification", {
error: emailError.message,
rentalId,
});
}
res.json({
success: true,
message: "Payment method updated. The owner can now retry approval.",
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error updating payment method", {
error: error.message,
stack: error.stack,
rentalId: req.params.id,
userId: req.user.id,
});
next(error);
}
});
module.exports = router; module.exports = router;

View File

@@ -51,7 +51,14 @@ class TemplateManager {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async loadEmailTemplates() { async loadEmailTemplates() {
const templatesDir = path.join(__dirname, "..", "..", "..", "templates", "emails"); const templatesDir = path.join(
__dirname,
"..",
"..",
"..",
"templates",
"emails"
);
// Critical templates that must load for the app to function // Critical templates that must load for the app to function
const criticalTemplates = [ const criticalTemplates = [
@@ -95,6 +102,8 @@ class TemplateManager {
"forumItemRequestNotification.html", "forumItemRequestNotification.html",
"forumPostDeletionToAuthor.html", "forumPostDeletionToAuthor.html",
"forumCommentDeletionToAuthor.html", "forumCommentDeletionToAuthor.html",
"paymentDeclinedToRenter.html",
"paymentMethodUpdatedToOwner.html",
]; ];
const failedTemplates = []; const failedTemplates = [];
@@ -129,7 +138,9 @@ class TemplateManager {
if (missingCriticalTemplates.length > 0) { if (missingCriticalTemplates.length > 0) {
const error = new Error( const error = new Error(
`Critical email templates failed to load: ${missingCriticalTemplates.join(", ")}` `Critical email templates failed to load: ${missingCriticalTemplates.join(
", "
)}`
); );
error.missingTemplates = missingCriticalTemplates; error.missingTemplates = missingCriticalTemplates;
throw error; throw error;
@@ -138,7 +149,9 @@ class TemplateManager {
// Warn if non-critical templates failed // Warn if non-critical templates failed
if (failedTemplates.length > 0) { if (failedTemplates.length > 0) {
console.warn( console.warn(
`⚠️ Non-critical templates failed to load: ${failedTemplates.join(", ")}` `⚠️ Non-critical templates failed to load: ${failedTemplates.join(
", "
)}`
); );
console.warn("These templates will use fallback versions"); console.warn("These templates will use fallback versions");
} }
@@ -483,6 +496,36 @@ class TemplateManager {
<p>Please review this feedback and take appropriate action if needed.</p> <p>Please review this feedback and take appropriate action if needed.</p>
` `
), ),
paymentDeclinedToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterFirstName}},</p>
<h2>Payment Issue with Your Rental Request</h2>
<p>The owner tried to approve your rental for <strong>{{itemName}}</strong>, but there was an issue processing your payment.</p>
<h3>What Happened</h3>
<p>{{declineReason}}</p>
<div class="info-box">
<p><strong>What You Can Do</strong></p>
<p>Please update your payment method so the owner can complete the approval of your rental request.</p>
</div>
<p>Once you update your payment method, the owner will be notified and can try to approve your rental again.</p>
`
),
paymentMethodUpdatedToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerFirstName}},</p>
<h2>Payment Method Updated</h2>
<p>The renter has updated their payment method for the rental of <strong>{{itemName}}</strong>.</p>
<div class="info-box">
<p><strong>Ready to Approve</strong></p>
<p>You can now try approving the rental request again. The renter's new payment method will be charged when you approve.</p>
</div>
<p style="text-align: center;"><a href="{{approvalUrl}}" class="button">Review & Approve Rental</a></p>
`
),
}; };
return ( return (

View File

@@ -0,0 +1,121 @@
const EmailClient = require("../core/EmailClient");
const TemplateManager = require("../core/TemplateManager");
/**
* PaymentEmailService handles payment-related emails
* This service is responsible for:
* - Sending payment declined notifications to renters
* - Sending payment method updated notifications to owners
*/
class PaymentEmailService {
constructor() {
this.emailClient = new EmailClient();
this.templateManager = new TemplateManager();
this.initialized = false;
}
/**
* Initialize the payment email service
* @returns {Promise<void>}
*/
async initialize() {
if (this.initialized) return;
await Promise.all([
this.emailClient.initialize(),
this.templateManager.initialize(),
]);
this.initialized = true;
console.log("Payment Email Service initialized successfully");
}
/**
* Send payment declined notification to renter
* @param {string} renterEmail - Renter's email address
* @param {Object} params - Email parameters
* @param {string} params.renterFirstName - Renter's first name
* @param {string} params.itemName - Item name
* @param {string} params.declineReason - User-friendly decline reason
* @param {string} params.rentalId - Rental ID
* @param {string} params.updatePaymentUrl - URL to update payment method
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendPaymentDeclinedNotification(renterEmail, params) {
if (!this.initialized) {
await this.initialize();
}
try {
const {
renterFirstName,
itemName,
declineReason,
updatePaymentUrl,
} = params;
const variables = {
renterFirstName: renterFirstName || "there",
itemName: itemName || "the item",
declineReason: declineReason || "Your payment could not be processed.",
updatePaymentUrl: updatePaymentUrl,
};
const htmlContent = await this.templateManager.renderTemplate(
"paymentDeclinedToRenter",
variables
);
return await this.emailClient.sendEmail(
renterEmail,
`Action Required: Payment Issue - ${itemName || "Your Rental"}`,
htmlContent
);
} catch (error) {
console.error("Failed to send payment declined notification:", error);
return { success: false, error: error.message };
}
}
/**
* Send payment method updated notification to owner
* @param {string} ownerEmail - Owner's email address
* @param {Object} params - Email parameters
* @param {string} params.ownerFirstName - Owner's first name
* @param {string} params.itemName - Item name
* @param {string} params.rentalId - Rental ID
* @param {string} params.approvalUrl - URL to approve the rental
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendPaymentMethodUpdatedNotification(ownerEmail, params) {
if (!this.initialized) {
await this.initialize();
}
try {
const { ownerFirstName, itemName, approvalUrl } = params;
const variables = {
ownerFirstName: ownerFirstName || "there",
itemName: itemName || "the item",
approvalUrl: approvalUrl,
};
const htmlContent = await this.templateManager.renderTemplate(
"paymentMethodUpdatedToOwner",
variables
);
return await this.emailClient.sendEmail(
ownerEmail,
`Payment Method Updated - ${itemName || "Your Item"}`,
htmlContent
);
} catch (error) {
console.error("Failed to send payment method updated notification:", error);
return { success: false, error: error.message };
}
}
}
module.exports = PaymentEmailService;

View File

@@ -7,6 +7,7 @@ const RentalFlowEmailService = require("./domain/RentalFlowEmailService");
const RentalReminderEmailService = require("./domain/RentalReminderEmailService"); const RentalReminderEmailService = require("./domain/RentalReminderEmailService");
const UserEngagementEmailService = require("./domain/UserEngagementEmailService"); const UserEngagementEmailService = require("./domain/UserEngagementEmailService");
const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService"); const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService");
const PaymentEmailService = require("./domain/PaymentEmailService");
/** /**
* EmailServices aggregates all domain-specific email services * EmailServices aggregates all domain-specific email services
@@ -24,6 +25,7 @@ class EmailServices {
this.rentalReminder = new RentalReminderEmailService(); this.rentalReminder = new RentalReminderEmailService();
this.userEngagement = new UserEngagementEmailService(); this.userEngagement = new UserEngagementEmailService();
this.alphaInvitation = new AlphaInvitationEmailService(); this.alphaInvitation = new AlphaInvitationEmailService();
this.payment = new PaymentEmailService();
this.initialized = false; this.initialized = false;
} }
@@ -45,6 +47,7 @@ class EmailServices {
this.rentalReminder.initialize(), this.rentalReminder.initialize(),
this.userEngagement.initialize(), this.userEngagement.initialize(),
this.alphaInvitation.initialize(), this.alphaInvitation.initialize(),
this.payment.initialize(),
]); ]);
this.initialized = true; this.initialized = true;

View File

@@ -1,5 +1,6 @@
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
class StripeService { class StripeService {
@@ -184,8 +185,21 @@ class StripeService {
amountCharged: amount, // Original amount in dollars amountCharged: amount, // Original amount in dollars
}; };
} catch (error) { } catch (error) {
logger.error("Error charging payment method", { error: error.message, stack: error.stack }); // Parse Stripe error into structured format
throw error; const parsedError = parseStripeError(error);
logger.error("Payment failed", {
code: parsedError.code,
ownerMessage: parsedError.ownerMessage,
originalError: parsedError._originalMessage,
stripeCode: parsedError._stripeCode,
paymentMethodId,
customerId,
amount,
stack: error.stack,
});
throw new PaymentError(parsedError);
} }
} }
@@ -205,6 +219,15 @@ class StripeService {
} }
static async getPaymentMethod(paymentMethodId) {
try {
return await stripe.paymentMethods.retrieve(paymentMethodId);
} catch (error) {
logger.error("Error retrieving payment method", { error: error.message, paymentMethodId });
throw error;
}
}
static async createSetupCheckoutSession({ customerId, metadata = {} }) { static async createSetupCheckoutSession({ customerId, metadata = {} }) {
try { try {
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({

View File

@@ -0,0 +1,308 @@
<!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 Issue - 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, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
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;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.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;
}
.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;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.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">Action Required: Payment Issue</div>
</div>
<div class="content">
<p>Hi {{renterFirstName}},</p>
<div class="warning-box">
<p><strong>Payment Issue with Your Rental Request</strong></p>
<p>
The owner tried to approve your rental for
<strong>{{itemName}}</strong>, but there was an issue processing
your payment.
</p>
</div>
<h2>What Happened</h2>
<p>{{declineReason}}</p>
<div class="info-box">
<p><strong>What You Need To Do</strong></p>
<p>To update your payment method:</p>
<ol style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>
Go directly to <strong>village-share.com</strong> in your browser
</li>
<li>Log in to your account</li>
<li>
Navigate to your rentals and find your pending request for
<strong>{{itemName}}</strong>
</li>
<li>Click "Update Payment Method" to enter new payment details</li>
</ol>
</div>
<p>
Once you update your payment method, the owner will be notified and
can try to approve your rental again.
</p>
<p>
If you have any questions or need assistance, please don't hesitate to
contact our support team.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a notification about your rental request. You received this
message because the owner tried to approve your rental but there was a
payment issue.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,257 @@
<!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 Method Updated - 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, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
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;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0 0 10px 0;
color: #155724;
}
.success-box p:last-child {
margin-bottom: 0;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p: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;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Payment Update</div>
</div>
<div class="content">
<p>Hi {{ownerFirstName}},</p>
<div class="success-box">
<p><strong>Payment Method Updated</strong></p>
<p>
The renter has updated their payment method for the rental of
<strong>{{itemName}}</strong>.
</p>
</div>
<div class="info-box">
<p><strong>Ready to Approve</strong></p>
<p>
You can now try approving the rental request again. The renter's new
payment method will be charged when you approve.
</p>
</div>
<div style="text-align: center">
<a href="{{approvalUrl}}" class="button">Review & Approve Rental</a>
</div>
<p>
If you have any questions or need assistance, please don't hesitate
to contact our support team.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a notification about a rental request for your item.
You received this message because the renter updated their payment method.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,239 @@
/**
* Stripe Payment Error Handling Utility
*
* Maps Stripe decline codes to user-friendly messages for both owners and renters.
* Provides structured error information for frontend handling.
*/
const DECLINE_MESSAGES = {
insufficient_funds: {
ownerMessage: "The renter's card has insufficient funds.",
renterMessage: "Your card has insufficient funds.",
canOwnerRetry: false,
requiresNewPaymentMethod: false, // renter might add funds
},
card_declined: {
ownerMessage: "The renter's card was declined by their bank.",
renterMessage:
"Your card was declined. Please contact your bank or try a different card.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
generic_decline: {
ownerMessage: "The renter's card was declined.",
renterMessage: "Your card was declined. Please try a different card.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
expired_card: {
ownerMessage: "The renter's card has expired.",
renterMessage: "Your card has expired. Please add a new payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
processing_error: {
ownerMessage:
"A payment processing error occurred. This is usually temporary.",
renterMessage: "A temporary error occurred processing your payment.",
canOwnerRetry: true, // Owner can retry immediately
requiresNewPaymentMethod: false,
},
lost_card: {
ownerMessage: "The renter's card cannot be used.",
renterMessage:
"Your card cannot be used. Please add a different payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
stolen_card: {
ownerMessage: "The renter's card cannot be used.",
renterMessage:
"Your card cannot be used. Please add a different payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
incorrect_cvc: {
ownerMessage: "Payment verification failed.",
renterMessage:
"Your card couldn't be verified. Please re-enter your payment details.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
invalid_cvc: {
ownerMessage: "Payment verification failed.",
renterMessage:
"Your card couldn't be verified. Please re-enter your payment details.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
fraudulent: {
ownerMessage: "This payment was blocked for security reasons.",
renterMessage:
"Your payment was blocked by our security system. Please try a different card.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
incorrect_zip: {
ownerMessage: "Billing address verification failed.",
renterMessage:
"Your billing address couldn't be verified. Please update your payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
card_velocity_exceeded: {
ownerMessage:
"Too many payment attempts on this card. Please try again later.",
renterMessage:
"Too many attempts on your card. Please wait or try a different card.",
canOwnerRetry: true, // After delay
requiresNewPaymentMethod: false,
},
do_not_honor: {
ownerMessage: "The renter's card was declined by their bank.",
renterMessage:
"Your card was declined. Please contact your bank or try a different card.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
invalid_account: {
ownerMessage: "The renter's card account is invalid.",
renterMessage:
"Your card account is invalid. Please use a different card.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
new_account_information_available: {
ownerMessage: "The renter's card information needs to be updated.",
renterMessage: "Your card information needs to be updated. Please re-enter your payment details.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
card_not_supported: {
ownerMessage: "The renter's card type is not supported.",
renterMessage: "This card type is not supported. Please use a different card.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
currency_not_supported: {
ownerMessage: "The renter's card doesn't support this currency.",
renterMessage: "Your card doesn't support USD payments. Please use a different card.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
},
try_again_later: {
ownerMessage: "The payment processor is temporarily unavailable. Please try again.",
renterMessage: "A temporary error occurred. The owner can try again shortly.",
canOwnerRetry: true,
requiresNewPaymentMethod: false,
},
};
// Default error for unknown decline codes
const DEFAULT_ERROR = {
ownerMessage: "The payment could not be processed.",
renterMessage: "Your payment could not be processed. Please try a different payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
};
/**
* Parse a Stripe error and return structured error information
* @param {Error} error - The error object from Stripe
* @returns {Object} Structured error with code, messages, and retry info
*/
function parseStripeError(error) {
// Check if this is a Stripe error
if (error.type === "StripeCardError" || error.code === "card_declined") {
const declineCode = error.decline_code || error.code || "card_declined";
const errorInfo = DECLINE_MESSAGES[declineCode] || DEFAULT_ERROR;
return {
code: declineCode,
ownerMessage: errorInfo.ownerMessage,
renterMessage: errorInfo.renterMessage,
canOwnerRetry: errorInfo.canOwnerRetry,
requiresNewPaymentMethod: errorInfo.requiresNewPaymentMethod,
// Include original error info for logging (not for users)
_originalMessage: error.message,
_stripeCode: error.code,
};
}
// Handle other Stripe error types
if (error.type === "StripeInvalidRequestError") {
return {
code: "invalid_request",
ownerMessage: "There was a problem processing this payment.",
renterMessage: "There was a problem with your payment method.",
canOwnerRetry: false,
requiresNewPaymentMethod: true,
_originalMessage: error.message,
_stripeCode: error.code,
};
}
if (error.type === "StripeAPIError" || error.type === "StripeConnectionError") {
return {
code: "api_error",
ownerMessage: "A temporary error occurred. Please try again.",
renterMessage: "A temporary error occurred processing your payment.",
canOwnerRetry: true,
requiresNewPaymentMethod: false,
_originalMessage: error.message,
_stripeCode: error.code,
};
}
if (error.type === "StripeRateLimitError") {
return {
code: "rate_limit",
ownerMessage: "Too many requests. Please wait a moment and try again.",
renterMessage: "Please wait a moment and try again.",
canOwnerRetry: true,
requiresNewPaymentMethod: false,
_originalMessage: error.message,
_stripeCode: error.code,
};
}
// Default fallback for unknown errors
return {
code: "unknown_error",
...DEFAULT_ERROR,
_originalMessage: error.message,
_stripeCode: error.code,
};
}
/**
* Custom PaymentError class for structured payment failures
*/
class PaymentError extends Error {
constructor(parsedError) {
super(parsedError.ownerMessage);
this.name = "PaymentError";
this.code = parsedError.code;
this.ownerMessage = parsedError.ownerMessage;
this.renterMessage = parsedError.renterMessage;
this.canOwnerRetry = parsedError.canOwnerRetry;
this.requiresNewPaymentMethod = parsedError.requiresNewPaymentMethod;
this._originalMessage = parsedError._originalMessage;
this._stripeCode = parsedError._stripeCode;
}
toJSON() {
return {
code: this.code,
ownerMessage: this.ownerMessage,
renterMessage: this.renterMessage,
canOwnerRetry: this.canOwnerRetry,
requiresNewPaymentMethod: this.requiresNewPaymentMethod,
};
}
}
module.exports = {
parseStripeError,
PaymentError,
DECLINE_MESSAGES,
};

View File

@@ -0,0 +1,82 @@
import React from "react";
interface PaymentFailedError {
error: string;
code: string;
ownerMessage: string;
renterMessage: string;
rentalId: string;
}
interface PaymentFailedModalProps {
show: boolean;
onHide: () => void;
paymentError: PaymentFailedError;
itemName: string;
}
const PaymentFailedModal: React.FC<PaymentFailedModalProps> = ({
show,
onHide,
paymentError,
itemName,
}) => {
if (!show) return null;
return (
<div
className="modal fade show d-block"
tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header border-bottom-0">
<h5 className="modal-title">
<span className="me-2">&#9888;</span>
Payment Failed
</h5>
<button
type="button"
className="btn-close"
onClick={onHide}
aria-label="Close"
/>
</div>
<div className="modal-body">
{/* Error Message */}
<div className="alert alert-warning mb-3">
<p className="mb-0">{paymentError.ownerMessage}</p>
</div>
{/* Item Info */}
<p className="text-muted mb-3">
<strong>Item:</strong> {itemName}
</p>
{/* What Happens Next */}
<div className="bg-light p-3 rounded mb-3">
<h6 className="mb-2">What happens next?</h6>
<p className="mb-0 small text-muted">
An email has been sent to the renter with instructions to update
their payment method. Once they do, you'll be able to approve
the rental.
</p>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onHide}
>
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default PaymentFailedModal;

View File

@@ -0,0 +1,190 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { loadStripe } from "@stripe/stripe-js";
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js";
import { stripeAPI, rentalAPI } from "../services/api";
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
);
interface UpdatePaymentMethodProps {
rentalId: string;
itemName: string;
onSuccess: () => void;
onError: (error: string) => void;
onCancel: () => void;
}
const UpdatePaymentMethod: React.FC<UpdatePaymentMethodProps> = ({
rentalId,
itemName,
onSuccess,
onError,
onCancel,
}) => {
const [clientSecret, setClientSecret] = useState<string>("");
const [creating, setCreating] = useState(false);
const [sessionId, setSessionId] = useState<string>("");
const [updating, setUpdating] = useState(false);
const hasCreatedSession = useRef(false);
// Use refs to avoid recreating handleComplete when props change
const rentalIdRef = useRef(rentalId);
const onSuccessRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
const sessionIdRef = useRef(sessionId);
// Keep refs up to date
rentalIdRef.current = rentalId;
onSuccessRef.current = onSuccess;
onErrorRef.current = onError;
sessionIdRef.current = sessionId;
const createCheckoutSession = useCallback(async () => {
// Prevent multiple session creations
if (hasCreatedSession.current) return;
try {
setCreating(true);
hasCreatedSession.current = true;
// Create a setup checkout session without rental data
// (we're updating an existing rental, not creating a new one)
const response = await stripeAPI.createSetupCheckoutSession({});
setClientSecret(response.data.clientSecret);
setSessionId(response.data.sessionId);
} catch (error: any) {
hasCreatedSession.current = false; // Reset on error so it can be retried
onError(
error.response?.data?.error || "Failed to create checkout session"
);
} finally {
setCreating(false);
}
}, [onError]);
useEffect(() => {
createCheckoutSession();
}, [createCheckoutSession]);
// Use useCallback with empty deps - refs provide access to latest values
const handleComplete = useCallback(() => {
(async () => {
try {
setUpdating(true);
if (!sessionIdRef.current) {
throw new Error("No session ID available");
}
// Get the completed checkout session
const sessionResponse = await stripeAPI.getCheckoutSession(
sessionIdRef.current
);
const { status: sessionStatus, setup_intent } = sessionResponse.data;
if (sessionStatus !== "complete") {
throw new Error("Payment setup was not completed");
}
if (!setup_intent?.payment_method) {
throw new Error("No payment method found in setup intent");
}
// Extract payment method ID - handle both string ID and object cases
const paymentMethodId =
typeof setup_intent.payment_method === "string"
? setup_intent.payment_method
: setup_intent.payment_method.id;
if (!paymentMethodId) {
throw new Error("No payment method ID found");
}
// Update the rental's payment method
await rentalAPI.updatePaymentMethod(rentalIdRef.current, paymentMethodId);
onSuccessRef.current();
} catch (error: any) {
onErrorRef.current(
error.response?.data?.error ||
error.message ||
"Failed to update payment method"
);
} finally {
setUpdating(false);
}
})();
}, []);
if (creating) {
return (
<div className="text-center py-4">
<div className="spinner-border mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p>Preparing secure checkout...</p>
</div>
);
}
if (updating) {
return (
<div className="text-center py-4">
<div className="spinner-border mb-3" role="status">
<span className="visually-hidden">Updating...</span>
</div>
<p>Updating payment method...</p>
</div>
);
}
if (!clientSecret) {
return (
<div className="text-center py-4">
<p className="text-muted">Unable to load checkout</p>
<button className="btn btn-secondary mt-2" onClick={onCancel}>
Go Back
</button>
</div>
);
}
return (
<div className="update-payment-method">
<div className="mb-4">
<h5>Update Payment Method</h5>
<p className="text-muted">
Update your payment method for <strong>{itemName}</strong>. Once
updated, the owner will be notified and can re-attempt to approve your
rental request.
</p>
</div>
<div id="embedded-checkout">
<EmbeddedCheckoutProvider
key={clientSecret}
stripe={stripePromise}
options={{
clientSecret,
onComplete: handleComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
<div className="mt-3 text-center">
<button className="btn btn-link text-muted" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
};
export default UpdatePaymentMethod;

View File

@@ -0,0 +1,70 @@
import React from "react";
import UpdatePaymentMethod from "./UpdatePaymentMethod";
interface UpdatePaymentMethodModalProps {
show: boolean;
onHide: () => void;
rentalId: string;
itemName: string;
onSuccess: () => void;
}
const UpdatePaymentMethodModal: React.FC<UpdatePaymentMethodModalProps> = ({
show,
onHide,
rentalId,
itemName,
onSuccess,
}) => {
const [error, setError] = React.useState<string | null>(null);
if (!show) return null;
const handleSuccess = () => {
setError(null);
onSuccess();
onHide();
};
const handleError = (errorMessage: string) => {
setError(errorMessage);
};
return (
<div
className="modal fade show d-block"
tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
<div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content">
<div className="modal-header border-bottom-0">
<h5 className="modal-title">Update Payment Method</h5>
<button
type="button"
className="btn-close"
onClick={onHide}
aria-label="Close"
/>
</div>
<div className="modal-body">
{error && (
<div className="alert alert-danger mb-3">
<small>{error}</small>
</div>
)}
<UpdatePaymentMethod
rentalId={rentalId}
itemName={itemName}
onSuccess={handleSuccess}
onError={handleError}
onCancel={onHide}
/>
</div>
</div>
</div>
</div>
);
};
export default UpdatePaymentMethodModal;

View File

@@ -151,6 +151,9 @@ export interface Rental {
bankDepositAt?: string; bankDepositAt?: string;
stripePayoutId?: string; stripePayoutId?: string;
bankDepositFailureCode?: string; bankDepositFailureCode?: string;
// Payment failure tracking
paymentFailedNotifiedAt?: string;
paymentMethodUpdatedAt?: string;
intendedUse?: string; intendedUse?: string;
rating?: number; rating?: number;
review?: string; review?: string;