Spaces:
Paused
Paused
RuPay-on-UPI, economics, raw-total ranking, merchant fix, remove debug
Browse files- app/card_catalogue.py +74 -0
- app/demo_data.py +1 -1
- app/main.py +6 -2
- app/merchants.py +13 -0
- app/scoring_engine.py +95 -41
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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.
|
| 190 |
-
all_scores.sort(key=lambda s: s.
|
| 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
|
| 202 |
lead.append(
|
| 203 |
-
|
| 204 |
-
|
|
|
|
| 205 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
else:
|
| 207 |
lead.append("Your best card for this purchase.")
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
f"less than {top.card_name} on this spend."
|
| 213 |
-
|
| 214 |
-
|
| 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 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
| 296 |
used_id = txn.get("card_id_used")
|
| 297 |
-
used = next((r for r in
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
lost = 0.0
|
| 301 |
-
|
| 302 |
-
if used and
|
| 303 |
-
lost = round(
|
| 304 |
-
|
| 305 |
total_lost += max(0.0, lost)
|
| 306 |
|
| 307 |
-
# update MTD spend
|
| 308 |
if used_id:
|
| 309 |
-
mtd.setdefault(used_id, {}).setdefault(
|
| 310 |
-
mtd[used_id][
|
| 311 |
|
| 312 |
results.append({
|
| 313 |
**txn,
|
| 314 |
"used_card": used,
|
| 315 |
-
"optimal_card":
|
| 316 |
-
"was_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 {
|