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,17 +316,19 @@ 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
) { ) {
if (!rental.stripePaymentMethodId) { // Skip payment processing for free rentals
return res if (rental.totalAmount > 0) {
.status(400) if (!rental.stripePaymentMethodId) {
.json({ error: "No payment method found for this rental" }); return res
} .status(400)
.json({ error: "No payment method found for this rental" });
}
try { try {
// Import StripeService to process the payment // Import StripeService to process the payment
@@ -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,22 +170,26 @@ 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">
<div className="alert alert-success mb-4"> {rental.totalAmount > 0 ? 'Refund Processed Successfully!' : 'Rental Cancelled Successfully!'}
<h5 className="mb-3"> </h3>
<strong>{formatCurrency(processedRefund.amount)}</strong> has been refunded {rental.totalAmount > 0 && (
</h5> <div className="alert alert-success mb-4">
<div className="small text-muted"> <h5 className="mb-3">
<p className="mb-2"> <strong>{formatCurrency(processedRefund.amount)}</strong> has been refunded
<i className="bi bi-clock me-2"></i> </h5>
Your refund will appear in your payment method within <strong>3-5 business days</strong> <div className="small text-muted">
</p> <p className="mb-2">
<p className="mb-0"> <i className="bi bi-clock me-2"></i>
<i className="bi bi-credit-card me-2"></i> Your refund will appear in your payment method within <strong>3-5 business days</strong>
Refund will be processed to your original payment method </p>
</p> <p className="mb-0">
<i className="bi bi-credit-card me-2"></i>
Refund will be processed to your original payment method
</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,28 +234,30 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
</div> </div>
</div> </div>
<div className="mb-4"> {rental.totalAmount > 0 && (
<h5>Refund Information</h5> <div className="mb-4">
<div <h5>Refund Information</h5>
className={`alert alert-${getRefundColor( <div
refundPreview.refundPercentage className={`alert alert-${getRefundColor(
)}`} refundPreview.refundPercentage
> )}`}
<div className="d-flex justify-content-between align-items-center"> >
<div> <div className="d-flex justify-content-between align-items-center">
<strong>Refund Amount:</strong>{" "} <div>
{formatCurrency(refundPreview.refundAmount)} <strong>Refund Amount:</strong>{" "}
</div> {formatCurrency(refundPreview.refundAmount)}
<div> </div>
<strong> <div>
{Math.round(refundPreview.refundPercentage * 100)}% <strong>
</strong> {Math.round(refundPreview.refundPercentage * 100)}%
</strong>
</div>
</div> </div>
<hr />
<small>{refundPreview.reason}</small>
</div> </div>
<hr />
<small>{refundPreview.reason}</small>
</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
refundPreview.refundAmount > 0 ? `Cancel with ${
? `Refund ${formatCurrency(refundPreview.refundAmount)}` refundPreview.refundAmount > 0
: "No Refund" ? `Refund ${formatCurrency(refundPreview.refundAmount)}`
}` : "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">
<p className="text-muted small mb-3"> {totalCost === 0
Add your payment method to complete your rental request. ? "Complete Your Borrow Request"
You'll only be charged if the owner approves your request. : "Complete Your Rental Request"}
</p> </h5>
{totalCost > 0 && (
<p className="text-muted small mb-3">
Add your payment method to complete your rental request.
You'll only be charged if the owner approves your
request.
</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 />