diff --git a/backend/services/refundService.js b/backend/services/refundService.js index 49a86f3..1198544 100644 --- a/backend/services/refundService.js +++ b/backend/services/refundService.js @@ -93,8 +93,12 @@ class RefundService { }; } - // Check payment status - allow cancellation for both paid and free rentals - if (rental.paymentStatus !== "paid" && rental.paymentStatus !== "not_required") { + // Allow cancellation for pending rentals (before owner approval) or paid/free rentals + const isPendingRequest = rental.status === "pending"; + const isPaymentSettled = + rental.paymentStatus === "paid" || rental.paymentStatus === "not_required"; + + if (!isPendingRequest && !isPaymentSettled) { return { canCancel: false, reason: "Cannot cancel rental that hasn't been paid", diff --git a/backend/tests/unit/services/refundService.test.js b/backend/tests/unit/services/refundService.test.js index d0a7205..8bd7e4a 100644 --- a/backend/tests/unit/services/refundService.test.js +++ b/backend/tests/unit/services/refundService.test.js @@ -277,8 +277,9 @@ describe('RefundService', () => { }); describe('Payment status validation', () => { - it('should reject cancellation for unpaid rental', () => { - const rental = { ...baseRental, paymentStatus: 'pending' }; + it('should reject cancellation for confirmed rental with unpaid status', () => { + // Confirmed rentals require payment to be settled + const rental = { ...baseRental, status: 'confirmed', paymentStatus: 'pending' }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ @@ -288,8 +289,8 @@ describe('RefundService', () => { }); }); - it('should reject cancellation for failed payment', () => { - const rental = { ...baseRental, paymentStatus: 'failed' }; + it('should reject cancellation for confirmed rental with failed payment', () => { + const rental = { ...baseRental, status: 'confirmed', paymentStatus: 'failed' }; const result = RefundService.validateCancellationEligibility(rental, 100); expect(result).toEqual({ @@ -311,6 +312,31 @@ describe('RefundService', () => { }); }); + describe('Pending rental cancellation (before owner approval)', () => { + it('should allow renter to cancel pending rental even with pending payment', () => { + // Pending rentals can be cancelled before owner approval, no payment processed yet + const rental = { ...baseRental, status: 'pending', paymentStatus: 'pending' }; + const result = RefundService.validateCancellationEligibility(rental, 100); + + expect(result).toEqual({ + canCancel: true, + reason: 'Cancellation allowed', + cancelledBy: 'renter' + }); + }); + + it('should allow owner to cancel pending rental', () => { + const rental = { ...baseRental, status: 'pending', paymentStatus: 'pending' }; + const result = RefundService.validateCancellationEligibility(rental, 200); + + expect(result).toEqual({ + canCancel: true, + reason: 'Cancellation allowed', + cancelledBy: 'owner' + }); + }); + }); + describe('Edge cases', () => { it('should handle string user IDs that don\'t match', () => { const result = RefundService.validateCancellationEligibility(baseRental, '100'); @@ -528,6 +554,40 @@ describe('RefundService', () => { }); }); + describe('Pending rental cancellation (before owner approval)', () => { + it('should process cancellation for pending rental without Stripe refund', async () => { + // Pending rental with no payment processed yet + mockRental.status = 'pending'; + mockRental.paymentStatus = 'pending'; + mockRental.stripePaymentIntentId = null; + + jest.useFakeTimers(); + jest.setSystemTime(new Date('2023-11-28T10:00:00Z')); + + const result = await RefundService.processCancellation(1, 100, 'Changed my mind'); + + // No Stripe refund should be attempted + expect(mockCreateRefund).not.toHaveBeenCalled(); + + // Rental should be updated + expect(mockRentalUpdate).toHaveBeenCalledWith({ + status: 'cancelled', + cancelledBy: 'renter', + cancelledAt: expect.any(Date), + refundAmount: 100.00, + refundProcessedAt: null, + refundReason: 'Changed my mind', + stripeRefundId: null, + payoutStatus: 'pending' + }); + + expect(result.refund.processed).toBe(false); + expect(result.refund.stripeRefundId).toBeNull(); + + jest.useRealTimers(); + }); + }); + describe('Error handling', () => { beforeEach(() => { jest.useFakeTimers();