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
@@ -6,6 +6,8 @@ import com.sb.web.account.domain.Wallet;
import com.sb.web.account.dto.AccountEntryRequest;
import com.sb.web.account.dto.AccountEntryResponse;
import com.sb.web.account.dto.AccountSummary;
import com.sb.web.account.dto.ConfirmRequest;
import com.sb.web.account.dto.NotificationRequest;
import com.sb.web.account.dto.AccountTagRequest;
import com.sb.web.account.dto.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat;
@@ -43,6 +45,7 @@ public class AccountService {
private final AccountTagMapper tagMapper;
private final WalletMapper walletMapper;
private final InvestService investService;
private final CardNotificationParser notificationParser;
/* ===================== 항목 ===================== */
@@ -202,6 +205,73 @@ public class AccountService {
mapper.delete(id, memberId);
}
/* ===================== 카드 결제 알림 자동인식 ===================== */
/** 미확인(확인 필요) 내역 개수 */
public int countPending(Long memberId) {
return mapper.countPending(memberId);
}
/**
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
*/
@Transactional
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
CardNotificationParser.Parsed p =
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
if (!p.isCard() || p.isCanceled() || p.getAmount() <= 0) {
return null; // 결제 승인 알림 아님 / 취소 → 무시
}
// 중복방지
if (p.getNotifKey() != null && mapper.findByMemberAndNotifKey(memberId, p.getNotifKey()) != null) {
return null;
}
// 카드사 → 등록된 카드(CARD) 매칭
Long walletId = matchCardWalletId(p.getIssuer(), memberId);
AccountEntry entry = AccountEntry.builder()
.memberId(memberId)
.entryDate(p.getDate() != null ? p.getDate() : java.time.LocalDate.now())
.type("EXPENSE")
.category(null)
.amount(p.getAmount())
.memo(p.getMerchant())
.walletId(walletId)
.toWalletId(null)
.installmentMonths(null)
.pending(true)
.notifKey(p.getNotifKey())
.build();
mapper.insert(entry);
return toResponse(mapper.findByIdAndMember(entry.getId(), memberId));
}
/** 미확인 내역 확정 (분류/계좌 보정 후 pending 해제) */
@Transactional
public AccountEntryResponse confirm(Long id, ConfirmRequest req, Long memberId) {
AccountEntry e = mustFind(id, memberId);
if (!Boolean.TRUE.equals(e.getPending())) {
return toResponse(e); // 이미 확정된 건
}
String category = req != null ? req.getCategory() : null;
Long walletId = req != null ? req.getWalletId() : null;
mapper.confirm(id, memberId, category, walletId);
return toResponse(mapper.findByIdAndMember(id, memberId));
}
/** 카드사명으로 등록된 CARD 지갑 id 찾기 (issuer/name 부분일치) */
private Long matchCardWalletId(String issuer, Long memberId) {
if (issuer == null) return null;
return walletMapper.findByMember(memberId).stream()
.filter(w -> "CARD".equals(w.getType()))
.filter(w -> (w.getIssuer() != null && w.getIssuer().contains(issuer))
|| (w.getName() != null && w.getName().contains(issuer)))
.map(Wallet::getId)
.findFirst()
.orElse(null);
}
/* ===================== 상환/납부 ===================== */
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 2건 생성 */