diff --git a/backend/models/Rental.js b/backend/models/Rental.js index a0000a3..e848377 100644 --- a/backend/models/Rental.js +++ b/backend/models/Rental.js @@ -62,7 +62,7 @@ const Rental = sequelize.define("Rental", { defaultValue: "pending", }, paymentStatus: { - type: DataTypes.ENUM("pending", "paid", "refunded"), + type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"), defaultValue: "pending", }, payoutStatus: { diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 5a5815e..0a5aaf1 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -233,12 +233,12 @@ router.post("/", authenticateToken, async (req, res) => { // Calculate fees using FeeCalculator const fees = FeeCalculator.calculateRentalFees(totalAmount); - // Validate that payment method was provided - if (!stripePaymentMethodId) { - return res.status(400).json({ error: "Payment method is required" }); + // 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 rental = await Rental.create({ + const rentalData = { itemId, renterId: req.user.id, ownerId: item.ownerId, @@ -247,13 +247,19 @@ router.post("/", authenticateToken, async (req, res) => { totalAmount: fees.totalChargedAmount, platformFee: fees.platformFee, payoutAmount: fees.payoutAmount, - paymentStatus: "pending", + paymentStatus: totalAmount > 0 ? "pending" : "not_required", status: "pending", deliveryMethod, deliveryAddress, notes, - stripePaymentMethodId, - }); + }; + + // 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: [ @@ -310,17 +316,19 @@ router.put("/:id/status", authenticateToken, async (req, res) => { return res.status(403).json({ error: "Unauthorized to update this rental" }); } - // If owner is approving a pending rental, charge the stored payment method + // If owner is approving a pending rental, handle payment for paid rentals 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" }); - } + // 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 @@ -385,6 +393,31 @@ router.put("/:id/status", authenticateToken, async (req, res) => { 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 }); diff --git a/backend/services/refundService.js b/backend/services/refundService.js index af6b01b..b9d4a33 100644 --- a/backend/services/refundService.js +++ b/backend/services/refundService.js @@ -92,8 +92,8 @@ class RefundService { }; } - // Check payment status - if (rental.paymentStatus !== "paid") { + // Check payment status - allow cancellation for both paid and free rentals + if (rental.paymentStatus !== "paid" && rental.paymentStatus !== "not_required") { return { canCancel: false, reason: "Cannot cancel rental that hasn't been paid", diff --git a/backend/tests/unit/routes/rentals.test.js b/backend/tests/unit/routes/rentals.test.js index 49949b0..2ee5546 100644 --- a/backend/tests/unit/routes/rentals.test.js +++ b/backend/tests/unit/routes/rentals.test.js @@ -323,7 +323,7 @@ describe('Rentals Routes', () => { expect(response.body).toEqual({ error: 'Item is already booked for these dates' }); }); - it('should return 400 when payment method is missing', async () => { + it('should return 400 when payment method is missing for paid rentals', async () => { const dataWithoutPayment = { ...rentalData }; delete dataWithoutPayment.stripePaymentMethodId; @@ -332,7 +332,49 @@ describe('Rentals Routes', () => { .send(dataWithoutPayment); expect(response.status).toBe(400); - expect(response.body).toEqual({ error: 'Payment method is required' }); + expect(response.body).toEqual({ error: 'Payment method is required for paid rentals' }); + }); + + it('should create a free rental without payment method', async () => { + // Set up a free item (both prices are 0) + Item.findByPk.mockResolvedValue({ + id: 1, + ownerId: 2, + availability: true, + pricePerHour: 0, + pricePerDay: 0 + }); + + const freeRentalData = { ...rentalData }; + delete freeRentalData.stripePaymentMethodId; + + const createdRental = { + id: 1, + ...freeRentalData, + renterId: 1, + ownerId: 2, + totalAmount: 0, + platformFee: 0, + payoutAmount: 0, + paymentStatus: 'not_required', + status: 'pending' + }; + + Rental.create.mockResolvedValue(createdRental); + Rental.findByPk.mockResolvedValue({ + ...createdRental, + item: { id: 1, name: 'Free Item' }, + owner: { id: 2, username: 'owner1', firstName: 'John', lastName: 'Doe' }, + renter: { id: 1, username: 'renter1', firstName: 'Jane', lastName: 'Smith' } + }); + + const response = await request(app) + .post('/rentals') + .send(freeRentalData); + + expect(response.status).toBe(201); + expect(response.body.paymentStatus).toBe('not_required'); + expect(response.body.totalAmount).toBe(0); }); it('should handle database errors during creation', async () => { @@ -422,6 +464,34 @@ describe('Rentals Routes', () => { }); }); + it('should approve free rental without payment processing', async () => { + const freeRental = { + ...mockRental, + totalAmount: 0, + paymentStatus: 'not_required', + stripePaymentMethodId: null, + update: jest.fn().mockResolvedValue(true) + }; + + mockRentalFindByPk.mockResolvedValueOnce(freeRental); + + const updatedFreeRental = { + ...freeRental, + status: 'confirmed' + }; + mockRentalFindByPk.mockResolvedValueOnce(updatedFreeRental); + + const response = await request(app) + .put('/rentals/1/status') + .send({ status: 'confirmed' }); + + expect(response.status).toBe(200); + expect(StripeService.chargePaymentMethod).not.toHaveBeenCalled(); + expect(freeRental.update).toHaveBeenCalledWith({ + status: 'confirmed' + }); + }); + it('should return 400 when renter has no Stripe customer ID', async () => { const rentalWithoutStripeCustomer = { ...mockRental, diff --git a/backend/tests/unit/services/refundService.test.js b/backend/tests/unit/services/refundService.test.js index 4dd0627..c985936 100644 --- a/backend/tests/unit/services/refundService.test.js +++ b/backend/tests/unit/services/refundService.test.js @@ -295,6 +295,17 @@ describe('RefundService', () => { cancelledBy: null }); }); + + it('should allow cancellation for free rental with not_required payment status', () => { + const rental = { ...baseRental, paymentStatus: 'not_required' }; + const result = RefundService.validateCancellationEligibility(rental, 100); + + expect(result).toEqual({ + canCancel: true, + reason: 'Cancellation allowed', + cancelledBy: 'renter' + }); + }); }); describe('Edge cases', () => { diff --git a/frontend/src/components/RentalCancellationModal.tsx b/frontend/src/components/RentalCancellationModal.tsx index 7a58f99..57d7f69 100644 --- a/frontend/src/components/RentalCancellationModal.tsx +++ b/frontend/src/components/RentalCancellationModal.tsx @@ -152,7 +152,10 @@ const RentalCancellationModal: React.FC = ({
- {success ? "Refund Confirmation" : "Cancel Rental"} + {success + ? (rental.totalAmount > 0 ? "Refund Confirmation" : "Cancellation Confirmation") + : "Cancel Rental" + }
-
-
Refund Information
-
-
-
- Refund Amount:{" "} - {formatCurrency(refundPreview.refundAmount)} -
-
- - {Math.round(refundPreview.refundPercentage * 100)}% - + {rental.totalAmount > 0 && ( +
+
Refund Information
+
+
+
+ Refund Amount:{" "} + {formatCurrency(refundPreview.refundAmount)} +
+
+ + {Math.round(refundPreview.refundPercentage * 100)}% + +
+
+ {refundPreview.reason}
-
- {refundPreview.reason}
-
+ )}
@@ -310,11 +319,13 @@ const RentalCancellationModal: React.FC = ({ Processing... ) : ( - `Cancel with ${ - refundPreview.refundAmount > 0 - ? `Refund ${formatCurrency(refundPreview.refundAmount)}` - : "No Refund" - }` + rental.totalAmount > 0 + ? `Cancel with ${ + refundPreview.refundAmount > 0 + ? `Refund ${formatCurrency(refundPreview.refundAmount)}` + : "No Refund" + }` + : "Cancel Rental" )} )} diff --git a/frontend/src/pages/RentItem.tsx b/frontend/src/pages/RentItem.tsx index b91c0fa..b968923 100644 --- a/frontend/src/pages/RentItem.tsx +++ b/frontend/src/pages/RentItem.tsx @@ -143,6 +143,21 @@ const RentItem: React.FC = () => { } }; + const handleFreeBorrow = async () => { + const rentalData = getRentalData(); + if (!rentalData) return; + + try { + setError(null); + await rentalAPI.createRental(rentalData); + setCompleted(true); + } catch (error: any) { + setError( + error.response?.data?.error || "Failed to create rental request" + ); + } + }; + const handleChange = ( e: React.ChangeEvent< HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement @@ -202,7 +217,7 @@ const RentItem: React.FC = () => {

Rental Request Sent!

- Your rental request has been submitted to the owner. + Your rental request has been submitted to the owner. You'll only be charged if they approve your request.

@@ -225,17 +240,54 @@ const RentItem: React.FC = () => { ) : (
-
Complete Your Rental Request
-

- Add your payment method to complete your rental request. - You'll only be charged if the owner approves your request. -

+
+ {totalCost === 0 + ? "Complete Your Borrow Request" + : "Complete Your Rental Request"} +
+ {totalCost > 0 && ( +

+ Add your payment method to complete your rental request. + You'll only be charged if the owner approves your + request. +

+ )} - {!manualSelection.startDate || !manualSelection.endDate || !getRentalData() ? ( + {!manualSelection.startDate || + !manualSelection.endDate || + !getRentalData() ? (
- Please complete the rental dates and details above to proceed with payment setup. + Please complete the rental dates and details above to + proceed with{" "} + {totalCost === 0 + ? "your borrow request" + : "payment setup"} + .
+ ) : totalCost === 0 ? ( + <> +
+ + This item is free to borrow! No payment required +
+
+ + +
+ ) : ( <> { onSuccess={() => setCompleted(true)} onError={(error) => setError(error)} /> - +