Files
sb-backend/src/main/java/com/sb/web/account/service/AccountService.java
T
ByungCheolandClaude Sonnet 4.6 6db6cb055e feat(shop): 포인트 상점 구현 — 계좌 슬롯(500P)·AI 통계 크레딧(1000P→10회)
- member 테이블: extra_wallet_slots, ai_stat_credits 컬럼 추가(ALTER IF NOT EXISTS)
- MemberMapper: 상점 관련 5개 메서드 추가(getExtraWalletSlots/getAiStatCredits/incrementExtraWalletSlots/addAiStatCredits/decrementAiStatCredit)
- ShopService: buyWalletSlot(500P, max 3개), buyAiStatPack(1000P→10회) 구현
- ShopController: GET /api/account/shop/status, POST /api/account/shop/buy
- AccountService.freeWalletLimit: bonus=0 TODO → memberMapper.getExtraWalletSlots 실제 조회
- AccountService.aiComment: FREE 회원 크레딧 차감 로직 추가, 소진 시 402 반환
- AccountController.aiComment: current 파라미터 전달
- WebConfig: /api/account/ai-comment 를 premiumInterceptor 에서 제거(서비스 레이어에서 처리)
- BackupController: @DeleteMapping import 누락 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 00:32:30 +09:00

817 lines
38 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.auth.dto.SessionUser;
import com.sb.web.auth.mapper.MemberMapper;
import com.sb.web.common.exception.ApiException;
import com.sb.web.point.PointService;
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;
private final AiEntryParser aiEntryParser;
private final PointService pointService;
private final MemberMapper memberMapper;
/* ===================== 항목 ===================== */
public List<AccountEntryResponse> list(Long memberId, Integer year, Integer month,
String type, String category, Long walletId, String keyword, Long tagId) {
List<AccountEntry> entries = mapper.findByMember(memberId, year, month,
blankToNull(type), blankToNull(category), walletId, blankToNull(keyword), tagId);
if (entries.isEmpty()) return List.of();
// 태그 N+1 방지: 한 번에 모든 entry 의 태그를 조회 후 Map 으로 그룹화
List<Long> ids = entries.stream().map(AccountEntry::getId).toList();
Map<Long, List<String>> tagsByEntry = new HashMap<>();
for (Map<String, Object> row : mapper.findTagsByEntryIds(ids)) {
Object rawId = row.get("entry_id");
String name = (String) row.get("tag_name");
if (rawId == null || name == null) continue;
Long eid = ((Number) rawId).longValue();
tagsByEntry.computeIfAbsent(eid, k -> new ArrayList<>()).add(name);
}
return entries.stream()
.map(e -> AccountEntryResponse.from(e, tagsByEntry.getOrDefault(e.getId(), List.of())))
.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))
.currency(req.getCurrency())
.originalAmount(req.getOriginalAmount())
.exchangeRate(req.getExchangeRate())
.build();
mapper.insert(entry);
applyTags(entry.getId(), req.getTagIds(), memberId);
pointService.awardAccountEntry(memberId);
awardIfMonthComplete(entry.getEntryDate(), memberId);
return toResponse(mapper.findByIdAndMember(entry.getId(), memberId));
}
/**
* 내역의 월이 '현재 진행 중인 달'이고 그 달 모든 날에 기록이 있으면 개근 보너스(랜덤 1~100P) 지급.
* 지난달을 소급 입력해 채우는 파밍을 막기 위해 현재 월만 인정한다(전 월 제외). 판정 실패는 저장에 영향 없음.
*/
private void awardIfMonthComplete(java.time.LocalDate entryDate, Long memberId) {
if (entryDate == null) return;
try {
java.time.LocalDate today = java.time.LocalDate.now();
if (entryDate.getYear() != today.getYear() || entryDate.getMonthValue() != today.getMonthValue()) return;
int recorded = mapper.countDistinctEntryDays(memberId, entryDate.getYear(), entryDate.getMonthValue());
if (recorded >= entryDate.lengthOfMonth()) {
pointService.awardMonthComplete(memberId, entryDate.getYear(), entryDate.getMonthValue());
}
} catch (Exception ignore) {
// 개근 판정 오류는 무시(내역 저장은 이미 완료)
}
}
@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));
entry.setCurrency(req.getCurrency());
entry.setOriginalAmount(req.getOriginalAmount());
entry.setExchangeRate(req.getExchangeRate());
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).
*/
/**
* 문자/푸시·자연어 한 줄 텍스트를 파싱해 폼 자동채움용 결과 반환 (저장 안 함).
* AI 파서(Claude Haiku)가 설정돼 있으면 우선 시도("어제 스타벅스 5천원" 같은 자유 입력 처리),
* 미설정/실패/미인식이면 기존 규칙기반 카드알림 파서로 폴백.
*/
public ParsedEntryResponse parseText(NotificationRequest req) {
java.time.LocalDate today = java.time.LocalDate.now();
java.util.Optional<ParsedEntryResponse> ai =
aiEntryParser.parse(req.getText(), today, req.getCategoryHints(), req.getWalletHints());
if (ai.isPresent() && ai.get().isRecognized()) {
return ai.get();
}
CardNotificationParser.Parsed p = notificationParser.parse(req.getTitle(), req.getText(), today);
return ParsedEntryResponse.builder()
.recognized(p.isRecognized())
.type(p.isIncome() ? "INCOME" : "EXPENSE")
.amount(p.getAmount())
.merchant(p.getMerchant())
.date(p.getDate())
.build();
}
/** 통계 화면 집계 → AI 재무 코멘트.
* - PREMIUM: 무제한.
* - FREE + ai_stat_credits > 0: 크레딧 1 차감 후 호출.
* - FREE + credits == 0: 402 에러(프론트가 상점으로 유도).
*/
@Transactional
public com.sb.web.account.dto.AiCommentResponse aiComment(
com.sb.web.account.dto.AiCommentRequest req, SessionUser current) {
if ("FREE".equals(current.getPlan())) {
int credits = memberMapper.getAiStatCredits(current.getId());
if (credits <= 0) {
throw new ApiException(HttpStatus.PAYMENT_REQUIRED, "AI_STAT_CREDIT_EMPTY");
}
memberMapper.decrementAiStatCredit(current.getId());
}
try {
String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(req);
return aiEntryParser.financeComment(json);
} catch (ApiException e) {
throw e;
} catch (Exception e) {
return com.sb.web.account.dto.AiCommentResponse.builder()
.bullets(java.util.List.of()).tips(java.util.List.of()).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();
}
// 무료 회원 계좌 등록 한도: 종류(은행·현금·카드·대출·증권·마이너스)별 기본 2개.
// 포인트 상점(추후)에서 영구 보너스 슬롯을 구매하면 종류별 최대 5개까지 확장 가능.
public static final int FREE_WALLET_BASE_LIMIT = 2;
public static final int FREE_WALLET_MAX_LIMIT = 5;
/** 무료 회원의 해당 타입 등록 한도 = 기본 2 + 포인트 상점 구매 슬롯, 최대 5. */
private int freeWalletLimit(Long memberId, String type) {
int bonus = memberMapper.getExtraWalletSlots(memberId);
return Math.min(FREE_WALLET_BASE_LIMIT + bonus, FREE_WALLET_MAX_LIMIT);
}
@Transactional
public WalletResponse createWallet(WalletRequest req, Long memberId, boolean premium) {
// 유료(또는 관리자)는 무제한. 무료는 종류별 한도 검증.
if (!premium) {
int limit = freeWalletLimit(memberId, req.getType());
if (walletMapper.countByType(memberId, req.getType()) >= limit) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"무료 회원은 종류별 계좌를 " + limit + "개까지 등록할 수 있어요. 유료로 업그레이드하면 무제한이에요.");
}
}
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()) || "MINUS".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);
boolean isLoan = "LOAN".equals(req.getType());
wallet.setLoanAmount(isLoan ? req.getLoanAmount() : null);
wallet.setLoanRate(isLoan ? req.getLoanRate() : null);
wallet.setLoanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null);
wallet.setLoanMonths(isLoan ? req.getLoanMonths() : null);
wallet.setLoanStart(isLoan ? req.getLoanStart() : null);
wallet.setLoanPayment(isLoan ? req.getLoanPayment() : 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) {
boolean isLoan = "LOAN".equals(req.getType());
return Wallet.builder()
.type(req.getType())
.name(req.getName().trim())
.issuer(blankToNull(req.getIssuer()))
.accountNumber(("BANK".equals(req.getType()) || "MINUS".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)
.loanAmount(isLoan ? req.getLoanAmount() : null)
.loanRate(isLoan ? req.getLoanRate() : null)
.loanMethod(isLoan ? blankToNull(req.getLoanMethod()) : null)
.loanMonths(isLoan ? req.getLoanMonths() : null)
.loanStart(isLoan ? req.getLoanStart() : null)
.loanPayment(isLoan ? req.getLoanPayment() : 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;
}
}