d19a14a45e
- InitiatePaymentAction + POST /api/v1/payments/{booking}/initiate
- Generic KBZ webhook (POST /api/v1/webhooks/{method}/{encryptBookingId?}),
signature verification per KBZ's real callback spec, PaymentGatewayInterface::handleWebhook()
- ConfirmPaymentAction: idempotent confirmation, PaymentCompleted/PaymentFailed events,
MarkBookingPaid listener
- RefundBookingAction + POST /api/v1/bookings/{booking}/refund: partial refunds validated
against remaining balance, RefundProcessed event, MarkBookingRefunded listener
- CancelBookingAction now refunds confirmed bookings instead of rejecting; BookingPolicy::cancel
requires process_refunds for confirmed bookings
- PaymentPlugin + PaymentResource/RefundResource Filament admin UI (read-only payments,
refund list + Process action)
- Booking detail page now shows related payments
- Fix CACHE_STORE mismatch (database -> redis) so tagged route caching works
- CLAUDE.md: never run migrate:fresh/migrate:refresh/db:wipe on dev without being asked
144 lines
5.7 KiB
PHP
144 lines
5.7 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use Modules\Booking\Enums\BookingStatus;
|
|
use Modules\Booking\Models\Booking;
|
|
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
|
use Modules\Payment\Data\PaymentRequestData;
|
|
use Modules\Payment\Data\PaymentResultData;
|
|
use Modules\Payment\Data\RefundResultData;
|
|
use Modules\Payment\Enums\PaymentMethod;
|
|
use Modules\Payment\Enums\RefundStatus;
|
|
use Modules\Payment\Factories\PaymentGatewayFactory;
|
|
use Modules\Payment\Models\Payment;
|
|
use Spatie\Permission\Models\Permission;
|
|
|
|
/**
|
|
* Never calls the real KBZ refund API in tests.
|
|
*/
|
|
class FakeCancelApiRefundGateway implements PaymentGatewayInterface
|
|
{
|
|
public function initiate(PaymentRequestData $data): PaymentResultData
|
|
{
|
|
throw new RuntimeException('not needed for this test');
|
|
}
|
|
|
|
public function verify(string $gatewayTransactionId): PaymentResultData
|
|
{
|
|
throw new RuntimeException('not needed for this test');
|
|
}
|
|
|
|
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
|
{
|
|
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
|
}
|
|
|
|
public function handleWebhook(array $payload): PaymentResultData
|
|
{
|
|
throw new RuntimeException('not needed for this test');
|
|
}
|
|
}
|
|
|
|
beforeEach(function () {
|
|
foreach (['manage_bookings', 'process_refunds'] as $permission) {
|
|
Permission::findOrCreate($permission, 'web');
|
|
}
|
|
|
|
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelApiRefundGateway::class);
|
|
|
|
$this->owner = User::factory()->create();
|
|
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
|
});
|
|
|
|
test('the owner can cancel their own pending_payment booking', function () {
|
|
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
|
->assertSuccessful()
|
|
->assertJsonPath('data.status', BookingStatus::Cancelled->value);
|
|
|
|
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
|
});
|
|
|
|
test('the owner cannot cancel their own confirmed booking without process_refunds', function () {
|
|
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
|
->assertForbidden();
|
|
|
|
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
|
});
|
|
|
|
test('staff with process_refunds can cancel a confirmed booking, which refunds it in full', function () {
|
|
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
|
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
|
|
|
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]);
|
|
Payment::factory()->completed()->create([
|
|
'booking_id' => $booking->id,
|
|
'gateway' => PaymentMethod::KbzMiniApp,
|
|
'amount' => 15000,
|
|
'gateway_transaction_id' => 'EVB-CANCEL-API-1',
|
|
]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
|
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
|
->assertSuccessful()
|
|
->assertJsonPath('data.status', BookingStatus::Cancelled->value);
|
|
|
|
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
|
});
|
|
|
|
test('cancelling a confirmed booking with no completed payment surfaces as 422 and leaves it untouched', function () {
|
|
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
|
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
|
|
|
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
|
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
|
->assertStatus(422);
|
|
|
|
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
|
});
|
|
|
|
test('a non-owner without manage_bookings cannot cancel 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/bookings/{$booking->booking_ref}/cancel")
|
|
->assertForbidden();
|
|
|
|
expect($booking->refresh()->status)->toBe(BookingStatus::PendingPayment);
|
|
});
|
|
|
|
test('staff with manage_bookings can cancel someone else\'s pending_payment booking', 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/bookings/{$booking->booking_ref}/cancel")
|
|
->assertSuccessful();
|
|
|
|
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
|
});
|
|
|
|
test('unauthenticated requests are rejected', function () {
|
|
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
|
|
|
$this->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")->assertUnauthorized();
|
|
});
|
|
|
|
test('404s for a booking that does not exist', function () {
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings/EVB-DOES-NOT-EXIST/cancel')
|
|
->assertNotFound();
|
|
});
|