failed payment method handling
This commit is contained in:
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
121
backend/services/email/domain/PaymentEmailService.js
Normal file
121
backend/services/email/domain/PaymentEmailService.js
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
308
backend/templates/emails/paymentDeclinedToRenter.html
Normal file
308
backend/templates/emails/paymentDeclinedToRenter.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
257
backend/templates/emails/paymentMethodUpdatedToOwner.html
Normal file
257
backend/templates/emails/paymentMethodUpdatedToOwner.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
239
backend/utils/stripeErrors.js
Normal file
239
backend/utils/stripeErrors.js
Normal 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,
|
||||||
|
};
|
||||||
82
frontend/src/components/PaymentFailedModal.tsx
Normal file
82
frontend/src/components/PaymentFailedModal.tsx
Normal 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">⚠</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;
|
||||||
190
frontend/src/components/UpdatePaymentMethod.tsx
Normal file
190
frontend/src/components/UpdatePaymentMethod.tsx
Normal 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;
|
||||||
70
frontend/src/components/UpdatePaymentMethodModal.tsx
Normal file
70
frontend/src/components/UpdatePaymentMethodModal.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user