Files
sb-backend/src/main/java/com/sb/web/account/service/AccountService.java
T
ByungCheol a1697a7a22
CI / build (push) Failing after 15m21s
feat: 자주 쓰는 내역(빠른등록) + 문자/푸시 텍스트 파싱 API
- quick_entry 테이블 + 도메인/매퍼/서비스/컨트롤러 (/account/quick-entries CRUD)
- /account/entries/parse: 문자·푸시 텍스트를 파서로 분석해 결과만 반환(저장X)
  → iPhone 등 알림 자동수집 불가 시 복사→붙여넣기 자동채움용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:47:00 +09:00

705 lines
32 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AccountEntryResponse> 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<CategoryStat> 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<NetWorthPoint> 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<String, Long> flow = new HashMap<>();
for (Map<String, Object> row : mapper.monthlyWalletFlow(memberId)) {
Object net = row.get("net");
flow.put((String) row.get("ym"), net == null ? 0L : ((Number) net).longValue());
}
// 개시잔액: 개시월이 범위 시작 이전(또는 미상)이면 기준선, 범위 내면 해당 월 버킷, 미래면 무시
List<Wallet> ws = walletMapper.findByMember(memberId);
Map<String, Long> 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<String, Long> e : flow.entrySet()) {
if (e.getKey().compareTo(startKey) < 0) {
baseline += e.getValue();
}
}
List<NetWorthPoint> 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<NetWorthPoint> 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<String, Long> flow = new HashMap<>();
for (Map<String, Object> row : mapper.weeklyWalletFlow(memberId)) {
Object net = row.get("net");
flow.put((String) row.get("wk"), net == null ? 0L : ((Number) net).longValue());
}
// 개시잔액: 개시주가 시작주 이전(또는 미상)이면 기준선, 범위 내면 해당 주 버킷
List<Wallet> ws = walletMapper.findByMember(memberId);
Map<String, Long> 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<String, Long> e : flow.entrySet()) {
if (e.getKey().compareTo(startKey) < 0) {
baseline += e.getValue();
}
}
List<NetWorthPoint> 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<PeriodStat> 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<String, Object> 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<Wallet> 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<WalletResponse> listWallets(Long memberId) {
Map<Long, Long> balances = balanceMap(memberId);
Map<Long, InvestService.WalletInvest> 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<Long, Long> balances = balanceMap(memberId);
Map<Long, InvestService.WalletInvest> 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<WalletResponse> reorderWallets(Long memberId, String type, List<Long> 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<Long, Long> balanceMap(Long memberId) {
Map<Long, Long> map = new HashMap<>();
for (Map<String, Object> 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<AccountEntryResponse> 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<AccountTagResponse> 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<AccountTagResponse> reorderTags(Long memberId, List<Long> 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<Long> tagIds, Long memberId) {
if (tagIds == null || tagIds.isEmpty()) {
return;
}
List<Long> 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;
}
}