PAYATHON 2026

如何动态计算 upgrade / downgrade 后的 subscription cost

支付阿杰

结论

多次 upgrade / downgrade 产生的优惠,不要累计到同一个 discount 字段中。更稳妥的方案是使用“账户余额 + 不可变账务流水”:

  1. 每次变更时,计算旧 plan 未使用部分对应的 credit。
  2. 计算新 plan 在剩余计费周期内的 charge。
  3. 用两者之差得出本次 adjustment。
  4. 将 adjustment 写入账务流水,同时更新账户余额。
  5. 下次变更时,只根据当时生效的 plan、剩余周期和账户余额重新计算。

计算公式如下:

unused_credit = old_plan_price × remaining_ratio
new_charge    = new_plan_price × remaining_ratio
adjustment    = new_charge - unused_credit

其中:

  • adjustment > 0:用户需要补款。
  • adjustment < 0:产生账户 credit,可用于抵扣后续账单。
  • adjustment = 0:无需收费或退款。

每次变更只需追加新的账务记录,无需回头统计用户改过多少次 plan。

为什么原公式不适合多次变更

下面的公式混合了不同维度的数据:

final plan cost = new plan cost * 12 - discount / (12 - month / 3 - quarter / 1 - year)

它有三个主要问题:

  • month、quarter、year 表示付款频率,不应直接参与剩余价值的算术运算。
  • 如果反复覆盖 discount,就很难判断优惠来自哪次变更,也无法进行审计。
  • 经过多次变更后,当前优惠不只取决于两个 plan 的价格差,还与每次变更的时间、计费周期,以及此前是否发生过退款或抵扣有关。

付款频率只用于确定计费周期的边界。例如:

month   -> 2026-01-01 至 2026-02-01
quarter -> 2026-01-01 至 2026-04-01
year    -> 2026-01-01 至 2027-01-01

计算时真正需要的是当前计费周期还剩多少,而不是一年内已经付款多少次。

每次 plan 变更的计算步骤

以下方案假设费用采用预付制,并且 upgrade 和 downgrade 都按时间比例结算。

1. 确定当前计费周期

需要保存以下数据:

period_start
period_end
changed_at
current_plan

剩余比例的计算方式是:

remaining_ratio =
    (period_end - changed_at) /
    (period_end - period_start)

如果业务要求按天结算,也可以根据剩余天数计算。无论按秒还是按天,都要统一规则,不能在同一系统中混用。

2. 退回旧 plan 未使用部分的价值

unused_credit = old_plan_period_price × remaining_ratio

3. 收取新 plan 在剩余周期内的费用

new_charge = new_plan_period_price × remaining_ratio

4. 计算本次差额

adjustment = new_charge - unused_credit

如果新旧 plan 使用同一个计费周期,也可以简化为:

adjustment =
    (new_plan_period_price - old_plan_period_price)
    × remaining_ratio

不过在账务记录中,仍建议分别保存 unused_credit 和 new_charge,以便后续核对。

多次变更示例

假设当前季度从 4 月 1 日开始,到 7 月 1 日结束:

  • Plan A:每季度 120 元
  • Plan B:每季度 240 元
  • Plan C:每季度 120 元

用户在剩余周期为 2/3 时,从 A upgrade 到 B:

旧 plan credit = 120 × 2/3 = 80
新 plan charge = 240 × 2/3 = 160
本次应补       = 160 - 80 = 80

之后,用户又在剩余周期为 1/3 时,从 B downgrade 到 C:

旧 plan credit = 240 × 1/3 = 80
新 plan charge = 120 × 1/3 = 40
本次 credit     = 40 - 80 = -40

第二次变更会产生 40 元账户余额。下一张账单金额为 120 元时:

invoice_due = max(0, 120 - 40) = 80

这次计算无需追溯用户此前是否从 A 升级过。当前 B 的有效价格和剩余周期已经提供了所需信息。

推荐的数据结构

不要只在 subscription 表中保存一个反复变化的 discount。至少应将 subscription 状态与账务流水分开保存。

subscriptions
-------------
id
customer_id
current_plan_id
billing_interval
period_start
period_end
status
version

ledger_entries
--------------
id
subscription_id
event_id
entry_type
amount
currency
effective_at
old_plan_id
new_plan_id
period_start
period_end
created_at

entry_type 可以包括:

invoice
payment
unused_plan_credit
new_plan_charge
manual_credit
refund

可以采用以下约定:

  • 正数表示用户欠款。
  • 负数表示用户拥有 credit。
  • 流水只能追加,不能直接修改历史记录。
  • 需要纠错时,新增一条反向记录,不删除原记录。
  • 每次变更都使用唯一的 event_id,避免接口重试造成重复扣款。

账户余额可以通过流水求和计算:

balance = sum(ledger_entries.amount)

数据量较大时可以缓存余额,但核对账务时仍应以流水为准。

TypeScript 计算示例

金额应使用“分”等最小货币单位,不能用浮点数保存人民币金额。

type PlanChangeInput = {
  oldPlanPriceCents: bigint;
  newPlanPriceCents: bigint;
  periodStart: Date;
  periodEnd: Date;
  changedAt: Date;
};

type PlanChangeResult = {
  unusedCreditCents: bigint;
  newChargeCents: bigint;
  adjustmentCents: bigint;
};

function prorate(
  amountCents: bigint,
  remainingMs: bigint,
  totalMs: bigint
): bigint {
  // 示例采用四舍五入。实际系统应统一并记录舍入规则。
  return (amountCents * remainingMs + totalMs / 2n) / totalMs;
}

function calculatePlanChange(
  input: PlanChangeInput
): PlanChangeResult {
  const startMs = BigInt(input.periodStart.getTime());
  const endMs = BigInt(input.periodEnd.getTime());
  const changedMs = BigInt(input.changedAt.getTime());

  if (endMs <= startMs) {
    throw new Error("Invalid billing period");
  }

  if (changedMs < startMs || changedMs > endMs) {
    throw new Error("changedAt is outside the billing period");
  }

  const totalMs = endMs - startMs;
  const remainingMs = endMs - changedMs;

  const unusedCreditCents = prorate(
    input.oldPlanPriceCents,
    remainingMs,
    totalMs
  );

  const newChargeCents = prorate(
    input.newPlanPriceCents,
    remainingMs,
    totalMs
  );

  return {
    unusedCreditCents,
    newChargeCents,
    adjustmentCents: newChargeCents - unusedCreditCents
  };
}

调用示例:

const result = calculatePlanChange({
  oldPlanPriceCents: 12000n,
  newPlanPriceCents: 24000n,
  periodStart: new Date("2026-04-01T00:00:00Z"),
  periodEnd: new Date("2026-07-01T00:00:00Z"),
  changedAt: new Date("2026-05-01T00:00:00Z")
});

if (result.adjustmentCents > 0n) {
  console.log("Amount due:", result.adjustmentCents);
} else {
  console.log("Account credit:", -result.adjustmentCents);
}

实际入账时,应在同一个数据库事务中完成所有操作:

async function applyPlanChange(input: PlanChangeInput) {
  return database.transaction(async (tx) => {
    const result = calculatePlanChange(input);

    await tx.ledgerEntries.insert({
      entryType: "unused_plan_credit",
      amount: -result.unusedCreditCents
    });

    await tx.ledgerEntries.insert({
      entryType: "new_plan_charge",
      amount: result.newChargeCents
    });

    await tx.subscriptions.update({
      currentPlanId: "new-plan-id"
    });

    return result;
  });
}

月付、季付和年付如何处理

不同付款频率应使用各自对应的 plan 价格:

monthly_price
quarterly_price
yearly_price

不要默认用 monthly_price × 12 计算年费,因为产品通常可能提供年付优惠。例如:

monthly_price × 12 = 1200
yearly_price       = 1000

年付用户中途变更 plan 时,应按 yearly_price 的未使用比例计算,不能先将其换算成月费。

如果用户同时更改 plan 和付款频率,例如从季度 Plan A 改为年付 Plan B,需要先确定采用哪种业务规则:

  • 保留原周期结束日期,仅对剩余时间按比例收费;
  • 立即结束旧周期,从变更时间开始创建新的年度周期;
  • 等当前周期结束后再切换。

三种方案的计算结果不同,单靠价格公式无法决定该采用哪一种。通常情况下,upgrade 立即生效、downgrade 在周期结束时生效,实现和解释的成本相对较低。

需要提前确定的业务规则

上线前需要明确这些细节:

  • 按秒、按小时还是按自然日计算剩余比例;
  • plan 变更立即生效,还是在下个周期生效;
  • downgrade 产生的 credit 是直接退款,还是只抵扣后续账单;
  • credit 是否会过期;
  • 优惠券、促销价和税费是否参与 prorating;
  • 年付折扣是否按比例退还;
  • 舍入发生在每条流水,还是整张账单;
  • 同一时刻连续提交多个变更时,应该如何排序;
  • 已经开具的发票是否允许生成 credit note。

同一笔价值只能抵扣一次。将每次 credit 和 charge 分别写入独立流水,再通过账户余额处理后续变更,比维护一个累计 discount 更稳定,也更便于审计和排查问题。

备注:内容仅供参考。