sammy786 commited on
Commit
5ffc0dd
Β·
1 Parent(s): ba9decf

RuPay-on-UPI, economics, raw-total ranking, merchant fix, remove debug

Browse files
app/card_catalogue.py CHANGED
@@ -55,6 +55,9 @@ class Card:
55
  highlights: List[str] = field(default_factory=list)
56
  persona_fit: List[str] = field(default_factory=list)
57
  annual_fee_text: str = ""
 
 
 
58
 
59
  def effective_rate(self, category: str) -> float:
60
  """Reward units earned per INR 100 for a given category."""
@@ -62,6 +65,18 @@ class Card:
62
  return 0.0
63
  return self.category_rates.get(category, self.base_rate)
64
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def value_per_100(self, category: str) -> float:
66
  """Net INR value earned per INR 100 spent in a category."""
67
  return self.effective_rate(category) * self.point_value_inr
@@ -341,6 +356,62 @@ CATALOGUE: List[Card] = [
341
  persona_fit=["young_professional", "food_lover", "value"],
342
  annual_fee_text="INR 500 + GST",
343
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  ]
345
 
346
 
@@ -362,6 +433,9 @@ CARD_ECON = {
362
  "amex_mrcc": {"annual_fee": 4500, "fee_waiver_spend": None, "welcome_bonus_inr": 450, "welcome_bonus_min_spend": 20000, "milestones": [{"spend": 180000, "value": 8100}]},
363
  "amex_platinum_travel": {"annual_fee": 5000, "fee_waiver_spend": None, "welcome_bonus_inr": 0, "welcome_bonus_min_spend": 0, "milestones": [{"spend": 190000, "value": 11250}, {"spend": 400000, "value": 22000}]},
364
  "hdfc_swiggy": {"annual_fee": 500, "fee_waiver_spend": 200000, "welcome_bonus_inr": 1500, "welcome_bonus_min_spend": 0, "milestones": []},
 
 
 
365
  }
366
 
367
 
 
55
  highlights: List[str] = field(default_factory=list)
56
  persona_fit: List[str] = field(default_factory=list)
57
  annual_fee_text: str = ""
58
+ upi_eligible: bool = False # RuPay credit cards can be linked to UPI
59
+ upi_base_rate: Optional[float] = None # some UPI-first cards earn more on UPI
60
+ upi_category_rates: Dict[str, float] = field(default_factory=dict)
61
 
62
  def effective_rate(self, category: str) -> float:
63
  """Reward units earned per INR 100 for a given category."""
 
65
  return 0.0
66
  return self.category_rates.get(category, self.base_rate)
67
 
68
+ def upi_effective_rate(self, category: str) -> float:
69
+ """Reward units per INR 100 on the UPI rail (UPI-specific rates take priority)."""
70
+ if category in self.excluded_categories:
71
+ return 0.0
72
+ if category in self.upi_category_rates:
73
+ return self.upi_category_rates[category]
74
+ if category in self.category_rates:
75
+ return self.category_rates[category]
76
+ if self.upi_base_rate is not None:
77
+ return self.upi_base_rate
78
+ return self.base_rate
79
+
80
  def value_per_100(self, category: str) -> float:
81
  """Net INR value earned per INR 100 spent in a category."""
82
  return self.effective_rate(category) * self.point_value_inr
 
356
  persona_fit=["young_professional", "food_lover", "value"],
357
  annual_fee_text="INR 500 + GST",
358
  ),
359
+ # ---- RuPay credit cards (linkable to UPI) ----
360
+ Card(
361
+ id="tataneu_infinity",
362
+ name="Tata Neu Infinity HDFC (RuPay)",
363
+ issuer="HDFC Bank",
364
+ network="RuPay",
365
+ segment="mid",
366
+ reward_unit="cashback",
367
+ point_value_inr=1.0,
368
+ base_rate=1.5,
369
+ category_rates={"online_shopping": 5.0, "groceries": 5.0},
370
+ caps={"online_shopping": 2000, "groceries": 2000},
371
+ excluded_categories=["fuel", "rent", "wallet_load"],
372
+ fees=FeeStructure(1499, 1499, 300000),
373
+ highlights=["1.5% NeuCoins on UPI spends", "5% on Tata Neu & partners", "UPI-linked"],
374
+ persona_fit=["online_shopper", "value", "young_professional"],
375
+ annual_fee_text="INR 1,499 + GST, waived on INR 3L spend",
376
+ upi_eligible=True,
377
+ ),
378
+ Card(
379
+ id="kiwi_axis",
380
+ name="Kiwi (Axis Bank) RuPay",
381
+ issuer="Axis Bank",
382
+ network="RuPay",
383
+ segment="entry",
384
+ reward_unit="cashback",
385
+ point_value_inr=1.0,
386
+ base_rate=1.0,
387
+ category_rates={"online_shopping": 2.0, "dining": 2.0, "groceries": 2.0, "utilities": 2.0},
388
+ caps={"online_shopping": 1000, "dining": 1000, "groceries": 1000, "utilities": 1000},
389
+ excluded_categories=["rent", "wallet_load"],
390
+ fees=FeeStructure(0, 0, None),
391
+ highlights=["Built for credit-on-UPI", "2% Neon cashback on UPI (vs 1% on swipe)", "Lifetime free"],
392
+ persona_fit=["young_professional", "value", "online_shopper"],
393
+ annual_fee_text="Lifetime free",
394
+ upi_eligible=True,
395
+ upi_base_rate=2.0, # earns 2% on UPI vs 1% base on a normal swipe
396
+ ),
397
+ Card(
398
+ id="sbi_rupay_select",
399
+ name="SBI Card (RuPay) SELECT",
400
+ issuer="SBI Card",
401
+ network="RuPay",
402
+ segment="mid",
403
+ reward_unit="points",
404
+ point_value_inr=0.25,
405
+ base_rate=2.0,
406
+ category_rates={"dining": 5.0, "online_shopping": 5.0, "entertainment": 5.0},
407
+ caps={"dining": 5000, "online_shopping": 5000, "entertainment": 5000},
408
+ excluded_categories=["fuel", "rent", "wallet_load"],
409
+ fees=FeeStructure(499, 499, 100000),
410
+ highlights=["5x on dining & online via UPI", "RuPay UPI-linked", "Movie offers"],
411
+ persona_fit=["mid_spender", "lifestyle", "value"],
412
+ annual_fee_text="INR 499 + GST, waived on INR 1L spend",
413
+ upi_eligible=True,
414
+ ),
415
  ]
416
 
417
 
 
433
  "amex_mrcc": {"annual_fee": 4500, "fee_waiver_spend": None, "welcome_bonus_inr": 450, "welcome_bonus_min_spend": 20000, "milestones": [{"spend": 180000, "value": 8100}]},
434
  "amex_platinum_travel": {"annual_fee": 5000, "fee_waiver_spend": None, "welcome_bonus_inr": 0, "welcome_bonus_min_spend": 0, "milestones": [{"spend": 190000, "value": 11250}, {"spend": 400000, "value": 22000}]},
435
  "hdfc_swiggy": {"annual_fee": 500, "fee_waiver_spend": 200000, "welcome_bonus_inr": 1500, "welcome_bonus_min_spend": 0, "milestones": []},
436
+ "tataneu_infinity": {"annual_fee": 1499, "fee_waiver_spend": 300000, "welcome_bonus_inr": 1499, "welcome_bonus_min_spend": 0, "milestones": []},
437
+ "kiwi_axis": {"annual_fee": 0, "fee_waiver_spend": None, "welcome_bonus_inr": 0, "welcome_bonus_min_spend": 0, "milestones": []},
438
+ "sbi_rupay_select": {"annual_fee": 499, "fee_waiver_spend": 100000, "welcome_bonus_inr": 500, "welcome_bonus_min_spend": 0, "milestones": []},
439
  }
440
 
441
 
app/demo_data.py CHANGED
@@ -1,6 +1,6 @@
1
  """Seed data so the app demos flawlessly with zero setup."""
2
 
3
- DEMO_WALLET = ["hdfc_millennia", "sbi_cashback", "axis_ace", "amex_mrcc"]
4
 
5
  DEMO_PERSONA = ["young_professional", "online_shopper", "traveller"]
6
 
 
1
  """Seed data so the app demos flawlessly with zero setup."""
2
 
3
+ DEMO_WALLET = ["hdfc_millennia", "sbi_cashback", "axis_ace", "amex_mrcc", "tataneu_infinity", "kiwi_axis"]
4
 
5
  DEMO_PERSONA = ["young_professional", "online_shopper", "traveller"]
6
 
app/main.py CHANGED
@@ -61,6 +61,7 @@ class RecommendRequest(BaseModel):
61
  amount: Optional[float] = None
62
  merchant: Optional[str] = None
63
  persona: Optional[List[str]] = None
 
64
 
65
 
66
  class AnalyzeRequest(BaseModel):
@@ -118,6 +119,7 @@ def recommend(req: RecommendRequest):
118
  held = req.held_cards or demo_data.DEMO_WALLET
119
  persona = req.persona or demo_data.DEMO_PERSONA
120
 
 
121
  if req.query:
122
  intent = llm.parse_intent(req.query)
123
  category = intent["category"]
@@ -125,6 +127,7 @@ def recommend(req: RecommendRequest):
125
  merchant = intent.get("merchant")
126
  offers = intent.get("offers") or []
127
  brand_key = intent.get("brand_key")
 
128
  else:
129
  category = req.category or "general"
130
  amount = req.amount or 5000
@@ -132,14 +135,15 @@ def recommend(req: RecommendRequest):
132
  resolved = resolve_merchant(merchant or "")
133
  offers = resolved["offers"]
134
  brand_key = resolved["brand_key"]
 
135
 
136
  ctx = TxnContext(category=category, amount=float(amount), merchant=merchant,
137
- brand_key=brand_key, offers=offers)
138
  result = score_transaction(held, ctx, include_discovery=True, persona=persona)
139
  result["narration"] = llm.narrate_recommendation(
140
  result["context"], result["held_ranked"], result["discovery"]
141
  )
142
- result["intent"] = {"category": category, "amount": amount, "merchant": merchant}
143
  return result
144
 
145
 
 
61
  amount: Optional[float] = None
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):
 
119
  held = req.held_cards or demo_data.DEMO_WALLET
120
  persona = req.persona or demo_data.DEMO_PERSONA
121
 
122
+ from merchants import detect_rail
123
  if req.query:
124
  intent = llm.parse_intent(req.query)
125
  category = intent["category"]
 
127
  merchant = intent.get("merchant")
128
  offers = intent.get("offers") or []
129
  brand_key = intent.get("brand_key")
130
+ rail = req.rail or detect_rail(req.query) or "card"
131
  else:
132
  category = req.category or "general"
133
  amount = req.amount or 5000
 
135
  resolved = resolve_merchant(merchant or "")
136
  offers = resolved["offers"]
137
  brand_key = resolved["brand_key"]
138
+ rail = req.rail or "card"
139
 
140
  ctx = TxnContext(category=category, amount=float(amount), merchant=merchant,
141
+ brand_key=brand_key, offers=offers, rail=rail)
142
  result = score_transaction(held, ctx, include_discovery=True, persona=persona)
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
 
app/merchants.py CHANGED
@@ -167,6 +167,19 @@ def resolve_merchant(text: str) -> Dict:
167
  }
168
 
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  def extract_amount(text: str) -> Optional[float]:
171
  """Pull an INR amount out of a spoken phrase, if present."""
172
  import re
 
167
  }
168
 
169
 
170
+ UPI_HINTS = ["upi", "scan", " qr", "qr code", "gpay", "google pay", "phonepe", "paytm", "bhim", "rupay"]
171
+
172
+
173
+ def detect_rail(text: str):
174
+ """Return 'upi', 'card', or None based on payment-method hints in the phrase."""
175
+ t = (text or "").lower()
176
+ if any(h in t for h in UPI_HINTS):
177
+ return "upi"
178
+ if "swipe" in t or "tap" in t or "card" in t:
179
+ return "card"
180
+ return None
181
+
182
+
183
  def extract_amount(text: str) -> Optional[float]:
184
  """Pull an INR amount out of a spoken phrase, if present."""
185
  import re
app/scoring_engine.py CHANGED
@@ -57,6 +57,12 @@ def apply_url_for(card_id: str) -> str:
57
  return f"https://www.google.com/search?q={q}"
58
 
59
 
 
 
 
 
 
 
60
  @dataclass
61
  class TxnContext:
62
  category: str
@@ -64,6 +70,7 @@ class TxnContext:
64
  merchant: Optional[str] = None
65
  brand_key: Optional[str] = None
66
  offers: Optional[List[Dict]] = None
 
67
 
68
 
69
  @dataclass
@@ -74,7 +81,8 @@ class CardScore:
74
  network: str
75
  reward_value_inr: float # rewards earned on this txn (capped)
76
  instant_offer_inr: float # instant discount from live offers
77
- total_value_inr: float # reward + instant offer
 
78
  effective_rate_pct: float # total value as % of spend
79
  capped: bool # whether monthly cap limited the reward
80
  reasons: List[str]
@@ -123,23 +131,39 @@ def score_transaction(
123
  held_scores: List[CardScore] = []
124
  all_scores: List[CardScore] = []
125
 
 
 
126
  for card in all_cards():
127
  held = card.id in held_card_ids
 
 
 
 
 
 
 
 
 
 
 
128
  # --- reward value, respecting monthly caps ---
129
- rate = card.effective_rate(ctx.category) # reward units / 100
 
 
 
 
 
130
  gross_units = ctx.amount / 100.0 * rate
131
  capped = False
132
 
133
  cap_units = card.caps.get(ctx.category)
134
- if cap_units is not None:
135
  spent = mtd_spend.get(card.id, {}).get(ctx.category, 0.0)
136
  already_units = spent / 100.0 * rate
137
  remaining_units = max(0.0, cap_units - already_units)
138
  if gross_units > remaining_units:
139
- # accelerated portion capped; remainder earns base rate
140
  accel_units = remaining_units
141
  base_units_value = card.base_rate if ctx.category not in card.excluded_categories else 0.0
142
- # rupees beyond the cap earn base rate
143
  rupees_at_accel = remaining_units / rate * 100.0 if rate > 0 else 0.0
144
  rupees_beyond = max(0.0, ctx.amount - rupees_at_accel)
145
  gross_units = accel_units + rupees_beyond / 100.0 * base_units_value
@@ -147,15 +171,20 @@ def score_transaction(
147
 
148
  reward_inr = gross_units * card.point_value_inr
149
 
150
- # --- instant offers ---
151
- offer_inr, offer_note = _instant_offer_value(card, ctx)
 
 
 
152
 
153
  total = reward_inr + offer_inr
154
  eff_pct = (total / ctx.amount * 100.0) if ctx.amount else 0.0
155
 
156
  # --- reasons ---
157
  reasons: List[str] = []
158
- if ctx.category in card.excluded_categories:
 
 
159
  reasons.append(f"{ctx.category.replace('_', ' ').title()} is excluded on this card β€” earns nothing.")
160
  else:
161
  reasons.append(
@@ -176,6 +205,7 @@ def score_transaction(
176
  reward_value_inr=round(reward_inr, 2),
177
  instant_offer_inr=round(offer_inr, 2),
178
  total_value_inr=round(total, 2),
 
179
  effective_rate_pct=round(eff_pct, 2),
180
  capped=capped,
181
  reasons=reasons,
@@ -185,9 +215,9 @@ def score_transaction(
185
  if held:
186
  held_scores.append(sc)
187
 
188
- # sort best-first
189
- held_scores.sort(key=lambda s: s.total_value_inr, reverse=True)
190
- all_scores.sort(key=lambda s: s.total_value_inr, reverse=True)
191
 
192
  # --- personalized, comparative reasoning for why each card ranks where it does ---
193
  def _money(n: float) -> str:
@@ -195,23 +225,36 @@ def score_transaction(
195
 
196
  if held_scores:
197
  top = held_scores[0]
 
198
  for i, s in enumerate(held_scores):
199
  lead = []
 
200
  if i == 0:
201
- if len(held_scores) > 1:
202
  lead.append(
203
- f"Your #1 here β€” returns {_money(top.total_value_inr - held_scores[1].total_value_inr)} "
204
- f"more than your next-best card ({held_scores[1].card_name})."
 
205
  )
 
 
 
 
 
 
 
 
 
 
206
  else:
207
  lead.append("Your best card for this purchase.")
208
- pl = _persona_line(s.card_id, persona)
209
- else:
210
- lead.append(
211
- f"Ranks #{i+1} β€” {_money(top.total_value_inr - s.total_value_inr)} "
212
- f"less than {top.card_name} on this spend."
213
- )
214
- pl = None
215
  s.reasons = lead + s.reasons + ([pl] if pl else [])
216
 
217
  # discovery: best non-held card that beats the user's best held card by a margin
@@ -265,6 +308,7 @@ def score_transaction(
265
  "merchant": ctx.merchant,
266
  "brand_key": ctx.brand_key,
267
  "offers": ctx.offers or [],
 
268
  },
269
  }
270
 
@@ -284,37 +328,47 @@ def analyse_transaction_history(
284
  mtd: Dict[str, Dict[str, float]] = {}
285
 
286
  for txn in transactions:
287
- ctx = TxnContext(
288
- category=txn["category"],
289
- amount=float(txn["amount"]),
290
- merchant=txn.get("merchant"),
291
- brand_key=txn.get("brand_key"),
292
- offers=txn.get("offers"),
293
- )
294
- scored = score_transaction(held_card_ids, ctx, mtd_spend=mtd, include_discovery=False)
295
- ranked = scored["held_ranked"]
 
 
296
  used_id = txn.get("card_id_used")
297
- used = next((r for r in ranked if r["card_id"] == used_id), None)
298
- best = ranked[0] if ranked else None
 
 
 
 
 
 
 
299
 
300
  lost = 0.0
301
- optimal = True
302
- if used and best:
303
- lost = round(best["total_value_inr"] - used["total_value_inr"], 2)
304
- optimal = lost <= 0.01
305
  total_lost += max(0.0, lost)
306
 
307
- # update MTD spend
308
  if used_id:
309
- mtd.setdefault(used_id, {}).setdefault(ctx.category, 0.0)
310
- mtd[used_id][ctx.category] += ctx.amount
311
 
312
  results.append({
313
  **txn,
314
  "used_card": used,
315
- "optimal_card": best,
316
- "was_optimal": optimal,
317
  "amount_lost": max(0.0, lost),
 
 
318
  })
319
 
320
  return {
 
57
  return f"https://www.google.com/search?q={q}"
58
 
59
 
60
+ # RuPay credit-on-UPI rules (NPCI): rewards only accrue at/above the interchange
61
+ # floor, and these categories earn nothing on the UPI rail.
62
+ UPI_MIN_REWARD_AMOUNT = 2000
63
+ UPI_EXCLUDED_CATEGORIES = {"fuel", "rent", "wallet_load"}
64
+
65
+
66
  @dataclass
67
  class TxnContext:
68
  category: str
 
70
  merchant: Optional[str] = None
71
  brand_key: Optional[str] = None
72
  offers: Optional[List[Dict]] = None
73
+ rail: str = "card" # "card" (POS/online) or "upi" (RuPay credit-on-UPI)
74
 
75
 
76
  @dataclass
 
81
  network: str
82
  reward_value_inr: float # rewards earned on this txn (capped)
83
  instant_offer_inr: float # instant discount from live offers
84
+ total_value_inr: float # reward + instant offer (rounded for display)
85
+ raw_total: float # unrounded total β€” used for ranking
86
  effective_rate_pct: float # total value as % of spend
87
  capped: bool # whether monthly cap limited the reward
88
  reasons: List[str]
 
131
  held_scores: List[CardScore] = []
132
  all_scores: List[CardScore] = []
133
 
134
+ rail = getattr(ctx, "rail", "card") or "card"
135
+
136
  for card in all_cards():
137
  held = card.id in held_card_ids
138
+
139
+ # --- RuPay credit-on-UPI eligibility ---
140
+ upi_block = None
141
+ if rail == "upi":
142
+ if not card.upi_eligible:
143
+ upi_block = f"{card.network} cards can't be linked to UPI β€” only RuPay credit cards work on UPI."
144
+ elif ctx.amount < UPI_MIN_REWARD_AMOUNT:
145
+ upi_block = f"UPI spends under β‚Ή{UPI_MIN_REWARD_AMOUNT:,.0f} earn no rewards (issuers get no interchange below this)."
146
+ elif ctx.category in UPI_EXCLUDED_CATEGORIES:
147
+ upi_block = f"{ctx.category.replace('_', ' ').title()} earns nothing on the UPI rail (excluded category)."
148
+
149
  # --- reward value, respecting monthly caps ---
150
+ if upi_block:
151
+ rate = 0.0
152
+ elif rail == "upi":
153
+ rate = card.upi_effective_rate(ctx.category)
154
+ else:
155
+ rate = card.effective_rate(ctx.category)
156
  gross_units = ctx.amount / 100.0 * rate
157
  capped = False
158
 
159
  cap_units = card.caps.get(ctx.category)
160
+ if not upi_block and cap_units is not None and rate > 0:
161
  spent = mtd_spend.get(card.id, {}).get(ctx.category, 0.0)
162
  already_units = spent / 100.0 * rate
163
  remaining_units = max(0.0, cap_units - already_units)
164
  if gross_units > remaining_units:
 
165
  accel_units = remaining_units
166
  base_units_value = card.base_rate if ctx.category not in card.excluded_categories else 0.0
 
167
  rupees_at_accel = remaining_units / rate * 100.0 if rate > 0 else 0.0
168
  rupees_beyond = max(0.0, ctx.amount - rupees_at_accel)
169
  gross_units = accel_units + rupees_beyond / 100.0 * base_units_value
 
171
 
172
  reward_inr = gross_units * card.point_value_inr
173
 
174
+ # --- instant offers (don't apply on the UPI rail) ---
175
+ if rail == "upi":
176
+ offer_inr, offer_note = 0.0, None
177
+ else:
178
+ offer_inr, offer_note = _instant_offer_value(card, ctx)
179
 
180
  total = reward_inr + offer_inr
181
  eff_pct = (total / ctx.amount * 100.0) if ctx.amount else 0.0
182
 
183
  # --- reasons ---
184
  reasons: List[str] = []
185
+ if upi_block:
186
+ reasons.append(upi_block)
187
+ elif ctx.category in card.excluded_categories:
188
  reasons.append(f"{ctx.category.replace('_', ' ').title()} is excluded on this card β€” earns nothing.")
189
  else:
190
  reasons.append(
 
205
  reward_value_inr=round(reward_inr, 2),
206
  instant_offer_inr=round(offer_inr, 2),
207
  total_value_inr=round(total, 2),
208
+ raw_total=total,
209
  effective_rate_pct=round(eff_pct, 2),
210
  capped=capped,
211
  reasons=reasons,
 
215
  if held:
216
  held_scores.append(sc)
217
 
218
+ # sort best-first by the unrounded total (display rounding must not flip ties)
219
+ held_scores.sort(key=lambda s: s.raw_total, reverse=True)
220
+ all_scores.sort(key=lambda s: s.raw_total, reverse=True)
221
 
222
  # --- personalized, comparative reasoning for why each card ranks where it does ---
223
  def _money(n: float) -> str:
 
225
 
226
  if held_scores:
227
  top = held_scores[0]
228
+ none_earn = top.total_value_inr <= 0
229
  for i, s in enumerate(held_scores):
230
  lead = []
231
+ pl = None
232
  if i == 0:
233
+ if none_earn:
234
  lead.append(
235
+ "None of your cards earn rewards on this UPI payment β€” here's why."
236
+ if rail == "upi" else
237
+ "None of your cards earn rewards on this purchase."
238
  )
239
+ elif len(held_scores) > 1:
240
+ d = top.total_value_inr - held_scores[1].total_value_inr
241
+ if d > 0:
242
+ lead.append(
243
+ f"Your #1 here β€” returns {_money(d)} "
244
+ f"more than your next-best card ({held_scores[1].card_name})."
245
+ )
246
+ else:
247
+ lead.append(f"Tied best with {held_scores[1].card_name} β€” both earn the same here.")
248
+ pl = _persona_line(s.card_id, persona)
249
  else:
250
  lead.append("Your best card for this purchase.")
251
+ pl = _persona_line(s.card_id, persona)
252
+ elif not none_earn and s.total_value_inr > 0:
253
+ d = top.total_value_inr - s.total_value_inr
254
+ if d > 0:
255
+ lead.append(f"Ranks #{i+1} β€” {_money(d)} less than {top.card_name} on this spend.")
256
+ else:
257
+ lead.append(f"Ties {top.card_name} here β€” same value.")
258
  s.reasons = lead + s.reasons + ([pl] if pl else [])
259
 
260
  # discovery: best non-held card that beats the user's best held card by a margin
 
308
  "merchant": ctx.merchant,
309
  "brand_key": ctx.brand_key,
310
  "offers": ctx.offers or [],
311
+ "rail": rail,
312
  },
313
  }
314
 
 
328
  mtd: Dict[str, Dict[str, float]] = {}
329
 
330
  for txn in transactions:
331
+ def _ctx(rail):
332
+ return TxnContext(
333
+ category=txn["category"], amount=float(txn["amount"]),
334
+ merchant=txn.get("merchant"), brand_key=txn.get("brand_key"),
335
+ offers=txn.get("offers"), rail=rail,
336
+ )
337
+ # statement transactions were card swipes -> "used" is on the card rail;
338
+ # also check the UPI (RuPay) rail to find the true cross-rail optimum.
339
+ scored_card = score_transaction(held_card_ids, _ctx("card"), mtd_spend=mtd, include_discovery=False)
340
+ scored_upi = score_transaction(held_card_ids, _ctx("upi"), mtd_spend=mtd, include_discovery=False)
341
+ ranked_card = scored_card["held_ranked"]
342
  used_id = txn.get("card_id_used")
343
+ used = next((r for r in ranked_card if r["card_id"] == used_id), None)
344
+ best_card = ranked_card[0] if ranked_card else None
345
+ best_upi = scored_upi["held_ranked"][0] if scored_upi["held_ranked"] else None
346
+
347
+ optimal = best_card
348
+ optimal_rail = "card"
349
+ if best_upi and best_upi["total_value_inr"] > (best_card["total_value_inr"] if best_card else 0):
350
+ optimal = best_upi
351
+ optimal_rail = "upi"
352
 
353
  lost = 0.0
354
+ was_optimal = True
355
+ if used and optimal:
356
+ lost = round(optimal["total_value_inr"] - used["total_value_inr"], 2)
357
+ was_optimal = lost <= 0.01
358
  total_lost += max(0.0, lost)
359
 
 
360
  if used_id:
361
+ mtd.setdefault(used_id, {}).setdefault(txn["category"], 0.0)
362
+ mtd[used_id][txn["category"]] += float(txn["amount"])
363
 
364
  results.append({
365
  **txn,
366
  "used_card": used,
367
+ "optimal_card": optimal,
368
+ "was_optimal": was_optimal,
369
  "amount_lost": max(0.0, lost),
370
+ "optimal_rail": optimal_rail,
371
+ "rail_switch_helps": optimal_rail == "upi" and not was_optimal,
372
  })
373
 
374
  return {