id = 'yoone_moneris'; $this->method_title = __('Moneris (Yoone)', 'yoone-moneris'); $this->method_description = __('使用 Moneris 支付,支持订阅自动续费(需接入 Moneris 令牌化)。', 'yoone-moneris'); $this->has_fields = true; /** * $this->supports 数组告诉 WooCommerce 本支付网关“支持”哪些功能。 * 每一项都是一个字符串标识,Woo 核心/扩展会根据这些标识判断能否调用对应功能: * - 'products' : 支持普通商品付款(默认就有,可省略) * - 'default_credit_card_form': 可使用 WC_Payment_Gateway_CC 自带的信用卡表单 * - 'tokenization' : 支持“令牌化”,即保存/复用信用卡(Woo 会显示“保存此卡”复选框) * - 'refunds' : 支持后台订单退款(需要实现 process_refund 方法) * - 'pre-orders' : 支持 WooCommerce Pre-Orders 插件 * - 'subscriptions' : 支持 WooCommerce Subscriptions 插件 * - 'subscription_cancellation' : 支持订阅取消 * - 'subscription_suspension' : 支持订阅暂停 * - 'subscription_reactivation' : 支持订阅重新激活 * - 'subscription_amount_changes' : 支持修改订阅金额 * - 'subscription_date_changes' : 支持修改订阅日期 * - 'multiple_subscriptions' : 支持一次性购买多个订阅 * - 'add_payment_method' : 支持“我的账户 → 付款方式”里手动添加卡片 * * 只要将对应字符串写进数组,Woo 就会在合适场景下触发对应钩子或显示对应 UI; * 如果缺少某项,Woo 会认为本网关不具备该能力,从而隐藏相关按钮或流程。 */ $this->supports = array( 'products', 'default_credit_card_form', 'tokenization', 'refunds', 'pre-orders', // Subscriptions 相关 'subscriptions', 'subscription_cancellation', 'subscription_suspension', 'subscription_reactivation', 'subscription_amount_changes', 'subscription_date_changes', 'multiple_subscriptions', 'add_payment_method', ); $this->init_form_fields(); $this->init_settings(); $this->title = $this->get_option('title', 'Moneris'); $this->description = $this->get_option('description', 'Pay with your credit card via Moneris.'); $this->enabled = $this->get_option('enabled', 'yes'); $this->sandbox = $this->get_option('sandbox', 'yes'); $this->store_id = $this->get_option('store_id', ''); $this->api_token = $this->get_option('api_token', ''); $this->country_code = $this->get_option('country_code', 'CA'); $this->crypt_type = $this->get_option('crypt_type', '7'); $this->status_check = 'yes' === $this->get_option('status_check', 'no'); $this->protocol = $this->get_option('protocol', 'https'); $this->port = absint($this->get_option('port', 443)); $this->host = $this->get_option('host', ''); $this->path = $this->get_option('path', ''); $this->http_method = $this->get_option('http_method', 'POST'); $this->request_timeout= absint($this->get_option('request_timeout', 30)); // 下面这一行 add_action 的作用是: // 当管理员在 WooCommerce → 设置 → 付款 → Moneris 里点击“保存设置”按钮时, // Woo 会触发钩子:woocommerce_update_options_payment_gateways_{网关ID}。 // 我们把当前网关类里自带的 process_admin_options() 方法挂到这个钩子上, // 从而把用户在后台表单里填的 store_id、api_token、是否启用沙箱等选项写进数据库。 add_action('woocommerce_update_options_payment_gateways_' . $this->id, array($this, 'process_admin_options')); } /** * init_form_fields() * * 这是 WooCommerce 支付网关的标准方法,用于定义网关在后端设置页面(WooCommerce → 设置 → 付款 → Moneris)里 * 需要显示的所有配置字段(表单控件)。把这些字段写进 $this->form_fields 数组后,WooCommerce 会自动: * 1. 在后台渲染对应的输入框、下拉框、复选框等; * 2. 接收管理员提交的值; * 3. 通过 process_admin_options() 把值保存到 wp_options 表(键名格式类似 woocommerce_yoone_moneris_settings)。 * * 换句话说:init_form_fields 就是“告诉 WooCommerce 我的网关有哪些设置项”。 */ /** * 初始化后台设置表单字段。 * * WooCommerce 根据 $this->form_fields 渲染后台“设置 → 付款 → Moneris (Yoone)”页面, * 并在保存时将值存入 wp_options,供 $this->get_option() 读取。 */ public function init_form_fields() { $this->form_fields = array( 'enabled' => array( 'title' => __('启用/禁用', 'yoone-moneris'), 'label' => __('启用 Moneris', 'yoone-moneris'), 'type' => 'checkbox', 'default' => 'yes', ), 'title' => array( 'title' => __('标题', 'yoone-moneris'), 'type' => 'text', 'default' => __('Moneris', 'yoone-moneris'), ), 'description' => array( 'title' => __('描述', 'yoone-moneris'), 'type' => 'textarea', 'default' => 'Pay with your credit card via Moneris.', ), 'sandbox' => array( 'title' => __('Sandbox', 'yoone-moneris'), 'label' => __('启用沙箱模式', 'yoone-moneris'), 'type' => 'checkbox', 'default' => 'yes', ), 'store_id' => array( 'title' => __('Store ID', 'yoone-moneris'), 'type' => 'text', 'desc_tip' => __('Moneris 账户 Store ID。', 'yoone-moneris'), ), 'api_token' => array( 'title' => __('API Token', 'yoone-moneris'), 'type' => 'text', 'desc_tip' => __('Moneris API Token。', 'yoone-moneris'), ), 'country_code' => array( 'title' => __('国家/地区', 'yoone-moneris'), 'type' => 'select', 'description' => __('选择 CA(加拿大)或 US(美国)。将影响请求路径与主机。', 'yoone-moneris'), 'default' => 'CA', 'options' => array( 'CA' => 'CA', 'US' => 'US', ), ), 'crypt_type' => array( 'title' => __('Crypt Type', 'yoone-moneris'), 'type' => 'text', 'description' => __('默认 7。用于交易加密类型。', 'yoone-moneris'), 'default' => '7', ), 'status_check' => array( 'title' => __('状态检查', 'yoone-moneris'), 'label' => __('启用 statusCheck', 'yoone-moneris'), 'type' => 'checkbox', 'default' => 'no', ), 'protocol' => array( 'title' => __('协议', 'yoone-moneris'), 'type' => 'text', 'default' => 'https', ), 'port' => array( 'title' => __('端口', 'yoone-moneris'), 'type' => 'number', 'default' => 443, ), 'host' => array( 'title' => __('自定义主机(高级)', 'yoone-moneris'), 'type' => 'text', 'description' => __('可选:覆盖默认主机。例如生产 CA: www3.moneris.com;测试: esqa.moneris.com。US 可参考文档。', 'yoone-moneris'), 'default' => '', ), 'path' => array( 'title' => __('自定义路径(高级)', 'yoone-moneris'), 'type' => 'text', 'description' => __('默认 CA: /gateway2/servlet/MpgRequest;US: /gateway_us/servlet/MpgRequest;MPI: /mpi/servlet/MpiServlet。', 'yoone-moneris'), 'default' => '', ), 'http_method' => array( 'title' => __('HTTP 方法', 'yoone-moneris'), 'type' => 'text', 'description' => __('默认 POST(Moneris XML API)。仅供调试或特殊需求。', 'yoone-moneris'), 'default' => 'POST', ), 'request_timeout' => array( 'title' => __('请求超时(秒)', 'yoone-moneris'), 'type' => 'number', 'default' => 30, ), ); } /** * 在结账页渲染本支付网关的表单字段(信用卡输入区)。 * 1. 先读取并拼接网关描述(含沙箱提示),用 wp_kses_post 过滤后输出。 * 2. 若网关声明支持 'default_credit_card_form',则调用父类方法输出 Woo 默认信用卡表单: * - 已登录用户会看到“使用已保存卡片/使用新卡片”的单选列表; * - 未登录或选“新卡片”则显示卡号、有效期、CVC 输入框。 * 注意:本实现针对经典短代码结账;若使用 WooCommerce Blocks 结账需另行集成。 */ public function payment_fields() { // 结账页(例如 https://canadiantails.local/checkout/ )会调用此方法渲染网关的前端表单。 // 这里我们使用 WooCommerce 默认的信用卡表单(classic checkout),即 WC_Payment_Gateway_CC 提供的字段: // - 卡号、有效期、CVC(字段名形如:yoone_moneris-card-number / -card-expiry / -card-cvc) // 若你使用 WooCommerce Blocks 的结账(新式块编辑器结账页),需要另外实现 Blocks 支付集成, // 当前插件仅针对经典结账表单;后续可添加 blocks 集成以支持新版结账。 $description = $this->get_description(); error_log('【yoone moneris 】这里想记的东西' . print_r($description, true)); if ('yes' === $this->sandbox) { $description .= ' ' . __('沙箱模式开启:使用测试卡(例如 4242 4242 4242 4242)。', 'yoone-moneris'); } if ($description) { // 在结账页输出网关描述(含沙箱提示),并渲染默认信用卡表单 echo wp_kses_post(wpautop(wptexturize(trim($description)))); } if ($this->supports('default_credit_card_form')) { // 输出默认信用卡表单。若用户已保存过卡片,Woo 会在此处同时渲染“选择已保存的卡/使用新卡”的单选列表。 parent::payment_fields(); } } /** * 控制本网关在结账页的可用性。 * * 返回 true 才会在结账页显示本支付方式。这里做最基本的判断: * - 网关已启用; * - 站点货币为 CAD 或 USD(Moneris 支持的主币种); * - 若站点未启用 SSL,仍允许在沙箱模式下使用;生产建议强制 SSL。 * * 如需更细粒度控制(如按国家/运送方式/订单总额限制),可在此扩展。 * * @return bool */ public function is_available() { return true; // 111 if ('yes' !== $this->enabled) { return false; } // 货币限制:Moneris 主要支持 CAD/USD $currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'CAD'; if (! in_array($currency, array('CAD', 'USD'), true)) { return false; } // 在生产环境建议强制 SSL;沙箱允许非 SSL,避免本地开发阻塞 $is_ssl = function_exists('is_ssl') ? is_ssl() : false; $is_sandbox = ('yes' === strtolower((string) $this->sandbox)); if (! $is_ssl && ! $is_sandbox) { return false; } return true; } /** * 验证结账页输入的信用卡字段。 * * 若选择已保存令牌则跳过校验;否则要求卡号、有效期(月/年)和 CVC 均存在。 * @return bool 通过返回 true,否则添加错误提示并返回 false */ public function validate_fields() { // 若用户选择了已保存的令牌,则无需验证卡片字段 $selected_token_id = $this->get_selected_token_id(); if ($selected_token_id) { return true; } // 新卡:需要确保卡片字段完整 $card = $this->get_posted_card(); if (empty($card['number']) || empty($card['exp_month']) || empty($card['exp_year']) || empty($card['cvc'])) { wc_add_notice(__('请填写完整的银行卡信息。' . $card, 'yoone-moneris'), 'error'); return false; } return true; } // 覆盖父类的字段 name 逻辑:始终输出 name 属性(MVP),便于后端读取 $_POST 中的卡信息。 // 注意:生产环境建议采用前端令牌化方案,不在服务器接收原始卡号。 /** * 返回输入字段的 name 属性字符串以便后端读取。 * * 某些主题/表单可能省略 name,本方法强制输出。 * @param string $name 字段基名(如 card-number、card-expiry、card-cvc) * @return string 形如 name="yoone_moneris-card-number" 的片段 */ public function field_name($name) { return ' name="' . esc_attr($this->id . '-' . $name) . '" '; } // 移除自定义 form(),恢复使用父类默认信用卡表单渲染(default_credit_card_form)。 // 注意:仍保留 field_name() 的覆盖以确保输入框拥有 name 属性,后端可从 $_POST 读取卡片信息。 /** * 从 $_POST 中提取并规范化用户输入的卡片信息。 * * 兼容多种字段命名,移除非数字字符,解析组合有效期(MM/YY)为分拆的月/年; * 若本次提交的支付方式不是当前网关,返回空卡数据。 * @return array{number:string,exp_month:string,exp_year:string,cvc:string} */ protected function get_posted_card() { // 如果当前提交并非选择我们网关,直接返回空卡数据,避免误读其他网关字段 $pm = isset($_POST['payment_method']) ? wc_clean(wp_unslash($_POST['payment_method'])) : ''; if ($pm && $pm !== $this->id) { return array('number' => '', 'exp_month' => '', 'exp_year' => '', 'cvc' => ''); } // 助手:按优先级从 $_POST 中读取第一个非空值 $read_post = function ($candidates) { foreach ((array) $candidates as $k) { if (isset($_POST[$k])) { $val = wc_clean(wp_unslash($_POST[$k])); if ('' !== $val) { return $val; } } } return ''; }; // 兼容不同主题/插件的字段命名 $number = $read_post(array( $this->id . '-card-number', 'wc-' . $this->id . '-card-number', 'card-number', 'wc-card-number', $this->id . '_card_number', 'cardnumber', 'card_number', )); $expiry_raw = $read_post(array( $this->id . '-card-expiry', 'wc-' . $this->id . '-card-expiry', 'card-expiry', 'wc-card-expiry', 'expiry', 'card_expiry', )); $cvc = $read_post(array( $this->id . '-card-cvc', 'wc-' . $this->id . '-card-cvc', 'card-cvc', 'wc-card-cvc', 'cvc', 'card_cvc', )); // 如果没有组合有效期,尝试读取分拆的 月/年 字段 $exp_month = $read_post(array( $this->id . '-exp-month', 'wc-' . $this->id . '-exp-month', $this->id . '-card-expiry-month', 'wc-' . $this->id . '-card-expiry-month', 'exp-month', 'card-expiry-month', 'expiry_month', )); $exp_year = $read_post(array( $this->id . '-exp-year', 'wc-' . $this->id . '-exp-year', $this->id . '-card-expiry-year', 'wc-' . $this->id . '-card-expiry-year', 'exp-year', 'card-expiry-year', 'expiry_year', )); // 规范化:仅保留数字 $number = preg_replace('/\D+/', '', (string) $number); $cvc = preg_replace('/\D+/', '', (string) $cvc); // 规范化有效期:优先解析组合字段(支持 "MM / YY" 或 "MM/YY" 或包含中文空格) $exp_month = preg_replace('/\D+/', '', (string) $exp_month); $exp_year = preg_replace('/\D+/', '', (string) $exp_year); if ($expiry_raw && (! $exp_month || ! $exp_year)) { $parts = array_map('trim', preg_split('/\s*\/\s*/', (string) $expiry_raw)); $m = isset($parts[0]) ? preg_replace('/\D+/', '', $parts[0]) : ''; $y = isset($parts[1]) ? preg_replace('/\D+/', '', $parts[1]) : ''; if ($m) { $exp_month = $m; } if ($y) { $exp_year = $y; } } // 两位年转四位年 if (strlen($exp_year) === 2) { $exp_year = '20' . $exp_year; } return array( 'number' => $number, 'exp_month' => $exp_month, 'exp_year' => $exp_year, 'cvc' => $cvc, ); } /** * 读取结账页提交的已保存支付令牌 ID。 * * 当用户选择“使用新卡”时值为 'new';选择已有令牌时返回其整数 ID。 * @return int 令牌 ID;若未选择或为 'new',返回 0 */ protected function get_selected_token_id() { // 该方法用于解析结账页(/checkout)提交的“选择已保存支付方式”的字段: // Woo 默认字段名:'wc-' . gateway_id . '-payment-token',例如:wc-yoone_moneris-payment-token // 当用户选择“已保存的卡”,此字段会传递一个 token 的 ID;当选择“使用新卡”,该字段值为 'new'。 // 注意:此字段由 Woo 默认信用卡表单生成,适用于经典结账;如果使用 Blocks 结账,需要对应 Blocks 集成来产生等效数据。 $field = 'wc-' . $this->id . '-payment-token'; if (isset($_POST[$field]) && 'new' !== $_POST[$field]) { // 将提交的令牌 ID 转为整数,后续用 WC_Payment_Tokens::get( $id ) 读取具体令牌对象 return absint($_POST[$field]); } return 0; } /** * 构造并返回直连 Moneris 的 API 客户端。 * * 将后台直连配置打包为 $direct 传入 API 层。 * @return Yoone_Moneris_API */ protected function api() { // 仅直连 Moneris if (! class_exists('Yoone_Moneris_API')) { // 防御:在极端情况下文件未加载 require_once dirname(__FILE__) . '/class-yoone-moneris-api.php'; } $config = array( 'country_code' => $this->country_code, 'crypt_type' => $this->crypt_type, 'status_check' => (bool) $this->status_check, 'protocol' => $this->protocol, 'port' => absint($this->port), 'host' => $this->host, 'path' => $this->path, 'http_method' => $this->http_method, 'timeout' => absint($this->request_timeout), ); return new Yoone_Moneris_API($this->store_id, $this->api_token, $this->sandbox, $config); } /** * 根据令牌化结果创建并保存 WooCommerce 支付令牌。 * * @param array $res tokenize_card 返回的结构,需包含 token/last4/exp_month/exp_year * @param int $user_id 用户 ID * @return int WC_Payment_Token_CC 的 ID */ protected function create_wc_token($res, $user_id) { $token = new WC_Payment_Token_CC(); $token->set_token($res['token']); $token->set_gateway_id($this->id); $token->set_user_id($user_id); $token->set_last4(substr(preg_replace('/\D+/', '', (string) $res['last4'] ?? ''), -4) ?: '0000'); $token->set_expiry_month(preg_replace('/\D+/', '', (string) ($res['exp_month'] ?? '')) ?: '01'); $token->set_expiry_year(preg_replace('/\D+/', '', (string) ($res['exp_year'] ?? '')) ?: (date('Y') + 1)); $token->save(); return $token->get_id(); } /** * 处理首笔支付:支持选择已保存令牌或新卡令牌化后扣款。 * * 成功时标记订单已支付并返回重定向地址;失败时返回 fail 并提示错误。 * @param int $order_id 订单 ID * @return array{result:string,redirect?:string} */ public function process_payment($order_id) { $order = wc_get_order($order_id); $user_id = $order->get_user_id(); // 支持 Blocks:若从 Store API 捕获到支付数据,优先使用该数据 $blocks_data = is_object($order) && method_exists($order, 'get_meta') ? (array) $order->get_meta('_yoone_moneris_pm_data') : array(); // 选择已保存的令牌或创建新令牌 $selected_token_id = $this->get_selected_token_id(); if (! $selected_token_id && ! empty($blocks_data['saved_token_id']) && 'new' !== $blocks_data['saved_token_id']) { $selected_token_id = absint($blocks_data['saved_token_id']); } $token_string = ''; if ($selected_token_id) { $token_obj = WC_Payment_Tokens::get($selected_token_id); if ($token_obj && $token_obj->get_user_id() == $user_id && $token_obj->get_gateway_id() === $this->id) { $token_string = $token_obj->get_token(); } else { wc_add_notice(__('选择的支付令牌不可用。', 'yoone-moneris'), 'error'); return array('result' => 'fail'); } } else { // 创建新令牌(占位) $card = $this->get_posted_card(); // 若 Blocks 提供了卡片数据,使用其值覆盖经典表单读取结果 if (! empty($blocks_data)) { $normalize = function($v){ return preg_replace('/\D+/', '', (string) $v); }; $number = isset($blocks_data['number']) ? $normalize($blocks_data['number']) : ''; $cvc = isset($blocks_data['cvc']) ? $normalize($blocks_data['cvc']) : ''; $exp_month = isset($blocks_data['exp_month']) ? $normalize($blocks_data['exp_month']) : ''; $exp_year = isset($blocks_data['exp_year']) ? $normalize($blocks_data['exp_year']) : ''; if (strlen($exp_year) === 2) { $exp_year = '20' . $exp_year; } $card = array( 'number' => $number ?: $card['number'], 'exp_month' => $exp_month ?: $card['exp_month'], 'exp_year' => $exp_year ?: $card['exp_year'], 'cvc' => $cvc ?: $card['cvc'], ); } $res = $this->api()->tokenize_card($card); if (empty($res['success'])) { wc_add_notice(__('令牌化失败:', 'yoone-moneris') . $res['error'], 'error'); return array('result' => 'fail'); } $token_string = $res['token']; // 保存到用户 if ($user_id) { $wc_token_id = $this->create_wc_token($res + ['exp_month' => $card['exp_month'], 'exp_year' => $card['exp_year']], $user_id); $order->add_payment_token($wc_token_id); } } // 首笔扣款 $amount = $order->get_total(); $currency = $order->get_currency(); $charge = $this->api()->charge_token($token_string, $amount, $currency, $order_id); if (empty($charge['success'])) { wc_add_notice(__('支付失败:', 'yoone-moneris') . ($charge['error'] ?? ''), 'error'); return array('result' => 'fail'); } // 标记订单已支付 $order->payment_complete($charge['transaction_id'] ?? ''); $order->add_order_note(sprintf('Moneris 首付成功,交易号:%s', $charge['transaction_id'] ?? 'N/A')); return array( 'result' => 'success', 'redirect' => $this->get_return_url($order), ); } /** * Woo Subscriptions 自动续费扣款。 * * 从订单或用户查找与本网关关联的令牌后进行扣款,并写入订单备注。 * @param float|int $amount_to_charge 本次应扣金额 * @param int|WC_Order $order 订单或订单 ID * @return void */ public function scheduled_subscription_payment($amount_to_charge, $order) { if (is_numeric($order)) { $order = wc_get_order($order); } if (! $order) return; $user_id = $order->get_user_id(); // 找到一个我们网关的令牌(订单或用户) $tokens = WC_Payment_Tokens::get_order_tokens($order->get_id()); $token_string = ''; foreach ($tokens as $t) { if ($t->get_gateway_id() === $this->id) { $token_string = $t->get_token(); break; } } if (! $token_string && $user_id) { $user_tokens = WC_Payment_Tokens::get_customer_tokens($user_id, $this->id); if (! empty($user_tokens)) { $first = array_shift($user_tokens); $token_string = $first->get_token(); } } if (! $token_string) { $order->update_status('failed', '未找到可用的支付令牌,自动续费失败。'); return; } $currency = $order->get_currency(); $charge = $this->api()->charge_token($token_string, $amount_to_charge, $currency, $order->get_id()); if (empty($charge['success'])) { $order->update_status('failed', '自动续费扣款失败:' . ($charge['error'] ?? '')); return; } $order->payment_complete($charge['transaction_id'] ?? ''); $order->add_order_note(sprintf('Moneris 自动续费成功,交易号:%s', $charge['transaction_id'] ?? 'N/A')); } /** * “我的账户 → 付款方式”添加新卡片并保存令牌。 * * @return array|void 成功返回重定向地址;失败添加错误提示 */ public function add_payment_method() { $user_id = get_current_user_id(); if (! $user_id) { wc_add_notice(__('请先登录再添加支付方式。', 'yoone-moneris'), 'error'); return; } $card = $this->get_posted_card(); $res = $this->api()->tokenize_card($card); if (empty($res['success'])) { wc_add_notice(__('添加支付方式失败:', 'yoone-moneris') . $res['error'], 'error'); return; } $wc_token_id = $this->create_wc_token($res + ['exp_month' => $card['exp_month'], 'exp_year' => $card['exp_year']], $user_id); wc_add_notice(__('支付方式已添加。', 'yoone-moneris'), 'success'); return array('result' => 'success', 'redirect' => wc_get_endpoint_url('payment-methods')); } /** * 后台订单退款。 * * 从订单获取交易号,调用 API 退款并记录订单备注。 * @param int $order_id 订单 ID * @param float|null $amount 退款金额 * @param string $reason 退款原因 * @return true|WP_Error 成功返回 true,失败返回 WP_Error */ public function process_refund($order_id, $amount = null, $reason = '') { $order = wc_get_order($order_id); $transaction_id = $order ? $order->get_transaction_id() : ''; if (! $transaction_id) { return new WP_Error('no_tx', __('缺少交易号,无法退款。', 'yoone-moneris')); } $res = $this->api()->refund($transaction_id, $amount); if (empty($res['success'])) { return new WP_Error('refund_failed', __('退款失败:', 'yoone-moneris') . ($res['error'] ?? '')); } $order->add_order_note(sprintf('Moneris 已退款:%s', wc_price($amount))); return true; } }