feat: 카드 결제 알림 자동인식(미확인 내역) 백엔드

- account_entry pending/notif_key 컬럼 추가(멱등 마이그레이션)
- CardNotificationParser: 카드사·금액·가맹점·승인/취소 파싱
- POST /entries/notification(미확인 지출 생성, 중복방지, 카드 자동매칭)
- POST /entries/{id}/confirm(확정), GET /entries/pending/count

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-03 18:51:30 +09:00
co-authored by Claude Opus 4.8
parent c1f97364aa
commit 8fec057752
10 changed files with 314 additions and 3 deletions
@@ -0,0 +1,130 @@
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<String> 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;
}
}