diff --git a/backend/jobs/payoutProcessor.js b/backend/jobs/payoutProcessor.js index 9982788..f55f42c 100644 --- a/backend/jobs/payoutProcessor.js +++ b/backend/jobs/payoutProcessor.js @@ -1,7 +1,7 @@ const cron = require("node-cron"); const PayoutService = require("../services/payoutService"); -const paymentsSchedule = "31 * * * *"; // Run every hour at minute 0 +const paymentsSchedule = "0 * * * *"; // Run every hour at minute 0 const retrySchedule = "0 7 * * *"; // Retry failed payouts once daily at 7 AM class PayoutProcessor { diff --git a/backend/models/Rental.js b/backend/models/Rental.js index 003f410..a1784c5 100644 --- a/backend/models/Rental.js +++ b/backend/models/Rental.js @@ -43,18 +43,10 @@ const Rental = sequelize.define("Rental", { type: DataTypes.DECIMAL(10, 2), allowNull: false, }, - baseRentalAmount: { - type: DataTypes.DECIMAL(10, 2), - allowNull: false, - }, platformFee: { type: DataTypes.DECIMAL(10, 2), allowNull: false, }, - processingFee: { - type: DataTypes.DECIMAL(10, 2), - allowNull: false, - }, payoutAmount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, @@ -83,6 +75,28 @@ const Rental = sequelize.define("Rental", { stripeTransferId: { type: DataTypes.STRING, }, + // Refund tracking fields + refundAmount: { + type: DataTypes.DECIMAL(10, 2), + }, + refundProcessedAt: { + type: DataTypes.DATE, + }, + refundReason: { + type: DataTypes.TEXT, + }, + stripeRefundId: { + type: DataTypes.STRING, + }, + cancelledBy: { + type: DataTypes.ENUM("renter", "owner"), + }, + cancelledAt: { + type: DataTypes.DATE, + }, + stripePaymentMethodId: { + type: DataTypes.STRING, + }, deliveryMethod: { type: DataTypes.ENUM("pickup", "delivery"), defaultValue: "pickup", diff --git a/backend/models/User.js b/backend/models/User.js index ae36aa0..4897df2 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -102,6 +102,10 @@ const User = sequelize.define('User', { stripeConnectedAccountId: { type: DataTypes.STRING, allowNull: true + }, + stripeCustomerId: { + type: DataTypes.STRING, + allowNull: true } }, { hooks: { diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 30594a5..16401c8 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 } = require("../middleware/auth"); const FeeCalculator = require("../utils/feeCalculator"); +const RefundService = require("../services/refundService"); const router = express.Router(); // Helper function to check and update review visibility @@ -103,7 +104,7 @@ router.post("/", authenticateToken, async (req, res) => { deliveryMethod, deliveryAddress, notes, - paymentStatus, + stripePaymentMethodId, } = req.body; const item = await Item.findByPk(itemId); @@ -115,7 +116,7 @@ router.post("/", authenticateToken, async (req, res) => { return res.status(400).json({ error: "Item is not available" }); } - let rentalStartDateTime, rentalEndDateTime, baseRentalAmount; + let rentalStartDateTime, rentalEndDateTime, totalAmount; // New UTC datetime format rentalStartDateTime = new Date(startDateTime); @@ -128,11 +129,11 @@ router.post("/", authenticateToken, async (req, res) => { // Calculate base amount based on duration if (item.pricePerHour && diffHours <= 24) { - baseRentalAmount = diffHours * Number(item.pricePerHour); + totalAmount = diffHours * Number(item.pricePerHour); } else if (item.pricePerDay) { - baseRentalAmount = diffDays * Number(item.pricePerDay); + totalAmount = diffDays * Number(item.pricePerDay); } else { - baseRentalAmount = 0; + totalAmount = 0; } // Check for overlapping rentals using datetime ranges @@ -178,7 +179,12 @@ router.post("/", authenticateToken, async (req, res) => { } // Calculate fees using FeeCalculator - const fees = FeeCalculator.calculateRentalFees(baseRentalAmount); + const fees = FeeCalculator.calculateRentalFees(totalAmount); + + // Validate that payment method was provided + if (!stripePaymentMethodId) { + return res.status(400).json({ error: "Payment method is required" }); + } const rental = await Rental.create({ itemId, @@ -187,14 +193,14 @@ router.post("/", authenticateToken, async (req, res) => { startDateTime: rentalStartDateTime, endDateTime: rentalEndDateTime, totalAmount: fees.totalChargedAmount, - baseRentalAmount: fees.baseRentalAmount, platformFee: fees.platformFee, - processingFee: fees.processingFee, payoutAmount: fees.payoutAmount, - paymentStatus: paymentStatus || "pending", + paymentStatus: "pending", + status: "pending", deliveryMethod, deliveryAddress, notes, + stripePaymentMethodId, }); const rentalWithDetails = await Rental.findByPk(rental.id, { @@ -222,7 +228,21 @@ router.post("/", authenticateToken, async (req, res) => { router.put("/:id/status", authenticateToken, async (req, res) => { try { const { status } = req.body; - const rental = await Rental.findByPk(req.params.id); + 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" }); @@ -232,6 +252,69 @@ router.put("/:id/status", authenticateToken, async (req, res) => { return res.status(403).json({ error: "Unauthorized" }); } + // If owner is approving a pending rental, charge the stored payment method + if (status === "confirmed" && rental.status === "pending" && rental.ownerId === req.user.id) { + 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) { + console.error("Payment failed during approval:", paymentError); + // Keep rental as pending, but inform of payment failure + return res.status(400).json({ + error: "Payment failed during approval", + details: paymentError.message + }); + } + } + await rental.update({ status }); const updatedRental = await Rental.findByPk(rental.id, { @@ -393,13 +476,13 @@ router.post("/:id/mark-completed", authenticateToken, async (req, res) => { // Calculate fees for rental pricing display router.post("/calculate-fees", authenticateToken, async (req, res) => { try { - const { baseAmount } = req.body; + const { totalAmount } = req.body; - if (!baseAmount || baseAmount <= 0) { + if (!totalAmount || totalAmount <= 0) { return res.status(400).json({ error: "Valid base amount is required" }); } - const fees = FeeCalculator.calculateRentalFees(baseAmount); + const fees = FeeCalculator.calculateRentalFees(totalAmount); const displayFees = FeeCalculator.formatFeesForDisplay(fees); res.json({ @@ -422,7 +505,7 @@ router.get("/earnings/status", authenticateToken, async (req, res) => { }, attributes: [ "id", - "baseRentalAmount", + "totalAmount", "platformFee", "payoutAmount", "payoutStatus", @@ -440,4 +523,56 @@ router.get("/earnings/status", authenticateToken, async (req, res) => { } }); +// 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) { + console.error("Error getting refund preview:", error); + 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) { + console.error("Error cancelling rental:", error); + res.status(400).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/routes/stripe.js b/backend/routes/stripe.js index 14d5622..03f175f 100644 --- a/backend/routes/stripe.js +++ b/backend/routes/stripe.js @@ -4,66 +4,6 @@ const { User, Item } = require("../models"); const StripeService = require("../services/stripeService"); const router = express.Router(); -router.post("/create-checkout-session", authenticateToken, async (req, res) => { - try { - const { itemName, total, return_url, rentalData } = req.body; - - if (!itemName) { - return res.status(400).json({ error: "No item name found" }); - } - if (total == null || total === undefined) { - return res.status(400).json({ error: "No total found" }); - } - if (!return_url) { - 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, - 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, - total: total, - return_url: return_url, - metadata: metadata, - }); - - res.json({ clientSecret: session.client_secret }); - } catch (error) { - console.error("Error creating checkout session:", error); - res.status(500).json({ error: error.message }); - } -}); - // Get checkout session status router.get("/checkout-session/:sessionId", async (req, res) => { try { @@ -75,6 +15,7 @@ router.get("/checkout-session/:sessionId", async (req, res) => { status: session.status, payment_status: session.payment_status, customer_email: session.customer_details?.email, + setup_intent: session.setup_intent, metadata: session.metadata, }); } catch (error) { @@ -179,4 +120,54 @@ router.get("/account-status", authenticateToken, async (req, res) => { } }); +// Create embedded setup checkout session for collecting payment method +router.post("/create-setup-checkout-session", authenticateToken, async (req, res) => { + try { + const { rentalData } = req.body; + + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + // Create or get Stripe customer + let stripeCustomerId = user.stripeCustomerId; + + if (!stripeCustomerId) { + // Create new Stripe customer + const customer = await StripeService.createCustomer({ + email: user.email, + name: `${user.firstName} ${user.lastName}`, + metadata: { + userId: user.id.toString() + } + }); + + stripeCustomerId = customer.id; + + // Save customer ID to user record + await user.update({ stripeCustomerId }); + } + + // Add rental data to metadata if provided + const metadata = rentalData ? { + rentalData: JSON.stringify(rentalData) + } : {}; + + const session = await StripeService.createSetupCheckoutSession({ + customerId: stripeCustomerId, + metadata + }); + + res.json({ + clientSecret: session.client_secret, + sessionId: session.id + }); + } catch (error) { + console.error("Error creating setup checkout session:", error); + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/services/payoutService.js b/backend/services/payoutService.js index ea057dc..88d7f8a 100644 --- a/backend/services/payoutService.js +++ b/backend/services/payoutService.js @@ -57,7 +57,7 @@ class PayoutService { metadata: { rentalId: rental.id, ownerId: rental.ownerId, - baseAmount: rental.baseRentalAmount.toString(), + totalAmount: rental.totalAmount.toString(), platformFee: rental.platformFee.toString(), startDateTime: rental.startDateTime.toISOString(), endDateTime: rental.endDateTime.toISOString(), diff --git a/backend/services/refundService.js b/backend/services/refundService.js new file mode 100644 index 0000000..af6b01b --- /dev/null +++ b/backend/services/refundService.js @@ -0,0 +1,229 @@ +const { Rental } = require("../models"); +const StripeService = require("./stripeService"); + +class RefundService { + /** + * Calculate refund amount based on policy and who cancelled + * @param {Object} rental - Rental instance + * @param {string} cancelledBy - 'renter' or 'owner' + * @returns {Object} - { refundAmount, refundPercentage, reason } + */ + static calculateRefundAmount(rental, cancelledBy) { + const totalAmount = rental.totalAmount; + let refundPercentage = 0; + let reason = ""; + + if (cancelledBy === "owner") { + // Owner cancellation = full refund + refundPercentage = 1.0; + reason = "Full refund - cancelled by owner"; + } else if (cancelledBy === "renter") { + // Calculate based on time until rental start + const now = new Date(); + const startDateTime = new Date(rental.startDateTime); + const hoursUntilStart = (startDateTime - now) / (1000 * 60 * 60); + + if (hoursUntilStart < 24) { + refundPercentage = 0.0; + reason = "No refund - cancelled within 24 hours of start time"; + } else if (hoursUntilStart < 48) { + refundPercentage = 0.5; + reason = "50% refund - cancelled between 24-48 hours of start time"; + } else { + refundPercentage = 1.0; + reason = "Full refund - cancelled more than 48 hours before start time"; + } + } + + const refundAmount = parseFloat((totalAmount * refundPercentage).toFixed(2)); + + return { + refundAmount, + refundPercentage, + reason, + }; + } + + /** + * Validate if a rental can be cancelled + * @param {Object} rental - Rental instance + * @param {string} userId - User ID attempting to cancel + * @returns {Object} - { canCancel, reason, cancelledBy } + */ + static validateCancellationEligibility(rental, userId) { + // Check if rental is already cancelled + if (rental.status === "cancelled") { + return { + canCancel: false, + reason: "Rental is already cancelled", + cancelledBy: null, + }; + } + + // Check if rental is completed + if (rental.status === "completed") { + return { + canCancel: false, + reason: "Cannot cancel completed rental", + cancelledBy: null, + }; + } + + // Check if rental is active + if (rental.status === "active") { + return { + canCancel: false, + reason: "Cannot cancel active rental", + cancelledBy: null, + }; + } + + // Check if user has permission to cancel + let cancelledBy = null; + if (rental.renterId === userId) { + cancelledBy = "renter"; + } else if (rental.ownerId === userId) { + cancelledBy = "owner"; + } else { + return { + canCancel: false, + reason: "You are not authorized to cancel this rental", + cancelledBy: null, + }; + } + + // Check payment status + if (rental.paymentStatus !== "paid") { + return { + canCancel: false, + reason: "Cannot cancel rental that hasn't been paid", + cancelledBy: null, + }; + } + + return { + canCancel: true, + reason: "Cancellation allowed", + cancelledBy, + }; + } + + /** + * Process the full cancellation and refund + * @param {string} rentalId - Rental ID + * @param {string} userId - User ID cancelling + * @param {string} cancellationReason - Optional reason provided by user + * @returns {Object} - Updated rental with refund information + */ + static async processCancellation(rentalId, userId, cancellationReason = null) { + const rental = await Rental.findByPk(rentalId); + + if (!rental) { + throw new Error("Rental not found"); + } + + // Validate cancellation eligibility + const eligibility = this.validateCancellationEligibility(rental, userId); + if (!eligibility.canCancel) { + throw new Error(eligibility.reason); + } + + // Calculate refund amount + const refundCalculation = this.calculateRefundAmount( + rental, + eligibility.cancelledBy + ); + + let stripeRefundId = null; + let refundProcessedAt = null; + + // Process refund with Stripe if amount > 0 and we have payment intent ID + if ( + refundCalculation.refundAmount > 0 && + rental.stripePaymentIntentId + ) { + try { + const refund = await StripeService.createRefund({ + paymentIntentId: rental.stripePaymentIntentId, + amount: refundCalculation.refundAmount, + metadata: { + rentalId: rental.id, + cancelledBy: eligibility.cancelledBy, + refundReason: refundCalculation.reason, + }, + }); + + stripeRefundId = refund.id; + refundProcessedAt = new Date(); + } catch (error) { + console.error("Error processing Stripe refund:", error); + throw new Error(`Failed to process refund: ${error.message}`); + } + } else if (refundCalculation.refundAmount > 0) { + // Log warning if we should refund but don't have payment intent + console.warn( + `Refund amount calculated but no payment intent ID for rental ${rentalId}` + ); + } + + // Update rental with cancellation and refund info + const updatedRental = await rental.update({ + status: "cancelled", + cancelledBy: eligibility.cancelledBy, + cancelledAt: new Date(), + refundAmount: refundCalculation.refundAmount, + refundProcessedAt, + refundReason: + cancellationReason || refundCalculation.reason, + stripeRefundId, + // Reset payout status since rental is cancelled + payoutStatus: "pending", + }); + + return { + rental: updatedRental, + refund: { + amount: refundCalculation.refundAmount, + percentage: refundCalculation.refundPercentage, + reason: refundCalculation.reason, + processed: !!refundProcessedAt, + stripeRefundId, + }, + }; + } + + /** + * Get refund preview without processing + * @param {string} rentalId - Rental ID + * @param {string} userId - User ID requesting preview + * @returns {Object} - Preview of refund calculation + */ + static async getRefundPreview(rentalId, userId) { + const rental = await Rental.findByPk(rentalId); + + if (!rental) { + throw new Error("Rental not found"); + } + + const eligibility = this.validateCancellationEligibility(rental, userId); + if (!eligibility.canCancel) { + throw new Error(eligibility.reason); + } + + const refundCalculation = this.calculateRefundAmount( + rental, + eligibility.cancelledBy + ); + + return { + canCancel: true, + cancelledBy: eligibility.cancelledBy, + refundAmount: refundCalculation.refundAmount, + refundPercentage: refundCalculation.refundPercentage, + reason: refundCalculation.reason, + totalAmount: rental.totalAmount, + }; + } +} + +module.exports = RefundService; \ No newline at end of file diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js index 2892ad0..ce46b6a 100644 --- a/backend/services/stripeService.js +++ b/backend/services/stripeService.js @@ -1,44 +1,12 @@ const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); class StripeService { - static async createCheckoutSession({ - item_name, - total, - return_url, - metadata = {}, - }) { - try { - const sessionConfig = { - line_items: [ - { - price_data: { - currency: "usd", - product_data: { - name: item_name, - }, - unit_amount: total * 100, - }, - quantity: 1, - }, - ], - mode: "payment", - ui_mode: "embedded", - return_url: return_url, - metadata: metadata, - }; - - const session = await stripe.checkout.sessions.create(sessionConfig); - - return session; - } catch (error) { - console.error("Error creating checkout session:", error); - throw error; - } - } static async getCheckoutSession(sessionId) { try { - return await stripe.checkout.sessions.retrieve(sessionId); + return await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['setup_intent', 'setup_intent.payment_method'] + }); } catch (error) { console.error("Error retrieving checkout session:", error); throw error; @@ -115,6 +83,97 @@ class StripeService { throw error; } } + + static async createRefund({ + paymentIntentId, + amount, + metadata = {}, + reason = "requested_by_customer", + }) { + try { + const refund = await stripe.refunds.create({ + payment_intent: paymentIntentId, + amount: Math.round(amount * 100), // Convert to cents + metadata, + reason, + }); + + return refund; + } catch (error) { + console.error("Error creating refund:", error); + throw error; + } + } + + static async getRefund(refundId) { + try { + return await stripe.refunds.retrieve(refundId); + } catch (error) { + console.error("Error retrieving refund:", error); + throw error; + } + } + + static async chargePaymentMethod(paymentMethodId, amount, customerId, metadata = {}) { + try { + // Create a payment intent with the stored payment method + const paymentIntent = await stripe.paymentIntents.create({ + amount: Math.round(amount * 100), // Convert to cents + currency: "usd", + payment_method: paymentMethodId, + customer: customerId, // Include customer ID + confirm: true, // Automatically confirm the payment + return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/payment-complete`, + metadata, + }); + + return { + paymentIntentId: paymentIntent.id, + status: paymentIntent.status, + clientSecret: paymentIntent.client_secret, + }; + } catch (error) { + console.error("Error charging payment method:", error); + throw error; + } + } + + static async createCustomer({ email, name, metadata = {} }) { + try { + const customer = await stripe.customers.create({ + email, + name, + metadata, + }); + + return customer; + } catch (error) { + console.error("Error creating customer:", error); + throw error; + } + } + + + static async createSetupCheckoutSession({ customerId, metadata = {} }) { + try { + const session = await stripe.checkout.sessions.create({ + customer: customerId, + payment_method_types: ['card', 'us_bank_account', 'link'], + mode: 'setup', + ui_mode: 'embedded', + redirect_on_completion: 'never', + metadata: { + type: 'payment_method_setup', + ...metadata + } + }); + + return session; + } catch (error) { + console.error("Error creating setup checkout session:", error); + throw error; + } + } } module.exports = StripeService; diff --git a/backend/utils/feeCalculator.js b/backend/utils/feeCalculator.js index 9c0e992..b638585 100644 --- a/backend/utils/feeCalculator.js +++ b/backend/utils/feeCalculator.js @@ -1,26 +1,21 @@ class FeeCalculator { - static calculateRentalFees(baseAmount) { + static calculateRentalFees(totalAmount) { const platformFeeRate = 0.2; - const stripeRate = 0.029; - const stripeFixedFee = 0.3; - const platformFee = baseAmount * platformFeeRate; - const processingFee = baseAmount * stripeRate + stripeFixedFee; + const platformFee = totalAmount * platformFeeRate; return { - baseRentalAmount: parseFloat(baseAmount.toFixed(2)), + totalAmount: parseFloat(totalAmount.toFixed(2)), platformFee: parseFloat(platformFee.toFixed(2)), - processingFee: parseFloat(processingFee.toFixed(2)), - totalChargedAmount: parseFloat((baseAmount + processingFee).toFixed(2)), - payoutAmount: parseFloat((baseAmount - platformFee).toFixed(2)), + totalChargedAmount: parseFloat(totalAmount.toFixed(2)), + payoutAmount: parseFloat((totalAmount - platformFee).toFixed(2)), }; } static formatFeesForDisplay(fees) { return { - baseRental: `$${fees.baseRentalAmount.toFixed(2)}`, + totalAmount: `$${fees.totalAmount.toFixed(2)}`, platformFee: `$${fees.platformFee.toFixed(2)} (20%)`, - processingFee: `$${fees.processingFee.toFixed(2)} (2.9% + $0.30)`, totalCharge: `$${fees.totalChargedAmount.toFixed(2)}`, ownerPayout: `$${fees.payoutAmount.toFixed(2)}`, }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d354f4..400051e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,7 +23,6 @@ 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'; @@ -131,14 +130,6 @@ function App() { } - /> - - - - } /> diff --git a/frontend/src/components/CheckoutReturn.tsx b/frontend/src/components/CheckoutReturn.tsx deleted file mode 100644 index 5f49b63..0000000 --- a/frontend/src/components/CheckoutReturn.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import React, { useState, useEffect, useRef } from "react"; -import { useSearchParams, useNavigate } from "react-router-dom"; -import { stripeAPI, rentalAPI } from "../services/api"; - -const CheckoutReturn: React.FC = () => { - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const [status, setStatus] = useState< - "loading" | "success" | "error" | "failed" | "rental_error" - >("loading"); - const [error, setError] = useState(null); - const [processing, setProcessing] = useState(false); - const [itemId, setItemId] = useState(null); - const [sessionMetadata, setSessionMetadata] = useState(null); - const hasProcessed = useRef(false); - - useEffect(() => { - if (hasProcessed.current) return; - - const sessionId = searchParams.get("session_id"); - - if (!sessionId) { - setStatus("error"); - setError("No session ID found in URL"); - return; - } - - hasProcessed.current = true; - checkSessionStatus(sessionId); - }, [searchParams]); - - const createRental = async (metadata: any) => { - try { - if (!metadata || !metadata.itemId) { - throw new Error("No rental data found in Stripe metadata"); - } - - // Convert metadata back to proper types - const rentalData = { - itemId: metadata.itemId, - 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); - - return response; - } catch (error: any) { - const errorMessage = - error.response?.data?.message || - error.message || - "Failed to create rental"; - console.error("Rental creation error:", errorMessage); - throw new Error(errorMessage); - } - }; - - const checkSessionStatus = async (sessionId: string) => { - try { - setProcessing(true); - - // Get checkout session status - const response = await stripeAPI.getCheckoutSession(sessionId); - - const { status: sessionStatus, payment_status, metadata } = response.data; - - // Store metadata for retry functionality - setSessionMetadata(metadata); - if (metadata?.itemId) { - setItemId(metadata.itemId); - } - - if (sessionStatus === "complete" && payment_status === "paid") { - // Payment was successful - now create the rental - try { - const rentalResult = await createRental(metadata); - setStatus("success"); - } catch (rentalError: any) { - // Payment succeeded but rental creation failed - setStatus("rental_error"); - setError(rentalError.message || "Failed to create rental record"); - } - } else if (sessionStatus === "open") { - // Payment was not completed - setStatus("failed"); - setError("Payment was not completed. Please try again."); - } else { - setStatus("error"); - setError("Payment failed or was cancelled."); - } - } catch (error: any) { - setStatus("error"); - setError( - error.response?.data?.error || "Failed to verify payment status" - ); - } finally { - setProcessing(false); - } - }; - - const handleRetry = () => { - // Go back to the item page to try payment again - if (itemId) { - navigate(`/items/${itemId}`); - } else { - navigate("/"); - } - }; - - const handleRetryRentalCreation = async () => { - setProcessing(true); - try { - await createRental(sessionMetadata); - setStatus("success"); - setError(null); - } catch (error: any) { - setError(error.message || "Failed to create rental record"); - } finally { - setProcessing(false); - } - }; - - const handleGoToRentals = () => { - navigate("/my-rentals"); - }; - - if (status === "loading" || processing) { - return ( -
-
-
-
- Loading... -
-

Processing your payment...

-

- Please wait while we confirm your payment and set up your rental. -

-
-
-
- ); - } - - if (status === "success") { - return ( -
-
-
-
- -

Payment Successful!

-

- Your rental has been confirmed. You can view the details in your - rentals page. -

-
- - -
-
-
-
-
- ); - } - - if (status === "rental_error") { - return ( -
-
-
-
- -

Payment Successful - Rental Setup Issue

-

- Your payment was processed successfully, but we encountered an - issue creating your rental record: -
- {error} -

-
- - -
-
-
-
-
- ); - } - - if (status === "failed" || status === "error") { - return ( -
-
-
-
- -

- {status === "failed" ? "Payment Incomplete" : "Payment Error"} -

-

- {error || "There was an issue processing your payment."} -

-
- - -
-
-
-
-
- ); - } - - return null; -}; - -export default CheckoutReturn; diff --git a/frontend/src/components/EmbeddedStripeCheckout.tsx b/frontend/src/components/EmbeddedStripeCheckout.tsx new file mode 100644 index 0000000..a8b0756 --- /dev/null +++ b/frontend/src/components/EmbeddedStripeCheckout.tsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { loadStripe } from "@stripe/stripe-js"; +import { + EmbeddedCheckoutProvider, + EmbeddedCheckout, +} from "@stripe/react-stripe-js"; +import { stripeAPI, rentalAPI } from "../services/api"; + +const stripePromise = loadStripe( + process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || "" +); + +interface EmbeddedStripeCheckoutProps { + rentalData: any; + onSuccess: () => void; + onError: (error: string) => void; +} + +const EmbeddedStripeCheckout: React.FC = ({ + rentalData, + onSuccess, + onError, +}) => { + const [clientSecret, setClientSecret] = useState(""); + const [creating, setCreating] = useState(false); + const [sessionId, setSessionId] = useState(""); + + const createCheckoutSession = useCallback(async () => { + try { + setCreating(true); + + const response = await stripeAPI.createSetupCheckoutSession({ + rentalData + }); + + setClientSecret(response.data.clientSecret); + setSessionId(response.data.sessionId); + } catch (error: any) { + onError( + error.response?.data?.error || "Failed to create checkout session" + ); + } finally { + setCreating(false); + } + }, [rentalData, onError]); + + useEffect(() => { + createCheckoutSession(); + }, [createCheckoutSession]); + + const handleComplete = useCallback(() => { + // For embedded checkout, we need to retrieve the session to get payment method + (async () => { + try { + if (!sessionId) { + throw new Error("No session ID available"); + } + + // Get the completed checkout session + const sessionResponse = await stripeAPI.getCheckoutSession(sessionId); + const { status: sessionStatus, setup_intent } = sessionResponse.data; + + if (sessionStatus !== "complete") { + throw new Error("Payment setup was not completed"); + } + + if (!setup_intent?.payment_method) { + throw new Error("No payment method found in setup intent"); + } + + // Extract payment method ID - handle both string ID and object cases + const paymentMethodId = typeof setup_intent.payment_method === 'string' + ? setup_intent.payment_method + : setup_intent.payment_method.id; + + if (!paymentMethodId) { + throw new Error("No payment method ID found"); + } + + // Create the rental with the payment method ID + const rentalPayload = { + ...rentalData, + stripePaymentMethodId: paymentMethodId + }; + + await rentalAPI.createRental(rentalPayload); + onSuccess(); + } catch (error: any) { + onError(error.response?.data?.error || error.message || "Failed to complete rental request"); + } + })(); + }, [sessionId, rentalData, onSuccess, onError]); + + if (creating) { + return ( +
+
+ Loading... +
+

Preparing secure checkout...

+
+ ); + } + + if (!clientSecret) { + return ( +
+

Unable to load checkout

+
+ ); + } + + return ( +
+ + + +
+ ); +}; + +export default EmbeddedStripeCheckout; \ No newline at end of file diff --git a/frontend/src/components/RentalCancellationModal.tsx b/frontend/src/components/RentalCancellationModal.tsx new file mode 100644 index 0000000..9c32abc --- /dev/null +++ b/frontend/src/components/RentalCancellationModal.tsx @@ -0,0 +1,252 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { rentalAPI } from "../services/api"; +import { RefundPreview, Rental } from "../types"; + +interface RentalCancellationModalProps { + show: boolean; + onHide: () => void; + rental: Rental; + onCancellationComplete: (updatedRental: Rental) => void; +} + +const RentalCancellationModal: React.FC = ({ + show, + onHide, + rental, + onCancellationComplete, +}) => { + const [refundPreview, setRefundPreview] = useState( + null + ); + const [loading, setLoading] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const [reason, setReason] = useState(""); + + useEffect(() => { + if (show && rental) { + loadRefundPreview(); + } + }, [show, rental]); + + const loadRefundPreview = async () => { + try { + setLoading(true); + setError(null); + const response = await rentalAPI.getRefundPreview(rental.id); + setRefundPreview(response.data); + } catch (error: any) { + setError( + error.response?.data?.error || "Failed to calculate refund preview" + ); + } finally { + setLoading(false); + } + }; + + const handleCancel = async () => { + if (!refundPreview) return; + + try { + setProcessing(true); + setError(null); + + const response = await rentalAPI.cancelRental(rental.id, reason.trim()); + onCancellationComplete(response.data.rental); + onHide(); + } catch (error: any) { + setError(error.response?.data?.error || "Failed to cancel rental"); + } finally { + setProcessing(false); + } + }; + + const formatCurrency = (amount: number | string | undefined) => { + const numAmount = Number(amount) || 0; + return `$${numAmount.toFixed(2)}`; + }; + + const getRefundColor = (percentage: number) => { + if (percentage === 0) return "danger"; + if (percentage === 0.5) return "warning"; + return "success"; + }; + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onHide(); + } + }, + [onHide] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + onHide(); + } + }, + [onHide] + ); + + useEffect(() => { + if (show) { + document.addEventListener("keydown", handleKeyDown); + document.body.style.overflow = "hidden"; + } else { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = "unset"; + } + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = "unset"; + }; + }, [show, handleKeyDown]); + + if (!show) return null; + + return ( +
+
+
+
+
Cancel Rental
+ +
+
+ {loading && ( +
+
+ Loading... +
+ Calculating refund... +
+ )} + + {error && ( +
+ {error} +
+ )} + + {refundPreview && !loading && ( + <> +
+
Rental Details
+
+

+ Item: {rental.item?.name} +

+

+ Start:{" "} + {new Date(rental.startDateTime).toLocaleString()} +

+

+ End:{" "} + {new Date(rental.endDateTime).toLocaleString()} +

+

+ Total Amount:{" "} + {formatCurrency(rental.totalAmount)} +

+
+
+ +
+
Refund Information
+
+
+
+ Refund Amount:{" "} + {formatCurrency(refundPreview.refundAmount)} +
+
+ + {Math.round(refundPreview.refundPercentage * 100)}% + +
+
+
+ {refundPreview.reason} +
+
+ +
+
+ +