merchantOrderId, gatewayPayload: ['prepay_id' => 'PREPAY123'], ); } public function verify(string $gatewayTransactionId): PaymentResultData { self::$verifyCalls++; return new PaymentResultData( status: self::$verifyStatus, gatewayTransactionId: $gatewayTransactionId, gatewayPayload: ['trade_status' => self::$verifyStatus->value], ); } public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData { throw new RuntimeException('not needed for this test'); } public function handleWebhook(array $payload): PaymentResultData { throw new RuntimeException('not needed for this test'); } } beforeEach(function () { Permission::findOrCreate('manage_bookings', 'web'); app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeInitiatePaymentGateway::class); FakeInitiatePaymentGateway::$lastRequest = null; FakeInitiatePaymentGateway::$initiateCalls = 0; FakeInitiatePaymentGateway::$verifyCalls = 0; FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending; $this->owner = User::factory()->create(); $this->token = $this->owner->createToken('test-token')->plainTextToken; }); test('the owner can initiate payment for their own pending_payment booking', function () { $booking = Booking::factory()->create([ 'user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment, 'price' => 15000, ]); $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertCreated() ->assertJsonPath('data.status', PaymentStatus::Pending->value) ->assertJsonPath('data.gateway_payload.prepay_id', 'PREPAY123'); expect(Payment::where('booking_id', $booking->id)->count())->toBe(1); $payment = Payment::where('booking_id', $booking->id)->first(); expect($payment->status)->toBe(PaymentStatus::Pending) ->and($payment->gateway)->toBe(PaymentMethod::KbzMiniApp) ->and((float) $payment->amount)->toBe(15000.0) ->and($payment->gateway_transaction_id)->toBe("{$booking->booking_ref}-1"); }); test('a repeat call while the previous attempt is still pending reuses it instead of precreating again', function () { $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending; $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertCreated(); $firstPaymentId = Payment::where('booking_id', $booking->id)->sole()->id; $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertCreated() ->assertJsonPath('data.id', $firstPaymentId); expect(Payment::where('booking_id', $booking->id)->count())->toBe(1) ->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1) ->and(FakeInitiatePaymentGateway::$verifyCalls)->toBe(1); }); test('a repeat call reused attempt found completed on re-verify is returned without precreating again', function () { $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertCreated(); FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Completed; $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertCreated() ->assertJsonPath('data.status', PaymentStatus::Completed->value); expect(Payment::where('booking_id', $booking->id)->count())->toBe(1) ->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1); }); test('a retried payment attempt gets a unique merchant order id', function () { $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); Payment::factory()->failed()->create(['booking_id' => $booking->id]); $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertCreated(); expect(FakeInitiatePaymentGateway::$lastRequest->merchantOrderId)->toBe("{$booking->booking_ref}-2"); }); test('initiating payment on a non-pending_payment booking surfaces as 422', function () { $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]); $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertStatus(422); expect(Payment::where('booking_id', $booking->id)->count())->toBe(0); }); test('a non-owner without manage_bookings cannot initiate payment for someone else\'s booking', function () { $booking = Booking::factory()->create([ 'user_id' => User::factory()->create()->id, 'status' => BookingStatus::PendingPayment, ]); $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertForbidden(); expect(Payment::where('booking_id', $booking->id)->count())->toBe(0); }); test('staff with manage_bookings can initiate payment on behalf of a customer', function () { $staff = User::factory()->create()->givePermissionTo('manage_bookings'); $staffToken = $staff->createToken('staff-token')->plainTextToken; $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); $this->withHeader('Authorization', "Bearer {$staffToken}") ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") ->assertCreated(); }); test('unauthenticated requests are rejected', function () { $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); $this->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")->assertUnauthorized(); }); test('404s for a booking that does not exist', function () { $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson('/api/v1/payments/EVB-DOES-NOT-EXIST/initiate') ->assertNotFound(); });