Complete Payment module: initiate/webhook/confirm/refund actions, Filament resources (T5.8-T5.13)

- 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
This commit is contained in:
Nyan Lin Paing
2026-08-09 16:20:21 +07:00
parent 4737838021
commit d19a14a45e
55 changed files with 2547 additions and 29 deletions
@@ -0,0 +1,153 @@
<?php
use Illuminate\Support\Facades\Event;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Actions\ConfirmPaymentAction;
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\PaymentStatus;
use Modules\Payment\Events\PaymentCompleted;
use Modules\Payment\Events\PaymentFailed;
use Modules\Payment\Factories\PaymentGatewayFactory;
use Modules\Payment\Models\Payment;
/**
* Fake gateway standing in for KbzMiniAppGateway::verify() never hits the
* real KBZ API in tests, and counts calls so idempotency (only one real
* re-verification per Payment) is provable.
*/
class FakeConfirmGateway implements PaymentGatewayInterface
{
public static int $verifyCallCount = 0;
public static PaymentStatus $verifyStatus = PaymentStatus::Completed;
public function initiate(PaymentRequestData $data): PaymentResultData
{
throw new RuntimeException('not needed for this test');
}
public function verify(string $gatewayTransactionId): PaymentResultData
{
self::$verifyCallCount++;
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 () {
FakeConfirmGateway::$verifyCallCount = 0;
FakeConfirmGateway::$verifyStatus = PaymentStatus::Completed;
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeConfirmGateway::class);
});
test('confirming a pending payment as completed dispatches PaymentCompleted once', function () {
Event::fake([PaymentCompleted::class, PaymentFailed::class]);
$payment = Payment::factory()->create([
'status' => PaymentStatus::Pending,
'gateway' => PaymentMethod::KbzMiniApp,
'gateway_transaction_id' => 'EVB-TXN-001',
]);
$result = app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-001');
expect($result->id)->toBe($payment->id)
->and($result->status)->toBe(PaymentStatus::Completed)
->and(FakeConfirmGateway::$verifyCallCount)->toBe(1);
Event::assertDispatchedTimes(PaymentCompleted::class, 1);
Event::assertNotDispatched(PaymentFailed::class);
});
test('a redelivered confirmation for an already-completed payment is a no-op', function () {
Event::fake([PaymentCompleted::class, PaymentFailed::class]);
Payment::factory()->create([
'status' => PaymentStatus::Pending,
'gateway' => PaymentMethod::KbzMiniApp,
'gateway_transaction_id' => 'EVB-TXN-002',
]);
$action = app(ConfirmPaymentAction::class);
$action->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-002');
$second = $action->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-002');
expect($second->status)->toBe(PaymentStatus::Completed)
->and(FakeConfirmGateway::$verifyCallCount)->toBe(1);
Event::assertDispatchedTimes(PaymentCompleted::class, 1);
});
test('confirming a payment the gateway reports as failed dispatches PaymentFailed and leaves the booking untouched', function () {
Event::fake([PaymentCompleted::class, PaymentFailed::class]);
FakeConfirmGateway::$verifyStatus = PaymentStatus::Failed;
$payment = Payment::factory()->create([
'status' => PaymentStatus::Pending,
'gateway' => PaymentMethod::KbzMiniApp,
'gateway_transaction_id' => 'EVB-TXN-003',
]);
$result = app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-003');
expect($result->status)->toBe(PaymentStatus::Failed);
Event::assertDispatchedTimes(PaymentFailed::class, 1);
Event::assertNotDispatched(PaymentCompleted::class);
});
test('a gateway verify still reporting pending leaves the payment pending and dispatches nothing', function () {
Event::fake([PaymentCompleted::class, PaymentFailed::class]);
FakeConfirmGateway::$verifyStatus = PaymentStatus::Pending;
Payment::factory()->create([
'status' => PaymentStatus::Pending,
'gateway' => PaymentMethod::KbzMiniApp,
'gateway_transaction_id' => 'EVB-TXN-004',
]);
$result = app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-004');
expect($result->status)->toBe(PaymentStatus::Pending);
Event::assertNothingDispatched();
});
test('returns null when no payment matches the gateway transaction id', function () {
$result = app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'does-not-exist');
expect($result)->toBeNull();
});
test('confirming a completed payment flips its booking to confirmed via MarkBookingPaid', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
Payment::factory()->create([
'booking_id' => $booking->id,
'status' => PaymentStatus::Pending,
'gateway' => PaymentMethod::KbzMiniApp,
'gateway_transaction_id' => 'EVB-TXN-005',
]);
app(ConfirmPaymentAction::class)->handle(PaymentMethod::KbzMiniApp, 'EVB-TXN-005');
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
});
@@ -0,0 +1,137 @@
<?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\PaymentStatus;
use Modules\Payment\Factories\PaymentGatewayFactory;
use Modules\Payment\Models\Payment;
use Spatie\Permission\Models\Permission;
/**
* A fake PaymentGatewayInterface swapped in via the factory never call the
* real KBZ gateway in tests (T5.8).
*/
class FakeInitiatePaymentGateway implements PaymentGatewayInterface
{
public static ?PaymentRequestData $lastRequest = null;
public function initiate(PaymentRequestData $data): PaymentResultData
{
self::$lastRequest = $data;
return new PaymentResultData(
status: PaymentStatus::Pending,
gatewayTransactionId: $data->merchantOrderId,
gatewayPayload: ['prepay_id' => 'PREPAY123'],
);
}
public function verify(string $gatewayTransactionId): PaymentResultData
{
throw new RuntimeException('not needed for this test');
}
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);
$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 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();
});
@@ -0,0 +1,79 @@
<?php
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Exceptions\InvalidWebhookSignatureException;
use Modules\Payment\Gateways\KbzMiniAppGateway;
use Modules\Payment\Support\KbzSignature;
$config = [
'app_id' => 'APPID123',
'merchant_code' => 'MERCH001',
'merchant_key' => 'test-merchant-key',
'base_url' => 'https://kbz.test/gateway',
];
/**
* @return array<string, mixed>
*/
$signedKbzNotification = function (array $overrides, string $merchantKey): array {
$notification = array_merge([
'appid' => 'APPID123',
'notify_time' => '1576842150',
'merch_code' => 'MERCH001',
'merch_order_id' => 'EVB-FIXTURE-001-1',
'mm_order_id' => '01001814070006560257',
'trans_currency' => 'MMK',
'total_amount' => '15000',
'trade_status' => 'PAY_SUCCESS',
'trans_end_time' => '1576834704',
'nonce_str' => '513ba55344ad44c8b69465aae66f7703',
'sign_type' => 'SHA256',
], $overrides);
$notification['sign'] = KbzSignature::sign($notification, $merchantKey);
return $notification;
};
test('handleWebhook maps a validly signed PAY_SUCCESS notification to a completed PaymentResultData', function () use ($config, $signedKbzNotification) {
$notification = $signedKbzNotification([], $config['merchant_key']);
$result = (new KbzMiniAppGateway($config))->handleWebhook(['Request' => $notification]);
expect($result->status)->toBe(PaymentStatus::Completed)
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001-1')
->and($result->gatewayPayload)->toBe($notification);
});
test('handleWebhook maps a validly signed WAIT_PAY notification to a pending PaymentResultData', function () use ($config, $signedKbzNotification) {
$notification = $signedKbzNotification(['trade_status' => 'WAIT_PAY'], $config['merchant_key']);
$result = (new KbzMiniAppGateway($config))->handleWebhook(['Request' => $notification]);
expect($result->status)->toBe(PaymentStatus::Pending);
});
test('handleWebhook throws InvalidWebhookSignatureException when the sign does not match', function () use ($config, $signedKbzNotification) {
$notification = $signedKbzNotification([], $config['merchant_key']);
$notification['sign'] = 'not-the-real-signature';
expect(fn () => (new KbzMiniAppGateway($config))->handleWebhook(['Request' => $notification]))
->toThrow(InvalidWebhookSignatureException::class);
});
test('handleWebhook throws InvalidWebhookSignatureException when signed with the wrong merchant key', function () use ($config, $signedKbzNotification) {
$notification = $signedKbzNotification([], 'a-different-merchant-key');
expect(fn () => (new KbzMiniAppGateway($config))->handleWebhook(['Request' => $notification]))
->toThrow(InvalidWebhookSignatureException::class);
});
test('handleWebhook throws InvalidWebhookSignatureException when the sign field is missing entirely', function () use ($config) {
expect(fn () => (new KbzMiniAppGateway($config))->handleWebhook(['Request' => ['trade_status' => 'PAY_SUCCESS']]))
->toThrow(InvalidWebhookSignatureException::class);
});
test('handleWebhook throws InvalidWebhookSignatureException when the Request key is missing entirely', function () use ($config) {
expect(fn () => (new KbzMiniAppGateway($config))->handleWebhook([]))
->toThrow(InvalidWebhookSignatureException::class);
});
@@ -0,0 +1,93 @@
<?php
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Modules\Booking\Models\Booking;
use Modules\Payment\Support\KbzSignature;
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function signedKbzWebhookBody(array $overrides = []): array
{
$merchantKey = 'test-merchant-key';
$notification = array_merge([
'appid' => 'APPID123',
'notify_time' => '1576842150',
'merch_code' => 'MERCH001',
'merch_order_id' => 'EVB-FIXTURE-001-1',
'mm_order_id' => '01001814070006560257',
'trans_currency' => 'MMK',
'total_amount' => '15000',
'trade_status' => 'PAY_SUCCESS',
'trans_end_time' => '1576834704',
'nonce_str' => '513ba55344ad44c8b69465aae66f7703',
'sign_type' => 'SHA256',
], $overrides);
$notification['sign'] = KbzSignature::sign($notification, $merchantKey);
return ['Request' => $notification];
}
beforeEach(function () {
config(['services.kbz.merchant_key' => 'test-merchant-key']);
});
test('a validly signed kbz webhook is acknowledged with the literal success body', function () {
Log::spy();
$this->postJson('/api/v1/webhooks/kbz_mini_app', signedKbzWebhookBody())
->assertOk()
->assertSee('success');
Log::shouldHaveReceived('info')->once();
});
test('an invalidly signed kbz webhook is rejected with 400, never 500', function () {
Log::spy();
$body = signedKbzWebhookBody();
$body['Request']['sign'] = 'tampered';
$this->postJson('/api/v1/webhooks/kbz_mini_app', $body)
->assertStatus(400);
Log::shouldHaveReceived('warning')->once();
});
test('an unknown gateway in the route segment 404s rather than reaching a controller', function () {
$this->postJson('/api/v1/webhooks/not_a_real_gateway', signedKbzWebhookBody())
->assertNotFound();
});
test('a missing signature is rejected with 400', function () {
$this->postJson('/api/v1/webhooks/kbz_mini_app', ['Request' => ['trade_status' => 'PAY_SUCCESS']])
->assertStatus(400);
});
test('the optional encrypted booking id segment is accepted and decrypted for logging', function () {
Log::spy();
$booking = Booking::factory()->create();
$encrypted = Crypt::encryptString((string) $booking->id);
$this->postJson("/api/v1/webhooks/kbz_mini_app/{$encrypted}", signedKbzWebhookBody())
->assertOk();
Log::shouldHaveReceived('info')->withArgs(
fn (string $message, array $context): bool => $context['booking_id'] === $booking->id
)->once();
});
test('a garbage encrypted booking id segment does not fail the webhook', function () {
$this->postJson('/api/v1/webhooks/kbz_mini_app/not-a-real-ciphertext', signedKbzWebhookBody())
->assertOk();
});
test('no auth:sanctum is required to reach the webhook', function () {
$this->postJson('/api/v1/webhooks/kbz_mini_app', signedKbzWebhookBody())
->assertOk();
});
@@ -0,0 +1,89 @@
<?php
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Data\PaymentResultData;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Factories\PaymentGatewayFactory;
use Modules\Payment\Gateways\KbzMiniAppGateway;
use Modules\Payment\Models\Payment;
use Modules\Payment\Support\KbzSignature;
/**
* Keeps KbzMiniAppGateway's real handleWebhook() (so the inbound signature
* check in the webhook route stays genuine end-to-end) but stubs verify()
* so the double-delivery/idempotency test never hits the real KBZ API and
* can count re-verification calls.
*/
class FakeVerifyingKbzGateway extends KbzMiniAppGateway
{
public static int $verifyCallCount = 0;
public function verify(string $gatewayTransactionId): PaymentResultData
{
self::$verifyCallCount++;
return new PaymentResultData(
status: PaymentStatus::Completed,
gatewayTransactionId: $gatewayTransactionId,
gatewayPayload: ['trade_status' => 'PAY_SUCCESS'],
);
}
}
beforeEach(function () {
FakeVerifyingKbzGateway::$verifyCallCount = 0;
config(['services.kbz.merchant_key' => 'test-merchant-key']);
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeVerifyingKbzGateway::class);
});
/**
* @return array<string, mixed>
*/
function signedKbzConfirmationBody(string $merchOrderId): array
{
$notification = [
'appid' => 'APPID123',
'notify_time' => '1576842150',
'merch_code' => 'MERCH001',
'merch_order_id' => $merchOrderId,
'mm_order_id' => '01001814070006560257',
'trans_currency' => 'MMK',
'total_amount' => '15000',
'trade_status' => 'PAY_SUCCESS',
'trans_end_time' => '1576834704',
'nonce_str' => '513ba55344ad44c8b69465aae66f7703',
'sign_type' => 'SHA256',
];
$notification['sign'] = KbzSignature::sign($notification, 'test-merchant-key');
return ['Request' => $notification];
}
test('a double-delivered kbz webhook only confirms the payment and its booking once', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
$payment = Payment::factory()->create([
'booking_id' => $booking->id,
'status' => PaymentStatus::Pending,
'gateway' => PaymentMethod::KbzMiniApp,
'gateway_transaction_id' => 'EVB-DUPTEST-1',
]);
$body = signedKbzConfirmationBody('EVB-DUPTEST-1');
$this->postJson('/api/v1/webhooks/kbz_mini_app', $body)->assertOk();
expect($payment->refresh()->status)->toBe(PaymentStatus::Completed)
->and($booking->refresh()->status)->toBe(BookingStatus::Confirmed)
->and(FakeVerifyingKbzGateway::$verifyCallCount)->toBe(1);
// KBZ redelivers the same notification — must not re-verify or re-confirm.
$this->postJson('/api/v1/webhooks/kbz_mini_app', $body)->assertOk();
expect($payment->refresh()->status)->toBe(PaymentStatus::Completed)
->and($booking->refresh()->status)->toBe(BookingStatus::Confirmed)
->and(FakeVerifyingKbzGateway::$verifyCallCount)->toBe(1);
});
@@ -0,0 +1,26 @@
<?php
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Events\PaymentCompleted;
use Modules\Payment\Listeners\MarkBookingPaid;
use Modules\Payment\Models\Payment;
test('flips a pending_payment booking to confirmed', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
$payment = Payment::factory()->completed()->create(['booking_id' => $booking->id]);
(new MarkBookingPaid)->handle(new PaymentCompleted($payment));
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
});
test('does not touch a booking that already moved on for another reason', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
$payment = Payment::factory()->completed()->create(['booking_id' => $booking->id, 'status' => PaymentStatus::Completed]);
(new MarkBookingPaid)->handle(new PaymentCompleted($payment));
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
});
@@ -30,6 +30,11 @@ class FakePaymentGateway implements PaymentGatewayInterface
{
throw new RuntimeException('not needed for this test');
}
public function handleWebhook(array $payload): PaymentResultData
{
throw new RuntimeException('not needed for this test');
}
}
test('the factory resolves KbzMiniAppGateway for the KbzMiniApp method by default', function () {
@@ -0,0 +1,80 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Filament\Resources\Payments\Pages\ListPayments;
use Modules\Payment\Filament\Resources\Payments\Pages\ViewPayment;
use Modules\Payment\Models\Payment;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
foreach (['view_payments', 'process_refunds'] as $permission) {
Permission::findOrCreate($permission, 'web');
}
});
test('a user with view_payments can list payments', function () {
$viewer = User::factory()->create()->givePermissionTo('view_payments');
$this->actingAs($viewer);
$payments = Payment::factory()->count(3)->create();
Livewire::test(ListPayments::class)
->assertOk()
->assertCanSeeTableRecords($payments);
});
test('can filter payments by status', function () {
$viewer = User::factory()->create()->givePermissionTo('view_payments');
$this->actingAs($viewer);
$pending = Payment::factory()->create(['status' => PaymentStatus::Pending]);
$completed = Payment::factory()->completed()->create();
Livewire::test(ListPayments::class)
->filterTable('status', PaymentStatus::Completed->value)
->assertCanSeeTableRecords([$completed])
->assertCanNotSeeTableRecords([$pending]);
});
test('can view a payment\'s detail page', function () {
$viewer = User::factory()->create()->givePermissionTo('view_payments');
$this->actingAs($viewer);
$booking = Booking::factory()->create();
$payment = Payment::factory()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'gateway_transaction_id' => 'EVB-VIEWTEST-1',
]);
Livewire::test(ViewPayment::class, ['record' => $payment->getRouteKey()])
->assertOk()
->assertSee($booking->booking_ref)
->assertSee('EVB-VIEWTEST-1');
});
test('the gateway response is visible to a user with process_refunds', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
$payment = Payment::factory()->create(['gateway_payload' => ['prepay_id' => 'PREPAY-SECRET-123']]);
Livewire::test(ViewPayment::class, ['record' => $payment->getRouteKey()])
->assertOk()
->assertSee('PREPAY-SECRET-123');
});
test('the gateway response is hidden from a user without process_refunds', function () {
$support = User::factory()->create()->givePermissionTo('view_payments');
$this->actingAs($support);
$payment = Payment::factory()->create(['gateway_payload' => ['prepay_id' => 'PREPAY-SECRET-123']]);
Livewire::test(ViewPayment::class, ['record' => $payment->getRouteKey()])
->assertOk()
->assertDontSee('PREPAY-SECRET-123');
});
@@ -0,0 +1,154 @@
<?php
use Illuminate\Support\Facades\Event;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Actions\RefundBookingAction;
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\Events\RefundProcessed;
use Modules\Payment\Exceptions\RefundFailedException;
use Modules\Payment\Exceptions\RefundNotAllowedException;
use Modules\Payment\Factories\PaymentGatewayFactory;
use Modules\Payment\Models\Payment;
use Modules\Payment\Models\Refund;
/**
* Never calls the real KBZ refund API in tests.
*/
class FakeRefundGateway implements PaymentGatewayInterface
{
public static ?RefundStatus $resultStatus = RefundStatus::Completed;
public static ?string $lastAmount = null;
public static ?string $lastReason = null;
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
{
self::$lastAmount = $amount;
self::$lastReason = $reason;
return new RefundResultData(
status: self::$resultStatus,
gatewayRefundId: self::$resultStatus === RefundStatus::Completed ? 'REFUND123' : null,
gatewayPayload: ['result' => self::$resultStatus->value],
message: self::$resultStatus === RefundStatus::Failed ? 'Refund window expired' : null,
);
}
public function handleWebhook(array $payload): PaymentResultData
{
throw new RuntimeException('not needed for this test');
}
}
beforeEach(function () {
FakeRefundGateway::$resultStatus = RefundStatus::Completed;
FakeRefundGateway::$lastAmount = null;
FakeRefundGateway::$lastReason = null;
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeRefundGateway::class);
});
function confirmedBookingWithPayment(string $amount = '15000'): Booking
{
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => $amount]);
Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => $amount,
'gateway_transaction_id' => 'EVB-REFUND-TEST-1',
]);
return $booking;
}
test('a full refund on a confirmed booking completes and flips the booking to cancelled', function () {
$booking = confirmedBookingWithPayment('15000');
$refund = app(RefundBookingAction::class)->handle($booking, '15000', 'customer requested cancellation');
expect($refund->status)->toBe(RefundStatus::Completed)
->and($refund->amount)->toBe('15000.00')
->and($refund->gateway_refund_id)->toBe('REFUND123')
->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled)
->and(FakeRefundGateway::$lastAmount)->toBe('15000')
->and(FakeRefundGateway::$lastReason)->toBe('customer requested cancellation');
});
test('a partial refund is wired through to the gateway and does not exceed the payment amount', function () {
$booking = confirmedBookingWithPayment('15000');
$refund = app(RefundBookingAction::class)->handle($booking, '8000', 'partial refund');
expect($refund->status)->toBe(RefundStatus::Completed)
->and($refund->amount)->toBe('8000.00')
->and(FakeRefundGateway::$lastAmount)->toBe('8000');
});
test('a second partial refund is validated against the remaining refundable balance, not the original total', function () {
$booking = confirmedBookingWithPayment('15000');
app(RefundBookingAction::class)->handle($booking, '10000', 'first partial refund');
expect(fn () => app(RefundBookingAction::class)->handle($booking, '6000', 'second partial refund'))
->toThrow(RefundNotAllowedException::class);
});
test('a second partial refund within the remaining balance succeeds', function () {
$booking = confirmedBookingWithPayment('15000');
app(RefundBookingAction::class)->handle($booking, '10000', 'first partial refund');
$second = app(RefundBookingAction::class)->handle($booking, '5000', 'second partial refund');
expect($second->status)->toBe(RefundStatus::Completed);
});
test('refunding a non-confirmed booking throws RefundNotAllowedException', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
expect(fn () => app(RefundBookingAction::class)->handle($booking, '5000', 'reason'))
->toThrow(RefundNotAllowedException::class);
});
test('refunding a confirmed booking with no completed payment throws RefundNotAllowedException', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
expect(fn () => app(RefundBookingAction::class)->handle($booking, '5000', 'reason'))
->toThrow(RefundNotAllowedException::class);
});
test('a failed gateway refund is persisted as failed, leaves the booking untouched, and throws RefundFailedException', function () {
Event::fake([RefundProcessed::class]);
FakeRefundGateway::$resultStatus = RefundStatus::Failed;
$booking = confirmedBookingWithPayment('15000');
try {
app(RefundBookingAction::class)->handle($booking, '15000', 'reason');
$this->fail('Expected RefundFailedException to be thrown.');
} catch (RefundFailedException $exception) {
expect($exception->getMessage())->toBe('Refund window expired');
}
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed)
->and(Refund::where('status', RefundStatus::Failed)->count())->toBe(1);
Event::assertNotDispatched(RefundProcessed::class);
});
@@ -0,0 +1,136 @@
<?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;
class FakeRefundApiGateway implements PaymentGatewayInterface
{
public static RefundStatus $resultStatus = RefundStatus::Completed;
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: self::$resultStatus,
gatewayRefundId: self::$resultStatus === RefundStatus::Completed ? 'REFUND123' : null,
gatewayPayload: [],
message: self::$resultStatus === RefundStatus::Failed ? 'Refund failed at gateway' : null,
);
}
public function handleWebhook(array $payload): PaymentResultData
{
throw new RuntimeException('not needed for this test');
}
}
beforeEach(function () {
FakeRefundApiGateway::$resultStatus = RefundStatus::Completed;
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeRefundApiGateway::class);
Permission::findOrCreate('process_refunds', 'web');
$this->staff = User::factory()->create()->givePermissionTo('process_refunds');
$this->staffToken = $this->staff->createToken('staff-token')->plainTextToken;
});
test('staff with process_refunds can refund a confirmed booking', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-API-REFUND-1',
]);
$this->withHeader('Authorization', "Bearer {$this->staffToken}")
->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [
'amount' => 15000,
'reason' => 'customer requested cancellation',
])
->assertCreated()
->assertJsonPath('data.status', RefundStatus::Completed->value);
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
});
test('a customer without process_refunds cannot refund their own booking', function () {
$owner = User::factory()->create();
$token = $owner->createToken('customer-token')->plainTextToken;
$booking = Booking::factory()->create(['user_id' => $owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]);
Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-API-REFUND-2',
]);
$this->withHeader('Authorization', "Bearer {$token}")
->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [
'amount' => 15000,
'reason' => 'customer requested cancellation',
])
->assertForbidden();
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
});
test('refunding a pending_payment booking surfaces as 422', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
$this->withHeader('Authorization', "Bearer {$this->staffToken}")
->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [
'amount' => 5000,
'reason' => 'reason',
])
->assertStatus(422);
});
test('a failed gateway refund surfaces the gateway message as 422 and leaves the booking confirmed', function () {
FakeRefundApiGateway::$resultStatus = RefundStatus::Failed;
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-API-REFUND-3',
]);
$this->withHeader('Authorization', "Bearer {$this->staffToken}")
->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", [
'amount' => 15000,
'reason' => 'reason',
])
->assertStatus(422)
->assertJsonPath('message', 'Refund failed at gateway');
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
});
test('unauthenticated requests are rejected', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
$this->postJson("/api/v1/bookings/{$booking->booking_ref}/refund", ['amount' => 100, 'reason' => 'x'])
->assertUnauthorized();
});
@@ -0,0 +1,126 @@
<?php
use App\Models\User;
use Livewire\Livewire;
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\Filament\Resources\Refunds\Pages\ListRefunds;
use Modules\Payment\Models\Payment;
use Modules\Payment\Models\Refund;
use Spatie\Permission\Models\Permission;
/**
* Never calls the real KBZ refund API in tests.
*/
class FakeFilamentRefundGateway 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 (['view_payments', 'process_refunds'] as $permission) {
Permission::findOrCreate($permission, 'web');
}
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeFilamentRefundGateway::class);
});
test('a user with view_payments can list refunds', function () {
$viewer = User::factory()->create()->givePermissionTo('view_payments');
$this->actingAs($viewer);
$refunds = Refund::factory()->count(3)->create();
Livewire::test(ListRefunds::class)
->assertOk()
->assertCanSeeTableRecords($refunds);
});
test('the process action is hidden from a user without process_refunds', function () {
$viewer = User::factory()->create()->givePermissionTo('view_payments');
$this->actingAs($viewer);
Livewire::test(ListRefunds::class)
->assertActionHidden('process');
});
test('the process action is visible to a user with process_refunds', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
Livewire::test(ListRefunds::class)
->assertActionVisible('process');
});
test('processing a refund via the action calls RefundBookingAction and cancels the booking', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
$payment = Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-FILAMENT-REFUND-1',
]);
Livewire::test(ListRefunds::class)
->callAction('process', data: [
'payment_id' => $payment->id,
'amount' => 15000,
'reason' => 'customer requested cancellation',
])
->assertNotified();
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled)
->and(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->exists())->toBeTrue();
});
test('a non-completed payment is not offered in the process action\'s payment select', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
$pendingPayment = Payment::factory()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
]);
// The Select itself rejects a value outside its "Completed only"
// options (domain.md §6) — RefundBookingAction's own guard against a
// non-confirmed booking is covered directly in RefundBookingActionTest.
Livewire::test(ListRefunds::class)
->callAction('process', data: [
'payment_id' => $pendingPayment->id,
'amount' => 1000,
'reason' => 'reason',
])
->assertHasFormErrors(['payment_id']);
expect(Refund::where('payment_id', $pendingPayment->id)->exists())->toBeFalse();
});