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:
The financial quantity the pipeline must get exactly right is net liability. For a case with disputed amount , an already-posted refund , and a representation win probability that operations does not attempt to model in the ledger, the immediate ledger impact is
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 is matched to the right tap, is not double-counted, and 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_partialgates 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_idand 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_centsabsorbs 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
Decimalat 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_refand 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
- Match against settled batches only. Confirm the settlement context comes from reconciled, immutable settlement, never the provisional feed.
- Version scheme policy. Pin representment-day windows and reason-code mappings per acquirer and stamp the version on every disposition.
- Alert on time-barred cases. Treat a rising
TIME_BARREDcount as an ingestion-lag incident, not a normal outcome. - Route no-match to humans. Ensure
NO_MATCHcases enter a manual review queue with the raw row attached, and never auto-post. - 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.
- Guard against double refunds. Confirm
already_refunded_centsis populated from the ledger before dispositioning, and assert net liability never goes negative. - Track representment win rate. Feed contested outcomes back so the evidence heuristic can be tuned against which reason codes actually win.
- Reconcile the chargeback ledger monthly. Confirm the sum of posted adjustments ties to the scheme’s chargeback statement to the cent.
Related deep-dive guides
- Automating EMV Contactless Chargeback Reconciliation — the matching algorithm that ties an acquirer chargeback to the original contactless tap and settlement by token, amount, and date, and computes the
Decimalnet liability.
Related
- Revenue Reconciliation & Settlement — produces the settled, reconciled batches the dispositioner matches against.
- Ledger & Audit Trail Design — the append-only, hash-chained store every chargeback adjustment is posted to.
- Tap Pattern Anomaly Detection — the real-time signal that flags the fraud a chargeback later confirms.
- Fare Evasion Analytics — the other revenue-protection workflow, sizing leakage rather than resolving disputes.
Part of Fraud Detection & Revenue Protection.