如何使用 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;
具体处理步骤如下:
- 原子地把选中的发票更新为
processing。 - 为每个扣款请求创建稳定的幂等键,例如
invoice-123-charge。 - 将幂等键写入本地数据库,同时传给支持幂等请求的支付服务商。
- 通过 webhook 确认最终结果,不能只依赖同步 API 响应。
- 扣款成功后,把发票更新为
paid,并推进next_billing_at。 - 扣款失败后,记录失败原因和下次重试时间。
- 为长时间停留在
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 建立唯一索引。
- 设置重试上限和退避策略,避免扣款失败后无限重试。
- 明确处理取消订阅、部分退款、支付方式过期、金额调整和补扣。
- 付款失败时,需要区分临时失败和永久失败,并按照业务规则通知用户或暂停服务。
- 生产环境需要记录审计日志,但日志不能包含完整卡号、令牌或其他敏感信息。
备注:内容仅供参考。
