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); }); /** * Round trip: payment is combined on the outbound ("primary") leg — the * return leg has no Payment of its own (domain.md §2b). */ function confirmedRoundTripWithCombinedPayment(string $outboundPrice, string $returnPrice): array { $outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => $outboundPrice]); $return = Booking::factory()->create([ 'status' => BookingStatus::Confirmed, 'price' => $returnPrice, 'is_return_leg' => true, 'linked_booking_id' => $outbound->id, ]); $outbound->update(['linked_booking_id' => $return->id]); $combined = bcadd($outboundPrice, $returnPrice, 2); Payment::factory()->completed()->create([ 'booking_id' => $outbound->id, 'gateway' => PaymentMethod::KbzMiniApp, 'amount' => $combined, 'gateway_transaction_id' => 'EVB-ROUNDTRIP-REFUND-1', ]); return [$outbound->fresh(), $return->fresh()]; } test('refunding a return leg draws a partial refund against the primary leg\'s combined payment', function () { [$outbound, $return] = confirmedRoundTripWithCombinedPayment('9000.00', '11000.00'); $refund = app(RefundBookingAction::class)->handle($return, '11000', 'return leg cancelled'); expect($refund->status)->toBe(RefundStatus::Completed) ->and($refund->payment_id)->toBe($outbound->payments()->first()->id) ->and($return->refresh()->status)->toBe(BookingStatus::Cancelled) ->and($outbound->refresh()->status)->toBe(BookingStatus::Confirmed); }); test('each leg of a round trip can be cancelled/refunded independently without exceeding the combined payment', function () { [$outbound, $return] = confirmedRoundTripWithCombinedPayment('9000.00', '11000.00'); app(RefundBookingAction::class)->handle($return, '11000', 'return leg cancelled'); $second = app(RefundBookingAction::class)->handle($outbound, '9000', 'outbound leg cancelled too'); expect($second->status)->toBe(RefundStatus::Completed) ->and($outbound->refresh()->status)->toBe(BookingStatus::Cancelled) ->and($return->refresh()->status)->toBe(BookingStatus::Cancelled); // Cumulative refunds (20000) exactly match the combined payment total — // a third refund attempt on either leg must now fail. expect(fn () => app(RefundBookingAction::class)->handle($outbound, '1', 'over the limit')) ->toThrow(RefundNotAllowedException::class); });