rewardpilot-api / app /statement_parser.py
sammy786's picture
Credit-limit detection: inline + positional + OCR fallback (Kotak/Axis/IDFC/HDFC)
7f1c52e
Raw
History Blame Contribute Delete
28.8 kB
"""
RewardPilot - Statement Parser
==============================
Parses uploaded credit card statements (PDF or CSV) into normalized
transactions. Real PDF text extraction via pdfplumber; CSV via stdlib.
Output transaction shape:
{date, description, merchant, category, amount, brand_key}
The parser is intentionally tolerant: Indian issuer statements vary widely, so
we use a robust line/row heuristic + the merchant resolver to categorize.
Password-protected PDFs are supported if the password is supplied.
"""
import csv
import io
import re
from datetime import datetime
from typing import Dict, List, Optional
from merchants import resolve_merchant
DATE_PATTERNS = [
"%d/%m/%Y", "%d-%m-%Y", "%d/%m/%y", "%d-%b-%Y", "%d %b %Y",
"%Y-%m-%d", "%m/%d/%Y", "%d.%m.%Y", "%d-%b-%y", "%d %b %y",
]
AMOUNT_RE = re.compile(r"(-?\d[\d,]*\.\d{2})") # strict (decimals) - safe to scan whole rows
_AMOUNT_LENIENT_RE = re.compile(r"(-?\d[\d,]*(?:\.\d{1,2})?)") # integer-or-decimal - dedicated amount column only
def _parse_amount_cell(s: str):
"""Parse a value from a known amount column: tolerate Rs/INR/₹, '/-', and missing decimals."""
cleaned = re.sub(r"(?:rs\.?|inr|₹)", "", s or "", flags=re.IGNORECASE).replace("/-", "").strip()
m = _AMOUNT_LENIENT_RE.search(cleaned)
if not m:
return None
try:
return float(m.group(1).replace(",", ""))
except ValueError:
return None
_DATE_TOKEN_RE = re.compile(
r"(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}|\d{1,2}[ \-]\w{3}[ \-]\d{2,4}|\d{4}-\d{2}-\d{2})"
)
# Rows whose description is really a payment/credit, not a purchase.
# No trailing \b: HDFC glues the next word onto "PAYMENT" ("CREDIT CARD PAYMENTNet
# Banking"), which a trailing word-boundary would miss, letting a bill payment be
# counted as spend.
_PAYMENT_RE = re.compile(
r"\b(payment received|credit card payment|card payment|neft|imps|rtgs|upi[\s/-]*payment|"
r"autopay|auto pay|bbps|nach|e-?mandate|refund|reversal|cashback received)",
re.IGNORECASE,
)
# Non-spend ledger lines: taxes, finance/interest charges, and card fees. These are
# not purchases the user chooses a card for, so they're excluded from the analysis.
_CHARGES_RE = re.compile(
r"(\bigst\b|\bcgst\b|\bsgst\b|\bgst\b|finance charge|interest charge|\binterest\b|"
r"late fee|late payment|membership fee|annual fee|joining fee|renewal fee|cash advance fee|"
r"over\s?limit fee|fuel surcharge|surcharge|"
r"forex markup|fx markup|markup fee|currency conversion|cross[\s-]?currency|"
r"reward point|\bfee reversal\b|\bemi (?:principal|interest)\b)",
re.IGNORECASE,
)
# Foreign-currency codes on international lines, and helpers to drop forex-conversion
# detail sub-lines and to render a clean merchant name from a raw descriptor.
_CCY_RE = re.compile(r"\b(usd|eur|gbp|aed|sgd|jpy|aud|cad|chf|hkd|thb|myr|sar|qar|cny)\b", re.IGNORECASE)
_FOREX_DETAIL_RE = re.compile(r"^(convert|conversion|fx|forex|foreign\s*currency|markup|intl\s*txn)\b", re.IGNORECASE)
_CCY_AMT_RE = re.compile(_CCY_RE.pattern + r"\s*[\d.,]+", re.IGNORECASE)
_PREFIX_RE = re.compile(r"^\s*(pos|vps|ecom(m)?|imps|neft|rtgs|upi|ach|nach|intl|int'?l)[\s/*:.\-]+", re.IGNORECASE)
def _is_forex_detail_line(desc: str) -> bool:
d = (desc or "").strip()
return bool(_FOREX_DETAIL_RE.match(d) and _CCY_RE.search(d))
def _title(s: str) -> str:
return re.sub(r"\bPaypal\b", "PayPal", s.title())
# HDFC glues the city onto the merchant with no space ("Entertainmenthyderabad",
# "Kurlamumbai"). Split a trailing known city off, longest names first.
_CITIES = tuple(sorted((
"navimumbai", "newdelhi", "bengaluru", "bangalore", "hyderabad", "ahmedabad",
"coimbatore", "visakhapatnam", "bhubaneswar", "chandigarh", "gurugram", "gurgaon",
"mumbai", "delhi", "chennai", "kolkata", "pune", "noida", "jaipur", "lucknow",
"kochi", "indore", "nagpur", "surat", "thane", "vadodara", "bhopal", "patna",
"ludhiana", "agra", "nashik", "faridabad", "ghaziabad", "rajkot", "meerut",
"amritsar", "mysuru", "mysore", "goa", "kanpur", "varanasi", "guwahati",
), key=len, reverse=True))
def _split_glued_city(s: str) -> str:
low = s.lower()
for c in _CITIES:
if low.endswith(c) and len(s) > len(c) + 2 and s[-len(c) - 1] not in " ,":
return s[:-len(c)] + " " + s[-len(c):]
return s
def _clean_merchant(raw: str) -> str:
original = re.sub(r"[\x00-\x1f\x7f]+", " ", (raw or "")).strip() # never show control chars in a name
strip = lambda x: re.sub(r"^[\s*.,\-]+|[\s*.,\-]+$", "", x).strip()
if re.search(r"paypal", original, re.IGNORECASE):
m = re.search(r"paypal\s*\*?\s*([a-z][a-z0-9 &._-]{1,24})", original, re.IGNORECASE)
sub = ""
if m and m.group(1):
sub = strip(re.sub(r"[\d.,]+", "", re.sub(r"\b(convert|conversion|usd|eur|gbp)\b", "", m.group(1), flags=re.IGNORECASE)))
sub = sub.upper() if len(sub) <= 4 else _title(sub)
return f"PayPal · {sub}" if len(sub) > 1 else "PayPal"
s = _PREFIX_RE.sub("", original)
s = _CCY_AMT_RE.sub(" ", s)
s = re.sub(r"\b(convert|conversion)\b", " ", s, flags=re.IGNORECASE)
s = re.sub(r"#\S*", " ", s)
s = re.sub(r"\d[\d.,]{3,}", " ", s)
s = re.sub(r"\*+", " ", s)
s = strip(re.sub(r"\s{2,}", " ", s))
if len(s) < 2:
return original[:40].strip() or "Transaction"
return _title(_split_glued_city(s))[:40].strip() # trim AFTER truncation so a cut long name has no trailing space
def _parse_date(s: str) -> Optional[str]:
"""Parse a date that may carry a trailing timestamp (e.g. '17/04/2026 22:46:14')."""
s = (s or "").strip()
if not s:
return None
# Axis prints the year with a leading apostrophe ("01 Jan '26") - drop it so
# strptime's %y matches, and collapse the double space it leaves behind.
s = re.sub(r"'\s*(\d)", r"\1", s)
s = re.sub(r"\s{2,}", " ", s)
# pull just the date token if there's a time or extra text alongside it
tok = _DATE_TOKEN_RE.search(s)
candidates = [s]
if tok:
candidates.insert(0, tok.group(1))
for cand in candidates:
for fmt in DATE_PATTERNS:
try:
return datetime.strptime(cand.strip(), fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return None
def _detect_delimiter(text: str) -> str:
"""Issuer CSVs vary: HDFC uses '~|~', others tab/pipe/semicolon/comma."""
if "~|~" in text:
return "~|~"
sample = next((ln for ln in text.splitlines() if ln.strip()), "")
counts = {d: sample.count(d) for d in ["\t", "|", ";", ","]}
best = max(counts, key=counts.get)
return best if counts[best] > 0 else ","
def _split_line(line: str, delim: str) -> List[str]:
if delim == ",":
try:
return [c for c in next(csv.reader([line]))]
except StopIteration:
return []
return line.split(delim)
def _find_col(header: List[str], names) -> int:
for i, h in enumerate(header):
if any(n in h for n in names):
return i
return -1
def _is_txn_header(fields: List[str]) -> bool:
joined = " ".join(f.lower() for f in fields)
has_date = "date" in joined
has_amt = any(k in joined for k in ("amt", "amount", "value", "debit", "withdrawal", "credit")) # debit/credit-split statements have no "amount" column
has_desc = any(k in joined for k in ("description", "narration", "details", "particular", "merchant"))
return has_date and has_amt and has_desc
def _normalize_row(date_s: str, desc: str, amount: float) -> Dict:
resolved = resolve_merchant(desc)
return {
"date": _parse_date(date_s) or date_s,
"description": desc.strip()[:120],
"merchant": resolved["matched_merchant"] or _clean_merchant(desc),
"category": resolved["category"],
"brand_key": resolved["brand_key"],
"amount": round(abs(amount), 2),
}
def _is_credit(direction: str, desc: str) -> bool:
"""True for refunds / payments / credits (excluded from spend analysis)."""
d = (direction or "").strip().lower()
if d in ("cr", "credit") or d.startswith("cr"):
return True
if _PAYMENT_RE.search(desc or "") or _CHARGES_RE.search(desc or ""):
return True
return False
def parse_csv(content: bytes) -> List[Dict]:
"""
Parse issuer CSV exports into purchase transactions.
Robust to real-world layouts: a metadata preamble, a dedicated transactions
section with its own header, non-comma delimiters (HDFC uses '~|~'),
timestamped dates ('17/04/2026 22:46:14'), Indian lakh grouping
('1,42,212.00'), and a Debit/Credit column. Credits, payments and refunds are
skipped so only real spends feed the optimal-card analysis.
"""
text = content.decode("utf-8-sig", errors="ignore")
text = text.replace("\x00", "") # NUL bytes (corrupt/binary upload) make csv.reader raise
delim = _detect_delimiter(text)
lines = [ln for ln in text.splitlines() if ln.strip()]
rows = [[c.strip() for c in _split_line(ln, delim)] for ln in lines]
rows = [r for r in rows if any(r)]
if not rows:
return []
# locate the transactions header (preferred path)
hidx = next((i for i, r in enumerate(rows) if _is_txn_header(r)), -1)
out: List[Dict] = []
if hidx >= 0:
header = [h.lower() for h in rows[hidx]]
di = _find_col(header, ("date",))
ci = _find_col(header, ("description", "narration", "details", "particular", "merchant"))
ai = _find_col(header, ("amt", "amount", "value"))
if ai < 0:
ai = _find_col(header, ("debit", "withdrawal"))
# the Debit/Credit indicator column
ki = next((i for i, h in enumerate(header) if "debit" in h and "credit" in h), -1)
if ki < 0:
ki = _find_col(header, ("dr/cr", "cr/dr", "type", "credit"))
for r in rows[hidx + 1:]:
date_s = r[di] if 0 <= di < len(r) else ""
iso = _parse_date(date_s)
if not iso:
continue # blank line, section break, or summary row
amount = _parse_amount_cell(r[ai]) if 0 <= ai < len(r) else None
if amount is None and ai < 0:
# ONLY when no amount/debit column exists: scan the row. If a debit column
# exists but is empty, the row is a credit/refund - do NOT scan (that would
# grab the Credit-column value and count it as a spend).
m = AMOUNT_RE.search(" ".join(r))
amount = float(m.group(1).replace(",", "")) if m else None
if not amount:
continue
desc = r[ci] if 0 <= ci < len(r) else " ".join(r)
direction = r[ki] if 0 <= ki < len(r) else ""
if _is_credit(direction, desc):
continue
if _is_forex_detail_line(desc): # drop forex conversion sub-lines ("Convert USD 324.50")
continue
out.append(_normalize_row(iso, desc, amount))
return out # header found: trust it (even if every row was a credit/payment)
# fallback: no recognizable header - infer per row from date + amount tokens
for r in rows:
date_field = next((c for c in r if _parse_date(c)), None)
if not date_field:
continue
amts = [c for c in r if AMOUNT_RE.search(c)]
if not amts:
continue
amt_raw = amts[-1]
m = AMOUNT_RE.search(amt_raw)
amount = float(m.group(1).replace(",", ""))
if amount == 0:
continue
others = [c for c in r if c not in (date_field, amt_raw) and not AMOUNT_RE.fullmatch(c.strip())]
desc = max(others, key=len) if others else " ".join(r)
if _is_credit("", desc):
continue
if _is_forex_detail_line(desc):
continue
out.append(_normalize_row(_parse_date(date_field), desc, amount))
return out
# a date anywhere on a line: dd/mm/yyyy, dd-mm-yy, dd Mon yy, dd-MON-yy, dd/Mon/yyyy
_PDF_DATE_RE = re.compile(r"(\d{1,2}[/\-. ](?:\d{1,2}|[A-Za-z]{3,9})[/\-. ]'?\d{2,4})")
_SIGNED_AMT_RE = re.compile(r"(-?\d[\d,]*\.\d{2})")
# lines that are clearly not transactions (summary / headers / footers)
_PDF_STOP = (
"total amount due", "minimum amount due", "credit limit", "available", "statement period",
"statement date", "reward", "opening balance", "closing balance", "payment due", "card number",
"transaction details", "your card", "page ", "apr", "gst summary", "important",
)
_PDF_START = ("transaction details", "your transactions", "transaction date", "date transaction")
# Amex prints month-first dates with no year on the row ("June 03"); the year comes
# from the statement period. Support that with a separate matcher + year injection.
_MONTHS = {m: i for i, m in enumerate(
["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1)}
_MONTH_DAY_RE = re.compile(
r"\b((?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2})(?!\d)", re.I)
def _parse_month_day(txt: str, year: int) -> Optional[str]:
m = re.match(r"([a-z]{3})[a-z]*\.?\s+(\d{1,2})", (txt or "").strip(), re.I)
if not m:
return None
mo = _MONTHS.get(m.group(1).lower())
day = int(m.group(2))
if not mo or not (1 <= day <= 31):
return None
return f"{year:04d}-{mo:02d}-{day:02d}"
# Descriptor noise seen on Kotak (and others): payment-gateway prefixes, embedded URLs,
# brand domains, and the glued "SpendsArea" MCC-group column printed before the amount.
_DOMAIN_RE = re.compile(r"\b([a-z][\w-]*)\.(?:com|in|org|net|io|co|edu|gov|sg|us|uk|app)\w*", re.I)
_AGG_PREFIX_RE = re.compile(r"\b(raz|pyu|payu|ccbill|billdesk|pinelabs)\s*\*\s*", re.I)
_SPENDS_AREA_RE = re.compile(
r"\s+(services|education|automotive|apparel(?:\s*&\s*accessories)?|"
r"transport\s*&?\s*freight|freight|quasi\s*cash|financial\s*services|"
r"government\s*services|professional\s*services|utilities|telecom|insurance)\s*$", re.I)
def _strip_descriptor_noise(s: str) -> str:
s = re.sub(r"https?[:/]*\S*", " ", s, flags=re.I) # URLs, incl. glued "httpsgmat.."
s = re.sub(r"\([^)]*\)", " ", s) # "(*ConverttoEMI)" markers
s = re.sub(r"\s*\([^)]*$", " ", s) # a marker truncated at a line wrap "(*Conv"
s = _AGG_PREFIX_RE.sub(" ", s) # "RAZ*RAPIDO" -> "RAPIDO"
s = _DOMAIN_RE.sub(r"\1", s) # "github.com" -> "github"
return re.sub(r"\s{2,}", " ", s).strip()
class _PdfRow:
pass
def _parse_pdf_lines(lines: List[str], gated: bool = True) -> List[Dict]:
out: List[Dict] = []
started = not gated # ungated fallback: parse from the top
pending: List[str] = [] # buffered wrapped merchant-name lines (IDFC style)
# Year for month-first dates that omit it (Amex "June 03"): take the latest 4-digit
# year printed anywhere on the statement, else the current year.
_yrs = re.findall(r"\b(20\d{2})\b", "\n".join(lines))
default_year = int(max(_yrs)) if _yrs else datetime.now().year
for raw in lines:
line = (raw or "").strip()
if not line:
continue
low = line.lower()
if not started:
if any(k in low for k in _PDF_START):
started = True
continue
dm = _PDF_DATE_RE.search(line)
md = None if dm else _MONTH_DAY_RE.search(line) # Amex month-first fallback
date_txt = dm.group(1) if dm else (md.group(1) if md else None)
amts = list(_SIGNED_AMT_RE.finditer(line))
if date_txt and amts:
iso = _parse_date(date_txt) if dm else _parse_month_day(date_txt, default_year)
if not iso: # date-like but not a real date -> buffer text
if re.search(r"[A-Za-z]", line):
pending = (pending + [line])[-2:]
continue
amt_m = amts[-1] # last money figure on the line is the INR amount
amount = float(amt_m.group(1).replace(",", ""))
tail = line[amt_m.end():].strip().upper()
is_credit = amount < 0 or tail.startswith("CR") or _is_credit("", line)
amount = abs(amount)
if amount == 0: # FX-only / zero rows
pending = []
continue
desc = line[:amt_m.start()].replace(date_txt, " ")
desc = desc.replace("₹", " ") # Axis prints "₹ 980.00"
desc = re.sub(r"\b(?:inr|rs)\.?\b", " ", desc, flags=re.I) # RBL "INR 1,899.00" prefix
desc = _strip_descriptor_noise(desc) # Kotak URLs / gateway prefix / EMI markers / domains
desc = re.sub(r"^[\s|]*\d{1,2}:\d{2}(?::\d{2})?\s+", " ", desc) # HDFC leading time "22:16"
desc = re.sub(r"\s\+\s*\d{1,3}\b", " ", desc) # HDFC "+ 15" reward-point marker
desc = re.sub(r"\b\d{6,}\b", " ", desc) # strip long reference numbers
desc = re.sub(r"#\w+", " ", desc) # Axis "#HJ1S1I6CZYEUZ9" transaction refs
desc = re.sub(r"\b0\.00\b", " ", desc) # strip the FX (international) 0.00 column
desc = re.sub(r"\b[DC]R\b", " ", desc, flags=re.I)
desc = re.sub(r"(?<![A-Za-z])[rR](?=\d)", " ", desc) # ₹ rendered as 'r'
desc = re.sub(r"\s+[CD]\s*$", " ", desc) # HDFC trailing debit/credit column letter
desc = _SPENDS_AREA_RE.sub(" ", desc) # Kotak "... Services"/"... Automotive" MCC column
desc = re.sub(r"\s+in\s*$", " ", desc, flags=re.I) # trailing India country code
desc = re.sub(r"\b(\w+)(\s+\1\b)+", r"\1", desc, flags=re.I) # collapse repeated words ("Gmatclub Gmatclub")
desc = re.sub(r"\s{2,}", " ", desc).strip(" ,|+")
# A real merchant name is one that survives stripping the issuer's EMI-convert
# marker and any "USD 324.50" forex fragment. IDFC prints an international
# purchase as "<merchant wrapped above>\n<date> Convert USD 324.50 <INR>", so
# the amount line itself has no merchant - recover it from the wrapped buffer.
core = re.sub(r"\b(convert|conversion|fx|forex)\b", " ", desc, flags=re.I)
core = _CCY_AMT_RE.sub(" ", core)
core = _CCY_RE.sub(" ", core)
core = re.sub(r"[\d.,]+", " ", core).strip(" ,|*")
if len(core) < 2: # inline text is only a forex marker
desc = " ".join(pending).strip(" ,|") or desc
pending = []
if is_credit:
continue
# Re-run the credit/charge check on the RESOLVED description: on wrapped
# layouts (IDFC) the merchant text comes from `pending`, so the raw `line`
# check above never sees words like "Interest charges" / "Forex Markup Fee".
if _is_credit("", desc) or _CHARGES_RE.search(desc):
continue
# ungated fallback: require a real merchant name so summary/total lines
# (which have no description) aren't mistaken for transactions
if not gated and not re.search(r"[A-Za-z]", desc):
continue
# last resort: an international row whose merchant never resolved - label it
# rather than showing the raw "Convert USD 324.50" forex fragment
if _is_forex_detail_line(desc):
desc = "International transaction"
out.append(_normalize_row(iso, desc or "Transaction", amount))
else:
# Candidate wrapped merchant name (no amount on this line). Some issuers
# (RBL) repeat the date on the continuation line, so strip any leading date
# before buffering it for the amount row that follows.
nm = (line.replace(date_txt, " ") if date_txt else line).strip(" ,|")
if re.search(r"[A-Za-z]", nm) and len(nm) <= 60 and not any(k in low for k in _PDF_STOP):
pending = (pending + [nm])[-2:]
else:
pending = []
return out
def parse_pdf(content: bytes, password: Optional[str] = None) -> List[Dict]:
"""
Extract transactions from a PDF statement (HDFC, IDFC, Axis, ICICI, etc.).
Section-gated line parser: starts at the transactions table, keeps debit rows,
excludes credits/payments/refunds (CR or negative amount), and reconstructs
merchant names that wrap onto separate lines. Password unlocks protected PDFs.
"""
try:
import pdfplumber
except ImportError:
raise RuntimeError("pdfplumber not installed. Run: pip install pdfplumber")
# detect encryption up-front so the app can ask the user for the PDF password
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
if reader.is_encrypted:
if not password:
raise RuntimeError("PASSWORD_REQUIRED")
if reader.decrypt(password) == 0:
raise RuntimeError("PASSWORD_INCORRECT")
except RuntimeError:
raise
except Exception:
pass # pypdf unavailable or non-fatal; pdfplumber will try below
lines: List[str] = []
with pdfplumber.open(io.BytesIO(content), password=password or "") as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
lines.extend(text.split("\n"))
out = _parse_pdf_lines(lines, gated=True)
if not out: # unknown header layout -> retry ungated (best-effort, merchant required)
out = _parse_pdf_lines(lines, gated=False)
return out
# "Credit Limit Rs. 11,30,000" appears in the statement header. We use it to estimate
# income when the user hasn't given one. Excludes "available" / "cash" limits, which are
# lower/different. `credit\s*limit` tolerates the glued "TotalCreditLimit" some PDFs render.
# Require a currency marker (Rs/INR/₹) right before the figure so we don't grab a stray
# year ("Credit Limit ... Jan 2026") when the label and value sit on different lines.
_CREDIT_LIMIT_RE = re.compile(
r"credit\s*limit\b[^0-9₹]{0,18}(?:rs\.?|inr|₹)\s*([1-9][\d,]{3,}(?:\.\d{1,2})?)", re.I)
# A word that is a rupee amount, possibly with an 'r'/Rs/₹ prefix (many PDFs render ₹ as 'r').
_LIMIT_AMT_WORD = re.compile(r"^(?:r|rs\.?|inr|₹)?\s*(\d[\d,]{4,}(?:\.\d{1,2})?)$", re.I)
def _plausible_limit(v: float) -> bool:
return 25000 <= v <= 1e8 # real card limits; floor rejects years/small numbers
def _inline_credit_limit(text: str) -> Optional[float]:
"""Inline 'Credit Limit Rs 11,30,000' on a single line (Kotak, SBI, many issuers)."""
best = None
for m in _CREDIT_LIMIT_RE.finditer(text or ""):
pre = text[max(0, m.start() - 18):m.start()].lower()
if "available" in pre or "cash" in pre:
continue
try:
v = float(m.group(1).replace(",", ""))
except ValueError:
continue
if _plausible_limit(v) and (best is None or v > best):
best = v
return best
def _credit_limit_from_words(words: List[dict]) -> Optional[float]:
"""Given positioned words ({text,x0,x1,top}) from a page or OCR, find the total credit
limit near a (non-available) 'Credit Limit' label: prefer a rupee amount to its right or
directly beneath it; failing that, take the LARGEST plausible amount in the summary band
around the label - a credit limit is always the biggest figure in its box (dues can never
exceed it), which reliably picks it out of statements like HDFC where labels and values
don't align in a column."""
best = None
for i in range(len(words) - 1):
if words[i]["text"].lower() != "credit" or words[i + 1]["text"].lower() != "limit":
continue
if abs(words[i + 1]["top"] - words[i]["top"]) > 4:
continue
pre = words[i - 1]["text"].lower() if i > 0 else ""
if pre in ("available", "cash") or "avl" in pre:
continue
lx0, lx1, ltop = words[i]["x0"], words[i + 1]["x1"], words[i]["top"]
aligned, band = [], []
for a in words:
m = _LIMIT_AMT_WORD.match(a["text"].replace(" ", ""))
if not m:
continue
try:
v = float(m.group(1).replace(",", ""))
except ValueError:
continue
if not _plausible_limit(v):
continue
if (abs(a["top"] - ltop) <= 4 and lx1 < a["x0"] < lx1 + 130) or \
(ltop < a["top"] <= ltop + 70 and a["x0"] < lx1 + 30 and a["x1"] > lx0 - 30):
aligned.append(v)
if ltop - 55 <= a["top"] <= ltop + 80:
band.append(v)
cand = max(aligned) if aligned else (max(band) if band else None)
if cand is not None and (best is None or cand > best):
best = cand
return best
def _positional_credit_limit(pdf) -> Optional[float]:
best = None
try:
pages = pdf.pages[:3]
except Exception:
return None
for page in pages:
try:
v = _credit_limit_from_words(page.extract_words())
except Exception:
continue
if v and (best is None or v > best):
best = v
return best
def _ocr_credit_limit(content: bytes, password: Optional[str] = None) -> Optional[float]:
"""Last-resort OCR for statements whose summary is a rendered image / has no usable text
layer (HDFC). Rasterises the first two pages and reuses the positional logic on the OCR'd
words. All heavy deps are lazily imported so a Space without them just skips this."""
try:
import io as _io
import pdf2image
import pytesseract
from pypdf import PdfReader, PdfWriter
except Exception:
return None
try:
reader = PdfReader(_io.BytesIO(content))
if reader.is_encrypted:
reader.decrypt(password or "")
writer = PdfWriter()
for p in reader.pages[:2]:
writer.add_page(p)
buf = _io.BytesIO(); writer.write(buf)
images = pdf2image.convert_from_bytes(buf.getvalue(), dpi=200)
except Exception:
return None
best = None
scale = 72.0 / 200.0 # normalise OCR pixels to PDF points so the same tolerances apply
for img in images[:2]:
try:
d = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
except Exception:
continue
words = []
for j in range(len(d["text"])):
t = (d["text"][j] or "").strip()
if t:
words.append({"text": t, "x0": d["left"][j] * scale,
"x1": (d["left"][j] + d["width"][j]) * scale, "top": d["top"][j] * scale})
text = " ".join(w["text"] for w in words)
v = _inline_credit_limit(text) or _credit_limit_from_words(words)
if v and (best is None or v > best):
best = v
return best
def detect_credit_limit(filename: str, content: bytes, password: Optional[str] = None) -> Optional[float]:
"""The card's total credit limit from the statement, ignoring available/cash limits.
Tiered: inline text, then column-aligned positional, then OCR (only if the text layer
yields nothing). Returns None (never a wrong guess) when nothing plausible is found."""
name = (filename or "").lower()
if name.endswith(".pdf"):
try:
import pdfplumber
with pdfplumber.open(io.BytesIO(content), password=password or "") as pdf:
text = "\n".join((p.extract_text() or "") for p in pdf.pages[:3])
cands = [x for x in (_inline_credit_limit(text), _positional_credit_limit(pdf)) if x]
if cands:
return max(cands)
except Exception:
pass
return _ocr_credit_limit(content, password) # image-only statements (HDFC)
try:
return _inline_credit_limit(content.decode("utf-8-sig", errors="ignore"))
except Exception:
return None
def parse_statement(filename: str, content: bytes, password: Optional[str] = None) -> List[Dict]:
name = filename.lower()
if name.endswith(".csv"):
return parse_csv(content)
if name.endswith(".pdf"):
return parse_pdf(content, password=password)
# try CSV as a fallback
try:
return parse_csv(content)
except Exception:
raise RuntimeError("Unsupported file type. Upload a .pdf or .csv statement.")