@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user