Spaces:
Paused
Paused
File size: 61,576 Bytes
888111a 7f1c52e 888111a 9908438 888111a 7f1c52e c1f7292 3eccb81 c1f7292 3eccb81 c1f7292 3eccb81 c1f7292 3eccb81 9908438 c2ddb81 9908438 7c1b9e0 c2ddb81 9908438 888111a a5516f5 7a66a35 c2ddb81 7a66a35 888111a c2ddb81 03c920c c2ddb81 03c920c c2ddb81 888111a 9908438 c2ddb81 9908438 888111a 9908438 154be3a 9908438 888111a c2ddb81 888111a 9908438 7a66a35 9908438 a5516f5 888111a 9908438 888111a 9908438 03c920c 9908438 888111a 9908438 888111a 9908438 c1f7292 154be3a 7f1c52e 154be3a c1f7292 888111a 9908438 3eccb81 9908438 a5516f5 9908438 c2ddb81 9908438 611b4f5 9908438 7f1c52e 9908438 a5516f5 888111a c2ddb81 9908438 888111a 611b4f5 9a100d5 611b4f5 9a100d5 611b4f5 d2e968d 611b4f5 c2ddb81 9a100d5 c2ddb81 9a100d5 c2ddb81 611b4f5 9a100d5 3eccb81 a5516f5 611b4f5 3eccb81 9a100d5 611b4f5 d2e968d 611b4f5 d2e968d 9a100d5 611b4f5 c2ddb81 611b4f5 9a100d5 611b4f5 c2ddb81 9a100d5 611b4f5 3eccb81 611b4f5 7c1b9e0 3eccb81 7c1b9e0 611b4f5 c2ddb81 7c1b9e0 c2ddb81 611b4f5 c2ddb81 611b4f5 c2ddb81 611b4f5 a5516f5 611b4f5 c2ddb81 a5516f5 c2ddb81 611b4f5 c2ddb81 611b4f5 c2ddb81 611b4f5 a5516f5 611b4f5 a5516f5 611b4f5 888111a 611b4f5 888111a 611b4f5 a5516f5 611b4f5 a5516f5 888111a 7f1c52e 2b55cd4 7f1c52e 2b55cd4 7f1c52e 9a100d5 7f1c52e c9afe29 a5516f5 888111a a5516f5 888111a a5516f5 888111a a5516f5 888111a a7eaed9 6303b7b af26b53 cb30e2e 6303b7b cb30e2e 6303b7b cb30e2e af26b53 6303b7b cb30e2e 6303b7b af26b53 6303b7b cb30e2e 6303b7b af26b53 d2e968d af26b53 d2e968d af26b53 d2e968d af26b53 6303b7b d485928 d2e968d d485928 a7eaed9 d485928 a7eaed9 af26b53 a7eaed9 af26b53 6303b7b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 | """
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.
"(1,200.00)" is the accounting convention for a CREDIT. The regex matched
happily inside the brackets and returned +1200, so a refund imported as a
purchase. _normalise_amount_signs exists for exactly this and was only ever
wired into the PDF path, never the CSV one.
"""
cleaned = re.sub(r"(?:rs\.?|inr|₹)", "", s or "", flags=re.IGNORECASE).replace("/-", "").strip()
negated = bool(re.fullmatch(r"\(\s*[^)]*\)", cleaned))
m = _AMOUNT_LENIENT_RE.search(cleaned)
if not m:
return None
try:
v = float(m.group(1).replace(",", ""))
except ValueError:
return None
return -abs(v) if negated else v
_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|"
# Axis and SBI print the monthly payment row as "PAYMENT - THANK YOU" with
# no 'received' - it imported as an Rs.8,000 PURCHASE in the layout test
# (2026-08-02) because none of these wordings matched it.
r"payment\s*[-\u2013]*\s*thank\s*you|thank\s*you\s*for\s*your\s*payment|"
r"autopay|auto pay|bbps|nach|e-?mandate|refund|reversal|cashback received)",
re.IGNORECASE,
)
# Merchant refunds/reversals: credits that CANCEL an earlier purchase (and its
# rewards), as opposed to bill payments. Collected as reversal events so the
# app can net them against the matching spend.
_REFUND_RE = re.compile(r"\b(refund(?:ed)?|revers(?:al|ed)|charge\s*back)\b", re.IGNORECASE)
def _credit_kind(direction: str, desc: str) -> str:
"""Classify a row: 'spend' (count it), 'payment' (ignore), 'refund' (net out)."""
d = (direction or "").strip().lower()
text = desc or ""
if _CHARGES_RE.search(text):
return "payment"
if _REFUND_RE.search(text):
return "refund"
if _PAYMENT_RE.search(text):
return "payment"
if d in ("cr", "credit") or d.startswith("cr"):
# a bare credit with merchant-looking text is a refund; else a payment
return "refund" if re.search(r"[A-Za-z]{3,}", text) else "payment"
return "spend"
# 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, reversals: Optional[List[Dict]] = None) -> 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)
# Signed single-amount statements: a NEGATIVE row is a CREDIT.
# _normalize_row does abs(), so without this branch a -899.00 refund
# imported as a +899.00 purchase - a 2x error on that row plus
# phantom rewards. The TS parser has always had this; the Python
# CSV path never did.
if amount < 0:
if _credit_kind("cr", desc) == "refund" and reversals is not None:
reversals.append({"date": iso, "description": desc.strip(), "amount": round(abs(amount), 2)})
continue
direction = r[ki] if 0 <= ki < len(r) else ""
kind = _credit_kind(direction, desc)
if kind == "refund":
if reversals is not None:
reversals.append({"date": iso, "description": desc.strip(), "amount": round(abs(amount), 2)})
continue
if kind == "payment":
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)
kind = _credit_kind("", desc)
if kind == "refund":
if reversals is not None:
reversals.append({"date": _parse_date(date_field), "description": desc.strip(), "amount": round(abs(amount), 2)})
continue
if kind == "payment":
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
# The alphabetic component must be a REAL month. It used to accept any 3-9 letter
# word, so "January 03 SWIGGY 642.00" matched "03 SWIGGY 642" as a date, failed to
# parse as one, and the whole row was discarded as a wrapped merchant name. Any
# merchant of 3-9 letters followed by a 2-4 digit number hit this.
_MONTH_WORD = (r"(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|"
r"aug(?:ust)?|sep(?:t|tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)")
_PDF_DATE_RE = re.compile(r"(\d{1,2}[/\-. ](?:\d{1,2}|" + _MONTH_WORD + r")[/\-. ]'?\d{2,4})", re.I)
_SIGNED_AMT_RE = re.compile(r"(-?\d[\d,]*\.\d{2})")
# A row printed WITHOUT paise ("DECATHLON 4,590") matched no amount at all and was
# silently discarded as a wrapped merchant name - real spend vanishing with no
# error and no count. Requires a grouping comma or a decimal so a bare 4-digit
# year can never be read as money. Used only on the tail of a dated line.
_LENIENT_AMT_RE = re.compile(r"(-?(?:\d{1,3}(?:,\d{2,3})+|\d+\.\d{1,2})(?:\.\d{1,2})?)")
# "(9,999.00)" is the accounting convention for a CREDIT. Untouched it parsed as
# +9,999 and imported a refund as a purchase - a 2x error on that line.
_PAREN_AMT_RE = re.compile(r"\((\d[\d,]*(?:\.\d{1,2})?)\)")
def _normalise_amount_signs(line: str) -> str:
"""(9,999.00) -> -9,999.00 so the existing negative handling takes over."""
return _PAREN_AMT_RE.sub(lambda m: "-" + m.group(1), line)
# 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")
# Start of the transaction TABLE. The four literals above only cover issuers that
# print "Transaction Date"; banks whose table header is a plain "Date Description
# Amount" (SBI Card, several PSU layouts) matched NOTHING, so gated parsing found
# no rows and fell through to the ungated fallback, which parses from the top of
# the page and turned the header's own "Statement Date ... Total Outstanding Rs.
# 4,569.50" summary line into a phantom transaction. Match the generic column
# header too, so those layouts gate properly.
_PDF_START_RE = re.compile(
r"\b(?:transaction\s+details|your\s+transactions|transaction\s+date|date\s+transaction)\b"
r"|(?:domestic|international)\s*/?\s*(?:and\s+international\s+)?transactions?\b"
r"|\bdate\b.{0,60}?\b(?:description|details|particulars|narration)\b.{0,60}?\b(?:amount|amt|debit)\b",
re.I,
)
# Header/summary lines that carry BOTH a date and an amount ("Statement Date: 12
# Jul 2026 Total Outstanding: Rs. 4,569.50"). _PDF_STOP is only consulted for
# wrapped-name lines (no amount), so these slipped straight through and were
# emitted as transactions. Reject them explicitly, in gated and ungated mode.
# NOTE \s* (not \s+) everywhere: several issuers (Kotak) extract with the spaces
# stripped out - "MinimumAmountDue(MAD) Rs.2,159.25", "TotalAmountDue(TAD)" - so a
# space-requiring pattern misses them and the summary row leaks in as a purchase.
_SUMMARY_RE = re.compile(
r"(?:statement\s*(?:date|period|summary)"
r"|total\s*(?:dues?|outstanding|amount\s*due)"
r"|(?:minimum|min\.?)\s*amount\s*due"
r"|payment\s*due\s*date"
r"|credit\s*limit"
r"|available\s*(?:credit|limit|cash)"
r"|cash\s*limit"
r"|(?:opening|closing|previous)\s*balance"
r"|reward\s*points?\s*(?:summary|balance)"
r"|card\s*(?:number|no\.?)\s*[:\-]"
r"|gst\s*summary)",
re.I,
)
# 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, period=None) -> 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
# With a known cycle, pick the year that puts this month/day INSIDE it. A
# December row in a 15-Dec-2025 -> 14-Jan-2026 cycle is 2025, not 2026, and
# only the period says so.
if period:
start, end = period
for cand in (end.year, end.year - 1, start.year):
try:
d = datetime(cand, mo, day).date()
except ValueError:
continue
if start <= d <= end:
return d.isoformat()
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
_PERIOD_RE = re.compile(
r"statement\s*(?:period|date|cycle)?\s*[:\-]?\s*"
r"([A-Za-z]{3,9}\s+\d{1,2},?\s+20\d{2}|\d{1,2}[/\-][A-Za-z0-9]{2,9}[/\-]20\d{2})"
r"\s*(?:to|-|–|through)\s*"
r"([A-Za-z]{3,9}\s+\d{1,2},?\s+20\d{2}|\d{1,2}[/\-][A-Za-z0-9]{2,9}[/\-]20\d{2})", re.I)
def _statement_period(blob: str):
"""(start, end) dates of the statement cycle, or None. The cycle is what tells
a month-day row which YEAR it belongs to - a December row in a Dec->Jan cycle
is last year's, and nothing else on the page says so."""
m = _PERIOD_RE.search(blob or "")
if not m:
return None
def _one(t: str):
t = t.replace(",", " ").strip()
for fmt in ("%B %d %Y", "%b %d %Y", "%d/%m/%Y", "%d-%m-%Y", "%d/%b/%Y", "%d-%b-%Y"):
try:
return datetime.strptime(re.sub(r"\s+", " ", t), fmt).date()
except ValueError:
continue
return None
a, b = _one(m.group(1)), _one(m.group(2))
return (a, b) if a and b and a <= b else None
def _has_balance_column(lines: List[str]) -> bool:
"""Does this statement print a RUNNING BALANCE after the amount?
Decided across the whole file rather than row by row, by the only property
that actually identifies a balance column: successive balances differ by
that row's amount. Kotak and several PSU layouts do this; most issuers do
not, and mis-classifying either way corrupts every figure in the import, so
the bar is deliberately high - at least two CONSECUTIVE confirmed pairs and
at least 70% of all pairs consistent. Two independent arithmetic
coincidences are needed for a false positive; a forex sub-amount column
("USD 25.00 1,200.00") fails immediately because the left figure bears no
relation to the difference between the right ones.
Direction is not assumed: a card statement's balance rises with spend, a
bank account's falls, so |balance_n - balance_(n-1)| == amount_n is the
test.
"""
rows: List[tuple] = []
for raw in lines:
line = (raw or "").strip()
if not line or _SUMMARY_RE.search(line.lower()):
continue
line = _normalise_amount_signs(line)
dm = _PDF_DATE_RE.search(line)
md = None if dm else _MONTH_DAY_RE.search(line)
if not (dm or md):
continue
amts = list(_SIGNED_AMT_RE.finditer(line))
if len(amts) < 2:
rows.append(()) # a 1-figure row breaks the balance chain
continue
try:
last = float(amts[-1].group(1).replace(",", ""))
prev = float(amts[-2].group(1).replace(",", ""))
except ValueError:
rows.append(())
continue
rows.append((prev, last))
ok = bad = 0
for i in range(1, len(rows)):
a, b = rows[i - 1], rows[i]
if len(a) != 2 or len(b) != 2:
continue
amount, balance = b
if abs(abs(balance - a[1]) - abs(amount)) < 0.51:
ok += 1
else:
bad += 1
return ok >= 2 and ok >= (ok + bad) * 0.7
def _parse_pdf_lines(lines: List[str], gated: bool = True, reversals: Optional[List[Dict]] = None) -> List[Dict]:
out: List[Dict] = []
started = not gated # ungated fallback: parse from the top
pending: List[str] = [] # buffered wrapped merchant-name lines (IDFC style)
# Decided ONCE for the whole statement by _has_balance_column below, not
# inferred row by row. The old streaming test asked whether
# (_running + prev) == last, but _running was only ever assigned INSIDE that
# same branch and started at 0.0 - so it could only bootstrap on a statement
# whose opening balance was zero. Credit-card statements always carry a
# previous balance, so the branch never fired and EVERY row on a
# balance-column layout imported the running balance as the spend, with the
# error compounding down the page.
_balance_col = _has_balance_column(lines)
# Year for month-first dates that omit it (Amex "June 03").
#
# This used to take max() of every 4-digit year printed anywhere, so a
# "Card valid thru 12/2030" or a copyright line hijacked every row, and a
# Dec->Jan cycle stamped December with the JANUARY year - twelve months out.
# Rows dated in the future fall outside every monthly bucket and can never be
# matched by a refund (applyReversals only looks backwards), so the error is
# silent and permanent. Read the statement PERIOD instead and let each row
# take the year that places it inside it.
_blob = "\n".join(lines)
_period = _statement_period(_blob)
_yrs = re.findall(r"\b(20\d{2})\b", _blob)
default_year = (_period[1].year if _period else (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 _PDF_START_RE.search(low):
started = True
continue
# A summary/header row is never a transaction, even though it carries a
# date and an amount. This is what produced hallucinated purchases like
# "Statement Date / Total Outstanding Rs.4,569.50".
if _SUMMARY_RE.search(low):
pending = []
continue
line = _normalise_amount_signs(line) # "(9,999.00)" is a CREDIT, not a spend
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 not amts:
# No paise on this row ("DECATHLON BENGALURU 4,590"). Look only AFTER
# the date so the date itself can never be read as money, and require
# a grouping comma or a decimal so a bare year cannot match.
tail_at = (dm.end() if dm else md.end())
amts = [m for m in _LENIENT_AMT_RE.finditer(line, tail_at)]
if date_txt and amts:
iso = _parse_date(date_txt) if dm else _parse_month_day(date_txt, default_year, _period)
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
# On a confirmed balance layout the amount is the SECOND-to-last
# figure; the last one is the running total. Decided for the file,
# so the first data row is classified correctly too.
amt_m = amts[-2] if (_balance_col and len(amts) >= 2) else amts[-1]
amount = float(amt_m.group(1).replace(",", ""))
tail = line[amt_m.end():].strip().upper()
# Axis-style layouts carry the direction as its own COLUMN before
# the amount ("... Debit 5,822.00" / "PAYMENT - THANK YOU Credit
# 8,000.00"). The word immediately left of the amount decides; it
# is also stripped from the description below so 'Debit' never
# reads as part of a merchant name.
_pre_words = line[:amt_m.start()].strip().split()
_dir_word = (_pre_words[-1].strip('.').lower() if _pre_words else "")
# A bare trailing "C"/"D" is HDFC's direction column. The code
# already knew it existed - it scrubs it out of the merchant name a
# few lines below - but is_credit only looked for a sign, a "CR"
# prefix or a Debit/Credit WORD, so a single letter was invisible
# and a merchant credit whose wording lacked "refund" imported as
# spend. The Axis long-word form was handled; this one was not.
_tail_letter = tail.split()[0] if tail.split() else ""
_col_credit = _dir_word in ("credit", "cr") or _tail_letter in ("C", "CR")
_col_debit = _dir_word in ("debit", "dr") or _tail_letter in ("D", "DR")
is_credit = amount < 0 or tail.startswith("CR") or _col_credit or (not _col_debit and _is_credit("", line))
amount = abs(amount)
if amount == 0: # FX-only / zero rows
pending = []
continue
desc = line[:amt_m.start()].replace(date_txt, " ")
if _col_credit or _col_debit:
desc = re.sub(r"\b(?:debit|credit|dr|cr)\.?\s*$", " ", desc, flags=re.I)
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:
# CR / negative rows: a merchant refund becomes a reversal event
# (nets out its spend and rewards); payments stay ignored.
if reversals is not None and _credit_kind("cr", desc) == "refund":
reversals.append({"date": iso, "description": desc.strip(" ,|"), "amount": round(abs(amount), 2)})
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".
kind2 = _credit_kind("", desc)
if kind2 == "refund":
if reversals is not None:
reversals.append({"date": iso, "description": desc.strip(" ,|"), "amount": round(abs(amount), 2)})
continue
if kind2 == "payment" 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, reversals: Optional[List[Dict]] = 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 payments/charges, reports merchant refunds into `reversals` (so the
app can net them against spends), 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"))
rev1: List[Dict] = []
out = _parse_pdf_lines(lines, gated=True, reversals=rev1)
if not out: # unknown header layout -> retry ungated (best-effort, merchant required)
rev1 = []
out = _parse_pdf_lines(lines, gated=False, reversals=rev1)
if reversals is not None:
reversals.extend(rev1)
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
# ---------------------------------------------------------------------------
# REWARD-POINT BALANCE
#
# The app can already tell you your points are worth more on a transfer partner
# than as statement credit - but only if it knows the BALANCE, and the balance
# was a number the user had to type in by hand. So the panel sat on a nudge and
# most people never saw an answer.
#
# Market check (Aug 2026): on TechnoFino the recurring complaint is that no
# Indian app automates this - people track 17 cards in Google Sheets. The one
# app users report as working is CRED, which reads the emailed statement. That
# is the approach here, and it needs no credentials and no account linking.
#
# EVERY STATEMENT PRINTS IT, because the issuer has to. The wording differs, so
# match the CONTEXT (a reward-points heading or label) and then the closing
# figure, rather than any bare number near the word "points".
# ---------------------------------------------------------------------------
# The label that names the balance we want. Deliberately narrow: "points
# earned", "points redeemed" and "points expiring" are NOT the balance, and
# grabbing one of those would show the user a confidently wrong number.
_PTS_CLOSING_RE = re.compile(
r"(?:closing|total|net|available|balance\s+of)?\s*"
r"(?:reward|edge|membership\s+reward|neu|payback|club\s*vistara|bonus)?\s*"
r"points?\s*(?:balance|as\s+on|closing|total)?\s*[:\-]?\s*"
r"([0-9][0-9,]{0,9})\b",
re.I,
)
# A reward-points SECTION heading; a "Closing Balance" beneath one is the
# balance even when the word "points" is not repeated on that line.
_PTS_SECTION_RE = re.compile(r"(reward|edge\s+reward|membership\s+reward|neu\s*coin|payback)\s*points?\s*(summary|details|statement)?", re.I)
_CLOSING_RE = re.compile(r"closing\s*(?:balance|points?)?\s*[:\-]?\s*([0-9][0-9,]{0,9})\b", re.I)
# Words that mean this figure is NOT the balance.
_PTS_REJECT = re.compile(r"earn|redeem|expir|lapse|adjust|convert|debit|credit\s*card\s*no|opening", re.I)
# A rupee context: points are a count, never a currency amount, so a figure
# introduced by a currency mark or carrying paise is the wrong figure.
_PTS_MONEY = re.compile(r"(?:rs\.?|inr|₹)\s*$", re.I)
# An integer that is NOT part of a decimal amount - points are whole units, so
# "5,000.00" is a rupee figure and must not be read as a balance.
# The WHOLE digit run, never a fragment of one. With a plain \b boundary,
# "99,99,99,999" backtracked to a valid-looking prefix, finditer then matched
# the trailing "999", and an absurd figure was read as a 999-point balance.
# The lookarounds refuse to start or stop inside a number, so an over-long run
# is rejected by _plausible_points as a whole instead of being salvaged.
_PTS_INT_RE = re.compile(r"(?<![0-9,])([0-9][0-9,]{0,13})(?![0-9,])(?!\s*\.\s*[0-9])")
def _plausible_points(v: float) -> bool:
"""A balance a real cardholder can hold. Rejects a stray year (2026), a
card fragment, and an absurd figure that is really a rupee total."""
return 0 <= v <= 5_000_000 and not (1990 <= v <= 2100 and v == int(v) and len(str(int(v))) == 4)
def detect_points_balance(text: str) -> Optional[int]:
"""The reward-point CLOSING balance from statement text, or None.
None is the honest answer whenever the statement is ambiguous: a wrong
balance would feed straight into "your points cover this flight", which is
a claim the user would act on.
"""
if not text:
return None
lines = [l.strip() for l in text.splitlines() if l.strip()]
cands: List[int] = []
for i, line in enumerate(lines):
low = line.lower()
# (a) an explicit labelled balance on one line. Once the line is
# CONFIRMED to name a balance, take the LAST plausible integer on it
# rather than trying to write one regex wide enough for every issuer's
# wording - Amex prints "Points Balance as on 31 Jul 2026: 2,15,300",
# and a pattern tight enough to reject "Points Earned 3,120" can never
# also span that date clause. Balances are printed last on their line.
if re.search(r"points?", low) and re.search(r"balance|closing|as on|total", low):
if _PTS_REJECT.search(low) and not re.search(r"closing|balance", low):
continue
line_vals: List[int] = []
for m in _PTS_INT_RE.finditer(line):
pre = line[max(0, m.start() - 6):m.start()]
if _PTS_MONEY.search(pre):
continue
try:
v = int(m.group(1).replace(",", ""))
except ValueError:
continue
if _plausible_points(v):
line_vals.append(v)
if line_vals:
cands.append(line_vals[-1])
# (b) a "Closing Balance" line inside a reward-points section
if _PTS_SECTION_RE.search(low):
for j in range(i, min(i + 6, len(lines))):
cm = _CLOSING_RE.search(lines[j])
if cm:
try:
v = int(cm.group(1).replace(",", ""))
except ValueError:
continue
if _plausible_points(v):
cands.append(v)
break
if not cands:
return None
# Several figures and no agreement means we cannot tell which is the
# balance - say nothing rather than pick one.
uniq = sorted(set(cands))
if len(uniq) > 1 and uniq[-1] != max(cands, key=cands.count):
return None
return max(set(cands), key=cands.count)
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_points_balance_file(filename: str, content: bytes, password: Optional[str] = None) -> Optional[int]:
"""The reward-point balance from a statement FILE. Same tiering discipline
as detect_credit_limit: read the text layer, and return None rather than a
guess. Deliberately does NOT fall through to OCR - a wrong balance would
feed "your points cover this flight", and no balance at all is the safer
failure."""
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[:4])
return detect_points_balance(text)
except Exception:
return None
try:
return detect_points_balance(content.decode("utf-8-sig", errors="ignore"))
except Exception:
return None
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)
# OCR IS FOR STATEMENTS WITH NO TEXT LAYER, NOT FOR STATEMENTS THAT
# SIMPLY DO NOT PRINT A LIMIT. Falling through to it whenever
# detection found nothing rasterised every ordinary statement at
# 200dpi and ran tesseract over it: measured 28.9s versus 0.56s to
# parse the same file, on every upload, behind a 3-permit semaphore
# with a 20s wait - so three uploads made everyone else 429, and the
# client retried, doubling the load. Most statements just have no
# limit line, and for those the honest answer is None, immediately.
if text.strip():
return None
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 find_pdf_password(content: bytes, candidates: List[str]) -> Optional[str]:
"""Probe candidate passwords against an encrypted PDF (CRED-style smart
unlock: bank statement passwords are formulaic - name4+DDMM etc - so the
app derives candidates from the user's details and we try them here).
Returns the working password, '' if the PDF isn't encrypted, or None."""
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
if not reader.is_encrypted:
return ""
for pw in candidates:
if not pw:
continue
try:
r = PdfReader(io.BytesIO(content))
if r.decrypt(pw) != 0:
return pw
except Exception:
continue
except Exception:
pass
return None
def parse_statement(filename: str, content: bytes, password: Optional[str] = None,
reversals: Optional[List[Dict]] = None) -> List[Dict]:
name = filename.lower()
if name.endswith(".csv"):
return parse_csv(content, reversals=reversals)
if name.endswith(".pdf"):
return parse_pdf(content, password=password, reversals=reversals)
# try CSV as a fallback
try:
return parse_csv(content, reversals=reversals)
except Exception:
raise RuntimeError("Unsupported file type. Upload a .pdf or .csv statement.")
# Exact catalogue issuer -> text/domain aliases. Mirrored in app statements.ts.
_ISSUER_TOKENS = [
("HDFC Bank", ["hdfc", "hdfcbank"]),
("SBI Card", ["sbi card", "sbi cards", "sbicard", "state bank"]),
("ICICI Bank", ["icici", "icicibank"]),
("Axis Bank", ["axis bank", "axisbank", "kiwi"]),
("American Express", ["american express", "amex", "americanexpress"]),
("IDFC FIRST Bank", ["idfc", "idfcfirstbank"]),
("AU Small Finance Bank", ["au small finance", "au bank", "aubank"]),
("IndusInd Bank", ["indusind"]),
("IDBI Bank", ["idbi"]),
("Bandhan Bank", ["bandhan bank", "bandhanbank"]),
("RBL Bank", ["rbl bank", "rblbank"]),
("BoB/Federal/SBM", ["onecard", "one card", "getonecard"]),
("HSBC", ["hsbc"]),
("Bank of Baroda", ["bobcard", "bank of baroda", "bankofbaroda"]),
("DBS Bank India", ["dbs bank", "dbs india", "dbsbank"]),
("Federal Bank", ["federal bank", "federalbank", "scapia"]),
("Kotak Mahindra Bank", ["kotak", "kotakbank"]),
("Punjab National Bank", ["punjab national bank", "pnb"]),
("Yes Bank", ["yes bank", "yesbank"]),
("Standard Chartered", ["standard chartered", "standardchartered", "sc bank"]),
]
# Issuer PAN (the 10 characters inside a GSTIN) -> issuer label.
#
# Why this exists: some issuers print their NAME only as a logo IMAGE and put the
# bank name in the terms pages, so the statement header carries no text bank name
# at all - and their text extracts glued ("SALIMRAMZANALISHAIKH"), which defeats
# word-boundary matching anyway. But every Indian statement prints the issuer's
# GSTIN in the header, and the PAN inside it identifies the bank exactly.
# Verified against real statements (Kotak = 27AAACK4409J..., HDFC = 33AAACH2702H...).
_GSTIN_ISSUER = [
("aaack4409j", "Kotak Mahindra Bank"),
("aaach2702h", "HDFC Bank"),
]
def detect_issuers_from_text(text: str) -> List[str]:
"""Issuer labels named in the statement header (fallback when no exact card
signature matches). Reads the same header region, so transaction-row bank
names can't leak in. Falls back to the issuer's GSTIN/PAN, which is present
even when the bank name is only a logo image."""
from merchants import keyword_hit
t = (text or "").lower()
out: List[str] = []
for label, toks in _ISSUER_TOKENS:
if any(keyword_hit(t, tok) for tok in toks) and label not in out:
out.append(label)
for pan, label in _GSTIN_ISSUER:
if pan in t and label not in out:
out.append(label)
return out
# ---------------------------------------------------------------------------
# Card detection: catalogue-derived, issuer-required and exclusive. The old
# 32-row ANY-token table made "HDFC Swiggy" match BLCK, ORNGE and legacy at once.
# This mirror covers the whole credit-card catalogue and retains equal top-score
# matches as ambiguous rather than guessing.
# ---------------------------------------------------------------------------
def _normalise_match_text(value: str) -> str:
s = (value or "").lower().replace("&", " and ").replace("+", " plus ")
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9]+", " ", s)).strip()
def _product_alias_hit(text: str, phrase: str) -> bool:
hay = f" {_normalise_match_text(text)} "
words = [w for w in _normalise_match_text(phrase).split(" ") if w]
return bool(words) and all(f" {word} " in hay for word in words)
_GENERIC_CARD_WORDS = {"bank", "credit", "card"}
_BRAND_ISSUER_ALIASES = {"kiwi", "scapia", "onecard", "one card", "getonecard"}
_CARD_PRODUCT_ALIAS_CACHE = {}
def _card_product_aliases(card) -> List[str]:
cached = _CARD_PRODUCT_ALIAS_CACHE.get(card.id)
if cached is not None:
return cached
name = _normalise_match_text(card.name)
issuer_aliases = next((aliases for issuer, aliases in _ISSUER_TOKENS if issuer == card.issuer), [])
removable = [card.issuer, *issuer_aliases]
removable = [x for x in removable if _normalise_match_text(x) not in _BRAND_ISSUER_ALIASES]
for alias in sorted(removable, key=len, reverse=True):
a = _normalise_match_text(alias)
if a:
name = f" {name} ".replace(f" {a} ", " ").strip()
name_product = " ".join(w for w in name.split() if w not in _GENERIC_CARD_WORDS)
issuer_id_words = {
word
for value in [card.issuer, *issuer_aliases]
for word in _normalise_match_text(value).split()
if len(word) > 1
}
id_product = " ".join(
word for word in _normalise_match_text(card.id.replace("_", " ")).split()
if word not in issuer_id_words and word != "first"
)
aliases = [name_product, id_product]
if card.id == "tataneu_infinity":
aliases += ["tata neu infinity", "neu infinity"]
if card.id == "hdfc_tata_neu_plus":
aliases += ["tata neu plus", "neu plus"]
if card.id == "onecard_metal":
aliases += ["onecard", "one card"]
if card.id == "amex_mrcc":
aliases += ["membership rewards", "mrcc"]
result = list(dict.fromkeys(filter(None, (_normalise_match_text(x) for x in aliases))))
_CARD_PRODUCT_ALIAS_CACHE[card.id] = result
return result
def detect_cards_from_text(text: str) -> List[str]:
from card_catalogue import CATALOGUE
issuers = set(detect_issuers_from_text(text))
if not issuers:
return []
matches = []
for card in CATALOGUE:
if getattr(card, "kind", "credit") == "debit" or card.issuer not in issuers:
continue
best = 0
for alias in _card_product_aliases(card):
if not _product_alias_hit(text, alias):
continue
words = [w for w in _normalise_match_text(alias).split() if w]
best = max(best, len(words) * 100 + len(_normalise_match_text(alias)))
if best:
matches.append((card.id, best))
if not matches:
return []
top = max(score for _, score in matches)
return [card_id for card_id, score in matches if score == top]
# Start of the transaction TABLE (not any stray "transaction" word): a section
# marker or the table's own column header. Matching the table start - rather
# than the first mention of "transaction" - keeps the card name (printed above
# the table) inside the header region even when the page has a "Transaction
# Summary" box up top.
_TXN_SECTION_RE = re.compile(
r"(?:domestic|international)\s*/?\s*(?:and\s*international\s*)?transactions?\b" # "Domestic / International Transactions"
r"|transaction\s+date\b|date\s+of\s+transaction\b|date\s+transaction\b" # transaction-table column header
r"|transaction\s+details\b" # "Transaction Details" section
r"|date\s*[,|~\t].{0,80}?(?:amount|amt|debit|withdrawal)" # delimited Date,...,Amount header (CSV)
r"|\bdate\b.{0,60}?\b(?:description|details|particulars|narration)\b.{0,60}?\b(?:amount|amt)\b", # plain "Date Description Amount" (SBI-style)
re.IGNORECASE,
)
# Safety cap for the rare statement with no recognisable table marker: the card
# name sits in the first-page header, so stop before the transaction list can
# realistically begin. Deliberately tight to avoid transaction rows leaking in.
_HEADER_MAX_CHARS = 1800
def _header_region(text: str) -> str:
"""Statement text BEFORE the transaction table. Card detection must never
read transaction rows: a Swiggy order inside an HDFC statement is not the
Swiggy HDFC card."""
m = _TXN_SECTION_RE.search(text or "")
cut = m.start() if m else len(text or "")
return (text or "")[: min(cut, _HEADER_MAX_CHARS)]
def _detection_text(filename: str, content: bytes, password: Optional[str] = None) -> str:
name = (filename or "").lower()
body = ""
if name.endswith(".pdf"):
try:
import pdfplumber
with pdfplumber.open(io.BytesIO(content), password=password or "") as pdf:
body = "\n".join((p.extract_text() or "") for p in pdf.pages[:2])
except Exception:
pass
else:
try:
body = content[:20000].decode("utf-8-sig", errors="ignore")
except Exception:
pass
return name + "\n" + _header_region(body)
def detect_cards(filename: str, content: bytes, password: Optional[str] = None) -> List[str]:
"""Card detection over a statement FILE (filename + pre-transaction header
region of the first PDF pages, or the CSV preamble)."""
return detect_cards_from_text(_detection_text(filename, content, password))
def detect_issuers(filename: str, content: bytes, password: Optional[str] = None) -> List[str]:
"""Issuer fallback over a statement FILE."""
return detect_issuers_from_text(_detection_text(filename, content, password))
def is_credit_card_statement_text(filename: str, text: str) -> bool:
"""Positive proof that text is from a credit-card statement, not merely a
statement/attachment sent by a bank. Mirrored in app statements.ts."""
sample = f"{filename or ''}\n{_header_region(text or '')}"
n = _normalise_match_text(sample)
explicit = bool(re.search(
r"\bcredit card (?:account )?(?:e )?statement\b"
r"|\b(?:e )?statement (?:for )?(?:your )?credit card\b", n))
if explicit:
return True
issuers = detect_issuers_from_text(sample)
cards = detect_cards_from_text(sample)
has_statement = bool(re.search(r"\bstatement\b", n))
if issuers and re.search(r"\bcredit card\b", n):
return True
if len(cards) == 1 and has_statement:
return True
signals = [
r"\btotal amount due\b", r"\bminimum amount due\b", r"\bpayment due date\b",
r"\bavailable credit(?: limit)?\b", r"\bcredit limit\b", r"\bcard (?:number|no|ending)\b",
r"\breward points? (?:summary|balance)\b", r"\bstatement balance\b",
r"\btotalamountdue\b", r"\bminimumamountdue\b", r"\bpaymentduedate\b", r"\bcreditlimit\b",
]
score = sum(1 for pattern in signals if re.search(pattern, n))
if score >= 2:
return True
return score >= 1 and (len(cards) == 1 or (has_statement and bool(issuers)))
def is_credit_card_statement(filename: str, content: bytes, password: Optional[str] = None) -> bool:
"""File-level wrapper used by the API before returning any transactions."""
detection_text = _detection_text(filename, content, password)
return is_credit_card_statement_text(filename, detection_text)
|