# 出售额度

> 为 API 或 AI 服务提供预付额度：买家自行充值，你的服务器按调用扣减。不从充值中扣取任何金额。

额度适合一切按用量出售的东西：token、图片、分钟数、搜索次数。买家用加密货币购买一个额度包；你在每次调用时从中扣减；额度用完时，买家在自己的页面上充值。绝不会自动扣款 — 永远由买家自己付款 — Perly 也不从充值中抽取任何费用。

## 设置

1. **创建商品和额度包** — Dashboard → Products → New，然后 New price → **Credit pack**：例如 $10 买 1 000 个额度，$40 买 5 000 个。或者 `pay.prices.create({ product, model: "credit_pack", unit_amount: 1000, credits: 1000 })`。
2. **决定何时告知买家余额** — 在商品页的 **Credits** 部分：提醒阈值、给买家的邮件，以及 **Any amount** — 以单个额度包为单位的 N 倍，让买家可以充 $25，而不只是固定额度包。
3. **为每位买家创建客户** — 每个用户创建一个，带上你的 id：`pay.customers.create({ email, external_id: user.id })`。之后 SDK 直接接受这个 id。

## 扣减、查询、充值

**Node**

```js
import { PerlyPay, InvalidRequestError } from "perly-pay";
const pay = new PerlyPay(process.env.PERLY_PAY_SECRET_KEY);

// Before the work: enough left?
const { balance, top_up_url } = await pay.credits.balance(user.id);

// After it: spend. The same idempotencyKey never spends twice.
try {
  await pay.credits.consume(user.id, 3, { action: "chat", idempotencyKey: request.id });
} catch (e) {
  if (e instanceof InvalidRequestError && e.code === "insufficient_credits") {
    return { error: "Out of credits", topUp: await pay.credits.topUpUrl(user.id) };
  }
  throw e;
}
```

**PHP**

```php
$perly = new \Perly\Pay\PerlyPay(getenv('PERLY_PAY_SECRET_KEY'));

$left = $perly->credits->balance($user->id)['balance'];

try {
    $perly->credits->consume($user->id, 3, ['action' => 'chat', 'idempotency_key' => $requestId]);
} catch (\Perly\Pay\Exception\InvalidRequestException $e) {
    if ($e->getErrorCode() === 'insufficient_credits') {
        header('Location: ' . $perly->credits->topUpUrl($user->id));
    }
}
```

**curl**

```bash
curl https://pay.perly.io/api/v1/credits/consume \
  -H "Authorization: Bearer sk_test_…" \
  -H "Idempotency-Key: req_8f2c" \
  -d '{"customer_external_id":"user_42","credits":3,"action":"chat"}'
```

- 客户可以是 `cus_…` id、邮箱，或你给它的自有 id（`external_id`）。如果你为多个商品出售额度，请指定 `product`。
- 扣减是原子操作，余额永远不会低于零：两个并发扣减不会同时用掉同一批额度。不足时返回 `insufficient_credits`（409），且不扣减任何额度。
- 把你的请求 id 作为幂等键传入。重试 — 无论在超时、故障之后，甚至第二天 — 都会返回第一次的扣减，而不会再扣一次。
- `action` 是你自己的标签（`chat`、`image.hd`、`api/search`）。商品页会按操作和按客户汇总用量。

## 充值页

`top_up_url` 是买家专属的页面，每次都相同：它以你的结账样式显示余额和你的额度包，买家选好额度包后开出一张普通账单。账单一付清，额度就会入账一次。把链接放在应用里、放在“额度已用完”的错误信息里，或者直接发给买家 — 买家门户里也有**充值**按钮。

## 额度用完时

| 事件 | 时机 |
| --- | --- |
| `credits.low` | 余额降到商品的阈值。每次跨越阈值触发一次：要等充值让余额回到阈值以上后才会再次触发。 |
| `credits.depleted` | 余额降到零。每次跨越触发一次。 |

两个事件都以客户作为 `data.object`，并附带 `data.product` 和 `data.balance`。如果设置了阈值并开启了邮件，买家还会收到一封附充值链接的邮件 — 每天最多一封。测试模式下邮件只发给你的团队。

> **注意:** 多个额度包的额度会累加；扣减时先用最早的额度包。
