refund-test-matrix-builder
Workflow-driven skill that builds a refund test matrix from a payment-flow inventory: refund variants (full / partial / multiple-partials / over-refund / refund-on-disputed / refund-on-already-refunded), per-gateway nuances (Stripe, Adyen, PayPal, Braintree refund APIs), and timing variants (immediate / next-day / declined-by-bank), one test case per cell. Use for refund coverage on a new integration; for chargeback / dispute coverage use chargeback-flow-test-author, for webhook redelivery + idempotency use payment-webhook-replay, and for the state model use payment-flow-states-reference.
Install with skills.sh (any agent)
npx skills add testland/qa --skill refund-test-matrix-builderrefund-test-matrix-builder
Overview
Refund logic is the second-most-misimplemented part of payment flows (after 3DS). Common bugs: double-refund, refund-before- capture, partial-refund-not-summing, refund-of-already-refunded.
This skill walks through producing the refund test matrix - not exhaustive (every gateway × every variant × every timing = thousands), but covering the canonical cells.
When to use
Step 1 - Inventory refund touchpoints
Grep for refund-issuing code:
grep -rn 'refund\|Refund\|REFUND' --include='*.{ts,js,py,java,go,rb,cs}' .Categorise per gateway + per code path:
| Touchpoint | Gateway | Trigger |
|---|---|---|
| Order cancellation flow | Stripe | User clicks "cancel" within 24h |
| Customer service portal | Stripe | CS rep issues partial refund |
| Subscription downgrade | Stripe | Pro-rated refund for unused time |
| Cross-tenant chargeback handler | Stripe / Adyen | Automatic on dispute lost |
Step 2 - The 7 canonical refund test 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; charge.amount_refunded reflects |
| 3 | Multiple partial refunds summing to full | All succeed; cumulative refunded = charge.amount; charge.refunded = true |
| 4 | Over-refund attempt (101% of total) | 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: blocks or allows but doesn't reverse dispute |
Step 3 - Per-gateway refund patterns
Stripe
Per docs.stripe.com/refunds (opens in new window):
test('full refund', async () => {
const intent = await createSucceededIntent({ amount: 1000 });
const refund = await stripe.refunds.create({ payment_intent: intent.id });
expect(refund.status).toBe('succeeded');
expect(refund.amount).toBe(1000);
});
test('partial refund', async () => {
const intent = await createSucceededIntent({ amount: 1000 });
const refund = await stripe.refunds.create({ payment_intent: intent.id, amount: 500 });
expect(refund.amount).toBe(500);
});
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
Per docs.adyen.com/online-payments/refund (opens in new window):
const result = await checkout.modificationsCorrespondingRefund({
originalReference: 'capture-pspReference',
modificationAmount: { value: 500, currency: 'EUR' },
});
expect(result.response).toBe('[refund-received]');
// Actual refund completion via webhook (notification)Adyen refunds are async; webhook handles [refund-received] → [REFUND] settled.
PayPal
Per developer.paypal.com/docs/api/payments/v2#captures_refund (opens in new window):
const request = new paypal.payments.CapturesRefundRequest(captureId);
request.requestBody({ amount: { value: '5.00', currency_code: 'USD' } });
const result = await client.execute(request);
expect(result.result.status).toBe('COMPLETED');Braintree
const result = await gateway.transaction.refund(transactionId, '5.00');
expect(result.success).toBe(true);
expect(result.transaction.type).toBe('credit');Braintree requires settlement first per braintree-test-cards.
Step 4 - Timing variants
| Variant | Test |
|---|---|
| Immediate refund | Issue + assert refund.status = succeeded |
| Async refund (Adyen, PayPal) | Issue + poll webhook |
| Same-day (within auth window) | Issue before settlement; assert auth-void semantics |
| Next-day | Issue after settlement; assert refund-credit semantics |
| Declined by bank | Per gateway: simulated via specific failure-mode test card |
Step 5 - Emit the test matrix
# tests/payment/refund-matrix.yaml
matrix:
gateways:
- stripe
- adyen
- paypal
- braintree
variants:
- full
- partial
- multiple-partials
- over-refund-attempt
- already-refunded
- failed-charge
- disputed-charge
timing:
- immediate
- async-webhookFor each (gateway, variant, timing) cell, generate a test:
// tests/payment/refund-stripe.test.ts
import { CASES } from './refund-matrix';
describe.each(CASES.stripe)('Stripe refund: $variant', ({ variant, expected }) => {
test(variant, async () => {
// ... per-variant logic + assertion
});
});Step 6 - Idempotency
Refunds are mutating operations; idempotency keys are critical:
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.
Step 7 - Reporting + reconciliation
Refund-test coverage matrix should be reported per release:
## Refund Coverage Matrix
| Gateway | Variant | Immediate | Async Webhook |
|---|---|---|---|
| Stripe | full | ✅ | n/a (sync) |
| Stripe | partial | ✅ | n/a |
| Stripe | over-refund | ✅ | n/a |
| Adyen | full | ✅ | ✅ |
| ... | ... | ... | ... |
## Documented gaps
- Braintree disputed-charge refund: deferred to phase 2
- Multi-partial refund crossing day boundaries: manual QAAnti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Test only full-refund happy path | Partial-refund accounting bugs hide | Per-variant test |
| Skip over-refund test | Sum-mismatch on partials | Always test |
| No idempotency key | Network retry → double refund | Always set |
| Test refunds against live API | Real money | Sandbox-only |
| Skip async webhook for Adyen | Refund status undetermined | Wait for [REFUND] notification |
| Hardcoded refund amounts | Cents vs dollars confusion | Per-currency tests |
| Test in one currency only | Cross-currency refund quirks | Test in EUR, GBP, JPY |
| Skip cross-tenant refund tests | Per cross-tenant-data-leak-tests (in the qa-multi-tenancy plugin), tenant A can't refund tenant B's charge | Cross-tenant probe |
Limitations
References
Related skills
3ds-test-flow-reference
Cross-gateway, protocol-level reference for 3-D Secure (3DS 2.x) test coverage. Covers the EMVCo frictionless / challenge / not-applicable flow paths, SCA under EU PSD2, and the per-PAN test cards for Stripe, Adyen, and Braintree. The gateway-specific wrappers (adyen-test-mode / stripe-test-cards-and-webhooks / braintree-test-cards) compose this skill for single-gateway work, so use one of those for a single-gateway query. Use this skill when designing multi-gateway 3DS test coverage, auditing a 3DS redirect round-trip, or investigating a challenge-flow regression that is not gateway-specific.
adyen-test-mode
Wraps Adyen test-mode patterns: test-API-key initialization, the canonical Adyen test cards (Visa 4111 1111 1111 1111; 5454 5454 5454 5454 3DS frictionless; 4917 6100 0000 0000 3DS 2 challenge), the per-flow result codes (Authorised / Refused / Pending / RedirectShopper / Error), the HMAC-SHA256 webhook validation, and notifications-vs-API duality (Adyen separates synchronous API calls from asynchronous notifications). Use when testing Adyen-integrated code.
braintree-test-cards
Wraps Braintree (PayPal-owned) sandbox testing patterns: sandbox merchant credentials, Drop-in / Hosted Fields client-side patterns, the Transaction lifecycle (submitted_for_settlement → settled), Braintree's distinctive test-card behaviours (specific PANs trigger specific errors), and the webhook verification (Braintree Webhook Parser). Use when testing Braintree-integrated code.
chargeback-flow-test-author
Workflow-driven skill that builds the chargeback / dispute test suite: canonical reason codes (Visa 10.4 fraud, 13.1 services-not-provided; Mastercard MCC 4855), per-gateway dispute APIs (Stripe Disputes, Adyen Chargeback notifications, PayPal Disputes), the evidence-submission flow + window, and disposition outcomes (won / lost / accepted). Use when designing dispute coverage; for refunds use refund-test-matrix-builder, for webhook redelivery + idempotency use payment-webhook-replay, and for the lifecycle state model use payment-flow-states-reference.
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, and refund / dispute / chargeback transitions. Use when designing tests for payment flows or auditing state-handling code; this is the state model, not a builder - to author suites on it use refund-test-matrix-builder (refunds), chargeback-flow-test-author (disputes), or payment-webhook-replay (webhook replay).
payment-webhook-replay
Workflow-driven skill that builds payment webhook replay + recovery tests. Covers the idempotency contract (every webhook handler must handle redelivery without side effects), the replay simulators (Stripe CLI `stripe trigger`, Adyen Customer Area resend, PayPal Webhook Simulator, Braintree webhook test), the signature-verification gauntlet (HMAC-SHA256 per gateway, expired-timestamps rejection), and the partial-failure recovery scenarios. Use when designing webhook robustness tests.
paypal-sandbox
Wraps PayPal Sandbox testing patterns: sandbox account creation (Business + Personal accounts in developer.paypal.com), the Orders v2 API (create / capture / refund), webhook event simulator (developer.paypal.com webhook simulator), sandbox-account-specific test cards, and the OAuth2 client-credentials flow for sandbox. Use when testing PayPal-integrated code.
pci-dss-scope-reference
Pure-reference catalog of PCI DSS v4.0 scope reduction techniques + the testable scope boundaries. Covers the SAQ levels (A through D, picked by how cardholder data flows), the PAN-storage prohibitions (only first-6 + last-4 retained; nothing else cleartext), the tokenization + hosted-fields scope-reduction patterns (Stripe Elements / Adyen Drop-in / Braintree Hosted Fields keep PAN off your servers), Network-Segmentation as PCI scope-reduction, and the testable behaviours the scope boundary creates. This is the catalog of WHY the boundary matters and what it makes testable - not a checker that verifies a given integration against the standard. Use when designing or auditing the PCI scope of a payment integration.
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-webhook-replay (idempotency + replay robustness). Does not cover single-event CLI replay or handler idempotency testing (see payment-webhook-replay 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 3ds-test-flow-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.