Files
rentall-app/backend/routes/rentals.js
2025-09-22 22:02:08 -04:00

708 lines
20 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 } = require("../middleware/auth");
const FeeCalculator = require("../utils/feeCalculator");
const RefundService = require("../services/refundService");
const logger = require("../utils/logger");
const router = express.Router();
// Helper function to check and update review visibility
const checkAndUpdateReviewVisibility = async (rental) => {
const now = new Date();
const tenMinutesInMs = 10 * 60 * 1000; // 10 minutes
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 (10-minute rule)
if (rental.itemReviewSubmittedAt && !rental.itemReviewVisible) {
const timeSinceSubmission = now - new Date(rental.itemReviewSubmittedAt);
if (timeSinceSubmission >= tenMinutesInMs) {
updates.itemReviewVisible = true;
needsUpdate = true;
}
}
// Check renter review visibility (10-minute rule)
if (rental.renterReviewSubmittedAt && !rental.renterReviewVisible) {
const timeSinceSubmission =
now - new Date(rental.renterReviewSubmittedAt);
if (timeSinceSubmission >= tenMinutesInMs) {
updates.renterReviewVisible = true;
needsUpdate = true;
}
}
}
if (needsUpdate) {
await rental.update(updates);
}
return rental;
};
router.get("/my-rentals", 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", "username", "firstName", "lastName"],
},
],
order: [["createdAt", "DESC"]],
});
res.json(rentals);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error in my-rentals route", {
error: error.message,
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: "Failed to fetch rentals" });
}
});
router.get("/my-listings", authenticateToken, async (req, res) => {
try {
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", "username", "firstName", "lastName"],
},
],
order: [["createdAt", "DESC"]],
});
res.json(rentals);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error in my-listings route", {
error: error.message,
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: "Failed to fetch listings" });
}
});
// 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", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: ["id", "username", "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(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, async (req, res) => {
try {
const {
itemId,
startDateTime,
endDateTime,
deliveryMethod,
deliveryAddress,
notes,
stripePaymentMethodId,
} = req.body;
const item = await Item.findByPk(itemId);
if (!item) {
return res.status(404).json({ error: "Item not found" });
}
if (!item.availability) {
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);
// Calculate rental duration
const diffMs = rentalEndDateTime.getTime() - rentalStartDateTime.getTime();
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
// Calculate base amount based on duration
if (item.pricePerHour && diffHours <= 24) {
totalAmount = diffHours * Number(item.pricePerHour);
} else if (item.pricePerDay) {
totalAmount = diffDays * Number(item.pricePerDay);
} else {
totalAmount = 0;
}
// Check for overlapping rentals using datetime ranges
const overlappingRental = await Rental.findOne({
where: {
itemId,
status: { [Op.in]: ["confirmed", "active"] },
[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,
notes,
};
// 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", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
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", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: [
"id",
"username",
"firstName",
"lastName",
"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,
}
);
// Update rental with payment completion
await rental.update({
status: "confirmed",
paymentStatus: "paid",
stripePaymentIntentId: paymentResult.paymentIntentId,
});
const updatedRental = await Rental.findByPk(rental.id, {
include: [
{ model: Item, as: "item" },
{
model: User,
as: "owner",
attributes: ["id", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
res.json(updatedRental);
return;
} catch (paymentError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Payment failed during approval", {
error: paymentError.message,
stack: paymentError.stack,
rentalId: req.params.id,
userId: req.user.id
});
// Keep rental as pending, but inform of payment failure
return res.status(400).json({
error: "Payment failed during approval",
details: paymentError.message,
});
}
} 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", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
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", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
res.json(updatedRental);
} catch (error) {
res.status(500).json({ error: "Failed to update rental status" });
}
});
// 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" });
}
});
// Mark rental as completed (owner only)
router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
try {
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 mark rentals as completed" });
}
if (!["active", "confirmed"].includes(rental.status)) {
return res.status(400).json({
error: "Can only mark active or confirmed rentals as completed",
});
}
await rental.update({ status: "completed" });
const updatedRental = await Rental.findByPk(rental.id, {
include: [
{ model: Item, as: "item" },
{
model: User,
as: "owner",
attributes: ["id", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
res.json(updatedRental);
} catch (error) {
res.status(500).json({ error: "Failed to update rental" });
}
});
// 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" });
}
});
// Get earnings status for owner's rentals
router.get("/earnings/status", authenticateToken, async (req, res) => {
try {
const ownerRentals = await Rental.findAll({
where: {
ownerId: req.user.id,
status: "completed",
},
attributes: [
"id",
"totalAmount",
"platformFee",
"payoutAmount",
"payoutStatus",
"payoutProcessedAt",
"stripeTransferId",
],
include: [{ model: Item, as: "item", attributes: ["name"] }],
order: [["createdAt", "DESC"]],
});
res.json(ownerRentals);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error getting earnings status", {
error: error.message,
stack: error.stack,
userId: req.user.id
});
res.status(500).json({ error: error.message });
}
});
// Get refund preview (what would happen if cancelled now)
router.get("/:id/refund-preview", authenticateToken, async (req, res) => {
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
});
res.status(400).json({ error: error.message });
}
});
// Cancel rental with refund processing
router.post("/:id/cancel", authenticateToken, async (req, res) => {
try {
const { reason } = req.body;
const result = await RefundService.processCancellation(
req.params.id,
req.user.id,
reason
);
// 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", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
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
});
res.status(400).json({ error: error.message });
}
});
module.exports = router;