From 3dca6c803a364fdef5f03072729affd658d944f4 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:54:27 -0500 Subject: [PATCH] pricing tiers --- backend/models/Item.js | 6 + backend/models/ItemRequestResponse.js | 6 + backend/routes/rentals.js | 74 ++++++++-- backend/services/lateReturnService.js | 42 +++--- backend/utils/rentalDurationCalculator.js | 62 ++++++++ frontend/src/components/ItemCard.tsx | 30 ++-- frontend/src/components/PricingForm.tsx | 165 +++++++++++++++++----- frontend/src/pages/CreateItem.tsx | 47 +++++- frontend/src/pages/EditItem.tsx | 91 ++++++++++-- frontend/src/pages/ItemDetail.tsx | 62 ++++---- frontend/src/pages/Owning.tsx | 2 +- frontend/src/pages/RentItem.tsx | 66 +++++---- frontend/src/services/api.ts | 5 + frontend/src/types/index.ts | 4 + 14 files changed, 508 insertions(+), 154 deletions(-) create mode 100644 backend/utils/rentalDurationCalculator.js diff --git a/backend/models/Item.js b/backend/models/Item.js index 8ad8c7b..0079ea9 100644 --- a/backend/models/Item.js +++ b/backend/models/Item.js @@ -48,6 +48,12 @@ const Item = sequelize.define("Item", { pricePerDay: { type: DataTypes.DECIMAL(10, 2), }, + pricePerWeek: { + type: DataTypes.DECIMAL(10, 2), + }, + pricePerMonth: { + type: DataTypes.DECIMAL(10, 2), + }, replacementCost: { type: DataTypes.DECIMAL(10, 2), allowNull: false, diff --git a/backend/models/ItemRequestResponse.js b/backend/models/ItemRequestResponse.js index 1e77114..7a17bd3 100644 --- a/backend/models/ItemRequestResponse.js +++ b/backend/models/ItemRequestResponse.js @@ -33,6 +33,12 @@ const ItemRequestResponse = sequelize.define('ItemRequestResponse', { offerPricePerDay: { type: DataTypes.DECIMAL(10, 2) }, + offerPricePerWeek: { + type: DataTypes.DECIMAL(10, 2) + }, + offerPricePerMonth: { + type: DataTypes.DECIMAL(10, 2) + }, availableStartDate: { type: DataTypes.DATE }, diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 21825dc..12b96ea 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -3,6 +3,7 @@ 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 DamageAssessmentService = require("../services/damageAssessmentService"); @@ -201,19 +202,12 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { 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; - } + // Calculate rental cost using duration calculator + totalAmount = RentalDurationCalculator.calculateRentalCost( + rentalStartDateTime, + rentalEndDateTime, + item + ); // Check for overlapping rentals using datetime ranges const overlappingRental = await Rental.findOne({ @@ -884,6 +878,60 @@ router.post("/calculate-fees", authenticateToken, async (req, res) => { } }); +// 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 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) => { try { diff --git a/backend/services/lateReturnService.js b/backend/services/lateReturnService.js index d51bee3..d908d3c 100644 --- a/backend/services/lateReturnService.js +++ b/backend/services/lateReturnService.js @@ -25,33 +25,27 @@ class LateReturnService { let lateFee = 0; let pricingType = "daily"; + const billableDays = Math.ceil(hoursLate / 24); - // Check if item has hourly or daily pricing - if (rental.item?.pricePerHour && rental.item.pricePerHour > 0) { - // Hourly pricing - charge per hour late - lateFee = hoursLate * parseFloat(rental.item.pricePerHour); - pricingType = "hourly"; - } else if (rental.item?.pricePerDay && rental.item.pricePerDay > 0) { - // Daily pricing - charge per day late (rounded up) - const billableDays = Math.ceil(hoursLate / 24); + // Calculate late fees per day, deriving daily rate from available pricing tiers + if (rental.item?.pricePerDay && rental.item.pricePerDay > 0) { + // Daily pricing - charge per day late lateFee = billableDays * parseFloat(rental.item.pricePerDay); - pricingType = "daily"; + } else if (rental.item?.pricePerWeek && rental.item.pricePerWeek > 0) { + // Weekly pricing - derive daily rate and charge per day late + const dailyRate = parseFloat(rental.item.pricePerWeek) / 7; + lateFee = billableDays * dailyRate; + } else if (rental.item?.pricePerMonth && rental.item.pricePerMonth > 0) { + // Monthly pricing - derive daily rate and charge per day late + const dailyRate = parseFloat(rental.item.pricePerMonth) / 30; + lateFee = billableDays * dailyRate; + } else if (rental.item?.pricePerHour && rental.item.pricePerHour > 0) { + // Hourly pricing - derive daily rate and charge per day late + const dailyRate = parseFloat(rental.item.pricePerHour) * 24; + lateFee = billableDays * dailyRate; } else { - // Free borrows: determine pricing type based on rental duration - const rentalStart = new Date(rental.startDateTime); - const rentalEnd = new Date(rental.endDateTime); - const rentalDurationHours = (rentalEnd - rentalStart) / (1000 * 60 * 60); - - if (rentalDurationHours <= 24) { - // Hourly rental - charge $10 per hour late - lateFee = hoursLate * 10.0; - pricingType = "hourly"; - } else { - // Daily rental - charge $10 per day late - const billableDays = Math.ceil(hoursLate / 24); - lateFee = billableDays * 10.0; - pricingType = "daily"; - } + // Free borrows - charge $10 per day late + lateFee = billableDays * 10.0; } return { diff --git a/backend/utils/rentalDurationCalculator.js b/backend/utils/rentalDurationCalculator.js new file mode 100644 index 0000000..5136a0e --- /dev/null +++ b/backend/utils/rentalDurationCalculator.js @@ -0,0 +1,62 @@ +class RentalDurationCalculator { + /** + * Calculate rental cost based on duration and item pricing tiers + * @param {Date|string} startDateTime - Rental start date/time + * @param {Date|string} endDateTime - Rental end date/time + * @param {Object} item - Item object with pricing information + * @returns {number} Total rental cost + */ + static calculateRentalCost(startDateTime, endDateTime, item) { + const rentalStartDateTime = new Date(startDateTime); + const 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)); + const diffWeeks = Math.ceil(diffDays / 7); + + // Calculate difference in calendar months + let diffMonths = (rentalEndDateTime.getFullYear() - rentalStartDateTime.getFullYear()) * 12; + diffMonths += rentalEndDateTime.getMonth() - rentalStartDateTime.getMonth(); + + // Add 1 if we're past the start day/time in the end month + if (rentalEndDateTime.getDate() > rentalStartDateTime.getDate()) { + diffMonths += 1; + } else if (rentalEndDateTime.getDate() === rentalStartDateTime.getDate() && + rentalEndDateTime.getTime() > rentalStartDateTime.getTime()) { + diffMonths += 1; + } + + diffMonths = Math.max(1, diffMonths); + + // Calculate base amount based on duration (tiered pricing) + let totalAmount; + + if (item.pricePerHour && diffHours <= 24) { + // Use hourly rate for rentals <= 24 hours + totalAmount = diffHours * Number(item.pricePerHour); + } else if (diffDays <= 7 && item.pricePerDay) { + // Use daily rate for rentals <= 7 days + totalAmount = diffDays * Number(item.pricePerDay); + } else if (diffMonths <= 1 && item.pricePerWeek) { + // Use weekly rate for rentals <= 1 calendar month + totalAmount = diffWeeks * Number(item.pricePerWeek); + } else if (diffMonths > 1 && item.pricePerMonth) { + // Use monthly rate for rentals > 1 calendar month + totalAmount = diffMonths * Number(item.pricePerMonth); + } else if (item.pricePerWeek) { + // Fallback to weekly rate if monthly not available + totalAmount = diffWeeks * Number(item.pricePerWeek); + } else if (item.pricePerDay) { + // Fallback to daily rate + totalAmount = diffDays * Number(item.pricePerDay); + } else { + totalAmount = 0; + } + + return totalAmount; + } +} + +module.exports = RentalDurationCalculator; diff --git a/frontend/src/components/ItemCard.tsx b/frontend/src/components/ItemCard.tsx index d87bbeb..bd990fd 100644 --- a/frontend/src/components/ItemCard.tsx +++ b/frontend/src/components/ItemCard.tsx @@ -14,16 +14,28 @@ const ItemCard: React.FC = ({ const isCompact = variant === 'compact'; const getPriceDisplay = () => { - if (item.pricePerDay !== undefined) { - return Number(item.pricePerDay) === 0 - ? "Free to Borrow" - : `$${Math.floor(Number(item.pricePerDay))}/Day`; - } else if (item.pricePerHour !== undefined) { - return Number(item.pricePerHour) === 0 - ? "Free to Borrow" - : `$${Math.floor(Number(item.pricePerHour))}/Hour`; + // Collect all available pricing tiers + const pricingTiers: string[] = []; + + if (item.pricePerHour && Number(item.pricePerHour) > 0) { + pricingTiers.push(`$${Math.floor(Number(item.pricePerHour))}/hr`); } - return null; + if (item.pricePerDay && Number(item.pricePerDay) > 0) { + pricingTiers.push(`$${Math.floor(Number(item.pricePerDay))}/day`); + } + if (item.pricePerWeek && Number(item.pricePerWeek) > 0) { + pricingTiers.push(`$${Math.floor(Number(item.pricePerWeek))}/wk`); + } + if (item.pricePerMonth && Number(item.pricePerMonth) > 0) { + pricingTiers.push(`$${Math.floor(Number(item.pricePerMonth))}/mo`); + } + + if (pricingTiers.length === 0) { + return "Free to Borrow"; + } + + // Display up to 2 pricing tiers separated by bullet point + return pricingTiers.slice(0, 2).join(" • "); }; const getLocationDisplay = () => { diff --git a/frontend/src/components/PricingForm.tsx b/frontend/src/components/PricingForm.tsx index c921704..9df785f 100644 --- a/frontend/src/components/PricingForm.tsx +++ b/frontend/src/components/PricingForm.tsx @@ -1,51 +1,105 @@ -import React from 'react'; +import React from "react"; interface PricingFormProps { - priceType: "hour" | "day"; pricePerHour: number | string; pricePerDay: number | string; + pricePerWeek: number | string; + pricePerMonth: number | string; replacementCost: number | string; - minimumRentalDays: number; - onPriceTypeChange: (type: "hour" | "day") => void; - onChange: (e: React.ChangeEvent) => void; + selectedPricingUnit: string; + showAdvancedPricing: boolean; + enabledTiers: { + hour: boolean; + day: boolean; + week: boolean; + month: boolean; + }; + onChange: ( + e: React.ChangeEvent + ) => void; + onPricingUnitChange: (e: React.ChangeEvent) => void; + onToggleAdvancedPricing: () => void; + onTierToggle: (tier: string) => void; } const PricingForm: React.FC = ({ - priceType, pricePerHour, pricePerDay, + pricePerWeek, + pricePerMonth, replacementCost, - minimumRentalDays, - onPriceTypeChange, - onChange + selectedPricingUnit, + showAdvancedPricing, + enabledTiers, + onChange, + onPricingUnitChange, + onToggleAdvancedPricing, + onTierToggle, }) => { + // Map pricing unit to field name and label + const pricingUnitMap: Record< + string, + { field: string; label: string; value: number | string } + > = { + hour: { field: "pricePerHour", label: "Hour", value: pricePerHour }, + day: { field: "pricePerDay", label: "Day", value: pricePerDay }, + week: { field: "pricePerWeek", label: "Week", value: pricePerWeek }, + month: { field: "pricePerMonth", label: "Month", value: pricePerMonth }, + }; + + // When advanced pricing is on, show ALL 4 options + // When advanced pricing is off, show only the 3 non-selected options + const advancedPricingOptions = showAdvancedPricing + ? Object.entries(pricingUnitMap) + : Object.entries(pricingUnitMap).filter( + ([key]) => key !== selectedPricingUnit + ); + + const selectedUnit = pricingUnitMap[selectedPricingUnit]; + return (
-
-
-
- -
-
+
Pricing
+

+ {showAdvancedPricing + ? "Set multiple pricing tiers for flexible rental rates." + : "Set your primary pricing rate, or use Advanced Pricing for multiple tiers."} +

+ + {/* Pricing Unit Dropdown - Only show when advanced pricing is OFF */} + {!showAdvancedPricing && ( + <> +
+
-
+ + {/* Primary Price Input */} +
+
$ = ({ />
-
-
+ + )} + {/* Advanced Pricing Toggle */}
- - +
+ {/* Advanced Pricing Section */} + {showAdvancedPricing && ( +
+

+ Set multiple pricing tiers. Check the boxes for the tiers you want to use. +

+ {advancedPricingOptions.map(([key, { field, label, value }]) => ( +
+
+ onTierToggle(key)} + /> + +
+
+ $ + +
+
+ ))} +
+ )} + +
+ + {/* Replacement Cost */}