add notes/remark and refactor round-trip
PHP Tests / php-tests (push) Has been cancelled

This commit is contained in:
Nyan Lin Paing
2026-08-22 21:43:41 +07:00
parent 894352b43f
commit fa908cdcaf
46 changed files with 1679 additions and 182 deletions
@@ -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
+12
View File
@@ -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');