diff --git a/.env.example b/.env.example index 9c86413..ed6a282 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,9 @@ KBZ_APP_ID= KBZ_MERCHANT_CODE= KBZ_MERCHANT_KEY= KBZ_BASE_URL= +KBZ_CREATE_ORDER_URL= +KBZ_QUERY_ORDER_URL= +KBZ_REFUND_ORDER_URL= KBZ_NOTIFY_URL= KBZ_CERT_PATH= KBZ_CERT_KEY_PATH= @@ -83,3 +86,6 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +FASTAPI_AGENT_JWT_SECRET= +FASTAPI_AGENT_JWT_ALGORITHM=HS256 diff --git a/app-modules/booking/routes/booking-routes.php b/app-modules/booking/routes/booking-routes.php index b385706..10b3558 100644 --- a/app-modules/booking/routes/booking-routes.php +++ b/app-modules/booking/routes/booking-routes.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Route; use Modules\Booking\Http\Controllers\BookingController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-write'])->group(function () { Route::get('/bookings', [BookingController::class, 'index'])->name('booking.bookings.index'); Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show'); Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store'); diff --git a/app-modules/booking/src/Enums/BookingChannel.php b/app-modules/booking/src/Enums/BookingChannel.php index 84d3fd9..38c2e0e 100644 --- a/app-modules/booking/src/Enums/BookingChannel.php +++ b/app-modules/booking/src/Enums/BookingChannel.php @@ -7,10 +7,29 @@ namespace Modules\Booking\Enums; */ enum BookingChannel: string { - case MiniApp = 'mini_app'; + case MiniApp = 'kbz_miniapp'; case Android = 'android'; case Ios = 'ios'; case Web = 'web'; case Agent = 'agent'; case Admin = 'admin'; + + /** + * Resolve the client's channel from its `Device-Type` header, defaulting + * to MiniApp when the header is missing or unrecognized. Agent/Admin are + * deliberately excluded from what a header can select — those two are + * derived from how the request authenticated (FastAPI JWT, Filament), + * never a client-supplied value, so a customer can't spoof one via the + * header. + */ + public static function fromDeviceTypeHeader(?string $deviceType): self + { + $channel = self::tryFrom((string) $deviceType); + + if ($channel === null || in_array($channel, [self::Agent, self::Admin], true)) { + return self::MiniApp; + } + + return $channel; + } } diff --git a/app-modules/booking/src/Http/Controllers/BookingController.php b/app-modules/booking/src/Http/Controllers/BookingController.php index 42aee6f..3e58d92 100644 --- a/app-modules/booking/src/Http/Controllers/BookingController.php +++ b/app-modules/booking/src/Http/Controllers/BookingController.php @@ -31,10 +31,21 @@ class BookingController extends Controller public function index(Request $request): AnonymousResourceCollection { - Gate::authorize('viewAny', Booking::class); + $openid = $request->attributes->get('fastapi_openid'); - $bookings = Booking::query() - ->where('user_id', $request->user()->id) + $query = Booking::query(); + + if ($openid !== null) { + // FastAPI agent (JWT auth, no Laravel user) — scoped to the + // verified token's own openid, never a client-supplied value, + // so one agent session can't list another customer's bookings. + $query->where('openid', $openid); + } else { + Gate::authorize('viewAny', Booking::class); + $query->where('user_id', $request->user()->id); + } + + $bookings = $query ->with(self::EAGER_LOADS) ->latest() ->paginate(); @@ -42,9 +53,15 @@ class BookingController extends Controller return BookingResource::collection($bookings); } - public function show(Booking $booking): BookingResource + public function show(Request $request, Booking $booking): BookingResource { - Gate::authorize('view', $booking); + $openid = $request->attributes->get('fastapi_openid'); + + if ($openid !== null) { + abort_if($booking->openid !== $openid, 404); + } else { + Gate::authorize('view', $booking); + } return new BookingResource($booking->load(self::EAGER_LOADS)); } @@ -52,6 +69,11 @@ class BookingController extends Controller public function store(StoreBookingRequest $request): JsonResponse { $validated = $request->validated(); + $openid = $request->attributes->get('fastapi_openid'); + + if ($openid === null) { + Gate::authorize('create', Booking::class); + } $selections = array_map( fn (array $selection) => new VehicleSelectionData( @@ -61,6 +83,14 @@ class BookingController extends Controller $validated['selections'], ); + // The agent's own auth path always wins over anything a header could + // claim; customer channels come from Device-Type, not a + // client-supplied body field (BookingChannel::fromDeviceTypeHeader + // already refuses to hand back Agent/Admin from a header value). + $channel = $openid !== null + ? BookingChannel::Agent + : BookingChannel::fromDeviceTypeHeader($request->header('Device-Type')); + $booking = $this->createBookingAction->handle(new CreateBookingData( evRouteId: $validated['ev_route_id'], departureTimeSlotId: $validated['departure_time_slot_id'], @@ -70,11 +100,12 @@ class BookingController extends Controller passengerPhone: $validated['passenger_phone'], pickupAddress: $validated['pickup_address'], dropoffAddress: $validated['dropoff_address'], - createdByChannel: isset($validated['created_by_channel']) - ? BookingChannel::from($validated['created_by_channel']) - : BookingChannel::MiniApp, + createdByChannel: $channel, + // A verified FastAPI JWT's own openid always wins over a + // client-supplied one — a request can never claim a different + // customer's identity than its own token proves. userId: $request->user()?->id, - openid: $validated['openid'] ?? null, + openid: $openid ?? $validated['openid'] ?? null, pickupLat: $validated['pickup_lat'] ?? null, pickupLng: $validated['pickup_lng'] ?? null, dropoffLat: $validated['dropoff_lat'] ?? null, diff --git a/app-modules/booking/src/Http/Requests/StoreBookingRequest.php b/app-modules/booking/src/Http/Requests/StoreBookingRequest.php index f1b94b1..98e33b7 100644 --- a/app-modules/booking/src/Http/Requests/StoreBookingRequest.php +++ b/app-modules/booking/src/Http/Requests/StoreBookingRequest.php @@ -4,7 +4,6 @@ namespace Modules\Booking\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use Modules\Booking\Enums\BookingChannel; use Modules\Shared\Enums\VehicleOption; /** @@ -41,11 +40,8 @@ class StoreBookingRequest extends FormRequest 'dropoff_address' => ['required', 'string', 'max:500'], 'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'], 'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'], - 'openid' => ['nullable', 'string', 'max:255'], 'is_round_trip' => ['sometimes', 'boolean'], 'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'], - // Admin-created bookings go through the Filament resource (T4.7), not this API. - 'created_by_channel' => ['sometimes', Rule::enum(BookingChannel::class)->except(BookingChannel::Admin)], ]; } } diff --git a/app-modules/booking/tests/Feature/BookingCreateApiTest.php b/app-modules/booking/tests/Feature/BookingCreateApiTest.php index e85384d..5461685 100644 --- a/app-modules/booking/tests/Feature/BookingCreateApiTest.php +++ b/app-modules/booking/tests/Feature/BookingCreateApiTest.php @@ -1,6 +1,7 @@ assertStatus(422) ->assertJsonValidationErrors(['selections']); }); + +test('created_by_channel defaults to kbz_miniapp when no Device-Type header is sent', function () { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ])) + ->assertCreated() + ->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value); +}); + +test('created_by_channel is taken from the Device-Type header', function (string $deviceType, BookingChannel $expected) { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->withHeader('Device-Type', $deviceType) + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ])) + ->assertCreated() + ->assertJsonPath('data.created_by_channel', $expected->value); +})->with([ + 'android' => ['android', BookingChannel::Android], + 'ios' => ['ios', BookingChannel::Ios], + 'web' => ['web', BookingChannel::Web], + 'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp], +]); + +test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) { + [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->withHeader('Device-Type', $deviceType) + ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ + ['vehicle_option' => 'back_seat', 'passenger_count' => 1], + ])) + ->assertCreated() + ->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value); +})->with([ + 'agent' => ['agent'], + 'admin' => ['admin'], + 'unrecognized value' => ['smart-fridge'], +]); diff --git a/app-modules/booking/tests/Feature/FastApiAgentBookingTest.php b/app-modules/booking/tests/Feature/FastApiAgentBookingTest.php new file mode 100644 index 0000000..1e24c35 --- /dev/null +++ b/app-modules/booking/tests/Feature/FastApiAgentBookingTest.php @@ -0,0 +1,86 @@ + 'test-fastapi-agent-secret-0123456789ABCDEF']); + config(['services.fastapi_agent.jwt_algorithm' => 'HS256']); +}); + +function fastApiAgentToken(string $openid): string +{ + return JWT::encode([ + 'sub' => $openid, + 'iat' => time(), + 'exp' => time() + 3600, + ], 'test-fastapi-agent-secret-0123456789ABCDEF', 'HS256'); +} + +test('a FastAPI JWT booking is stored against the verified openid, ignoring a spoofed body value', function () { + config(['booking.back_seat_enabled' => true]); + + $route = EvRoute::factory()->create(['is_active' => true]); + $timeSlot = DepartureTimeSlot::factory()->create(); + $route->timeSlots()->attach($timeSlot->id, ['is_active' => true]); + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::BackSeat, + 'price' => '15000.00', + ]); + + $token = fastApiAgentToken('real-customer-openid'); + + $this->withHeader('Authorization', "Bearer {$token}") + ->withHeader('Device-Type', 'android') // the agent's own channel always wins, ignored here. + ->postJson('/api/v1/bookings', [ + 'ev_route_id' => $route->id, + 'departure_time_slot_id' => $timeSlot->id, + 'travel_date' => now()->addDay()->toDateString(), + 'selections' => [['vehicle_option' => 'back_seat', 'passenger_count' => 1]], + 'passenger_name' => 'Jane Doe', + 'passenger_phone' => '+959123456789', + 'pickup_address' => '123 Pickup St', + 'dropoff_address' => '456 Dropoff Ave', + 'openid' => 'spoofed-openid', + ]) + ->assertCreated(); + + $booking = Booking::sole(); + expect($booking->openid)->toBe('real-customer-openid') + ->and($booking->user_id)->toBeNull() + ->and($booking->created_by_channel)->toBe(BookingChannel::Agent); +}); + +test('a FastAPI JWT can list and show only its own openid\'s bookings', function () { + $mine = Booking::factory()->create(['openid' => 'agent-openid-mine']); + Booking::factory()->create(['openid' => 'agent-openid-someone-else']); + + $token = fastApiAgentToken('agent-openid-mine'); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/v1/bookings') + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $mine->id); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson("/api/v1/bookings/{$mine->booking_ref}") + ->assertSuccessful() + ->assertJsonPath('data.id', $mine->id); +}); + +test('a FastAPI JWT gets a 404 for a booking belonging to a different openid', function () { + $someoneElses = Booking::factory()->create(['openid' => 'agent-openid-someone-else']); + + $token = fastApiAgentToken('agent-openid-mine'); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson("/api/v1/bookings/{$someoneElses->booking_ref}") + ->assertNotFound(); +}); diff --git a/app-modules/catalog/routes/catalog-routes.php b/app-modules/catalog/routes/catalog-routes.php index 6f452e5..2e0c5c0 100644 --- a/app-modules/catalog/routes/catalog-routes.php +++ b/app-modules/catalog/routes/catalog-routes.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route; use Modules\Catalog\Http\Controllers\DestinationController; use Modules\Catalog\Http\Controllers\EvCompanyController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () { Route::get('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index'); Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index'); }); diff --git a/app-modules/catalog/src/Http/Controllers/DestinationController.php b/app-modules/catalog/src/Http/Controllers/DestinationController.php index afc9732..6f35901 100644 --- a/app-modules/catalog/src/Http/Controllers/DestinationController.php +++ b/app-modules/catalog/src/Http/Controllers/DestinationController.php @@ -2,6 +2,7 @@ namespace Modules\Catalog\Http\Controllers; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Routing\Controller; use Modules\Catalog\Http\Resources\DestinationResource; @@ -9,10 +10,23 @@ use Modules\Catalog\Models\Destination; class DestinationController extends Controller { - public function index(): AnonymousResourceCollection + public function index(Request $request): AnonymousResourceCollection { + $terms = array_filter(array_map( + trim(...), + explode(',', (string) $request->string('search')), + )); + return DestinationResource::collection( - Destination::query()->where('is_active', true)->get() + Destination::query() + ->where('is_active', true) + ->when($terms !== [], fn ($query) => $query->where(function ($query) use ($terms) { + foreach ($terms as $term) { + $query->orWhere('name', 'ilike', "%{$term}%") + ->orWhere('mm_name', 'ilike', "%{$term}%"); + } + })) + ->paginate() ); } } diff --git a/app-modules/catalog/src/Http/Controllers/EvCompanyController.php b/app-modules/catalog/src/Http/Controllers/EvCompanyController.php index 078d340..c88a392 100644 --- a/app-modules/catalog/src/Http/Controllers/EvCompanyController.php +++ b/app-modules/catalog/src/Http/Controllers/EvCompanyController.php @@ -12,7 +12,7 @@ class EvCompanyController extends Controller public function index(): AnonymousResourceCollection { return EvCompanyResource::collection( - EvCompany::query()->where('is_active', true)->get() + EvCompany::query()->where('is_active', true)->paginate() ); } } diff --git a/app-modules/catalog/tests/Feature/CatalogReadApiTest.php b/app-modules/catalog/tests/Feature/CatalogReadApiTest.php index 622c4d4..ea1966c 100644 --- a/app-modules/catalog/tests/Feature/CatalogReadApiTest.php +++ b/app-modules/catalog/tests/Feature/CatalogReadApiTest.php @@ -30,6 +30,36 @@ test('lists active destinations', function () { ->assertJsonFragment(['id' => $active->id]); }); +test('paginates companies and destinations', function () { + EvCompany::factory()->count(20)->create(['is_active' => true]); + Destination::factory()->count(20)->create(['is_active' => true]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/companies') + ->assertSuccessful() + ->assertJsonCount(15, 'data') + ->assertJsonPath('meta.total', 20); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/destinations') + ->assertSuccessful() + ->assertJsonCount(15, 'data') + ->assertJsonPath('meta.total', 20); +}); + +test('searches destinations by comma-separated terms', function () { + $yangon = Destination::factory()->create(['is_active' => true, 'name' => 'Yangon']); + $mandalay = Destination::factory()->create(['is_active' => true, 'name' => 'Mandalay']); + Destination::factory()->create(['is_active' => true, 'name' => 'Bagan']); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/destinations?search=yangon,mandalay') + ->assertSuccessful() + ->assertJsonCount(2, 'data') + ->assertJsonFragment(['id' => $yangon->id]) + ->assertJsonFragment(['id' => $mandalay->id]); +}); + test('companies endpoint rejects unauthenticated requests', function () { $this->getJson('/api/v1/companies')->assertUnauthorized(); }); diff --git a/app-modules/identity/src/Http/Middleware/AuthenticateSanctumOrFastApiJwt.php b/app-modules/identity/src/Http/Middleware/AuthenticateSanctumOrFastApiJwt.php new file mode 100644 index 0000000..0072f0a --- /dev/null +++ b/app-modules/identity/src/Http/Middleware/AuthenticateSanctumOrFastApiJwt.php @@ -0,0 +1,59 @@ +check()) { + Auth::shouldUse('sanctum'); + + return $next($request); + } + + if ($token = $request->bearerToken()) { + try { + $payload = JWT::decode($token, new Key( + config('services.fastapi_agent.jwt_secret'), + config('services.fastapi_agent.jwt_algorithm'), + )); + + $request->attributes->set('fastapi_openid', $payload->sub); + + return $next($request); + } catch (Throwable $e) { + // Expired/malformed/wrong-signature tokens are routine auth + // failures, not application errors — log at debug level + // only, never report() to the error tracker. + Log::debug('FastAPI agent JWT rejected.', ['reason' => $e->getMessage()]); + } + } + + abort(401, 'Unauthenticated.'); + } +} diff --git a/app-modules/identity/tests/Feature/AuthenticateSanctumOrFastApiJwtTest.php b/app-modules/identity/tests/Feature/AuthenticateSanctumOrFastApiJwtTest.php new file mode 100644 index 0000000..b8be404 --- /dev/null +++ b/app-modules/identity/tests/Feature/AuthenticateSanctumOrFastApiJwtTest.php @@ -0,0 +1,73 @@ + 'test-fastapi-agent-secret-0123456789ABCDEF']); + config(['services.fastapi_agent.jwt_algorithm' => 'HS256']); + + Route::middleware(['api.auth']) + ->get('/__test/sanctum-or-fastapi-jwt', fn (Request $request) => response()->json([ + 'openid' => $request->attributes->get('fastapi_openid'), + 'user_id' => $request->user()?->id, + ])); +}); + +function fastApiJwt(array $overrides = []): string +{ + $payload = array_merge([ + 'sub' => 'mini-app-openid-123', + 'iat' => time(), + 'exp' => time() + 3600, + ], $overrides); + + return JWT::encode($payload, 'test-fastapi-agent-secret-0123456789ABCDEF', 'HS256'); +} + +test('a valid Sanctum token authenticates as a real user, no openid attribute set', function () { + $user = User::factory()->create(); + $token = $user->createToken('test-token')->plainTextToken; + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/__test/sanctum-or-fastapi-jwt') + ->assertSuccessful() + ->assertJson(['openid' => null, 'user_id' => $user->id]); +}); + +test('a valid FastAPI JWT authenticates with the verified openid claim and no user', function () { + $token = fastApiJwt(['sub' => 'agent-openid-456']); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/__test/sanctum-or-fastapi-jwt') + ->assertSuccessful() + ->assertJson(['openid' => 'agent-openid-456', 'user_id' => null]); +}); + +test('an expired FastAPI JWT is rejected', function () { + $token = fastApiJwt(['exp' => time() - 60]); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/__test/sanctum-or-fastapi-jwt') + ->assertUnauthorized(); +}); + +test('a FastAPI JWT signed with the wrong secret is rejected', function () { + $token = JWT::encode(['sub' => 'agent-openid-456', 'exp' => time() + 3600], 'wrong-secret-0123456789ABCDEFGHIJKLMNOP', 'HS256'); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/__test/sanctum-or-fastapi-jwt') + ->assertUnauthorized(); +}); + +test('a malformed bearer token is rejected', function () { + $this->withHeader('Authorization', 'Bearer not-a-real-token') + ->getJson('/__test/sanctum-or-fastapi-jwt') + ->assertUnauthorized(); +}); + +test('a request with no Authorization header is rejected', function () { + $this->getJson('/__test/sanctum-or-fastapi-jwt')->assertUnauthorized(); +}); diff --git a/app-modules/payment/routes/payment-routes.php b/app-modules/payment/routes/payment-routes.php index 2005b5f..ec15afc 100644 --- a/app-modules/payment/routes/payment-routes.php +++ b/app-modules/payment/routes/payment-routes.php @@ -5,7 +5,7 @@ use Modules\Payment\Http\Controllers\PaymentController; use Modules\Payment\Http\Controllers\PaymentWebhookController; use Modules\Payment\Http\Controllers\RefundController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-write'])->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'); }); diff --git a/app-modules/payment/src/Actions/InitiatePaymentAction.php b/app-modules/payment/src/Actions/InitiatePaymentAction.php index a71a902..a1a9238 100644 --- a/app-modules/payment/src/Actions/InitiatePaymentAction.php +++ b/app-modules/payment/src/Actions/InitiatePaymentAction.php @@ -8,6 +8,7 @@ use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Models\Booking; use Modules\Payment\Data\PaymentRequestData; use Modules\Payment\Enums\PaymentMethod; +use Modules\Payment\Enums\PaymentStatus; use Modules\Payment\Exceptions\PaymentInitiationNotAllowedException; use Modules\Payment\Models\Payment; use Modules\Payment\Services\PaymentService; @@ -19,6 +20,13 @@ use Modules\Payment\Services\PaymentService; * * Booking status only ever flips to `confirmed` once the gateway confirms * success via the webhook/verify path (T5.9/T5.10) — never here. + * + * Idempotent per booking: KBZ's precreate rejects a second call tied to an + * order that's still in flight, so a repeat call (double-tap on "Pay", the + * customer re-opening the payment screen) must not blindly precreate again. + * If the latest attempt is still `pending`, it's re-verified against the + * gateway (via ConfirmPaymentAction, the same logic the webhook path uses) + * and reused instead of starting a new one. */ class InitiatePaymentAction { @@ -26,6 +34,7 @@ class InitiatePaymentAction public function __construct( private PaymentService $paymentService, + private ConfirmPaymentAction $confirmPayment, ) {} public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment @@ -34,27 +43,46 @@ class InitiatePaymentAction throw PaymentInitiationNotAllowedException::notPendingPayment($booking); } - $merchantOrderId = $this->merchantOrderId($booking); + return DB::transaction(function () use ($booking, $method) { + $booking = Booking::whereKey($booking->id)->lockForUpdate()->first(); - $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), - )); + $latest = $booking->payments()->latest('id')->first(); - 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(), - ])); + if ($latest !== null) { + // No-op for an already-terminal payment (ConfirmPaymentAction + // only re-verifies `pending` ones), so this is cheap even for + // a Failed/Completed latest attempt — and it guards against a + // narrow race where a webhook already completed the payment + // but the queued booking-status listener hasn't run yet. + $reverified = $this->confirmPayment->handle($latest->gateway, $latest->gateway_transaction_id); + + if ($reverified !== null && $reverified->status !== PaymentStatus::Failed) { + return $reverified; + } + } + + $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 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(), + ]); + }); } /** diff --git a/app-modules/payment/src/Gateways/KbzMiniAppGateway.php b/app-modules/payment/src/Gateways/KbzMiniAppGateway.php index 8d13fa7..0d3282a 100644 --- a/app-modules/payment/src/Gateways/KbzMiniAppGateway.php +++ b/app-modules/payment/src/Gateways/KbzMiniAppGateway.php @@ -32,6 +32,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface private readonly string $baseUrl; + private readonly string $createOrderUrl; + + private readonly string $queryOrderUrl; + + private readonly string $refundOrderUrl; + private readonly ?string $notifyUrl; private readonly ?string $certPath; @@ -53,6 +59,11 @@ class KbzMiniAppGateway implements PaymentGatewayInterface $this->merchantCode = (string) ($config['merchant_code'] ?? ''); $this->merchantKey = (string) ($config['merchant_key'] ?? ''); $this->baseUrl = (string) ($config['base_url'] ?? ''); + // Falls back to base_url for gateways/environments that haven't + // configured per-operation endpoints yet. + $this->createOrderUrl = (string) ($config['create_order_url'] ?? $this->baseUrl); + $this->queryOrderUrl = (string) ($config['query_order_url'] ?? $this->baseUrl); + $this->refundOrderUrl = (string) ($config['refund_order_url'] ?? $this->baseUrl); $this->notifyUrl = $config['notify_url'] ?? null; $this->certPath = $config['cert_path'] ?? null; $this->certKeyPath = $config['cert_key_path'] ?? null; @@ -63,10 +74,18 @@ class KbzMiniAppGateway implements PaymentGatewayInterface public function initiate(PaymentRequestData $data): PaymentResultData { $params = $this->buildPrecreateParams($data); - + logger($params); try { - $response = Http::asJson()->post($this->baseUrl, ['Request' => $params]); + $response = Http::post($this->createOrderUrl, ['Request' => $params]); + + logger($response); } catch (ConnectionException $exception) { + \Log::error('KBZ Mini App precreate connection error: '.$exception->getMessage(), [ + 'merchant_order_id' => $data->merchantOrderId, + 'amount' => $data->amount, + 'currency' => $data->currency, + ]); + return new PaymentResultData( status: PaymentStatus::Failed, gatewayTransactionId: null, @@ -79,6 +98,14 @@ class KbzMiniAppGateway implements PaymentGatewayInterface $body = $response->json('Response', []); if (! $response->successful() || ($body['result'] ?? null) !== 'SUCCESS') { + \Log::error('KBZ Mini App precreate failed: '.($body['msg'] ?? 'Unknown error'), [ + 'merchant_order_id' => $data->merchantOrderId, + 'amount' => $data->amount, + 'currency' => $data->currency, + 'http_status' => $response->status(), + 'raw_body' => $response->body(), + ]); + return new PaymentResultData( status: PaymentStatus::Failed, gatewayTransactionId: $body['prepay_id'] ?? null, @@ -102,8 +129,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface $params = $this->buildQueryOrderParams($gatewayTransactionId); try { - $response = Http::asJson()->post($this->baseUrl, ['Request' => $params]); + $response = Http::asJson()->post($this->queryOrderUrl, ['Request' => $params]); } catch (ConnectionException $exception) { + \Log::error('KBZ Mini App verify connection error: '.$exception->getMessage(), [ + 'gateway_transaction_id' => $gatewayTransactionId, + ]); + return new PaymentResultData( status: PaymentStatus::Failed, gatewayTransactionId: $gatewayTransactionId, @@ -130,7 +161,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface try { $response = Http::asJson() ->withOptions($this->mtlsOptions()) - ->post($this->baseUrl, ['Request' => $params]); + ->post($this->refundOrderUrl, ['Request' => $params]); } catch (ConnectionException $exception) { return new RefundResultData( status: RefundStatus::Failed, @@ -208,7 +239,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface 'timestamp' => (string) now()->timestamp, 'method' => 'kbz.payment.precreate', 'notify_url' => $data->notifyUrl ?? $this->notifyUrl, - 'nonce_str' => (string) Str::uuid(), + 'nonce_str' => $this->nonceStr(), 'version' => '1.0', 'biz_content' => [ 'appid' => $this->appId, @@ -235,7 +266,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface $params = [ 'timestamp' => (string) now()->timestamp, 'method' => 'kbz.payment.queryorder', - 'nonce_str' => (string) Str::uuid(), + 'nonce_str' => $this->nonceStr(), 'version' => '1.0', 'biz_content' => [ 'appid' => $this->appId, @@ -272,7 +303,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface $params = [ 'timestamp' => (string) now()->timestamp, 'method' => 'kbz.payment.refund', - 'nonce_str' => (string) Str::uuid(), + 'nonce_str' => $this->nonceStr(), 'version' => '1.0', 'biz_content' => [ 'appid' => $this->appId, @@ -298,6 +329,18 @@ class KbzMiniAppGateway implements PaymentGatewayInterface return now()->format('YmdHi').strtoupper(Str::random(8)); } + /** + * KBZ requires `nonce_str` to be a plain alphanumeric string of at most + * 32 characters — no hyphens or other special characters (confirmed + * against KBZ's "Query Order" field spec). `Str::uuid()` violates both + * constraints (36 chars, hyphenated), which silently broke `precreate` + * downstream even though the request's own signature still validated. + */ + private function nonceStr(): string + { + return strtoupper(Str::random(32)); + } + /** * mTLS options for the refund call — KBZ requires a client cert/key + * CA bundle on `kbz.payment.refund` specifically (domain.md §6). diff --git a/app-modules/payment/src/Http/Controllers/PaymentController.php b/app-modules/payment/src/Http/Controllers/PaymentController.php index 9d213ea..bf4420c 100644 --- a/app-modules/payment/src/Http/Controllers/PaymentController.php +++ b/app-modules/payment/src/Http/Controllers/PaymentController.php @@ -3,6 +3,7 @@ namespace Modules\Payment\Http\Controllers; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Gate; use Modules\Booking\Models\Booking; @@ -15,9 +16,13 @@ class PaymentController extends Controller private InitiatePaymentAction $initiatePaymentAction, ) {} - public function initiate(Booking $booking): JsonResponse + public function initiate(Request $request, Booking $booking): JsonResponse { - Gate::authorize('pay', $booking); + $openid = $request->attributes->get('fastapi_openid'); + + if ($openid === null) { + Gate::authorize('create', Booking::class); + } $payment = $this->initiatePaymentAction->handle($booking); diff --git a/app-modules/payment/src/Http/Controllers/RefundController.php b/app-modules/payment/src/Http/Controllers/RefundController.php index 1256b9d..f834c86 100644 --- a/app-modules/payment/src/Http/Controllers/RefundController.php +++ b/app-modules/payment/src/Http/Controllers/RefundController.php @@ -18,10 +18,14 @@ class RefundController extends Controller public function refund(RefundBookingRequest $request, Booking $booking): JsonResponse { - Gate::authorize('refund', $booking); - $validated = $request->validated(); + $openid = $request->attributes->get('fastapi_openid'); + + if ($openid === null) { + Gate::authorize('refund', $booking); + } + $refund = $this->refundBookingAction->handle( $booking, (string) $validated['amount'], diff --git a/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php b/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php index 253bccc..8a237c2 100644 --- a/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php +++ b/app-modules/payment/tests/Feature/InitiatePaymentApiTest.php @@ -21,9 +21,16 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface { public static ?PaymentRequestData $lastRequest = null; + public static int $initiateCalls = 0; + + public static int $verifyCalls = 0; + + public static PaymentStatus $verifyStatus = PaymentStatus::Pending; + public function initiate(PaymentRequestData $data): PaymentResultData { self::$lastRequest = $data; + self::$initiateCalls++; return new PaymentResultData( status: PaymentStatus::Pending, @@ -34,7 +41,13 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface public function verify(string $gatewayTransactionId): PaymentResultData { - throw new RuntimeException('not needed for this test'); + self::$verifyCalls++; + + return new PaymentResultData( + status: self::$verifyStatus, + gatewayTransactionId: $gatewayTransactionId, + gatewayPayload: ['trade_status' => self::$verifyStatus->value], + ); } public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData @@ -53,6 +66,11 @@ beforeEach(function () { app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeInitiatePaymentGateway::class); + FakeInitiatePaymentGateway::$lastRequest = null; + FakeInitiatePaymentGateway::$initiateCalls = 0; + FakeInitiatePaymentGateway::$verifyCalls = 0; + FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending; + $this->owner = User::factory()->create(); $this->token = $this->owner->createToken('test-token')->plainTextToken; }); @@ -79,6 +97,44 @@ test('the owner can initiate payment for their own pending_payment booking', fun ->and($payment->gateway_transaction_id)->toBe("{$booking->booking_ref}-1"); }); +test('a repeat call while the previous attempt is still pending reuses it instead of precreating again', function () { + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); + FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending; + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") + ->assertCreated(); + + $firstPaymentId = Payment::where('booking_id', $booking->id)->sole()->id; + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") + ->assertCreated() + ->assertJsonPath('data.id', $firstPaymentId); + + expect(Payment::where('booking_id', $booking->id)->count())->toBe(1) + ->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1) + ->and(FakeInitiatePaymentGateway::$verifyCalls)->toBe(1); +}); + +test('a repeat call reused attempt found completed on re-verify is returned without precreating again', function () { + $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") + ->assertCreated(); + + FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Completed; + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->postJson("/api/v1/payments/{$booking->booking_ref}/initiate") + ->assertCreated() + ->assertJsonPath('data.status', PaymentStatus::Completed->value); + + expect(Payment::where('booking_id', $booking->id)->count())->toBe(1) + ->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1); +}); + test('a retried payment attempt gets a unique merchant order id', function () { $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); Payment::factory()->failed()->create(['booking_id' => $booking->id]); diff --git a/app-modules/routing/routes/routing-routes.php b/app-modules/routing/routes/routing-routes.php index ebdf42c..6037588 100644 --- a/app-modules/routing/routes/routing-routes.php +++ b/app-modules/routing/routes/routing-routes.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Route; use Modules\Routing\Http\Controllers\EvRouteController; -Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () { +Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () { Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index'); Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show'); Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing'); diff --git a/app-modules/routing/src/Http/Controllers/EvRouteController.php b/app-modules/routing/src/Http/Controllers/EvRouteController.php index 83324c8..7ab7bb3 100644 --- a/app-modules/routing/src/Http/Controllers/EvRouteController.php +++ b/app-modules/routing/src/Http/Controllers/EvRouteController.php @@ -25,9 +25,10 @@ class EvRouteController extends Controller public function index(Request $request): AnonymousResourceCollection { $filters = $request->only(['company', 'from', 'to', 'date']); + $page = $request->integer('page', 1); $routes = Cache::tags(self::CACHE_TAG)->remember( - 'routes:index:'.md5(json_encode($filters)), + 'routes:index:'.md5(json_encode($filters + ['page' => $page])), now()->addMinutes(self::CACHE_TTL_MINUTES), fn () => EvRoute::query() ->where('is_active', true) @@ -37,7 +38,7 @@ class EvRouteController extends Controller // `date` is accepted for forward-compatibility with future per-date capacity // checks (domain.md §7), but v1 has no route-level calendar to filter against. ->with(self::EAGER_LOADS) - ->get(), + ->paginate(), ); return EvRouteResource::collection($routes); diff --git a/app-modules/routing/tests/Feature/RoutesReadApiTest.php b/app-modules/routing/tests/Feature/RoutesReadApiTest.php index 609f3ea..806921c 100644 --- a/app-modules/routing/tests/Feature/RoutesReadApiTest.php +++ b/app-modules/routing/tests/Feature/RoutesReadApiTest.php @@ -78,6 +78,16 @@ test('filters routes by company, from, and to', function () { ->assertJsonPath('data.0.id', $matching->id); }); +test('paginates routes', function () { + EvRoute::factory()->count(20)->create(['is_active' => true]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/routes') + ->assertSuccessful() + ->assertJsonCount(15, 'data') + ->assertJsonPath('meta.total', 20); +}); + test('shows a single active route', function () { $route = EvRoute::factory()->create(['is_active' => true]); diff --git a/bootstrap/app.php b/bootstrap/app.php index 2c9bd05..e5f0d6c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; +use Modules\Identity\Http\Middleware\AuthenticateSanctumOrFastApiJwt; use Modules\Identity\Http\Middleware\EnsureFastApiAgent; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; @@ -25,6 +26,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ 'fastapi.agent' => EnsureFastApiAgent::class, + 'api.auth' => AuthenticateSanctumOrFastApiJwt::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index 05bb611..1c072b6 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.3", "filament/filament": "^4.0", + "firebase/php-jwt": "^7.1", "gboquizosanchez/filament-log-viewer": "^2.3", "laravel/framework": "^13.0", "laravel/sanctum": "^4.0", diff --git a/composer.lock b/composer.lock index 04ea963..85dd2f5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5ec2c4de349d84433a04f4f044d7f7ed", + "content-hash": "fc70f50c4813ef7d40eb39729fcef597", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1476,6 +1476,72 @@ }, "time": "2026-07-29T13:07:28+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", diff --git a/config/services.php b/config/services.php index 57389e4..70453f9 100644 --- a/config/services.php +++ b/config/services.php @@ -35,11 +35,19 @@ return [ ], ], + 'fastapi_agent' => [ + 'jwt_secret' => env('FASTAPI_AGENT_JWT_SECRET'), + 'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'), + ], + 'kbz' => [ 'app_id' => env('KBZ_APP_ID'), 'merchant_code' => env('KBZ_MERCHANT_CODE'), 'merchant_key' => env('KBZ_MERCHANT_KEY'), 'base_url' => env('KBZ_BASE_URL'), + 'create_order_url' => env('KBZ_CREATE_ORDER_URL'), + 'query_order_url' => env('KBZ_QUERY_ORDER_URL'), + 'refund_order_url' => env('KBZ_REFUND_ORDER_URL'), 'notify_url' => env('KBZ_NOTIFY_URL'), 'cert_path' => env('KBZ_CERT_PATH'), 'cert_key_path' => env('KBZ_CERT_KEY_PATH'),