# 多货币定价 (/zh/docs/billing-multi-currency)



让一个 Stripe 产品以多种货币销售。覆盖购买页货币分组与显式选择，以及面向第三方应用的 api-ext 按货币解析接口。

## 适合谁看 [#适合谁看]

* 想让同一产品以多种货币销售的 Realm Admin
* 把 Herald 购买流程接进自己应用、需要知道权益支持哪些货币以及如何按货币解析价格行的开发者

## 前置条件 [#前置条件]

* Stripe 已配置，且你能执行产品同步（见 [Stripe 对接指南](/docs/zh/billing-stripe-payment)）
* 知道 Entitlement Mapping 是什么（不知道的话先看[计费架构](/docs/zh/billing-overview)）

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

Herald 没有本地商品目录。价格挂在 Stripe 的 `Price` 对象上，同步时每个 Price 生成一条独立的 Entitlement Mapping 行。一个配了 USD 月付、USD 年付、EUR 月付的产品，同步后就是三行映射。多货币能力完全建立在这个模型上：不新增任何存储，每行自带的货币就是全部事实。

没有默认货币，也没有用户级偏好货币。购买页展示全部已配置货币，用户显式选择一个之后才出现价格行；应用侧则由调用方显式传 `currency` 参数解析。无论哪一侧选择，最终扣的都是被选中的那行映射。

货币本身定位不了一行价格。同一产品可以同时有 USD 月付和 USD 年付，所以货币只是过滤条件，不是唯一键——(权益 + 计费类型 + 计费周期 + 货币) 才能锁定一行。凡是解析结果不是恰好一行，Herald 都会拒绝而不是猜一个。不会出现静默换币，也不会出现零金额扣款。

## 第 1 步：在 Stripe 搭建多货币目录 [#第-1-步在-stripe-搭建多货币目录]

1. 在 [Stripe Dashboard → Products](https://dashboard.stripe.com/products) 打开你的产品
2. 每种货币加一个 Price，比如在 `$9.99 / month` 之外加 `€9.99 / month` 和 `¥69 / month`
3. 在 Herald 打开 **Entitlement Mappings**，执行 **Sync Provider Products**

每个 Price 到达后各占一行映射，带各自的货币和金额。把要卖的行启用。禁用的行不会出现在购买页，也不会进入提供给第三方应用的货币集合。

## 第 2 步：用户在购买页看到什么 [#第-2-步用户在购买页看到什么]

Stripe 产品有多个货币的价格时，购买页按货币分组，每个货币是一个可选按钮。没有预选货币：用户显式选择之前不渲染任何价格行——页面显示「请选择货币」的提示。选中的组内，每个计费周期一行（月付、年付并排放在 USD 组里）。

只有一种货币的产品不显示选择器——唯一的货币直接展示价格行，因为没什么可选。

其他渠道的产品也没有选择器。Creem 按产品定价，IAP 按商店地区定价，WeChat Pay 的价格手工配置在映射上——它们都只展示单一价格。

一个 Stripe 侧的注意点：如果你在 Stripe 启用了 Adaptive Pricing，Stripe 收银台可能把金额换算成买家本地货币。Herald 购买页始终展示 Price 上的基础货币，并标注「实际扣款以支付页为准」。Herald 自己不做任何换算。

## 第 3 步：在应用里按货币解析价格 [#第-3-步在应用里按货币解析价格]

第三方应用用两个接口处理货币。都走 API key（`X-API-Key` 请求头）加 `billing.view` 权限，且都只统计已启用的 Stripe 映射行。

查询权益支持的货币集合：

```bash
curl -H "X-API-Key: your-api-key" \
  "https://your-herald-domain/api/ext/{realmId}/entitlements/{entitlementKey}/currencies"
```

```json
{
  "entitlementKey": "pro-plan",
  "currencies": ["USD", "EUR", "CNY"]
}
```

按货币解析默认价格行，可用计费类型和周期收窄范围：

```bash
curl -H "X-API-Key: your-api-key" \
  "https://your-herald-domain/api/ext/{realmId}/entitlements/{entitlementKey}/default-price?currency=EUR&billingType=recurring&billingPeriod=monthly"
```

```json
{
  "mappingId": "0198c7e4-...",
  "entitlementKey": "pro-plan",
  "paymentProvider": "stripe",
  "currency": "EUR",
  "amount": 999,
  "billingType": "recurring",
  "billingPeriod": "monthly",
  "externalPriceId": "price_..."
}
```

拿响应里的 `mappingId` 作为购买支付尝试的 `targetId` 发起购买——和浏览器购买页走的显式选行是同一条路径。

失败行为是有意区分的：

* `400` —— 货币码缺失或格式非法
* `404` —— 该权益在这个货币下没有已启用的 Stripe 行。不会回退到任何其他货币，怎么提示用户由调用方决定
* `409` —— 匹配到多行（比如只传 `currency=USD` 时同时存在 USD 月付和年付）。补上 `billingType` 或 `billingPeriod` 再试

货币码与目录比较时不区分大小写，响应统一返回大写 ISO 码（`usd` 和 `USD` 是同一货币）。订阅类和一次性映射都带货币——订阅详情和一次性映射响应里有 `currency` 字段。

## 容易踩的规则 [#容易踩的规则]

**货币永远是显式选定的。** 没有 Realm 默认货币、没有用户偏好、没有回退链。购买页在用户选定货币后才渲染价格行；程序化接口只按你传入的货币解析一次，解析不了就报错。这正是为了不让用户被以一个从没选过的货币扣款。

**Stripe 行缺价格信息会被拒绝，不会补默认值。** Stripe 映射行万一缺了价格数据，购买会以明确错误被拒绝，而不是编一个金额发出去。健康的行上显式 `targetId` 购买不受影响。store 侧定价渠道（Creem、IAP、WeChat Pay）恰好相反：它们的行本来就不带 Herald 侧价格，因为订单金额由渠道决定。WeChat 映射是唯一的手工场景——价格和货币在创建弹窗里填写，随每笔订单下发。

## 排查 [#排查]

### 刚加的货币 `default-price` 返回 404 [#刚加的货币-default-price-返回-404]

依次确认：映射行是 **Enabled**；行的 provider 是 Stripe（Creem/IAP/WeChat 行永远不会被考虑）；在 Stripe 加完 Price 之后确实重新同步过。

### `default-price` 返回 409 [#default-price-返回-409]

该权益在这个货币下有多行——常见是月付加年付。补上 `billingType` 或 `billingPeriod` 再试。

### `default-price` 拒绝我传的货币码 [#default-price-拒绝我传的货币码]

货币码必须是恰好 3 个大写字母，`XXX`/`XTS` 是保留码。`美元`、带空格的 `usd`、4 位码都会以 `400` 被拒，不会进入解析。

## 检查清单 [#检查清单]

* [ ] Stripe 产品为每个要卖的货币各配了一个 Price
* [ ] 执行过 Sync Provider Products；每个货币行可见且 Enabled
* [ ] 用非默认货币完成过一次购买；扣款货币与所选行一致
* [ ] （应用）渲染货币选择器前先调 `currencies`；`404`/`409` 有面向用户的处理
