stripe-billing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Stripe billing

Stripe 计费

Stripe holds the money. Your database holds the entitlement. Every serious billing defect is one fact living in two systems that stopped agreeing — a price, a quantity, a period, a refunded total.
Stripe's API is not where integrations fail. Failures happen at the seam: the callback that arrived twice, the renewal that granted a month of product for a $0.40 proration invoice, the upgrade that charged the card and then failed to write the row.
This skill is that seam. For which Stripe primitive to use, the official
stripe-best-practices
skill is the authority and wins any disagreement; for lookups,
stripe-docs
. Examples are TypeScript; the rules are language-neutral.
Deep material, loaded on demand:
ReadWhen
references/stripe-agent-toolchain.md
starting from nothing, or about to guess an API shape — CLI, MCP, skills index, key handling
references/webhook-events.md
writing or reviewing the handler — event catalogue, payload shapes, ordering, failure semantics
references/subscription-lifecycle.md
implementing checkout, verify, renewal, seats, plan change, trials, clawback
references/cancellation-and-retention.md
implementing cancel or reactivation, or offering a coupon or a pause at the cancel step
references/price-integrity.md
pricing lives in more than one file, or an advertised price must be proved against Stripe
references/testing-and-local-dev.md
local webhooks and mocks, and the shipped
fixtures/
— the suite, already written
references/provider-concentration.md
growing revenue, opening a second market, separating involuntary churn, or if the payment account is limited

Stripe 保管资金。你的数据库保管 entitlement。每个严重的计费缺陷,都是同一个事实存在于两个系统中且二者不再一致——一个 price、一个 quantity、一个 period 或一个 refunded total。
Stripe 的 API 并不是集成失败的地方。失败发生在 seam:重复到达的 callback、按 $0.40 的 proration invoice 授予一个月产品的 renewal,以及已从卡中扣款却未能写入数据行的 upgrade。
本 skill 聚焦的正是这个 seam。关于应使用哪种 Stripe primitive,官方
stripe-best-practices
skill 是权威来源,任何分歧均以它为准;查询则使用
stripe-docs
。示例采用 TypeScript;规则与语言无关。
按需加载的深入资料:
阅读何时阅读
references/stripe-agent-toolchain.md
从零开始,或准备猜测 API 结构——CLI、MCP、skills 索引、key 管理
references/webhook-events.md
编写或审查 handler——事件目录、payload 结构、顺序和失败语义
references/subscription-lifecycle.md
实现 checkout、verify、renewal、席位、plan 变更、trial 和 clawback
references/cancellation-and-retention.md
实现 cancel 或 reactivation,或在 cancel 步骤提供 coupon 或 pause
references/price-integrity.md
价格存在于多个文件,或必须根据 Stripe 证明对外公布的价格
references/testing-and-local-dev.md
本地 webhook 和 mock,以及随附的
fixtures/
——测试套件已经写好
references/provider-concentration.md
收入增长、开拓第二个市场、区分非自愿 churn,或 payment account 受到限制

Start with Stripe's own tooling

先使用 Stripe 自带的工具

Stripe ships its own MCP server, CLI and agent skills; the API moves monthly, so reach for them first. This skill owns the seams they do not: which signal is the payment, what the redirect proves, and where money gets counted twice. Commands in
references/stripe-agent-toolchain.md
.
Stripe 提供了自己的 MCP server、CLI 和 agent skills;API 每月都会变化,因此 优先使用它们。本 skill 负责它们未覆盖的 seam:哪个 signal 才代表支付、redirect 能证明什么,以及 money 会在哪里被重复计算。相关命令见
references/stripe-agent-toolchain.md
。

The two ledgers

两套账本

   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 ─────►│
Amounts, tax, invoices, dunning and payment methods are Stripe's. Who may use what, how many seats and what credit was granted are yours. The link between them is ids, and nothing else.
One home per fact. Copy an amount out of Stripe into your code and you own the drift — silently, because checkout sends a price id and Stripe holds the number. A wrong amount never fails a request; it is only ever shown to customers. See
references/price-integrity.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 ─────►│
金额、tax、invoice、dunning 和 payment method 归 Stripe 管。谁可以使用什么、有多少席位以及授予了多少 credit,则归 你 管。两者之间的关联只有 id,仅此而已。
每个事实只有一个归属。 把金额从 Stripe 复制到代码中,就等于由你承担 drift 风险——而且是静默发生,因为 checkout 发送的是 price id,而数字由 Stripe 保管。错误金额永远不会导致请求失败;它只会展示给客户。参见
references/price-integrity.md
。

The client

客户端

ts
// 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;
}
  • Lazy, not module-level. A
    new Stripe(...)
    at import time crashes every build step that imports the module without a key.
  • Pin
    apiVersion
    .
    Unpinned, response shapes change on Stripe's schedule rather than yours. Upgrades are a task (
    stripe:upgrade-stripe
    ), not a deploy-day surprise.
  • maxNetworkRetries
    , never a hand-rolled loop.
    The SDK generates an idempotency key per request, which is the only thing that makes retrying a write safe. A loop around
    subscriptions.create
    buys two subscriptions for one intent. For deliberate retries, pass your own stable
    { idempotencyKey }
    .

ts
// 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;
}
  • 惰性初始化,而非模块级初始化。 在 import 时执行
    new Stripe(...)
    ,会让每个导入该模块但没有 key 的构建步骤崩溃。
  • 固定
    apiVersion
    。
    不固定时,response 结构会按照 Stripe 的更新节奏变化,而不是按你的节奏。升级是一项任务(
    stripe:upgrade-stripe
    ),不应在部署当天才意外发生。
  • 使用
    maxNetworkRetries
    ,绝不手写循环。
    SDK 会为每个请求生成 idempotency key,这是安全重试 write 操作的唯一原因。围绕
    subscriptions.create
    写循环,会让一个 intent 买下两份 subscription。需要有意重试时,传入自己的稳定
    { idempotencyKey }
    。

Products, prices, two modes

Product、price 与两种模式

Checkout takes a price; your code should speak product. Prices are replaced when you reprice; products are stable.
  • One Product per plan a customer can choose. Several Prices on one Product only for variants of the same plan (monthly vs annual, per currency). Tiers sharing a Product make every invoice line read the same name and destroy the product-id → plan mapping the rest of your code depends on.
  • Pin price ids in configuration, keep
    prices.list({ active: true })
    as the fallback.
    After a reprice a product has two active prices, in an order nobody promised.
  • Validation allowlists need ids from both modes. Your database holds rows written in test and rows written in live; a webhook checking "do we sell this" against only the current mode rejects real history.
  • resource_missing
    almost always means mode mismatch.
    Catch it and say so, naming the mode and the key prefix — raw, it reads as "product deleted".

Checkout 接收的是 price;你的代码应以 product 为抽象。重新定价时会替换 price;product 则保持稳定。
  • 客户可选择的每个 plan 对应一个 Product。 仅当多个 Price 属于同一 plan 的变体(月付与年付、不同 currency)时,才应将它们放在同一个 Product 下。多个 tier 共用 Product 会让每条 invoice line 都显示相同名称,并破坏其余代码所依赖的 product-id → plan 映射。
  • 将 price id 固定在配置中,并让
    prices.list({ active: true })
    作为 fallback。
    重新定价后,一个 product 会拥有两个 active price,其顺序不受任何承诺。
  • Validation allowlist 需要同时包含两种模式下的 id。 你的数据库同时存有测试环境写入的行和线上环境写入的行;如果 webhook 只按当前模式检查“我们是否销售此产品”,就会拒绝真实历史记录。
  • resource_missing
    几乎总是意味着模式不匹配。
    捕获它并明确指出这一点,同时说明模式和 key prefix;直接抛出原始错误时,它看起来像“product 已删除”。

Get-or-create customer is a race

获取或创建 customer 存在竞态

Two tabs, two requests, two customers, and the second silently owns the subscription the first is charging. Create, then claim the id with a conditional update; if the update matched no row you lost the race, so delete the orphan customer and read the winner's id. Before creating, retrieve the stored id and treat
resource_missing
or
deleted: true
as "create a new one" — the normal state after a key rotation. The code is in
references/subscription-lifecycle.md
.

两个标签页、两个请求、两个 customer,而第二个会静默接管第一个正在扣款的 subscription。先创建,再用条件更新认领 id;如果更新未匹配到任何行,说明你输掉了竞态,因此删除孤立的 customer,并读取胜出者的 id。创建前先检索已存储的 id,并将
resource_missing
或
deleted: true
视为“创建一个新的”——这是 key rotation 后的正常状态。代码见
references/subscription-lifecycle.md
。

Checkout session

Checkout session

ts
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. Write metadata twice. Session metadata does not propagate to the subscription. Renewal invoices and every
    customer.subscription.*
    event carry the subscription — a year later the session is gone and the only
    userId
    you have is the one in
    subscription_data.metadata
    .
  2. Validate caller-supplied return URLs against your own origin. An endpoint that passes a body-supplied
    successUrl
    through is an open redirect wearing a payment flow.
  3. Guard duplicates before the money moves, not after: an active subscription to the same product answers 409 with the existing id. A second active subscription for one seat is a refund conversation.
{CHECKOUT_SESSION_ID}
is substituted by Stripe, not rendered by you. On
2026-03-25.dahlia
+ an
integration_identifier
label lets you compare flows in the Dashboard (floor checked 2026-08-30 against Stripe's changelog).
Three decisions belong to
stripe-best-practices
: usage-based billing (Metronome for anything new; Billing Meters is a low-level primitive), tax (
automatic_tax
collects nothing until a registration exists — enabling it is not compliance), and Connect when money routes to third parties.

ts
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. 写入两次 metadata。 Session metadata 不会传播到 subscription。Renewal invoice 和之后的每个
    customer.subscription.*
    事件都会携带 subscription——一年后 session 已经消失,而唯一还能取得的
    userId
    就是
    subscription_data.metadata
    中的那个。
  2. 将调用方提供的返回 URL 与你自己的 origin 进行校验。 把请求体中的
    successUrl
    直接透传的 endpoint,就是披着支付流程外衣的开放重定向。
  3. 在资金流转前而非之后防止重复。 同一 product 的 active subscription 应返回 409,并带上现有 id。同一个席位出现第二份 active subscription,就意味着需要处理退款。
{CHECKOUT_SESSION_ID}
由 Stripe 替换,而不是由你渲染。在
2026-03-25.dahlia
及后续版本中,
integration_identifier
标签可用于在 Dashboard 中比较不同流程(截至 2026-08-30 已根据 Stripe changelog 核实)。
三个决策应由
stripe-best-practices
负责:usage-based billing(新项目一律使用 Metronome;Billing Meters 是低层 primitive)、tax(
automatic_tax
在完成注册前不会收取任何款项——启用它并不等于合规),以及在资金流向第三方时使用 Connect。

The webhook is the payment

Webhook 才是支付依据

ts
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 });
}
  • Raw body. Re-serializing a parsed body changes bytes and the signature fails. Disable auto-parsing for this route.
  • Exempt this path — and only this path — from CSRF and session auth, by exact match: a prefix match over
    /api/billing
    exempts checkout too, which is where the money is.
  • Claim before working — a claim is a receipt, not completion.
    SELECT
    then
    INSERT
    is a race; two deliveries 40 ms apart both credit. The row stays
    processing
    until the grant's transaction marks it
    completed
    — states, expiry, takeover and release:
    references/webhook-events.md
    .
  • Answer honestly. 200 handled or duplicate, 400 bad signature, 5xx try again, 200 for types you do not handle. Never 200 on failure to stop retries — that discards a payment quietly.
  • Order inside a handler: fallible external calls first, then one transaction — entitlement, dedup marker, completion, outbox rows — then side effects drain from the outbox, each under its own consumer key.
Per-event detail:
references/webhook-events.md
.

ts
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 });
}
  • 原始 body。 重新序列化已解析的 body 会改变字节,签名验证随即失败。为此路由应禁用自动解析。
  • 仅对此路径——且只对此路径——按精确匹配豁免 CSRF 和 session auth。 对
    /api/billing
    使用前缀匹配会把 checkout 也豁免掉,而那里正是资金流转之处。
  • 开始处理前先 claim——claim 是收据,不是完成标记。 先
    SELECT
    再
    INSERT
    会产生竞态;相隔 40 ms 到达的两次投递都会发放权益。该行会一直保持
    processing
    ,直到发放权益的 transaction 将其标记为
    completed
    ——状态、过期、接管和释放机制见
    references/webhook-events.md
    。
  • 如实返回响应。 已处理或重复事件返回 200,签名错误返回 400,5xx 表示稍后重试,未处理的事件类型也返回 200。绝不能在失败时返回 200 来阻止重试——那会悄悄丢弃一笔支付。
  • **handler 内部的顺序:**先执行可能失败的外部调用,然后用一个 transaction 完成 entitlement、去重标记、完成标记和 outbox 行写入,最后从 outbox 排空副作用,每项使用各自的 consumer key。
各事件的详细信息见
references/webhook-events.md
。

The redirect proves a browser

Redirect 只能证明来自浏览器

Stripe redirects when the card clears; the webhook lands when it lands. In that window the user is on your success page and your database knows nothing.
Ship a verify endpoint the success page calls: retrieve the session, check
metadata.userId
equals the caller, check
status === "complete"
and
payment_status !== "unpaid"
, then perform exactly the writes the webhook would — and let the unique constraint arbitrate. Whoever loses catches the duplicate-key error and reports success.
The ownership check is security-critical: without it, any authenticated user who learns a
cs_…
id claims someone else's purchase. This is also the only way local development works — but it is a safety net, never the primary path. A user who closes the tab must still get what they paid for.

Stripe 在卡扣款成功后执行 redirect;webhook 则在实际到账时送达。在这个窗口期内,用户正位于 success page,而你的数据库还一无所知。
提供一个 verify endpoint,供 success page 调用:检索 session,检查
metadata.userId
等于调用方,检查
status === "complete"
且
payment_status !== "unpaid"
,然后执行 与 webhook 完全相同的写入——并让唯一约束仲裁。写入失败的一方捕获 duplicate-key error,并返回成功。
所有权检查对安全至关重要:如果缺少它,任何知道
cs_…
id 的已认证用户都可以声称拥有他人的购买。这也是本地开发唯一可行的方式——但它只是安全网,绝不能作为主要路径。用户关闭标签页后,仍必须获得其已付费的权益。

Renewal: billing_reason decides

Renewal:由
billing_reason
决定

invoice.paid
fires for several different things. Granting product on all of them is the most expensive mistake in this document.
billing_reason
What it isGrant?
subscription_create
first invoiceno — checkout did it
subscription_cycle
the renewalyes
subscription_update
mid-cycle prorationno
manual
,
subscription_threshold
an invoice you or a meter raisedexplicitly
A quantity change emits
subscription_update
immediately. If that path grants, a user who adds and removes a seat four times has been given four months of product for four proration invoices.
Read the period from the subscription item —
sub.items.data[0].current_period_start
/
current_period_end
. Recent API versions moved it; code reading the old top-level fields gets
undefined
and stores an epoch date, with no error.
Guard the grant with a marker (
lastGrantedPeriodStart
, or an audit row keyed by invoice id) checked inside the same transaction as the grant, so the webhook and the reconciliation job cannot both grant one period.

invoice.paid
会因多种不同原因触发。在所有这些事件上都授予产品,是本文档中代价最高的错误。
billing_reason
含义是否授予?
subscription_create
首张 invoice否——checkout 已经处理
subscription_cycle
renewal是
subscription_update
周期内的 proration否
manual
,
subscription_threshold
由你或 meter 创建的 invoice明确决定
Quantity 变更会立即触发
subscription_update
。如果这条路径也授予权益,那么一个用户四次添加并移除席位,就会因四张 proration invoice 而获得四个月的产品权益。
从 subscription item 读取 period——
sub.items.data[0].current_period_start
/
current_period_end
。近期 API 版本已移动该字段;读取旧顶层字段的代码会得到
undefined
并存储 epoch date,且不会报错。
用一个 marker(
lastGrantedPeriodStart
,或以 invoice id 为键的 audit row)保护发放操作;该 marker 必须与发放操作在同一个 transaction 内检查,这样 webhook 和 reconciliation job 才不会重复发放同一 period。

billing_mode
— a one-way choice made at creation

billing_mode
——创建时作出的单向选择

Stripe creates every subscription in one of two billing modes. The choice is made at
subscriptions.create
(or
subscription_data
on a Checkout session), cannot be reversed, and Stripe recommends flexible for new subscriptions.
It is in the body because it changes arithmetic the next sections teach: under flexible mode a credit proration is computed from the amount originally debited, so one change can emit several credit prorations where classic emitted one. Code that takes
[0]
of the proration lines breaks quietly in the clawback path — the one place here where a wrong number is money. It also decides which field records a scheduled cancellation. Choosing between the modes is a product decision
stripe-best-practices
owns.
Stripe 会以两种 billing mode 之一创建每份 subscription。该选择在
subscriptions.create
(或 Checkout session 的
subscription_data
)时作出,无法撤销;Stripe 建议新 subscription 使用 flexible。
之所以详细介绍,是因为它会改变后续章节所讲的算术:在 flexible 模式下,credit proration 根据最初实际扣减的金额计算,因此一次变更可能产生多条 credit proration,而 classic 模式只会产生一条。直接取 proration lines 的
[0]
会在 clawback 路径中悄然出错——这里是错误数字会直接造成资金损失的地方。它还决定由哪个字段记录 scheduled cancellation。两种模式之间的选择属于产品决策,由
stripe-best-practices
负责。

Seats and proration

席位与 proration

  • proration_behavior
    :
    always_invoice
    bills now,
    create_prorations
    defers to the next invoice,
    none
    adjusts nothing — the right choice for a revert. The call, with its compensating revert, is in
    references/subscription-lifecycle.md
    .
  • payment_behavior: "error_if_incomplete"
    on upgrades.
    Without it a declined card leaves the subscription upgraded and unpaid while your database agrees with the upgrade. Catch
    StripeCardError
    and answer 402.
  • Write ordering: Stripe first, then your database. If the database write fails, revert Stripe with
    proration_behavior: "none"
    and log a revert failure loudly — that is the one state a human must fix. The reverse order bills for seats Stripe never sold.
  • Cap quantity server-side, floor it at 1. Removing the last seat is a cancellation and goes through that path. Reducing below what is in use is a business decision: answer 409 with what must be released, and let the user choose which.

  • proration_behavior
    :
    always_invoice
    表示立即计费,
    create_prorations
    表示递延到下一张 invoice,
    none
    表示不做任何调整——这是 revert(撤销)时的正确选择。相关调用及其补偿性 revert 见
    references/subscription-lifecycle.md
    。
  • 升级时使用
    payment_behavior: "error_if_incomplete"
    。
    否则,卡被拒绝时 subscription 已升级但未付款,而你的数据库也认同升级状态。捕获
    StripeCardError
    并返回 402。
  • **写入顺序:**先 Stripe,后数据库。如果数据库写入失败,使用
    proration_behavior: "none"
    回滚 Stripe,并醒目记录回滚失败——这是必须由人工修复的状态。反过来则可能为 Stripe 从未售出的席位收取费用。
  • 在服务端限制 quantity 上限,并设置下限为 1。移除最后一个席位属于 cancellation,必须走该路径。将数量降低到低于正在使用的数量属于业务决策:返回 409,并说明必须释放哪些内容,再让用户选择。

Cancellation, and the offer that deflects it

Cancellation,以及用于阻止取消的优惠

  • At period end is the default — the user keeps what they paid for and
    status
    stays
    active
    . Under flexible
    billing_mode
    a portal cancellation sets
    cancel_at
    and leaves
    cancel_at_period_end
    false — derive "is cancelling" from both fields, never from the boolean alone.
  • Do the teardown in
    customer.subscription.deleted
    , never beside the API call, so one path serves your UI, the portal and dunning alike.
  • On
    invoice.payment_failed
    , mark
    past_due
    and notify — do not cancel. Stripe's dunning decides the retries and the terminal state.
  • A save offer's eligibility is yours. Stripe cannot answer "was this customer already discounted" — a
    duration=once
    discount leaves
    subscription.discounts
    at finalization — so track eligibility yourself.
Both, with the code:
references/cancellation-and-retention.md
.
  • 默认在 period end 取消——用户保留已付费内容,且
    status
    保持
    active
    。在 flexible
    billing_mode
    下,通过 portal 取消会设置
    cancel_at
    ,同时让
    cancel_at_period_end
    保持 false——判断“是否正在取消”时应同时使用两个字段,绝不能只看该 boolean。
  • 在
    customer.subscription.deleted
    中执行 teardown
    ,绝不要放在 API 调用旁边,这样 UI、portal 和 dunning 都能共用同一路径。
  • 遇到
    invoice.payment_failed
    时,标记
    past_due
    并通知——不要取消。Stripe 的 dunning 会决定重试方式和最终状态。
  • 挽留优惠的资格由你判断。 Stripe 无法回答“该 customer 是否已经享受过折扣”——因为
    duration=once
    的 discount 在 finalization 后仍会留在
    subscription.discounts
    上——所以必须自行跟踪资格。
两者及其代码见
references/cancellation-and-retention.md
。

Refunds arrive cumulative

Refund 是累计值

charge.amount_refunded
is the total refunded so far, not this refund. Two partial refunds deliver
4000
then
9000
; read as an increment, that claws back $130 against a $90 charge. Compute the increment against a stored total and write it with a compare-and-swap, so a concurrent delivery loses rather than clawing back twice — the code is in
references/subscription-lifecycle.md
.
A refund belongs either to a one-off payment (find it by
payment_intent
) or to a subscription invoice (no purchase row — resolve
charge.invoice
→ invoice → subscription). Handle both, or subscription refunds silently leave the customer holding the product.
charge.amount_refunded
是迄今为止的累计退款金额,而不是本次退款金额。两笔 partial refund 依次返回
4000
和
9000
;如果把它当作增量读取,就会从一笔 $90 的扣款中追回 $130。根据已存储的总额计算增量,并以 compare-and-swap 写入,这样并发投递中失败的一方不会重复追回——代码见
references/subscription-lifecycle.md
。
一笔 refund 要么属于一次性 payment(通过
payment_intent
查找),要么属于 subscription invoice(不存在 purchase row——按
charge.invoice
→ invoice → subscription 解析)。两种情况都必须处理,否则 subscription refund 会让 customer 在不再付费的情况下继续持有产品。

Reconciliation

对账

Webhooks are best-effort and outages are not hypothetical. Run a job that lists subscriptions with
status: "all"
, creates what is missing, and updates status, period, quantity and price where they differ — sequentially, because each iteration opens a transaction and may call an external API. Any grant it performs reuses the webhook's idempotency marker, or a nightly job becomes a nightly gift.
The guard that matters: mark local rows canceled when Stripe has no such subscription, excluding rows that were never Stripe's. Comped, manual and other-provider plans carry synthetic ids, and cancelling them is a self-inflicted outage. The code, and the "Sync now" button it doubles as, are in
references/subscription-lifecycle.md
.
Money is minor units: convert once, at the boundary, and compare in integer cents —
Math.abs(20.83 - 20.84) > 0.01
is
true
in floating point.

Webhook 是尽力而为的,服务中断也并非假设情况。运行一个 job,列出
status: "all"
的 subscription,补建缺失项,并在 status、period、quantity 和 price 存在差异时进行更新——应串行执行,因为每次迭代都会开启 transaction 并可能调用外部 API。它执行的任何发放操作都应复用 webhook 的幂等标记,否则 nightly job 就会变成 nightly gift。
真正重要的 guard:当 Stripe 中不存在相应 subscription 时,将本地行标记为 canceled,但要排除从未属于 Stripe 的行。赠送、manual 和其他 provider 的 plan 使用 synthetic id,取消它们会制造人为故障。相关代码以及与“Sync now”按钮共用的实现见
references/subscription-lifecycle.md
。
金额使用最小货币单位:只在边界处转换一次,并以整数 cents 比较——在浮点运算中,
Math.abs(20.83 - 20.84) > 0.01
的结果是
true
。

Depending on one provider

依赖单一 provider

One provider for card payments is a single point of failure for revenue, and the decision belongs to the business rather than to this skill. What the code owes it is a seam: keep the charge behind an interface, keep the customer id yours, and never let a Stripe object id be the only key to a paying account. The argument, the migration shapes and the cost are in
references/provider-concentration.md
.
仅用一家 provider 处理 card payment,会让收入暴露于 single point of failure;这个决策属于业务,而不是本 skill。代码需要向它提供一个 seam:把扣款封装在 interface 后面,customer id 归你所有,并且绝不能让 Stripe object id 成为付费账户的唯一键。相关论述、migration 结构和成本见
references/provider-concentration.md
。

Local development and the test matrix

本地开发与测试矩阵

Both in
references/testing-and-local-dev.md
—
stripe listen --forward-to
, the CLI trigger verbs, the test cards and decline codes, and the clock tricks for renewal and proration. The one thing to know before you get there: the signing secret
stripe listen
prints is not the dashboard's, and using the wrong one fails verification in a way that reads like a key problem.
两者都记录在
references/testing-and-local-dev.md
——
stripe listen --forward-to
、CLI trigger verbs、test cards 和 decline codes,以及 renewal 和 proration 的 clock tricks。开始之前需要知道一件事:
stripe listen
输出的 signing secret 不是 Dashboard 中的那个;使用错误的 secret 会导致验证失败,看起来像是 key 问题。

Degradation

能力降级

  • Not Claude Code (Cursor, Codex, the skills CLI, the API container): the
    PreToolUse
    money gate this pack ships does not exist there — nothing refuses a refund, a payout, a dispute close or a live
    sk_live_…
    key on your behalf. Every rule below still holds; it is advice again rather than a precondition, so the authorisation step becomes a human one. Say which you are on before acting on money.
  • Installed by copy rather than as a plugin: same loss, same remedy — a plain
    ~/.claude/skills/
    copy carries the doctrine and not
    hooks/money-gate.js
    . The README names the categories and how one is signed off; without the hook, that sign-off is a sentence somebody has to actually say.
  • No Stripe CLI, no network, or test keys only: webhook signature verification and the local replay loop are the parts that need the binary. State which is missing once, do the rest, and record what could not be verified rather than implying it was.
  • 不在 Claude Code 中运行(Cursor、Codex、skills CLI、API container):本 pack 附带的
    PreToolUse
    money gate 在这些环境中不存在——因此不会替你拒绝 refund、payout、dispute close 或 live
    sk_live_…
    key。下面的每条规则仍然成立;它们此时只是建议而非前置条件,因此授权步骤改由人工完成。对 money 执行操作前,先说明你使用的是哪个环境。
  • 通过复制而非 plugin 安装:会失去同样的能力,解决方式也相同——普通的
    ~/.claude/skills/
    副本只包含这套方法论,不包含
    hooks/money-gate.js
    。README 会列出各类别及其签核方式;没有 hook 时,那次签核就只是某个人必须亲口说出的声明。
  • 没有 Stripe CLI、没有网络,或只有 test key:webhook 签名验证和本地 replay loop 需要该 binary。只说明一次缺少什么,完成其余工作,并记录哪些内容无法验证,而不是暗示它们已经验证完毕。

Before you ship

发布前检查

Not a second checklist — every rule below already has a section above, and a rule with two homes drifts at one of them. These are the four whose failure is money rather than an error page:
  1. The webhook is the payment. Never grant on the redirect (§ The webhook is the payment, § The redirect proves a browser).
  2. Verify the signature against the raw body. A parsed body fails verification for a reason that reads like a key problem.
  3. Every handler is idempotent on
    event.id
    .
    Stripe retries, and a retry that grants twice is a refund conversation.
  4. Reconcile on a schedule. A webhook that never arrived leaves a paid customer without access, and nothing in the logs says so (§ Reconciliation).
The full security checklist lives in
references/stripe-agent-toolchain.md
; every pitfall it used to list is stated where the rule is, which is the only place it can be kept true.
这不是另一份 checklist——上面的每条规则都已有对应章节,而一条规则若出现在两个地方,迟早会在其中一处失真。以下四条一旦失败,损失的是资金,而不只是错误页面:
  1. Webhook 才是支付依据。 绝不能依据 redirect 授予权益(§ Webhook 才是支付依据、§ Redirect 只能证明来自浏览器)。
  2. 使用原始 body 验证签名。 已解析的 body 无法通过验证,而且原因看起来像是 key 问题。
  3. 每个 handler 都必须按
    event.id
    实现幂等。
    Stripe 会重试,而一次重试若发放两次权益,就会演变成退款纠纷。
  4. 定期执行 reconciliation。 始终未到达的 webhook 会让已付款客户无法使用产品,而且日志中没有任何迹象表明这一点(§ 对账)。
完整 security checklist 位于
references/stripe-agent-toolchain.md
;它过去列出的每个陷阱都已写在对应规则处,而那里是唯一能够长期保持其真实性的位置。

Triggers

触发词

More phrases that route here: "invoice.paid", "proration", "refund", "retention coupon", "скидка при отмене", "биллинг подписок", "оплата".
更多会将请求路由到这里的短语:"invoice.paid"、"proration"、"refund"、"retention coupon"、"скидка при отмене"、"биллинг подписок"、"оплата"。