sammy786 commited on
Commit
faf005c
·
1 Parent(s): 7f1c52e

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 ADDED
@@ -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=
.gitignore CHANGED
@@ -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
app/apply_links.py ADDED
@@ -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
+ }
app/availability.py ADDED
@@ -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"
app/channels.py ADDED
@@ -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
+ }
app/connectors/__init__.py ADDED
@@ -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
+ """
app/connectors/affiliate_feeds.py ADDED
@@ -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)]
app/connectors/base.py ADDED
@@ -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
+ }
app/connectors/clo.py ADDED
@@ -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)]
app/connectors/ingest.py ADDED
@@ -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)
app/connectors/issuer_scrapers.py ADDED
@@ -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
+ ]
app/main.py CHANGED
@@ -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
- result["intent"] = {"category": category, "amount": amount, "merchant": merchant, "rail": rail}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, detect_credit_limit
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": f"https://rewardpilot.app/apply/{card_id}", # placeholder deep link
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 "
app/merchants.py CHANGED
@@ -1,118 +1,196 @@
1
  """
2
  RewardPilot - Merchant & MCC resolution
3
  =======================================
4
- Maps a free-text merchant / spoken phrase to a canonical category and detects
5
- live brand offers. In production this is backed by a continuously curated
6
- merchant table + a live offers feed; here it is a deterministic keyword resolver
7
- that is good enough for real demos and a real first version.
 
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
- # merchant keyword -> (canonical category, brand_key)
21
  MERCHANT_MAP: Dict[str, Tuple[str, Optional[str]]] = {
22
- # electronics / large format retail
23
- "croma": ("electronics", "croma"),
24
  "reliance digital": ("electronics", "reliance_digital"),
25
  "vijay sales": ("electronics", "vijay_sales"),
26
- "apple": ("electronics", "apple"),
27
  "imagine": ("electronics", "apple"),
 
28
  "iphone": ("electronics", "apple"),
29
- # online marketplaces
 
 
 
 
 
 
 
 
 
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
- # food / dining
 
 
 
 
 
 
 
 
 
 
38
  "swiggy": ("dining", "swiggy"),
39
  "instamart": ("groceries", "instamart"),
40
  "zomato": ("dining", "zomato"),
41
- "dominos": ("dining", None),
42
- "starbucks": ("dining", None),
43
- "mcdonald": ("dining", None),
44
- "restaurant": ("dining", None),
 
 
 
 
 
 
 
 
 
 
 
 
45
  "cafe": ("dining", None),
 
 
 
 
46
  "dineout": ("dining", "dineout"),
47
- # groceries
 
 
 
 
48
  "bigbasket": ("groceries", "bigbasket"),
49
- "blinkit": ("groceries", "blinkit"),
50
- "zepto": ("groceries", "zepto"),
51
  "dmart": ("groceries", "dmart"),
52
- "more": ("groceries", None),
 
 
 
 
 
53
  "grocery": ("groceries", None),
54
- # fuel
 
 
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
- # travel
 
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
- "flight": ("travel_flights", None),
73
- "hotel": ("travel_hotels", None),
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
- # apparel
86
- "zara": ("apparel", None),
87
- "h&m": ("apparel", None),
88
- "lifestyle": ("apparel", None),
89
- "shoppers stop": ("apparel", None),
90
- "marks": ("apparel", "marks_spencer"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  "westside": ("apparel", "westside"),
92
- # entertainment
 
 
 
 
 
 
 
 
93
  "bookmyshow": ("entertainment", "bookmyshow"),
94
  "pvr": ("entertainment", "pvr"),
95
- "inox": ("entertainment", "inox"),
96
- "netflix": ("entertainment", None),
 
 
97
  "movie": ("entertainment", None),
98
- # pharmacy / health
99
- "pharmeasy": ("pharmacy", None),
100
- "apollo": ("pharmacy", None),
101
- "1mg": ("pharmacy", None),
102
- "medplus": ("pharmacy", None),
 
 
103
  "pharmacy": ("pharmacy", None),
104
- # utilities / bills
 
 
 
 
105
  "electricity": ("utilities", None),
106
- "bill": ("utilities", None),
107
  "broadband": ("utilities", None),
108
  "mobile recharge": ("utilities", None),
 
109
  "dth": ("utilities", None),
110
- # education / insurance / rent
111
- "school": ("education", None),
112
- "college": ("education", None),
 
 
 
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
- # Lightweight live-offer table (brand_key -> offer text). In production this is a
155
- # feed refreshed continuously; for a demo it is curated and date-stamped.
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", "swiggy", "instamart", "zomato", "dominos", "starbucks", "bigbasket",
182
- "blinkit", "zepto", "dmart", "makemytrip", "goibibo", "indigo", "vistara", "oyo", "taj",
183
- "bookmyshow", "pvr", "inox", "apollo", "pharmeasy", "westside",
184
- "cleartrip", "yatra", "ixigo", "easemytrip", "abhibus", "redbus",
 
 
 
 
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
- Resolve a free-text phrase to category + brand + live offers.
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": 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))
app/offers.py CHANGED
@@ -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),
app/offers_data/curated_offers.json ADDED
@@ -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
+ ]
app/scoring_engine.py CHANGED
@@ -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 ''}")