payment-testing

>-

petrkindlmann/qa-skills372 installsMITSynced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: payment-testing
description: >-
license: MIT
---

<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>

## Quick Route

| You need to test… | Go to | Reference |
|-------------------|-------|-----------|
| Success / decline / insufficient-funds outcomes | [Test cards](#stripe-test-cards-and-outcomes) | `references/stripe-test-cards.md` |
| A 3DS/SCA challenge that pops a modal | [3DS challenge](#3ds--sca-the-nested-iframe-challenge) | `references/playwright-3ds.md` |
| A subscription renewal months/years out | [Test clocks](#test-clocks-server-side-time-travel) | `references/webhooks-and-clocks.md` |
| Webhooks reaching localhost + signatures | [Webhooks](#webhooks-local-delivery-signatures-idempotency) | `references/webhooks-and-clocks.md` |
| A failed renewal then a refund | [Failed payments](#failed-payments-dunning-and-refunds) | `references/webhooks-and-clocks.md` |
| Fulfillment only after real payment | [Reconciliation](#reconciliation-fulfill-on-the-webhook-not-the-redirect) | `references/webhooks-and-clocks.md` |
| Adyen / PayPal / Braintree sandboxes | [Multi-PSP](#multi-psp-adyen-paypal-braintree) | `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](#multi-psp-adyen-paypal-braintree).
- **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.

## 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.

## Stripe Test Cards and Outcomes

Drive each outcome with the card that deterministically produces it. The four you need most:

| PAN | Outcome | Code |
|-----|---------|------|
| `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`.

## 3DS / SCA: The Nested-Iframe Challenge

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.

## 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).

## Webhooks: Local Delivery, Signatures, Idempotency

**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).

## 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).

## Reconciliation: Fulfill on the Webhook, Not the Redirect

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).

## Multi-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`.

## Anti-Patterns

### 1. Reaching for `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`).

### 2. Treating the card field as a normal input
`page.locator('#card-number')` silently matches nothing because the field is in a
cross-origin iframe. Use `frameLocator`.

### 3. Selecting iframes by index
`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.

### 4. `waitForTimeout` to "wait for the challenge"
Flaky on slow CI, wasteful on fast CI. Wait on the element (`expect(...).toBeVisible()` /
auto-waiting locator actions), never the clock.

### 5. Client-side time mocking for billing
`jest.useFakeTimers` / sinon / mocking `Date` cannot move Stripe's server-side billing.
Use a test clock.

### 6. ngrok or polling for local webhooks
`stripe listen --forward-to localhost:3000/webhooks` tunnels events natively; `stripe
trigger` fires them. No public tunnel, no status polling.

### 7. Parsing the body before verifying the signature
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.

### 8. Confusing the request idempotency key with webhook dedup, or using an in-memory set
Outbound idempotency keys don't dedup inbound webhooks; an in-memory `Set` dies on restart.
Persist `event.id` with a UNIQUE constraint.

### 9. Fulfilling on the redirect / client success flag
The `return_url` can be premature, replayed, or forged. Fulfill only on the verified
`payment_intent.succeeded` webhook.

### 10. "Fixing" a failed renewal by deleting the subscription
The correct resolution is a refund via `refunds.create`, leaving the dunning lifecycle
(`invoice.payment_failed` → `past_due`) intact and testable.

### 11. Rationalizing a real card "just in 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.

## 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.

## 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.)

## 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.

## Reference Files (in `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.

More Testing skills

← All Testing skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY