PAYATHON 2026

如何为 osCommerce 产品集成 Recurring payments?

支付阿杰

结论

osCommerce 主要负责商品、订单和结账流程,通常没有完整的 recurring payments(周期性付款)功能。更可靠的做法是接入支付服务商的订阅 API,由服务商保存付款凭据并按周期扣款,osCommerce 只记录订阅标识、状态及其关联订单。

不要在 osCommerce 数据库中保存银行卡号、CVV 等敏感信息,也不要通过定时任务反复提交原始卡片资料。

具体的 API 名称和参数取决于支付服务商及 osCommerce 版本。下面介绍通用设计和 PHP 示例,其中的接口地址、签名算法和字段都需要按照服务商的实际规定替换。

为什么普通支付模块不能直接实现

普通的 osCommerce 支付模块一般只处理一次性交易:

  1. 顾客完成结账。
  2. 支付网关执行一次扣款。
  3. 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 暂时不可用;
  • 续费订单的库存、税费、邮件和发货流程。

比起寻找一个只会“重复扣款”的简单插件,更稳妥的做法是将订阅生命周期设计为独立的业务模块,并由支付服务商保存付款凭据、执行周期扣款。

备注:内容仅供参考。