a1697a7a22
CI / build (push) Failing after 15m21s
- quick_entry 테이블 + 도메인/매퍼/서비스/컨트롤러 (/account/quick-entries CRUD) - /account/entries/parse: 문자·푸시 텍스트를 파서로 분석해 결과만 반환(저장X) → iPhone 등 알림 자동수집 불가 시 복사→붙여넣기 자동채움용 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
705 lines
32 KiB
Java
705 lines
32 KiB
Java
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;
|
||
}
|
||
}
|