378 lines
13 KiB
PHP
378 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Modules\Payment\Gateways;
|
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
|
use Modules\Payment\Data\PaymentRequestData;
|
|
use Modules\Payment\Data\PaymentResultData;
|
|
use Modules\Payment\Data\RefundResultData;
|
|
use Modules\Payment\Enums\PaymentMethod;
|
|
use Modules\Payment\Enums\PaymentStatus;
|
|
use Modules\Payment\Enums\RefundStatus;
|
|
use Modules\Payment\Exceptions\InvalidWebhookSignatureException;
|
|
use Modules\Payment\Support\KbzSignature;
|
|
|
|
/**
|
|
* KBZ Mini App gateway strategy, ported from bnf_event's
|
|
* `App\Strategies\Payments\KBZMiniApp`/`KBZPay` (domain.md §6).
|
|
*
|
|
* Reads credentials from config('services.kbz') by default; a config array
|
|
* may be injected directly (used by tests / the factory).
|
|
*/
|
|
class KbzMiniAppGateway implements PaymentGatewayInterface
|
|
{
|
|
private readonly string $appId;
|
|
|
|
private readonly string $merchantCode;
|
|
|
|
private readonly string $merchantKey;
|
|
|
|
private readonly string $baseUrl;
|
|
|
|
private readonly string $createOrderUrl;
|
|
|
|
private readonly string $queryOrderUrl;
|
|
|
|
private readonly string $refundOrderUrl;
|
|
|
|
private readonly ?string $notifyUrl;
|
|
|
|
private readonly ?string $certPath;
|
|
|
|
private readonly ?string $certKeyPath;
|
|
|
|
private readonly ?string $caPath;
|
|
|
|
private readonly ?string $certPassword;
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $config
|
|
*/
|
|
public function __construct(?array $config = null)
|
|
{
|
|
$config ??= (array) config('services.kbz');
|
|
|
|
$this->appId = (string) ($config['app_id'] ?? '');
|
|
$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;
|
|
$this->caPath = $config['ca_path'] ?? null;
|
|
$this->certPassword = $config['cert_password'] ?? null;
|
|
}
|
|
|
|
public function initiate(PaymentRequestData $data): PaymentResultData
|
|
{
|
|
$params = $this->buildPrecreateParams($data);
|
|
logger($params);
|
|
try {
|
|
$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,
|
|
gatewayPayload: [],
|
|
message: $exception->getMessage(),
|
|
);
|
|
}
|
|
|
|
/** @var array<string, mixed> $body */
|
|
$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,
|
|
gatewayPayload: $body,
|
|
message: $body['msg'] ?? 'KBZ precreate failed.',
|
|
);
|
|
}
|
|
|
|
$orderInfo = $this->createOrderInfo($body['prepay_id'] ?? '');
|
|
|
|
return new PaymentResultData(
|
|
// KBZ's queryorder/refund calls both key off our own merch_order_id,
|
|
// not their prepay_id — so that's what gets stored/passed forward as
|
|
// the gateway transaction id (prepay_id still lives in the payload).
|
|
status: PaymentStatus::Pending,
|
|
gatewayTransactionId: $data->merchantOrderId,
|
|
gatewayPayload: [
|
|
'prepayId' => $body['prepay_id'] ?? null,
|
|
'orderInfo' => KbzSignature::joinKeyVal($orderInfo),
|
|
'signature' => KbzSignature::sign($orderInfo, $this->merchantKey),
|
|
],
|
|
);
|
|
}
|
|
|
|
public function verify(string $gatewayTransactionId): PaymentResultData
|
|
{
|
|
$params = $this->buildQueryOrderParams($gatewayTransactionId);
|
|
|
|
try {
|
|
$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,
|
|
gatewayPayload: [],
|
|
message: $exception->getMessage(),
|
|
);
|
|
}
|
|
|
|
/** @var array<string, mixed> $body */
|
|
$body = $response->json('Response', []);
|
|
|
|
return new PaymentResultData(
|
|
status: $this->mapTradeStatus($body['trade_status'] ?? null),
|
|
gatewayTransactionId: $gatewayTransactionId,
|
|
gatewayPayload: $body,
|
|
message: $body['trade_status'] ?? null,
|
|
);
|
|
}
|
|
|
|
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
|
{
|
|
$params = $this->buildRefundParams($gatewayTransactionId, $amount, $reason);
|
|
|
|
try {
|
|
$response = Http::asJson()
|
|
->withOptions($this->mtlsOptions())
|
|
->post($this->refundOrderUrl, ['Request' => $params]);
|
|
} catch (ConnectionException $exception) {
|
|
return new RefundResultData(
|
|
status: RefundStatus::Failed,
|
|
gatewayRefundId: null,
|
|
gatewayPayload: [],
|
|
message: $exception->getMessage(),
|
|
);
|
|
}
|
|
|
|
/** @var array<string, mixed> $body */
|
|
$body = $response->json('Response', []);
|
|
|
|
if (! $response->successful() || ($body['result'] ?? null) !== 'SUCCESS') {
|
|
return new RefundResultData(
|
|
status: RefundStatus::Failed,
|
|
gatewayRefundId: $body['refund_order_id'] ?? null,
|
|
gatewayPayload: $body,
|
|
message: $body['msg'] ?? 'KBZ refund failed.',
|
|
);
|
|
}
|
|
|
|
return new RefundResultData(
|
|
status: RefundStatus::Completed,
|
|
gatewayRefundId: $body['refund_order_id'] ?? null,
|
|
gatewayPayload: $body,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* bnf_event's equivalent (`OrderController::paymentComplete` /
|
|
* `KBZMiniApp::save`) trusted the raw `trade_status` from the POST body
|
|
* and only re-verified via `queryorder` afterward — it never checked
|
|
* `sign` on the inbound payload at all. This closes that gap: KBZ signs
|
|
* webhook notifications with the same scheme as our outbound calls
|
|
* (confirmed against KBZ's "6 Callback Interface" spec), so the
|
|
* signature is checked first, before any of the payload is trusted.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
public function handleWebhook(array $payload): PaymentResultData
|
|
{
|
|
/** @var array<string, mixed> $notification */
|
|
$notification = (array) ($payload['Request'] ?? []);
|
|
|
|
if (! $this->hasValidSignature($notification)) {
|
|
throw InvalidWebhookSignatureException::forGateway(PaymentMethod::KbzMiniApp);
|
|
}
|
|
|
|
return new PaymentResultData(
|
|
status: $this->mapTradeStatus($notification['trade_status'] ?? null),
|
|
gatewayTransactionId: $notification['merch_order_id'] ?? null,
|
|
gatewayPayload: $notification,
|
|
message: $notification['trade_status'] ?? null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $notification
|
|
*/
|
|
private function hasValidSignature(array $notification): bool
|
|
{
|
|
if (! isset($notification['sign']) || ! is_string($notification['sign']) || $this->merchantKey === '') {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals(KbzSignature::sign($notification, $this->merchantKey), strtoupper($notification['sign']));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildPrecreateParams(PaymentRequestData $data): array
|
|
{
|
|
$params = [
|
|
'timestamp' => (string) now()->timestamp,
|
|
'method' => 'kbz.payment.precreate',
|
|
'notify_url' => $data->notifyUrl ?? $this->notifyUrl,
|
|
'nonce_str' => uniqid(),
|
|
'version' => '1.0',
|
|
'biz_content' => [
|
|
'appid' => $this->appId,
|
|
'merch_code' => $this->merchantCode,
|
|
'merch_order_id' => $data->merchantOrderId,
|
|
'trade_type' => 'MINIAPP',
|
|
'total_amount' => $data->amount,
|
|
'trans_currency' => $data->currency,
|
|
'callback_info' => 'urlencode',
|
|
],
|
|
];
|
|
|
|
$params['sign'] = KbzSignature::sign($params, $this->merchantKey);
|
|
$params['sign_type'] = 'SHA256';
|
|
|
|
return $params;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildQueryOrderParams(string $merchantOrderId): array
|
|
{
|
|
$params = [
|
|
'timestamp' => (string) now()->timestamp,
|
|
'method' => 'kbz.payment.queryorder',
|
|
'nonce_str' => uniqid(),
|
|
'version' => '1.0',
|
|
'biz_content' => [
|
|
'appid' => $this->appId,
|
|
'merch_code' => $this->merchantCode,
|
|
'merch_order_id' => $merchantOrderId,
|
|
],
|
|
];
|
|
|
|
$params['sign'] = KbzSignature::sign($params, $this->merchantKey);
|
|
$params['sign_type'] = 'SHA256';
|
|
|
|
return $params;
|
|
}
|
|
|
|
/**
|
|
* KBZ's queryorder trade_status values — mapped conservatively: anything
|
|
* not explicitly a success/pending state is treated as failed rather
|
|
* than silently left as an unhandled status (domain.md §6).
|
|
*/
|
|
private function mapTradeStatus(?string $tradeStatus): PaymentStatus
|
|
{
|
|
return match ($tradeStatus) {
|
|
'PAY_SUCCESS' => PaymentStatus::Completed,
|
|
'WAIT_PAY', 'USERPAYING' => PaymentStatus::Pending,
|
|
default => PaymentStatus::Failed,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildRefundParams(string $merchantOrderId, string $amount, string $reason): array
|
|
{
|
|
$params = [
|
|
'timestamp' => (string) now()->timestamp,
|
|
'method' => 'kbz.payment.refund',
|
|
'nonce_str' => uniqid(),
|
|
'version' => '1.0',
|
|
'biz_content' => [
|
|
'appid' => $this->appId,
|
|
'merch_code' => $this->merchantCode,
|
|
'merch_order_id' => $merchantOrderId,
|
|
'refund_request_no' => $this->refundRequestNo(),
|
|
// Unlike bnf_event (refund_amount was commented out, full-refund
|
|
// only), this is wired through to support partial refunds —
|
|
// domain.md §6.
|
|
'refund_amount' => $amount,
|
|
'refund_reason' => $reason,
|
|
],
|
|
];
|
|
|
|
$params['sign'] = KbzSignature::sign($params, $this->merchantKey);
|
|
$params['sign_type'] = 'SHA256';
|
|
|
|
return $params;
|
|
}
|
|
|
|
private function refundRequestNo(): string
|
|
{
|
|
return now()->format('YmdHi').strtoupper(Str::random(8));
|
|
}
|
|
|
|
/**
|
|
* mTLS options for the refund call — KBZ requires a client cert/key +
|
|
* CA bundle on `kbz.payment.refund` specifically (domain.md §6).
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function mtlsOptions(): array
|
|
{
|
|
$options = [];
|
|
|
|
if ($this->certPath !== null) {
|
|
$options['cert'] = $this->certPassword !== null
|
|
? [$this->certPath, $this->certPassword]
|
|
: $this->certPath;
|
|
}
|
|
|
|
if ($this->certKeyPath !== null) {
|
|
$options['ssl_key'] = $this->certPassword !== null
|
|
? [$this->certKeyPath, $this->certPassword]
|
|
: $this->certKeyPath;
|
|
}
|
|
|
|
if ($this->caPath !== null) {
|
|
$options['verify'] = $this->caPath;
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
public function createOrderInfo($prepayId): array
|
|
{
|
|
return [
|
|
'appid' => $this->appId,
|
|
'merch_code' => $this->merchantCode,
|
|
'nonce_str' => uniqid(),
|
|
'prepay_id' => $prepayId,
|
|
'timestamp' => (string)time()
|
|
];
|
|
}
|
|
}
|