惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
A
About on SuperTechFans
IT之家
IT之家
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Blog — PlanetScale
Blog — PlanetScale
aimingoo的专栏
aimingoo的专栏
云风的 BLOG
云风的 BLOG
The GitHub Blog
The GitHub Blog
Vercel News
Vercel News
G
Google Developers Blog
J
Java Code Geeks
宝玉的分享
宝玉的分享
T
Tailwind CSS Blog
Cloudbric
Cloudbric
L
LINUX DO - 最新话题
MyScale Blog
MyScale Blog
H
Heimdal Security Blog
PCI Perspectives
PCI Perspectives
Attack and Defense Labs
Attack and Defense Labs
S
Security @ Cisco Blogs
Latest news
Latest news
I
Intezer
L
Lohrmann on Cybersecurity
C
CXSECURITY Database RSS Feed - CXSecurity.com
月光博客
月光博客
T
Threatpost
博客园 - 【当耐特】
S
Schneier on Security
P
Privacy International News Feed
G
GRAHAM CLULEY
T
Tenable Blog
AWS News Blog
AWS News Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
雷峰网
雷峰网
博客园 - Franky
Engineering at Meta
Engineering at Meta
美团技术团队
S
Secure Thoughts
T
Troy Hunt's Blog
Microsoft Security Blog
Microsoft Security Blog
SecWiki News
SecWiki News
V
Visual Studio Blog
人人都是产品经理
人人都是产品经理
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Cisco Talos Blog
Cisco Talos Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Martin Fowler
Martin Fowler
Webroot Blog
Webroot Blog
Google DeepMind News
Google DeepMind News
H
Hackread – Cybersecurity News, Data Breaches, AI and More

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 排序基准测试:用数据说话
【金融科技工程】订阅与计费系统:用量计量、账单、发票、出海 VAT/GST
Liao Tonglang · 2026-04-22 · via 土法炼钢兴趣小组的算法知识备份

本文为【金融科技工程】系列第 10 篇。前一篇讨论了支付网关如何把一次「扣款」安全送达,本篇向前推一步:在什么规则下计算「应扣多少」、以什么形式「开出账单和发票」、如何把「出海的税」算对。

引子:为什么计费比支付还难

如果说支付解决的是「钱如何安全流转」,那么订阅与计费解决的是「在复杂的商业规则下,该收多少、向谁收、什么时候收、开什么票」。对大多数工程师来说,接支付接口是一个有限问题——文档、签名、回调、对账,半年之内能做到熟练。但计费系统不一样:它是产品、销售、财务、税务、法务、合规同时下场的系统,是公司对外「商业模式」的直接投射。每一次打折、每一次涨价、每一次加入一个新的国家市场、每一次推出一个新的用量维度,都会在计费系统里落下一块伤疤。

几个具体的难点:

  • 时间维度:订阅有「账期」(Billing Period),用量有「计量窗口」(Metering Window),发票有「开票期」(Invoice Period),税务有「申报期」(Tax Period)。四种时间都不一样,还要考虑时区、夏令时、闰秒。
  • 金额维度:一个账单里既有固定费(订阅)、又有浮动费(用量),还有折扣、Credit、退款、代金券、税。这些都要按规则分摊(Proration)和凑整(Rounding),结果还要反过来校验到分。
  • 合规维度:中国要开增值税发票(专票、普票),欧盟要区分 B2B/B2C 并判断是否 Reverse Charge,美国 sales tax 按 state+city+zip 三级,日本要考虑「适格请求书」制度,澳洲要区分 GST-free。
  • 历史维度:价格一旦开出去,除极个别情况,永远不能回溯修改。一个 2022 年签的三年合同,必须用 2022 年那一版价格算到 2025 年。

这篇文章面向的是「计划或正在构建 SaaS、API、云服务计费系统」的工程团队——可能你们正在评估 Stripe Billing 和自研的取舍,可能你们正在把产品卖到第一个海外国家,也可能你们被财务催着解决「发票号跳号」的陈年老账。下文不会教你「怎么设计一张漂亮的定价页」,而是把引擎盖打开,看看 billing engine 内部这台机器是怎么运转的。

一、计费模型分类:从 Flat 到 Usage-Based

1.1 五种基本形态

在设计数据模型之前,先把业界常见的计费形态梳理清楚。不同计费形态决定了订阅记录、用量记录、账单生成时机完全不同。

模型 中文 典型产品 计算逻辑
Flat-rate 固定订阅 Netflix、Notion Team price_per_period
Per-seat 按席位 Slack、GitHub、Salesforce seats × unit_price
Tiered 分层 CDN 流量阶梯价 落入某一层就整单按该层单价
Graduated / Volume 阶梯累进 AWS、GCP、阿里云 每一层单价不同,用量跨层分段计算
Usage-based (Pay-as-you-go) 纯按量 Twilio、Stripe API、OpenAI usage × unit_price
Package + Overage 套餐 + 超额 运营商流量套餐、API 配额 套餐内固定费,超出部分按量

Tiered 和 Graduated 在中文里都被翻译为「阶梯计价」,但计算方式差别极大,这是最容易搞错的一对概念。举一个 API 调用的例子:

  • 0–10,000 次:¥0.10 / 次
  • 10,001–100,000 次:¥0.08 / 次
  • 100,001+ 次:¥0.05 / 次

用户用了 50,000 次

  • Tiered(分层):整单按第二层,50,000 × 0.08 = ¥4,000
  • Graduated(阶梯累进):10,000 × 0.10 + 40,000 × 0.08 = ¥1,000 + ¥3,200 = ¥4,200

销售说「阶梯价」的时候,工程师一定要追问一句:是 tiered 还是 graduated。合同里、定价页里、计费引擎里这三处必须一致,否则 QA 的测试用例和销售谈出来的数永远对不上。

1.2 Prepaid vs Postpaid

正交于计费模型的另一个维度是「先付还是后付」:

  • Prepaid(预付):用户先充值成「余额」或「Credits」,使用时扣减。好处是资金安全、坏账风险低;坏处是用户决策门槛高。OpenAI 在 2024 年把 API 从后付迁到了预付 Credits,一部分原因是为了解决全球范围的坏账。
  • Postpaid(后付):先用后付,月底生成账单。好处是体验无感;坏处是坏账和 dunning 流程(催收)要做得非常扎实。AWS、GCP 默认都是 postpaid。

很多公司会混用:小额账户 postpaid(坏账风险可控),大企业账户 Annual Prepaid(一次性预付一年,给折扣),超高用量用户可能同时有 Commitment(承诺量预付)+ Overage(超量后付)。

1.3 折扣、Credit、Coupon 的优先级

真实的账单不是「单价 × 数量」那么简单,至少有四层价格变换:

  1. Catalog Price(目录价)
  2. Contract Price(合同协商价,针对大客户)
  3. Coupon / Promotion(活动折扣,通常带有时效)
  4. Credits(余额抵扣,可能来自充值、SLA 赔偿、运营赠送)

顺序不能搞反。工程上常见的错是「先减 credits 再算税」,结果税务口径下应纳税额偏低,审计时被认定为「漏税」。正确的顺序是先在 pre-tax 金额上做折扣,然后基于 post-discount 金额计算税,最后用 credits 冲抵 total-with-tax。Stripe、Chargebee 的实现都遵循这个顺序。

二、计量系统:Metering 的工程深坑

对于 usage-based 计费,计量系统是整个计费链路的最上游,也是最容易出错的地方。一次「漏计」或「重复计」在单次调用里看不出来,在月底账单上会被放大 N 万倍。

2.1 计量事件的基本模型

message UsageEvent {
  string event_id    = 1;   // 幂等键,事件唯一 ID
  string customer_id = 2;   // 账户 ID
  string resource_id = 3;   // 订阅 ID / API Key / 实例 ID
  string metric      = 4;   // api_calls / gb_transferred / cpu_seconds
  double quantity    = 5;   // 用量值
  int64  event_ts_ms = 6;   // 业务发生时间(UTC ms)
  int64  ingest_ts_ms = 7;  // 接收时间
  map<string,string> dimensions = 8; // region / sku / tier 等维度
  string idempotency_key = 9; // 上游幂等键
}

几个设计要点:

  • event_ts vs ingest_ts 分离:前者决定「归属哪个账期」,后者决定「何时能开账」。两者可以相差小时级甚至天级(比如边缘节点断网后补传)。
  • event_id 必须全局唯一且幂等:否则重传时会重复计费。
  • dimensions 必须可扩展:计费维度会不断增加(比如从一个 region 变成 20 个 region、新增不同硬件规格)。
  • quantity 用 double 还是 int64?——调用次数用 int64,字节数、秒数、token 数用 int64 更安全。涉及小数(比如按 GB-hour 记账)建议存放放大后的整数(如微秒、字节、兆字节整数),见本系列第 2 篇《钱的建模》的同类讨论。

2.2 管道架构:采集 → 去重 → 聚合

flowchart LR
    A[业务服务<br/>API Gateway] -- UsageEvent --> B[Kafka<br/>raw-usage]
    B --> C[Flink<br/>去重 + 窗口聚合]
    C --> D[时序库<br/>ClickHouse/Druid]
    C --> E[聚合结果<br/>aggregated-usage]
    E --> F[计费引擎<br/>Rating]
    F --> G[Billing DB<br/>invoice_lines]
    D --> H[用户 Dashboard<br/>实时用量可视化]
    B -.补偿.-> I[Late Event Handler]
    I --> C
  • raw 层:保留原始事件至少 13 个月(覆盖「年度账单 + 一个争议期」),用于审计、重算、纠纷回溯。
  • 去重:基于 event_id 用 RocksDB state 或 Redis SET 做 windowed dedup;窗口一般 24–72 小时,覆盖大部分重传场景。
  • 聚合窗口
    • 滚动窗口(Tumbling):1 分钟/1 小时,用于实时大盘。
    • 会话窗口(Session):比如「一次 WebSocket 连接计一次」「一次 VM 启动到停机算一个 session」。
    • 计费窗口(Billing Window):按账期切分,跨账期事件单独处理。
  • 迟到事件(Late Arrival)补偿
    • 设定 watermark 允许延迟(如 6 小时)。
    • 超过 watermark 的事件写入「补偿账户」,下一期账单里以 [Prior Period Adjustment] 明细出现。
    • 如果事件极端迟到(比如月底后一周才到),在中国会计准则下属于「跨期收入」,需要做调整分录,不能直接塞到本月账单里。

2.3 OLAP + 时序:为什么要两个库

很多团队只用一个 ClickHouse 就想解决所有问题,跑一段时间会遇到:

  • 用户 Dashboard 要 近 1 小时 / 1 分钟粒度 的实时曲线 → 时序库强项。
  • 计费引擎需要 按账期 × 账户 × metric × dimension 的聚合结果 → OLAP 强项。
  • 财务要历史数据的多维下钻分析 → OLAP/数仓强项。

拆成两条链路更合理:

用途 存储 粒度 保留期
实时 Dashboard Prometheus / Druid 秒—分钟 30–90 天
计费聚合结果 Postgres / TiDB 小时/天 + 账期 永久
原始事件 S3/OSS + ClickHouse 单条 13–36 个月

2.4 真实案例:AWS 和 Google 的 Metering 差别

AWS 的计费有一个众所周知的特性:账单不是实时的。你在 Console 里看到的「今日用量」通常延迟 24–48 小时,月底之后还会有最多 7–10 天的「Finalization Period」,这期间金额会微调。AWS 公开披露过其内部 billing pipeline 会批处理大约 每秒数百万条 metering 事件,并在 month-end close 时做最终对账(参考 AWS re:Invent 历年 ARC/FIN 专题)。

Google Cloud 更激进一点,Cloud Billing 的「near real-time」延迟约数小时,但月底同样有 finalization。阿里云、腾讯云的明细账单也有类似的「T+1 出账」机制。

这个延迟不是技术做不到实时,而是故意留出来的修正窗口:任何一个上游数据源(比如某 region 的网关)宕机延迟上报,都可能引起账单重算。如果你做一个号称「实时精确」的计费系统,反而会在第一次出故障时陷入巨大的对账噩梦。

2.5 一个关于「可重放」的工程原则

计量聚合的最高原则是可重放(Replayable):给定一批原始事件和一份定价版本,系统任意时刻重跑都应该得到一模一样的 invoice line。这个性质在以下场景救命:

  • 用户投诉「你们多算了我 500 次 API 调用」。客服要能在不动生产的前提下,重放一段事件流给客户看清单。
  • Bug 发生后,要能「拉回过去 30 天事件,用修好的代码重跑,和线上结果 diff」。
  • 跨云迁移或底层存储换代时,新旧两套计量链要能对齐小数点后两位。

为了做到这点:

  1. Rating 必须是纯函数:输入事件 + plan + tax 版本,输出明细。不读当前时间、不查随机 DB 状态。
  2. 计费决策引用的任何状态都要版本化:plan、tax rate、exchange rate、coupon、credits 状态都要带 as_of 时间戳。
  3. 原始事件 13+ 个月内可检索:别只存聚合结果。
  4. Replay 工具是 Day-1 交付物:不要等出事再写。

三、订阅生命周期

3.1 状态机

stateDiagram-v2
    [*] --> Trialing: 开始试用
    Trialing --> Active: 试用转正
    Trialing --> Canceled: 试用期内取消
    Active --> PastDue: 账单支付失败
    PastDue --> Active: Dunning 成功
    PastDue --> Unpaid: Dunning 失败
    Unpaid --> Canceled: 超出宽限期
    Active --> Paused: 用户暂停
    Paused --> Active: 恢复
    Active --> Canceled: 用户主动取消
    Canceled --> Active: 重新订阅 (Reactivate)
    Canceled --> [*]

几个关键状态的业务含义:

  • Trialing:试用期内,通常不收费但可以使用。要决定:试用期结束是「自动续费」还是「停止」。自动续费要在 T-3 天发邮件提醒(欧盟 Digital Services Act、美国 FTC 已在收紧此类要求)。
  • Active:正常订阅状态。
  • PastDue:账单到期未付。服务是否降级在这里是一个产品决策:SaaS 常见做法是保留 7–30 天访问权限继续 dunning,超过则降级为只读/只导出。
  • Unpaid / Canceled:最终状态。Canceled 有「立即生效」和「账期末生效」两种,合同和 UI 必须说清楚。
  • Paused:不收费、不使用。健身 App、教育 App 常用。注意 pause 的会计处理:本质是订阅期延长,不是新合同。

3.2 Proration:升降级的按比例扣费

假设用户在账期的第 10 天(总 30 天)从 Plan A(¥100/月)升级到 Plan B(¥300/月),proration 怎么算?业界几种做法:

Approach 1:Credit + Charge - 退还 Plan A 未使用部分:100 × (20/30) = 66.67 - 收取 Plan B 剩余部分:300 × (20/30) = 200.00 - 本次补差:200.00 - 66.67 = 133.33

Approach 2:账期末出一个合并账单 - 本账期账单里,Plan A 行 100 × 10/30 = 33.33,Plan B 行 300 × 20/30 = 200.00,合计 233.33。 - 用户原本已付 100,credit 里挂 -100,本月实际扣 133.33。

Approach 3:No Proration(不按比例) - 本期不找零,Plan B 下个账期开始生效。 - 仅在营销活动(试用转正)时使用。

工程上推荐 Approach 2,原因是每一行 invoice line 都是独立的账务凭证,退款、税务都可追溯。Stripe Billing、Chargebee 默认都是 Approach 2。

Python 示例:

from decimal import Decimal, ROUND_HALF_UP
from datetime import date

def prorate_amount(
    full_price: Decimal,
    period_start: date,
    period_end: date,
    effective_from: date,
    effective_to: date | None = None,
) -> Decimal:
    """按天数比例计算。period 为账期,effective 为实际使用区间。"""
    effective_to = effective_to or period_end
    total_days = (period_end - period_start).days
    used_days  = (effective_to - effective_from).days
    if total_days <= 0 or used_days <= 0:
        return Decimal("0.00")
    ratio = Decimal(used_days) / Decimal(total_days)
    return (full_price * ratio).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

# 升级补差
credit_a = prorate_amount(Decimal("100"), date(2026,4,1), date(2026,5,1),
                          date(2026,4,10), date(2026,5,1))  # 66.67
charge_b = prorate_amount(Decimal("300"), date(2026,4,1), date(2026,5,1),
                          date(2026,4,10), date(2026,5,1))  # 200.00
delta = charge_b - credit_a  # 133.33

几个坑:

  • 按秒还是按天 prorate?云厂商多半按秒(AWS EC2 per-second billing),SaaS 通常按天。合同要白纸黑字写清楚。
  • Rounding 顺序:每一行各自 round 还是最后一次性 round?要和会计确认后写进 coding guideline。
  • 多币种:升级时换了币种怎么处理?一般规则是「只允许 upgrade 不换币种」,换币种要走「取消 + 新建」。

3.3 升降级的 Anchor Date

如果用户在 4 月 10 日订阅(anchor = 10 日),那么账期是 4/10–5/10、5/10–6/10……

几个常见决策: - 是否把 anchor 对齐到月初?:财务更喜欢对齐(好对账),用户不一定喜欢(因为第一期要么短、要么被 prorate)。 - 2 月 31 日问题:1 月 31 日订阅的,2 月 anchor 是哪天?惯例是「每月最后一天」,即 2 月 28/29、3 月 31、4 月 30。 - 跨时区 anchor:建议所有 anchor 保存为 UTC timestamp,按商户时区展示。闰秒在计费里实际很少引起问题(因为都 round 到天了),但测试环境一定要能注入「闰秒日 23:59:60」,避免出现 Java 某些版本 Instant 解析报错。

四、账单(Invoice)的生成与推送

4.1 账期触发与账单生成流水线

sequenceDiagram
    participant Cron as Billing Scheduler
    participant Agg as Usage Aggregator
    participant Eng as Rating Engine
    participant Tax as Tax Engine
    participant Inv as Invoice Service
    participant Pay as Payment Gateway
    participant Mail as Email/Notification

    Cron->>Agg: 关账期 (period cutoff)
    Agg-->>Cron: usage_summary
    Cron->>Eng: 生成 invoice draft
    Eng->>Eng: 合并 subscription + usage + proration
    Eng->>Eng: 应用 discount / coupon / credit
    Eng->>Tax: 计算税
    Tax-->>Eng: tax_lines
    Eng->>Inv: invoice_draft
    Inv->>Inv: 内部 QA/人工复核(可选)
    Inv-->>Mail: 推送 PDF/通知
    Inv->>Pay: 扣款(auto-collect)
    Pay-->>Inv: 支付结果
    Inv-->>Mail: 收据 / dunning

账单生成是个幂等、可重入、可回滚的流程。几点实现经验:

  • Draft / Finalized 两阶段:draft 可以修改、删除;finalized 不可变。finalized 后才分配发票号、才推送支付。
  • 每个订阅独立出账还是合并?大企业客户会要求「每个 BU 一张账单」,个人用户希望「全家桶合并一张」。数据模型里 invoicesubscription 是 1:N,不要锁死成 1:1。
  • 幂等键(customer_id, period_start, period_end) 做唯一索引,防止定时任务重复跑导致一个账期两张账单。
  • 预览(Upcoming Invoice):用户在月中改计划时,应该能看到「下期账单预计 X 元」。这需要把账单生成逻辑拆成纯函数,不要一路写库。

4.2 Dunning:支付失败的重试与催收

自动扣款失败是日常现象(信用卡到期、额度不足、银行 3DS 验证超时)。Dunning(催缴)策略一般长这样:

flowchart TD
    A[Invoice 到期扣款失败] --> B{第 1 次重试<br/>+3 天}
    B -->|成功| OK[Active]
    B -->|失败| C{第 2 次重试<br/>+5 天}
    C -->|成功| OK
    C -->|失败| D[邮件通知用户<br/>更新支付方式]
    D --> E{第 3 次重试<br/>+7 天}
    E -->|成功| OK
    E -->|失败| F[降级服务<br/>PastDue]
    F --> G{人工介入 / 最终通知<br/>+14 天}
    G -->|成功| OK
    G -->|失败| H[取消订阅<br/>Unpaid]
    H --> I[转催收 / 坏账核销]

重试策略有几种:

  • Smart Retry:根据信用卡 decline reason 决定——insufficient_funds 在月初工资日重试;expired_card 直接不重试,让用户主动换卡;do_not_honor 尝试不同地区的收单行。
  • Retry Schedule:经验值是「3–5–7 天」或「1–3–5–7–14 天」,不是越频繁越好;太频繁会被发卡行标记为可疑。
  • Grace Period:7–30 天不等。B2B 大客户一般长,消费者短。

Stripe 官方文档披露了他们的 “Smart Retries”:根据机器学习模型选择最可能成功的重试时间,公开披露可把回收率提高 10% 左右。

4.3 账单数据模型

-- 订阅
CREATE TABLE subscriptions (
    id               BIGINT PRIMARY KEY,
    customer_id      BIGINT NOT NULL,
    plan_id          BIGINT NOT NULL,
    plan_version     INT    NOT NULL,          -- 定价版本,不可变
    status           VARCHAR(16) NOT NULL,      -- trialing/active/past_due/...
    currency         CHAR(3) NOT NULL,
    anchor_at        TIMESTAMP NOT NULL,
    current_period_start TIMESTAMP NOT NULL,
    current_period_end   TIMESTAMP NOT NULL,
    cancel_at        TIMESTAMP,
    created_at       TIMESTAMP NOT NULL,
    updated_at       TIMESTAMP NOT NULL
);

-- 账单
CREATE TABLE invoices (
    id               BIGINT PRIMARY KEY,
    number           VARCHAR(32) UNIQUE,        -- 发票流水号,finalize 时分配
    customer_id      BIGINT NOT NULL,
    status           VARCHAR(16) NOT NULL,      -- draft/open/paid/void/uncollectible
    currency         CHAR(3) NOT NULL,
    period_start     TIMESTAMP NOT NULL,
    period_end       TIMESTAMP NOT NULL,
    subtotal         BIGINT NOT NULL,           -- 分
    discount_total   BIGINT NOT NULL DEFAULT 0,
    tax_total        BIGINT NOT NULL DEFAULT 0,
    total            BIGINT NOT NULL,
    amount_paid      BIGINT NOT NULL DEFAULT 0,
    amount_due       BIGINT NOT NULL,
    due_at           TIMESTAMP,
    finalized_at     TIMESTAMP,
    paid_at          TIMESTAMP,
    created_at       TIMESTAMP NOT NULL,
    UNIQUE KEY uk_customer_period (customer_id, period_start, period_end)
);

-- 账单明细
CREATE TABLE invoice_lines (
    id               BIGINT PRIMARY KEY,
    invoice_id       BIGINT NOT NULL,
    subscription_id  BIGINT,                    -- 可为 null(一次性费用)
    description      VARCHAR(255),
    quantity         DECIMAL(20,6),
    unit_amount      BIGINT,                    -- 分
    amount           BIGINT NOT NULL,           -- 分
    period_start     TIMESTAMP,
    period_end       TIMESTAMP,
    proration        BOOLEAN DEFAULT FALSE,
    metric           VARCHAR(64),               -- 仅 usage 行
    tax_rate_id      BIGINT,
    discount_ids     JSON,
    metadata         JSON
);

-- 用量事件(归档到 OLAP,这里只放日聚合)
CREATE TABLE usage_daily (
    customer_id      BIGINT NOT NULL,
    subscription_id  BIGINT NOT NULL,
    metric           VARCHAR(64) NOT NULL,
    day              DATE NOT NULL,
    quantity         DECIMAL(24,6) NOT NULL,
    dimensions       JSON,
    PRIMARY KEY (customer_id, subscription_id, metric, day)
);

-- 账期(显式物化有助于幂等)
CREATE TABLE billing_periods (
    subscription_id  BIGINT NOT NULL,
    seq              INT NOT NULL,              -- 第几期
    period_start     TIMESTAMP NOT NULL,
    period_end       TIMESTAMP NOT NULL,
    status           VARCHAR(16) NOT NULL,      -- open/closed/billed
    invoice_id       BIGINT,
    PRIMARY KEY (subscription_id, seq)
);

-- 税率
CREATE TABLE tax_rates (
    id               BIGINT PRIMARY KEY,
    jurisdiction     VARCHAR(64) NOT NULL,      -- 'DE'/'US-CA-SF'/'CN'
    tax_type         VARCHAR(32) NOT NULL,      -- VAT/GST/SALES_TAX/CT
    rate_bps         INT NOT NULL,              -- 基点:2000 = 20%
    inclusive        BOOLEAN NOT NULL,          -- 价内 / 价外
    effective_from   DATE NOT NULL,
    effective_to     DATE
);

几个设计点:

  • 金额全部存分(integer):不要存 decimal 的元。浮点绝对不行,参见第 2 篇
  • plan_version 显式字段:plans 表可以改,但 subscription 必须记住签约时的版本。
  • billing_periods 物化:不要每次用 date 函数算,显式存下来,方便幂等与审计。
  • invoice.number 必须连续递增:中国税法、欧盟 VAT Directive 都要求「发票号不得跳号」。做分布式生成时不能简单用雪花算法,要做「号段 + 事务」。

4.4 发票号的分配

# 伪代码:发票号段分配
class InvoiceNumberService:
    def __init__(self, db):
        self.db = db

    def allocate(self, country: str, series: str, year: int) -> str:
        # SELECT ... FOR UPDATE 串行化
        with self.db.transaction():
            row = self.db.query(
                "SELECT next_seq FROM invoice_number_seq "
                "WHERE country=%s AND series=%s AND year=%s FOR UPDATE",
                country, series, year,
            )
            seq = row["next_seq"]
            self.db.execute(
                "UPDATE invoice_number_seq SET next_seq = next_seq + 1 "
                "WHERE country=%s AND series=%s AND year=%s",
                country, series, year,
            )
        return f"{country}-{series}-{year}-{seq:08d}"

为什么不能用雪花/UUID?因为税务局要求「按时间顺序连续」。为什么不能提前批量分配号段?因为分配了没用会留空洞;欧盟 VAT 审计会让你解释每一个缺失号。

五、发票(Fapiao / Tax Invoice)

在中国之外,“invoice” 通常指上一节的账单。在中国大陆语境下,需要把账单(Invoice / 对账单)发票(Fapiao / 税务发票)严格区分开。

5.1 中国大陆:增值税发票

  • 增值税专用发票(专票):B2B 交易用,购方可以抵扣进项税。对开票方要求严格:客户必须有一般纳税人资格、提供完整税号、银行账户等信息。
  • 增值税普通发票(普票):B2C 或小规模纳税人用,不能抵扣。
  • 数字化电子发票(数电票):2021 年起推广,2023 年之后大范围铺开。取消了税控盘,由「乐企」「税务 UKey」等平台对接。
  • 税率:SaaS 服务属「信息技术服务」或「软件服务」,一般纳税人 6%,小规模纳税人 3%(阶段性减按 1%,以当年政策为准)。

工程对接要点:

  • 发票通常不是计费系统直接开,而是调用第三方服务(航信、百望、高灯、亿企赢)或对接省级电子税务局。
  • 开票接口是异步的(几分钟—几小时),要有状态机:requested → processing → issued → failed → reverted
  • 红冲(作废/退款)有严格流程:当月可作废,跨月必须红冲。红冲也是异步。
  • 一张 invoice(账单)可能对应多张 fapiao(一次开一张不够抬头长度、或按项目拆票)。模型上是 1:N。
sequenceDiagram
    participant U as 用户
    participant B as Billing
    participant F as 开票服务
    participant T as 税局/数电
    U->>B: 提交开票信息(抬头/税号)
    B->>F: 开票请求(idempotency_key)
    F->>T: 上传
    T-->>F: 发票代码/号码
    F-->>B: issued
    B-->>U: 发票 PDF/OFD
    U->>B: 申请退款
    B->>F: 红冲请求
    F->>T: 红冲
    T-->>F: 红冲完成
    F-->>B: reverted

5.2 海外:VAT Invoice

英国、欧盟、澳洲等地的「税务发票(Tax Invoice)」本质是一张合规的 PDF,自己生成即可,不需要走国家平台。但内容有硬性要求:

  • 开票方完整公司名、地址、税号(VAT number / ABN)
  • 购方名字、地址(B2B 还要购方 VAT 号)
  • 发票号(连续递增)、开票日期、供货日期(tax point)
  • 每一行:品名、数量、单价、税前金额、税率、税额
  • 合计:subtotal、tax total、grand total
  • 若为 Reverse Charge,应显式标注:"Reverse charge, VAT to be accounted for by the recipient"

这类发票无需提交到政府平台(除了个别要求 e-invoicing 的国家,如意大利 FatturaPA、法国 2026 年起强制 e-invoicing、沙特 ZATCA phase 2),生成好直接邮件/ Dashboard 下载即可。

5.3 发票 PDF 生成

工程实现一般有两种:

  1. HTML → PDF:Chromium headless(puppeteer / playwright)或 wkhtmltopdf。好处是模板维护方便,坏处是渲染一致性差、字体嵌入需配置。
  2. 直接用 PDF 库:Python reportlab、Go gofpdf、Java iText。好处是可控、二进制体积小,坏处是排版代码写起来冗长。

规模一大,PDF 生成会成为瓶颈。建议:

  • 异步化:finalize invoice → enqueue PDF 任务 → worker 拉取渲染 → 上传对象存储 → 写回 invoice.pdf_url
  • 模板引擎 + 字体预加载:中文字体 5–10MB,冷启动慢。
  • 打防篡改水印:简单做法是 PDF 里嵌入签名(PAdES),或发票号对应一个可验证的 URL/二维码。

六、出海税务:VAT / GST / Sales Tax 全景

对出海的 SaaS 公司来说,税务是一个一旦犯错就非常昂贵的坑。2015 年欧盟 VAT MOSS 规则正式实施、2018 年美国最高法院 South Dakota v. Wayfair 判决、2023 年起多国纷纷实施 “digital services tax”——近十年海外税务对 SaaS 越来越不友好,也越来越复杂。

下文旨在给出工程视角的税务决策树,不构成税务建议。跨境税务问题请咨询持牌税务顾问。

6.1 欧盟 VAT(含 OSS/IOSS)

  • 2015 年规则:电子服务按消费者所在国征税(destination principle),而不是供应者所在国。
  • MOSS → OSS:2021 年 Mini-One-Stop-Shop 扩展为 OSS(One-Stop-Shop),企业在一个成员国注册 OSS 即可统一申报全欧盟销售给消费者的 VAT。
  • B2B Reverse Charge:若购方是欧盟境内另一国的企业且提供了有效 VAT 号,供应方不收 VAT,而是由购方在自己国家做「反向征收」(自己申报销项同时抵扣进项)。这是欧盟单一市场的核心机制。
  • VIES 校验:购方提供的 VAT 号必须通过欧盟委员会 VIES 系统校验(API 可用)。

伪代码:

def eu_vat(seller_country, buyer_country, buyer_vat_number, amount):
    if buyer_country not in EU_COUNTRIES:
        return TaxResult(rate=0, note="non-EU, zero-rated for goods export rules")
    if buyer_country == seller_country:
        # 国内销售,按本国 VAT
        return TaxResult(rate=VAT_RATES[seller_country], note="domestic")
    if buyer_vat_number and vies_is_valid(buyer_country, buyer_vat_number):
        return TaxResult(rate=0, note="reverse charge")
    # B2C 跨境:OSS,按买方国 VAT
    return TaxResult(rate=VAT_RATES[buyer_country], note="OSS destination")

6.2 英国 VAT

2020 年 Brexit 之后,英国独立于欧盟 VAT 体系:

  • 英国本土 VAT 标准税率 20%。
  • B2B Reverse Charge 机制类似欧盟,但要验证 GB VAT 号(HMRC 有公开 API)。
  • 非英国境内企业卖 digital services 给英国消费者需注册 UK VAT(无起征点阈值)。
  • MTD(Making Tax Digital)要求通过认证软件申报。

6.3 美国 sales tax:nexus 体系

美国没有联邦增值税。sales tax 是 50 个州各自立法,加上县、市、特区,全美组合出 10,000+ 个独立税率。

  • Nexus:你必须在某州有「充分联系」才在该州有征税义务。传统上是「实体存在」(办公室、员工、仓库)。
  • Wayfair 判决(2018):允许各州对 remote sellers 征税。目前大部分州采用 经济 nexus 阈值:过去 12 个月销售额超 $100,000 或交易数超 200 单,就需要在该州注册 sales tax 并开始征收。
  • Source-based vs Destination-based:绝大多数州按买方所在地的税率(destination),少数州(如 AZ、TX 内州销售)按卖方所在地。
  • Product taxability:SaaS 在某些州应税(NY、TX、WA),某些州不应税(CA、FL 对 pure SaaS 一般免税,但 2026 年前后多州仍在频繁调整)。

真实情况是:几乎没有一家出海 SaaS 公司会自研美国 sales tax 引擎,而是接入 Avalara / TaxJar(现 Stripe 旗下)/ Vertex 这类专业服务,每一次 invoice 生成时调用它们的 API 拿税率。

6.4 其他主要司法辖区

国家/地区 税种 税率(截至 2026 Q2,以当年法规为准) 特点
日本 消費税 10%(标准)/ 8%(食品) 2023 年起适格請求書(Qualified Invoice)制度
澳大利亚 GST 10% 年销售额 A$75,000 以上强制注册
新西兰 GST 15% 对非居民数字服务商有强制注册阈值
新加坡 GST 9%(2024 起) Overseas Vendor Registration 制度
印度 GST 18%(SaaS) 需注册 GSTIN,B2B reverse charge 类似
巴西 ICMS + PIS/COFINS + ISS 多税种叠加 极其复杂,基本必须找本地服务商
沙特 VAT 15% ZATCA phase 2 e-invoicing 强制

6.5 税务引擎的工程架构

一个可用的海外税务引擎,至少要具备以下能力:

flowchart LR
    A[Invoice Draft] --> B[Tax Resolver]
    B --> C{客户画像}
    C -->|B2B + valid VAT| D[Reverse Charge]
    C -->|B2C 本国| E[本国税率]
    C -->|B2C 跨境 EU| F[OSS]
    C -->|US 客户| G[Avalara/TaxJar]
    C -->|其他国家| H[国家税率表]
    D --> I[生成 tax_lines]
    E --> I
    F --> I
    G --> I
    H --> I
    I --> J[写回 invoice]

关键点:

  1. Tax Determination 必须独立服务:不要嵌进 billing engine,否则税规则一变要重发计费代码。
  2. 税率要版本化:和价格一样,一张已开出的发票即使税率后来调整,也不能重算。
  3. VAT 号校验要缓存:VIES 偶尔挂机,要有降级策略(允许 soft-fail 但标记待复核)。
  4. Place of Supply 判定证据:欧盟要求保留两项非矛盾证据(IP 地址 + 账单地址 / 信用卡签发国 / SIM 归属地)以证明客户所在国,并保存 10 年。

七、定价引擎设计:规则、DSL 还是硬编码

7.1 三种实现方式对比

方案 硬编码 规则引擎(Drools 等) DSL(自研)
开发速度 起步最快 最慢
业务自助改价 不行 一般(需要业务懂语法) 好(可视化编辑)
可测试性 一般
性能 最好 较差
适用阶段 0–1,业务单一 1–10,规则多变 10+,有专职 pricing 团队

多数中型 SaaS 最终会落到一个折中方案:

  • Plan 结构用 DSL / JSON Schema 配置(如 {type:"graduated", tiers:[{up_to:1000, unit_price:10},...]})。
  • Coupon / Credit / Promotion 用规则引擎(条件 + 动作)。
  • 计算内核用纯函数 Go/Java/Rust,保证可单测、可重放、无副作用。

7.2 定价版本化:不可回溯

核心原则:任何已生效的订阅必须使用签约时的定价,后续任何改动都是新版本

CREATE TABLE plans (
    id          BIGINT PRIMARY KEY,
    code        VARCHAR(64) NOT NULL,
    version     INT NOT NULL,
    currency    CHAR(3) NOT NULL,
    spec        JSON NOT NULL,    -- 完整定价 DSL
    effective_from DATE NOT NULL,
    effective_to   DATE,
    created_at  TIMESTAMP NOT NULL,
    UNIQUE KEY uk_code_version (code, version)
);

改价的流程应该是:

  1. 新建 (code, version=N+1) 记录。
  2. 对老订阅:继续用 version=N。可选策略「老客户 X 个月内也涨价」→ 需要邮件通知、给机会取消,并且从 UI 告知。
  3. 新订阅:默认用 version=N+1
  4. 历史账单的重新出具(比如客户投诉多算了):只能用 version=N 重算。

7.3 促销与 Coupon 模型

CREATE TABLE coupons (
    id             BIGINT PRIMARY KEY,
    code           VARCHAR(64) UNIQUE NOT NULL,
    kind           VARCHAR(16) NOT NULL,        -- percent/fixed/free_trial/free_units
    value_bps      INT,                         -- percent=1000 表示 10%
    value_amount   BIGINT,                      -- fixed=多少分
    currency       CHAR(3),
    max_redemptions INT,
    redeemed_count  INT NOT NULL DEFAULT 0,
    valid_from     DATE,
    valid_to       DATE,
    applies_to     JSON,                         -- plan ids / products
    duration       VARCHAR(16),                  -- once/repeating/forever
    duration_in_months INT,
    metadata       JSON
);

CREATE TABLE customer_credits (
    id             BIGINT PRIMARY KEY,
    customer_id    BIGINT NOT NULL,
    amount         BIGINT NOT NULL,             -- 分(正=余额,负=欠)
    currency       CHAR(3) NOT NULL,
    source         VARCHAR(32),                 -- topup/refund/gift/sla
    applied_invoice_id BIGINT,                  -- 已用于哪张账单
    expires_at     TIMESTAMP,
    created_at     TIMESTAMP NOT NULL
);

Credits 本质是账户的「预收款余额」。它和发票、退款、坏账都有复杂互动(详见《复式记账工程化》):

  • 充值 Credits:Dr 银行 / Cr 预收账款
  • 用 Credits 付 invoice:Dr 预收账款 / Cr 主营业务收入 + 应交税费
  • Credits 过期:Dr 预收账款 / Cr 营业外收入(通常收入确认有特殊规则)。

八、计量聚合与定价的代码示例

8.1 Go:Graduated(阶梯累进)Rating 引擎

package billing

import "math/big"

type Tier struct {
    UpTo     *int64 // nil 表示无上限
    FlatFee  int64  // 每 tier 固定费(单位:分)
    UnitAmt  *big.Rat // 每单位价格(分)
}

type GraduatedPlan struct {
    Tiers []Tier
}

// Rate 计算 graduated 计费:usage 跨多个 tier 分段计算。
func (p *GraduatedPlan) Rate(usage int64) int64 {
    remaining := usage
    total := big.NewRat(0, 1)
    var prevCap int64

    for _, t := range p.Tiers {
        if remaining <= 0 {
            break
        }
        var take int64
        if t.UpTo == nil {
            take = remaining
        } else {
            cap := *t.UpTo - prevCap
            if remaining < cap {
                take = remaining
            } else {
                take = cap
            }
            prevCap = *t.UpTo
        }
        tierAmt := new(big.Rat).Mul(t.UnitAmt, big.NewRat(take, 1))
        total.Add(total, tierAmt)
        total.Add(total, big.NewRat(t.FlatFee, 1))
        remaining -= take
    }
    // 最后四舍五入到分
    num, denom := total.Num(), total.Denom()
    q := new(big.Int).Quo(num, denom)
    rem := new(big.Int).Mod(num, denom)
    two := big.NewInt(2)
    if new(big.Int).Mul(rem, two).Cmp(denom) >= 0 {
        q.Add(q, big.NewInt(1))
    }
    return q.Int64()
}

单测必须覆盖:

  • 恰好落在 tier 边界(9999 / 10000 / 10001)。
  • 单 tier、跨两个 tier、跨所有 tier。
  • 0 用量、负用量(正常不会出现,但测试要能 fail fast)。

8.2 Python:从事件到日聚合

from dataclasses import dataclass
from collections import defaultdict
from typing import Iterable

@dataclass(frozen=True)
class Event:
    event_id: str
    customer_id: str
    subscription_id: str
    metric: str
    quantity: float
    event_ts_ms: int

def aggregate_daily(events: Iterable[Event], seen_ids: set[str]) -> dict:
    """
    对事件按 (customer, subscription, metric, day) 聚合。
    seen_ids 用于幂等去重,外部传入(例如 Redis SET / RocksDB)。
    """
    buckets = defaultdict(float)
    for e in events:
        if e.event_id in seen_ids:
            continue
        seen_ids.add(e.event_id)
        day = e.event_ts_ms // (86400 * 1000)
        key = (e.customer_id, e.subscription_id, e.metric, day)
        buckets[key] += e.quantity
    return dict(buckets)

生产环境中 seen_ids 通常用 Bloom filter + Redis SET:Bloom filter 过滤大部分重复(99.9%),剩下的用 Redis SET 做精确去重。Bloom filter 带 1–7 天 TTL 滚动。

8.3 Python:Invoice 生成(简化版)

from decimal import Decimal
from datetime import date
from typing import List

def build_invoice(
    subscription,
    usage_summary: List[dict],
    proration_lines: List[dict],
    discounts: List[dict],
    credits: Decimal,
    tax_resolver,
    period_start: date,
    period_end: date,
) -> dict:
    lines = []

    # 1. 订阅固定费
    lines.append({
        "desc": f"{subscription['plan_code']} subscription",
        "quantity": 1,
        "unit_amount": subscription["flat_price_cents"],
        "amount": subscription["flat_price_cents"],
        "period_start": period_start,
        "period_end": period_end,
    })

    # 2. Proration 行(升降级)
    for p in proration_lines:
        lines.append(p)

    # 3. 用量行
    for u in usage_summary:
        amt = rate_graduated(u["plan"], u["quantity"])  # 见 8.1
        lines.append({
            "desc": f"{u['metric']} usage",
            "quantity": u["quantity"],
            "unit_amount": None,
            "amount": amt,
            "metric": u["metric"],
            "period_start": period_start,
            "period_end": period_end,
        })

    subtotal = sum(l["amount"] for l in lines)

    # 4. 折扣(pre-tax)
    discount_total = 0
    for d in discounts:
        if d["kind"] == "percent":
            cut = int(subtotal * d["value_bps"] / 10000)
        else:
            cut = d["value_amount"]
        discount_total += cut

    pre_tax = subtotal - discount_total

    # 5. 税
    tax_lines = tax_resolver.resolve(
        buyer=subscription["customer"],
        seller=subscription["seller_entity"],
        amount=pre_tax,
        lines=lines,
    )
    tax_total = sum(t["amount"] for t in tax_lines)

    total_with_tax = pre_tax + tax_total

    # 6. Credits(post-tax 冲抵)
    applied_credit = min(credits, Decimal(total_with_tax))
    amount_due = total_with_tax - int(applied_credit)

    return {
        "lines": lines,
        "subtotal": subtotal,
        "discount_total": discount_total,
        "tax_lines": tax_lines,
        "tax_total": tax_total,
        "total": total_with_tax,
        "credit_applied": int(applied_credit),
        "amount_due": amount_due,
        "period_start": period_start,
        "period_end": period_end,
        "status": "draft",
    }

注意几个顺序:

  • Discount 一定在 pre-tax 上做(除非合同另有约定)。
  • Tax 基于 post-discount 金额。
  • Credits 在 total-with-tax 上冲抵。

九、对接第三方:Stripe Billing、Chargebee、Recurly、Zuora

9.1 对比

平台 强项 弱项 典型客户
Stripe Billing 开发者体验最好、与 Stripe 支付一体化、Smart Retries、Tax 内建 企业级合同谈判、ERP 集成稍弱 中小 SaaS、开发者工具
Chargebee 订阅业务特性最丰富、多网关、多币种、多实体、发票定制 价格、仪表盘有时慢 成长期 SaaS,尤其 B2B
Recurly 信用卡回收率模型成熟、Revenue Recognition 强 国际化略弱 北美订阅媒体
Zuora 企业级、ASC 606 收入确认、复杂合同能力 贵、重、部署周期长 上市公司、复杂合同(电信、IoT)
Maxio (ex-Chartmogul + SaaSOptics) SaaS metrics + billing 整合 品牌认知 中型 SaaS 财务导向

9.2 国内为什么没有成熟的通用 SaaS 计费方案

可以列出几条原因:

  • SaaS 市场本身成熟度不够:国内 SaaS 营收规模相对较小(2023 年中国 SaaS 市场约为美国市场的十分之一,多家市场研究机构口径略有差异),通用计费平台的商业天花板不足以支撑一个「中国版 Stripe Billing」。
  • 发票体系的深度耦合:数电票对接是强本地化工作,每家公司的财务流程定制化极重。
  • 支付多样性:微信、支付宝、对公转账、分期、花呗、白条……真正能做到一站式接入的寥寥。
  • 合规个案多:每个行业(教育、医疗、跨境电商)都有自己的开票细则,通用产品难以覆盖。

结果是:国内 SaaS 企业大多自研计费 + 接入本地开票服务商(航信、百望、高灯),或者使用有赞、微盟等偏零售电商方向的订阅付费模块。

9.3 自研 vs SaaS:决策模型

评估 Stripe Billing / Chargebee 等 SaaS 计费产品 vs 自研,常见的误区是只比「License 费」。真正的成本结构至少要算上:

  • License 成本:Stripe Billing 按交易量收 0.4–0.7%(2026 Q2 官网披露,以签约条款为准)。当 GMV 上到千万美元级,年费就会显著。
  • 集成成本:用 Stripe 大约 1–2 个工程师季度;从零自研需要 4–6 人年起,且要踩遍后文「坑点」。
  • 二次开发成本:Stripe / Chargebee 的 webhook、metadata、扩展能力是有限的。一个罕见定价模式(如「承诺量 + 双阶梯 + 跨 region 合并」)SaaS 可能做不到。
  • 数据主权与锁定:一旦在 Stripe Billing 里积累了百万订阅记录,迁出成本极高——客户托管 token、发票历史、税务 ID、credits 都绑着。建议从第一天就把 Stripe 的对象镜像到自家库
  • 合规责任转移:Stripe Tax、TaxJar 承担 tax 计算正确性,出错由服务商兜底部分责任。自研出错由公司自己承担税务罚款。

决策原则简化为一句话:业务形态收敛时 SaaS 优先;业务形态仍在剧烈演化时留自研的接口

9.4 计费系统与财务系统的边界

一个容易混淆的问题:计费(Billing)、账务(Ledger/General Ledger)、收入确认(Revenue Recognition) 三者什么关系?

  • Billing:向客户收多少钱、什么时候收、开什么票。看客户。
  • Ledger:公司内部的复式记账,每笔经济业务记到科目。见第 3 篇。看公司资产负债表。
  • Rev Rec:GAAP (ASC 606) / IFRS 15 规定的收入确认规则——现金收到不等于收入确认。比如一次性预付 12 个月 $1200,现金当月入账,但收入每月按 $100 分摊确认。看利润表。

这三者在工程上应该是三套独立的数据流,由 billing 作为事件源向 ledger 和 rev rec 发事件:

flowchart LR
    B[Billing System] -->|invoice.finalized| L[Ledger]
    B -->|payment.succeeded| L
    B -->|invoice.finalized| R[Rev Rec Engine]
    R -->|journal entries| L
    L --> GL[总账 / 财务报表]

不要让 billing 系统直接生成会计分录;也不要让 ledger 倒推账单。

十、工程坑点(真实故事)

  1. 时区漂移:Billing 定时任务用了 JVM 默认时区,部署到新加坡机房 Asia/Singapore,所有账期偏移 8 小时;月底出账比合同晚了半天,大客户投诉「过期用量被算进下月」。修复:所有时间字段一律 UTC 存,仅展示层按 customer.display_tz 格式化;定时任务注解 @Scheduled(zone = "UTC")
  2. 跨月事件归属:Redis 延迟 30 分钟,一批 23:59 的事件到达 Kafka 已是 00:15,被归到次月。修复:以 event_ts(业务时间)归属账期,不用 ingest_ts。账期结账必须显式等待 watermark(常用 6h)。
  3. 多币种:客户把账单货币从 USD 换到 EUR,历史 credits 是 USD 记账,抵扣时按「实时汇率」还是「入账汇率」?选择:保留原币种 credits,展示时给出「按当前汇率约合 X 元」,实际结算时按冻结汇率(ECB/PBoC 中间价 T-1)。
  4. 退款对 invoice 的影响:全额退款 → invoice 整单作废还是保留+挂 Credit Note?推荐:保留原 invoice,生成 Credit Note 冲销;对账更清晰,中国红冲制度也要求这么做。
  5. Delayed Usage:海外 region 的边缘节点断网 3 天,3 天的用量集中在恢复后涌入。直接塞进当月会导致突刺,还可能跨了账期。解决:按 event_ts 分发到正确账期;已关账的账期产生的用量自动挂「Prior Period Adjustment」到下期账单明细。
  6. Rounding 悖论:10 个用户每人 $9.999 / 月,分别 round 是 10.00,合计 100.00;整单合计 99.99 后 round 还是 99.99。规则:逐行 round,subtotal 为各行之和;绝不能「先加总再 round」,否则会和明细对不上。
  7. 发票号跳号:finalize 时分配了号,PDF 生成失败回滚;下次重试又分配了新号,空档出来一个。修复:号段只分配一次;PDF 失败不影响号段;中国口径下缺口需要年底向税局报备或红冲冲销。
  8. Plan 改名引起的账单迷惑:市场把 “Pro” 改名为 “Business”,工程师在 plans.code 就地 update。老客户账单开出来品名和合同不符。修复plans.display_name 字段和 code 分离;code 不可变。
  9. 促销叠加爆炸:coupon A 八折、coupon B 减 50、credits 余额 200,用户按不同顺序组合,产生四种不同 total。修复:产品侧明确「单个 invoice 仅可用一个 coupon」;credits 在最后一步、对税后金额冲抵。所有折扣计算在服务端,前端只展示。
  10. 数据保留合规:欧盟 VAT 要求发票保存 10 年,中国税法至少 30 年(账簿、凭证),GDPR 又要求用户注销后删除 PII。妥协:PII 可擦除,但发票上的「抬头、税号、地址」保留(有税务抗辩法律依据)。

十一、选型与落地清单

起步阶段(0–10k MRR): - 直接用 Stripe Billing + Stripe Tax,出海侧几乎全覆盖。 - 国内侧用微信/支付宝常规接入 + 第三方开票。 - 不自研计费引擎。

成长阶段(10k–1M MRR): - 用 Chargebee / Stripe Billing 作为计费 SaaS,但自己存一份 shadow DB:订阅、发票、事件全量拷贝到自家库,方便对账、报表、审计。 - 海外税交给 Avalara / TaxJar。 - 自研 metering(raw events + Flink 聚合),只把聚合结果推给 Stripe/Chargebee。

规模阶段(1M MRR 以上): - 自研 rating engine、invoice engine,Stripe 仅做支付网关。 - 引入专业 Revenue Recognition 工具(如 Sage Intacct、NetSuite、Zuora Revenue)。 - 税务用 Avalara/Vertex,但内部有 fallback 税率表。 - 审计 trail:每一次定价修改、手工账单调整都要有记录,走审批流。

落地检查表

  • 金额全部用整数(分),严禁浮点。
  • 所有时间戳 UTC,展示层再转时区。
  • 定价 plans 版本化,subscription.plan_version 落地。
  • Invoice number 连续、不可跳号,按国家/年独立号段。
  • Draft / Finalized 两阶段,finalized 不可变。
  • 退款走 Credit Note,不改原 invoice。
  • 幂等键覆盖:metering event、invoice、payment、refund、invoice number allocate。
  • 试算环境能重放某账期:给定事件流 + plan 版本 → 能生成一致的 invoice(bit-exact)。
  • 税务 fallback:税务服务挂掉能降级(缓存 + 标记待复核)。
  • Dunning 流程 + 降级策略。
  • PII 在 GDPR 注销时可擦除、发票核心字段保留。
  • 对账:Billing → Ledger → 支付渠道三方对账日终闭环,见《对账系统》

十二、与本系列其他文章的交叉引用

十三、参考资料


上一篇《支付网关设计:路由、限流、补单、异步通知、签名与防重放》

下一篇《清算 vs 结算 vs 资金归集:T+0/T+1、NDS、PvP/DvP》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。

2026-04-22 · architecture / fintech

【金融科技工程】幂等、事务与一致性:SAGA、TCC、对账补偿

支付与账务系统里,"这笔操作能不能重放一遍"几乎是每一次故障复盘都会问到的问题。本文从网络重试的本质谈起,讲清楚幂等(idempotency)的三层设计、Idempotency-Key 的工程细节、订单状态机的落库方式,并横向对比 2PC、TCC、SAGA、可靠消息四种分布式事务方案,配合 Outbox Pattern、CDC、补偿策略与真实事故案例,给出一份可以直接落地的检查清单。

2026-04-22 · architecture / fintech

【金融科技工程】09 支付网关设计:路由、限流、补单、异步通知、签名与防重放

从业务系统到支付宝、微信、银联、Visa、Stripe、Airwallex,中间这一层"支付网关"承担了路由、限流、熔断、补单、签名、异步通知分发等几乎所有脏活累活。本文系统整理一个自研或半自研支付网关的工程设计,包括数据模型、双状态机、路由策略、密钥管理与可观测性。