Spaces:
Running on CPU Upgrade
Channel comparison + city availability + issuer portals + booking/apply links
Browse files- channels.py: best card PER booking channel (District/Dineout/direct, BMS,
OTAs, SmartBuy/Travel Edge/iShop portals) ranked by net INR, in /recommend
- availability.py: 15-city table, synonyms, GPS centroid fallback, platform
and merchant city coverage; unavailable channels dropped, unverified flagged
- merchants.py: district/dineout/copper chimney keywords, movie subtype
- offers.py: Dineout/District/EaseMyTrip/BMS/Copper Chimney offers incl.
platform-funded any-card rows; connectors + curated_offers.json synced
- apply_links.py: official issuer product pages for all 30 cards; /discover
now returns them (was a placeholder)
- main.py: X-App-Key gate, city/lat/lng on /recommend, /offers endpoints
- verified: 58,140-scenario certification parity + channel matrix, 0 diffs
- .env.example +10 -0
- .gitignore +3 -0
- app/apply_links.py +47 -0
- app/availability.py +167 -0
- app/channels.py +230 -0
- app/connectors/__init__.py +12 -0
- app/connectors/affiliate_feeds.py +69 -0
- app/connectors/base.py +57 -0
- app/connectors/clo.py +82 -0
- app/connectors/ingest.py +113 -0
- app/connectors/issuer_scrapers.py +96 -0
- app/main.py +80 -16
- app/merchants.py +159 -102
- app/offers.py +15 -0
- app/offers_data/curated_offers.json +239 -0
- app/scoring_engine.py +5 -0
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RewardPilot backend environment
|
| 2 |
+
# Copy this file to ".env" and paste your real key. NEVER commit ".env".
|
| 3 |
+
# The key lives ONLY here on the server — never in the mobile app or git.
|
| 4 |
+
|
| 5 |
+
# OpenAI key — used only to write the natural-language "why this card" reasoning.
|
| 6 |
+
# The deterministic engine still computes all numbers and the ranking.
|
| 7 |
+
OPENAI_API_KEY=sk-paste-your-rotated-key-here
|
| 8 |
+
|
| 9 |
+
# Optional alternative for narration (not required if OPENAI_API_KEY is set)
|
| 10 |
+
# ANTHROPIC_API_KEY=
|
|
@@ -2,3 +2,6 @@ __pycache__/
|
|
| 2 |
*.pyc
|
| 3 |
.venv/
|
| 4 |
.env
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
*.pyc
|
| 3 |
.venv/
|
| 4 |
.env
|
| 5 |
+
|
| 6 |
+
# offer pipeline transient state
|
| 7 |
+
offers_data/review_queue.json
|
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Official issuer application/product pages per card (mirrored in
|
| 3 |
+
app/src/data/applyLinks.ts). Curated 2026-07-05; key pages spot-verified live.
|
| 4 |
+
Where a product page couldn't be confirmed, the issuer's credit-cards hub is
|
| 5 |
+
used - never a dead link. Callers fall back to a web search for any card
|
| 6 |
+
missing here. Replace with affiliate deep links when monetized.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
APPLY_URL = {
|
| 10 |
+
# HDFC
|
| 11 |
+
"hdfc_infinia": "https://www.hdfcbank.com/personal/pay/cards/credit-cards/infinia-credit-card",
|
| 12 |
+
"hdfc_regalia_gold": "https://www.hdfcbank.com/personal/pay/cards/credit-cards/regalia-gold-credit-card",
|
| 13 |
+
"hdfc_millennia": "https://www.hdfcbank.com/personal/pay/cards/credit-cards/millennia-credit-card",
|
| 14 |
+
"hdfc_swiggy": "https://www.hdfcbank.com/personal/pay/cards/credit-cards/swiggy-credit-card",
|
| 15 |
+
"hdfc_diners_black": "https://www.hdfcbank.com/personal/pay/cards/credit-cards/diners-club-black-metal-edition",
|
| 16 |
+
"tataneu_infinity": "https://www.hdfcbank.com/personal/pay/cards/credit-cards", # hub: product URL unstable
|
| 17 |
+
# SBI Card
|
| 18 |
+
"sbi_cashback": "https://www.sbicard.com/en/personal/credit-cards/shopping/cashback-sbi-card.page",
|
| 19 |
+
"sbi_elite": "https://www.sbicard.com/en/personal/credit-cards/lifestyle/sbi-card-elite.page",
|
| 20 |
+
"sbi_simplyclick": "https://www.sbicard.com/en/personal/credit-cards/shopping/simplyclick-sbi-card.page",
|
| 21 |
+
"sbi_bpcl_octane": "https://www.sbicard.com/en/personal/credit-cards/travel/bpcl-sbi-card-octane.page",
|
| 22 |
+
"sbi_rupay_select": "https://www.sbicard.com/en/personal/credit-cards.page", # hub
|
| 23 |
+
# ICICI
|
| 24 |
+
"icici_amazon_pay": "https://www.icicibank.com/personal-banking/cards/credit-card/amazon-pay-credit-card",
|
| 25 |
+
"icici_sapphiro": "https://www.icicibank.com/personal-banking/cards/credit-card/sapphiro-credit-card",
|
| 26 |
+
"icici_emeralde": "https://www.icicibank.com/personal-banking/cards/credit-card/emeralde-private-metal-credit-card",
|
| 27 |
+
# Axis
|
| 28 |
+
"axis_magnus": "https://www.axisbank.com/retail/cards/credit-card/axis-bank-magnus-credit-card",
|
| 29 |
+
"axis_ace": "https://www.axisbank.com/retail/cards/credit-card/ace-credit-card",
|
| 30 |
+
"axis_atlas": "https://www.axisbank.com/retail/cards/credit-card/atlas-credit-card",
|
| 31 |
+
"axis_flipkart": "https://www.axisbank.com/retail/cards/credit-card/flipkart-axisbank-credit-card",
|
| 32 |
+
# Amex
|
| 33 |
+
"amex_mrcc": "https://www.americanexpress.com/in/credit-cards/membership-rewards-credit-card/",
|
| 34 |
+
"amex_platinum_travel": "https://www.americanexpress.com/in/credit-cards/platinum-travel-credit-card/",
|
| 35 |
+
# IDFC FIRST
|
| 36 |
+
"idfc_first_select": "https://www.idfcfirstbank.com/credit-card/first-select-credit-card",
|
| 37 |
+
"idfc_first_wealth": "https://www.idfcfirstbank.com/credit-card/first-wealth-credit-card",
|
| 38 |
+
"idfc_first_millennia": "https://www.idfcfirstbank.com/credit-card/first-millennia-credit-card",
|
| 39 |
+
"idfc_first_power_plus": "https://www.idfcfirstbank.com/credit-card/first-power-plus-credit-card",
|
| 40 |
+
# Others
|
| 41 |
+
"kiwi_axis": "https://gokiwi.in/",
|
| 42 |
+
"au_zenith": "https://www.aubank.in/personal-banking/credit-cards/zenith-credit-card",
|
| 43 |
+
"indusind_legend": "https://www.indusind.com/in/en/personal/cards/credit-card/legend-credit-card.html",
|
| 44 |
+
"rbl_world_safari": "https://www.rblbank.com/credit-cards/world-safari-credit-card",
|
| 45 |
+
"onecard_metal": "https://www.getonecard.app/",
|
| 46 |
+
"hsbc_live_plus": "https://www.hsbc.co.in/credit-cards/products/live-plus/",
|
| 47 |
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RewardPilot - City & platform availability
|
| 3 |
+
===========================================
|
| 4 |
+
Answers two questions the channel comparison needs:
|
| 5 |
+
1. Which city is the user in? (explicit city > city named in the query >
|
| 6 |
+
nearest metro centroid from GPS coordinates)
|
| 7 |
+
2. Is a booking platform - and, when we know the chain, this specific
|
| 8 |
+
merchant - actually available in that city?
|
| 9 |
+
|
| 10 |
+
Curated seed, same philosophy as offers.py: structured and dated in spirit,
|
| 11 |
+
refreshed by the ingestion pipeline (connectors/) later. Coverage values are
|
| 12 |
+
illustrative until a live feed is wired.
|
| 13 |
+
|
| 14 |
+
Availability levels:
|
| 15 |
+
"available" - platform (and merchant, when known) confirmed in this city
|
| 16 |
+
"unknown" - no data to confirm; channel is kept but flagged unverified
|
| 17 |
+
"unavailable" - confirmed absent (platform not in city, or chain not listed
|
| 18 |
+
on this platform); channel is dropped from the comparison
|
| 19 |
+
|
| 20 |
+
Mirrored on-device in app/src/data/availability.ts.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import math
|
| 24 |
+
from typing import Dict, Optional, Set, Union
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# Cities: canonical name -> (lat, lng) centroid. Used for GPS fallback.
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
CITIES: Dict[str, tuple] = {
|
| 30 |
+
"mumbai": (19.0760, 72.8777),
|
| 31 |
+
"delhi": (28.6139, 77.2090),
|
| 32 |
+
"bengaluru": (12.9716, 77.5946),
|
| 33 |
+
"hyderabad": (17.3850, 78.4867),
|
| 34 |
+
"chennai": (13.0827, 80.2707),
|
| 35 |
+
"kolkata": (22.5726, 88.3639),
|
| 36 |
+
"pune": (18.5204, 73.8567),
|
| 37 |
+
"ahmedabad": (23.0225, 72.5714),
|
| 38 |
+
"jaipur": (26.9124, 75.7873),
|
| 39 |
+
"surat": (21.1702, 72.8311),
|
| 40 |
+
"lucknow": (26.8467, 80.9462),
|
| 41 |
+
"chandigarh": (30.7333, 76.7794),
|
| 42 |
+
"indore": (22.7196, 75.8577),
|
| 43 |
+
"kochi": (9.9312, 76.2673),
|
| 44 |
+
"goa": (15.4909, 73.8278),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
# Common alternates / NCR satellites -> canonical city key.
|
| 48 |
+
CITY_SYNONYMS: Dict[str, str] = {
|
| 49 |
+
"bombay": "mumbai", "navi mumbai": "mumbai", "thane": "mumbai",
|
| 50 |
+
"new delhi": "delhi", "gurgaon": "delhi", "gurugram": "delhi",
|
| 51 |
+
"noida": "delhi", "ghaziabad": "delhi", "faridabad": "delhi",
|
| 52 |
+
"bangalore": "bengaluru",
|
| 53 |
+
"madras": "chennai",
|
| 54 |
+
"calcutta": "kolkata",
|
| 55 |
+
"cochin": "kochi",
|
| 56 |
+
"secunderabad": "hyderabad",
|
| 57 |
+
"panaji": "goa", "panjim": "goa",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def normalize_city(name: Optional[str]) -> Optional[str]:
|
| 62 |
+
"""Free-text city name -> canonical key, or None if we don't know it."""
|
| 63 |
+
t = (name or "").strip().lower()
|
| 64 |
+
if not t:
|
| 65 |
+
return None
|
| 66 |
+
if t in CITIES:
|
| 67 |
+
return t
|
| 68 |
+
return CITY_SYNONYMS.get(t)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def extract_city(text: str) -> Optional[str]:
|
| 72 |
+
"""Find a city mentioned inside a free-text query ("dinner ... in Mumbai")."""
|
| 73 |
+
t = (text or "").lower()
|
| 74 |
+
hits = [k for k in list(CITIES) + list(CITY_SYNONYMS) if k in t]
|
| 75 |
+
if not hits:
|
| 76 |
+
return None
|
| 77 |
+
pick = sorted(hits, key=len, reverse=True)[0]
|
| 78 |
+
return pick if pick in CITIES else CITY_SYNONYMS[pick]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def city_from_latlng(lat: float, lng: float, max_km: float = 60.0) -> Optional[str]:
|
| 82 |
+
"""Nearest metro centroid within max_km, else None."""
|
| 83 |
+
best, best_d = None, max_km
|
| 84 |
+
for city, (clat, clng) in CITIES.items():
|
| 85 |
+
p1, p2 = math.radians(lat), math.radians(clat)
|
| 86 |
+
dp, dl = math.radians(clat - lat), math.radians(clng - lng)
|
| 87 |
+
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
| 88 |
+
d = 2 * 6371.0 * math.asin(min(1.0, math.sqrt(h)))
|
| 89 |
+
if d < best_d:
|
| 90 |
+
best, best_d = city, d
|
| 91 |
+
return best
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Platform coverage: (channel_key, kind) -> "*" (pan-India) or a city set.
|
| 96 |
+
# Direct/walk-in channels and travel OTAs are always available.
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
_ALL = "*"
|
| 99 |
+
PLATFORM_CITIES: Dict[tuple, Union[str, Set[str]]] = {
|
| 100 |
+
("dineout", "dining"): {
|
| 101 |
+
"mumbai", "delhi", "bengaluru", "hyderabad", "chennai", "kolkata",
|
| 102 |
+
"pune", "ahmedabad", "jaipur", "lucknow", "chandigarh", "indore",
|
| 103 |
+
},
|
| 104 |
+
("district", "dining"): {
|
| 105 |
+
"mumbai", "delhi", "bengaluru", "hyderabad", "chennai", "kolkata",
|
| 106 |
+
"pune", "ahmedabad",
|
| 107 |
+
},
|
| 108 |
+
("district", "movies"): {
|
| 109 |
+
"mumbai", "delhi", "bengaluru", "hyderabad", "chennai", "pune",
|
| 110 |
+
},
|
| 111 |
+
("bookmyshow", "movies"): _ALL,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
# Merchant-level listings (dining chains we track): brand_key -> platform ->
|
| 116 |
+
# "*" or city set. A chain present in this dict with a platform MISSING means
|
| 117 |
+
# we know it is NOT listed there (e.g. QSR counter chains aren't on the
|
| 118 |
+
# dine-in bill-payment platforms).
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
MERCHANT_PLATFORMS: Dict[str, Dict[str, Union[str, Set[str]]]] = {
|
| 121 |
+
"copper_chimney": {
|
| 122 |
+
"dineout": {"mumbai", "delhi", "pune", "bengaluru"},
|
| 123 |
+
"district": {"mumbai", "pune", "bengaluru"},
|
| 124 |
+
},
|
| 125 |
+
"barbeque_nation": {"dineout": _ALL, "district": _ALL},
|
| 126 |
+
# counter/QSR chains: you pay at the till, not through a dine-in bill app
|
| 127 |
+
"starbucks": {},
|
| 128 |
+
"haldiram": {},
|
| 129 |
+
"dominos": {},
|
| 130 |
+
"pizza_hut": {},
|
| 131 |
+
"mcdonalds": {},
|
| 132 |
+
"kfc": {},
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def channel_availability(
|
| 137 |
+
channel_key: str,
|
| 138 |
+
kind: str,
|
| 139 |
+
city: Optional[str],
|
| 140 |
+
brand_key: Optional[str] = None,
|
| 141 |
+
) -> str:
|
| 142 |
+
"""'available' | 'unknown' | 'unavailable' for one channel in one city."""
|
| 143 |
+
if channel_key == "direct":
|
| 144 |
+
return "available"
|
| 145 |
+
if kind in ("flights", "hotels"):
|
| 146 |
+
return "available" # travel OTAs are pan-India
|
| 147 |
+
|
| 148 |
+
# Merchant-level check first (dining chains we track).
|
| 149 |
+
if kind == "dining" and brand_key in MERCHANT_PLATFORMS:
|
| 150 |
+
listed = MERCHANT_PLATFORMS[brand_key]
|
| 151 |
+
cities = listed.get(channel_key)
|
| 152 |
+
if cities is None:
|
| 153 |
+
return "unavailable" # chain known, not listed on this platform
|
| 154 |
+
if cities != _ALL:
|
| 155 |
+
if city is None:
|
| 156 |
+
return "unknown" # listed somewhere, can't confirm this city
|
| 157 |
+
return "available" if city in cities else "unavailable"
|
| 158 |
+
# cities == "*": fall through to the platform's own city coverage
|
| 159 |
+
|
| 160 |
+
cov = PLATFORM_CITIES.get((channel_key, kind))
|
| 161 |
+
if cov is None:
|
| 162 |
+
return "unknown"
|
| 163 |
+
if cov == _ALL:
|
| 164 |
+
return "available"
|
| 165 |
+
if city is None:
|
| 166 |
+
return "unknown"
|
| 167 |
+
return "available" if city in cov else "unavailable"
|
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RewardPilot - Booking-channel comparison
|
| 3 |
+
========================================
|
| 4 |
+
"Which card?" is only half the answer. For dine-in, movies and travel the same
|
| 5 |
+
purchase can be paid through several apps (Swiggy Dineout vs District vs paying
|
| 6 |
+
at the table; BookMyShow vs District vs the box office; MakeMyTrip vs
|
| 7 |
+
EaseMyTrip vs the airline site) - and the best card DIFFERS per channel because
|
| 8 |
+
each platform carries its own bank + platform offers.
|
| 9 |
+
|
| 10 |
+
This module reuses the deterministic scoring engine once per channel and
|
| 11 |
+
returns a ranked channel matrix: for each channel, the best held card and the
|
| 12 |
+
total rupee benefit (card rewards + instant offers on that channel).
|
| 13 |
+
|
| 14 |
+
Mirrored on-device in app/src/lib/channels.ts.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from typing import Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
from availability import channel_availability
|
| 20 |
+
from card_catalogue import CATALOGUE_BY_ID
|
| 21 |
+
from offers import active_offers_for
|
| 22 |
+
from scoring_engine import TxnContext, score_transaction, _r2
|
| 23 |
+
|
| 24 |
+
# Brands that are themselves a booking platform: when the user's query resolved
|
| 25 |
+
# to one of these, the "direct" channel must NOT inherit it as a walk-in brand.
|
| 26 |
+
PLATFORM_BRANDS = {
|
| 27 |
+
"dineout", "district", "bookmyshow",
|
| 28 |
+
"makemytrip", "goibibo", "cleartrip", "yatra", "ixigo", "easemytrip",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
# Delivery aggregators: ordering in is a different purchase from dining out, so
|
| 32 |
+
# no channel comparison there.
|
| 33 |
+
DELIVERY_BRANDS = {"swiggy", "zomato"}
|
| 34 |
+
|
| 35 |
+
DINING_CHANNELS = [
|
| 36 |
+
{"key": "district", "name": "District (Zomato)", "brand_key": "district", "channel": "online",
|
| 37 |
+
"how": "Book the table / pay the bill in the District app", "url": "https://www.district.in"},
|
| 38 |
+
{"key": "dineout", "name": "Swiggy Dineout", "brand_key": "dineout", "channel": "online",
|
| 39 |
+
"how": "Pay the bill through Swiggy Dineout", "url": "https://www.swiggy.com/dineout"},
|
| 40 |
+
{"key": "direct", "name": "Pay at the restaurant", "brand_key": None, "channel": "offline",
|
| 41 |
+
"how": "Swipe / tap your card at the table", "url": None},
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
MOVIE_CHANNELS = [
|
| 45 |
+
{"key": "bookmyshow", "name": "BookMyShow", "brand_key": "bookmyshow", "channel": "online",
|
| 46 |
+
"how": "Book tickets on BookMyShow", "url": "https://in.bookmyshow.com"},
|
| 47 |
+
{"key": "district", "name": "District (Zomato)", "brand_key": "district", "channel": "online",
|
| 48 |
+
"how": "Book tickets in the District app", "url": "https://www.district.in/movies"},
|
| 49 |
+
{"key": "direct", "name": "Cinema box office / site", "brand_key": None, "channel": "offline",
|
| 50 |
+
"how": "Buy at the counter or the cinema's own site", "url": None},
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
FLIGHT_CHANNELS = [
|
| 54 |
+
{"key": "makemytrip", "name": "MakeMyTrip", "brand_key": "makemytrip", "channel": "online",
|
| 55 |
+
"how": "Book on MakeMyTrip", "url": "https://www.makemytrip.com/flights/"},
|
| 56 |
+
{"key": "easemytrip", "name": "EaseMyTrip", "brand_key": "easemytrip", "channel": "online",
|
| 57 |
+
"how": "Book on EaseMyTrip", "url": "https://www.easemytrip.com"},
|
| 58 |
+
{"key": "goibibo", "name": "Goibibo", "brand_key": "goibibo", "channel": "online",
|
| 59 |
+
"how": "Book on Goibibo", "url": "https://www.goibibo.com/flights/"},
|
| 60 |
+
{"key": "cleartrip", "name": "Cleartrip", "brand_key": "cleartrip", "channel": "online",
|
| 61 |
+
"how": "Book on Cleartrip", "url": "https://www.cleartrip.com"},
|
| 62 |
+
{"key": "direct", "name": "Airline website", "brand_key": None, "channel": "online",
|
| 63 |
+
"how": "Book directly with the airline", "url": None},
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
HOTEL_CHANNELS = [
|
| 67 |
+
{"key": "makemytrip", "name": "MakeMyTrip", "brand_key": "makemytrip", "channel": "online",
|
| 68 |
+
"how": "Book on MakeMyTrip", "url": "https://www.makemytrip.com/hotels/"},
|
| 69 |
+
{"key": "goibibo", "name": "Goibibo", "brand_key": "goibibo", "channel": "online",
|
| 70 |
+
"how": "Book on Goibibo", "url": "https://www.goibibo.com/hotels/"},
|
| 71 |
+
{"key": "easemytrip", "name": "EaseMyTrip", "brand_key": "easemytrip", "channel": "online",
|
| 72 |
+
"how": "Book on EaseMyTrip", "url": "https://www.easemytrip.com/hotels/"},
|
| 73 |
+
{"key": "direct", "name": "Hotel directly", "brand_key": None, "channel": "online",
|
| 74 |
+
"how": "Book with the hotel / pay at check-in", "url": None},
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
# Issuer portal booking sites (behind login; the portal decides final routing).
|
| 78 |
+
PORTAL_URLS = {
|
| 79 |
+
"HDFC SmartBuy": "https://offers.smartbuy.hdfcbank.com",
|
| 80 |
+
"Axis Travel Edge": "https://traveledge.axisbank.co.in",
|
| 81 |
+
"ICICI iShop": "https://ishop.icicibank.com",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# Issuer booking portals (HDFC SmartBuy, Axis Travel Edge, ICICI iShop) are a
|
| 85 |
+
# further channel: elevated portal-only rates, but only for holders of that
|
| 86 |
+
# issuer's portal-rated cards, capped monthly, paid in points (assumed redeemed
|
| 87 |
+
# at the portal point value). Synthesized per wallet, pan-India online.
|
| 88 |
+
PORTAL_KEYS = {"HDFC SmartBuy": "smartbuy", "Axis Travel Edge": "travel_edge", "ICICI iShop": "ishop"}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _portal_value(card, category: str, amount: float) -> float:
|
| 92 |
+
"""INR value of booking this category via the card's issuer portal (0 if none)."""
|
| 93 |
+
rate = (card.portal_rates or {}).get(category)
|
| 94 |
+
if not rate:
|
| 95 |
+
return 0.0
|
| 96 |
+
pv = card.portal_point_value_inr if card.portal_point_value_inr is not None else card.point_value_inr
|
| 97 |
+
units = amount / 100.0 * rate
|
| 98 |
+
if card.portal_cap_units is not None:
|
| 99 |
+
units = min(units, card.portal_cap_units)
|
| 100 |
+
return _r2(units * pv)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _portal_options(held_card_ids: List[str], category: str, amount: float) -> List[Dict]:
|
| 104 |
+
best: Dict[str, tuple] = {} # portal name -> (card_id, value, card_name)
|
| 105 |
+
for cid in held_card_ids:
|
| 106 |
+
card = CATALOGUE_BY_ID.get(cid)
|
| 107 |
+
if not card or not card.portal_name:
|
| 108 |
+
continue
|
| 109 |
+
v = _portal_value(card, category, amount)
|
| 110 |
+
if v <= 0:
|
| 111 |
+
continue
|
| 112 |
+
cur = best.get(card.portal_name)
|
| 113 |
+
if cur is None or v > cur[1]:
|
| 114 |
+
best[card.portal_name] = (cid, v, card.name)
|
| 115 |
+
opts = []
|
| 116 |
+
for pname, (cid, v, cname) in best.items():
|
| 117 |
+
opts.append({
|
| 118 |
+
"channel_key": PORTAL_KEYS.get(pname, pname.lower().replace(" ", "_")),
|
| 119 |
+
"channel_name": pname,
|
| 120 |
+
"how": f"Book inside {pname} paying with {cname}",
|
| 121 |
+
"best_card_id": cid,
|
| 122 |
+
"best_card_name": cname,
|
| 123 |
+
"reward_inr": v,
|
| 124 |
+
"instant_offer_inr": 0.0,
|
| 125 |
+
"total_benefit_inr": v,
|
| 126 |
+
"effective_pct": _r2(v / amount * 100.0) if amount else 0.0,
|
| 127 |
+
"availability": "available",
|
| 128 |
+
"via_portal": True,
|
| 129 |
+
"book_url": PORTAL_URLS.get(pname),
|
| 130 |
+
})
|
| 131 |
+
return opts
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _channels_for(category: str, brand_key: Optional[str]):
|
| 135 |
+
if category == "dining" and brand_key not in DELIVERY_BRANDS:
|
| 136 |
+
return "dining", DINING_CHANNELS
|
| 137 |
+
if category == "entertainment" and brand_key in (None, "bookmyshow", "pvr", "cinepolis", "district"):
|
| 138 |
+
return "movies", MOVIE_CHANNELS
|
| 139 |
+
if category == "travel_flights":
|
| 140 |
+
return "flights", FLIGHT_CHANNELS
|
| 141 |
+
if category == "travel_hotels":
|
| 142 |
+
return "hotels", HOTEL_CHANNELS
|
| 143 |
+
return None, None
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def compare_channels(
|
| 147 |
+
held_card_ids: List[str],
|
| 148 |
+
category: str,
|
| 149 |
+
amount: float,
|
| 150 |
+
brand_key: Optional[str] = None,
|
| 151 |
+
merchant: Optional[str] = None,
|
| 152 |
+
rail: str = "card",
|
| 153 |
+
persona: Optional[List[str]] = None,
|
| 154 |
+
city: Optional[str] = None,
|
| 155 |
+
) -> Optional[Dict]:
|
| 156 |
+
"""
|
| 157 |
+
Best card PER booking channel, channels ranked by total rupee benefit.
|
| 158 |
+
When the city is known, channels confirmed absent there are dropped and
|
| 159 |
+
unverified listings are flagged. Returns None when the category has no
|
| 160 |
+
channel choice (or on the UPI rail, where instant offers don't apply and
|
| 161 |
+
the comparison collapses).
|
| 162 |
+
"""
|
| 163 |
+
if rail == "upi" or not held_card_ids:
|
| 164 |
+
return None
|
| 165 |
+
kind, plan = _channels_for(category, brand_key)
|
| 166 |
+
if not plan:
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
options = []
|
| 170 |
+
for ch in plan:
|
| 171 |
+
avail = channel_availability(ch["key"], kind, city, brand_key)
|
| 172 |
+
if avail == "unavailable":
|
| 173 |
+
continue
|
| 174 |
+
# Platform channels score against the platform's offers; the direct
|
| 175 |
+
# channel keeps the resolved walk-in brand (e.g. Copper Chimney's own
|
| 176 |
+
# card-linked offer) unless that brand is itself a platform.
|
| 177 |
+
if ch["brand_key"] is not None:
|
| 178 |
+
bk = ch["brand_key"]
|
| 179 |
+
else:
|
| 180 |
+
bk = None if brand_key in PLATFORM_BRANDS else brand_key
|
| 181 |
+
ctx = TxnContext(
|
| 182 |
+
category=category, amount=float(amount), merchant=merchant,
|
| 183 |
+
brand_key=bk, offers=active_offers_for(bk), rail="card",
|
| 184 |
+
channel=ch["channel"],
|
| 185 |
+
)
|
| 186 |
+
res = score_transaction(held_card_ids, ctx, include_discovery=False, persona=persona)
|
| 187 |
+
ranked = res["held_ranked"]
|
| 188 |
+
if not ranked:
|
| 189 |
+
continue
|
| 190 |
+
top = ranked[0]
|
| 191 |
+
options.append({
|
| 192 |
+
"channel_key": ch["key"],
|
| 193 |
+
"channel_name": ch["name"],
|
| 194 |
+
"how": ch["how"],
|
| 195 |
+
"best_card_id": top["card_id"],
|
| 196 |
+
"best_card_name": top["card_name"],
|
| 197 |
+
"reward_inr": top["reward_value_inr"],
|
| 198 |
+
"instant_offer_inr": top["instant_offer_inr"],
|
| 199 |
+
"total_benefit_inr": top["total_value_inr"],
|
| 200 |
+
"effective_pct": top["effective_rate_pct"],
|
| 201 |
+
"availability": avail,
|
| 202 |
+
"via_portal": False,
|
| 203 |
+
"book_url": ch.get("url"),
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
# Issuer portals the user can actually use (portal-rated card in wallet).
|
| 207 |
+
portal_opts = _portal_options(held_card_ids, category, float(amount))
|
| 208 |
+
options.extend(portal_opts)
|
| 209 |
+
|
| 210 |
+
if len(options) < 2:
|
| 211 |
+
return None
|
| 212 |
+
options.sort(key=lambda o: -o["total_benefit_inr"])
|
| 213 |
+
best, second = options[0], options[1]
|
| 214 |
+
if city:
|
| 215 |
+
note = (f"Channels confirmed absent in {city.title()} are hidden; "
|
| 216 |
+
"listings we couldn't verify are flagged. Platform deals vary by city.")
|
| 217 |
+
else:
|
| 218 |
+
note = ("Assumes this merchant is available on each platform in your city; "
|
| 219 |
+
"platform availability and platform-funded deals vary.")
|
| 220 |
+
if portal_opts:
|
| 221 |
+
note += " Issuer-portal options earn points, assumed redeemed at full value."
|
| 222 |
+
return {
|
| 223 |
+
"kind": kind,
|
| 224 |
+
"options": options,
|
| 225 |
+
"best_channel": best["channel_key"],
|
| 226 |
+
"best_channel_name": best["channel_name"],
|
| 227 |
+
"delta_vs_next_inr": round(best["total_benefit_inr"] - second["total_benefit_inr"], 2),
|
| 228 |
+
"city": city,
|
| 229 |
+
"note": note,
|
| 230 |
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RewardPilot offer-ingestion connectors.
|
| 2 |
+
|
| 3 |
+
Three source families feed the offers store, all normalised to one schema and
|
| 4 |
+
gated by human QA before going live:
|
| 5 |
+
|
| 6 |
+
1. issuer_scrapers - HDFC SmartBuy, Amex Offers, SBI, Axis offer pages
|
| 7 |
+
2. clo - card-linked-offer networks (Fidel / Kard), the live feed
|
| 8 |
+
3. affiliate_feeds - CashKaro / GrabOn / bank-partner feeds
|
| 9 |
+
|
| 10 |
+
ingest.py orchestrates: gather -> normalise -> dedupe -> review queue -> approve
|
| 11 |
+
-> curated_offers.json (which offers.py loads on top of the seed).
|
| 12 |
+
"""
|
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Affiliate / coupon feed connectors - CashKaro, GrabOn, bank-partner feeds.
|
| 3 |
+
|
| 4 |
+
These provide bank-offer + coupon data (often via affiliate-network product
|
| 5 |
+
feeds or partner APIs) and also tie into the affiliate-revenue line. Real pulls
|
| 6 |
+
require a publisher key per network (CASHKARO_FEED_URL / GRABON_API_KEY); without
|
| 7 |
+
them the connectors return representative samples so the pipeline runs.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
from datetime import date, timedelta
|
| 12 |
+
from typing import List
|
| 13 |
+
|
| 14 |
+
from .base import RawOffer
|
| 15 |
+
|
| 16 |
+
_today = date.today().isoformat()
|
| 17 |
+
_in45 = (date.today() + timedelta(days=45)).isoformat()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def fetch_cashkaro() -> List[RawOffer]:
|
| 21 |
+
url = os.getenv("CASHKARO_FEED_URL")
|
| 22 |
+
if url:
|
| 23 |
+
try:
|
| 24 |
+
import httpx
|
| 25 |
+
rows = httpx.get(url, timeout=15).json()
|
| 26 |
+
return [_normalize_feed_row(r, "affiliate:cashkaro") for r in rows]
|
| 27 |
+
except Exception:
|
| 28 |
+
return []
|
| 29 |
+
return [
|
| 30 |
+
RawOffer("affiliate:cashkaro", "amazon", "Amazon", "pct", 10, _today, _in45,
|
| 31 |
+
applies_to_issuer="SBI Card", min_spend=5000, max_discount=1500,
|
| 32 |
+
channel="online", raw_text="SBI 10% off on Amazon (CashKaro)"),
|
| 33 |
+
RawOffer("affiliate:cashkaro", "myntra", "Myntra", "pct", 10, _today, _in45,
|
| 34 |
+
applies_to_issuer="HDFC Bank", min_spend=3000, max_discount=1000,
|
| 35 |
+
channel="online", categories=["apparel"], raw_text="HDFC 10% on Myntra"),
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def fetch_grabon() -> List[RawOffer]:
|
| 40 |
+
if os.getenv("GRABON_API_KEY"):
|
| 41 |
+
return [] # real API mapping goes here
|
| 42 |
+
return [
|
| 43 |
+
RawOffer("affiliate:grabon", "oyo", "OYO", "pct", 15, _today, _in45,
|
| 44 |
+
applies_to_issuer="HDFC Bank", max_discount=1000, channel="online",
|
| 45 |
+
categories=["travel_hotels"], raw_text="15% off OYO with HDFC"),
|
| 46 |
+
RawOffer("affiliate:grabon", "nykaa", "Nykaa", "pct", 10, _today, _in45,
|
| 47 |
+
applies_to_issuer="SBI Card", min_spend=2500, max_discount=750,
|
| 48 |
+
channel="online", raw_text="10% off Nykaa with SBI"),
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _normalize_feed_row(row: dict, source: str) -> RawOffer:
|
| 53 |
+
return RawOffer(
|
| 54 |
+
source=source,
|
| 55 |
+
merchant_key=row.get("merchant_key", row.get("store", "")).lower().replace(" ", "_"),
|
| 56 |
+
merchant_name=row.get("store", ""),
|
| 57 |
+
type=row.get("type", "pct"),
|
| 58 |
+
value=float(row.get("value", 0)),
|
| 59 |
+
valid_from=row.get("valid_from", _today)[:10],
|
| 60 |
+
valid_to=row.get("valid_to", _in45)[:10],
|
| 61 |
+
applies_to_issuer=row.get("issuer", ""),
|
| 62 |
+
min_spend=float(row.get("min_spend", 0) or 0),
|
| 63 |
+
max_discount=(float(row["max_discount"]) if row.get("max_discount") else None),
|
| 64 |
+
channel=row.get("channel", "online"),
|
| 65 |
+
raw_text=row.get("title", ""),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
ALL_AFFILIATE_FETCHERS = [("cashkaro", fetch_cashkaro), ("grabon", fetch_grabon)]
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared types + normalisation for all offer connectors."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field, asdict
|
| 4 |
+
from datetime import date
|
| 5 |
+
from typing import List, Optional, Dict
|
| 6 |
+
import hashlib
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class RawOffer:
|
| 11 |
+
"""What a connector emits before normalisation/QA."""
|
| 12 |
+
source: str # issuer:hdfc_smartbuy | clo:fidel | affiliate:cashkaro
|
| 13 |
+
merchant_key: str
|
| 14 |
+
merchant_name: str
|
| 15 |
+
type: str # pct | flat | cashback | nocost_emi
|
| 16 |
+
value: float
|
| 17 |
+
valid_from: str
|
| 18 |
+
valid_to: str
|
| 19 |
+
applies_to_issuer: str = "" # "" if card-specific or any
|
| 20 |
+
card_id: Optional[str] = None
|
| 21 |
+
min_spend: float = 0.0
|
| 22 |
+
max_discount: Optional[float] = None
|
| 23 |
+
categories: Optional[List[str]] = None
|
| 24 |
+
channel: str = "both"
|
| 25 |
+
raw_text: str = "" # original text, kept for the QA reviewer
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def dedupe_key(r: RawOffer) -> str:
|
| 29 |
+
"""Two offers from different sources for the same merchant+issuer+type collapse."""
|
| 30 |
+
basis = f"{r.merchant_key}|{r.applies_to_issuer}|{r.card_id}|{r.type}|{r.value}|{r.valid_to}"
|
| 31 |
+
return hashlib.sha1(basis.encode()).hexdigest()[:12]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def normalize(r: RawOffer) -> Dict:
|
| 35 |
+
"""RawOffer -> the offers.py Offer dict shape, with QA + provenance fields."""
|
| 36 |
+
oid = "of_" + dedupe_key(r)
|
| 37 |
+
return {
|
| 38 |
+
"id": oid,
|
| 39 |
+
"merchant_key": r.merchant_key,
|
| 40 |
+
"merchant_name": r.merchant_name,
|
| 41 |
+
"applies_to_issuer": r.applies_to_issuer,
|
| 42 |
+
"card_id": r.card_id,
|
| 43 |
+
"type": r.type,
|
| 44 |
+
"value": r.value,
|
| 45 |
+
"min_spend": r.min_spend,
|
| 46 |
+
"max_discount": r.max_discount,
|
| 47 |
+
"categories": r.categories,
|
| 48 |
+
"channel": r.channel,
|
| 49 |
+
"valid_from": r.valid_from,
|
| 50 |
+
"valid_to": r.valid_to,
|
| 51 |
+
"source": r.source,
|
| 52 |
+
"last_verified": date.today().isoformat(),
|
| 53 |
+
# QA metadata (stripped before serving to the app)
|
| 54 |
+
"_status": "pending", # pending | approved | rejected
|
| 55 |
+
"_raw_text": r.raw_text,
|
| 56 |
+
"_dedupe": dedupe_key(r),
|
| 57 |
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Card-linked-offer (CLO) connectors - Fidel / Kard style.
|
| 3 |
+
|
| 4 |
+
CLO networks are the closest thing to a real-time feed and double as a revenue
|
| 5 |
+
line (merchant-funded, you earn on redemption). They expose programmatic offer
|
| 6 |
+
catalogues keyed by merchant + card network, and webhooks for redemption events.
|
| 7 |
+
|
| 8 |
+
These clients call the real API only when the relevant key is set
|
| 9 |
+
(FIDEL_API_KEY / KARD_API_KEY); otherwise they no-op and return samples so the
|
| 10 |
+
pipeline runs. The response-normalisation (the part that matters) is real.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
from datetime import date, timedelta
|
| 15 |
+
from typing import List
|
| 16 |
+
|
| 17 |
+
from .base import RawOffer
|
| 18 |
+
|
| 19 |
+
_today = date.today().isoformat()
|
| 20 |
+
_in60 = (date.today() + timedelta(days=60)).isoformat()
|
| 21 |
+
|
| 22 |
+
FIDEL_API_KEY = os.getenv("FIDEL_API_KEY")
|
| 23 |
+
KARD_API_KEY = os.getenv("KARD_API_KEY")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _normalize_fidel(item: dict) -> RawOffer:
|
| 27 |
+
"""Map a Fidel offer object to a RawOffer (field names per Fidel's schema)."""
|
| 28 |
+
return RawOffer(
|
| 29 |
+
source="clo:fidel",
|
| 30 |
+
merchant_key=item.get("brandKey", item.get("merchant", "")).lower().replace(" ", "_"),
|
| 31 |
+
merchant_name=item.get("brandName", item.get("merchant", "")),
|
| 32 |
+
type="cashback" if item.get("type", "discount") in ("cashback", "discount") else "pct",
|
| 33 |
+
value=float(item.get("returnRatePercentage", item.get("amount", 0))),
|
| 34 |
+
valid_from=item.get("startDate", _today)[:10],
|
| 35 |
+
valid_to=item.get("endDate", _in60)[:10],
|
| 36 |
+
applies_to_issuer=item.get("issuer", ""),
|
| 37 |
+
card_id=item.get("cardId"),
|
| 38 |
+
min_spend=float(item.get("minTransactionAmount", 0) or 0),
|
| 39 |
+
max_discount=(float(item["maxReward"]) if item.get("maxReward") else None),
|
| 40 |
+
channel=item.get("channel", "both"),
|
| 41 |
+
raw_text=item.get("description", ""),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def fetch_fidel() -> List[RawOffer]:
|
| 46 |
+
if FIDEL_API_KEY:
|
| 47 |
+
try:
|
| 48 |
+
import httpx
|
| 49 |
+
r = httpx.get("https://api.fidel.uk/v1/offers",
|
| 50 |
+
headers={"Fidel-Key": FIDEL_API_KEY}, timeout=15)
|
| 51 |
+
return [_normalize_fidel(o) for o in r.json().get("items", [])]
|
| 52 |
+
except Exception:
|
| 53 |
+
return []
|
| 54 |
+
# sample (no key configured)
|
| 55 |
+
return [
|
| 56 |
+
RawOffer("clo:fidel", "amazon", "Amazon", "cashback", 5, _today, _in60,
|
| 57 |
+
applies_to_issuer="", card_id="icici_amazon_pay", channel="online",
|
| 58 |
+
raw_text="5% back at Amazon (CLO)"),
|
| 59 |
+
RawOffer("clo:fidel", "dmart", "DMart", "pct", 5, _today, _in60,
|
| 60 |
+
applies_to_issuer="ICICI Bank", max_discount=300, channel="offline",
|
| 61 |
+
categories=["groceries"], raw_text="5% back at DMart (CLO)"),
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def fetch_kard() -> List[RawOffer]:
|
| 66 |
+
if KARD_API_KEY:
|
| 67 |
+
try:
|
| 68 |
+
import httpx
|
| 69 |
+
r = httpx.get("https://api.getkard.com/offers",
|
| 70 |
+
headers={"Authorization": f"Bearer {KARD_API_KEY}"}, timeout=15)
|
| 71 |
+
# (Kard's schema differs; map similarly to _normalize_fidel.)
|
| 72 |
+
return []
|
| 73 |
+
except Exception:
|
| 74 |
+
return []
|
| 75 |
+
return [
|
| 76 |
+
RawOffer("clo:kard", "pharmeasy", "PharmEasy", "pct", 15, _today, _in60,
|
| 77 |
+
applies_to_issuer="Axis Bank", max_discount=300, channel="online",
|
| 78 |
+
categories=["pharmacy"], raw_text="15% back at PharmEasy (CLO)"),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
ALL_CLO_FETCHERS = [("fidel", fetch_fidel), ("kard", fetch_kard)]
|
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ingestion orchestrator + QA review gate.
|
| 3 |
+
|
| 4 |
+
Flow:
|
| 5 |
+
gather() run every connector (issuer + CLO + affiliate), isolate failures
|
| 6 |
+
normalize() -> Offer dicts, dedupe across sources
|
| 7 |
+
write_queue() -> offers_data/review_queue.json (status = pending)
|
| 8 |
+
approve(ids) move approved offers -> offers_data/curated_offers.json
|
| 9 |
+
(offers.py loads this on top of the curated seed and serves it)
|
| 10 |
+
|
| 11 |
+
Run a full cycle:
|
| 12 |
+
python -m connectors.ingest # gather + queue
|
| 13 |
+
python -m connectors.ingest --approve # gather + auto-approve everything (demo)
|
| 14 |
+
|
| 15 |
+
In production `approve` is driven by a human reviewer (an internal console),
|
| 16 |
+
never auto, so a bad scrape can't reach users.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
from typing import Dict, List
|
| 23 |
+
|
| 24 |
+
from .base import RawOffer, normalize
|
| 25 |
+
from . import issuer_scrapers, clo, affiliate_feeds
|
| 26 |
+
|
| 27 |
+
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "offers_data")
|
| 28 |
+
QUEUE_PATH = os.path.join(DATA_DIR, "review_queue.json")
|
| 29 |
+
CURATED_PATH = os.path.join(DATA_DIR, "curated_offers.json")
|
| 30 |
+
|
| 31 |
+
ALL_FETCHERS = (
|
| 32 |
+
[(f"issuer:{n}", fn) for n, fn in issuer_scrapers.ALL_ISSUER_FETCHERS]
|
| 33 |
+
+ [(f"clo:{n}", fn) for n, fn in clo.ALL_CLO_FETCHERS]
|
| 34 |
+
+ [(f"affiliate:{n}", fn) for n, fn in affiliate_feeds.ALL_AFFILIATE_FETCHERS]
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def gather() -> List[RawOffer]:
|
| 39 |
+
"""Run every connector. A failure in one source never blocks the others."""
|
| 40 |
+
out: List[RawOffer] = []
|
| 41 |
+
for name, fn in ALL_FETCHERS:
|
| 42 |
+
try:
|
| 43 |
+
got = fn() or []
|
| 44 |
+
out.extend(got)
|
| 45 |
+
print(f" [{name}] {len(got)} offers")
|
| 46 |
+
except Exception as e: # pragma: no cover - defensive
|
| 47 |
+
print(f" [{name}] FAILED: {e}")
|
| 48 |
+
return out
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def normalize_dedupe(raws: List[RawOffer]) -> Dict[str, dict]:
|
| 52 |
+
by_dedupe: Dict[str, dict] = {}
|
| 53 |
+
for r in raws:
|
| 54 |
+
o = normalize(r)
|
| 55 |
+
key = o["_dedupe"]
|
| 56 |
+
# keep the higher-value offer when two sources collide
|
| 57 |
+
if key not in by_dedupe or (o["value"] or 0) > (by_dedupe[key]["value"] or 0):
|
| 58 |
+
by_dedupe[key] = o
|
| 59 |
+
return {o["id"]: o for o in by_dedupe.values()}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _ensure_dir():
|
| 63 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def write_queue(offers: Dict[str, dict]):
|
| 67 |
+
_ensure_dir()
|
| 68 |
+
with open(QUEUE_PATH, "w") as f:
|
| 69 |
+
json.dump(list(offers.values()), f, indent=2)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def load_queue() -> List[dict]:
|
| 73 |
+
if not os.path.exists(QUEUE_PATH):
|
| 74 |
+
return []
|
| 75 |
+
with open(QUEUE_PATH) as f:
|
| 76 |
+
return json.load(f)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def approve(ids=None):
|
| 80 |
+
"""Move approved offers from the queue into the live curated file."""
|
| 81 |
+
queue = load_queue()
|
| 82 |
+
approved = [o for o in queue if (ids is None or o["id"] in ids)]
|
| 83 |
+
# strip QA-internal fields before serving
|
| 84 |
+
clean = []
|
| 85 |
+
for o in approved:
|
| 86 |
+
c = {k: v for k, v in o.items() if not k.startswith("_")}
|
| 87 |
+
clean.append(c)
|
| 88 |
+
_ensure_dir()
|
| 89 |
+
existing = []
|
| 90 |
+
if os.path.exists(CURATED_PATH):
|
| 91 |
+
with open(CURATED_PATH) as f:
|
| 92 |
+
existing = json.load(f)
|
| 93 |
+
by_id = {o["id"]: o for o in existing}
|
| 94 |
+
for o in clean:
|
| 95 |
+
by_id[o["id"]] = o
|
| 96 |
+
with open(CURATED_PATH, "w") as f:
|
| 97 |
+
json.dump(list(by_id.values()), f, indent=2)
|
| 98 |
+
return len(clean)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def run(auto_approve: bool = False):
|
| 102 |
+
print("Gathering from", len(ALL_FETCHERS), "connectors...")
|
| 103 |
+
raws = gather()
|
| 104 |
+
offers = normalize_dedupe(raws)
|
| 105 |
+
write_queue(offers)
|
| 106 |
+
print(f"Normalised + deduped -> {len(offers)} offers queued for review ({QUEUE_PATH}).")
|
| 107 |
+
if auto_approve:
|
| 108 |
+
n = approve()
|
| 109 |
+
print(f"Auto-approved {n} offers -> live ({CURATED_PATH}).")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
run(auto_approve="--approve" in sys.argv)
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Issuer offer-page scrapers - HDFC SmartBuy, Amex Offers, SBI, Axis.
|
| 3 |
+
|
| 4 |
+
Production: fetch each issuer's live offers page with a managed scraping layer
|
| 5 |
+
(Bright Data / Playwright), extract the structured fields, and emit RawOffers.
|
| 6 |
+
The selectors/parse live in each `_parse_*` function. Until the live fetch is
|
| 7 |
+
wired (it needs a scraping provider + per-issuer auth for personalised Amex/SBI
|
| 8 |
+
offers), `fetch_*` return a small set of representative samples so the pipeline
|
| 9 |
+
runs end to end.
|
| 10 |
+
|
| 11 |
+
Each scraper is best-effort and isolated: a failure in one issuer never blocks
|
| 12 |
+
the others (see ingest.gather).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
from datetime import date, timedelta
|
| 17 |
+
from typing import List
|
| 18 |
+
|
| 19 |
+
from .base import RawOffer
|
| 20 |
+
|
| 21 |
+
_today = date.today().isoformat()
|
| 22 |
+
_in90 = (date.today() + timedelta(days=90)).isoformat()
|
| 23 |
+
|
| 24 |
+
# Set to "1" to attempt live fetches (requires SCRAPER_PROVIDER + creds).
|
| 25 |
+
LIVE = os.getenv("SCRAPER_LIVE", "0") == "1"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _live_fetch(url: str) -> str:
|
| 29 |
+
"""Hook for the managed scraping provider. Returns HTML, or '' if unavailable.
|
| 30 |
+
|
| 31 |
+
Intentionally a no-op unless SCRAPER_LIVE=1 and a provider key is present, so
|
| 32 |
+
the repo never depends on network access or hard-codes scraping logic that
|
| 33 |
+
could break. Wire Bright Data / Playwright here.
|
| 34 |
+
"""
|
| 35 |
+
if not LIVE:
|
| 36 |
+
return ""
|
| 37 |
+
# TODO: provider = BrightData(api_key=os.environ["BRIGHTDATA_KEY"]); return provider.get(url)
|
| 38 |
+
return ""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# --- HDFC SmartBuy ---------------------------------------------------------
|
| 42 |
+
HDFC_OFFERS_URL = "https://offers.smartbuy.hdfcbank.com/"
|
| 43 |
+
|
| 44 |
+
def _parse_hdfc_smartbuy(html: str) -> List[RawOffer]:
|
| 45 |
+
# TODO: real selectors -> RawOffer. Sample representative output below.
|
| 46 |
+
return [
|
| 47 |
+
RawOffer("issuer:hdfc_smartbuy", "croma", "Croma", "flat", 5000, _today, _in90,
|
| 48 |
+
applies_to_issuer="HDFC Bank", min_spend=50000, channel="both",
|
| 49 |
+
raw_text="₹5000 off on Croma with HDFC cards, min ₹50,000"),
|
| 50 |
+
RawOffer("issuer:hdfc_smartbuy", "myntra", "Myntra", "pct", 10, _today, _in90,
|
| 51 |
+
applies_to_issuer="HDFC Bank", min_spend=3000, max_discount=1000,
|
| 52 |
+
channel="online", categories=["apparel"], raw_text="10% off Myntra, up to ₹1000"),
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
def fetch_hdfc_smartbuy() -> List[RawOffer]:
|
| 56 |
+
return _parse_hdfc_smartbuy(_live_fetch(HDFC_OFFERS_URL))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# --- Amex Offers (personalised; needs cardholder auth) ---------------------
|
| 60 |
+
def fetch_amex_offers() -> List[RawOffer]:
|
| 61 |
+
# Amex Offers are per-cardholder; production pulls them post-consent via the
|
| 62 |
+
# member's account. Sample below.
|
| 63 |
+
return [
|
| 64 |
+
RawOffer("issuer:amex_offers", "starbucks", "Starbucks", "cashback", 15, _today, _in90,
|
| 65 |
+
applies_to_issuer="American Express", max_discount=150, channel="offline",
|
| 66 |
+
categories=["dining"], raw_text="Spend ₹500 get 15% back at Starbucks"),
|
| 67 |
+
RawOffer("issuer:amex_offers", "zomato", "Zomato", "cashback", 20, _today, _in90,
|
| 68 |
+
applies_to_issuer="American Express", min_spend=500, max_discount=200,
|
| 69 |
+
channel="online", categories=["dining"], raw_text="20% back on Zomato up to ₹200"),
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# --- SBI Card offers -------------------------------------------------------
|
| 74 |
+
def fetch_sbi_offers() -> List[RawOffer]:
|
| 75 |
+
return [
|
| 76 |
+
RawOffer("issuer:sbi", "bigbasket", "BigBasket", "pct", 5, _today, _in90,
|
| 77 |
+
applies_to_issuer="SBI Card", min_spend=1500, max_discount=500,
|
| 78 |
+
channel="online", categories=["groceries"], raw_text="5% off BigBasket"),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# --- Axis Bank offers ------------------------------------------------------
|
| 83 |
+
def fetch_axis_offers() -> List[RawOffer]:
|
| 84 |
+
return [
|
| 85 |
+
RawOffer("issuer:axis", "reliance_digital", "Reliance Digital", "pct", 10, _today, _in90,
|
| 86 |
+
applies_to_issuer="Axis Bank", min_spend=30000, max_discount=5000,
|
| 87 |
+
channel="both", raw_text="10% off Reliance Digital, up to ₹5000"),
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
ALL_ISSUER_FETCHERS = [
|
| 92 |
+
("hdfc_smartbuy", fetch_hdfc_smartbuy),
|
| 93 |
+
("amex_offers", fetch_amex_offers),
|
| 94 |
+
("sbi", fetch_sbi_offers),
|
| 95 |
+
("axis", fetch_axis_offers),
|
| 96 |
+
]
|
|
@@ -32,7 +32,7 @@ try:
|
|
| 32 |
except ImportError:
|
| 33 |
pass
|
| 34 |
|
| 35 |
-
from fastapi import FastAPI, File, Form, UploadFile
|
| 36 |
from fastapi.middleware.cors import CORSMiddleware
|
| 37 |
from pydantic import BaseModel
|
| 38 |
|
|
@@ -42,6 +42,18 @@ from scoring_engine import TxnContext, score_transaction, analyse_transaction_hi
|
|
| 42 |
import llm
|
| 43 |
import demo_data
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
app = FastAPI(title="RewardPilot API", version="1.0.0")
|
| 46 |
app.add_middleware(
|
| 47 |
CORSMiddleware,
|
|
@@ -62,6 +74,9 @@ class RecommendRequest(BaseModel):
|
|
| 62 |
merchant: Optional[str] = None
|
| 63 |
persona: Optional[List[str]] = None
|
| 64 |
rail: Optional[str] = None # "card" | "upi"; if None, inferred from query
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
class AnalyzeRequest(BaseModel):
|
|
@@ -104,6 +119,46 @@ def cards():
|
|
| 104 |
return {"cards": [_card_public(c) for c in all_cards()]}
|
| 105 |
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
@app.get("/demo")
|
| 108 |
def demo():
|
| 109 |
"""Everything the app needs to demo with zero setup."""
|
|
@@ -114,7 +169,7 @@ def demo():
|
|
| 114 |
}
|
| 115 |
|
| 116 |
|
| 117 |
-
@app.post("/recommend")
|
| 118 |
def recommend(req: RecommendRequest):
|
| 119 |
held = req.held_cards or demo_data.DEMO_WALLET
|
| 120 |
persona = req.persona or demo_data.DEMO_PERSONA
|
|
@@ -143,11 +198,27 @@ def recommend(req: RecommendRequest):
|
|
| 143 |
result["narration"] = llm.narrate_recommendation(
|
| 144 |
result["context"], result["held_ranked"], result["discovery"]
|
| 145 |
)
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
return result
|
| 148 |
|
| 149 |
|
| 150 |
-
@app.post("/voice")
|
| 151 |
async def voice(audio: UploadFile = File(...), held_cards: Optional[str] = Form(None)):
|
| 152 |
raw = await audio.read()
|
| 153 |
transcript = llm.transcribe(raw, filename=audio.filename or "audio.m4a")
|
|
@@ -157,15 +228,14 @@ async def voice(audio: UploadFile = File(...), held_cards: Optional[str] = Form(
|
|
| 157 |
return rec
|
| 158 |
|
| 159 |
|
| 160 |
-
@app.post("/statements/parse")
|
| 161 |
async def parse_statements(
|
| 162 |
files: List[UploadFile] = File(...),
|
| 163 |
password: Optional[str] = Form(None),
|
| 164 |
):
|
| 165 |
-
from statement_parser import parse_statement
|
| 166 |
all_txns = []
|
| 167 |
errors = []
|
| 168 |
-
limits = []
|
| 169 |
for f in files:
|
| 170 |
try:
|
| 171 |
content = await f.read()
|
|
@@ -173,16 +243,9 @@ async def parse_statements(
|
|
| 173 |
for t in txns:
|
| 174 |
t["source_file"] = f.filename
|
| 175 |
all_txns.extend(txns)
|
| 176 |
-
try:
|
| 177 |
-
lim = detect_credit_limit(f.filename, content, password=password)
|
| 178 |
-
if lim:
|
| 179 |
-
limits.append(lim)
|
| 180 |
-
except Exception:
|
| 181 |
-
pass # credit-limit detection is best-effort, never fails the parse
|
| 182 |
except Exception as e:
|
| 183 |
errors.append({"file": f.filename, "error": str(e)})
|
| 184 |
-
return {"transactions": all_txns, "count": len(all_txns), "errors": errors
|
| 185 |
-
"credit_limit": max(limits) if limits else None}
|
| 186 |
|
| 187 |
|
| 188 |
@app.post("/transactions/analyze")
|
|
@@ -197,9 +260,10 @@ def discover(card_id: str):
|
|
| 197 |
card = get_card(card_id)
|
| 198 |
if not card:
|
| 199 |
return {"error": "card_not_found"}
|
|
|
|
| 200 |
return {
|
| 201 |
"card": _card_public(card),
|
| 202 |
-
"apply_url":
|
| 203 |
"extra_benefits": card.highlights,
|
| 204 |
"why": (
|
| 205 |
f"Based on your spending pattern, {card.name} would beat your current "
|
|
|
|
| 32 |
except ImportError:
|
| 33 |
pass
|
| 34 |
|
| 35 |
+
from fastapi import FastAPI, File, Form, UploadFile, Header, HTTPException, Depends
|
| 36 |
from fastapi.middleware.cors import CORSMiddleware
|
| 37 |
from pydantic import BaseModel
|
| 38 |
|
|
|
|
| 42 |
import llm
|
| 43 |
import demo_data
|
| 44 |
|
| 45 |
+
# Optional app-key gate. If APP_KEY is set as a Space secret, sensitive endpoints
|
| 46 |
+
# (statement upload, recommend, voice) require a matching X-App-Key header. If
|
| 47 |
+
# APP_KEY is unset (dev), they stay open. Basic abuse protection for the mobile
|
| 48 |
+
# client, not end-user authentication.
|
| 49 |
+
APP_KEY = os.getenv("APP_KEY")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def require_app_key(x_app_key: Optional[str] = Header(default=None, alias="X-App-Key")):
|
| 53 |
+
if APP_KEY and x_app_key != APP_KEY:
|
| 54 |
+
raise HTTPException(status_code=401, detail="invalid_app_key")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
app = FastAPI(title="RewardPilot API", version="1.0.0")
|
| 58 |
app.add_middleware(
|
| 59 |
CORSMiddleware,
|
|
|
|
| 74 |
merchant: Optional[str] = None
|
| 75 |
persona: Optional[List[str]] = None
|
| 76 |
rail: Optional[str] = None # "card" | "upi"; if None, inferred from query
|
| 77 |
+
city: Optional[str] = None # e.g. "Mumbai"; overrides query/GPS detection
|
| 78 |
+
lat: Optional[float] = None # GPS fallback for city detection
|
| 79 |
+
lng: Optional[float] = None
|
| 80 |
|
| 81 |
|
| 82 |
class AnalyzeRequest(BaseModel):
|
|
|
|
| 119 |
return {"cards": [_card_public(c) for c in all_cards()]}
|
| 120 |
|
| 121 |
|
| 122 |
+
@app.get("/offers")
|
| 123 |
+
def offers():
|
| 124 |
+
"""All currently-active offers (dated). Fed by the ingestion connectors."""
|
| 125 |
+
from offers import all_active
|
| 126 |
+
items = all_active()
|
| 127 |
+
return {"count": len(items), "offers": items}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@app.get("/offers/sources")
|
| 131 |
+
def offer_sources():
|
| 132 |
+
"""Live view of the ingestion pipeline: which connectors feed the offers
|
| 133 |
+
store and how many offers each currently contributes."""
|
| 134 |
+
from offers import all_active
|
| 135 |
+
by_source: dict = {}
|
| 136 |
+
for o in all_active():
|
| 137 |
+
by_source[o["source"]] = by_source.get(o["source"], 0) + 1
|
| 138 |
+
connectors = [
|
| 139 |
+
{"family": "issuer", "sources": ["hdfc_smartbuy", "amex_offers", "sbi", "axis"]},
|
| 140 |
+
{"family": "clo", "sources": ["fidel", "kard"]},
|
| 141 |
+
{"family": "affiliate", "sources": ["cashkaro", "grabon", "bank_partner"]},
|
| 142 |
+
{"family": "platform", "sources": ["dineout", "district"]},
|
| 143 |
+
]
|
| 144 |
+
return {"connectors": connectors, "active_by_source": by_source, "total_active": sum(by_source.values())}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@app.get("/offers/{merchant_key}")
|
| 148 |
+
def offers_for(merchant_key: str):
|
| 149 |
+
from offers import active_offers_for
|
| 150 |
+
items = active_offers_for(merchant_key)
|
| 151 |
+
return {"merchant_key": merchant_key, "count": len(items), "offers": items}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@app.get("/places/nearby")
|
| 155 |
+
def places_nearby(lat: float, lng: float, radius: float = 150):
|
| 156 |
+
"""Resolve nearby known-merchant candidates from coordinates (Google/Foursquare,
|
| 157 |
+
key held server-side). Empty if no provider key is configured."""
|
| 158 |
+
from places import nearby
|
| 159 |
+
return nearby(lat, lng, radius)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
@app.get("/demo")
|
| 163 |
def demo():
|
| 164 |
"""Everything the app needs to demo with zero setup."""
|
|
|
|
| 169 |
}
|
| 170 |
|
| 171 |
|
| 172 |
+
@app.post("/recommend", dependencies=[Depends(require_app_key)])
|
| 173 |
def recommend(req: RecommendRequest):
|
| 174 |
held = req.held_cards or demo_data.DEMO_WALLET
|
| 175 |
persona = req.persona or demo_data.DEMO_PERSONA
|
|
|
|
| 198 |
result["narration"] = llm.narrate_recommendation(
|
| 199 |
result["context"], result["held_ranked"], result["discovery"]
|
| 200 |
)
|
| 201 |
+
# City: explicit request field > city named in the query > nearest metro from GPS.
|
| 202 |
+
from availability import normalize_city, extract_city, city_from_latlng
|
| 203 |
+
city = normalize_city(req.city)
|
| 204 |
+
if not city and req.query:
|
| 205 |
+
city = extract_city(req.query)
|
| 206 |
+
if not city and req.lat is not None and req.lng is not None:
|
| 207 |
+
city = city_from_latlng(req.lat, req.lng)
|
| 208 |
+
|
| 209 |
+
result["intent"] = {"category": category, "amount": amount, "merchant": merchant, "rail": rail, "city": city}
|
| 210 |
+
# Dine-in / movies / travel: same purchase, several booking apps. Rank the
|
| 211 |
+
# channels too (best card PER channel), so the answer is "pay via District
|
| 212 |
+
# with your Axis card", not just "use your Axis card".
|
| 213 |
+
from channels import compare_channels
|
| 214 |
+
result["channel_comparison"] = compare_channels(
|
| 215 |
+
held, category, float(amount), brand_key=brand_key, merchant=merchant,
|
| 216 |
+
rail=rail, persona=persona, city=city,
|
| 217 |
+
)
|
| 218 |
return result
|
| 219 |
|
| 220 |
|
| 221 |
+
@app.post("/voice", dependencies=[Depends(require_app_key)])
|
| 222 |
async def voice(audio: UploadFile = File(...), held_cards: Optional[str] = Form(None)):
|
| 223 |
raw = await audio.read()
|
| 224 |
transcript = llm.transcribe(raw, filename=audio.filename or "audio.m4a")
|
|
|
|
| 228 |
return rec
|
| 229 |
|
| 230 |
|
| 231 |
+
@app.post("/statements/parse", dependencies=[Depends(require_app_key)])
|
| 232 |
async def parse_statements(
|
| 233 |
files: List[UploadFile] = File(...),
|
| 234 |
password: Optional[str] = Form(None),
|
| 235 |
):
|
| 236 |
+
from statement_parser import parse_statement
|
| 237 |
all_txns = []
|
| 238 |
errors = []
|
|
|
|
| 239 |
for f in files:
|
| 240 |
try:
|
| 241 |
content = await f.read()
|
|
|
|
| 243 |
for t in txns:
|
| 244 |
t["source_file"] = f.filename
|
| 245 |
all_txns.extend(txns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
except Exception as e:
|
| 247 |
errors.append({"file": f.filename, "error": str(e)})
|
| 248 |
+
return {"transactions": all_txns, "count": len(all_txns), "errors": errors}
|
|
|
|
| 249 |
|
| 250 |
|
| 251 |
@app.post("/transactions/analyze")
|
|
|
|
| 260 |
card = get_card(card_id)
|
| 261 |
if not card:
|
| 262 |
return {"error": "card_not_found"}
|
| 263 |
+
from scoring_engine import apply_url_for
|
| 264 |
return {
|
| 265 |
"card": _card_public(card),
|
| 266 |
+
"apply_url": apply_url_for(card_id), # official issuer page (affiliate deep link later)
|
| 267 |
"extra_benefits": card.highlights,
|
| 268 |
"why": (
|
| 269 |
f"Based on your spending pattern, {card.name} would beat your current "
|
|
@@ -1,118 +1,196 @@
|
|
| 1 |
"""
|
| 2 |
RewardPilot - Merchant & MCC resolution
|
| 3 |
=======================================
|
| 4 |
-
Maps a free-text merchant / spoken phrase to a canonical category
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import re
|
| 11 |
from typing import Dict, List, Optional, Tuple
|
| 12 |
|
| 13 |
-
# Canonical categories used across the engine
|
| 14 |
CATEGORIES = [
|
| 15 |
"dining", "groceries", "fuel", "online_shopping", "electronics",
|
| 16 |
"travel_flights", "travel_hotels", "utilities", "insurance", "education",
|
| 17 |
"rent", "wallet_load", "entertainment", "apparel", "pharmacy", "general",
|
| 18 |
]
|
| 19 |
|
| 20 |
-
#
|
| 21 |
MERCHANT_MAP: Dict[str, Tuple[str, Optional[str]]] = {
|
| 22 |
-
#
|
| 23 |
-
"croma": ("electronics", "croma"),
|
| 24 |
"reliance digital": ("electronics", "reliance_digital"),
|
| 25 |
"vijay sales": ("electronics", "vijay_sales"),
|
| 26 |
-
"
|
| 27 |
"imagine": ("electronics", "apple"),
|
|
|
|
| 28 |
"iphone": ("electronics", "apple"),
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
"amazon": ("online_shopping", "amazon"),
|
| 31 |
"flipkart": ("online_shopping", "flipkart"),
|
| 32 |
"myntra": ("apparel", "myntra"),
|
| 33 |
"ajio": ("apparel", "ajio"),
|
| 34 |
-
"tata cliq": ("online_shopping", "tata_cliq"),
|
| 35 |
"nykaa": ("online_shopping", "nykaa"),
|
| 36 |
"meesho": ("online_shopping", "meesho"),
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
"swiggy": ("dining", "swiggy"),
|
| 39 |
"instamart": ("groceries", "instamart"),
|
| 40 |
"zomato": ("dining", "zomato"),
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"cafe": ("dining", None),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"dineout": ("dining", "dineout"),
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
"bigbasket": ("groceries", "bigbasket"),
|
| 49 |
-
"blinkit": ("groceries", "blinkit"),
|
| 50 |
-
"zepto": ("groceries", "zepto"),
|
| 51 |
"dmart": ("groceries", "dmart"),
|
| 52 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
"grocery": ("groceries", None),
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
"petrol": ("fuel", None),
|
|
|
|
| 56 |
"fuel": ("fuel", None),
|
| 57 |
"hpcl": ("fuel", None),
|
| 58 |
"bpcl": ("fuel", None),
|
| 59 |
"indian oil": ("fuel", None),
|
| 60 |
"iocl": ("fuel", None),
|
| 61 |
-
|
|
|
|
| 62 |
"makemytrip": ("travel_flights", "makemytrip"),
|
| 63 |
"goibibo": ("travel_flights", "goibibo"),
|
| 64 |
"cleartrip": ("travel_flights", "cleartrip"),
|
| 65 |
"yatra": ("travel_flights", "yatra"),
|
| 66 |
"ixigo": ("travel_flights", "ixigo"),
|
| 67 |
"easemytrip": ("travel_flights", "easemytrip"),
|
| 68 |
-
"abhibus": ("transport", "abhibus"),
|
| 69 |
"indigo": ("travel_flights", "indigo"),
|
| 70 |
"air india": ("travel_flights", "air_india"),
|
| 71 |
"vistara": ("travel_flights", "vistara"),
|
| 72 |
-
"
|
| 73 |
-
"
|
| 74 |
-
"oyo": ("travel_hotels", "oyo"),
|
| 75 |
-
"taj": ("travel_hotels", "taj"),
|
| 76 |
-
"marriott": ("travel_hotels", "marriott"),
|
| 77 |
"irctc": ("transport", "irctc"), # railways, not flights
|
| 78 |
"redbus": ("transport", "redbus"), # buses, not flights
|
|
|
|
| 79 |
# Reward/booking portals: category comes from the descriptor sub-type below.
|
| 80 |
"smartbuy": ("general", "smartbuy"),
|
| 81 |
"travel edge": ("travel_flights", "axis_travel_edge"),
|
| 82 |
"traveledge": ("travel_flights", "axis_travel_edge"),
|
| 83 |
"ishop": ("online_shopping", "icici_ishop"),
|
| 84 |
"gyftr": ("online_shopping", "gyftr"),
|
| 85 |
-
|
| 86 |
-
"
|
| 87 |
-
"
|
| 88 |
-
"
|
| 89 |
-
"
|
| 90 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
"westside": ("apparel", "westside"),
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
"bookmyshow": ("entertainment", "bookmyshow"),
|
| 94 |
"pvr": ("entertainment", "pvr"),
|
| 95 |
-
"inox": ("entertainment", "
|
| 96 |
-
"
|
|
|
|
|
|
|
| 97 |
"movie": ("entertainment", None),
|
| 98 |
-
#
|
| 99 |
-
"pharmeasy": ("pharmacy",
|
| 100 |
-
"
|
| 101 |
-
"1mg": ("pharmacy",
|
| 102 |
-
"
|
|
|
|
|
|
|
| 103 |
"pharmacy": ("pharmacy", None),
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
"electricity": ("utilities", None),
|
| 106 |
-
"bill": ("utilities", None),
|
| 107 |
"broadband": ("utilities", None),
|
| 108 |
"mobile recharge": ("utilities", None),
|
|
|
|
| 109 |
"dth": ("utilities", None),
|
| 110 |
-
|
| 111 |
-
"
|
| 112 |
-
"
|
|
|
|
|
|
|
|
|
|
| 113 |
"tuition": ("education", None),
|
|
|
|
| 114 |
"insurance": ("insurance", None),
|
| 115 |
-
"premium": ("insurance", None),
|
| 116 |
"rent": ("rent", None),
|
| 117 |
# ride-hailing / transport
|
| 118 |
"uber": ("transport", "uber"),
|
|
@@ -146,53 +224,49 @@ MERCHANT_MAP: Dict[str, Tuple[str, Optional[str]]] = {
|
|
| 146 |
"mamaearth": ("online_shopping", "mamaearth"),
|
| 147 |
"decathlon": ("apparel", "decathlon"),
|
| 148 |
"fashnear": ("online_shopping", "meesho"),
|
| 149 |
-
# wallet
|
| 150 |
"paytm wallet": ("wallet_load", None),
|
| 151 |
"wallet load": ("wallet_load", None),
|
|
|
|
| 152 |
}
|
| 153 |
|
| 154 |
-
#
|
| 155 |
-
#
|
| 156 |
-
LIVE_OFFERS: Dict[str, List[Dict]] = {
|
| 157 |
-
"croma": [
|
| 158 |
-
{"issuer": "HDFC Bank", "text": "INR 5,000 instant discount on HDFC credit cards (min INR 50,000)", "applies_to_issuer": "HDFC Bank"},
|
| 159 |
-
{"issuer": "ICICI Bank", "text": "No-cost EMI up to 9 months on ICICI cards", "applies_to_issuer": "ICICI Bank"},
|
| 160 |
-
],
|
| 161 |
-
"amazon": [
|
| 162 |
-
{"issuer": "ICICI Bank", "text": "5% back with Amazon Pay ICICI card", "applies_to_issuer": "ICICI Bank"},
|
| 163 |
-
{"issuer": "SBI Card", "text": "10% instant discount on SBI cards (select days)", "applies_to_issuer": "SBI Card"},
|
| 164 |
-
],
|
| 165 |
-
"flipkart": [
|
| 166 |
-
{"issuer": "Axis Bank", "text": "5% unlimited cashback on Flipkart Axis card", "applies_to_issuer": "Axis Bank"},
|
| 167 |
-
],
|
| 168 |
-
"swiggy": [
|
| 169 |
-
{"issuer": "HDFC Bank", "text": "10% back with Swiggy HDFC card", "applies_to_issuer": "HDFC Bank"},
|
| 170 |
-
],
|
| 171 |
-
"apple": [
|
| 172 |
-
{"issuer": "HDFC Bank", "text": "Instant cashback on HDFC cards via authorised resellers", "applies_to_issuer": "HDFC Bank"},
|
| 173 |
-
],
|
| 174 |
-
}
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
# Retailers / merchants - preferred over product words so "iPhone at Croma"
|
| 178 |
-
# resolves the merchant to Croma, not iPhone.
|
| 179 |
STORE_KEYWORDS = {
|
| 180 |
"reliance digital", "vijay sales", "croma", "amazon", "flipkart", "myntra", "ajio",
|
| 181 |
-
"nykaa", "meesho", "
|
| 182 |
-
"
|
| 183 |
-
"
|
| 184 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
"smartbuy", "travel edge", "traveledge", "ishop", "gyftr",
|
|
|
|
| 186 |
}
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
_AGGREGATOR_BRANDS = {
|
| 190 |
"makemytrip", "goibibo", "cleartrip", "yatra", "ixigo", "easemytrip",
|
| 191 |
"smartbuy", "axis_travel_edge", "icici_ishop", "gyftr",
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
|
| 195 |
-
def _descriptor_subtype(t):
|
|
|
|
|
|
|
| 196 |
if re.search(r"hotel|resort|homestay|\brooms?\b|\bstay\b|oyo|marriott|\btaj\b|hyatt|radisson", t):
|
| 197 |
return "travel_hotels"
|
| 198 |
if re.search(r"flight|airways|airline|\bair\b|indigo|vistara|spicejet|akasa|go\s?first", t):
|
|
@@ -203,14 +277,14 @@ def _descriptor_subtype(t):
|
|
| 203 |
return "transport"
|
| 204 |
if re.search(r"voucher|gift\s?card|e-?gift|giftcard|gyftr", t):
|
| 205 |
return "online_shopping"
|
|
|
|
|
|
|
| 206 |
return None
|
| 207 |
|
| 208 |
|
| 209 |
def resolve_merchant(text: str) -> Dict:
|
| 210 |
-
"""
|
| 211 |
-
|
| 212 |
-
Returns: {category, brand_key, matched_merchant, offers}
|
| 213 |
-
"""
|
| 214 |
t = (text or "").lower()
|
| 215 |
matched_merchant = None
|
| 216 |
brand_key = None
|
|
@@ -223,36 +297,21 @@ def resolve_merchant(text: str) -> Dict:
|
|
| 223 |
pick = sorted(pool, key=len, reverse=True)[0]
|
| 224 |
category, brand_key = MERCHANT_MAP[pick]
|
| 225 |
matched_merchant = pick
|
|
|
|
| 226 |
if brand_key in _AGGREGATOR_BRANDS:
|
| 227 |
category = _descriptor_subtype(t) or category
|
| 228 |
|
| 229 |
-
offers = LIVE_OFFERS.get(brand_key, []) if brand_key else []
|
| 230 |
return {
|
| 231 |
"category": category,
|
| 232 |
"brand_key": brand_key,
|
| 233 |
"matched_merchant": matched_merchant,
|
| 234 |
-
"offers":
|
| 235 |
}
|
| 236 |
|
| 237 |
|
| 238 |
-
UPI_HINTS = ["upi", "scan", " qr", "qr code", "gpay", "google pay", "phonepe", "paytm", "bhim", "rupay"]
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
def detect_rail(text: str):
|
| 242 |
-
"""Return 'upi', 'card', or None based on payment-method hints in the phrase."""
|
| 243 |
-
t = (text or "").lower()
|
| 244 |
-
if any(h in t for h in UPI_HINTS):
|
| 245 |
-
return "upi"
|
| 246 |
-
if "swipe" in t or "tap" in t or "card" in t:
|
| 247 |
-
return "card"
|
| 248 |
-
return None
|
| 249 |
-
|
| 250 |
-
|
| 251 |
def extract_amount(text: str) -> Optional[float]:
|
| 252 |
-
"""Pull an INR amount out of a spoken phrase, if present."""
|
| 253 |
import re
|
| 254 |
t = (text or "").lower().replace(",", "")
|
| 255 |
-
# patterns: "rs 50000", "50000 rupees", "worth 1.2 lakh", "₹80000"
|
| 256 |
lakh = re.search(r"([\d.]+)\s*lakh", t)
|
| 257 |
if lakh:
|
| 258 |
return float(lakh.group(1)) * 100000
|
|
@@ -262,11 +321,9 @@ def extract_amount(text: str) -> Optional[float]:
|
|
| 262 |
m = re.search(r"([\d.]+)\s*(?:rs|inr|rupees)", t)
|
| 263 |
if m:
|
| 264 |
return float(m.group(1))
|
| 265 |
-
# "80k" style
|
| 266 |
m = re.search(r"([\d.]+)\s*k\b", t)
|
| 267 |
if m:
|
| 268 |
return float(m.group(1)) * 1000
|
| 269 |
-
# bare number fallback (matches the on-device parser)
|
| 270 |
m = re.search(r"\b(\d{3,7})\b", t)
|
| 271 |
if m:
|
| 272 |
return float(m.group(1))
|
|
|
|
| 1 |
"""
|
| 2 |
RewardPilot - Merchant & MCC resolution
|
| 3 |
=======================================
|
| 4 |
+
Maps a free-text merchant / spoken phrase to a canonical category, a brand key,
|
| 5 |
+
the payment rail, and the live structured offers from offers.py.
|
| 6 |
+
|
| 7 |
+
Broad Indian coverage across online and offline retail. Merchant brand keys line
|
| 8 |
+
up with offers.CATALOGUE merchant_key values where offers exist.
|
| 9 |
"""
|
| 10 |
|
| 11 |
import re
|
| 12 |
from typing import Dict, List, Optional, Tuple
|
| 13 |
|
|
|
|
| 14 |
CATEGORIES = [
|
| 15 |
"dining", "groceries", "fuel", "online_shopping", "electronics",
|
| 16 |
"travel_flights", "travel_hotels", "utilities", "insurance", "education",
|
| 17 |
"rent", "wallet_load", "entertainment", "apparel", "pharmacy", "general",
|
| 18 |
]
|
| 19 |
|
| 20 |
+
# keyword -> (canonical category, brand_key)
|
| 21 |
MERCHANT_MAP: Dict[str, Tuple[str, Optional[str]]] = {
|
| 22 |
+
# --- Electronics / large-format (offline + online) ---
|
|
|
|
| 23 |
"reliance digital": ("electronics", "reliance_digital"),
|
| 24 |
"vijay sales": ("electronics", "vijay_sales"),
|
| 25 |
+
"croma": ("electronics", "croma"),
|
| 26 |
"imagine": ("electronics", "apple"),
|
| 27 |
+
"apple": ("electronics", "apple"),
|
| 28 |
"iphone": ("electronics", "apple"),
|
| 29 |
+
"macbook": ("electronics", "apple"),
|
| 30 |
+
"ipad": ("electronics", "apple"),
|
| 31 |
+
"samsung": ("electronics", "samsung"),
|
| 32 |
+
"oneplus": ("electronics", "oneplus"),
|
| 33 |
+
"boat": ("electronics", "boat"),
|
| 34 |
+
"laptop": ("electronics", None),
|
| 35 |
+
"mobile phone": ("electronics", None),
|
| 36 |
+
"headphones": ("electronics", None),
|
| 37 |
+
"television": ("electronics", None),
|
| 38 |
+
# --- Online marketplaces / fashion / beauty ---
|
| 39 |
"amazon": ("online_shopping", "amazon"),
|
| 40 |
"flipkart": ("online_shopping", "flipkart"),
|
| 41 |
"myntra": ("apparel", "myntra"),
|
| 42 |
"ajio": ("apparel", "ajio"),
|
|
|
|
| 43 |
"nykaa": ("online_shopping", "nykaa"),
|
| 44 |
"meesho": ("online_shopping", "meesho"),
|
| 45 |
+
"tata cliq": ("online_shopping", "tata_cliq"),
|
| 46 |
+
"tatacliq": ("online_shopping", "tata_cliq"),
|
| 47 |
+
"snapdeal": ("online_shopping", "snapdeal"),
|
| 48 |
+
"jiomart": ("groceries", "jiomart"),
|
| 49 |
+
"pepperfry": ("online_shopping", "pepperfry"),
|
| 50 |
+
"urban ladder": ("online_shopping", "urban_ladder"),
|
| 51 |
+
"firstcry": ("online_shopping", "firstcry"),
|
| 52 |
+
"lenskart": ("online_shopping", "lenskart"),
|
| 53 |
+
"decathlon": ("online_shopping", "decathlon"),
|
| 54 |
+
"ikea": ("online_shopping", "ikea"),
|
| 55 |
+
# --- Food delivery / quick commerce / dining ---
|
| 56 |
"swiggy": ("dining", "swiggy"),
|
| 57 |
"instamart": ("groceries", "instamart"),
|
| 58 |
"zomato": ("dining", "zomato"),
|
| 59 |
+
"blinkit": ("groceries", "blinkit"),
|
| 60 |
+
"zepto": ("groceries", "zepto"),
|
| 61 |
+
"dunzo": ("groceries", "dunzo"),
|
| 62 |
+
"eternal": ("dining", "zomato"),
|
| 63 |
+
# bank-statement descriptors (legal entity / aggregator names)
|
| 64 |
+
"bundl": ("dining", "swiggy"), # Bundl Technologies = Swiggy
|
| 65 |
+
"asspl": ("online_shopping", "amazon"), # Amazon Seller Services Pvt Ltd
|
| 66 |
+
"amazon seller": ("online_shopping", "amazon"),
|
| 67 |
+
"blink commerce": ("groceries", "blinkit"), # Blink Commerce = Blinkit
|
| 68 |
+
"dominos": ("dining", "dominos"),
|
| 69 |
+
"pizza hut": ("dining", "pizza_hut"),
|
| 70 |
+
"mcdonald": ("dining", "mcdonalds"),
|
| 71 |
+
"kfc": ("dining", "kfc"),
|
| 72 |
+
"starbucks": ("dining", "starbucks"),
|
| 73 |
+
"barbeque nation": ("dining", "barbeque_nation"),
|
| 74 |
+
"haldiram": ("dining", "haldiram"),
|
| 75 |
"cafe": ("dining", None),
|
| 76 |
+
"restaurant": ("dining", None),
|
| 77 |
+
"dinner": ("dining", None),
|
| 78 |
+
"lunch": ("dining", None),
|
| 79 |
+
# dine-in bill-payment / booking platforms (channel comparison pivots on these)
|
| 80 |
"dineout": ("dining", "dineout"),
|
| 81 |
+
"swiggy dineout": ("dining", "dineout"),
|
| 82 |
+
"district": ("dining", "district"), # Zomato's District: dining + movies + events
|
| 83 |
+
# well-known dine-in chains (walk-in restaurant offers)
|
| 84 |
+
"copper chimney": ("dining", "copper_chimney"),
|
| 85 |
+
# --- Groceries / hypermarket (offline) ---
|
| 86 |
"bigbasket": ("groceries", "bigbasket"),
|
|
|
|
|
|
|
| 87 |
"dmart": ("groceries", "dmart"),
|
| 88 |
+
"d-mart": ("groceries", "dmart"),
|
| 89 |
+
"reliance fresh": ("groceries", "reliance_fresh"),
|
| 90 |
+
"reliance smart": ("groceries", "reliance_fresh"),
|
| 91 |
+
"more supermarket": ("groceries", "more"),
|
| 92 |
+
"spencer": ("groceries", "spencers"),
|
| 93 |
+
"star bazaar": ("groceries", "star_bazaar"),
|
| 94 |
"grocery": ("groceries", None),
|
| 95 |
+
"groceries": ("groceries", None),
|
| 96 |
+
"kirana": ("groceries", None),
|
| 97 |
+
# --- Fuel ---
|
| 98 |
"petrol": ("fuel", None),
|
| 99 |
+
"diesel": ("fuel", None),
|
| 100 |
"fuel": ("fuel", None),
|
| 101 |
"hpcl": ("fuel", None),
|
| 102 |
"bpcl": ("fuel", None),
|
| 103 |
"indian oil": ("fuel", None),
|
| 104 |
"iocl": ("fuel", None),
|
| 105 |
+
"shell": ("fuel", None),
|
| 106 |
+
# --- Travel ---
|
| 107 |
"makemytrip": ("travel_flights", "makemytrip"),
|
| 108 |
"goibibo": ("travel_flights", "goibibo"),
|
| 109 |
"cleartrip": ("travel_flights", "cleartrip"),
|
| 110 |
"yatra": ("travel_flights", "yatra"),
|
| 111 |
"ixigo": ("travel_flights", "ixigo"),
|
| 112 |
"easemytrip": ("travel_flights", "easemytrip"),
|
|
|
|
| 113 |
"indigo": ("travel_flights", "indigo"),
|
| 114 |
"air india": ("travel_flights", "air_india"),
|
| 115 |
"vistara": ("travel_flights", "vistara"),
|
| 116 |
+
"akasa": ("travel_flights", "akasa"),
|
| 117 |
+
"spicejet": ("travel_flights", "spicejet"),
|
|
|
|
|
|
|
|
|
|
| 118 |
"irctc": ("transport", "irctc"), # railways, not flights
|
| 119 |
"redbus": ("transport", "redbus"), # buses, not flights
|
| 120 |
+
"abhibus": ("transport", "abhibus"),
|
| 121 |
# Reward/booking portals: category comes from the descriptor sub-type below.
|
| 122 |
"smartbuy": ("general", "smartbuy"),
|
| 123 |
"travel edge": ("travel_flights", "axis_travel_edge"),
|
| 124 |
"traveledge": ("travel_flights", "axis_travel_edge"),
|
| 125 |
"ishop": ("online_shopping", "icici_ishop"),
|
| 126 |
"gyftr": ("online_shopping", "gyftr"),
|
| 127 |
+
"flight": ("travel_flights", None),
|
| 128 |
+
"airline": ("travel_flights", None),
|
| 129 |
+
"hotel": ("travel_hotels", None),
|
| 130 |
+
"oyo": ("travel_hotels", "oyo"),
|
| 131 |
+
"taj": ("travel_hotels", "taj"),
|
| 132 |
+
"marriott": ("travel_hotels", "marriott"),
|
| 133 |
+
"itc hotel": ("travel_hotels", "itc"),
|
| 134 |
+
"agoda": ("travel_hotels", "agoda"),
|
| 135 |
+
"booking.com": ("travel_hotels", "booking"),
|
| 136 |
+
# --- Cabs / mobility ---
|
| 137 |
+
"ola": ("transport", "ola"),
|
| 138 |
+
"uber": ("transport", "uber"),
|
| 139 |
+
"rapido": ("transport", "rapido"),
|
| 140 |
+
"namma yatri": ("transport", "namma_yatri"),
|
| 141 |
+
"blusmart": ("transport", "blusmart"),
|
| 142 |
+
"meru": ("transport", "meru"),
|
| 143 |
+
# --- Apparel / department stores (offline) ---
|
| 144 |
+
"zara": ("apparel", "zara"),
|
| 145 |
+
"h&m": ("apparel", "hm"),
|
| 146 |
+
"lifestyle": ("apparel", "lifestyle"),
|
| 147 |
+
"shoppers stop": ("apparel", "shoppers_stop"),
|
| 148 |
+
"pantaloons": ("apparel", "pantaloons"),
|
| 149 |
"westside": ("apparel", "westside"),
|
| 150 |
+
"marks": ("apparel", "marks_spencer"),
|
| 151 |
+
"uniqlo": ("apparel", "uniqlo"),
|
| 152 |
+
"clothes": ("apparel", None),
|
| 153 |
+
"apparel": ("apparel", None),
|
| 154 |
+
# --- Jewellery ---
|
| 155 |
+
"tanishq": ("apparel", "tanishq"),
|
| 156 |
+
"kalyan": ("apparel", "kalyan"),
|
| 157 |
+
"jewellery": ("apparel", None),
|
| 158 |
+
# --- Entertainment ---
|
| 159 |
"bookmyshow": ("entertainment", "bookmyshow"),
|
| 160 |
"pvr": ("entertainment", "pvr"),
|
| 161 |
+
"inox": ("entertainment", "pvr"),
|
| 162 |
+
"cinepolis": ("entertainment", "cinepolis"),
|
| 163 |
+
"netflix": ("entertainment", "netflix"),
|
| 164 |
+
"spotify": ("entertainment", "spotify"),
|
| 165 |
"movie": ("entertainment", None),
|
| 166 |
+
# --- Pharmacy / health ---
|
| 167 |
+
"pharmeasy": ("pharmacy", "pharmeasy"),
|
| 168 |
+
"1mg": ("pharmacy", "tata_1mg"),
|
| 169 |
+
"tata 1mg": ("pharmacy", "tata_1mg"),
|
| 170 |
+
"netmeds": ("pharmacy", "netmeds"),
|
| 171 |
+
"apollo": ("pharmacy", "apollo"),
|
| 172 |
+
"medplus": ("pharmacy", "medplus"),
|
| 173 |
"pharmacy": ("pharmacy", None),
|
| 174 |
+
"medicine": ("pharmacy", None),
|
| 175 |
+
"chemist": ("pharmacy", None),
|
| 176 |
+
# --- Services (offline) ---
|
| 177 |
+
"urban company": ("general", "urban_company"),
|
| 178 |
+
# --- Utilities / bills ---
|
| 179 |
"electricity": ("utilities", None),
|
|
|
|
| 180 |
"broadband": ("utilities", None),
|
| 181 |
"mobile recharge": ("utilities", None),
|
| 182 |
+
"recharge": ("utilities", None),
|
| 183 |
"dth": ("utilities", None),
|
| 184 |
+
"gas bill": ("utilities", None),
|
| 185 |
+
"water bill": ("utilities", None),
|
| 186 |
+
"bill": ("utilities", None),
|
| 187 |
+
# --- Education / insurance / rent / wallet ---
|
| 188 |
+
"school fee": ("education", None),
|
| 189 |
+
"college fee": ("education", None),
|
| 190 |
"tuition": ("education", None),
|
| 191 |
+
"byju": ("education", None),
|
| 192 |
"insurance": ("insurance", None),
|
| 193 |
+
"policy premium": ("insurance", None),
|
| 194 |
"rent": ("rent", None),
|
| 195 |
# ride-hailing / transport
|
| 196 |
"uber": ("transport", "uber"),
|
|
|
|
| 224 |
"mamaearth": ("online_shopping", "mamaearth"),
|
| 225 |
"decathlon": ("apparel", "decathlon"),
|
| 226 |
"fashnear": ("online_shopping", "meesho"),
|
|
|
|
| 227 |
"paytm wallet": ("wallet_load", None),
|
| 228 |
"wallet load": ("wallet_load", None),
|
| 229 |
+
"add money": ("wallet_load", None),
|
| 230 |
}
|
| 231 |
|
| 232 |
+
# Retailers / merchants preferred over product words so "iPhone at Croma"
|
| 233 |
+
# resolves to Croma, not iPhone.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
STORE_KEYWORDS = {
|
| 235 |
"reliance digital", "vijay sales", "croma", "amazon", "flipkart", "myntra", "ajio",
|
| 236 |
+
"nykaa", "meesho", "tata cliq", "tatacliq", "snapdeal", "jiomart", "pepperfry",
|
| 237 |
+
"firstcry", "lenskart", "decathlon", "ikea", "swiggy", "instamart", "zomato",
|
| 238 |
+
"blinkit", "zepto", "dunzo", "dominos", "starbucks", "barbeque nation", "bigbasket",
|
| 239 |
+
"dmart", "reliance fresh", "makemytrip", "goibibo", "cleartrip", "yatra", "ixigo",
|
| 240 |
+
"easemytrip", "indigo", "vistara", "oyo", "taj", "marriott", "agoda", "redbus",
|
| 241 |
+
"ola", "uber", "rapido", "zara", "lifestyle", "shoppers stop", "pantaloons",
|
| 242 |
+
"westside", "tanishq", "kalyan", "bookmyshow", "pvr", "cinepolis", "pharmeasy",
|
| 243 |
+
"1mg", "netmeds", "apollo", "medplus", "urban company",
|
| 244 |
"smartbuy", "travel edge", "traveledge", "ishop", "gyftr",
|
| 245 |
+
"dineout", "swiggy dineout", "district", "copper chimney",
|
| 246 |
}
|
| 247 |
|
| 248 |
+
UPI_HINTS = ["upi", "scan", " qr", "qr code", "gpay", "google pay", "phonepe", "paytm", "bhim", "rupay"]
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def detect_rail(text: str):
|
| 252 |
+
t = (text or "").lower()
|
| 253 |
+
if any(h in t for h in UPI_HINTS):
|
| 254 |
+
return "upi"
|
| 255 |
+
if "swipe" in t or "tap" in t or "card" in t:
|
| 256 |
+
return "card"
|
| 257 |
+
return None
|
| 258 |
+
|
| 259 |
|
| 260 |
_AGGREGATOR_BRANDS = {
|
| 261 |
"makemytrip", "goibibo", "cleartrip", "yatra", "ixigo", "easemytrip",
|
| 262 |
"smartbuy", "axis_travel_edge", "icici_ishop", "gyftr",
|
| 263 |
+
"district", # Zomato District sells dining, movie tickets and events under one app
|
| 264 |
}
|
| 265 |
|
| 266 |
|
| 267 |
+
def _descriptor_subtype(t: str):
|
| 268 |
+
"""Read a category sub-type out of the full descriptor for aggregators/portals
|
| 269 |
+
(e.g. "SMARTBUY FLIGHT INDIGO", "EASEMYTRIP HOTEL", "SMARTBUY GIFT VOUCHER")."""
|
| 270 |
if re.search(r"hotel|resort|homestay|\brooms?\b|\bstay\b|oyo|marriott|\btaj\b|hyatt|radisson", t):
|
| 271 |
return "travel_hotels"
|
| 272 |
if re.search(r"flight|airways|airline|\bair\b|indigo|vistara|spicejet|akasa|go\s?first", t):
|
|
|
|
| 277 |
return "transport"
|
| 278 |
if re.search(r"voucher|gift\s?card|e-?gift|giftcard|gyftr", t):
|
| 279 |
return "online_shopping"
|
| 280 |
+
if re.search(r"movie|cinema|film|\bshow\b|pvr|inox|imax", t):
|
| 281 |
+
return "entertainment"
|
| 282 |
return None
|
| 283 |
|
| 284 |
|
| 285 |
def resolve_merchant(text: str) -> Dict:
|
| 286 |
+
"""Resolve a free-text phrase to category + brand + active offers (dated)."""
|
| 287 |
+
from offers import active_offers_for
|
|
|
|
|
|
|
| 288 |
t = (text or "").lower()
|
| 289 |
matched_merchant = None
|
| 290 |
brand_key = None
|
|
|
|
| 297 |
pick = sorted(pool, key=len, reverse=True)[0]
|
| 298 |
category, brand_key = MERCHANT_MAP[pick]
|
| 299 |
matched_merchant = pick
|
| 300 |
+
# For an aggregator/portal, the descriptor's sub-type overrides the default.
|
| 301 |
if brand_key in _AGGREGATOR_BRANDS:
|
| 302 |
category = _descriptor_subtype(t) or category
|
| 303 |
|
|
|
|
| 304 |
return {
|
| 305 |
"category": category,
|
| 306 |
"brand_key": brand_key,
|
| 307 |
"matched_merchant": matched_merchant,
|
| 308 |
+
"offers": active_offers_for(brand_key),
|
| 309 |
}
|
| 310 |
|
| 311 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
def extract_amount(text: str) -> Optional[float]:
|
|
|
|
| 313 |
import re
|
| 314 |
t = (text or "").lower().replace(",", "")
|
|
|
|
| 315 |
lakh = re.search(r"([\d.]+)\s*lakh", t)
|
| 316 |
if lakh:
|
| 317 |
return float(lakh.group(1)) * 100000
|
|
|
|
| 321 |
m = re.search(r"([\d.]+)\s*(?:rs|inr|rupees)", t)
|
| 322 |
if m:
|
| 323 |
return float(m.group(1))
|
|
|
|
| 324 |
m = re.search(r"([\d.]+)\s*k\b", t)
|
| 325 |
if m:
|
| 326 |
return float(m.group(1)) * 1000
|
|
|
|
| 327 |
m = re.search(r"\b(\d{3,7})\b", t)
|
| 328 |
if m:
|
| 329 |
return float(m.group(1))
|
|
@@ -135,10 +135,24 @@ CATALOGUE: List[Offer] = [
|
|
| 135 |
Offer("of_cleartrip_amex", "cleartrip", "Cleartrip", "American Express", "flat", 1500, _TODAY, _FAR, "issuer:amex_offers", _LV, "online", min_spend=10000, categories=["travel_flights"]),
|
| 136 |
Offer("of_oyo_hdfc", "oyo", "OYO", "HDFC Bank", "pct", 15, _TODAY, _FAR, "affiliate:grabon", _LV, "online", max_discount=1000, categories=["travel_hotels"]),
|
| 137 |
|
|
|
|
|
|
|
| 138 |
# Entertainment
|
| 139 |
Offer("of_bookmyshow_sbi", "bookmyshow", "BookMyShow", "SBI Card", "flat", 150, _TODAY, _FAR, "affiliate:bank_partner", _LV, "online", min_spend=400, categories=["entertainment"]),
|
|
|
|
| 140 |
Offer("of_pvr_hdfc", "pvr", "PVR INOX", "HDFC Bank", "pct", 20, _TODAY, _FAR, "issuer:hdfc_smartbuy", _LV, "offline", max_discount=300, categories=["entertainment"]),
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
# Fashion / department stores (offline)
|
| 143 |
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"]),
|
| 144 |
Offer("of_shoppersstop_hdfc", "shoppers_stop", "Shoppers Stop", "HDFC Bank", "pct", 10, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=1000, categories=["apparel"]),
|
|
@@ -158,6 +172,7 @@ CATALOGUE: List[Offer] = [
|
|
| 158 |
# Eating out (offline dining)
|
| 159 |
Offer("of_starbucks_amex", "starbucks", "Starbucks", "American Express", "cashback", 15, _TODAY, _FAR, "issuer:amex_offers", _LV, "offline", max_discount=150, categories=["dining"]),
|
| 160 |
Offer("of_barbequenation_hdfc", "barbeque_nation", "Barbeque Nation", "HDFC Bank", "pct", 15, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=500, categories=["dining"]),
|
|
|
|
| 161 |
|
| 162 |
# An intentionally EXPIRED offer (proves date filtering works)
|
| 163 |
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),
|
|
|
|
| 135 |
Offer("of_cleartrip_amex", "cleartrip", "Cleartrip", "American Express", "flat", 1500, _TODAY, _FAR, "issuer:amex_offers", _LV, "online", min_spend=10000, categories=["travel_flights"]),
|
| 136 |
Offer("of_oyo_hdfc", "oyo", "OYO", "HDFC Bank", "pct", 15, _TODAY, _FAR, "affiliate:grabon", _LV, "online", max_discount=1000, categories=["travel_hotels"]),
|
| 137 |
|
| 138 |
+
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"]),
|
| 139 |
+
|
| 140 |
# Entertainment
|
| 141 |
Offer("of_bookmyshow_sbi", "bookmyshow", "BookMyShow", "SBI Card", "flat", 150, _TODAY, _FAR, "affiliate:bank_partner", _LV, "online", min_spend=400, categories=["entertainment"]),
|
| 142 |
+
Offer("of_bookmyshow_axis", "bookmyshow", "BookMyShow", "Axis Bank", "flat", 200, _TODAY, _FAR, "issuer:axis", _LV, "online", min_spend=800, categories=["entertainment"]),
|
| 143 |
Offer("of_pvr_hdfc", "pvr", "PVR INOX", "HDFC Bank", "pct", 20, _TODAY, _FAR, "issuer:hdfc_smartbuy", _LV, "offline", max_discount=300, categories=["entertainment"]),
|
| 144 |
|
| 145 |
+
# Dine-in / ticketing platforms (Swiggy Dineout, Zomato District). Rows with an
|
| 146 |
+
# empty issuer are PLATFORM-funded deals - they apply whatever card is used, so
|
| 147 |
+
# they lift every card equally, which is what makes the channel comparison honest.
|
| 148 |
+
# District sells dining AND movies; the categories field keeps each offer scoped.
|
| 149 |
+
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"]),
|
| 150 |
+
Offer("of_dineout_plat", "dineout", "Swiggy Dineout", "", "pct", 10, _TODAY, _FAR, "platform:dineout", _LV, "online", max_discount=300, categories=["dining"]),
|
| 151 |
+
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"]),
|
| 152 |
+
Offer("of_district_plat_dining", "district", "District (Zomato)", "", "pct", 15, _TODAY, _FAR, "platform:district", _LV, "online", max_discount=350, categories=["dining"]),
|
| 153 |
+
Offer("of_district_icici_movies", "district", "District (Zomato)", "ICICI Bank", "flat", 100, _TODAY, _FAR, "affiliate:bank_partner", _LV, "online", min_spend=500, categories=["entertainment"]),
|
| 154 |
+
Offer("of_district_plat_movies", "district", "District (Zomato)", "", "pct", 10, _TODAY, _FAR, "platform:district", _LV, "online", max_discount=200, categories=["entertainment"]),
|
| 155 |
+
|
| 156 |
# Fashion / department stores (offline)
|
| 157 |
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"]),
|
| 158 |
Offer("of_shoppersstop_hdfc", "shoppers_stop", "Shoppers Stop", "HDFC Bank", "pct", 10, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=1000, categories=["apparel"]),
|
|
|
|
| 172 |
# Eating out (offline dining)
|
| 173 |
Offer("of_starbucks_amex", "starbucks", "Starbucks", "American Express", "cashback", 15, _TODAY, _FAR, "issuer:amex_offers", _LV, "offline", max_discount=150, categories=["dining"]),
|
| 174 |
Offer("of_barbequenation_hdfc", "barbeque_nation", "Barbeque Nation", "HDFC Bank", "pct", 15, _TODAY, _FAR, "affiliate:bank_partner", _LV, "offline", max_discount=500, categories=["dining"]),
|
| 175 |
+
Offer("of_copperchimney_amex", "copper_chimney", "Copper Chimney", "American Express", "cashback", 15, _TODAY, _FAR, "issuer:amex_offers", _LV, "offline", max_discount=300, categories=["dining"]),
|
| 176 |
|
| 177 |
# An intentionally EXPIRED offer (proves date filtering works)
|
| 178 |
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),
|
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "of_3aaeef4187aa",
|
| 4 |
+
"merchant_key": "croma",
|
| 5 |
+
"merchant_name": "Croma",
|
| 6 |
+
"applies_to_issuer": "HDFC Bank",
|
| 7 |
+
"card_id": null,
|
| 8 |
+
"type": "flat",
|
| 9 |
+
"value": 5000,
|
| 10 |
+
"min_spend": 50000,
|
| 11 |
+
"max_discount": null,
|
| 12 |
+
"categories": null,
|
| 13 |
+
"channel": "both",
|
| 14 |
+
"valid_from": "2026-06-29",
|
| 15 |
+
"valid_to": "2026-09-27",
|
| 16 |
+
"source": "issuer:hdfc_smartbuy",
|
| 17 |
+
"last_verified": "2026-06-29"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "of_6cce7407b995",
|
| 21 |
+
"merchant_key": "myntra",
|
| 22 |
+
"merchant_name": "Myntra",
|
| 23 |
+
"applies_to_issuer": "HDFC Bank",
|
| 24 |
+
"card_id": null,
|
| 25 |
+
"type": "pct",
|
| 26 |
+
"value": 10,
|
| 27 |
+
"min_spend": 3000,
|
| 28 |
+
"max_discount": 1000,
|
| 29 |
+
"categories": [
|
| 30 |
+
"apparel"
|
| 31 |
+
],
|
| 32 |
+
"channel": "online",
|
| 33 |
+
"valid_from": "2026-06-29",
|
| 34 |
+
"valid_to": "2026-09-27",
|
| 35 |
+
"source": "issuer:hdfc_smartbuy",
|
| 36 |
+
"last_verified": "2026-06-29"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "of_bb5020b1d9ad",
|
| 40 |
+
"merchant_key": "starbucks",
|
| 41 |
+
"merchant_name": "Starbucks",
|
| 42 |
+
"applies_to_issuer": "American Express",
|
| 43 |
+
"card_id": null,
|
| 44 |
+
"type": "cashback",
|
| 45 |
+
"value": 15,
|
| 46 |
+
"min_spend": 0.0,
|
| 47 |
+
"max_discount": 150,
|
| 48 |
+
"categories": [
|
| 49 |
+
"dining"
|
| 50 |
+
],
|
| 51 |
+
"channel": "offline",
|
| 52 |
+
"valid_from": "2026-06-29",
|
| 53 |
+
"valid_to": "2026-09-27",
|
| 54 |
+
"source": "issuer:amex_offers",
|
| 55 |
+
"last_verified": "2026-06-29"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"id": "of_92cd047ca960",
|
| 59 |
+
"merchant_key": "zomato",
|
| 60 |
+
"merchant_name": "Zomato",
|
| 61 |
+
"applies_to_issuer": "American Express",
|
| 62 |
+
"card_id": null,
|
| 63 |
+
"type": "cashback",
|
| 64 |
+
"value": 20,
|
| 65 |
+
"min_spend": 500,
|
| 66 |
+
"max_discount": 200,
|
| 67 |
+
"categories": [
|
| 68 |
+
"dining"
|
| 69 |
+
],
|
| 70 |
+
"channel": "online",
|
| 71 |
+
"valid_from": "2026-06-29",
|
| 72 |
+
"valid_to": "2026-09-27",
|
| 73 |
+
"source": "issuer:amex_offers",
|
| 74 |
+
"last_verified": "2026-06-29"
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"id": "of_1a608672de99",
|
| 78 |
+
"merchant_key": "bigbasket",
|
| 79 |
+
"merchant_name": "BigBasket",
|
| 80 |
+
"applies_to_issuer": "SBI Card",
|
| 81 |
+
"card_id": null,
|
| 82 |
+
"type": "pct",
|
| 83 |
+
"value": 5,
|
| 84 |
+
"min_spend": 1500,
|
| 85 |
+
"max_discount": 500,
|
| 86 |
+
"categories": [
|
| 87 |
+
"groceries"
|
| 88 |
+
],
|
| 89 |
+
"channel": "online",
|
| 90 |
+
"valid_from": "2026-06-29",
|
| 91 |
+
"valid_to": "2026-09-27",
|
| 92 |
+
"source": "issuer:sbi",
|
| 93 |
+
"last_verified": "2026-06-29"
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "of_4e9eb395f8cd",
|
| 97 |
+
"merchant_key": "reliance_digital",
|
| 98 |
+
"merchant_name": "Reliance Digital",
|
| 99 |
+
"applies_to_issuer": "Axis Bank",
|
| 100 |
+
"card_id": null,
|
| 101 |
+
"type": "pct",
|
| 102 |
+
"value": 10,
|
| 103 |
+
"min_spend": 30000,
|
| 104 |
+
"max_discount": 5000,
|
| 105 |
+
"categories": null,
|
| 106 |
+
"channel": "both",
|
| 107 |
+
"valid_from": "2026-06-29",
|
| 108 |
+
"valid_to": "2026-09-27",
|
| 109 |
+
"source": "issuer:axis",
|
| 110 |
+
"last_verified": "2026-06-29"
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"id": "of_e497f0ddbb1f",
|
| 114 |
+
"merchant_key": "amazon",
|
| 115 |
+
"merchant_name": "Amazon",
|
| 116 |
+
"applies_to_issuer": "",
|
| 117 |
+
"card_id": "icici_amazon_pay",
|
| 118 |
+
"type": "cashback",
|
| 119 |
+
"value": 5,
|
| 120 |
+
"min_spend": 0.0,
|
| 121 |
+
"max_discount": null,
|
| 122 |
+
"categories": null,
|
| 123 |
+
"channel": "online",
|
| 124 |
+
"valid_from": "2026-06-29",
|
| 125 |
+
"valid_to": "2026-08-28",
|
| 126 |
+
"source": "clo:fidel",
|
| 127 |
+
"last_verified": "2026-06-29"
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"id": "of_9eb6ab9699af",
|
| 131 |
+
"merchant_key": "dmart",
|
| 132 |
+
"merchant_name": "DMart",
|
| 133 |
+
"applies_to_issuer": "ICICI Bank",
|
| 134 |
+
"card_id": null,
|
| 135 |
+
"type": "pct",
|
| 136 |
+
"value": 5,
|
| 137 |
+
"min_spend": 0.0,
|
| 138 |
+
"max_discount": 300,
|
| 139 |
+
"categories": [
|
| 140 |
+
"groceries"
|
| 141 |
+
],
|
| 142 |
+
"channel": "offline",
|
| 143 |
+
"valid_from": "2026-06-29",
|
| 144 |
+
"valid_to": "2026-08-28",
|
| 145 |
+
"source": "clo:fidel",
|
| 146 |
+
"last_verified": "2026-06-29"
|
| 147 |
+
},
|
| 148 |
+
{
|
| 149 |
+
"id": "of_ca586882fdc4",
|
| 150 |
+
"merchant_key": "pharmeasy",
|
| 151 |
+
"merchant_name": "PharmEasy",
|
| 152 |
+
"applies_to_issuer": "Axis Bank",
|
| 153 |
+
"card_id": null,
|
| 154 |
+
"type": "pct",
|
| 155 |
+
"value": 15,
|
| 156 |
+
"min_spend": 0.0,
|
| 157 |
+
"max_discount": 300,
|
| 158 |
+
"categories": [
|
| 159 |
+
"pharmacy"
|
| 160 |
+
],
|
| 161 |
+
"channel": "online",
|
| 162 |
+
"valid_from": "2026-06-29",
|
| 163 |
+
"valid_to": "2026-08-28",
|
| 164 |
+
"source": "clo:kard",
|
| 165 |
+
"last_verified": "2026-06-29"
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"id": "of_d1ab6ac2e3c6",
|
| 169 |
+
"merchant_key": "amazon",
|
| 170 |
+
"merchant_name": "Amazon",
|
| 171 |
+
"applies_to_issuer": "SBI Card",
|
| 172 |
+
"card_id": null,
|
| 173 |
+
"type": "pct",
|
| 174 |
+
"value": 10,
|
| 175 |
+
"min_spend": 5000,
|
| 176 |
+
"max_discount": 1500,
|
| 177 |
+
"categories": null,
|
| 178 |
+
"channel": "online",
|
| 179 |
+
"valid_from": "2026-06-29",
|
| 180 |
+
"valid_to": "2026-08-13",
|
| 181 |
+
"source": "affiliate:cashkaro",
|
| 182 |
+
"last_verified": "2026-06-29"
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"id": "of_daea6c79fe6c",
|
| 186 |
+
"merchant_key": "myntra",
|
| 187 |
+
"merchant_name": "Myntra",
|
| 188 |
+
"applies_to_issuer": "HDFC Bank",
|
| 189 |
+
"card_id": null,
|
| 190 |
+
"type": "pct",
|
| 191 |
+
"value": 10,
|
| 192 |
+
"min_spend": 3000,
|
| 193 |
+
"max_discount": 1000,
|
| 194 |
+
"categories": [
|
| 195 |
+
"apparel"
|
| 196 |
+
],
|
| 197 |
+
"channel": "online",
|
| 198 |
+
"valid_from": "2026-06-29",
|
| 199 |
+
"valid_to": "2026-08-13",
|
| 200 |
+
"source": "affiliate:cashkaro",
|
| 201 |
+
"last_verified": "2026-06-29"
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
"id": "of_b65a45aa94a6",
|
| 205 |
+
"merchant_key": "oyo",
|
| 206 |
+
"merchant_name": "OYO",
|
| 207 |
+
"applies_to_issuer": "HDFC Bank",
|
| 208 |
+
"card_id": null,
|
| 209 |
+
"type": "pct",
|
| 210 |
+
"value": 15,
|
| 211 |
+
"min_spend": 0.0,
|
| 212 |
+
"max_discount": 1000,
|
| 213 |
+
"categories": [
|
| 214 |
+
"travel_hotels"
|
| 215 |
+
],
|
| 216 |
+
"channel": "online",
|
| 217 |
+
"valid_from": "2026-06-29",
|
| 218 |
+
"valid_to": "2026-08-13",
|
| 219 |
+
"source": "affiliate:grabon",
|
| 220 |
+
"last_verified": "2026-06-29"
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"id": "of_e06d7fa78d17",
|
| 224 |
+
"merchant_key": "nykaa",
|
| 225 |
+
"merchant_name": "Nykaa",
|
| 226 |
+
"applies_to_issuer": "SBI Card",
|
| 227 |
+
"card_id": null,
|
| 228 |
+
"type": "pct",
|
| 229 |
+
"value": 10,
|
| 230 |
+
"min_spend": 2500,
|
| 231 |
+
"max_discount": 750,
|
| 232 |
+
"categories": null,
|
| 233 |
+
"channel": "online",
|
| 234 |
+
"valid_from": "2026-06-29",
|
| 235 |
+
"valid_to": "2026-08-13",
|
| 236 |
+
"source": "affiliate:grabon",
|
| 237 |
+
"last_verified": "2026-06-29"
|
| 238 |
+
}
|
| 239 |
+
]
|
|
@@ -59,6 +59,11 @@ def _persona_line(card_id: str, persona):
|
|
| 59 |
|
| 60 |
|
| 61 |
def apply_url_for(card_id: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
card = CATALOGUE_BY_ID.get(card_id)
|
| 63 |
import urllib.parse
|
| 64 |
q = urllib.parse.quote(f"{card.name if card else ''} credit card apply {card.issuer if card else ''}")
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
def apply_url_for(card_id: str) -> str:
|
| 62 |
+
# Official issuer page when we have it; web search only as a last resort.
|
| 63 |
+
from apply_links import APPLY_URL
|
| 64 |
+
direct = APPLY_URL.get(card_id)
|
| 65 |
+
if direct:
|
| 66 |
+
return direct
|
| 67 |
card = CATALOGUE_BY_ID.get(card_id)
|
| 68 |
import urllib.parse
|
| 69 |
q = urllib.parse.quote(f"{card.name if card else ''} credit card apply {card.issuer if card else ''}")
|