Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Bug hunt round 2: statement parsing, offer gating, geo scope, flight backend
Browse files- app/card_catalogue.py +23 -0
- app/channels.py +42 -7
- app/flight_sellers.py +105 -12
- app/geo.py +23 -0
- app/main.py +23 -5
- app/offers.py +74 -7
- app/scoring_engine.py +57 -7
- app/statement_parser.py +93 -28
app/card_catalogue.py
CHANGED
|
@@ -48,6 +48,12 @@ class Card:
|
|
| 48 |
point_value_inr: float
|
| 49 |
base_rate: float # reward units per INR 100
|
| 50 |
kind: str = "credit" # "credit" | "debit" - debit cards join the best-card reco, not "apply for"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
category_rates: Dict[str, float] = field(default_factory=dict)
|
| 52 |
caps: Dict[str, Optional[int]] = field(default_factory=dict) # per-category monthly cap (reward units)
|
| 53 |
cap_groups: List[Dict] = field(default_factory=list) # [{"categories": [...], "cap": N}] shared monthly cap
|
|
@@ -2016,3 +2022,20 @@ def is_likely_eligible(card_id: str, band) -> bool:
|
|
| 2016 |
if not band or band not in INCOME_BAND_CEILING:
|
| 2017 |
return True
|
| 2018 |
return CARD_MIN_INCOME_INR.get(card_id, 0) < INCOME_BAND_CEILING[band]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
point_value_inr: float
|
| 49 |
base_rate: float # reward units per INR 100
|
| 50 |
kind: str = "credit" # "credit" | "debit" - debit cards join the best-card reco, not "apply for"
|
| 51 |
+
# Closed to NEW applicants. Still scored for holders, never suggested as a
|
| 52 |
+
# discovery. The field did not exist here at all, so the server happily told
|
| 53 |
+
# people to apply for Axis Atlas (closed since Sep 2025) while the app,
|
| 54 |
+
# which has always carried the flag, recommended something they could
|
| 55 |
+
# actually get. Mirror of cards.ts `closed`.
|
| 56 |
+
closed: bool = False
|
| 57 |
category_rates: Dict[str, float] = field(default_factory=dict)
|
| 58 |
caps: Dict[str, Optional[int]] = field(default_factory=dict) # per-category monthly cap (reward units)
|
| 59 |
cap_groups: List[Dict] = field(default_factory=list) # [{"categories": [...], "cap": N}] shared monthly cap
|
|
|
|
| 2022 |
if not band or band not in INCOME_BAND_CEILING:
|
| 2023 |
return True
|
| 2024 |
return CARD_MIN_INCOME_INR.get(card_id, 0) < INCOME_BAND_CEILING[band]
|
| 2025 |
+
|
| 2026 |
+
|
| 2027 |
+
# The 7 cards cards.ts marks `closed: true`. Applied after construction so the
|
| 2028 |
+
# per-card literals above stay untouched and the two lists cannot drift apart
|
| 2029 |
+
# silently - tests/parity assert this set matches cards.ts exactly.
|
| 2030 |
+
CLOSED_TO_NEW_APPLICANTS = {
|
| 2031 |
+
"amex_mrcc",
|
| 2032 |
+
"amex_platinum_travel",
|
| 2033 |
+
"axis_atlas",
|
| 2034 |
+
"hdfc_regalia",
|
| 2035 |
+
"kotak_mojo",
|
| 2036 |
+
"kotak_myntra",
|
| 2037 |
+
"kotak_royale",
|
| 2038 |
+
}
|
| 2039 |
+
for _c in CATALOGUE:
|
| 2040 |
+
if _c.id in CLOSED_TO_NEW_APPLICANTS:
|
| 2041 |
+
_c.closed = True
|
app/channels.py
CHANGED
|
@@ -227,17 +227,48 @@ FUEL_CHANNELS = [
|
|
| 227 |
]
|
| 228 |
|
| 229 |
# Issuer portal booking sites (behind login; the portal decides final routing).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
PORTAL_URLS = {
|
| 231 |
"HDFC SmartBuy": "https://offers.smartbuy.hdfcbank.com",
|
|
|
|
|
|
|
|
|
|
| 232 |
"Axis Travel Edge": "https://traveledge.axisbank.co.in",
|
| 233 |
"ICICI iShop": "https://ishop.icicibank.com",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
}
|
| 235 |
|
| 236 |
# Issuer booking portals (HDFC SmartBuy, Axis Travel Edge, ICICI iShop) are a
|
| 237 |
# further channel: elevated portal-only rates, but only for holders of that
|
| 238 |
# issuer's portal-rated cards, capped monthly, paid in points (assumed redeemed
|
| 239 |
# at the portal point value). Synthesized per wallet, pan-India online.
|
| 240 |
-
PORTAL_KEYS = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
|
| 243 |
def _portal_value(card, category: str, amount: float) -> float:
|
|
@@ -253,7 +284,7 @@ def _portal_value(card, category: str, amount: float) -> float:
|
|
| 253 |
|
| 254 |
|
| 255 |
def _portal_options(held_card_ids: List[str], category: str, amount: float) -> List[Dict]:
|
| 256 |
-
best: Dict[str, tuple] = {} # portal
|
| 257 |
for cid in held_card_ids:
|
| 258 |
card = CATALOGUE_BY_ID.get(cid)
|
| 259 |
if not card or not card.portal_name:
|
|
@@ -261,24 +292,27 @@ def _portal_options(held_card_ids: List[str], category: str, amount: float) -> L
|
|
| 261 |
v = _portal_value(card, category, amount)
|
| 262 |
if v <= 0:
|
| 263 |
continue
|
| 264 |
-
|
|
|
|
| 265 |
if cur is None or v > cur[1]:
|
| 266 |
-
best[
|
| 267 |
opts = []
|
| 268 |
-
for
|
|
|
|
| 269 |
opts.append({
|
| 270 |
-
"channel_key":
|
| 271 |
"channel_name": pname,
|
| 272 |
"how": f"Book inside {pname} paying with {cname}",
|
| 273 |
"best_card_id": cid,
|
| 274 |
"best_card_name": cname,
|
| 275 |
"reward_inr": v,
|
| 276 |
"instant_offer_inr": 0.0,
|
|
|
|
| 277 |
"total_benefit_inr": v,
|
| 278 |
"effective_pct": _r2(v / amount * 100.0) if amount else 0.0,
|
| 279 |
"availability": "available",
|
| 280 |
"via_portal": True,
|
| 281 |
-
"book_url": PORTAL_URLS.get(
|
| 282 |
# A portal quotes its own fare behind a login, so we never have one.
|
| 283 |
"price_inr": _r2(float(amount)),
|
| 284 |
"price_known": False,
|
|
@@ -499,6 +533,7 @@ def compare_channels(
|
|
| 499 |
"best_card_name": top["card_name"],
|
| 500 |
"reward_inr": top["reward_value_inr"],
|
| 501 |
"instant_offer_inr": top["instant_offer_inr"],
|
|
|
|
| 502 |
"total_benefit_inr": top["total_value_inr"],
|
| 503 |
"effective_pct": top["effective_rate_pct"],
|
| 504 |
"availability": avail,
|
|
|
|
| 227 |
]
|
| 228 |
|
| 229 |
# Issuer portal booking sites (behind login; the portal decides final routing).
|
| 230 |
+
# EVERY portal_name in the card catalogue must appear here, or that portal
|
| 231 |
+
# wins a row and the tile renders no Book button - the same "booking links are
|
| 232 |
+
# absent" bug as the OTA rows, arriving through a different door. Mirror of
|
| 233 |
+
# channels.ts PORTAL_URLS; app/scripts/booklink_regress.ts asserts coverage.
|
| 234 |
PORTAL_URLS = {
|
| 235 |
"HDFC SmartBuy": "https://offers.smartbuy.hdfcbank.com",
|
| 236 |
+
# Four HDFC cards spell it without the issuer prefix. Same portal, and
|
| 237 |
+
# PORTAL_KEYS collapses both spellings onto one channel key.
|
| 238 |
+
"SmartBuy": "https://offers.smartbuy.hdfcbank.com",
|
| 239 |
"Axis Travel Edge": "https://traveledge.axisbank.co.in",
|
| 240 |
"ICICI iShop": "https://ishop.icicibank.com",
|
| 241 |
+
"HSBC Rewards Marketplace": "https://rewards.hsbc.co.in",
|
| 242 |
+
"Scapia app (flights/hotels/buses/trains)": "https://www.scapia.cards",
|
| 243 |
+
# Amex India's rewards hub. Reward Multiplier has no standalone domain
|
| 244 |
+
# (rewardmultiplier.in does not resolve), so this is the issuer page the
|
| 245 |
+
# redemption actually lives behind - never a broken link.
|
| 246 |
+
"Reward Multiplier": "https://www.americanexpress.com/in/rewards/",
|
| 247 |
}
|
| 248 |
|
| 249 |
# Issuer booking portals (HDFC SmartBuy, Axis Travel Edge, ICICI iShop) are a
|
| 250 |
# further channel: elevated portal-only rates, but only for holders of that
|
| 251 |
# issuer's portal-rated cards, capped monthly, paid in points (assumed redeemed
|
| 252 |
# at the portal point value). Synthesized per wallet, pan-India online.
|
| 253 |
+
PORTAL_KEYS = {
|
| 254 |
+
"HDFC SmartBuy": "smartbuy", "SmartBuy": "smartbuy",
|
| 255 |
+
"Axis Travel Edge": "travel_edge", "ICICI iShop": "ishop",
|
| 256 |
+
"HSBC Rewards Marketplace": "hsbc_rewards", "Reward Multiplier": "amex_rewards",
|
| 257 |
+
"Scapia app (flights/hotels/buses/trains)": "scapia",
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
# What we DISPLAY for a portal key. Grouping by key (not by the raw string) is
|
| 261 |
+
# what stops "HDFC SmartBuy" and "SmartBuy" - the same portal, spelled two ways
|
| 262 |
+
# across the catalogue - from becoming two rows in one comparison.
|
| 263 |
+
PORTAL_DISPLAY = {
|
| 264 |
+
"smartbuy": "HDFC SmartBuy", "travel_edge": "Axis Travel Edge", "ishop": "ICICI iShop",
|
| 265 |
+
"hsbc_rewards": "HSBC Rewards Marketplace", "amex_rewards": "Amex Reward Multiplier",
|
| 266 |
+
"scapia": "the Scapia app",
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _portal_key(pname: str) -> str:
|
| 271 |
+
return PORTAL_KEYS.get(pname, pname.lower().replace(" ", "_"))
|
| 272 |
|
| 273 |
|
| 274 |
def _portal_value(card, category: str, amount: float) -> float:
|
|
|
|
| 284 |
|
| 285 |
|
| 286 |
def _portal_options(held_card_ids: List[str], category: str, amount: float) -> List[Dict]:
|
| 287 |
+
best: Dict[str, tuple] = {} # portal KEY -> (card_id, value, card_name, raw portal name)
|
| 288 |
for cid in held_card_ids:
|
| 289 |
card = CATALOGUE_BY_ID.get(cid)
|
| 290 |
if not card or not card.portal_name:
|
|
|
|
| 292 |
v = _portal_value(card, category, amount)
|
| 293 |
if v <= 0:
|
| 294 |
continue
|
| 295 |
+
k = _portal_key(card.portal_name)
|
| 296 |
+
cur = best.get(k)
|
| 297 |
if cur is None or v > cur[1]:
|
| 298 |
+
best[k] = (cid, v, card.name, card.portal_name)
|
| 299 |
opts = []
|
| 300 |
+
for key, (cid, v, cname, raw_name) in best.items():
|
| 301 |
+
pname = PORTAL_DISPLAY.get(key, raw_name)
|
| 302 |
opts.append({
|
| 303 |
+
"channel_key": key,
|
| 304 |
"channel_name": pname,
|
| 305 |
"how": f"Book inside {pname} paying with {cname}",
|
| 306 |
"best_card_id": cid,
|
| 307 |
"best_card_name": cname,
|
| 308 |
"reward_inr": v,
|
| 309 |
"instant_offer_inr": 0.0,
|
| 310 |
+
"instant_offer_code": None,
|
| 311 |
"total_benefit_inr": v,
|
| 312 |
"effective_pct": _r2(v / amount * 100.0) if amount else 0.0,
|
| 313 |
"availability": "available",
|
| 314 |
"via_portal": True,
|
| 315 |
+
"book_url": PORTAL_URLS.get(raw_name),
|
| 316 |
# A portal quotes its own fare behind a login, so we never have one.
|
| 317 |
"price_inr": _r2(float(amount)),
|
| 318 |
"price_known": False,
|
|
|
|
| 533 |
"best_card_name": top["card_name"],
|
| 534 |
"reward_inr": top["reward_value_inr"],
|
| 535 |
"instant_offer_inr": top["instant_offer_inr"],
|
| 536 |
+
"instant_offer_code": top.get("instant_offer_code"),
|
| 537 |
"total_benefit_inr": top["total_value_inr"],
|
| 538 |
"effective_pct": top["effective_rate_pct"],
|
| 539 |
"availability": avail,
|
app/flight_sellers.py
CHANGED
|
@@ -30,7 +30,9 @@ import hashlib
|
|
| 30 |
import json
|
| 31 |
import os
|
| 32 |
import re
|
|
|
|
| 33 |
import time
|
|
|
|
| 34 |
from typing import Dict, List, Optional, Tuple
|
| 35 |
|
| 36 |
SERPAPI_URL = "https://serpapi.com/search"
|
|
@@ -157,7 +159,42 @@ def _params(
|
|
| 157 |
# own timing. Exposed via /flights/search's search_debug so a live check can
|
| 158 |
# prove whether deep_search actually reached and ran upstream - the parameter
|
| 159 |
# was being sent yet fast flat-priced results kept coming back.
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
|
| 163 |
def _get(params: Dict) -> Dict:
|
|
@@ -168,11 +205,10 @@ def _get(params: Dict) -> Dict:
|
|
| 168 |
r = client.get(SERPAPI_URL, params=params)
|
| 169 |
r.raise_for_status()
|
| 170 |
data = r.json()
|
| 171 |
-
global LAST_META
|
| 172 |
sm = data.get("search_metadata") or {}
|
| 173 |
echo = dict(data.get("search_parameters") or {})
|
| 174 |
echo.pop("api_key", None) # never reflect the key
|
| 175 |
-
|
| 176 |
"upstream_time_s": sm.get("total_time_taken"),
|
| 177 |
"status": sm.get("status"),
|
| 178 |
# When this differs from now, upstream served a stored result:
|
|
@@ -222,7 +258,11 @@ def _itinerary(entry: Dict, requested_date: str) -> Optional[Dict]:
|
|
| 222 |
"duration_min": entry.get("total_duration"),
|
| 223 |
"seats_left": None,
|
| 224 |
"found_at": None,
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
"is_alt_date": date_part != requested_date,
|
| 227 |
"oneway_fallback": False,
|
| 228 |
"book_url": None, # filled per-seller by sellers_for()
|
|
@@ -232,10 +272,31 @@ def _itinerary(entry: Dict, requested_date: str) -> Optional[Dict]:
|
|
| 232 |
# booking token only appears once that leg is chosen. See
|
| 233 |
# _resolve_round_trip_token.
|
| 234 |
"departure_token": entry.get("departure_token") or "",
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
}
|
| 237 |
|
| 238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
def _matches(row: Dict, nonstop: Optional[bool], airlines: Optional[List[str]],
|
| 240 |
max_price: Optional[float]) -> bool:
|
| 241 |
"""Same filter semantics as flights_provider, applied client-side."""
|
|
@@ -478,13 +539,37 @@ def return_options(
|
|
| 478 |
legs = e.get("flights") or []
|
| 479 |
if not legs or e.get("price") is None or not e.get("booking_token"):
|
| 480 |
continue
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
break
|
| 487 |
-
|
|
|
|
|
|
|
| 488 |
first, last = rlegs[0], rlegs[-1]
|
| 489 |
dep = first.get("departure_airport") or {}
|
| 490 |
arr = last.get("arrival_airport") or {}
|
|
@@ -510,7 +595,15 @@ def return_options(
|
|
| 510 |
"book_url": None,
|
| 511 |
"booking_token": e.get("booking_token") or "",
|
| 512 |
"departure_token": "",
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
})
|
| 515 |
rows.sort(key=lambda r: r["fare_inr"])
|
| 516 |
# Belt over Google's own filtering: a stop cap the upstream ignored must
|
|
|
|
| 30 |
import json
|
| 31 |
import os
|
| 32 |
import re
|
| 33 |
+
import threading
|
| 34 |
import time
|
| 35 |
+
from datetime import datetime
|
| 36 |
from typing import Dict, List, Optional, Tuple
|
| 37 |
|
| 38 |
SERPAPI_URL = "https://serpapi.com/search"
|
|
|
|
| 159 |
# own timing. Exposed via /flights/search's search_debug so a live check can
|
| 160 |
# prove whether deep_search actually reached and ran upstream - the parameter
|
| 161 |
# was being sent yet fast flat-priced results kept coming back.
|
| 162 |
+
# PER-REQUEST, not process-wide. FastAPI runs these sync endpoints in a
|
| 163 |
+
# threadpool, so a single module global was overwritten by every concurrent
|
| 164 |
+
# call - and it carries the caller's echoed departure_id, arrival_id,
|
| 165 |
+
# outbound_date and passenger counts, which meant one user's search_debug
|
| 166 |
+
# could describe ANOTHER user's route. Thread-local keeps the diagnostic
|
| 167 |
+
# without the leak.
|
| 168 |
+
_META = threading.local()
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class _LastMetaProxy:
|
| 172 |
+
"""Reads like the old module attribute (flight_sellers.LAST_META) but
|
| 173 |
+
resolves to the calling thread's own value."""
|
| 174 |
+
|
| 175 |
+
def _v(self) -> Dict:
|
| 176 |
+
return getattr(_META, "value", {}) or {}
|
| 177 |
+
|
| 178 |
+
def get(self, k, default=None):
|
| 179 |
+
return self._v().get(k, default)
|
| 180 |
+
|
| 181 |
+
def __bool__(self):
|
| 182 |
+
return bool(self._v())
|
| 183 |
+
|
| 184 |
+
def __iter__(self):
|
| 185 |
+
return iter(self._v())
|
| 186 |
+
|
| 187 |
+
def __getitem__(self, k):
|
| 188 |
+
return self._v()[k]
|
| 189 |
+
|
| 190 |
+
def __repr__(self):
|
| 191 |
+
return repr(self._v())
|
| 192 |
+
|
| 193 |
+
def to_dict(self) -> Dict:
|
| 194 |
+
return dict(self._v())
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
LAST_META = _LastMetaProxy()
|
| 198 |
|
| 199 |
|
| 200 |
def _get(params: Dict) -> Dict:
|
|
|
|
| 205 |
r = client.get(SERPAPI_URL, params=params)
|
| 206 |
r.raise_for_status()
|
| 207 |
data = r.json()
|
|
|
|
| 208 |
sm = data.get("search_metadata") or {}
|
| 209 |
echo = dict(data.get("search_parameters") or {})
|
| 210 |
echo.pop("api_key", None) # never reflect the key
|
| 211 |
+
_META.value = {
|
| 212 |
"upstream_time_s": sm.get("total_time_taken"),
|
| 213 |
"status": sm.get("status"),
|
| 214 |
# When this differs from now, upstream served a stored result:
|
|
|
|
| 258 |
"duration_min": entry.get("total_duration"),
|
| 259 |
"seats_left": None,
|
| 260 |
"found_at": None,
|
| 261 |
+
# The app ranks and tags on date_offset; hardcoding 0 while computing
|
| 262 |
+
# is_alt_date from the same comparison meant an alternate-date row
|
| 263 |
+
# sorted as if it were on the requested date and rendered without its
|
| 264 |
+
# "+1d" tag.
|
| 265 |
+
"date_offset": _day_delta(requested_date, date_part),
|
| 266 |
"is_alt_date": date_part != requested_date,
|
| 267 |
"oneway_fallback": False,
|
| 268 |
"book_url": None, # filled per-seller by sellers_for()
|
|
|
|
| 272 |
# booking token only appears once that leg is chosen. See
|
| 273 |
# _resolve_round_trip_token.
|
| 274 |
"departure_token": entry.get("departure_token") or "",
|
| 275 |
+
# THE WHOLE ITINERARY, not just the first leg's flight number. Two
|
| 276 |
+
# genuinely different itineraries routinely open with the same flight -
|
| 277 |
+
# MAA->SXR returned "6E 2369" at 1 stop / ₹10,865 and at 2 stops /
|
| 278 |
+
# ₹11,338, both with offer_id "6E 2369". The app keys selection, dedupe
|
| 279 |
+
# and prefetched quotes on offer_id, so it handed /flights/sellers the
|
| 280 |
+
# wrong token and quoted a price for a flight the user had not chosen.
|
| 281 |
+
"offer_id": "|".join([
|
| 282 |
+
flight_no or "",
|
| 283 |
+
dep_time or "",
|
| 284 |
+
str(max(0, len(legs) - 1)),
|
| 285 |
+
(entry.get("booking_token") or entry.get("departure_token") or "")[:8],
|
| 286 |
+
]),
|
| 287 |
}
|
| 288 |
|
| 289 |
|
| 290 |
+
def _day_delta(requested: str, actual: str) -> int:
|
| 291 |
+
"""Whole days from the REQUESTED date to the date this fare actually flies."""
|
| 292 |
+
try:
|
| 293 |
+
a = datetime.strptime(requested, "%Y-%m-%d").date()
|
| 294 |
+
b = datetime.strptime(actual, "%Y-%m-%d").date()
|
| 295 |
+
except (ValueError, TypeError):
|
| 296 |
+
return 0
|
| 297 |
+
return (b - a).days
|
| 298 |
+
|
| 299 |
+
|
| 300 |
def _matches(row: Dict, nonstop: Optional[bool], airlines: Optional[List[str]],
|
| 301 |
max_price: Optional[float]) -> bool:
|
| 302 |
"""Same filter semantics as flights_provider, applied client-side."""
|
|
|
|
| 539 |
legs = e.get("flights") or []
|
| 540 |
if not legs or e.get("price") is None or not e.get("booking_token"):
|
| 541 |
continue
|
| 542 |
+
# WHERE DOES THE RETURN JOURNEY START?
|
| 543 |
+
#
|
| 544 |
+
# By GEOGRAPHY, not by date. The old test took the first leg departing
|
| 545 |
+
# on or after return_date, which broke two real itineraries:
|
| 546 |
+
# - a SAME-DAY round trip (date == return_date) matched leg 0, so
|
| 547 |
+
# cut == 0, and `if cut else legs` then kept EVERY leg - producing
|
| 548 |
+
# exactly the DEL->DEL, 1-stop, 260-minute "both journeys combined"
|
| 549 |
+
# tile this code exists to prevent;
|
| 550 |
+
# - an OVERNIGHT outbound connection pushed its second leg onto the
|
| 551 |
+
# return date, so half the outbound was spliced onto the return and
|
| 552 |
+
# the user was told their return departed BOM at 03:15 when it
|
| 553 |
+
# actually departed DXB at 22:00.
|
| 554 |
+
#
|
| 555 |
+
# The return journey is the run of legs that begins where the whole
|
| 556 |
+
# itinerary began - the first leg AFTER the outbound reaches the
|
| 557 |
+
# farthest point and turns around, i.e. the first leg departing from
|
| 558 |
+
# the itinerary's original origin. `cut is None` means we could not
|
| 559 |
+
# identify it, and an unidentified boundary must SKIP the row rather
|
| 560 |
+
# than fall back to "use all legs" and quote a fabricated journey.
|
| 561 |
+
# The outbound ends the FIRST time the itinerary arrives at the
|
| 562 |
+
# destination this search asked for; everything after that is the
|
| 563 |
+
# return. Geography, not dates.
|
| 564 |
+
want_dst = {p.strip().upper() for p in (dst or "").split(",") if p.strip()}
|
| 565 |
+
cut = None
|
| 566 |
+
for i, leg in enumerate(legs[:-1]):
|
| 567 |
+
if ((leg.get("arrival_airport") or {}).get("id") or "").upper() in want_dst:
|
| 568 |
+
cut = i + 1
|
| 569 |
break
|
| 570 |
+
if cut is None:
|
| 571 |
+
continue
|
| 572 |
+
rlegs = legs[cut:]
|
| 573 |
first, last = rlegs[0], rlegs[-1]
|
| 574 |
dep = first.get("departure_airport") or {}
|
| 575 |
arr = last.get("arrival_airport") or {}
|
|
|
|
| 595 |
"book_url": None,
|
| 596 |
"booking_token": e.get("booking_token") or "",
|
| 597 |
"departure_token": "",
|
| 598 |
+
# Same rule as the outbound rows: a return pairing that opens with
|
| 599 |
+
# the same flight number as another pairing must not collide.
|
| 600 |
+
# AI 2838 came back twice live, at ₹20,953 and ₹22,507.
|
| 601 |
+
"offer_id": "|".join([
|
| 602 |
+
first.get("flight_number") or "",
|
| 603 |
+
(first.get("departure_airport", {}) or {}).get("time", ""),
|
| 604 |
+
str(len(legs) - 1),
|
| 605 |
+
(e.get("booking_token") or "")[:8],
|
| 606 |
+
]),
|
| 607 |
})
|
| 608 |
rows.sort(key=lambda r: r["fare_inr"])
|
| 609 |
# Belt over Google's own filtering: a stop cap the upstream ignored must
|
app/geo.py
CHANGED
|
@@ -84,6 +84,29 @@ def iata_is_international(code: Optional[str]) -> Optional[bool]:
|
|
| 84 |
return False if all_india else None
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
def city_is_international(name: Optional[str]) -> Optional[bool]:
|
| 88 |
if not name:
|
| 89 |
return None
|
|
|
|
| 84 |
return False if all_india else None
|
| 85 |
|
| 86 |
|
| 87 |
+
def route_is_international(src: Optional[str], dst: Optional[str]) -> Optional[bool]:
|
| 88 |
+
"""Is this ROUTE international? Judged on BOTH endpoints, not the destination.
|
| 89 |
+
|
| 90 |
+
Classifying by destination alone made every INBOUND leg domestic: DXB->BOM
|
| 91 |
+
returned False because BOM is in INDIA_IATA, so the 103 domestic-scoped
|
| 92 |
+
offers were kept on a genuinely international ticket and the 70
|
| 93 |
+
international-scoped ones were dropped. The same ₹40,000 fare therefore
|
| 94 |
+
scored differently in each direction - overstated coming home, understated
|
| 95 |
+
going out.
|
| 96 |
+
|
| 97 |
+
True if EITHER endpoint is international; False only when both are known
|
| 98 |
+
domestic; None when anything is unrecognised, which callers treat as the
|
| 99 |
+
safe domestic default. Mirrored in app/src/data/geo.ts.
|
| 100 |
+
"""
|
| 101 |
+
a = iata_is_international(src)
|
| 102 |
+
b = iata_is_international(dst)
|
| 103 |
+
if a is True or b is True:
|
| 104 |
+
return True
|
| 105 |
+
if a is False and b is False:
|
| 106 |
+
return False
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
def city_is_international(name: Optional[str]) -> Optional[bool]:
|
| 111 |
if not name:
|
| 112 |
return None
|
app/main.py
CHANGED
|
@@ -457,7 +457,8 @@ def flights_search(req: FlightSearchRequest, request: Request = None):
|
|
| 457 |
persona = req.persona or demo_data.DEMO_PERSONA
|
| 458 |
city = normalize_city(req.city)
|
| 459 |
import geo
|
| 460 |
-
|
|
|
|
| 461 |
|
| 462 |
# SerpApi's Google Flights results carry a booking token, which is what
|
| 463 |
# unlocks per-platform prices later via /flights/sellers. Prefer it when a
|
|
@@ -491,7 +492,15 @@ def flights_search(req: FlightSearchRequest, request: Request = None):
|
|
| 491 |
window_days=req.window_days if req.window_days is not None else 3,
|
| 492 |
return_date=req.return_date,
|
| 493 |
)
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
fares_live = False # cached prices, 2-7 days old by the provider's own docs
|
| 496 |
else:
|
| 497 |
fares_live = bool(flights) # a real quote pulled for this route and date
|
|
@@ -512,7 +521,16 @@ def flights_search(req: FlightSearchRequest, request: Request = None):
|
|
| 512 |
results.append({"flight": f, "channel_comparison": cmp})
|
| 513 |
|
| 514 |
return {
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
# live = a fare quoted for this search, not merely "a token is set".
|
| 517 |
# The cached-fare path reports false even when its token is configured.
|
| 518 |
"live": fares_live,
|
|
@@ -520,7 +538,7 @@ def flights_search(req: FlightSearchRequest, request: Request = None):
|
|
| 520 |
"per_platform_prices": flight_sellers.is_live(), # can /flights/sellers answer?
|
| 521 |
# Upstream's own account of the last call (its parameter echo and
|
| 522 |
# timing) - live proof of whether deep_search ran, key never included.
|
| 523 |
-
"search_debug":
|
| 524 |
"count": len(results),
|
| 525 |
"results": results,
|
| 526 |
}
|
|
@@ -564,7 +582,7 @@ def flights_sellers(req: FlightSellersRequest, request: Request = None):
|
|
| 564 |
brand_key=None, merchant=None, rail="card",
|
| 565 |
persona=req.persona or demo_data.DEMO_PERSONA,
|
| 566 |
city=normalize_city(req.city),
|
| 567 |
-
international=geo.
|
| 568 |
passengers=(req.adults or 1) + (req.children or 0),
|
| 569 |
round_trip=bool(getattr(req, "return_date", None)),
|
| 570 |
channel_prices=data["channel_prices"],
|
|
|
|
| 457 |
persona = req.persona or demo_data.DEMO_PERSONA
|
| 458 |
city = normalize_city(req.city)
|
| 459 |
import geo
|
| 460 |
+
# BOTH endpoints: a return leg into India is still an international trip.
|
| 461 |
+
intl = geo.route_is_international(req.src, req.dst) # international route? offers gate on it
|
| 462 |
|
| 463 |
# SerpApi's Google Flights results carry a booking token, which is what
|
| 464 |
# unlocks per-platform prices later via /flights/sellers. Prefer it when a
|
|
|
|
| 492 |
window_days=req.window_days if req.window_days is not None else 3,
|
| 493 |
return_date=req.return_date,
|
| 494 |
)
|
| 495 |
+
# SAY WHY WE FELL BACK. Overwriting source unconditionally made a
|
| 496 |
+
# SerpAPI timeout, a rate-limit and an honest "this route has no
|
| 497 |
+
# flights" indistinguishable - all three logged as
|
| 498 |
+
# source=travelpayouts, count=0. The upstream's own status is right
|
| 499 |
+
# there in LAST_META; carry it.
|
| 500 |
+
_meta = flight_sellers.LAST_META.to_dict()
|
| 501 |
+
_why = _meta.get("status") or _meta.get("error")
|
| 502 |
+
_base = "travelpayouts" if flights_provider.is_live() else "sample (no token)"
|
| 503 |
+
source = f"{_base} (primary: {_why})" if _why and _why != "Success" else _base
|
| 504 |
fares_live = False # cached prices, 2-7 days old by the provider's own docs
|
| 505 |
else:
|
| 506 |
fares_live = bool(flights) # a real quote pulled for this route and date
|
|
|
|
| 521 |
results.append({"flight": f, "channel_comparison": cmp})
|
| 522 |
|
| 523 |
return {
|
| 524 |
+
# return_date belongs in the echo: without it neither a client nor a
|
| 525 |
+
# log could tell a ₹20,467 ROUND-TRIP PAIRING TOTAL from a ₹20,467
|
| 526 |
+
# one-way fare, and the rows themselves are shaped like one-way legs.
|
| 527 |
+
"query": {
|
| 528 |
+
"src": req.src.upper(), "dst": req.dst.upper(), "date": req.date,
|
| 529 |
+
"return_date": req.return_date, "city": city,
|
| 530 |
+
},
|
| 531 |
+
# Every fare in `results` is a pairing total when this is true.
|
| 532 |
+
"round_trip": bool(req.return_date),
|
| 533 |
+
"fare_basis": "pairing_total" if req.return_date else "one_way",
|
| 534 |
# live = a fare quoted for this search, not merely "a token is set".
|
| 535 |
# The cached-fare path reports false even when its token is configured.
|
| 536 |
"live": fares_live,
|
|
|
|
| 538 |
"per_platform_prices": flight_sellers.is_live(), # can /flights/sellers answer?
|
| 539 |
# Upstream's own account of the last call (its parameter echo and
|
| 540 |
# timing) - live proof of whether deep_search ran, key never included.
|
| 541 |
+
"search_debug": flight_sellers.LAST_META.to_dict(),
|
| 542 |
"count": len(results),
|
| 543 |
"results": results,
|
| 544 |
}
|
|
|
|
| 582 |
brand_key=None, merchant=None, rail="card",
|
| 583 |
persona=req.persona or demo_data.DEMO_PERSONA,
|
| 584 |
city=normalize_city(req.city),
|
| 585 |
+
international=geo.route_is_international(req.src, req.dst),
|
| 586 |
passengers=(req.adults or 1) + (req.children or 0),
|
| 587 |
round_trip=bool(getattr(req, "return_date", None)),
|
| 588 |
channel_prices=data["channel_prices"],
|
app/offers.py
CHANGED
|
@@ -19,10 +19,26 @@ Each offer is structured (not free text), so the engine can value it exactly:
|
|
| 19 |
|
| 20 |
import re
|
| 21 |
from dataclasses import dataclass, field, asdict
|
| 22 |
-
from datetime import date
|
| 23 |
from typing import Dict, List, Optional
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def _inr(n: float) -> str:
|
| 27 |
"""Indian digit grouping: 1,00,000 - not 100,000.
|
| 28 |
|
|
@@ -515,14 +531,21 @@ _TRAVEL_OTAS = {"makemytrip", "goibibo", "easemytrip", "cleartrip", "yatra", "ix
|
|
| 515 |
_CAT_WORDS = [
|
| 516 |
(re.compile(r"\b(flights?|airfares?)\b", re.I), "travel_flights"),
|
| 517 |
(re.compile(r"\bhotels?\b", re.I), "travel_hotels"),
|
| 518 |
-
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
]
|
| 521 |
# Only meaningful for a travel OTA: "package" and "holiday" are ordinary words
|
| 522 |
# elsewhere (a spa package, a holiday menu) and would scope a dining offer out
|
| 523 |
# of dining.
|
| 524 |
_OTA_ONLY_CAT_WORDS = [
|
| 525 |
-
|
|
|
|
| 526 |
]
|
| 527 |
|
| 528 |
|
|
@@ -667,11 +690,55 @@ def _best_unlinked(offs, amount, category, channel, conditional: bool):
|
|
| 667 |
return best, note, code, prebook
|
| 668 |
|
| 669 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
def offer_value(off: Dict, amount: float, category: Optional[str] = None) -> float:
|
| 671 |
"""Monetary value of a (dict) offer for a given spend. 0 if not applicable."""
|
| 672 |
if amount < (off.get("min_spend") or 0):
|
| 673 |
return 0.0
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
if cats and category is not None and category not in cats:
|
| 676 |
return 0.0
|
| 677 |
t = off.get("type")
|
|
@@ -719,7 +786,7 @@ def _days_between(a: str, b: str) -> int:
|
|
| 719 |
|
| 720 |
def is_fresh(off: Offer, today: Optional[str] = None) -> bool:
|
| 721 |
"""Was this offer verified recently enough to still be worth asserting?"""
|
| 722 |
-
today = today or
|
| 723 |
return _days_between(off.last_verified, today) <= MAX_VERIFIED_AGE_DAYS
|
| 724 |
|
| 725 |
|
|
@@ -741,7 +808,7 @@ def has_provenance(off: Offer) -> bool:
|
|
| 741 |
|
| 742 |
|
| 743 |
def _active(off: Offer, today: Optional[str]) -> bool:
|
| 744 |
-
today = today or
|
| 745 |
if not (off.valid_from <= today <= off.valid_to):
|
| 746 |
return False
|
| 747 |
# Day-of-week scope: an offer the page limits to certain weekdays does not
|
|
|
|
| 19 |
|
| 20 |
import re
|
| 21 |
from dataclasses import dataclass, field, asdict
|
| 22 |
+
from datetime import date, datetime, timedelta, timezone
|
| 23 |
from typing import Dict, List, Optional
|
| 24 |
|
| 25 |
|
| 26 |
+
|
| 27 |
+
# THE SERVER'S "TODAY" IS INDIA'S TODAY.
|
| 28 |
+
#
|
| 29 |
+
# date.today() on a UTC host is still YESTERDAY between 00:00 and 05:30 IST.
|
| 30 |
+
# The app deliberately uses a LOCAL date (offers.ts: toLocaleDateString('en-CA'),
|
| 31 |
+
# with a comment explaining exactly this), but the server is the only source of
|
| 32 |
+
# offers - anything not active on the UTC date is never sent, and the app cannot
|
| 33 |
+
# re-add what it never received. Measured: 80 offers unreachable at 02:00 IST on
|
| 34 |
+
# a Monday, including every Monday-gated row.
|
| 35 |
+
_IST = timezone(timedelta(hours=5, minutes=30))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _today_ist() -> str:
|
| 39 |
+
return datetime.now(_IST).date().isoformat()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
def _inr(n: float) -> str:
|
| 43 |
"""Indian digit grouping: 1,00,000 - not 100,000.
|
| 44 |
|
|
|
|
| 531 |
_CAT_WORDS = [
|
| 532 |
(re.compile(r"\b(flights?|airfares?)\b", re.I), "travel_flights"),
|
| 533 |
(re.compile(r"\bhotels?\b", re.I), "travel_hotels"),
|
| 534 |
+
# "bus"/"cabs"/"holiday" are NOT categories the engine knows - the Category
|
| 535 |
+
# union and MERCHANT_MAP never produce them - so scoping an offer to one
|
| 536 |
+
# made offer_value's category gate return 0 forever. A verified AU Bank
|
| 537 |
+
# "10% off, up to ₹200" on AbhiBus paid out nothing, and a zeroed offer
|
| 538 |
+
# looks identical to no offer, so it was invisible. Map onto the vocabulary
|
| 539 |
+
# the scorer actually speaks.
|
| 540 |
+
(re.compile(r"\b(bus|buses)\b", re.I), "transport"),
|
| 541 |
+
(re.compile(r"\b(cabs?|car rentals?)\b", re.I), "transport"),
|
| 542 |
]
|
| 543 |
# Only meaningful for a travel OTA: "package" and "holiday" are ordinary words
|
| 544 |
# elsewhere (a spa package, a holiday menu) and would scope a dining offer out
|
| 545 |
# of dining.
|
| 546 |
_OTA_ONLY_CAT_WORDS = [
|
| 547 |
+
# A holiday package is a hotel + a flight; both are priceable categories.
|
| 548 |
+
(re.compile(r"\b(holidays?|packages?)\b", re.I), "travel_hotels"),
|
| 549 |
]
|
| 550 |
|
| 551 |
|
|
|
|
| 690 |
return best, note, code, prebook
|
| 691 |
|
| 692 |
|
| 693 |
+
def scope_filter(offers, scope) -> list:
|
| 694 |
+
"""TRIP SCOPE: geo, party size and one-way vs round trip.
|
| 695 |
+
|
| 696 |
+
These gates lived ONLY inside compare_channels, so the channel rows honoured
|
| 697 |
+
them and the card recommendation did not - the same response carried
|
| 698 |
+
"instant offer 3000" on the card and 799 on the channel row, because the
|
| 699 |
+
card had been given Yatra's THREE-PASSENGER ROUND-TRIP slab for a
|
| 700 |
+
one-passenger one-way fare. Same shape for geo: an international-only hotel
|
| 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 []:
|
| 716 |
+
g = o.get("geo") or "any"
|
| 717 |
+
if g == "international" and not intl:
|
| 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 |
+
|
| 732 |
+
|
| 733 |
def offer_value(off: Dict, amount: float, category: Optional[str] = None) -> float:
|
| 734 |
"""Monetary value of a (dict) offer for a given spend. 0 if not applicable."""
|
| 735 |
if amount < (off.get("min_spend") or 0):
|
| 736 |
return 0.0
|
| 737 |
+
# `[]` is FALSY in Python and TRUTHY in JS, so an offer carrying an empty
|
| 738 |
+
# categories array paid out in full server-side and scored zero on device.
|
| 739 |
+
# No curated row has one today; one ingestion row would silently split the
|
| 740 |
+
# two rails. Both now treat "no categories" as "not scoped".
|
| 741 |
+
cats = off.get("categories") or None
|
| 742 |
if cats and category is not None and category not in cats:
|
| 743 |
return 0.0
|
| 744 |
t = off.get("type")
|
|
|
|
| 786 |
|
| 787 |
def is_fresh(off: Offer, today: Optional[str] = None) -> bool:
|
| 788 |
"""Was this offer verified recently enough to still be worth asserting?"""
|
| 789 |
+
today = today or _today_ist()
|
| 790 |
return _days_between(off.last_verified, today) <= MAX_VERIFIED_AGE_DAYS
|
| 791 |
|
| 792 |
|
|
|
|
| 808 |
|
| 809 |
|
| 810 |
def _active(off: Offer, today: Optional[str]) -> bool:
|
| 811 |
+
today = today or _today_ist()
|
| 812 |
if not (off.valid_from <= today <= off.valid_to):
|
| 813 |
return False
|
| 814 |
# Day-of-week scope: an offer the page limits to certain weekdays does not
|
app/scoring_engine.py
CHANGED
|
@@ -20,7 +20,7 @@ Design principles:
|
|
| 20 |
on a single transaction.
|
| 21 |
"""
|
| 22 |
|
| 23 |
-
from dataclasses import dataclass
|
| 24 |
from typing import Dict, List, Optional
|
| 25 |
|
| 26 |
from card_catalogue import Card, CATALOGUE_BY_ID, all_cards
|
|
@@ -64,6 +64,12 @@ class TxnContext:
|
|
| 64 |
offers: Optional[List[Dict]] = None
|
| 65 |
rail: str = "card" # "card" (POS/online) or "upi" (RuPay credit-on-UPI)
|
| 66 |
channel: Optional[str] = None # "online" | "offline"; when known, filters offers by channel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
@dataclass
|
|
@@ -87,6 +93,9 @@ class CardScore:
|
|
| 87 |
emi_offer_inr: float = 0.0
|
| 88 |
emi_offer_text: Optional[str] = None
|
| 89 |
emi_offer_code: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
def _offer_applies(off: Dict, card: Card, allow_emi: bool = False) -> bool:
|
|
@@ -127,7 +136,12 @@ def _offer_applies(off: Dict, card: Card, allow_emi: bool = False) -> bool:
|
|
| 127 |
# Issuer-level offers still carry hidden card scoping the issuer field ignores:
|
| 128 |
# a network limit ("Mastercard only") or a debit-card requirement. Enforce both,
|
| 129 |
# else an HDFC-Mastercard-debit offer wrongly credits an HDFC-RuPay-credit card.
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
return False
|
| 132 |
net = (off.get("network") or "any").lower()
|
| 133 |
if net != "any" and net != (card.network or "").lower():
|
|
@@ -140,6 +154,11 @@ def _instant_offer_value(card: Card, ctx: TxnContext):
|
|
| 140 |
from offers import offer_value
|
| 141 |
best = 0.0
|
| 142 |
note = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
for off in (ctx.offers or []):
|
| 144 |
if not _offer_applies(off, card):
|
| 145 |
continue
|
|
@@ -160,7 +179,8 @@ def _instant_offer_value(card: Card, ctx: TxnContext):
|
|
| 160 |
val = ctx.amount * float(pct.group(1)) / 100.0 if pct else (float(flat.group(1).replace(",", "")) if flat else 0.0)
|
| 161 |
if val > best:
|
| 162 |
best, note = val, txt
|
| 163 |
-
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
def _emi_offer_value(card: Card, ctx: TxnContext):
|
|
@@ -188,6 +208,15 @@ def _emi_offer_value(card: Card, ctx: TxnContext):
|
|
| 188 |
return best, note, code
|
| 189 |
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
def score_transaction(
|
| 192 |
held_card_ids: List[str],
|
| 193 |
ctx: TxnContext,
|
|
@@ -204,6 +233,16 @@ def score_transaction(
|
|
| 204 |
mtd_spend: {card_id: {category: rupees_spent_this_month}}
|
| 205 |
"""
|
| 206 |
mtd_spend = mtd_spend or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
persona = persona or []
|
| 208 |
held_scores: List[CardScore] = []
|
| 209 |
all_scores: List[CardScore] = []
|
|
@@ -278,10 +317,10 @@ def score_transaction(
|
|
| 278 |
|
| 279 |
# --- instant offers (don't apply on the UPI rail) ---
|
| 280 |
if rail == "upi":
|
| 281 |
-
offer_inr, offer_note = 0.0, None
|
| 282 |
emi_inr, emi_note, emi_code = 0.0, None, None
|
| 283 |
else:
|
| 284 |
-
offer_inr, offer_note = _instant_offer_value(card, ctx)
|
| 285 |
emi_inr, emi_note, emi_code = _emi_offer_value(card, ctx)
|
| 286 |
|
| 287 |
total = reward_inr + offer_inr
|
|
@@ -335,6 +374,7 @@ def score_transaction(
|
|
| 335 |
network=card.network,
|
| 336 |
reward_value_inr=_r2(reward_inr),
|
| 337 |
instant_offer_inr=_r2(offer_inr),
|
|
|
|
| 338 |
total_value_inr=_r2(total),
|
| 339 |
raw_total=total,
|
| 340 |
effective_rate_pct=_r2(eff_pct),
|
|
@@ -356,7 +396,11 @@ def score_transaction(
|
|
| 356 |
_upi_ok = {c.id for c in all_cards() if getattr(c, "upi_eligible", False)}
|
| 357 |
def _rank_key(s):
|
| 358 |
demerit = 1 if (rail == "upi" and s.card_id not in _upi_ok) else 0
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
held_scores.sort(key=_rank_key)
|
| 361 |
all_scores.sort(key=_rank_key)
|
| 362 |
|
|
@@ -400,11 +444,17 @@ def score_transaction(
|
|
| 400 |
if include_discovery and held_scores:
|
| 401 |
best_held = held_scores[0].total_value_inr
|
| 402 |
for sc in all_scores:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
if not sc.held and sc.total_value_inr > best_held * 1.10: # >10% better
|
| 404 |
discovery = sc
|
| 405 |
break
|
| 406 |
elif include_discovery and not held_scores:
|
| 407 |
-
discovery = next((s for s in all_scores if not s.held), None)
|
| 408 |
|
| 409 |
# --- richer reasoning for the better card the user does NOT hold ---
|
| 410 |
if discovery:
|
|
|
|
| 20 |
on a single transaction.
|
| 21 |
"""
|
| 22 |
|
| 23 |
+
from dataclasses import dataclass, replace
|
| 24 |
from typing import Dict, List, Optional
|
| 25 |
|
| 26 |
from card_catalogue import Card, CATALOGUE_BY_ID, all_cards
|
|
|
|
| 64 |
offers: Optional[List[Dict]] = None
|
| 65 |
rail: str = "card" # "card" (POS/online) or "upi" (RuPay credit-on-UPI)
|
| 66 |
channel: Optional[str] = None # "online" | "offline"; when known, filters offers by channel
|
| 67 |
+
# TRIP SCOPE for travel scorings. None means "unknown, do not filter on it".
|
| 68 |
+
# Enforced in score_transaction via offers.scope_filter, so the card
|
| 69 |
+
# recommendation can no longer price a slab the channel rows refuse.
|
| 70 |
+
international: Optional[bool] = None
|
| 71 |
+
passengers: Optional[int] = None
|
| 72 |
+
round_trip: Optional[bool] = None
|
| 73 |
|
| 74 |
|
| 75 |
@dataclass
|
|
|
|
| 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
|
| 99 |
|
| 100 |
|
| 101 |
def _offer_applies(off: Dict, card: Card, allow_emi: bool = False) -> bool:
|
|
|
|
| 136 |
# Issuer-level offers still carry hidden card scoping the issuer field ignores:
|
| 137 |
# a network limit ("Mastercard only") or a debit-card requirement. Enforce both,
|
| 138 |
# else an HDFC-Mastercard-debit offer wrongly credits an HDFC-RuPay-credit card.
|
| 139 |
+
# CARD KIND. The old comment claimed the catalogue is credit cards; that has
|
| 140 |
+
# been false since the debit cards were added - there are 19. With no
|
| 141 |
+
# inverse gate, an issuer-wide offer whose own evidence reads "ICICI Bank
|
| 142 |
+
# CREDIT Card 25% discount" paid out on that issuer's DEBIT card, and could
|
| 143 |
+
# make the debit card win the comparison outright. Both directions now hold.
|
| 144 |
+
if bool(off.get("requires_debit")) != (getattr(card, "kind", "credit") == "debit"):
|
| 145 |
return False
|
| 146 |
net = (off.get("network") or "any").lower()
|
| 147 |
if net != "any" and net != (card.network or "").lower():
|
|
|
|
| 154 |
from offers import offer_value
|
| 155 |
best = 0.0
|
| 156 |
note = None
|
| 157 |
+
# THE CODE COMES OUT TOO. _offer_applies prices a card-linked offer as soon
|
| 158 |
+
# as its promo_code is non-empty, but this returned only the money - so 50
|
| 159 |
+
# active offers were priced into the headline with the code the user has to
|
| 160 |
+
# type never surfaced anywhere. Mirror of engine.ts instantOffer.
|
| 161 |
+
code = None
|
| 162 |
for off in (ctx.offers or []):
|
| 163 |
if not _offer_applies(off, card):
|
| 164 |
continue
|
|
|
|
| 179 |
val = ctx.amount * float(pct.group(1)) / 100.0 if pct else (float(flat.group(1).replace(",", "")) if flat else 0.0)
|
| 180 |
if val > best:
|
| 181 |
best, note = val, txt
|
| 182 |
+
code = (off.get("promo_code") or "").strip() or None
|
| 183 |
+
return best, note, code
|
| 184 |
|
| 185 |
|
| 186 |
def _emi_offer_value(card: Card, ctx: TxnContext):
|
|
|
|
| 208 |
return best, note, code
|
| 209 |
|
| 210 |
|
| 211 |
+
def _closed_or_debit(card_id: str) -> bool:
|
| 212 |
+
"""Cards that must never be offered as a DISCOVERY ("you should apply for
|
| 213 |
+
this"). Mirror of engine.ts, which excludes both."""
|
| 214 |
+
c = CATALOGUE_BY_ID.get(card_id)
|
| 215 |
+
if c is None:
|
| 216 |
+
return False
|
| 217 |
+
return bool(getattr(c, "closed", False)) or getattr(c, "kind", "credit") == "debit"
|
| 218 |
+
|
| 219 |
+
|
| 220 |
def score_transaction(
|
| 221 |
held_card_ids: List[str],
|
| 222 |
ctx: TxnContext,
|
|
|
|
| 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.
|
| 239 |
+
if ctx.offers:
|
| 240 |
+
from offers import scope_filter
|
| 241 |
+
ctx = replace(ctx, offers=scope_filter(ctx.offers, {
|
| 242 |
+
"international": ctx.international,
|
| 243 |
+
"passengers": ctx.passengers,
|
| 244 |
+
"round_trip": ctx.round_trip,
|
| 245 |
+
}))
|
| 246 |
persona = persona or []
|
| 247 |
held_scores: List[CardScore] = []
|
| 248 |
all_scores: List[CardScore] = []
|
|
|
|
| 317 |
|
| 318 |
# --- instant offers (don't apply on the UPI rail) ---
|
| 319 |
if rail == "upi":
|
| 320 |
+
offer_inr, offer_note, offer_code = 0.0, None, None
|
| 321 |
emi_inr, emi_note, emi_code = 0.0, None, None
|
| 322 |
else:
|
| 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
|
|
|
|
| 374 |
network=card.network,
|
| 375 |
reward_value_inr=_r2(reward_inr),
|
| 376 |
instant_offer_inr=_r2(offer_inr),
|
| 377 |
+
instant_offer_code=offer_code,
|
| 378 |
total_value_inr=_r2(total),
|
| 379 |
raw_total=total,
|
| 380 |
effective_rate_pct=_r2(eff_pct),
|
|
|
|
| 396 |
_upi_ok = {c.id for c in all_cards() if getattr(c, "upi_eligible", False)}
|
| 397 |
def _rank_key(s):
|
| 398 |
demerit = 1 if (rail == "upi" and s.card_id not in _upi_ok) else 0
|
| 399 |
+
# Card id is the FINAL tie-break. Without it a tie was broken by
|
| 400 |
+
# catalogue insertion order, and card_catalogue.py and cards.ts list the
|
| 401 |
+
# same 139 ids in different order - so the server and the app named
|
| 402 |
+
# DIFFERENT winners for the same tied purchase. Mirror of engine.ts.
|
| 403 |
+
return (demerit, -s.raw_total, s.card_id)
|
| 404 |
held_scores.sort(key=_rank_key)
|
| 405 |
all_scores.sort(key=_rank_key)
|
| 406 |
|
|
|
|
| 444 |
if include_discovery and held_scores:
|
| 445 |
best_held = held_scores[0].total_value_inr
|
| 446 |
for sc in all_scores:
|
| 447 |
+
# "Apply for this card" must never name a card no one can apply for,
|
| 448 |
+
# nor a DEBIT card (you do not choose a debit card for its rewards).
|
| 449 |
+
# engine.ts filtered both; this rail filtered neither, so the server
|
| 450 |
+
# recommended Axis Atlas - closed to new applicants since Sep 2025.
|
| 451 |
+
if _closed_or_debit(sc.card_id):
|
| 452 |
+
continue
|
| 453 |
if not sc.held and sc.total_value_inr > best_held * 1.10: # >10% better
|
| 454 |
discovery = sc
|
| 455 |
break
|
| 456 |
elif include_discovery and not held_scores:
|
| 457 |
+
discovery = next((s for s in all_scores if not s.held and not _closed_or_debit(s.card_id)), None)
|
| 458 |
|
| 459 |
# --- richer reasoning for the better card the user does NOT hold ---
|
| 460 |
if discovery:
|
app/statement_parser.py
CHANGED
|
@@ -30,15 +30,23 @@ _AMOUNT_LENIENT_RE = re.compile(r"(-?\d[\d,]*(?:\.\d{1,2})?)") # integer-or-dec
|
|
| 30 |
|
| 31 |
|
| 32 |
def _parse_amount_cell(s: str):
|
| 33 |
-
"""Parse a value from a known amount column: tolerate Rs/INR/₹, '/-', and missing decimals.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
cleaned = re.sub(r"(?:rs\.?|inr|₹)", "", s or "", flags=re.IGNORECASE).replace("/-", "").strip()
|
|
|
|
| 35 |
m = _AMOUNT_LENIENT_RE.search(cleaned)
|
| 36 |
if not m:
|
| 37 |
return None
|
| 38 |
try:
|
| 39 |
-
|
| 40 |
except ValueError:
|
| 41 |
return None
|
|
|
|
| 42 |
_DATE_TOKEN_RE = re.compile(
|
| 43 |
r"(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}|\d{1,2}[ \-]\w{3}[ \-]\d{2,4}|\d{4}-\d{2}-\d{2})"
|
| 44 |
)
|
|
@@ -277,6 +285,15 @@ def parse_csv(content: bytes, reversals: Optional[List[Dict]] = None) -> List[Di
|
|
| 277 |
if not amount:
|
| 278 |
continue
|
| 279 |
desc = r[ci] if 0 <= ci < len(r) else " ".join(r)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
direction = r[ki] if 0 <= ki < len(r) else ""
|
| 281 |
kind = _credit_kind(direction, desc)
|
| 282 |
if kind == "refund":
|
|
@@ -462,11 +479,71 @@ def _statement_period(blob: str):
|
|
| 462 |
return (a, b) if a and b and a <= b else None
|
| 463 |
|
| 464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
def _parse_pdf_lines(lines: List[str], gated: bool = True, reversals: Optional[List[Dict]] = None) -> List[Dict]:
|
| 466 |
out: List[Dict] = []
|
| 467 |
started = not gated # ungated fallback: parse from the top
|
| 468 |
pending: List[str] = [] # buffered wrapped merchant-name lines (IDFC style)
|
| 469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
# Year for month-first dates that omit it (Amex "June 03").
|
| 471 |
#
|
| 472 |
# This used to take max() of every 4-digit year printed anywhere, so a
|
|
@@ -517,29 +594,10 @@ def _parse_pdf_lines(lines: List[str], gated: bool = True, reversals: Optional[L
|
|
| 517 |
if re.search(r"[A-Za-z]", line):
|
| 518 |
pending = (pending + [line])[-2:]
|
| 519 |
continue
|
| 520 |
-
#
|
| 521 |
-
#
|
| 522 |
-
#
|
| 523 |
-
|
| 524 |
-
# description and the last one is the running total of the previous
|
| 525 |
-
# rows plus this one, the amount is the second-to-last.
|
| 526 |
-
amt_m = amts[-1] # last money figure on the line is the INR amount
|
| 527 |
-
if len(amts) >= 2:
|
| 528 |
-
try:
|
| 529 |
-
last = float(amts[-1].group(1).replace(",", ""))
|
| 530 |
-
prev = float(amts[-2].group(1).replace(",", ""))
|
| 531 |
-
except ValueError:
|
| 532 |
-
last = prev = None
|
| 533 |
-
if last is not None and prev is not None:
|
| 534 |
-
if abs((_running + prev) - last) < 0.51 and prev > 0:
|
| 535 |
-
amt_m = amts[-2] # confirmed balance column
|
| 536 |
-
_running = last
|
| 537 |
-
elif _running == 0.0 and last > prev > 0 and abs(last - prev) > 0.005:
|
| 538 |
-
# first data row of a balance layout: balance == amount, or
|
| 539 |
-
# opening balance + amount. Only adopt when a later row confirms.
|
| 540 |
-
pass
|
| 541 |
-
if len(amts) < 2:
|
| 542 |
-
_running = 0.0
|
| 543 |
amount = float(amt_m.group(1).replace(",", ""))
|
| 544 |
tail = line[amt_m.end():].strip().upper()
|
| 545 |
# Axis-style layouts carry the direction as its own COLUMN before
|
|
@@ -549,8 +607,15 @@ def _parse_pdf_lines(lines: List[str], gated: bool = True, reversals: Optional[L
|
|
| 549 |
# reads as part of a merchant name.
|
| 550 |
_pre_words = line[:amt_m.start()].strip().split()
|
| 551 |
_dir_word = (_pre_words[-1].strip('.').lower() if _pre_words else "")
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
is_credit = amount < 0 or tail.startswith("CR") or _col_credit or (not _col_debit and _is_credit("", line))
|
| 555 |
amount = abs(amount)
|
| 556 |
if amount == 0: # FX-only / zero rows
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def _parse_amount_cell(s: str):
|
| 33 |
+
"""Parse a value from a known amount column: tolerate Rs/INR/₹, '/-', and missing decimals.
|
| 34 |
+
|
| 35 |
+
"(1,200.00)" is the accounting convention for a CREDIT. The regex matched
|
| 36 |
+
happily inside the brackets and returned +1200, so a refund imported as a
|
| 37 |
+
purchase. _normalise_amount_signs exists for exactly this and was only ever
|
| 38 |
+
wired into the PDF path, never the CSV one.
|
| 39 |
+
"""
|
| 40 |
cleaned = re.sub(r"(?:rs\.?|inr|₹)", "", s or "", flags=re.IGNORECASE).replace("/-", "").strip()
|
| 41 |
+
negated = bool(re.fullmatch(r"\(\s*[^)]*\)", cleaned))
|
| 42 |
m = _AMOUNT_LENIENT_RE.search(cleaned)
|
| 43 |
if not m:
|
| 44 |
return None
|
| 45 |
try:
|
| 46 |
+
v = float(m.group(1).replace(",", ""))
|
| 47 |
except ValueError:
|
| 48 |
return None
|
| 49 |
+
return -abs(v) if negated else v
|
| 50 |
_DATE_TOKEN_RE = re.compile(
|
| 51 |
r"(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}|\d{1,2}[ \-]\w{3}[ \-]\d{2,4}|\d{4}-\d{2}-\d{2})"
|
| 52 |
)
|
|
|
|
| 285 |
if not amount:
|
| 286 |
continue
|
| 287 |
desc = r[ci] if 0 <= ci < len(r) else " ".join(r)
|
| 288 |
+
# Signed single-amount statements: a NEGATIVE row is a CREDIT.
|
| 289 |
+
# _normalize_row does abs(), so without this branch a -899.00 refund
|
| 290 |
+
# imported as a +899.00 purchase - a 2x error on that row plus
|
| 291 |
+
# phantom rewards. The TS parser has always had this; the Python
|
| 292 |
+
# CSV path never did.
|
| 293 |
+
if amount < 0:
|
| 294 |
+
if _credit_kind("cr", desc) == "refund" and reversals is not None:
|
| 295 |
+
reversals.append({"date": iso, "description": desc.strip(), "amount": round(abs(amount), 2)})
|
| 296 |
+
continue
|
| 297 |
direction = r[ki] if 0 <= ki < len(r) else ""
|
| 298 |
kind = _credit_kind(direction, desc)
|
| 299 |
if kind == "refund":
|
|
|
|
| 479 |
return (a, b) if a and b and a <= b else None
|
| 480 |
|
| 481 |
|
| 482 |
+
def _has_balance_column(lines: List[str]) -> bool:
|
| 483 |
+
"""Does this statement print a RUNNING BALANCE after the amount?
|
| 484 |
+
|
| 485 |
+
Decided across the whole file rather than row by row, by the only property
|
| 486 |
+
that actually identifies a balance column: successive balances differ by
|
| 487 |
+
that row's amount. Kotak and several PSU layouts do this; most issuers do
|
| 488 |
+
not, and mis-classifying either way corrupts every figure in the import, so
|
| 489 |
+
the bar is deliberately high - at least two CONSECUTIVE confirmed pairs and
|
| 490 |
+
at least 70% of all pairs consistent. Two independent arithmetic
|
| 491 |
+
coincidences are needed for a false positive; a forex sub-amount column
|
| 492 |
+
("USD 25.00 1,200.00") fails immediately because the left figure bears no
|
| 493 |
+
relation to the difference between the right ones.
|
| 494 |
+
|
| 495 |
+
Direction is not assumed: a card statement's balance rises with spend, a
|
| 496 |
+
bank account's falls, so |balance_n - balance_(n-1)| == amount_n is the
|
| 497 |
+
test.
|
| 498 |
+
"""
|
| 499 |
+
rows: List[tuple] = []
|
| 500 |
+
for raw in lines:
|
| 501 |
+
line = (raw or "").strip()
|
| 502 |
+
if not line or _SUMMARY_RE.search(line.lower()):
|
| 503 |
+
continue
|
| 504 |
+
line = _normalise_amount_signs(line)
|
| 505 |
+
dm = _PDF_DATE_RE.search(line)
|
| 506 |
+
md = None if dm else _MONTH_DAY_RE.search(line)
|
| 507 |
+
if not (dm or md):
|
| 508 |
+
continue
|
| 509 |
+
amts = list(_SIGNED_AMT_RE.finditer(line))
|
| 510 |
+
if len(amts) < 2:
|
| 511 |
+
rows.append(()) # a 1-figure row breaks the balance chain
|
| 512 |
+
continue
|
| 513 |
+
try:
|
| 514 |
+
last = float(amts[-1].group(1).replace(",", ""))
|
| 515 |
+
prev = float(amts[-2].group(1).replace(",", ""))
|
| 516 |
+
except ValueError:
|
| 517 |
+
rows.append(())
|
| 518 |
+
continue
|
| 519 |
+
rows.append((prev, last))
|
| 520 |
+
|
| 521 |
+
ok = bad = 0
|
| 522 |
+
for i in range(1, len(rows)):
|
| 523 |
+
a, b = rows[i - 1], rows[i]
|
| 524 |
+
if len(a) != 2 or len(b) != 2:
|
| 525 |
+
continue
|
| 526 |
+
amount, balance = b
|
| 527 |
+
if abs(abs(balance - a[1]) - abs(amount)) < 0.51:
|
| 528 |
+
ok += 1
|
| 529 |
+
else:
|
| 530 |
+
bad += 1
|
| 531 |
+
return ok >= 2 and ok >= (ok + bad) * 0.7
|
| 532 |
+
|
| 533 |
+
|
| 534 |
def _parse_pdf_lines(lines: List[str], gated: bool = True, reversals: Optional[List[Dict]] = None) -> List[Dict]:
|
| 535 |
out: List[Dict] = []
|
| 536 |
started = not gated # ungated fallback: parse from the top
|
| 537 |
pending: List[str] = [] # buffered wrapped merchant-name lines (IDFC style)
|
| 538 |
+
# Decided ONCE for the whole statement by _has_balance_column below, not
|
| 539 |
+
# inferred row by row. The old streaming test asked whether
|
| 540 |
+
# (_running + prev) == last, but _running was only ever assigned INSIDE that
|
| 541 |
+
# same branch and started at 0.0 - so it could only bootstrap on a statement
|
| 542 |
+
# whose opening balance was zero. Credit-card statements always carry a
|
| 543 |
+
# previous balance, so the branch never fired and EVERY row on a
|
| 544 |
+
# balance-column layout imported the running balance as the spend, with the
|
| 545 |
+
# error compounding down the page.
|
| 546 |
+
_balance_col = _has_balance_column(lines)
|
| 547 |
# Year for month-first dates that omit it (Amex "June 03").
|
| 548 |
#
|
| 549 |
# This used to take max() of every 4-digit year printed anywhere, so a
|
|
|
|
| 594 |
if re.search(r"[A-Za-z]", line):
|
| 595 |
pending = (pending + [line])[-2:]
|
| 596 |
continue
|
| 597 |
+
# On a confirmed balance layout the amount is the SECOND-to-last
|
| 598 |
+
# figure; the last one is the running total. Decided for the file,
|
| 599 |
+
# so the first data row is classified correctly too.
|
| 600 |
+
amt_m = amts[-2] if (_balance_col and len(amts) >= 2) else amts[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
amount = float(amt_m.group(1).replace(",", ""))
|
| 602 |
tail = line[amt_m.end():].strip().upper()
|
| 603 |
# Axis-style layouts carry the direction as its own COLUMN before
|
|
|
|
| 607 |
# reads as part of a merchant name.
|
| 608 |
_pre_words = line[:amt_m.start()].strip().split()
|
| 609 |
_dir_word = (_pre_words[-1].strip('.').lower() if _pre_words else "")
|
| 610 |
+
# A bare trailing "C"/"D" is HDFC's direction column. The code
|
| 611 |
+
# already knew it existed - it scrubs it out of the merchant name a
|
| 612 |
+
# few lines below - but is_credit only looked for a sign, a "CR"
|
| 613 |
+
# prefix or a Debit/Credit WORD, so a single letter was invisible
|
| 614 |
+
# and a merchant credit whose wording lacked "refund" imported as
|
| 615 |
+
# spend. The Axis long-word form was handled; this one was not.
|
| 616 |
+
_tail_letter = tail.split()[0] if tail.split() else ""
|
| 617 |
+
_col_credit = _dir_word in ("credit", "cr") or _tail_letter in ("C", "CR")
|
| 618 |
+
_col_debit = _dir_word in ("debit", "dr") or _tail_letter in ("D", "DR")
|
| 619 |
is_credit = amount < 0 or tail.startswith("CR") or _col_credit or (not _col_debit and _is_credit("", line))
|
| 620 |
amount = abs(amount)
|
| 621 |
if amount == 0: # FX-only / zero rows
|