|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 $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 $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 $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 $payload */ public function handleWebhook(array $payload): PaymentResultData { /** @var array $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 $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 */ 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 */ 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 */ 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 */ 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() ]; } }