package com.sb.web.account.service; import lombok.Builder; import lombok.Getter; import org.springframework.stereotype.Component; import java.time.LocalDate; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 카드사 결제 푸시 알림 텍스트 파싱. * 카드사마다 문구가 달라 휴리스틱으로 카드사·금액·가맹점·승인/취소를 추출한다. * (서버에 두어 APK 재빌드 없이 규칙 보강 가능) */ @Component public class CardNotificationParser { // 감지할 카드사 (긴 이름 우선) private static final List ISSUERS = List.of( "KB국민", "국민", "삼성", "신한", "현대", "롯데", "하나", "우리", "비씨", "BC", "농협", "NH", "씨티", "카카오뱅크", "카카오", "토스", "수협", "광주", "전북", "제주" ); private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원"); private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})"); @Getter @Builder public static class Parsed { private boolean card; // 카드 결제 알림으로 인식되었는가 private boolean canceled; // 취소/취소승인 private String issuer; // 감지된 카드사 (없으면 null) private long amount; // 금액(원) private String merchant; // 가맹점(추정) private LocalDate date; // 거래일(추정, 없으면 null) private String notifKey; // 중복방지 키 } /** 카드 결제 알림 파싱. card=false 면 결제 알림이 아님(무시 대상). */ public Parsed parse(String title, String text, LocalDate today) { String raw = ((title == null ? "" : title) + "\n" + (text == null ? "" : text)).trim(); if (raw.isBlank()) return notCard(); boolean approved = raw.contains("승인") || raw.contains("결제") || raw.contains("출금"); boolean canceled = raw.contains("취소") || raw.contains("환불"); Matcher am = AMOUNT.matcher(raw); if (!am.find() || (!approved && !canceled)) { return notCard(); } long amount = Long.parseLong(am.group(1).replace(",", "")); String issuer = detectIssuer(raw); String merchant = guessMerchant(raw); LocalDate date = guessDate(raw, today); String notifKey = (issuer == null ? "?" : issuer) + "|" + amount + "|" + (merchant == null ? "" : merchant) + "|" + date + "|" + (canceled ? "C" : "A"); return Parsed.builder() .card(true) .canceled(canceled) .issuer(issuer) .amount(amount) .merchant(merchant) .date(date) .notifKey(notifKey) .build(); } private Parsed notCard() { return Parsed.builder().card(false).build(); } private String detectIssuer(String raw) { for (String c : ISSUERS) { if (raw.contains(c)) return c; } return null; } private LocalDate guessDate(String raw, LocalDate today) { Matcher m = DATE_MD.matcher(raw); while (m.find()) { int mo = Integer.parseInt(m.group(1)); int d = Integer.parseInt(m.group(2)); if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31) { try { LocalDate cand = LocalDate.of(today.getYear(), mo, d); // 미래 날짜(연말연초 경계)면 작년으로 return cand.isAfter(today.plusDays(1)) ? cand.minusYears(1) : cand; } catch (Exception ignore) { // 다음 후보 } } } return today; } /** * 가맹점 추정: 잡음(카드사·승인문구·금액·날짜·시간·마스킹 이름 등)을 제거한 뒤 * 남은 한글/영문 토큰 중 가장 그럴듯한 것을 고른다. (완벽하지 않음 — 사용자가 확정 시 수정) */ private String guessMerchant(String raw) { String s = raw .replaceAll("\\[?\\s*Web발신\\s*]?", " ") .replaceAll("[0-9]{1,3}(?:,[0-9]{3})+\\s*원|[0-9]{4,}\\s*원", " ") // 금액 .replaceAll("\\d{1,2}[:시]\\d{2}분?", " ") // 시간 .replaceAll("\\d{1,2}[./]\\d{1,2}", " ") // 날짜 .replaceAll("\\([^)]*\\)", " ") // 괄호(카드 끝번호 등) .replaceAll("승인취소|취소승인|승인|결제|취소|환불|일시불|할부|누적|잔액|출금|입금|체크|신용", " ") .replaceAll("[가-힣]\\*+[가-힣]?", " "); // 마스킹 이름(홍*동) for (String c : ISSUERS) { s = s.replace(c, " "); } s = s.replace("카드", " "); String[] tokens = s.trim().split("\\s+"); String best = null; for (String t : tokens) { String tok = t.replaceAll("[^0-9A-Za-z가-힣]", "").trim(); if (tok.length() < 2 || tok.length() > 20) continue; if (tok.matches("\\d+")) continue; if (!tok.matches(".*[가-힣A-Za-z].*")) continue; // 더 긴(구체적인) 토큰을 가맹점으로 선호 if (best == null || tok.length() > best.length()) best = tok; } return best; } }