This commit is contained in:
@@ -191,3 +191,44 @@ test('404s for a booking that does not exist', function () {
|
||||
->postJson('/api/v1/payments/EVB-DOES-NOT-EXIST/initiate')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('round trip: initiating payment on the primary leg charges the combined total of both legs', function () {
|
||||
$outbound = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'price' => 9000,
|
||||
]);
|
||||
$return = Booking::factory()->create([
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'price' => 11000,
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$outbound->booking_ref}/initiate")
|
||||
->assertCreated();
|
||||
|
||||
$payment = Payment::where('booking_id', $outbound->id)->sole();
|
||||
|
||||
expect((float) $payment->amount)->toBe(20000.0)
|
||||
->and(Payment::where('booking_id', $return->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('round trip: initiating payment on the return leg directly surfaces as 422', function () {
|
||||
$outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
$return = Booking::factory()->create([
|
||||
'user_id' => $this->owner->id,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$return->booking_ref}/initiate")
|
||||
->assertStatus(422);
|
||||
|
||||
expect(Payment::where('booking_id', $return->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -33,3 +33,20 @@ test('does not crash if the booking was soft-deleted before this queued listener
|
||||
expect(fn () => (new MarkBookingPaid)->handle(new PaymentCompleted($payment->fresh())))
|
||||
->not->toThrow(Throwable::class);
|
||||
});
|
||||
|
||||
test('a round trip: paying the primary leg also confirms its linked return leg', function () {
|
||||
$outbound = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$return = Booking::factory()->create([
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_return_leg' => true,
|
||||
'linked_booking_id' => $outbound->id,
|
||||
]);
|
||||
$outbound->update(['linked_booking_id' => $return->id]);
|
||||
|
||||
$payment = Payment::factory()->completed()->create(['booking_id' => $outbound->id]);
|
||||
|
||||
(new MarkBookingPaid)->handle(new PaymentCompleted($payment));
|
||||
|
||||
expect($outbound->refresh()->status)->toBe(BookingStatus::Confirmed)
|
||||
->and($return->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
@@ -152,3 +152,57 @@ test('a failed gateway refund is persisted as failed, leaves the booking untouch
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user