Testland
Browse all skills & agents

refund-test-matrix-builder

Workflow-driven skill that builds a refund test matrix from a payment flow inventory. Covers the refund variants (full / partial / multiple-partials / overrefund-attempt / refund-on-disputed / refund-on-already-refunded / refund-on-failed-charge), the per-gateway nuances (Stripe RefundIntent; Adyen Modifications refund; PayPal Captures.refund; Braintree Transaction.refund), the timing variants (immediate / next-day / declined-by-bank), and emits the test cases per cell. Use when designing refund coverage for a new integration. Composes payment-flow-states-reference + stripe-test-cards-and-webhooks + adyen-test-mode + paypal-sandbox + braintree-test-cards.

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

  • New payment integration; need refund test coverage.
  • Refund-related incident; need to backfill tests.
  • Gateway migration; need to re-validate refund logic.

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:

TouchpointGatewayTrigger
Order cancellation flowStripeUser clicks "cancel" within 24h
Customer service portalStripeCS rep issues partial refund
Subscription downgradeStripePro-rated refund for unused time
Cross-tenant chargeback handlerStripe / AdyenAutomatic on dispute lost

Step 2 - The 7 canonical refund test 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; charge.amount_refunded reflects
3Multiple partial refunds summing to fullAll succeed; cumulative refunded = charge.amount; charge.refunded = true
4Over-refund attempt (101% of total)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: blocks or allows but doesn't reverse dispute

Step 3 - Per-gateway refund patterns

Stripe

Per docs.stripe.com/refunds:

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:

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:

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

VariantTest
Immediate refundIssue + assert refund.status = succeeded
Async refund (Adyen, PayPal)Issue + poll webhook
Same-day (within auth window)Issue before settlement; assert auth-void semantics
Next-dayIssue after settlement; assert refund-credit semantics
Declined by bankPer 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-webhook

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

Anti-patterns

Anti-patternWhy it failsFix
Test only full-refund happy pathPartial-refund accounting bugs hidePer-variant test
Skip over-refund testSum-mismatch on partialsAlways test
No idempotency keyNetwork retry → double refundAlways set
Test refunds against live APIReal moneySandbox-only
Skip async webhook for AdyenRefund status undeterminedWait for [REFUND] notification
Hardcoded refund amountsCents vs dollars confusionPer-currency tests
Test in one currency onlyCross-currency refund quirksTest in EUR, GBP, JPY
Skip cross-tenant refund testsPer qa-multi-tenancy/cross-tenant-data-leak-tests, tenant A can't refund tenant B's chargeCross-tenant probe

Limitations

  • Refund-of-disputed-charge is gateway-policy-specific. Some allow with warnings; some block.
  • Currency conversion at refund time may differ from charge time. Test with currency-changing scenarios.
  • Bank-declined refunds simulate poorly; rely on platform- doc'd test cases.
  • No automation for actual money movement. Sandbox refunds don't move real funds; production verification is separate.

References