如何动态计算 upgrade / downgrade 后的 subscription cost
结论
多次 upgrade / downgrade 产生的优惠,不要累计到同一个 discount 字段中。更稳妥的方案是使用“账户余额 + 不可变账务流水”:
- 每次变更时,计算旧 plan 未使用部分对应的 credit。
- 计算新 plan 在剩余计费周期内的 charge。
- 用两者之差得出本次 adjustment。
- 将 adjustment 写入账务流水,同时更新账户余额。
- 下次变更时,只根据当时生效的 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 更稳定,也更便于审计和排查问题。
备注:内容仅供参考。
