payment-flow-test-author
Build-an-X workflow that authors the full payment-flow test suite in three phases: the refund matrix (full / partial / multiple-partials / over-refund / already-refunded, per-gateway APIs for Stripe, Adyen, PayPal, Braintree), the chargeback / dispute suite (Visa + Mastercard reason codes, evidence submission windows, won / lost / accepted dispositions), and webhook replay + recovery via gateway-native simulators (Stripe CLI trigger / resend, Adyen Customer Area resend, PayPal Webhook Simulator, Braintree sampleNotification). Driven by the state model in payment-flow-states-reference. Use when building refund, dispute, or payment-webhook-robustness coverage for a payment integration; for generic (non-payment) webhook receiver testing use webhook-delivery-tester in the qa-notifications plugin.
Install with skills.sh (any agent)
npx skills add testland/qa --skill payment-flow-test-authorpayment-flow-test-author
Overview
The three highest-incident payment surfaces after the happy path - refunds, disputes, and webhook handling - are one suite-authoring job, because they share the same substrate: the gateway's async state machine. This workflow builds all three phases in dependency order, driven by the canonical states in payment-flow-states-reference.
Common bugs each phase catches: double-refund and partial-refund-not-summing (Phase 1); missed evidence deadlines and unreconciled chargeback fees (Phase 2); non-idempotent redelivery handling and spoofable handlers (Phase 3).
When to use
Phase 0 - Map the state machine
Before writing any test, translate the canonical lifecycle (created → requires_action → processing → succeeded → refunded / disputed) into your gateway's vocabulary using payment-flow-states-reference. Every assertion below is on a state from that grid, and every async assertion waits on the webhook, never the synchronous API return.
Phase 1 - The refund matrix
Inventory refund touchpoints
grep -rn 'refund\|Refund\|REFUND' --include='*.{ts,js,py,java,go,rb,cs}' .Categorise per gateway + per code path (order cancellation, CS portal, subscription downgrade, dispute-lost automation).
The 7 canonical refund cases
For each (gateway, touchpoint):
| # | Test | Expected |
|---|---|---|
| 1 | Full refund of captured charge | refund.status = succeeded; charge.amount_refunded = charge.amount |
| 2 | Partial refund (50%) | refund.amount = 0.5x charge.amount |
| 3 | Multiple partials summing to full | Cumulative refunded = charge.amount; charge.refunded = true |
| 4 | Over-refund attempt (101%) | Gateway rejects; descriptive error |
| 5 | Refund of already-fully-refunded charge | Rejected with "charge_already_refunded" or equivalent |
| 6 | Refund of failed charge | Rejected; no refund created |
| 7 | Refund of disputed charge | Per gateway policy: blocks or allows without reversing the dispute |
Per-gateway refund APIs
Stripe, per docs.stripe.com/refunds (opens in new window):
test('over-refund rejected', async () => {
const intent = await createSucceededIntent({ amount: 1000 });
await expect(
stripe.refunds.create({ payment_intent: intent.id, amount: 1500 })
).rejects.toThrow(/refund_amount_exceeds_charge_amount/);
});Adyen refunds are async, per docs.adyen.com/online-payments/refund (opens in new window): the call returns [refund-received]; completion arrives as a [REFUND] notification - assert via webhook, not the sync response. PayPal uses CapturesRefundRequest per developer.paypal.com/docs/api/payments/v2#captures_refund (opens in new window); Braintree requires settlement first (gateway.testing.settle, sandbox-only) before gateway.transaction.refund.
Refund idempotency
test('idempotent refund', async () => {
const intent = await createSucceededIntent({ amount: 1000 });
const key = 'refund-' + intent.id;
const r1 = await stripe.refunds.create({ payment_intent: intent.id, amount: 500 }, { idempotencyKey: key });
const r2 = await stripe.refunds.create({ payment_intent: intent.id, amount: 500 }, { idempotencyKey: key });
expect(r1.id).toBe(r2.id);
});Without idempotency, network retries double-refund the customer.
Phase 2 - The chargeback / dispute suite
test('lost dispute reverses funds in ledger', async () => {
const intent = await createSucceededIntent({ amount: 1000 });
const dispute = await triggerLostDispute(intent);
await waitForChargebackEvent();
const ledger = await getLedgerEntries(intent.id);
expect(ledger).toContainEqual(expect.objectContaining({ type: 'chargeback', amount: -1000 }));
expect(ledger).toContainEqual(expect.objectContaining({ type: 'chargeback_fee' }));
});Phase 3 - Webhook replay via gateway-native simulators
Payment webhooks are the source of truth for async transitions, so every handler must be signature-verified, idempotent, order-tolerant, and replay-safe.
test('redelivered webhook handled idempotently', async () => {
const payload = makeWebhookPayload({ type: 'payment_intent.succeeded' });
const sig = signPayload(payload);
await postWebhook(payload, sig);
await postWebhook(payload, sig); // redelivery
const rows = await db.payment_records.count({ event_id: payload.id });
expect(rows).toBe(1); // processed exactly once
});stripe trigger payment_intent.succeeded # synthetic event to the forward URL
stripe events resend evt_test_12345 # replay a captured event (30-day window)For generic webhook receiver testing (Standard-Webhooks signature scheme, non-payment senders, inbound replay hardening), use webhook-delivery-tester in the qa-notifications plugin; this phase covers the payment-gateway-specific surface only.
Emit the coverage matrix
# tests/payment/flow-matrix.yaml
matrix:
gateways: [stripe, adyen, paypal, braintree]
refund_variants: [full, partial, multiple-partials, over-refund, already-refunded, failed-charge, disputed-charge]
dispute_cells: ["Visa 10.4", "Visa 13.1", "Mastercard 4855"] # x [won, lost, accepted]
webhook_cases: [signature-gauntlet, idempotent-redelivery, out-of-order, crash-recovery]Report the matrix per release and document deliberate gaps.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Test only full-refund happy path | Partial-refund accounting bugs hide | Per-variant test |
| No idempotency key on mutating calls | Network retry → double refund / charge | Always set |
| Skip dispute tests | Worst-case impact is high | Cover top 3-5 reason codes |
| No due-date tracking | Evidence submitted late → auto-lose | Test the due-date watcher |
| Skip chargeback-fee reconciliation | Books don't match | Test the ledger |
| Skip signature verification | Spoofed webhook payloads | Four-case gauntlet per gateway |
| Trust HTTP 200 == processed | Server may have crashed mid-handler | Atomic commit + event-ID record |
| Sync assertions on async flows | Adyen / PayPal finalize via webhook | Webhook-driven asserts per Phase 0 |
| Test against live APIs | Real money | Sandbox-only, per payment-gateway-sandboxes |
Limitations
References
Webhook order-tolerance, partial-failure, and archive-replay scenarios
View source (opens in new window)Webhook order-tolerance, partial-failure, and archive-replay scenarios
Deep reference for payment-flow-test-author SKILL.md. Consult once the core signature + idempotency + replay-simulator surface is in place and the suite needs the harder recovery scenarios: out-of-order delivery, mid-handler crash recovery, replay of old events from the gateway archive, and the per-gateway suite layout.
Order-tolerance tests
Webhooks can arrive out of order:
test('out-of-order event delivery handled', async () => {
const completedEvent = makeEvent({ type: 'payment_intent.succeeded' });
const creatingEvent = makeEvent({ type: 'payment_intent.created' });
// Deliver completed BEFORE created
await postWebhook(completedEvent);
await postWebhook(creatingEvent);
// Final state should still be correct
const record = await db.payments.findOne({ intent_id: completedEvent.data.id });
expect(record.status).toBe('succeeded');
});The handler must use versioned events or state-machine gates to handle this:
# Don't blindly overwrite state
def handle_event(event):
record = db.payments.get(event.intent_id)
new_state = event.data.status
if state_transition_allowed(record.status, new_state):
record.status = new_state
record.save()
# else: stale event, ignorePartial-failure scenarios
What happens when the handler crashes mid-processing?
test('crash mid-processing → retry succeeds', async () => {
let crashOnce = true;
const handler = makeHandler({
onProcessEvent: () => {
if (crashOnce) {
crashOnce = false;
throw new Error('simulated crash');
}
},
});
await expect(handler.process(event)).rejects.toThrow(); // First attempt crashes
await handler.process(event); // Retry succeeds; idempotent
const record = await db.payments.findOne({ event_id: event.id });
expect(record).toBeTruthy();
});Handlers should commit state changes atomically - either the processing succeeds and the event is marked handled, or both roll back.
Replay-from-archive
Production sometimes loses webhooks (network outage, deploy issue). Per gateway docs, all support some form of replay:
| Gateway | Replay window | Method |
|---|---|---|
| Stripe | 30 days | stripe events resend <event_id> |
| Adyen | Unlimited (Customer Area) | Manual or notification-resend API |
| PayPal | 30 days | Webhook resend endpoint |
| Braintree | Unlimited (Control Panel) | Manual or webhookTesting.sampleNotification |
Test:
test('handler accepts replay from 7-day-old event', async () => {
const oldEvent = makeEvent({ created: Math.floor(Date.now()/1000) - 7*86400 });
const result = await handler.process(oldEvent);
expect(result).toBe(200);
});Suite layout
One directory per gateway so per-gateway quirks stay isolated:
tests/payment/webhooks/
stripe/
signature.test.ts
idempotency.test.ts
order-tolerance.test.ts
replay.test.ts
adyen/
... (same structure)
paypal/
...
braintree/
...
fixtures/
payloads/Chargeback reason code catalog
View source (opens in new window)Chargeback reason code catalog
Pick the 3-5 most-common reason codes for your business and verify the evidence-collection flow for each. Each code carries different evidence requirements, so a single-reason test under-covers the suite. Visa, Mastercard, and AmEx each maintain their own reason-code catalogs.
Visa
Per Visa Chargeback Reason Codes (cite by stable ID: Visa Chargeback Management Guidelines):
| Code | Category | Description |
|---|---|---|
| 10.4 | Fraud | "Card-absent environment fraud" |
| 11.1 | Authorization | Card recovery bulletin |
| 11.2 | Authorization | Declined authorization |
| 11.3 | Authorization | No authorization |
| 12.1 | Processing errors | Late presentment |
| 12.2 | Processing errors | Incorrect transaction code |
| 12.3 | Processing errors | Incorrect currency |
| 12.4 | Processing errors | Incorrect account number |
| 13.1 | Consumer disputes | Merchandise/services not received |
| 13.2 | Consumer disputes | Cancelled recurring transaction |
| 13.3 | Consumer disputes | Not as described |
| 13.5 | Consumer disputes | Misrepresentation |
Mastercard
Per Mastercard MCC chargeback reason codes (cite by stable ID: Mastercard Chargeback Guide):
| Code | Description |
|---|---|
| 4853 | Cardholder disputes |
| 4855 | Non-receipt of merchandise |
| 4859 | Services not rendered |
| 4863 | Cardholder doesn't recognize |
Sources
Gateway-native replay simulators + signature schemes
View source (opens in new window)Gateway-native replay simulators + signature schemes
Deep reference for payment-flow-test-author SKILL.md Phase 3. The per-gateway mechanics for driving and replaying real webhook events, and the signature-verification gauntlet each gateway needs.
Replay simulators
Each gateway ships a way to (re)send a real event at the handler:
| Gateway | Replay | Notes |
|---|---|---|
| Stripe | stripe trigger payment_intent.succeeded; stripe events resend evt_test_12345 | CLI (stripe-cli (opens in new window)); resend covers a 30-day window (cli/events/resend (opens in new window)) |
| Adyen | Customer Area transaction "Resend webhook" | Re-sends with the original signature - useful for idempotency tests |
| PayPal | Dashboard simulator; REST equivalent via the API | simulate-event (opens in new window) |
| Braintree | gateway.webhookTesting.sampleNotification(kind, id) | Generates a test signature for any event kind (parse/node (opens in new window)) |
Signature schemes
The scheme differs per gateway; run the same four-case gauntlet against each:
| Gateway | Signature scheme | Reference |
|---|---|---|
| Stripe | HMAC-SHA256 over the payload; the signature carries a timestamp, so old timestamps reject | webhooks/signatures (opens in new window) |
| Adyen | HMAC-SHA256 over the canonical string; validated per-event, not per-request | verify-hmac-signatures (opens in new window) |
| PayPal | SHA256-with-RSA; verify via the PayPal verification endpoint or SDK helper | webhooks/rest (opens in new window) |
| Braintree | parser validates bt_signature against the merchant's public key | Braintree webhook parser |
The four-case signature gauntlet (Stripe form)
Per docs.stripe.com/webhooks/signatures (opens in new window), the gauntlet is four cases - unsigned, wrong-secret, valid, and expired-timestamp:
const payload = JSON.stringify({ type: 'payment_intent.succeeded' });
test('rejects unsigned payload', async () => {
const res = await fetch('/webhooks/stripe', { method: 'POST', body: payload });
expect(res.status).toBe(401); // No Stripe-Signature header
});
test('rejects wrong-secret signature', async () => {
const wrongSig = stripe.webhooks.generateTestHeaderString({ payload, secret: 'wrong-secret' });
const res = await fetch('/webhooks/stripe', {
method: 'POST', body: payload, headers: { 'stripe-signature': wrongSig },
});
expect(res.status).toBe(401);
});
test('accepts valid signature', async () => {
const sig = stripe.webhooks.generateTestHeaderString({
payload, secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
const res = await fetch('/webhooks/stripe', {
method: 'POST', body: payload, headers: { 'stripe-signature': sig },
});
expect(res.status).toBe(200);
});
test('rejects expired timestamp', async () => {
const oldSig = stripe.webhooks.generateTestHeaderString({
payload,
secret: process.env.STRIPE_WEBHOOK_SECRET!,
timestamp: Math.floor(Date.now()/1000) - 3600, // 1 hour ago
});
const res = await fetch('/webhooks/stripe', {
method: 'POST', body: payload, headers: { 'stripe-signature': oldSig },
});
expect(res.status).toBe(401);
});Gotcha: the handler must read the raw request body - a parsed-then-restringified copy fails signature verification on the restringified bytes.
Sources
Related skills
payment-flow-states-reference
Pure-reference catalog of payment lifecycle state machines across Stripe, Adyen, PayPal, and Braintree: canonical states (created / requires_action / processing / succeeded / requires_capture / canceled / failed), authorisation vs capture, asynchronous webhook states, refund / dispute / chargeback transitions, and the 3-D Secure (EMVCo 3DS 2.x) frictionless / challenge flow paths with per-gateway 3DS test cards (references/3ds-flows.md). Use when designing tests for payment flows, auditing state-handling code, or covering a 3DS round-trip; this is the state model, not a builder - to author suites on it use payment-flow-test-author (refunds, disputes, webhook replay).
payment-gateway-sandboxes
Wraps the vendor-generic payment-gateway sandbox pattern - test credentials, sandbox base URLs / environment switches, deterministic test-card matrices, and gateway-native webhook simulators - with per-gateway references for Adyen test mode, PayPal Sandbox, and Braintree sandbox. Use when testing code integrated with Adyen, PayPal, or Braintree; for Stripe use stripe-test-cards-and-webhooks (one-time payments) or stripe-subscription-billing-test-author (recurring billing).
stripe-subscription-billing-test-author
Builds test suites for Stripe recurring-billing flows: trial-to-paid conversion, proration on plan upgrade and downgrade, dunning on failed renewal, cancel and reactivation, and the full subscription webhook event matrix (invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, invoice.paid). Uses Stripe Billing test clocks (POST /v1/test_helpers/test_clocks) to time-travel through billing cycles without calendar delay. Distinct from stripe-test-cards-and-webhooks (one-time PaymentIntents) and payment-flow-test-author (idempotency + replay robustness). Does not cover single-event CLI replay or handler idempotency testing (see payment-flow-test-author for those). Use when authoring tests for subscription or recurring-billing integrations.
stripe-test-cards-and-webhooks
Wraps Stripe API testing patterns: test-mode initialization, the canonical test cards (4242 success; 4000 0000 0000 0002 declined; 4000 0027 6000 3184 3DS challenge per the 3DS flows reference in payment-flow-states-reference), the Stripe CLI webhook flow (`stripe listen --forward-to`), the Stripe CLI fixture commands (`stripe trigger payment_intent.succeeded`), and the webhook signature verification (Stripe-Signature header + HMAC-SHA256). Use when testing Stripe-integrated code.