Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
offers: +356 verified market-sweep deals (2026-08-08)
Browse files- app/card_catalogue.py +15 -12
- app/channels.py +7 -0
- app/main.py +19 -1
- app/offers.py +36 -20
- app/offers_data/curated_offers.PRESWEEP-2026-08-08.bak.json +0 -0
- app/offers_data/curated_offers.json +0 -0
- app/rails.py +135 -0
- app/redemptions.py +138 -21
- app/scoring_engine.py +72 -13
- app/statement_parser.py +134 -0
- tests/hotel_totals_regress.py +110 -0
- tests/points_balance_regress.py +91 -0
app/card_catalogue.py
CHANGED
|
@@ -148,8 +148,11 @@ CATALOGUE: List[Card] = [
|
|
| 148 |
"travel_flights": 15000,
|
| 149 |
"travel_hotels": 15000,
|
| 150 |
"insurance": 10000,
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
| 153 |
portal_rates={"travel_flights": 10.0, "travel_hotels": 10.0}, # only via SmartBuy
|
| 154 |
portal_name="HDFC SmartBuy", portal_cap_units=15000,
|
| 155 |
excluded_categories=["fuel", "rent", "wallet_load"],
|
|
@@ -193,7 +196,7 @@ CATALOGUE: List[Card] = [
|
|
| 193 |
point_value_inr=1.0,
|
| 194 |
base_rate=1.0, online_boost_only=True, # 1% base cashback
|
| 195 |
category_rates={},
|
| 196 |
-
caps={},
|
| 197 |
brand_bonuses={"amazon": 5.0, "flipkart": 5.0, "swiggy": 5.0, "zomato": 5.0, "uber": 5.0, "myntra": 5.0, "tata_cliq": 5.0, "bookmyshow": 5.0, "cult_fit": 5.0, "sonyliv": 5.0}, # 5% on 10 brands only
|
| 198 |
brand_caps=[{"brands": ["amazon", "flipkart", "swiggy", "zomato", "uber", "myntra", "tata_cliq", "bookmyshow", "cult_fit", "sonyliv"], "cap": 1000}], # ₹1,000/mo shared
|
| 199 |
excluded_categories=["rent", "wallet_load", "fuel"],
|
|
@@ -217,9 +220,9 @@ CATALOGUE: List[Card] = [
|
|
| 217 |
"apparel": 5.0,
|
| 218 |
"dining": 5.0,
|
| 219 |
},
|
| 220 |
-
caps={},
|
| 221 |
-
cap_groups=[{"categories": ["online_shopping", "electronics", "apparel", "dining"], "cap": 2000}], #
|
| 222 |
-
excluded_categories=["rent", "wallet_load", "fuel", "utilities", "insurance"],
|
| 223 |
fees=FeeStructure(999, 999, 200000),
|
| 224 |
highlights=["Flat 5% on ALL online spends", "1% offline", "No merchant lock-in"],
|
| 225 |
persona_fit=["online_shopper", "value", "young_professional"],
|
|
@@ -253,7 +256,7 @@ CATALOGUE: List[Card] = [
|
|
| 253 |
base_rate=1.0, online_boost_only=True,
|
| 254 |
category_rates={"utilities": 2.0, "insurance": 2.0},
|
| 255 |
caps={},
|
| 256 |
-
excluded_categories=["rent", "wallet_load"],
|
| 257 |
brand_bonuses={"amazon": 5.0}, # 5% Amazon (Prime); 2% utilities/insurance/partners; 1% else
|
| 258 |
fees=FeeStructure(0, 0, None), # lifetime free
|
| 259 |
highlights=["5% on Amazon (Prime)", "2% on partner merchants", "Lifetime free"],
|
|
@@ -494,9 +497,9 @@ CATALOGUE: List[Card] = [
|
|
| 494 |
point_value_inr=1.0,
|
| 495 |
base_rate=1.5,
|
| 496 |
category_rates={},
|
| 497 |
-
brand_bonuses={"tata_neu": 5.0, "croma": 5.0, "bigbasket": 5.0, "tata_1mg": 5.0, "westside": 5.0, "air_india": 5.0, "tata_cliq": 5.0, "tata_play": 5.0}, upi_reward_cap_units=500, # 5% Tata brands; 1.5%
|
| 498 |
brand_caps=[{"brands": ["tata_neu", "croma", "bigbasket", "tata_1mg", "westside", "air_india", "tata_cliq", "tata_play"], "cap": 2000}],
|
| 499 |
-
caps={},
|
| 500 |
excluded_categories=["fuel", "rent", "wallet_load"],
|
| 501 |
fees=FeeStructure(1499, 1499, 300000),
|
| 502 |
highlights=["5% NeuCoins on Tata brands", "1.5% on other UPI/spends", "UPI-linked"],
|
|
@@ -622,7 +625,7 @@ CATALOGUE: List[Card] = [
|
|
| 622 |
Card(
|
| 623 |
id="au_zenith", name="AU Bank Zenith", issuer="AU Small Finance Bank", network="Visa", segment="premium",
|
| 624 |
reward_unit="points", point_value_inr=0.22, base_rate=3.0,
|
| 625 |
-
category_rates={"dining": 5.0, "groceries": 5.0}, caps={}, excluded_categories=["fuel", "rent", "wallet_load"], # devalued 1 Jan 2026; RP ~₹0.25 general / ₹0.20 voucher → modeled ₹0.22 net of ₹99 redemption fee
|
| 626 |
fees=FeeStructure(7999, 7999, 800000),
|
| 627 |
highlights=["Devalued Jan 2026: 5 RP dining/grocery", "3 RP/₹100 base", "Lounge access"], persona_fit=["premium", "traveller", "high_spender"], annual_fee_text="₹7,999 + GST · waived on ₹8L spend",
|
| 628 |
),
|
|
@@ -1195,7 +1198,7 @@ CATALOGUE += [
|
|
| 1195 |
id="hdfc_irctc", name="IRCTC HDFC Bank Credit Card", issuer="HDFC Bank", network="RuPay",
|
| 1196 |
segment="entry", reward_unit="points", point_value_inr=1, base_rate=1,
|
| 1197 |
category_rates={},
|
| 1198 |
-
caps={
|
| 1199 |
excluded_categories=["fuel", "rent", "wallet_load", "education"],
|
| 1200 |
brand_bonuses={"irctc": 5},
|
| 1201 |
portal_rates={"transport": 5}, portal_name="SmartBuy",
|
|
@@ -1276,7 +1279,7 @@ CATALOGUE += [
|
|
| 1276 |
id="hdfc_tata_neu_plus", name="Tata Neu Plus HDFC Bank Credit Card", issuer="HDFC Bank", network="RuPay",
|
| 1277 |
segment="entry", reward_unit="points", point_value_inr=1, base_rate=1,
|
| 1278 |
category_rates={},
|
| 1279 |
-
caps={
|
| 1280 |
excluded_categories=["fuel", "rent", "wallet_load"],
|
| 1281 |
brand_bonuses={"tata_neu": 7},
|
| 1282 |
upi_eligible=True,
|
|
|
|
| 148 |
"travel_flights": 15000,
|
| 149 |
"travel_hotels": 15000,
|
| 150 |
"insurance": 10000,
|
| 151 |
+
# SEPARATE 2,000 RP/mo ceilings, not one shared bucket - HDFC's
|
| 152 |
+
# terms name grocery, utility and telecom individually.
|
| 153 |
+
"groceries": 2000,
|
| 154 |
+
"utilities": 2000,
|
| 155 |
+
}, # insurance cap raised to 10,000 RP/mo (eff Jul 2025)
|
| 156 |
portal_rates={"travel_flights": 10.0, "travel_hotels": 10.0}, # only via SmartBuy
|
| 157 |
portal_name="HDFC SmartBuy", portal_cap_units=15000,
|
| 158 |
excluded_categories=["fuel", "rent", "wallet_load"],
|
|
|
|
| 196 |
point_value_inr=1.0,
|
| 197 |
base_rate=1.0, online_boost_only=True, # 1% base cashback
|
| 198 |
category_rates={},
|
| 199 |
+
caps={c: 1000 for c in ("general","dining","groceries","online_shopping","electronics","apparel","entertainment","transport","utilities","travel_flights","travel_hotels","pharmacy","insurance","education")}, # the 1% base tier is itself capped at 1,000 CashPoints/mo
|
| 200 |
brand_bonuses={"amazon": 5.0, "flipkart": 5.0, "swiggy": 5.0, "zomato": 5.0, "uber": 5.0, "myntra": 5.0, "tata_cliq": 5.0, "bookmyshow": 5.0, "cult_fit": 5.0, "sonyliv": 5.0}, # 5% on 10 brands only
|
| 201 |
brand_caps=[{"brands": ["amazon", "flipkart", "swiggy", "zomato", "uber", "myntra", "tata_cliq", "bookmyshow", "cult_fit", "sonyliv"], "cap": 1000}], # ₹1,000/mo shared
|
| 202 |
excluded_categories=["rent", "wallet_load", "fuel"],
|
|
|
|
| 220 |
"apparel": 5.0,
|
| 221 |
"dining": 5.0,
|
| 222 |
},
|
| 223 |
+
caps={c: 2000 for c in ("general","groceries","transport","entertainment","pharmacy","travel_flights","travel_hotels")}, # the 1% tier caps at Rs.2,000/cycle too (eff 1 Apr 2026)
|
| 224 |
+
cap_groups=[{"categories": ["online_shopping", "electronics", "apparel", "dining"], "cap": 2000}], # Rs.2,000/mo shared
|
| 225 |
+
excluded_categories=["rent", "wallet_load", "fuel", "utilities", "insurance", "education"], # "School and Educational Services" is named in SBI's own exclusion list
|
| 226 |
fees=FeeStructure(999, 999, 200000),
|
| 227 |
highlights=["Flat 5% on ALL online spends", "1% offline", "No merchant lock-in"],
|
| 228 |
persona_fit=["online_shopper", "value", "young_professional"],
|
|
|
|
| 256 |
base_rate=1.0, online_boost_only=True,
|
| 257 |
category_rates={"utilities": 2.0, "insurance": 2.0},
|
| 258 |
caps={},
|
| 259 |
+
excluded_categories=["rent", "wallet_load", "fuel", "education"], # fuel, education, tax and gold earn nothing on this card
|
| 260 |
brand_bonuses={"amazon": 5.0}, # 5% Amazon (Prime); 2% utilities/insurance/partners; 1% else
|
| 261 |
fees=FeeStructure(0, 0, None), # lifetime free
|
| 262 |
highlights=["5% on Amazon (Prime)", "2% on partner merchants", "Lifetime free"],
|
|
|
|
| 497 |
point_value_inr=1.0,
|
| 498 |
base_rate=1.5,
|
| 499 |
category_rates={},
|
| 500 |
+
brand_bonuses={"tata_neu": 5.0, "croma": 5.0, "bigbasket": 5.0, "tata_1mg": 5.0, "westside": 5.0, "air_india": 5.0, "tata_cliq": 5.0, "tata_play": 5.0}, upi_reward_cap_units=500, upi_base_rate=0.5, # 5% Tata brands; 1.5% on card; UPI earns 0.5% unless paid from the Tata Neu UPI ID; NeuCoins capped 500/mo
|
| 501 |
brand_caps=[{"brands": ["tata_neu", "croma", "bigbasket", "tata_1mg", "westside", "air_india", "tata_cliq", "tata_play"], "cap": 2000}],
|
| 502 |
+
caps={"groceries": 2000, "insurance": 2000, "utilities": 2000}, # 2,000 NeuCoins/mo each
|
| 503 |
excluded_categories=["fuel", "rent", "wallet_load"],
|
| 504 |
fees=FeeStructure(1499, 1499, 300000),
|
| 505 |
highlights=["5% NeuCoins on Tata brands", "1.5% on other UPI/spends", "UPI-linked"],
|
|
|
|
| 625 |
Card(
|
| 626 |
id="au_zenith", name="AU Bank Zenith", issuer="AU Small Finance Bank", network="Visa", segment="premium",
|
| 627 |
reward_unit="points", point_value_inr=0.22, base_rate=3.0,
|
| 628 |
+
category_rates={"dining": 5.0, "groceries": 5.0, "insurance": 1.0, "utilities": 1.0}, caps={}, excluded_categories=["fuel", "rent", "wallet_load"], # insurance/utility/telecom held at 1 RP per 100 # devalued 1 Jan 2026; RP ~₹0.25 general / ₹0.20 voucher → modeled ₹0.22 net of ₹99 redemption fee
|
| 629 |
fees=FeeStructure(7999, 7999, 800000),
|
| 630 |
highlights=["Devalued Jan 2026: 5 RP dining/grocery", "3 RP/₹100 base", "Lounge access"], persona_fit=["premium", "traveller", "high_spender"], annual_fee_text="₹7,999 + GST · waived on ₹8L spend",
|
| 631 |
),
|
|
|
|
| 1198 |
id="hdfc_irctc", name="IRCTC HDFC Bank Credit Card", issuer="HDFC Bank", network="RuPay",
|
| 1199 |
segment="entry", reward_unit="points", point_value_inr=1, base_rate=1,
|
| 1200 |
category_rates={},
|
| 1201 |
+
caps={}, upi_reward_cap_units=500, # the 500/mo figure is the UPI cap, not a cap on a category the card excludes
|
| 1202 |
excluded_categories=["fuel", "rent", "wallet_load", "education"],
|
| 1203 |
brand_bonuses={"irctc": 5},
|
| 1204 |
portal_rates={"transport": 5}, portal_name="SmartBuy",
|
|
|
|
| 1279 |
id="hdfc_tata_neu_plus", name="Tata Neu Plus HDFC Bank Credit Card", issuer="HDFC Bank", network="RuPay",
|
| 1280 |
segment="entry", reward_unit="points", point_value_inr=1, base_rate=1,
|
| 1281 |
category_rates={},
|
| 1282 |
+
caps={}, upi_reward_cap_units=500, # the 500/mo figure is the UPI cap, not a cap on a category the card excludes
|
| 1283 |
excluded_categories=["fuel", "rent", "wallet_load"],
|
| 1284 |
brand_bonuses={"tata_neu": 7},
|
| 1285 |
upi_eligible=True,
|
app/channels.py
CHANGED
|
@@ -483,12 +483,19 @@ def compare_channels(
|
|
| 483 |
# 5,000 rupee fare is not the same money as on a 5,800 rupee one.
|
| 484 |
price_known = ch["key"] in prices
|
| 485 |
price = prices[ch["key"]] if price_known else float(amount)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
ctx = TxnContext(
|
| 487 |
category=category, amount=price, merchant=merchant,
|
| 488 |
brand_key=bk,
|
| 489 |
offers=_trip_filter(_geo_filter(active_offers_for(bk), international),
|
| 490 |
passengers, round_trip),
|
| 491 |
rail="card", channel=ch["channel"],
|
|
|
|
| 492 |
)
|
| 493 |
res = score_transaction(held_card_ids, ctx, include_discovery=False, persona=persona)
|
| 494 |
ranked = res["held_ranked"]
|
|
|
|
| 483 |
# 5,000 rupee fare is not the same money as on a 5,800 rupee one.
|
| 484 |
price_known = ch["key"] in prices
|
| 485 |
price = prices[ch["key"]] if price_known else float(amount)
|
| 486 |
+
# THE SCOPE TRAVELS WITH THE CONTEXT. The pool below is already geo- and
|
| 487 |
+
# trip-filtered, but score_transaction re-applies scope_filter to
|
| 488 |
+
# whatever ctx it is handed - and with no `international` flag that
|
| 489 |
+
# reads as DOMESTIC, so every international card offer this pool had
|
| 490 |
+
# deliberately kept was thrown away again one line later. The filter is
|
| 491 |
+
# only idempotent if both halves are asked the same question.
|
| 492 |
ctx = TxnContext(
|
| 493 |
category=category, amount=price, merchant=merchant,
|
| 494 |
brand_key=bk,
|
| 495 |
offers=_trip_filter(_geo_filter(active_offers_for(bk), international),
|
| 496 |
passengers, round_trip),
|
| 497 |
rail="card", channel=ch["channel"],
|
| 498 |
+
international=international, passengers=passengers, round_trip=round_trip,
|
| 499 |
)
|
| 500 |
res = score_transaction(held_card_ids, ctx, include_discovery=False, persona=persona)
|
| 501 |
ranked = res["held_ranked"]
|
app/main.py
CHANGED
|
@@ -767,6 +767,7 @@ async def parse_statements(
|
|
| 767 |
):
|
| 768 |
_limit(request, "parse", rate=12) # 12/min per IP per worker
|
| 769 |
from statement_parser import (parse_statement, detect_cards, detect_issuers,
|
|
|
|
| 770 |
find_pdf_password, detect_credit_limit)
|
| 771 |
# Smart unlock: the password field may carry MULTIPLE candidates (newline or
|
| 772 |
# comma separated), derived app-side from the user's details the way every
|
|
@@ -784,6 +785,7 @@ async def parse_statements(
|
|
| 784 |
detected: List[str] = []
|
| 785 |
issuers: List[str] = []
|
| 786 |
credit_limit = None # highest total limit seen across the batch
|
|
|
|
| 787 |
for f in files:
|
| 788 |
content = await f.read()
|
| 789 |
# Resolve THE working password for this file up front (cheap pypdf
|
|
@@ -830,6 +832,22 @@ async def parse_statements(
|
|
| 830 |
credit_limit = _lim
|
| 831 |
except Exception:
|
| 832 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
except Exception as e:
|
| 834 |
errors.append({"file": f.filename, "error": str(e)})
|
| 835 |
# A locked/broken file can still identify its card from the
|
|
@@ -845,7 +863,7 @@ async def parse_statements(
|
|
| 845 |
pass
|
| 846 |
return {"transactions": all_txns, "count": len(all_txns), "errors": errors,
|
| 847 |
"detected_cards": detected, "detected_issuers": issuers, "reversals": all_reversals,
|
| 848 |
-
"credit_limit": credit_limit}
|
| 849 |
finally:
|
| 850 |
_PARSE_SEM.release()
|
| 851 |
|
|
|
|
| 767 |
):
|
| 768 |
_limit(request, "parse", rate=12) # 12/min per IP per worker
|
| 769 |
from statement_parser import (parse_statement, detect_cards, detect_issuers,
|
| 770 |
+
detect_points_balance_file,
|
| 771 |
find_pdf_password, detect_credit_limit)
|
| 772 |
# Smart unlock: the password field may carry MULTIPLE candidates (newline or
|
| 773 |
# comma separated), derived app-side from the user's details the way every
|
|
|
|
| 785 |
detected: List[str] = []
|
| 786 |
issuers: List[str] = []
|
| 787 |
credit_limit = None # highest total limit seen across the batch
|
| 788 |
+
points_balances: Dict[str, int] = {} # cardId -> reward-point balance read off that card's statement
|
| 789 |
for f in files:
|
| 790 |
content = await f.read()
|
| 791 |
# Resolve THE working password for this file up front (cheap pypdf
|
|
|
|
| 832 |
credit_limit = _lim
|
| 833 |
except Exception:
|
| 834 |
pass
|
| 835 |
+
# The reward-point BALANCE, attributed to the card this file
|
| 836 |
+
# names. Without it the pay-with-points panel can only nudge the
|
| 837 |
+
# user to type the number in by hand, which is why nobody ever
|
| 838 |
+
# saw an answer. Attributed only when the file identifies
|
| 839 |
+
# exactly ONE card - the same rule the transactions follow, and
|
| 840 |
+
# a balance on the wrong card is worse than no balance.
|
| 841 |
+
try:
|
| 842 |
+
_file_cards = await anyio.to_thread.run_sync(
|
| 843 |
+
lambda c=content, n=f.filename, p=pw: detect_cards(n, c, password=p))
|
| 844 |
+
if len(_file_cards) == 1:
|
| 845 |
+
_bal = await anyio.to_thread.run_sync(
|
| 846 |
+
lambda c=content, n=f.filename, p=pw: detect_points_balance_file(n, c, password=p))
|
| 847 |
+
if _bal:
|
| 848 |
+
points_balances[_file_cards[0]] = int(_bal)
|
| 849 |
+
except Exception:
|
| 850 |
+
pass
|
| 851 |
except Exception as e:
|
| 852 |
errors.append({"file": f.filename, "error": str(e)})
|
| 853 |
# A locked/broken file can still identify its card from the
|
|
|
|
| 863 |
pass
|
| 864 |
return {"transactions": all_txns, "count": len(all_txns), "errors": errors,
|
| 865 |
"detected_cards": detected, "detected_issuers": issuers, "reversals": all_reversals,
|
| 866 |
+
"credit_limit": credit_limit, "points_balances": points_balances}
|
| 867 |
finally:
|
| 868 |
_PARSE_SEM.release()
|
| 869 |
|
app/offers.py
CHANGED
|
@@ -701,15 +701,20 @@ def scope_filter(offers, scope) -> list:
|
|
| 701 |
offer paid out on a domestic stay. Living here, where every consumer reaches
|
| 702 |
it, is what stops a caller forgetting it. Mirror of offers.ts scopeFilter.
|
| 703 |
|
| 704 |
-
|
| 705 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
"""
|
| 707 |
if not scope:
|
| 708 |
return list(offers or [])
|
| 709 |
intl = bool(scope.get("international"))
|
| 710 |
-
|
| 711 |
-
pax =
|
| 712 |
-
known_trip = scope.get("round_trip") is not None
|
| 713 |
rt = bool(scope.get("round_trip"))
|
| 714 |
out = []
|
| 715 |
for o in offers or []:
|
|
@@ -718,14 +723,13 @@ def scope_filter(offers, scope) -> list:
|
|
| 718 |
continue
|
| 719 |
if g == "domestic" and intl:
|
| 720 |
continue
|
| 721 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
continue
|
| 723 |
-
if known_trip:
|
| 724 |
-
tt = str(o.get("trip_type") or "any").lower()
|
| 725 |
-
if tt == "round" and not rt:
|
| 726 |
-
continue
|
| 727 |
-
if tt == "oneway" and rt:
|
| 728 |
-
continue
|
| 729 |
out.append(o)
|
| 730 |
return out
|
| 731 |
|
|
@@ -744,18 +748,30 @@ def offer_value(off: Dict, amount: float, category: Optional[str] = None) -> flo
|
|
| 744 |
t = off.get("type")
|
| 745 |
v = off.get("value") or 0.0
|
| 746 |
if t in ("pct", "cashback"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
val = amount * v / 100.0
|
| 748 |
cap = off.get("max_discount")
|
| 749 |
-
if cap
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
val = min(val, cap)
|
| 751 |
-
return val
|
| 752 |
if t == "flat":
|
| 753 |
-
# A
|
| 754 |
-
#
|
| 755 |
-
#
|
| 756 |
-
#
|
| 757 |
-
#
|
| 758 |
-
|
|
|
|
|
|
|
| 759 |
# bogo and nocost_emi carry no computable rupee value: a "buy one get one" is
|
| 760 |
# worth whatever the second ticket costs, which we do not know, and no-cost EMI
|
| 761 |
# is a financing benefit. Both are surfaced as TEXT and valued at zero, so they
|
|
|
|
| 701 |
offer paid out on a domestic stay. Living here, where every consumer reaches
|
| 702 |
it, is what stops a caller forgetting it. Mirror of offers.ts scopeFilter.
|
| 703 |
|
| 704 |
+
UNKNOWN SCOPE MEANS THE FLOOR, NOT A FREE PASS. This used to skip the
|
| 705 |
+
passenger and trip gates entirely when the scope was unknown, while
|
| 706 |
+
channels.trip_filter - reading the same offers - defaulted to one passenger,
|
| 707 |
+
one way. So the identical Yatra row priced 1,800 on the channel list and
|
| 708 |
+
3,000 in the card panel, the 3,000 being a slab that requires three
|
| 709 |
+
passengers on a round trip. A discount you have not qualified for is not a
|
| 710 |
+
discount, so "we do not know" resolves to the smaller claim. Mirror of
|
| 711 |
+
offers.ts scopeFilter.
|
| 712 |
"""
|
| 713 |
if not scope:
|
| 714 |
return list(offers or [])
|
| 715 |
intl = bool(scope.get("international"))
|
| 716 |
+
raw_pax = scope.get("passengers")
|
| 717 |
+
pax = raw_pax if isinstance(raw_pax, (int, float)) and raw_pax > 0 else 1
|
|
|
|
| 718 |
rt = bool(scope.get("round_trip"))
|
| 719 |
out = []
|
| 720 |
for o in offers or []:
|
|
|
|
| 723 |
continue
|
| 724 |
if g == "domestic" and intl:
|
| 725 |
continue
|
| 726 |
+
if int(o.get("min_passengers") or 0) > pax:
|
| 727 |
+
continue
|
| 728 |
+
tt = str(o.get("trip_type") or "any").lower()
|
| 729 |
+
if tt == "round" and not rt:
|
| 730 |
+
continue
|
| 731 |
+
if tt == "oneway" and rt:
|
| 732 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
out.append(o)
|
| 734 |
return out
|
| 735 |
|
|
|
|
| 748 |
t = off.get("type")
|
| 749 |
v = off.get("value") or 0.0
|
| 750 |
if t in ("pct", "cashback"):
|
| 751 |
+
# A PERCENTAGE OUTSIDE (0, 100] IS NOT A DISCOUNT. The feed is ingested
|
| 752 |
+
# from scrapers, and a malformed row priced straight through: value -20
|
| 753 |
+
# returned -2,000, a "discount" that RAISES the net cost.
|
| 754 |
+
if not (v > 0) or v > 100 or v != v:
|
| 755 |
+
return 0.0
|
| 756 |
val = amount * v / 100.0
|
| 757 |
cap = off.get("max_discount")
|
| 758 |
+
# `if cap` treated a cap of ZERO as "no cap" - the one value that most
|
| 759 |
+
# obviously means the opposite - and let a NEGATIVE cap through as the
|
| 760 |
+
# answer.
|
| 761 |
+
if cap is not None:
|
| 762 |
+
if not (cap > 0):
|
| 763 |
+
return 0.0
|
| 764 |
val = min(val, cap)
|
| 765 |
+
return max(0.0, min(val, amount))
|
| 766 |
if t == "flat":
|
| 767 |
+
# A FLAT COUPON BIGGER THAN THE BILL DOES NOT MAKE THE BILL FREE.
|
| 768 |
+
# Clamping "Flat Rs.3,000 off" to a Rs.500 basket priced that basket at
|
| 769 |
+
# ZERO. Such a coupon almost always carries a minimum spend we do not
|
| 770 |
+
# hold; with none recorded, the honest answer is that it does not apply.
|
| 771 |
+
# Mirrored in offers.ts offerValue.
|
| 772 |
+
if not (v > 0):
|
| 773 |
+
return 0.0
|
| 774 |
+
return 0.0 if float(v) > amount else float(v)
|
| 775 |
# bogo and nocost_emi carry no computable rupee value: a "buy one get one" is
|
| 776 |
# worth whatever the second ticket costs, which we do not know, and no-cost EMI
|
| 777 |
# is a financing benefit. Both are surfaced as TEXT and valued at zero, so they
|
app/offers_data/curated_offers.PRESWEEP-2026-08-08.bak.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/offers_data/curated_offers.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/rails.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WHEN THE CARD IS THE WRONG ANSWER (mirror of app/src/data/rails.ts).
|
| 3 |
+
|
| 4 |
+
Rent, wallet loads, big utility bills, third-party education fees and fuel
|
| 5 |
+
outside the surcharge waiver all carry a 1% + GST charge at most Indian
|
| 6 |
+
issuers, and the app used to render them as "excluded, earns nothing" - which
|
| 7 |
+
reads as neutral. A Rs.35,000 rent payment costs Rs.413 in fees to earn zero.
|
| 8 |
+
|
| 9 |
+
The rent CHANNEL mostly closed on 15 Sep 2025: the RBI's revised Payment
|
| 10 |
+
Aggregator directions require an aggregator to hold a direct contract with the
|
| 11 |
+
merchant it collects for, and CRED, PhonePe and Paytm stopped card rent that
|
| 12 |
+
week. So the useful answer is which rail still works, not what the card costs.
|
| 13 |
+
|
| 14 |
+
Sourced from issuer fee schedules and MITC PDFs. An issuer whose own document
|
| 15 |
+
could not be read is simply absent - the app never invents a charge.
|
| 16 |
+
|
| 17 |
+
PARITY RULE: identical to the TypeScript half. Change both or neither.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from typing import Dict, Optional
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
from card_catalogue import get_card
|
| 24 |
+
except ImportError: # pragma: no cover
|
| 25 |
+
from .card_catalogue import get_card
|
| 26 |
+
|
| 27 |
+
RAILS_REVIEWED = "2026-08-07"
|
| 28 |
+
|
| 29 |
+
# 18% GST applies on top of every one of these fees; issuer schedules state
|
| 30 |
+
# their charges exclusive of it.
|
| 31 |
+
FEE_GST_MULT = 1.18
|
| 32 |
+
|
| 33 |
+
# issuer -> category -> {pct, threshold_inr, cap_inr, exempt_card_ids, source}
|
| 34 |
+
SURCHARGES: Dict[str, Dict[str, Dict]] = {
|
| 35 |
+
"HDFC Bank": {
|
| 36 |
+
"rent": {"pct": 1, "threshold_inr": 0, "cap_inr": 4999, "source": "HDFC fee revision, eff 1 Jul 2025"},
|
| 37 |
+
"wallet_load": {"pct": 1, "threshold_inr": 10000, "cap_inr": 4999, "source": "HDFC fee revision, eff 1 Jul 2025"},
|
| 38 |
+
"utilities": {"pct": 1, "threshold_inr": 50000, "cap_inr": 4999, "source": "HDFC fee revision, eff 1 Jul 2025"},
|
| 39 |
+
"education": {"pct": 1, "threshold_inr": 0, "cap_inr": 4999, "source": "HDFC: third-party apps only; direct to the institution is free"},
|
| 40 |
+
},
|
| 41 |
+
"Axis Bank": {
|
| 42 |
+
"rent": {"pct": 1, "threshold_inr": 0, "exempt_card_ids": ["axis_olympus", "axis_primus"], "source": "Axis Schedule of Charges (KFS)"},
|
| 43 |
+
"wallet_load": {"pct": 1, "threshold_inr": 10000, "source": "Axis KFS: cumulative 10,000+ per statement cycle"},
|
| 44 |
+
"utilities": {"pct": 1, "threshold_inr": 25000, "source": "Axis KFS: cumulative 25,000+ per statement cycle"},
|
| 45 |
+
"education": {"pct": 1, "threshold_inr": 0, "source": "Axis KFS: third-party apps only"},
|
| 46 |
+
},
|
| 47 |
+
"IndusInd Bank": {
|
| 48 |
+
"rent": {"pct": 1, "threshold_inr": 0, "exempt_card_ids": ["indusind_legend"], "source": "IndusInd MITC (several premium tiers waived)"},
|
| 49 |
+
"wallet_load": {"pct": 1, "threshold_inr": 20000, "source": "IndusInd MITC: above 20,000 per statement cycle"},
|
| 50 |
+
"utilities": {"pct": 1, "threshold_inr": 25000, "source": "IndusInd MITC: above 25,000 per statement cycle"},
|
| 51 |
+
"education": {"pct": 1, "threshold_inr": 45000, "source": "IndusInd MITC: third-party apps, above 45,000 per cycle"},
|
| 52 |
+
},
|
| 53 |
+
"Kotak Mahindra Bank": {
|
| 54 |
+
"rent": {"pct": 1, "threshold_inr": 0, "source": "Kotak revision, eff 1 Jun 2025"},
|
| 55 |
+
"wallet_load": {"pct": 1, "threshold_inr": 10000, "source": "Kotak revision, eff 1 Jun 2025"},
|
| 56 |
+
"utilities": {"pct": 1, "threshold_inr": 35000, "source": "Kotak revision, eff 1 Jun 2025 (threshold varies by tier; the lowest is used)"},
|
| 57 |
+
"education": {"pct": 1, "threshold_inr": 0, "source": "Kotak revision, eff 1 Jun 2025"},
|
| 58 |
+
},
|
| 59 |
+
"SBI Card": {
|
| 60 |
+
"wallet_load": {"pct": 1, "threshold_inr": 1000, "source": "SBI Card revision, eff 1 Nov 2025 - the lowest threshold in the market"},
|
| 61 |
+
"education": {"pct": 1, "threshold_inr": 0, "source": "SBI Card, eff 1 Nov 2025: third-party apps only"},
|
| 62 |
+
},
|
| 63 |
+
"IDFC FIRST Bank": {
|
| 64 |
+
"rent": {"pct": 1, "threshold_inr": 0, "source": "IDFC First MITC, eff 3 Mar 2023 (not refunded if the payment reverses)"},
|
| 65 |
+
},
|
| 66 |
+
"Standard Chartered": {
|
| 67 |
+
"rent": {"pct": 1, "threshold_inr": 0, "source": "Standard Chartered, eff 2 Apr 2023 - no cap and no minimum"},
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
FUEL_WAIVER_MIN_INR = 400
|
| 72 |
+
FUEL_WAIVER_MAX_INR = 4000
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _r2(x: float) -> float:
|
| 76 |
+
from math import floor
|
| 77 |
+
return floor(x * 100 + 0.5) / 100
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def surcharge_for(card_id: str, category: str) -> Optional[Dict]:
|
| 81 |
+
card = get_card(card_id)
|
| 82 |
+
if card is None:
|
| 83 |
+
return None
|
| 84 |
+
rule = SURCHARGES.get(card.issuer, {}).get(category)
|
| 85 |
+
if not rule:
|
| 86 |
+
return None
|
| 87 |
+
if card_id in (rule.get("exempt_card_ids") or []):
|
| 88 |
+
return None
|
| 89 |
+
return rule
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def surcharge_inr(card_id: str, category: str, amount_inr: float, already_this_cycle_inr: float = 0.0) -> float:
|
| 93 |
+
"""Fee on THIS payment. Thresholds are cumulative per statement cycle, so
|
| 94 |
+
only the part above the line is charged."""
|
| 95 |
+
rule = surcharge_for(card_id, category)
|
| 96 |
+
if not rule:
|
| 97 |
+
return 0.0
|
| 98 |
+
try:
|
| 99 |
+
amt = float(amount_inr)
|
| 100 |
+
except (TypeError, ValueError):
|
| 101 |
+
return 0.0
|
| 102 |
+
if amt != amt or amt <= 0 or amt == float("inf"):
|
| 103 |
+
return 0.0
|
| 104 |
+
prior = already_this_cycle_inr if (isinstance(already_this_cycle_inr, (int, float)) and already_this_cycle_inr > 0) else 0.0
|
| 105 |
+
threshold = rule["threshold_inr"]
|
| 106 |
+
chargeable = max(0.0, (prior + amt) - threshold) - max(0.0, prior - threshold)
|
| 107 |
+
if chargeable <= 0:
|
| 108 |
+
return 0.0
|
| 109 |
+
fee = chargeable * rule["pct"] / 100.0
|
| 110 |
+
cap = rule.get("cap_inr")
|
| 111 |
+
if cap is not None:
|
| 112 |
+
prior_fee = max(0.0, prior - threshold) * rule["pct"] / 100.0
|
| 113 |
+
fee = max(0.0, min(fee, cap - prior_fee))
|
| 114 |
+
return _r2(fee * FEE_GST_MULT)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def fuel_fee_inr(amount_inr: float) -> float:
|
| 118 |
+
"""The 1% fuel surcharge is waived inside the band - but NO issuer refunds
|
| 119 |
+
the GST on it (IDFC, IndusInd and HSBC all say so), so a fully waived fill
|
| 120 |
+
still costs about 0.18%, and a fill outside the band costs the lot."""
|
| 121 |
+
try:
|
| 122 |
+
amt = float(amount_inr)
|
| 123 |
+
except (TypeError, ValueError):
|
| 124 |
+
return 0.0
|
| 125 |
+
if amt != amt or amt <= 0 or amt == float("inf"):
|
| 126 |
+
return 0.0
|
| 127 |
+
if amt > FUEL_WAIVER_MAX_INR or amt < FUEL_WAIVER_MIN_INR:
|
| 128 |
+
return _r2(amt * 0.01 * FEE_GST_MULT)
|
| 129 |
+
return _r2(amt * 0.01 * (FEE_GST_MULT - 1))
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def fee_for(card_id: str, category: str, amount_inr: float, already_this_cycle_inr: float = 0.0) -> float:
|
| 133 |
+
if category == "fuel":
|
| 134 |
+
return fuel_fee_inr(amount_inr)
|
| 135 |
+
return surcharge_inr(card_id, category, amount_inr, already_this_cycle_inr)
|
app/redemptions.py
CHANGED
|
@@ -26,7 +26,7 @@ try:
|
|
| 26 |
except ImportError: # pragma: no cover - package-style import
|
| 27 |
from .card_catalogue import get_card
|
| 28 |
|
| 29 |
-
REDEMPTIONS_REVIEWED = "2026-
|
| 30 |
|
| 31 |
# ---------------------------------------------------------------------------
|
| 32 |
# Partner mile values (INR per PARTNER mile/point, not per card point)
|
|
@@ -48,20 +48,24 @@ MILE_VALUE: Dict[str, Dict[str, float]] = {
|
|
| 48 |
}
|
| 49 |
|
| 50 |
# mode: key, label, kind (cashback|voucher|portal), per_point_inr,
|
| 51 |
-
# best_per_point_inr (optional), travel_bookable (optional),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
REDEMPTION_MODES: Dict[str, List[Dict]] = {
|
| 53 |
"hdfc_infinia": [
|
| 54 |
-
{"key": "smartbuy", "label": "SmartBuy flights and hotels", "kind": "portal", "per_point_inr": 1.0, "travel_bookable": True, "note": "Up to 1.5 lakh points a month, max 70% of the booking"},
|
| 55 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.35, "best_per_point_inr": 0.5},
|
| 56 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.3, "note": "Capped at 50,000 points a month"},
|
| 57 |
],
|
| 58 |
"hdfc_diners_black": [
|
| 59 |
-
{"key": "smartbuy", "label": "SmartBuy flights and hotels", "kind": "portal", "per_point_inr": 1.0, "travel_bookable": True, "note": "Monthly cap applies, max 70% of the booking"},
|
| 60 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.35, "best_per_point_inr": 0.5},
|
| 61 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.3},
|
| 62 |
],
|
| 63 |
"hdfc_regalia_gold": [
|
| 64 |
-
{"key": "smartbuy", "label": "SmartBuy flights and hotels", "kind": "portal", "per_point_inr": 0.5, "travel_bookable": True},
|
| 65 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.35},
|
| 66 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.2},
|
| 67 |
],
|
|
@@ -84,11 +88,11 @@ REDEMPTION_MODES: Dict[str, List[Dict]] = {
|
|
| 84 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.25},
|
| 85 |
],
|
| 86 |
"icici_emeralde": [
|
| 87 |
-
{"key": "ishop", "label": "iShop flights, hotels and vouchers", "kind": "portal", "per_point_inr": 1.0, "travel_bookable": True},
|
| 88 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.6},
|
| 89 |
],
|
| 90 |
"icici_sapphiro": [
|
| 91 |
-
{"key": "ishop", "label": "iShop flights and hotels", "kind": "portal", "per_point_inr": 0.45, "best_per_point_inr": 1.0, "travel_bookable": True, "note": "iShop value varies by card tier"},
|
| 92 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.4},
|
| 93 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.25},
|
| 94 |
],
|
|
@@ -114,10 +118,10 @@ REDEMPTION_MODES: Dict[str, List[Dict]] = {
|
|
| 114 |
# per-booking redemption fee; best is the advertised value on a booking
|
| 115 |
# large enough that the fee vanishes into it.
|
| 116 |
"hdfc_6e_rewards": [
|
| 117 |
-
{"key": "indigo", "label": "IndiGo flights and add-ons (6E Rewards)", "kind": "portal", "per_point_inr":
|
| 118 |
],
|
| 119 |
"hdfc_6e_rewards_xl": [
|
| 120 |
-
{"key": "indigo", "label": "IndiGo flights and add-ons (6E Rewards)", "kind": "portal", "per_point_inr":
|
| 121 |
],
|
| 122 |
}
|
| 123 |
|
|
@@ -399,46 +403,145 @@ def transfer_uplift_fraction(card_id: str) -> float:
|
|
| 399 |
return _r2(f) if f > 0.05 else 0
|
| 400 |
|
| 401 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
def pay_with_points(wallet_ids: List[str], balances: Dict[str, float],
|
| 403 |
-
category: str, amount_inr: float
|
|
|
|
| 404 |
"""For a live-priced flight or hotel, which held card's points could pay
|
| 405 |
-
for it, and on which route.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
try:
|
| 407 |
amt = float(amount_inr)
|
| 408 |
except (TypeError, ValueError):
|
| 409 |
return []
|
| 410 |
if amt != amt or amt <= 0 or amt == float("inf"):
|
| 411 |
return []
|
|
|
|
|
|
|
| 412 |
want_kind = "airline" if category == "travel_flights" else "hotel"
|
|
|
|
| 413 |
rows: List[Dict] = []
|
| 414 |
for card_id in wallet_ids:
|
| 415 |
card = get_card(card_id)
|
| 416 |
if card is None or card.reward_unit == "cashback":
|
| 417 |
continue
|
| 418 |
balance = _sanitize_points((balances or {}).get(card_id))
|
|
|
|
| 419 |
pick = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
for m in REDEMPTION_MODES.get(card_id, []):
|
| 421 |
if not m.get("travel_bookable") or m["per_point_inr"] <= 0:
|
| 422 |
continue
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
for p in TRANSFER_PARTNERS.get(card_id, []):
|
| 429 |
if p["kind"] != want_kind:
|
| 430 |
continue
|
|
|
|
|
|
|
| 431 |
mv = MILE_VALUE.get(p["key"])
|
| 432 |
if not mv or mv["realistic"] <= 0:
|
| 433 |
continue
|
| 434 |
miles_needed = ceil(amt / mv["realistic"])
|
| 435 |
-
|
|
|
|
|
|
|
| 436 |
per = _r2((p["to_units"] / p["from_units"]) * mv["realistic"])
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
|
|
|
|
|
|
| 440 |
if pick is None:
|
| 441 |
continue
|
|
|
|
| 442 |
rows.append({
|
| 443 |
"card_id": card_id,
|
| 444 |
"card_name": card.name,
|
|
@@ -446,14 +549,28 @@ def pay_with_points(wallet_ids: List[str], balances: Dict[str, float],
|
|
| 446 |
"balance": balance,
|
| 447 |
"route": pick["route"],
|
| 448 |
"route_label": pick["label"],
|
|
|
|
|
|
|
| 449 |
"points_needed": pick["needed"],
|
| 450 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
"enough": balance >= pick["needed"],
|
| 452 |
"per_point_inr": pick["per"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
"note": pick["note"],
|
| 454 |
})
|
| 455 |
rows.sort(key=lambda r: (
|
| 456 |
0 if r["enough"] else 1,
|
|
|
|
|
|
|
|
|
|
| 457 |
r["points_needed"] if r["enough"] else -r["coverage_pct"],
|
| 458 |
))
|
| 459 |
return rows
|
|
|
|
| 26 |
except ImportError: # pragma: no cover - package-style import
|
| 27 |
from .card_catalogue import get_card
|
| 28 |
|
| 29 |
+
REDEMPTIONS_REVIEWED = "2026-08-07"
|
| 30 |
|
| 31 |
# ---------------------------------------------------------------------------
|
| 32 |
# Partner mile values (INR per PARTNER mile/point, not per card point)
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
# mode: key, label, kind (cashback|voucher|portal), per_point_inr,
|
| 51 |
+
# best_per_point_inr (optional), travel_bookable (optional),
|
| 52 |
+
# max_share_pct (optional - the largest share of ONE booking this route
|
| 53 |
+
# lets points pay; absent means 100. HDFC caps SmartBuy travel
|
| 54 |
+
# redemptions at 70%, which the note said in prose while the
|
| 55 |
+
# arithmetic ignored it), note (optional)
|
| 56 |
REDEMPTION_MODES: Dict[str, List[Dict]] = {
|
| 57 |
"hdfc_infinia": [
|
| 58 |
+
{"key": "smartbuy", "label": "SmartBuy flights and hotels", "kind": "portal", "per_point_inr": 1.0, "travel_bookable": True, "max_share_pct": 70, "note": "Up to 1.5 lakh points a month, max 70% of the booking"},
|
| 59 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.35, "best_per_point_inr": 0.5},
|
| 60 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.3, "note": "Capped at 50,000 points a month"},
|
| 61 |
],
|
| 62 |
"hdfc_diners_black": [
|
| 63 |
+
{"key": "smartbuy", "label": "SmartBuy flights and hotels", "kind": "portal", "per_point_inr": 1.0, "travel_bookable": True, "max_share_pct": 70, "note": "Monthly cap applies, max 70% of the booking"},
|
| 64 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.35, "best_per_point_inr": 0.5},
|
| 65 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.3},
|
| 66 |
],
|
| 67 |
"hdfc_regalia_gold": [
|
| 68 |
+
{"key": "smartbuy", "label": "SmartBuy flights and hotels", "kind": "portal", "per_point_inr": 0.5, "travel_bookable": True, "max_share_pct": 70, "note": "Max 70% of the booking in points"},
|
| 69 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.35},
|
| 70 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.2},
|
| 71 |
],
|
|
|
|
| 88 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.25},
|
| 89 |
],
|
| 90 |
"icici_emeralde": [
|
| 91 |
+
{"key": "ishop", "label": "iShop flights, hotels and vouchers", "kind": "portal", "per_point_inr": 1.0, "travel_bookable": True, "max_share_pct_hotel": 90, "note": "Hotels cap at 90% in points; no redemption fee on this tier"},
|
| 92 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.6},
|
| 93 |
],
|
| 94 |
"icici_sapphiro": [
|
| 95 |
+
{"key": "ishop", "label": "iShop flights and hotels", "kind": "portal", "per_point_inr": 0.45, "best_per_point_inr": 1.0, "travel_bookable": True, "max_share_pct_hotel": 90, "redemption_fee_inr": 117, "note": "iShop value varies by card tier; Rs.99 + GST redemption fee"},
|
| 96 |
{"key": "voucher", "label": "Voucher catalogue", "kind": "voucher", "per_point_inr": 0.4},
|
| 97 |
{"key": "cashback", "label": "Statement credit", "kind": "cashback", "per_point_inr": 0.25},
|
| 98 |
],
|
|
|
|
| 118 |
# per-booking redemption fee; best is the advertised value on a booking
|
| 119 |
# large enough that the fee vanishes into it.
|
| 120 |
"hdfc_6e_rewards": [
|
| 121 |
+
{"key": "indigo", "label": "IndiGo flights and add-ons (6E Rewards)", "kind": "portal", "per_point_inr": 1.0, "travel_bookable": True, "redemption_fee_inr": 236, "note": "1 6E Reward = ₹1, on IndiGo only; ₹200 + GST per redemption booking"},
|
| 122 |
],
|
| 123 |
"hdfc_6e_rewards_xl": [
|
| 124 |
+
{"key": "indigo", "label": "IndiGo flights and add-ons (6E Rewards)", "kind": "portal", "per_point_inr": 1.0, "travel_bookable": True, "redemption_fee_inr": 236, "note": "1 6E Reward = ₹1, on IndiGo only; ₹200 + GST per redemption booking"},
|
| 125 |
],
|
| 126 |
}
|
| 127 |
|
|
|
|
| 403 |
return _r2(f) if f > 0.05 else 0
|
| 404 |
|
| 405 |
|
| 406 |
+
STAR = ["SQ", "UA", "TK", "AC", "LH", "NH", "TG", "AI", "OZ", "SA", "ET", "MS", "BR", "CA", "ZH", "SK", "LO", "OU", "JP", "A3", "TP", "AV", "CM", "NZ"]
|
| 407 |
+
SKYTEAM = ["AF", "KL", "DL", "KE", "MU", "CZ", "VN", "SV", "AZ", "RO", "UX", "AM", "GA", "KQ", "ME", "VS"]
|
| 408 |
+
ONEWORLD = ["BA", "QR", "AY", "CX", "JL", "MH", "QF", "AA", "IB", "RJ", "UL", "AT", "FJ", "AS"]
|
| 409 |
+
|
| 410 |
+
# WHERE A TRANSFER CAN ACTUALLY BE FLOWN OR SLEPT. The panel was answering "how
|
| 411 |
+
# many miles is this fare worth" and printing it as "how to pay for THIS
|
| 412 |
+
# flight", so a Rs.8,012 IndiGo BLR-PAT hop was offered as 3,642 Singapore
|
| 413 |
+
# KrisFlyer miles. Singapore Airlines does not fly BLR-PAT, IndiGo is in no
|
| 414 |
+
# alliance and is not a KrisFlyer redemption partner: the route does not exist
|
| 415 |
+
# at any number of miles. Accor points likewise cannot book a Taj. Mirror of
|
| 416 |
+
# redemptions.ts AIRLINE_REACH / HOTEL_REACH.
|
| 417 |
+
AIRLINE_REACH = {
|
| 418 |
+
"krisflyer": STAR,
|
| 419 |
+
"united": STAR,
|
| 420 |
+
"turkish": STAR,
|
| 421 |
+
"aeroplan": STAR,
|
| 422 |
+
"maharaja": ["AI", "IX"] + STAR,
|
| 423 |
+
"flying_blue": SKYTEAM,
|
| 424 |
+
"avios": ONEWORLD,
|
| 425 |
+
"emirates": ["EK", "FZ", "QF", "JL", "UL"],
|
| 426 |
+
"etihad": ["EY", "AI", "IX", "VS", "JL"],
|
| 427 |
+
}
|
| 428 |
+
HOTEL_REACH = {
|
| 429 |
+
"marriott": ["marriott", "sheraton", "westin", "st. regis", "st regis", "le meridien", "jw ", "ritz-carlton", "ritz carlton", "courtyard", "fairfield", "aloft", "four points", "moxy", "w "],
|
| 430 |
+
"ihg": ["intercontinental", "holiday inn", "crowne plaza", "holidayinn", "even hotel", "hotel indigo", "voco", "kimpton", "staybridge"],
|
| 431 |
+
"accor": ["novotel", "ibis", "sofitel", "pullman", "mercure", "fairmont", "raffles", "swissotel", "movenpick", "mgallery", "grand mercure"],
|
| 432 |
+
"itc": ["itc ", "welcomhotel", "welcomhotels", "storii", "fortune "],
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
# Per-programme transfer mechanics: what a transfer costs and the block size it
|
| 436 |
+
# moves in. 3,642 is not a number Amex or SBI will transfer.
|
| 437 |
+
_GST = 1.18
|
| 438 |
+
PROGRAM_RULES = {
|
| 439 |
+
"axis_magnus": {"transfer_fee_inr": round(199 * _GST), "transfer_increment": 1000},
|
| 440 |
+
"axis_atlas": {"transfer_fee_inr": round(199 * _GST), "transfer_increment": 1000},
|
| 441 |
+
"axis_horizon": {"transfer_fee_inr": round(199 * _GST), "transfer_increment": 1000},
|
| 442 |
+
"axis_olympus": {"transfer_fee_inr": round(199 * _GST), "transfer_increment": 1000},
|
| 443 |
+
"hdfc_infinia": {"transfer_increment": 100},
|
| 444 |
+
"hdfc_diners_black": {"transfer_increment": 100},
|
| 445 |
+
"hdfc_regalia_gold": {"transfer_increment": 100},
|
| 446 |
+
"hdfc_regalia": {"transfer_increment": 100},
|
| 447 |
+
"hdfc_diners_privilege": {"transfer_increment": 100},
|
| 448 |
+
}
|
| 449 |
+
DEFAULT_TRANSFER_INCREMENT = 1000
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _transfer_serves(p: Dict, subject: Optional[Dict]) -> bool:
|
| 453 |
+
"""Can this partner's currency actually be used on this booking?"""
|
| 454 |
+
subject = subject or {}
|
| 455 |
+
if p.get("kind") == "airline":
|
| 456 |
+
c = str(subject.get("carrier") or "").strip().upper()[:2]
|
| 457 |
+
if not c:
|
| 458 |
+
return False
|
| 459 |
+
return c in AIRLINE_REACH.get(p["key"], [])
|
| 460 |
+
n = str(subject.get("property_name") or "").strip().lower()
|
| 461 |
+
if not n:
|
| 462 |
+
return False
|
| 463 |
+
return any(b in n for b in HOTEL_REACH.get(p["key"], []))
|
| 464 |
+
|
| 465 |
+
|
| 466 |
def pay_with_points(wallet_ids: List[str], balances: Dict[str, float],
|
| 467 |
+
category: str, amount_inr: float,
|
| 468 |
+
subject: Optional[Dict] = None) -> List[Dict]:
|
| 469 |
"""For a live-priced flight or hotel, which held card's points could pay
|
| 470 |
+
for it, and on which route. Mirrors the TypeScript half.
|
| 471 |
+
|
| 472 |
+
TWO KINDS OF CLAIM: a portal route is a rate the issuer publishes
|
| 473 |
+
("fixed"), a transfer is the price divided by a realistic per-mile value
|
| 474 |
+
("estimated"). A fixed route can still be PARTIAL (max_share_pct) or carry
|
| 475 |
+
a redemption fee, both of which land in cash_remainder_inr.
|
| 476 |
+
|
| 477 |
+
A transfer is only offered when `subject` shows the operating carrier or
|
| 478 |
+
the property's chain is reachable through that programme.
|
| 479 |
+
"""
|
| 480 |
try:
|
| 481 |
amt = float(amount_inr)
|
| 482 |
except (TypeError, ValueError):
|
| 483 |
return []
|
| 484 |
if amt != amt or amt <= 0 or amt == float("inf"):
|
| 485 |
return []
|
| 486 |
+
if category not in ("travel_flights", "travel_hotels"):
|
| 487 |
+
return []
|
| 488 |
want_kind = "airline" if category == "travel_flights" else "hotel"
|
| 489 |
+
is_hotel = category == "travel_hotels"
|
| 490 |
rows: List[Dict] = []
|
| 491 |
for card_id in wallet_ids:
|
| 492 |
card = get_card(card_id)
|
| 493 |
if card is None or card.reward_unit == "cashback":
|
| 494 |
continue
|
| 495 |
balance = _sanitize_points((balances or {}).get(card_id))
|
| 496 |
+
prog = PROGRAM_RULES.get(card_id, {})
|
| 497 |
pick = None
|
| 498 |
+
best_fixed = None
|
| 499 |
+
|
| 500 |
+
def _rank(c):
|
| 501 |
+
# A route the balance can complete, then the one that covers most
|
| 502 |
+
# of the price, then the cheapest in points. Ranking on value per
|
| 503 |
+
# point alone put a route paying 70% of a stay above one paying all
|
| 504 |
+
# of it, purely because paying for less costs fewer points.
|
| 505 |
+
return (0 if balance > 0 and balance >= c["needed"] else 1, -c["share_pct"], c["needed"])
|
| 506 |
+
|
| 507 |
+
def _better(cand, cur):
|
| 508 |
+
return cur is None or _rank(cand) < _rank(cur)
|
| 509 |
+
|
| 510 |
for m in REDEMPTION_MODES.get(card_id, []):
|
| 511 |
if not m.get("travel_bookable") or m["per_point_inr"] <= 0:
|
| 512 |
continue
|
| 513 |
+
raw_share = m.get("max_share_pct_hotel") if (is_hotel and m.get("max_share_pct_hotel") is not None) else m.get("max_share_pct")
|
| 514 |
+
share_pct = _rint(raw_share) if raw_share is not None and 0 < raw_share < 100 else 100
|
| 515 |
+
needed = ceil(amt * share_pct / 100 / m["per_point_inr"])
|
| 516 |
+
fee = m.get("redemption_fee_inr") or 0
|
| 517 |
+
cand = {"route": "portal", "label": PORTAL_ROUTE_LABEL.get(m["key"], f"Book via {m['label']}"),
|
| 518 |
+
"needed": needed, "per": m["per_point_inr"], "share_pct": share_pct,
|
| 519 |
+
"fee_inr": _rint(fee) if fee > 0 else 0, "note": m.get("note")}
|
| 520 |
+
if _better(cand, pick):
|
| 521 |
+
pick = cand
|
| 522 |
+
if _better(cand, best_fixed):
|
| 523 |
+
best_fixed = cand
|
| 524 |
for p in TRANSFER_PARTNERS.get(card_id, []):
|
| 525 |
if p["kind"] != want_kind:
|
| 526 |
continue
|
| 527 |
+
if not _transfer_serves(p, subject):
|
| 528 |
+
continue
|
| 529 |
mv = MILE_VALUE.get(p["key"])
|
| 530 |
if not mv or mv["realistic"] <= 0:
|
| 531 |
continue
|
| 532 |
miles_needed = ceil(amt / mv["realistic"])
|
| 533 |
+
raw_needed = ceil(miles_needed * (p["from_units"] / p["to_units"]))
|
| 534 |
+
step = prog.get("transfer_increment", DEFAULT_TRANSFER_INCREMENT)
|
| 535 |
+
needed = max(step, ceil(raw_needed / step) * step)
|
| 536 |
per = _r2((p["to_units"] / p["from_units"]) * mv["realistic"])
|
| 537 |
+
cand = {"route": "transfer", "label": f"Transfer to {p['partner']}",
|
| 538 |
+
"needed": needed, "per": per, "share_pct": 100,
|
| 539 |
+
"fee_inr": prog.get("transfer_fee_inr", 0), "note": p.get("note")}
|
| 540 |
+
if _better(cand, pick):
|
| 541 |
+
pick = cand
|
| 542 |
if pick is None:
|
| 543 |
continue
|
| 544 |
+
covered = _r2(amt * pick["share_pct"] / 100)
|
| 545 |
rows.append({
|
| 546 |
"card_id": card_id,
|
| 547 |
"card_name": card.name,
|
|
|
|
| 549 |
"balance": balance,
|
| 550 |
"route": pick["route"],
|
| 551 |
"route_label": pick["label"],
|
| 552 |
+
"certainty": "fixed" if pick["route"] == "portal" else "estimated",
|
| 553 |
+
"max_share_pct": pick["share_pct"],
|
| 554 |
"points_needed": pick["needed"],
|
| 555 |
+
"cash_remainder_inr": max(0.0, _r2(amt - covered + pick["fee_inr"])),
|
| 556 |
+
"fee_inr": pick["fee_inr"],
|
| 557 |
+
# FLOOR, not round: rounding printed "100% of it in points" on a
|
| 558 |
+
# balance one point short of the target.
|
| 559 |
+
"coverage_pct": min(100, int(balance / pick["needed"] * 100)),
|
| 560 |
"enough": balance >= pick["needed"],
|
| 561 |
"per_point_inr": pick["per"],
|
| 562 |
+
# A floor is only a floor if the balance reaches it.
|
| 563 |
+
"fixed_alt": ({"route_label": best_fixed["label"],
|
| 564 |
+
"points_needed": best_fixed["needed"],
|
| 565 |
+
"max_share_pct": best_fixed["share_pct"]}
|
| 566 |
+
if pick["route"] == "transfer" and best_fixed and balance >= best_fixed["needed"] else None),
|
| 567 |
"note": pick["note"],
|
| 568 |
})
|
| 569 |
rows.sort(key=lambda r: (
|
| 570 |
0 if r["enough"] else 1,
|
| 571 |
+
(0 if r["certainty"] == "fixed" else 1) if r["enough"] else 0,
|
| 572 |
+
-r["max_share_pct"] if r["enough"] else 0,
|
| 573 |
+
r["cash_remainder_inr"] if r["enough"] else 0,
|
| 574 |
r["points_needed"] if r["enough"] else -r["coverage_pct"],
|
| 575 |
))
|
| 576 |
return rows
|
app/scoring_engine.py
CHANGED
|
@@ -24,6 +24,7 @@ from dataclasses import dataclass, replace
|
|
| 24 |
from typing import Dict, List, Optional
|
| 25 |
|
| 26 |
from card_catalogue import Card, CATALOGUE_BY_ID, all_cards
|
|
|
|
| 27 |
|
| 28 |
import math
|
| 29 |
|
|
@@ -80,7 +81,7 @@ class CardScore:
|
|
| 80 |
network: str
|
| 81 |
reward_value_inr: float # rewards earned on this txn (capped)
|
| 82 |
instant_offer_inr: float # instant discount from live offers
|
| 83 |
-
total_value_inr: float # reward + instant offer (rounded for display)
|
| 84 |
raw_total: float # unrounded total - used for ranking
|
| 85 |
effective_rate_pct: float # total value as % of spend
|
| 86 |
capped: bool # whether monthly cap limited the reward
|
|
@@ -93,6 +94,11 @@ class CardScore:
|
|
| 93 |
emi_offer_inr: float = 0.0
|
| 94 |
emi_offer_text: Optional[str] = None
|
| 95 |
emi_offer_code: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# The promo code the PRICED instant discount needs, when it needs one.
|
| 97 |
# Mirror of CardScore.instantOfferCode in engine.ts.
|
| 98 |
instant_offer_code: Optional[str] = None
|
|
@@ -116,19 +122,26 @@ def _offer_applies(off: Dict, card: Card, allow_emi: bool = False) -> bool:
|
|
| 116 |
# Needs a code we could not name, so the user cannot apply it.
|
| 117 |
if off.get("requires_promo_code") and not (off.get("promo_code") or "").strip():
|
| 118 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
cid = off.get("card_id")
|
| 120 |
-
if cid:
|
| 121 |
-
return
|
| 122 |
iss = off.get("applies_to_issuer") or ""
|
| 123 |
# An offer naming NO issuer and no card is open to anyone paying any way.
|
| 124 |
# A blank issuer used to mean "any issuer", which handed every promo code
|
| 125 |
# and platform sale to the whole catalogue as if plastic had earned it.
|
| 126 |
# These are surfaced separately (offers.best_open_offer) so the user still
|
| 127 |
# sees the money without it deciding which card to pull out.
|
| 128 |
-
if not
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
| 132 |
# "all HDFC Bank Credit Cards except Infinia & Diners Club Black" - the
|
| 133 |
# offer is the issuer's, minus a named few. Mirrored in engine.ts.
|
| 134 |
if card.id in (off.get("excluded_card_ids") or []):
|
|
@@ -233,6 +246,18 @@ def score_transaction(
|
|
| 233 |
mtd_spend: {card_id: {category: rupees_spent_this_month}}
|
| 234 |
"""
|
| 235 |
mtd_spend = mtd_spend or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
# Trip scope applied ONCE, here, so every downstream consumer of ctx.offers
|
| 237 |
# inherits it. compare_channels also pre-filters; the predicate is
|
| 238 |
# idempotent, so neither can be the only guard.
|
|
@@ -289,17 +314,41 @@ def score_transaction(
|
|
| 289 |
else:
|
| 290 |
cap_units = card.caps.get(ctx.category)
|
| 291 |
if not upi_block and cap_units is not None and rate > 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
if brand_grp:
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
| 294 |
elif cat_grp:
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
| 296 |
else:
|
| 297 |
-
|
| 298 |
-
already_units = spent / 100.0 * rate
|
| 299 |
remaining_units = max(0.0, cap_units - already_units)
|
| 300 |
if gross_units > remaining_units:
|
| 301 |
accel_units = remaining_units
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
rupees_at_accel = remaining_units / rate * 100.0 if rate > 0 else 0.0
|
| 304 |
rupees_beyond = max(0.0, ctx.amount - rupees_at_accel)
|
| 305 |
gross_units = accel_units + rupees_beyond / 100.0 * base_units_value
|
|
@@ -323,7 +372,16 @@ def score_transaction(
|
|
| 323 |
offer_inr, offer_note, offer_code = _instant_offer_value(card, ctx)
|
| 324 |
emi_inr, emi_note, emi_code = _emi_offer_value(card, ctx)
|
| 325 |
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
eff_pct = (total / ctx.amount * 100.0) if ctx.amount else 0.0
|
| 328 |
|
| 329 |
# --- reasons ---
|
|
@@ -384,6 +442,7 @@ def score_transaction(
|
|
| 384 |
emi_offer_inr=_r2(emi_inr),
|
| 385 |
emi_offer_text=emi_note,
|
| 386 |
emi_offer_code=emi_code,
|
|
|
|
| 387 |
)
|
| 388 |
all_scores.append(sc)
|
| 389 |
if held:
|
|
|
|
| 24 |
from typing import Dict, List, Optional
|
| 25 |
|
| 26 |
from card_catalogue import Card, CATALOGUE_BY_ID, all_cards
|
| 27 |
+
from rails import fee_for
|
| 28 |
|
| 29 |
import math
|
| 30 |
|
|
|
|
| 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 - issuer fee (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
|
|
|
|
| 94 |
emi_offer_inr: float = 0.0
|
| 95 |
emi_offer_text: Optional[str] = None
|
| 96 |
emi_offer_code: Optional[str] = None
|
| 97 |
+
# What this card charges to MAKE this payment, GST included: 1% on rent,
|
| 98 |
+
# wallet loads, big utility bills and third-party education fees at most
|
| 99 |
+
# issuers, and the fuel surcharge outside the waiver band. Netted out of
|
| 100 |
+
# total_value_inr so a charging card cannot outrank a free one.
|
| 101 |
+
fee_inr: float = 0.0
|
| 102 |
# The promo code the PRICED instant discount needs, when it needs one.
|
| 103 |
# Mirror of CardScore.instantOfferCode in engine.ts.
|
| 104 |
instant_offer_code: Optional[str] = None
|
|
|
|
| 122 |
# Needs a code we could not name, so the user cannot apply it.
|
| 123 |
if off.get("requires_promo_code") and not (off.get("promo_code") or "").strip():
|
| 124 |
return False
|
| 125 |
+
# AN EXPLICIT CARD IS NOT A LICENCE TO SKIP THE REST. This used to RETURN on
|
| 126 |
+
# the card match, jumping over the excluded-card, card-kind and network
|
| 127 |
+
# gates below - so a row naming a card while also carrying requires_debit,
|
| 128 |
+
# a network it is not on, AND that same card in its own excluded list
|
| 129 |
+
# priced anyway. requires_debit is derived from evidence text after the
|
| 130 |
+
# loader's contradiction check, so the feed can produce exactly that row.
|
| 131 |
cid = off.get("card_id")
|
| 132 |
+
if cid and cid != card.id:
|
| 133 |
+
return False
|
| 134 |
iss = off.get("applies_to_issuer") or ""
|
| 135 |
# An offer naming NO issuer and no card is open to anyone paying any way.
|
| 136 |
# A blank issuer used to mean "any issuer", which handed every promo code
|
| 137 |
# and platform sale to the whole catalogue as if plastic had earned it.
|
| 138 |
# These are surfaced separately (offers.best_open_offer) so the user still
|
| 139 |
# sees the money without it deciding which card to pull out.
|
| 140 |
+
if not cid:
|
| 141 |
+
if not iss:
|
| 142 |
+
return False
|
| 143 |
+
if iss != card.issuer:
|
| 144 |
+
return False
|
| 145 |
# "all HDFC Bank Credit Cards except Infinia & Diners Club Black" - the
|
| 146 |
# offer is the issuer's, minus a named few. Mirrored in engine.ts.
|
| 147 |
if card.id in (off.get("excluded_card_ids") or []):
|
|
|
|
| 246 |
mtd_spend: {card_id: {category: rupees_spent_this_month}}
|
| 247 |
"""
|
| 248 |
mtd_spend = mtd_spend or {}
|
| 249 |
+
# A BAD AMOUNT MUST NOT REACH THE REWARD MATH. engine.ts has clamped this
|
| 250 |
+
# since the beginning; the server did not, so the same request that returned
|
| 251 |
+
# Rs.0 on the device returned a NEGATIVE reward and a positive effective
|
| 252 |
+
# rate from the API for amount = -10,000.
|
| 253 |
+
try:
|
| 254 |
+
_amt = float(ctx.amount)
|
| 255 |
+
except (TypeError, ValueError):
|
| 256 |
+
_amt = 0.0
|
| 257 |
+
if not (_amt > 0) or _amt != _amt or _amt == float("inf"):
|
| 258 |
+
_amt = 0.0
|
| 259 |
+
if _amt != ctx.amount:
|
| 260 |
+
ctx = replace(ctx, amount=_amt)
|
| 261 |
# Trip scope applied ONCE, here, so every downstream consumer of ctx.offers
|
| 262 |
# inherits it. compare_channels also pre-filters; the predicate is
|
| 263 |
# idempotent, so neither can be the only guard.
|
|
|
|
| 314 |
else:
|
| 315 |
cap_units = card.caps.get(ctx.category)
|
| 316 |
if not upi_block and cap_units is not None and rate > 0:
|
| 317 |
+
# WHAT THE CAP HAS ALREADY EATEN, valued at each member's OWN rate.
|
| 318 |
+
# Prior spend is stored in rupees and used to be converted back at
|
| 319 |
+
# THIS transaction's rate, which is only right when every member of
|
| 320 |
+
# the bucket earns the same. Axis ACE shares one Rs.500/month cap
|
| 321 |
+
# across bill payments at 5% and food delivery at 4%: after the cap
|
| 322 |
+
# was fully spent on bills, a Swiggy order still paid out, because
|
| 323 |
+
# the same rupees re-read at 4% looked like less of the cap.
|
| 324 |
if brand_grp:
|
| 325 |
+
already_units = sum(
|
| 326 |
+
mtd_spend.get(card.id, {}).get("~" + b, 0.0) / 100.0
|
| 327 |
+
* (card.brand_bonuses or {}).get(b, card.base_rate)
|
| 328 |
+
for b in brand_grp["brands"])
|
| 329 |
elif cat_grp:
|
| 330 |
+
already_units = sum(
|
| 331 |
+
mtd_spend.get(card.id, {}).get(c, 0.0) / 100.0
|
| 332 |
+
* (0.0 if c in card.excluded_categories else card.category_rates.get(c, card.base_rate))
|
| 333 |
+
for c in cat_grp["categories"])
|
| 334 |
else:
|
| 335 |
+
already_units = mtd_spend.get(card.id, {}).get(ctx.category, 0.0) / 100.0 * rate
|
|
|
|
| 336 |
remaining_units = max(0.0, cap_units - already_units)
|
| 337 |
if gross_units > remaining_units:
|
| 338 |
accel_units = remaining_units
|
| 339 |
+
# WHAT SPEND BEYOND THE CAP EARNS. Falling back to the base rate
|
| 340 |
+
# made the cap ARITHMETICALLY INERT wherever the capped category
|
| 341 |
+
# had no accelerated rate of its own - 28 cards are in that
|
| 342 |
+
# position. HDFC caps Infinia grocery and utility earn at 2,000
|
| 343 |
+
# points a month; since utilities earn the base rate, "cap the
|
| 344 |
+
# first 2,000 then pay base on the rest" reconstitutes the
|
| 345 |
+
# uncapped total exactly, and Rs.2,00,000 of utility spend
|
| 346 |
+
# returned Rs.6,600 against a Rs.2,000 ceiling. A cap on the
|
| 347 |
+
# BASE rate is a hard cap; a cap on an ACCELERATED rate still
|
| 348 |
+
# drops to base, which is the real rule on Millennia.
|
| 349 |
+
base_units_value = 0.0
|
| 350 |
+
if ctx.category not in card.excluded_categories and rate > card.base_rate:
|
| 351 |
+
base_units_value = card.base_rate
|
| 352 |
rupees_at_accel = remaining_units / rate * 100.0 if rate > 0 else 0.0
|
| 353 |
rupees_beyond = max(0.0, ctx.amount - rupees_at_accel)
|
| 354 |
gross_units = accel_units + rupees_beyond / 100.0 * base_units_value
|
|
|
|
| 372 |
offer_inr, offer_note, offer_code = _instant_offer_value(card, ctx)
|
| 373 |
emi_inr, emi_note, emi_code = _emi_offer_value(card, ctx)
|
| 374 |
|
| 375 |
+
# WHAT THE CARD CHARGES TO MAKE THIS PAYMENT, GST included. Netted out
|
| 376 |
+
# of the total so an issuer that charges 1% on rent can no longer
|
| 377 |
+
# outrank one that charges nothing. Thresholds are cumulative per
|
| 378 |
+
# STATEMENT CYCLE, so same-category spend already through this card in
|
| 379 |
+
# this cycle decides how much of THIS payment sits above the line.
|
| 380 |
+
prior_cycle = mtd_spend.get(card.id, {}).get(ctx.category, 0.0)
|
| 381 |
+
fee_inr = 0.0 if rail == "upi" else fee_for(card.id, ctx.category, ctx.amount, prior_cycle)
|
| 382 |
+
# _r2, not round(): Python's round is banker's and JS Math.round is
|
| 383 |
+
# half-up, which put the two engines a paisa apart on 448 scorings.
|
| 384 |
+
total = _r2(reward_inr + offer_inr - fee_inr)
|
| 385 |
eff_pct = (total / ctx.amount * 100.0) if ctx.amount else 0.0
|
| 386 |
|
| 387 |
# --- reasons ---
|
|
|
|
| 442 |
emi_offer_inr=_r2(emi_inr),
|
| 443 |
emi_offer_text=emi_note,
|
| 444 |
emi_offer_code=emi_code,
|
| 445 |
+
fee_inr=_r2(fee_inr),
|
| 446 |
)
|
| 447 |
all_scores.append(sc)
|
| 448 |
if held:
|
app/statement_parser.py
CHANGED
|
@@ -744,6 +744,119 @@ def _plausible_limit(v: float) -> bool:
|
|
| 744 |
return 25000 <= v <= 1e8 # real card limits; floor rejects years/small numbers
|
| 745 |
|
| 746 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
def _inline_credit_limit(text: str) -> Optional[float]:
|
| 748 |
"""Inline 'Credit Limit Rs 11,30,000' on a single line (Kotak, SBI, many issuers)."""
|
| 749 |
best = None
|
|
@@ -857,6 +970,27 @@ def _ocr_credit_limit(content: bytes, password: Optional[str] = None) -> Optiona
|
|
| 857 |
return best
|
| 858 |
|
| 859 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
def detect_credit_limit(filename: str, content: bytes, password: Optional[str] = None) -> Optional[float]:
|
| 861 |
"""The card's total credit limit from the statement, ignoring available/cash limits.
|
| 862 |
Tiered: inline text, then column-aligned positional, then OCR (only if the text layer
|
|
|
|
| 744 |
return 25000 <= v <= 1e8 # real card limits; floor rejects years/small numbers
|
| 745 |
|
| 746 |
|
| 747 |
+
|
| 748 |
+
# ---------------------------------------------------------------------------
|
| 749 |
+
# REWARD-POINT BALANCE
|
| 750 |
+
#
|
| 751 |
+
# The app can already tell you your points are worth more on a transfer partner
|
| 752 |
+
# than as statement credit - but only if it knows the BALANCE, and the balance
|
| 753 |
+
# was a number the user had to type in by hand. So the panel sat on a nudge and
|
| 754 |
+
# most people never saw an answer.
|
| 755 |
+
#
|
| 756 |
+
# Market check (Aug 2026): on TechnoFino the recurring complaint is that no
|
| 757 |
+
# Indian app automates this - people track 17 cards in Google Sheets. The one
|
| 758 |
+
# app users report as working is CRED, which reads the emailed statement. That
|
| 759 |
+
# is the approach here, and it needs no credentials and no account linking.
|
| 760 |
+
#
|
| 761 |
+
# EVERY STATEMENT PRINTS IT, because the issuer has to. The wording differs, so
|
| 762 |
+
# match the CONTEXT (a reward-points heading or label) and then the closing
|
| 763 |
+
# figure, rather than any bare number near the word "points".
|
| 764 |
+
# ---------------------------------------------------------------------------
|
| 765 |
+
|
| 766 |
+
# The label that names the balance we want. Deliberately narrow: "points
|
| 767 |
+
# earned", "points redeemed" and "points expiring" are NOT the balance, and
|
| 768 |
+
# grabbing one of those would show the user a confidently wrong number.
|
| 769 |
+
_PTS_CLOSING_RE = re.compile(
|
| 770 |
+
r"(?:closing|total|net|available|balance\s+of)?\s*"
|
| 771 |
+
r"(?:reward|edge|membership\s+reward|neu|payback|club\s*vistara|bonus)?\s*"
|
| 772 |
+
r"points?\s*(?:balance|as\s+on|closing|total)?\s*[:\-]?\s*"
|
| 773 |
+
r"([0-9][0-9,]{0,9})\b",
|
| 774 |
+
re.I,
|
| 775 |
+
)
|
| 776 |
+
# A reward-points SECTION heading; a "Closing Balance" beneath one is the
|
| 777 |
+
# balance even when the word "points" is not repeated on that line.
|
| 778 |
+
_PTS_SECTION_RE = re.compile(r"(reward|edge\s+reward|membership\s+reward|neu\s*coin|payback)\s*points?\s*(summary|details|statement)?", re.I)
|
| 779 |
+
_CLOSING_RE = re.compile(r"closing\s*(?:balance|points?)?\s*[:\-]?\s*([0-9][0-9,]{0,9})\b", re.I)
|
| 780 |
+
# Words that mean this figure is NOT the balance.
|
| 781 |
+
_PTS_REJECT = re.compile(r"earn|redeem|expir|lapse|adjust|convert|debit|credit\s*card\s*no|opening", re.I)
|
| 782 |
+
# A rupee context: points are a count, never a currency amount, so a figure
|
| 783 |
+
# introduced by a currency mark or carrying paise is the wrong figure.
|
| 784 |
+
_PTS_MONEY = re.compile(r"(?:rs\.?|inr|₹)\s*$", re.I)
|
| 785 |
+
# An integer that is NOT part of a decimal amount - points are whole units, so
|
| 786 |
+
# "5,000.00" is a rupee figure and must not be read as a balance.
|
| 787 |
+
# The WHOLE digit run, never a fragment of one. With a plain \b boundary,
|
| 788 |
+
# "99,99,99,999" backtracked to a valid-looking prefix, finditer then matched
|
| 789 |
+
# the trailing "999", and an absurd figure was read as a 999-point balance.
|
| 790 |
+
# The lookarounds refuse to start or stop inside a number, so an over-long run
|
| 791 |
+
# is rejected by _plausible_points as a whole instead of being salvaged.
|
| 792 |
+
_PTS_INT_RE = re.compile(r"(?<![0-9,])([0-9][0-9,]{0,13})(?![0-9,])(?!\s*\.\s*[0-9])")
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
def _plausible_points(v: float) -> bool:
|
| 796 |
+
"""A balance a real cardholder can hold. Rejects a stray year (2026), a
|
| 797 |
+
card fragment, and an absurd figure that is really a rupee total."""
|
| 798 |
+
return 0 <= v <= 5_000_000 and not (1990 <= v <= 2100 and v == int(v) and len(str(int(v))) == 4)
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
def detect_points_balance(text: str) -> Optional[int]:
|
| 802 |
+
"""The reward-point CLOSING balance from statement text, or None.
|
| 803 |
+
|
| 804 |
+
None is the honest answer whenever the statement is ambiguous: a wrong
|
| 805 |
+
balance would feed straight into "your points cover this flight", which is
|
| 806 |
+
a claim the user would act on.
|
| 807 |
+
"""
|
| 808 |
+
if not text:
|
| 809 |
+
return None
|
| 810 |
+
lines = [l.strip() for l in text.splitlines() if l.strip()]
|
| 811 |
+
cands: List[int] = []
|
| 812 |
+
|
| 813 |
+
for i, line in enumerate(lines):
|
| 814 |
+
low = line.lower()
|
| 815 |
+
# (a) an explicit labelled balance on one line. Once the line is
|
| 816 |
+
# CONFIRMED to name a balance, take the LAST plausible integer on it
|
| 817 |
+
# rather than trying to write one regex wide enough for every issuer's
|
| 818 |
+
# wording - Amex prints "Points Balance as on 31 Jul 2026: 2,15,300",
|
| 819 |
+
# and a pattern tight enough to reject "Points Earned 3,120" can never
|
| 820 |
+
# also span that date clause. Balances are printed last on their line.
|
| 821 |
+
if re.search(r"points?", low) and re.search(r"balance|closing|as on|total", low):
|
| 822 |
+
if _PTS_REJECT.search(low) and not re.search(r"closing|balance", low):
|
| 823 |
+
continue
|
| 824 |
+
line_vals: List[int] = []
|
| 825 |
+
for m in _PTS_INT_RE.finditer(line):
|
| 826 |
+
pre = line[max(0, m.start() - 6):m.start()]
|
| 827 |
+
if _PTS_MONEY.search(pre):
|
| 828 |
+
continue
|
| 829 |
+
try:
|
| 830 |
+
v = int(m.group(1).replace(",", ""))
|
| 831 |
+
except ValueError:
|
| 832 |
+
continue
|
| 833 |
+
if _plausible_points(v):
|
| 834 |
+
line_vals.append(v)
|
| 835 |
+
if line_vals:
|
| 836 |
+
cands.append(line_vals[-1])
|
| 837 |
+
# (b) a "Closing Balance" line inside a reward-points section
|
| 838 |
+
if _PTS_SECTION_RE.search(low):
|
| 839 |
+
for j in range(i, min(i + 6, len(lines))):
|
| 840 |
+
cm = _CLOSING_RE.search(lines[j])
|
| 841 |
+
if cm:
|
| 842 |
+
try:
|
| 843 |
+
v = int(cm.group(1).replace(",", ""))
|
| 844 |
+
except ValueError:
|
| 845 |
+
continue
|
| 846 |
+
if _plausible_points(v):
|
| 847 |
+
cands.append(v)
|
| 848 |
+
break
|
| 849 |
+
|
| 850 |
+
if not cands:
|
| 851 |
+
return None
|
| 852 |
+
# Several figures and no agreement means we cannot tell which is the
|
| 853 |
+
# balance - say nothing rather than pick one.
|
| 854 |
+
uniq = sorted(set(cands))
|
| 855 |
+
if len(uniq) > 1 and uniq[-1] != max(cands, key=cands.count):
|
| 856 |
+
return None
|
| 857 |
+
return max(set(cands), key=cands.count)
|
| 858 |
+
|
| 859 |
+
|
| 860 |
def _inline_credit_limit(text: str) -> Optional[float]:
|
| 861 |
"""Inline 'Credit Limit Rs 11,30,000' on a single line (Kotak, SBI, many issuers)."""
|
| 862 |
best = None
|
|
|
|
| 970 |
return best
|
| 971 |
|
| 972 |
|
| 973 |
+
def detect_points_balance_file(filename: str, content: bytes, password: Optional[str] = None) -> Optional[int]:
|
| 974 |
+
"""The reward-point balance from a statement FILE. Same tiering discipline
|
| 975 |
+
as detect_credit_limit: read the text layer, and return None rather than a
|
| 976 |
+
guess. Deliberately does NOT fall through to OCR - a wrong balance would
|
| 977 |
+
feed "your points cover this flight", and no balance at all is the safer
|
| 978 |
+
failure."""
|
| 979 |
+
name = (filename or "").lower()
|
| 980 |
+
if name.endswith(".pdf"):
|
| 981 |
+
try:
|
| 982 |
+
import pdfplumber
|
| 983 |
+
with pdfplumber.open(io.BytesIO(content), password=password or "") as pdf:
|
| 984 |
+
text = "\n".join((p.extract_text() or "") for p in pdf.pages[:4])
|
| 985 |
+
return detect_points_balance(text)
|
| 986 |
+
except Exception:
|
| 987 |
+
return None
|
| 988 |
+
try:
|
| 989 |
+
return detect_points_balance(content.decode("utf-8-sig", errors="ignore"))
|
| 990 |
+
except Exception:
|
| 991 |
+
return None
|
| 992 |
+
|
| 993 |
+
|
| 994 |
def detect_credit_limit(filename: str, content: bytes, password: Optional[str] = None) -> Optional[float]:
|
| 995 |
"""The card's total credit limit from the statement, ignoring available/cash limits.
|
| 996 |
Tiered: inline text, then column-aligned positional, then OCR (only if the text layer
|
tests/hotel_totals_regress.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
HOTEL PRICE-BASIS REGRESSION.
|
| 4 |
+
|
| 5 |
+
The bug: price_inr is documented as the STAY TOTAL - the figure the card is
|
| 6 |
+
charged, and therefore the figure every offer minimum and cap is judged
|
| 7 |
+
against - but when a vendor omitted total_rate the code silently put its
|
| 8 |
+
PER-NIGHT rate in that field:
|
| 9 |
+
|
| 10 |
+
"price_inr": total if total is not None else night
|
| 11 |
+
|
| 12 |
+
channel_prices then takes min() across vendors, so on a five-night stay the
|
| 13 |
+
single most expensive vendor (Yatra, 6,900/night = 34,500) presented as the
|
| 14 |
+
cheapest at 6,900, became priced_total_inr, and became the base for offer
|
| 15 |
+
minimums and caps - a factor-of-five error, in the app's favour-looking
|
| 16 |
+
direction, on a number the user is asked to trust.
|
| 17 |
+
|
| 18 |
+
Second, smaller version of the same sin: _amount fell back from
|
| 19 |
+
extracted_lowest (with taxes) to extracted_before_taxes_fees (without), so a
|
| 20 |
+
before-tax quote competed against with-tax ones in the same min().
|
| 21 |
+
|
| 22 |
+
Run: python3 backend/tests/hotel_totals_regress.py
|
| 23 |
+
"""
|
| 24 |
+
import os
|
| 25 |
+
import sys
|
| 26 |
+
|
| 27 |
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app"))
|
| 28 |
+
|
| 29 |
+
import hotel_sellers as hs # noqa: E402
|
| 30 |
+
import hotels_provider as hp # noqa: E402
|
| 31 |
+
|
| 32 |
+
PASS = FAIL = 0
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def t(name, ok, detail=None):
|
| 36 |
+
global PASS, FAIL
|
| 37 |
+
if ok:
|
| 38 |
+
PASS += 1
|
| 39 |
+
print(f"PASS {name}")
|
| 40 |
+
else:
|
| 41 |
+
FAIL += 1
|
| 42 |
+
print(f"FAIL {name}" + (f" {detail}" if detail is not None else ""))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
CI, CO = "2026-09-10", "2026-09-15" # five nights
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------- nights
|
| 48 |
+
t("nights: 10->15 Sep is 5", hs._nights(CI, CO) == 5, hs._nights(CI, CO))
|
| 49 |
+
t("nights: same day is None (not 0)", hs._nights(CI, CI) is None)
|
| 50 |
+
t("nights: reversed dates is None", hs._nights(CO, CI) is None)
|
| 51 |
+
t("nights: garbage is None", hs._nights("not-a-date", CO) is None)
|
| 52 |
+
|
| 53 |
+
# ------------------------------------------------------------ stay total
|
| 54 |
+
t("total_rate is used as-is", hs._stay_total(32339.0, 6900.0, 5) == (32339.0, False))
|
| 55 |
+
t("nightly-only is MULTIPLIED OUT, not substituted",
|
| 56 |
+
hs._stay_total(None, 6900.0, 5) == (34500.0, True), hs._stay_total(None, 6900.0, 5))
|
| 57 |
+
t("nightly-only with unusable dates yields nothing (caller drops the row)",
|
| 58 |
+
hs._stay_total(None, 6900.0, None) == (None, False))
|
| 59 |
+
t("neither figure yields nothing", hs._stay_total(None, None, 5) == (None, False))
|
| 60 |
+
t("a one-night stay is unchanged by the multiply",
|
| 61 |
+
hs._stay_total(None, 6900.0, 1) == (6900.0, True))
|
| 62 |
+
|
| 63 |
+
# ------------------------------------------------------------- tax basis
|
| 64 |
+
t("extracted_lowest is tax-inclusive",
|
| 65 |
+
hs._amount_taxed({"extracted_lowest": 31000}) == (31000.0, True))
|
| 66 |
+
t("before-taxes figure is flagged as such",
|
| 67 |
+
hs._amount_taxed({"extracted_before_taxes_fees": 27000}) == (27000.0, False))
|
| 68 |
+
t("missing block is (None, False)", hs._amount_taxed(None) == (None, False))
|
| 69 |
+
|
| 70 |
+
# ------------------------------- the reported scenario, end to end
|
| 71 |
+
VENDORS = [
|
| 72 |
+
("Agoda", {"extracted_lowest": 32339}, None),
|
| 73 |
+
("MakeMyTrip", {"extracted_lowest": 31000}, None),
|
| 74 |
+
("Yatra", None, {"extracted_lowest": 6900}), # nightly only: a 34,500 stay
|
| 75 |
+
("Booking.com", {"extracted_before_taxes_fees": 27000}, None),
|
| 76 |
+
]
|
| 77 |
+
nights = hs._nights(CI, CO)
|
| 78 |
+
rows = []
|
| 79 |
+
for name, total_b, night_b in VENDORS:
|
| 80 |
+
total, t_tax = hs._amount_taxed(total_b)
|
| 81 |
+
night, n_tax = hs._amount_taxed(night_b)
|
| 82 |
+
stay, derived = hs._stay_total(total, night, nights)
|
| 83 |
+
rows.append({"seller": name, "price_inr": stay, "total_derived": derived,
|
| 84 |
+
"taxes_included": t_tax if total is not None else n_tax})
|
| 85 |
+
|
| 86 |
+
yatra = next(r for r in rows if r["seller"] == "Yatra")
|
| 87 |
+
t("Yatra's nightly-only quote reads as its real 34,500 stay total",
|
| 88 |
+
yatra["price_inr"] == 34500.0 and yatra["total_derived"] is True, yatra)
|
| 89 |
+
t("the most expensive vendor is no longer the cheapest",
|
| 90 |
+
min(rows, key=lambda r: r["price_inr"])["seller"] != "Yatra",
|
| 91 |
+
min(rows, key=lambda r: r["price_inr"]))
|
| 92 |
+
|
| 93 |
+
taxed = [r["price_inr"] for r in rows if r["taxes_included"]]
|
| 94 |
+
headline = min(taxed) if taxed else min(r["price_inr"] for r in rows)
|
| 95 |
+
t("the headline total is the cheapest TAX-INCLUSIVE quote, not a before-tax one",
|
| 96 |
+
headline == 31000.0, headline)
|
| 97 |
+
|
| 98 |
+
# --------------------------------------------------- the other provider
|
| 99 |
+
t("provider: nightly-only is multiplied out",
|
| 100 |
+
hp._price_of({"price": {"nightlyPrice": 6900}}, 5) == 34500.0,
|
| 101 |
+
hp._price_of({"price": {"nightlyPrice": 6900}}, 5))
|
| 102 |
+
t("provider: nightly-only with no dates is refused (0 => row dropped)",
|
| 103 |
+
hp._price_of({"price": {"nightlyPrice": 6900}}, None) == 0.0)
|
| 104 |
+
t("provider: a real total still wins over the nightly figure",
|
| 105 |
+
hp._price_of({"price": {"totalPrice": 34500, "nightlyPrice": 6900}}, 5) == 34500.0)
|
| 106 |
+
t("provider: nights helper agrees with hotel_sellers",
|
| 107 |
+
hp._nights_between(CI, CO) == hs._nights(CI, CO))
|
| 108 |
+
|
| 109 |
+
print(f"\nHOTEL TOTALS REGRESS: {PASS} passed, {FAIL} failed")
|
| 110 |
+
sys.exit(1 if FAIL else 0)
|
tests/points_balance_regress.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
REWARD-BALANCE DETECTION REGRESSIONS.
|
| 4 |
+
|
| 5 |
+
payWithPoints could always say "your points cover this flight" - but only from
|
| 6 |
+
a balance the user typed in by hand, so the panel showed a nudge and never an
|
| 7 |
+
answer. Market check (Aug 2026): the recurring complaint on TechnoFino is that
|
| 8 |
+
no Indian app automates this; people track 17 cards in Google Sheets. The one
|
| 9 |
+
app users report as working is CRED, which reads the emailed statement. That is
|
| 10 |
+
what this does, and it needs no credentials and no account linking.
|
| 11 |
+
|
| 12 |
+
The bar is deliberately high in one direction: a WRONG balance feeds straight
|
| 13 |
+
into "your points cover this flight", which the user would act on. None is
|
| 14 |
+
always the safer answer.
|
| 15 |
+
|
| 16 |
+
Run: python3 backend/tests/points_balance_regress.py
|
| 17 |
+
"""
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
|
| 21 |
+
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app")
|
| 22 |
+
sys.path.insert(0, _APP)
|
| 23 |
+
# statement_parser imports merchants for categorisation; on a bare checkout of
|
| 24 |
+
# just this file that module may not be importable, and none of the detection
|
| 25 |
+
# under test needs it.
|
| 26 |
+
try:
|
| 27 |
+
import merchants # noqa: F401
|
| 28 |
+
except ModuleNotFoundError:
|
| 29 |
+
import types
|
| 30 |
+
_stub = types.ModuleType("merchants")
|
| 31 |
+
_stub.resolve_merchant = lambda *a, **k: {"brand_key": None, "category": "general", "offers": []}
|
| 32 |
+
sys.modules["merchants"] = _stub
|
| 33 |
+
import statement_parser as sp # noqa: E402
|
| 34 |
+
|
| 35 |
+
PASS = FAIL = 0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def t(name, ok, detail=None):
|
| 39 |
+
global PASS, FAIL
|
| 40 |
+
if ok:
|
| 41 |
+
PASS += 1
|
| 42 |
+
print(f"PASS {name}")
|
| 43 |
+
else:
|
| 44 |
+
FAIL += 1
|
| 45 |
+
print(f"FAIL {name}" + (f" {detail}" if detail is not None else ""))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------- real issuer layouts
|
| 49 |
+
FOUND = [
|
| 50 |
+
("HDFC reward-points section", "Reward Points Summary\nOpening Balance 12,450\n"
|
| 51 |
+
"Points Earned 3,120\nPoints Redeemed 5,000\nClosing Balance 10,570", 10570),
|
| 52 |
+
("Axis inline EDGE points", "EDGE REWARD POINTS BALANCE : 48,900", 48900),
|
| 53 |
+
("SBI total balance", "Reward Points Summary\nTotal Reward Points Balance 1,04,220", 104220),
|
| 54 |
+
# Amex puts a date clause between the label and the figure, which is why the
|
| 55 |
+
# detector takes the LAST plausible integer on a confirmed balance line
|
| 56 |
+
# rather than trying to span it with one regex.
|
| 57 |
+
("Amex with an 'as on' date", "Membership Rewards Points Balance as on 31 Jul 2026: 2,15,300", 215300),
|
| 58 |
+
("ICICI closing under a heading", "Reward Points\nClosing Balance 7,845", 7845),
|
| 59 |
+
("IDFC single line", "Total Reward Points Balance: 9,310", 9310),
|
| 60 |
+
("lakh-grouped digits", "Reward Points Balance 12,34,567", 1234567),
|
| 61 |
+
]
|
| 62 |
+
for name, text, want in FOUND:
|
| 63 |
+
got = sp.detect_points_balance(text)
|
| 64 |
+
t(f"reads {name}", got == want, f"got {got}, want {want}")
|
| 65 |
+
|
| 66 |
+
# ------------------------------------- silence beats a wrong number
|
| 67 |
+
SILENT = [
|
| 68 |
+
("a statement with no points at all", "Statement Date 05/08/2026\nTotal Amount Due Rs 12,340.00"),
|
| 69 |
+
("points EARNED, which is not the balance", "Reward Points Earned This Month 3,120"),
|
| 70 |
+
("points REDEEMED", "Reward Points Redeemed 4,500"),
|
| 71 |
+
("points EXPIRING", "Reward Points Expiring Next Month 1,200"),
|
| 72 |
+
("a rupee value that mentions points", "Points Redeemed Value Rs. 5,000.00"),
|
| 73 |
+
("a bare year", "Reward Points Balance 2026"),
|
| 74 |
+
("empty input", ""),
|
| 75 |
+
("None input", None),
|
| 76 |
+
]
|
| 77 |
+
for name, text in SILENT:
|
| 78 |
+
got = sp.detect_points_balance(text)
|
| 79 |
+
t(f"stays silent on {name}", got is None, f"got {got}")
|
| 80 |
+
|
| 81 |
+
# --------------------------------------------------- sanity bounds
|
| 82 |
+
t("rejects an implausible balance", sp.detect_points_balance("Reward Points Balance 99,99,99,999") is None)
|
| 83 |
+
t("accepts a zero-ish small balance", sp.detect_points_balance("Reward Points Balance 250") == 250)
|
| 84 |
+
|
| 85 |
+
# ------------------------------------------------- file-level entry
|
| 86 |
+
t("CSV bytes are read", sp.detect_points_balance_file("s.csv", b"Reward Points Balance 48,900") == 48900)
|
| 87 |
+
t("an unreadable file is None, not a crash", sp.detect_points_balance_file("s.pdf", b"not a pdf") is None)
|
| 88 |
+
t("a file with no balance is None", sp.detect_points_balance_file("s.csv", b"Date,Desc,Amount\n01/08/2026,X,100") is None)
|
| 89 |
+
|
| 90 |
+
print(f"\nPOINTS BALANCE REGRESS: {PASS} passed, {FAIL} failed")
|
| 91 |
+
sys.exit(1 if FAIL else 0)
|