""" RewardPilot - Booking-channel comparison ======================================== "Which card?" is only half the answer. For dine-in, movies and travel the same purchase can be paid through several apps (Swiggy Dineout vs District vs paying at the table; BookMyShow vs District vs the box office; MakeMyTrip vs EaseMyTrip vs the airline site) - and the best card DIFFERS per channel because each platform carries its own bank + platform offers. This module reuses the deterministic scoring engine once per channel and returns a ranked channel matrix: for each channel, the best held card and the total rupee benefit (card rewards + instant offers on that channel). Mirrored on-device in app/src/lib/channels.ts. """ from typing import Dict, List, Optional from availability import channel_availability from card_catalogue import CATALOGUE_BY_ID from offers import active_offers_for from scoring_engine import TxnContext, score_transaction, _r2 # Brands that are themselves a booking platform: when the user's query resolved # to one of these, the "direct" channel must NOT inherit it as a walk-in brand. PLATFORM_BRANDS = { "dineout", "district", "bookmyshow", "makemytrip", "goibibo", "cleartrip", "yatra", "ixigo", "easemytrip", } # Delivery aggregators: ordering in is a different purchase from dining out, so # no channel comparison there. DELIVERY_BRANDS = {"swiggy", "zomato"} DINING_CHANNELS = [ {"key": "district", "name": "District (Zomato)", "brand_key": "district", "channel": "online", "how": "Book the table / pay the bill in the District app", "url": "https://www.district.in"}, {"key": "dineout", "name": "Swiggy Dineout", "brand_key": "dineout", "channel": "online", "how": "Pay the bill through Swiggy Dineout", "url": "https://www.swiggy.com/dineout"}, {"key": "direct", "name": "Pay at the restaurant", "brand_key": None, "channel": "offline", "how": "Swipe / tap your card at the table", "url": None}, ] MOVIE_CHANNELS = [ {"key": "bookmyshow", "name": "BookMyShow", "brand_key": "bookmyshow", "channel": "online", "how": "Book tickets on BookMyShow", "url": "https://in.bookmyshow.com"}, {"key": "district", "name": "District (Zomato)", "brand_key": "district", "channel": "online", "how": "Book tickets in the District app", "url": "https://www.district.in/movies"}, {"key": "direct", "name": "Cinema box office / site", "brand_key": None, "channel": "offline", "how": "Buy at the counter or the cinema's own site", "url": None}, ] FLIGHT_CHANNELS = [ {"key": "makemytrip", "name": "MakeMyTrip", "brand_key": "makemytrip", "channel": "online", "how": "Book on MakeMyTrip", "url": "https://www.makemytrip.com/flights/"}, {"key": "easemytrip", "name": "EaseMyTrip", "brand_key": "easemytrip", "channel": "online", "how": "Book on EaseMyTrip", "url": "https://www.easemytrip.com"}, {"key": "goibibo", "name": "Goibibo", "brand_key": "goibibo", "channel": "online", "how": "Book on Goibibo", "url": "https://www.goibibo.com/flights/"}, {"key": "cleartrip", "name": "Cleartrip", "brand_key": "cleartrip", "channel": "online", "how": "Book on Cleartrip", "url": "https://www.cleartrip.com"}, {"key": "direct", "name": "Airline website", "brand_key": None, "channel": "online", "how": "Book directly with the airline", "url": None}, ] HOTEL_CHANNELS = [ {"key": "makemytrip", "name": "MakeMyTrip", "brand_key": "makemytrip", "channel": "online", "how": "Book on MakeMyTrip", "url": "https://www.makemytrip.com/hotels/"}, {"key": "goibibo", "name": "Goibibo", "brand_key": "goibibo", "channel": "online", "how": "Book on Goibibo", "url": "https://www.goibibo.com/hotels/"}, {"key": "easemytrip", "name": "EaseMyTrip", "brand_key": "easemytrip", "channel": "online", "how": "Book on EaseMyTrip", "url": "https://www.easemytrip.com/hotels/"}, {"key": "direct", "name": "Hotel directly", "brand_key": None, "channel": "online", "how": "Book with the hotel / pay at check-in", "url": None}, ] # Issuer portal booking sites (behind login; the portal decides final routing). PORTAL_URLS = { "HDFC SmartBuy": "https://offers.smartbuy.hdfcbank.com", "Axis Travel Edge": "https://traveledge.axisbank.co.in", "ICICI iShop": "https://ishop.icicibank.com", } # Issuer booking portals (HDFC SmartBuy, Axis Travel Edge, ICICI iShop) are a # further channel: elevated portal-only rates, but only for holders of that # issuer's portal-rated cards, capped monthly, paid in points (assumed redeemed # at the portal point value). Synthesized per wallet, pan-India online. PORTAL_KEYS = {"HDFC SmartBuy": "smartbuy", "Axis Travel Edge": "travel_edge", "ICICI iShop": "ishop"} def _portal_value(card, category: str, amount: float) -> float: """INR value of booking this category via the card's issuer portal (0 if none).""" rate = (card.portal_rates or {}).get(category) if not rate: return 0.0 pv = card.portal_point_value_inr if card.portal_point_value_inr is not None else card.point_value_inr units = amount / 100.0 * rate if card.portal_cap_units is not None: units = min(units, card.portal_cap_units) return _r2(units * pv) def _portal_options(held_card_ids: List[str], category: str, amount: float) -> List[Dict]: best: Dict[str, tuple] = {} # portal name -> (card_id, value, card_name) for cid in held_card_ids: card = CATALOGUE_BY_ID.get(cid) if not card or not card.portal_name: continue v = _portal_value(card, category, amount) if v <= 0: continue cur = best.get(card.portal_name) if cur is None or v > cur[1]: best[card.portal_name] = (cid, v, card.name) opts = [] for pname, (cid, v, cname) in best.items(): opts.append({ "channel_key": PORTAL_KEYS.get(pname, pname.lower().replace(" ", "_")), "channel_name": pname, "how": f"Book inside {pname} paying with {cname}", "best_card_id": cid, "best_card_name": cname, "reward_inr": v, "instant_offer_inr": 0.0, "total_benefit_inr": v, "effective_pct": _r2(v / amount * 100.0) if amount else 0.0, "availability": "available", "via_portal": True, "book_url": PORTAL_URLS.get(pname), }) return opts def _channels_for(category: str, brand_key: Optional[str]): if category == "dining" and brand_key not in DELIVERY_BRANDS: return "dining", DINING_CHANNELS if category == "entertainment" and brand_key in (None, "bookmyshow", "pvr", "cinepolis", "district"): return "movies", MOVIE_CHANNELS if category == "travel_flights": return "flights", FLIGHT_CHANNELS if category == "travel_hotels": return "hotels", HOTEL_CHANNELS return None, None def compare_channels( held_card_ids: List[str], category: str, amount: float, brand_key: Optional[str] = None, merchant: Optional[str] = None, rail: str = "card", persona: Optional[List[str]] = None, city: Optional[str] = None, at_place: bool = False, ) -> Optional[Dict]: """ Best card PER booking channel, channels ranked by total rupee benefit. When the city is known, channels confirmed absent there are dropped and unverified listings are flagged. Returns None when the category has no channel choice (or on the UPI rail, where instant offers don't apply and the comparison collapses). """ if rail == "upi" or not held_card_ids: return None kind, plan = _channels_for(category, brand_key) if not plan: return None options = [] for ch in plan: avail = channel_availability(ch["key"], kind, city, brand_key, at_place=at_place) if avail == "unavailable": continue # TRUTHFULNESS: standing AT a place whose merchant we do not even # recognise (no brand key), platform rows must not appear at all. # "Save 20% via District" about an unknown dairy is misleading no # matter the caveat. Recognised merchants keep their honestly-caveated # "if listed there" rows. if at_place and not brand_key and ch["brand_key"] is not None: continue # Platform channels score against the platform's offers; the direct # channel keeps the resolved walk-in brand (e.g. Copper Chimney's own # card-linked offer) unless that brand is itself a platform. if ch["brand_key"] is not None: bk = ch["brand_key"] else: bk = None if brand_key in PLATFORM_BRANDS else brand_key ctx = TxnContext( category=category, amount=float(amount), merchant=merchant, brand_key=bk, offers=active_offers_for(bk), rail="card", channel=ch["channel"], ) res = score_transaction(held_card_ids, ctx, include_discovery=False, persona=persona) ranked = res["held_ranked"] if not ranked: continue top = ranked[0] options.append({ "channel_key": ch["key"], "channel_name": ch["name"], "how": ch["how"], "best_card_id": top["card_id"], "best_card_name": top["card_name"], "reward_inr": top["reward_value_inr"], "instant_offer_inr": top["instant_offer_inr"], "total_benefit_inr": top["total_value_inr"], "effective_pct": top["effective_rate_pct"], "availability": avail, "via_portal": False, "book_url": ch.get("url"), }) # Issuer portals the user can actually use (portal-rated card in wallet). portal_opts = _portal_options(held_card_ids, category, float(amount)) options.extend(portal_opts) if len(options) < 2: return None options.sort(key=lambda o: -o["total_benefit_inr"]) best, second = options[0], options[1] if city: note = (f"Channels confirmed absent in {city.title()} are hidden; " "listings we couldn't verify are flagged. Platform deals vary by city.") else: note = ("Assumes this merchant is available on each platform in your city; " "platform availability and platform-funded deals vary.") if portal_opts: note += " Issuer-portal options earn points, assumed redeemed at full value." return { "kind": kind, "options": options, "best_channel": best["channel_key"], "best_channel_name": best["channel_name"], "delta_vs_next_inr": round(best["total_benefit_inr"] - second["total_benefit_inr"], 2), "city": city, "note": note, }