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



























本文为【金融科技工程】系列第 10 篇。前一篇讨论了支付网关如何把一次「扣款」安全送达,本篇向前推一步:在什么规则下计算「应扣多少」、以什么形式「开出账单和发票」、如何把「出海的税」算对。
如果说支付解决的是「钱如何安全流转」,那么订阅与计费解决的是「在复杂的商业规则下,该收多少、向谁收、什么时候收、开什么票」。对大多数工程师来说,接支付接口是一个有限问题——文档、签名、回调、对账,半年之内能做到熟练。但计费系统不一样:它是产品、销售、财务、税务、法务、合规同时下场的系统,是公司对外「商业模式」的直接投射。每一次打折、每一次涨价、每一次加入一个新的国家市场、每一次推出一个新的用量维度,都会在计费系统里落下一块伤疤。
几个具体的难点:
这篇文章面向的是「计划或正在构建 SaaS、API、云服务计费系统」的工程团队——可能你们正在评估 Stripe Billing 和自研的取舍,可能你们正在把产品卖到第一个海外国家,也可能你们被财务催着解决「发票号跳号」的陈年老账。下文不会教你「怎么设计一张漂亮的定价页」,而是把引擎盖打开,看看 billing engine 内部这台机器是怎么运转的。
在设计数据模型之前,先把业界常见的计费形态梳理清楚。不同计费形态决定了订阅记录、用量记录、账单生成时机完全不同。
| 模型 | 中文 | 典型产品 | 计算逻辑 |
|---|---|---|---|
| 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 的测试用例和销售谈出来的数永远对不上。
正交于计费模型的另一个维度是「先付还是后付」:
很多公司会混用:小额账户 postpaid(坏账风险可控),大企业账户 Annual Prepaid(一次性预付一年,给折扣),超高用量用户可能同时有 Commitment(承诺量预付)+ Overage(超量后付)。
真实的账单不是「单价 × 数量」那么简单,至少有四层价格变换:
顺序不能搞反。工程上常见的错是「先减 credits 再算税」,结果税务口径下应纳税额偏低,审计时被认定为「漏税」。正确的顺序是先在 pre-tax 金额上做折扣,然后基于 post-discount 金额计算税,最后用 credits 冲抵 total-with-tax。Stripe、Chargebee 的实现都遵循这个顺序。
对于 usage-based 计费,计量系统是整个计费链路的最上游,也是最容易出错的地方。一次「漏计」或「重复计」在单次调用里看不出来,在月底账单上会被放大 N 万倍。
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; // 上游幂等键
}几个设计要点:
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
event_id 用
RocksDB state 或 Redis SET 做 windowed dedup;窗口一般 24–72
小时,覆盖大部分重传场景。[Prior Period Adjustment] 明细出现。很多团队只用一个 ClickHouse 就想解决所有问题,跑一段时间会遇到:
近 1 小时 / 1 分钟粒度
的实时曲线 → 时序库强项。按账期 × 账户 × metric × dimension 的聚合结果 →
OLAP 强项。拆成两条链路更合理:
| 用途 | 存储 | 粒度 | 保留期 |
|---|---|---|---|
| 实时 Dashboard | Prometheus / Druid | 秒—分钟 | 30–90 天 |
| 计费聚合结果 | Postgres / TiDB | 小时/天 + 账期 | 永久 |
| 原始事件 | S3/OSS + ClickHouse | 单条 | 13–36 个月 |
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 的网关)宕机延迟上报,都可能引起账单重算。如果你做一个号称「实时精确」的计费系统,反而会在第一次出故障时陷入巨大的对账噩梦。
计量聚合的最高原则是可重放(Replayable):给定一批原始事件和一份定价版本,系统任意时刻重跑都应该得到一模一样的 invoice line。这个性质在以下场景救命:
为了做到这点:
as_of 时间戳。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 --> [*]
几个关键状态的业务含义:
假设用户在账期的第 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几个坑:
如果用户在 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
解析报错。
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
账单生成是个幂等、可重入、可回滚的流程。几点实现经验:
invoice 和 subscription 是
1:N,不要锁死成 1:1。(customer_id, period_start, period_end)
做唯一索引,防止定时任务重复跑导致一个账期两张账单。自动扣款失败是日常现象(信用卡到期、额度不足、银行 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[转催收 / 坏账核销]
重试策略有几种:
insufficient_funds
在月初工资日重试;expired_card
直接不重试,让用户主动换卡;do_not_honor
尝试不同地区的收单行。Stripe 官方文档披露了他们的 “Smart Retries”:根据机器学习模型选择最可能成功的重试时间,公开披露可把回收率提高 10% 左右。
-- 订阅
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
);几个设计点:
plan_version
显式字段:plans 表可以改,但 subscription
必须记住签约时的版本。billing_periods
物化:不要每次用 date
函数算,显式存下来,方便幂等与审计。invoice.number
必须连续递增:中国税法、欧盟 VAT Directive
都要求「发票号不得跳号」。做分布式生成时不能简单用雪花算法,要做「号段
+ 事务」。# 伪代码:发票号段分配
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 审计会让你解释每一个缺失号。
在中国之外,“invoice” 通常指上一节的账单。在中国大陆语境下,需要把账单(Invoice / 对账单)和发票(Fapiao / 税务发票)严格区分开。
工程对接要点:
requested → processing → issued → failed → reverted。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
英国、欧盟、澳洲等地的「税务发票(Tax Invoice)」本质是一张合规的 PDF,自己生成即可,不需要走国家平台。但内容有硬性要求:
"Reverse charge, VAT to be accounted for by the recipient"这类发票无需提交到政府平台(除了个别要求 e-invoicing 的国家,如意大利 FatturaPA、法国 2026 年起强制 e-invoicing、沙特 ZATCA phase 2),生成好直接邮件/ Dashboard 下载即可。
工程实现一般有两种:
reportlab、Go gofpdf、Java
iText。好处是可控、二进制体积小,坏处是排版代码写起来冗长。规模一大,PDF 生成会成为瓶颈。建议:
invoice.pdf_url。对出海的 SaaS 公司来说,税务是一个一旦犯错就非常昂贵的坑。2015 年欧盟 VAT MOSS 规则正式实施、2018 年美国最高法院 South Dakota v. Wayfair 判决、2023 年起多国纷纷实施 “digital services tax”——近十年海外税务对 SaaS 越来越不友好,也越来越复杂。
下文旨在给出工程视角的税务决策树,不构成税务建议。跨境税务问题请咨询持牌税务顾问。
伪代码:
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")2020 年 Brexit 之后,英国独立于欧盟 VAT 体系:
美国没有联邦增值税。sales tax 是 50 个州各自立法,加上县、市、特区,全美组合出 10,000+ 个独立税率。
真实情况是:几乎没有一家出海 SaaS 公司会自研美国 sales tax 引擎,而是接入 Avalara / TaxJar(现 Stripe 旗下)/ Vertex 这类专业服务,每一次 invoice 生成时调用它们的 API 拿税率。
| 国家/地区 | 税种 | 税率(截至 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 强制 |
一个可用的海外税务引擎,至少要具备以下能力:
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]
关键点:
| 方案 | 硬编码 | 规则引擎(Drools 等) | DSL(自研) |
|---|---|---|---|
| 开发速度 | 起步最快 | 中 | 最慢 |
| 业务自助改价 | 不行 | 一般(需要业务懂语法) | 好(可视化编辑) |
| 可测试性 | 好 | 一般 | 好 |
| 性能 | 最好 | 较差 | 好 |
| 适用阶段 | 0–1,业务单一 | 1–10,规则多变 | 10+,有专职 pricing 团队 |
多数中型 SaaS 最终会落到一个折中方案:
{type:"graduated", tiers:[{up_to:1000, unit_price:10},...]})。核心原则:任何已生效的订阅必须使用签约时的定价,后续任何改动都是新版本。
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)
);改价的流程应该是:
(code, version=N+1) 记录。version=N。可选策略「老客户 X 个月内也涨价」→
需要邮件通知、给机会取消,并且从 UI 告知。version=N+1。version=N 重算。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 本质是账户的「预收款余额」。它和发票、退款、坏账都有复杂互动(详见《复式记账工程化》):
Dr 银行 / Cr 预收账款。Dr 预收账款 / Cr 主营业务收入 + 应交税费。Dr 预收账款 / Cr 营业外收入(通常收入确认有特殊规则)。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()
}单测必须覆盖:
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 滚动。
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 | 开发者体验最好、与 Stripe 支付一体化、Smart Retries、Tax 内建 | 企业级合同谈判、ERP 集成稍弱 | 中小 SaaS、开发者工具 |
| Chargebee | 订阅业务特性最丰富、多网关、多币种、多实体、发票定制 | 价格、仪表盘有时慢 | 成长期 SaaS,尤其 B2B |
| Recurly | 信用卡回收率模型成熟、Revenue Recognition 强 | 国际化略弱 | 北美订阅媒体 |
| Zuora | 企业级、ASC 606 收入确认、复杂合同能力 | 贵、重、部署周期长 | 上市公司、复杂合同(电信、IoT) |
| Maxio (ex-Chartmogul + SaaSOptics) | SaaS metrics + billing 整合 | 品牌认知 | 中型 SaaS 财务导向 |
可以列出几条原因:
结果是:国内 SaaS 企业大多自研计费 + 接入本地开票服务商(航信、百望、高灯),或者使用有赞、微盟等偏零售电商方向的订阅付费模块。
评估 Stripe Billing / Chargebee 等 SaaS 计费产品 vs 自研,常见的误区是只比「License 费」。真正的成本结构至少要算上:
决策原则简化为一句话:业务形态收敛时 SaaS 优先;业务形态仍在剧烈演化时留自研的接口。
一个容易混淆的问题:计费(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 倒推账单。
Asia/Singapore,所有账期偏移 8
小时;月底出账比合同晚了半天,大客户投诉「过期用量被算进下月」。修复:所有时间字段一律
UTC 存,仅展示层按 customer.display_tz
格式化;定时任务注解
@Scheduled(zone = "UTC")。event_ts(业务时间)归属账期,不用
ingest_ts。账期结账必须显式等待 watermark(常用
6h)。event_ts
分发到正确账期;已关账的账期产生的用量自动挂「Prior Period
Adjustment」到下期账单明细。plans.code 就地
update。老客户账单开出来品名和合同不符。修复:plans.display_name
字段和 code 分离;code
不可变。起步阶段(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:每一次定价修改、手工账单调整都要有记录,走审批流。
落地检查表:
plans
版本化,subscription.plan_version
落地。上一篇:《支付网关设计:路由、限流、补单、异步通知、签名与防重放》
下一篇:《清算 vs 结算 vs 资金归集:T+0/T+1、NDS、PvP/DvP》
把当前热点继续串成多页阅读,而不是停在单篇消费。
2026-04-22 · architecture / fintech
金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。
2026-04-22 · architecture / fintech
支付与账务系统里,"这笔操作能不能重放一遍"几乎是每一次故障复盘都会问到的问题。本文从网络重试的本质谈起,讲清楚幂等(idempotency)的三层设计、Idempotency-Key 的工程细节、订单状态机的落库方式,并横向对比 2PC、TCC、SAGA、可靠消息四种分布式事务方案,配合 Outbox Pattern、CDC、补偿策略与真实事故案例,给出一份可以直接落地的检查清单。
2026-04-22 · architecture / fintech
从业务系统到支付宝、微信、银联、Visa、Stripe、Airwallex,中间这一层"支付网关"承担了路由、限流、熔断、补单、签名、异步通知分发等几乎所有脏活累活。本文系统整理一个自研或半自研支付网关的工程设计,包括数据模型、双状态机、路由策略、密钥管理与可观测性。
2026-04-22 · architecture / fintech
深入剖析中央限价订单簿(CLOB)撮合引擎的数据结构与算法,覆盖价格时间优先、Pro-Rata、订单类型、自成交预防、集合竞价、确定性回放、WAL 快照、单线程事件循环与 Disruptor 模型,并给出 Rust/Go 简化实现与单元测试清单。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。