Spaces:
Running
Running
| """ | |
| 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 | |
| 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} | |