Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| 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, | |
| } | |