yoone-wc-moneris-payments/includes/class-yoone-moneris-api.php

475 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Moneris API 占位类
* 说明:此处为示例/占位实现,便于搭建整体流程。
* 上线前请替换为实际的 Moneris Vault/Tokenization 与支付接口对接代码。
*/
class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
// 基本配置
protected $store_id;
protected $api_token;
protected $sandbox; // 'yes' or 'no'
protected $timeout = 30; // 请求超时(秒)
protected $country_code = 'CA'; // CA 或 US
protected $crypt_type = '7'; // 默认 7
protected $status_check = false;
protected $protocol = 'https';
protected $port = 443;
protected $host_override = ''; // 自定义主机(高级)
protected $path_override = ''; // 自定义路径(高级)
protected $http_method = 'POST';
/**
* 构造函数:初始化直连 Moneris 的基础与高级配置。
*
* @param string $store_id Moneris Store ID
* @param string $api_token Moneris API Token
* @param 'yes'|'no' $sandbox 是否为沙箱模式('yes' 表示沙箱)
* @param array<mixed> $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( Yoone_Moneris_Txn_Types::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 ? Yoone_Moneris_Txn_Types::RES_PURCHASE_CC : Yoone_Moneris_Txn_Types::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( Yoone_Moneris_Txn_Types::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<string,mixed> $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】发送 XMLtype=' . $type . ' endpoint=' . $endpoint);
// 构建请求体
$body = array(
'storeId' => $this->store_id,
'apiToken' => $this->api_token,
// 明确发送字符串 true/false避免 boolean false 被序列化为空字符串
'statusCheck' => $this->status_check ? 'true' : 'false',
// 添加处理国家代码CA/US与端点匹配
'processingCountryCode' => strtoupper($this->country_code),
// 在沙箱环境显式声明 test=true与官方示例一致
'test' => $this->is_test_mode() ? 'true' : 'false',
);
// 风险查询特殊结构(暂不支持;如需支持可扩展)
$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>[^<]*<\/pan>/', '<pan>****</pan>', $xml_log);
$xml_log = preg_replace('/<data_key>[^<]*<\/data_key>/', '<data_key>****</data_key>', $xml_log);
$xml_log = preg_replace('/<api_token>[^<]*<\/api_token>/', '<api_token>****</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 非 2xxcode=' . $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<string,mixed> $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<string,mixed> $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';
}
}