From d19a14a45ed9f99eb8e55e95bede37f36799f084 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:20:21 +0700 Subject: [PATCH] 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 --- CLAUDE.md | 5 + .../src/Actions/CancelBookingAction.php | 30 +++- .../BookingCannotBeCancelledException.php | 11 +- .../Bookings/Schemas/BookingInfolist.php | 29 ++++ .../Http/Controllers/BookingController.php | 4 +- .../booking/src/Policies/BookingPolicy.php | 23 ++- .../tests/Feature/BookingCancelApiTest.php | 75 ++++++++- .../tests/Feature/BookingResourceTest.php | 30 +++- .../tests/Unit/CancelBookingActionTest.php | 67 +++++++- app-modules/payment/routes/payment-routes.php | 17 ++ .../src/Actions/ConfirmPaymentAction.php | 62 +++++++ .../src/Actions/InitiatePaymentAction.php | 88 ++++++++++ .../src/Actions/RefundBookingAction.php | 84 ++++++++++ .../src/Contracts/PaymentGatewayInterface.php | 15 ++ .../payment/src/Events/PaymentCompleted.php | 18 ++ .../payment/src/Events/PaymentFailed.php | 18 ++ .../payment/src/Events/RefundProcessed.php | 18 ++ .../InvalidWebhookSignatureException.php | 26 +++ .../PaymentInitiationNotAllowedException.php | 27 +++ .../src/Exceptions/RefundFailedException.php | 31 ++++ .../Exceptions/RefundNotAllowedException.php | 40 +++++ .../payment/src/Filament/Pages/.gitkeep | 0 .../Resources/Payments/Pages/ListPayments.php | 18 ++ .../Resources/Payments/Pages/ViewPayment.php | 11 ++ .../Resources/Payments/PaymentResource.php | 47 ++++++ .../Payments/Schemas/PaymentInfolist.php | 55 +++++++ .../Payments/Tables/PaymentsTable.php | 66 ++++++++ .../Refunds/Actions/ProcessRefundAction.php | 74 +++++++++ .../Resources/Refunds/Pages/ListRefunds.php | 21 +++ .../Resources/Refunds/RefundResource.php | 39 +++++ .../Resources/Refunds/Tables/RefundsTable.php | 62 +++++++ .../payment/src/Filament/Widgets/.gitkeep | 0 .../src/Gateways/KbzMiniAppGateway.php | 42 +++++ .../Http/Controllers/PaymentController.php | 28 ++++ .../Controllers/PaymentWebhookController.php | 92 +++++++++++ .../src/Http/Controllers/RefundController.php | 36 ++++ .../Http/Requests/RefundBookingRequest.php | 28 ++++ .../src/Http/Resources/PaymentResource.php | 31 ++++ .../src/Http/Resources/RefundResource.php | 29 ++++ .../payment/src/Listeners/MarkBookingPaid.php | 25 +++ .../src/Listeners/MarkBookingRefunded.php | 25 +++ app-modules/payment/src/PaymentPlugin.php | 38 +++++ .../src/Providers/PaymentServiceProvider.php | 11 +- .../Feature/ConfirmPaymentActionTest.php | 153 +++++++++++++++++ .../tests/Feature/InitiatePaymentApiTest.php | 137 ++++++++++++++++ .../KbzMiniAppGatewayHandleWebhookTest.php | 79 +++++++++ .../tests/Feature/KbzWebhookApiTest.php | 93 +++++++++++ .../Feature/KbzWebhookConfirmationTest.php | 89 ++++++++++ .../tests/Feature/MarkBookingPaidTest.php | 26 +++ .../Feature/PaymentGatewayFactoryTest.php | 5 + .../tests/Feature/PaymentResourceTest.php | 80 +++++++++ .../tests/Feature/RefundBookingActionTest.php | 154 ++++++++++++++++++ .../tests/Feature/RefundBookingApiTest.php | 136 ++++++++++++++++ .../tests/Feature/RefundResourceTest.php | 126 ++++++++++++++ app/Providers/Filament/AdminPanelProvider.php | 2 + 55 files changed, 2547 insertions(+), 29 deletions(-) create mode 100644 app-modules/payment/src/Actions/ConfirmPaymentAction.php create mode 100644 app-modules/payment/src/Actions/InitiatePaymentAction.php create mode 100644 app-modules/payment/src/Actions/RefundBookingAction.php create mode 100644 app-modules/payment/src/Events/PaymentCompleted.php create mode 100644 app-modules/payment/src/Events/PaymentFailed.php create mode 100644 app-modules/payment/src/Events/RefundProcessed.php create mode 100644 app-modules/payment/src/Exceptions/InvalidWebhookSignatureException.php create mode 100644 app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php create mode 100644 app-modules/payment/src/Exceptions/RefundFailedException.php create mode 100644 app-modules/payment/src/Exceptions/RefundNotAllowedException.php create mode 100644 app-modules/payment/src/Filament/Pages/.gitkeep create mode 100644 app-modules/payment/src/Filament/Resources/Payments/Pages/ListPayments.php create mode 100644 app-modules/payment/src/Filament/Resources/Payments/Pages/ViewPayment.php create mode 100644 app-modules/payment/src/Filament/Resources/Payments/PaymentResource.php create mode 100644 app-modules/payment/src/Filament/Resources/Payments/Schemas/PaymentInfolist.php create mode 100644 app-modules/payment/src/Filament/Resources/Payments/Tables/PaymentsTable.php create mode 100644 app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php create mode 100644 app-modules/payment/src/Filament/Resources/Refunds/Pages/ListRefunds.php create mode 100644 app-modules/payment/src/Filament/Resources/Refunds/RefundResource.php create mode 100644 app-modules/payment/src/Filament/Resources/Refunds/Tables/RefundsTable.php create mode 100644 app-modules/payment/src/Filament/Widgets/.gitkeep create mode 100644 app-modules/payment/src/Http/Controllers/PaymentController.php create mode 100644 app-modules/payment/src/Http/Controllers/PaymentWebhookController.php create mode 100644 app-modules/payment/src/Http/Controllers/RefundController.php create mode 100644 app-modules/payment/src/Http/Requests/RefundBookingRequest.php create mode 100644 app-modules/payment/src/Http/Resources/PaymentResource.php create mode 100644 app-modules/payment/src/Http/Resources/RefundResource.php create mode 100644 app-modules/payment/src/Listeners/MarkBookingPaid.php create mode 100644 app-modules/payment/src/Listeners/MarkBookingRefunded.php create mode 100644 app-modules/payment/src/PaymentPlugin.php create mode 100644 app-modules/payment/tests/Feature/ConfirmPaymentActionTest.php create mode 100644 app-modules/payment/tests/Feature/InitiatePaymentApiTest.php create mode 100644 app-modules/payment/tests/Feature/KbzMiniAppGatewayHandleWebhookTest.php create mode 100644 app-modules/payment/tests/Feature/KbzWebhookApiTest.php create mode 100644 app-modules/payment/tests/Feature/KbzWebhookConfirmationTest.php create mode 100644 app-modules/payment/tests/Feature/MarkBookingPaidTest.php create mode 100644 app-modules/payment/tests/Feature/PaymentResourceTest.php create mode 100644 app-modules/payment/tests/Feature/RefundBookingActionTest.php create mode 100644 app-modules/payment/tests/Feature/RefundBookingApiTest.php create mode 100644 app-modules/payment/tests/Feature/RefundResourceTest.php diff --git a/CLAUDE.md b/CLAUDE.md index d669edf..a8290a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,6 +164,11 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - For binaries not wrapped by Sail's own commands (e.g. Pint), run them inside the container: `./vendor/bin/sail exec laravel.test vendor/bin/pint --dirty --format agent`. - Check containers are up first with `./vendor/bin/sail ps` before running commands; start them with `./vendor/bin/sail up -d` if they aren't. +## Database / Migrations + +- **Never run `php artisan migrate:fresh`, `migrate:refresh`, `migrate:reset`, or `db:wipe` against the dev database unless the user explicitly asks for it in that turn.** These drop/recreate all tables and destroy dev data. Use `php artisan migrate` (apply pending) and `php artisan migrate:rollback` (undo the last batch) instead for normal migration work. +- Dev data loss happened once (2026-08-08 ~22:05 local) from exactly this kind of command — do not repeat it. + ## Architecture / ERD Diagram - The canonical tldraw board for this project's architecture and ERD lives at `/home/marcspecta/Documents/EV Booking System Architecture.tldraw` (outside the repo — not committed). Use this path when opening/updating the board with the tldraw-offline skill/agent. diff --git a/app-modules/booking/src/Actions/CancelBookingAction.php b/app-modules/booking/src/Actions/CancelBookingAction.php index 231be9f..c086442 100644 --- a/app-modules/booking/src/Actions/CancelBookingAction.php +++ b/app-modules/booking/src/Actions/CancelBookingAction.php @@ -5,17 +5,37 @@ namespace Modules\Booking\Actions; use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Exceptions\BookingCannotBeCancelledException; use Modules\Booking\Models\Booking; +use Modules\Payment\Actions\RefundBookingAction; /** - * Unpaid path only — a pending_payment booking has no money moved yet, so - * it can be cancelled directly. A confirmed (paid) booking must go through - * a refund first; this action explicitly guards against bypassing that - * (domain.md §5). Wired into that refund path in T5.12. + * A pending_payment booking has no money moved yet, so it cancels directly. + * A confirmed (paid) booking is cancelled by refunding it in full first — + * delegates to RefundBookingAction (Payment module); the booking only + * actually flips to cancelled once that refund succeeds, via + * RefundProcessed/MarkBookingRefunded, not here (domain.md §5). Any other + * status (already cancelled/expired) is rejected outright. */ class CancelBookingAction { - public function handle(Booking $booking): Booking + private const CANCELLATION_REFUND_REASON = 'Booking cancellation'; + + public function __construct( + private RefundBookingAction $refundBookingAction, + ) {} + + public function handle(Booking $booking, ?int $requestedBy = null): Booking { + if ($booking->status === BookingStatus::Confirmed) { + $this->refundBookingAction->handle( + $booking, + (string) $booking->price, + self::CANCELLATION_REFUND_REASON, + $requestedBy, + ); + + return $booking->refresh(); + } + if ($booking->status !== BookingStatus::PendingPayment) { throw BookingCannotBeCancelledException::notPendingPayment($booking); } diff --git a/app-modules/booking/src/Exceptions/BookingCannotBeCancelledException.php b/app-modules/booking/src/Exceptions/BookingCannotBeCancelledException.php index c59ed6e..a1543b1 100644 --- a/app-modules/booking/src/Exceptions/BookingCannotBeCancelledException.php +++ b/app-modules/booking/src/Exceptions/BookingCannotBeCancelledException.php @@ -4,19 +4,20 @@ namespace Modules\Booking\Exceptions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Models\Booking; use RuntimeException; class BookingCannotBeCancelledException extends RuntimeException { + /** + * Confirmed bookings no longer reach this — CancelBookingAction (T5.12) + * refunds them instead. This is only for statuses that can't be + * cancelled at all (already cancelled/expired). + */ public static function notPendingPayment(Booking $booking): self { return new self( - "Booking [{$booking->booking_ref}] cannot be cancelled directly because its status is [{$booking->status->value}]." - .($booking->status === BookingStatus::Confirmed - ? ' A confirmed (paid) booking must go through a refund first.' - : '') + "Booking [{$booking->booking_ref}] cannot be cancelled because its status is [{$booking->status->value}]." ); } diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php b/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php index eb31e4e..d5ff989 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Schemas/BookingInfolist.php @@ -8,6 +8,7 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Modules\Booking\Enums\BookingStatus; +use Modules\Payment\Enums\PaymentStatus; class BookingInfolist { @@ -92,6 +93,34 @@ class BookingInfolist TextEntry::make('car_model')->label('Car Model')->placeholder('—'), ]), ]), + // A booking can have more than one payment attempt if an + // earlier one failed and the customer retried (domain.md §1) + // — full detail (gateway response, refunds) lives on the + // Payment/Refund Filament resources (T5.13), this is just a + // quick-glance summary from the booking side. + Section::make('Payments') + ->schema([ + RepeatableEntry::make('payments') + ->label('') + ->schema([ + Grid::make(6) + ->schema([ + TextEntry::make('gateway')->badge(), + TextEntry::make('status') + ->badge() + ->color(fn (PaymentStatus $state) => match ($state) { + PaymentStatus::Pending => 'warning', + PaymentStatus::Completed => 'success', + PaymentStatus::Failed => 'danger', + }), + TextEntry::make('amount')->numeric(2), + TextEntry::make('currency'), + TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID')->placeholder('—'), + TextEntry::make('completed_at')->dateTime()->placeholder('—'), + ]), + ]) + ->placeholder('No payment attempts yet.'), + ]), ]); } } diff --git a/app-modules/booking/src/Http/Controllers/BookingController.php b/app-modules/booking/src/Http/Controllers/BookingController.php index f19e0ba..42aee6f 100644 --- a/app-modules/booking/src/Http/Controllers/BookingController.php +++ b/app-modules/booking/src/Http/Controllers/BookingController.php @@ -88,11 +88,11 @@ class BookingController extends Controller ->setStatusCode(201); } - public function cancel(Booking $booking): BookingResource + public function cancel(Request $request, Booking $booking): BookingResource { Gate::authorize('cancel', $booking); - $this->cancelBookingAction->handle($booking); + $this->cancelBookingAction->handle($booking, $request->user()?->id); return new BookingResource($booking->load(self::EAGER_LOADS)); } diff --git a/app-modules/booking/src/Policies/BookingPolicy.php b/app-modules/booking/src/Policies/BookingPolicy.php index 372f95c..b6cd470 100644 --- a/app-modules/booking/src/Policies/BookingPolicy.php +++ b/app-modules/booking/src/Policies/BookingPolicy.php @@ -3,6 +3,7 @@ namespace Modules\Booking\Policies; use App\Models\User; +use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Models\Booking; class BookingPolicy @@ -33,12 +34,18 @@ class BookingPolicy } /** - * A booking's owner may cancel their own (still pending_payment only — - * enforced by CancelBookingAction, not here); staff can cancel any - * booking via manage_bookings (domain.md §8). + * A booking's owner may cancel their own pending_payment booking; staff + * can cancel any pending_payment booking via manage_bookings. Cancelling + * a confirmed (paid) booking refunds it (CancelBookingAction, T5.12) — + * that's the same authorization boundary as refund(), staff only + * (domain.md §8: refund initiation is a staff-only operation). */ public function cancel(User $user, Booking $booking): bool { + if ($booking->status === BookingStatus::Confirmed) { + return $user->can('process_refunds'); + } + return $user->id === $booking->user_id || $user->can('manage_bookings'); } @@ -46,4 +53,14 @@ class BookingPolicy { return $user->can('process_refunds'); } + + /** + * A booking's owner may pay for their own (still pending_payment only — + * enforced by InitiatePaymentAction, not here); staff can initiate on + * behalf of a customer via manage_bookings. + */ + public function pay(User $user, Booking $booking): bool + { + return $user->id === $booking->user_id || $user->can('manage_bookings'); + } } diff --git a/app-modules/booking/tests/Feature/BookingCancelApiTest.php b/app-modules/booking/tests/Feature/BookingCancelApiTest.php index 77e62ae..9a3985f 100644 --- a/app-modules/booking/tests/Feature/BookingCancelApiTest.php +++ b/app-modules/booking/tests/Feature/BookingCancelApiTest.php @@ -3,10 +3,48 @@ 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 () { - Permission::findOrCreate('manage_bookings', 'web'); + 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; @@ -23,10 +61,43 @@ test('the owner can cancel their own pending_payment booking', function () { expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled); }); -test('cancelling a confirmed booking surfaces as 422 and leaves it untouched', function () { +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); diff --git a/app-modules/booking/tests/Feature/BookingResourceTest.php b/app-modules/booking/tests/Feature/BookingResourceTest.php index bfcdafd..8fb26f3 100644 --- a/app-modules/booking/tests/Feature/BookingResourceTest.php +++ b/app-modules/booking/tests/Feature/BookingResourceTest.php @@ -7,15 +7,18 @@ use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings; use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking; use Modules\Booking\Models\Booking; use Modules\Booking\Models\BookingVehicleOption; +use Modules\Payment\Enums\PaymentMethod; +use Modules\Payment\Enums\PaymentStatus; +use Modules\Payment\Models\Payment; use Modules\Shared\Enums\VehicleOption; use Spatie\Permission\Models\Permission; beforeEach(function () { - foreach (['view_bookings', 'manage_bookings'] as $permission) { + foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) { Permission::findOrCreate($permission, 'web'); } - $this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings']); + $this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings', 'process_refunds']); $this->actingAs($this->admin); }); @@ -114,6 +117,29 @@ test('can view a booking\'s detail page', function () { ->assertSee($booking->dropoff_address); }); +test('the booking detail page shows its related payments', function () { + $booking = Booking::factory()->create(); + + Payment::factory()->completed()->create([ + 'booking_id' => $booking->id, + 'gateway' => PaymentMethod::KbzMiniApp, + 'gateway_transaction_id' => 'EVB-INFOLIST-TEST-1', + ]); + + Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()]) + ->assertOk() + ->assertSee('EVB-INFOLIST-TEST-1') + ->assertSee(PaymentStatus::Completed->value); +}); + +test('the booking detail page shows a placeholder when there are no payments yet', function () { + $booking = Booking::factory()->create(); + + Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()]) + ->assertOk() + ->assertSee('No payment attempts yet.'); +}); + test('the assign driver action is visible for a confirmed booking and hidden otherwise', function () { $confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]); $pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); diff --git a/app-modules/booking/tests/Unit/CancelBookingActionTest.php b/app-modules/booking/tests/Unit/CancelBookingActionTest.php index 1422beb..aedaf90 100644 --- a/app-modules/booking/tests/Unit/CancelBookingActionTest.php +++ b/app-modules/booking/tests/Unit/CancelBookingActionTest.php @@ -4,35 +4,84 @@ use Modules\Booking\Actions\CancelBookingAction; use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Exceptions\BookingCannotBeCancelledException; 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; -test('it cancels a pending_payment booking', function () { +/** + * Never calls the real KBZ refund API in tests. + */ +class FakeCancelRefundGateway implements PaymentGatewayInterface +{ + public static ?string $lastAmount = 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; + + 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 () { + FakeCancelRefundGateway::$lastAmount = null; + app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelRefundGateway::class); +}); + +test('it cancels a pending_payment booking directly', function () { $booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]); - $cancelled = (new CancelBookingAction)->handle($booking); + $cancelled = app(CancelBookingAction::class)->handle($booking); expect($cancelled->status)->toBe(BookingStatus::Cancelled) ->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled); }); -test('it guards against cancelling a confirmed booking', function () { - $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); +test('it cancels a confirmed booking by refunding it in full', 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-CANCEL-TEST-1', + ]); - expect(fn () => (new CancelBookingAction)->handle($booking)) - ->toThrow(BookingCannotBeCancelledException::class); + $cancelled = app(CancelBookingAction::class)->handle($booking); - expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed); + expect($cancelled->status)->toBe(BookingStatus::Cancelled) + ->and(FakeCancelRefundGateway::$lastAmount)->toBe('15000.00'); }); test('it guards against cancelling an already cancelled booking', function () { $booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]); - expect(fn () => (new CancelBookingAction)->handle($booking)) + expect(fn () => app(CancelBookingAction::class)->handle($booking)) ->toThrow(BookingCannotBeCancelledException::class); }); test('it guards against cancelling an expired booking', function () { $booking = Booking::factory()->create(['status' => BookingStatus::Expired]); - expect(fn () => (new CancelBookingAction)->handle($booking)) + expect(fn () => app(CancelBookingAction::class)->handle($booking)) ->toThrow(BookingCannotBeCancelledException::class); }); diff --git a/app-modules/payment/routes/payment-routes.php b/app-modules/payment/routes/payment-routes.php index b3d9bbc..d1d63c0 100644 --- a/app-modules/payment/routes/payment-routes.php +++ b/app-modules/payment/routes/payment-routes.php @@ -1 +1,18 @@ middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { + Route::post('/payments/{booking:booking_ref}/initiate', [PaymentController::class, 'initiate'])->name('payment.payments.initiate'); + Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund'); +}); + +// No auth:sanctum — the gateway authenticates itself via its own signed +// payload (verified inside each gateway's handleWebhook()), not a bearer +// token (domain.md §6). +Route::prefix('api/v1')->middleware(['api', 'throttle:60,1'])->group(function () { + Route::post('/webhooks/{method}/{encryptBookingId?}', [PaymentWebhookController::class, 'handle'])->name('payment.webhooks.handle'); +}); diff --git a/app-modules/payment/src/Actions/ConfirmPaymentAction.php b/app-modules/payment/src/Actions/ConfirmPaymentAction.php new file mode 100644 index 0000000..7d77340 --- /dev/null +++ b/app-modules/payment/src/Actions/ConfirmPaymentAction.php @@ -0,0 +1,62 @@ +where('gateway_transaction_id', $gatewayTransactionId) + ->first(); + + if ($payment === null || $payment->status !== PaymentStatus::Pending) { + return $payment; + } + + $verified = $this->paymentService->verify($method, $gatewayTransactionId); + + if ($verified->status === PaymentStatus::Pending) { + return $payment; + } + + return DB::transaction(function () use ($payment, $verified) { + $payment->update([ + 'status' => $verified->status, + 'gateway_payload' => $verified->gatewayPayload, + 'completed_at' => now(), + ]); + + match ($verified->status) { + PaymentStatus::Completed => PaymentCompleted::dispatch($payment), + PaymentStatus::Failed => PaymentFailed::dispatch($payment), + PaymentStatus::Pending => null, + }; + + return $payment; + }); + } +} diff --git a/app-modules/payment/src/Actions/InitiatePaymentAction.php b/app-modules/payment/src/Actions/InitiatePaymentAction.php new file mode 100644 index 0000000..a71a902 --- /dev/null +++ b/app-modules/payment/src/Actions/InitiatePaymentAction.php @@ -0,0 +1,88 @@ +status !== BookingStatus::PendingPayment) { + throw PaymentInitiationNotAllowedException::notPendingPayment($booking); + } + + $merchantOrderId = $this->merchantOrderId($booking); + + $result = $this->paymentService->initiate(new PaymentRequestData( + bookingId: $booking->id, + merchantOrderId: $merchantOrderId, + amount: (string) $booking->price, + currency: self::CURRENCY, + method: $method, + notifyUrl: $this->notifyUrl($booking, $method), + )); + + return DB::transaction(fn () => Payment::create([ + 'booking_id' => $booking->id, + 'gateway' => $method, + 'status' => $result->status, + 'amount' => $booking->price, + 'currency' => self::CURRENCY, + 'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId, + 'gateway_payload' => $result->gatewayPayload, + 'initiated_at' => now(), + ])); + } + + /** + * A booking can have more than one payment attempt (retry after + * failure), so the merchant order id must be unique per attempt, not + * just per booking — suffixed with the attempt number. + */ + private function merchantOrderId(Booking $booking): string + { + $attempt = $booking->payments()->count() + 1; + + return "{$booking->booking_ref}-{$attempt}"; + } + + /** + * Embeds the booking id (encrypted, so the URL doesn't leak a raw + * sequential id) as an optional path segment on the webhook URL — + * mirrors bnf_event's `{encryptOrderId?}` on `paymentComplete`, giving + * the webhook a direct way to locate the booking as a redundant check + * alongside `merch_order_id` in the signed payload. Uses Laravel's + * Crypt facade rather than porting bnf_event's hand-rolled openssl + * helper (BNFEventEncryption) — same idea, standard implementation. + */ + private function notifyUrl(Booking $booking, PaymentMethod $method): string + { + return route('payment.webhooks.handle', [ + 'method' => $method->value, + 'encryptBookingId' => Crypt::encryptString((string) $booking->id), + ]); + } +} diff --git a/app-modules/payment/src/Actions/RefundBookingAction.php b/app-modules/payment/src/Actions/RefundBookingAction.php new file mode 100644 index 0000000..e93d0d0 --- /dev/null +++ b/app-modules/payment/src/Actions/RefundBookingAction.php @@ -0,0 +1,84 @@ +status !== BookingStatus::Confirmed) { + throw RefundNotAllowedException::notConfirmed($booking); + } + + $payment = $booking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first(); + + if ($payment === null) { + throw RefundNotAllowedException::noCompletedPayment($booking); + } + + $this->assertWithinRefundableBalance($payment, $amount); + + $result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason); + + $refund = DB::transaction(function () use ($payment, $amount, $reason, $result, $requestedBy) { + $refund = Refund::create([ + 'payment_id' => $payment->id, + 'status' => $result->status, + 'amount' => $amount, + 'reason' => $reason, + 'gateway_refund_id' => $result->gatewayRefundId, + 'gateway_payload' => $result->gatewayPayload, + 'requested_by' => $requestedBy, + 'requested_at' => now(), + 'completed_at' => now(), + ]); + + if ($result->status === RefundStatus::Completed) { + RefundProcessed::dispatch($refund); + } + + return $refund; + }); + + if ($result->status === RefundStatus::Failed) { + throw RefundFailedException::fromResult($result); + } + + return $refund; + } + + private function assertWithinRefundableBalance(Payment $payment, string $amount): void + { + $alreadyRefunded = (string) $payment->refunds()->where('status', RefundStatus::Completed->value)->sum('amount'); + $remaining = bcsub((string) $payment->amount, $alreadyRefunded, 2); + + if (bccomp($amount, $remaining, 2) === 1) { + throw RefundNotAllowedException::exceedsRefundableBalance($payment, $amount, $remaining); + } + } +} diff --git a/app-modules/payment/src/Contracts/PaymentGatewayInterface.php b/app-modules/payment/src/Contracts/PaymentGatewayInterface.php index 4679211..0d0f7ec 100644 --- a/app-modules/payment/src/Contracts/PaymentGatewayInterface.php +++ b/app-modules/payment/src/Contracts/PaymentGatewayInterface.php @@ -5,6 +5,7 @@ namespace Modules\Payment\Contracts; use Modules\Payment\Data\PaymentRequestData; use Modules\Payment\Data\PaymentResultData; use Modules\Payment\Data\RefundResultData; +use Modules\Payment\Exceptions\InvalidWebhookSignatureException; /** * Contract every payment gateway strategy implements (e.g. KbzMiniAppGateway). @@ -30,4 +31,18 @@ interface PaymentGatewayInterface * Reverse (all or part of) a successful payment. */ public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData; + + /** + * Handle an inbound webhook notification — verifies the payload is + * genuinely from the gateway (throwing InvalidWebhookSignatureException + * if not) and maps it to a PaymentResultData. Every gateway implements + * this itself since the notification shape and signing scheme are + * gateway-specific; the webhook controller only routes by PaymentMethod + * and never branches on gateway (domain.md §6). + * + * @param array $payload The raw, as-posted webhook body. + * + * @throws InvalidWebhookSignatureException + */ + public function handleWebhook(array $payload): PaymentResultData; } diff --git a/app-modules/payment/src/Events/PaymentCompleted.php b/app-modules/payment/src/Events/PaymentCompleted.php new file mode 100644 index 0000000..5dade9c --- /dev/null +++ b/app-modules/payment/src/Events/PaymentCompleted.php @@ -0,0 +1,18 @@ +value}]."); + } + + public function render(Request $request): ?JsonResponse + { + return response()->json(['message' => $this->getMessage()], 400); + } +} diff --git a/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php b/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php new file mode 100644 index 0000000..00bda2e --- /dev/null +++ b/app-modules/payment/src/Exceptions/PaymentInitiationNotAllowedException.php @@ -0,0 +1,27 @@ +booking_ref}] cannot have a payment initiated because its status is [{$booking->status->value}], not pending_payment." + ); + } + + public function render(Request $request): ?JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['message' => $this->getMessage()], 422); + } + + return null; + } +} diff --git a/app-modules/payment/src/Exceptions/RefundFailedException.php b/app-modules/payment/src/Exceptions/RefundFailedException.php new file mode 100644 index 0000000..1065d86 --- /dev/null +++ b/app-modules/payment/src/Exceptions/RefundFailedException.php @@ -0,0 +1,31 @@ +message ?? 'Refund failed.'); + } + + public function render(Request $request): ?JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['message' => $this->getMessage()], 422); + } + + return null; + } +} diff --git a/app-modules/payment/src/Exceptions/RefundNotAllowedException.php b/app-modules/payment/src/Exceptions/RefundNotAllowedException.php new file mode 100644 index 0000000..6875058 --- /dev/null +++ b/app-modules/payment/src/Exceptions/RefundNotAllowedException.php @@ -0,0 +1,40 @@ +booking_ref}] cannot be refunded because its status is [{$booking->status->value}], not confirmed." + ); + } + + public static function noCompletedPayment(Booking $booking): self + { + return new self("Booking [{$booking->booking_ref}] has no completed payment to refund."); + } + + public static function exceedsRefundableBalance(Payment $payment, string $amount, string $remaining): self + { + return new self( + "Refund amount [{$amount}] exceeds the remaining refundable balance [{$remaining}] on payment [{$payment->id}]." + ); + } + + public function render(Request $request): ?JsonResponse + { + if ($request->expectsJson()) { + return response()->json(['message' => $this->getMessage()], 422); + } + + return null; + } +} diff --git a/app-modules/payment/src/Filament/Pages/.gitkeep b/app-modules/payment/src/Filament/Pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/payment/src/Filament/Resources/Payments/Pages/ListPayments.php b/app-modules/payment/src/Filament/Resources/Payments/Pages/ListPayments.php new file mode 100644 index 0000000..a99ed11 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Payments/Pages/ListPayments.php @@ -0,0 +1,18 @@ + ListPayments::route('/'), + 'view' => ViewPayment::route('/{record}'), + ]; + } +} diff --git a/app-modules/payment/src/Filament/Resources/Payments/Schemas/PaymentInfolist.php b/app-modules/payment/src/Filament/Resources/Payments/Schemas/PaymentInfolist.php new file mode 100644 index 0000000..e57fd61 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Payments/Schemas/PaymentInfolist.php @@ -0,0 +1,55 @@ +components([ + Section::make('Payment') + ->schema([ + Grid::make(4) + ->schema([ + TextEntry::make('booking.booking_ref')->label('Booking'), + TextEntry::make('gateway')->badge(), + TextEntry::make('status') + ->badge() + ->color(fn (PaymentStatus $state) => match ($state) { + PaymentStatus::Pending => 'warning', + PaymentStatus::Completed => 'success', + PaymentStatus::Failed => 'danger', + }), + TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID'), + TextEntry::make('amount')->numeric(2), + TextEntry::make('currency'), + TextEntry::make('initiated_at')->dateTime(), + TextEntry::make('completed_at')->dateTime()->placeholder('—'), + ]), + ]), + // Raw gateway response — may include data not meant for the + // support role, so it's gated the same as refund initiation + // (process_refunds: admin/super_admin only, domain.md §6). + Section::make('Gateway Response') + ->visible(fn () => auth()->user()?->can('process_refunds') ?? false) + ->schema([ + TextEntry::make('gateway_payload') + ->label('') + ->formatStateUsing(fn (mixed $state) => match (true) { + is_array($state) => json_encode($state, JSON_PRETTY_PRINT), + is_string($state) && $state !== '' => $state, + default => null, + }) + ->placeholder('—') + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app-modules/payment/src/Filament/Resources/Payments/Tables/PaymentsTable.php b/app-modules/payment/src/Filament/Resources/Payments/Tables/PaymentsTable.php new file mode 100644 index 0000000..46160e7 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Payments/Tables/PaymentsTable.php @@ -0,0 +1,66 @@ +modifyQueryUsing(fn (Builder $query) => $query->with('booking')) + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('booking.booking_ref') + ->label('Booking') + ->searchable() + ->sortable(), + TextColumn::make('gateway') + ->badge(), + TextColumn::make('status') + ->badge() + ->color(fn (PaymentStatus $state) => match ($state) { + PaymentStatus::Pending => 'warning', + PaymentStatus::Completed => 'success', + PaymentStatus::Failed => 'danger', + }), + TextColumn::make('amount') + ->numeric(2) + ->sortable(), + TextColumn::make('currency'), + TextColumn::make('gateway_transaction_id') + ->label('Gateway Txn ID') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('initiated_at') + ->dateTime() + ->sortable(), + TextColumn::make('completed_at') + ->dateTime() + ->placeholder('—') + ->sortable(), + ]) + ->filters([ + SelectFilter::make('status') + ->options(array_combine( + array_map(fn (PaymentStatus $status) => $status->value, PaymentStatus::cases()), + array_map(fn (PaymentStatus $status) => str($status->value)->headline()->toString(), PaymentStatus::cases()), + )), + SelectFilter::make('gateway') + ->options(array_combine( + array_map(fn (PaymentMethod $method) => $method->value, PaymentMethod::cases()), + array_map(fn (PaymentMethod $method) => str($method->value)->headline()->toString(), PaymentMethod::cases()), + )), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php new file mode 100644 index 0000000..2c5ab98 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Refunds/Actions/ProcessRefundAction.php @@ -0,0 +1,74 @@ +label('Process Refund') + ->icon(Heroicon::OutlinedReceiptRefund) + ->color('danger') + ->visible(fn (): bool => auth()->user()?->can('process_refunds') ?? false) + ->schema([ + Select::make('payment_id') + ->label('Payment') + ->options(fn () => Payment::query() + ->where('status', PaymentStatus::Completed->value) + ->with('booking') + ->get() + ->mapWithKeys(fn (Payment $payment) => [ + $payment->id => "{$payment->booking?->booking_ref} — {$payment->amount} {$payment->currency} (#{$payment->id})", + ])) + ->searchable() + ->required(), + TextInput::make('amount') + ->numeric() + ->minValue(0.01) + ->required(), + Textarea::make('reason') + ->required(), + ]) + ->action(function (array $data): void { + $payment = Payment::with('booking')->findOrFail($data['payment_id']); + + try { + app(RefundBookingAction::class)->handle( + $payment->booking, + (string) $data['amount'], + $data['reason'], + auth()->id(), + ); + + Notification::make() + ->title('Refund processed') + ->success() + ->send(); + } catch (RefundNotAllowedException|RefundFailedException $exception) { + Notification::make() + ->title('Refund failed') + ->body($exception->getMessage()) + ->danger() + ->send(); + } + }); + } +} diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Pages/ListRefunds.php b/app-modules/payment/src/Filament/Resources/Refunds/Pages/ListRefunds.php new file mode 100644 index 0000000..6d9dcdb --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Refunds/Pages/ListRefunds.php @@ -0,0 +1,21 @@ + ListRefunds::route('/'), + ]; + } +} diff --git a/app-modules/payment/src/Filament/Resources/Refunds/Tables/RefundsTable.php b/app-modules/payment/src/Filament/Resources/Refunds/Tables/RefundsTable.php new file mode 100644 index 0000000..ac94908 --- /dev/null +++ b/app-modules/payment/src/Filament/Resources/Refunds/Tables/RefundsTable.php @@ -0,0 +1,62 @@ +modifyQueryUsing(fn (Builder $query) => $query->with(['payment.booking', 'requestedBy'])) + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('payment.booking.booking_ref') + ->label('Booking') + ->searchable() + ->sortable(), + TextColumn::make('payment.gateway') + ->label('Gateway') + ->badge(), + TextColumn::make('status') + ->badge() + ->color(fn (RefundStatus $state) => match ($state) { + RefundStatus::Pending => 'warning', + RefundStatus::Completed => 'success', + RefundStatus::Failed => 'danger', + }), + TextColumn::make('amount') + ->numeric(2) + ->sortable(), + TextColumn::make('reason') + ->limit(40) + ->tooltip(fn (?string $state) => $state), + TextColumn::make('gateway_refund_id') + ->label('Gateway Refund ID') + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('requestedBy.name') + ->label('Requested By') + ->placeholder('—'), + TextColumn::make('requested_at') + ->dateTime() + ->sortable(), + TextColumn::make('completed_at') + ->dateTime() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->options(array_combine( + array_map(fn (RefundStatus $status) => $status->value, RefundStatus::cases()), + array_map(fn (RefundStatus $status) => str($status->value)->headline()->toString(), RefundStatus::cases()), + )), + ]); + } +} diff --git a/app-modules/payment/src/Filament/Widgets/.gitkeep b/app-modules/payment/src/Filament/Widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/payment/src/Gateways/KbzMiniAppGateway.php b/app-modules/payment/src/Gateways/KbzMiniAppGateway.php index 692d33a..8d13fa7 100644 --- a/app-modules/payment/src/Gateways/KbzMiniAppGateway.php +++ b/app-modules/payment/src/Gateways/KbzMiniAppGateway.php @@ -9,8 +9,10 @@ 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\Enums\RefundStatus; +use Modules\Payment\Exceptions\InvalidWebhookSignatureException; use Modules\Payment\Support\KbzSignature; /** @@ -157,6 +159,46 @@ class KbzMiniAppGateway implements PaymentGatewayInterface ); } + /** + * bnf_event's equivalent (`OrderController::paymentComplete` / + * `KBZMiniApp::save`) trusted the raw `trade_status` from the POST body + * and only re-verified via `queryorder` afterward — it never checked + * `sign` on the inbound payload at all. This closes that gap: KBZ signs + * webhook notifications with the same scheme as our outbound calls + * (confirmed against KBZ's "6 Callback Interface" spec), so the + * signature is checked first, before any of the payload is trusted. + * + * @param array $payload + */ + public function handleWebhook(array $payload): PaymentResultData + { + /** @var array $notification */ + $notification = (array) ($payload['Request'] ?? []); + + if (! $this->hasValidSignature($notification)) { + throw InvalidWebhookSignatureException::forGateway(PaymentMethod::KbzMiniApp); + } + + return new PaymentResultData( + status: $this->mapTradeStatus($notification['trade_status'] ?? null), + gatewayTransactionId: $notification['merch_order_id'] ?? null, + gatewayPayload: $notification, + message: $notification['trade_status'] ?? null, + ); + } + + /** + * @param array $notification + */ + private function hasValidSignature(array $notification): bool + { + if (! isset($notification['sign']) || ! is_string($notification['sign']) || $this->merchantKey === '') { + return false; + } + + return hash_equals(KbzSignature::sign($notification, $this->merchantKey), strtoupper($notification['sign'])); + } + /** * @return array */ diff --git a/app-modules/payment/src/Http/Controllers/PaymentController.php b/app-modules/payment/src/Http/Controllers/PaymentController.php new file mode 100644 index 0000000..9d213ea --- /dev/null +++ b/app-modules/payment/src/Http/Controllers/PaymentController.php @@ -0,0 +1,28 @@ +initiatePaymentAction->handle($booking); + + return (new PaymentResource($payment)) + ->response() + ->setStatusCode(201); + } +} diff --git a/app-modules/payment/src/Http/Controllers/PaymentWebhookController.php b/app-modules/payment/src/Http/Controllers/PaymentWebhookController.php new file mode 100644 index 0000000..bcc02c0 --- /dev/null +++ b/app-modules/payment/src/Http/Controllers/PaymentWebhookController.php @@ -0,0 +1,92 @@ +all(); + + // Optional, mirrors bnf_event's `{encryptOrderId?}` — a redundant, + // signature-independent way to locate the booking directly from the + // URL (used by ConfirmPaymentAction, T5.10) alongside whatever + // order id the gateway's own signed payload carries. Never fatal if + // it's missing or fails to decrypt; the signature check is what + // actually authenticates this request. + $bookingId = $this->decryptBookingId($encryptBookingId); + + try { + $result = $this->gateways->make($method)->handleWebhook($payload); + } catch (InvalidWebhookSignatureException $exception) { + // Raw payload persisted regardless of outcome (domain.md §6). + Log::warning('Payment webhook rejected: invalid signature', [ + 'gateway' => $method->value, + 'booking_id' => $bookingId, + 'payload' => $payload, + ]); + + throw $exception; + } + + Log::info('Payment webhook received', [ + 'gateway' => $method->value, + 'booking_id' => $bookingId, + 'status' => $result->status->value, + 'gateway_transaction_id' => $result->gatewayTransactionId, + 'payload' => $payload, + ]); + + $payment = $result->gatewayTransactionId !== null + ? $this->confirmPaymentAction->handle($method, $result->gatewayTransactionId) + : null; + + if ($payment === null) { + Log::warning('Payment webhook has no matching payment to confirm', [ + 'gateway' => $method->value, + 'gateway_transaction_id' => $result->gatewayTransactionId, + ]); + } + + // KBZ retries any delivery that doesn't get back this exact literal + // body — we acknowledge regardless of whether a matching payment + // was found, since retrying won't fix that mismatch. + return response('success', 200); + } + + private function decryptBookingId(?string $encryptBookingId): ?int + { + if ($encryptBookingId === null) { + return null; + } + + try { + return (int) Crypt::decryptString($encryptBookingId); + } catch (DecryptException) { + return null; + } + } +} diff --git a/app-modules/payment/src/Http/Controllers/RefundController.php b/app-modules/payment/src/Http/Controllers/RefundController.php new file mode 100644 index 0000000..1256b9d --- /dev/null +++ b/app-modules/payment/src/Http/Controllers/RefundController.php @@ -0,0 +1,36 @@ +validated(); + + $refund = $this->refundBookingAction->handle( + $booking, + (string) $validated['amount'], + $validated['reason'], + $request->user()?->id, + ); + + return (new RefundResource($refund)) + ->response() + ->setStatusCode(201); + } +} diff --git a/app-modules/payment/src/Http/Requests/RefundBookingRequest.php b/app-modules/payment/src/Http/Requests/RefundBookingRequest.php new file mode 100644 index 0000000..e226ae9 --- /dev/null +++ b/app-modules/payment/src/Http/Requests/RefundBookingRequest.php @@ -0,0 +1,28 @@ +> + */ + public function rules(): array + { + return [ + 'amount' => ['required', 'numeric', 'gt:0'], + 'reason' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/app-modules/payment/src/Http/Resources/PaymentResource.php b/app-modules/payment/src/Http/Resources/PaymentResource.php new file mode 100644 index 0000000..aeed1d1 --- /dev/null +++ b/app-modules/payment/src/Http/Resources/PaymentResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'booking_id' => $this->booking_id, + 'gateway' => $this->gateway, + 'status' => $this->status, + 'amount' => $this->amount, + 'currency' => $this->currency, + 'gateway_transaction_id' => $this->gateway_transaction_id, + // The gateway's raw response — the client needs this to render the + // KBZ Mini App payment sheet (e.g. prepay_id). + 'gateway_payload' => $this->gateway_payload, + 'initiated_at' => $this->initiated_at, + ]; + } +} diff --git a/app-modules/payment/src/Http/Resources/RefundResource.php b/app-modules/payment/src/Http/Resources/RefundResource.php new file mode 100644 index 0000000..43c35c2 --- /dev/null +++ b/app-modules/payment/src/Http/Resources/RefundResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'payment_id' => $this->payment_id, + 'status' => $this->status, + 'amount' => $this->amount, + 'reason' => $this->reason, + 'gateway_refund_id' => $this->gateway_refund_id, + 'requested_by' => $this->requested_by, + 'requested_at' => $this->requested_at, + 'completed_at' => $this->completed_at, + ]; + } +} diff --git a/app-modules/payment/src/Listeners/MarkBookingPaid.php b/app-modules/payment/src/Listeners/MarkBookingPaid.php new file mode 100644 index 0000000..c4bf839 --- /dev/null +++ b/app-modules/payment/src/Listeners/MarkBookingPaid.php @@ -0,0 +1,25 @@ +payment->booking; + + if ($booking->status === BookingStatus::PendingPayment) { + $booking->update(['status' => BookingStatus::Confirmed]); + } + } +} diff --git a/app-modules/payment/src/Listeners/MarkBookingRefunded.php b/app-modules/payment/src/Listeners/MarkBookingRefunded.php new file mode 100644 index 0000000..952500d --- /dev/null +++ b/app-modules/payment/src/Listeners/MarkBookingRefunded.php @@ -0,0 +1,25 @@ +refund->payment->booking; + + if ($booking->status === BookingStatus::Confirmed) { + $booking->update(['status' => BookingStatus::Cancelled]); + } + } +} diff --git a/app-modules/payment/src/PaymentPlugin.php b/app-modules/payment/src/PaymentPlugin.php new file mode 100644 index 0000000..cc74a1a --- /dev/null +++ b/app-modules/payment/src/PaymentPlugin.php @@ -0,0 +1,38 @@ +discoverResources( + in: __DIR__.'/Filament/Resources', + for: 'Modules\Payment\Filament\Resources', + ) + ->discoverPages( + in: __DIR__.'/Filament/Pages', + for: 'Modules\Payment\Filament\Pages', + ) + ->discoverWidgets( + in: __DIR__.'/Filament/Widgets', + for: 'Modules\Payment\Filament\Widgets', + ); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(static::class); + } +} diff --git a/app-modules/payment/src/Providers/PaymentServiceProvider.php b/app-modules/payment/src/Providers/PaymentServiceProvider.php index 8a027c9..9fa2af7 100644 --- a/app-modules/payment/src/Providers/PaymentServiceProvider.php +++ b/app-modules/payment/src/Providers/PaymentServiceProvider.php @@ -2,10 +2,15 @@ namespace Modules\Payment\Providers; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Modules\Payment\Enums\PaymentMethod; +use Modules\Payment\Events\PaymentCompleted; +use Modules\Payment\Events\RefundProcessed; use Modules\Payment\Factories\PaymentGatewayFactory; use Modules\Payment\Gateways\KbzMiniAppGateway; +use Modules\Payment\Listeners\MarkBookingPaid; +use Modules\Payment\Listeners\MarkBookingRefunded; class PaymentServiceProvider extends ServiceProvider { @@ -19,5 +24,9 @@ class PaymentServiceProvider extends ServiceProvider }); } - public function boot(): void {} + public function boot(): void + { + Event::listen(PaymentCompleted::class, MarkBookingPaid::class); + Event::listen(RefundProcessed::class, MarkBookingRefunded::class); + } } diff --git a/app-modules/payment/tests/Feature/ConfirmPaymentActionTest.php b/app-modules/payment/tests/Feature/ConfirmPaymentActionTest.php new file mode 100644 index 0000000..91d6033 --- /dev/null +++ b/app-modules/payment/tests/Feature/ConfirmPaymentActionTest.php @@ -0,0 +1,153 @@ + 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); +}); diff --git a/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php b/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php new file mode 100644 index 0000000..253bccc --- /dev/null +++ b/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php @@ -0,0 +1,137 @@ +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(); +}); diff --git a/app-modules/payment/tests/Feature/KbzMiniAppGatewayHandleWebhookTest.php b/app-modules/payment/tests/Feature/KbzMiniAppGatewayHandleWebhookTest.php new file mode 100644 index 0000000..f9040d4 --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzMiniAppGatewayHandleWebhookTest.php @@ -0,0 +1,79 @@ + 'APPID123', + 'merchant_code' => 'MERCH001', + 'merchant_key' => 'test-merchant-key', + 'base_url' => 'https://kbz.test/gateway', +]; + +/** + * @return array + */ +$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); +}); diff --git a/app-modules/payment/tests/Feature/KbzWebhookApiTest.php b/app-modules/payment/tests/Feature/KbzWebhookApiTest.php new file mode 100644 index 0000000..5637a08 --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzWebhookApiTest.php @@ -0,0 +1,93 @@ + $overrides + * @return array + */ +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(); +}); diff --git a/app-modules/payment/tests/Feature/KbzWebhookConfirmationTest.php b/app-modules/payment/tests/Feature/KbzWebhookConfirmationTest.php new file mode 100644 index 0000000..d4c08d0 --- /dev/null +++ b/app-modules/payment/tests/Feature/KbzWebhookConfirmationTest.php @@ -0,0 +1,89 @@ + '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 + */ +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); +}); diff --git a/app-modules/payment/tests/Feature/MarkBookingPaidTest.php b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php new file mode 100644 index 0000000..67037e0 --- /dev/null +++ b/app-modules/payment/tests/Feature/MarkBookingPaidTest.php @@ -0,0 +1,26 @@ +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); +}); diff --git a/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php b/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php index a1b9994..62d83d2 100644 --- a/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php +++ b/app-modules/payment/tests/Feature/PaymentGatewayFactoryTest.php @@ -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 () { diff --git a/app-modules/payment/tests/Feature/PaymentResourceTest.php b/app-modules/payment/tests/Feature/PaymentResourceTest.php new file mode 100644 index 0000000..19caef6 --- /dev/null +++ b/app-modules/payment/tests/Feature/PaymentResourceTest.php @@ -0,0 +1,80 @@ +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'); +}); diff --git a/app-modules/payment/tests/Feature/RefundBookingActionTest.php b/app-modules/payment/tests/Feature/RefundBookingActionTest.php new file mode 100644 index 0000000..ef59361 --- /dev/null +++ b/app-modules/payment/tests/Feature/RefundBookingActionTest.php @@ -0,0 +1,154 @@ + 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); +}); diff --git a/app-modules/payment/tests/Feature/RefundBookingApiTest.php b/app-modules/payment/tests/Feature/RefundBookingApiTest.php new file mode 100644 index 0000000..fbab93f --- /dev/null +++ b/app-modules/payment/tests/Feature/RefundBookingApiTest.php @@ -0,0 +1,136 @@ +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(); +}); diff --git a/app-modules/payment/tests/Feature/RefundResourceTest.php b/app-modules/payment/tests/Feature/RefundResourceTest.php new file mode 100644 index 0000000..a12c84e --- /dev/null +++ b/app-modules/payment/tests/Feature/RefundResourceTest.php @@ -0,0 +1,126 @@ +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(); +}); diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index ec078bc..4731363 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -21,6 +21,7 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use Modules\Booking\BookingPlugin; use Modules\Catalog\CatalogPlugin; +use Modules\Payment\PaymentPlugin; use Modules\Routing\RoutingPlugin; class AdminPanelProvider extends PanelProvider @@ -45,6 +46,7 @@ class AdminPanelProvider extends PanelProvider CatalogPlugin::make(), RoutingPlugin::make(), BookingPlugin::make(), + PaymentPlugin::make(), ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')