如何为 osCommerce 产品集成 Recurring payments?
结论
osCommerce 主要负责商品、订单和结账流程,通常没有完整的 recurring payments(周期性付款)功能。更可靠的做法是接入支付服务商的订阅 API,由服务商保存付款凭据并按周期扣款,osCommerce 只记录订阅标识、状态及其关联订单。
不要在 osCommerce 数据库中保存银行卡号、CVV 等敏感信息,也不要通过定时任务反复提交原始卡片资料。
具体的 API 名称和参数取决于支付服务商及 osCommerce 版本。下面介绍通用设计和 PHP 示例,其中的接口地址、签名算法和字段都需要按照服务商的实际规定替换。
为什么普通支付模块不能直接实现
普通的 osCommerce 支付模块一般只处理一次性交易:
- 顾客完成结账。
- 支付网关执行一次扣款。
- osCommerce 创建订单并更新订单状态。
周期性付款还要完成以下工作:
- 创建和维护订阅计划;
- 安全保存付款方式;
- 按期自动扣款;
- 处理续费成功、失败和重试;
- 支持暂停、取消和退款;
- 接收异步 Webhook 通知;
- 将每次续费同步为 osCommerce 订单或付款记录。
所以,仅修改商品价格或重复调用一次性付款接口无法完整实现订阅功能。
推荐的集成方案
优先使用支付服务商提供的 hosted checkout、subscription 或 recurring billing API。常见流程如下:
顾客购买订阅商品
↓
osCommerce 创建待付款订单
↓
后端调用支付服务商 API 创建订阅
↓
顾客在支付服务商页面授权付款
↓
支付服务商定期扣款
↓
Webhook 通知 osCommerce
↓
osCommerce 更新订阅,并为成功续费创建订单或付款记录
扣款计划应由支付服务商执行,osCommerce 不保存卡片信息,也不自行发起原始卡片扣款。
实现步骤
1. 确认支付服务商是否支持订阅
至少需要确认以下功能和条件:
- 固定周期或自定义周期扣款;
- Hosted Checkout 或付款方式 Token;
- Webhook 事件通知;
- 取消和暂停订阅;
- 扣款失败后的重试;
- 测试环境;
- 支持当前经营地区和币种;
- 满足 3-D Secure、SCA 等合规要求。
如果现有的 osCommerce 支付模块只支持一次性付款,就需要扩展该模块,或者为订阅商品单独开发支付模块。
2. 给商品增加订阅属性
可以在商品表中增加订阅字段,也可以单独建立订阅方案表。周期和金额应使用结构化数据,不能只保存“每月付款”之类的显示文字。
例如:
CREATE TABLE subscription_plans (
plan_id INT NOT NULL AUTO_INCREMENT,
products_id INT NOT NULL,
provider_plan_id VARCHAR(191) DEFAULT NULL,
interval_unit VARCHAR(20) NOT NULL,
interval_count INT NOT NULL DEFAULT 1,
amount DECIMAL(15,4) NOT NULL,
currency CHAR(3) NOT NULL,
trial_days INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'active',
PRIMARY KEY (plan_id),
KEY idx_products_id (products_id)
);
可以在应用层将 interval_unit 限制为 day、week、month 或 year。最终支持哪些周期,要以支付服务商的 API 为准。
价格应从数据库中的商品和订阅方案读取,不能采用浏览器提交的金额。
3. 保存订阅与订单的关联
建议单独建立订阅表:
CREATE TABLE customer_subscriptions (
subscription_id INT NOT NULL AUTO_INCREMENT,
customers_id INT NOT NULL,
orders_id INT DEFAULT NULL,
plan_id INT NOT NULL,
provider_customer_id VARCHAR(191) DEFAULT NULL,
provider_subscription_id VARCHAR(191) NOT NULL,
status VARCHAR(30) NOT NULL,
current_period_end DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (subscription_id),
UNIQUE KEY uq_provider_subscription (provider_subscription_id),
KEY idx_customers_id (customers_id)
);
本地只需保存支付服务商返回的 customer ID、subscription ID 和订阅状态,不应保存完整卡号或 CVV。
4. 在结账时创建订阅
下面的代码只展示通用结构,并不对应任何支付服务商的真实接口:
<?php
function createSubscription(array $order, array $plan): array
{
$payload = [
'external_order_id' => (string) $order['orders_id'],
'customer' => [
'email' => $order['customers_email_address'],
],
'plan_id' => $plan['provider_plan_id'],
'quantity' => 1,
'success_url' => HTTPS_SERVER . DIR_WS_HTTPS_CATALOG
. 'checkout_success.php?order_id=' . urlencode($order['orders_id']),
'cancel_url' => HTTPS_SERVER . DIR_WS_HTTPS_CATALOG
. 'checkout_payment.php',
];
$ch = curl_init('https://api.payment-provider.example/v1/subscriptions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . PAYMENT_PROVIDER_SECRET,
'Content-Type: application/json',
'Idempotency-Key: order-' . $order['orders_id'],
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
CURLOPT_TIMEOUT => 30,
]);
$responseBody = curl_exec($ch);
if ($responseBody === false) {
$message = curl_error($ch);
curl_close($ch);
throw new RuntimeException('创建订阅请求失败:' . $message);
}
$httpStatus = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$response = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
if ($httpStatus < 200 || $httpStatus >= 300) {
throw new RuntimeException('支付服务商拒绝创建订阅请求');
}
return $response;
}
实施时需要注意:
- API 密钥只能保存在服务器端;
- 所有请求都应使用 HTTPS;
- 设置超时并记录失败原因;
- 使用 idempotency key,避免重复提交产生多个订阅;
- 跳转到成功页面并不表示付款已经成功,最终结果应通过 Webhook 或服务商 API 确认。
5. 验证并处理 Webhook
首次付款、续费、付款失败或订阅取消时,支付服务商会向系统发送通知。Webhook 必须验证签名,不能直接根据请求体中的状态修改订单。
<?php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PROVIDER_SIGNATURE'] ?? '';
if (!verifyProviderSignature($rawBody, $signature, PAYMENT_WEBHOOK_SECRET)) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
$eventId = $event['id'] ?? null;
$eventType = $event['type'] ?? '';
$data = $event['data'] ?? [];
if ($eventId === null || webhookAlreadyProcessed($eventId)) {
http_response_code(200);
exit('OK');
}
switch ($eventType) {
case 'subscription.activated':
updateSubscriptionStatus(
$data['subscription_id'],
'active',
$data['current_period_end'] ?? null
);
break;
case 'invoice.paid':
createRenewalOrderIfMissing(
$data['subscription_id'],
$data['invoice_id'],
$data['amount'],
$data['currency']
);
break;
case 'invoice.payment_failed':
updateSubscriptionStatus(
$data['subscription_id'],
'past_due',
null
);
break;
case 'subscription.cancelled':
updateSubscriptionStatus(
$data['subscription_id'],
'cancelled',
null
);
break;
}
markWebhookProcessed($eventId);
http_response_code(200);
echo 'OK';
verifyProviderSignature() 必须按照支付服务商公布的签名规则实现。不要假定签名只是简单的 SHA-256,也不要自行设计验证算法。
Webhook 可能重复发送,也可能不按顺序到达。处理时应做到:
- 使用事件 ID 去重;
- 使用服务商的 invoice ID,防止重复创建续费订单;
- 在数据库事务中完成重要更新;
- 必要时比较事件时间,或者通过 API 重新查询最新的订阅状态;
- 尽快返回成功响应,将耗时操作交给队列或后台任务。
6. 决定如何记录每次续费
一般有两种处理方式:
- 每次续费成功后创建一张新的 osCommerce 订单;
- 保留原始订单,另行建立续费付款记录。
如果续费涉及发货、库存、税务或发票,通常应为每次续费创建新订单。创建时要复用 osCommerce 原有的订单总额、税费和库存逻辑,不要直接插入一条不完整的 orders 记录。
库存策略也要提前确定。可以在首次订阅时预留库存,也可以等每次续费成功后再扣减。对于实体商品,通常更适合在续费成功后扣减库存。
取消订阅
顾客点击取消后,服务器应调用支付服务商的 API,而不是只修改本地状态:
<?php
function cancelSubscription(string $providerSubscriptionId): void
{
$url = 'https://api.payment-provider.example/v1/subscriptions/'
. rawurlencode($providerSubscriptionId);
$payload = [
'cancel_at_period_end' => true,
];
// 按支付服务商文档发起认证请求。
// API 返回成功后可记录“等待周期结束”,最终状态仍由 Webhook 确认。
}
业务规则中需要明确:
- 立即取消,还是在当前计费周期结束后取消;
- 已付款周期内是否继续提供服务;
- 是否按比例退款;
- 取消后能否恢复;
- 退款是否会同步修改 osCommerce 订单状态。
如果支付服务商没有订阅 API
只有当支付服务商明确支持 merchant-initiated transaction 或 tokenized recurring payment 时,才可以通过本地定时任务发起周期扣款。此时保存的应是服务商生成的付款方式 Token,而不是卡号。
定时任务可以通过 cron 执行,例如:
*/15 * * * * /usr/bin/php /path/to/catalog/cron/process_recurring.php
任务需要查询到期订阅并锁定记录,使用幂等键发起扣款,同时记录每次尝试。还要处理并发执行、失败重试、时区、月底日期、Token 失效和顾客取消等情况。
如果支付服务商既没有订阅 API,也不允许使用 Token 完成后续扣款,osCommerce 就无法安全地自行实现自动续费。可以更换支付服务商,或者在订阅到期后向顾客发送手动付款链接。
重要注意事项
PCI DSS 与敏感数据
不要收集或保存:
- 完整银行卡号;
- CVV/CVC;
- 磁道数据;
- 可以直接用于扣款的原始支付凭据。
优先使用支付服务商托管的付款页面或前端组件,减少敏感数据进入 osCommerce 服务器的机会。具体的合规范围仍需根据支付流程和业务所在地区确认。
金额、税费和价格变更
订阅价格必须由服务端确定。还要提前规定:
- 商品涨价是否影响已有订阅;
- 运费是否在每个周期重新计算;
- 税率变化如何处理;
- 优惠券用于首期还是全部周期;
- 免费试用结束后何时扣款;
- 是否允许切换币种。
计算金额时不要使用二进制浮点数。应使用数据库定点数,或者支付服务商规定的最小货币单位,例如分。
osCommerce 版本兼容性
不同 osCommerce 版本和分支的支付模块接口、目录结构及订单状态处理方式可能不同。集成前应确认:
- 当前使用的 osCommerce 准确版本;
- PHP 版本;
- 现有支付模块的回调机制;
- 订单创建和库存扣减的位置;
- 数据库字符集与表结构;
- 是否安装了会改变结账流程的扩展。
不要把针对其他版本开发的支付模块直接复制到生产环境。
上线前测试
至少要覆盖以下情况:
- 首次付款成功和失败;
- Webhook 重复、延迟和乱序;
- 顾客重复点击付款;
- 续费成功;
- 余额不足或付款方式失效;
- 付款失败后的自动重试;
- 立即取消和周期结束后取消;
- 退款与争议;
- 月末、闰年和时区边界;
- 服务商 API 暂时不可用;
- 续费订单的库存、税费、邮件和发货流程。
比起寻找一个只会“重复扣款”的简单插件,更稳妥的做法是将订阅生命周期设计为独立的业务模块,并由支付服务商保存付款凭据、执行周期扣款。
备注:内容仅供参考。
