如何用代码实现每月动态金额的 Recurring Billing?
明确结论
可以实现。这个需求通常叫按量计费(usage-based billing / metered billing),与固定金额订阅不同。
系统需要完成两项工作:
- 持续记录客户的实际使用量。
- 每个计费周期结束后,由代码汇总使用量并计算应付金额,再通过支付平台自动生成账单和扣款。
例如,每周收费 $10,月度费用可以这样计算:
amount = usedWeeks × 10
如果客户分别使用了 1、3、2 周,对应账单就是 $10、$30、$20,不需要人工修改订阅价格。
推荐的实现方式
通常有两种方案。
方案一:使用支付平台的按量计费功能
Stripe Billing 等订阅计费平台提供过按量计费或计量事件相关功能。业务系统负责上报使用量,平台在计费周期结束时汇总数据并生成账单。
这种方式适合以下情况:
- 使用量可以按次数、时长、流量等单位统计;
- 计费规则相对稳定;
- 希望由支付平台处理账单、自动扣款、失败重试和收据。
不同平台的 API 和计量模型可能发生调整,接入前应查看所选平台当前的官方文档。
方案二:业务系统计算金额,支付平台负责扣款
如果计费规则比较复杂,可以由业务系统计算金额,再按月创建动态金额的发票。
基本流程如下:
记录使用事件
↓
计费周期结束
↓
汇总本月使用量
↓
计算应付金额
↓
创建账单明细
↓
生成并确认发票
↓
使用客户保存的付款方式自动扣款
这种方式更灵活。折扣、封顶价格、阶梯价格和补偿金额都可以由业务代码控制。
数据设计
不要只在用户表中保存一个”本月使用周数”字段。更稳妥的做法是保存不可变的使用记录:
usage_events
- id
- customer_id
- quantity
- occurred_at
- billing_period
- status
例如:
[
{
"id": "evt_1001",
"customer_id": "cus_123",
"quantity": 1,
"occurred_at": "2026-08-03T00:00:00Z"
},
{
"id": "evt_1002",
"customer_id": "cus_123",
"quantity": 1,
"occurred_at": "2026-08-10T00:00:00Z"
}
]
月底按客户和计费周期汇总:
SELECT customer_id, SUM(quantity) AS used_weeks
FROM usage_events
WHERE occurred_at >= :period_start
AND occurred_at < :period_end
GROUP BY customer_id;
JavaScript 计算示例
金额应使用最小货币单位。例如,美元应使用美分,以免产生浮点数误差:
function calculateMonthlyCharge(usedWeeks) {
if (!Number.isInteger(usedWeeks) || usedWeeks < 0) {
throw new Error("usedWeeks must be a non-negative integer");
}
const pricePerWeekInCents = 1000;
return usedWeeks * pricePerWeekInCents;
}
console.log(calculateMonthlyCharge(1)); // 1000,即 $10
console.log(calculateMonthlyCharge(3)); // 3000,即 $30
console.log(calculateMonthlyCharge(2)); // 2000,即 $20
创建动态发票的示例
下面用 Stripe 风格的 Node.js API 展示整体思路。实际字段和调用顺序需要根据当前 SDK 版本及账户配置确认。
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function billCustomer({
customerId,
usedWeeks,
billingPeriod,
}) {
const amount = usedWeeks * 1000;
if (amount === 0) {
return {
customerId,
billingPeriod,
amount: 0,
status: "no_charge",
};
}
const invoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: "charge_automatically",
auto_advance: false,
metadata: {
billing_period: billingPeriod,
},
},
{
idempotencyKey: `invoice:${customerId}:${billingPeriod}`,
}
);
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: invoice.id,
amount,
currency: "usd",
description: `${billingPeriod}: ${usedWeeks} service week(s)`,
metadata: {
used_weeks: String(usedWeeks),
},
},
{
idempotencyKey: `invoice-item:${customerId}:${billingPeriod}`,
}
);
const finalizedInvoice = await stripe.invoices.finalizeInvoice(
invoice.id
);
return {
invoiceId: finalizedInvoice.id,
amount,
status: finalizedInvoice.status,
};
}
客户需要提前保存可用于后续扣款的付款方式,并完成相应授权。发票确认后是否立即扣款,是由平台自动推进,还是由代码调用支付接口,需要根据实际业务流程配置。
定时结算任务
可以每月运行一次定时任务:
async function runMonthlyBilling(periodStart, periodEnd) {
const usageSummaries = await getUsageSummaries(
periodStart,
periodEnd
);
for (const usage of usageSummaries) {
await billCustomer({
customerId: usage.paymentCustomerId,
usedWeeks: usage.usedWeeks,
billingPeriod: usage.billingPeriod,
});
await markUsageAsBilled(
usage.customerId,
usage.billingPeriod
);
}
}
在生产环境中,扣款请求刚提交时不能直接将账单标记为支付成功。系统应监听支付平台的 Webhook,并根据发票支付成功、支付失败等事件更新账单状态。
必须注意的问题
防止重复扣款
定时任务可能因为超时或重试而执行多次。每个”客户 + 计费周期”都必须有唯一的账单编号,支付请求也要设置幂等键。
数据库中还应添加唯一约束,例如:
UNIQUE (customer_id, billing_period)
固定计费周期边界
需要明确月份采用哪个时区,以及计费周期是否按自然月计算。例如:
[2026-08-01 00:00:00, 2026-09-01 00:00:00)
建议采用左闭右开区间,避免月末事件被重复计算。
处理延迟上报
如果使用数据可能延迟到达,可以采用以下方式处理:
- 在月末预留结算缓冲时间;
- 将迟到的数据计入下一个周期;
- 为已经出具的账单创建补充账单或抵扣项。
具体规则应提前写入计费协议。
不要直接用浮点数计算金额
不要这样计算:
const amount = 10.1 * 100;
美分、分或其他最小货币单位应始终以整数形式保存。涉及比例、税率或折扣时,可以使用十进制定点库,并明确舍入规则。
处理支付失败
自动扣款并不保证付款一定成功,还需要处理以下情况:
- 银行卡过期;
- 余额不足;
- 需要额外身份验证;
- 重试失败;
- 客户取消付款授权。
系统应监听 Webhook,并将账单状态区分为 pending、paid、failed、void 等。
选择建议
如果价格只是”使用量 × 单价”,可以优先考虑支付平台提供的按量计费功能。
如果金额取决于阶梯价格、最低消费、月度封顶、优惠券或人工补偿等复杂业务规则,更适合由业务系统计算金额,再调用支付平台创建动态发票。
业务系统负责准确记录和计算使用量,支付平台负责保存付款方式、生成账单并执行扣款。
备注:内容仅供参考。
