> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vibestrap.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 积分

> 一个不可变的账本，只有 4 种交易类型 —— 接通了所有支付 Provider。

Vibestrap 内置的积分账本刻意做得很小：每用户一行可变余额（`userCredit`），
加一份只追加的日志（`creditTransaction`），**只允许 4 种交易类型**。所有支付
Provider 最终都汇入同一组 `addCredits` / `consumeCredits` / `refundCredits`
原语，所以积分行为不管用户从 Stripe 还是微信付的钱都完全一样。

## 4 种交易类型

就这些。别再加 —— 细分语义放在 `sourceType` 里。

| Type      | 何时       | `sourceType` 示例                                    |
| --------- | -------- | -------------------------------------------------- |
| `GRANT`   | 发放       | `register_gift`、`subscription`、`one_time`、`manual` |
| `CONSUME` | 消耗       | `ai_call`、`image_generation`                       |
| `EXPIRE`  | 到期（cron） | `expiration_cron`                                  |
| `REFUND`  | 退还       | `failed_call`、`manual_reversal`                    |

Schema 在 `src/db/app.schema.ts`；原语在 `src/credits/server.ts`；业务封装在
`src/credits/index.ts`。

## 原子原语

三个原语都把 UPDATE + INSERT 放在同一个事务里，余额和日志永远不会漂移：

```ts theme={null}
export async function addCredits(input: AddCreditsInput): Promise<void> {
  await ensureUserCredit(input.userId);
  await db.transaction(async (tx) => {
    await tx
      .update(userCredit)
      .set({ currentCredits: sql`${userCredit.currentCredits} + ${input.amount}` })
      .where(eq(userCredit.userId, input.userId));
    await tx.insert(creditTransaction).values({
      type: 'GRANT', amount: input.amount, ...
    });
  });
}
```

`consumeCredits` 在事务内检查余额，不够时返回
`{ ok: false, reason: 'INSUFFICIENT' }`，不会抛错。`refundCredits` 跟
`addCredits` 对称，只是 `type: 'REFUND'`。

## 业务封装

多数调用方不直接 `addCredits`，而是用 `src/credits/index.ts` 里的高层 helper：

```ts theme={null}
addRegisterGiftCredits(userId);                     // 注册赠送
grantPlanCredits({ userId, planId, paymentId });    // demoPlans 任意一档
```

注册赠送来自 `siteConfig.credits.registerGift`；套餐发放数来自
`siteConfig.demoPlans` 每一档的 `creditsGranted`。改 demoPlans 就能改整个
发放矩阵。

## 支付怎么发积分

`src/payment/handlers/core.ts` 里的 webhook handler 只有一句 dispatch：

```ts theme={null}
if (p.scene === siteConfig.product.id) return; // Vibestrap 本体走 license，不发积分
await grantPlanCredits({ userId: p.userId, planId: p.scene, paymentId });
```

`grantPlanCredits` 按 id 查 `siteConfig.demoPlans`，每次成功扣款都按
`creditsGranted` 发一次。年付订阅 = 一次发 12 个月的量；月付 = 一次发 1 个月；
一次性 = 永远只发一次。

## 多币种

`payment.amount` 用最小单位存（cents、分……），`payment.currency` 存 ISO 代码。
USD 和 CNY 都是一等公民。积分账本本身跟币种无关 —— 积分是纯整数，钱到积分的
换算发生在发放时，规则来自 `siteConfig.demoPlans[*].creditsGranted`。

## 过期

`creditTransaction.expirationDate` 只在需要过期的 `GRANT` 行上设值（例如
`register_gift` 按 `siteConfig.credits.registerGift` 30 天后过期）。一个 cron
扫这些行并插对应的 `EXPIRE` 行。不部署 cron 就不会过期 —— 但积分也永远不会
变成负数。

## 用户侧 UI

积分中心在 `/settings/credits`：

* 当前余额（`userCredit.currentCredits`）
* 交易记录（分页的 `creditTransaction` 日志）
* 购买更多 / 升级套餐入口（默认是 demo —— 看下一节）

订阅相关的开票和 portal 入口在 `/settings/billing`。

## 从 demo 切换到真实 checkout

Vibestrap 把 `/settings/credits` 当成**起步骨架** —— 演示 credits 和套餐购买页能长什么样。
开箱即用的 Buy 按钮**只弹 toast**（"仅演示"），不会真扣 Stripe。这样 vibestrap.dev
本身不会因为某个意外点击给买家创建无意义的订阅，买家也能在配置 Stripe key 之前安全地点着试。

要换成真实 checkout，**改代码**就行（不需要切换什么开关）：

1. 在 `siteConfig.demoPlans` 里配上你自己 provider 的 price ID（以及 `STRIPE_PRICE_*` 等环境变量）
2. 打开 `src/app/[locale]/(app)/settings/credits/buy-plan-button.tsx`，把里面的内容替换成
   一个调 `createCheckoutAction` 的 `CheckoutButton`。`/pricing` 页面的 `<CheckoutButton>`
   （在 `src/components/payment/checkout-button.tsx`）就是接好真实 Stripe 的同款模式
3. 删掉 `src/app/[locale]/(app)/settings/credits/page.tsx` 顶部的 demo banner
4. 删掉 demo i18n 键：`Settings.credits.demoNotice`、`Settings.credits.demoBanner.title`、
   `Settings.credits.demoBanner.body`（en + zh 两份都删）

10 分钟机械操作。**我们故意不做"demo mode"环境变量** —— 那样买家上线时会多一个忘记切换的开关；
直接删 demo 代码反而更清楚。

## 验证

1. 注册一个新用户 —— 注册流程会触发 `addRegisterGiftCredits`。
2. 查 Postgres：
   ```sql theme={null}
   select type, amount, source_type from credit_transaction
   where user_id = '<新用户 id>';
   ```
   应该能看到 `GRANT 50 register_gift`。
3. 在测试模式下买一个 `demoPlans` 档位，应该多一行 `GRANT`，
   `source_type = 'subscription'`（或 `'one_time'`），金额是该档的
   `creditsGranted`。
4. 触发一次 AI 调用（或者写个脚本调 `consumeCredits`）—— 应该能看到 `CONSUME`
   行，余额下降。

## 常见坑

* **新增交易类型** —— 别。新增类型会破坏所有遍历这 4 种的统计和管理 UI。
  细分用 `sourceType`。
* **绕过事务** —— 永远不要在 helper 之外直接 UPDATE `userCredit.currentCredits`。
  只有 `addCredits` / `consumeCredits` / `refundCredits` 是安全的，因为它们把
  UPDATE + INSERT 放在同一个 DB 事务里。
* **先发积分再插 payment 行** —— webhook handler 是先插 payment 再发积分。
  顺序反过来会导致重投时重复发积分，因为幂等检查是查 `payment.invoiceId`。
* **`siteConfig.demoPlans` 引用过期** —— `grantPlanCredits` 按 id 查档位；
  如果 config 里删了某档但还有付费订单引用它，helper 会静默 return。
  删档前先确认没有在途订单。
* **过期 cron 没部署** —— `expirationDate` 是数据，不是触发器。没 cron 就永远
  不会插 `EXPIRE` 行。

## 官方文档

* Drizzle ORM 事务：[orm.drizzle.team/docs/transactions](https://orm.drizzle.team/docs/transactions)
* PostgreSQL `SELECT … FOR UPDATE`（Drizzle 隐式使用）：[postgresql.org/docs/current/explicit-locking.html](https://www.postgresql.org/docs/current/explicit-locking.html)
* Vibestrap 源码：`src/credits/server.ts`、`src/credits/index.ts`、
  `src/db/app.schema.ts`
