Files
rentall-app/backend/routes/rentals.js

1933 lines
58 KiB
JavaScript

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 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
// Note: "active" rentals are stored as "confirmed" with startDateTime in the past
const overlappingRental = await Rental.findOne({
where: {
itemId,
status: "confirmed",
[Op.or]: [
{
[Op.and]: [
{ startDateTime: { [Op.not]: null } },
{ endDateTime: { [Op.not]: null } },
{
[Op.or]: [
{
startDateTime: {
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
},
},
{
endDateTime: {
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
},
},
{
[Op.and]: [
{ startDateTime: { [Op.lte]: rentalStartDateTime } },
{ endDateTime: { [Op.gte]: rentalEndDateTime } },
],
},
],
},
],
},
],
},
});
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"],
},
],
});
// 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
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 {
// 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"],
},
],
});
// 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",
});
}
// 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,
});
// 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;