"""carrier별 결제/입출금 SMS 파서. Python 3.9 호환을 위해 union type은 문자열로 표기. 각 파서는 (raw_text, created_at_iso) -> "dict | None". 반환 dict 필드: - kind: "withdrawal" | "deposit" | "card_approval" | "card_cancel" - entry_date: "YYYYMMDD" - amount: int (원, 양수) - merchant: str (가맹점/메모/송수신처) - raw: 원문 그대로 파싱 실패 시 None을 반환해 raw 폴백 처리하도록 한다. """ import re from datetime import datetime from zoneinfo import ZoneInfo KST = ZoneInfo("Asia/Seoul") def _iso_to_kst_date(iso: str) -> str: """imsg created_at(UTC ISO) -> KST YYYYMMDD.""" try: dt = datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(KST) return dt.strftime("%Y%m%d") except Exception: return datetime.now(KST).strftime("%Y%m%d") def _normalize(text: str) -> str: """전각 공백·전각 영문 → 일반화.""" return text.replace("\u3000", " ").replace("\xa0", " ").strip() HANA_RE = re.compile( r"\[Web발신\]\s*\n" r"하나,(?P\d{2}/\d{2})\s+(?P\d{2}:\d{2})\s*\n" r"(?P\S+)\s*\n" r"(?P입금|출금)(?P[\d,]+)원\s*\n?" r"(?P[^\n]*)" r"(?:\s*\n잔액(?P[\d,]+)원)?", ) def parse_hana_bank(raw: str, created_at: str) -> "dict | None": text = raw.strip() m = HANA_RE.match(text) if not m: return None kind = "deposit" if m.group("kind") == "입금" else "withdrawal" amount = int(m.group("amount").replace(",", "")) merchant = _normalize(m.group("merchant")) or "(메모없음)" result = { "kind": kind, "entry_date": _iso_to_kst_date(created_at), "amount": amount, "merchant": merchant, "raw": raw, } if m.group("balance"): result["balance"] = int(m.group("balance").replace(",", "")) return result # 신한카드 신형식: "신한카드(6847)승인 방*원님 아파트 관리비 249,710원 정상승인 되었습니다." # entry_date 는 SMS created_at 기반(_iso_to_kst_date)이라 메시지 내 날짜 추출 불필요. SHINHAN_APPROVE_RE = re.compile( r"신한(?:카드)?(?:\(\d+\))?\s*(?P승인|매입취소|승인취소)\s+" r"(?P\S+?님)\s+" r"(?P.+?)\s+" r"(?P[\d,]+)\s*원", re.DOTALL, ) SHINHAN_BANK_RE = re.compile( r"\[Web발신\]\s*\n" r"신한\s*(?P\d{2}/\d{2})\s+(?P\d{2}:\d{2})\s*\n" r"(?P\S+)\s*\n" r"(?P입금|출금)\s+(?P[\d,]+)\s*\n" r"잔액\s+(?P[\d,]+)\s*\n?" r"(?P.*)", re.DOTALL, ) def parse_shinhan_bank(raw: str, created_at: str) -> "dict | None": text = raw.strip() m = SHINHAN_BANK_RE.match(text) if not m: return None kind = "deposit" if m.group("kind") == "입금" else "withdrawal" amount = int(m.group("amount").replace(",", "")) merchant = _normalize(m.group("merchant")).split("\n")[0].strip() or "(메모없음)" result = { "kind": kind, "entry_date": _iso_to_kst_date(created_at), "amount": amount, "merchant": merchant, "raw": raw, } if m.group("balance"): result["balance"] = int(m.group("balance").replace(",", "")) return result def parse_shinhan_card(raw: str, created_at: str) -> "dict | None": text = _normalize(raw).replace("[Web발신]", "") m = SHINHAN_APPROVE_RE.search(text) if not m: return None kind_raw = m.group("kind") kind = "card_cancel" if "취소" in kind_raw else "card_approval" amount = int(m.group("amount").replace(",", "")) merchant = m.group("merchant").strip().split("\n")[0] merchant = re.sub(r"\s+", " ", merchant).strip() or "(가맹점없음)" return { "kind": kind, "entry_date": _iso_to_kst_date(created_at), "amount": amount, "merchant": merchant, "raw": raw, } KAKAOBANK_RE = re.compile( r"\[Web발신\]\s*\[카카오뱅크\]\s+" r"(?P\S+)\s+" r"(?P\d{2}/\d{2})\s+(?P\d{2}:\d{2})\s+" r"(?P입금|출금)\s+(?P[\d,]+)원\s+" r"(?P[^\n]+?)" r"(?:\s*\n잔액\s+(?P[\d,]+)원)?\s*$", re.DOTALL, ) def parse_kakao_bank(raw: str, created_at: str) -> "dict | None": text = _normalize(raw) m = KAKAOBANK_RE.search(text) if not m: return None kind = "deposit" if m.group("kind") == "입금" else "withdrawal" amount = int(m.group("amount").replace(",", "")) merchant = _normalize(m.group("merchant")).split("\n")[0].strip() or "(메모없음)" result = { "kind": kind, "entry_date": _iso_to_kst_date(created_at), "amount": amount, "merchant": merchant, "raw": raw, } if m.group("balance"): result["balance"] = int(m.group("balance").replace(",", "")) return result # 현대카드 승인 형식 (취소는 별도 샘플 확보 후 추가): # 현대카드 블루멤버스 승인 # 방*원 # 5,170원 일시불 # 04/25 11:34 # 쿠팡 # 누적1,466,011원 HYUNDAI_CARD_APPROVE_RE = re.compile( r"^\s*현대카드[^\n]*승인\s*\n" r"(?P[^\n]+)\s*\n" r"(?P[\d,]+)\s*원[^\n]*\n" r"\d{2}/\d{2}\s+\d{2}:\d{2}\s*\n" r"(?P[^\n]+)" r"(?:\s*\n누적\s*(?P[\d,]+)\s*원)?", re.MULTILINE, ) # RCS/MAAP 형식은 한 줄로 들어온다: # [Web발신] 현대카드 블루멤버스 승인 방*원 84,000원 일시불 05/16 11:57 네이버페이 누적820,000원 HYUNDAI_CARD_APPROVE_INLINE_RE = re.compile( r"현대카드[^\n]*승인\s+" r"(?P\S+)\s+" r"(?P[\d,]+)\s*원[^\n]*?" r"\d{2}/\d{2}\s+\d{2}:\d{2}\s+" r"(?P.+?)" r"(?:\s+누적\s*(?P[\d,]+)\s*원?)?\s*$", re.MULTILINE, ) def parse_hyundai_card(raw: str, created_at: str) -> "dict | None": text = _normalize(raw).replace("[Web발신]", "").strip() m = HYUNDAI_CARD_APPROVE_RE.search(text) or HYUNDAI_CARD_APPROVE_INLINE_RE.search(text) if not m: return None amount = int(m.group("amount").replace(",", "")) merchant = re.sub(r"\s+", " ", m.group("merchant").strip()) or "(가맹점없음)" result = { "kind": "card_approval", "entry_date": _iso_to_kst_date(created_at), "amount": amount, "merchant": merchant, "raw": raw, } if m.group("cumulative"): result["cumulative"] = int(m.group("cumulative").replace(",", "")) return result PARSERS = { "+8215991111": parse_hana_bank, "+8215447200": parse_shinhan_card, "+8215778000": parse_shinhan_bank, "+8215993333": parse_kakao_bank, "+8215776200": parse_hyundai_card, "15776200@botplatform.maapservice.com": parse_hyundai_card, } def parse(sender: str, raw: str, created_at: str) -> "dict | None": fn = PARSERS.get(sender) if fn is None: return None try: return fn(raw, created_at) except Exception: return None