@@ -5,7 +5,7 @@ use Modules\Payment\Http\Controllers\PaymentController;
|
||||
use Modules\Payment\Http\Controllers\PaymentWebhookController;
|
||||
use Modules\Payment\Http\Controllers\RefundController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () {
|
||||
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-write'])->group(function () {
|
||||
Route::post('/payments/{booking:booking_ref}/initiate', [PaymentController::class, 'initiate'])->name('payment.payments.initiate');
|
||||
Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund');
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Exceptions\PaymentInitiationNotAllowedException;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Services\PaymentService;
|
||||
@@ -19,6 +20,13 @@ use Modules\Payment\Services\PaymentService;
|
||||
*
|
||||
* Booking status only ever flips to `confirmed` once the gateway confirms
|
||||
* success via the webhook/verify path (T5.9/T5.10) — never here.
|
||||
*
|
||||
* Idempotent per booking: KBZ's precreate rejects a second call tied to an
|
||||
* order that's still in flight, so a repeat call (double-tap on "Pay", the
|
||||
* customer re-opening the payment screen) must not blindly precreate again.
|
||||
* If the latest attempt is still `pending`, it's re-verified against the
|
||||
* gateway (via ConfirmPaymentAction, the same logic the webhook path uses)
|
||||
* and reused instead of starting a new one.
|
||||
*/
|
||||
class InitiatePaymentAction
|
||||
{
|
||||
@@ -26,6 +34,7 @@ class InitiatePaymentAction
|
||||
|
||||
public function __construct(
|
||||
private PaymentService $paymentService,
|
||||
private ConfirmPaymentAction $confirmPayment,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment
|
||||
@@ -34,27 +43,46 @@ class InitiatePaymentAction
|
||||
throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
$merchantOrderId = $this->merchantOrderId($booking);
|
||||
return DB::transaction(function () use ($booking, $method) {
|
||||
$booking = Booking::whereKey($booking->id)->lockForUpdate()->first();
|
||||
|
||||
$result = $this->paymentService->initiate(new PaymentRequestData(
|
||||
bookingId: $booking->id,
|
||||
merchantOrderId: $merchantOrderId,
|
||||
amount: (string) $booking->price,
|
||||
currency: self::CURRENCY,
|
||||
method: $method,
|
||||
notifyUrl: $this->notifyUrl($booking, $method),
|
||||
));
|
||||
$latest = $booking->payments()->latest('id')->first();
|
||||
|
||||
return DB::transaction(fn () => Payment::create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => $method,
|
||||
'status' => $result->status,
|
||||
'amount' => $booking->price,
|
||||
'currency' => self::CURRENCY,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
'initiated_at' => now(),
|
||||
]));
|
||||
if ($latest !== null) {
|
||||
// No-op for an already-terminal payment (ConfirmPaymentAction
|
||||
// only re-verifies `pending` ones), so this is cheap even for
|
||||
// a Failed/Completed latest attempt — and it guards against a
|
||||
// narrow race where a webhook already completed the payment
|
||||
// but the queued booking-status listener hasn't run yet.
|
||||
$reverified = $this->confirmPayment->handle($latest->gateway, $latest->gateway_transaction_id);
|
||||
|
||||
if ($reverified !== null && $reverified->status !== PaymentStatus::Failed) {
|
||||
return $reverified;
|
||||
}
|
||||
}
|
||||
|
||||
$merchantOrderId = $this->merchantOrderId($booking);
|
||||
|
||||
$result = $this->paymentService->initiate(new PaymentRequestData(
|
||||
bookingId: $booking->id,
|
||||
merchantOrderId: $merchantOrderId,
|
||||
amount: (string) $booking->price,
|
||||
currency: self::CURRENCY,
|
||||
method: $method,
|
||||
notifyUrl: $this->notifyUrl($booking, $method),
|
||||
));
|
||||
|
||||
return Payment::create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => $method,
|
||||
'status' => $result->status,
|
||||
'amount' => $booking->price,
|
||||
'currency' => self::CURRENCY,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
'initiated_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
|
||||
private readonly string $baseUrl;
|
||||
|
||||
private readonly string $createOrderUrl;
|
||||
|
||||
private readonly string $queryOrderUrl;
|
||||
|
||||
private readonly string $refundOrderUrl;
|
||||
|
||||
private readonly ?string $notifyUrl;
|
||||
|
||||
private readonly ?string $certPath;
|
||||
@@ -53,6 +59,11 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$this->merchantCode = (string) ($config['merchant_code'] ?? '');
|
||||
$this->merchantKey = (string) ($config['merchant_key'] ?? '');
|
||||
$this->baseUrl = (string) ($config['base_url'] ?? '');
|
||||
// Falls back to base_url for gateways/environments that haven't
|
||||
// configured per-operation endpoints yet.
|
||||
$this->createOrderUrl = (string) ($config['create_order_url'] ?? $this->baseUrl);
|
||||
$this->queryOrderUrl = (string) ($config['query_order_url'] ?? $this->baseUrl);
|
||||
$this->refundOrderUrl = (string) ($config['refund_order_url'] ?? $this->baseUrl);
|
||||
$this->notifyUrl = $config['notify_url'] ?? null;
|
||||
$this->certPath = $config['cert_path'] ?? null;
|
||||
$this->certKeyPath = $config['cert_key_path'] ?? null;
|
||||
@@ -63,10 +74,18 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
$params = $this->buildPrecreateParams($data);
|
||||
|
||||
logger($params);
|
||||
try {
|
||||
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]);
|
||||
$response = Http::post($this->createOrderUrl, ['Request' => $params]);
|
||||
|
||||
logger($response);
|
||||
} catch (ConnectionException $exception) {
|
||||
\Log::error('KBZ Mini App precreate connection error: '.$exception->getMessage(), [
|
||||
'merchant_order_id' => $data->merchantOrderId,
|
||||
'amount' => $data->amount,
|
||||
'currency' => $data->currency,
|
||||
]);
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: null,
|
||||
@@ -79,6 +98,14 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$body = $response->json('Response', []);
|
||||
|
||||
if (! $response->successful() || ($body['result'] ?? null) !== 'SUCCESS') {
|
||||
\Log::error('KBZ Mini App precreate failed: '.($body['msg'] ?? 'Unknown error'), [
|
||||
'merchant_order_id' => $data->merchantOrderId,
|
||||
'amount' => $data->amount,
|
||||
'currency' => $data->currency,
|
||||
'http_status' => $response->status(),
|
||||
'raw_body' => $response->body(),
|
||||
]);
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: $body['prepay_id'] ?? null,
|
||||
@@ -102,8 +129,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$params = $this->buildQueryOrderParams($gatewayTransactionId);
|
||||
|
||||
try {
|
||||
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]);
|
||||
$response = Http::asJson()->post($this->queryOrderUrl, ['Request' => $params]);
|
||||
} catch (ConnectionException $exception) {
|
||||
\Log::error('KBZ Mini App verify connection error: '.$exception->getMessage(), [
|
||||
'gateway_transaction_id' => $gatewayTransactionId,
|
||||
]);
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Failed,
|
||||
gatewayTransactionId: $gatewayTransactionId,
|
||||
@@ -130,7 +161,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->withOptions($this->mtlsOptions())
|
||||
->post($this->baseUrl, ['Request' => $params]);
|
||||
->post($this->refundOrderUrl, ['Request' => $params]);
|
||||
} catch (ConnectionException $exception) {
|
||||
return new RefundResultData(
|
||||
status: RefundStatus::Failed,
|
||||
@@ -208,7 +239,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.precreate',
|
||||
'notify_url' => $data->notifyUrl ?? $this->notifyUrl,
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'nonce_str' => $this->nonceStr(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -235,7 +266,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.queryorder',
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'nonce_str' => $this->nonceStr(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -272,7 +303,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.refund',
|
||||
'nonce_str' => (string) Str::uuid(),
|
||||
'nonce_str' => $this->nonceStr(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -298,6 +329,18 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
return now()->format('YmdHi').strtoupper(Str::random(8));
|
||||
}
|
||||
|
||||
/**
|
||||
* KBZ requires `nonce_str` to be a plain alphanumeric string of at most
|
||||
* 32 characters — no hyphens or other special characters (confirmed
|
||||
* against KBZ's "Query Order" field spec). `Str::uuid()` violates both
|
||||
* constraints (36 chars, hyphenated), which silently broke `precreate`
|
||||
* downstream even though the request's own signature still validated.
|
||||
*/
|
||||
private function nonceStr(): string
|
||||
{
|
||||
return strtoupper(Str::random(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* mTLS options for the refund call — KBZ requires a client cert/key +
|
||||
* CA bundle on `kbz.payment.refund` specifically (domain.md §6).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Modules\Payment\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Models\Booking;
|
||||
@@ -15,9 +16,13 @@ class PaymentController extends Controller
|
||||
private InitiatePaymentAction $initiatePaymentAction,
|
||||
) {}
|
||||
|
||||
public function initiate(Booking $booking): JsonResponse
|
||||
public function initiate(Request $request, Booking $booking): JsonResponse
|
||||
{
|
||||
Gate::authorize('pay', $booking);
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid === null) {
|
||||
Gate::authorize('create', Booking::class);
|
||||
}
|
||||
|
||||
$payment = $this->initiatePaymentAction->handle($booking);
|
||||
|
||||
|
||||
@@ -18,10 +18,14 @@ class RefundController extends Controller
|
||||
|
||||
public function refund(RefundBookingRequest $request, Booking $booking): JsonResponse
|
||||
{
|
||||
Gate::authorize('refund', $booking);
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid === null) {
|
||||
Gate::authorize('refund', $booking);
|
||||
}
|
||||
|
||||
$refund = $this->refundBookingAction->handle(
|
||||
$booking,
|
||||
(string) $validated['amount'],
|
||||
|
||||
@@ -21,9 +21,16 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static ?PaymentRequestData $lastRequest = null;
|
||||
|
||||
public static int $initiateCalls = 0;
|
||||
|
||||
public static int $verifyCalls = 0;
|
||||
|
||||
public static PaymentStatus $verifyStatus = PaymentStatus::Pending;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
self::$lastRequest = $data;
|
||||
self::$initiateCalls++;
|
||||
|
||||
return new PaymentResultData(
|
||||
status: PaymentStatus::Pending,
|
||||
@@ -34,7 +41,13 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
self::$verifyCalls++;
|
||||
|
||||
return new PaymentResultData(
|
||||
status: self::$verifyStatus,
|
||||
gatewayTransactionId: $gatewayTransactionId,
|
||||
gatewayPayload: ['trade_status' => self::$verifyStatus->value],
|
||||
);
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
@@ -53,6 +66,11 @@ beforeEach(function () {
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeInitiatePaymentGateway::class);
|
||||
|
||||
FakeInitiatePaymentGateway::$lastRequest = null;
|
||||
FakeInitiatePaymentGateway::$initiateCalls = 0;
|
||||
FakeInitiatePaymentGateway::$verifyCalls = 0;
|
||||
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending;
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
@@ -79,6 +97,44 @@ test('the owner can initiate payment for their own pending_payment booking', fun
|
||||
->and($payment->gateway_transaction_id)->toBe("{$booking->booking_ref}-1");
|
||||
});
|
||||
|
||||
test('a repeat call while the previous attempt is still pending reuses it instead of precreating again', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated();
|
||||
|
||||
$firstPaymentId = Payment::where('booking_id', $booking->id)->sole()->id;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.id', $firstPaymentId);
|
||||
|
||||
expect(Payment::where('booking_id', $booking->id)->count())->toBe(1)
|
||||
->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1)
|
||||
->and(FakeInitiatePaymentGateway::$verifyCalls)->toBe(1);
|
||||
});
|
||||
|
||||
test('a repeat call reused attempt found completed on re-verify is returned without precreating again', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated();
|
||||
|
||||
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Completed;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', PaymentStatus::Completed->value);
|
||||
|
||||
expect(Payment::where('booking_id', $booking->id)->count())->toBe(1)
|
||||
->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1);
|
||||
});
|
||||
|
||||
test('a retried payment attempt gets a unique merchant order id', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
Payment::factory()->failed()->create(['booking_id' => $booking->id]);
|
||||
|
||||
Reference in New Issue
Block a user