rewardpilot-api / app /offers.py
sammy786's picture
Channel comparison + city availability + issuer portals + booking/apply links
faf005c
Raw
History Blame Contribute Delete
13.7 kB
"""
RewardPilot - Offers store
==========================
Structured, dated, source-attributed card offers. This is the source of truth
the engine reads at recommend time. It is fed by the ingestion connectors
(issuer scrapers, CLO networks, affiliate feeds) after human QA - see
connectors/ingest.py. Until a live feed is wired, it ships a curated seed.
Each offer is structured (not free text), so the engine can value it exactly:
type : 'pct' | 'flat' | 'cashback' | 'nocost_emi'
value : percent (pct/cashback) or rupees (flat)
min_spend, max_discount (cap for pct)
applies_to_issuer / card_id : who it applies to
categories : optional MCC restriction
channel : 'online' | 'offline' | 'both'
valid_from / valid_to : activation window (ISO date)
source / last_verified : provenance + freshness (shown in-app)
"""
from dataclasses import dataclass, field, asdict
from datetime import date
from typing import Dict, List, Optional
@dataclass
class Offer:
id: str
merchant_key: str
merchant_name: str
applies_to_issuer: str # "HDFC Bank" etc.; "" = any issuer
type: str # pct | flat | cashback | nocost_emi
value: float # percent or rupees
valid_from: str
valid_to: str
source: str # issuer:hdfc_smartbuy | clo:fidel | affiliate:cashkaro | manual
last_verified: str
channel: str = "both" # online | offline | both
card_id: Optional[str] = None # specific card, optional
min_spend: float = 0.0
max_discount: Optional[float] = None
categories: Optional[List[str]] = None
def text(self) -> str:
if self.type == "pct":
cap = f", up to ₹{self.max_discount:,.0f}" if self.max_discount else ""
mn = f" (min ₹{self.min_spend:,.0f})" if self.min_spend else ""
return f"{self.value:g}% instant discount{cap}{mn}"
if self.type == "cashback":
return f"{self.value:g}% cashback"
if self.type == "flat":
mn = f" (min ₹{self.min_spend:,.0f})" if self.min_spend else ""
return f"₹{self.value:,.0f} instant discount{mn}"
if self.type == "nocost_emi":
return f"No-cost EMI up to {self.value:g} months"
return ""
def to_dict(self) -> Dict:
d = asdict(self)
d["text"] = self.text()
return d
def offer_value(off: Dict, amount: float, category: Optional[str] = None) -> float:
"""Monetary value of a (dict) offer for a given spend. 0 if not applicable."""
if amount < (off.get("min_spend") or 0):
return 0.0
cats = off.get("categories")
if cats and category is not None and category not in cats:
return 0.0
t = off.get("type")
v = off.get("value") or 0.0
if t in ("pct", "cashback"):
val = amount * v / 100.0
cap = off.get("max_discount")
if cap:
val = min(val, cap)
return val
if t == "flat":
return float(v)
# nocost_emi has no direct rupee value (financing benefit), surfaced as text only
return 0.0
def _active(off: Offer, today: Optional[str]) -> bool:
today = today or date.today().isoformat()
return off.valid_from <= today <= off.valid_to
def active_offers_for(merchant_key: Optional[str], today: Optional[str] = None) -> List[Dict]:
if not merchant_key:
return []
return [o.to_dict() for o in CATALOGUE if o.merchant_key == merchant_key and _active(o, today)]
def all_active(today: Optional[str] = None) -> List[Dict]:
return [o.to_dict() for o in CATALOGUE if _active(o, today)]
# ---------------------------------------------------------------------------
# Curated seed (replaced/augmented by the ingestion pipeline). Dates illustrative.
# ---------------------------------------------------------------------------
_TODAY = "2026-06-01"
_FAR = "2027-12-31" # generous window so seed offers don't silently expire before pipeline refresh
_LV = "2026-06-20"
CATALOGUE: List[Offer] = [
# Electronics / large-format (offline + online)
Offer("of_croma_hdfc", "croma", "Croma", "HDFC Bank", "flat", 5000, _TODAY, _FAR, "issuer:hdfc_smartbuy", _LV, "both", min_spend=50000),
Offer("of_croma_icici", "croma", "Croma", "ICICI Bank", "nocost_emi", 9, _TODAY, _FAR, "issuer:icici", _LV, "both"),
Offer("of_croma_sbi", "croma", "Croma", "SBI Card", "pct", 7.5, _TODAY, _FAR, "affiliate:bank_partner", _LV, "both", max_discount=4000, min_spend=20000),
Offer("of_reliancedigital_axis", "reliance_digital", "Reliance Digital", "Axis Bank", "pct", 10, _TODAY, _FAR, "issuer:axis", _LV, "both", max_discount=5000, min_spend=30000),
Offer("of_vijaysales_hdfc", "vijay_sales", "Vijay Sales", "HDFC Bank", "flat", 3000, _TODAY, _FAR, "affiliate:bank_partner", _LV, "both", min_spend=40000),
Offer("of_apple_hdfc", "apple", "Apple authorised", "HDFC Bank", "pct", 6, _TODAY, _FAR, "issuer:hdfc_smartbuy", _LV, "both", max_discount=8000, min_spend=50000),
# Online marketplaces
Offer("of_amazon_icici", "amazon", "Amazon", "ICICI Bank", "cashback", 5, _TODAY, _FAR, "clo:fidel", _LV, "online", card_id="icici_amazon_pay"),
Offer("of_amazon_sbi", "amazon", "Amazon", "SBI Card", "pct", 10, _TODAY, _FAR, "affiliate:cashkaro", _LV, "online", max_discount=1500, min_spend=5000),
Offer("of_flipkart_axis", "flipkart", "Flipkart", "Axis Bank", "cashback", 5, _TODAY, _FAR, "issuer:axis", _LV, "online"),
Offer("of_flipkart_icici", "flipkart", "Flipkart", "ICICI Bank", "pct", 10, _TODAY, _FAR, "affiliate:grabon", _LV, "online", max_discount=1250, min_spend=5000),
Offer("of_myntra_hdfc", "myntra", "Myntra", "HDFC Bank", "pct", 10, _TODAY, _FAR, "affiliate:cashkaro", _LV, "online", max_discount=1000, min_spend=3000),
Offer("of_ajio_amex", "ajio", "AJIO", "American Express", "pct", 12, _TODAY, _FAR, "issuer:amex_offers", _LV, "online", max_discount=1500, min_spend=4000),
Offer("of_nykaa_sbi", "nykaa", "Nykaa", "SBI Card", "pct", 10, _TODAY, _FAR, "affiliate:grabon", _LV, "online", max_discount=750, min_spend=2500),
Offer("of_tatacliq_icici", "tata_cliq", "Tata CLiQ", "ICICI Bank", "pct", 10, _TODAY, _FAR, "affiliate:cashkaro", _LV, "online", max_discount=1500, min_spend=5000),
# Food delivery / quick commerce
Offer("of_swiggy_hdfc", "swiggy", "Swiggy", "HDFC Bank", "cashback", 10, _TODAY, _FAR, "issuer:hdfc_smartbuy", _LV, "online", card_id="hdfc_swiggy"),
Offer("of_zomato_amex", "zomato", "Zomato", "American Express", "cashback", 20, _TODAY, _FAR, "issuer:amex_offers", _LV, "online", max_discount=200, min_spend=500),
Offer("of_instamart_hdfc", "instamart", "Swiggy Instamart", "HDFC Bank", "cashback", 10, _TODAY, _FAR, "issuer:hdfc_smartbuy", _LV, "online", card_id="hdfc_swiggy"),
Offer("of_blinkit_axis", "blinkit", "Blinkit", "Axis Bank", "cashback", 5, _TODAY, _FAR, "clo:fidel", _LV, "online"),
Offer("of_bigbasket_sbi", "bigbasket", "BigBasket", "SBI Card", "pct", 5, _TODAY, _FAR, "affiliate:cashkaro", _LV, "online", max_discount=500, min_spend=1500),
# Travel
Offer("of_makemytrip_icici", "makemytrip", "MakeMyTrip", "ICICI Bank", "pct", 12, _TODAY, _FAR, "affiliate:bank_partner", _LV, "online", max_discount=5000, categories=["travel_flights", "travel_hotels"]),
Offer("of_goibibo_axis", "goibibo", "Goibibo", "Axis Bank", "pct", 10, _TODAY, _FAR, "affiliate:bank_partner", _LV, "online", max_discount=4000, categories=["travel_flights", "travel_hotels"]),
Offer("of_cleartrip_amex", "cleartrip", "Cleartrip", "American Express", "flat", 1500, _TODAY, _FAR, "issuer:amex_offers", _LV, "online", min_spend=10000, categories=["travel_flights"]),
Offer("of_oyo_hdfc", "oyo", "OYO", "HDFC Bank", "pct", 15, _TODAY, _FAR, "affiliate:grabon", _LV, "online", max_discount=1000, categories=["travel_hotels"]),
Offer("of_easemytrip_sbi", "easemytrip", "EaseMyTrip", "SBI Card", "pct", 10, _TODAY, _FAR, "affiliate:bank_partner", _LV, "online", max_discount=1500, min_spend=5000, categories=["travel_flights", "travel_hotels"]),
# Entertainment
Offer("of_bookmyshow_sbi", "bookmyshow", "BookMyShow", "SBI Card", "flat", 150, _TODAY, _FAR, "affiliate:bank_partner", _LV, "online", min_spend=400, categories=["entertainment"]),
Offer("of_bookmyshow_axis", "bookmyshow", "BookMyShow", "Axis Bank", "flat", 200, _TODAY, _FAR, "issuer:axis", _LV, "online", min_spend=800, categories=["entertainment"]),
Offer("of_pvr_hdfc", "pvr", "PVR INOX", "HDFC Bank", "pct", 20, _TODAY, _FAR, "issuer:hdfc_smartbuy", _LV, "offline", max_discount=300, categories=["entertainment"]),
# Dine-in / ticketing platforms (Swiggy Dineout, Zomato District). Rows with an
# empty issuer are PLATFORM-funded deals - they apply whatever card is used, so
# they lift every card equally, which is what makes the channel comparison honest.
# District sells dining AND movies; the categories field keeps each offer scoped.
Offer("of_dineout_hdfc", "dineout", "Swiggy Dineout", "HDFC Bank", "pct", 15, _TODAY, _FAR, "issuer:hdfc_smartbuy", _LV, "online", max_discount=500, min_spend=1000, categories=["dining"]),
Offer("of_dineout_plat", "dineout", "Swiggy Dineout", "", "pct", 10, _TODAY, _FAR, "platform:dineout", _LV, "online", max_discount=300, categories=["dining"]),
Offer("of_district_axis", "district", "District (Zomato)", "Axis Bank", "pct", 20, _TODAY, _FAR, "issuer:axis", _LV, "online", max_discount=500, min_spend=2000, categories=["dining"]),
Offer("of_district_plat_dining", "district", "District (Zomato)", "", "pct", 15, _TODAY, _FAR, "platform:district", _LV, "online", max_discount=350, categories=["dining"]),
Offer("of_district_icici_movies", "district", "District (Zomato)", "ICICI Bank", "flat", 100, _TODAY, _FAR, "affiliate:bank_partner", _LV, "online", min_spend=500, categories=["entertainment"]),
Offer("of_district_plat_movies", "district", "District (Zomato)", "", "pct", 10, _TODAY, _FAR, "platform:district", _LV, "online", max_discount=200, categories=["entertainment"]),
# Fashion / department stores (offline)
Offer("of_lifestyle_axis", "lifestyle", "Lifestyle", "Axis Bank", "pct", 10, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=1000, min_spend=3000, categories=["apparel"]),
Offer("of_shoppersstop_hdfc", "shoppers_stop", "Shoppers Stop", "HDFC Bank", "pct", 10, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=1000, categories=["apparel"]),
Offer("of_westside_icici", "westside", "Westside", "ICICI Bank", "flat", 500, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", min_spend=3000, categories=["apparel"]),
# Jewellery (offline, high ticket)
Offer("of_tanishq_sbi", "tanishq", "Tanishq", "SBI Card", "pct", 3, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=10000, min_spend=50000),
# Pharmacy / health
Offer("of_pharmeasy_axis", "pharmeasy", "PharmEasy", "Axis Bank", "pct", 15, _TODAY, _FAR, "clo:kard", _LV, "online", max_discount=300, categories=["pharmacy"]),
Offer("of_apollo_hdfc", "apollo", "Apollo Pharmacy", "HDFC Bank", "pct", 10, _TODAY, _FAR, "affiliate:bank_partner", _LV, "both", max_discount=250, categories=["pharmacy"]),
# Grocery / hypermarket (offline)
Offer("of_dmart_icici", "dmart", "DMart", "ICICI Bank", "pct", 5, _TODAY, _FAR, "clo:fidel", _LV, "offline", max_discount=300, categories=["groceries"]),
Offer("of_reliancefresh_sbi", "reliance_fresh", "Reliance Fresh", "SBI Card", "pct", 5, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=250, categories=["groceries"]),
# Eating out (offline dining)
Offer("of_starbucks_amex", "starbucks", "Starbucks", "American Express", "cashback", 15, _TODAY, _FAR, "issuer:amex_offers", _LV, "offline", max_discount=150, categories=["dining"]),
Offer("of_barbequenation_hdfc", "barbeque_nation", "Barbeque Nation", "HDFC Bank", "pct", 15, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=500, categories=["dining"]),
Offer("of_copperchimney_amex", "copper_chimney", "Copper Chimney", "American Express", "cashback", 15, _TODAY, _FAR, "issuer:amex_offers", _LV, "offline", max_discount=300, categories=["dining"]),
# An intentionally EXPIRED offer (proves date filtering works)
Offer("of_amazon_hdfc_expired", "amazon", "Amazon", "HDFC Bank", "pct", 10, "2026-01-01", "2026-03-31", "affiliate:cashkaro", "2026-03-15", "online", max_discount=2000),
]
def _load_curated() -> None:
"""Merge human-approved offers from the ingestion pipeline (offers_data/
curated_offers.json) on top of the seed. Safe no-op if the file is absent."""
import json, os
path = os.path.join(os.path.dirname(__file__), "offers_data", "curated_offers.json")
if not os.path.exists(path):
return
try:
with open(path) as f:
rows = json.load(f)
except Exception:
return
fields = Offer.__dataclass_fields__.keys()
def sig(o: Offer):
return (o.merchant_key, o.applies_to_issuer, o.card_id, o.type)
for r in rows:
data = {k: r.get(k) for k in fields if k in r}
if not data.get("id"):
continue
off = Offer(**data)
# the pipeline copy supersedes any seed/existing offer for the same
# merchant+issuer+card+type (fresher last_verified), avoiding duplicates
CATALOGUE[:] = [o for o in CATALOGUE if o.id != off.id and sig(o) != sig(off)]
CATALOGUE.append(off)
_load_curated()
CATALOGUE_BY_ID = {o.id: o for o in CATALOGUE}