支付、积分与任务
当前支付能力
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 设置:
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 | 保留预扣,等待核查;请求取消不能直接退款 |
终态、结算 / 退款与账本在同一事务提交。并发取消、失败和发布由任务行锁裁定;旧执行版本不能发布,已退款任务不能领取或重试。数据库异常或原预扣不一致时回滚并报错,不伪装成已退款。
本地检查与验收
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。任务执行细节见任务生命周期;已执行证据见迭代记录。云端、真实验证码、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 幂等请求说明。
创建和恢复已知 Checkout 都分页检查该客户的当前全部订阅,最多 1,000 条;不能只检查第一页,遇到未结束订阅或超过上限时保留核查。设置页从当前本人 / 组织账单接口获得原请求,提供“Resume original checkout”,不同时提供新购按钮;用户无法从查询其他账户拿到该请求。续费失败或旧取消记录不会遮住新的待支付记录,多笔未解决订阅不任意挑一笔。
新请求在 Checkout 元信息中保存 subscriptionAttemptId,用于查找已接受但本地失去响应的会话。升级前已经发送的旧请求若没有标记,仍沿用原 SDK 参数摘要和幂等键,不在重试时追加字段。设置页提供“Check previous checkout”,可以在原请求超过本地期限后继续核查。
该按钮调用 /api/auth/subscription/recover,只核查服务端保存的原请求,不能由浏览器指定待认领的会话 ID。已有 ID 时读取当前会话;未知时,按原客户和固定一小时创建窗口分页读取 Checkout,最多检查 1,000 条,并要求恰好一个请求标记匹配。读取后重新核对主体、客户、模式、本地插件行、原期限及标记;列表中的历史状态不能直接作为结果。Stripe 会话列表接口。
| 当前原会话 | 恢复行为 |
|---|---|
| open | 核对当前客户没有未结束订阅后保存原会话;页面刷新并允许继续原请求,核查本身不跳转或创建付款 |
| complete 且 paid / no_payment_required | 复用已验证当前资源和插件回跳处理,事务内保存校验、提交后核对,再确认原请求已解决;页面按实际订阅状态显示权限 |
| expired | 会话没有关联订阅,客户没有未结束订阅,且原本地行仍 incomplete / 没有供应商订阅时,释放原请求;供应商提前过期也适用,随后才允许新请求键并复用插件行 |
| 未付款、找不到、多个匹配、列表不完整 / 超限或信息不符 | 保留核查;不从“查不到”推断可以再付一次,失败不会创建替代 Checkout |
供应商不可用返回 503,可以再次核查;主体忙或仍无法确认返回 409。核查中的旧租约和已撤销组织管理权不能写回。旧请求未保存会话 ID,且实际 Checkout 没有新增标记时,仍不能自动归并;已有 ID 的旧请求可以继续核查,没有 ID 的旧请求使用下面的管理员人工流程。不要手删操作记录来解锁付款。
未标记旧结账的人工核查
站点管理员在管理页选择账号,进入订阅记录中的旧结账恢复表单;组织账单可在全部记录中填写实际组织 ID。需要当前有效的站点管理员身份及近期登录,团队所有者不因此获得管理权限。账单配置必须完整,正常未配置支付的本地预览不会调用真实 Stripe。
- 在正确测试 / 正式账户的 Stripe 控制台核对原始创建请求:客户、本地订阅编号、
subscription-checkout-<attemptId>幂等键及返回的会话编号。单凭邮箱、金额或“看起来相同”的会话不能认领。此步骤需要实际人工确认;接口的归属核对不能证明原幂等键。 - 填入原会话编号,先核查。服务端按原客户和固定一小时窗口分页读取所有状态,最多 1,000 条;同一本地订阅编号及原到期时间必须恰好对应这一个会话。再读取当前会话,核对主体、客户、模式、测试 / 正式环境、原期限及创建时间,且确实没有新请求标记。本地原请求必须仍未解决、没有已知会话,原插件行仍未完成且没有供应商订阅,客户只能归属这一账单主体。找不到、多个候选、分页异常或信息不符均保持待核查。
- 页面展示原尝试编号、当前状态和到期时间。填写原请求核对的原因,再确认关联。提交重新读取供应商,按核查摘要防止会话状态或本地请求在确认期间变化;变化后取消并重新核查。管理员权限、会话及原租约在提交事务再次验证。
- 原会话编号 / 原继续地址和两条永久审计一起提交:
subscription.checkout_link_requested、subscription.checkout_link_completed。审计失败全部回滚,保留原请求 ID 重试;失去响应后再次确认相同请求不会重复关联。审计保存账单主体、原尝试、会话编号、摘要、状态及人工原因,不保存供应商完整载荷、支付地址或密钥。 - 关联成功仅补齐原会话记录;没有创建付款、退款、发放权益或解除购买阻塞。账号持有人随后在账单页核查原结账,复用上表的 open / complete / expired 处理。即使会话显示完成,实际付款和订阅状态仍由原核查与插件验证。
缺少原始请求证据、相同时间窗存在多条候选、价格 / 客户配置变化导致原恢复仍拒绝时,继续调查供应商记录;本入口没有强制清空或指定“已支付”的选项。带新请求标记的未知会话使用原自动核查,不走这个兼容入口。Stripe 会话读取接口、分页与状态过滤用于当前资源核对;本地受控响应不能代替真实沙箱验收。
回跳要求新鲜登录和账单管理权限,先核对 provider 会话、元信息、本地订阅、客户及测试 / 正式模式;插件再次读取供应商时也经过相同归属校验。不能用其他人的 Checkout ID 让插件写入其订阅。升级 / 门户等写入口每人每分钟最多 10 次,回跳及原请求核查共用每分钟 30 次的独立额度;跨站来源在处理前被拒绝,回跳地址只能是当前站点。
当前状态同步与故障重投
订阅回调先限制原始载荷为 256 KiB,保持原始字节验签,再核对模式和本地主体后获取租约。过大返回 413;错误签名、归属或模式返回 400;主体忙、供应商读取失败、当前商品列表不完整或无法确认受支持方案返回 503,允许再次投递。所有非 2xx 都不能当作处理成功。
租约内通过真实 SDK 读取当前订阅;Checkout 通知还先读取当前会话,核对完成状态、关联订阅、客户和元信息。校验当前对象与本地记录的主体、客户、订阅 ID、模式、配置价格、月付 / 年付周期及试用范围一致。已存在的行将当前对象交给插件的更新处理器;只属于一个已知客户且尚无记录的订阅交给创建处理器。旧 created / updated / deleted 或 Checkout 通知因此不会直接用历史快照恢复旧试用、覆盖新的取消状态。Stripe 不保证事件按顺序到达,应在需要时读取最新对象,见官方回调说明和订阅读取接口。
订阅表仍只由 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 官方说明;同时保留项目的补丁说明与第三方许可证。
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 后才能启用:
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 文档。
创建、回跳和发放
/credits显示本站公开目录及本人购买历史。个人所有权不随组织切换改变,匿名会话与跨站创建被拒绝;操作有独立限流和可选 Turnstile 验证。- 服务端先保存
creating购买和不可变报价,再调用 Stripe;客户端仅确认 SKU、版本、金额、币种和积分,不能指定 Price ID 或其他用户。原请求键用于断线重试,同键改商品或报价被拒绝。 - 每笔购买有 60 秒操作租约。Checkout 使用固定购买 ID 作为 provider 幂等键,返回链接只能来自
https://checkout.stripe.com。外部调用在数据库事务之外;未知创建结果保留原购买,避免另开一笔。购买固定一小时到期,到期后不再重新创建。 - 成功和取消都回到发起语言的购买详情:英文
/credits/purchases/<购买 ID>,西班牙语/es/credits/purchases/<购买 ID>。页面查询服务端状态,URL 不携带发放权限。等待状态定时刷新,已确认状态显示积分;网络故障时不提供可能过时的继续支付按钮。 - 签名事件处理期间读取 Stripe 当前 Checkout、PaymentIntent 和 Charge,核对归属、商品、数量、金额、币种、模式及实际支付状态。延迟支付未成功时不发积分,旧失败或过期事件不能覆盖已经发放的购买。
- 唯一事件记录、
paid购买状态、账户余额与purchase:<ID>:grant账本事件在同一事务提交。不同事件 ID 指向同一笔付款也只能发一次;失败回滚后可重新投递。账本可进入关联购买详情。
迁移 0008_credit_purchases.sql 增加购买和去重记录,并给账户增加支付核查标记、账本增加购买关联。历史迁移不被重写。账号删除当前仍沿用购买级联删除;事件和账本的购买关联不是级联外键,尚未构成永久财务审计保留制度,生产售卖前需补齐 I9 的保留 / 删除规则。
退款、争议与积分差额
每次事件和本人发起的服务端核查都会读取当前 Checkout、PaymentIntent、Charge,并分页读取全部退款与争议,不按事件载荷或到达顺序直接加减积分。单种记录最多自动检查 1,000 条;超过上限、未知状态或付款信息不符时保留核查,不用截断结果计算。重读 Charge 与列表不一致时等待后续核查,不据此恢复积分。退款状态、争议状态。
| 情况 | 当前积分规则 |
|---|---|
| 成功退款 | 只累计 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 创建参数、Better Auth Stripe 插件。