From b59fc07fc3acbe3705875655ab697ffc2c1bd583 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:15:09 -0400 Subject: [PATCH] payouts --- backend/models/Rental.js | 117 +++--- backend/routes/rentals.js | 102 +++--- backend/routes/stripe.js | 35 +- backend/services/payoutService.js | 2 + backend/services/stripeService.js | 2 +- frontend/src/App.tsx | 9 + frontend/src/components/CheckoutReturn.tsx | 7 +- frontend/src/components/EarningsStatus.tsx | 76 ++++ frontend/src/components/Navbar.tsx | 6 + .../src/components/RequestResponseModal.tsx | 346 ++++++++++-------- .../src/components/ReviewDetailsModal.tsx | 22 +- frontend/src/components/ReviewModal.tsx | 12 +- frontend/src/components/ReviewRenterModal.tsx | 12 +- .../components/StripeConnectOnboarding.tsx | 211 +++++++++++ frontend/src/pages/CreateItemRequest.tsx | 131 ++++--- frontend/src/pages/EarningsDashboard.tsx | 288 +++++++++++++++ frontend/src/pages/ItemDetail.tsx | 4 +- frontend/src/pages/MyListings.tsx | 15 +- frontend/src/pages/MyRentals.tsx | 14 +- frontend/src/pages/Profile.tsx | 28 +- frontend/src/pages/RentItem.tsx | 29 +- frontend/src/services/api.ts | 13 +- frontend/src/types/index.ts | 16 +- 23 files changed, 1080 insertions(+), 417 deletions(-) create mode 100644 frontend/src/components/EarningsStatus.tsx create mode 100644 frontend/src/components/StripeConnectOnboarding.tsx create mode 100644 frontend/src/pages/EarningsDashboard.tsx diff --git a/backend/models/Rental.js b/backend/models/Rental.js index 505862c..003f410 100644 --- a/backend/models/Rental.js +++ b/backend/models/Rental.js @@ -1,152 +1,141 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); -const Rental = sequelize.define('Rental', { +const Rental = sequelize.define("Rental", { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, - primaryKey: true + primaryKey: true, }, itemId: { type: DataTypes.UUID, allowNull: false, references: { - model: 'Items', - key: 'id' - } + model: "Items", + key: "id", + }, }, renterId: { type: DataTypes.UUID, allowNull: false, references: { - model: 'Users', - key: 'id' - } + model: "Users", + key: "id", + }, }, ownerId: { type: DataTypes.UUID, allowNull: false, references: { - model: 'Users', - key: 'id' - } + model: "Users", + key: "id", + }, }, - startDate: { + startDateTime: { type: DataTypes.DATE, - allowNull: false + allowNull: false, }, - endDate: { + endDateTime: { type: DataTypes.DATE, - allowNull: false - }, - startTime: { - type: DataTypes.STRING - }, - endTime: { - type: DataTypes.STRING + allowNull: false, }, totalAmount: { type: DataTypes.DECIMAL(10, 2), - allowNull: false + allowNull: false, }, baseRentalAmount: { type: DataTypes.DECIMAL(10, 2), - allowNull: false + allowNull: false, }, platformFee: { type: DataTypes.DECIMAL(10, 2), - allowNull: false + allowNull: false, }, processingFee: { type: DataTypes.DECIMAL(10, 2), - allowNull: false + allowNull: false, }, payoutAmount: { type: DataTypes.DECIMAL(10, 2), - allowNull: false + allowNull: false, }, status: { - type: DataTypes.ENUM('pending', 'confirmed', 'active', 'completed', 'cancelled'), - defaultValue: 'pending' + type: DataTypes.ENUM( + "pending", + "confirmed", + "active", + "completed", + "cancelled" + ), + defaultValue: "pending", }, paymentStatus: { - type: DataTypes.ENUM('pending', 'paid', 'refunded'), - defaultValue: 'pending' + type: DataTypes.ENUM("pending", "paid", "refunded"), + defaultValue: "pending", }, payoutStatus: { - type: DataTypes.ENUM('pending', 'processing', 'completed', 'failed'), - defaultValue: 'pending' + type: DataTypes.ENUM("pending", "processing", "completed", "failed"), + defaultValue: "pending", }, payoutProcessedAt: { - type: DataTypes.DATE + type: DataTypes.DATE, }, stripeTransferId: { - type: DataTypes.STRING + type: DataTypes.STRING, }, deliveryMethod: { - type: DataTypes.ENUM('pickup', 'delivery'), - defaultValue: 'pickup' + type: DataTypes.ENUM("pickup", "delivery"), + defaultValue: "pickup", }, deliveryAddress: { - type: DataTypes.TEXT + type: DataTypes.TEXT, }, notes: { - type: DataTypes.TEXT + type: DataTypes.TEXT, }, // Renter's review of the item (existing fields renamed for clarity) itemRating: { type: DataTypes.INTEGER, validate: { min: 1, - max: 5 - } + max: 5, + }, }, itemReview: { - type: DataTypes.TEXT + type: DataTypes.TEXT, }, itemReviewSubmittedAt: { - type: DataTypes.DATE + type: DataTypes.DATE, }, itemReviewVisible: { type: DataTypes.BOOLEAN, - defaultValue: false + defaultValue: false, }, // Owner's review of the renter renterRating: { type: DataTypes.INTEGER, validate: { min: 1, - max: 5 - } + max: 5, + }, }, renterReview: { - type: DataTypes.TEXT + type: DataTypes.TEXT, }, renterReviewSubmittedAt: { - type: DataTypes.DATE + type: DataTypes.DATE, }, renterReviewVisible: { type: DataTypes.BOOLEAN, - defaultValue: false + defaultValue: false, }, // Private messages (always visible to recipient) itemPrivateMessage: { - type: DataTypes.TEXT + type: DataTypes.TEXT, }, renterPrivateMessage: { - type: DataTypes.TEXT + type: DataTypes.TEXT, }, - // Legacy fields for backwards compatibility - rating: { - type: DataTypes.INTEGER, - validate: { - min: 1, - max: 5 - } - }, - review: { - type: DataTypes.TEXT - } }); -module.exports = Rental; \ No newline at end of file +module.exports = Rental; diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index c67aa0a..30594a5 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -98,13 +98,12 @@ router.post("/", authenticateToken, async (req, res) => { try { const { itemId, - startDate, - endDate, - startTime, - endTime, + startDateTime, + endDateTime, deliveryMethod, deliveryAddress, notes, + paymentStatus, } = req.body; const item = await Item.findByPk(itemId); @@ -116,16 +115,57 @@ router.post("/", authenticateToken, async (req, res) => { return res.status(400).json({ error: "Item is not available" }); } + let rentalStartDateTime, rentalEndDateTime, baseRentalAmount; + + // 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) { + baseRentalAmount = diffHours * Number(item.pricePerHour); + } else if (item.pricePerDay) { + baseRentalAmount = diffDays * Number(item.pricePerDay); + } else { + baseRentalAmount = 0; + } + + // Check for overlapping rentals using datetime ranges const overlappingRental = await Rental.findOne({ where: { itemId, status: { [Op.in]: ["confirmed", "active"] }, [Op.or]: [ { - startDate: { [Op.between]: [startDate, endDate] }, - }, - { - endDate: { [Op.between]: [startDate, endDate] }, + [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 } }, + ], + }, + ], + }, + ], }, ], }, @@ -137,11 +177,6 @@ router.post("/", authenticateToken, async (req, res) => { .json({ error: "Item is already booked for these dates" }); } - const rentalDays = Math.ceil( - (new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24) - ); - const baseRentalAmount = rentalDays * (item.pricePerDay || 0); - // Calculate fees using FeeCalculator const fees = FeeCalculator.calculateRentalFees(baseRentalAmount); @@ -149,15 +184,14 @@ router.post("/", authenticateToken, async (req, res) => { itemId, renterId: req.user.id, ownerId: item.ownerId, - startDate, - endDate, - startTime, - endTime, + startDateTime: rentalStartDateTime, + endDateTime: rentalEndDateTime, totalAmount: fees.totalChargedAmount, baseRentalAmount: fees.baseRentalAmount, platformFee: fees.platformFee, processingFee: fees.processingFee, payoutAmount: fees.payoutAmount, + paymentStatus: paymentStatus || "pending", deliveryMethod, deliveryAddress, notes, @@ -356,34 +390,6 @@ router.post("/:id/mark-completed", authenticateToken, async (req, res) => { } }); -// Legacy review endpoint (for backward compatibility) -router.post("/:id/review", authenticateToken, async (req, res) => { - try { - const { rating, review } = 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 leave reviews" }); - } - - if (rental.status !== "completed") { - return res - .status(400) - .json({ error: "Can only review completed rentals" }); - } - - await rental.update({ rating, review }); - - res.json(rental); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - // Calculate fees for rental pricing display router.post("/calculate-fees", authenticateToken, async (req, res) => { try { @@ -406,8 +412,8 @@ router.post("/calculate-fees", authenticateToken, async (req, res) => { } }); -// Get payout status for owner's rentals -router.get("/payouts/status", authenticateToken, async (req, res) => { +// Get earnings status for owner's rentals +router.get("/earnings/status", authenticateToken, async (req, res) => { try { const ownerRentals = await Rental.findAll({ where: { @@ -429,7 +435,7 @@ router.get("/payouts/status", authenticateToken, async (req, res) => { res.json(ownerRentals); } catch (error) { - console.error("Error getting payout status:", error); + console.error("Error getting earnings status:", error); res.status(500).json({ error: error.message }); } }); diff --git a/backend/routes/stripe.js b/backend/routes/stripe.js index b65c96d..14d5622 100644 --- a/backend/routes/stripe.js +++ b/backend/routes/stripe.js @@ -1,12 +1,10 @@ const express = require("express"); const { authenticateToken } = require("../middleware/auth"); -const { User } = require("../models"); -const { Rental, Item } = require("../models"); +const { User, Item } = require("../models"); const StripeService = require("../services/stripeService"); const router = express.Router(); -const platformFee = 0.1; -router.post("/create-checkout-session", async (req, res) => { +router.post("/create-checkout-session", authenticateToken, async (req, res) => { try { const { itemName, total, return_url, rentalData } = req.body; @@ -20,18 +18,37 @@ router.post("/create-checkout-session", async (req, res) => { return res.status(400).json({ error: "No return_url found" }); } + // Validate rental data and user authorization + if (rentalData && rentalData.itemId) { + const item = await Item.findByPk(rentalData.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 for rent" }); + } + + // Check if user is trying to rent their own item + if (item.ownerId === req.user.id) { + return res.status(400).json({ error: "You cannot rent your own item" }); + } + } + // Prepare metadata - Stripe metadata keys must be strings const metadata = rentalData ? { itemId: rentalData.itemId, - startDate: rentalData.startDate, - endDate: rentalData.endDate, - startTime: rentalData.startTime, - endTime: rentalData.endTime, + renterId: req.user.id.toString(), // Add authenticated user ID + startDateTime: rentalData.startDateTime, + endDateTime: rentalData.endDateTime, totalAmount: rentalData.totalAmount.toString(), deliveryMethod: rentalData.deliveryMethod, } - : {}; + : { renterId: req.user.id.toString() }; const session = await StripeService.createCheckoutSession({ item_name: itemName, diff --git a/backend/services/payoutService.js b/backend/services/payoutService.js index 1eb2e59..ea057dc 100644 --- a/backend/services/payoutService.js +++ b/backend/services/payoutService.js @@ -59,6 +59,8 @@ class PayoutService { ownerId: rental.ownerId, baseAmount: rental.baseRentalAmount.toString(), platformFee: rental.platformFee.toString(), + startDateTime: rental.startDateTime.toISOString(), + endDateTime: rental.endDateTime.toISOString(), }, }); diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js index 966d3cd..2892ad0 100644 --- a/backend/services/stripeService.js +++ b/backend/services/stripeService.js @@ -48,7 +48,7 @@ class StripeService { static async createConnectedAccount({ email, country = "US" }) { try { const account = await stripe.accounts.create({ - type: "standard", + type: "express", email, country, capabilities: { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23889f9..0d354f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import ItemRequests from './pages/ItemRequests'; import ItemRequestDetail from './pages/ItemRequestDetail'; import CreateItemRequest from './pages/CreateItemRequest'; import MyRequests from './pages/MyRequests'; +import EarningsDashboard from './pages/EarningsDashboard'; import CheckoutReturn from './components/CheckoutReturn'; import PrivateRoute from './components/PrivateRoute'; import './App.css'; @@ -123,6 +124,14 @@ function App() { } /> + + + + } + /> { // Convert metadata back to proper types const rentalData = { itemId: metadata.itemId, - startDate: metadata.startDate, - endDate: metadata.endDate, - startTime: metadata.startTime, - endTime: metadata.endTime, + startDateTime: metadata.startDateTime, + endDateTime: metadata.endDateTime, totalAmount: parseFloat(metadata.totalAmount), deliveryMethod: metadata.deliveryMethod, + paymentStatus: "paid", // Set since payment already succeeded }; const response = await rentalAPI.createRental(rentalData); diff --git a/frontend/src/components/EarningsStatus.tsx b/frontend/src/components/EarningsStatus.tsx new file mode 100644 index 0000000..92b7641 --- /dev/null +++ b/frontend/src/components/EarningsStatus.tsx @@ -0,0 +1,76 @@ +import React from "react"; + +interface EarningsStatusProps { + hasStripeAccount: boolean; + onSetupClick: () => void; +} + +const EarningsStatus: React.FC = ({ + hasStripeAccount, + onSetupClick, +}) => { + // No Stripe account exists + if (!hasStripeAccount) { + return ( +
+
+ +
+
Earnings Not Set Up
+

+ Set up earnings to automatically receive payments when rentals are + completed. +

+ +
+ ); + } + + // Account exists and is set up + return ( +
+
+ +
+
Earnings Active
+

+ Your earnings are set up and working. You'll receive payments + automatically. +

+ +
+
+ Earnings Enabled: + + Yes + +
+
+ Status: + + Active + +
+
+ +
+ + +
+ ); +}; + +export default EarningsStatus; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index b68c741..f669998 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -162,6 +162,12 @@ const Navbar: React.FC = () => { Looking For +
  • + + + Earnings + +
  • Messages diff --git a/frontend/src/components/RequestResponseModal.tsx b/frontend/src/components/RequestResponseModal.tsx index 3e7084f..3de47e6 100644 --- a/frontend/src/components/RequestResponseModal.tsx +++ b/frontend/src/components/RequestResponseModal.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; -import { useAuth } from '../contexts/AuthContext'; -import { itemRequestAPI, itemAPI } from '../services/api'; -import { ItemRequest, Item } from '../types'; +import React, { useState, useEffect } from "react"; +import { useAuth } from "../contexts/AuthContext"; +import { itemRequestAPI, itemAPI } from "../services/api"; +import { ItemRequest, Item } from "../types"; interface RequestResponseModalProps { show: boolean; @@ -14,21 +14,21 @@ const RequestResponseModal: React.FC = ({ show, onHide, request, - onResponseSubmitted + onResponseSubmitted, }) => { const { user } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [userItems, setUserItems] = useState([]); - + const [formData, setFormData] = useState({ - message: '', - offerPricePerHour: '', - offerPricePerDay: '', - availableStartDate: '', - availableEndDate: '', - existingItemId: '', - contactInfo: '' + message: "", + offerPricePerHour: "", + offerPricePerDay: "", + availableStartDate: "", + availableEndDate: "", + existingItemId: "", + contactInfo: "", }); useEffect(() => { @@ -43,26 +43,30 @@ const RequestResponseModal: React.FC = ({ const response = await itemAPI.getItems({ owner: user?.id }); setUserItems(response.data.items || []); } catch (err) { - console.error('Failed to fetch user items:', err); + console.error("Failed to fetch user items:", err); } }; const resetForm = () => { setFormData({ - message: '', - offerPricePerHour: '', - offerPricePerDay: '', - availableStartDate: '', - availableEndDate: '', - existingItemId: '', - contactInfo: '' + message: "", + offerPricePerHour: "", + offerPricePerDay: "", + availableStartDate: "", + availableEndDate: "", + existingItemId: "", + contactInfo: "", }); setError(null); }; - const handleChange = (e: React.ChangeEvent) => { + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + > + ) => { const { name, value } = e.target; - setFormData(prev => ({ ...prev, [name]: value })); + setFormData((prev) => ({ ...prev, [name]: value })); }; const handleSubmit = async (e: React.FormEvent) => { @@ -75,18 +79,22 @@ const RequestResponseModal: React.FC = ({ try { const responseData = { ...formData, - offerPricePerHour: formData.offerPricePerHour ? parseFloat(formData.offerPricePerHour) : null, - offerPricePerDay: formData.offerPricePerDay ? parseFloat(formData.offerPricePerDay) : null, + offerPricePerHour: formData.offerPricePerHour + ? parseFloat(formData.offerPricePerHour) + : null, + offerPricePerDay: formData.offerPricePerDay + ? parseFloat(formData.offerPricePerDay) + : null, existingItemId: formData.existingItemId || null, availableStartDate: formData.availableStartDate || null, - availableEndDate: formData.availableEndDate || null + availableEndDate: formData.availableEndDate || null, }; await itemRequestAPI.respondToRequest(request.id, responseData); onResponseSubmitted(); onHide(); } catch (err: any) { - setError(err.response?.data?.error || 'Failed to submit response'); + setError(err.response?.data?.error || "Failed to submit response"); } finally { setLoading(false); } @@ -95,157 +103,187 @@ const RequestResponseModal: React.FC = ({ if (!request) return null; return ( -
    +
    Respond to Request
    - +
    - +
    -
    -
    {request.title}
    -

    {request.description}

    -
    - - {error && ( -
    - {error} -
    - )} - -
    -
    - -