From 1c509079571ac5e403407380ab22deb7524df06d Mon Sep 17 00:00:00 2001 From: tikkhun Date: Tue, 4 Nov 2025 18:49:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0Moneris=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E7=BD=91=E5=85=B3=E6=8F=92=E4=BB=B6=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现Moneris支付网关插件核心功能,包括: - 支付令牌化与自动续费接口 - WooCommerce Subscriptions集成 - 支付日志记录工具 - 文档与API参考 docs: 添加技术文档与需求说明 refactor: 优化代码结构与分层设计 --- README.md | 116 +++ assets/js/blocks/moneris.js | 100 +++ docs/api 参考.md | 1 + docs/woocommerce-gateway-moneris参考.md | 589 +++++++++++++++ docs/技术文档 AI 提示.md | 3 + docs/技术文档.md | 374 ++++++++++ docs/需求.md | 4 + .../blocks/class-yoone-blocks-moneris.php | 110 +++ includes/class-yoone-gateway-moneris.php | 671 ++++++++++++++++++ includes/class-yoone-moneris-api.php | 418 +++++++++++ includes/constants/moneris.php | 80 +++ .../class-yoone-moneris-api-interface.php | 41 ++ includes/utils/logger.php | 31 + readme.txt | 21 + yoone-moneris-payments.php | 144 ++++ 15 files changed, 2703 insertions(+) create mode 100644 README.md create mode 100644 assets/js/blocks/moneris.js create mode 100644 docs/api 参考.md create mode 100644 docs/woocommerce-gateway-moneris参考.md create mode 100644 docs/技术文档 AI 提示.md create mode 100644 docs/技术文档.md create mode 100644 docs/需求.md create mode 100644 includes/blocks/class-yoone-blocks-moneris.php create mode 100644 includes/class-yoone-gateway-moneris.php create mode 100644 includes/class-yoone-moneris-api.php create mode 100644 includes/constants/moneris.php create mode 100644 includes/interfaces/class-yoone-moneris-api-interface.php create mode 100644 includes/utils/logger.php create mode 100644 readme.txt create mode 100644 yoone-moneris-payments.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c78775 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Yoone Moneris Payments 插件 + +为 WooCommerce 集成 Moneris 支付网关,支持普通支付与 WooCommerce Subscriptions 订阅支付(令牌化与使用令牌自动续费)。本插件可直接对接 Moneris. + +重点能力一览: +- 普通支付(首笔扣款):在经典结账表单收集卡片信息,进行令牌化后完成首笔扣款; +- 令牌化(Tokenization):将卡片信息转换为 Vault 令牌,保存为 WooCommerce 的 Payment Token; +- 使用令牌支付(Token Payment):使用保存的令牌完成后续扣款,适用于订阅自动续费; +- Woo Subscriptions 集成:实现 `scheduled_subscription_payment` 自动续费; +- 我的账户添加支付方式:用户可在“我的账户 → 支付方式”中添加、保存卡片令牌; +- 退款:提供基础退款接口; +- 分层架构:`Yoone_Gateway_Moneris`(网关)→ `Yoone_Moneris_API`(领域 API)→ moneryze/Moneris(传输层)。 + +## 安装与配置 +1) 将插件目录 `yoone-moneris-payments` 放置于 `wp-content/plugins/` 下并启用插件。 +2) 在 WooCommerce → 设置 → 付款 → Moneris (Yoone) 中配置: + - Store ID:Moneris 的 Store ID; + - API Token:Moneris 的 API Token; + - Sandbox:开发测试阶段可开启; + - Moneryze Base URL(可选):指向已部署的 moneryze Node 服务基础 URL,例如 `https://moneryze.example.com/api`。配置后,插件将通过该服务间接调用 Moneris 接口。 + +## 与 WooCommerce Subscriptions 的接口要求说明 +Woo Subscriptions 对支付网关的关键要求主要集中在两点: + +1. 令牌化(Tokenization) + - 在首笔支付时(或用户在“我的账户”添加支付方式时),将卡片信息令牌化,返回一个可持久化的令牌(Vault Token)。 + - 令牌需绑定到 WooCommerce 的 `WC_Payment_Token`,并关联用户(`user_id`)与网关(`gateway_id`)。 + - 建议同时保存卡片 `last4`、`brand`、`exp_month`、`exp_year`,以便展示与到期提醒。 + +2. 使用令牌进行后续扣款(Scheduled Payments) + - Subscriptions 会在到期时调用网关的 `scheduled_subscription_payment( $amount_to_charge, $order )` 方法; + - 网关需查找订单或用户的保存令牌,并使用令牌完成扣款; + - 成功后应调用 `$order->payment_complete( $transaction_id )` 并记录订单备注;失败则更新订单为 `failed` 并给出错误说明。 + +本插件在 `class-yoone-gateway-moneris.php` 中实现了上述流程,使用 `WC_Payment_Gateway_CC` 的默认信用卡表单(经典结账页面)。如果使用 Woo Blocks 结账,需要额外实现 Blocks 的网关集成(不在本版范围)。 + +## 架构与代码分层 +- 网关层:`includes/class-yoone-gateway-moneris.php` + - 负责 WooCommerce 网关注册、设置项、前端表单渲染、订单处理、令牌保存与自动续费对接。 + - 关键方法: + - `process_payment( $order_id )`:首笔支付流程; + - `scheduled_subscription_payment( $amount, $order )`:自动续费流程; + - `add_payment_method()`:我的账户添加支付方式; + - `process_refund( $order_id, $amount )`:退款; + +- 领域 API 层:`includes/class-yoone-moneris-api.php` + - 封装令牌化、令牌扣款、退款等领域动作; + - 如果配置了 `Moneryze Base URL`,通过 `send_request()` 以 JSON POST 调用 moneryze Node 服务;否则使用占位实现(确保流程可运行,便于集成测试)。 + - 关键方法: + - `tokenize_card( $card, $customer_id = null )`:令牌化卡片,返回 `{ success, token, last4, brand, exp_month, exp_year }`; + - `charge_token( $token, $amount, $currency, $order_id, $capture = true )`:使用令牌扣款,返回 `{ success, transaction_id }`; + - `refund( $transaction_id, $amount )`:退款,返回 `{ success }`; + +### 与 moneryze 的对接约定(参考) +为便于与 `/Users/zksu/Developer/work/code/moneryze`(Node.js Moneris 接口)对齐,本插件的 API 层约定以下 HTTP 端点与数据格式(可根据实际服务调整): + +- POST `{BASE_URL}/tokenize` + - 请求:`{ number, exp_month, exp_year, cvc, customer_id, store_id, api_token, sandbox }` + - 响应成功:`{ success: true, token, last4, brand, exp_month, exp_year }` + - 响应失败:`{ success: false, error }` + +- POST `{BASE_URL}/charge` + - 请求:`{ token, amount, currency, order_id, capture, store_id, api_token, sandbox }` + - 响应成功:`{ success: true, transaction_id }` + - 响应失败:`{ success: false, error }` + +- POST `{BASE_URL}/refund` + - 请求:`{ transaction_id, amount, store_id, api_token, sandbox }` + - 响应成功:`{ success: true }` + - 响应失败:`{ success: false, error }` + +### 典型流程说明 +1. 首笔支付(普通商品或订阅首付) + - 用户在结账页填写卡片信息; + - 网关调用 `tokenize_card()` 获取令牌; + - 若用户已登录,保存令牌到 `WC_Payment_Token_CC`,并关联订单; + - 调用 `charge_token()` 进行扣款; + - 成功:`payment_complete()`;失败:显示错误并返回 `fail`。 + +2. 自动续费(订阅) + - Subscriptions 调用 `scheduled_subscription_payment()`; + - 网关查找订单或用户的令牌; + - 调用 `charge_token()` 完成扣款; + - 成功:`payment_complete()`;失败:更新为 `failed` 并记录原因。 + +3. 我的账户添加支付方式 + - 用户在“我的账户 → 支付方式”提交卡片信息; + - 网关调用 `tokenize_card()` 并保存到 `WC_Payment_Token_CC`; + - 成功后返回支付方式列表页面。 + +## 与 WooCommerce 的数据对象 +- `WC_Payment_Token_CC`:保存令牌、用户、到期月/年、后四位等信息; +- `WC_Order`:订单对象,包含总额、货币、交易号等;通过 `$order->payment_complete( $transaction_id )` 标记成功。 + +## 开发注意事项 +- 生产环境应使用前端令牌化或受控代理(如 moneryze)避免在服务器接收原始卡号; +- 错误处理与日志:建议在 API 层对异常进行捕获并返回统一错误信息; +- 安全:`Store ID` 和 `API Token` 不应暴露到前端; +- Woo Blocks 结账:如需支持,请实现与 Blocks 的支付集成(独立的注册与数据通道)。 + +## 后续扩展建议 +- 支持预授权与完成(preauth/capture)的配置切换; +- 保存并管理多张卡的默认标记; +- 交易查询与对账; +- Webhook/回调:用于异步更新交易状态(若 moneryze 支持)。 + +## 目录结构 +- `includes/class-yoone-gateway-moneris.php`:Woo 网关入口与生命周期; +- `includes/class-yoone-moneris-api.php`:Moneris 领域 API 封装(支持 moneryze 代理调用)。 + +## 测试 +- 在沙箱模式下使用测试卡进行结账与添加支付方式; +- 创建订阅商品,验证自动续费路径(到期后查看订单备注与状态)。 + +## 免责声明 +当前内置的直接 Moneris 接口为占位实现,用于演示与集成验证;生产环境请配置并使用 moneryze 服务或替换为官方 SDK/接口调用。 diff --git a/assets/js/blocks/moneris.js b/assets/js/blocks/moneris.js new file mode 100644 index 0000000..dc37a88 --- /dev/null +++ b/assets/js/blocks/moneris.js @@ -0,0 +1,100 @@ +/* global wp, wc, window */ +(function() { + // Ensure Blocks registry and settings are available + var registry = (wc && wc.blocksRegistry) ? wc.blocksRegistry : null; + var settingsAPI = (window.wcSettings && window.wcSettings.getSetting) ? window.wcSettings : null; + var i18n = (wp && wp.i18n) ? wp.i18n : null; + var element = (wp && wp.element) ? wp.element : null; + + if (!registry || !settingsAPI || !element) { + return; + } + + var __ = i18n ? i18n.__ : function(s){ return s; }; + var React = element; + + var pmData = settingsAPI.getSetting('yooneMonerisData', {}); + var label = pmData.title || __('Credit Card (Moneris)', 'yoone-moneris'); + + var Content = function(props) { + var el = React.createElement; + var useState = React.useState, useEffect = React.useEffect; + + var _a = useState(''), number = _a[0], setNumber = _a[1]; + var _b = useState(''), expiry = _b[0], setExpiry = _b[1]; + var _c = useState(''), cvc = _c[0], setCvc = _c[1]; + + useEffect(function() { + var unsubscribe = props.eventRegistration.onPaymentProcessing(function() { + var parts = (expiry || '').split('/'); + var m = parts[0] ? parts[0].trim() : ''; + var y = parts[1] ? parts[1].trim() : ''; + if (y && y.length === 2) { y = '20' + y; } + return { + type: 'success', + meta: { + paymentMethodData: { + yoone_moneris: { + number: String(number || ''), + exp_month: String(m || ''), + exp_year: String(y || ''), + cvc: String(cvc || ''), + } + } + } + }; + }); + return function() { unsubscribe(); }; + }, [number, expiry, cvc]); + + return el('div', { className: 'yoone-moneris-blocks-fields' }, [ + el('div', { className: 'yoone-moneris-field' }, [ + el('label', {}, __('Card Number', 'yoone-moneris')), + el('input', { + type: 'text', + inputMode: 'numeric', + autoComplete: 'cc-number', + value: number, + onChange: function(e){ setNumber(e.target.value); }, + placeholder: '4111111111111111' + }) + ]), + el('div', { className: 'yoone-moneris-field' }, [ + el('label', {}, __('Expiry (MM/YY)', 'yoone-moneris')), + el('input', { + type: 'text', + inputMode: 'numeric', + autoComplete: 'cc-exp', + value: expiry, + onChange: function(e){ setExpiry(e.target.value); }, + placeholder: 'MM/YY' + }) + ]), + el('div', { className: 'yoone-moneris-field' }, [ + el('label', {}, __('CVC', 'yoone-moneris')), + el('input', { + type: 'text', + inputMode: 'numeric', + autoComplete: 'cc-csc', + value: cvc, + onChange: function(e){ setCvc(e.target.value); }, + placeholder: '123' + }) + ]) + ]); + }; + + registry.registerPaymentMethod({ + name: 'yoone_moneris', + label: label, + ariaLabel: label, + content: Content, + edit: Content, + canMakePayment: function() { + return !! pmData.enabled; + }, + supports: { + features: ['products'] + } + }); +})(); \ No newline at end of file diff --git a/docs/api 参考.md b/docs/api 参考.md new file mode 100644 index 0000000..7d10235 --- /dev/null +++ b/docs/api 参考.md @@ -0,0 +1 @@ +https://developer.moneris.com/Documentation/NA/E-Commerce%20Solutions/API/Pre-Authorization?lang=php \ No newline at end of file diff --git a/docs/woocommerce-gateway-moneris参考.md b/docs/woocommerce-gateway-moneris参考.md new file mode 100644 index 0000000..242f547 --- /dev/null +++ b/docs/woocommerce-gateway-moneris参考.md @@ -0,0 +1,589 @@ +# WooCommerce Gateway Moneris 令牌化与自动续费实现参考 + +## 背景意义 + +### 为什么需要令牌化 +- **安全性**:避免存储敏感的信用卡信息 +- **合规性**:符合 PCI DSS 标准要求 +- **用户体验**:支持一键支付和自动续费 +- **业务价值**:提高订阅业务的续费成功率 + +### 为什么需要自动续费 +- **订阅业务**:WooCommerce Subscriptions 的核心功能 +- **减少流失**:避免手动续费导致的客户流失 +- **现金流**:提供可预测的收入流 +- **运营效率**:减少人工干预和客服成本 + +## 核心概念定义 + +### 令牌化(Tokenization) +将敏感的支付信息(如信用卡号)替换为安全的令牌(Token),用于后续的支付操作。 + +### 自动续费(Auto Renewal) +基于已保存的支付令牌,在订阅到期时自动处理续费支付。 + +## 实现路径对比 + +| 实现方式 | 适用场景 | 安全级别 | 实现复杂度 | 用户体验 | +|---------|---------|---------|-----------|---------| +| Checkout 令牌化 | 标准电商支付 | 高 | 低 | 优秀 | +| 直接 API 令牌化 | 自定义支付流程 | 高 | 中 | 良好 | +| 临时转永久令牌 | 特殊业务需求 | 高 | 高 | 一般 | + +## 使用流程 + +### 1. Checkout 令牌化流程 + +#### 步骤说明 +1. 用户在结账页面输入支付信息 +2. 前端调用 Moneris Checkout API +3. 获取临时令牌 +4. 后端将临时令牌转换为永久令牌 +5. 保存令牌到用户账户 + +#### 核心代码示例 + +```php +/** + * Checkout 令牌化处理类 + * 负责处理 Moneris Checkout 的令牌化流程 + */ +class WC_Gateway_Moneris_Checkout_Credit_Card extends WC_Gateway_Moneris_Credit_Card { + + /** + * 处理支付并创建令牌 + * @param int $order_id 订单ID + * @return array 支付结果 + */ + public function process_payment($order_id) { + $order = wc_get_order($order_id); + + // 获取 Checkout 响应中的临时令牌 + $temp_token = $this->get_checkout_temp_token(); + + if ($temp_token && $this->should_tokenize_payment_method()) { + // 将临时令牌转换为永久令牌 + $permanent_token = $this->create_permanent_token($temp_token, $order); + + if ($permanent_token) { + // 保存令牌到用户账户 + $this->get_payment_tokens_handler()->add_token($order->get_user_id(), $permanent_token); + } + } + + return $this->process_standard_payment($order); + } + + /** + * 创建永久令牌 + * @param string $temp_token 临时令牌 + * @param WC_Order $order 订单对象 + * @return WC_Gateway_Moneris_Payment_Token|null + */ + protected function create_permanent_token($temp_token, $order) { + try { + // 调用 Moneris API 创建永久令牌 + $request = $this->get_api()->get_add_token_request($order); + $request->set_temp_token($temp_token); + + $response = $this->get_api()->add_token($request); + + if ($response && $response->transaction_approved()) { + // 创建令牌对象 + return new WC_Gateway_Moneris_Payment_Token( + $response->get_payment_token_id(), + array( + 'type' => 'credit_card', + 'last4' => $response->get_masked_pan(), + 'exp_month' => $response->get_exp_month(), + 'exp_year' => $response->get_exp_year(), + 'card_type' => $response->get_card_type(), + ) + ); + } + } catch (Exception $e) { + $this->add_debug_message('令牌创建失败: ' . $e->getMessage()); + } + + return null; + } +} +``` + +### 2. 直接 API 令牌化流程 + +```php +/** + * 支付令牌处理器 + * 负责令牌的创建、验证和管理 + */ +class WC_Gateway_Moneris_Payment_Tokens_Handler { + + /** + * 创建支付令牌 + * @param WC_Order $order 订单对象 + * @param array $payment_data 支付数据 + * @return WC_Gateway_Moneris_Payment_Token|null + */ + public function create_token($order, $payment_data) { + // 检查是否应该进行令牌化 + if (!$this->should_tokenize($order, $payment_data)) { + return null; + } + + try { + // 构建令牌化请求 + $request = $this->build_tokenize_request($order, $payment_data); + + // 调用 Moneris API + $response = $this->get_api()->tokenize_payment_method($request); + + if ($response && $response->transaction_approved()) { + // 创建并保存令牌 + $token = $this->build_token_from_response($response); + $this->add_token($order->get_user_id(), $token); + + return $token; + } + } catch (Exception $e) { + $this->log_error('令牌创建失败', $e); + } + + return null; + } + + /** + * 判断是否应该进行令牌化 + * @param WC_Order $order 订单对象 + * @param array $payment_data 支付数据 + * @return bool + */ + protected function should_tokenize($order, $payment_data) { + // 用户选择保存支付方式 + if (!empty($payment_data['save_payment_method'])) { + return true; + } + + // 订阅订单自动令牌化 + if (function_exists('wcs_order_contains_subscription') && + wcs_order_contains_subscription($order)) { + return true; + } + + // 预授权订单 + if ($this->is_pre_order($order)) { + return true; + } + + return false; + } + + /** + * 从 API 响应构建令牌对象 + * @param WC_Moneris_API_Response $response API 响应 + * @return WC_Gateway_Moneris_Payment_Token + */ + protected function build_token_from_response($response) { + return new WC_Gateway_Moneris_Payment_Token( + $response->get_payment_token_id(), + array( + 'type' => 'credit_card', + 'last4' => $response->get_masked_pan(), + 'exp_month' => $response->get_exp_month(), + 'exp_year' => $response->get_exp_year(), + 'card_type' => $response->get_card_type(), + 'gateway_id' => $this->get_gateway()->get_id(), + 'user_id' => get_current_user_id(), + ) + ); + } +} +``` + +### 3. WooCommerce Subscriptions 集成 + +```php +/** + * Moneris 订阅集成类 + * 处理与 WooCommerce Subscriptions 的集成 + */ +class WC_Moneris_Payment_Gateway_Integration_Subscriptions { + + /** + * 处理订阅续费 + * @param float $amount 续费金额 + * @param WC_Order $renewal_order 续费订单 + * @return void + */ + public function process_subscription_payment($amount, $renewal_order) { + try { + // 获取父订单的支付令牌 + $subscription = wcs_get_subscription($renewal_order->get_meta('_subscription_renewal')); + $parent_order = $subscription->get_parent(); + $token = $this->get_order_token($parent_order); + + if (!$token) { + throw new Exception('未找到有效的支付令牌'); + } + + // 使用令牌处理续费支付 + $result = $this->process_token_payment($renewal_order, $token, $amount); + + if ($result['success']) { + // 续费成功 + $renewal_order->payment_complete($result['transaction_id']); + $renewal_order->add_order_note( + sprintf('Moneris 自动续费成功 - 交易ID: %s', $result['transaction_id']) + ); + } else { + // 续费失败 + $renewal_order->update_status('failed', $result['message']); + } + + } catch (Exception $e) { + $renewal_order->update_status('failed', '自动续费处理失败: ' . $e->getMessage()); + } + } + + /** + * 使用令牌处理支付 + * @param WC_Order $order 订单对象 + * @param WC_Gateway_Moneris_Payment_Token $token 支付令牌 + * @param float $amount 支付金额 + * @return array 支付结果 + */ + protected function process_token_payment($order, $token, $amount) { + // 构建支付请求 + $request = $this->build_payment_request($order, $token, $amount); + + // 设置 Credential on File (CoF) 参数 + $request->set_cof_info(array( + 'payment_indicator' => 'R', // R = Recurring (订阅续费) + 'payment_information' => '2', // 2 = Subsequent payment + 'issuer_id' => $token->get_issuer_id(), // 从初始交易获取 + )); + + // 调用支付 API + $response = $this->get_api()->credit_card_purchase($request); + + return array( + 'success' => $response && $response->transaction_approved(), + 'transaction_id' => $response ? $response->get_transaction_id() : null, + 'message' => $response ? $response->get_message() : '支付处理失败', + ); + } + + /** + * 获取订单关联的支付令牌 + * @param WC_Order $order 订单对象 + * @return WC_Gateway_Moneris_Payment_Token|null + */ + protected function get_order_token($order) { + $token_id = $order->get_meta('_payment_token_id'); + + if ($token_id) { + $tokens = WC_Payment_Tokens::get_customer_tokens($order->get_user_id()); + + foreach ($tokens as $token) { + if ($token->get_token() === $token_id && + $token instanceof WC_Gateway_Moneris_Payment_Token) { + return $token; + } + } + } + + return null; + } +} +``` + +### 4. 令牌对象结构 + +```php +/** + * Moneris 支付令牌类 + * 继承自 WooCommerce 的支付令牌基类 + */ +class WC_Gateway_Moneris_Payment_Token extends WC_Payment_Token_CC { + + /** @var string 令牌类型 */ + protected $type = 'moneris_cc'; + + /** + * 获取令牌的额外数据 + * @return array + */ + protected function get_hook_prefix() { + return 'woocommerce_payment_token_moneris_cc_get_'; + } + + /** + * 验证令牌数据 + * @return bool + */ + public function validate() { + if (false === parent::validate()) { + return false; + } + + // 验证 Moneris 特定字段 + if (!$this->get_token()) { + return false; + } + + return true; + } + + /** + * 获取 Moneris 令牌ID + * @return string + */ + public function get_moneris_token_id() { + return $this->get_meta('moneris_token_id'); + } + + /** + * 设置 Moneris 令牌ID + * @param string $token_id + */ + public function set_moneris_token_id($token_id) { + $this->add_meta_data('moneris_token_id', $token_id, true); + } + + /** + * 获取发卡行ID(用于 CoF) + * @return string + */ + public function get_issuer_id() { + return $this->get_meta('issuer_id'); + } + + /** + * 设置发卡行ID + * @param string $issuer_id + */ + public function set_issuer_id($issuer_id) { + $this->add_meta_data('issuer_id', $issuer_id, true); + } +} +``` + +## 端到端流程示例 + +### 订阅创建到自动续费完整流程 + +```php +/** + * 完整的订阅支付流程示例 + */ +class Moneris_Subscription_Flow_Example { + + /** + * 步骤1: 用户创建订阅订单 + */ + public function create_subscription_order() { + // 1. 用户在前端选择订阅产品并结账 + // 2. 系统检测到订阅产品,自动启用令牌化 + // 3. 调用 process_payment 处理初始支付 + + $order_id = 12345; + $order = wc_get_order($order_id); + + // 检查是否为订阅订单 + if (wcs_order_contains_subscription($order)) { + // 强制启用令牌化 + add_filter('wc_moneris_tokenize_payment_method', '__return_true'); + } + + return $this->gateway->process_payment($order_id); + } + + /** + * 步骤2: 处理初始支付并保存令牌 + */ + public function process_initial_payment($order) { + // 1. 处理支付 + $payment_result = $this->process_credit_card_payment($order); + + if ($payment_result['success']) { + // 2. 创建并保存令牌 + $token = $this->create_payment_token($order, $payment_result['response']); + + // 3. 将令牌关联到订阅 + $subscriptions = wcs_get_subscriptions_for_order($order); + foreach ($subscriptions as $subscription) { + $subscription->update_meta_data('_payment_token_id', $token->get_id()); + $subscription->save(); + } + } + + return $payment_result; + } + + /** + * 步骤3: 自动续费处理(由 WooCommerce Subscriptions 触发) + */ + public function process_renewal_payment($amount, $renewal_order) { + // 1. 获取原始订阅和令牌 + $subscription = wcs_get_subscription($renewal_order->get_meta('_subscription_renewal')); + $token_id = $subscription->get_meta('_payment_token_id'); + $token = WC_Payment_Tokens::get($token_id); + + if (!$token) { + $renewal_order->update_status('failed', '未找到有效的支付令牌'); + return; + } + + // 2. 使用令牌处理续费 + try { + $request = $this->build_renewal_request($renewal_order, $token, $amount); + $response = $this->get_api()->credit_card_purchase($request); + + if ($response && $response->transaction_approved()) { + // 续费成功 + $renewal_order->payment_complete($response->get_transaction_id()); + $renewal_order->add_order_note( + sprintf('自动续费成功 - 令牌: %s, 交易ID: %s', + $token->get_token(), + $response->get_transaction_id() + ) + ); + } else { + // 续费失败 + $error_msg = $response ? $response->get_message() : '支付处理失败'; + $renewal_order->update_status('failed', $error_msg); + } + + } catch (Exception $e) { + $renewal_order->update_status('failed', '续费异常: ' . $e->getMessage()); + } + } + + /** + * 构建续费支付请求 + */ + protected function build_renewal_request($order, $token, $amount) { + $request = new WC_Moneris_API_Credit_Card_Purchase_Request(); + + // 基本支付信息 + $request->set_order_id($order->get_order_number()); + $request->set_amount($amount); + $request->set_payment_token($token->get_token()); + + // CoF (Credential on File) 信息 - 用于订阅续费 + $request->set_cof_info(array( + 'payment_indicator' => 'R', // R = Recurring + 'payment_information' => '2', // 2 = Subsequent payment + 'issuer_id' => $token->get_issuer_id(), + )); + + // 客户信息 + $request->set_customer_info(array( + 'email' => $order->get_billing_email(), + 'phone' => $order->get_billing_phone(), + )); + + return $request; + } +} +``` + +## 关键配置与注意事项 + +### 1. 网关配置要求 + +| 配置项 | 说明 | 必需性 | +|--------|------|--------| +| Store ID | Moneris 商户标识 | 必需 | +| API Token | API 访问令牌 | 必需 | +| 令牌化支持 | 启用令牌化功能 | 订阅必需 | +| CoF 支持 | Credential on File 支持 | 续费推荐 | +| CSC 验证 | 安全码验证设置 | 可选 | + +### 2. 订阅集成配置 + +```php +/** + * 订阅功能集成配置 + */ +public function configure_subscription_support() { + // 声明支持的功能 + $this->supports = array( + 'products', + 'subscriptions', + 'subscription_cancellation', + 'subscription_suspension', + 'subscription_reactivation', + 'subscription_amount_changes', + 'subscription_date_changes', + 'multiple_subscriptions', + 'tokenization', + ); + + // 注册订阅相关的钩子 + add_action('woocommerce_scheduled_subscription_payment_' . $this->id, + array($this, 'process_subscription_payment'), 10, 2); + + add_action('wcs_resubscribe_order_created', + array($this, 'delete_resubscribe_meta'), 10); + + add_filter('wcs_renewal_order_meta_query', + array($this, 'remove_renewal_order_meta'), 10); +} +``` + +### 3. 错误处理与重试机制 + +```php +/** + * 续费失败处理 + */ +public function handle_renewal_failure($renewal_order, $error_message) { + // 记录失败原因 + $renewal_order->add_order_note('自动续费失败: ' . $error_message); + + // 根据错误类型决定处理策略 + if ($this->is_temporary_error($error_message)) { + // 临时错误:标记为待重试 + $renewal_order->update_meta_data('_renewal_retry_count', + $renewal_order->get_meta('_renewal_retry_count') + 1); + + // 安排重试(如果未超过最大重试次数) + if ($renewal_order->get_meta('_renewal_retry_count') < 3) { + wp_schedule_single_event( + time() + (24 * 60 * 60), // 24小时后重试 + 'moneris_retry_renewal_payment', + array($renewal_order->get_id()) + ); + } + } else { + // 永久错误:通知客户更新支付方式 + $this->send_payment_method_update_notice($renewal_order); + } +} +``` + +## 限制与注意事项 + +### 技术限制 +- **CSC 要求**:如果启用 CSC 验证,可能影响令牌化支付 +- **令牌有效期**:需要处理令牌过期和更新 +- **网络依赖**:依赖 Moneris API 的可用性 + +### 合规要求 +- **PCI DSS**:必须遵循 PCI 合规标准 +- **数据保护**:不得存储敏感的卡片信息 +- **用户同意**:令牌化需要用户明确同意 + +### 最佳实践 +- **错误处理**:实现完善的错误处理和重试机制 +- **日志记录**:记录关键操作用于调试和审计 +- **测试环境**:充分测试令牌化和续费流程 +- **监控告警**:监控续费成功率和失败原因 + +## 参考文档 + +- [Moneris API 官方文档](https://developer.moneris.com/) +- [WooCommerce Subscriptions 开发文档](https://docs.woocommerce.com/document/subscriptions/) +- [WooCommerce Payment Gateway API](https://docs.woocommerce.com/document/payment-gateway-api/) +- [PCI DSS 合规指南](https://www.pcisecuritystandards.org/) \ No newline at end of file diff --git a/docs/技术文档 AI 提示.md b/docs/技术文档 AI 提示.md new file mode 100644 index 0000000..d8dec4c --- /dev/null +++ b/docs/技术文档 AI 提示.md @@ -0,0 +1,3 @@ +# 支付网关 + +## \ No newline at end of file diff --git a/docs/技术文档.md b/docs/技术文档.md new file mode 100644 index 0000000..1887446 --- /dev/null +++ b/docs/技术文档.md @@ -0,0 +1,374 @@ +# Yoone Moneris 支付网关技术文档 + +## 1. 插件概述 + +Yoone Moneris 支付网关是一个专为 WooCommerce 设计的支付处理插件,与 Yoone Subscriptions 订阅系统无缝集成,实现信用卡令牌化存储和自动续费功能。本插件支持 Moneris 信用卡支付,并提供完整的订阅支付解决方案。 + +### 1.1 核心功能 + +- 支持 Yoone Subscriptions 订阅系统的自动续费 +- 信用卡支付令牌化存储 +- 安全的客户支付信息管理 +- 支付失败自动重试机制 +- 与 WooCommerce Subscriptions 插件的兼容性 +- 完整的支付日志记录 + +## 2. 系统架构 + +### 2.1 整体架构 + +Yoone Moneris 支付网关采用分层架构设计,与 Yoone Subscriptions 插件紧密集成: + +``` +┌─────────────────────────┐ +│ WooCommerce 前端界面 │ +└────────────────┬────────┘ + ↓ +┌─────────────────────────┐ ┌─────────────────┐ +│ Yoone Subscriptions │────▶│ 订阅管理系统 │ +└────────────────┬────────┘ └─────────────────┘ + ↓ +┌─────────────────────────┐ ┌─────────────────┐ +│ Yoone Moneris 支付网关 │────▶│ Moneris API │ +└─────────────────────────┘ └─────────────────┘ +``` + +### 2.2 模块关系 + +- **订阅系统**:管理订阅计划和生命周期 +- **支付网关**:处理支付交易和令牌化 +- **Moneris API**:与 Moneris 支付处理系统通信 +- **令牌存储**:安全保存客户支付令牌 +- **定时任务**:处理自动续费和失败重试 + +## 3. 支付令牌化实现 + +### 3.1 令牌化流程 + +令牌化是实现自动续费的关键技术,流程如下: + +1. **首次支付流程** + - 客户在结账页面输入信用卡信息 + - 支付信息通过安全通道发送到 Moneris + - Moneris 处理支付并返回支付令牌 + - 支付令牌安全存储在 WordPress 数据库中 + +2. **令牌存储机制** + - 使用 WooCommerce 支付令牌系统存储令牌信息 + - 令牌关联到客户账户和订阅记录 + - 实际信用卡信息不会存储在本地系统 + +### 3.2 技术实现细节 + +```php +// 首次支付时保存支付令牌 +public function save_payment_token_for_subscription( $order_id ) { + $order = wc_get_order( $order_id ); + + // 检查订单是否包含订阅商品 + if ( ! $order || ! self::order_contains_subscription( $order ) ) { + return; + } + + // 获取支付方式和网关 + $payment_method = $order->get_payment_method(); + $gateway = WC()->payment_gateways()->get_available_payment_gateways()[ $payment_method ] ?? null; + + // 验证支付网关支持订阅功能 + if ( ! $gateway || ! self::gateway_supports_subscriptions( $payment_method ) ) { + return; + } + + // 获取支付令牌 + $payment_token = null; + + // 从订单元数据获取令牌 + $token_id = $order->get_meta( '_payment_token_id' ); + if ( $token_id ) { + $payment_token = WC_Payment_Tokens::get( $token_id ); + } + + // 如果没有找到令牌,尝试从网关获取 + if ( ! $payment_token && method_exists( $gateway, 'get_order_payment_token' ) ) { + $payment_token = $gateway->get_order_payment_token( $order ); + } + + // 保存令牌到订阅记录 + if ( $payment_token ) { + $subscriptions = self::get_subscriptions_for_order( $order_id ); + + foreach ( $subscriptions as $subscription_id ) { + update_post_meta( $subscription_id, '_payment_token_id', $payment_token->get_id() ); + update_post_meta( $subscription_id, '_payment_method', $payment_method ); + } + } +} +``` + +## 4. 自动续费机制 + +### 4.1 续费处理流程 + +自动续费是订阅系统的核心功能,由定时任务触发: + +1. **续费触发** + - WordPress Cron 系统按计划触发续费任务 + - 检查每个订阅的下次支付日期 + - 对到期订阅执行续费处理 + +2. **支付处理** + - 从订阅记录获取保存的支付令牌 + - 创建续费订单 + - 使用令牌进行自动扣款 + - 处理支付结果(成功/失败) + +3. **状态更新** + - 支付成功:更新订阅状态和下次续费日期 + - 支付失败:安排重试或暂停订阅 + +### 4.2 技术实现细节 + +```php +// 处理订阅续费 +public function process_subscription_renewal( $subscription_id ) { + $subscription = get_post( $subscription_id ); + + if ( ! $subscription || 'yoone_subscription' !== $subscription->post_type ) { + return; + } + + // 获取支付方式和令牌 + $payment_method = get_post_meta( $subscription_id, '_payment_method', true ); + $payment_token_id = get_post_meta( $subscription_id, '_payment_token_id', true ); + + if ( ! $payment_method || ! $payment_token_id ) { + $this->handle_renewal_failure( $subscription_id, '缺少支付方式或支付令牌' ); + return; + } + + // 获取网关和令牌 + $gateway = WC()->payment_gateways()->get_available_payment_gateways()[ $payment_method ] ?? null; + $payment_token = WC_Payment_Tokens::get( $payment_token_id ); + + if ( ! $gateway || ! $payment_token ) { + $this->handle_renewal_failure( $subscription_id, '支付网关或支付令牌无效' ); + return; + } + + // 创建续费订单 + $renewal_order = $this->create_renewal_order( $subscription_id ); + + if ( ! $renewal_order ) { + $this->handle_renewal_failure( $subscription_id, '创建续费订单失败' ); + return; + } + + // 处理续费支付 + try { + $result = $this->process_renewal_payment( $gateway, $renewal_order, $payment_token ); + + if ( $result ) { + $this->handle_renewal_success( $subscription_id, $renewal_order ); + } else { + $this->handle_renewal_failure( $subscription_id, '续费支付处理失败', $renewal_order ); + } + } catch ( Exception $e ) { + $this->handle_renewal_failure( $subscription_id, '续费支付异常: ' . $e->getMessage(), $renewal_order ); + } +} +``` + +## 5. 支付失败处理机制 + +### 5.1 失败重试策略 + +支付失败是订阅系统中常见的情况,系统实现了智能重试机制: + +1. **重试配置** + - 最大重试次数:3次 + - 重试间隔:24小时 + - 超过重试次数后可自动暂停订阅 + +2. **失败处理流程** + - 支付失败时,订阅状态设为暂停 + - 记录失败原因到订阅日志 + - 发送失败通知给客户 + - 安排下次重试任务 + +3. **客户通知** + - 发送支付失败邮件,提醒客户更新支付方式 + - 提供方便的支付信息更新入口 + +### 5.2 技术实现细节 + +```php +// 处理续费失败 +private function handle_renewal_failure( $subscription_id, $error_message, $renewal_order = null ) { + // 更新订阅状态为暂停 + update_post_meta( $subscription_id, '_status', 'on-hold' ); + + // 添加失败记录 + $this->add_subscription_note( $subscription_id, '续费失败: ' . $error_message ); + + // 如果有续费订单,设置为失败状态 + if ( $renewal_order ) { + $renewal_order->update_status( 'failed', '续费支付失败: ' . $error_message ); + } + + // 安排重试(24小时后) + wp_schedule_single_event( time() + DAY_IN_SECONDS, 'yoone_subscription_process_renewal', [ $subscription_id ] ); + + // 发送续费失败邮件 + do_action( 'yoone_subscription_renewal_failed', $subscription_id, $error_message, $renewal_order ); +} +``` + +## 6. 与 WooCommerce Subscriptions 的兼容性 + +插件设计为与 WooCommerce Subscriptions 插件完全兼容,实现了双向集成: + +1. **兼容性功能** + - 支持 WCS 的订阅管理界面 + - 同步处理 WCS 计划支付 + - 处理支付方式变更 + - 共享支付令牌系统 + +2. **集成实现** + - 提供 WCS 支持标记 + - 处理 WCS 续费订单 + - 同步支付状态 + +### 6.1 技术实现细节 + +```php +// 处理 WooCommerce Subscriptions 续费订单 +public function handle_wcs_renewal_order( $renewal_order, $subscription ) { + // 如果是 Yoone 订阅,同步处理 + $yoone_subscription_id = $subscription->get_meta( '_yoone_subscription_id' ); + + if ( $yoone_subscription_id ) { + $renewal_order->add_meta_data( '_yoone_subscription_renewal', $yoone_subscription_id ); + $renewal_order->save(); + } + + return $renewal_order; +} + +// 处理 WooCommerce Subscriptions 计划支付 +public function handle_wcs_scheduled_payment( $subscription_id ) { + $subscription = wcs_get_subscription( $subscription_id ); + + if ( ! $subscription ) { + return; + } + + $yoone_subscription_id = $subscription->get_meta( '_yoone_subscription_id' ); + + if ( $yoone_subscription_id ) { + // 触发 Yoone 订阅续费处理 + do_action( 'yoone_subscription_process_renewal', $yoone_subscription_id ); + } +} +``` + +## 7. 安全考虑 + +### 7.1 支付安全措施 + +- 信用卡信息通过加密通道传输 +- 不存储实际信用卡信息,仅保存支付令牌 +- 遵循 PCI DSS 合规标准 +- 使用 WooCommerce 安全令牌存储机制 +- 定期安全审计和更新 + +### 7.2 数据保护 + +- 支付令牌存储在 WordPress 安全的用户元数据中 +- 使用 WordPress 的数据验证和转义机制 +- 访问控制限制对支付数据的访问 +- 敏感操作记录详细日志 + +## 8. Moneris API 集成 + +### 8.1 API 连接配置 + +- **API 端点**:Moneris 测试/生产环境端点 +- **凭证管理**:商户ID和API令牌安全存储 +- **请求格式**:XML/JSON请求格式 +- **响应处理**:标准化响应解析 + +### 8.2 主要 API 功能 + +- **支付处理**:单次支付和授权 +- **令牌创建**:信用卡令牌化 +- **令牌支付**:使用令牌进行支付 +- **交易查询**:获取交易状态 +- **退款处理**:处理退款请求 + +## 9. 安装与配置 + +### 9.1 系统要求 + +- WordPress 6.4 或更高版本 +- WooCommerce 8.0 或更高版本 +- PHP 7.4 或更高版本 +- Yoone Subscriptions 插件 +- SSL 证书(必需,用于安全支付) + +### 9.2 配置步骤 + +1. 安装并激活 Yoone Subscriptions 插件 +2. 安装并激活 Yoone Moneris 支付网关插件 +3. 在 WooCommerce 设置中配置 Moneris 凭证 +4. 启用订阅支持选项 +5. 配置支付选项和退款政策 +6. 测试支付流程 + +## 10. 开发与扩展 + +### 10.1 可用钩子 + +插件提供了多个钩子用于扩展功能: + +- `yoone_moneris_payment_processed`:支付处理完成后触发 +- `yoone_moneris_token_saved`:支付令牌保存后触发 +- `yoone_moneris_renewal_before_process`:续费处理前触发 +- `yoone_moneris_renewal_after_process`:续费处理后触发 +- `yoone_moneris_payment_failed`:支付失败时触发 + +### 10.2 自定义开发 + +- 添加自定义支付验证 +- 集成第三方通知系统 +- 自定义支付成功/失败页面 +- 扩展支付网关功能 + +## 11. 性能优化 + +### 11.1 性能考量 + +- 优化 API 请求频率 +- 使用缓存减少重复请求 +- 异步处理非关键操作 +- 定时任务优化 +- 数据库查询优化 + +## 12. 故障排除 + +### 12.1 常见问题 + +- **支付失败**:检查信用卡信息、余额和有效期 +- **令牌创建失败**:验证 Moneris 凭证和 API 端点 +- **自动续费失败**:检查支付令牌状态和有效性 +- **定时任务不执行**:验证 WordPress Cron 设置 + +### 12.2 日志系统 + +- 详细的支付处理日志 +- 错误捕获和记录 +- 订阅状态变更日志 +- API 通信日志 + +## 13. 总结 + +Yoone Moneris 支付网关为 WooCommerce 电商平台提供了完整的订阅支付解决方案,通过安全的令牌化机制和可靠的自动续费功能,满足了订阅业务的核心需求。插件设计为高度可扩展,同时保持与 WooCommerce 生态系统的兼容性,为商家提供灵活而强大的支付工具。 \ No newline at end of file diff --git a/docs/需求.md b/docs/需求.md new file mode 100644 index 0000000..1a9cd40 --- /dev/null +++ b/docs/需求.md @@ -0,0 +1,4 @@ +# 需求 + +支持 yoone-subscriptions 的订阅制 +可以实现信用卡令牌化和订阅制的自动续费逻辑 \ No newline at end of file diff --git a/includes/blocks/class-yoone-blocks-moneris.php b/includes/blocks/class-yoone-blocks-moneris.php new file mode 100644 index 0000000..0c0bd18 --- /dev/null +++ b/includes/blocks/class-yoone-blocks-moneris.php @@ -0,0 +1,110 @@ +get_param('payment_method'); + } elseif (is_array($result) && isset($result['payment_method'])) { + $pm = (string) $result['payment_method']; + } + if ('yoone_moneris' !== $pm) { + return; + } + // Extract payment method data. + $data = array(); + if (is_object($request) && method_exists($request, 'get_param')) { + $data = $request->get_param('payment_method_data'); + } elseif (is_array($result) && isset($result['payment_method_data'])) { + $data = $result['payment_method_data']; + } + if (is_array($data) && isset($data['yoone_moneris']) && is_array($data['yoone_moneris'])) { + $payload = $data['yoone_moneris']; + } else { + $payload = is_array($data) ? $data : array(); + } + if (! empty($payload)) { + $order->update_meta_data('_yoone_moneris_pm_data', $payload); + $order->save(); + } +} + +// Only declare the Blocks payment method class when the Blocks base type exists to prevent type resolution issues. +if (class_exists('Automattic\WooCommerce\Blocks\Payments\PaymentMethodType') && ! class_exists('Yoone_Moneris_Blocks_Payment_Method')) { + class Yoone_Moneris_Blocks_Payment_Method extends Automattic\WooCommerce\Blocks\Payments\PaymentMethodType { + /** + * Unique name of the payment method in Blocks. + * This MUST match the gateway id used by WooCommerce (yoone_moneris). + */ + public function get_name() { + return 'yoone_moneris'; + } + + /** + * Whether this payment method should be active in Blocks. + * Mirrors the gateway availability: enabled + CAD/USD + HTTPS or sandbox. + */ + public function is_active() { + $settings = get_option('woocommerce_yoone_moneris_settings', array()); + $enabled = isset($settings['enabled']) ? (string)$settings['enabled'] : 'no'; + if ('yes' !== strtolower($enabled)) { + return false; + } + $currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'CAD'; + if (! in_array($currency, array('CAD', 'USD'), true)) { + return false; + } + $is_ssl = function_exists('is_ssl') ? is_ssl() : false; + $sandbox = isset($settings['sandbox']) ? (string)$settings['sandbox'] : 'yes'; + if (! $is_ssl && 'yes' !== strtolower($sandbox)) { + return false; + } + return true; + } + + /** + * Script handles required to render this payment method in Blocks checkout. + * The script is registered in the plugin bootstrap and provides UI + data posting. + */ + public function get_payment_method_script_handles() { + return array('yoone-moneris-blocks'); + } + + /** + * Data exposed to the frontend via wcSettings for the JS to consume. + */ + public function get_payment_method_data() { + $settings = get_option('woocommerce_yoone_moneris_settings', array()); + $title = isset($settings['title']) ? (string)$settings['title'] : __('Credit Card (Moneris)', 'yoone-moneris'); + return array( + 'title' => $title, + 'enabled' => isset($settings['enabled']) ? ('yes' === strtolower((string)$settings['enabled'])) : false, + 'sandbox' => isset($settings['sandbox']) ? (string)$settings['sandbox'] : 'yes', + 'currency' => function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'CAD', + ); + } + + /** + * Declares supported features in Blocks. + */ + public function get_supported_features() { + return array('products'); + } + } +} + +// Register hooks to capture Store API checkout data for Blocks. +add_action('woocommerce_store_api_checkout_order_processed', 'yoone_moneris_store_payment_data', 10, 3); +// Legacy/alternate hook name for some Woo versions. +add_action('woocommerce_blocks_checkout_order_processed', 'yoone_moneris_store_payment_data', 10, 3); \ No newline at end of file diff --git a/includes/class-yoone-gateway-moneris.php b/includes/class-yoone-gateway-moneris.php new file mode 100644 index 0000000..49bd7c3 --- /dev/null +++ b/includes/class-yoone-gateway-moneris.php @@ -0,0 +1,671 @@ +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; + } +} diff --git a/includes/class-yoone-moneris-api.php b/includes/class-yoone-moneris-api.php new file mode 100644 index 0000000..6ff575a --- /dev/null +++ b/includes/class-yoone-moneris-api.php @@ -0,0 +1,418 @@ + $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; + } + yoone_moneris_log_debug( 'Moneris API 配置', 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, + ) ); + } + + /** + * 令牌化卡片(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'] ), + ); + } + $res = $this->send_moneris_xml( 'res_add_cc', $payload ); + 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' ); + } + $type = $capture ? 'res_purchase_cc' : 'res_preauth_cc'; + $payload = array( + 'dataKey' => (string) $token, + 'orderId' => (string) $order_id, + 'amount' => $this->format_amount( $amount ), + 'cryptType' => $this->crypt_type, + ); + $res = $this->send_moneris_xml( $type, $payload ); + if ( $res['ok'] ) { + return array( + 'success' => true, + 'transaction_id' => (string) ( $res['receipt']['txnNumber'] ?? '' ), + ); + } + return array( 'success' => false, 'error' => (string) $res['error'] ); + } + + /** + * 退款(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' ); + } + + // 构建请求体 + $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 ) ); + + $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 ) ) { + 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 ) { + return array( 'ok' => false, 'error' => 'HTTP ' . $code . ' ' . $raw ); + } + + // 解析 XML + $parsed = $this->parse_moneris_xml( $raw ); + if ( ! $parsed['parsed'] ) { + 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' ); + return array( 'ok' => false, 'error' => $message ); + } + + 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'; + } +} \ No newline at end of file diff --git a/includes/constants/moneris.php b/includes/constants/moneris.php new file mode 100644 index 0000000..a6a699d --- /dev/null +++ b/includes/constants/moneris.php @@ -0,0 +1,80 @@ + array( + 'sandbox' => array( + 'host' => 'esqa.moneris.com', + 'path' => '/gateway2/servlet/MpgRequest', + 'protocol' => YOONE_MONERIS_DEFAULT_PROTOCOL, + 'port' => YOONE_MONERIS_DEFAULT_PORT, + ), + 'production' => array( + 'host' => 'www3.moneris.com', + 'path' => '/gateway2/servlet/MpgRequest', + 'protocol' => YOONE_MONERIS_DEFAULT_PROTOCOL, + 'port' => YOONE_MONERIS_DEFAULT_PORT, + ), + ), + 'US' => array( + 'sandbox' => array( + 'host' => 'esplusqa.moneris.com', + 'path' => '/gateway_us/servlet/MpgRequest', + 'protocol' => YOONE_MONERIS_DEFAULT_PROTOCOL, + 'port' => YOONE_MONERIS_DEFAULT_PORT, + ), + 'production' => array( + // 注意:US 生产主机需根据商户环境确认,建议在设置中显式覆盖;此处仅提供路径与端口默认值。 + 'host' => '', + 'path' => '/gateway_us/servlet/MpgRequest', + 'protocol' => YOONE_MONERIS_DEFAULT_PROTOCOL, + 'port' => YOONE_MONERIS_DEFAULT_PORT, + ), + ), +); + +/** + * 判断是否启用沙箱(环境值统一为 'yes'/'no')。 + * + * @param string $sandboxOption 'yes' 或 'no' + * @return bool + */ +function yoone_moneris_is_sandbox($sandboxOption) +{ + return strtolower((string) $sandboxOption) === 'yes'; +} + +/** + * 获取端点默认配置(主机/路径/端口/协议)。 + * + * @param string $countryCode 'CA' 或 'US' + * @param bool $isSandbox 是否沙箱 + * @return array{host:string,path:string,protocol:string,port:int} + */ +function yoone_moneris_endpoint_defaults($countryCode, $isSandbox) +{ + $cc = strtoupper((string) $countryCode); + $env = $isSandbox ? 'sandbox' : 'production'; + $map = $GLOBALS['YOONE_MONERIS_ENDPOINTS']; + if (isset($map[$cc][$env])) { + return $map[$cc][$env]; + } + // 回退 CA 生产 + return $map['CA']['production']; +} \ No newline at end of file diff --git a/includes/interfaces/class-yoone-moneris-api-interface.php b/includes/interfaces/class-yoone-moneris-api-interface.php new file mode 100644 index 0000000..0c2654f --- /dev/null +++ b/includes/interfaces/class-yoone-moneris-api-interface.php @@ -0,0 +1,41 @@ +log($level, $message, $context); + return; + } + // 回退到 PHP error_log + error_log('[' . strtoupper((string) $level) . '] ' . $message); +} + +function yoone_moneris_log_error($message, $context = array()) +{ + yoone_moneris_log('error', $message, $context); +} + +function yoone_moneris_log_debug($message, $context = array()) +{ + yoone_moneris_log('debug', $message, $context); +} \ No newline at end of file diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..87d75c2 --- /dev/null +++ b/readme.txt @@ -0,0 +1,21 @@ +=== Yoone Moneris Payments === +Contributors: yoone +Tags: moneris, woocommerce, payment gateway, subscriptions +Requires at least: 5.0 +Tested up to: 6.8.2 +Stable tag: 0.1.0 +License: GPLv3 +License URI: https://www.gnu.org/licenses/gpl-3.0.html + +自定义 Moneris 支付网关,提供 WooCommerce Subscriptions 自动续费所需的令牌化与定时扣款骨架。当前为占位实现,需要你将 class-yoone-moneris-api.php 接入 Moneris 的实际 API(Vault/Tokenization 与支付/退款)。 + +== 安装 == +1. 将 yoone-moneris-payments 上传到 /wp-content/plugins/ +2. 在后台插件页面启用 "Yoone Moneris Payments" +3. 进入 WooCommerce -> 设置 -> 支付 -> Moneris (Yoone),填入 Store ID 与 API Token,选择 Sandbox +4. 创建订阅商品进行测试 + +== TODO == +- 接入 Moneris Hosted Tokenization/Vault 实际请求 +- 完善错误处理与日志 +- 完善风控与 3DS(如 Moneris 支持) \ No newline at end of file diff --git a/yoone-moneris-payments.php b/yoone-moneris-payments.php new file mode 100644 index 0000000..4f6a526 --- /dev/null +++ b/yoone-moneris-payments.php @@ -0,0 +1,144 @@ +

Yoone Moneris Payments 需要 WooCommerce 插件已启用。

'; + } ); + // 由于 Woo 未启用,直接返回,不再继续初始化插件逻辑。 + return; + } + + // 加载常量/接口/工具与类:require_once 是 PHP 语句,确保文件仅加载一次; + // __DIR__ 是当前文件所在目录,拼接 includes 路径以找到类定义文件。 + require_once __DIR__ . '/includes/constants/moneris.php'; + require_once __DIR__ . '/includes/interfaces/class-yoone-moneris-api-interface.php'; + require_once __DIR__ . '/includes/utils/logger.php'; + require_once __DIR__ . '/includes/class-yoone-moneris-api.php'; + require_once __DIR__ . '/includes/class-yoone-gateway-moneris.php'; + + // 注册到 WooCommerce 支付网关列表:add_filter 是 WordPress 的过滤器钩子 API, + // 'woocommerce_payment_gateways' 钩子在 Woo 初始化网关列表时触发,回调接收现有网关类名数组 $methods; + // 我们把自定义网关类名 'Yoone_Gateway_Moneris' 追加进去并返回。 + add_filter( 'woocommerce_payment_gateways', function( $methods ) { + $methods[] = 'Yoone_Gateway_Moneris'; + return $methods; + } ); +} +// 在 WordPress 的 'plugins_loaded' 钩子上注册初始化函数: +// 'plugins_loaded' 在所有插件加载完成后触发,优先级 0 表示尽早执行; +// 这样可以保证 WooCommerce 已加载,从而我们的初始化逻辑能正确检测与注册网关。 +add_action( 'plugins_loaded', 'yoone_moneris_payments_init', 0 ); + +/** + * 声明 HPOS 兼容(如启用) + */ +// 说明:HPOS(High-Performance Order Storage)是 WooCommerce 的高性能订单存储机制; +// 若站点启用该特性,插件需要声明兼容,避免因订单数据结构变化导致不兼容问题。 +function yoone_moneris_declare_hpos_compat() { + if ( class_exists( '\\Automattic\\WooCommerce\\Utilities\\FeaturesUtil' ) ) { + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true ); + } +} +// 在 WooCommerce 初始化前('before_woocommerce_init' 钩子)注册兼容性声明函数: +// 该钩子在 Woo 启动早期触发,确保 Woo 在初始化过程中已经知道本插件对 HPOS 的兼容性。 +add_action( 'before_woocommerce_init', 'yoone_moneris_declare_hpos_compat' ); + +/** + * 检测是否使用了 WooCommerce Blocks 结账但当前网关未提供 Blocks 集成,提示管理员。 + */ +function yoone_moneris_blocks_checkout_admin_notice() { + if ( ! class_exists( 'WooCommerce' ) ) { + return; + } + // 仅在后台提示 + if ( ! is_admin() ) { + return; + } + + // 网关是否启用 + $settings = get_option( 'woocommerce_yoone_moneris_settings', array() ); + $enabled = isset( $settings['enabled'] ) ? $settings['enabled'] : 'no'; + if ( 'yes' !== $enabled ) { + return; + } + + // 检查结账页是否为 Blocks 结账 + $checkout_id = function_exists( 'wc_get_page_id' ) ? wc_get_page_id( 'checkout' ) : 0; + if ( $checkout_id && function_exists( 'has_block' ) ) { + $post = get_post( $checkout_id ); + if ( $post && has_block( 'woocommerce/checkout', $post ) ) { + // 当前插件未提供 Blocks 前端集成,因此 Blocks 结账不会显示本网关 + add_action( 'admin_notices', function() { + echo '

Yoone Moneris Payments 当前未集成 WooCommerce Blocks 结账。如果你的结账页使用了 Blocks,请改用经典结账(在结账页添加短代码 [woocommerce_checkout] 并在 WooCommerce → 设置 → 高级 中设置该页面为结账页),或联系开发者添加 Blocks 集成。

'; + } ); + } + } +} +add_action( 'admin_init', 'yoone_moneris_blocks_checkout_admin_notice' ); + +/** + * Blocks 结账支付方式集成:注册脚本、向前端暴露设置、注册支付方式类型。 + */ +function yoone_moneris_blocks_bootstrap() { + // 仅当 WooCommerce Blocks 环境可用时进行注册 + if ( ! class_exists( '\\Automattic\\WooCommerce\\Blocks\\Payments\\PaymentMethodRegistry' ) ) { + return; + } + + // 注册前端脚本(不打包版本,依赖 Blocks 与 WP 提供的全局模块) + $asset_deps = array( 'wc-blocks-registry', 'wc-settings', 'wp-element', 'wp-i18n' ); + wp_register_script( + 'yoone-moneris-blocks', + plugins_url( 'yoone-moneris-payments/assets/js/blocks/moneris.js', dirname(__FILE__) ), + $asset_deps, + '1.0.0', + true + ); + + // 暴露网关基础设置到前端 wcSettings + $settings = get_option( 'woocommerce_yoone_moneris_settings', array() ); + $data = array( + 'title' => isset( $settings['title'] ) ? (string) $settings['title'] : __( 'Credit Card (Moneris)', 'yoone-moneris' ), + 'enabled' => isset( $settings['enabled'] ) && 'yes' === strtolower( (string) $settings['enabled'] ), + 'sandbox' => isset( $settings['sandbox'] ) ? (string) $settings['sandbox'] : 'yes', + 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'CAD', + ); + wp_add_inline_script( 'yoone-moneris-blocks', 'window.wcSettings = window.wcSettings || {}; window.wcSettings.yooneMonerisData = ' . wp_json_encode( $data ) . ';', 'before' ); + + // 注册 Blocks 支付方式类型(在 Blocks 类型存在时才注册,避免类未定义导致致命错误) + require_once __DIR__ . '/includes/blocks/class-yoone-blocks-moneris.php'; + add_action( 'woocommerce_blocks_payment_method_type_registration', function( $payment_method_registry ) { + if ( class_exists( 'Yoone_Moneris_Blocks_Payment_Method' ) ) { + $payment_method_registry->register( new Yoone_Moneris_Blocks_Payment_Method() ); + } + } ); +} +add_action( 'woocommerce_blocks_loaded', 'yoone_moneris_blocks_bootstrap' ); \ No newline at end of file