Herald 计费架构
Herald 的计费系统不维护本地商品目录。Stripe、Creem 这些支付方管理 Product、Price、Checkout 和订阅生命周期;Herald 只保留做授权和积分发放所需的最小数据。
Herald 的计费系统不维护本地商品目录。Stripe、Creem 这些支付方管理 Product、Price、Checkout 和订阅生命周期;Herald 只保留做授权和积分发放所需的最小数据。
给谁看
需要理解 Herald 计费模块设计的开发者。不涉及具体支付方的对接操作,对接指南见各支付方的单独文档。
核心概念
积分账户
积分账户是积分的隔离单元。一个 Realm 可以建多个积分账户,各个账户的余额互相独立、互不影响。
它解决的问题是:一个 Realm 下可能同时跑多条业务线,比如一个 AI 对话应用和一个图片生成应用,它们的积分要分开结算、分开消费,不能串。每个积分账户覆盖一组 Client App,只有被覆盖的 Client App 才能消费这个池的积分。
每个积分账户的字段:
| 字段 | 说明 |
|---|---|
bucket_key | 标识,匹配 ^[a-z0-9-]{1,64}$,Realm 内唯一 |
name | 展示名 |
display_order | 展示顺序 |
enabled | 是否启用。禁用后对新用户不可见、不可购;已持有该账户的用户仍能消费剩余积分 |
receives_registration_credits | 是否是本 Realm 的注册积分接收账户 |
注册积分接收池:每个 Realm 至多一个积分账户标为 receives_registration_credits,数据库用部分唯一索引 uq_credit_buckets_registration_pool 保证唯一。用户注册时发放的积分、免费的周期性积分,都进这个账户。如果 Realm 没配接收池,这些系统发放的积分无处可去,直接不发放——不会偷偷落到某个隐式默认账户。Herald 没有"默认积分账户"这个概念,每一个积分账户都必须是管理员显式建出来的。
积分账户目录的管理 API 在 /api/realms/{realmId}/billing/credit-buckets 下,包含列表、详情、创建、更新、删除,外加一个 overview 接口返回每个积分账户 × 每种积分类型的余额矩阵和跨账户合计。全部需要 Realm Admin 的 points.manage 权限。
几个会直接报错而不是静默处理的约束:
- 覆盖集不能为空。创建和更新都校验,空集返回 400。
- 从一个积分账户移除已挂载的映射会被拒绝,返回
bucket_orphan_mapping。要移动映射,先把它挂到目标积分账户。 - 删除积分账户时,如果还有活跃订阅、或有余额的钱包,返回 409
bucket_in_use。 - 改覆盖集只影响未来的路由,不回收已经发出去的余额。
池里的积分按来源分五种类型,余额页和后台概览都按这五类分别统计。积分账户概览矩阵和用户余额都沿这五个 key 展开:
| key | 含义 |
|---|---|
topup | 充值积分(用户主动购买) |
subscription | 会员积分(订阅套餐赠送) |
registration | 注册积分(注册时一次性赠送) |
free_periodic | 免费周期积分(按周期自动发放) |
granted | 主动发放积分(管理员或 SDK 发放) |
Entitlement Mapping
Entitlement Mapping 是一张映射表,把支付方的外部商品 ID 映射到 Herald 内部的 entitlement_key,同时归属到一个积分账户。它不是商品目录,是 allowlist 加同步缓存。
每条映射包含:
| 字段 | 说明 |
|---|---|
payment_provider | 支付方名称(stripe、creem) |
external_product_id | 支付方侧的商品 ID(如 prod_xxxx) |
external_price_id | 支付方侧的价格 ID(Stripe 有,Creem 不适用) |
entitlement_key | Herald 内部的权益标识(如 pro-plan) |
bucket_id | 归属的积分账户。购买该商品后积分进入这个积分账户的池 |
billing_type | 计费类型:Recurring 或 OneTime |
points_per_period | 每个周期发放的积分数 |
grant_on_subscribe | 首次订阅是否发放积分 |
validity_days | 积分有效期(天) |
enabled | 是否启用。禁用后 webhook 仍更新订阅投影,但不触发积分发放 |
映射里还带一份从支付方同步过来的展示信息(provider_product_info),只读,用来在管理后台展示商品原貌,不参与计费:
| 字段 | 说明 |
|---|---|
| 产品名 | 列表和分组的主标签优先用它。缺失时回退到外部产品 id,再不行给占位,不显示空标签 |
| 价格 | 展示时按支付方区分单位。Stripe 同步的是最小货币单位整数(例如 999 表示 9.99),换算成主货币单位展示;Creem 直接用原值(字符串如 "9.99"),不再统一除以 100。单位换算由支付方来源驱动,不跨支付方共用同一条换算分支 |
| 币种 | 商品币种,跟随同步覆盖 |
| 计费类型 | recurring / one_time,来自支付方 |
| 计费周期 | Stripe 取 Price.recurring.interval(day/week/month/year),只读,不接受人工输入,重新同步以 Stripe 当前值为准。Creem 取其产品响应里的 billing_period(形如 every-month,前端做文案映射),缺失时显示"—",不伪造 |
| metadata | 同步 Stripe Product.metadata 和 Price.metadata(商户自定义键值对),只读展示。Creem Product 无原生 metadata,按空处理,不伪造 |
展示信息每次重新同步以支付方当前值为准覆盖本地;entitlement_key、points、grant 策略、quota_windows 等业务字段不被同步覆盖。
计费周期(来自 Stripe)和额度滚动窗口(quota_windows,如 5 小时 / 周)是两个独立概念,刻意解耦。计费周期决定 Stripe 何时扣款、何时发续费 webhook;额度窗口决定用户在滚动窗口内可用多少额度,两者不要求相等、不要求整除、不做对齐。长计费周期(年付)里仍可配滚动限额(月窗 / 周窗)。额度授予由续费 webhook 事件驱动,按 provider 返回的 (period_start, period_end) 锚定一期权益,不预发整周期总额、不按日历固定日清零。
映射通过两种方式产生:
- 管理员手动同步:调用支付方 Product API,拉取所有商品,自动创建或更新映射。新建的映射先绑定到本 Realm 的注册接收池,之后管理员可以把它挪到别的积分账户。
- Webhook 增量更新:支付事件触发时从 webhook payload 提取信息更新缓存。
同步失败时本地缓存继续服务,不会静默降级。
Subscription Projection
Subscription 是支付方订阅状态的本地只读副本,不是 Herald 拥有的订阅。SDK 和授权查询读取这个投影,不依赖实时支付方 API。
投影字段包括 realm_id、user_id、entitlement_key、bucket_id、status、current_period_start/end、provider_metadata 等。订阅在建立时就绑定到购买时那个 Entitlement Mapping 所属的积分账户,之后续费、升降级、取消、退款的积分发放和回收都回到同一个池,不会串到别的积分账户。需要计费周期或档位信息时,从 entitlement_key 或 provider_metadata 派生。
订阅状态:Active、Trialing、PastDue、Canceled、Expired、Paused、Disputed、ScheduledCancel、Incomplete。
has_access() 方法判断用户是否有权限:Active 和 Trialing 状态返回 true,其他返回 false。
支付尝试(Payment Attempt)
支付尝试是记录每一次成功支付的统一账本,覆盖一次性购买、订阅首期和订阅续费。订阅续费以前只更新订阅周期和积分,不留下"这次支付"的记录;现在每次成功续费也写一条已成功的支付尝试,并作为外部发票的本地归属锚点。每张第三方同步的发票都能直接定位到它归属的支付尝试或订阅。零金额周期(100% 折扣 / 免费档)不产生续费支付尝试。归属和异常发现的细节见发票管理。
Metadata 契约
所有 Herald 使用的 metadata key 统一用 herald_ 前缀。这些 metadata 写入 Checkout Session 或 Subscription,支付方在 webhook 中原样返回。
必填 metadata:
| Key | 说明 |
|---|---|
herald_realm_id | Realm ID |
herald_client_app_id | Client App ID |
herald_user_id | 用户 ID |
herald_entitlement_key | 权益标识 |
herald_billing_kind | subscription 或 points_package |
Checkout 创建时验证这些 metadata,缺失则拒绝创建。Webhook 收到事件后按以下顺序解析 entitlement_key:
- Webhook metadata 中的
herald_entitlement_key - 本地映射表(按 provider + external_product_id 查询)
- 都找不到则记录错误,不静默跳过
积分策略
积分策略的 source of truth 是 Herald 本地的 Entitlement Mapping,不是支付方的 metadata。Stripe 的 Product.metadata 和 Price.metadata 会跟随产品同步进入展示信息(仅 Stripe 适用),Herald 端只读,不回写、不编辑。Creem 无原生 metadata 功能,必须在 Herald 中手动配置。
策略按 entitlement_key 查询,覆盖:首次订阅发放、续费发放、取消回收、退款回收、升降级处理。发放和回收都定位到订阅绑定的积分账户池,按上面五种 credit_type 分别入账。
管理员可以在 Herald 中修改积分策略。修改的是 Herald 的业务规则,不会回写到支付方。
数据流向
用户支付流程:
- 前端调用 Checkout API,传入
entitlement_key和payment_provider - Herald 查 Entitlement Mapping,找到对应的外部商品和它归属的积分账户
- Herald 调用支付方 API 创建 Checkout Session,metadata 写入
herald_*字段 - 用户在支付方页面完成付款
- 支付方发 Webhook 给 Herald
- Herald 从 metadata 解析 entitlement_key,创建/更新 Subscription Projection,绑定到映射的积分账户
- Herald 按 entitlement_key 查积分策略,把积分发到该积分账户的池,或从该池回收
SDK 消费怎么路由
第三方应用通过 SDK 消费积分时只传 Client App 和金额,不感知积分账户。Herald 找到所有覆盖这个 Client App 的积分账户池,按过期时间从近到远跨池扣减,原子完成、不超额。如果没有任何覆盖的池、或覆盖的池合计余额不足,消费被拒绝并返回明确的余额不足提示。积分账户没覆盖这个 Client App 时,它的池不会被扣减。
支持的支付方
| 支付方 | 商业目录 | 订阅 | 一次性购买(积分包) | Metadata 支持 |
|---|---|---|---|---|
| Stripe | Product / Price API | Checkout Session + Subscription | Payment Intent | Product/Price/Checkout/Subscription 均支持 |
| Creem | Product API | Checkout | Checkout | Checkout metadata,webhook 返回 |
Stripe、Creem 是发起式平台,通过 PaymentAttempt 统一管理支付过程。
相关文档
- 积分账户用户故事 — 积分账户目录、覆盖集、购买入池、跨池消费、订阅生命周期的验收场景
- Stripe 对接指南 — Stripe 支付方配置和 Webhook 处理
- Creem 对接指南 — Creem 支付方配置和 Webhook 处理
- 发票管理 — 发票创建、开票、PDF 生成