PAYATHON 2026

如何使用 PHP 和 MySQL 实现 recurring billing system?

支付老李

明确结论

不要把 11 个月的付款状态放在同一行,也不能只靠一个 monthly_due 字段判断款项是否已经支付。建议按职责拆分:

  • subscriptions:保存订阅规则,例如金额、周期、下次扣款时间和订阅状态。
  • invoices:每个账期对应一条应收记录。
  • payments:每次实际扣款尝试对应一条记录,涵盖成功、失败和退款状态。
  • 使用支付服务商提供的 recurring billing、tokenization 和 webhook,不要在 MySQL 中保存银行卡号或 CVV。
  • cron 可以定期扫描到期记录,但需要处理幂等、并发锁和失败重试。

如果支付服务商已经负责完整的订阅周期,本地数据库只需保存订阅状态、服务商 ID 和 webhook 事件,无须自行创建 11 个扣款任务。

推荐的数据结构

下面是一个简化的数据模型。金额使用最小货币单位,例如人民币的“分”,以免产生浮点数误差。

CREATE TABLE subscriptions (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    member_id BIGINT UNSIGNED NOT NULL,
    provider_subscription_id VARCHAR(191) NULL,
    amount_minor INT UNSIGNED NOT NULL,
    currency CHAR(3) NOT NULL DEFAULT 'CNY',
    billing_day TINYINT UNSIGNED NOT NULL,
    next_billing_at DATETIME NOT NULL,
    remaining_cycles SMALLINT UNSIGNED NOT NULL DEFAULT 11,
    status ENUM('active', 'past_due', 'completed', 'cancelled') NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    INDEX idx_subscription_due (status, next_billing_at)
);

CREATE TABLE invoices (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    subscription_id BIGINT UNSIGNED NOT NULL,
    period_start DATE NOT NULL,
    period_end DATE NOT NULL,
    due_at DATETIME NOT NULL,
    amount_minor INT UNSIGNED NOT NULL,
    status ENUM('pending', 'processing', 'paid', 'failed', 'cancelled') NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uq_subscription_period (subscription_id, period_start),
    INDEX idx_invoice_due (status, due_at),
    CONSTRAINT fk_invoice_subscription
        FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

CREATE TABLE payments (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    invoice_id BIGINT UNSIGNED NOT NULL,
    provider_payment_id VARCHAR(191) NULL,
    idempotency_key VARCHAR(191) NOT NULL,
    amount_minor INT UNSIGNED NOT NULL,
    status ENUM('created', 'succeeded', 'failed', 'refunded') NOT NULL,
    failure_code VARCHAR(100) NULL,
    attempted_at DATETIME NOT NULL,
    UNIQUE KEY uq_payment_idempotency (idempotency_key),
    CONSTRAINT fk_payment_invoice
        FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);

如果业务需要记录未来账期,可以提前创建 11 条 invoices。但多数情况下,只保存 next_billing_at,等账期到期后再生成当期发票会更灵活。这样在暂停、改价或取消订阅时,不需要批量修改未来记录。

用 PHP 生成未来 11 个账期

PHP 可以使用 DateTimeImmutable、DateInterval 和 DateTimeZone 生成账期。月末日期需要单独处理:如果直接对 2026-01-31 连续执行 +1 month,可能因各月天数不同而发生日期漂移。

可以采用这条规则:每个月优先使用注册日;如果当月没有这一天,就使用当月最后一天。

<?php

function generateMonthlyDueDates(
    DateTimeImmutable $start,
    int $months,
    DateTimeZone $timezone
): array {
    $start = $start->setTimezone($timezone);
    $billingDay = (int) $start->format('j');
    $dates = [];

    for ($i = 1; $i <= $months; $i++) {
        // 先移动到目标月份的第一天,避免月末溢出。
        $targetMonth = $start
            ->modify('first day of this month')
            ->add(new DateInterval("P{$i}M"));

        $lastDay = (int) $targetMonth->format('t');
        $day = min($billingDay, $lastDay);

        $dates[] = $targetMonth
            ->setDate(
                (int) $targetMonth->format('Y'),
                (int) $targetMonth->format('n'),
                $day
            )
            ->setTime(9, 0);
    }

    return $dates;
}

$timezone = new DateTimeZone('Asia/Shanghai');
$signupAt = new DateTimeImmutable('2026-01-31 09:00:00', $timezone);

foreach (generateMonthlyDueDates($signupAt, 11, $timezone) as $dueAt) {
    echo $dueAt->format('Y-m-d H:i:s') . PHP_EOL;
}

如果业务约定在每月最后一天扣款,可以直接使用:

$dueAt = $targetMonth->modify('last day of this month')->setTime(9, 0);

开发前需要确定以下账期规则:

  • 31 日注册的用户在二月哪一天扣款;
  • 扣款时间采用哪个时区;
  • “11 个月”是否包含注册当天的首次付款;
  • 暂停、取消、改价和退款会怎样影响剩余账期。

安全写入账期记录

使用 PDO 预处理语句和事务写入记录,不要拼接 SQL。

<?php

$pdo->beginTransaction();

try {
    $sql = <<<'SQL'
        INSERT INTO invoices (
            subscription_id,
            period_start,
            period_end,
            due_at,
            amount_minor,
            status,
            created_at,
            updated_at
        ) VALUES (
            :subscription_id,
            :period_start,
            :period_end,
            :due_at,
            :amount_minor,
            'pending',
            UTC_TIMESTAMP(),
            UTC_TIMESTAMP()
        )
    SQL;

    $stmt = $pdo->prepare($sql);

    foreach ($dueDates as $dueAt) {
        $periodStart = $dueAt->modify('first day of this month');
        $periodEnd = $dueAt->modify('last day of this month');

        $stmt->execute([
            'subscription_id' => $subscriptionId,
            'period_start'    => $periodStart->format('Y-m-d'),
            'period_end'      => $periodEnd->format('Y-m-d'),
            'due_at'          => $dueAt
                ->setTimezone(new DateTimeZone('UTC'))
                ->format('Y-m-d H:i:s'),
            'amount_minor'    => $amountMinor,
        ]);
    }

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

数据库通常统一保存 UTC 时间。展示时间或计算业务账期时,再转换为订阅所属的时区。

cron 应该如何运行

cron 不需要等到每月才执行一次。更稳妥的做法是每 5~15 分钟扫描一次,找出所有已经到期且仍待处理的发票:

*/10 * * * * /usr/bin/php /path/to/project/bin/process-due-invoices.php

查询时应比较准确的到期时间,不能只比较“当前月份”:

SELECT id, subscription_id, amount_minor
FROM invoices
WHERE status = 'pending'
  AND due_at <= UTC_TIMESTAMP()
ORDER BY due_at
LIMIT 100;

如果只按月份查询,可能漏掉上个月扣款失败、仍需重试的账单,也可能提前处理本月尚未到期的账单。

防止 cron 重复扣款

cron 可能重叠执行,进程也可能崩溃或遇到网络超时。因此,实现时要按同一张发票可能被多次处理来设计。

在 MySQL 8.0 或其他支持相应语法的版本中,可以结合事务和 SKIP LOCKED,让多个 worker 安全领取任务:

START TRANSACTION;

SELECT id
FROM invoices
WHERE status = 'pending'
  AND due_at <= UTC_TIMESTAMP()
ORDER BY due_at
LIMIT 100
FOR UPDATE SKIP LOCKED;

-- 将选中的记录更新为 processing,然后提交事务。
COMMIT;

具体处理步骤如下:

  1. 原子地把选中的发票更新为 processing。
  2. 为每个扣款请求创建稳定的幂等键,例如 invoice-123-charge。
  3. 将幂等键写入本地数据库,同时传给支持幂等请求的支付服务商。
  4. 通过 webhook 确认最终结果,不能只依赖同步 API 响应。
  5. 扣款成功后,把发票更新为 paid,并推进 next_billing_at。
  6. 扣款失败后,记录失败原因和下次重试时间。
  7. 为长时间停留在 processing 状态的记录设置恢复机制。

不要在持有数据库行锁期间调用耗时的外部支付接口。通常先用一个短事务领取任务并提交,再调用支付 API,最后通过另一个短事务更新处理结果。

扣款流程建议

一个完整账期通常按以下顺序处理:

订阅到期
  → 创建或领取 pending invoice
  → 写入 payment attempt 和幂等键
  → 使用支付服务商保存的 payment method token 发起扣款
  → 接收并验证 webhook
  → 更新 payment 与 invoice
  → 成功则推进账期,失败则进入重试或 past_due

如果用户在注册时“授权未来 11 个月扣款”,还要确认支付服务商支持这种授权方式,并保存用户同意的时间、协议版本和订阅条款。具体合规要求由支付渠道和业务所在地决定。

重要注意事项

  • 不要保存银行卡号、CVV 或其他敏感认证数据。应使用支付服务商返回的 customer、payment method 或 subscription ID。
  • 不要把 cron 的“请求已发送”视为“付款成功”。最终状态需要结合经过签名验证的 webhook 判断。
  • webhook 可能重复到达,事件处理也必须幂等。可以为服务商事件 ID 建立唯一索引。
  • 设置重试上限和退避策略,避免扣款失败后无限重试。
  • 明确处理取消订阅、部分退款、支付方式过期、金额调整和补扣。
  • 付款失败时,需要区分临时失败和永久失败,并按照业务规则通知用户或暂停服务。
  • 生产环境需要记录审计日志,但日志不能包含完整卡号、令牌或其他敏感信息。

备注:内容仅供参考。