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:
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_centsrather 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_gapguard 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?
$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?
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?
Related
- Chargeback & Dispute Automation — the parent component that disposes the matched, priced cases this engine produces.
- Matching Settlement Files to Tap Ledgers — the sibling reconciliation that ties settlement records to internal taps by the same token, amount, and date keys.
- Ledger & Audit Trail Design — the append-only store the emitted adjustment entries post to.
- Revenue Reconciliation & Settlement — produces the settled, reconciled charges this engine matches against.
Part of Chargeback & Dispute Automation, within Fraud Detection & Revenue Protection.