Testland
Browse all skills & agents

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-author
View source

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

  • New payment integration; need refund / dispute / webhook coverage.
  • A refund- or webhook-related incident; need to backfill tests.
  • Gateway migration; need to re-validate flow logic.

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

#TestExpected
1Full refund of captured chargerefund.status = succeeded; charge.amount_refunded = charge.amount
2Partial refund (50%)refund.amount = 0.5x charge.amount
3Multiple partials summing to fullCumulative refunded = charge.amount; charge.refunded = true
4Over-refund attempt (101%)Gateway rejects; descriptive error
5Refund of already-fully-refunded chargeRejected with "charge_already_refunded" or equivalent
6Refund of failed chargeRejected; no refund created
7Refund of disputed chargePer 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

  1. Pick the 3-5 reason codes most common for your business from references/reason-codes.md - each code has different evidence requirements.
  2. Trigger a disputable charge in the gateway's test mode (Stripe pm_card_createDispute per docs.stripe.com/testing#disputes (opens in new window); an Adyen [CHARGEBACK] notification per docs.adyen.com/risk-management/disputes-api (opens in new window); a PayPal sandbox dispute per developer.paypal.com/docs/api/customer-disputes/v1 (opens in new window)).
  3. Assert the dispute lands in needs_response (or the gateway equivalent) with the expected reason.
  4. Submit evidence before evidence_details.due_by and assert has_evidence is set, per docs.stripe.com/disputes/responding (opens in new window).
  5. Drive each disposition - won (winning evidence), lost (no response), accepted - and confirm the final state via the charge.dispute.closed webhook.
  6. Verify the ledger reverses funds plus the chargeback fee on a lost dispute.
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.

  1. Confirm the handler reads the raw request body - signature verification fails on parsed-then-restringified bytes.
  2. Run the four-case signature gauntlet (unsigned / wrong-secret / valid / expired-timestamp) for your gateway; the per-gateway signature schemes, simulator commands, and the full gauntlet code are in references/replay-simulators.md.
  3. Add the idempotency dedup test - redelivery must not double-process:
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
});
  1. Wire the gateway's replay simulator and drive a real event end to end:
stripe trigger payment_intent.succeeded    # synthetic event to the forward URL
stripe events resend evt_test_12345        # replay a captured event (30-day window)
  1. Add the harder recovery scenarios - out-of-order delivery, mid-handler crash, archive replay, per-gateway suite layout - from references/advanced-recovery-scenarios.md.

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-patternWhy it failsFix
Test only full-refund happy pathPartial-refund accounting bugs hidePer-variant test
No idempotency key on mutating callsNetwork retry → double refund / chargeAlways set
Skip dispute testsWorst-case impact is highCover top 3-5 reason codes
No due-date trackingEvidence submitted late → auto-loseTest the due-date watcher
Skip chargeback-fee reconciliationBooks don't matchTest the ledger
Skip signature verificationSpoofed webhook payloadsFour-case gauntlet per gateway
Trust HTTP 200 == processedServer may have crashed mid-handlerAtomic commit + event-ID record
Sync assertions on async flowsAdyen / PayPal finalize via webhookWebhook-driven asserts per Phase 0
Test against live APIsReal moneySandbox-only, per payment-gateway-sandboxes

Limitations

  • Real chargebacks take weeks. Test mode collapses the timeline; production verification needs production traffic.
  • Reason codes change. Visa / Mastercard publish updates; test data goes stale annually.
  • Bank-declined refunds simulate poorly; rely on platform-documented test cases.
  • Bank-initiated webhooks aren't always test-mode-triggerable.

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

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

GatewayReplay windowMethod
Stripe30 daysstripe events resend <event_id>
AdyenUnlimited (Customer Area)Manual or notification-resend API
PayPal30 daysWebhook resend endpoint
BraintreeUnlimited (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):

CodeCategoryDescription
10.4Fraud"Card-absent environment fraud"
11.1AuthorizationCard recovery bulletin
11.2AuthorizationDeclined authorization
11.3AuthorizationNo authorization
12.1Processing errorsLate presentment
12.2Processing errorsIncorrect transaction code
12.3Processing errorsIncorrect currency
12.4Processing errorsIncorrect account number
13.1Consumer disputesMerchandise/services not received
13.2Consumer disputesCancelled recurring transaction
13.3Consumer disputesNot as described
13.5Consumer disputesMisrepresentation

Mastercard

Per Mastercard MCC chargeback reason codes (cite by stable ID: Mastercard Chargeback Guide):

CodeDescription
4853Cardholder disputes
4855Non-receipt of merchandise
4859Services not rendered
4863Cardholder doesn't recognize

Sources

  • Visa Chargeback Reason Codes (cite by stable ID: Visa Chargeback Management Guidelines).
  • Mastercard Chargeback Guide (cite by stable ID).

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:

GatewayReplayNotes
Stripestripe trigger payment_intent.succeeded; stripe events resend evt_test_12345CLI (stripe-cli (opens in new window)); resend covers a 30-day window (cli/events/resend (opens in new window))
AdyenCustomer Area transaction "Resend webhook"Re-sends with the original signature - useful for idempotency tests
PayPalDashboard simulator; REST equivalent via the APIsimulate-event (opens in new window)
Braintreegateway.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:

GatewaySignature schemeReference
StripeHMAC-SHA256 over the payload; the signature carries a timestamp, so old timestamps rejectwebhooks/signatures (opens in new window)
AdyenHMAC-SHA256 over the canonical string; validated per-event, not per-requestverify-hmac-signatures (opens in new window)
PayPalSHA256-with-RSA; verify via the PayPal verification endpoint or SDK helperwebhooks/rest (opens in new window)
Braintreeparser validates bt_signature against the merchant's public keyBraintree 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.