PAYATHON 2026

如何按指定日期创建仅扣款两次的 recurring payment

支付老李

结论

不要把这种需求当作普通的“每隔 N 天无限续费”。更准确的做法是把它设计成一个两阶段付款计划:

  1. 创建订单时立即收取预付款;
  2. 根据统一的目标日期计算等待时间,并在当天收取尾款;
  3. 尾款支付成功后结束计划,不再扣款。

如果支付平台允许动态设置首个账单日期和扣款总次数,可以创建只执行两次的订阅计划。如果平台不支持,建议把预付款和尾款拆成两笔交易,第二笔通过支付平台的定时扣款能力或业务系统的调度任务执行。

等待时间如何计算

假设尾款统一在 targetDate 扣取,用户首次付款时间为 purchaseDate,创建付款计划时应动态计算等待时间:

delay = targetDate - purchaseDate

例如,目标日期固定在 60 天后:

首次付款时间计算出的等待时间
今天60 天
明天59 天
后天58 天

需要保存的是固定的目标日期,而不是固定的“60 天间隔”。每次用户下单时,都要重新计算首次付款日至目标日期之间的差值。

推荐实现方式

方案一:预付款与尾款拆成两笔交易

对于尾款日期固定、两次金额不同,而且总共只扣两次的场景,这种设计通常更稳妥。

创建订单时:

  1. 立即创建并确认预付款交易;
  2. 在用户明确授权的前提下,保存支付平台返回的 customer、mandate、payment method token 等可复用支付凭证;
  3. 保存尾款金额和目标扣款时间;
  4. 到期后由调度任务发起第二笔交易;
  5. 使用幂等键,避免任务重试导致重复扣款。

下面是一个与具体支付平台无关的 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”,取决于支付平台是否支持动态账单日期、不同阶段金额和有限扣款次数。只要缺少其中一项,把付款拆成“即时预付款 + 指定日期尾款”就更符合这个业务模型。

备注:内容仅供参考。