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.
75
94%
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
A chargeback (also: dispute) is a customer-initiated reversal of a settled transaction. The merchant has a fixed window (typically 7-30 days) to submit evidence. Outcomes: merchant wins (funds retained) or loses (funds + chargeback fee deducted).
Per stripe.com/docs/disputes: "Disputes (also known as chargebacks) start when a cardholder questions a payment with their card issuer." Visa, Mastercard, AmEx each maintain their own reason-code catalogs.
pm_card_createDispute, an Adyen chargeback notification, a PayPal sandbox dispute).needs_response (or the gateway equivalent) with the expected reason.evidence_details.due_by and assert has_evidence is set.charge.dispute.closed webhook.Visa, Mastercard, and AmEx each maintain their own reason-code catalogs. Pick the 3-5 most-common codes for your business and verify the evidence-collection flow for each - each code has different evidence requirements.
See references/reason-codes.md for the full Visa and Mastercard reason-code tables (Visa 10.4 fraud, 13.1 services-not-provided; Mastercard MCC 4855 non-receipt, and more).
Per stripe.com/docs/disputes:
the dispute object has a status field (needs_response,
under_review, won, lost, warning_*).
Test mode:
// Create a disputable charge
const charge = await stripe.paymentIntents.create({
amount: 1000, currency: 'usd',
payment_method: 'pm_card_createDispute', // Special test method
confirm: true, return_url: 'https://example.com/return',
});
// Wait for the dispute event via webhook
// Or fetch directly
const disputes = await stripe.disputes.list({ payment_intent: charge.id });
expect(disputes.data[0].status).toBe('needs_response');
expect(disputes.data[0].reason).toBe('fraudulent');Per Stripe testing docs, special payment methods trigger disputes in test mode.
Per docs.adyen.com/risk-management/disputes-api:
Adyen sends [CHARGEBACK] notifications.
// Simulate via test mode + webhook
const dispute = await disputesApi.acceptDispute({
disputePspReference: 'test-dispute-ref',
merchantAccountCode: process.env.ADYEN_MERCHANT_ACCOUNT,
});Per developer.paypal.com/docs/api/customer-disputes/v1:
const dispute = await disputesClient.get(disputeId);
expect(dispute.status).toBe('OPEN');Per stripe.com/docs/disputes/responding:
test('submit dispute evidence', async () => {
const dispute = disputes.data[0];
const result = await stripe.disputes.update(dispute.id, {
evidence: {
product_description: 'Premium subscription month 5',
service_documentation: fileUploadId, // FileUpload object
shipping_documentation: shippingFileId,
customer_email_address: 'user@example.com',
customer_purchase_ip: '203.0.113.1',
receipt: receiptFileId,
},
});
expect(result.evidence_details.has_evidence).toBe(true);
});The evidence must be submitted before the due date
(evidence_details.due_by).
test('won disputes update internal state', async () => {
// Trigger via test mode
const dispute = await createTestDispute({ reason: 'product_not_received' });
await submitWinningEvidence(dispute.id);
// In Stripe test mode, certain evidence text wins the dispute
await waitForWebhook('charge.dispute.closed', { matching: { id: dispute.id } });
const final = await stripe.disputes.retrieve(dispute.id);
expect(final.status).toBe('won');
expect(final.amount).toBe(0); // No funds deducted
});
test('lost disputes update internal state', async () => {
const dispute = await createTestDispute({});
// Don't submit evidence; let it expire
await skipPastDueDate(dispute);
await waitForWebhook('charge.dispute.closed');
const final = await stripe.disputes.retrieve(dispute.id);
expect(final.status).toBe('lost');
});Many merchants auto-collect evidence on every charge - purchase description, shipping info, customer IP - to streamline disputes. Tests should verify:
test('every charge has auto-evidence', async () => {
const intent = await createSucceededIntent({...});
const evidence = await loadEvidenceForCharge(intent.charges.data[0].id);
expect(evidence).toMatchObject({
customer_email_address: expect.any(String),
customer_purchase_ip: expect.any(String),
receipt_url: expect.any(String),
});
});# tests/payment/chargeback-matrix.yaml
matrix:
reason_codes:
- "Visa 10.4 - fraud"
- "Visa 13.1 - services not provided"
- "Mastercard 4855 - non-receipt"
gateways:
- stripe
- adyen
- paypal
outcomes:
- won-with-evidence
- lost-no-response
- acceptedPer (gateway, reason, outcome) cell, generate a test.
Chargebacks affect accounting:
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: 'charge', amount: 1000 }));
expect(ledger).toContainEqual(expect.objectContaining({ type: 'chargeback', amount: -1000 }));
expect(ledger).toContainEqual(expect.objectContaining({ type: 'chargeback_fee' }));
});A SaaS team on Stripe wants coverage for a Visa 13.1 (services/merchandise not received) dispute they expect to win.
product_not_received reason.payment_method: 'pm_card_createDispute', confirms it, lists disputes for the intent, and asserts status === 'needs_response'.service_documentation FileUpload and calls stripe.disputes.update with product_description: 'Premium subscription month 5' plus the customer email + IP, asserting evidence_details.has_evidence === true and that the call ran before due_by.charge.dispute.closed webhook and asserts status === 'won' and amount === 0 (no funds deducted).status === 'lost' plus a chargeback and chargeback_fee pair in the ledger.Result: the 13.1 won-with-evidence and lost-no-response cells are both green, and the due-date watcher is exercised.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Skip dispute tests | "Won't happen often" but worst-case impact is high | Always cover |
| Hand-coded evidence submission | Schema drift; missed fields | Use gateway SDK helpers |
| No due-date tracking | Evidence submitted late → auto-lose | Test the due-date watcher |
| Test only one reason code | Each reason has different evidence requirements | Cover top 3-5 |
| Mock the dispute object | Loses gateway-side state transitions | Use test-mode dispute triggers |
| Skip chargeback-fee reconciliation | Books don't match | Test the ledger |
No webhook handling for charge.dispute.closed | Final state unknown | Webhook-driven |
| Hardcoded "$15 chargeback fee" | Per-gateway, per-region; varies | Read from gateway response |
payment-flow-states-reference.stripe-test-cards-and-webhooks,
adyen-test-mode,
paypal-sandbox,
braintree-test-cards.refund-test-matrix-builder,
payment-webhook-replay.