rewardpilot-api / app /channels.py
sammy786's picture
At-place truthfulness tightened: no platform rows for unrecognised merchants
9486fa6
Raw
History Blame Contribute Delete
10.8 kB
"""
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,
}