# 크레딧 판매

> API나 AI 서비스용 선불 크레딧: 구매자가 직접 충전하고, 내 서버가 호출마다 차감합니다. 충전에서 떼어 가는 것은 없습니다.

크레딧은 사용량으로 파는 모든 것에 맞습니다: 토큰, 이미지, 분, 검색. 구매자가 암호화폐로 패키지를 사고, 호출마다 거기서 차감하며, 크레딧이 떨어지면 구매자가 자기 페이지에서 충전합니다. 자동으로 청구되는 것은 없습니다 — 구매자가 항상 직접 결제합니다 — 그리고 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`를 지정하세요.
- 차감은 원자적이며 절대 0 아래로 가지 않습니다: 같은 크레딧에 동시에 두 번 차감되지 않습니다. 부족하면 `insufficient_credits`(409)이고, 아무것도 차감되지 않습니다.
- 요청 id를 멱등성 키로 넘기세요. 타임아웃, 장애 뒤, 심지어 다음 날 재시도해도 첫 차감이 돌아올 뿐 다시 차감되지 않습니다.
- `action`은 내 라벨입니다(`chat`, `image.hd`, `api/search`). 상품 페이지가 동작별·고객별 사용량을 합산합니다.

## 충전 페이지

`top_up_url`은 구매자 전용 페이지로, 항상 같습니다: 내 결제창 스타일로 잔액과 패키지를 보여 주고, 구매자가 패키지를 고르면 일반 청구서를 엽니다. 결제되면 크레딧이 잔액에 한 번 더해집니다. 앱 안에, '크레딧 소진' 오류에 링크를 보여 주거나 보내 주세요 — 구매자 포털에도 **충전** 버튼이 있습니다.

## 크레딧이 떨어질 때

| 이벤트 | 시점 |
| --- | --- |
| `credits.low` | 잔액이 상품의 기준값까지 내려감. 넘을 때마다 한 번: 충전으로 다시 올라간 뒤에야 다시 옵니다. |
| `credits.depleted` | 잔액이 0이 됨. 넘을 때마다 한 번. |

둘 다 고객을 `data.object`로, 그리고 `data.product`와 `data.balance`를 담습니다. 기준값이 있고 이메일이 켜져 있으면 구매자도 충전 링크가 담긴 이메일을 한 통 받습니다 — 하루 한 번 이하. 테스트 모드에서는 이메일이 내 팀에게만 갑니다.

> **참고:** 여러 패키지의 크레딧은 합산됩니다. 차감은 가장 오래된 패키지부터.
