# 配方：API 额度

> 为 API 或 AI 功能出售预付额度，每次请求扣一个。余额不足时返回 402 和充值链接。

买家预先购买额度包；每次请求消耗若干额度。记账由 Perly Pay 负责：扣减是一次原子调用，余额不会变成负数，每位买家都有自己的充值页。绝不自动扣款 — 额度用完时由买家充值。额度的全部内容见 [Sell credits](https://pay.perly.io/docs/credits.md)；这里是围绕它的配方。

> **注意:** 配合助手：Perly Pay 插件的 `sell-credits` 技能会在你的项目中实现这个配方。可运行的示例是插件仓库中的 `examples/api-credits`（MIT）。

## 如何运作

1. **额度包** — 一个商品和一个价格，带 `model: "credit_pack"`、以分为单位的 `unit_amount` 和 `credits`。每种模式设置一次。
2. **你的用户就是客户** — 用 `external_id`（你的用户 ID）创建一次；之后 `credits.*` 直接接受这个 ID。
3. **充值** — `credits.topUpUrl(userId)` 是买家专属页面：选择额度包并付款。每个已付额度包都会增加额度。
4. **每次请求扣减** — 先 `credits.consume(userId, cost, { idempotencyKey })`，再干活。用同一个键重试会返回第一次扣减，所以每个请求的键由你的服务器生成，绝不取自调用方。
5. **不足？** — `insufficient_credits`（409）不扣任何额度：返回 `402` 和充值页，而不是 500。

**Spend one request's credits**

```js
try {
  await pay.credits.consume(userId, cost, { product: PRODUCT, action: "api", idempotencyKey: `req-${userId}-${requestId}` });
} catch (e) {
  if (e.code !== "insufficient_credits") throw e;
  return reply(402, { top_up: await pay.credits.topUpUrl(userId, { product: PRODUCT }) });
}
// now do the work
```

## 运行示例

1. **安装** — 在 `examples/api-credits` 中运行 `npm install`（Node 20.12+）。
2. **密钥** — 把 `.env.example` 复制为 `.env`，在 `PERLY_PAY_SECRET_KEY` 中填入测试密钥（`sk_test_…`）。
3. **准备** — `npm run setup` 用你的密钥创建商品和价格，并打印它们的 ID 供 `.env` 使用。
4. **启动** — 按其 README 运行 `npm start`（或 `npm run dev`）。
5. **无需钱包即可付款** — 调用 `POST /v1/shout`（402），打开 `POST /top-up` 给出的页面，选一个额度包，在账单上点 **Simulate payment**，再调用一次：200，`X-Credits-Remaining: 499`。

## 陷阱

- 扣减时不带幂等键：重试的请求会扣两次。
- 扣减键取自客户端发送的内容（`Idempotency-Key` 请求头）：同一个键会把第一次扣减当作成功返回，客户端不付钱就又得到了结果。
- 先查余额、再分两步扣减。`consume` 本身就不会扣成负数；余额只用于显示。
- 先干活、后扣减。应该反过来：先扣减。
- 用正式密钥配测试 ID。商品、价格和 Webhook 都属于某一种模式：上线前用 `sk_live_…` 重做一遍设置。
