payment-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese
<objective> Payment flows fail in ways generic E2E tests miss: a card field in a cross-origin iframe that `page.locator` silently never reaches, a renewal that won't trigger for a year, a webhook handler that "works" until Stripe retries and fulfills an order twice, an order marked paid on a redirect that the browser forged. This skill makes you test payments the way they actually break — against PSP sandboxes, with the real test cards, the nested 3DS challenge, server-side test clocks, signature-verified webhooks, and idempotent fulfillment. Never a real PAN, never a live key. </objective>
<objective> 通用端到端测试无法覆盖支付流程的各类故障场景:跨域iframe中的卡号输入框无法被`page.locator`定位、需等待一年才会触发的订阅续费、看似正常但在Stripe重试时会重复执行订单履约的Webhook处理器、浏览器伪造跳转后标记为已支付的订单。本技能指导你按照支付流程实际故障的方式进行测试——基于PSP沙箱,使用真实测试卡、嵌套3DS挑战、服务端测试时钟、签名验证的Webhook以及幂等履约机制。全程不使用真实银行卡号,不使用生产密钥。 </objective>

Quick Route

快速指引

You need to test…Go toReference
Success / decline / insufficient-funds outcomesTest cards
references/stripe-test-cards.md
A 3DS/SCA challenge that pops a modal3DS challenge
references/playwright-3ds.md
A subscription renewal months/years outTest clocks
references/webhooks-and-clocks.md
Webhooks reaching localhost + signaturesWebhooks
references/webhooks-and-clocks.md
A failed renewal then a refundFailed payments
references/webhooks-and-clocks.md
Fulfillment only after real paymentReconciliation
references/webhooks-and-clocks.md
Adyen / PayPal / Braintree sandboxesMulti-PSP
references/multi-psp.md
你需要测试…跳转至参考文档
成功/拒付/余额不足场景测试卡号
references/stripe-test-cards.md
弹出模态框的3DS/SCA挑战3DS挑战
references/playwright-3ds.md
数月/数年后的订阅续费测试时钟
references/webhooks-and-clocks.md
Webhook本地接收及签名验证Webhook
references/webhooks-and-clocks.md
续费失败后退款流程失败支付
references/webhooks-and-clocks.md
仅在真实支付完成后履约对账
references/webhooks-and-clocks.md
Adyen / PayPal / Braintree沙箱多PSP适配
references/multi-psp.md

Discovery Questions

调研问题

First, check
.agents/qa-project-context.md
in the project root and skip anything it already answers (PSP, stack, test framework, existing fixtures). Then clarify:
  • Which PSP, and is it Stripe? Stripe is the default here. Other PSPs share the pattern but have their own sandbox cards/accounts — see Multi-PSP.
  • One-time payments, subscriptions, or both? Subscriptions pull in test clocks, dunning, and the
    invoice.*
    lifecycle; one-time payments don't.
  • Is SCA/3DS in play? EU/UK card flows almost always challenge. If yes you need the nested-iframe pattern, not plain locators.
  • Do you fulfill on a webhook or on the redirect today? If fulfillment happens on
    return_url
    , that's the bug to test for — fulfillment must wait for the verified webhook.
  • Where does the webhook handler run in tests? Local (
    stripe listen
    ) vs a deployed preview env changes how you deliver events.
首先查看项目根目录下的
.agents/qa-project-context.md
,跳过已明确的内容(PSP、技术栈、测试框架、现有夹具)。然后确认以下信息:
  • 使用哪款PSP,是否为Stripe? 本技能默认以Stripe为主。其他PSP遵循相同测试模式,但有各自的沙箱卡号/账户——详见多PSP适配
  • 是一次性支付、订阅支付,还是两者都有? 订阅支付涉及测试时钟、催缴流程及
    invoice.*
    生命周期;一次性支付则无需这些。
  • 是否启用SCA/3DS? 欧盟/英国的银行卡流程几乎都会触发挑战。若是,则需使用嵌套iframe定位方式,而非普通定位器。
  • 当前是基于Webhook还是跳转链接完成履约?若履约依赖
    return_url
    ,这是需要测试的缺陷——履约必须等待经过签名验证的Webhook。
  • Webhook处理器在测试环境中运行于何处? 本地环境(
    stripe listen
    )与部署的预览环境会影响事件传递方式。

Core Principles

核心原则

  1. Never touch a real card or a live key — and this is non-negotiable, not a preference. Real PANs in any environment violate Stripe's Services Agreement and drag your repo into PCI scope. Test mode (
    pk_test_
    /
    sk_test_
    ) with Stripe's published test cards is the only correct answer. Masking or encrypting a real number does not fix it; removing it does.
  2. Money is confirmed server-side, never client-side. A redirect, an
    onApprove
    callback, or a
    ?status=success
    query param can be premature, replayed, or forged. Fulfill an order only after a signature-verified
    payment_intent.succeeded
    webhook, re-confirmed with an API
    retrieve
    .
  3. Verify the signature before you parse the body. Parsing JSON first destroys the raw bytes
    constructEvent
    needs. The webhook route gets the raw body; everything else can parse JSON.
  4. Time is a server-side construct for billing. Stripe's billing engine runs on Stripe's servers. Faking the clock in your test process changes nothing. Use test clocks.
  5. Assume every webhook is delivered more than once. Stripe retries. Idempotency keyed on
    event.id
    in durable storage is mandatory; an in-memory set is not idempotency.
  1. 严禁使用真实银行卡或生产密钥——这是硬性要求,而非可选方案。 在任何环境中使用真实银行卡号都会违反Stripe服务协议,并使你的代码库落入PCI合规范围。唯一正确的方式是使用测试模式(
    pk_test_
    /
    sk_test_
    )及Stripe官方发布的测试卡号。对真实卡号进行掩码或加密无法解决问题,必须彻底移除。
  2. 支付状态需通过服务端确认,而非客户端。 跳转链接、
    onApprove
    回调或
    ?status=success
    查询参数可能存在提前触发、重复执行或伪造的风险。仅在收到签名验证通过的
    payment_intent.succeeded
    Webhook,并通过API
    retrieve
    再次确认后,方可执行订单履约。
  3. 验证签名后再解析请求体。 先解析JSON会破坏
    constructEvent
    所需的原始字节数据。Webhook路由需获取原始请求体;其他路由可正常解析JSON。
  4. 账单时间由服务端控制。 Stripe的账单引擎运行于Stripe服务器。在测试流程中伪造本地时钟不会对Stripe的账单引擎产生任何影响,必须使用测试时钟。
  5. 假设每个Webhook都会被多次传递。 Stripe会重试Webhook传递。必须基于
    event.id
    在持久化存储中实现幂等性;内存集合无法实现真正的幂等性。

Stripe Test Cards and Outcomes

Stripe测试卡号及场景

Drive each outcome with the card that deterministically produces it. The four you need most:
PANOutcomeCode
4242424242424242
Succeeds
4000000000000002
Declined
card_declined
(generic_decline)
4000000000009995
Declined
insufficient_funds
4000000000003220
3DS always challenges
4000000000000341
Attaches, then fails on charge
card_declined
Use test keys (
pk_test_…
/
sk_test_…
) in the app under test and assert that in setup. Do not use
4111111111111111
— that is a generic Braintree/PayPal-era Luhn number, not a Stripe test card, and it does not deterministically decline.
The card field is in a cross-origin Stripe iframe, so fill it through
frameLocator
, never
page.locator
directly. Assert outcomes on UI copy for a smoke test, or more robustly on the server-side
last_payment_error.decline_code
from
paymentIntents.retrieve
. Full Playwright tests for success /
card_declined
/
insufficient_funds
:
references/stripe-test-cards.md
.
使用特定卡号触发对应的测试场景。最常用的四类卡号如下:
卡号场景代码
4242424242424242
支付成功
4000000000000002
支付被拒
card_declined
(generic_decline)
4000000000009995
余额不足
insufficient_funds
4000000000003220
始终触发3DS挑战
4000000000000341
卡片绑定成功,但后续支付失败
card_declined
在被测应用中使用测试密钥(
pk_test_…
/
sk_test_…
),并在初始化阶段进行断言。请勿使用
4111111111111111
——这是Braintree/PayPal时代的通用Luhn校验卡号,并非Stripe测试卡号,无法稳定触发拒付场景。
卡号输入框位于跨域Stripe iframe中,需通过
frameLocator
填充,不可直接使用
page.locator
。冒烟测试可通过UI文案断言结果,更可靠的方式是通过服务端调用
paymentIntents.retrieve
获取
last_payment_error.decline_code
进行断言。完整的Playwright测试用例(成功/
card_declined
/
insufficient_funds
)详见:
references/stripe-test-cards.md

3DS / SCA: The Nested-Iframe Challenge

3DS / SCA: 嵌套iframe挑战

This is the hardest part to get right. The Stripe 3DS challenge is a frame nested inside the Stripe modal frame — a single
frameLocator
cannot reach it. Chain
frameLocator
outer → inner, then click Complete authentication.
What fails, and why:
  • page.locator('#card')
    → the input is cross-origin; the locator matches nothing.
  • page.frames()[1]
    → frame index shifts when Stripe adds/reorders frames. Never select frames by index.
  • await page.waitForTimeout(5000)
    → guessing the challenge duration. Wait on the element.
The correct shape (full test, including the fail-authentication variant, in
references/playwright-3ds.md
):
ts
// 3DS-required card so the challenge always appears.
await card.getByPlaceholder('Card number').fill('4000000000003220');
await page.getByRole('button', { name: /pay/i }).click();

// Nested: outer Stripe challenge frame → inner ACS frame. One frameLocator is not enough.
const inner = page
  .frameLocator('iframe[name^="__privateStripeFrame"]')
  .frameLocator('iframe#challengeFrame, iframe[name="acsFrame"]');
await inner.getByRole('button', { name: /complete authentication|complete|authorize/i }).click();

await expect(page).toHaveURL(/\/success/);              // assert the succeeded state
await expect(page.getByText(/payment succeeded/i)).toBeVisible();
4000002760003184
is the alternative SCA card for setup-intent / first-use flows; the eval and docs accept it where a one-time-payment 3DS card is wanted.
这是最容易出错的环节。Stripe 3DS挑战是嵌套在Stripe模态框iframe中的子iframe——单个
frameLocator
无法定位到目标元素。需链式调用
frameLocator
(外层→内层),然后点击「完成验证」按钮。
常见错误及原因:
  • page.locator('#card')
    → 输入框处于跨域环境,定位器无法匹配到任何元素。
  • page.frames()[1]
    → 当Stripe添加/重新排序iframe时,frame索引会发生变化。切勿通过索引选择iframe。
  • await page.waitForTimeout(5000)
    → 猜测挑战加载时长。应等待元素加载完成,而非固定时长。
正确的代码示例(完整测试用例,包括验证失败场景,详见
references/playwright-3ds.md
):
ts
// 使用需3DS验证的卡号,确保挑战弹窗触发
await card.getByPlaceholder('Card number').fill('4000000000003220');
await page.getByRole('button', { name: /pay/i }).click();

// 嵌套结构:外层Stripe挑战iframe → 内层ACS iframe。单个frameLocator无法满足需求
const inner = page
  .frameLocator('iframe[name^="__privateStripeFrame"]')
  .frameLocator('iframe#challengeFrame, iframe[name="acsFrame"]');
await inner.getByRole('button', { name: /complete authentication|complete|authorize/i }).click();

await expect(page).toHaveURL(/\/success/);              // 断言支付成功状态
await expect(page.getByText(/payment succeeded/i)).toBeVisible();
4000002760003184
是适用于setup-intent/首次使用流程的替代SCA卡号;在需要一次性支付3DS卡号的场景中,可使用该卡号。

Test Clocks: Server-Side Time Travel

测试时钟: 服务端时间模拟

To test an annual renewal without waiting a year, use a Stripe test clock — a server-side construct. Client-side fakes (
jest.useFakeTimers
, sinon, mocking
Date
) do nothing to Stripe's billing engine.
Rules that bite if missed:
  • Create the clock at a
    frozen_time
    , then attach the customer at creation with
    test_clock: clock.id
    . You cannot attach an existing customer to a clock afterward.
  • testHelpers.testClocks.advance
    moves time forward only — you cannot rewind. Advance at most two billing cycles per call.
  • After advancing, poll the clock to
    ready
    , then assert the renewal invoice and webhooks.
ts
const clock = await stripe.testHelpers.testClocks.create({
  frozen_time: Math.floor(Date.now() / 1000), name: 'annual-renewal',
});
const customer = await stripe.customers.create({ test_clock: clock.id /* … */ });
// …create subscription, then advance ~12 months forward:
await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: oneYearLater });
Full create/advance/assert flow:
references/webhooks-and-clocks.md
(section 4).
若要测试年度续费流程而无需等待一年,需使用Stripe测试时钟——这是服务端专属的时间模拟机制。客户端时间伪造(
jest.useFakeTimers
、sinon、mock
Date
)无法影响Stripe的账单引擎。
需注意的规则:
  • 创建时钟时需指定
    frozen_time
    ,并在创建客户时关联时钟(
    test_clock: clock.id
    )。无法为已存在的客户关联时钟。
  • testHelpers.testClocks.advance
    仅能向前调整时间——无法回退。每次调用最多调整两个账单周期。
  • 调整时间后,需轮询时钟状态至
    ready
    ,再断言续费发票及Webhook事件。
ts
const clock = await stripe.testHelpers.testClocks.create({
  frozen_time: Math.floor(Date.now() / 1000), name: 'annual-renewal',
});
const customer = await stripe.customers.create({ test_clock: clock.id /* … */ });
// …创建订阅,然后将时间向前调整约12个月:
await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: oneYearLater });
完整的创建/调整/断言流程详见:
references/webhooks-and-clocks.md
(第4节)。

Webhooks: Local Delivery, Signatures, Idempotency

Webhook: 本地传递、签名验证、幂等性

Local delivery. Do not expose your endpoint with ngrok and do not poll the API for status.
stripe listen
tunnels test events to localhost natively;
stripe trigger
fires them on demand:
bash
stripe listen --forward-to localhost:3000/webhooks   # prints whsec_… ONCE at startup
stripe trigger payment_intent.succeeded
Copy that
whsec_…
into
STRIPE_WEBHOOK_SECRET
. It is the signing secret, a different value from
STRIPE_SECRET_KEY
(
sk_test_…
) — do not conflate them.
Signature verification. Mount
express.raw
on the webhook route before any global
express.json()
, so
constructEvent
gets the raw body. A forged or tampered event must be rejected with 400; never hand-roll a
=== signature
string comparison.
ts
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'] as string;
  try {
    const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
    return handleEvent(event, res);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${(err as Error).message}`); // SignatureVerificationError
  }
});
app.use(express.json()); // everything else, after the webhook route
Idempotency. Stripe retries delivery, so the same
event.id
arrives twice. Request-side idempotency keys (for outbound API calls) do not dedup inbound webhooks. Store
event.id
with a UNIQUE constraint and short-circuit on conflict; an in-memory set is lost on restart and useless across instances. The handler returns 200 for a duplicate so Stripe stops retrying, and fulfillment runs exactly once.
ts
const inserted = await db.query(
  `INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id`, [event.id]);
if (inserted.rowCount === 0) return res.status(200).send('duplicate ignored');
The signature test (valid accepted, forged → 400) and the "deliver the same event twice, assert fulfilled once" idempotency test are in
references/webhooks-and-clocks.md
(sections 2–3).
本地传递。无需使用ngrok暴露端点,也无需轮询API状态。
stripe listen
可原生将测试事件转发至本地;
stripe trigger
可按需触发事件:
bash
stripe listen --forward-to localhost:3000/webhooks   # 启动时会打印唯一的whsec_…密钥
stripe trigger payment_intent.succeeded
将打印的
whsec_…
密钥复制到
STRIPE_WEBHOOK_SECRET
中。这是签名密钥,与
STRIPE_SECRET_KEY
sk_test_…
)是不同的密钥——请勿混淆。
签名验证。在Webhook路由上挂载
express.raw
优先于全局
express.json()
,确保
constructEvent
能获取原始请求体。伪造或篡改的事件需返回400状态码;切勿手动进行
===
签名字符串对比。
ts
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'] as string;
  try {
    const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
    return handleEvent(event, res);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${(err as Error).message}`); // SignatureVerificationError
  }
});
app.use(express.json()); // 其他路由需在Webhook路由之后挂载
幂等性。Stripe会重试事件传递,因此同一
event.id
可能多次到达。请求端的幂等密钥无法去重入站Webhook;内存集合会在重启后丢失,且无法跨实例生效。需将
event.id
存储在带有唯一约束的持久化存储中,冲突时直接返回;重复事件需返回200状态码,确保Stripe停止重试,且履约流程仅执行一次
ts
const inserted = await db.query(
  `INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id`, [event.id]);
if (inserted.rowCount === 0) return res.status(200).send('duplicate ignored');
签名测试(合法事件通过,伪造事件返回400)及“同一事件传递两次,仅履约一次”的幂等性测试详见:
references/webhooks-and-clocks.md
(第2-3节)。

Failed Payments: Dunning and Refunds

失败支付: 催缴与退款

To test a failed recurring charge end to end, subscribe with
4000000000000341
(SDK token
pm_card_chargeCustomerFail
) — it attaches to the customer but fails on the later charge, which is what the renewal needs. Cards that decline at attach time can't be saved, so they can't model a renewal failure.
Drive the lifecycle with a test clock:
  1. Subscribe the customer (on a test clock) with the attach-then-fail card.
  2. advance
    the clock past the renewal date → Stripe attempts the charge.
  3. The charge fails → Stripe emits
    invoice.payment_failed
    and the subscription goes
    past_due
    . Assert both.
  4. Resolve by issuing a refund with
    refunds.create
    (fires
    charge.refunded
    ) — do not "fix" it by deleting the subscription.
Full driver in
references/webhooks-and-clocks.md
(section 5).
若要端到端测试 recurring charge失败流程,需使用卡号
4000000000000341
(SDK令牌
pm_card_chargeCustomerFail
)订阅——该卡号可成功绑定客户,但后续支付会失败,正好模拟续费失败场景。绑定阶段即拒付的卡片无法保存,无法模拟续费失败。
使用测试时钟驱动整个生命周期:
  1. 在测试时钟上为客户绑定该卡片并创建订阅。
  2. 将时钟调整至续费日期之后 → Stripe尝试扣款。
  3. 扣款失败 → Stripe触发**
    invoice.payment_failed
    事件,订阅状态变为
    past_due
    **。需同时断言这两个结果。
  4. 通过调用**
    refunds.create
    **发起退款(触发
    charge.refunded
    事件)解决问题——请勿通过删除订阅“修复”该场景。
完整流程示例详见:
references/webhooks-and-clocks.md
(第5节)。

Reconciliation: Fulfill on the Webhook, Not the Redirect

对账: 基于Webhook而非跳转履约

Mark an order paid only after a signature-verified
payment_intent.succeeded
webhook, re-confirmed against the API — never on the
return_url
redirect or a client-side success flag, and never by polling with a sleep.
ts
if (event.type === 'payment_intent.succeeded') {
  const verified = await stripe.paymentIntents.retrieve(event.data.object.id);
  if (verified.status === 'succeeded' && verified.amount_received === expected) {
    await markOrderPaid(verified.metadata.orderId); // fulfillment happens HERE
  }
}
The reconciliation test asserts the order is still
pending
after the redirect and only
paid
after the verified webhook:
references/webhooks-and-clocks.md
(section 6).
仅在收到签名验证通过的
payment_intent.succeeded
Webhook,并通过API再次确认后,方可标记订单为已支付——切勿依赖
return_url
跳转或客户端成功标记,也切勿通过轮询+等待的方式判断状态。
ts
if (event.type === 'payment_intent.succeeded') {
  const verified = await stripe.paymentIntents.retrieve(event.data.object.id);
  if (verified.status === 'succeeded' && verified.amount_received === expected) {
    await markOrderPaid(verified.metadata.orderId); // 履约流程在此执行
  }
}
对账测试需断言:跳转后订单仍处于
pending
状态,仅在收到验证后的Webhook后才变为
paid
状态。详见:
references/webhooks-and-clocks.md
(第6节)。

Multi-PSP: Adyen, PayPal, Braintree

多PSP适配: Adyen, PayPal, Braintree

Stripe test cards do not work on other PSPs. Each has its own sandbox cards and sandbox buyer accounts. Port the structure of your Stripe tests; swap in the PSP's sandbox values. Never reuse Stripe PANs or live/production keys.
  • Adyen — own test cards (e.g.
    4212345678910014
    for 3DS2); many declines are driven by the transaction amount (
    .13
    refused,
    .51
    referral), not the card. Events arrive as HMAC-signed notifications.
  • PayPal — log in with a sandbox buyer account (sandbox personal email/password), not a card. Confirm server-side via the Orders API / webhooks, not the client
    onApprove
    .
  • Braintree — own sandbox test card numbers via Drop-in UI / Hosted Fields; amount drives transaction outcome, card number drives verification.
What stays the same: separate test/sandbox credentials, no real card, and fulfillment on the verified server-side event/notification. Details:
references/multi-psp.md
.
Stripe测试卡号无法用于其他PSP。每个PSP都有自己的沙箱卡号和沙箱买家账户。可复用Stripe测试用例的结构,替换为对应PSP的沙箱参数。切勿复用Stripe卡号或生产/测试密钥。
  • Adyen — 使用专属测试卡号(如
    4212345678910014
    用于3DS2);多数拒付场景由交易金额驱动(
    .13
    金额会被拒绝,
    .51
    金额会被转介),而非卡号。事件以HMAC签名通知的形式传递。
  • PayPal — 使用沙箱买家账户(沙箱个人邮箱/密码)登录,而非银行卡。需通过Orders API/Webhook在服务端确认支付,而非客户端
    onApprove
    回调。
  • Braintree — 通过Drop-in UI/Hosted Fields使用专属沙箱测试卡号;交易金额驱动交易结果,卡号驱动验证流程。
通用规则:使用独立的测试/沙箱凭证,不使用真实银行卡,基于验证后的服务端事件/通知执行履约。详细内容详见:
references/multi-psp.md

Anti-Patterns

反模式

1. Reaching for
4111111111111111

1. 使用
4111111111111111
卡号

That Luhn-valid number is a Braintree/PayPal-era generic PAN, not a Stripe test card. Use
4242424242424242
for success and the specific decline cards (
4000000000000002
,
4000000000009995
).
该Luhn校验合法的卡号是Braintree/PayPal时代的通用卡号,并非Stripe测试卡号。请使用
4242424242424242
测试支付成功,使用特定拒付卡号(
4000000000000002
4000000000009995
)测试拒付场景。

2. Treating the card field as a normal input

2. 将卡号输入框视为普通输入框

page.locator('#card-number')
silently matches nothing because the field is in a cross-origin iframe. Use
frameLocator
.
page.locator('#card-number')
无法匹配到任何元素,因为输入框处于跨域iframe中。需使用
frameLocator

3. Selecting iframes by index

3. 通过索引选择iframe

page.frames()[1]
breaks the instant Stripe reorders frames. Match the frame by a stable name prefix (
iframe[name^="__privateStripeFrame"]
) and chain
frameLocator
for the nested 3DS challenge.
page.frames()[1]
会在Stripe重新排序iframe时失效。请通过稳定的名称前缀(
iframe[name^="__privateStripeFrame"]
)匹配iframe,并链式调用
frameLocator
处理嵌套3DS挑战。

4.
waitForTimeout
to "wait for the challenge"

4. 使用
waitForTimeout
等待挑战加载

Flaky on slow CI, wasteful on fast CI. Wait on the element (
expect(...).toBeVisible()
/ auto-waiting locator actions), never the clock.
在慢速CI环境中会不稳定,在快速CI环境中会浪费时间。应等待元素加载完成(
expect(...).toBeVisible()
/ 自动等待的定位器操作),而非固定时长。

5. Client-side time mocking for billing

5. 客户端时间伪造用于账单测试

jest.useFakeTimers
/ sinon / mocking
Date
cannot move Stripe's server-side billing. Use a test clock.
jest.useFakeTimers
/ sinon / mock
Date
无法影响Stripe服务端的账单引擎。需使用测试时钟。

6. ngrok or polling for local webhooks

6. 使用ngrok或轮询处理本地Webhook

stripe listen --forward-to localhost:3000/webhooks
tunnels events natively;
stripe trigger
fires them. No public tunnel, no status polling.
stripe listen --forward-to localhost:3000/webhooks
可原生转发事件;
stripe trigger
可按需触发事件。无需使用公网隧道或状态轮询。

7. Parsing the body before verifying the signature

7. 验证签名前解析请求体

A global
express.json()
ahead of the webhook route destroys the raw body
constructEvent
needs, so verification can never pass. Mount
express.raw
on the webhook route first.
Webhook路由之前的全局
express.json()
会破坏
constructEvent
所需的原始请求体,导致验证无法通过。需先在Webhook路由上挂载
express.raw

8. Confusing the request idempotency key with webhook dedup, or using an in-memory set

8. 将请求幂等密钥与Webhook去重混淆,或使用内存集合

Outbound idempotency keys don't dedup inbound webhooks; an in-memory
Set
dies on restart. Persist
event.id
with a UNIQUE constraint.
请求端幂等密钥无法去重入站Webhook;内存
Set
会在重启后丢失。需将
event.id
存储在带有唯一约束的持久化存储中。

9. Fulfilling on the redirect / client success flag

9. 基于跳转链接/客户端成功标记执行履约

The
return_url
can be premature, replayed, or forged. Fulfill only on the verified
payment_intent.succeeded
webhook.
return_url
可能存在提前触发、重复执行或伪造的风险。仅在收到验证后的
payment_intent.succeeded
Webhook后执行履约。

10. "Fixing" a failed renewal by deleting the subscription

10. 通过删除订阅“修复”续费失败场景

The correct resolution is a refund via
refunds.create
, leaving the dunning lifecycle (
invoice.payment_failed
past_due
) intact and testable.
正确的处理方式是通过
refunds.create
发起退款,保留完整的催缴生命周期(
invoice.payment_failed
past_due
)以便测试。

11. Rationalizing a real card "just in CI"

11. 为CI环境“破例”使用真实银行卡

A hardcoded real PAN is a PCI/compliance violation regardless of environment. The fix is a test card in test mode — plus removing the secret from the repo and git history and rotating any exposed key. Masking or encrypting it does not make it acceptable.
硬编码真实银行卡号无论在何种环境中都违反PCI合规要求。正确的做法是使用测试模式下的测试卡号——同时需从代码库及Git历史中移除相关密钥,并轮换所有暴露的密钥。掩码或加密真实卡号无法解决合规问题。

Verification

验证标准

  • stripe listen --forward-to localhost:3000/webhooks
    prints a
    whsec_…
    and shows events arriving when you run
    stripe trigger payment_intent.succeeded
    .
  • Running the 3DS test with
    4000000000003220
    reaches and clicks the Complete authentication button (the test fails loudly, not silently, if the nested frame isn't found).
  • The signature test: a tampered
    Stripe-Signature
    returns 400; a header from
    generateTestHeaderString
    returns 200.
  • The idempotency test: delivering the same
    event.id
    twice fulfills once.
  • grep -rE 'pk_live|sk_live|4111111111111111'
    over the test suite returns nothing.
  • 执行
    stripe listen --forward-to localhost:3000/webhooks
    时会打印
    whsec_…
    密钥,且执行
    stripe trigger payment_intent.succeeded
    时能看到事件到达。
  • 使用
    4000000000003220
    执行3DS测试时,能定位并点击「完成验证」按钮(若未找到嵌套iframe,测试会直接失败,而非静默通过)。
  • 签名测试:篡改
    Stripe-Signature
    返回400;使用
    generateTestHeaderString
    生成的签名返回200
  • 幂等性测试:同一
    event.id
    传递两次,仅执行一次履约。
  • 在测试套件中执行
    grep -rE 'pk_live|sk_live|4111111111111111'
    无任何结果。

Done When

完成标准

  • Checkout suite covers success (
    4242424242424242
    ),
    card_declined
    (
    4000000000000002
    ), and
    insufficient_funds
    (
    4000000000009995
    ), each asserting the matching outcome, using
    pk_test_
    /
    sk_test_
    keys.
  • A 3DS test fills
    4000000000003220
    , reaches the nested challenge frame via chained
    frameLocator
    , clicks Complete authentication, and asserts the succeeded state — no
    frames()[index]
    , no
    waitForTimeout
    .
  • A subscription-renewal test uses a Stripe test clock (
    testHelpers.testClocks.create
    +
    advance
    , forward-only, customer attached at creation) instead of any client-side time mock.
  • Local webhooks are received via
    stripe listen --forward-to
    /
    stripe trigger
    , with the
    whsec_…
    wired into
    STRIPE_WEBHOOK_SECRET
    (distinct from
    STRIPE_SECRET_KEY
    ).
  • The webhook handler verifies the signature with
    constructEvent
    on the raw body before parsing, returns 400 on a forged event, and a test proves it.
  • Idempotency is enforced by persisting
    event.id
    with a UNIQUE constraint; a duplicate delivery returns 200 and fulfills exactly once, proven by a test.
  • A failed-renewal test drives
    invoice.payment_failed
    past_due
    refunds.create
    via a test clock and the attach-then-fail card
    4000000000000341
    .
  • A reconciliation test confirms the order is
    paid
    only after the verified
    payment_intent.succeeded
    webhook (re-checked with
    paymentIntents.retrieve
    ), not on the redirect.
  • grep -rE 'pk_live|sk_live|4111111111111111'
    finds no live key or banned PAN in the test suite. (A bare
    [0-9]{16}
    scan would false-positive on every legitimate test card — match live-key prefixes and the banned
    4111…
    number, not all 16-digit strings.)
  • 结账测试套件覆盖成功(
    4242424242424242
    )、
    card_declined
    4000000000000002
    )、
    insufficient_funds
    4000000000009995
    )场景,每个场景都断言对应结果,且使用
    pk_test_
    /
    sk_test_
    密钥。
  • 3DS测试使用
    4000000000003220
    卡号,通过链式
    frameLocator
    定位嵌套挑战iframe,点击「完成验证」按钮,并断言支付成功状态——未使用
    frames()[index]
    waitForTimeout
  • 订阅续费测试使用Stripe测试时钟(
    testHelpers.testClocks.create
    +
    advance
    ,仅向前调整时间,创建客户时关联时钟),未使用任何客户端时间伪造方式。
  • 本地Webhook通过
    stripe listen --forward-to
    /
    stripe trigger
    接收,
    whsec_…
    密钥已配置到
    STRIPE_WEBHOOK_SECRET
    (与
    STRIPE_SECRET_KEY
    区分开)。
  • Webhook处理器在解析请求体前,使用
    constructEvent
    基于原始请求体验证签名;伪造事件返回400,且有测试用例验证该逻辑。
  • 通过将
    event.id
    存储在带有唯一约束的持久化存储中实现幂等性;重复事件返回200且仅执行一次履约,有测试用例验证该逻辑。
  • 续费失败测试通过测试时钟及绑定后失败卡号
    4000000000000341
    驱动
    invoice.payment_failed
    past_due
    refunds.create
    流程。
  • 对账测试确认订单仅在收到验证后的
    payment_intent.succeeded
    Webhook(通过
    paymentIntents.retrieve
    再次确认)后才变为
    paid
    状态,而非跳转后立即标记。
  • 在测试套件中执行
    grep -rE 'pk_live|sk_live|4111111111111111'
    未找到生产密钥或禁用卡号。(扫描
    [0-9]{16}
    会误报所有合法测试卡号——需匹配生产密钥前缀及禁用的
    4111…
    卡号,而非所有16位数字字符串。)

Related Skills

相关技能

  • api-testing — General REST/GraphQL endpoint testing, schema validation, and auth flows for non-payment endpoints. Go there when the target isn't a PSP checkout/webhook.
  • playwright-automation — Page Object Model, fixtures, and general browser E2E mechanics that the 3DS flow here builds on.
  • compliance-testing — PCI-DSS, GDPR, and regulatory audit work. This skill keeps you out of PCI scope by using test cards; go there for a formal compliance audit.
  • test-data-management — Seeding customers, subscriptions, and fixtures; managing the test-clock-bound customers this skill creates.
  • qa-project-context — The universal first stop: PSP, stack, and fixture conventions that every question above should defer to.
  • api-testing — 非支付端点的通用REST/GraphQL端点测试、Schema验证及认证流程。当测试目标不是PSP结账/Webhook时,使用该技能。
  • playwright-automation — 页面对象模型、夹具及通用浏览器端到端测试机制,本技能中的3DS流程基于该技能实现。
  • compliance-testing — PCI-DSS、GDPR及合规性审计工作。本技能通过使用测试卡号帮助你规避PCI合规范围;如需正式合规审计,使用该技能。
  • test-data-management — 客户、订阅及夹具数据初始化;管理本技能中创建的与测试时钟绑定的客户数据。
  • qa-project-context — 通用前置技能:PSP、技术栈及夹具约定,本技能中的所有问题均需优先参考该技能。

Reference Files (in
references/
)

参考文档(位于
references/
目录)

  • stripe-test-cards.md — Full test-card catalogue with decline codes and the Playwright success/decline/insufficient-funds tests.
  • playwright-3ds.md — Nested-iframe 3DS challenge handling, complete and fail variants, and selector notes.
  • webhooks-and-clocks.md
    stripe listen
    /
    trigger
    , raw-body signature verification, idempotency by
    event.id
    , test clocks, failed-renewal dunning + refunds, and reconciliation.
  • multi-psp.md — Adyen, PayPal, and Braintree sandbox patterns and what differs from Stripe.
  • stripe-test-cards.md — 完整测试卡号目录,包含拒付码及Playwright成功/拒付/余额不足测试用例。
  • playwright-3ds.md — 嵌套iframe 3DS挑战处理方式、完整及失败场景变体、选择器说明。
  • webhooks-and-clocks.md
    stripe listen
    /
    trigger
    使用方法、原始请求体签名验证、基于
    event.id
    的幂等性、测试时钟、续费失败催缴+退款、对账流程。
  • multi-psp.md — Adyen、PayPal及Braintree沙箱测试模式,以及与Stripe的差异点。