Spaces:
Paused
Paused
Download app/scoring_engine.py from sammy786/rewardpilot-api: direct link, hf CLI and curl.
- Browser
- Download file 52.6 kB
-
https://huggingface.co/spaces/sammy786/rewardpilot-api/resolve/main/app/scoring_engine.py
- Command line
-
hf download hf://spaces/sammy786/rewardpilot-api/app/scoring_engine.py
-
curl -L -o scoring_engine.py https://huggingface.co/spaces/sammy786/rewardpilot-api/resolve/main/app/scoring_engine.py
52.6 kB
| """ | |
| RewardPilot - Deterministic Scoring Engine | |
| ========================================== | |
| The engine sets the number; the LLM only narrates it. | |
| Given: | |
| - a set of cards the user holds (and optionally the full catalogue for discovery) | |
| - a transaction context (category, amount, merchant, brand offers) | |
| - month-to-date spend per card per category (to respect caps) | |
| - a light user persona | |
| ...it returns a point-value-adjusted ranking with transparent reasoning and | |
| the rupee value each card returns on this specific transaction. | |
| Design principles: | |
| 1. Everything is expressed in *net INR value*, never raw points. | |
| 2. Monthly caps are respected: accelerated rate applies only up to remaining cap. | |
| 3. Instant brand/issuer offers are added on top of rewards. | |
| 4. Annual fee is amortized as a tie-breaker / context signal, not a hard penalty | |
| on a single transaction. | |
| """ | |
| from dataclasses import dataclass, replace | |
| from typing import Dict, List, Optional | |
| from card_catalogue import Card, CATALOGUE_BY_ID, all_cards, canonical_card_id | |
| from rails import fee_for, fuel_waiver_inr, surcharge_known, FEE_GST_MULT | |
| import math | |
| def _r2(x: float) -> float: | |
| """Round money to 2 dp, half-up, in lockstep with engine.ts.""" | |
| # Decimal half-paisas such as 0.225 are commonly represented infinitesimally | |
| # below their mathematical value by binary floats. A tiny scaled epsilon | |
| # preserves the documented half-up rule instead of rounding them down. | |
| return math.floor(x * 100 + 0.5 + 1e-9) / 100 | |
| def apply_url_for(card_id: str) -> str: | |
| # Official issuer page when we have it; web search only as a last resort. | |
| from apply_links import APPLY_URL | |
| card = CATALOGUE_BY_ID.get(card_id) | |
| if card is not None and getattr(card, "closed", False): | |
| return "" | |
| direct = APPLY_URL.get(card_id) | |
| if direct: | |
| return direct | |
| import urllib.parse | |
| # Debit cards come with a bank account, so their fallback drops "credit card". | |
| kind_words = "apply" if card is not None and getattr(card, "kind", "credit") == "debit" else "credit card apply" | |
| q = urllib.parse.quote(f"{card.name if card else ''} {kind_words} {card.issuer if card else ''}") | |
| return f"https://www.google.com/search?q={q}" | |
| # Backward-compatible export for fuzz oracles. Product-specific terms live on | |
| # Card.upi_min_reward_amount; there is no universal transaction-size floor. | |
| UPI_MIN_REWARD_AMOUNT = 0 | |
| UPI_EXCLUDED_CATEGORIES = {"fuel", "rent", "wallet_load"} | |
| CONSERVATIVE_POINT_VALUE_INR = { | |
| "hdfc_infinia": 0.5, "hdfc_diners_black": 0.5, "hdfc_regalia_gold": 0.5, | |
| "icici_emeralde": 0.4, "axis_atlas": 0.5, "axis_magnus": 0.3, | |
| "amex_mrcc": 0.25, "amex_platinum_travel": 0.3, | |
| } | |
| def cash_point_value(card: Card) -> float: | |
| return 1.0 if card.reward_unit == "cashback" else CONSERVATIVE_POINT_VALUE_INR.get(card.id, card.point_value_inr) | |
| def _units_for_amount(card: Card, amount: float, rate: float, rail: str = "card", portal: bool = False) -> float: | |
| """Floor each transaction to the issuer's published earn block.""" | |
| block = ((getattr(card, "portal_earn_block_inr", None) or getattr(card, "earn_block_inr", None)) if portal else | |
| (getattr(card, "upi_earn_block_inr", None) or getattr(card, "earn_block_inr", None)) if rail == "upi" else | |
| getattr(card, "earn_block_inr", None)) | |
| eligible = math.floor(max(0.0, amount) / block) * block if block and block > 0 else max(0.0, amount) | |
| return eligible / 100.0 * rate | |
| def _portal_token(value) -> str: | |
| return "".join(ch for ch in str(value or "").lower() if ch.isalnum()) | |
| def _explicit_portal_attribution(card: Card, brand_key) -> bool: | |
| if not card.portal_name or not brand_key: | |
| return False | |
| brand = _portal_token(brand_key) | |
| portal = _portal_token(card.portal_name) | |
| aliases = (["smartbuy", "hdfcsmartbuy"] if "smartbuy" in portal else | |
| ["traveledge", "axistraveledge"] if "traveledge" in portal else | |
| ["ishop", "iciciishop"] if "ishop" in portal else | |
| ["hsbcrewards", "hsbcrewardsmarketplace"] if "rewardsmarketplace" in portal else | |
| ["rewardmultiplier", "amexrewardmultiplier"] if "rewardmultiplier" in portal else | |
| ["scapia"] if "scapia" in portal else [portal]) | |
| return brand in aliases | |
| class TxnContext: | |
| category: str | |
| amount: float | |
| merchant: Optional[str] = None | |
| brand_key: Optional[str] = None | |
| offers: Optional[List[Dict]] = None | |
| rail: str = "card" # "card" (POS/online) or "upi" (RuPay credit-on-UPI) | |
| channel: Optional[str] = None # "online" | "offline"; when known, filters offers by channel | |
| # TRIP SCOPE for travel scorings. None means "unknown, do not filter on it". | |
| # Enforced in score_transaction via offers.scope_filter, so the card | |
| # recommendation can no longer price a slab the channel rows refuse. | |
| international: Optional[bool] = None | |
| passengers: Optional[int] = None | |
| round_trip: Optional[bool] = None | |
| # card product id -> actual network printed on the held instance. Required | |
| # for products issued on multiple networks; absence fails closed. | |
| card_networks: Optional[Dict[str, str]] = None | |
| class CardScore: | |
| card_id: str | |
| card_name: str | |
| issuer: str | |
| network: str | |
| reward_cap_period: str | |
| reward_value_inr: float # rewards earned on this txn (capped) | |
| instant_offer_inr: float # instant discount from live offers | |
| deferred_cashback_inr: float # posts after payment; never reduces payable | |
| total_value_inr: float # reward + instant offer - issuer fee (rounded for display) | |
| raw_total: float # unrounded total - used for ranking | |
| effective_rate_pct: float # total value as % of spend | |
| capped: bool # whether monthly cap limited the reward | |
| reasons: List[str] | |
| held: bool # does the user hold this card | |
| point_value_inr_used: float = 1.0 | |
| point_value_inr_ceiling: float = 1.0 | |
| fee_known: bool = True | |
| # Best EMI-conditioned deal for this card, kept OUT of every total: the | |
| # discount only exists if the payment becomes a loan at checkout, so it is | |
| # reported beside the answer, never inside it. Defaults keep every | |
| # existing constructor call valid. | |
| emi_offer_inr: float = 0.0 | |
| emi_offer_text: Optional[str] = None | |
| emi_offer_code: Optional[str] = None | |
| # What this card charges to MAKE this payment, GST included: 1% on rent, | |
| # wallet loads, big utility bills and third-party education fees at most | |
| # issuers, and the fuel surcharge outside the waiver band. Netted out of | |
| # total_value_inr so a charging card cannot outrank a free one. | |
| fee_inr: float = 0.0 | |
| # The promo code the PRICED instant discount needs, when it needs one. | |
| # Mirror of CardScore.instantOfferCode in engine.ts. | |
| instant_offer_code: Optional[str] = None | |
| def _offer_applies(off: Dict, card: Card, allow_emi: bool = False, | |
| allow_unverified: bool = False) -> bool: | |
| # A term we never read - an uncapped % or a flat travel discount with no | |
| # minimum - is a discount we cannot compute, so unverified rows are refused | |
| # everywhere money is decided. channels.py's unverified-coupon line is the | |
| # ONE display path allowed through (allow_unverified=True) - it borrows this | |
| # function's card-matching so "your Axis card has a coupon here" is said only | |
| # to someone actually holding an Axis card, and offer_value()'s own zero for | |
| # these rows means even that path cannot price one by accident. Mirrored in | |
| # engine.ts offerApplies. | |
| from offers import is_unverified | |
| if is_unverified(off) and not allow_unverified: | |
| return False | |
| # The discount exists only if the payment is converted to EMI - a loan | |
| # decision we must never price in silently. Excluded from instant-offer | |
| # money; surfaced instead as a labelled EMI line (_emi_offer_value passes | |
| # allow_emi=True and requires the flag). Mirrored in engine.ts. | |
| if bool(off.get("requires_emi")) != allow_emi: | |
| return False | |
| # Claimable once, by someone who has never booked here. We cannot know that | |
| # about the user, so it is never priced as card value. Shown as a condition | |
| # instead (channels.py -> conditional_discount_inr). | |
| if off.get("requires_new_user"): | |
| return False | |
| # Confined to a payments app (BHIM etc): not claimable on a card at all. | |
| if off.get("requires_upi_app"): | |
| return False | |
| # Needs a code we could not name, so the user cannot apply it. | |
| if off.get("requires_promo_code") and not (off.get("promo_code") or "").strip(): | |
| return False | |
| # AN EXPLICIT CARD IS NOT A LICENCE TO SKIP THE REST. This used to RETURN on | |
| # the card match, jumping over the excluded-card, card-kind and network | |
| # gates below - so a row naming a card while also carrying requires_debit, | |
| # a network it is not on, AND that same card in its own excluded list | |
| # priced anyway. requires_debit is derived from evidence text after the | |
| # loader's contradiction check, so the feed can produce exactly that row. | |
| cid = off.get("card_id") | |
| if cid and canonical_card_id(cid) != card.id: | |
| return False | |
| iss = off.get("applies_to_issuer") or "" | |
| # An offer naming NO issuer and no card is open to anyone paying any way. | |
| # A blank issuer used to mean "any issuer", which handed every promo code | |
| # and platform sale to the whole catalogue as if plastic had earned it. | |
| # These are surfaced separately (offers.best_open_offer) so the user still | |
| # sees the money without it deciding which card to pull out. | |
| if not cid: | |
| if not iss: | |
| return False | |
| if iss != card.issuer: | |
| return False | |
| # "all HDFC Bank Credit Cards except Infinia & Diners Club Black" - the | |
| # offer is the issuer's, minus a named few. Mirrored in engine.ts. | |
| if card.id in [canonical_card_id(x) for x in (off.get("excluded_card_ids") or [])]: | |
| return False | |
| # Issuer-level offers still carry hidden card scoping the issuer field ignores: | |
| # a network limit ("Mastercard only") or a debit-card requirement. Enforce both, | |
| # else an HDFC-Mastercard-debit offer wrongly credits an HDFC-RuPay-credit card. | |
| # CARD KIND. The old comment claimed the catalogue is credit cards; that has | |
| # been false since the debit cards were added - there are 19. With no | |
| # inverse gate, an issuer-wide offer whose own evidence reads "ICICI Bank | |
| # CREDIT Card 25% discount" paid out on that issuer's DEBIT card, and could | |
| # make the debit card win the comparison outright. Both directions now hold. | |
| card_kind = getattr(card, "kind", "credit") | |
| kind_scope = off.get("card_kind_scope") or ("debit" if off.get("requires_debit") else "credit") | |
| if kind_scope != "both" and kind_scope != card_kind: | |
| return False | |
| net = (off.get("network") or "any").lower() | |
| if net != "any" and net != (card.network or "").lower(): | |
| return False | |
| return True | |
| def _instant_offer_value(card: Card, ctx: TxnContext): | |
| """Best applicable instant discount for this card on this txn (structured offers).""" | |
| from offers import offer_value | |
| best = 0.0 | |
| note = None | |
| # THE CODE COMES OUT TOO. _offer_applies prices a card-linked offer as soon | |
| # as its promo_code is non-empty, but this returned only the money - so 50 | |
| # active offers were priced into the headline with the code the user has to | |
| # type never surfaced anywhere. Mirror of engine.ts instantOffer. | |
| code = None | |
| cashback = False | |
| for off in (ctx.offers or []): | |
| if not _offer_applies(off, card): | |
| continue | |
| # channel filter: only when txn channel is known; 'both' always applies | |
| ch = getattr(ctx, "channel", None) | |
| off_ch = off.get("channel", "both") | |
| if ch and off_ch != "both" and off_ch != ch: | |
| continue | |
| if "type" in off: | |
| val = offer_value(off, ctx.amount, ctx.category) | |
| txt = off.get("text") or "" | |
| else: | |
| # legacy free-text offer (back-compat): parse "5%" / "₹5,000" | |
| import re | |
| txt = off.get("text", "") | |
| pct = re.search(r"(\d+(?:\.\d+)?)\s*%", txt) | |
| flat = re.search(r"(?:INR|Rs|₹)\s*([\d,]+)", txt) | |
| val = ctx.amount * float(pct.group(1)) / 100.0 if pct else (float(flat.group(1).replace(",", "")) if flat else 0.0) | |
| if val > best: | |
| best, note = val, txt | |
| code = (off.get("promo_code") or "").strip() or None | |
| cashback = off.get("type") == "cashback" | |
| return best, note, code, cashback | |
| def _emi_offer_value(card: Card, ctx: TxnContext): | |
| """Best EMI-conditioned discount for this card on this txn, with its code. | |
| Same claimability rules as instant offers (issuer/card/network/channel/ | |
| min-spend, and a code-requiring offer must have its code named), but ONLY | |
| offers flagged requires_emi. The value is NEVER added to card totals or | |
| net cost: it is returned separately so the UI can say "if you convert to | |
| EMI" in so many words. Mirrored in engine.ts.""" | |
| from offers import offer_value | |
| best, note, code = 0.0, None, None | |
| for off in (ctx.offers or []): | |
| if not _offer_applies(off, card, allow_emi=True): | |
| continue | |
| ch = getattr(ctx, "channel", None) | |
| off_ch = off.get("channel", "both") | |
| if ch and off_ch != "both" and off_ch != ch: | |
| continue | |
| val = offer_value(off, ctx.amount, ctx.category) if "type" in off else 0.0 | |
| if val > best: | |
| best = val | |
| note = off.get("text") or "" | |
| code = (off.get("promo_code") or "").strip() or None | |
| return best, note, code | |
| def _closed_or_debit(card_id: str) -> bool: | |
| """Cards that must never be offered as a DISCOVERY ("you should apply for | |
| this"). Mirror of engine.ts, which excludes both.""" | |
| c = CATALOGUE_BY_ID.get(card_id) | |
| if c is None: | |
| return False | |
| return bool(getattr(c, "closed", False)) or getattr(c, "kind", "credit") == "debit" | |
| def score_transaction( | |
| held_card_ids: List[str], | |
| ctx: TxnContext, | |
| mtd_spend: Optional[Dict[str, Dict[str, float]]] = None, | |
| include_discovery: bool = False, | |
| persona: Optional[List[str]] = None, | |
| ) -> Dict: | |
| """ | |
| Returns { | |
| held_ranked: [CardScore...], # user's own cards, best-first | |
| discovery: CardScore | None, # best card the user does NOT hold, if materially better | |
| context: {...} | |
| } | |
| mtd_spend: {card_id: {category: rupees_spent_this_month}} | |
| """ | |
| mtd_spend = mtd_spend or {} | |
| held_card_ids = list(dict.fromkeys(canonical_card_id(x) for x in held_card_ids)) | |
| # Legacy product state is the same physical card/cycle, not a second cap. | |
| for _old_id, _state in list(mtd_spend.items()): | |
| mtd_spend.setdefault(canonical_card_id(_old_id), _state) | |
| if ctx.card_networks: | |
| ctx = replace(ctx, card_networks={canonical_card_id(k): v for k, v in ctx.card_networks.items()}) | |
| # A BAD AMOUNT MUST NOT REACH THE REWARD MATH. engine.ts has clamped this | |
| # since the beginning; the server did not, so the same request that returned | |
| # Rs.0 on the device returned a NEGATIVE reward and a positive effective | |
| # rate from the API for amount = -10,000. | |
| try: | |
| _amt = float(ctx.amount) | |
| except (TypeError, ValueError): | |
| _amt = 0.0 | |
| if not (_amt > 0) or _amt != _amt or _amt == float("inf"): | |
| _amt = 0.0 | |
| if _amt != ctx.amount: | |
| ctx = replace(ctx, amount=_amt) | |
| # Trip scope applied ONCE, here, so every downstream consumer of ctx.offers | |
| # inherits it. compare_channels also pre-filters; the predicate is | |
| # idempotent, so neither can be the only guard. | |
| if ctx.offers: | |
| from offers import scope_filter | |
| ctx = replace(ctx, offers=scope_filter(ctx.offers, { | |
| "international": ctx.international, | |
| "passengers": ctx.passengers, | |
| "round_trip": ctx.round_trip, | |
| })) | |
| persona = persona or [] | |
| held_scores: List[CardScore] = [] | |
| all_scores: List[CardScore] = [] | |
| rail = getattr(ctx, "rail", "card") or "card" | |
| for catalogue_card in all_cards(): | |
| card = catalogue_card | |
| variants = list(getattr(card, "network_variants", None) or [card.network]) | |
| held = card.id in held_card_ids | |
| if held: | |
| requested = (ctx.card_networks or {}).get(card.id, "") | |
| selected = next((v for v in variants if v.lower() == str(requested).strip().lower()), None) | |
| card = replace( | |
| card, | |
| network=selected or "Unknown", | |
| upi_eligible=bool(selected and selected.lower() == "rupay"), | |
| ) | |
| # --- RuPay credit-on-UPI eligibility --- | |
| upi_block = None | |
| if rail == "upi": | |
| if not card.upi_eligible: | |
| upi_block = f"{card.network} cards can't be linked to UPI. Only RuPay credit cards work on UPI." | |
| elif ctx.amount < getattr(card, "upi_min_reward_amount", 0.0): | |
| upi_block = f"This card's UPI rewards start at ₹{card.upi_min_reward_amount:,.0f} per purchase." | |
| elif ctx.category in UPI_EXCLUDED_CATEGORIES: | |
| upi_block = f"{ctx.category.replace('_', ' ').title()} earns nothing on the UPI rail (excluded category)." | |
| # --- reward value, respecting monthly caps --- | |
| txn_channel = getattr(ctx, "channel", None) | |
| # Missing import/payment provenance is not proof that the purchase was | |
| # online. Cards with online-only accelerators therefore fall back to | |
| # their guaranteed offline/base tier until the channel is explicit. | |
| if txn_channel not in ("online", "offline") and card.online_boost_only: | |
| txn_channel = "offline" | |
| if upi_block: | |
| rate = 0.0 | |
| elif rail == "upi": | |
| rate = card.upi_effective_rate(ctx.category, ctx.brand_key, ctx.amount, txn_channel) | |
| else: | |
| rate = card.effective_rate(ctx.category, ctx.brand_key, ctx.amount, txn_channel) | |
| gross_units = _units_for_amount(card, ctx.amount, rate, rail) | |
| base_units_earned = 0.0 | |
| capped = False | |
| # Which monthly cap applies, in priority: 1) a BRAND cap (when the rate came | |
| # from a brand bonus, e.g. Millennia 5% on Amazon/Swiggy... INR 1,000/mo shared | |
| # across brands), 2) a category CAP GROUP (e.g. HSBC INR 1,000/mo), 3) per-category. | |
| using_brand = (not card.boost_suppressed(txn_channel) | |
| and ctx.brand_key is not None and ctx.brand_key in card.brand_bonuses | |
| and ctx.amount >= card.brand_min_txn.get(ctx.brand_key, 0) | |
| and card.brand_bonuses[ctx.brand_key] >= card.category_rates.get(ctx.category, card.base_rate)) | |
| brand_grp = next((g for g in card.brand_caps if ctx.brand_key in g.get("brands", [])), None) if using_brand else None | |
| cat_grp = (None if card.boost_suppressed(txn_channel) and card.online_boost_only else | |
| next((g for g in card.cap_groups if ctx.category in g.get("categories", [])), None)) | |
| cap_bucket_key = None | |
| brand_txn_cap = card.brand_txn_caps.get(ctx.brand_key) if using_brand and ctx.brand_key else None | |
| if brand_txn_cap is not None: | |
| gross_units = min(gross_units, brand_txn_cap) | |
| if rate > card.base_rate and not upi_block and card.accelerated_cap_units is not None: | |
| cap_units = card.accelerated_cap_units | |
| cap_bucket_key = "__acceleratedUnits" | |
| elif brand_grp: | |
| cap_units = brand_grp["cap"] | |
| cap_bucket_key = "__cap:brand:" + "|".join(brand_grp["brands"]) | |
| elif cat_grp: | |
| cap_units = cat_grp["cap"] | |
| cap_bucket_key = "__cap:cat:" + "|".join(cat_grp["categories"]) | |
| else: | |
| cap_units = card.caps.get(ctx.category) | |
| if cap_units is not None: | |
| cap_bucket_key = "__cap:single:" + ctx.category | |
| if not upi_block and cap_units is not None and rate > 0: | |
| # WHAT THE CAP HAS ALREADY EATEN, valued at each member's OWN rate. | |
| # Prior spend is stored in rupees and used to be converted back at | |
| # THIS transaction's rate, which is only right when every member of | |
| # the bucket earns the same. Axis ACE shares one Rs.500/month cap | |
| # across bill payments at 5% and food delivery at 4%: after the cap | |
| # was fully spent on bills, a Swiggy order still paid out, because | |
| # the same rupees re-read at 4% looked like less of the cap. | |
| if mtd_spend.get(card.id, {}).get("__bucketState") and cap_bucket_key: | |
| already_units = mtd_spend.get(card.id, {}).get(cap_bucket_key, 0.0) | |
| elif brand_grp: | |
| already_units = sum( | |
| mtd_spend.get(card.id, {}).get("~" + b, 0.0) / 100.0 | |
| * (card.brand_bonuses or {}).get(b, card.base_rate) | |
| for b in brand_grp["brands"]) | |
| elif cat_grp: | |
| already_units = sum( | |
| mtd_spend.get(card.id, {}).get(c, 0.0) / 100.0 | |
| * (0.0 if c in card.excluded_categories else card.category_rates.get(c, card.base_rate)) | |
| for c in cat_grp["categories"]) | |
| else: | |
| already_units = mtd_spend.get(card.id, {}).get(ctx.category, 0.0) / 100.0 * rate | |
| remaining_units = max(0.0, cap_units - already_units) | |
| if gross_units > remaining_units: | |
| accel_units = remaining_units | |
| # WHAT SPEND BEYOND THE CAP EARNS. Falling back to the base rate | |
| # made the cap ARITHMETICALLY INERT wherever the capped category | |
| # had no accelerated rate of its own - 28 cards are in that | |
| # position. HDFC caps Infinia grocery and utility earn at 2,000 | |
| # points a month; since utilities earn the base rate, "cap the | |
| # first 2,000 then pay base on the rest" reconstitutes the | |
| # uncapped total exactly, and Rs.2,00,000 of utility spend | |
| # returned Rs.6,600 against a Rs.2,000 ceiling. A cap on the | |
| # BASE rate is a hard cap; a cap on an ACCELERATED rate still | |
| # drops to base, which is the real rule on Millennia. | |
| base_units_value = 0.0 | |
| if ctx.category not in card.excluded_categories and rate > card.base_rate: | |
| base_units_value = card.base_rate | |
| rupees_at_accel = remaining_units / rate * 100.0 if rate > 0 else 0.0 | |
| rupees_beyond = max(0.0, ctx.amount - rupees_at_accel) | |
| base_units_earned = _units_for_amount(card, rupees_beyond, base_units_value, rail) | |
| gross_units = accel_units + base_units_earned | |
| capped = True | |
| elif rate <= card.base_rate: | |
| base_units_earned = gross_units | |
| elif not upi_block and rate <= card.base_rate: | |
| base_units_earned = gross_units | |
| # Products such as SBI Cashback cap the accelerated 5% tier and the | |
| # ordinary/fallback 1% tier independently. Clamp only the latter here; | |
| # combining both ceilings or leaving the fallback uncapped overstates a | |
| # Rs.10 lakh online purchase by thousands. | |
| if not upi_block and card.base_cap_units is not None and base_units_earned > 0: | |
| remain_base = max(0.0, card.base_cap_units - mtd_spend.get(card.id, {}).get("__baseUnits", 0.0)) | |
| if base_units_earned > remain_base: | |
| gross_units -= base_units_earned - remain_base | |
| base_units_earned = remain_base | |
| capped = True | |
| # UPI-rail monthly reward-unit cap (e.g. Tata Neu 500 NeuCoins/mo on UPI). Hard cap: | |
| # units beyond it earn nothing. Accumulated UPI units live under the "__upiUnits" key. | |
| if not upi_block and rail == "upi" and card.upi_reward_cap_units is not None and gross_units > 0: | |
| remain_upi = max(0.0, card.upi_reward_cap_units - mtd_spend.get(card.id, {}).get("__upiUnits", 0.0)) | |
| if gross_units > remain_upi: | |
| gross_units = remain_upi | |
| capped = True | |
| if not upi_block and card.global_cap_units is not None and gross_units > 0: | |
| remain_global = max(0.0, card.global_cap_units - mtd_spend.get(card.id, {}).get("__globalUnits", 0.0)) | |
| if gross_units > remain_global: | |
| ratio = remain_global / gross_units if gross_units else 0.0 | |
| gross_units = remain_global; base_units_earned *= ratio; capped = True | |
| annual_grp = next((g for g in card.brand_annual_caps if ctx.brand_key in g.get("brands", [])), None) if using_brand else None | |
| if not upi_block and annual_grp and gross_units > 0: | |
| key = "__annual:brand:" + "|".join(annual_grp["brands"]) | |
| remain_annual = max(0.0, annual_grp["cap"] - mtd_spend.get(card.id, {}).get(key, 0.0)) | |
| if gross_units > remain_annual: | |
| ratio = remain_annual / gross_units if gross_units else 0.0 | |
| gross_units = remain_annual; base_units_earned *= ratio; capped = True | |
| point_value_inr_used = cash_point_value(card) | |
| reward_inr = gross_units * point_value_inr_used | |
| # --- instant offers (don't apply on the UPI rail) --- | |
| if rail == "upi": | |
| offer_value_inr, offer_note, offer_code, offer_is_cashback = 0.0, None, None, False | |
| emi_inr, emi_note, emi_code = 0.0, None, None | |
| else: | |
| offer_value_inr, offer_note, offer_code, offer_is_cashback = _instant_offer_value(card, ctx) | |
| emi_inr, emi_note, emi_code = _emi_offer_value(card, ctx) | |
| offer_inr = 0.0 if offer_is_cashback else offer_value_inr | |
| deferred_cashback_inr = offer_value_inr if offer_is_cashback else 0.0 | |
| # WHAT THE CARD CHARGES TO MAKE THIS PAYMENT, GST included. Netted out | |
| # of the total so an issuer that charges 1% on rent can no longer | |
| # outrank one that charges nothing. Thresholds are cumulative per | |
| # STATEMENT CYCLE, so same-category spend already through this card in | |
| # this cycle decides how much of THIS payment sits above the line. | |
| # Reward caps and issuer fee thresholds do not necessarily share a | |
| # reset clock. Historical replay writes fee-cycle spend under a | |
| # dedicated key; keep the legacy category fallback for older clients. | |
| prior_cycle = mtd_spend.get(card.id, {}).get( | |
| "__fee:" + ctx.category, | |
| mtd_spend.get(card.id, {}).get(ctx.category, 0.0), | |
| ) | |
| direct_education = ctx.category == "education" and not ctx.brand_key | |
| fee_unknown = (rail == "card" and ctx.category != "fuel" and not direct_education | |
| and ctx.category in {"rent", "wallet_load", "utilities", "education"} | |
| and not surcharge_known(card.id, ctx.category)) | |
| fee_inr = (0.0 if rail == "upi" else | |
| 0.0 if ctx.category == "education" and not ctx.brand_key else | |
| _r2(ctx.amount * 0.01 * FEE_GST_MULT) if fee_unknown else | |
| fee_for(card.id, ctx.category, ctx.amount, prior_cycle, ctx.brand_key, | |
| mtd_spend.get(card.id, {}).get("__fuelWaiverInr", 0.0))) | |
| # _r2, not round(): Python's round is banker's and JS Math.round is | |
| # half-up, which put the two engines a paisa apart on 448 scorings. | |
| total = _r2(reward_inr + offer_inr + deferred_cashback_inr - fee_inr) | |
| eff_pct = (total / ctx.amount * 100.0) if ctx.amount else 0.0 | |
| # --- reasons --- | |
| reasons: List[str] = [] | |
| if upi_block: | |
| reasons.append(upi_block) | |
| elif ctx.category in card.excluded_categories: | |
| reasons.append(f"{ctx.category.replace('_', ' ').title()} is excluded on this card, so it earns nothing.") | |
| elif card.boost_suppressed(txn_channel): | |
| # Honest in-store framing: say WHY the number is the base rate. | |
| reasons.append( | |
| f"Earns {rate:g} {card.reward_unit}/₹100 in-store = ₹{reward_inr:,.0f} back. " | |
| f"This card's higher rates apply to online spends only." | |
| ) | |
| else: | |
| reasons.append( | |
| f"Earns {rate:g} {card.reward_unit}/₹100 in {ctx.category.replace('_',' ')} " | |
| f"(₹{card.point_value_inr:g}/{card.reward_unit[:-1] if card.reward_unit.endswith('s') else card.reward_unit}) " | |
| f"= ₹{reward_inr:,.0f} back." | |
| ) | |
| if not upi_block and card.reward_unit != "cashback" and reward_inr > 0: | |
| ceiling = (f"; selected premium redemptions can reach ₹{card.point_value_inr:g}/point" | |
| if card.point_value_inr > point_value_inr_used else "") | |
| reasons.append(f"Ranked at a conservative ₹{point_value_inr_used:g}/point{ceiling}.") | |
| if offer_value_inr > 0 and offer_note: | |
| label = "After payment" if offer_is_cashback else "Live offer" | |
| reasons.append(f"{label}: {offer_note} (~₹{offer_value_inr:,.0f}).") | |
| if fee_unknown: | |
| reasons.insert(0, f"Issuer fee is not verified for this card. Ranking reserves an estimated 1% plus GST (₹{fee_inr:,.0f}) instead of treating the unknown as free.") | |
| # Portal-only elevated rate (HDFC SmartBuy, Axis Travel Edge) - informational, | |
| # NOT scored, since it isn't earned on a direct merchant booking. | |
| portal_rate = card.portal_rates.get(ctx.category) if not upi_block else None | |
| if portal_rate and portal_rate > rate and ctx.category not in card.excluded_categories: | |
| # Portal earn is unit-based and capped monthly (SmartBuy 15k RP/mo, iShop 18k/mo); | |
| # redeem at the portal point value. Show the TRUE effective %, never the raw unit rate. | |
| portal_pv = card.portal_point_value_inr if card.portal_point_value_inr is not None else card.point_value_inr | |
| gross_units = _units_for_amount(card, ctx.amount, portal_rate, "card", portal=True) | |
| portal_state = mtd_spend.get(card.id, {}) | |
| portal_usage_known = card.portal_cap_units is None or "__portalUnits" in portal_state | |
| prior_portal_units = float(portal_state.get("__portalUnits", 0.0)) if portal_usage_known else 0.0 | |
| remaining_portal = (gross_units if card.portal_cap_units is None else | |
| max(0.0, card.portal_cap_units - prior_portal_units)) | |
| portal_units = min(gross_units, remaining_portal) | |
| portal_inr = portal_units * portal_pv | |
| portal_capped = card.portal_cap_units is not None and gross_units > remaining_portal | |
| eff_portal_pct = (portal_inr / ctx.amount * 100.0) if ctx.amount else 0.0 | |
| if not portal_usage_known: | |
| up_to = min(gross_units, card.portal_cap_units) * portal_pv | |
| reasons.append( | |
| f"Booking via {card.portal_name or 'the issuer portal'} could return up to ₹{up_to:,.0f}, " | |
| "but this month's portal-cap usage is unverified, so it is not ranked above guaranteed value." | |
| ) | |
| elif portal_inr > total: | |
| cap_note = f", capped at the portal's monthly {card.portal_cap_units:,.0f}-point limit" if portal_capped else "" | |
| reasons.append( | |
| f"Booking via {card.portal_name or 'the issuer portal'} would earn " | |
| f"about {round(eff_portal_pct, 1):g}% here (₹{portal_inr:,.0f}){cap_note}." | |
| ) | |
| if capped: | |
| reasons.append("Monthly accelerated cap partly reached. Value above the cap drops to the base rate.") | |
| sc = CardScore( | |
| card_id=card.id, | |
| card_name=card.name, | |
| issuer=card.issuer, | |
| network=card.network, | |
| reward_cap_period=card.reward_cap_period or "unknown", | |
| reward_value_inr=_r2(reward_inr), | |
| instant_offer_inr=_r2(offer_inr), | |
| deferred_cashback_inr=_r2(deferred_cashback_inr), | |
| instant_offer_code=offer_code, | |
| total_value_inr=_r2(total), | |
| raw_total=total, | |
| effective_rate_pct=_r2(eff_pct), | |
| capped=capped, | |
| reasons=reasons, | |
| held=held, | |
| point_value_inr_used=point_value_inr_used, | |
| point_value_inr_ceiling=card.point_value_inr, | |
| emi_offer_inr=_r2(emi_inr), | |
| emi_offer_text=emi_note, | |
| emi_offer_code=emi_code, | |
| fee_inr=_r2(fee_inr), | |
| fee_known=not fee_unknown, | |
| ) | |
| all_scores.append(sc) | |
| if held: | |
| held_scores.append(sc) | |
| # sort best-first by the unrounded total (display rounding must not flip ties). | |
| # On the UPI rail a non-RuPay card can't be linked at all, so it must never rank above a | |
| # UPI-eligible card of equal value (e.g. a sub-₹2,000 UPI spend earns everyone ₹0 - the top | |
| # pick should still be a card you can actually pay with on UPI). | |
| def _rank_key(s): | |
| demerit = 1 if (rail == "upi" and str(s.network).lower() != "rupay") else 0 | |
| # Card id is the FINAL tie-break. Without it a tie was broken by | |
| # catalogue insertion order, and card_catalogue.py and cards.ts list the | |
| # same 139 ids in different order - so the server and the app named | |
| # DIFFERENT winners for the same tied purchase. Mirror of engine.ts. | |
| return (demerit, -s.raw_total, s.card_id) | |
| held_scores.sort(key=_rank_key) | |
| all_scores.sort(key=_rank_key) | |
| # --- personalized, comparative reasoning for why each card ranks where it does --- | |
| def _money(n: float) -> str: | |
| return "₹{:,.0f}".format(n) | |
| if held_scores: | |
| top = held_scores[0] | |
| none_earn = top.total_value_inr <= 0 | |
| for i, s in enumerate(held_scores): | |
| lead = [] | |
| if i == 0: | |
| if none_earn: | |
| lead.append( | |
| "None of your cards earn rewards on this UPI payment. Here's why." | |
| if rail == "upi" else | |
| "None of your cards earn rewards on this purchase." | |
| ) | |
| elif len(held_scores) > 1: | |
| d = top.total_value_inr - held_scores[1].total_value_inr | |
| if d > 0: | |
| lead.append( | |
| f"Your top card here, returning {_money(d)} " | |
| f"more than your next best ({held_scores[1].card_name})." | |
| ) | |
| else: | |
| lead.append(f"Tied with {held_scores[1].card_name}; both earn the same here.") | |
| else: | |
| lead.append("Your best card for this purchase.") | |
| elif not none_earn and s.total_value_inr > 0: | |
| d = top.total_value_inr - s.total_value_inr | |
| if d > 0: | |
| lead.append(f"Ranks #{i+1}, {_money(d)} less than {top.card_name} on this spend.") | |
| else: | |
| lead.append(f"Ties {top.card_name} at the same value.") | |
| s.reasons = lead + s.reasons | |
| # discovery: best non-held card that beats the user's best held card by a margin | |
| discovery = None | |
| if include_discovery and held_scores: | |
| best_held = held_scores[0].total_value_inr | |
| for sc in all_scores: | |
| # "Apply for this card" must never name a card no one can apply for, | |
| # nor a DEBIT card (you do not choose a debit card for its rewards). | |
| # engine.ts filtered both; this rail filtered neither, so the server | |
| # recommended Axis Atlas - closed to new applicants since Sep 2025. | |
| if _closed_or_debit(sc.card_id): | |
| continue | |
| if not sc.held and sc.total_value_inr > best_held * 1.10: # >10% better | |
| discovery = sc | |
| break | |
| elif include_discovery and not held_scores: | |
| discovery = next((s for s in all_scores if not s.held and not _closed_or_debit(s.card_id)), None) | |
| # --- richer reasoning for the better card the user does NOT hold --- | |
| if discovery: | |
| dcard = CATALOGUE_BY_ID.get(discovery.card_id) | |
| best_held_val = held_scores[0].total_value_inr if held_scores else 0.0 | |
| extra = discovery.total_value_inr - best_held_val | |
| r: List[str] = [] | |
| if held_scores: | |
| r.append( | |
| f"Beats your best card here by {_money(extra)} " | |
| f"({_money(discovery.total_value_inr)} vs {_money(best_held_val)} on {held_scores[0].card_name})." | |
| ) | |
| else: | |
| r.append(f"Would return {_money(discovery.total_value_inr)} on this purchase.") | |
| if dcard: | |
| d_rate = dcard.category_rates.get(ctx.category, dcard.base_rate) | |
| if ctx.category not in dcard.excluded_categories: | |
| r.append(f"Earns {d_rate:g} {dcard.reward_unit}/₹100 on {ctx.category.replace('_',' ')}.") | |
| if dcard.highlights: | |
| r.append(dcard.highlights[0] + ".") | |
| r.append(f"Annual fee: {dcard.annual_fee_text}.") | |
| discovery.reasons = r | |
| # savings vs best held: difference between top held and the worst reasonable choice | |
| savings_vs_worst = 0.0 | |
| if len(held_scores) >= 2: | |
| savings_vs_worst = _r2(held_scores[0].total_value_inr - held_scores[-1].total_value_inr) | |
| return { | |
| "held_ranked": [s.__dict__ for s in held_scores], | |
| "discovery": discovery.__dict__ if discovery else None, | |
| "savings_vs_worst": savings_vs_worst, | |
| "context": { | |
| "category": ctx.category, | |
| "amount": ctx.amount, | |
| "merchant": ctx.merchant, | |
| "brand_key": ctx.brand_key, | |
| "offers": ctx.offers or [], | |
| "rail": rail, | |
| }, | |
| } | |
| def accumulate_spend(mtd: Dict[str, Dict[str, float]], card_id: str, txn: Dict) -> None: | |
| """Replay the actual earned-unit buckets for one attributed transaction. | |
| Category/brand rupees remain for fee thresholds and legacy callers, while | |
| named buckets preserve the rail/channel/minimum-sensitive reward units that | |
| cannot be reconstructed from rupees later. | |
| """ | |
| card = CATALOGUE_BY_ID.get(card_id) | |
| if card is None: | |
| return | |
| try: | |
| amount = max(0.0, float(txn.get("amount") or 0.0)) | |
| except (TypeError, ValueError): | |
| amount = 0.0 | |
| category = str(txn.get("category") or "general") | |
| brand = txn.get("brand_key") | |
| rail = txn.get("rail") if txn.get("rail") in ("card", "upi") else "card" | |
| # Missing history provenance never authorises an online-only accelerator. | |
| channel = txn.get("channel") if txn.get("channel") in ("online", "offline") else ("offline" if card.online_boost_only else None) | |
| if rail == "upi": | |
| blocked = (not card.upi_eligible or amount < card.upi_min_reward_amount or category in UPI_EXCLUDED_CATEGORIES) | |
| rate = 0.0 if blocked else card.upi_effective_rate(category, brand, amount, channel) | |
| else: | |
| blocked = False | |
| rate = card.effective_rate(category, brand, amount, channel) | |
| gross = _units_for_amount(card, amount, rate, rail) | |
| using_brand = (not card.boost_suppressed(channel) and brand in card.brand_bonuses | |
| and amount >= card.brand_min_txn.get(brand, 0) | |
| and card.brand_bonuses[brand] >= card.category_rates.get(category, card.base_rate)) | |
| brand_grp = next((g for g in card.brand_caps if brand in g.get("brands", [])), None) if using_brand else None | |
| cat_grp = (None if card.boost_suppressed(channel) and card.online_boost_only else | |
| next((g for g in card.cap_groups if category in g.get("categories", [])), None)) | |
| cap = None | |
| bucket = None | |
| if using_brand and brand and card.brand_txn_caps.get(brand) is not None: | |
| gross = min(gross, card.brand_txn_caps[brand]) | |
| if rate > card.base_rate and not blocked and card.accelerated_cap_units is not None: | |
| cap = float(card.accelerated_cap_units); bucket = "__acceleratedUnits" | |
| elif brand_grp: | |
| cap = float(brand_grp["cap"]); bucket = "__cap:brand:" + "|".join(brand_grp["brands"]) | |
| elif cat_grp: | |
| cap = float(cat_grp["cap"]); bucket = "__cap:cat:" + "|".join(cat_grp["categories"]) | |
| elif card.caps.get(category) is not None: | |
| cap = float(card.caps[category]); bucket = "__cap:single:" + category | |
| state = mtd.setdefault(card_id, {}) | |
| fuel_waived = (fuel_waiver_inr(card_id, amount, brand, state.get("__fuelWaiverInr", 0.0)) | |
| if category == "fuel" and rail == "card" else 0.0) | |
| prior_cap = state.get(bucket, 0.0) if bucket else 0.0 | |
| remaining = max(0.0, cap - prior_cap) if cap is not None else None | |
| cap_consumed = min(gross, remaining) if remaining is not None else 0.0 | |
| base_units = gross if (not blocked and rate <= card.base_rate) else 0.0 | |
| if remaining is not None and gross > remaining: | |
| if rate > card.base_rate and category not in card.excluded_categories: | |
| beyond_rupees = max(0.0, amount - (remaining / rate * 100.0 if rate else 0.0)) | |
| base_units = _units_for_amount(card, beyond_rupees, card.base_rate, rail) | |
| gross = remaining + base_units | |
| else: | |
| gross = remaining | |
| base_units = remaining if rate <= card.base_rate else 0.0 | |
| if card.base_cap_units is not None and base_units > 0: | |
| remain_base = max(0.0, card.base_cap_units - state.get("__baseUnits", 0.0)) | |
| if base_units > remain_base: | |
| gross -= base_units - remain_base | |
| base_units = remain_base | |
| if rail == "upi" and card.upi_reward_cap_units is not None and gross > 0: | |
| remain_upi = max(0.0, card.upi_reward_cap_units - state.get("__upiUnits", 0.0)) | |
| if gross > remain_upi: | |
| ratio = remain_upi / gross if gross else 0.0 | |
| gross = remain_upi; base_units *= ratio; cap_consumed *= ratio | |
| if card.global_cap_units is not None and gross > 0: | |
| remain_global = max(0.0, card.global_cap_units - state.get("__globalUnits", 0.0)) | |
| if gross > remain_global: | |
| ratio = remain_global / gross if gross else 0.0 | |
| gross = remain_global; base_units *= ratio; cap_consumed *= ratio | |
| annual_grp = next((g for g in card.brand_annual_caps if brand in g.get("brands", [])), None) if using_brand else None | |
| if annual_grp and gross > 0: | |
| annual_key = "__annual:brand:" + "|".join(annual_grp["brands"]) | |
| remain_annual = max(0.0, annual_grp["cap"] - state.get(annual_key, 0.0)) | |
| if gross > remain_annual: | |
| ratio = remain_annual / gross if gross else 0.0 | |
| gross = remain_annual; base_units *= ratio; cap_consumed *= ratio | |
| state["__bucketState"] = 1.0 | |
| state[category] = state.get(category, 0.0) + amount | |
| if brand: | |
| state["~" + brand] = state.get("~" + brand, 0.0) + amount | |
| if bucket and cap_consumed > 0: | |
| state[bucket] = state.get(bucket, 0.0) + cap_consumed | |
| if base_units > 0: | |
| state["__baseUnits"] = state.get("__baseUnits", 0.0) + base_units | |
| if gross > 0: | |
| state["__globalUnits"] = state.get("__globalUnits", 0.0) + gross | |
| if annual_grp and gross > 0: | |
| state[annual_key] = state.get(annual_key, 0.0) + gross | |
| if rail == "upi" and gross > 0: | |
| state["__upiUnits"] = state.get("__upiUnits", 0.0) + gross | |
| if fuel_waived > 0: | |
| state["__fuelWaiverInr"] = state.get("__fuelWaiverInr", 0.0) + fuel_waived | |
| if rail == "card" and _explicit_portal_attribution(card, brand): | |
| portal_rate = (card.portal_rates or {}).get(category) | |
| if portal_rate: | |
| gross_portal = _units_for_amount(card, amount, portal_rate, "card", portal=True) | |
| remaining_portal = (gross_portal if card.portal_cap_units is None else | |
| max(0.0, card.portal_cap_units - state.get("__portalUnits", 0.0))) | |
| state["__portalUnits"] = state.get("__portalUnits", 0.0) + min(gross_portal, remaining_portal) | |
| def _history_month(txn: Dict) -> str: | |
| date = str(txn.get("date") or "") | |
| return date[:7] if len(date) >= 7 else "_" | |
| def _history_statement_period(txn: Dict, day) -> str: | |
| fallback = _history_month(txn) | |
| try: | |
| close_day = int(day) | |
| except (TypeError, ValueError): | |
| return fallback | |
| date = str(txn.get("date") or "") | |
| if close_day < 1 or close_day > 28 or len(date) < 10: | |
| return fallback | |
| try: | |
| year, month, txn_day = int(date[:4]), int(date[5:7]), int(date[8:10]) | |
| except (TypeError, ValueError): | |
| return fallback | |
| if not (1 <= month <= 12 and 1 <= txn_day <= 31): | |
| return fallback | |
| end_month = year * 12 + month - 1 + (1 if txn_day > close_day else 0) | |
| return f"s:{end_month // 12}-{end_month % 12 + 1:02d}" | |
| def _history_bucket(store: Dict, card_id: str, key: str) -> Dict[str, float]: | |
| return store.setdefault(card_id, {}).setdefault(key, {}) | |
| def _historical_mtd_for(card: Card, txn: Dict, state: Dict, statement_days: Dict) -> Dict: | |
| reward_key = (_history_statement_period(txn, statement_days.get(card.id)) | |
| if card.reward_cap_period == "statement_cycle" else _history_month(txn)) | |
| fee_key = _history_statement_period(txn, statement_days.get(card.id)) | |
| date = str(txn.get("date") or "") | |
| annual_key = date[:4] if len(date) >= 4 else "_" | |
| merged = dict(_history_bucket(state["reward"], card.id, reward_key)) | |
| for key, value in _history_bucket(state["fee"], card.id, fee_key).items(): | |
| if key == "__fuelWaiverInr": | |
| merged[key] = value | |
| elif not key.startswith("__") and not key.startswith("~"): | |
| merged["__fee:" + key] = value | |
| for key, value in _history_bucket(state["annual"], card.id, annual_key).items(): | |
| if key.startswith("__annual:"): | |
| merged[key] = value | |
| portal = _history_bucket(state["portal"], card.id, _history_month(txn)) | |
| if "__portalUnits" in portal: | |
| merged["__portalUnits"] = portal["__portalUnits"] | |
| return {card.id: merged} | |
| def _accumulate_historical_spend(card: Card, txn: Dict, state: Dict, statement_days: Dict) -> None: | |
| try: | |
| amount = max(0.0, float(txn.get("amount") or 0.0)) | |
| except (TypeError, ValueError): | |
| amount = 0.0 | |
| if amount <= 0: | |
| return | |
| reward_key = (_history_statement_period(txn, statement_days.get(card.id)) | |
| if card.reward_cap_period == "statement_cycle" else _history_month(txn)) | |
| fee_key = _history_statement_period(txn, statement_days.get(card.id)) | |
| date = str(txn.get("date") or "") | |
| annual_key = date[:4] if len(date) >= 4 else "_" | |
| reward_state = _history_bucket(state["reward"], card.id, reward_key) | |
| fee_state = _history_bucket(state["fee"], card.id, fee_key) | |
| annual_state = _history_bucket(state["annual"], card.id, annual_key) | |
| portal_state = _history_bucket(state["portal"], card.id, _history_month(txn)) | |
| # Reuse the single-transaction unit oracle against the exact merged ledger, | |
| # then distribute its typed outputs back to their independent clocks. | |
| visible = _historical_mtd_for(card, txn, state, statement_days) | |
| replay = {card.id: dict(visible[card.id])} | |
| accumulate_spend(replay, card.id, txn) | |
| after = replay[card.id] | |
| category = str(txn.get("category") or "general") | |
| brand = txn.get("brand_key") | |
| reward_keys = { | |
| "__bucketState", "__baseUnits", "__globalUnits", "__upiUnits", "__acceleratedUnits", | |
| } | |
| for key, value in after.items(): | |
| if key == category or (brand and key == "~" + str(brand)) or key in reward_keys or key.startswith("__cap:"): | |
| reward_state[key] = value | |
| fee_state[category] = fee_state.get(category, 0.0) + amount | |
| if brand: | |
| fee_state["~" + str(brand)] = fee_state.get("~" + str(brand), 0.0) + amount | |
| if "__fuelWaiverInr" in after: | |
| fee_state["__fuelWaiverInr"] = after["__fuelWaiverInr"] | |
| for key, value in after.items(): | |
| if key.startswith("__annual:"): | |
| annual_state[key] = value | |
| if "__portalUnits" in after: | |
| portal_state["__portalUnits"] = after["__portalUnits"] | |
| def analyse_transaction_history( | |
| held_card_ids: List[str], | |
| transactions: List[Dict], | |
| statement_days: Optional[Dict[str, int]] = None, | |
| held_networks: Optional[Dict[str, str]] = None, | |
| ) -> Dict: | |
| """ | |
| For each past transaction, determine whether the card used was optimal, | |
| and how much was left on the table. | |
| transactions: [{id, date, merchant, category, amount, card_id_used}] | |
| """ | |
| results = [None] * len(transactions) | |
| total_lost = 0.0 | |
| held_card_ids = list(dict.fromkeys(canonical_card_id(card_id) for card_id in held_card_ids)) | |
| statement_days = {canonical_card_id(key): value for key, value in (statement_days or {}).items()} | |
| held_networks = {canonical_card_id(key): value for key, value in (held_networks or {}).items()} | |
| history_state = {"reward": {}, "fee": {}, "annual": {}, "portal": {}} | |
| order = sorted(range(len(transactions)), key=lambda i: ( | |
| str(transactions[i].get("date") or ""), str(transactions[i].get("id") or ""), i | |
| )) | |
| for index in order: | |
| txn = transactions[index] | |
| mtd = {} | |
| for card_id in held_card_ids: | |
| card = CATALOGUE_BY_ID.get(card_id) | |
| if card is not None: | |
| mtd.update(_historical_mtd_for(card, txn, history_state, statement_days)) | |
| def _ctx(rail): | |
| return TxnContext( | |
| category=txn["category"], amount=float(txn["amount"]), | |
| merchant=txn.get("merchant"), brand_key=txn.get("brand_key"), | |
| offers=txn.get("offers"), rail=rail, channel=txn.get("channel"), | |
| card_networks=held_networks, | |
| ) | |
| # statement transactions were card swipes -> "used" is on the card rail; | |
| # also check the UPI (RuPay) rail to find the true cross-rail optimum. | |
| scored_card = score_transaction(held_card_ids, _ctx("card"), mtd_spend=mtd, include_discovery=False) | |
| scored_upi = score_transaction(held_card_ids, _ctx("upi"), mtd_spend=mtd, include_discovery=False) | |
| ranked_card = scored_card["held_ranked"] | |
| used_id = canonical_card_id(txn.get("card_id_used")) if txn.get("card_id_used") else None | |
| actual_rail = "upi" if txn.get("rail") == "upi" else "card" | |
| used_ranked = scored_upi["held_ranked"] if actual_rail == "upi" else ranked_card | |
| used = next((r for r in used_ranked if r["card_id"] == used_id), None) | |
| best_card = ranked_card[0] if ranked_card else None | |
| best_upi = scored_upi["held_ranked"][0] if scored_upi["held_ranked"] else None | |
| optimal = best_card | |
| optimal_rail = "card" | |
| if best_upi and best_upi["total_value_inr"] > (best_card["total_value_inr"] if best_card else 0): | |
| optimal = best_upi | |
| optimal_rail = "upi" | |
| lost = 0.0 | |
| was_optimal = True | |
| if used and optimal: | |
| lost = _r2(optimal["total_value_inr"] - used["total_value_inr"]) | |
| was_optimal = lost <= 0.01 | |
| total_lost += max(0.0, lost) | |
| # Only the physical card that was actually used consumes historical | |
| # issuer buckets. A counterfactual optimal card/rail must never rewrite | |
| # the real ledger used to score later purchases. | |
| used_card = CATALOGUE_BY_ID.get(canonical_card_id(used_id)) if used_id else None | |
| if used_card is not None: | |
| _accumulate_historical_spend(used_card, txn, history_state, statement_days) | |
| results[index] = { | |
| **txn, | |
| "used_card": used, | |
| "optimal_card": optimal, | |
| "was_optimal": was_optimal, | |
| "amount_lost": max(0.0, lost), | |
| "optimal_rail": optimal_rail, | |
| "rail_switch_helps": optimal_rail != actual_rail and not was_optimal, | |
| } | |
| return { | |
| "transactions": results, | |
| "total_lost_inr": _r2(total_lost), | |
| "count": len(results), | |
| "missed_count": sum(1 for r in results if r is not None and not r["was_optimal"]), | |
| } | |