const express = require("express"); const { Op } = require("sequelize"); const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations const { authenticateToken, requireVerifiedEmail, } = require("../middleware/auth"); const FeeCalculator = require("../utils/feeCalculator"); const RentalDurationCalculator = require("../utils/rentalDurationCalculator"); 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 EventBridgeSchedulerService = require("../services/eventBridgeSchedulerService"); 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"); const router = express.Router(); // Helper to add displayStatus to rental response const addDisplayStatus = (rental) => { if (!rental) return rental; const rentalJson = rental.toJSON ? rental.toJSON() : rental; return { ...rentalJson, displayStatus: getEffectiveStatus(rentalJson), }; }; // Helper to add displayStatus to array of rentals const addDisplayStatusToArray = (rentals) => { return rentals.map(addDisplayStatus); }; // Helper function to check and update review visibility const checkAndUpdateReviewVisibility = async (rental) => { const now = new Date(); const seventyTwoHoursInMs = 72 * 60 * 60 * 1000; // 72 hours let needsUpdate = false; let updates = {}; // Check if both reviews are submitted if (rental.itemReviewSubmittedAt && rental.renterReviewSubmittedAt) { if (!rental.itemReviewVisible || !rental.renterReviewVisible) { updates.itemReviewVisible = true; updates.renterReviewVisible = true; needsUpdate = true; } } else { // Check item review visibility (72-hour rule) if (rental.itemReviewSubmittedAt && !rental.itemReviewVisible) { const timeSinceSubmission = now - new Date(rental.itemReviewSubmittedAt); if (timeSinceSubmission >= seventyTwoHoursInMs) { updates.itemReviewVisible = true; needsUpdate = true; } } // Check renter review visibility (72-hour rule) if (rental.renterReviewSubmittedAt && !rental.renterReviewVisible) { const timeSinceSubmission = now - new Date(rental.renterReviewSubmittedAt); if (timeSinceSubmission >= seventyTwoHoursInMs) { updates.renterReviewVisible = true; needsUpdate = true; } } } if (needsUpdate) { await rental.update(updates); } return rental; }; router.get("/renting", authenticateToken, async (req, res) => { try { const rentals = await Rental.findAll({ where: { renterId: req.user.id }, // Remove explicit attributes to let Sequelize handle missing columns gracefully include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "imageFilename"], }, ], order: [["createdAt", "DESC"]], }); res.json(addDisplayStatusToArray(rentals)); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error in renting route", { error: error.message, stack: error.stack, userId: req.user.id, }); res.status(500).json({ error: "Failed to fetch rentals" }); } }); 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 include: [ { model: Item, as: "item" }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName", "imageFilename"], }, ], order: [["createdAt", "DESC"]], }); res.json(addDisplayStatusToArray(rentals)); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error in owning route", { error: error.message, stack: error.stack, userId: req.user.id, }); res.status(500).json({ error: "Failed to fetch listings" }); } }); // Get count of pending rental requests for owner router.get("/pending-requests-count", authenticateToken, async (req, res) => { try { const count = await Rental.count({ where: { ownerId: req.user.id, status: "pending", }, }); res.json({ count }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error getting pending rental count", { error: error.message, stack: error.stack, userId: req.user.id, }); res.status(500).json({ error: "Failed to get pending rental count" }); } }); // Get rental by ID router.get("/:id", authenticateToken, async (req, res) => { try { const rental = await Rental.findByPk(req.params.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: ["id", "firstName", "lastName"], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName"], }, ], }); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } // Check if user is authorized to view this rental if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { return res .status(403) .json({ error: "Unauthorized to view this rental" }); } res.json(addDisplayStatus(rental)); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error fetching rental", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); res.status(500).json({ error: "Failed to fetch rental" }); } }); router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { try { const { itemId, startDateTime, endDateTime, deliveryMethod, deliveryAddress, intendedUse, stripePaymentMethodId, } = req.body; const item = await Item.findByPk(itemId); if (!item) { return res.status(404).json({ error: "Item not found" }); } if (!item.isAvailable) { return res.status(400).json({ error: "Item is not available" }); } let rentalStartDateTime, rentalEndDateTime, totalAmount; // New UTC datetime format rentalStartDateTime = new Date(startDateTime); rentalEndDateTime = new Date(endDateTime); // Validate date formats if (isNaN(rentalStartDateTime.getTime())) { return res.status(400).json({ error: "Invalid start date format" }); } if (isNaN(rentalEndDateTime.getTime())) { return res.status(400).json({ error: "Invalid end date format" }); } // Validate start date is not in the past (with 5 minute grace period for form submission) 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" }); } // 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" }); } // Calculate rental cost using duration calculator totalAmount = RentalDurationCalculator.calculateRentalCost( rentalStartDateTime, rentalEndDateTime, item, ); // Check for overlapping rentals using datetime ranges // "active" rentals are stored as "confirmed" with startDateTime in the past // Two ranges [A,B] and [C,D] overlap if and only if A < D AND C < B // Here: existing rental [existingStart, existingEnd], new rental [rentalStartDateTime, rentalEndDateTime] // Overlap: existingStart < rentalEndDateTime AND rentalStartDateTime < existingEnd const overlappingRental = await Rental.findOne({ where: { itemId, status: "confirmed", startDateTime: { [Op.not]: null }, endDateTime: { [Op.not]: null }, [Op.and]: [ // existingStart < newEnd (existing rental starts before new one ends) { startDateTime: { [Op.lt]: rentalEndDateTime } }, // existingEnd > newStart (existing rental ends after new one starts) { endDateTime: { [Op.gt]: rentalStartDateTime } }, ], }, }); if (overlappingRental) { return res .status(400) .json({ error: "Item is already booked for these dates" }); } // Calculate fees using FeeCalculator const fees = FeeCalculator.calculateRentalFees(totalAmount); // Validate that payment method was provided for paid rentals if (totalAmount > 0 && !stripePaymentMethodId) { return res .status(400) .json({ error: "Payment method is required for paid rentals" }); } const rentalData = { itemId, renterId: req.user.id, ownerId: item.ownerId, startDateTime: rentalStartDateTime, endDateTime: rentalEndDateTime, totalAmount: fees.totalChargedAmount, platformFee: fees.platformFee, payoutAmount: fees.payoutAmount, paymentStatus: totalAmount > 0 ? "pending" : "not_required", status: "pending", deliveryMethod, deliveryAddress, intendedUse, }; // Only add stripePaymentMethodId if it's provided (for paid rentals) if (stripePaymentMethodId) { rentalData.stripePaymentMethodId = stripePaymentMethodId; } const rental = await Rental.create(rentalData); const rentalWithDetails = await Rental.findByPk(rental.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email"], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email"], }, ], }); // Send rental request notification to owner try { await emailServices.rentalFlow.sendRentalRequestEmail( rentalWithDetails.owner, rentalWithDetails.renter, rentalWithDetails, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental request notification sent to owner", { rentalId: rental.id, ownerId: rentalWithDetails.ownerId, }); } catch (emailError) { // Log error but don't fail the request const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send rental request email", { error: emailError.message, stack: emailError.stack, rentalId: rental.id, ownerId: rentalWithDetails.ownerId, }); } // Send rental request confirmation to renter try { await emailServices.rentalFlow.sendRentalRequestConfirmationEmail( rentalWithDetails.renter, rentalWithDetails, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental request confirmation sent to renter", { rentalId: rental.id, renterId: rentalWithDetails.renterId, }); } catch (emailError) { // Log error but don't fail the request const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send rental request confirmation email", { error: emailError.message, stack: emailError.stack, rentalId: rental.id, renterId: rentalWithDetails.renterId, }); } res.status(201).json(rentalWithDetails); } catch (error) { res.status(500).json({ error: "Failed to create rental" }); } }); router.put("/:id/status", authenticateToken, async (req, res) => { try { const { status } = req.body; const rental = await Rental.findByPk(req.params.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: [ "id", "firstName", "lastName", "email", "stripeConnectedAccountId", ], }, { model: User, as: "renter", attributes: [ "id", "firstName", "lastName", "email", "stripeCustomerId", ], }, ], }); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { return res .status(403) .json({ error: "Unauthorized to update this rental" }); } // If owner is approving a pending rental, handle payment for paid rentals if ( status === "confirmed" && rental.status === "pending" && rental.ownerId === req.user.id ) { // Skip payment processing for free rentals if (rental.totalAmount > 0) { if (!rental.stripePaymentMethodId) { return res .status(400) .json({ error: "No payment method found for this rental" }); } try { // Import StripeService to process the payment const StripeService = require("../services/stripeService"); // Check if renter has a stripe customer ID if (!rental.renter.stripeCustomerId) { return res.status(400).json({ error: "Renter does not have a Stripe customer account", }); } // Create payment intent and charge the stored payment method const paymentResult = await StripeService.chargePaymentMethod( rental.stripePaymentMethodId, rental.totalAmount, rental.renter.stripeCustomerId, { rentalId: rental.id, itemName: rental.item.name, renterId: rental.renterId, ownerId: rental.ownerId, }, ); // Check if 3DS authentication is required if (paymentResult.requiresAction) { // Store payment intent for later completion await rental.update({ stripePaymentIntentId: paymentResult.paymentIntentId, paymentStatus: "requires_action", }); // Send email to renter (without direct link for security) try { await emailServices.rentalFlow.sendAuthenticationRequiredEmail( rental.renter.email, { renterName: rental.renter.firstName, itemName: rental.item.name, ownerName: rental.owner.firstName, amount: rental.totalAmount, }, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Authentication required email sent to renter", { rentalId: rental.id, renterId: rental.renterId, }); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send authentication required email", { error: emailError.message, stack: emailError.stack, rentalId: rental.id, renterId: rental.renterId, }); } return res.status(402).json({ error: "authentication_required", requiresAction: true, message: "The renter's card requires additional authentication.", rentalId: rental.id, }); } // Update rental with payment completion await rental.update({ status: "confirmed", paymentStatus: "paid", stripePaymentIntentId: paymentResult.paymentIntentId, paymentMethodBrand: paymentResult.paymentMethod?.brand || null, paymentMethodLast4: paymentResult.paymentMethod?.last4 || null, chargedAt: paymentResult.chargedAt || new Date(), }); const updatedRental = await Rental.findByPk(rental.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: [ "id", "firstName", "lastName", "email", "stripeConnectedAccountId", ], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email"], }, ], }); // Create condition check reminder schedules try { await EventBridgeSchedulerService.createConditionCheckSchedules( updatedRental, ); } catch (schedulerError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to create condition check schedules", { error: schedulerError.message, rentalId: updatedRental.id, }); // Don't fail the confirmation - schedules are non-critical } // Send confirmation emails // Send approval confirmation to owner with Stripe reminder try { await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail( updatedRental.owner, updatedRental.renter, updatedRental, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental approval confirmation sent to owner", { rentalId: updatedRental.id, ownerId: updatedRental.ownerId, }); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error( "Failed to send rental approval confirmation email to owner", { error: emailError.message, stack: emailError.stack, rentalId: updatedRental.id, ownerId: updatedRental.ownerId, }, ); } // Send rental confirmation to renter with payment receipt try { const renter = await User.findByPk(updatedRental.renterId, { attributes: ["email", "firstName"], }); if (renter) { const renterNotification = { type: "rental_confirmed", title: "Rental Confirmed", message: `Your rental of "${updatedRental.item.name}" has been confirmed.`, rentalId: updatedRental.id, userId: updatedRental.renterId, metadata: { rentalStart: updatedRental.startDateTime }, }; await emailServices.rentalFlow.sendRentalConfirmation( renter.email, renterNotification, updatedRental, renter.firstName, true, // isRenter = true to show payment receipt ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental confirmation sent to renter", { rentalId: updatedRental.id, renterId: updatedRental.renterId, }); } } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error( "Failed to send rental confirmation email to renter", { error: emailError.message, stack: emailError.stack, rentalId: updatedRental.id, renterId: updatedRental.renterId, }, ); } res.json(updatedRental); return; } catch (paymentError) { 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, }); // 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 and reason await rental.update({ paymentFailedNotifiedAt: new Date(), paymentFailedReason: renterMessage, }); // 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 { // For free rentals, just update status directly await rental.update({ status: "confirmed", }); const updatedRental = await Rental.findByPk(rental.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: [ "id", "firstName", "lastName", "email", "stripeConnectedAccountId", ], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email"], }, ], }); // Create condition check reminder schedules try { await EventBridgeSchedulerService.createConditionCheckSchedules( updatedRental, ); } catch (schedulerError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to create condition check schedules", { error: schedulerError.message, rentalId: updatedRental.id, }); // Don't fail the confirmation - schedules are non-critical } // Send confirmation emails // Send approval confirmation to owner (for free rentals, no Stripe reminder shown) try { await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail( updatedRental.owner, updatedRental.renter, updatedRental, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental approval confirmation sent to owner", { rentalId: updatedRental.id, ownerId: updatedRental.ownerId, }); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error( "Failed to send rental approval confirmation email to owner", { error: emailError.message, stack: emailError.stack, rentalId: updatedRental.id, ownerId: updatedRental.ownerId, }, ); } // Send rental confirmation to renter try { const renter = await User.findByPk(updatedRental.renterId, { attributes: ["email", "firstName"], }); if (renter) { const renterNotification = { type: "rental_confirmed", title: "Rental Confirmed", message: `Your rental of "${updatedRental.item.name}" has been confirmed.`, rentalId: updatedRental.id, userId: updatedRental.renterId, metadata: { rentalStart: updatedRental.startDateTime }, }; await emailServices.rentalFlow.sendRentalConfirmation( renter.email, renterNotification, updatedRental, renter.firstName, true, // isRenter = true (for free rentals, shows "no payment required") ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental confirmation sent to renter", { rentalId: updatedRental.id, renterId: updatedRental.renterId, }); } } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error( "Failed to send rental confirmation email to renter", { error: emailError.message, stack: emailError.stack, rentalId: updatedRental.id, renterId: updatedRental.renterId, }, ); } res.json(updatedRental); return; } } await rental.update({ status }); const updatedRental = await Rental.findByPk(rental.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: ["id", "firstName", "lastName"], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName"], }, ], }); res.json(updatedRental); } catch (error) { res.status(500).json({ error: "Failed to update rental status" }); } }); // Decline rental request (owner only) router.put("/:id/decline", authenticateToken, async (req, res) => { try { const { reason } = req.body; // Validate that reason is provided if (!reason || reason.trim() === "") { return res.status(400).json({ error: "A reason for declining is required", }); } const rental = await Rental.findByPk(req.params.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: ["id", "firstName", "lastName"], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email"], }, ], }); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } // Only owner can decline if (rental.ownerId !== req.user.id) { return res .status(403) .json({ error: "Only the item owner can decline rental requests" }); } // Can only decline pending rentals if (rental.status !== "pending") { return res.status(400).json({ error: "Can only decline pending rental requests", }); } // Update rental status to declined await rental.update({ status: "declined", declineReason: reason, }); const updatedRental = await Rental.findByPk(rental.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email"], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email"], }, ], }); // Send decline notification email to renter try { await emailServices.rentalFlow.sendRentalDeclinedEmail( updatedRental.renter, updatedRental, reason, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental decline notification sent to renter", { rentalId: rental.id, renterId: updatedRental.renterId, }); } catch (emailError) { // Log error but don't fail the request const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send rental decline email", { error: emailError.message, stack: emailError.stack, rentalId: rental.id, renterId: updatedRental.renterId, }); } res.json(updatedRental); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error declining rental", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); res.status(500).json({ error: "Failed to decline rental" }); } }); // Owner reviews renter router.post("/:id/review-renter", authenticateToken, async (req, res) => { try { const { rating, review, privateMessage } = req.body; const rental = await Rental.findByPk(req.params.id); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } if (rental.ownerId !== req.user.id) { return res.status(403).json({ error: "Only owners can review renters" }); } if (rental.status !== "completed") { return res .status(400) .json({ error: "Can only review completed rentals" }); } if (rental.renterReviewSubmittedAt) { return res.status(400).json({ error: "Renter review already submitted" }); } // Submit the review and private message await rental.update({ renterRating: rating, renterReview: review, renterReviewSubmittedAt: new Date(), renterPrivateMessage: privateMessage, }); // Check and update visibility const updatedRental = await checkAndUpdateReviewVisibility(rental); res.json({ success: true, reviewVisible: updatedRental.renterReviewVisible, }); } catch (error) { res.status(500).json({ error: "Failed to submit review" }); } }); // Renter reviews item router.post("/:id/review-item", authenticateToken, async (req, res) => { try { const { rating, review, privateMessage } = req.body; const rental = await Rental.findByPk(req.params.id); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } if (rental.renterId !== req.user.id) { return res.status(403).json({ error: "Only renters can review items" }); } if (rental.status !== "completed") { return res .status(400) .json({ error: "Can only review completed rentals" }); } if (rental.itemReviewSubmittedAt) { return res.status(400).json({ error: "Item review already submitted" }); } // Submit the review and private message await rental.update({ itemRating: rating, itemReview: review, itemReviewSubmittedAt: new Date(), itemPrivateMessage: privateMessage, }); // Check and update visibility const updatedRental = await checkAndUpdateReviewVisibility(rental); res.json({ success: true, reviewVisible: updatedRental.itemReviewVisible, }); } catch (error) { res.status(500).json({ error: "Failed to submit review" }); } }); // Calculate fees for rental pricing display router.post("/calculate-fees", authenticateToken, async (req, res) => { try { const { totalAmount } = req.body; if (!totalAmount || totalAmount <= 0) { return res.status(400).json({ error: "Valid base amount is required" }); } const fees = FeeCalculator.calculateRentalFees(totalAmount); const displayFees = FeeCalculator.formatFeesForDisplay(fees); res.json({ fees, display: displayFees, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error calculating fees", { error: error.message, stack: error.stack, userId: req.user.id, startDate: req.query.startDate, endDate: req.query.endDate, itemId: req.query.itemId, }); res.status(500).json({ error: "Failed to calculate fees" }); } }); // Preview rental cost calculation (no rental creation) router.post("/cost-preview", authenticateToken, async (req, res) => { try { const { itemId, startDateTime, endDateTime } = req.body; // Validate inputs if (!itemId || !startDateTime || !endDateTime) { return res.status(400).json({ error: "itemId, startDateTime, and endDateTime are required", }); } // Fetch item const item = await Item.findByPk(itemId); if (!item) { return res.status(404).json({ error: "Item not found" }); } // Parse datetimes const rentalStartDateTime = new Date(startDateTime); const rentalEndDateTime = new Date(endDateTime); // Validate date formats if (isNaN(rentalStartDateTime.getTime())) { return res.status(400).json({ error: "Invalid start date format" }); } if (isNaN(rentalEndDateTime.getTime())) { return res.status(400).json({ error: "Invalid end date format" }); } // Validate start date is not in the past (with 5 minute grace period) 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" }); } // Validate date range if (rentalEndDateTime <= rentalStartDateTime) { return res.status(400).json({ error: "End date/time must be after start date/time", }); } // Check for overlapping rentals (same logic as in POST /rentals) // Two ranges overlap if: existingStart < newEnd AND existingEnd > newStart const overlappingRental = await Rental.findOne({ where: { itemId, status: "confirmed", startDateTime: { [Op.not]: null }, endDateTime: { [Op.not]: null }, [Op.and]: [ { startDateTime: { [Op.lt]: rentalEndDateTime } }, { endDateTime: { [Op.gt]: rentalStartDateTime } }, ], }, }); if (overlappingRental) { return res .status(400) .json({ error: "Item is already booked for these dates" }); } // Calculate rental cost using duration calculator const totalAmount = RentalDurationCalculator.calculateRentalCost( rentalStartDateTime, rentalEndDateTime, item, ); // Calculate fees const fees = FeeCalculator.calculateRentalFees(totalAmount); res.json({ baseAmount: totalAmount, fees, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error calculating rental cost preview", { error: error.message, stack: error.stack, userId: req.user.id, }); res.status(500).json({ error: "Failed to calculate rental cost preview" }); } }); // Get earnings status for owner's rentals router.get("/earnings/status", authenticateToken, async (req, res, next) => { const reqLogger = logger.withRequestId(req.id); try { // Trigger payout reconciliation in background (non-blocking) // This catches any missed payout.paid or payout.failed webhooks StripeWebhookService.reconcilePayoutStatuses(req.user.id).catch((err) => { reqLogger.error("Background payout reconciliation failed", { error: err.message, userId: req.user.id, }); }); const ownerRentals = await Rental.findAll({ where: { ownerId: req.user.id, status: "completed", }, attributes: [ "id", "totalAmount", "platformFee", "payoutAmount", "payoutStatus", "payoutProcessedAt", "stripeTransferId", "bankDepositStatus", "bankDepositAt", "bankDepositFailureCode", ], include: [{ model: Item, as: "item", attributes: ["name"] }], order: [["createdAt", "DESC"]], }); res.json(ownerRentals); } catch (error) { reqLogger.error("Error getting earnings status", { error: error.message, stack: error.stack, userId: req.user.id, }); next(error); } }); // Get refund preview (what would happen if cancelled now) router.get("/:id/refund-preview", authenticateToken, async (req, res, next) => { try { const preview = await RefundService.getRefundPreview( req.params.id, req.user.id, ); res.json(preview); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error getting refund preview", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); next(error); } }); // Get late fee preview router.get( "/:id/late-fee-preview", authenticateToken, async (req, res, next) => { try { const { actualReturnDateTime } = req.query; if (!actualReturnDateTime) { return res .status(400) .json({ error: "actualReturnDateTime is required" }); } const rental = await Rental.findByPk(req.params.id, { include: [{ model: Item, as: "item" }], }); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } // Check authorization if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { return res.status(403).json({ error: "Unauthorized" }); } const lateCalculation = LateReturnService.calculateLateFee( rental, actualReturnDateTime, ); res.json(lateCalculation); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error getting late fee preview", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); next(error); } }, ); // Cancel rental with refund processing router.post("/:id/cancel", authenticateToken, async (req, res, next) => { try { const { reason } = req.body; // Validate that reason is provided if (!reason || !reason.trim()) { return res.status(400).json({ error: "Cancellation reason is required" }); } const result = await RefundService.processCancellation( req.params.id, req.user.id, reason.trim(), ); // Return the updated rental with refund information const updatedRental = await Rental.findByPk(result.rental.id, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email"], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email"], }, ], }); // Send cancellation notification emails try { await emailServices.rentalFlow.sendRentalCancellationEmails( updatedRental.owner, updatedRental.renter, updatedRental, result.refund, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Cancellation emails sent", { rentalId: updatedRental.id, cancelledBy: updatedRental.cancelledBy, }); } catch (emailError) { // Log error but don't fail the request const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send cancellation emails", { error: emailError.message, stack: emailError.stack, rentalId: updatedRental.id, cancelledBy: updatedRental.cancelledBy, }); } res.json({ rental: updatedRental, refund: result.refund, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error cancelling rental", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); next(error); } }); // Mark item return status (owner only) router.post("/:id/mark-return", authenticateToken, async (req, res, next) => { try { const { status, actualReturnDateTime, statusOptions } = req.body; const rentalId = req.params.id; const userId = req.user.id; const rental = await Rental.findByPk(rentalId, { include: [{ model: Item, as: "item" }], }); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } if (rental.ownerId !== userId) { return res .status(403) .json({ error: "Only the item owner can mark return status" }); } if (!isActive(rental)) { return res.status(400).json({ error: "Can only mark return status for active rentals", }); } let updatedRental; let additionalInfo = {}; switch (status) { case "returned": // Item returned on time updatedRental = await rental.update({ status: "completed", payoutStatus: "pending", actualReturnDateTime: actualReturnDateTime || rental.endDateTime, }); // Fetch full rental details with associations for email const rentalWithDetails = await Rental.findByPk(rentalId, { include: [ { model: Item, as: "item" }, { model: User, as: "owner", attributes: [ "id", "firstName", "lastName", "email", "stripeConnectedAccountId", ], }, { model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email"], }, ], }); // Send completion emails to both renter and owner try { await emailServices.rentalFlow.sendRentalCompletionEmails( rentalWithDetails.owner, rentalWithDetails.renter, rentalWithDetails, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental completion emails sent", { rentalId, ownerId: rental.ownerId, renterId: rental.renterId, }); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send rental completion emails", { error: emailError.message, stack: emailError.stack, rentalId, }); } // Trigger immediate payout attempt (non-blocking) PayoutService.triggerPayoutOnCompletion(rentalId).catch((err) => { logger.error("Error triggering payout on mark-return", { rentalId, error: err.message, }); }); break; case "damaged": // Item returned damaged const damageUpdates = { status: "damaged", payoutStatus: "pending", actualReturnDateTime: actualReturnDateTime || rental.endDateTime, }; // Check if ALSO returned late if (statusOptions?.returned_late && actualReturnDateTime) { const lateReturnDamaged = await LateReturnService.processLateReturn( rentalId, actualReturnDateTime, ); damageUpdates.status = "returned_late_and_damaged"; damageUpdates.lateFees = lateReturnDamaged.lateCalculation.lateFee; damageUpdates.actualReturnDateTime = lateReturnDamaged.rental.actualReturnDateTime; additionalInfo.lateCalculation = lateReturnDamaged.lateCalculation; } updatedRental = await rental.update(damageUpdates); break; case "returned_late": // Item returned late - calculate late fees if (!actualReturnDateTime) { return res.status(400).json({ error: "Actual return date/time is required for late returns", }); } const lateReturn = await LateReturnService.processLateReturn( rentalId, actualReturnDateTime, ); updatedRental = lateReturn.rental; additionalInfo.lateCalculation = lateReturn.lateCalculation; break; case "lost": // Item reported as lost updatedRental = await rental.update({ status: "lost", payoutStatus: "pending", itemLostReportedAt: new Date(), }); // Send notification to customer service const owner = await User.findByPk(rental.ownerId); const renter = await User.findByPk(rental.renterId); await emailServices.customerService.sendLostItemToCustomerService( updatedRental, owner, renter, ); break; default: return res.status(400).json({ error: "Invalid status. Use 'returned', 'returned_late', 'damaged', or 'lost'", }); } const reqLogger = logger.withRequestId(req.id); reqLogger.info("Return status marked", { rentalId, status, ownerId: userId, lateFee: updatedRental.lateFees || 0, }); res.json({ success: true, rental: updatedRental, ...additionalInfo, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error marking return status", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); next(error); } }); // Allowed fields for damage report (prevents mass assignment) const ALLOWED_DAMAGE_REPORT_FIELDS = [ "description", "canBeFixed", "repairCost", "needsReplacement", "replacementCost", "proofOfOwnership", "actualReturnDateTime", "imageFilenames", ]; function extractAllowedDamageFields(body) { const result = {}; for (const field of ALLOWED_DAMAGE_REPORT_FIELDS) { if (body[field] !== undefined) { result[field] = body[field]; } } return result; } // Report item as damaged (owner only) router.post("/:id/report-damage", authenticateToken, async (req, res, next) => { try { const rentalId = req.params.id; const userId = req.user.id; // Extract only allowed fields (prevents mass assignment) const damageInfo = extractAllowedDamageFields(req.body); // Validate imageFilenames if provided if (damageInfo.imageFilenames !== undefined) { const imageFilenamesArray = Array.isArray(damageInfo.imageFilenames) ? damageInfo.imageFilenames : []; const keyValidation = validateS3Keys( imageFilenamesArray, "damage-reports", { maxKeys: IMAGE_LIMITS.damageReports, }, ); if (!keyValidation.valid) { return res.status(400).json({ error: keyValidation.error, details: keyValidation.invalidKeys, }); } damageInfo.imageFilenames = imageFilenamesArray; } const result = await DamageAssessmentService.processDamageAssessment( rentalId, damageInfo, userId, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Damage reported", { rentalId, ownerId: userId, damageFee: result.damageAssessment.feeCalculation.amount, lateFee: result.lateCalculation?.lateFee || 0, }); res.json({ success: true, ...result, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Error reporting damage", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); next(error); } }); // 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); } }); /** * GET /rentals/:id/payment-client-secret * Returns client secret for 3DS completion (renter only) */ router.get( "/:id/payment-client-secret", authenticateToken, async (req, res, next) => { try { const rental = await Rental.findByPk(req.params.id, { include: [ { model: User, as: "renter", attributes: ["id", "stripeCustomerId"] }, ], }); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } if (rental.renterId !== req.user.id) { return res.status(403).json({ error: "Not authorized" }); } if (!rental.stripePaymentIntentId) { return res.status(400).json({ error: "No payment intent found" }); } const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); const paymentIntent = await stripe.paymentIntents.retrieve( rental.stripePaymentIntentId, ); return res.json({ clientSecret: paymentIntent.client_secret, status: paymentIntent.status, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Get client secret error", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); next(error); } }, ); /** * POST /rentals/:id/complete-payment * Called after renter completes 3DS authentication */ router.post( "/:id/complete-payment", authenticateToken, async (req, res, next) => { try { const rental = await Rental.findByPk(req.params.id, { include: [ { model: User, as: "renter", attributes: [ "id", "firstName", "lastName", "email", "stripeCustomerId", ], }, { model: User, as: "owner", attributes: [ "id", "firstName", "lastName", "email", "stripeConnectedAccountId", "stripePayoutsEnabled", ], }, { model: Item, as: "item" }, ], }); if (!rental) { return res.status(404).json({ error: "Rental not found" }); } if (rental.renterId !== req.user.id) { return res.status(403).json({ error: "Not authorized" }); } if (rental.paymentStatus !== "requires_action") { return res.status(400).json({ error: "Invalid state", message: "This rental is not awaiting payment authentication", }); } // Retrieve payment intent to check status (expand latest_charge for payment method details) const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); const paymentIntent = await stripe.paymentIntents.retrieve( rental.stripePaymentIntentId, { expand: ["latest_charge.payment_method_details"] }, ); if (paymentIntent.status !== "succeeded") { return res.status(402).json({ error: "payment_incomplete", status: paymentIntent.status, message: paymentIntent.status === "requires_action" ? "Authentication not yet completed" : "Payment could not be completed", }); } // Extract payment method details from latest_charge (charges is deprecated) const charge = paymentIntent.latest_charge; const paymentMethodDetails = charge?.payment_method_details; let paymentMethodBrand = null; let paymentMethodLast4 = null; if (paymentMethodDetails) { const type = paymentMethodDetails.type; if (type === "card") { paymentMethodBrand = paymentMethodDetails.card?.brand || "card"; paymentMethodLast4 = paymentMethodDetails.card?.last4 || null; } else if (type === "us_bank_account") { paymentMethodBrand = "bank_account"; paymentMethodLast4 = paymentMethodDetails.us_bank_account?.last4 || null; } } // Payment succeeded - complete rental confirmation await rental.update({ status: "confirmed", paymentStatus: "paid", chargedAt: new Date(), paymentMethodBrand, paymentMethodLast4, }); // Create condition check reminder schedules try { await EventBridgeSchedulerService.createConditionCheckSchedules(rental); } catch (schedulerError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to create condition check schedules", { error: schedulerError.message, rentalId: rental.id, }); // Don't fail the confirmation - schedules are non-critical } // Send confirmation emails try { await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail( rental.owner, rental.renter, rental, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info( "Rental approval confirmation sent to owner (after 3DS)", { rentalId: rental.id, ownerId: rental.ownerId, }, ); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error( "Failed to send rental approval confirmation email after 3DS", { error: emailError.message, rentalId: rental.id, }, ); } try { const renterNotification = { type: "rental_confirmed", title: "Rental Confirmed", message: `Your rental of "${rental.item.name}" has been confirmed.`, rentalId: rental.id, userId: rental.renterId, metadata: { rentalStart: rental.startDateTime }, }; await emailServices.rentalFlow.sendRentalConfirmation( rental.renter.email, renterNotification, rental, rental.renter.firstName, true, ); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental confirmation sent to renter (after 3DS)", { rentalId: rental.id, renterId: rental.renterId, }); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to send rental confirmation email after 3DS", { error: emailError.message, rentalId: rental.id, }); } // Trigger payout if owner has payouts enabled if ( rental.owner.stripePayoutsEnabled && rental.owner.stripeConnectedAccountId ) { try { await PayoutService.processRentalPayout(rental); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Payout processed after 3DS completion", { rentalId: rental.id, ownerId: rental.ownerId, }); } catch (payoutError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Payout failed after 3DS completion", { error: payoutError.message, rentalId: rental.id, }); } } return res.json({ success: true, rental: { id: rental.id, status: "confirmed", paymentStatus: "paid", }, }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Complete payment error", { error: error.message, stack: error.stack, rentalId: req.params.id, userId: req.user.id, }); next(error); } }, ); module.exports = router;