sammy786 commited on
Commit
6303b7b
·
1 Parent(s): c4a2298

Fix Gmail statement attribution and reject non-card imports

Browse files
Files changed (2) hide show
  1. app/main.py +18 -2
  2. app/statement_parser.py +130 -69
app/main.py CHANGED
@@ -867,7 +867,7 @@ async def parse_statements(
867
  ):
868
  _limit(request, "parse", rate=12) # 12/min per IP per worker
869
  from statement_parser import (parse_statement, detect_cards, detect_issuers,
870
- detect_points_balance_file,
871
  find_pdf_password, detect_credit_limit)
872
  # Smart unlock: the password field may carry MULTIPLE candidates (newline or
873
  # comma separated), derived app-side from the user's details the way every
@@ -902,6 +902,16 @@ async def parse_statements(
902
  txns = await anyio.to_thread.run_sync(
903
  lambda c=content, n=f.filename, p=pw, r=revs: parse_statement(n, c, password=p, reversals=r)
904
  )
 
 
 
 
 
 
 
 
 
 
905
  for t in txns:
906
  t["source_file"] = f.filename
907
  for r in revs:
@@ -992,7 +1002,7 @@ async def email_inbound(request: Request):
992
  """Inbound-email webhook (provider-agnostic: reads recipient/to plus any file
993
  fields from the multipart form)."""
994
  _limit(request, "inbound", rate=30)
995
- from statement_parser import parse_statement, detect_cards
996
  form = await request.form()
997
  token = _inbox_token(str(form.get("recipient") or form.get("to") or ""))
998
  if not token:
@@ -1007,6 +1017,12 @@ async def email_inbound(request: Request):
1007
  revs: list = []
1008
  txns = await anyio.to_thread.run_sync(
1009
  lambda c=content, n=val.filename, r=revs: parse_statement(n, c, reversals=r))
 
 
 
 
 
 
1010
  cards = await anyio.to_thread.run_sync(
1011
  lambda c=content, n=val.filename: detect_cards(n, c))
1012
  for t in txns:
 
867
  ):
868
  _limit(request, "parse", rate=12) # 12/min per IP per worker
869
  from statement_parser import (parse_statement, detect_cards, detect_issuers,
870
+ detect_points_balance_file, is_credit_card_statement,
871
  find_pdf_password, detect_credit_limit)
872
  # Smart unlock: the password field may carry MULTIPLE candidates (newline or
873
  # comma separated), derived app-side from the user's details the way every
 
902
  txns = await anyio.to_thread.run_sync(
903
  lambda c=content, n=f.filename, p=pw, r=revs: parse_statement(n, c, password=p, reversals=r)
904
  )
905
+ # Parsing rows is not proof that this is a CREDIT-CARD statement:
906
+ # savings/current-account CSVs share the same Date/Description/
907
+ # Amount shape. Reject them before any transaction, balance or
908
+ # inferred card id can reach the device.
909
+ is_card_statement = await anyio.to_thread.run_sync(
910
+ lambda c=content, n=f.filename, p=pw: is_credit_card_statement(n, c, password=p)
911
+ )
912
+ if not is_card_statement:
913
+ errors.append({"file": f.filename, "error": "NOT_CREDIT_CARD_STATEMENT"})
914
+ continue
915
  for t in txns:
916
  t["source_file"] = f.filename
917
  for r in revs:
 
1002
  """Inbound-email webhook (provider-agnostic: reads recipient/to plus any file
1003
  fields from the multipart form)."""
1004
  _limit(request, "inbound", rate=30)
1005
+ from statement_parser import parse_statement, detect_cards, is_credit_card_statement
1006
  form = await request.form()
1007
  token = _inbox_token(str(form.get("recipient") or form.get("to") or ""))
1008
  if not token:
 
1017
  revs: list = []
1018
  txns = await anyio.to_thread.run_sync(
1019
  lambda c=content, n=val.filename, r=revs: parse_statement(n, c, reversals=r))
1020
+ is_card_statement = await anyio.to_thread.run_sync(
1021
+ lambda c=content, n=val.filename: is_credit_card_statement(n, c))
1022
+ if not is_card_statement:
1023
+ results.append({"file": val.filename, "count": 0, "transactions": [],
1024
+ "detected_cards": [], "error": "NOT_CREDIT_CARD_STATEMENT"})
1025
+ continue
1026
  cards = await anyio.to_thread.run_sync(
1027
  lambda c=content, n=val.filename: detect_cards(n, c))
1028
  for t in txns:
app/statement_parser.py CHANGED
@@ -1061,85 +1061,28 @@ def parse_statement(filename: str, content: bytes, password: Optional[str] = Non
1061
  raise RuntimeError("Unsupported file type. Upload a .pdf or .csv statement.")
1062
 
1063
 
1064
- # ---------------------------------------------------------------------------
1065
- # Card detection: which catalogue card does this statement belong to?
1066
- # (card_id, required issuer tokens - ANY must appear - or None when the product
1067
- # name is unique enough, product tokens - ANY must appear). Short tokens are
1068
- # matched at word boundaries via merchants.keyword_hit so 'ace' cannot fire
1069
- # inside 'place'. Mirrored in app statements.ts (detectCardsFromText).
1070
- # ---------------------------------------------------------------------------
1071
- _CARD_SIGNATURES = [
1072
- ("hdfc_infinia", None, ["infinia"]),
1073
- ("hdfc_regalia_gold", None, ["regalia"]),
1074
- ("hdfc_millennia", ["hdfc"], ["millennia"]),
1075
- # Before the legacy row: "Swiggy BLCK HDFC" contains "swiggy" and "hdfc" too,
1076
- # so a bare hdfc+swiggy signature would claim it first.
1077
- ("hdfc_swiggy_blck", ["hdfc", "blck"], ["swiggy"]),
1078
- ("hdfc_swiggy_ornge", ["hdfc", "ornge"], ["swiggy"]),
1079
- ("hdfc_swiggy", ["hdfc"], ["swiggy"]),
1080
- ("hdfc_diners_black", None, ["diners club black", "diners black"]),
1081
- ("tataneu_infinity", None, ["tata neu infinity", "neu infinity"]),
1082
- ("sbi_cashback", ["sbi"], ["cashback"]),
1083
- ("sbi_elite", ["sbi"], ["elite"]),
1084
- ("sbi_simplyclick", None, ["simplyclick", "simply click"]),
1085
- ("sbi_bpcl_octane", None, ["octane"]),
1086
- ("sbi_rupay_select", ["sbi"], ["rupay select"]),
1087
- ("icici_amazon_pay", ["icici"], ["amazon pay"]),
1088
- ("icici_sapphiro", None, ["sapphiro"]),
1089
- ("icici_emeralde", None, ["emeralde"]),
1090
- ("axis_magnus", None, ["magnus"]),
1091
- ("axis_ace", ["axis"], ["ace"]),
1092
- ("axis_atlas", ["axis"], ["atlas"]),
1093
- ("axis_flipkart", ["axis"], ["flipkart"]),
1094
- ("amex_mrcc", ["american express", "amex"], ["membership rewards"]),
1095
- ("amex_platinum_travel", ["american express", "amex"], ["platinum travel"]),
1096
- ("kiwi_axis", None, ["kiwi"]),
1097
- ("idfc_first_select", ["idfc"], ["first select"]),
1098
- ("idfc_first_wealth", ["idfc"], ["first wealth"]),
1099
- ("idfc_first_millennia", ["idfc"], ["millennia"]),
1100
- ("idfc_first_power_plus", ["idfc"], ["power+", "power plus"]),
1101
- ("au_zenith", ["au bank", "au small finance", "aubank"], ["zenith"]),
1102
- ("indusind_legend", ["indusind"], ["legend"]),
1103
- ("rbl_world_safari", None, ["world safari"]),
1104
- ("onecard_metal", None, ["onecard", "one card"]),
1105
- ("hsbc_live_plus", ["hsbc"], ["live+", "live plus"]),
1106
- ]
1107
-
1108
-
1109
- def detect_cards_from_text(text: str) -> List[str]:
1110
- """Catalogue card ids this statement text plausibly belongs to, in signature
1111
- order, deduped. Pure and testable; parity-checked against the app mirror."""
1112
- from merchants import keyword_hit
1113
- t = (text or "").lower()
1114
- out: List[str] = []
1115
- for card_id, issuers, products in _CARD_SIGNATURES:
1116
- if issuers is not None and not any(keyword_hit(t, i) for i in issuers):
1117
- continue
1118
- if any(keyword_hit(t, p) for p in products):
1119
- out.append(card_id)
1120
- return out
1121
-
1122
-
1123
- # Issuer name fragments -> catalogue issuer label. Used as a FALLBACK: when the
1124
- # exact product name doesn't match a signature but the bank is clearly named, we
1125
- # still know the issuer, so the app can ask "which of your <issuer> cards?".
1126
- # Tokens include the email-DOMAIN forms (hdfcbank, axisbank, sbicard...) so a
1127
- # FROM address like alerts@axisbank.com is recognised even without body text.
1128
  _ISSUER_TOKENS = [
1129
  ("HDFC Bank", ["hdfc", "hdfcbank"]),
1130
- ("SBI", ["sbi card", "sbi cards", "sbicard", "state bank"]),
1131
  ("ICICI Bank", ["icici", "icicibank"]),
1132
- ("Axis Bank", ["axis bank", "axisbank"]),
1133
  ("American Express", ["american express", "amex", "americanexpress"]),
1134
  ("IDFC FIRST Bank", ["idfc", "idfcfirstbank"]),
1135
  ("AU Small Finance Bank", ["au small finance", "au bank", "aubank"]),
1136
  ("IndusInd Bank", ["indusind"]),
 
 
1137
  ("RBL Bank", ["rbl bank", "rblbank"]),
 
1138
  ("HSBC", ["hsbc"]),
1139
- ("Kotak Mahindra Bank", ["kotak"]),
 
 
 
 
1140
  ("Yes Bank", ["yes bank", "yesbank"]),
1141
- ("OneCard", ["onecard", "one card", "getonecard"]),
1142
- ("Kiwi", ["kiwi"]),
1143
  ]
1144
 
1145
 
@@ -1174,6 +1117,89 @@ def detect_issuers_from_text(text: str) -> List[str]:
1174
  return out
1175
 
1176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1177
  # Start of the transaction TABLE (not any stray "transaction" word): a section
1178
  # marker or the table's own column header. Matching the table start - rather
1179
  # than the first mention of "transaction" - keeps the card name (printed above
@@ -1229,3 +1255,38 @@ def detect_cards(filename: str, content: bytes, password: Optional[str] = None)
1229
  def detect_issuers(filename: str, content: bytes, password: Optional[str] = None) -> List[str]:
1230
  """Issuer fallback over a statement FILE."""
1231
  return detect_issuers_from_text(_detection_text(filename, content, password))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1061
  raise RuntimeError("Unsupported file type. Upload a .pdf or .csv statement.")
1062
 
1063
 
1064
+ # Exact catalogue issuer -> text/domain aliases. Mirrored in app statements.ts.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1065
  _ISSUER_TOKENS = [
1066
  ("HDFC Bank", ["hdfc", "hdfcbank"]),
1067
+ ("SBI Card", ["sbi card", "sbi cards", "sbicard", "state bank"]),
1068
  ("ICICI Bank", ["icici", "icicibank"]),
1069
+ ("Axis Bank", ["axis bank", "axisbank", "kiwi"]),
1070
  ("American Express", ["american express", "amex", "americanexpress"]),
1071
  ("IDFC FIRST Bank", ["idfc", "idfcfirstbank"]),
1072
  ("AU Small Finance Bank", ["au small finance", "au bank", "aubank"]),
1073
  ("IndusInd Bank", ["indusind"]),
1074
+ ("IDBI Bank", ["idbi"]),
1075
+ ("Bandhan Bank", ["bandhan bank", "bandhanbank"]),
1076
  ("RBL Bank", ["rbl bank", "rblbank"]),
1077
+ ("BoB/Federal/SBM", ["onecard", "one card", "getonecard"]),
1078
  ("HSBC", ["hsbc"]),
1079
+ ("Bank of Baroda", ["bobcard", "bank of baroda", "bankofbaroda"]),
1080
+ ("DBS Bank India", ["dbs bank", "dbs india", "dbsbank"]),
1081
+ ("Federal Bank", ["federal bank", "federalbank", "scapia"]),
1082
+ ("Kotak Mahindra Bank", ["kotak", "kotakbank"]),
1083
+ ("Punjab National Bank", ["punjab national bank", "pnb"]),
1084
  ("Yes Bank", ["yes bank", "yesbank"]),
1085
+ ("Standard Chartered", ["standard chartered", "standardchartered", "sc bank"]),
 
1086
  ]
1087
 
1088
 
 
1117
  return out
1118
 
1119
 
1120
+ # ---------------------------------------------------------------------------
1121
+ # Card detection: catalogue-derived, issuer-required and exclusive. The old
1122
+ # 32-row ANY-token table made "HDFC Swiggy" match BLCK, ORNGE and legacy at once.
1123
+ # This mirror covers the whole credit-card catalogue and retains equal top-score
1124
+ # matches as ambiguous rather than guessing.
1125
+ # ---------------------------------------------------------------------------
1126
+ def _normalise_match_text(value: str) -> str:
1127
+ s = (value or "").lower().replace("&", " and ").replace("+", " plus ")
1128
+ return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9]+", " ", s)).strip()
1129
+
1130
+
1131
+ def _product_alias_hit(text: str, phrase: str) -> bool:
1132
+ hay = f" {_normalise_match_text(text)} "
1133
+ words = [w for w in _normalise_match_text(phrase).split(" ") if w]
1134
+ return bool(words) and all(f" {word} " in hay for word in words)
1135
+
1136
+
1137
+ _GENERIC_CARD_WORDS = {"bank", "credit", "card"}
1138
+ _BRAND_ISSUER_ALIASES = {"kiwi", "scapia", "onecard", "one card", "getonecard"}
1139
+ _CARD_PRODUCT_ALIAS_CACHE = {}
1140
+
1141
+
1142
+ def _card_product_aliases(card) -> List[str]:
1143
+ cached = _CARD_PRODUCT_ALIAS_CACHE.get(card.id)
1144
+ if cached is not None:
1145
+ return cached
1146
+ name = _normalise_match_text(card.name)
1147
+ issuer_aliases = next((aliases for issuer, aliases in _ISSUER_TOKENS if issuer == card.issuer), [])
1148
+ removable = [card.issuer, *issuer_aliases]
1149
+ removable = [x for x in removable if _normalise_match_text(x) not in _BRAND_ISSUER_ALIASES]
1150
+ for alias in sorted(removable, key=len, reverse=True):
1151
+ a = _normalise_match_text(alias)
1152
+ if a:
1153
+ name = f" {name} ".replace(f" {a} ", " ").strip()
1154
+ name_product = " ".join(w for w in name.split() if w not in _GENERIC_CARD_WORDS)
1155
+
1156
+ issuer_id_words = {
1157
+ word
1158
+ for value in [card.issuer, *issuer_aliases]
1159
+ for word in _normalise_match_text(value).split()
1160
+ if len(word) > 1
1161
+ }
1162
+ id_product = " ".join(
1163
+ word for word in _normalise_match_text(card.id.replace("_", " ")).split()
1164
+ if word not in issuer_id_words and word != "first"
1165
+ )
1166
+ aliases = [name_product, id_product]
1167
+ if card.id == "tataneu_infinity":
1168
+ aliases += ["tata neu infinity", "neu infinity"]
1169
+ if card.id == "hdfc_tata_neu_plus":
1170
+ aliases += ["tata neu plus", "neu plus"]
1171
+ if card.id == "onecard_metal":
1172
+ aliases += ["onecard", "one card"]
1173
+ if card.id == "amex_mrcc":
1174
+ aliases += ["membership rewards", "mrcc"]
1175
+ result = list(dict.fromkeys(filter(None, (_normalise_match_text(x) for x in aliases))))
1176
+ _CARD_PRODUCT_ALIAS_CACHE[card.id] = result
1177
+ return result
1178
+
1179
+
1180
+ def detect_cards_from_text(text: str) -> List[str]:
1181
+ from card_catalogue import CATALOGUE
1182
+ issuers = set(detect_issuers_from_text(text))
1183
+ if not issuers:
1184
+ return []
1185
+ matches = []
1186
+ for card in CATALOGUE:
1187
+ if getattr(card, "kind", "credit") == "debit" or card.issuer not in issuers:
1188
+ continue
1189
+ best = 0
1190
+ for alias in _card_product_aliases(card):
1191
+ if not _product_alias_hit(text, alias):
1192
+ continue
1193
+ words = [w for w in _normalise_match_text(alias).split() if w]
1194
+ best = max(best, len(words) * 100 + len(_normalise_match_text(alias)))
1195
+ if best:
1196
+ matches.append((card.id, best))
1197
+ if not matches:
1198
+ return []
1199
+ top = max(score for _, score in matches)
1200
+ return [card_id for card_id, score in matches if score == top]
1201
+
1202
+
1203
  # Start of the transaction TABLE (not any stray "transaction" word): a section
1204
  # marker or the table's own column header. Matching the table start - rather
1205
  # than the first mention of "transaction" - keeps the card name (printed above
 
1255
  def detect_issuers(filename: str, content: bytes, password: Optional[str] = None) -> List[str]:
1256
  """Issuer fallback over a statement FILE."""
1257
  return detect_issuers_from_text(_detection_text(filename, content, password))
1258
+
1259
+
1260
+ def is_credit_card_statement_text(filename: str, text: str) -> bool:
1261
+ """Positive proof that text is from a credit-card statement, not merely a
1262
+ statement/attachment sent by a bank. Mirrored in app statements.ts."""
1263
+ sample = f"{filename or ''}\n{_header_region(text or '')}"
1264
+ n = _normalise_match_text(sample)
1265
+ explicit = bool(re.search(
1266
+ r"\bcredit card (?:account )?(?:e )?statement\b"
1267
+ r"|\b(?:e )?statement (?:for )?(?:your )?credit card\b", n))
1268
+ if explicit:
1269
+ return True
1270
+ issuers = detect_issuers_from_text(sample)
1271
+ cards = detect_cards_from_text(sample)
1272
+ has_statement = bool(re.search(r"\bstatement\b", n))
1273
+ if issuers and re.search(r"\bcredit card\b", n):
1274
+ return True
1275
+ if len(cards) == 1 and has_statement:
1276
+ return True
1277
+ signals = [
1278
+ r"\btotal amount due\b", r"\bminimum amount due\b", r"\bpayment due date\b",
1279
+ r"\bavailable credit(?: limit)?\b", r"\bcredit limit\b", r"\bcard (?:number|no|ending)\b",
1280
+ r"\breward points? (?:summary|balance)\b", r"\bstatement balance\b",
1281
+ r"\btotalamountdue\b", r"\bminimumamountdue\b", r"\bpaymentduedate\b", r"\bcreditlimit\b",
1282
+ ]
1283
+ score = sum(1 for pattern in signals if re.search(pattern, n))
1284
+ if score >= 2:
1285
+ return True
1286
+ return score >= 1 and (len(cards) == 1 or (has_statement and bool(issuers)))
1287
+
1288
+
1289
+ def is_credit_card_statement(filename: str, content: bytes, password: Optional[str] = None) -> bool:
1290
+ """File-level wrapper used by the API before returning any transactions."""
1291
+ detection_text = _detection_text(filename, content, password)
1292
+ return is_credit_card_statement_text(filename, detection_text)