fix dashboard and apis
PHP Tests / php-tests (push) Failing after 9m1s

This commit is contained in:
Nyan Lin Paing
2026-08-16 23:50:39 +07:00
parent 60413bdebf
commit 8d74ac74cd
26 changed files with 638 additions and 55 deletions
+6
View File
@@ -61,6 +61,9 @@ KBZ_APP_ID=
KBZ_MERCHANT_CODE= KBZ_MERCHANT_CODE=
KBZ_MERCHANT_KEY= KBZ_MERCHANT_KEY=
KBZ_BASE_URL= KBZ_BASE_URL=
KBZ_CREATE_ORDER_URL=
KBZ_QUERY_ORDER_URL=
KBZ_REFUND_ORDER_URL=
KBZ_NOTIFY_URL= KBZ_NOTIFY_URL=
KBZ_CERT_PATH= KBZ_CERT_PATH=
KBZ_CERT_KEY_PATH= KBZ_CERT_KEY_PATH=
@@ -83,3 +86,6 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
FASTAPI_AGENT_JWT_SECRET=
FASTAPI_AGENT_JWT_ALGORITHM=HS256
@@ -3,7 +3,7 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Modules\Booking\Http\Controllers\BookingController; 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', [BookingController::class, 'index'])->name('booking.bookings.index');
Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show'); Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show');
Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store'); Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store');
@@ -7,10 +7,29 @@ namespace Modules\Booking\Enums;
*/ */
enum BookingChannel: string enum BookingChannel: string
{ {
case MiniApp = 'mini_app'; case MiniApp = 'kbz_miniapp';
case Android = 'android'; case Android = 'android';
case Ios = 'ios'; case Ios = 'ios';
case Web = 'web'; case Web = 'web';
case Agent = 'agent'; case Agent = 'agent';
case Admin = 'admin'; 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;
}
} }
@@ -31,10 +31,21 @@ class BookingController extends Controller
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
Gate::authorize('viewAny', Booking::class); $openid = $request->attributes->get('fastapi_openid');
$bookings = Booking::query() $query = Booking::query();
->where('user_id', $request->user()->id)
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) ->with(self::EAGER_LOADS)
->latest() ->latest()
->paginate(); ->paginate();
@@ -42,9 +53,15 @@ class BookingController extends Controller
return BookingResource::collection($bookings); 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)); return new BookingResource($booking->load(self::EAGER_LOADS));
} }
@@ -52,6 +69,11 @@ class BookingController extends Controller
public function store(StoreBookingRequest $request): JsonResponse public function store(StoreBookingRequest $request): JsonResponse
{ {
$validated = $request->validated(); $validated = $request->validated();
$openid = $request->attributes->get('fastapi_openid');
if ($openid === null) {
Gate::authorize('create', Booking::class);
}
$selections = array_map( $selections = array_map(
fn (array $selection) => new VehicleSelectionData( fn (array $selection) => new VehicleSelectionData(
@@ -61,6 +83,14 @@ class BookingController extends Controller
$validated['selections'], $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( $booking = $this->createBookingAction->handle(new CreateBookingData(
evRouteId: $validated['ev_route_id'], evRouteId: $validated['ev_route_id'],
departureTimeSlotId: $validated['departure_time_slot_id'], departureTimeSlotId: $validated['departure_time_slot_id'],
@@ -70,11 +100,12 @@ class BookingController extends Controller
passengerPhone: $validated['passenger_phone'], passengerPhone: $validated['passenger_phone'],
pickupAddress: $validated['pickup_address'], pickupAddress: $validated['pickup_address'],
dropoffAddress: $validated['dropoff_address'], dropoffAddress: $validated['dropoff_address'],
createdByChannel: isset($validated['created_by_channel']) createdByChannel: $channel,
? BookingChannel::from($validated['created_by_channel']) // A verified FastAPI JWT's own openid always wins over a
: BookingChannel::MiniApp, // client-supplied one — a request can never claim a different
// customer's identity than its own token proves.
userId: $request->user()?->id, userId: $request->user()?->id,
openid: $validated['openid'] ?? null, openid: $openid ?? $validated['openid'] ?? null,
pickupLat: $validated['pickup_lat'] ?? null, pickupLat: $validated['pickup_lat'] ?? null,
pickupLng: $validated['pickup_lng'] ?? null, pickupLng: $validated['pickup_lng'] ?? null,
dropoffLat: $validated['dropoff_lat'] ?? null, dropoffLat: $validated['dropoff_lat'] ?? null,
@@ -4,7 +4,6 @@ namespace Modules\Booking\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Modules\Booking\Enums\BookingChannel;
use Modules\Shared\Enums\VehicleOption; use Modules\Shared\Enums\VehicleOption;
/** /**
@@ -41,11 +40,8 @@ class StoreBookingRequest extends FormRequest
'dropoff_address' => ['required', 'string', 'max:500'], 'dropoff_address' => ['required', 'string', 'max:500'],
'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'], 'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'],
'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'], 'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'],
'openid' => ['nullable', 'string', 'max:255'],
'is_round_trip' => ['sometimes', 'boolean'], 'is_round_trip' => ['sometimes', 'boolean'],
'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'], '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)],
]; ];
} }
} }
@@ -1,6 +1,7 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot; use Modules\Catalog\Models\DepartureTimeSlot;
@@ -176,3 +177,47 @@ test('shape validation rejects an empty selections array', function () {
->assertStatus(422) ->assertStatus(422)
->assertJsonValidationErrors(['selections']); ->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'],
]);
@@ -0,0 +1,86 @@
<?php
use Firebase\JWT\JWT;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption;
beforeEach(function () {
config(['services.fastapi_agent.jwt_secret' => '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();
});
@@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route;
use Modules\Catalog\Http\Controllers\DestinationController; use Modules\Catalog\Http\Controllers\DestinationController;
use Modules\Catalog\Http\Controllers\EvCompanyController; 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('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index');
Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index'); Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index');
}); });
@@ -2,6 +2,7 @@
namespace Modules\Catalog\Http\Controllers; namespace Modules\Catalog\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Modules\Catalog\Http\Resources\DestinationResource; use Modules\Catalog\Http\Resources\DestinationResource;
@@ -9,10 +10,23 @@ use Modules\Catalog\Models\Destination;
class DestinationController extends Controller 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( 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()
); );
} }
} }
@@ -12,7 +12,7 @@ class EvCompanyController extends Controller
public function index(): AnonymousResourceCollection public function index(): AnonymousResourceCollection
{ {
return EvCompanyResource::collection( return EvCompanyResource::collection(
EvCompany::query()->where('is_active', true)->get() EvCompany::query()->where('is_active', true)->paginate()
); );
} }
} }
@@ -30,6 +30,36 @@ test('lists active destinations', function () {
->assertJsonFragment(['id' => $active->id]); ->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 () { test('companies endpoint rejects unauthenticated requests', function () {
$this->getJson('/api/v1/companies')->assertUnauthorized(); $this->getJson('/api/v1/companies')->assertUnauthorized();
}); });
@@ -0,0 +1,59 @@
<?php
namespace Modules\Identity\Http\Middleware;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* Accepts either of two bearer schemes on the same routes:
*
* - A Sanctum personal access token, for real database users (mini app,
* mobile, web, admin) resolved exactly as `auth:sanctum` would.
* - A self-signed JWT minted by the FastAPI AI agent, carrying the real
* end-customer's identity in its `sub` claim. No Laravel `User` is
* created or attached for this path the verified claim is stashed as
* the `fastapi_openid` request attribute for controllers to scope by
* (domain.md §8; the agent has no database identity of its own).
*
* Payment/refund routes deliberately keep plain `auth:sanctum` instead of
* this middleware, so a JWT-authenticated request can never reach them.
*/
class AuthenticateSanctumOrFastApiJwt implements AuthenticatesRequests
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::guard('sanctum')->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.');
}
}
@@ -0,0 +1,73 @@
<?php
use App\Models\User;
use Firebase\JWT\JWT;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
beforeEach(function () {
config(['services.fastapi_agent.jwt_secret' => '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();
});
@@ -5,7 +5,7 @@ use Modules\Payment\Http\Controllers\PaymentController;
use Modules\Payment\Http\Controllers\PaymentWebhookController; use Modules\Payment\Http\Controllers\PaymentWebhookController;
use Modules\Payment\Http\Controllers\RefundController; 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('/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'); Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund');
}); });
@@ -8,6 +8,7 @@ use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Payment\Data\PaymentRequestData; use Modules\Payment\Data\PaymentRequestData;
use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Exceptions\PaymentInitiationNotAllowedException; use Modules\Payment\Exceptions\PaymentInitiationNotAllowedException;
use Modules\Payment\Models\Payment; use Modules\Payment\Models\Payment;
use Modules\Payment\Services\PaymentService; 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 * Booking status only ever flips to `confirmed` once the gateway confirms
* success via the webhook/verify path (T5.9/T5.10) never here. * 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 class InitiatePaymentAction
{ {
@@ -26,6 +34,7 @@ class InitiatePaymentAction
public function __construct( public function __construct(
private PaymentService $paymentService, private PaymentService $paymentService,
private ConfirmPaymentAction $confirmPayment,
) {} ) {}
public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment
@@ -34,27 +43,46 @@ class InitiatePaymentAction
throw PaymentInitiationNotAllowedException::notPendingPayment($booking); 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( $latest = $booking->payments()->latest('id')->first();
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([ if ($latest !== null) {
'booking_id' => $booking->id, // No-op for an already-terminal payment (ConfirmPaymentAction
'gateway' => $method, // only re-verifies `pending` ones), so this is cheap even for
'status' => $result->status, // a Failed/Completed latest attempt — and it guards against a
'amount' => $booking->price, // narrow race where a webhook already completed the payment
'currency' => self::CURRENCY, // but the queued booking-status listener hasn't run yet.
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId, $reverified = $this->confirmPayment->handle($latest->gateway, $latest->gateway_transaction_id);
'gateway_payload' => $result->gatewayPayload,
'initiated_at' => now(), 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(),
]);
});
} }
/** /**
@@ -32,6 +32,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
private readonly string $baseUrl; private readonly string $baseUrl;
private readonly string $createOrderUrl;
private readonly string $queryOrderUrl;
private readonly string $refundOrderUrl;
private readonly ?string $notifyUrl; private readonly ?string $notifyUrl;
private readonly ?string $certPath; private readonly ?string $certPath;
@@ -53,6 +59,11 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$this->merchantCode = (string) ($config['merchant_code'] ?? ''); $this->merchantCode = (string) ($config['merchant_code'] ?? '');
$this->merchantKey = (string) ($config['merchant_key'] ?? ''); $this->merchantKey = (string) ($config['merchant_key'] ?? '');
$this->baseUrl = (string) ($config['base_url'] ?? ''); $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->notifyUrl = $config['notify_url'] ?? null;
$this->certPath = $config['cert_path'] ?? null; $this->certPath = $config['cert_path'] ?? null;
$this->certKeyPath = $config['cert_key_path'] ?? null; $this->certKeyPath = $config['cert_key_path'] ?? null;
@@ -63,10 +74,18 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
public function initiate(PaymentRequestData $data): PaymentResultData public function initiate(PaymentRequestData $data): PaymentResultData
{ {
$params = $this->buildPrecreateParams($data); $params = $this->buildPrecreateParams($data);
logger($params);
try { try {
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]); $response = Http::post($this->createOrderUrl, ['Request' => $params]);
logger($response);
} catch (ConnectionException $exception) { } 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( return new PaymentResultData(
status: PaymentStatus::Failed, status: PaymentStatus::Failed,
gatewayTransactionId: null, gatewayTransactionId: null,
@@ -79,6 +98,14 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$body = $response->json('Response', []); $body = $response->json('Response', []);
if (! $response->successful() || ($body['result'] ?? null) !== 'SUCCESS') { 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( return new PaymentResultData(
status: PaymentStatus::Failed, status: PaymentStatus::Failed,
gatewayTransactionId: $body['prepay_id'] ?? null, gatewayTransactionId: $body['prepay_id'] ?? null,
@@ -102,8 +129,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$params = $this->buildQueryOrderParams($gatewayTransactionId); $params = $this->buildQueryOrderParams($gatewayTransactionId);
try { try {
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]); $response = Http::asJson()->post($this->queryOrderUrl, ['Request' => $params]);
} catch (ConnectionException $exception) { } catch (ConnectionException $exception) {
\Log::error('KBZ Mini App verify connection error: '.$exception->getMessage(), [
'gateway_transaction_id' => $gatewayTransactionId,
]);
return new PaymentResultData( return new PaymentResultData(
status: PaymentStatus::Failed, status: PaymentStatus::Failed,
gatewayTransactionId: $gatewayTransactionId, gatewayTransactionId: $gatewayTransactionId,
@@ -130,7 +161,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
try { try {
$response = Http::asJson() $response = Http::asJson()
->withOptions($this->mtlsOptions()) ->withOptions($this->mtlsOptions())
->post($this->baseUrl, ['Request' => $params]); ->post($this->refundOrderUrl, ['Request' => $params]);
} catch (ConnectionException $exception) { } catch (ConnectionException $exception) {
return new RefundResultData( return new RefundResultData(
status: RefundStatus::Failed, status: RefundStatus::Failed,
@@ -208,7 +239,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
'timestamp' => (string) now()->timestamp, 'timestamp' => (string) now()->timestamp,
'method' => 'kbz.payment.precreate', 'method' => 'kbz.payment.precreate',
'notify_url' => $data->notifyUrl ?? $this->notifyUrl, 'notify_url' => $data->notifyUrl ?? $this->notifyUrl,
'nonce_str' => (string) Str::uuid(), 'nonce_str' => $this->nonceStr(),
'version' => '1.0', 'version' => '1.0',
'biz_content' => [ 'biz_content' => [
'appid' => $this->appId, 'appid' => $this->appId,
@@ -235,7 +266,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$params = [ $params = [
'timestamp' => (string) now()->timestamp, 'timestamp' => (string) now()->timestamp,
'method' => 'kbz.payment.queryorder', 'method' => 'kbz.payment.queryorder',
'nonce_str' => (string) Str::uuid(), 'nonce_str' => $this->nonceStr(),
'version' => '1.0', 'version' => '1.0',
'biz_content' => [ 'biz_content' => [
'appid' => $this->appId, 'appid' => $this->appId,
@@ -272,7 +303,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$params = [ $params = [
'timestamp' => (string) now()->timestamp, 'timestamp' => (string) now()->timestamp,
'method' => 'kbz.payment.refund', 'method' => 'kbz.payment.refund',
'nonce_str' => (string) Str::uuid(), 'nonce_str' => $this->nonceStr(),
'version' => '1.0', 'version' => '1.0',
'biz_content' => [ 'biz_content' => [
'appid' => $this->appId, 'appid' => $this->appId,
@@ -298,6 +329,18 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
return now()->format('YmdHi').strtoupper(Str::random(8)); 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 + * mTLS options for the refund call KBZ requires a client cert/key +
* CA bundle on `kbz.payment.refund` specifically (domain.md §6). * CA bundle on `kbz.payment.refund` specifically (domain.md §6).
@@ -3,6 +3,7 @@
namespace Modules\Payment\Http\Controllers; namespace Modules\Payment\Http\Controllers;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
@@ -15,9 +16,13 @@ class PaymentController extends Controller
private InitiatePaymentAction $initiatePaymentAction, 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); $payment = $this->initiatePaymentAction->handle($booking);
@@ -18,10 +18,14 @@ class RefundController extends Controller
public function refund(RefundBookingRequest $request, Booking $booking): JsonResponse public function refund(RefundBookingRequest $request, Booking $booking): JsonResponse
{ {
Gate::authorize('refund', $booking);
$validated = $request->validated(); $validated = $request->validated();
$openid = $request->attributes->get('fastapi_openid');
if ($openid === null) {
Gate::authorize('refund', $booking);
}
$refund = $this->refundBookingAction->handle( $refund = $this->refundBookingAction->handle(
$booking, $booking,
(string) $validated['amount'], (string) $validated['amount'],
@@ -21,9 +21,16 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface
{ {
public static ?PaymentRequestData $lastRequest = null; 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 public function initiate(PaymentRequestData $data): PaymentResultData
{ {
self::$lastRequest = $data; self::$lastRequest = $data;
self::$initiateCalls++;
return new PaymentResultData( return new PaymentResultData(
status: PaymentStatus::Pending, status: PaymentStatus::Pending,
@@ -34,7 +41,13 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface
public function verify(string $gatewayTransactionId): PaymentResultData 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 public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
@@ -53,6 +66,11 @@ beforeEach(function () {
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeInitiatePaymentGateway::class); 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->owner = User::factory()->create();
$this->token = $this->owner->createToken('test-token')->plainTextToken; $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"); ->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 () { test('a retried payment attempt gets a unique merchant order id', function () {
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]); $booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
Payment::factory()->failed()->create(['booking_id' => $booking->id]); Payment::factory()->failed()->create(['booking_id' => $booking->id]);
@@ -3,7 +3,7 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Modules\Routing\Http\Controllers\EvRouteController; 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', [EvRouteController::class, 'index'])->name('routing.routes.index');
Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show'); Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show');
Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing'); Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing');
@@ -25,9 +25,10 @@ class EvRouteController extends Controller
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
$filters = $request->only(['company', 'from', 'to', 'date']); $filters = $request->only(['company', 'from', 'to', 'date']);
$page = $request->integer('page', 1);
$routes = Cache::tags(self::CACHE_TAG)->remember( $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), now()->addMinutes(self::CACHE_TTL_MINUTES),
fn () => EvRoute::query() fn () => EvRoute::query()
->where('is_active', true) ->where('is_active', true)
@@ -37,7 +38,7 @@ class EvRouteController extends Controller
// `date` is accepted for forward-compatibility with future per-date capacity // `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. // checks (domain.md §7), but v1 has no route-level calendar to filter against.
->with(self::EAGER_LOADS) ->with(self::EAGER_LOADS)
->get(), ->paginate(),
); );
return EvRouteResource::collection($routes); return EvRouteResource::collection($routes);
@@ -78,6 +78,16 @@ test('filters routes by company, from, and to', function () {
->assertJsonPath('data.0.id', $matching->id); ->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 () { test('shows a single active route', function () {
$route = EvRoute::factory()->create(['is_active' => true]); $route = EvRoute::factory()->create(['is_active' => true]);
+2
View File
@@ -9,6 +9,7 @@ use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Modules\Identity\Http\Middleware\AuthenticateSanctumOrFastApiJwt;
use Modules\Identity\Http\Middleware\EnsureFastApiAgent; use Modules\Identity\Http\Middleware\EnsureFastApiAgent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
@@ -25,6 +26,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([ $middleware->alias([
'fastapi.agent' => EnsureFastApiAgent::class, 'fastapi.agent' => EnsureFastApiAgent::class,
'api.auth' => AuthenticateSanctumOrFastApiJwt::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
+1
View File
@@ -8,6 +8,7 @@
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"filament/filament": "^4.0", "filament/filament": "^4.0",
"firebase/php-jwt": "^7.1",
"gboquizosanchez/filament-log-viewer": "^2.3", "gboquizosanchez/filament-log-viewer": "^2.3",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
Generated
+67 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5ec2c4de349d84433a04f4f044d7f7ed", "content-hash": "fc70f50c4813ef7d40eb39729fcef597",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -1476,6 +1476,72 @@
}, },
"time": "2026-07-29T13:07:28+00:00" "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", "name": "fruitcake/php-cors",
"version": "v1.4.0", "version": "v1.4.0",
+8
View File
@@ -35,11 +35,19 @@ return [
], ],
], ],
'fastapi_agent' => [
'jwt_secret' => env('FASTAPI_AGENT_JWT_SECRET'),
'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'),
],
'kbz' => [ 'kbz' => [
'app_id' => env('KBZ_APP_ID'), 'app_id' => env('KBZ_APP_ID'),
'merchant_code' => env('KBZ_MERCHANT_CODE'), 'merchant_code' => env('KBZ_MERCHANT_CODE'),
'merchant_key' => env('KBZ_MERCHANT_KEY'), 'merchant_key' => env('KBZ_MERCHANT_KEY'),
'base_url' => env('KBZ_BASE_URL'), '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'), 'notify_url' => env('KBZ_NOTIFY_URL'),
'cert_path' => env('KBZ_CERT_PATH'), 'cert_path' => env('KBZ_CERT_PATH'),
'cert_key_path' => env('KBZ_CERT_KEY_PATH'), 'cert_key_path' => env('KBZ_CERT_KEY_PATH'),