diff --git a/backend/migrations/20260105000001-add-payment-failure-tracking.js b/backend/migrations/20260105000001-add-payment-failure-tracking.js new file mode 100644 index 0000000..1f2f9ae --- /dev/null +++ b/backend/migrations/20260105000001-add-payment-failure-tracking.js @@ -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"); + }, +}; diff --git a/backend/models/Rental.js b/backend/models/Rental.js index 6160803..359e588 100644 --- a/backend/models/Rental.js +++ b/backend/models/Rental.js @@ -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", diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 2123024..e5305c1 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -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; diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js index 041176b..d92c626 100644 --- a/backend/services/email/core/TemplateManager.js +++ b/backend/services/email/core/TemplateManager.js @@ -51,7 +51,14 @@ class TemplateManager { * @returns {Promise} */ 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 {

Please review this feedback and take appropriate action if needed.

` ), + + paymentDeclinedToRenter: baseTemplate.replace( + "{{content}}", + ` +

Hi {{renterFirstName}},

+

Payment Issue with Your Rental Request

+

The owner tried to approve your rental for {{itemName}}, but there was an issue processing your payment.

+

What Happened

+

{{declineReason}}

+
+

What You Can Do

+

Please update your payment method so the owner can complete the approval of your rental request.

+
+

Once you update your payment method, the owner will be notified and can try to approve your rental again.

+ ` + ), + + paymentMethodUpdatedToOwner: baseTemplate.replace( + "{{content}}", + ` +

Hi {{ownerFirstName}},

+

Payment Method Updated

+

The renter has updated their payment method for the rental of {{itemName}}.

+
+

Ready to Approve

+

You can now try approving the rental request again. The renter's new payment method will be charged when you approve.

+
+

Review & Approve Rental

+ ` + ), }; return ( diff --git a/backend/services/email/domain/PaymentEmailService.js b/backend/services/email/domain/PaymentEmailService.js new file mode 100644 index 0000000..b29e902 --- /dev/null +++ b/backend/services/email/domain/PaymentEmailService.js @@ -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} + */ + 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; diff --git a/backend/services/email/index.js b/backend/services/email/index.js index d084b53..c0d9f38 100644 --- a/backend/services/email/index.js +++ b/backend/services/email/index.js @@ -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; diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js index 1a76ffd..a73f4cc 100644 --- a/backend/services/stripeService.js +++ b/backend/services/stripeService.js @@ -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({ diff --git a/backend/templates/emails/paymentDeclinedToRenter.html b/backend/templates/emails/paymentDeclinedToRenter.html new file mode 100644 index 0000000..ddfc381 --- /dev/null +++ b/backend/templates/emails/paymentDeclinedToRenter.html @@ -0,0 +1,308 @@ + + + + + + + Payment Issue - Village Share + + + + + + diff --git a/backend/templates/emails/paymentMethodUpdatedToOwner.html b/backend/templates/emails/paymentMethodUpdatedToOwner.html new file mode 100644 index 0000000..823fde5 --- /dev/null +++ b/backend/templates/emails/paymentMethodUpdatedToOwner.html @@ -0,0 +1,257 @@ + + + + + + + Payment Method Updated - Village Share + + + + + + diff --git a/backend/utils/stripeErrors.js b/backend/utils/stripeErrors.js new file mode 100644 index 0000000..8c23a74 --- /dev/null +++ b/backend/utils/stripeErrors.js @@ -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, +}; diff --git a/frontend/src/components/PaymentFailedModal.tsx b/frontend/src/components/PaymentFailedModal.tsx new file mode 100644 index 0000000..7dd4500 --- /dev/null +++ b/frontend/src/components/PaymentFailedModal.tsx @@ -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 = ({ + show, + onHide, + paymentError, + itemName, +}) => { + if (!show) return null; + + return ( +
+
+
+
+
+ + Payment Failed +
+
+
+ {/* Error Message */} +
+

{paymentError.ownerMessage}

+
+ + {/* Item Info */} +

+ Item: {itemName} +

+ + {/* What Happens Next */} +
+
What happens next?
+

+ 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. +

+
+
+
+ +
+
+
+
+ ); +}; + +export default PaymentFailedModal; diff --git a/frontend/src/components/UpdatePaymentMethod.tsx b/frontend/src/components/UpdatePaymentMethod.tsx new file mode 100644 index 0000000..f7845b6 --- /dev/null +++ b/frontend/src/components/UpdatePaymentMethod.tsx @@ -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 = ({ + rentalId, + itemName, + onSuccess, + onError, + onCancel, +}) => { + const [clientSecret, setClientSecret] = useState(""); + const [creating, setCreating] = useState(false); + const [sessionId, setSessionId] = useState(""); + 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 ( +
+
+ Loading... +
+

Preparing secure checkout...

+
+ ); + } + + if (updating) { + return ( +
+
+ Updating... +
+

Updating payment method...

+
+ ); + } + + if (!clientSecret) { + return ( +
+

Unable to load checkout

+ +
+ ); + } + + return ( +
+
+
Update Payment Method
+

+ Update your payment method for {itemName}. Once + updated, the owner will be notified and can re-attempt to approve your + rental request. +

+
+ +
+ + + +
+ +
+ +
+
+ ); +}; + +export default UpdatePaymentMethod; diff --git a/frontend/src/components/UpdatePaymentMethodModal.tsx b/frontend/src/components/UpdatePaymentMethodModal.tsx new file mode 100644 index 0000000..6258bad --- /dev/null +++ b/frontend/src/components/UpdatePaymentMethodModal.tsx @@ -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 = ({ + show, + onHide, + rentalId, + itemName, + onSuccess, +}) => { + const [error, setError] = React.useState(null); + + if (!show) return null; + + const handleSuccess = () => { + setError(null); + onSuccess(); + onHide(); + }; + + const handleError = (errorMessage: string) => { + setError(errorMessage); + }; + + return ( +
+
+
+
+
Update Payment Method
+
+
+ {error && ( +
+ {error} +
+ )} + +
+
+
+
+ ); +}; + +export default UpdatePaymentMethodModal; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e5b8d3a..c7218b3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -151,6 +151,9 @@ export interface Rental { bankDepositAt?: string; stripePayoutId?: string; bankDepositFailureCode?: string; + // Payment failure tracking + paymentFailedNotifiedAt?: string; + paymentMethodUpdatedAt?: string; intendedUse?: string; rating?: number; review?: string;