如何使用 Moneris API 自动跟踪网站订阅续费状态?
结论
Moneris 的 recurring payment 配置不应视为可查询的“订阅对象”。创建 recurring payment 后,Moneris 会按计划执行后续扣款。如果当前接入的产品没有 webhook、POST back 或查询接口,网站就无法仅凭 recurring payment ID 实时获取每期扣款结果。
有两种可靠的实现方式:
- **推荐:由网站调度续费,Moneris 负责扣款。**保存 Moneris 返回的支付凭证或 token,通过定时任务在续费日期发起交易,并立即记录响应。
- **继续使用 Moneris 托管的 recurring billing:定期导入 Moneris 交易报告并完成对账。**可用的报表下载、批次查询或自动传输功能取决于商户账户及所用的 Moneris 产品,需要向 Moneris 支持团队确认,不能假设存在未在文档中公布的查询 API。
无论选择哪种方式,网站都应在自己的数据库中维护订阅状态和交易历史,不要等到用户登录时再临时查询支付网关。
推荐的数据模型
至少需要保存订阅和交易两类数据:
CREATE TABLE subscriptions (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL,
payment_token VARCHAR(255) NULL,
status VARCHAR(32) NOT NULL,
next_billing_at DATETIME NULL,
retry_count INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE payment_transactions (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
subscription_id BIGINT UNSIGNED NOT NULL,
merchant_order_id VARCHAR(64) NOT NULL,
gateway_transaction_id VARCHAR(128) NULL,
amount DECIMAL(10, 2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'CAD',
status VARCHAR(32) NOT NULL,
response_code VARCHAR(32) NULL,
response_message VARCHAR(255) NULL,
processed_at DATETIME NULL,
raw_response JSON NULL,
UNIQUE KEY uq_merchant_order_id (merchant_order_id)
);
订阅状态可以分为:
active:当前有效;past_due:本期扣款失败,正处于宽限期或重试阶段;suspended:已经超过重试次数或宽限期限,服务暂停;cancelled:已经取消;expired:订阅自然结束。
交易状态应单独记录,例如 pending、approved、declined、refunded。一次扣款失败不代表订阅需要永久取消。
方案一:网站自行发起每期扣款
首次付款或绑卡时,通过 Moneris 支持的 tokenization/Vault 功能取得支付凭证。网站只保存 token,不保存完整卡号或 CVV。此后,定时任务负责查找已经到期的订阅,并调用当前 Moneris 产品对应的购买或预授权接口。
下面的 PHP 代码只展示业务结构。MonerisGateway 是应用自己的适配层,实际使用的请求类和参数必须以当前 Moneris SDK 文档为准:
<?php
final class SubscriptionRenewalService
{
public function __construct(
private PDO $db,
private MonerisGateway $gateway
) {
}
public function renew(array $subscription): void
{
$orderId = sprintf(
'sub-%d-%s',
$subscription['id'],
(new DateTimeImmutable())->format('Ymd')
);
$this->createPendingTransaction(
(int) $subscription['id'],
$orderId,
(string) $subscription['amount']
);
try {
$result = $this->gateway->chargeToken(
paymentToken: $subscription['payment_token'],
orderId: $orderId,
amount: number_format((float) $subscription['amount'], 2, '.', '')
);
if ($result->isApproved()) {
$this->markApproved($orderId, $result);
$this->activateAndAdvanceBillingDate((int) $subscription['id']);
} else {
$this->markDeclined($orderId, $result);
$this->markPastDue((int) $subscription['id']);
}
} catch (Throwable $exception) {
// 超时或网络错误不等于交易失败,必须先标记为结果未知并进行对账。
$this->markUnknown($orderId, $exception->getMessage());
}
}
// 数据库方法略
}
定时任务可以每隔几分钟运行一次:
*/10 * * * * /usr/bin/php /var/www/app/bin/process-renewals.php
处理到期订阅时,需要使用数据库锁或任务锁,以免多个进程重复扣款。例如,可以在事务中使用 SELECT ... FOR UPDATE,并为 merchant_order_id 建立唯一索引。
这种方案可以同步取得每次扣款的响应,网站也能自行控制交易历史、重试策略和账户状态。
方案二:对账 Moneris 托管的 recurring billing
如果每期扣款已经交给 Moneris 自动执行,就需要建立周期性对账流程:
- 创建 recurring payment 时,保存本地订阅 ID、Moneris 返回的标识、金额及扣款计划。
- 每日或按照结算批次获取 Moneris 交易报告。
- 使用商户订单号、reference number、transaction number 等稳定字段匹配本地订阅。
- 将批准、拒绝、退款及撤销结果写入
payment_transactions。 - 把未匹配、重复或结果不明确的记录交给人工复核。
- 客户查看历史记录时,从本地数据库读取,不要直接查询 Moneris 后台。
Moneris 开放哪些报表功能,可能因产品、账户地区和商户配置而异。需要向 Moneris 确认以下问题:
- recurring billing 的每期交易结果能否通过 API 查询;
- 是否支持自动下载结算或交易报告;
- 报告何时生成,以及应该使用哪个字段关联 recurring payment;
- 拒绝交易、退款、撤销和 chargeback 是否记录在同一数据源中;
- 报告采用的时区、结算日边界及数据保留期限。
如果只能从管理后台手动下载报告,就无法做到完全自动化。此时应考虑改用网站自行调度续费的方案。
失败、超时与卡片过期处理
交易结果不能只分为“成功”和“失败”:
- 明确拒绝:记录拒绝码,将订阅改为
past_due,再按照业务规则重试,并通知客户更新付款方式。 - 网络超时或响应丢失:将交易标记为
unknown,先查询或对账原交易。不要立即重新扣款,否则可能造成重复收费。 - 卡片过期:可以提前提醒客户更新,但不要只依赖网站保存的到期日,因为 token 对应的卡片信息可能由发卡行或网关更新。
- 临时拒绝:可以间隔一段时间后重试,但应限制重试次数。
- 永久性问题:停止自动重试,要求客户更换付款方式。
- 退款、撤销和 chargeback:作为后续交易记录单独保存,不要覆盖原付款记录。
是否暂停账户由业务规则决定。常见做法是在首次失败后进入宽限期,经过多次失败或超过指定天数后再暂停服务,而不是第一次失败就删除或取消账户。
安全与一致性注意事项
- 不要在网站数据库中保存完整卡号、CVV 或其他不必要的持卡人数据。
- 金额应使用十进制定点值或最小货币单位,不要直接使用浮点数计算。
- 每次扣款都应使用唯一且可以重复识别的 merchant order ID,以便进行幂等处理。
- 保存网关原始响应前,需要移除敏感字段。
- 只有收到明确的批准结果,或者通过后续查询、报表确认批准后,才能将交易标记为
approved。 - 即使由网站主动发起扣款,也应定期对账,不能只依赖同步响应。
- Moneris 的具体 SDK 类名、响应字段和批准判断规则可能因产品及接口版本而异,应以商户账户当前对应的官方文档和认证环境结果为准。
备注:内容仅供参考。
