Skip payment process if item is free to borrow

This commit is contained in:
jackiettran
2025-09-22 22:02:08 -04:00
parent 3e76769a3e
commit 67cc997ddc
7 changed files with 245 additions and 68 deletions

View File

@@ -62,7 +62,7 @@ const Rental = sequelize.define("Rental", {
defaultValue: "pending", defaultValue: "pending",
}, },
paymentStatus: { paymentStatus: {
type: DataTypes.ENUM("pending", "paid", "refunded"), type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"),
defaultValue: "pending", defaultValue: "pending",
}, },
payoutStatus: { payoutStatus: {

View File

@@ -233,12 +233,12 @@ router.post("/", authenticateToken, async (req, res) => {
// Calculate fees using FeeCalculator // Calculate fees using FeeCalculator
const fees = FeeCalculator.calculateRentalFees(totalAmount); const fees = FeeCalculator.calculateRentalFees(totalAmount);
// Validate that payment method was provided // Validate that payment method was provided for paid rentals
if (!stripePaymentMethodId) { if (totalAmount > 0 && !stripePaymentMethodId) {
return res.status(400).json({ error: "Payment method is required" }); return res.status(400).json({ error: "Payment method is required for paid rentals" });
} }
const rental = await Rental.create({ const rentalData = {
itemId, itemId,
renterId: req.user.id, renterId: req.user.id,
ownerId: item.ownerId, ownerId: item.ownerId,
@@ -247,13 +247,19 @@ router.post("/", authenticateToken, async (req, res) => {
totalAmount: fees.totalChargedAmount, totalAmount: fees.totalChargedAmount,
platformFee: fees.platformFee, platformFee: fees.platformFee,
payoutAmount: fees.payoutAmount, payoutAmount: fees.payoutAmount,
paymentStatus: "pending", paymentStatus: totalAmount > 0 ? "pending" : "not_required",
status: "pending", status: "pending",
deliveryMethod, deliveryMethod,
deliveryAddress, deliveryAddress,
notes, 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, { const rentalWithDetails = await Rental.findByPk(rental.id, {
include: [ include: [
@@ -310,12 +316,14 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
return res.status(403).json({ error: "Unauthorized to update this rental" }); 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 ( if (
status === "confirmed" && status === "confirmed" &&
rental.status === "pending" && rental.status === "pending" &&
rental.ownerId === req.user.id rental.ownerId === req.user.id
) { ) {
// Skip payment processing for free rentals
if (rental.totalAmount > 0) {
if (!rental.stripePaymentMethodId) { if (!rental.stripePaymentMethodId) {
return res return res
.status(400) .status(400)
@@ -385,6 +393,31 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
details: paymentError.message, 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 }); await rental.update({ status });

View File

@@ -92,8 +92,8 @@ class RefundService {
}; };
} }
// Check payment status // Check payment status - allow cancellation for both paid and free rentals
if (rental.paymentStatus !== "paid") { if (rental.paymentStatus !== "paid" && rental.paymentStatus !== "not_required") {
return { return {
canCancel: false, canCancel: false,
reason: "Cannot cancel rental that hasn't been paid", reason: "Cannot cancel rental that hasn't been paid",

View File

@@ -323,7 +323,7 @@ describe('Rentals Routes', () => {
expect(response.body).toEqual({ error: 'Item is already booked for these dates' }); 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 }; const dataWithoutPayment = { ...rentalData };
delete dataWithoutPayment.stripePaymentMethodId; delete dataWithoutPayment.stripePaymentMethodId;
@@ -332,7 +332,49 @@ describe('Rentals Routes', () => {
.send(dataWithoutPayment); .send(dataWithoutPayment);
expect(response.status).toBe(400); 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 () => { 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 () => { it('should return 400 when renter has no Stripe customer ID', async () => {
const rentalWithoutStripeCustomer = { const rentalWithoutStripeCustomer = {
...mockRental, ...mockRental,

View File

@@ -295,6 +295,17 @@ describe('RefundService', () => {
cancelledBy: null 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', () => { describe('Edge cases', () => {

View File

@@ -152,7 +152,10 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
<div className="modal-content"> <div className="modal-content">
<div className="modal-header"> <div className="modal-header">
<h5 className="modal-title"> <h5 className="modal-title">
{success ? "Refund Confirmation" : "Cancel Rental"} {success
? (rental.totalAmount > 0 ? "Refund Confirmation" : "Cancellation Confirmation")
: "Cancel Rental"
}
</h5> </h5>
<button <button
type="button" type="button"
@@ -167,7 +170,10 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
<div className="mb-4"> <div className="mb-4">
<i className="bi bi-check-circle-fill text-success" style={{ fontSize: '4rem' }}></i> <i className="bi bi-check-circle-fill text-success" style={{ fontSize: '4rem' }}></i>
</div> </div>
<h3 className="text-success mb-3">Refund Processed Successfully!</h3> <h3 className="text-success mb-3">
{rental.totalAmount > 0 ? 'Refund Processed Successfully!' : 'Rental Cancelled Successfully!'}
</h3>
{rental.totalAmount > 0 && (
<div className="alert alert-success mb-4"> <div className="alert alert-success mb-4">
<h5 className="mb-3"> <h5 className="mb-3">
<strong>{formatCurrency(processedRefund.amount)}</strong> has been refunded <strong>{formatCurrency(processedRefund.amount)}</strong> has been refunded
@@ -183,6 +189,7 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
</p> </p>
</div> </div>
</div> </div>
)}
<p className="text-muted mb-4"> <p className="text-muted mb-4">
Thank you for using our platform. We hope you'll rent with us again soon! Thank you for using our platform. We hope you'll rent with us again soon!
</p> </p>
@@ -227,6 +234,7 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
</div> </div>
</div> </div>
{rental.totalAmount > 0 && (
<div className="mb-4"> <div className="mb-4">
<h5>Refund Information</h5> <h5>Refund Information</h5>
<div <div
@@ -249,6 +257,7 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
<small>{refundPreview.reason}</small> <small>{refundPreview.reason}</small>
</div> </div>
</div> </div>
)}
<form> <form>
<div className="mb-3"> <div className="mb-3">
@@ -310,11 +319,13 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
Processing... Processing...
</> </>
) : ( ) : (
`Cancel with ${ rental.totalAmount > 0
? `Cancel with ${
refundPreview.refundAmount > 0 refundPreview.refundAmount > 0
? `Refund ${formatCurrency(refundPreview.refundAmount)}` ? `Refund ${formatCurrency(refundPreview.refundAmount)}`
: "No Refund" : "No Refund"
}` }`
: "Cancel Rental"
)} )}
</button> </button>
)} )}

View File

@@ -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 = ( const handleChange = (
e: React.ChangeEvent< e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
@@ -225,17 +240,54 @@ const RentItem: React.FC = () => {
) : ( ) : (
<div className="card mb-4"> <div className="card mb-4">
<div className="card-body"> <div className="card-body">
<h5 className="card-title">Complete Your Rental Request</h5> <h5 className="card-title">
{totalCost === 0
? "Complete Your Borrow Request"
: "Complete Your Rental Request"}
</h5>
{totalCost > 0 && (
<p className="text-muted small mb-3"> <p className="text-muted small mb-3">
Add your payment method to complete your rental request. Add your payment method to complete your rental request.
You'll only be charged if the owner approves your request. You'll only be charged if the owner approves your
request.
</p> </p>
)}
{!manualSelection.startDate || !manualSelection.endDate || !getRentalData() ? ( {!manualSelection.startDate ||
!manualSelection.endDate ||
!getRentalData() ? (
<div className="alert alert-info"> <div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i> <i className="bi bi-info-circle me-2"></i>
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"}
.
</div> </div>
) : totalCost === 0 ? (
<>
<div className="alert alert-success">
<i className="bi bi-check-circle me-2"></i>
This item is free to borrow! No payment required
</div>
<div className="d-grid gap-2">
<button
type="button"
className="btn btn-primary"
onClick={handleFreeBorrow}
>
Confirm Borrow Request
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => navigate(`/items/${id}`)}
>
Cancel Request
</button>
</div>
</>
) : ( ) : (
<> <>
<EmbeddedStripeCheckout <EmbeddedStripeCheckout
@@ -280,7 +332,7 @@ const RentItem: React.FC = () => {
<p className="text-muted small"> <p className="text-muted small">
{item.city && item.state {item.city && item.state
? `${item.city}, ${item.state}` ? `${item.city}, ${item.state}`
: ''} : ""}
</p> </p>
<hr /> <hr />