# Herald 计费架构 (/zh/docs/billing-overview)



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]

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)` 锚定一期权益，不预发整周期总额、不按日历固定日清零。

映射通过两种方式产生：

1. **管理员手动同步**：调用支付方 Product API，拉取所有商品，自动创建或更新映射。新建的映射先绑定到本 Realm 的注册接收池，之后管理员可以把它挪到别的积分账户。
2. **Webhook 增量更新**：支付事件触发时从 webhook payload 提取信息更新缓存。

同步失败时本地缓存继续服务，不会静默降级。

### Subscription Projection [#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） [#支付尝试payment-attempt]

支付尝试是记录每一次成功支付的统一账本，覆盖一次性购买、订阅首期和订阅续费。订阅续费以前只更新订阅周期和积分，不留下"这次支付"的记录；现在每次成功续费也写一条已成功的支付尝试，并作为外部发票的本地归属锚点。每张第三方同步的发票都能直接定位到它归属的支付尝试或订阅。零金额周期（100% 折扣 / 免费档）不产生续费支付尝试。归属和异常发现的细节见[发票管理](/docs/billing-invoice)。

### Metadata 契约 [#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：

1. Webhook metadata 中的 `herald_entitlement_key`
2. 本地映射表（按 provider + external\_product\_id 查询）
3. 都找不到则记录错误，不静默跳过

### 积分策略 [#积分策略]

积分策略的 source of truth 是 Herald 本地的 Entitlement Mapping，不是支付方的 metadata。Stripe 的 `Product.metadata` 和 `Price.metadata` 会跟随产品同步进入展示信息（仅 Stripe 适用），Herald 端只读，不回写、不编辑。Creem 无原生 metadata 功能，必须在 Herald 中手动配置。

策略按 `entitlement_key` 查询，覆盖：首次订阅发放、续费发放、取消回收、退款回收、升降级处理。发放和回收都定位到订阅绑定的积分账户池，按上面五种 credit\_type 分别入账。

管理员可以在 Herald 中修改积分策略。修改的是 Herald 的业务规则，不会回写到支付方。

## 数据流向 [#数据流向]

<Mermaid
  chart="flowchart LR
    subgraph 支付方
        CK[Checkout Session]
        WH[Webhook]
    end

    subgraph Herald
        EM[Entitlement Mapping]
        SP[Subscription Projection]
        PP[积分策略]
        CB[(积分账户池)]
    end

    subgraph 管理员
        SYNC[同步商品]
        BUCKET[建积分账户 / 配覆盖集]
        CFG[配积分策略]
    end

    BUCKET --> CB
    SYNC --> EM
    EM -. 归属 .-> CB
    CFG --> PP
    EM --> CK
    CK -->| herald_* metadata | WH
    WH --> SP
    SP -. 绑定 .-> CB
    WH --> PP
    PP -->| 发放/回收到指定池 | CB"
/>

用户支付流程：

1. 前端调用 Checkout API，传入 `entitlement_key` 和 `payment_provider`
2. Herald 查 Entitlement Mapping，找到对应的外部商品和它归属的积分账户
3. Herald 调用支付方 API 创建 Checkout Session，metadata 写入 `herald_*` 字段
4. 用户在支付方页面完成付款
5. 支付方发 Webhook 给 Herald
6. Herald 从 metadata 解析 entitlement\_key，创建/更新 Subscription Projection，绑定到映射的积分账户
7. Herald 按 entitlement\_key 查积分策略，把积分发到该积分账户的池，或从该池回收

## SDK 消费怎么路由 [#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 统一管理支付过程。

## 相关文档 [#相关文档]

* [积分账户用户故事](https://github.com/timzaak/cas-2/blob/main/docs/user-stories/billing/credit-bucket.md) — 积分账户目录、覆盖集、购买入池、跨池消费、订阅生命周期的验收场景
* [Stripe 对接指南](/docs/billing-stripe-payment) — Stripe 支付方配置和 Webhook 处理
* [Creem 对接指南](/docs/billing-creem-payment) — Creem 支付方配置和 Webhook 处理
* [发票管理](/docs/billing-invoice) — 发票创建、开票、PDF 生成
