|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'] ?? ''); $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); try { $response = Http::asJson()->post($this->baseUrl, ['Request' => $params]); } catch (ConnectionException $exception) { 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') { return new PaymentResultData( status: PaymentStatus::Failed, gatewayTransactionId: $body['prepay_id'] ?? null, gatewayPayload: $body, message: $body['msg'] ?? 'KBZ precreate failed.', ); } 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: $body, ); } public function verify(string $gatewayTransactionId): PaymentResultData { $params = $this->buildQueryOrderParams($gatewayTransactionId); try { $response = Http::asJson()->post($this->baseUrl, ['Request' => $params]); } catch (ConnectionException $exception) { 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->baseUrl, ['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' => (string) Str::uuid(), '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' => (string) Str::uuid(), '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' => (string) Str::uuid(), '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; } }