← Files Cashback CardARCHIVED FILE

skills/find-best-cashback/scripts/rank_cards.py

32.2 KB · Oct 4, 2026 · 12:29 UTC

↓ Download file

#!/usr/bin/env python3
"""Rank cashback / rewards cards for one specific purchase.

The engine is deliberately explicit: every number in the output can be traced
to a rule in the card catalog plus a merchant resolution. It never invents a
rate. If the catalog has no entry, the card is reported as `unknown` rather
than silently earning the base rate.

Typical use:

    rank_cards.py --merchant shell --channel pump --amount 60 \
        --country US --cards amex-bcp,citi-custom-cash,chase-freedom-unlimited

    rank_cards.py request.json
    rank_cards.py --explain-merchant walmart --country US
    rank_cards.py --list-cards IN
"""

from __future__ import annotations

import argparse
import json
import math
import sys
from datetime import date, datetime
from pathlib import Path
from typing import Any

DATA_DIR = Path(__file__).resolve().parent.parent / "data"
STALE_AFTER_DAYS = 120

CONFIDENCE_ORDER = {"high": 3, "medium": 2, "low": 1, "unknown": 0}

# Users say "UK" and "USA"; the catalogs are keyed on ISO codes.
COUNTRY_ALIASES = {
    "UK": "GB", "ENGLAND": "GB", "SCOTLAND": "GB", "WALES": "GB",
    "USA": "US", "U.S.": "US", "U.S.A.": "US", "AMERICA": "US",
    "INDIA": "IN", "BHARAT": "IN", "CANADA": "CA",
}

DEFAULT_CURRENCY = {"US": "USD", "IN": "INR", "GB": "GBP", "CA": "CAD"}


def normalize_country(value: str) -> str:
    code = str(value or "US").strip().upper()
    return COUNTRY_ALIASES.get(code, code)


# --------------------------------------------------------------------------
# small helpers
# --------------------------------------------------------------------------


class InputError(ValueError):
    """Raised for anything wrong with caller-supplied JSON."""


def num(value: Any, field: str, *, default: float | None = None) -> float:
    if value is None:
        if default is None:
            raise InputError(f"{field} is required")
        return float(default)
    if isinstance(value, bool):
        raise InputError(f"{field} must be a number, not a boolean")
    try:
        result = float(value)
    except (TypeError, ValueError) as exc:
        raise InputError(f"{field} must be a number") from exc
    if not math.isfinite(result):
        raise InputError(f"{field} must be a finite number")
    if result < 0:
        raise InputError(f"{field} cannot be negative")
    return result


def slug(value: str) -> str:
    return "".join(ch if ch.isalnum() else "-" for ch in value.strip().lower()).strip("-")


def as_list(value: Any) -> list[str]:
    if value is None:
        return []
    if isinstance(value, str):
        return [value]
    if isinstance(value, list):
        return [str(item) for item in value]
    raise InputError(f"expected a string or list, got {type(value).__name__}")


def parse_day(value: str | None, field: str) -> date | None:
    if not value:
        return None
    text = str(value)
    for fmt in ("%Y-%m-%d", "%Y-%m", "%Y"):
        try:
            return datetime.strptime(text, fmt).date()
        except ValueError:
            continue
    raise InputError(f"{field} must look like YYYY-MM-DD, YYYY-MM or YYYY")


# --------------------------------------------------------------------------
# catalog loading
# --------------------------------------------------------------------------


def load_json(path: Path) -> dict:
    if not path.exists():
        raise InputError(f"missing data file: {path.name}")
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise InputError(f"{path.name} is not valid JSON: {exc}") from exc


def load_cards(country: str) -> dict:
    code = normalize_country(country)
    path = DATA_DIR / f"cards-{code.lower()}.json"
    if not path.exists():
        available = sorted(
            p.stem.split("-", 1)[1].upper() for p in DATA_DIR.glob("cards-*.json")
        )
        raise InputError(
            f"no bundled catalog for country {code!r}; available: {', '.join(available)}. "
            "Pass full card definitions inline instead."
        )
    return load_json(path)


def load_merchants() -> dict:
    return load_json(DATA_DIR / "merchants.json")


# --------------------------------------------------------------------------
# merchant resolution
# --------------------------------------------------------------------------


def resolve_merchant(name: str, channel: str | None, country: str,
                     catalog: dict) -> dict:
    """Map a store name + purchase channel onto spend categories and MCCs.

    Returns a resolution dict even when the merchant is unknown, so the caller
    always gets an auditable record of what was assumed.
    """
    wanted = slug(name)
    merchants = catalog.get("merchants", {})

    entry = merchants.get(wanted)
    if entry is None:
        for key, candidate in merchants.items():
            aliases = {slug(a) for a in candidate.get("aliases", [])}
            aliases.add(key)
            if wanted in aliases:
                entry = candidate
                wanted = key
                break

    if entry is None:
        return {
            "merchant": name,
            "merchant_id": None,
            "matched": False,
            "channel": channel or "unspecified",
            "categories": [],
            "mcc": [],
            "confidence": "unknown",
            "caveats": [
                f"{name!r} is not in the bundled merchant table. Resolve its category "
                "from live research or ask the user how the purchase is likely to code."
            ],
        }

    countries = [normalize_country(c) for c in entry.get("countries", [])]
    code = normalize_country(country)
    caveats: list[str] = []
    if countries and code not in countries:
        caveats.append(
            f"Bundled coding notes for {entry.get('display_name', name)} were written for "
            f"{', '.join(countries)}; behaviour in {code} may differ."
        )

    variants = entry.get("variants", {})
    default_key = entry.get("default_channel")
    chosen_key = channel or default_key
    variant = variants.get(chosen_key) if chosen_key else None

    if variant is None:
        variant = variants.get(default_key, {}) if default_key else {}
        if channel:
            caveats.append(
                f"Channel {channel!r} is not modelled for this merchant; used "
                f"{default_key!r} instead."
            )
        chosen_key = default_key

    if not channel and len(variants) > 1:
        caveats.append(
            "Channel was not specified and this merchant codes differently by channel "
            f"({', '.join(sorted(variants))}); assumed {chosen_key!r}. Confirm with "
            "the user before treating the answer as settled."
        )

    caveats.extend(variant.get("caveats", []))
    caveats.extend(entry.get("caveats", []))

    # A store format that issuers treat as a separate merchant (Walmart
    # Neighborhood Market, a supermarket's fuel forecourt) gets its own id so
    # that a by-name exclusion on the parent brand does not wrongly catch it.
    resolved_id = variant.get("merchant_id", wanted)

    return {
        "merchant": variant.get("display_name", entry.get("display_name", name)),
        "merchant_id": resolved_id,
        "matched": True,
        "channel": chosen_key or "unspecified",
        "categories": list(variant.get("categories", [])),
        "mcc": list(variant.get("mcc", [])),
        "confidence": variant.get("confidence", "low"),
        "caveats": caveats,
        "source": entry.get("source"),
    }


# --------------------------------------------------------------------------
# rule matching
# --------------------------------------------------------------------------


def rule_matches(rule: dict, ctx: dict) -> tuple[bool, str | None]:
    """Does this earning rule apply to the purchase? Returns (ok, reason_if_not)."""
    # Exclusions are checked first: "Walmart is excluded from the grocery
    # category" is a far more useful explanation than "category did not match",
    # and it is the reason users most often get a surprising answer.
    excludes = rule.get("excludes", {})
    bad_categories = set(as_list(excludes.get("categories"))) & set(ctx["categories"])
    if bad_categories:
        return False, f"excluded category: {', '.join(sorted(bad_categories))}"
    if ctx["merchant_id"] and ctx["merchant_id"] in set(as_list(excludes.get("merchants"))):
        return False, f"{ctx['merchant']} is explicitly excluded by this rule"
    bad_mcc = set(as_list(excludes.get("mcc"))) & set(ctx["mcc"])
    if bad_mcc:
        return False, f"excluded MCC: {', '.join(sorted(bad_mcc))}"

    match = rule.get("match", {})

    want_categories = set(as_list(match.get("categories")))
    want_merchants = set(as_list(match.get("merchants")))
    if want_categories or want_merchants:
        by_category = bool(want_categories & set(ctx["categories"]))
        by_merchant = bool(want_merchants) and ctx["merchant_id"] in want_merchants
        if not (by_category or by_merchant):
            if want_merchants and not want_categories:
                return False, "merchant not in the rule's merchant list"
            return False, "category does not match"

    want_mcc = set(as_list(match.get("mcc")))
    if want_mcc and not want_mcc & set(ctx["mcc"]):
        return False, "MCC not in the rule's MCC list"

    want_channels = set(as_list(match.get("channels")))
    if want_channels and ctx["channel"] not in want_channels:
        return False, f"rule applies only to channel(s) {', '.join(sorted(want_channels))}"

    return True, None


def earn_cash(rule: dict, spend: float, card: dict) -> tuple[float, float, str]:
    """Convert spend under one rule into cash value.

    Returns (cash, raw_points, human readable rate label).
    """
    unit = rule.get("unit", "percent")
    point_value = num(
        rule.get("point_value_cents", card.get("point_value_cents", 1.0)),
        "point_value_cents",
        default=1.0,
    )

    if unit == "percent":
        rate = num(rule.get("rate"), "rate")
        return spend * rate / 100.0, 0.0, f"{rate:g}%"

    if unit == "points_per_unit":
        rate = num(rule.get("rate"), "rate")
        points = spend * rate
        return points * point_value / 100.0, points, f"{rate:g}x points"

    if unit == "points_per_slab":
        rate = num(rule.get("rate"), "rate")
        slab = num(rule.get("slab"), "slab")
        if slab <= 0:
            raise InputError("slab must be greater than zero")
        points = math.floor(spend / slab) * rate
        return points * point_value / 100.0, points, f"{rate:g} pts / {slab:g} spent"

    raise InputError(f"unknown rule unit {unit!r}")


def cap_limited_spend(rule: dict, spend: float,
                      used: float) -> tuple[float, bool, str | None]:
    """Apply a cap to the spend eligible for a bonus rule.

    Only spend-basis caps can be resolved here; reward-basis caps are applied
    after the reward is computed.
    """
    cap = rule.get("cap")
    if not cap:
        return spend, False, None

    basis = cap.get("basis", "spend")
    amount = num(cap.get("amount"), "cap.amount")
    period = cap.get("period", "year")

    if basis != "spend":
        return spend, False, None

    remaining = max(amount - used, 0.0)
    if spend <= remaining:
        return spend, False, None
    note = (
        f"Bonus rate is capped at {amount:g} spend per {period}"
        + (f"; {used:g} already used" if used else "")
        + f", so only {remaining:g} of this purchase earns the bonus rate."
    )
    return remaining, True, note


def apply_reward_cap(rule: dict, cash: float, points: float, used_cash: float,
                     used_points: float) -> tuple[float, bool, str | None]:
    cap = rule.get("cap")
    if not cap:
        return cash, False, None
    basis = cap.get("basis", "spend")
    if basis not in {"reward_cash", "points"}:
        return cash, False, None

    amount = num(cap.get("amount"), "cap.amount")
    period = cap.get("period", "year")

    if basis == "reward_cash":
        remaining = max(amount - used_cash, 0.0)
        if cash <= remaining:
            return cash, False, None
        return remaining, True, (
            f"Reward is capped at {amount:g} cash back per {period}; this purchase "
            f"only earns {remaining:.2f} at the bonus rate."
        )

    remaining_points = max(amount - used_points, 0.0)
    if points <= remaining_points or points <= 0:
        return cash, False, None
    scaled = cash * (remaining_points / points)
    return scaled, True, (
        f"Reward is capped at {amount:g} points per {period}; this purchase is limited "
        f"to {remaining_points:g} points."
    )


# --------------------------------------------------------------------------
# per-card evaluation
# --------------------------------------------------------------------------


def evaluate_card(card: dict, state: dict, ctx: dict) -> dict:
    name = card.get("name") or card.get("id") or "unnamed card"
    amount = ctx["amount"]
    caveats: list[str] = []
    confidence = ctx["confidence"]

    # A card can be blocked at the merchant entirely (network not accepted,
    # or the issuer earns nothing on this category at all).
    no_earn = set(as_list(card.get("no_earn_categories")))
    blocked_by = no_earn & set(ctx["categories"])
    network_ok = True
    if ctx["accepted_networks"] and card.get("network"):
        network_ok = card["network"] in ctx["accepted_networks"]

    base_rule = {
        "id": "base",
        "label": card.get("base_label", "base rate"),
        "unit": card.get("base_unit", "percent"),
        "rate": card.get("base_rate", 0),
        "slab": card.get("base_slab"),
    }

    candidates: list[dict] = []
    rejections: list[str] = []

    for rule in card.get("rules", []):
        ok, why = rule_matches(rule, ctx)
        if not ok:
            rejections.append(f"{rule.get('label', rule.get('id', 'rule'))}: {why}")
            continue

        needs_activation = bool(rule.get("activation_required"))
        activated = bool(state.get("activated", False))
        if needs_activation and not activated:
            rejections.append(
                f"{rule.get('label', 'bonus rule')}: requires activation and the user "
                "has not confirmed it is activated"
            )
            continue

        candidates.append(rule)

    # Pick the single best-paying applicable bonus rule. Real issuers pay one
    # category rate per transaction; they do not stack.
    chosen = None
    best_cash = -1.0
    for rule in candidates:
        preview_cash, _, _ = earn_cash(rule, amount, card)
        if preview_cash > best_cash:
            best_cash, chosen = preview_cash, rule

    bonus_cash = bonus_points = 0.0
    bonus_spend = 0.0
    cap_binding = False
    rate_label = None

    if blocked_by or not network_ok:
        base_cash = base_points = 0.0
        base_spend = 0.0
        if blocked_by:
            caveats.append(
                f"This card earns nothing on {', '.join(sorted(blocked_by))} purchases."
            )
        if not network_ok:
            caveats.append(
                f"{ctx['merchant']} does not accept {card.get('network', 'this network')}."
            )
        rate_label = "not earning"
    else:
        if chosen is not None:
            cap_key = chosen.get("cap", {}).get("shared_key", chosen.get("id", "bonus"))
            used_spend = num(state.get("cap_used", {}).get(cap_key, 0), "cap_used",
                             default=0)
            used_cash = num(state.get("cap_used_cash", {}).get(cap_key, 0),
                            "cap_used_cash", default=0)
            used_points = num(state.get("cap_used_points", {}).get(cap_key, 0),
                              "cap_used_points", default=0)

            bonus_spend, capped, cap_note = cap_limited_spend(
                chosen, amount, used_spend
            )
            bonus_cash, bonus_points, rate_label = earn_cash(chosen, bonus_spend, card)
            bonus_cash, reward_capped, reward_note = apply_reward_cap(
                chosen, bonus_cash, bonus_points, used_cash, used_points
            )
            cap_binding = capped or reward_capped
            for note in (cap_note, reward_note):
                if note:
                    caveats.append(note)
            if chosen.get("note"):
                caveats.append(chosen["note"])
            if chosen.get("confidence"):
                confidence = min(
                    confidence, chosen["confidence"], key=lambda c: CONFIDENCE_ORDER[c]
                )
        else:
            rate_label = None

        base_spend = amount - bonus_spend
        base_cash, base_points, base_label = earn_cash(base_rule, base_spend, card)
        if rate_label is None:
            rate_label = base_label

    # Runtime offers (Amex Offers, Chase Offers, issuer statement credits) do
    # stack on top of category earning, so they are added, not compared.
    offers_cash = 0.0
    for offer in state.get("offers", []):
        label = offer.get("label", "issuer offer")
        min_spend = num(offer.get("min_spend", 0), f"{label}.min_spend", default=0)
        if amount < min_spend:
            caveats.append(
                f"Offer {label!r} needs {min_spend:g} minimum spend; this purchase "
                "does not qualify."
            )
            continue
        value = num(offer.get("value", 0), f"{label}.value", default=0)
        if offer.get("type") == "percent":
            value = amount * value / 100.0
            cap = offer.get("cap")
            if cap is not None:
                value = min(value, num(cap, f"{label}.cap"))
        offers_cash += value
        caveats.append(f"Includes {label}: {value:.2f} credit.")

    # Fees
    fees_cash = 0.0
    if ctx["currency"] != card.get("currency", ctx["currency"]):
        fx = num(card.get("foreign_transaction_fee_percent", 0),
                 "foreign_transaction_fee_percent", default=0)
        if fx:
            fee = amount * fx / 100.0
            fees_cash += fee
            caveats.append(
                f"{fx:g}% foreign transaction fee applies ({fee:.2f}) because the "
                f"purchase is in {ctx['currency']}."
            )

    surcharge_percent = num(ctx.get("surcharge_percent", 0), "surcharge_percent",
                            default=0)
    if surcharge_percent:
        gross_surcharge = amount * surcharge_percent / 100.0
        waiver = card.get("surcharge_waiver")
        waived = 0.0
        if waiver and set(as_list(waiver.get("categories"))) & set(ctx["categories"]):
            within_range = True
            lo = waiver.get("min_txn")
            hi = waiver.get("max_txn")
            if lo is not None and amount < num(lo, "surcharge_waiver.min_txn"):
                within_range = False
            if hi is not None and amount > num(hi, "surcharge_waiver.max_txn"):
                within_range = False
            if within_range:
                waived = min(gross_surcharge,
                             num(waiver.get("cap_per_cycle", gross_surcharge),
                                 "surcharge_waiver.cap_per_cycle"))
            else:
                caveats.append(
                    "Surcharge waiver has a transaction size limit that this purchase "
                    "falls outside of."
                )
        net_surcharge = max(gross_surcharge - waived, 0.0)
        fees_cash += net_surcharge
        if waived:
            caveats.append(
                f"{surcharge_percent:g}% surcharge of {gross_surcharge:.2f} is waived "
                f"by {waived:.2f}."
            )
        elif net_surcharge:
            caveats.append(
                f"{surcharge_percent:g}% surcharge of {net_surcharge:.2f} is not waived "
                "on this card."
            )

    gross = bonus_cash + base_cash
    net = gross + offers_cash - fees_cash

    if card.get("reward_type") == "points":
        pv = card.get("point_value_cents")
        caveats.append(
            f"Points valued at {pv:g} cents each; the real value depends on how the "
            "user redeems."
        )

    minimum = card.get("minimum_redemption")
    if minimum and net < num(minimum, "minimum_redemption"):
        caveats.append(
            f"Rewards are only redeemable in blocks of {minimum:g}; a single purchase "
            "may not be redeemable on its own."
        )

    # Card-level context: how the rewards actually redeem, acceptance limits,
    # what the headline multiplier hides. This is often the most decision-
    # changing thing in the catalog, so it has to reach the user.
    if card.get("notes"):
        caveats.append(card["notes"])

    as_of = card.get("as_of")
    stale_days = None
    if as_of and ctx["today"]:
        parsed = parse_day(as_of, f"{name}.as_of")
        if parsed:
            stale_days = (ctx["today"] - parsed).days
            if stale_days > STALE_AFTER_DAYS:
                confidence = "low"
                caveats.append(
                    f"Catalog terms for this card were last checked {as_of} "
                    f"({stale_days} days ago). Verify before relying on this number."
                )

    return {
        "card_id": card.get("id"),
        "name": name,
        "issuer": card.get("issuer"),
        "network": card.get("network"),
        "currency": card.get("currency", ctx["currency"]),
        "applied_rule": (chosen or base_rule).get("label", "base rate")
        if not (blocked_by or not network_ok) else "none",
        "rate_label": rate_label,
        "bonus_spend": round(bonus_spend, 2),
        "base_spend": round(max(amount - bonus_spend, 0.0) if not (blocked_by or not network_ok) else 0.0, 2),
        "gross_reward": round(gross, 2),
        "offers": round(offers_cash, 2),
        "fees": round(fees_cash, 2),
        "net_reward": round(net, 2),
        "effective_rate_percent": round(net / amount * 100, 3) if amount else 0.0,
        "cap_binding": cap_binding,
        "earning_blocked": bool(blocked_by) or not network_ok,
        "confidence": confidence,
        "caveats": caveats,
        "rules_rejected": rejections,
        "annual_fee": card.get("annual_fee"),
        "data_as_of": as_of,
        "source": card.get("source"),
    }


# --------------------------------------------------------------------------
# top level
# --------------------------------------------------------------------------


def build_context(purchase: dict, resolution: dict, today: date | None) -> dict:
    categories = as_list(purchase.get("categories")) or resolution["categories"]
    mcc = as_list(purchase.get("mcc")) or resolution["mcc"]
    country = normalize_country(purchase.get("country", "US"))
    return {
        "amount": num(purchase.get("amount"), "purchase.amount"),
        "currency": str(purchase.get("currency") or
                        DEFAULT_CURRENCY.get(country, "USD")).upper(),
        "country": country,
        "merchant": resolution["merchant"],
        "merchant_id": resolution["merchant_id"],
        "channel": resolution["channel"],
        "categories": categories,
        "mcc": mcc,
        "confidence": resolution["confidence"],
        "accepted_networks": as_list(purchase.get("accepted_networks")),
        "surcharge_percent": purchase.get("surcharge_percent", 0),
        "today": today,
    }


def sort_key(result: dict) -> tuple:
    return (
        0 if result["earning_blocked"] else 1,
        round(result["net_reward"], 2),
        CONFIDENCE_ORDER.get(result["confidence"], 0),
        0 if result["cap_binding"] else 1,
        -len(result["caveats"]),
    )


def run(request: dict) -> dict:
    purchase = request.get("purchase")
    if not isinstance(purchase, dict):
        raise InputError("request.purchase must be an object")

    country = normalize_country(purchase.get("country", "US"))
    today = parse_day(request.get("today"), "today") or date.today()

    merchant_catalog = load_merchants()
    resolution = resolve_merchant(
        str(purchase.get("merchant", "")), purchase.get("channel"), country,
        merchant_catalog,
    )
    ctx = build_context(purchase, resolution, today)
    if not ctx["categories"]:
        resolution["caveats"].append(
            "No spend category could be determined, so every card falls back to its "
            "base rate. Determine the category before presenting a winner."
        )

    wallet = request.get("wallet")
    if not isinstance(wallet, list) or not wallet:
        raise InputError("request.wallet must be a non-empty list")

    catalog: dict = {}
    catalog_meta: dict = {}
    try:
        catalog_file = load_cards(country)
        catalog_meta = {
            "country": catalog_file.get("country"),
            "as_of": catalog_file.get("as_of"),
            "currency": catalog_file.get("currency"),
        }
        catalog = {card["id"]: card for card in catalog_file.get("cards", [])}
    except InputError as exc:
        catalog_meta = {"error": str(exc)}

    warnings: list[str] = []
    results: list[dict] = []
    seen: set[str] = set()

    for index, held in enumerate(wallet):
        if isinstance(held, str):
            held = {"id": held}
        if not isinstance(held, dict):
            raise InputError(f"wallet[{index}] must be a string id or an object")

        card = held.get("card")
        if card is None:
            card_id = held.get("id")
            if not card_id:
                raise InputError(f"wallet[{index}] needs an 'id' or an inline 'card'")
            card = catalog.get(card_id)
            if card is None:
                warnings.append(
                    f"Card id {card_id!r} is not in the {country} catalog. Research its "
                    "current terms and pass them inline, or ask the user for the rate."
                )
                results.append({
                    "card_id": card_id,
                    "name": held.get("name", card_id),
                    "net_reward": None,
                    "confidence": "unknown",
                    "earning_blocked": False,
                    "cap_binding": False,
                    "caveats": ["Terms unknown - not scored."],
                    "unknown": True,
                })
                continue

        if card.get("id") in seen:
            warnings.append(f"Duplicate card {card.get('id')!r} in wallet; scored once.")
            continue
        seen.add(card.get("id"))

        results.append(evaluate_card(card, held, ctx))

    scored = [r for r in results if not r.get("unknown")]
    unknown = [r for r in results if r.get("unknown")]
    scored.sort(key=sort_key, reverse=True)

    if len(scored) >= 2:
        top, second = scored[0], scored[1]
        if abs(top["net_reward"] - second["net_reward"]) < 0.01:
            warnings.append(
                f"{top['name']} and {second['name']} are effectively tied. Break the tie "
                "on redemption friction or merchant-coding confidence, not on the number."
            )

    return {
        "purchase": {
            "merchant": ctx["merchant"],
            "channel": ctx["channel"],
            "amount": ctx["amount"],
            "currency": ctx["currency"],
            "country": ctx["country"],
            "categories": ctx["categories"],
            "mcc": ctx["mcc"],
        },
        "merchant_resolution": resolution,
        "catalog": catalog_meta,
        "evaluated_on": today.isoformat(),
        "results": scored + unknown,
        "warnings": warnings,
    }


# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Request JSON schema:

{
  "today": "2026-07-25",
  "purchase": {
    "merchant": "shell",           # store name or id from merchants.json
    "channel": "pump",             # pump | convenience_store | in_store | online | app | delivery
    "amount": 60,
    "currency": "USD",
    "country": "US",
    "categories": ["gas"],         # optional override of merchant resolution
    "mcc": ["5541"],               # optional, if the user knows the code
    "surcharge_percent": 0,        # e.g. 1 for an Indian fuel surcharge
    "accepted_networks": []        # e.g. ["visa","mastercard"] where Amex is refused
  },
  "wallet": [
    "amex-bcp",
    {"id": "chase-freedom-flex", "activated": true,
     "cap_used": {"rotating-5": 1200},
     "offers": [{"label": "Chase Offer 10%", "type": "percent", "value": 10, "cap": 15}]},
    {"card": { ...full inline card definition... }}
  ]
}
""",
    )
    parser.add_argument("input", nargs="?", type=Path,
                        help="path to a request JSON file, or - for stdin")
    parser.add_argument("--merchant")
    parser.add_argument("--channel")
    parser.add_argument("--amount", type=float)
    parser.add_argument("--currency")
    parser.add_argument("--country", default="US")
    parser.add_argument("--cards", help="comma separated card ids from the catalog")
    parser.add_argument("--today", help="override today's date, YYYY-MM-DD")
    parser.add_argument("--list-cards", metavar="COUNTRY",
                        help="print the card ids available for a country")
    parser.add_argument("--explain-merchant", metavar="NAME",
                        help="print how a merchant is expected to code")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)

    try:
        if args.list_cards:
            data = load_cards(args.list_cards)
            print(json.dumps({
                "country": data.get("country"),
                "as_of": data.get("as_of"),
                "cards": [
                    {"id": c["id"], "name": c["name"], "issuer": c.get("issuer"),
                     "annual_fee": c.get("annual_fee")}
                    for c in data.get("cards", [])
                ],
            }, indent=2))
            return 0

        if args.explain_merchant:
            catalog = load_merchants()
            entry = catalog.get("merchants", {}).get(slug(args.explain_merchant))
            if entry is None:
                out = resolve_merchant(args.explain_merchant, args.channel,
                                       args.country, catalog)
            else:
                out = {"merchant_id": slug(args.explain_merchant), **entry}
            print(json.dumps(out, indent=2))
            return 0

        if args.input:
            raw = sys.stdin.read() if str(args.input) == "-" else \
                args.input.read_text(encoding="utf-8")
            request = json.loads(raw)
        elif args.merchant and args.amount is not None and args.cards:
            request = {
                "today": args.today,
                "purchase": {
                    "merchant": args.merchant,
                    "channel": args.channel,
                    "amount": args.amount,
                    "currency": args.currency or
                    DEFAULT_CURRENCY.get(normalize_country(args.country), "USD"),
                    "country": args.country,
                },
                "wallet": [c.strip() for c in args.cards.split(",") if c.strip()],
            }
        else:
            build_parser().print_help()
            return 2

        print(json.dumps(run(request), indent=2))
    except InputError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    except json.JSONDecodeError as exc:
        print(f"error: invalid JSON input: {exc}", file=sys.stderr)
        return 2
    except OSError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: d9a900a17a634da8edbd66b8f0632059501ae583ca3f15defec4888100739f84