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



Herald 的计费系统不维护本地商品目录。Stripe、Creem 这些支付方管理 Product、Price、Checkout 和订阅生命周期；Herald 只保留做授权和积分发放所需的最小数据。

## 付费墙闭环 [#付费墙闭环]

这个模块里的一切设计都服务于同一个目标：把支付变成一面自动保持同步的付费墙。

1. 用户通过 Stripe、Creem、WeChat Pay 或应用内购完成付款；
2. Herald 把这笔购买解析成 `entitlement_key`，并向绑定的积分账户发放积分；
3. 你的产品据此检查权益或积分余额，放行或拦截这次请求；
4. 退款和取消订阅会自动扣回积分；订阅权益任意退款即撤销，一次性购买的角色只在退款覆盖整笔支付时撤销，部分退款保留。

免费额度和滚动配额窗口走同一套模型，所以免费增值、订阅制、按量计费最终都收敛为同一次检查。

## 给谁看 [#给谁看]

需要理解 Herald 计费模块设计的开发者。不涉及具体支付方的对接操作，对接指南见各支付方的单独文档。

## 核心概念 [#核心概念]

### 积分账户 [#积分账户]

积分账户是积分的隔离单元。一个 Realm 可以建多个积分账户，各个账户的余额互相独立、互不影响。它解决的问题是：一个 Realm 下可能同时跑多条业务线，比如一个 AI 对话应用和一个图片生成应用，它们的积分要分开结算、分开消费，不能串。每个积分账户覆盖一组 Client App，只有被覆盖的 Client App 才能消费这个池的积分。

支付入账进 Entitlement Mapping 绑定的账户；注册与免费周期积分由 Realm 的发放规则路由。余额由逐笔发放的积分账本派生，沿五种积分类型（充值、会员、注册、免费周期、主动发放）分别展开。完整模型——字段、约束、账本与管理 API——见[积分账户](/docs/zh/points/account)；消费侧见[积分消耗](/docs/zh/points/consumption)。

### Entitlement Mapping [#entitlement-mapping]

Entitlement Mapping 是一张映射表，把支付方的外部商品 ID 映射到 Herald 内部的 `entitlement_key`，同时归属到一个积分账户。它不是商品目录，是 allowlist 加同步缓存。

每条映射包含：

| 字段                    | 说明                                                             |
| --------------------- | -------------------------------------------------------------- |
| `payment_provider`    | 支付方名称（stripe、creem、apple、google、wechat）                        |
| `external_product_id` | 支付方侧的商品 ID（如 `prod_xxxx`；IAP 为商店商品 ID）                         |
| `external_price_id`   | 支付方侧的价格 ID（Stripe 有，Creem 和 IAP 不适用）                           |
| `entitlement_key`     | Herald 内部的权益标识（如 `pro-plan`）                                   |
| `bucket_id`           | 归属的积分账户。购买该商品后积分进入这个积分账户的池                                     |
| `billing_type`        | 计费类型：Recurring、OneTime 或 Non-renewing                          |
| `point_rules`         | 映射拥有的积分分发规则。留空是合法的——纯权益映射或纯支付记录（有效期、发放策略都配在规则上）                |
| `granted_role_ids`    | 支付成功后自动授予的角色。空 = 不授予角色；配置步骤见[支付墙配置指南](/docs/zh/points/paywall) |
| `enabled`             | 是否启用。禁用后 webhook 仍更新订阅投影，但不触发积分发放                              |

映射里还带一份从支付方同步过来的展示信息（`provider_product_info`），只读，用来在管理后台展示商品原貌，不参与计费：

| 字段       | 说明                                                                                                                                                                                |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 产品名      | 列表和分组的主标签优先用它。缺失时回退到外部产品 id，再不行给占位，不显示空标签                                                                                                                                         |
| 价格       | 展示时按支付方区分单位。Stripe 同步的是最小货币单位整数（例如 999 表示 9.99），换算成主货币单位展示；Creem 直接用原值（字符串如 `"9.99"`），不再统一除以 100。WeChat Pay 没有可同步的目录——价格直接配置在映射上，以分为单位的整数，下单时直接作为订单金额。单位换算由支付方来源驱动，不跨支付方共用同一条换算分支 |
| 币种       | 商品币种，跟随同步覆盖                                                                                                                                                                       |
| 计费类型     | recurring / one\_time / non\_renewing；Stripe 和 Creem 从支付方同步，无目录的支付方（IAP、WeChat Pay）由管理员设置                                                                                         |
| 计费周期     | 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 的一个积分账户（必填，管理员可在同步后改挂到别的账户）。适用于 Stripe 和 Creem。
2. **管理员手工创建**：IAP 支付方（App Store / Google Play）和 WeChat Pay 没有商品目录同步，管理员直接建映射——IAP 用商店商品 ID，WeChat Pay 自定商品 ID 并在映射上配价格。
3. **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/zh/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 分别入账（积分类型的完整说明见[积分账户](/docs/zh/points/account)）。

管理员可以在 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 的积分账户池，先扣配额窗口、再按过期时间从近到远扣池余额，原子完成、不超额。完整的消费语义——请求、扣减顺序、流水与回冲——见[积分消耗](/docs/zh/points/consumption)。

## 支持的支付方 [#支持的支付方]

| 支付方                 | 商业目录                         | 订阅                              | 一次性购买（积分包）                     | Metadata 支持                             |
| ------------------- | ---------------------------- | ------------------------------- | ------------------------------ | --------------------------------------- |
| Stripe              | Product / Price API          | Checkout Session + Subscription | Payment Intent                 | Product/Price/Checkout/Subscription 均支持 |
| Creem               | Product API                  | Checkout                        | Checkout                       | Checkout metadata，webhook 返回            |
| Apple App Store IAP | App Store Connect（无同步，手工建映射） | StoreKit 2 + 服务端通知 V2           | 消耗型，凭 receipt 提交履约             | 经 `appAccountToken` 绑定用户                |
| Google Play Billing | Play Console（无同步，手工建映射）      | Play Billing + 定时轮询（无 RTDN）     | 消耗型，凭 receipt 提交履约             | 经 `obfuscatedExternalAccountId` 绑定用户    |
| WeChat Pay          | 无（按订单定价，手工建映射）               | 仅 non-renewing——固定期、单次付款、不自动续费  | 统一下单（PC 扫码 Native / 微信内 JSAPI） | 无目录 metadata；价格与币种配置在映射上                |

Stripe、Creem、WeChat Pay 是发起式平台，通过 PaymentAttempt 统一管理支付过程。WeChat 订阅到固定截止日为止——没有续费扣款，用户重新购买续期。IAP 同样经 PaymentAttempt 入账，但履约由移动 App 提交购买凭证（Apple `jwsRepresentation` / Google `purchaseToken`）触发，而不是 checkout webhook——平台通知（Apple）和定时轮询（Google）只负责之后的生命周期。详见 [IAP 对接指南](/docs/zh/billing/iap) 与 [WeChat Pay 对接指南](/docs/zh/billing/wechat-payment)。

### 多货币 [#多货币]

货币挂在目录本身：每个 Stripe Price 自带货币，一个产品卖多种货币就是多行映射。没有默认货币——购买页展示全部已配置货币，由用户显式选择；按货币解析是严格的——不回退第二个货币，不产生零金额扣款。Creem、IAP、WeChat Pay 保持单一价格（WeChat 的手工价格与币种配置在映射上）。详见[多货币定价指南](/docs/zh/billing/multi-currency)。

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

* [支付墙配置指南](/docs/zh/points/paywall) — 在映射上配置授予角色：支付授予、退款回收、应用侧检查
* [积分账户](/docs/zh/points/account) — 积分账户模型：字段、覆盖集、积分类型与账本
* [积分消耗](/docs/zh/points/consumption) — 消费调用、扣减顺序、流水记录与回冲
* [积分账户用户故事](https://github.com/timzaak/herald/blob/main/docs/user-stories/billing/credit-bucket.md) — 积分账户目录、覆盖集、购买入池、跨池消费、订阅生命周期的验收场景
* [Stripe 对接指南](/docs/zh/billing/stripe-payment) — Stripe 支付方配置和 Webhook 处理
* [多货币定价指南](/docs/zh/billing/multi-currency) — 货币偏好、购买页分组与按货币解析价格
* [Creem 对接指南](/docs/zh/billing/creem-payment) — Creem 支付方配置和 Webhook 处理
* [IAP 对接指南](/docs/zh/billing/iap) — App Store / Google Play 内购配置、receipt 提交与生命周期处理
* [WeChat Pay 对接指南](/docs/zh/billing/wechat-payment) — WeChat Pay 配置、按订单定价的映射与 Native / JSAPI 购买流程
* [发票管理](/docs/zh/billing/invoice) — 发票创建、开票、PDF 生成
