failed payment method handling

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

View File

@@ -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 (

View File

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

View File

@@ -7,6 +7,7 @@ const RentalFlowEmailService = require("./domain/RentalFlowEmailService");
const RentalReminderEmailService = require("./domain/RentalReminderEmailService");
const 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;

View File

@@ -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({