""" 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}