sammy786 commited on
Commit
2b55cd4
·
1 Parent(s): fe91223

offers: +356 verified market-sweep deals (2026-08-08)

Browse files
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
- cap_groups=[{"categories": ["groceries", "utilities"], "cap": 2000}], # insurance cap raised to 10,000 RP/mo (eff Jul 2025); grocery/utility 2,000 RP/mo
 
 
 
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}], # 2,000/mo shared
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% else; UPI NeuCoins capped 500/mo
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={"wallet_load": 500},
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={"wallet_load": 500},
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
- Unknown scope means DO NOT FILTER: a null passenger count must not silently
705
- delete every slabbed offer from a non-travel scoring.
 
 
 
 
 
 
706
  """
707
  if not scope:
708
  return list(offers or [])
709
  intl = bool(scope.get("international"))
710
- known_pax = scope.get("passengers") is not None
711
- pax = scope.get("passengers") or 1
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 known_pax and int(o.get("min_passengers") or 0) > pax:
 
 
 
 
 
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 DISCOUNT CANNOT EXCEED THE BILL. "Flat Rs.3,000 off" on a Rs.500
754
- # basket was valued at 3,000 - the user walkthrough (2026-07-29) showed
755
- # a card "saving" six times the purchase. Most such offers carry a
756
- # min_spend that gates this; for the few whose page states none, the
757
- # spend itself is the ceiling. Mirrored in offers.ts offerValue.
758
- return min(float(v), amount)
 
 
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-07-27"
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), note (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": 0.9, "best_per_point_inr": 1.0, "travel_bookable": True, "note": "1 6E Reward = ₹1 on IndiGo only; ₹200 + GST fee per redemption booking"},
118
  ],
119
  "hdfc_6e_rewards_xl": [
120
- {"key": "indigo", "label": "IndiGo flights and add-ons (6E Rewards)", "kind": "portal", "per_point_inr": 0.9, "best_per_point_inr": 1.0, "travel_bookable": True, "note": "1 6E Reward = ₹1 on IndiGo only; ₹200 + GST fee per redemption booking"},
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) -> List[Dict]:
 
404
  """For a live-priced flight or hotel, which held card's points could pay
405
- for it, and on which route. Estimates only; mirrors the TypeScript half."""
 
 
 
 
 
 
 
 
 
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
- needed = ceil(amt / m["per_point_inr"])
424
- label = PORTAL_ROUTE_LABEL.get(m["key"], f"Book via {m['label']}")
425
- if pick is None or needed < pick["needed"]:
426
- pick = {"route": "portal", "label": label, "needed": needed,
427
- "per": m["per_point_inr"], "note": m.get("note")}
 
 
 
 
 
 
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
- needed = ceil(miles_needed * (p["from_units"] / p["to_units"]))
 
 
436
  per = _r2((p["to_units"] / p["from_units"]) * mv["realistic"])
437
- if pick is None or needed < pick["needed"]:
438
- pick = {"route": "transfer", "label": f"Transfer to {p['partner']}",
439
- "needed": needed, "per": per, "note": p.get("note")}
 
 
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
- "coverage_pct": min(100, _rint((balance / pick["needed"]) * 100)),
 
 
 
 
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 cid == card.id # explicit card wins; trust it
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 iss:
129
- return False
130
- if iss != card.issuer:
131
- return False
 
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
- spent = sum(mtd_spend.get(card.id, {}).get("~" + b, 0.0) for b in brand_grp["brands"])
 
 
 
294
  elif cat_grp:
295
- spent = sum(mtd_spend.get(card.id, {}).get(c, 0.0) for c in cat_grp["categories"])
 
 
 
296
  else:
297
- spent = mtd_spend.get(card.id, {}).get(ctx.category, 0.0)
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
- base_units_value = card.base_rate if ctx.category not in card.excluded_categories else 0.0
 
 
 
 
 
 
 
 
 
 
 
 
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
- total = reward_inr + offer_inr
 
 
 
 
 
 
 
 
 
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)