如何按指定日期创建仅扣款两次的 recurring payment
结论
不要把这种需求当作普通的“每隔 N 天无限续费”。更准确的做法是把它设计成一个两阶段付款计划:
- 创建订单时立即收取预付款;
- 根据统一的目标日期计算等待时间,并在当天收取尾款;
- 尾款支付成功后结束计划,不再扣款。
如果支付平台允许动态设置首个账单日期和扣款总次数,可以创建只执行两次的订阅计划。如果平台不支持,建议把预付款和尾款拆成两笔交易,第二笔通过支付平台的定时扣款能力或业务系统的调度任务执行。
等待时间如何计算
假设尾款统一在 targetDate 扣取,用户首次付款时间为 purchaseDate,创建付款计划时应动态计算等待时间:
delay = targetDate - purchaseDate
例如,目标日期固定在 60 天后:
| 首次付款时间 | 计算出的等待时间 |
|---|---|
| 今天 | 60 天 |
| 明天 | 59 天 |
| 后天 | 58 天 |
需要保存的是固定的目标日期,而不是固定的“60 天间隔”。每次用户下单时,都要重新计算首次付款日至目标日期之间的差值。
推荐实现方式
方案一:预付款与尾款拆成两笔交易
对于尾款日期固定、两次金额不同,而且总共只扣两次的场景,这种设计通常更稳妥。
创建订单时:
- 立即创建并确认预付款交易;
- 在用户明确授权的前提下,保存支付平台返回的 customer、mandate、payment method token 等可复用支付凭证;
- 保存尾款金额和目标扣款时间;
- 到期后由调度任务发起第二笔交易;
- 使用幂等键,避免任务重试导致重复扣款。
下面是一个与具体支付平台无关的 JavaScript 示例:
async function createTwoStagePayment({
customerId,
paymentMethodToken,
totalAmount,
advanceAmount,
targetDate
}) {
const now = new Date();
const chargeAt = new Date(targetDate);
if (Number.isNaN(chargeAt.getTime())) {
throw new Error("Invalid targetDate");
}
if (chargeAt <= now) {
throw new Error("targetDate must be later than the initial payment date");
}
const remainingAmount = totalAmount - advanceAmount;
if (advanceAmount <= 0 || remainingAmount <= 0) {
throw new Error("Payment amounts must be greater than zero");
}
const advancePayment = await paymentProvider.charge({
customerId,
paymentMethodToken,
amount: advanceAmount,
idempotencyKey: `order:${orderId}:advance`
});
await database.paymentSchedules.insert({
orderId,
customerId,
paymentMethodToken,
amount: remainingAmount,
chargeAt: chargeAt.toISOString(),
status: "scheduled",
initialPaymentId: advancePayment.id
});
return advancePayment;
}
定时任务负责收取尾款:
async function processScheduledPayment(schedule) {
if (schedule.status !== "scheduled") {
return;
}
const payment = await paymentProvider.charge({
customerId: schedule.customerId,
paymentMethodToken: schedule.paymentMethodToken,
amount: schedule.amount,
idempotencyKey: `order:${schedule.orderId}:remaining`
});
await database.paymentSchedules.update(schedule.id, {
status: "paid",
paymentId: payment.id,
paidAt: new Date().toISOString()
});
}
这里的 paymentProvider.charge()、字段名称和支付凭证类型都只是抽象示例,需要替换为实际支付平台的 API。银行卡号、CVV 等敏感信息不能直接保存在数据库中。
方案二:使用支付平台的订阅或账单计划
只有当支付平台同时具备以下能力时,才适合使用 recurring payment:
- 首次扣款可以立即执行;
- 第二次扣款日期可以动态指定;
- 两次扣款金额可以不同;
- 可以限制扣款总次数,或在第二次扣款后自动结束;
- 支持非等间隔的付款阶段。
计划在概念上可以表示为:
{
"phases": [
{
"amount": 3000,
"charge_at": "immediately",
"iterations": 1
},
{
"amount": 7000,
"charge_at": "2026-10-28T02:00:00Z",
"iterations": 1
}
]
}
如果平台只能配置类似 interval = 60 days 的固定周期,扣款日期通常会根据每个用户创建订阅的时间计算,无法保证所有用户都在同一个目标日期付款。此时不要通过每天修改 interval 来模拟,应该改用两笔交易加调度任务的方案。
按天计算时的示例
如果平台要求传入等待天数,而不是绝对扣款时间,可以按日历日期计算:
function differenceInCalendarDays(targetDate, purchaseDate, timeZone) {
const target = toCalendarDate(targetDate, timeZone);
const purchase = toCalendarDate(purchaseDate, timeZone);
const millisecondsPerDay = 24 * 60 * 60 * 1000;
return Math.round(
(Date.UTC(target.year, target.month - 1, target.day) -
Date.UTC(purchase.year, purchase.month - 1, purchase.day)) /
millisecondsPerDay
);
}
实际项目中可以使用支持时区的日期库实现 toCalendarDate()。不要直接用毫秒差除以 86400000 后取整,否则夏令时、时区和具体扣款时刻可能让结果提前或延后一天。
注意事项
- 如果 API 支持
charge_at、billing_cycle_anchor、start_date等绝对时间字段,优先直接传入目标时间。 - 第二次无交互扣款通常要求用户在首次付款时明确授权,同时还要满足支付平台和当地法规的要求。
- 需要为扣款失败设计重试次数、重试间隔、用户通知和更换支付方式的流程。
- 尾款任务可能重复执行,因此必须使用稳定的幂等键,并在本地记录交易状态。
- 金额通常要使用分等最小货币单位的整数值,避免浮点数计算误差。
- 如果用户在目标日期当天或之后购买,应拒绝创建该计划,或者转入人工确认、立即收取尾款等单独流程。
- 不要自行存储银行卡信息。应保存支付平台提供的 token、payment method ID 或 mandate ID,并遵守相应的 PCI DSS 要求。
能否直接创建“两次 recurring payment”,取决于支付平台是否支持动态账单日期、不同阶段金额和有限扣款次数。只要缺少其中一项,把付款拆成“即时预付款 + 指定日期尾款”就更符合这个业务模型。
备注:内容仅供参考。
