mcpField
S
stripe-billing

stripe-billing

github.com/ssheleg开发4220 次安装3 天前更新
代码仓库

关于 stripe-billing

stripe-billing 是面向开发者的 Stripe 订阅与计费技能,重点说明如何在集成边界保持支付事件、权益和应用数据一致。

主要功能

  1. 01Webhook 处理与去重
  2. 02结账和订阅生命周期指南
  3. 03权益授予与续订规则
  4. 04价格和产品完整性检查
  5. 05测试与本地开发指南

适用场景

  1. 01实现订阅结账与验证
  2. 02防止 Webhook 重复授予权益
  3. 03处理续订、计划变更和取消
  4. 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.mjs
charge-refunded-partial.json
charge-refunded-remainder.json
checkout-session-async-payment-failed.json
checkout-session-completed-async-unpaid.json
checkout-session-completed-paid.json
customer-discount-created-retention.json
customer-subscription-updated-cancel-classic.json
customer-subscription-updated-cancel-flexible.json
customer-subscription-updated-discount-consumed.json
invoice-paid-subscription-cycle-next-period.json
invoice-paid-subscription-cycle-redelivery.json
invoice-paid-subscription-cycle.json
invoice-paid-subscription-update-proration.json
manifest.json
README.md
reference-handler.mjs
references/
cancellation-and-retention.md
price-integrity.md
provider-concentration.md
stripe-agent-toolchain.md
subscription-lifecycle.md
testing-and-local-dev.md
webhook-events.md
scripts/
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`,
});
  1. 写入两次元数据。 会话元数据不会传递到订阅。续订发票和每个 customer.subscription.* 事件都携带订阅信息——一年后,会话已不存在,你唯一拥有的 userId 就是 subscription_data.metadata 中的那个。
  2. 针对你自己的来源校验调用方提供的返回 URL。 如果某个端点直接传递请求正文中提供的 successUrl,它就是披着支付流程外衣的开放重定向。
  3. 在资金流转之前防止重复,而不是事后处理:如果同一产品已有有效订阅,则返回 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 签名验证和本地重放循环都需要该二进制文件。说明一次缺少什么,然后继续完成其余工作,并记录无法验证的内容,不要暗示已经验证过。

发布之前

这不是第二份检查清单——以下每条规则都已在上文有对应章节,而一条规则如果出现在两个地方,其中一处就会逐渐偏离。以下四项一旦出错,造成的损失会涉及资金,而非错误页面:

  1. Webhook 就是付款凭证。 切勿依据重定向授权(§ Webhook 就是付款凭证,§ 重定向只能证明浏览器完成了跳转)。
  2. 根据原始请求体验证签名。 使用解析后的请求体会导致验证失败,而且错误看起来像是密钥有问题。
  3. 每个处理程序都必须以 event.id 实现幂等。 Stripe 会重试,而重试导致重复授权,就得讨论退款了。
  4. 按计划进行对账。 未送达的 Webhook 会导致已付款客户无法访问,而日志中不会有任何提示(§ 对账)。

完整的安全检查清单位于 references/stripe-agent-toolchain.md;其中原先列出的每个陷阱都已在对应规则处说明,而这正是确保规则始终准确的唯一位置。

触发词

更多会将请求路由到此处的短语:"invoice.paid"、"proration"、"refund"、"retention coupon"、"скидка при отмене"、"биллинг подписок"、"оплата"。

以下短语于 0.16.2 从描述移至此处:"Stripe checkout"、"subscription billing"、"cancel subscription"、"оплата подпиской"、"вебхук Stripe"。

评论

支持 Markdown