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

【金融科技工程】订阅与计费系统:用量计量、账单、发票、出海 VAT/GST

文章导航

分类入口
architecturefintech
标签入口
#billing#subscription#metering#usage-based#invoice#vat#gst#sales-tax#stripe-billing#saas#dunning

目录

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

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

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

几个具体的难点:

这篇文章面向的是「计划或正在构建 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 调用的例子:

用户用了 50,000 次

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

1.2 Prepaid vs 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; // 上游幂等键
}

几个设计要点:

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

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

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

拆成两条链路更合理:

用途 存储 粒度 保留期
实时 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。这个性质在以下场景救命:

为了做到这点:

  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 --> [*]

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

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

几个坑:

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

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

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[转催收 / 坏账核销]

重试策略有几种:

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
);

几个设计点:

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 中国大陆:增值税发票

工程对接要点:

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,自己生成即可,不需要走国家平台。但内容有硬性要求:

这类发票无需提交到政府平台(除了个别要求 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 生成会成为瓶颈。建议:

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

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

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

6.1 欧盟 VAT(含 OSS/IOSS)

伪代码:

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 体系:

6.3 美国 sales tax:nexus 体系

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

真实情况是:几乎没有一家出海 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 最终会落到一个折中方案:

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

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

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()
}

单测必须覆盖:

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",
    }

注意几个顺序:

九、对接第三方: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 企业大多自研计费 + 接入本地开票服务商(航信、百望、高灯),或者使用有赞、微盟等偏零售电商方向的订阅付费模块。

9.3 自研 vs SaaS:决策模型

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

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

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

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

这三者在工程上应该是三套独立的数据流,由 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:每一次定价修改、手工账单调整都要有记录,走审批流。

落地检查表

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

十三、参考资料


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

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

同主题继续阅读

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

2026-04-22 · architecture / fintech

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

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

2026-04-22 · architecture / fintech

【金融科技工程】复式记账工程化:科目、分录、余额、对账

把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。

2026-04-22 · architecture / fintech

【金融科技工程】账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户

账务(Ledger)数据库是金融系统最硬的那块骨头。本文从 RPO/RTO 目标出发,对比 PostgreSQL、MySQL、OceanBase、TiDB、CockroachDB、Oracle、TigerBeetle 等主流选型,讲分片维度、热点账户拆解、索引设计、冷热归档、MVCC 并发控制与审计合规,辅以蚂蚁、Stripe、PayPal、Square 的真实演进路径。


By .