self::$resultStatus->value], message: self::$resultStatus === RefundStatus::Failed ? 'Refund window expired' : null, ); } public function handleWebhook(array $payload): PaymentResultData { throw new RuntimeException('not needed for this test'); } } beforeEach(function () { FakeRefundGateway::$resultStatus = RefundStatus::Completed; FakeRefundGateway::$lastAmount = null; FakeRefundGateway::$lastReason = null; app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeRefundGateway::class); }); function confirmedBookingWithPayment(string $amount = '15000'): Booking { $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => $amount]); Payment::factory()->completed()->create([ 'booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp, 'amount' => $amount, 'gateway_transaction_id' => 'EVB-REFUND-TEST-1', ]); return $booking; } test('a full refund on a confirmed booking completes and flips the booking to cancelled', function () { $booking = confirmedBookingWithPayment('15000'); $refund = app(RefundBookingAction::class)->handle($booking, '15000', 'customer requested cancellation'); expect($refund->status)->toBe(RefundStatus::Completed) ->and($refund->amount)->toBe('15000.00') ->and($refund->gateway_refund_id)->toBe('REFUND123') ->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled) ->and(FakeRefundGateway::$lastAmount)->toBe('15000') ->and(FakeRefundGateway::$lastReason)->toBe('customer requested cancellation'); }); test('a partial refund is wired through to the gateway and does not exceed the payment amount', function () { $booking = confirmedBookingWithPayment('15000'); $refund = app(RefundBookingAction::class)->handle($booking, '8000', 'partial refund'); expect($refund->status)->toBe(RefundStatus::Completed) ->and($refund->amount)->toBe('8000.00') ->and(FakeRefundGateway::$lastAmount)->toBe('8000'); }); test('a second partial refund is validated against the remaining refundable balance, not the original total', function () { $booking = confirmedBookingWithPayment('15000'); app(RefundBookingAction::class)->handle($booking, '10000', 'first partial refund'); expect(fn () => app(RefundBookingAction::class)->handle($booking, '6000', 'second partial refund')) ->toThrow(RefundNotAllowedException::class); }); test('a second partial refund within the remaining balance succeeds', function () { $booking = confirmedBookingWithPayment('15000'); app(RefundBookingAction::class)->handle($booking, '10000', 'first partial refund'); $second = app(RefundBookingAction::class)->handle($booking, '5000', 'second partial refund'); expect($second->status)->toBe(RefundStatus::Completed); }); test('refunding a non-confirmed booking throws RefundNotAllowedException', function () { $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); expect(fn () => app(RefundBookingAction::class)->handle($booking, '5000', 'reason')) ->toThrow(RefundNotAllowedException::class); }); test('refunding a confirmed booking with no completed payment throws RefundNotAllowedException', function () { $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); expect(fn () => app(RefundBookingAction::class)->handle($booking, '5000', 'reason')) ->toThrow(RefundNotAllowedException::class); }); test('a failed gateway refund is persisted as failed, leaves the booking untouched, and throws RefundFailedException', function () { Event::fake([RefundProcessed::class]); FakeRefundGateway::$resultStatus = RefundStatus::Failed; $booking = confirmedBookingWithPayment('15000'); try { app(RefundBookingAction::class)->handle($booking, '15000', 'reason'); $this->fail('Expected RefundFailedException to be thrown.'); } catch (RefundFailedException $exception) { expect($exception->getMessage())->toBe('Refund window expired'); } expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed) ->and(Refund::where('status', RefundStatus::Failed)->count())->toBe(1); Event::assertNotDispatched(RefundProcessed::class); });