package com.sb.web.account.service; import com.sb.web.account.domain.AccountEntry; import com.sb.web.account.domain.AccountTag; 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.ParsedEntryResponse; import com.sb.web.account.dto.AccountTagRequest; import com.sb.web.account.dto.AccountTagResponse; import com.sb.web.account.dto.CategoryStat; import com.sb.web.account.dto.NetWorthPoint; import com.sb.web.account.dto.NetWorthResponse; import com.sb.web.account.dto.PeriodStat; import com.sb.web.account.dto.RepaymentRequest; import com.sb.web.account.dto.WalletRequest; import com.sb.web.account.dto.WalletResponse; import com.sb.web.account.mapper.AccountEntryMapper; import com.sb.web.account.mapper.AccountTagMapper; import com.sb.web.account.mapper.WalletMapper; import com.sb.web.common.exception.ApiException; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.YearMonth; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** * 가계부 서비스. 항목·태그 모두 memberId(소유자)로 격리되어 본인 데이터만 접근한다. * 가계부 태그는 사용자별(account_tag)로 관리한다. */ @Service @RequiredArgsConstructor public class AccountService { private final AccountEntryMapper mapper; private final AccountTagMapper tagMapper; private final WalletMapper walletMapper; private final InvestService investService; private final CardNotificationParser notificationParser; /* ===================== 항목 ===================== */ public List list(Long memberId, Integer year, Integer month, String type, String category, Long walletId, String keyword, Long tagId) { return mapper.findByMember(memberId, year, month, blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId).stream() .map(this::toResponse) .toList(); } /** 분류(카테고리)별 합계 — 파이차트용. type=EXPENSE(기본)/INCOME */ public List categoryStats(Long memberId, String type, Integer year, Integer month) { String t = "INCOME".equalsIgnoreCase(type) ? "INCOME" : "EXPENSE"; return mapper.sumByCategory(memberId, t, year, month).stream() .map(r -> CategoryStat.builder() .category((String) r.get("category")) .total(((Number) r.get("total")).longValue()) .build()) .toList(); } /** 월별 순자산 추이 (최근 months개월, 기본 12). 순자산 = 개시잔액 + 누적(수입−지출, 계좌 지정분) */ public List netWorthTrend(Long memberId, Integer months) { int n = (months == null || months <= 0 || months > 60) ? 12 : months; YearMonth current = YearMonth.from(java.time.LocalDate.now()); YearMonth start = current.minusMonths(n - 1L); Map flow = new HashMap<>(); for (Map row : mapper.monthlyWalletFlow(memberId)) { Object net = row.get("net"); flow.put((String) row.get("ym"), net == null ? 0L : ((Number) net).longValue()); } // 개시잔액: 개시월이 범위 시작 이전(또는 미상)이면 기준선, 범위 내면 해당 월 버킷, 미래면 무시 List ws = walletMapper.findByMember(memberId); Map openingByMonth = new HashMap<>(); long baseline = 0; for (Wallet w : ws) { long ob = w.getOpeningBalance() == null ? 0L : w.getOpeningBalance(); if (ob == 0) { continue; } YearMonth om = w.getOpeningDate() == null ? null : YearMonth.from(w.getOpeningDate()); if (om == null || om.isBefore(start)) { baseline += ob; } else if (!om.isAfter(current)) { openingByMonth.merge(om.toString(), ob, Long::sum); } } // 범위 시작 이전의 현금흐름은 기준선에 누적 String startKey = start.toString(); for (Map.Entry e : flow.entrySet()) { if (e.getKey().compareTo(startKey) < 0) { baseline += e.getValue(); } } List result = new ArrayList<>(); long running = baseline; for (YearMonth m = start; !m.isAfter(current); m = m.plusMonths(1)) { String key = m.toString(); running += flow.getOrDefault(key, 0L); running += openingByMonth.getOrDefault(key, 0L); result.add(NetWorthPoint.builder().month(key).netWorth(running).build()); } // 투자 손익(실현+평가)은 현재 시점에만 반영 (과거 평가 이력이 없으므로 마지막 점에 가산) // 매매에 따른 예수금 증감 + 주식 평가금액 = 현금기준 대비 순자산 증감분 if (!result.isEmpty()) { long investAdjust = 0; for (InvestService.WalletInvest wi : investService.valuationByWallet(memberId).values()) { investAdjust += wi.cashDelta + wi.stockEval; } if (investAdjust != 0) { NetWorthPoint last = result.get(result.size() - 1); last.setNetWorth(last.getNetWorth() + investAdjust); } } return result; } /** 주별 순자산 추이 (월요일 기준 버킷). 기본 13주(최근 약 3개월). */ public List netWorthTrendWeekly(Long memberId, Integer weeks) { int n = (weeks == null || weeks <= 0 || weeks > 53) ? 13 : weeks; LocalDate today = LocalDate.now(); LocalDate currentMon = today.minusDays(today.getDayOfWeek().getValue() - 1L); // 이번 주 월요일 LocalDate startMon = currentMon.minusWeeks(n - 1L); Map flow = new HashMap<>(); for (Map row : mapper.weeklyWalletFlow(memberId)) { Object net = row.get("net"); flow.put((String) row.get("wk"), net == null ? 0L : ((Number) net).longValue()); } // 개시잔액: 개시주가 시작주 이전(또는 미상)이면 기준선, 범위 내면 해당 주 버킷 List ws = walletMapper.findByMember(memberId); Map openingByWeek = new HashMap<>(); long baseline = 0; for (Wallet w : ws) { long ob = w.getOpeningBalance() == null ? 0L : w.getOpeningBalance(); if (ob == 0) { continue; } LocalDate od = w.getOpeningDate(); LocalDate om = od == null ? null : od.minusDays(od.getDayOfWeek().getValue() - 1L); if (om == null || om.isBefore(startMon)) { baseline += ob; } else if (!om.isAfter(currentMon)) { openingByWeek.merge(om.toString(), ob, Long::sum); } } String startKey = startMon.toString(); for (Map.Entry e : flow.entrySet()) { if (e.getKey().compareTo(startKey) < 0) { baseline += e.getValue(); } } List result = new ArrayList<>(); long running = baseline; for (LocalDate m = startMon; !m.isAfter(currentMon); m = m.plusWeeks(1)) { String key = m.toString(); running += flow.getOrDefault(key, 0L); running += openingByWeek.getOrDefault(key, 0L); result.add(NetWorthPoint.builder().month(key).netWorth(running).build()); } // 투자 손익(실현+평가)은 과거 주별 이력이 없으므로 마지막 점에만 가산 (월간과 동일) if (!result.isEmpty()) { long investAdjust = 0; for (InvestService.WalletInvest wi : investService.valuationByWallet(memberId).values()) { investAdjust += wi.cashDelta + wi.stockEval; } if (investAdjust != 0) { NetWorthPoint last = result.get(result.size() - 1); last.setNetWorth(last.getNetWorth() + investAdjust); } } return result; } /** 기간 버킷(일/주/월/년)별 수입·지출 통계 */ public List stats(Long memberId, String unit, Integer year, Integer month) { String u = switch (unit == null ? "" : unit.toUpperCase()) { case "DAY", "WEEK", "YEAR" -> unit.toUpperCase(); default -> "MONTH"; }; return mapper.statsByPeriod(memberId, u, year, month).stream() .map(row -> PeriodStat.builder() .bucket(String.valueOf(((Number) row.get("bucket")).longValue())) .income(((Number) row.get("income")).longValue()) .expense(((Number) row.get("expense")).longValue()) .build()) .toList(); } public AccountSummary summary(Long memberId, Integer year, Integer month) { long income = 0; long expense = 0; for (Map row : mapper.sumByType(memberId, year, month)) { String type = (String) row.get("type"); long total = ((Number) row.get("total")).longValue(); if ("INCOME".equals(type)) { income = total; } else if ("EXPENSE".equals(type)) { expense = total; } } return AccountSummary.of(income, expense); } @Transactional public AccountEntryResponse create(AccountEntryRequest req, Long memberId) { validateEntryWallets(req, memberId); boolean transfer = "TRANSFER".equals(req.getType()); AccountEntry entry = AccountEntry.builder() .memberId(memberId) .entryDate(req.getEntryDate()) .type(req.getType()) .category(transfer ? null : req.getCategory()) .amount(req.getAmount()) .memo(req.getMemo()) .walletId(req.getWalletId()) .toWalletId(transfer ? req.getToWalletId() : null) .installmentMonths(installmentOf(req)) .build(); mapper.insert(entry); applyTags(entry.getId(), req.getTagIds(), memberId); return toResponse(mapper.findByIdAndMember(entry.getId(), memberId)); } @Transactional public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) { AccountEntry entry = mustFind(id, memberId); validateEntryWallets(req, memberId); boolean transfer = "TRANSFER".equals(req.getType()); entry.setEntryDate(req.getEntryDate()); entry.setType(req.getType()); entry.setCategory(transfer ? null : req.getCategory()); entry.setAmount(req.getAmount()); entry.setMemo(req.getMemo()); entry.setWalletId(req.getWalletId()); entry.setToWalletId(transfer ? req.getToWalletId() : null); entry.setInstallmentMonths(installmentOf(req)); mapper.update(entry); mapper.deleteEntryTagsByEntryId(id); applyTags(id, req.getTagIds(), memberId); return toResponse(mapper.findByIdAndMember(id, memberId)); } @Transactional public void delete(Long id, Long memberId) { mustFind(id, memberId); mapper.deleteEntryTagsByEntryId(id); mapper.delete(id, memberId); } /* ===================== 카드 결제 알림 자동인식 ===================== */ /** 미확인(확인 필요) 내역 개수 */ public int countPending(Long memberId) { return mapper.countPending(memberId); } /** * 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성. * 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1). */ /** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */ public ParsedEntryResponse parseText(NotificationRequest req) { CardNotificationParser.Parsed p = notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now()); return ParsedEntryResponse.builder() .recognized(p.isRecognized()) .type(p.isIncome() ? "INCOME" : "EXPENSE") .amount(p.getAmount()) .merchant(p.getMerchant()) .date(p.getDate()) .build(); } @Transactional public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) { CardNotificationParser.Parsed p = notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now()); if (!p.isRecognized() || p.getAmount() <= 0) { return null; // 결제/이체 알림 아님 → 무시 } // 중복방지 if (p.getNotifKey() != null && mapper.findByMemberAndNotifKey(memberId, p.getNotifKey()) != null) { return null; } // 카드 결제 → 카드(CARD) 매칭 / 은행 입출금·이체 → 은행(BANK) 매칭 String walletType = p.isBankTransfer() ? "BANK" : "CARD"; Long walletId = matchWalletId(p.getIssuer(), memberId, walletType); // 분류 자동학습: 같은 메모(가맹점) 키워드로 과거에 확정한 분류가 있으면 그대로 채워준다. String type = p.isIncome() ? "INCOME" : "EXPENSE"; String merchant = p.getMerchant(); String learnedCategory = (merchant != null && !merchant.isBlank()) ? mapper.findLearnedCategory(memberId, type, merchant) : null; AccountEntry entry = AccountEntry.builder() .memberId(memberId) .entryDate(p.getDate() != null ? p.getDate() : java.time.LocalDate.now()) .type(type) .category(learnedCategory) .amount(p.getAmount()) .memo(merchant) .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)); } /** 카드사/은행명으로 등록된 해당 type(CARD/BANK) 지갑 id 찾기 (issuer/name 유연 매칭). * 미매칭이라도 그 type 지갑이 정확히 1개면 그 지갑으로 기본 매칭. */ private Long matchWalletId(String issuer, Long memberId, String walletType) { List wallets = walletMapper.findByMember(memberId).stream() .filter(w -> walletType.equals(w.getType())) .toList(); if (issuer != null) { Long matched = wallets.stream() .filter(w -> overlaps(w.getIssuer(), issuer) || overlaps(w.getName(), issuer)) .map(Wallet::getId) .findFirst() .orElse(null); if (matched != null) return matched; } return wallets.size() == 1 ? wallets.get(0).getId() : null; } /** 표기 차이를 흡수한 카드사 매칭 (KB국민 ↔ 국민 ↔ 국민카드 ↔ KB카드 등) */ private boolean overlaps(String walletField, String issuer) { return CardIssuerMatcher.matches(walletField, issuer); } /* ===================== 상환/납부 ===================== */ /** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자·연회비는 지출로 분리 생성 */ @Transactional public void repay(RepaymentRequest req, Long memberId) { long principal = req.getPrincipal() != null ? req.getPrincipal() : 0L; long interest = req.getInterest() != null ? req.getInterest() : 0L; long annualFee = req.getAnnualFee() != null ? req.getAnnualFee() : 0L; if (principal <= 0 && interest <= 0 && annualFee <= 0) { throw new ApiException(HttpStatus.BAD_REQUEST, "원금·이자·연회비 중 하나는 입력하세요."); } requireOwnedWallet(req.getFromWalletId(), memberId); Wallet target = walletMapper.findByIdAndMember(req.getTargetWalletId(), memberId); if (target == null) { throw new ApiException(HttpStatus.BAD_REQUEST, "대상 계좌가 올바르지 않습니다."); } if (!"LOAN".equals(target.getType()) && !"CARD".equals(target.getType())) { throw new ApiException(HttpStatus.BAD_REQUEST, "상환 대상은 대출/카드만 가능합니다."); } if (annualFee > 0 && !"CARD".equals(target.getType())) { throw new ApiException(HttpStatus.BAD_REQUEST, "연회비는 카드 상환에만 입력할 수 있습니다."); } if (req.getFromWalletId().equals(req.getTargetWalletId())) { throw new ApiException(HttpStatus.BAD_REQUEST, "출금/대상 계좌가 같을 수 없습니다."); } // 원금: 이체 (출금 → 대상, 부채 감소) if (principal > 0) { mapper.insert(AccountEntry.builder() .memberId(memberId) .entryDate(req.getEntryDate()) .type("TRANSFER") .amount(principal) .walletId(req.getFromWalletId()) .toWalletId(req.getTargetWalletId()) .memo(req.getMemo() != null ? req.getMemo() : "원금상환") .build()); } // 이자: 지출 (출금 계좌에서, 분류=이자) if (interest > 0) { mapper.insert(AccountEntry.builder() .memberId(memberId) .entryDate(req.getEntryDate()) .type("EXPENSE") .category("이자") .amount(interest) .walletId(req.getFromWalletId()) .memo(req.getMemo() != null ? req.getMemo() : "이자") .build()); } // 연회비: 지출 (카드, 출금 계좌에서, 분류=연회비) if (annualFee > 0) { mapper.insert(AccountEntry.builder() .memberId(memberId) .entryDate(req.getEntryDate()) .type("EXPENSE") .category("연회비") .amount(annualFee) .walletId(req.getFromWalletId()) .memo(req.getMemo() != null ? req.getMemo() : "연회비") .build()); } } /* ===================== 계좌/카드 (사용자별) ===================== */ public List listWallets(Long memberId) { Map balances = balanceMap(memberId); Map inv = investService.valuationByWallet(memberId); return walletMapper.findByMember(memberId).stream() .map(w -> { long cash = balances.getOrDefault(w.getId(), 0L); WalletResponse r = WalletResponse.from(w, cash); if ("INVEST".equals(w.getType())) { InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest()); long deposit = cash + wi.cashDelta; // 예수금 if (w.getCurrentValue() != null) { // 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 총평가로 사용 long total = w.getCurrentValue(); r.setBalance(total); r.setInvestedAmount(cash); // 누적 적립원금(순입금) r.setDeposit(deposit); r.setStockValue(total - deposit); r.setValuationGain(total - cash); r.setManualValuation(true); } else { long total = deposit + wi.stockEval; // 총평가 r.setBalance(total); r.setInvestedAmount(cash); // 순입금(투입원금) r.setDeposit(deposit); r.setStockValue(wi.stockEval); r.setValuationGain(total - cash); // 실현+평가 손익 r.setManualValuation(false); } } return r; }) .toList(); } /** 순자산 = 총자산(BANK/CASH/INVEST) − 총부채(CARD/LOAN) */ public NetWorthResponse netWorth(Long memberId) { Map balances = balanceMap(memberId); Map inv = investService.valuationByWallet(memberId); long assets = 0; long liabilities = 0; for (Wallet w : walletMapper.findByMember(memberId)) { long bal = balances.getOrDefault(w.getId(), 0L); if ("INVEST".equals(w.getType())) { if (w.getCurrentValue() != null) { // 평가액 직접입력형(퇴직연금·연금 등): 입력한 평가액을 자산으로 assets += w.getCurrentValue(); } else { // 투자: 예수금(현금+매매증감) + 주식 평가금액 InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest()); assets += bal + wi.cashDelta + wi.stockEval; } } else if ("BANK".equals(w.getType()) || "CASH".equals(w.getType())) { assets += bal; } else { liabilities += -bal; // 부채는 음수 잔액 → 양수 부채로 표시 } } return NetWorthResponse.builder() .totalAssets(assets) .totalLiabilities(liabilities) .netWorth(assets - liabilities) .build(); } @Transactional public WalletResponse createWallet(WalletRequest req, Long memberId) { Wallet wallet = toWallet(req); wallet.setMemberId(memberId); Integer max = walletMapper.maxSortOrder(memberId, req.getType()); wallet.setSortOrder(max == null ? 0 : max + 1); // 같은 타입의 맨 뒤 walletMapper.insert(wallet); return WalletResponse.from(wallet, wallet.getOpeningBalance()); // 신규 → 잔액=초기잔액 } /** 드래그앤드랍 순서 변경: 전달된 id 순서대로 sort_order 재설정 (본인·동일 타입만) */ @Transactional public List reorderWallets(Long memberId, String type, List ids) { int i = 0; for (Long id : ids) { Wallet w = walletMapper.findByIdAndMember(id, memberId); if (w != null && w.getType().equals(type)) { walletMapper.updateSortOrder(id, memberId, i++); } } return listWallets(memberId); } @Transactional public WalletResponse updateWallet(Long id, WalletRequest req, Long memberId) { Wallet wallet = mustFindWallet(id, memberId); wallet.setType(req.getType()); wallet.setName(req.getName().trim()); wallet.setIssuer(blankToNull(req.getIssuer())); wallet.setAccountNumber("BANK".equals(req.getType()) ? blankToNull(req.getAccountNumber()) : null); wallet.setCardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null); wallet.setOpeningBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L); wallet.setOpeningDate(req.getOpeningDate()); wallet.setCurrentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null); walletMapper.update(wallet); return WalletResponse.from(wallet, balanceMap(memberId).getOrDefault(id, wallet.getOpeningBalance())); } private Map balanceMap(Long memberId) { Map map = new HashMap<>(); for (Map row : walletMapper.findBalances(memberId)) { Long id = ((Number) row.get("id")).longValue(); Object b = row.get("balance"); map.put(id, b == null ? 0L : ((Number) b).longValue()); } return map; } @Transactional public void deleteWallet(Long id, Long memberId) { mustFindWallet(id, memberId); walletMapper.delete(id, memberId); } /** 특정 계좌와 연관된 내역 목록 */ public List listEntriesByWallet(Long walletId, Long memberId) { mustFindWallet(walletId, memberId); return mapper.findByWalletAndMember(memberId, walletId).stream() .map(this::toResponse) .toList(); } private Wallet toWallet(WalletRequest req) { return Wallet.builder() .type(req.getType()) .name(req.getName().trim()) .issuer(blankToNull(req.getIssuer())) .accountNumber("BANK".equals(req.getType()) ? blankToNull(req.getAccountNumber()) : null) .cardType("CARD".equals(req.getType()) ? blankToNull(req.getCardType()) : null) .openingBalance(req.getOpeningBalance() != null ? req.getOpeningBalance() : 0L) .openingDate(req.getOpeningDate()) .currentValue("INVEST".equals(req.getType()) ? req.getCurrentValue() : null) .build(); } private Wallet mustFindWallet(Long id, Long memberId) { Wallet wallet = walletMapper.findByIdAndMember(id, memberId); if (wallet == null) { throw new ApiException(HttpStatus.NOT_FOUND, "계좌를 찾을 수 없습니다."); } return wallet; } /** 내역의 계좌 검증. 이체는 출금/입금 계좌 필수·본인·서로 다름, 그 외는 선택 계좌만 검증 */ private void validateEntryWallets(AccountEntryRequest req, Long memberId) { if ("TRANSFER".equals(req.getType())) { if (req.getWalletId() == null || req.getToWalletId() == null) { throw new ApiException(HttpStatus.BAD_REQUEST, "이체는 출금/입금 계좌를 모두 선택해야 합니다."); } if (req.getWalletId().equals(req.getToWalletId())) { throw new ApiException(HttpStatus.BAD_REQUEST, "출금/입금 계좌가 같을 수 없습니다."); } requireOwnedWallet(req.getWalletId(), memberId); requireOwnedWallet(req.getToWalletId(), memberId); } else if (req.getWalletId() != null) { requireOwnedWallet(req.getWalletId(), memberId); } } private void requireOwnedWallet(Long walletId, Long memberId) { if (walletMapper.findByIdAndMember(walletId, memberId) == null) { throw new ApiException(HttpStatus.BAD_REQUEST, "선택한 계좌가 올바르지 않습니다."); } } private String blankToNull(String s) { return (s == null || s.isBlank()) ? null : s.trim(); } /* ===================== 태그 (사용자별) ===================== */ public List listTags(Long memberId) { return tagMapper.findByMember(memberId).stream().map(AccountTagResponse::from).toList(); } @Transactional public AccountTagResponse createTag(AccountTagRequest req, Long memberId) { String name = req.getName().trim(); if (tagMapper.countByName(memberId, name, null) > 0) { throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다."); } Integer max = tagMapper.maxSortOrder(memberId); AccountTag tag = AccountTag.builder() .memberId(memberId) .name(name) .sortOrder(max == null ? 0 : max + 1) .build(); tagMapper.insert(tag); return AccountTagResponse.from(tag); } /** 태그 순서 변경 — ids 순서대로 sort_order 재설정 */ @Transactional public List reorderTags(Long memberId, List ids) { int i = 0; for (Long id : ids) { if (tagMapper.findByIdAndMember(id, memberId) != null) { tagMapper.updateSortOrder(id, memberId, i++); } } return listTags(memberId); } @Transactional public AccountTagResponse updateTag(Long id, AccountTagRequest req, Long memberId) { AccountTag tag = mustFindTag(id, memberId); String name = req.getName().trim(); if (tagMapper.countByName(memberId, name, id) > 0) { throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 태그입니다."); } tag.setName(name); tagMapper.update(tag); return AccountTagResponse.from(tag); } @Transactional public void deleteTag(Long id, Long memberId) { mustFindTag(id, memberId); tagMapper.deleteEntryLinksByTagId(id); tagMapper.delete(id, memberId); } /* ===================== 내부 ===================== */ private AccountEntryResponse toResponse(AccountEntry entry) { return AccountEntryResponse.from(entry, mapper.findTagNamesByEntryId(entry.getId())); } /** 할부 개월수 정규화: 지출(EXPENSE)이고 2개월 이상일 때만 저장, 그 외(일시불·수입·이체)는 null */ private Integer installmentOf(AccountEntryRequest req) { Integer m = req.getInstallmentMonths(); if (!"EXPENSE".equals(req.getType()) || m == null || m < 2) { return null; } return m; } /** 선택된 태그 ID 중 해당 사용자 소유로 존재하는 것만 연결 */ private void applyTags(Long entryId, List tagIds, Long memberId) { if (tagIds == null || tagIds.isEmpty()) { return; } List distinct = tagIds.stream().filter(Objects::nonNull).distinct().toList(); if (distinct.isEmpty()) { return; } for (Long tagId : tagMapper.findExistingIds(memberId, distinct)) { mapper.insertEntryTag(entryId, tagId); } } private AccountEntry mustFind(Long id, Long memberId) { AccountEntry entry = mapper.findByIdAndMember(id, memberId); if (entry == null) { throw new ApiException(HttpStatus.NOT_FOUND, "내역을 찾을 수 없습니다."); } return entry; } private AccountTag mustFindTag(Long id, Long memberId) { AccountTag tag = tagMapper.findByIdAndMember(id, memberId); if (tag == null) { throw new ApiException(HttpStatus.NOT_FOUND, "태그를 찾을 수 없습니다."); } return tag; } }