Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Fix Gmail statement attribution and reject non-card imports
Browse files- app/main.py +18 -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 |
-
("
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1140 |
("Yes Bank", ["yes bank", "yesbank"]),
|
| 1141 |
-
("
|
| 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)
|