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
92%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Medium
Suggest reviewing before use
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.
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 |
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 |
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/);
});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.
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');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.
| 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 |
# 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
});
});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.
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-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 |
payment-flow-states-reference,
pci-dss-scope-reference.stripe-test-cards-and-webhooks,
adyen-test-mode,
paypal-sandbox,
braintree-test-cards.chargeback-flow-test-author,
payment-webhook-replay.