- Skill ID
- petrkindlmann/qa-skills/payment-testing
- Publisher
- petrkindlmann
- Repository
- qa-skills
- Installs
- 0
- Files
- 5
- License
- MIT
- Synced
- Sep 23, 2026
Open any RiverX project, open the Skills panel in the chat, and search for this identifier. The files are fetched from the source repository at install time.
petrkindlmann/qa-skills/payment-testingInstalls these files- SKILL.md
- references/multi-psp.md
- references/playwright-3ds.md
- references/stripe-test-cards.md
- references/webhooks-and-clocks.md
What this skill tells the agent
<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 | references/stripe-test-cards.md |
| A 3DS/SCA challenge that pops a modal | 3DS challenge | references/playwright-3ds.md |
| A subscription renewal months/years out | Test clocks | references/webhooks-and-clocks.md |
| Webhooks reaching localhost + signatures | Webhooks | references/webhooks-and-clocks.md |
| A failed renewal then a refund | Failed payments | references/webhooks-and-clocks.md |
| Fulfillment only after real payment | Reconciliation | references/webhooks-and-clocks.md |
| Adyen / PayPal / Braintree sandboxes | Multi-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.
Core Principles
- 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.
- Money is confirmed server-side, never client-side. A redirect, an
onApprovecallback, or a?status=successquery param can be premature, replayed, or forged. Fulfill an order only after a signature-verifiedpayment_intent.succeededwebhook, re-confirmed with an APIretrieve.
- Verify the signature before you parse the body. Parsing JSON first destroys the raw bytes
constructEventneeds. The webhook route gets the raw body; everything else can parse JSON.
- 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.
- Assume every webhook is delivered more than once. Stripe retries. Idempotency keyed on
event.idin 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):
// 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 withtest_clock: clock.id. You cannot attach an existing customer to a clock afterward. testHelpers.testClocks.advancemoves 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.
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:
stripe listen --forward-to localhost:3000/webhooks # prints whsec_… ONCE at startupMore from petrkindlmann
Use this skill in a project
Open any RiverX project, search the Skills panel for petrkindlmann/qa-skills/payment-testing, and install it in one click.
