关于 stripe-billing
stripe-billing 是面向开发者的 Stripe 订阅与计费技能,重点说明如何在集成边界保持支付事件、权益和应用数据一致。
主要功能
- 01Webhook 处理与去重
- 02结账和订阅生命周期指南
- 03权益授予与续订规则
- 04价格和产品完整性检查
- 05测试与本地开发指南
适用场景
- 01实现订阅结账与验证
- 02防止 Webhook 重复授予权益
- 03处理续订、计划变更和取消
- 04核对 Stripe 与应用代码中的价格
MCP 相关
该技能建议优先使用 Stripe 自带的 MCP 服务、CLI 和 Agent 技能,并聚焦于这些工具未覆盖的集成问题。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ ssheleg/ sheleg-dev --skill stripe-billing安装后在 Claude Code 中输入/stripe-billing确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
fixtures/assert-money-invariants.mjscharge-refunded-partial.jsoncharge-refunded-remainder.jsoncheckout-session-async-payment-failed.jsoncheckout-session-completed-async-unpaid.jsoncheckout-session-completed-paid.jsoncustomer-discount-created-retention.jsoncustomer-subscription-updated-cancel-classic.jsoncustomer-subscription-updated-cancel-flexible.jsoncustomer-subscription-updated-discount-consumed.jsoninvoice-paid-subscription-cycle-next-period.jsoninvoice-paid-subscription-cycle-redelivery.jsoninvoice-paid-subscription-cycle.jsoninvoice-paid-subscription-update-proration.jsonmanifest.jsonREADME.mdreference-handler.mjsreferences/cancellation-and-retention.mdprice-integrity.mdprovider-concentration.mdstripe-agent-toolchain.mdsubscription-lifecycle.mdtesting-and-local-dev.mdwebhook-events.mdscripts/assert-stripe-env.mjs详细介绍
Stripe 计费
Stripe 负责保管资金。你的数据库负责保管权益。所有严重的计费缺陷,都是同一个事实存在于两个系统中,却不再一致——价格、数量、周期或退款总额。
Stripe 的 API 并不是集成出错的地方。故障发生在衔接处:回调重复送达、续订因一张 $0.40 的按比例计费发票就赠送了一个月的产品,或升级已扣款却未能写入记录。
这个技能关注的正是这些衔接处。对于应使用哪种 Stripe 基础功能,官方的 stripe-best-practices 技能具有权威性;如有分歧,以它为准;查找资料则使用 stripe-docs。示例使用 TypeScript;规则与语言无关。
深入内容按需加载:
| 阅读 | 适用场景 |
|---|---|
references/stripe-agent-toolchain.md | 从零开始,或准备猜测 API 形式时——CLI、MCP、技能索引、密钥处理 |
references/webhook-events.md | 编写或审查处理程序时——事件目录、负载结构、顺序、故障语义 |
references/subscription-lifecycle.md | 实现结账、验证、续订、席位、套餐变更、试用、权益收回时 |
references/cancellation-and-retention.md | 实现取消或重新激活,或在取消步骤提供优惠券或暂停服务时 |
references/price-integrity.md | 定价信息存在于多个文件中,或必须对照 Stripe 验证展示价格时 |
references/testing-and-local-dev.md | 本地 Webhook 和模拟,以及已随项目提供的 fixtures/——现成的测试套件 |
references/provider-concentration.md | 收入增长、开拓第二个市场、区分非自愿流失,或支付账户受限时 |
从 Stripe 自带的工具开始
Stripe 提供自己的 MCP 服务、CLI 和 Agent 技能;API 每月都会变化,因此优先使用这些工具。这个技能负责处理它们无法覆盖的衔接问题:哪个信号代表付款、重定向能证明什么,以及资金会在哪里被重复计算。相关命令见 references/stripe-agent-toolchain.md。
两本账本
browser your server Stripe
│ choose plan ──────────►│
│ │ create checkout session ──►│
│◄──────── url ───────────│◄──── session + hosted page ─│
│ pays on Stripe's page ─┼────────────────────────────►│
│◄─ redirect (proves nothing) ──────────────────────── │
│ │◄──── webhook (at-least-once)│
│ │ write entitlement │
│ │◄──── webhook (again, later) │
└── nightly: reconcile ─────►│
金额、税费、发票、催款和支付方式归 Stripe 管理。谁可以使用什么、可以有多少个席位,以及授予了多少额度,则归你管理。两者之间仅通过 ID 关联。
每个事实只保留一个归属地。 把金额从 Stripe 复制到代码中,你就要承担数据漂移的风险——而且这种风险不会显现,因为结账时传递的是价格 ID,具体金额则由 Stripe 保管。金额错误不会导致请求失败;它只会错误地展示给客户。详见 references/price-integrity.md。
客户端
// Copy the linked assert-stripe-env.mjs beside this client (see price-integrity.md).
import { assertStripeEnv } from "./assert-stripe-env.mjs";
let client: Stripe | null = null;
export function getStripe(): Stripe {
if (!client) {
const key = process.env.STRIPE_SECRET_KEY;
assertStripeEnv(process.env.STRIPE_PRODUCT_MODE, key);
// Pin checked 2026-08-30 — 2026-08-26.dahlia was already newer. Confirm
// against Stripe's changelog before pinning; this line goes stale monthly.
client = new Stripe(key, { apiVersion: "2026-07-29.dahlia", maxNetworkRetries: 2 });
}
return client;
}
- 延迟初始化,而非在模块级初始化。 在导入时执行
new Stripe(...),会导致任何在没有密钥的情况下导入该模块的构建步骤崩溃。 - 固定
apiVersion。 不固定时,响应结构会按 Stripe 的节奏而非你的节奏变化。升级应作为一项任务(stripe:upgrade-stripe)处理,而不是在部署当天突然出现意外。 - 使用
maxNetworkRetries,不要手写循环。 SDK 会为每个请求生成幂等键,这是重试写入操作时唯一能确保安全的机制。围绕subscriptions.create编写循环,一次操作意图可能会创建两个订阅。如需主动重试,请传入你自己的稳定{ idempotencyKey }。
产品、价格和两种模式
结账流程使用价格;你的代码应使用产品。调价时,价格会被替换;产品则保持稳定。
- 客户可选的每个套餐对应一个产品。 同一产品上的多个价格只用于同一套餐的不同版本(按月与按年、不同货币)。多个层级共用一个产品,会让每条发票项目都显示相同的名称,并破坏其余代码依赖的产品 ID → 套餐映射。
- 在配置中固定价格 ID,并将
prices.list({ active: true })作为后备方案。 调价后,一个产品会有两个有效价格,且顺序无法保证。 - 校验允许列表需要包含两种模式的 ID。 数据库中既有测试模式写入的记录,也有实时模式写入的记录;如果 Webhook 只根据当前模式检查“是否销售此产品”,就会拒绝真实历史记录。
resource_missing几乎总是意味着模式不匹配。 捕获该错误并说明原因,注明模式和密钥前缀——未经处理时,它会显示为“产品已删除”。
获取或创建客户是一场竞态
两个标签页、两个请求、两个客户,第二个客户会悄无声息地接管第一个客户正在扣款的订阅。先创建客户,再通过条件更新认领其 ID;如果更新未匹配到任何行,说明你输掉了竞态,因此应删除孤立客户并读取胜出者的 ID。创建之前,先检索已存储的 ID;如果出现 resource_missing 或 deleted: true,就将其视为“创建一个新的”——密钥轮换后通常会出现这种情况。代码见
references/subscription-lifecycle.md。
结账会话
const metadata = { userId, productId, quantity: String(qty) };
await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: qty }],
// Never pass payment_method_types — Stripe picks eligible methods from
// Dashboard settings; hardcoding ['card'] locks out methods that convert.
metadata, // reaches checkout.session.completed
subscription_data: { metadata }, // reaches EVERY later subscription event
success_url: `${origin}/after?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/plans`,
});
- 写入两次元数据。 会话元数据不会传递到订阅。续订发票和每个
customer.subscription.*事件都携带订阅信息——一年后,会话已不存在,你唯一拥有的userId就是subscription_data.metadata中的那个。 - 针对你自己的来源校验调用方提供的返回 URL。 如果某个端点直接传递请求正文中提供的
successUrl,它就是披着支付流程外衣的开放重定向。 - 在资金流转之前防止重复,而不是事后处理:如果同一产品已有有效订阅,则返回 409 和现有 ID。一个席位出现第二个有效订阅,就得讨论退款了。
{CHECKOUT_SESSION_ID} 由 Stripe 替换,而不是由你渲染。2026-03-25.dahlia+ 中的 integration_identifier 标签让你可以在 Dashboard 中比较各个流程(底线已于 2026-08-30 对照 Stripe 的更新日志核查)。
有三项决策属于 stripe-best-practices:按用量计费
(任何新项目都用 Metronome;Billing Meters 是底层原语)、税务
(在存在注册之前,automatic_tax 不会收取任何税款——启用它并不代表合规),以及在资金流向第三方时使用 Connect。
Webhook 就是支付
export async function POST(request: Request) {
const body = await request.text(); // RAW — not parsed JSON
const signature = request.headers.get("stripe-signature");
if (!signature) return json({ error: "missing signature" }, 400);
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return json({ error: "invalid signature" }, 400); // no detail — it is an oracle
}
const claim = await claimEvent(event.id); // INSERT on a primary key
if (claim === "completed") return json({ received: true, duplicate: true });
if (claim === "in_flight") return json({ error: "in flight" }, 500); // retry later
try {
await handle(event);
} catch {
await releaseEventClaim(event.id); // let the retry back in
return json({ error: "handler error" }, 500);
}
return json({ received: true });
}
- 原始请求正文。 重新序列化已解析的请求正文会改变字节内容,导致签名校验失败。请为此路由禁用自动解析。
- 仅对这个路径豁免 CSRF 和会话身份验证,并且只豁免这个路径——使用精确匹配:对
/api/billing进行前缀匹配也会豁免结账流程,而资金就在这里流转。 - 先认领,再处理——认领是收据,不代表已完成。 先
SELECT再INSERT会产生竞态;相隔 40 ms 的两次投递都会发放权益。该行会保持processing状态,直到发放操作的事务将其标记为completed——状态、过期时间、接管和释放方式见:references/webhook-events.md。 - 如实响应。 已处理或重复事件返回 200,签名错误返回 400,发生故障时返回 5xx 以便重试,未处理的类型返回 200。切勿在失败时返回 200 来阻止重试——这样会悄无声息地丢弃一笔支付。
- 处理程序内的顺序: 先执行可能失败的外部调用,然后在一个事务中处理所有操作——权益、去重标记、完成状态、发件箱记录——之后再从发件箱排出副作用,每项都使用各自的消费者键。
各事件的详细信息:references/webhook-events.md。
重定向证明用户在浏览器中
银行卡扣款成功时,Stripe 会执行重定向;Webhook 何时到达,就何时到达。在这段时间里,用户已经打开你的成功页面,而数据库对此一无所知。
部署一个由成功页面调用的验证端点:检索会话,检查 metadata.userId 是否与调用者一致,检查 status === "complete" 且 payment_status !== "unpaid",然后执行 与 Webhook 完全相同的写入——并让唯一约束来裁定。无论哪一方遇到重复键错误,都捕获错误并报告成功。
所有权检查至关重要:没有它,任何得知 cs_… ID 的已验证用户都能冒领他人的购买记录。这也是本地开发唯一可行的方式——但它只是安全网,绝不能成为主要路径。用户关闭标签页后,仍必须获得已付款的商品。
续订:由 billing_reason 决定
invoice.paid 会因多种情况触发。在所有情况下都发放产品,是本文中代价最高的错误。
billing_reason | 含义 | 发放? |
|---|---|---|
subscription_create | 首张发票 | 否 — 结账时已处理 |
subscription_cycle | 续订 | 是 |
subscription_update | 周期中按比例计费 | 否 |
manual、subscription_threshold | 由你或计量器创建的发票 | 明确处理 |
数量变更会立即触发 subscription_update。如果该路径会发放产品,用户添加并移除席位四次,就会因四张按比例计费的发票而获得四个月的产品使用权。
从订阅项目读取周期 — sub.items.data[0].current_period_start / current_period_end。近期 API 版本调整了这些字段的位置;读取旧版顶层字段的代码会得到 undefined,并存入一个纪元日期,且不会报错。
使用标记(lastGrantedPeriodStart,或以发票 ID 为键的审计行)来保护发放操作;在与发放操作相同的事务中检查该标记,以免 Webhook 和对账任务同时发放同一周期的权益。
billing_mode — 创建时做出的一次性选择
Stripe 会为每个订阅设置两种计费模式之一。在 subscriptions.create(或 Checkout 会话中的 subscription_data)时做出选择,之后无法更改;Stripe 建议新订阅使用 flexible。
之所以在此处介绍,是因为它会改变后续章节讲解的计算方式:在 flexible 模式下,抵扣类按比例调整项根据最初扣取的金额计算,因此一次变更可能产生多条抵扣类按比例调整项,而 classic 模式只会产生一条。在撤销路径中,取按比例调整项的 [0] 会悄无声息地出错——这是本文中唯一一次算错金额就会造成实际损失的地方。它还决定用于记录计划取消的字段。模式选择属于 stripe-best-practices 负责的产品决策。
席位和按比例计费
proration_behavior:always_invoice表示立即收费,create_prorations表示推迟到下一张发票,none表示不做调整——这是 撤销 的正确选择。包含补偿性撤销的调用见references/subscription-lifecycle.md。- 升级时使用
payment_behavior: "error_if_incomplete"。 否则,银行卡扣款被拒后,订阅仍会升级且未付款,而你的数据库也会记录升级成功。捕获StripeCardError并返回 402。 - 写入顺序: 先写 Stripe,再写数据库。如果数据库写入失败,使用
proration_behavior: "none"撤销 Stripe 端的变更,并大声记录撤销失败——这是唯一需要人工修复的状态。反过来则会为 Stripe 根本没有售出的席位收费。 - 在服务端限制数量上限,并将数量下限设为 1。移除最后一个席位属于取消,应通过取消路径处理。将数量降至当前使用量以下属于业务决策:返回 409,并说明必须释放哪些资源,让用户自行选择。
取消订阅,以及挽留优惠
- 默认在周期结束时取消——用户可以继续使用已付费的服务,
status保持为active。在灵活的billing_mode下,通过门户取消订阅会设置cancel_at,并让cancel_at_period_end保持为 false——应根据这两个字段判断“是否正在取消”,绝不能只看布尔值。 - 在
customer.subscription.deleted中执行清理,绝不要紧挨着 API 调用执行,这样 UI、门户和催款流程都能走同一条路径。 - 收到
invoice.payment_failed时,将状态标记为past_due并通知用户——不要取消订阅。重试和最终状态由 Stripe 的催款流程决定。 - 挽留优惠的资格由你决定。 Stripe 无法回答“这位客户之前是否享受过折扣”——
duration=once的折扣在最终结算后仍会留在subscription.discounts中——因此要自行跟踪资格。
两者的代码见:
references/cancellation-and-retention.md。
退款金额是累计值
charge.amount_refunded 是迄今为止的退款总额,不是本次退款金额。两笔部分退款会分别返回 4000 和 9000;如果将其视为增量,就会从 $90 的扣款中追回 $130。应根据已存储的总额计算增量,并使用比较并交换写入,这样并发到达时只会有一次成功,不会重复追回款项——代码见
references/subscription-lifecycle.md。
退款要么属于一次性付款(通过 payment_intent 查找),要么属于订阅发票(没有购买记录——按 charge.invoice → 发票 → 订阅的路径解析)。两种情况都要处理,否则订阅退款会悄无声息地让客户继续持有产品。
对账
Webhook 只能尽力投递,服务中断也并非不可能。运行一个任务,使用 status: "all" 列出订阅,创建缺失的记录,并在状态、周期、数量和价格不同时更新它们——要逐个顺序处理,因为每次迭代都会开启事务,还可能调用外部 API。任务执行的任何授权操作都要复用 Webhook 的幂等标记,否则夜间任务就会变成夜间送礼。
关键防护措施:当 Stripe 中不存在对应订阅时,将本地记录标记为已取消,但排除从未属于 Stripe 的记录。赠送、手动及其他支付服务商的套餐使用合成 ID,取消它们会造成自找的服务中断。代码以及它还兼作的“立即同步”按钮,见
references/subscription-lifecycle.md。
金额使用最小货币单位:在边界处转换一次,并以整数美分进行比较——浮点数中 Math.abs(20.83 - 20.84) > 0.01 的结果是 true。
依赖单一服务商
只依赖一家服务商处理银行卡支付,会让收入面临单点故障;是否如此取决于业务,而非本技能。代码应提供一个衔接点:通过接口处理扣款,客户 ID 由你掌控,绝不能让 Stripe 对象 ID 成为付费账户的唯一标识。相关论述、迁移方案和成本见
references/provider-concentration.md。
本地开发与测试矩阵
两者都见 references/testing-and-local-dev.md——stripe listen --forward-to、CLI 触发命令、测试卡和拒绝代码,以及用于续订和按比例计费的时钟技巧。在开始前需要了解一点:stripe listen 打印的签名密钥不是仪表板中的密钥;使用错误的密钥会导致验证失败,且报错看起来像是密钥有问题。
降级处理
- 不是 Claude Code(Cursor、Codex、skills CLI、API 容器):本工具包提供的
PreToolUse资金关卡在这些环境中不存在——没有任何机制会代你拒绝退款、打款、关闭争议或使用有效的sk_live_…密钥。以下每条规则仍然适用;但它们只是建议,而非前置条件,因此授权步骤需要由人工完成。操作资金前,请说明你使用的是哪种环境。 - 通过复制而非作为插件安装:同样会失去关卡,补救方式也相同——直接复制到
~/.claude/skills/只包含原则,不包含hooks/money-gate.js。README 列出了相关类别及其签署方式;没有这个钩子,就必须有人实际说出这句签署声明。 - 没有 Stripe CLI、没有网络或只有测试密钥:Webhook 签名验证和本地重放循环都需要该二进制文件。说明一次缺少什么,然后继续完成其余工作,并记录无法验证的内容,不要暗示已经验证过。
发布之前
这不是第二份检查清单——以下每条规则都已在上文有对应章节,而一条规则如果出现在两个地方,其中一处就会逐渐偏离。以下四项一旦出错,造成的损失会涉及资金,而非错误页面:
- Webhook 就是付款凭证。 切勿依据重定向授权(§ Webhook 就是付款凭证,§ 重定向只能证明浏览器完成了跳转)。
- 根据原始请求体验证签名。 使用解析后的请求体会导致验证失败,而且错误看起来像是密钥有问题。
- 每个处理程序都必须以
event.id实现幂等。 Stripe 会重试,而重试导致重复授权,就得讨论退款了。 - 按计划进行对账。 未送达的 Webhook 会导致已付款客户无法访问,而日志中不会有任何提示(§ 对账)。
完整的安全检查清单位于 references/stripe-agent-toolchain.md;其中原先列出的每个陷阱都已在对应规则处说明,而这正是确保规则始终准确的唯一位置。
触发词
更多会将请求路由到此处的短语:"invoice.paid"、"proration"、"refund"、"retention coupon"、"скидка при отмене"、"биллинг подписок"、"оплата"。
以下短语于 0.16.2 从描述移至此处:"Stripe checkout"、"subscription billing"、"cancel subscription"、"оплата подпиской"、"вебхук Stripe"。