This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* A round trip's Payment is combined on the primary (outbound) leg
|
||||
* (domain.md §2b), so `refund->payment->booking` is no longer reliable
|
||||
* for identifying which leg a refund actually cancels — a refund
|
||||
* against the return leg still hangs off the primary's Payment.
|
||||
* `booking_id` records the actual leg RefundBookingAction was asked to
|
||||
* refund, so MarkBookingRefunded flips the right booking to cancelled.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('refunds', function (Blueprint $table) {
|
||||
$table->foreignId('booking_id')->nullable()->after('payment_id')
|
||||
->constrained('bookings')->nullOnDelete();
|
||||
});
|
||||
|
||||
// Backfill existing rows from their Payment's booking — correct for
|
||||
// every pre-existing refund, since round trip didn't exist yet.
|
||||
DB::statement(
|
||||
'update refunds set booking_id = payments.booking_id '.
|
||||
'from payments where payments.id = refunds.payment_id'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('refunds', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('booking_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -43,6 +43,10 @@ class InitiatePaymentAction
|
||||
throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
if ($booking->is_return_leg) {
|
||||
throw PaymentInitiationNotAllowedException::isReturnLeg($booking);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($booking, $method) {
|
||||
$booking = Booking::whereKey($booking->id)->lockForUpdate()->first();
|
||||
|
||||
@@ -62,11 +66,12 @@ class InitiatePaymentAction
|
||||
}
|
||||
|
||||
$merchantOrderId = $this->merchantOrderId($booking);
|
||||
$amount = $this->amount($booking);
|
||||
|
||||
$result = $this->paymentService->initiate(new PaymentRequestData(
|
||||
bookingId: $booking->id,
|
||||
merchantOrderId: $merchantOrderId,
|
||||
amount: (string) $booking->price,
|
||||
amount: $amount,
|
||||
currency: self::CURRENCY,
|
||||
method: $method,
|
||||
notifyUrl: $this->notifyUrl($booking, $method),
|
||||
@@ -76,7 +81,7 @@ class InitiatePaymentAction
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => $method,
|
||||
'status' => $result->status,
|
||||
'amount' => $booking->price,
|
||||
'amount' => $amount,
|
||||
'currency' => self::CURRENCY,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
@@ -85,6 +90,20 @@ class InitiatePaymentAction
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A round trip's payment is combined on the outbound leg — covers both
|
||||
* legs' price, since the return leg never gets its own Payment
|
||||
* (domain.md §2b). A plain one-way booking just pays its own price.
|
||||
*/
|
||||
private function amount(Booking $booking): string
|
||||
{
|
||||
if ($booking->linked_booking_id === null) {
|
||||
return (string) $booking->price;
|
||||
}
|
||||
|
||||
return bcadd((string) $booking->price, (string) $booking->linkedBooking->price, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking can have more than one payment attempt (retry after
|
||||
* failure), so the merchant order id must be unique per attempt, not
|
||||
|
||||
@@ -35,7 +35,14 @@ class RefundBookingAction
|
||||
throw RefundNotAllowedException::notConfirmed($booking);
|
||||
}
|
||||
|
||||
$payment = $booking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
|
||||
// Round trip: payment is combined on the outbound leg, so a return
|
||||
// leg has no Payment of its own — refund against its linked leg's
|
||||
// Payment instead (domain.md §2b). The Confirmed check above still
|
||||
// applies to $booking itself, not the payment holder, so each leg
|
||||
// remains independently cancellable/refundable.
|
||||
$paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking;
|
||||
|
||||
$payment = $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
|
||||
|
||||
if ($payment === null) {
|
||||
throw RefundNotAllowedException::noCompletedPayment($booking);
|
||||
@@ -45,9 +52,10 @@ class RefundBookingAction
|
||||
|
||||
$result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason);
|
||||
|
||||
$refund = DB::transaction(function () use ($payment, $amount, $reason, $result, $requestedBy) {
|
||||
$refund = DB::transaction(function () use ($booking, $payment, $amount, $reason, $result, $requestedBy) {
|
||||
$refund = Refund::create([
|
||||
'payment_id' => $payment->id,
|
||||
'booking_id' => $booking->id,
|
||||
'status' => $result->status,
|
||||
'amount' => $amount,
|
||||
'reason' => $reason,
|
||||
|
||||
@@ -16,6 +16,18 @@ class PaymentInitiationNotAllowedException extends RuntimeException
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A round trip's payment is combined on the outbound leg — the return
|
||||
* leg is marked paid when the outbound leg's payment succeeds
|
||||
* (MarkBookingPaid), never via its own Payment (domain.md §2b).
|
||||
*/
|
||||
public static function isReturnLeg(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] is a round trip's return leg — initiate payment on its linked outbound booking instead."
|
||||
);
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
|
||||
@@ -29,5 +29,14 @@ class MarkBookingPaid implements ShouldQueue
|
||||
if ($booking->status === BookingStatus::PendingPayment) {
|
||||
$booking->update(['status' => BookingStatus::Confirmed]);
|
||||
}
|
||||
|
||||
// Round trip: payment is combined on the outbound leg, so its
|
||||
// success also confirms the linked return leg — the return leg
|
||||
// never gets its own Payment (domain.md §2b).
|
||||
$linkedBooking = $booking->linkedBooking;
|
||||
|
||||
if ($linkedBooking !== null && $linkedBooking->status === BookingStatus::PendingPayment) {
|
||||
$linkedBooking->update(['status' => BookingStatus::Confirmed]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ class MarkBookingRefunded implements ShouldQueue
|
||||
{
|
||||
public function handle(RefundProcessed $event): void
|
||||
{
|
||||
$booking = $event->refund->payment->booking;
|
||||
// The leg actually refunded — not payment->booking, since a round
|
||||
// trip's return leg refunds against the primary leg's shared
|
||||
// Payment (domain.md §2b). Falls back to payment->booking for
|
||||
// pre-redesign rows where booking_id wasn't yet recorded.
|
||||
$booking = $event->refund->booking ?? $event->refund->payment->booking;
|
||||
|
||||
// Booking uses SoftDeletes — normally unreachable here (a confirmed
|
||||
// booking is never deletable, BookingPolicy::delete), but this
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Database\Factories\RefundFactory;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
@@ -38,6 +39,7 @@ class Refund extends Model
|
||||
*/
|
||||
protected $fillable = [
|
||||
'payment_id',
|
||||
'booking_id',
|
||||
'status',
|
||||
'amount',
|
||||
'reason',
|
||||
@@ -67,6 +69,16 @@ class Refund extends Model
|
||||
return $this->belongsTo(Payment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg actually being refunded/cancelled — not necessarily
|
||||
* payment->booking, since a round trip's return leg refunds against the
|
||||
* primary leg's shared Payment (domain.md §2b).
|
||||
*/
|
||||
public function booking(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Booking::class);
|
||||
}
|
||||
|
||||
public function requestedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requested_by');
|
||||
|
||||
@@ -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