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: {
|
||||
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: {
|
||||
type: DataTypes.ENUM("pickup", "delivery"),
|
||||
defaultValue: "pickup",
|
||||
|
||||
@@ -11,8 +11,11 @@ const RefundService = require("../services/refundService");
|
||||
const LateReturnService = require("../services/lateReturnService");
|
||||
const PayoutService = require("../services/payoutService");
|
||||
const DamageAssessmentService = require("../services/damageAssessmentService");
|
||||
const StripeWebhookService = require("../services/stripeWebhookService");
|
||||
const StripeService = require("../services/stripeService");
|
||||
const emailServices = require("../services/email");
|
||||
const logger = require("../utils/logger");
|
||||
const { PaymentError } = require("../utils/stripeErrors");
|
||||
const { validateS3Keys } = require("../utils/s3KeyValidator");
|
||||
const { IMAGE_LIMITS } = require("../config/imageLimits");
|
||||
const { isActive, getEffectiveStatus } = require("../utils/rentalStatus");
|
||||
@@ -106,6 +109,19 @@ router.get("/renting", authenticateToken, async (req, res) => {
|
||||
|
||||
router.get("/owning", authenticateToken, async (req, res) => {
|
||||
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({
|
||||
where: { ownerId: req.user.id },
|
||||
// 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 fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
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
|
||||
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
|
||||
@@ -403,12 +423,24 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeConnectedAccountId",
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
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,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeConnectedAccountId",
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
@@ -559,14 +597,55 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Payment failed during approval", {
|
||||
error: paymentError.message,
|
||||
code: paymentError.code,
|
||||
stack: paymentError.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
// Keep rental as pending, but inform of payment failure
|
||||
return res.status(400).json({
|
||||
error: "Payment failed during approval",
|
||||
details: paymentError.message,
|
||||
|
||||
// Determine the renter-facing message
|
||||
const renterMessage =
|
||||
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 {
|
||||
@@ -581,7 +660,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeConnectedAccountId",
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
@@ -942,7 +1027,9 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
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
|
||||
@@ -1195,7 +1282,13 @@ router.post("/:id/mark-return", authenticateToken, async (req, res, next) => {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"stripeConnectedAccountId",
|
||||
],
|
||||
},
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -51,7 +51,14 @@ class TemplateManager {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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
|
||||
const criticalTemplates = [
|
||||
@@ -95,6 +102,8 @@ class TemplateManager {
|
||||
"forumItemRequestNotification.html",
|
||||
"forumPostDeletionToAuthor.html",
|
||||
"forumCommentDeletionToAuthor.html",
|
||||
"paymentDeclinedToRenter.html",
|
||||
"paymentMethodUpdatedToOwner.html",
|
||||
];
|
||||
|
||||
const failedTemplates = [];
|
||||
@@ -129,7 +138,9 @@ class TemplateManager {
|
||||
|
||||
if (missingCriticalTemplates.length > 0) {
|
||||
const error = new Error(
|
||||
`Critical email templates failed to load: ${missingCriticalTemplates.join(", ")}`
|
||||
`Critical email templates failed to load: ${missingCriticalTemplates.join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
error.missingTemplates = missingCriticalTemplates;
|
||||
throw error;
|
||||
@@ -138,7 +149,9 @@ class TemplateManager {
|
||||
// Warn if non-critical templates failed
|
||||
if (failedTemplates.length > 0) {
|
||||
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");
|
||||
}
|
||||
@@ -483,6 +496,36 @@ class TemplateManager {
|
||||
<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 (
|
||||
|
||||
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 UserEngagementEmailService = require("./domain/UserEngagementEmailService");
|
||||
const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService");
|
||||
const PaymentEmailService = require("./domain/PaymentEmailService");
|
||||
|
||||
/**
|
||||
* EmailServices aggregates all domain-specific email services
|
||||
@@ -24,6 +25,7 @@ class EmailServices {
|
||||
this.rentalReminder = new RentalReminderEmailService();
|
||||
this.userEngagement = new UserEngagementEmailService();
|
||||
this.alphaInvitation = new AlphaInvitationEmailService();
|
||||
this.payment = new PaymentEmailService();
|
||||
|
||||
this.initialized = false;
|
||||
}
|
||||
@@ -45,6 +47,7 @@ class EmailServices {
|
||||
this.rentalReminder.initialize(),
|
||||
this.userEngagement.initialize(),
|
||||
this.alphaInvitation.initialize(),
|
||||
this.payment.initialize(),
|
||||
]);
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const logger = require("../utils/logger");
|
||||
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
||||
|
||||
class StripeService {
|
||||
|
||||
@@ -184,8 +185,21 @@ class StripeService {
|
||||
amountCharged: amount, // Original amount in dollars
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error charging payment method", { error: error.message, stack: error.stack });
|
||||
throw error;
|
||||
// Parse Stripe error into structured format
|
||||
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 = {} }) {
|
||||
try {
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user