---
url: /docs/agentbuff-stack/billing-tasks.md
---
# 支付、积分与任务

## 当前支付能力

`websiteConfig.payment` 控制是否允许计费及 Pro 试用天数，Stripe 完整凭据组控制服务是否实际可用。`apps/api/lib/auth.ts` 配置 Stripe 插件，`routers/billing.ts` 提供账单可用性、当前计划与可管理权限。

I6 已增加一次性积分包、Checkout、签名回调和退款 / 争议对账；**默认关闭，真实 Stripe 沙箱与订阅完整生命周期仍待验收**。公开定价页明确为模板示例价；不是已售产品。服务端 Price ID 决定真实收费，网页展示价格需要站主同步。

关闭计费只停用插件与可用性，不删除用户订阅记录，也不能代替 Stripe 平台取消实际订阅。沙箱验收至少覆盖 Checkout、webhook 签名、重放、回跳、账单门户、取消 / 恢复及非管理员访问。

## 托管支付与回站语言

新积分包购买保存发起页面的 `en` / `es` 语言：成功与取消都进入同语言的购买详情，Stripe Checkout 同时收到明确语言。`0024_credit_checkout_language.sql` 新增可选的一对一语言快照表，不改既有购买字段或历史迁移；启用积分包前完整迁移至 0024，重建 API 与工作台。正常预览仍使用已授权的 0012，积分包关闭时不会读取新增表。语言不是权限或发放依据，返回页面仍不能发积分。

语言与购买请求一同确定。同一请求更换商品或显式语言返回冲突；购买详情的恢复从原记录读取，不按当前页面重新生成。旧客户端未提供语言、旧购买没有快照时，保持原英文地址，并保留不含 `locale` 的原 SDK 请求；不能给同一个幂等键临时增加语言参数。未知语言拒绝，新购买使用已关闭语言也拒绝。

订阅的新结账成功 / 取消地址来自当前语言的设置页，已有未解决结账使用保存的原地址。Stripe 语言从这对保存地址确定，两个地址的语言必须一致；兼容已经接受的旧参数摘要，原请求是否包含请求标记和语言都保持不变。订阅成功返回入口仍在根路径 `/api/auth/subscription/success`，只有原有归属、当前资源与保存结果核查通过后，才回到保存的语言地址。未付款返回仍拒绝，不发积分、不释放未解决请求。

账单门户使用当前界面语言，返回固定的同语言 `/settings`，丢弃无关查询参数与片段；个人 / 团队账单主体、当前管理员权限与新鲜会话要求保持。外部 Checkout 和门户的实际翻译由供应商负责，本地只能核对送出的参数及本站返回行为，不能据此声称托管页面已实测。

**停用语言前，先等待该语言的未解决结账到期或完成核查。** 已接受的付款请求仍保留原语言与地址；直接关闭路由可能使历史付款回站 404。本阶段没有实现改写历史付款或自动替换到其他语言，修改幂等请求也不是修复办法。

`bun payments:validate-language` 使用实际 workerd、一次性本地 PostgreSQL 与受控 Stripe HTTP，验证两次真正 Worker 重启、丢失响应后的同键同载荷、改变语言拒绝、未付款拒绝、确认后的西班牙语回站与门户参数，并确认余额不增加；程序清理专用资源，不迁移主库。开发环境可追加 `--ui` 启动专用双语言工作台，使用合成账号检查返回页和设置页；不能跟随夹具的托管付款链接。实际浏览器已检查 320 / 390 像素与浅深主题，购买详情的长操作文案允许换行。真实 Stripe 沙箱仍是外部待验收。

## 当前积分能力

`apps/api/lib/credits.ts` 保存可用余额和不可重复的增减事件。欢迎积分按 `credits.signupCredits` 在账户首次初始化时发放一次（整数 0—1,000,000）；已有账户不会因改配置重复领取。任务受理、预扣、输入保留与待投递状态使用同一事务，任一步失败全部回滚。外部队列和模型调用在提交之后。积分属于个人，切换组织不会转移积分。

JSON / 文本浏览器工具继续免费。I4 本地任务 API、队列执行、私有结果、工作台、死信恢复与预算限制已实现；I5 新增报价确认、预扣、成功结算、取消 / 最终失败返还与输入到期维护。确定性文本任务默认免费，尚未接入收费模型；积分购买代码已接入，默认关闭。

## 配置任务价格

在 `packages/core/website.ts` 设置：

```ts
tasks: {
  enabled: true,
  maxActive: 5,
  maxAttempts: 3,
  writesPerMinute: 10,
  readsPerMinute: 120,
  creditCost: 0,
  priceVersion: "text-normalize:v1",
},
```

`creditCost` 是整数积分，范围 0—1,000,000；0 表示免费，无预扣事件。`priceVersion` 长度 1—64，使用小写字母、数字、冒号、点、下划线或连字符，以字母 / 数字开头。调整价格时同时更新版本，运行离线检查并重建前后端。服务端同时核对积分数和版本，不信任页面提交的价格。

登录后 `tasks.quote` 返回处理器报价和本人可用余额。收费提交需要 `acceptedQuote: { credits, version }`；页面显示价格、余额和确认选项。旧报价返回 `QUOTE_CHANGED`，余额不足返回 `INSUFFICIENT_CREDITS`，都不创建任务或扣积分。免费任务保留旧接口兼容，可不传报价。

每个任务保存受理时的价格与版本，后续改价不改变旧任务。响应丢失时页面保存完整原请求与报价，原键重放返回原任务，不再扣费。同键换输入、参数或报价返回冲突。收到明确报价 / 余额错误后，点击重新查看价格，确认新报价再提交。

## 预扣与返还规则

`balance` 表示可用积分，预扣后立即减少。任务保存收费状态；账本使用 `task:<任务 ID>:reserve` 和 `task:<任务 ID>:refund` 唯一事件键。历史任务关联无级联外键，后续归档不删除账本；账号删除仍沿用原级联规则，这不是永久财务审计存储。

| 任务情况 | 积分处理 |
| --- | --- |
| 免费任务 | `free`，无预扣 |
| 已受理、等待、执行、自动等待重试 | `reserved`，保留原预扣 |
| 成功发布结果 | 同一事务标为 `settled`，不二次扣费 |
| 可手动重试的失败，仍有预算且输入有效 | 保留预扣，按原价和原预算重试 |
| 本人放弃失败任务 | 点击取消并返还，标为 `canceled/refunded` |
| 不可重试失败或预算耗尽 | 与失败终态同事务返还一次 |
| 运行中取消 | 先记录请求，执行器结束或失联恢复后返还 |
| 可重试失败的输入删除、变更或到期 | 定时维护核对后返还，退款后禁止继续重试 |
| 执行结果不确定，`reconciling` | 保留预扣，等待核查；请求取消不能直接退款 |

终态、结算 / 退款与账本在同一事务提交。并发取消、失败和发布由任务行锁裁定；旧执行版本不能发布，已退款任务不能领取或重试。数据库异常或原预扣不一致时回滚并报错，不伪装成已退款。

## 本地检查与验收

```sh
bun run test -- --run apps/api/lib/tasks/charges.test.ts apps/api/routers/tasks.test.ts apps/app/components/tasks/workspace.test.tsx
bun tasks:validate-charges
```

前者使用实际迁移 PGlite / 本地 R2；页面接口为受控夹具。后者创建临时 PostgreSQL 与八个独立连接，检查重复只预扣一次、余额不足、取消 / 发布竞争和其他消费竞争，不迁移当前配置数据库。它验证数据库与服务，不代表实际业务 Queue。

真正收费 Worker 验收命令为 `bun tasks:validate-paid`。先用 `site:create` 创建独立验收站、独立 loopback 数据库与端口，完成安装与邮件包构建；仅该站配置正数 `creditCost`（如 3），欢迎积分至少覆盖三个任务。脚本不修改价格，主模板免费配置会明确拒绝执行。脚本另建 / 销毁临时数据库与 Queue / R2 目录，用实际 HTTP 与生产 Worker 核对报价、重复提交、成功结算、取消返还、原价重试、定时到期返还及重启下载。收费夹具不连接 Stripe，不代表真实付款。

增量迁移 `0007_task_charges.sql` 增加价格快照、收费状态与账本任务关联。上线前按历史应用迁移，不能只更新 Worker。任务执行细节见[任务生命周期](./task-lifecycle)；已执行证据见[迭代记录](./iteration-progress)。云端、真实验证码、Stripe 沙箱与模型仍按后续迭代验收。

## 订阅：权限、恢复与本地证据

状态维护仍归 Better Auth Stripe 1.7.4，本项目只读取其 `subscription` 表，不新增第二套订阅状态写入器，不将组织订阅自动发成个人积分。当前 SDK 22.6.2 使用 Workers 兼容的 Fetch 客户端，10 秒超时、SDK 自动网络重试关闭；事件处理失败交给投递重试。

服务端返回两组信息：`subscriptionPlan` / `status` 是现有账单状态，`plan` / `accessActive` / `accessUntil` / `limits` 是当前有效访问。有效访问要求 active 周期或 trialing 试用截止可核实、仍在有效期，截止还取周期、提前取消或 endedAt 的最早值。时间缺失、到期、续费失败和暂停都停止付费限制；当前未配置宽限期。显示成员上限不表示邀请入口已经执行该限制，相关实施与账号流程按 I8 验收。

设置页保留原方案和付款异常，待付、逾期、未付、暂停或周期信息缺失时不提供新的 Checkout。没有订阅、已取消，或原 Checkout 已确认过期且未关联订阅、本人有管理权时才显示新订阅入口。门户入口按本人的 Stripe 客户、活动组织客户或插件支持的活动订阅客户判断；不能将个人客户当成组织客户。组织成员可以查看，只有 owner / admin 可以管理。门户返回仍查询服务器，不按返回网址宣布付款成功。页面每 30 秒、回到窗口和手动刷新时检查最新状态；查询失败隐藏旧操作，缓存同时按用户与组织隔离。

### 订阅结账保护与恢复

设置页新增 Pro 年付入口，仅在完整计费组启用且配置独立 `STRIPE_PRO_ANNUAL_PRICE_ID` 时提供；Starter 仍为月付。现有订阅改周期走门户，未解决原请求只能按原方案 / 周期继续。页面显示服务器保存的 `billingInterval` 与原请求的月付 / 年付选择，不根据本地按钮或网址猜测已开通。升级响应失败后会重新查询原主体的账单，避免丢失已接受的年付请求；核查和后续动作清除旧错误提示，忙碌时禁用刷新和其他付款动作。

Better Auth 当前版本的价格读取会吞掉异常并回退到配置 ID。本项目在实际 SDK Checkout 创建前另核对收费项：恰好一个已配置 Price ID、正整数数量、同模式、已启用、recurring / per\_unit / licensed、一月或一年且 `interval_count=1`、正整数金额。读取失败、价格已停用或信息不符不会发送 Checkout POST。这里比插件多一次当前 Price 读取，外部调用仍在事务外，租约与请求指纹规则保持。

年度入口不展示推算的折扣或年度金额；`plans.price` 仍是公开月度展示价。最终金额与周期在 Checkout 确认。当前本地验收使用受控年度 Price 与无需付款的试用会话，不代表真实年费已扣款。

插件仍是 `subscription` 状态的唯一写入者。`subscription_operation` 是结账请求和短期操作租约，不是另一张订阅状态表；迁移 `0010_subscription_operations.sql` 仅新增这张协调表，保留 0000—0009。

每个个人 / 组织账单主体持有两分钟数据库租约，插件的单行创建 / 更新在短事务内重新校验令牌、期限和当前管理权限。外部调用不持有数据库事务；旧持有者到期后不能覆盖新操作，也不能释放新令牌。不同账单主体使用不同租约。受保护的 HTTP 入口覆盖升级、取消、恢复、门户、结账回跳和订阅签名事件；直接使用未分配 scope 的服务端插件 API 不能写订阅。其他认证请求保持原生命周期。组织客户必须明确使用组织模式，个人模式不能借组织 ID 误用个人 Stripe 客户。

升级入口先读取新鲜本地订阅，再保存原方案、年付选项、回跳地址、配置价格指纹和固定一小时期限。已有有效、试用、欠费、暂停、未知或未完成订阅时不能另开 Checkout；改方案走现有账单门户。插件创建后的数据库订阅 ID 绑定到同一次请求。供应商请求参数只保存摘要，不保存整段 provider 载荷；Checkout SDK 请求使用稳定的 `subscription-checkout-<attemptId>` 幂等键，期限保持不变，客户创建也沿用该次请求的稳定键。失去响应后，只能重试原请求；更改商品、数量、元信息、回跳地址或价格配置会被拒绝。[Stripe 幂等请求说明](https://docs.stripe.com/api/idempotent_requests)。

创建和恢复已知 Checkout 都分页检查该客户的当前全部订阅，最多 1,000 条；不能只检查第一页，遇到未结束订阅或超过上限时保留核查。设置页从当前本人 / 组织账单接口获得原请求，提供“Resume original checkout”，不同时提供新购按钮；用户无法从查询其他账户拿到该请求。续费失败或旧取消记录不会遮住新的待支付记录，多笔未解决订阅不任意挑一笔。

新请求在 Checkout 元信息中保存 `subscriptionAttemptId`，用于查找已接受但本地失去响应的会话。升级前已经发送的旧请求若没有标记，仍沿用原 SDK 参数摘要和幂等键，不在重试时追加字段。设置页提供“Check previous checkout”，可以在原请求超过本地期限后继续核查。

该按钮调用 `/api/auth/subscription/recover`，只核查服务端保存的原请求，不能由浏览器指定待认领的会话 ID。已有 ID 时读取当前会话；未知时，按原客户和固定一小时创建窗口分页读取 Checkout，最多检查 1,000 条，并要求恰好一个请求标记匹配。读取后重新核对主体、客户、模式、本地插件行、原期限及标记；列表中的历史状态不能直接作为结果。[Stripe 会话列表接口](https://docs.stripe.com/api/checkout/sessions/list)。

| 当前原会话 | 恢复行为 |
| --- | --- |
| open | 核对当前客户没有未结束订阅后保存原会话；页面刷新并允许继续原请求，核查本身不跳转或创建付款 |
| complete 且 paid / no\_payment\_required | 复用已验证当前资源和插件回跳处理，事务内保存校验、提交后核对，再确认原请求已解决；页面按实际订阅状态显示权限 |
| expired | 会话没有关联订阅，客户没有未结束订阅，且原本地行仍 incomplete / 没有供应商订阅时，释放原请求；供应商提前过期也适用，随后才允许新请求键并复用插件行 |
| 未付款、找不到、多个匹配、列表不完整 / 超限或信息不符 | 保留核查；不从“查不到”推断可以再付一次，失败不会创建替代 Checkout |

供应商不可用返回 503，可以再次核查；主体忙或仍无法确认返回 409。核查中的旧租约和已撤销组织管理权不能写回。**旧请求未保存会话 ID，且实际 Checkout 没有新增标记时，仍不能自动归并**；已有 ID 的旧请求可以继续核查，没有 ID 的旧请求使用下面的管理员人工流程。不要手删操作记录来解锁付款。

### 未标记旧结账的人工核查

站点管理员在管理页选择账号，进入订阅记录中的旧结账恢复表单；组织账单可在全部记录中填写实际组织 ID。需要当前有效的站点管理员身份及近期登录，团队所有者不因此获得管理权限。账单配置必须完整，正常未配置支付的本地预览不会调用真实 Stripe。

1. 在正确测试 / 正式账户的 Stripe 控制台核对原始创建请求：客户、本地订阅编号、`subscription-checkout-<attemptId>` 幂等键及返回的会话编号。单凭邮箱、金额或“看起来相同”的会话不能认领。此步骤需要实际人工确认；接口的归属核对不能证明原幂等键。
2. 填入原会话编号，先核查。服务端按原客户和固定一小时窗口分页读取所有状态，最多 1,000 条；同一本地订阅编号及原到期时间必须恰好对应这一个会话。再读取当前会话，核对主体、客户、模式、测试 / 正式环境、原期限及创建时间，且确实没有新请求标记。本地原请求必须仍未解决、没有已知会话，原插件行仍未完成且没有供应商订阅，客户只能归属这一账单主体。找不到、多个候选、分页异常或信息不符均保持待核查。
3. 页面展示原尝试编号、当前状态和到期时间。填写原请求核对的原因，再确认关联。提交重新读取供应商，按核查摘要防止会话状态或本地请求在确认期间变化；变化后取消并重新核查。管理员权限、会话及原租约在提交事务再次验证。
4. 原会话编号 / 原继续地址和两条永久审计一起提交：`subscription.checkout_link_requested`、`subscription.checkout_link_completed`。审计失败全部回滚，保留原请求 ID 重试；失去响应后再次确认相同请求不会重复关联。审计保存账单主体、原尝试、会话编号、摘要、状态及人工原因，不保存供应商完整载荷、支付地址或密钥。
5. 关联成功仅补齐原会话记录；没有创建付款、退款、发放权益或解除购买阻塞。账号持有人随后在账单页核查原结账，复用上表的 open / complete / expired 处理。即使会话显示完成，实际付款和订阅状态仍由原核查与插件验证。

缺少原始请求证据、相同时间窗存在多条候选、价格 / 客户配置变化导致原恢复仍拒绝时，继续调查供应商记录；本入口没有强制清空或指定“已支付”的选项。带新请求标记的未知会话使用原自动核查，不走这个兼容入口。[Stripe 会话读取接口](https://docs.stripe.com/api/checkout/sessions/retrieve)、[分页与状态过滤](https://docs.stripe.com/api/checkout/sessions/list)用于当前资源核对；本地受控响应不能代替真实沙箱验收。

回跳要求新鲜登录和账单管理权限，先核对 provider 会话、元信息、本地订阅、客户及测试 / 正式模式；插件再次读取供应商时也经过相同归属校验。不能用其他人的 Checkout ID 让插件写入其订阅。升级 / 门户等写入口每人每分钟最多 10 次，回跳及原请求核查共用每分钟 30 次的独立额度；跨站来源在处理前被拒绝，回跳地址只能是当前站点。

### 当前状态同步与故障重投

订阅回调先限制原始载荷为 256 KiB，保持原始字节验签，再核对模式和本地主体后获取租约。过大返回 413；错误签名、归属或模式返回 400；主体忙、供应商读取失败、当前商品列表不完整或无法确认受支持方案返回 503，允许再次投递。所有非 2xx 都不能当作处理成功。

租约内通过真实 SDK 读取当前订阅；Checkout 通知还先读取当前会话，核对完成状态、关联订阅、客户和元信息。校验当前对象与本地记录的主体、客户、订阅 ID、模式、配置价格、月付 / 年付周期及试用范围一致。已存在的行将当前对象交给插件的更新处理器；只属于一个已知客户且尚无记录的订阅交给创建处理器。旧 created / updated / deleted 或 Checkout 通知因此不会直接用历史快照恢复旧试用、覆盖新的取消状态。Stripe 不保证事件按顺序到达，应在需要时读取最新对象，见[官方回调说明](https://docs.stripe.com/webhooks)和[订阅读取接口](https://docs.stripe.com/api/subscriptions/retrieve)。

订阅表仍只由 Better Auth 插件写入。插件写入的短事务内读取刚保存的记录，核对主体、客户、方案、席位、周期、试用、取消 / 结束时间和日程关联；不一致则整个写入回滚，插件吞掉错误时也不返回成功。锁定版本创建处理器遗漏的取消 / 结束 / 日程字段，由适配器根据已校验当前对象补入同一次插件创建，避免先提交缺失字段的权限记录。提交后仍再次核对保存结果。历史已使用的试用时间允许插件保留，当前 trialing 必须有完整可核对的范围，访问权限另按截止时间判断。

内部处理事件的类型代表当前对象应走的创建或更新路径，**不保证调用原通知对应的完成 / 删除钩子**。目前没有在这些钩子发邮件或积分。原通知 ID / 类型另行保留于本次处理上下文；需要一次性副作用时，必须在 I12 实现持久事件去重和通知发件箱，不能依赖本次重投天然只执行一次。供应商读取和本地提交也不是跨系统原子事务，读取后发生的新变化仍需后续通知收敛。

每个 SDK 客户端在包装验签方法前复制其共享的回调对象，防止不同请求的模式 / 租约校验串用。GET 结账回跳现在复用相同的当前资源 / 保存校验；回到设置页仍不代表付款成功。

### 结账回跳：当前资源与保存结果

回跳要求新鲜登录和当前账单管理权，核对当前 Checkout、本地主体 / 客户与模式；只有 complete 且 paid 或 no\_payment\_required 的会话进入更新，open、expired 或 unpaid 返回 409。读取失败返回 503，保存失败返回非 2xx，均没有成功跳转，也不提前释放原结账请求。失败修复后可以重新访问同一回跳，不重新创建付款。

回跳在主体租约内读取当前订阅。本次请求内插件复用已核实的 SDK 会话与订阅响应，避免重复外部读取造成快照不一致；订阅仍由原插件更新，事务内 / 提交后检查状态、周期、试用、席位、取消和日程。回跳不伪造已签名 Stripe 通知，也不发个人积分。当前 canceled / past\_due 可以同步并返回设置页，页面必须按服务器实际状态判断权限。

锁定 Better Auth Stripe 1.7.4 的原回跳在本地 active / trialing 时会提前返回，并遗漏 endedAt / stripeScheduleId。`patches/` 小型补丁取消提前返回并补齐字段，API 精确锁定版本；Bun 安装自动应用，生成器及二次生成保留补丁。新生成站已实际离线安装并核对补丁生效。升级和移除补丁的流程参考 [Bun 官方说明](https://bun.sh/docs/pm/cli/patch)；同时保留项目的补丁说明与第三方许可证。

`apps/api/lib/subscriptions.test.ts` 使用真实 Better Auth 路由、实际迁移 PGlite、真实 SDK 和受控 HTTP，覆盖正常生命周期、原请求复用 / 响应丢失、门禁 / 回跳归属、旧事件与旧 Checkout、供应商失败恢复、创建和 Checkout 保存失败、年付状态同步及客户端校验隔离，另覆盖未知会话查找、分页 / 歧义 / 超限、供应商失败、旧请求参数兼容、提前过期、完成保存失败重试和旧租约写回拒绝。另覆盖年付首购 SDK 参数、同键重试与改月付拒绝、缺少配置、错误价格类型 / 模式 / 周期 / 归属、插件读取后商品停用、价格读取失败；年付首购页面另由实际浏览器和受控原生 Worker 验收，组件测试接口仍为受控夹具。

`bun subscriptions:validate` 使用八个实际 PostgreSQL 连接和插件 HTTP handler，验证结账并发 / 原键重试、旧操作被拒绝、不同主体与撤权保护，并验证一条签名通知处理中其余七条返回 503、重投后均保存当前 active 状态。另让原请求恢复查询暂停，其余七个并发核查返回 409；解除后全部继续核查同一会话，没有再次创建。SDK HTTP 全部受控，不访问 Stripe；这条脚本的插件 HTTP 在 Bun 执行，Worker 验收由下一条脚本覆盖。

`bun payments:validate` 另在生产 workerd 和一次性 PostgreSQL 验证订阅签名、大小、模式、正常状态更新、旧 created / deleted、当前读取失败及触发器故障后的相同事件重投。用实际 Worker 注册会话验证 GET 回跳更新、失败不跳转与修复后重试。本批另验证 Worker 发出的实际 SDK Checkout POST：受控服务先接受带请求标记的会话再模拟响应丢失；核查找回 open、确认提前 expired，重新请求后完成会话经插件同步，核查不增加创建次数。所有外部请求都由本地拦截返回受控响应，其他目的地拒绝；没有实际 provider API、门户 HTTP 或浏览器订阅付款。

浏览器在独立一次性数据库 / 受控 Worker 走过核查失败、修复重试、确认过期后新购按钮与刷新保持，整个验收只有一份受控创建；320 像素无横向溢出，临时资源已删除。本批另在独立回环主机名 127.0.0.1 的 13410 站点，通过浏览器选择年付、接受后失去响应、自动读取原年付请求，再核查同步 yearly / trialing，刷新保持；只有一个受控 Checkout POST，原主预览 Cookie 不受影响。320 像素下年付选项无横向溢出，临时资源删除。未标记旧未知会话的人工核查 / 关联、审计故障回滚、同请求重试及原用户恢复已在一次性 PostgreSQL / Worker 和实际管理页验收；完成 / 过期会话另以真实 SDK 受控响应和实际插件流程验收。真实 Stripe 沙箱和生产绑定仍按 I6 / I9 验收。

## 一次性积分包：I6 本地实现

状态：服务端、页面和本地受控测试已接入，不代表实际收款已验收。当前支持 USD、GBP、CAD、AUD、EUR，金额按整数最小货币单位保存；暂不支持零小数币种、优惠码、自动税费或运费。

在 `packages/core/website.ts` 配置独立目录。以下为未启用示例，金额 900 表示 9.00 美元；必须替换为本站 Stripe 测试账户的真实一次性 Price ID 后才能启用：

```ts
creditPacks: {
  enabled: false,
  packs: [{
    sku: "credits-100",
    name: "100 credits",
    version: "credits-100:v1",
    amount: 900,
    currency: "usd",
    credits: 100,
    priceId: null,
  }],
},
```

SKU 与 Price ID 均不能重复，最多 20 个积分包；金额和积分为 1—1,000,000 的整数。调整报价时更新版本，前后端一起重建。`payment.enabled` 与 `creditPacks.enabled` 必须同时为真，服务凭据完整时目录才允许创建购买。离线检查不证明 Stripe 商品有效，创建 Checkout 时还会核对该账户当前 Price 的模式、金额、币种及一次性类型。

### 环境和事件职责

当前 API 沿用完整订阅凭据组：`STRIPE_SECRET_KEY`、`STRIPE_WEBHOOK_SECRET`、`STRIPE_STARTER_PRICE_ID`、`STRIPE_PRO_PRICE_ID` 必须一起配置。积分包另需 `STRIPE_CREDITS_WEBHOOK_SECRET`；仅填写两个积分包变量不足以启动整套 API。测试密钥使用 `sk_test_`，生产使用 `sk_live_`，商品及回调必须属于同一模式。

订阅继续由 Better Auth 的 `/api/auth/stripe/webhook` 处理。积分包使用独立的 `/api/payments/stripe` 回调及独立签名 secret，不能复用订阅 endpoint secret。当前已安装 Stripe SDK 22.6.2、Better Auth Stripe 1.7.4；本批没有添加第二套订阅状态处理器。

积分 endpoint 接收以下事件：`checkout.session.completed`、`checkout.session.async_payment_succeeded`、`checkout.session.async_payment_failed`、`checkout.session.expired`、`charge.refunded`、`refund.created` / `updated` / `failed`、`charge.dispute.created` / `updated` / `closed` / `funds_withdrawn` / `funds_reinstated`。其余已验签事件被忽略，不参与积分发放。

回调先限制原始请求为 256 KiB，再用 SDK 与 Web Crypto 校验签名、时间和测试 / 生产模式。错误签名或模式返回 400，过大请求返回 413，处理失败返回 503 以允许 Stripe 重试；只有事务已提交或事件确实不属于本模块时返回 200。不要将解析后的 JSON 重新编码来验签。[Stripe Webhook 文档](https://docs.stripe.com/webhooks)。

### 创建、回跳和发放

1. `/credits` 显示本站公开目录及本人购买历史。个人所有权不随组织切换改变，匿名会话与跨站创建被拒绝；操作有独立限流和可选 Turnstile 验证。
2. 服务端先保存 `creating` 购买和不可变报价，再调用 Stripe；客户端仅确认 SKU、版本、金额、币种和积分，不能指定 Price ID 或其他用户。原请求键用于断线重试，同键改商品或报价被拒绝。
3. 每笔购买有 60 秒操作租约。Checkout 使用固定购买 ID 作为 provider 幂等键，返回链接只能来自 `https://checkout.stripe.com`。外部调用在数据库事务之外；未知创建结果保留原购买，避免另开一笔。购买固定一小时到期，到期后不再重新创建。
4. 成功和取消都回到发起语言的购买详情：英文 `/credits/purchases/<购买 ID>`，西班牙语 `/es/credits/purchases/<购买 ID>`。页面查询服务端状态，URL 不携带发放权限。等待状态定时刷新，已确认状态显示积分；网络故障时不提供可能过时的继续支付按钮。
5. 签名事件处理期间读取 Stripe 当前 Checkout、PaymentIntent 和 Charge，核对归属、商品、数量、金额、币种、模式及实际支付状态。延迟支付未成功时不发积分，旧失败或过期事件不能覆盖已经发放的购买。
6. 唯一事件记录、`paid` 购买状态、账户余额与 `purchase:<ID>:grant` 账本事件在同一事务提交。不同事件 ID 指向同一笔付款也只能发一次；失败回滚后可重新投递。账本可进入关联购买详情。

迁移 `0008_credit_purchases.sql` 增加购买和去重记录，并给账户增加支付核查标记、账本增加购买关联。历史迁移不被重写。账号删除当前仍沿用购买级联删除；事件和账本的购买关联不是级联外键，**尚未构成永久财务审计保留制度**，生产售卖前需补齐 I9 的保留 / 删除规则。

### 退款、争议与积分差额

每次事件和本人发起的服务端核查都会读取当前 Checkout、PaymentIntent、Charge，并分页读取全部退款与争议，不按事件载荷或到达顺序直接加减积分。单种记录最多自动检查 1,000 条；超过上限、未知状态或付款信息不符时保留核查，不用截断结果计算。重读 Charge 与列表不一致时等待后续核查，不据此恢复积分。[退款状态](https://docs.stripe.com/api/refunds/object)、[争议状态](https://docs.stripe.com/api/disputes/object)。

| 情况 | 当前积分规则 |
| --- | --- |
| 成功退款 | 只累计 `succeeded` 金额，按购买快照计算扣回 |
| 待处理 / 需要操作退款 | 不扣回该退款积分，暂停进一步收费使用 |
| 失败 / 取消退款 | 不计入当前成功退款；如曾扣回，仅恢复实际扣过的部分 |
| 争议处理中，包括 inquiry | 暂停收费，并按争议金额预留扣回；暂时资金恢复事件不能代替最终结果 |
| 争议败诉 `lost` | 保留扣回；扣回齐且无其他未解决情况时可解除该笔核查 |
| 胜诉、询问关闭或争议被阻止 | `won` / `warning_closed` / `prevented` 不再占用争议扣回额度；已成功退款的额度仍保留 |
| 信息不匹配 | 持续人工核查，普通重查不能自动清除；管理员处理界面留在 I9 |

扣回总额采用可复核的累计公式：`floor(购买积分 × min(原付款金额, 成功退款总额 + 未恢复争议金额) / 原付款金额)`。全额退款扣回全部积分，多次部分退款统一累计后向下取整，避免每个退款单独取整造成偏差。退款与争议重叠时最多扣回原积分包数量；手续费和汇率损失不换算成额外积分。超过购买金额或不支持的币种状态转入核查。

例如 100 积分售价 900 最小货币单位，两笔各退款 200 时，累计应扣回 `floor(100 × 400 / 900) = 44`；第一次扣 22，第二次再扣 22。补齐全额退款时累计扣回 100。

余额不足时先扣可用部分，余额保持非负，并保存 `shortfallCredits`。例如账户只剩 10，应扣回 100，则余额为 0、已扣回 10、差额 90。暂停新积分包购买、新收费任务和收费任务手动重试；已有 Checkout 链接也在本站界面隐藏。免费任务、历史查看、取消返还仍可用，执行中的已预扣任务继续原结算。本站隐藏链接不会使已在外部打开的 Stripe 页面失效，最终仍以签名事件和当前付款状态核对。

争议胜诉时，只恢复实际扣过的积分，不把 90 的差额也当成可返积分。上例恢复 10，差额清零。若是退款且未解除，任务取消等使余额重新可用后，差额仍保持核查，直到另一次可信事件或 `purchases.reconcile` 从当前支付状态重新核对并扣回剩余部分；不自动谅解差额，不通过手改余额绕过状态。

详情页显示退款 / 争议金额、累计应扣回、实际已扣回和差额，另显示最近 20 条事件与服务端核查记录。`Check payment status` 只读取本站状态；已配置支付服务时的 `Recheck with payment provider` 会调用当前支付方核查。后者要求本人、同站来源和业务限流，客户端仅提交购买 ID；关闭新售卖后仍可核查既有购买，不能随便提交扣回金额或解除标记。

迁移 `0009_credit_purchase_settlement.sql` 增加累计对账字段和事件快照。每次余额调整使用单调递增版本 `purchase:<ID>:adjustment:<版本>`，并以 `reversal` / `restoration` 保存账本。事件、购买快照、余额和核查解除同事务提交。所有购买处理先持有自己的租约，再持有余额锁；解除账户标记时检查本人全部购买，避免另一笔并发争议被清掉。

### 验收边界

本地支付测试使用真实 Stripe SDK、受控 HTTP 响应和实际 PGlite 迁移，覆盖响应丢失、报价变化、重复 / 乱序 / 延迟事件、事务回滚再投递、信息不匹配、退款 / 争议核查与越权拒绝。页面测试覆盖旧请求保留、服务端确认、差额显示、核查返回的实际恢复额，以及另一笔付款暂停时禁用已有 Checkout；这些均不是真实支付。

`bun payments:validate` 使用生产 workerd bundle 与一次性本地 PostgreSQL，实测签名、超时签名、模式及大小边界；合法的订阅发票事件被忽略，未调用 Stripe API，也未验收 Checkout 发放。该命令另验收上文 Better Auth 在原生 Worker 中的订阅更新边界；其临时资源会清理。`bun payments:validate-settlement` 另用八个实际 PostgreSQL 会话、真实 SDK 和受控 HTTP 验证并发 Checkout、不同事件只发一次、退款与任务消费竞争、多笔核查 / 恢复、回滚再投递；它不是 Worker HTTP 或真实支付。真正 Stripe 沙箱购买、退款 / 争议和订阅完整生命周期继续保留为 I6 外部验收门槛。[Checkout 创建参数](https://docs.stripe.com/api/checkout/sessions/create)、[Better Auth Stripe 插件](https://better-auth.com/docs/plugins/stripe)。
