CtrlK
BlogDocsLog inGet started
Tessl Logo

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

75

Quality

94%

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:
chargeback-flow-test-author
description:
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.

chargeback-flow-test-author

Overview

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.

When to use

  • Designing dispute test coverage for a new integration.
  • Auditing existing dispute-evidence-submission code.
  • Building dispute analytics dashboards.

How to use

  1. Pick the 3-5 reason codes most common for your business from references/reason-codes.md.
  2. Trigger a disputable charge in the gateway's test mode (Stripe pm_card_createDispute, an Adyen chargeback notification, a PayPal sandbox dispute).
  3. Assert the dispute lands in needs_response (or the gateway equivalent) with the expected reason.
  4. Submit evidence before evidence_details.due_by and assert has_evidence is set.
  5. Drive each disposition - won (winning evidence), lost (no response), accepted - and confirm the final state via the charge.dispute.closed webhook.
  6. Verify the ledger reverses funds plus the chargeback fee on a lost dispute.
  7. Expand into the (gateway, reason, outcome) matrix so every cell has a test.

Step 1 - Reason code catalog

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).

Step 2 - Per-gateway dispute API

Stripe

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.

Adyen

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,
});

PayPal Disputes API

Per developer.paypal.com/docs/api/customer-disputes/v1:

const dispute = await disputesClient.get(disputeId);
expect(dispute.status).toBe('OPEN');

Step 3 - Submit evidence

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).

Step 4 - Test the disposition flow

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');
});

Step 5 - Auto-evidence collection

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),
  });
});

Step 6 - Test matrix

# 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
    - accepted

Per (gateway, reason, outcome) cell, generate a test.

Step 7 - Reconciliation

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' }));
});

Worked example

A SaaS team on Stripe wants coverage for a Visa 13.1 (services/merchandise not received) dispute they expect to win.

  1. From references/reason-codes.md they map 13.1 to Stripe's product_not_received reason.
  2. A test creates a charge with payment_method: 'pm_card_createDispute', confirms it, lists disputes for the intent, and asserts status === 'needs_response'.
  3. The test uploads a 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.
  4. It waits for the charge.dispute.closed webhook and asserts status === 'won' and amount === 0 (no funds deducted).
  5. A sibling test skips evidence, advances past the due date, and asserts 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-patterns

Anti-patternWhy it failsFix
Skip dispute tests"Won't happen often" but worst-case impact is highAlways cover
Hand-coded evidence submissionSchema drift; missed fieldsUse gateway SDK helpers
No due-date trackingEvidence submitted late → auto-loseTest the due-date watcher
Test only one reason codeEach reason has different evidence requirementsCover top 3-5
Mock the dispute objectLoses gateway-side state transitionsUse test-mode dispute triggers
Skip chargeback-fee reconciliationBooks don't matchTest the ledger
No webhook handling for charge.dispute.closedFinal state unknownWebhook-driven
Hardcoded "$15 chargeback fee"Per-gateway, per-region; variesRead from gateway response

Limitations

  • Real chargebacks take weeks. Test mode collapses the timeline; production verification needs production traffic.
  • Reason codes change. Visa / Mastercard publish updates; test data goes stale annually.
  • Bank-side evidence review isn't fully testable.
  • Per-jurisdiction rules. EU has stronger consumer protection; US has different.

References

Workspace
testland
Visibility
Public
Created
Last updated
Publish Source
GitHub
Badge
testland/chargeback-flow-test-author badge