Skip payment process if item is free to borrow
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user