Billing system 架构有哪些最佳实践?
结论
Billing system 的重点不是“每月执行一次扣款”,而是建立一套可追溯、可重算、能够幂等执行的账务模型。
对于小型 SIP provider,可以先在 Symfony 单体应用中划分清晰的模块边界:
- 产品与价格:定义收费项目、价格版本和套餐。
- 订阅:管理客户选择的套餐及其生效周期。
- 用量计费:接收并汇总通话记录等使用量。
- 账单:生成不能随意修改的账单和账单项。
- 支付:处理收款、退款以及与支付渠道的交互。
- 账务流水:记录每笔应收、付款、退款和调整。
- 税务与展示:处理税率、舍入、账单编号和 PDF 等外围需求。
不要把所有逻辑都塞进 Plan、User 或一个庞大的 BillingService。Symfony 提供的是技术基础,领域模型、状态转换和数据一致性才是设计重点。
先明确计费模型
“收费项目”“价格”“套餐”和“订阅”应当分开建模。
Product / PricingItem
用于描述卖的是什么,例如:
- SIP 账号开通费
- 月租
- DID 号码
- 国内通话分钟数
- 国际通话分钟数
Price
用于描述如何收费,例如:
- 一次性收取 20 EUR
- 每月收取 10 EUR
- 每年收取 100 EUR
- 每分钟收取 0.03 EUR
价格应有版本和生效时间。管理员修改当前价格后,已经开出的账单不能随之变化。
Price
├── id
├── product_id
├── amount_minor
├── currency
├── billing_type
├── interval
├── valid_from
└── valid_until
金额最好以货币最小单位的整数保存。例如,1099 表示 10.99 EUR。不要使用 float。
final readonly class Money
{
public function __construct(
public int $amountMinor,
public string $currency,
) {
if ($currency === '') {
throw new InvalidArgumentException('Currency is required.');
}
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new DomainException('Cannot add different currencies.');
}
return new self(
$this->amountMinor + $other->amountMinor,
$this->currency,
);
}
}
Plan
套餐是产品和价格的组合,不是一种特殊的金额字段。
例如,“基础 SIP 套餐”可以包含:
- 一次性开通费
- 每月固定月租
- 每月 500 分钟免费额度
- 超出部分按分钟收费
采用这种模型后,增加年付、阶梯价格或按用量收费时,就不必重写整个套餐模型。
Subscription
订阅记录客户选择的内容及其生效时间:
Subscription
├── customer_id
├── plan_version_id
├── status
├── started_at
├── current_period_start
├── current_period_end
├── cancel_at
└── cancelled_at
常见状态可以是:
pending → active → past_due → cancelled
↘ suspended
状态转换应通过明确的领域方法完成,不能允许业务代码随意调用 setStatus()。
用统一规则表示付款周期
onetime、monthly 和 annual 不需要各写一套账单生成代码。可以把它们统一表示为计费频率:
enum BillingInterval: string
{
case OneTime = 'one_time';
case Month = 'month';
case Year = 'year';
}
周期性价格还需要保存周期数量。例如,按季度收费可以表示为:
interval = month
interval_count = 3
月度周期不能简单地按固定 30 天计算。如果订阅从 1 月 31 日开始,必须提前定义下一个计费日的计算方式。常见做法是单独保存 billing_anchor,并明确月底的处理规则。
把计费计算与支付执行分开
这两个过程经常被混在一起,实际应分为:
- 计费引擎计算客户应付的金额。
- 账单模块固化计算结果。
- 支付模块尝试收款。
- 账务模块记录收款或失败结果。
支付失败不能导致账单消失,重复支付也不能生成第二张账单。
主要流程可以设计为:
Usage/CDR
↓
Rating
↓
Invoice Draft
↓
Invoice Finalization
↓
Payment Attempt
↓
Ledger Entry
其中:
Rating将 SIP 通话记录转换为金额。Invoice表示客户在某个周期内的应付明细。PaymentAttempt表示一次实际的扣款尝试。LedgerEntry表示已经发生的财务变化。
SIP 场景要单独设计用量计费
对 SIP provider 来说,复杂的通常不是月租,而是 CDR(Call Detail Record)计费。不要直接在原始 CDR 表上反复计算账单。
可以将处理过程分成三步:
1. 接收原始 CDR
保留上游系统提供的原始数据和唯一标识:
CallDetailRecord
├── external_id
├── account_id
├── started_at
├── answered_at
├── ended_at
├── destination
├── duration_seconds
└── received_at
应为 external_id 建立唯一约束,防止同一条记录被重复导入。
2. 匹配费率
根据号码前缀、目的地、时间和客户使用的价格版本,找到适用费率。电话号码应先规范化,例如统一转换为 E.164 格式,然后执行最长前缀匹配。
3. 生成计费结果
不要只在 CDR 上保存一个可以覆盖的金额,而要生成独立的计费记录:
RatedUsage
├── cdr_id
├── price_version_id
├── billable_units
├── unit_price_minor
├── amount_minor
├── currency
└── rated_at
这样才能解释“这通电话为什么收取了这个价格”。
如果有按 60 秒进位、最低收费时长等规则,也应明确记录计算公式。例如:
billable_seconds = ceil(actual_seconds / pulse_seconds) × pulse_seconds
amount_minor = ceil(billable_seconds × rate_minor_per_minute / 60)
具体的舍入规则需要由客户确认,尤其要明确金额是按每次通话舍入,还是汇总后统一舍入。这两种算法可能得到不同的结果。
账单必须保存价格快照
账单生成后,不能再通过关联当前 Price 动态展示金额。账单项至少应保存:
InvoiceLine
├── description
├── quantity
├── unit_amount_minor
├── subtotal_minor
├── tax_minor
├── total_minor
├── currency
├── service_period_start
├── service_period_end
└── source_reference
以后即使产品改名或价格调整,历史账单也应保持原样。
账单通常会经历以下状态:
draft → finalized → paid
↘ open → void
↘ uncollectible
只有 draft 可以直接修改。已经 finalized 的账单如果有误,通常应通过贷项、退款或调整项纠正,不能直接覆盖原记录。具体处理方式还需符合业务所在地的税务和会计要求。
使用复式思路记录账务流水
小型模块不一定要实现完整的总账系统,但至少应使用追加式流水,并区分:
- 应收增加
- 客户付款
- 退款
- 贷项
- 手工调整
- 支付渠道手续费
不要只在客户表中维护一个可以随意覆盖的 balance。
简化后的模型可以是:
LedgerTransaction
├── id
├── type
├── reference
├── occurred_at
└── entries
LedgerEntry
├── transaction_id
├── account
├── direction
├── amount_minor
└── currency
同一笔交易的借贷金额应当平衡。即使暂时不建立完整的会计科目,追加式流水也能改善审计、退款和余额核对。
所有批处理和回调都必须幂等
账单任务、CDR 导入和支付网关 webhook 都可能重复执行。不能因为任务在正常情况下不会重复,就忽略幂等性。
例如,可以为每个订阅周期建立唯一键:
CREATE UNIQUE INDEX uniq_invoice_subscription_period
ON invoice (subscription_id, period_start, period_end);
支付回调也要保存渠道事件 ID:
CREATE UNIQUE INDEX uniq_payment_provider_event
ON payment_event (provider, provider_event_id);
Symfony Messenger 的消息处理器同样要进行幂等检查:
final readonly class GenerateInvoiceHandler
{
public function __construct(
private InvoiceRepository $invoices,
private InvoiceGenerator $generator,
) {
}
public function __invoke(GenerateInvoice $command): void
{
if ($this->invoices->existsForPeriod(
$command->subscriptionId,
$command->periodStart,
$command->periodEnd,
)) {
return;
}
$this->generator->generate(
$command->subscriptionId,
$command->periodStart,
$command->periodEnd,
);
}
}
应用层检查可以减少无效操作,数据库唯一约束则负责最后兜底。两层都需要保留。
Symfony 中的模块划分
小型系统没有必要一开始就拆成微服务。目录可以按下面的方式组织:
src/
└── Billing/
├── Catalog/
│ ├── Domain/
│ └── Infrastructure/
├── Subscription/
│ ├── Domain/
│ └── Application/
├── Usage/
│ ├── Domain/
│ └── Application/
├── Invoicing/
│ ├── Domain/
│ └── Application/
├── Payment/
│ ├── Domain/
│ └── Infrastructure/
└── Ledger/
├── Domain/
└── Infrastructure/
核心实体可以通过 Doctrine ORM 持久化,以下任务则可以交给 Symfony Messenger:
- 周期账单生成
- CDR 导入和计费
- 支付请求
- webhook 后续处理
- 邮件和 PDF 生成
不过,向消息队列发送事件和提交数据库事务之间会有一致性问题。对于重要事件,可以使用 Transactional Outbox。业务数据与待发送事件在同一个数据库事务中写入,再由后台任务可靠发送。
时间、税率和折算规则必须提前确定
Billing system 的错误经常出现在边界条件上:
- 时区以客户时区、服务时区还是 UTC 为准?
- 夏令时切换时如何处理?
- 月中升级套餐是否按比例收费?
- 降级立即生效还是在下个周期生效?
- 试用期是否产生账单?
- 免费额度按账号计算,还是由组织共享?
- 税率依据客户地址、服务所在地还是其他条件?
- 退款是否退税?
- 账单金额在哪一步舍入?
- 付款日遇到月底时如何计算?
内部时间最好统一保存为 UTC,同时保留客户的计费时区。金额计算必须使用整数或可靠的十进制定点类型,不能使用二进制浮点数。
按比例收费也不能简单写成“月费除以 30”。一种可选的按秒折算规则是:
prorated_amount =
full_period_amount
× active_seconds
÷ total_period_seconds
这只是一种策略,不是通用标准。最终采用哪种规则,需要由业务方确认,并通过测试固定下来。
测试重点
计费代码应优先编写确定性的单元测试,尤其要覆盖:
- 闰年和 2 月月底
- 1 月 31 日开始的月度订阅
- 年付订阅续期
- 月中升级和降级
- 价格版本跨周期切换
- 免费额度耗尽
- 通话时长进位
- 多币种拒绝混算
- webhook 重复到达
- 后台任务重复执行
- 支付成功但本地处理超时
- 账单作废、退款和贷项
系统还应提供“计费预览”接口,在正式生成账单前返回计算明细。预览和正式计费必须使用同一个领域计算器,以免两套规则逐渐出现差异。
推荐的实施顺序
小型项目可以按以下顺序实现:
- 实现
Money、价格版本和明确的周期规则。 - 实现套餐与订阅生命周期。
- 实现账单、账单项和价格快照。
- 加入唯一约束与幂等任务。
- 接入支付渠道,并单独保存每次支付尝试。
- 实现 CDR 导入、费率匹配和用量计费。
- 增加退款、贷项和追加式账务流水。
- 最后处理税务、PDF、通知、催款和运营报表。
如果业务以后涉及预付费余额、实时停机或高并发 CDR,架构复杂度会明显上升。预付费需要近实时地预留和扣减额度,不能直接复用月结后付费流程,应作为独立子域评估。
值得参考的资料
没有哪一本书可以直接给出完整的 SIP billing 实现,但以下资料有助于建立架构基础:
- Martin Fowler 的 Patterns of Enterprise Application Architecture:涵盖事务脚本、领域模型和数据映射等企业应用模式。
- Eric Evans 的 Domain-Driven Design:适合梳理订阅、账单、支付和账务之间的领域边界。
- Martin Kleppmann 的 Designing Data-Intensive Applications:适合了解幂等、消息处理、一致性和事件流。
- Gregor Hohpe、Bobby Woolf 的 Enterprise Integration Patterns:适合处理支付回调、异步任务和外部系统集成。
- Stripe、Adyen 等支付平台的公开文档:可以参考 Subscription、Invoice、Payment、Refund、Credit Note 和 webhook 的资源划分。它们的模型可以借鉴,但不能直接作为会计或税务规范。
- TM Forum 关于计费、评级和账务的相关模型:更接近电信场景,但范围较大,小型项目可以选择性参考。
设计时可以抓住几条原则:价格有版本、账单有快照、流水只追加、任务可重试、计算可解释、金额不用浮点数。落实这些原则后,即使以后增加套餐升级、用量计费和退款,也不必推翻现有系统重做。
备注:内容仅供参考。
