feat: 添加Moneris支付网关插件基础实现
实现Moneris支付网关插件核心功能,包括: - 支付令牌化与自动续费接口 - WooCommerce Subscriptions集成 - 支付日志记录工具 - 文档与API参考 docs: 添加技术文档与需求说明 refactor: 优化代码结构与分层设计
This commit is contained in:
commit
1c50907957
|
|
@ -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/接口调用。
|
||||
|
|
@ -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']
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -0,0 +1 @@
|
|||
https://developer.moneris.com/Documentation/NA/E-Commerce%20Solutions/API/Pre-Authorization?lang=php
|
||||
|
|
@ -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/)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# 支付网关
|
||||
|
||||
##
|
||||
|
|
@ -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 生态系统的兼容性,为商家提供灵活而强大的支付工具。
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# 需求
|
||||
|
||||
支持 yoone-subscriptions 的订阅制
|
||||
可以实现信用卡令牌化和订阅制的自动续费逻辑
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
if (! defined('ABSPATH')) { exit; }
|
||||
|
||||
// Minimal WooCommerce Blocks integration to register Moneris as a Blocks payment method
|
||||
// and capture payment data posted via the Store API.
|
||||
|
||||
/**
|
||||
* Store API checkout payment data capturing.
|
||||
* Defined as a standalone function to avoid hard dependency on Blocks classes during autoload.
|
||||
*/
|
||||
function yoone_moneris_store_payment_data($order, $result, $request) {
|
||||
if (! $order || ! is_object($order)) {
|
||||
return;
|
||||
}
|
||||
// Determine the payment method used in request.
|
||||
$pm = '';
|
||||
if (is_object($request) && method_exists($request, 'get_param')) {
|
||||
$pm = (string) $request->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);
|
||||
|
|
@ -0,0 +1,671 @@
|
|||
<?php
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yoone Moneris 网关(骨架)
|
||||
*/
|
||||
class Yoone_Gateway_Moneris extends WC_Payment_Gateway_CC
|
||||
{
|
||||
|
||||
// 直连 Moneris 配置(用于表单保存)
|
||||
public $sandbox; // 沙盒模式
|
||||
/** 商店ID,由支付网关提供的唯一标识符 */
|
||||
public $store_id;
|
||||
/** API令牌,用于身份验证的密钥 */
|
||||
public $api_token;
|
||||
/** 国家代码,支持 "CA"(加拿大)和 "US"(美国) */
|
||||
public $country_code; // CA/US
|
||||
/** 是否启用状态检查,默认为 false */
|
||||
public $status_check; // 布尔
|
||||
public $crypt_type; // 默认 7
|
||||
|
||||
/** 国家/地区:CA 或 US */
|
||||
// 提示:仅直连一种形式时,使用通用命名即可,无需 前缀
|
||||
public $protocol; // https
|
||||
public $port; // 443
|
||||
public $host; // 可选:覆盖默认主机
|
||||
public $path; // 可选:覆盖默认路径
|
||||
public $http_method; // POST
|
||||
public $request_timeout; // 30
|
||||
|
||||
/**
|
||||
* 构造函数:初始化网关配置与能力,并读取后台保存的设置。
|
||||
*
|
||||
* - 定义网关 ID/标题/描述/支持的能力
|
||||
* - 初始化设置表单字段并加载已保存的设置值
|
||||
* - 挂载后台保存钩子以持久化管理员输入
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
<?php
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moneris API 占位类
|
||||
* 说明:此处为示例/占位实现,便于搭建整体流程。
|
||||
* 上线前请替换为实际的 Moneris Vault/Tokenization 与支付接口对接代码。
|
||||
*/
|
||||
class Yoone_Moneris_API implements Yoone_Moneris_API_Interface {
|
||||
|
||||
// 基本配置
|
||||
protected $store_id;
|
||||
protected $api_token;
|
||||
protected $sandbox; // 'yes' or 'no'
|
||||
protected $timeout = 30; // 请求超时(秒)
|
||||
protected $country_code = 'CA'; // CA 或 US
|
||||
protected $crypt_type = '7'; // 默认 7
|
||||
protected $status_check = false;
|
||||
protected $protocol = 'https';
|
||||
protected $port = 443;
|
||||
protected $host_override = ''; // 自定义主机(高级)
|
||||
protected $path_override = ''; // 自定义路径(高级)
|
||||
protected $http_method = 'POST';
|
||||
|
||||
/**
|
||||
* 构造函数:初始化直连 Moneris 的基础与高级配置。
|
||||
*
|
||||
* @param string $store_id Moneris Store ID
|
||||
* @param string $api_token Moneris API Token
|
||||
* @param 'yes'|'no' $sandbox 是否为沙箱模式('yes' 表示沙箱)
|
||||
* @param array<mixed> $config 直连配置:country_code, crypt_type, status_check, protocol, port, host, path, http_method, timeout
|
||||
*/
|
||||
public function __construct( $store_id, $api_token, $sandbox = 'yes', $config = array() ) {
|
||||
$this->store_id = (string) $store_id;
|
||||
$this->api_token = (string) $api_token;
|
||||
$this->sandbox = (string) $sandbox;
|
||||
|
||||
// 合并配置
|
||||
if ( is_array( $config ) ) {
|
||||
$this->country_code = isset( $config['country_code'] ) ? (string) $config['country_code'] : $this->country_code;
|
||||
$this->crypt_type = isset( $config['crypt_type'] ) ? (string) $config['crypt_type'] : $this->crypt_type;
|
||||
$this->status_check = isset( $config['status_check'] ) ? (bool) $config['status_check'] : $this->status_check;
|
||||
$this->protocol = isset( $config['protocol'] ) ? (string) $config['protocol'] : $this->protocol;
|
||||
$this->port = isset( $config['port'] ) ? absint( $config['port'] ) : $this->port;
|
||||
$this->host_override= isset( $config['host'] ) ? (string) $config['host'] : $this->host_override;
|
||||
$this->path_override= isset( $config['path'] ) ? (string) $config['path'] : $this->path_override;
|
||||
$this->http_method = isset( $config['http_method'] ) ? strtoupper( (string) $config['http_method'] ) : $this->http_method;
|
||||
$this->timeout = isset( $config['timeout'] ) ? absint( $config['timeout'] ) : $this->timeout;
|
||||
}
|
||||
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<string,mixed> $payload 交易负载(键支持驼峰/蛇形,内部转蛇形)
|
||||
* @return array{ok:bool,receipt?:array,error?:string}
|
||||
*/
|
||||
protected function send_moneris_xml( $type, $payload ) {
|
||||
$endpoint = $this->build_endpoint( $type );
|
||||
if ( ! $endpoint ) {
|
||||
return array( 'ok' => false, 'error' => 'Endpoint not configured' );
|
||||
}
|
||||
|
||||
// 构建请求体
|
||||
$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<string,mixed> $data 顶层通常为 ['request' => [...]]
|
||||
* @return string XML 字符串
|
||||
*/
|
||||
protected function array_to_moneris_xml( $data ) {
|
||||
// 简单地用 DOMDocument 生成,保持节点顺序与结构
|
||||
$doc = new DOMDocument( '1.0', 'UTF-8' );
|
||||
$doc->formatOutput = true;
|
||||
|
||||
$build = function( $parent, $key, $value ) use ( &$build, $doc ) {
|
||||
$key = $this->camel_to_snake( $key );
|
||||
$node = $doc->createElement( $key );
|
||||
if ( is_array( $value ) ) {
|
||||
foreach ( $value as $k => $v ) {
|
||||
$build( $node, $k, $v );
|
||||
}
|
||||
} else {
|
||||
$node->appendChild( $doc->createTextNode( (string) $value ) );
|
||||
}
|
||||
$parent->appendChild( $node );
|
||||
};
|
||||
|
||||
$root_key = key( $data );
|
||||
$root_val = current( $data );
|
||||
$root = $doc->createElement( $this->camel_to_snake( $root_key ) );
|
||||
foreach ( $root_val as $k => $v ) {
|
||||
$build( $root, $k, $v );
|
||||
}
|
||||
$doc->appendChild( $root );
|
||||
return $doc->saveXML();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将驼峰命名转换为蛇形命名。
|
||||
*
|
||||
* @param string $name 字段名(如 orderId、dataKey、cvdInfo)
|
||||
* @return string 转换后的蛇形命名(小写)
|
||||
*/
|
||||
protected function camel_to_snake( $name ) {
|
||||
$out = preg_replace( '/([a-z])([A-Z])/', '$1_$2', (string) $name );
|
||||
return strtolower( $out );
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Moneris XML 响应为数组。
|
||||
*
|
||||
* 期望结构:response → receipt;统一为小写蛇形键,同时保留常见原始大小写键以兼容。
|
||||
* @param string $xml 响应 XML 字符串
|
||||
* @return array{parsed:bool,receipt?:array,error?:string}
|
||||
*/
|
||||
protected function parse_moneris_xml( $xml ) {
|
||||
libxml_use_internal_errors( true );
|
||||
$obj = simplexml_load_string( $xml );
|
||||
if ( false === $obj ) {
|
||||
$err = 'Invalid XML';
|
||||
$errors = libxml_get_errors();
|
||||
if ( ! empty( $errors ) ) {
|
||||
$err = trim( $errors[0]->message );
|
||||
}
|
||||
libxml_clear_errors();
|
||||
return array( 'parsed' => false, 'error' => $err );
|
||||
}
|
||||
$json = json_decode( json_encode( $obj ), true );
|
||||
// 期望结构:response -> receipt
|
||||
$receipt = isset( $json['receipt'] ) ? $json['receipt'] : ( isset( $json['response']['receipt'] ) ? $json['response']['receipt'] : array() );
|
||||
// 统一键名:小写蛇形
|
||||
$norm = array();
|
||||
foreach ( (array) $receipt as $k => $v ) {
|
||||
$norm[ $this->camel_to_snake( $k ) ] = is_array( $v ) ? ( isset( $v['_text'] ) ? $v['_text'] : ( isset( $v[0] ) ? $v[0] : current( $v ) ) ) : $v;
|
||||
}
|
||||
// 同时保留常见字段的原始大小写以兼容(例如 txnNumber/TxnNumber)
|
||||
$norm['txnNumber'] = isset( $receipt['TxnNumber'] ) ? $receipt['TxnNumber'] : ( $norm['txn_number'] ?? '' );
|
||||
$norm['dataKey'] = isset( $receipt['DataKey'] ) ? $receipt['DataKey'] : ( $norm['data_key'] ?? '' );
|
||||
$norm['responseCode']= isset( $receipt['ResponseCode'] ) ? $receipt['ResponseCode'] : ( $norm['response_code'] ?? '' );
|
||||
$norm['message'] = isset( $receipt['Message'] ) ? $receipt['Message'] : ( $norm['message'] ?? '' );
|
||||
$norm['cardType'] = isset( $receipt['CardType'] ) ? $receipt['CardType'] : ( $norm['card_type'] ?? '' );
|
||||
$norm['maskedPan'] = isset( $receipt['MaskedPan'] ) ? $receipt['MaskedPan'] : ( $norm['masked_pan'] ?? '' );
|
||||
return array( 'parsed' => true, 'receipt' => $norm );
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断交易是否批准(ResponseCode < 50)。
|
||||
*
|
||||
* @param array<string,mixed> $receipt 解析后的收据数组
|
||||
* @return bool 批准返回 true
|
||||
*/
|
||||
protected function is_approved( $receipt ) {
|
||||
$code = isset( $receipt['response_code'] ) ? $receipt['response_code'] : ( $receipt['ResponseCode'] ?? ( $receipt['responseCode'] ?? '' ) );
|
||||
$code = is_numeric( $code ) ? intval( $code ) : 999;
|
||||
return $code >= 0 && $code < 50; // Moneris: <50 视为批准
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化有效期为 YYMM。
|
||||
*
|
||||
* @param array{exp_year?:string|int,exp_month?:string|int} $card 卡片有效期
|
||||
* @return string 两位年+两位月的 YYMM 格式
|
||||
*/
|
||||
protected function format_expdate( $card ) {
|
||||
$yy = '';
|
||||
$mm = '';
|
||||
if ( ! empty( $card['exp_year'] ) ) {
|
||||
$yy = substr( preg_replace( '/\D+/', '', (string) $card['exp_year'] ), -2 );
|
||||
}
|
||||
if ( ! empty( $card['exp_month'] ) ) {
|
||||
$mm = substr( '0' . preg_replace( '/\D+/', '', (string) $card['exp_month'] ), -2 );
|
||||
}
|
||||
return $yy . $mm; // YYMM
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额为两位小数的字符串。
|
||||
*
|
||||
* @param float $amount 金额
|
||||
* @return string 形如 "10.00" 的字符串
|
||||
*/
|
||||
protected function format_amount( $amount ) {
|
||||
return number_format( (float) $amount, 2, '.', '' );
|
||||
}
|
||||
/**
|
||||
* 卡片识别
|
||||
*/
|
||||
/**
|
||||
* 简单识别卡组织(卡品牌)。
|
||||
*
|
||||
* @param string $number 卡号
|
||||
* @return string 可能值:visa/mastercard/amex/discover/card
|
||||
*/
|
||||
protected function detect_brand( $number ) {
|
||||
$n = preg_replace( '/\D+/', '', $number );
|
||||
if ( preg_match( '/^4[0-9]{12}(?:[0-9]{3})?$/', $n ) ) return 'visa';
|
||||
if ( preg_match( '/^5[1-5][0-9]{14}$/', $n ) ) return 'mastercard';
|
||||
if ( preg_match( '/^3[47][0-9]{13}$/', $n ) ) return 'amex';
|
||||
if ( preg_match( '/^6(?:011|5[0-9]{2})[0-9]{12}$/', $n ) ) return 'discover';
|
||||
return 'card';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yoone Moneris 常量与端点默认值
|
||||
* 参考 moneryze 项目结构,集中管理常量与默认配置。
|
||||
*/
|
||||
|
||||
// 默认配置常量
|
||||
define('YOONE_MONERIS_DEFAULT_CRYPT_TYPE', '7');
|
||||
define('YOONE_MONERIS_DEFAULT_TIMEOUT', 30);
|
||||
define('YOONE_MONERIS_DEFAULT_HTTP_METHOD', 'POST');
|
||||
define('YOONE_MONERIS_DEFAULT_PROTOCOL', 'https');
|
||||
define('YOONE_MONERIS_DEFAULT_PORT', 443);
|
||||
define('YOONE_MONERIS_MPI_PATH', '/mpi/servlet/MpiServlet');
|
||||
|
||||
// 端点映射(按国家与环境)
|
||||
$GLOBALS['YOONE_MONERIS_ENDPOINTS'] = array(
|
||||
'CA' => 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'];
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yoone Moneris API 接口定义
|
||||
* 提供令牌化、扣款与退款的统一方法签名,便于后续替换/扩展实现。
|
||||
*/
|
||||
interface Yoone_Moneris_API_Interface
|
||||
{
|
||||
/**
|
||||
* 令牌化卡片(添加到 Moneris Vault)。
|
||||
*
|
||||
* @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);
|
||||
|
||||
/**
|
||||
* 使用令牌扣款。
|
||||
*
|
||||
* @param string $token
|
||||
* @param float $amount
|
||||
* @param string $currency
|
||||
* @param int $order_id
|
||||
* @param bool $capture
|
||||
* @return array{success:bool,transaction_id?:string,error?:string}
|
||||
*/
|
||||
public function charge_token($token, $amount, $currency, $order_id, $capture = true);
|
||||
|
||||
/**
|
||||
* 退款。
|
||||
*
|
||||
* @param string $transaction_id
|
||||
* @param float $amount
|
||||
* @return array{success:bool,error?:string}
|
||||
*/
|
||||
public function refund($transaction_id, $amount);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一日志工具(优先使用 WooCommerce Logger)。
|
||||
*/
|
||||
function yoone_moneris_log($level, $message, $context = array())
|
||||
{
|
||||
// $level: 'emergency'|'alert'|'critical'|'error'|'warning'|'notice'|'info'|'debug'
|
||||
if (function_exists('wc_get_logger')) {
|
||||
$logger = wc_get_logger();
|
||||
$context = is_array($context) ? $context : array();
|
||||
$context['source'] = 'yoone-moneris';
|
||||
$logger->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);
|
||||
}
|
||||
|
|
@ -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 支持)
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Yoone Moneris Payments
|
||||
* Description: 自定义 Moneris 支付网关,支持 WooCommerce Subscriptions 的令牌化与自动续费(占位实现,待接入 Moneris 实际 API)。
|
||||
* Version: 0.1.0
|
||||
* Author: Yoone
|
||||
* Requires at least: 5.0
|
||||
* Requires PHP: 7.4
|
||||
* Requires Plugins: woocommerce
|
||||
* WC requires at least: 6.0
|
||||
* WC tested up to: 10.1.0
|
||||
*/
|
||||
// 以上为 WordPress 插件头部信息:
|
||||
// - Plugin Name/Description/Version/Author 等会在后台插件列表展示;
|
||||
// - Requires at least/Requires PHP 指定最低 WordPress/PHP 版本要求;
|
||||
// - Requires Plugins: woocommerce 表示此插件依赖 WooCommerce;
|
||||
// - WC requires at least / WC tested up to 提供与 WooCommerce 版本兼容性提示。
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
// 说明:这是 WordPress 的常量 ABSPATH(WP 根目录路径),未定义通常表示非 WP 环境直接访问 PHP 文件;
|
||||
// 为安全起见,若未定义则退出,避免被直接访问执行。
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化插件
|
||||
*/
|
||||
function yoone_moneris_payments_init() {
|
||||
if ( ! class_exists( 'WooCommerce' ) ) {
|
||||
// add_action 是 WordPress 的动作钩子 API:在 'admin_notices' 钩子上注册一个回调,
|
||||
// 该钩子用于在后台页面顶部显示提示信息;这里我们输出一条错误提示,提醒需要启用 WooCommerce。
|
||||
add_action( 'admin_notices', function() {
|
||||
echo '<div class="error"><p><strong>Yoone Moneris Payments 需要 WooCommerce 插件已启用。</strong></p></div>';
|
||||
} );
|
||||
// 由于 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 '<div class="notice notice-warning"><p>Yoone Moneris Payments 当前未集成 WooCommerce Blocks 结账。如果你的结账页使用了 Blocks,请改用经典结账(在结账页添加短代码 [woocommerce_checkout] 并在 WooCommerce → 设置 → 高级 中设置该页面为结账页),或联系开发者添加 Blocks 集成。</p></div>';
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
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' );
|
||||
Loading…
Reference in New Issue