Chargeback & Dispute Automation

Chargeback and dispute automation is the workflow inside Fraud Detection & Revenue Protection that handles what happens after an open-loop contactless rider disputes a fare: the acquirer sends a chargeback file, the transit operator has a fixed number of days to either accept the loss or represent with evidence, and the outcome has to land back in the ledger to the cent. Done by hand it is a queue of PDFs and a spreadsheet; done right it is a deterministic pipeline that ingests the acquirer file, matches each case to the original tap and settlement, decides accept-versus-represent against the evidence on hand, and writes an auditable Decimal adjustment. This page builds that pipeline as production code, because a chargeback that misses its representment deadline is unrecoverable revenue and a mismatched one is a double loss.

The problem is narrow but unforgiving. Open-loop EMV fares are aggregated, capped, and settled days after the tap, so a single disputed charge on a rider’s card statement may correspond to several taps bundled into one settlement amount. Matching a chargeback back to those taps by card reference, amount, and date — then classifying it as a valid loss, a representable case, or an already-resolved duplicate — is where the money is won or lost. The token-and-amount matching mechanics are worked in full in the deep dive on automating EMV contactless chargeback reconciliation; this page is the surrounding component that ingests, decides, and posts.

Architecture: From Acquirer File to Ledger Adjustment

A chargeback enters as a row in an acquirer or scheme file, not as a rich object. The pipeline normalizes it, finds the settled tap or settlement batch it refers to, runs a disposition rule that weighs the reason code and the available evidence against the representment deadline, and emits a ledger adjustment — a refund, a contingent hold pending representment, or nothing at all when the case is already resolved.

The flow below shows the four stages and the three terminal dispositions:

Chargeback automation flow from acquirer file to ledger adjustment An acquirer chargeback file is matched to the original tap and settlement. A disposition stage then routes each case to one of three outcomes: accept and refund, represent with evidence, or no adjustment because the case is already resolved or time-barred. The accept-and-refund and represent-with-evidence outcomes both write an entry to the ledger adjustment stage; the already-resolved outcome is terminal and writes nothing. valid evidence resolved Chargeback file acquirer / scheme Match to tap & settlement Disposition Accept & refund post credit Represent contingent hold No adjustment resolved / time-barred Ledger adjustment Decimal, audit-chained

The financial quantity the pipeline must get exactly right is net liability. For a case with disputed amount DD, an already-posted refund RR, and a representation win probability that operations does not attempt to model in the ledger, the immediate ledger impact is

L=max ⁣(0,  DR)L = \max\!\left(0,\; D - R\right)

posted as a debit when the case is accepted and held as a contingent entry when it is represented. Everything else in the component exists to make sure DD is matched to the right tap, RR is not double-counted, and LL is posted once.

The accept-versus-represent decision is where automation earns its keep, and where it most often goes wrong by hand. Representing a chargeback — contesting it with evidence that the fare was legitimately incurred — costs staff time and is only worth doing when the evidence is strong and the reason code is one that schemes actually reverse. Fraud reason codes almost never win representment, because the cardholder’s bank has already sided with a claim of unauthorized use; service codes such as “goods or services not received” are frequently winnable when the operator can show a valid tap and a correctly-capped fare. A human queue tends to either over-represent, burning effort on unwinnable fraud cases to protect a few dollars each, or under-represent, writing off recoverable service disputes because the deadline slipped. Encoding the policy as a reason-code map plus an evidence flag makes that trade-off explicit and consistent across thousands of cases.

Prerequisites & Environment

The component runs as a scheduled batch on Python 3.11+ with decimal for all money, datetime for the representment-deadline arithmetic, and pandas>=2.1 only to load and normalize the acquirer file. It depends on read access to the settled tap ledger and the acquirer chargeback feed, and write access — through an append-only interface — to the adjustment ledger described in Ledger & Audit Trail Design.

Input / assumption Expectation Why it matters
Chargeback file Acquirer or scheme format (e.g. Visa/Mastercard dispute file), one row per case The row’s reason code and amount drive the disposition; a mis-parsed amount posts the wrong liability
Card reference Tokenized PAN reference (pan_ref) or network token, never a raw PAN Matching keys on it; storing a raw PAN would breach PCI-DSS scope
Disputed amount Decimal minor units A single dispute may map to several capped taps; float aggregation drifts
Settlement linkage Each tap carries its settlement batch id and date The chargeback dates against settlement, not the tap instant, so the window keys on settlement
Reason code Mapped to a disposition policy (fraud, not-received, duplicate) Fraud codes rarely win representment; service codes often do
Representment deadline Scheme-defined days from the chargeback date A case past its deadline cannot be represented and must auto-accept
Prior refund state Whether the tap was already refunded or credited An already-refunded case must not be debited twice

Deadlines and reason-code policies are configuration, not code. Scheme rules change and per-acquirer service agreements differ, so the disposition thresholds are loaded per acquirer and versioned rather than compiled into the binary. A concrete failure mode drives this: the card schemes revise their reason-code taxonomies and representment windows on their own cadence, and a hardcoded 30 for the deadline that was correct last year silently starts either abandoning winnable cases early or, worse, letting the pipeline believe it can still contest a case the scheme has already closed. Loading the window and the reason-code map from a versioned policy store means a scheme bulletin becomes a config change with an effective date, and every disposition records which policy version produced it — so a decision can be re-justified against the rules that were actually in force when it was made.

Core Implementation

The dispositioner takes a normalized chargeback and its matched settlement context and returns an immutable decision carrying the disposition, the Decimal net liability, and the ledger entry to post. It never posts directly — it emits an intent that an append-only ledger writer applies — so the decision is replayable and the same case processed twice produces one entry.

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
from enum import Enum
from typing import Optional

logger = logging.getLogger("transit.chargeback")

CENTS = Decimal("0.01")


class Disposition(Enum):
    ACCEPT_REFUND = "accept_refund"       # valid dispute; credit the rider, take the loss
    REPRESENT = "represent"               # evidence supports contesting; contingent hold
    ALREADY_RESOLVED = "already_resolved" # duplicate or already-refunded; no new entry
    TIME_BARRED = "time_barred"           # past representment deadline; auto-accept
    NO_MATCH = "no_match"                 # cannot be matched to a settled tap; manual review


class ChargebackError(Exception):
    """Base exception for chargeback dispositioning failures."""


@dataclass(frozen=True)
class Chargeback:
    case_id: str
    pan_ref: str                 # tokenized card reference, never a raw PAN
    disputed_cents: Decimal
    reason_code: str
    chargeback_date: date
    is_partial: bool             # disputes only part of the settled amount


@dataclass(frozen=True)
class SettlementContext:
    """The matched settled amount and its prior adjustment state."""
    settlement_id: str
    settled_cents: Decimal
    settlement_date: date
    already_refunded_cents: Decimal   # sum of refunds/credits already posted
    has_representable_evidence: bool  # e.g. valid tap + capped-fare proof on file


@dataclass(frozen=True)
class DispositionResult:
    case_id: str
    disposition: Disposition
    net_liability_cents: Decimal      # immediate ledger impact
    ledger_entry: Optional[dict]      # intent for the append-only writer, or None


class ChargebackDispositioner:
    """Decides accept-vs-represent for matched chargebacks and emits ledger intents."""

    # Reason-code prefixes that empirically almost never win representment.
    _FRAUD_CODES = frozenset({"10.4", "10.5", "83", "37"})

    def __init__(self, representment_days: int = 30) -> None:
        self.representment_days = representment_days

    def _quantize(self, amount: Decimal) -> Decimal:
        return amount.quantize(CENTS, rounding=ROUND_HALF_UP)

    def _net_liability(self, cb: Chargeback, ctx: SettlementContext) -> Decimal:
        """Disputed amount less refunds already posted, never below zero."""
        disputed = cb.disputed_cents
        if cb.is_partial and disputed > ctx.settled_cents:
            raise ChargebackError(
                f"Partial dispute {disputed} exceeds settled {ctx.settled_cents} for {cb.case_id}"
            )
        liability = disputed - ctx.already_refunded_cents
        return self._quantize(max(Decimal("0"), liability))

    def _is_time_barred(self, cb: Chargeback, today: date) -> bool:
        deadline = cb.chargeback_date + timedelta(days=self.representment_days)
        return today > deadline

    def disposition(
        self,
        cb: Chargeback,
        ctx: Optional[SettlementContext],
        today: date,
    ) -> DispositionResult:
        try:
            if cb.disputed_cents <= 0:
                raise ChargebackError(f"Disputed amount must be positive for {cb.case_id}")

            # 1. No settlement match -> cannot dispose automatically.
            if ctx is None:
                logger.warning("No settlement match for chargeback %s", cb.case_id)
                return DispositionResult(cb.case_id, Disposition.NO_MATCH, Decimal("0.00"), None)

            net = self._net_liability(cb, ctx)

            # 2. Already refunded to (or beyond) the disputed amount -> no new entry.
            if net == 0:
                logger.info("Chargeback %s already resolved (net liability zero)", cb.case_id)
                return DispositionResult(cb.case_id, Disposition.ALREADY_RESOLVED, Decimal("0.00"), None)

            # 3. Past the representment deadline -> must auto-accept the loss.
            if self._is_time_barred(cb, today):
                logger.info("Chargeback %s time-barred; auto-accepting", cb.case_id)
                return DispositionResult(
                    cb.case_id, Disposition.TIME_BARRED, net,
                    self._entry(cb, ctx, net, "debit", "time_barred_auto_accept"),
                )

            # 4. Representable only if evidence exists and the reason is contestable.
            reason_is_fraud = any(cb.reason_code.startswith(p) for p in self._FRAUD_CODES)
            if ctx.has_representable_evidence and not reason_is_fraud:
                return DispositionResult(
                    cb.case_id, Disposition.REPRESENT, net,
                    self._entry(cb, ctx, net, "contingent", "represent_pending"),
                )

            # 5. Default: accept the dispute and refund.
            return DispositionResult(
                cb.case_id, Disposition.ACCEPT_REFUND, net,
                self._entry(cb, ctx, net, "debit", "accept_refund"),
            )
        except (InvalidOperation, ChargebackError) as exc:
            logger.error("Dispositioning failed for %s: %s", cb.case_id, exc)
            raise

    def _entry(self, cb: Chargeback, ctx: SettlementContext, net: Decimal,
               entry_type: str, rationale: str) -> dict:
        """Build an append-only ledger intent; the writer assigns the hash chain."""
        return {
            "case_id": cb.case_id,
            "settlement_id": ctx.settlement_id,
            "pan_ref": cb.pan_ref,
            "entry_type": entry_type,        # 'debit' | 'contingent'
            "amount_cents": str(net),        # Decimal serialized as string, never float
            "reason_code": cb.reason_code,
            "rationale": rationale,
        }

Three properties keep it audit-safe. Net liability is disputed - already_refunded floored at zero, so a case already credited to the rider produces ALREADY_RESOLVED and no second debit. The representment deadline is checked before the evidence branch, so a strong case that arrived too late still auto-accepts rather than being queued past its window. And the dispositioner emits an intent with the amount serialized as a Decimal string, leaving the hash-chained write to the ledger layer — the decision and the posting are separable and independently replayable.

The order of the branches is not cosmetic; it encodes the priority of the failure modes. The no-match check comes first because a case with no settlement context cannot be priced at all, and forcing it through the liability math would either raise or, worse, post a zero-amount entry that looks resolved. The already-resolved check comes before the deadline check so that a case already credited to the rider never reopens on a technicality about its age. The deadline check comes before the evidence branch because the strongest evidence in the world is worthless once the representment window has closed — a case queued for contest past its deadline is simply an accepted loss the operator forgot to book. Reordering any pair of these guards changes which mistake the pipeline makes under pressure, which is why the sequence is fixed and covered by tests rather than left to whichever condition an author happened to write first.

Schema Validation & Edge Cases

Chargeback files are among the messiest inputs in the revenue stack: they arrive out of order, restate prior cases, and mix partials with full disputes. The edges that cause double losses are all in the matching and the state.

  • Partial chargebacks. A rider may dispute only part of a capped daily fare. is_partial gates a check that the partial amount does not exceed the settled amount, and the net liability is the partial figure, not the whole settlement — debiting the full settled amount for a partial dispute over-refunds.
  • Duplicates and re-presentments. The same case id can appear across files as the scheme cycles it through stages. Deduplicate on case_id and the settlement it matches; a case already carrying a posted debit must not generate a second one.
  • Already-refunded taps. If the operator already issued a goodwill refund for the tap, already_refunded_cents absorbs it and the net liability drops — often to zero. Skipping this check double-credits the rider.
  • Time-barred disputes. A case whose chargeback date is older than the representment window can no longer be contested; it must auto-accept, and the pipeline should alert if time-barred cases are common because that signals a file-ingestion lag, not a decisioning problem.
  • No-match cases. A chargeback that cannot be tied to any settled tap — wrong token, a refund on a non-transit merchant miscoded to the operator, a pre-tokenization legacy charge — must route to manual review as NO_MATCH, never be auto-accepted into the ledger.
  • Amount and currency normalization. Amounts arrive in mixed formats and, on cross-border acquirers, mixed currencies. Normalize to minor units in the operator’s settlement currency as Decimal at ingestion; a decimal-point misread posts a 100x liability.

Integration Pattern

Chargeback automation is downstream of settlement and upstream of the audit ledger, and it depends on both being correct. The settlement context it matches against — which tap belongs to which settled batch, and what was already refunded — comes from Revenue Reconciliation & Settlement; running the dispositioner before settlement has cleared means matching against provisional amounts that later move, and every such move re-opens a chargeback decision. So the component reads settled, reconciled batches and treats them as immutable.

On the write side, every disposition is a financial event that must be tamper-evident, so the emitted intent is applied by the append-only, hash-chained writer from Ledger & Audit Trail Design. Posting the credit or contingent hold there — rather than mutating a settlement row in place — is what lets an operator prove, months later during a scheme audit, exactly which chargeback produced which adjustment and why. The contingent-hold pattern matters here: a represented case is neither a settled loss nor recovered revenue until the scheme rules, so it is posted as a distinct entry type that reserves the amount without writing it off. When the representment outcome arrives — a win reverses the hold, a loss converts it to a debit — a second append-only entry records the resolution, and the two entries together form a complete, replayable history of the dispute. Never collapse that into a single mutable field that flips between states, because the intermediate contingent period is exactly what an auditor asks to see. The matching step that finds the settlement context in the first place, keying on token or PAN reference plus amount and date, is the subject of the deep dive on automating EMV contactless chargeback reconciliation.

Performance & Scale Considerations

Chargeback volume is a small fraction of tap volume — basis points of settled transactions — so the pipeline is bounded by the match lookups, not by decisioning.

  • Index the settlement side. Load the settled taps into a lookup keyed on pan_ref and settlement date range before processing the file; a per-case scan of the ledger turns a minutes-long batch into hours.
  • Batch by acquirer file. Process one acquirer file as a unit so deadline and reason-code configuration is loaded once per file rather than per case.
  • Keep decisioning pure. The dispositioner does no I/O; it takes context in and returns an intent out. This makes it trivially parallelizable across cases and, more importantly, unit-testable without a ledger.
  • Idempotent posting. The append-only writer dedupes on (case_id, settlement_id, entry_type), so re-running a file after a partial failure re-posts nothing already applied.
  • Retain the source row. Store the raw acquirer row alongside the disposition so a disputed decision can be re-derived from the exact input, including a later-discovered mis-parse.

Operational Checklist

  1. Match against settled batches only. Confirm the settlement context comes from reconciled, immutable settlement, never the provisional feed.
  2. Version scheme policy. Pin representment-day windows and reason-code mappings per acquirer and stamp the version on every disposition.
  3. Alert on time-barred cases. Treat a rising TIME_BARRED count as an ingestion-lag incident, not a normal outcome.
  4. Route no-match to humans. Ensure NO_MATCH cases enter a manual review queue with the raw row attached, and never auto-post.
  5. Post through the append-only ledger. Verify every entry is applied by the hash-chained writer, and reconcile posted debits against the acquirer’s expected loss report.
  6. Guard against double refunds. Confirm already_refunded_cents is populated from the ledger before dispositioning, and assert net liability never goes negative.
  7. Track representment win rate. Feed contested outcomes back so the evidence heuristic can be tuned against which reason codes actually win.
  8. Reconcile the chargeback ledger monthly. Confirm the sum of posted adjustments ties to the scheme’s chargeback statement to the cent.

Part of Fraud Detection & Revenue Protection.