$config 直连配置:country_code, crypt_type, status_check, protocol, port, host, path, http_method, timeout */ public function __construct( $store_id, $api_token, $sandbox = 'yes', $config = array() ) { $this->store_id = (string) $store_id; $this->api_token = (string) $api_token; $this->sandbox = (string) $sandbox; // 合并配置 if ( is_array( $config ) ) { $this->country_code = isset( $config['country_code'] ) ? (string) $config['country_code'] : $this->country_code; $this->crypt_type = isset( $config['crypt_type'] ) ? (string) $config['crypt_type'] : $this->crypt_type; $this->status_check = isset( $config['status_check'] ) ? (bool) $config['status_check'] : $this->status_check; $this->protocol = isset( $config['protocol'] ) ? (string) $config['protocol'] : $this->protocol; $this->port = isset( $config['port'] ) ? absint( $config['port'] ) : $this->port; $this->host_override= isset( $config['host'] ) ? (string) $config['host'] : $this->host_override; $this->path_override= isset( $config['path'] ) ? (string) $config['path'] : $this->path_override; $this->http_method = isset( $config['http_method'] ) ? strtoupper( (string) $config['http_method'] ) : $this->http_method; $this->timeout = isset( $config['timeout'] ) ? absint( $config['timeout'] ) : $this->timeout; } error_log( 'Moneris API 配置' . print_r( array( 'store_id' => $this->store_id, 'sandbox' => $this->sandbox, 'country_code' => $this->country_code, 'crypt_type' => $this->crypt_type, 'status_check' => $this->status_check, 'protocol' => $this->protocol, 'port' => $this->port, 'host_override'=> $this->host_override, 'path_override'=> $this->path_override, 'http_method' => $this->http_method, 'timeout' => $this->timeout, ) ,true)); } /** * 令牌化卡片(res_add_cc)。 * * @param array{number:string,exp_month:string|int,exp_year:string|int,cvc?:string} $card 原始卡信息 * @param int|null $customer_id 预留(未使用) * @return array{success:bool,token?:string,last4?:string,brand?:string,exp_month?:string,exp_year?:string,error?:string} */ public function tokenize_card( $card, $customer_id = null ) { // 仅直连 Moneris Vault: res_add_cc if ( empty( $this->store_id ) || empty( $this->api_token ) ) { return array( 'success' => false, 'error' => 'Missing Moneris credentials' ); } $payload = array( 'pan' => isset( $card['number'] ) ? preg_replace( '/\D+/', '', (string) $card['number'] ) : '', // expdate 采用 YYMM 'expdate' => $this->format_expdate( $card ), 'cryptType'=> $this->crypt_type, ); if ( ! empty( $card['cvc'] ) ) { $payload['cvdInfo'] = array( 'cvdIndicator' => 1, 'cvdValue' => preg_replace( '/\D+/', '', (string) $card['cvc'] ), ); } $num = isset($card['number']) ? preg_replace('/\D+/', '', (string) $card['number']) : ''; $masked = $num ? str_repeat('*', max(strlen($num) - 4, 0)) . substr($num, -4) : ''; error_log('【Yoone Moneris API】tokenize_card 请求:last4=' . ($num ? substr($num, -4) : '') . ' len=' . strlen($num) . ' expdate=' . $payload['expdate'] . ' cryptType=' . $payload['cryptType']); $res = $this->send_moneris_xml( 'res_add_cc', $payload ); error_log('$res11111111'. print_r($res, true)); if ( $res['ok'] ) { return array( 'success' => true, 'token' => (string) ( $res['receipt']['dataKey'] ?? '' ), 'last4' => (string) ( $res['receipt']['last4'] ?? $res['receipt']['maskedPan'] ?? '' ), 'brand' => (string) ( $res['receipt']['cardType'] ?? '' ), 'exp_month' => isset( $card['exp_month'] ) ? (string) $card['exp_month'] : '', 'exp_year' => isset( $card['exp_year'] ) ? (string) $card['exp_year'] : '', ); } return array( 'success' => false, 'error' => (string) $res['error'] ); } /** * 使用令牌扣款(res_purchase_cc 或 res_preauth_cc)。 * * @param string $token Moneris Vault 返回的 dataKey * @param float $amount 扣款金额 * @param string $currency 货币(当前占位未使用) * @param int $order_id 订单号(作为 orderId 传入) * @param bool $capture 是否直接购买(true)或仅预授权(false) * @return array{success:bool,transaction_id?:string,error?:string} */ public function charge_token( $token, $amount, $currency, $order_id, $capture = true ) { // 仅直连 Moneris Vault token 扣款:res_purchase_cc 或 res_preauth_cc if ( empty( $this->store_id ) || empty( $this->api_token ) ) { return array( 'success' => false, 'error' => 'Missing Moneris credentials' ); } // 为避免重复 orderId 导致主机拒绝,生成唯一订单 ID if ( ! function_exists( 'yoone_moneris_unique_order_id' ) ) { require_once __DIR__ . '/utils/orderid.php'; } $unique_order_id = yoone_moneris_unique_order_id( $order_id ); $type = $capture ? 'res_purchase_cc' : 'res_preauth_cc'; $payload = array( 'dataKey' => (string) $token, 'orderId' => (string) $unique_order_id, 'amount' => $this->format_amount( $amount ), 'cryptType' => $this->crypt_type, ); error_log('【Yoone Moneris API】charge_token 请求:type=' . $type . ' token_len=' . strlen($token) . ' amount=' . $payload['amount'] . ' currency=' . $currency . ' orderId=' . $unique_order_id . ' (源Woo订单ID=' . $order_id . ')'); $res = $this->send_moneris_xml( $type, $payload ); if ( $res['ok'] ) { return array( 'success' => true, 'transaction_id' => (string) ( $res['receipt']['txnNumber'] ?? '' ), 'order_id_used' => $unique_order_id, 'retry' => false, ); } // 如果出现系统错误,尝试一次自动重试(更换新的唯一 orderId) $err_msg = isset($res['error']) ? (string)$res['error'] : ''; $is_system_error = stripos($err_msg, 'SYSTEM ERROR') !== false; if ( $is_system_error ) { $retry_order_id = yoone_moneris_unique_order_id( $order_id ); $payload['orderId'] = (string) $retry_order_id; error_log('【Yoone Moneris API】检测到 SYSTEM ERROR,准备重试一次。新的 orderId=' . $retry_order_id); // 短暂延时,避免立即重复错误 usleep(300 * 1000); $res_retry = $this->send_moneris_xml( $type, $payload ); if ( $res_retry['ok'] ) { return array( 'success' => true, 'transaction_id' => (string) ( $res_retry['receipt']['txnNumber'] ?? '' ), 'order_id_used' => $retry_order_id, 'retry' => true, ); } // 记录重试失败 error_log('【Yoone Moneris API】重试仍失败:' . (string) ($res_retry['error'] ?? 'Unknown')); return array( 'success' => false, 'error' => (string) ($res_retry['error'] ?? $err_msg) ); } return array( 'success' => false, 'error' => (string) $err_msg ); } /** * 退款(refund)。 * * @param string $transaction_id 原交易 txnNumber * @param float $amount 退款金额 * @return array{success:bool,error?:string} */ public function refund( $transaction_id, $amount ) { // 仅直连 Moneris 退款 if ( empty( $this->store_id ) || empty( $this->api_token ) ) { return array( 'success' => false, 'error' => 'Missing Moneris credentials' ); } $payload = array( 'txnNumber' => (string) $transaction_id, 'amount' => $this->format_amount( $amount ), 'cryptType' => $this->crypt_type, ); $res = $this->send_moneris_xml( 'refund', $payload ); if ( $res['ok'] ) { return array( 'success' => true ); } return array( 'success' => false, 'error' => (string) $res['error'] ); } /** * 低层请求封装:POST JSON 到 moneris */ // 移除 moneryze 代理模式,仅保留直连实现 /** * 直连 Moneris:构建 XML 并发送。 * * 参考 moneryze 的实现按国家/环境选择主机与路径,POST text/xml。 * @param string $type 交易类型(如 res_add_cc、res_purchase_cc、refund) * @param array $payload 交易负载(键支持驼峰/蛇形,内部转蛇形) * @return array{ok:bool,receipt?:array,error?:string} */ protected function send_moneris_xml( $type, $payload ) { $endpoint = $this->build_endpoint( $type ); if ( ! $endpoint ) { return array( 'ok' => false, 'error' => 'Endpoint not configured' ); } error_log('【Yoone Moneris API】发送 XML:type=' . $type . ' endpoint=' . $endpoint); // 构建请求体 $body = array( 'storeId' => $this->store_id, 'apiToken' => $this->api_token, 'statusCheck'=> (bool) $this->status_check, ); // 风险查询特殊结构(暂不支持;如需支持可扩展) $body[ $type ] = $payload; $xml = $this->array_to_moneris_xml( array( 'request' => $body ) ); // 日志:打印部分已脱敏的 XML 以便排查格式问题(掩码 pan、data_key、api_token) $xml_log = $xml; $xml_log = preg_replace('/[^<]*<\/pan>/', '****', $xml_log); $xml_log = preg_replace('/[^<]*<\/data_key>/', '****', $xml_log); $xml_log = preg_replace('/[^<]*<\/api_token>/', '****', $xml_log); error_log('【Yoone Moneris API】请求 XML(脱敏)前 400 字符:' . substr($xml_log, 0, 400)); $args = array( 'method' => $this->http_method ?: 'POST', 'timeout' => $this->timeout, 'headers' => array( 'User-Agent' => 'PHP NA - yoone-moneris/1.0.0', 'Content-Type' => 'text/xml', ), 'body' => $xml, 'data_format' => 'body', ); // 仅支持 POST $resp = wp_remote_post( $endpoint, $args ); if ( is_wp_error( $resp ) ) { error_log('【Yoone Moneris API】HTTP 错误:' . $resp->get_error_message()); return array( 'ok' => false, 'error' => $resp->get_error_message() ); } $code = wp_remote_retrieve_response_code( $resp ); $raw = wp_remote_retrieve_body( $resp ); if ( $code < 200 || $code >= 300 ) { error_log('【Yoone Moneris API】HTTP 非 2xx:code=' . $code . ' body=' . substr($raw, 0, 200)); return array( 'ok' => false, 'error' => 'HTTP ' . $code . ' ' . $raw ); } // 解析 XML $parsed = $this->parse_moneris_xml( $raw ); // 打印部分响应原文,便于调试 error_log('【Yoone Moneris API】响应原文前 300 字符:' . substr($raw, 0, 300)); if ( ! $parsed['parsed'] ) { error_log('【Yoone Moneris API】XML 解析失败:' . ($parsed['error'] ?? 'Unknown')); return array( 'ok' => false, 'error' => 'Parse error: ' . ( $parsed['error'] ?? 'Unknown' ) ); } $receipt = $parsed['receipt']; $approved = $this->is_approved( $receipt ); if ( ! $approved ) { $message = isset( $receipt['message'] ) ? $receipt['message'] : ( $receipt['Message'] ?? 'Declined' ); error_log('【Yoone Moneris API】交易未批准:ResponseCode=' . ($receipt['responseCode'] ?? $receipt['response_code'] ?? '') . ' message=' . $message); return array( 'ok' => false, 'error' => $message ); } error_log('【Yoone Moneris API】交易批准:ResponseCode=' . ($receipt['responseCode'] ?? $receipt['response_code'] ?? '') . ' txnNumber=' . ($receipt['txnNumber'] ?? $receipt['txn_number'] ?? '')); error_log('receipt'. print_r($receipt,true)); return array( 'ok' => true, 'receipt' => $receipt ); } /** * 构建 Moneris endpoint(主机与路径)。 * * 优先使用高级覆盖,其次依据国家/环境选择默认主机与路径;部分类型可走 MPI。 * @param string $type 交易类型 * @return string 完整 URL(含协议/主机/端口/路径) */ protected function build_endpoint( $type ) { // 自定义覆盖优先 if ( $this->host_override && $this->path_override ) { $host = $this->host_override; $path = $this->path_override; return $this->protocol . '://' . $host . ':' . $this->port . $path; } // 使用常量默认值映射 $defaults = yoone_moneris_endpoint_defaults( $this->country_code, $this->is_test_mode() ); $host = $defaults['host']; $path = $defaults['path']; $protocol = $defaults['protocol']; $port = $defaults['port']; // 3DS MPI 交易(如果未来需要) if ( in_array( $type, array( 'acs', 'txn' ), true ) ) { $path = YOONE_MONERIS_MPI_PATH; } // 自定义覆盖优先 if ( $this->host_override ) { $host = $this->host_override; } if ( $this->path_override ) { $path = $this->path_override; } // 使用对象上的协议/端口(允许覆盖默认) $protocol = $this->protocol ?: $protocol; $port = $this->port ?: $port; return $protocol . '://' . $host . ':' . $port . $path; } /** * 判断是否处于沙箱模式。 * * @return bool true 表示沙箱环境 */ protected function is_test_mode() { return strtolower( (string) $this->sandbox ) === 'yes'; } /** * 将数组转换为 Moneris XML(统一为小写蛇形命名)。 * * @param array $data 顶层通常为 ['request' => [...]] * @return string XML 字符串 */ protected function array_to_moneris_xml( $data ) { // 简单地用 DOMDocument 生成,保持节点顺序与结构 $doc = new DOMDocument( '1.0', 'UTF-8' ); $doc->formatOutput = true; $build = function( $parent, $key, $value ) use ( &$build, $doc ) { $key = $this->camel_to_snake( $key ); $node = $doc->createElement( $key ); if ( is_array( $value ) ) { foreach ( $value as $k => $v ) { $build( $node, $k, $v ); } } else { $node->appendChild( $doc->createTextNode( (string) $value ) ); } $parent->appendChild( $node ); }; $root_key = key( $data ); $root_val = current( $data ); $root = $doc->createElement( $this->camel_to_snake( $root_key ) ); foreach ( $root_val as $k => $v ) { $build( $root, $k, $v ); } $doc->appendChild( $root ); return $doc->saveXML(); } /** * 将驼峰命名转换为蛇形命名。 * * @param string $name 字段名(如 orderId、dataKey、cvdInfo) * @return string 转换后的蛇形命名(小写) */ protected function camel_to_snake( $name ) { $out = preg_replace( '/([a-z])([A-Z])/', '$1_$2', (string) $name ); return strtolower( $out ); } /** * 解析 Moneris XML 响应为数组。 * * 期望结构:response → receipt;统一为小写蛇形键,同时保留常见原始大小写键以兼容。 * @param string $xml 响应 XML 字符串 * @return array{parsed:bool,receipt?:array,error?:string} */ protected function parse_moneris_xml( $xml ) { libxml_use_internal_errors( true ); $obj = simplexml_load_string( $xml ); if ( false === $obj ) { $err = 'Invalid XML'; $errors = libxml_get_errors(); if ( ! empty( $errors ) ) { $err = trim( $errors[0]->message ); } libxml_clear_errors(); return array( 'parsed' => false, 'error' => $err ); } $json = json_decode( json_encode( $obj ), true ); // 期望结构:response -> receipt $receipt = isset( $json['receipt'] ) ? $json['receipt'] : ( isset( $json['response']['receipt'] ) ? $json['response']['receipt'] : array() ); // 统一键名:小写蛇形 $norm = array(); foreach ( (array) $receipt as $k => $v ) { $norm[ $this->camel_to_snake( $k ) ] = is_array( $v ) ? ( isset( $v['_text'] ) ? $v['_text'] : ( isset( $v[0] ) ? $v[0] : current( $v ) ) ) : $v; } // 同时保留常见字段的原始大小写以兼容(例如 txnNumber/TxnNumber) $norm['txnNumber'] = isset( $receipt['TxnNumber'] ) ? $receipt['TxnNumber'] : ( $norm['txn_number'] ?? '' ); $norm['dataKey'] = isset( $receipt['DataKey'] ) ? $receipt['DataKey'] : ( $norm['data_key'] ?? '' ); $norm['responseCode']= isset( $receipt['ResponseCode'] ) ? $receipt['ResponseCode'] : ( $norm['response_code'] ?? '' ); $norm['message'] = isset( $receipt['Message'] ) ? $receipt['Message'] : ( $norm['message'] ?? '' ); $norm['cardType'] = isset( $receipt['CardType'] ) ? $receipt['CardType'] : ( $norm['card_type'] ?? '' ); $norm['maskedPan'] = isset( $receipt['MaskedPan'] ) ? $receipt['MaskedPan'] : ( $norm['masked_pan'] ?? '' ); return array( 'parsed' => true, 'receipt' => $norm ); } /** * 判断交易是否批准(ResponseCode < 50)。 * * @param array $receipt 解析后的收据数组 * @return bool 批准返回 true */ protected function is_approved( $receipt ) { $code = isset( $receipt['response_code'] ) ? $receipt['response_code'] : ( $receipt['ResponseCode'] ?? ( $receipt['responseCode'] ?? '' ) ); $code = is_numeric( $code ) ? intval( $code ) : 999; return $code >= 0 && $code < 50; // Moneris: <50 视为批准 } /** * 格式化有效期为 YYMM。 * * @param array{exp_year?:string|int,exp_month?:string|int} $card 卡片有效期 * @return string 两位年+两位月的 YYMM 格式 */ protected function format_expdate( $card ) { $yy = ''; $mm = ''; if ( ! empty( $card['exp_year'] ) ) { $yy = substr( preg_replace( '/\D+/', '', (string) $card['exp_year'] ), -2 ); } if ( ! empty( $card['exp_month'] ) ) { $mm = substr( '0' . preg_replace( '/\D+/', '', (string) $card['exp_month'] ), -2 ); } return $yy . $mm; // YYMM } /** * 格式化金额为两位小数的字符串。 * * @param float $amount 金额 * @return string 形如 "10.00" 的字符串 */ protected function format_amount( $amount ) { return number_format( (float) $amount, 2, '.', '' ); } /** * 卡片识别 */ /** * 简单识别卡组织(卡品牌)。 * * @param string $number 卡号 * @return string 可能值:visa/mastercard/amex/discover/card */ protected function detect_brand( $number ) { $n = preg_replace( '/\D+/', '', $number ); if ( preg_match( '/^4[0-9]{12}(?:[0-9]{3})?$/', $n ) ) return 'visa'; if ( preg_match( '/^5[1-5][0-9]{14}$/', $n ) ) return 'mastercard'; if ( preg_match( '/^3[47][0-9]{13}$/', $n ) ) return 'amex'; if ( preg_match( '/^6(?:011|5[0-9]{2})[0-9]{12}$/', $n ) ) return 'discover'; return 'card'; } }