CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/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.

73

Quality

92%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Medium

Suggest reviewing before use

Overview
Quality
Evals
Security
Files
name:
refund-test-matrix-builder
description:
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.

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 cross-tenant-data-leak-tests (in the qa-multi-tenancy plugin), 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

Workspace
testland
Visibility
Public
Created
Last updated
Publish Source
GitHub
Badge
testland/refund-test-matrix-builder badge