Automating EMV Contactless Chargeback Reconciliation

This guide is the matching engine behind Chargeback & Dispute Automation, within Fraud Detection & Revenue Protection: given an acquirer chargeback record for an open-loop contactless fare, find the original tap-and-settlement it refers to using the tokenized card reference, the amount, and the date, classify the case as valid, represent-with-evidence, or already-resolved, and produce a Decimal net-liability figure with the ledger adjustments to post. Open-loop settlement bundles several capped taps into one charge, so the match is against a settled amount, not a single tap — which is exactly what makes naive amount matching fail. The taps and settlements are assumed already ingested; this page owns the reconciliation from chargeback row to classified, priced case.

Match-and-classify flow

The engine narrows a chargeback to one settlement through two guards — a window-and-amount filter, then a prior-refund check — before it ever classifies. Ordering matters: a case with no candidate must exit as NO_MATCH before any liability is computed, and an already-refunded case must short-circuit before the accept-versus-represent branch. The flow below traces those guards to a single terminal class:

EMV contactless chargeback match-and-classify flow A chargeback record is used to look up candidate taps by tokenized card reference. A first guard tests whether any candidate falls within the settlement date window and amount tolerance; if none does, the case is NO_MATCH and routed to manual review. If candidates exist, the engine ranks them and picks the best, then a second guard tests whether that settlement was already refunded; if so the case is ALREADY_RESOLVED. Otherwise the engine classifies the case and computes the Decimal net liability. yes no no yes Chargeback record pan_ref, amount, date Candidates by pan_ref token index lookup In window & amount? NO_MATCH manual review Rank & pick best amount then date Already refunded? ALREADY_RESOLVED no new entry Classify & price Decimal net liability

Step 1 — Index settlements by token reference

Matching starts from the tokenized card reference, never a raw PAN — storing a PAN would drag the whole pipeline into PCI-DSS cardholder-data scope. Build a lookup from pan_ref to the settled charges for that token so each chargeback is an O(1) candidate fetch rather than a table scan. Open-loop settlements aggregate capped taps, so the unit indexed is the settlement, carrying its settled amount, date, and prior-refund total.

from __future__ import annotations

import logging
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Iterable

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


@dataclass(frozen=True)
class SettledCharge:
    settlement_id: str
    pan_ref: str                 # tokenized card reference
    settled_cents: Decimal       # aggregated capped taps in this settlement
    settlement_date: date
    already_refunded_cents: Decimal
    has_representable_evidence: bool


@dataclass(frozen=True)
class Chargeback:
    case_id: str
    pan_ref: str
    disputed_cents: Decimal
    reason_code: str
    chargeback_date: date


def index_by_token(charges: Iterable[SettledCharge]) -> dict[str, list[SettledCharge]]:
    """Group settled charges by tokenized card reference for O(1) candidate lookup."""
    index: dict[str, list[SettledCharge]] = defaultdict(list)
    for ch in charges:
        if not ch.pan_ref:
            logger.warning("Skipping settlement %s with empty pan_ref", ch.settlement_id)
            continue
        index[ch.pan_ref].append(ch)
    return dict(index)

Step 2 — Filter and rank candidates

A token can have many settlements. Keep only those whose date falls inside the settlement window that precedes the chargeback — a chargeback dates after the settlement it disputes — and whose amount matches within a small tolerance for cross-border FX rounding. Then rank: an exact-amount, closest-date settlement is the strongest candidate. The comparison is Decimal throughout; the amount tolerance is minor units, not a float percentage.

from datetime import timedelta


class MatchError(Exception):
    """Raised when candidate selection receives inconsistent inputs."""


@dataclass(frozen=True)
class Candidate:
    charge: SettledCharge
    amount_delta_cents: Decimal   # abs difference from the disputed amount
    day_gap: int                  # settlement-to-chargeback days


def find_candidates(
    cb: Chargeback,
    index: dict[str, list[SettledCharge]],
    window_days: int = 45,
    amount_tolerance_cents: Decimal = Decimal("2"),
) -> list[Candidate]:
    """Return date/amount-eligible settlements for a chargeback, best first."""
    if cb.disputed_cents <= 0:
        raise MatchError(f"Disputed amount must be positive for {cb.case_id}")

    candidates: list[Candidate] = []
    for charge in index.get(cb.pan_ref, ()):
        day_gap = (cb.chargeback_date - charge.settlement_date).days
        # Chargeback must fall after settlement, within the dispute window.
        if not (0 <= day_gap <= window_days):
            continue
        amount_delta = abs(charge.settled_cents - cb.disputed_cents)
        if amount_delta > amount_tolerance_cents:
            continue
        candidates.append(Candidate(charge, amount_delta, day_gap))

    # Best = smallest amount delta, then smallest day gap.
    candidates.sort(key=lambda c: (c.amount_delta_cents, c.day_gap))
    logger.debug("Chargeback %s matched %d candidate(s)", cb.case_id, len(candidates))
    return candidates

Step 3 — Classify and compute net liability

With a best candidate in hand, classify the case and price it. An already-refunded settlement whose credits cover the dispute is ALREADY_RESOLVED with zero net liability. A contestable reason code with evidence on file is REPRESENT. Everything else is a valid dispute the operator accepts. Net liability is the disputed amount less prior refunds, floored at zero and quantized once.

from decimal import ROUND_HALF_UP
from enum import Enum
from typing import Optional


class MatchClass(Enum):
    VALID = "valid"
    REPRESENT = "represent_with_evidence"
    ALREADY_RESOLVED = "already_resolved"
    NO_MATCH = "no_match"


_FRAUD_CODES = frozenset({"10.4", "10.5", "83", "37"})
CENTS = Decimal("0.01")


@dataclass(frozen=True)
class Reconciliation:
    case_id: str
    match_class: MatchClass
    settlement_id: Optional[str]
    net_liability_cents: Decimal
    ledger_entry: Optional[dict]


def reconcile(cb: Chargeback, candidates: list[Candidate]) -> Reconciliation:
    """Classify a chargeback against its best candidate and price the net liability."""
    if not candidates:
        logger.info("No settlement candidate for chargeback %s", cb.case_id)
        return Reconciliation(cb.case_id, MatchClass.NO_MATCH, None, Decimal("0.00"), None)

    charge = candidates[0].charge
    net = (cb.disputed_cents - charge.already_refunded_cents)
    net = max(Decimal("0"), net).quantize(CENTS, rounding=ROUND_HALF_UP)

    if net == 0:
        return Reconciliation(cb.case_id, MatchClass.ALREADY_RESOLVED,
                              charge.settlement_id, Decimal("0.00"), None)

    reason_is_fraud = any(cb.reason_code.startswith(p) for p in _FRAUD_CODES)
    if charge.has_representable_evidence and not reason_is_fraud:
        entry = _entry(cb, charge, net, "contingent", "represent_pending")
        return Reconciliation(cb.case_id, MatchClass.REPRESENT, charge.settlement_id, net, entry)

    entry = _entry(cb, charge, net, "debit", "accept_refund")
    return Reconciliation(cb.case_id, MatchClass.VALID, charge.settlement_id, net, entry)


def _entry(cb: Chargeback, charge: SettledCharge, net: Decimal,
           entry_type: str, rationale: str) -> dict:
    return {
        "case_id": cb.case_id,
        "settlement_id": charge.settlement_id,
        "pan_ref": cb.pan_ref,
        "entry_type": entry_type,
        "amount_cents": str(net),        # Decimal as string, never float
        "reason_code": cb.reason_code,
        "rationale": rationale,
    }

Validation & Test Cases

The first case is a clean match a rider genuinely disputes; the second is a represent-worthy edge where evidence and a service reason code flip the classification away from an automatic refund.

from datetime import date
from decimal import Decimal

charges = [
    SettledCharge("STL_100", "TOKEN_AAA", Decimal("870"), date(2026, 6, 10),
                  already_refunded_cents=Decimal("0"), has_representable_evidence=False),
    SettledCharge("STL_101", "TOKEN_BBB", Decimal("540"), date(2026, 6, 12),
                  already_refunded_cents=Decimal("0"), has_representable_evidence=True),
]
index = index_by_token(charges)

# Clean fraud dispute: matches STL_100 on token + amount + window -> VALID, refund $8.70.
cb1 = Chargeback("CASE_1", "TOKEN_AAA", Decimal("870"), "10.4", date(2026, 6, 25))
r1 = reconcile(cb1, find_candidates(cb1, index))
assert r1.match_class is MatchClass.VALID
assert r1.net_liability_cents == Decimal("8.70")
assert r1.ledger_entry["entry_type"] == "debit"

# Represent-worthy: service reason code + evidence on file -> contingent hold, not a refund.
cb2 = Chargeback("CASE_2", "TOKEN_BBB", Decimal("540"), "13.1", date(2026, 6, 20))
r2 = reconcile(cb2, find_candidates(cb2, index))
assert r2.match_class is MatchClass.REPRESENT
assert r2.net_liability_cents == Decimal("5.40")
assert r2.ledger_entry["entry_type"] == "contingent"

# No candidate: chargeback amount outside tolerance -> NO_MATCH, no ledger entry.
cb3 = Chargeback("CASE_3", "TOKEN_AAA", Decimal("500"), "10.4", date(2026, 6, 25))
r3 = reconcile(cb3, find_candidates(cb3, index))
assert r3.match_class is MatchClass.NO_MATCH
assert r3.ledger_entry is None

# Already resolved: settlement fully refunded before the chargeback arrives.
resolved = [SettledCharge("STL_200", "TOKEN_CCC", Decimal("300"), date(2026, 6, 1),
                          already_refunded_cents=Decimal("300"), has_representable_evidence=False)]
cb4 = Chargeback("CASE_4", "TOKEN_CCC", Decimal("300"), "10.4", date(2026, 6, 20))
r4 = reconcile(cb4, find_candidates(cb4, index_by_token(resolved)))
assert r4.match_class is MatchClass.ALREADY_RESOLVED
assert r4.net_liability_cents == Decimal("0.00")

The VALID case posts an 8.70 debit because a fraud reason code with no evidence is not worth contesting. The REPRESENT case, differing only in reason code and evidence flag, posts a contingent hold instead — the operator will fight it, so the money is reserved but not written off. The NO_MATCH case never posts, and the ALREADY_RESOLVED case shows the refund floor at work: a settlement already credited in full yields zero net liability and no second debit.

Edge Cases

  • Aggregated settlements. One dispute may reference a bundle of capped taps. Match against the settled amount, not an individual tap fare, or the amount filter rejects every real candidate.
  • FX rounding. Cross-border acquirers restate the amount after currency conversion, so require a small amount_tolerance_cents rather than an exact match — but keep it tight, since a wide tolerance mis-matches adjacent settlements.
  • Multiple candidates. If two settlements tie on amount and date, do not guess; surface both and route to review. Auto-picking risks refunding against the wrong charge.
  • Window edges. A chargeback dated before its settlement is a data fault, not a match; the 0 <= day_gap guard rejects it rather than pairing a chargeback to a later settlement.

Integration Note

This engine produces the settlement context that the parent Chargeback & Dispute Automation dispositioner consumes — the matched settlement, its refund state, and the priced net liability become the input to deadline and policy decisioning. Its closest sibling is Matching Settlement Files to Tap Ledgers: both reconcile an external financial record against internal tap data by token, amount, and date, and the token index built here is the same structure that settlement matching relies on. Read the reconciled ledger from that pipeline so a chargeback matches against settled, immutable amounts rather than provisional ones that may still move.

FAQ

Why match on the settled amount instead of the individual tap fare?
Because open-loop contactless fares are aggregated. A rider's taps across a day are capped and bundled into one settlement charge that hits their card statement, and the chargeback disputes that charge — not a single $2.90 tap. Matching against the per-tap fare would fail the amount filter on almost every real case; the settlement is the unit the acquirer and the cardholder both see.
Is a raw PAN ever needed to match a chargeback?
No, and using one would be a compliance mistake. Network tokenization gives every card a stable pan_ref that appears on both the settlement and the chargeback, so the whole match keys on the token. Keeping raw PANs out of the pipeline holds the reconciliation system outside PCI-DSS cardholder-data scope, which is a hard requirement for most transit operators.
What stops a wide amount tolerance from mis-matching settlements?
The tolerance is deliberately tight — a couple of minor units to absorb FX rounding, not a percentage. Candidates are then ranked by amount delta first and date gap second, so an exact match always beats a near one. When two settlements tie inside the tolerance, the engine surfaces both for manual review rather than auto-picking, because refunding against the wrong charge is a second loss on top of the first.

Part of Chargeback & Dispute Automation, within Fraud Detection & Revenue Protection.