Files
sb-backend/src/main/java/com/sb/web/account/service/AccountService.java
T
ByungCheolandClaude Opus 4.8 f5e9b78a14 feat: 예상 수입 월별 저장 + 계좌 순서변경 API
- 예상 수입: budget_income(연·월별) 로 변경, GET/PUT 에 year·month
- 계좌: wallet.sort_order + reorder 엔드포인트, 생성 시 맨 뒤 배치

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:54:23 +09:00

481 lines
21 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.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.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;
/* ===================== 항목 ===================== */
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;
}
/** 기간 버킷(일/주/월/년)별 수입·지출 통계 */
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)
.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);
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);
}
/* ===================== 상환/납부 ===================== */
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 2건 생성 */
@Transactional
public void repay(RepaymentRequest req, Long memberId) {
long principal = req.getPrincipal() != null ? req.getPrincipal() : 0L;
long interest = req.getInterest() != null ? req.getInterest() : 0L;
if (principal <= 0 && interest <= 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 (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());
}
}
/* ===================== 계좌/카드 (사용자별) ===================== */
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; // 예수금
long total = deposit + wi.stockEval; // 총평가
r.setBalance(total);
r.setInvestedAmount(cash); // 순입금(투입원금)
r.setDeposit(deposit);
r.setStockValue(wi.stockEval);
r.setValuationGain(total - cash); // 실현+평가 손익
}
return r;
})
.toList();
}
/** 순자산 = 총자산(BANK) − 총부채(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())) {
// 투자: 예수금(현금+매매증감) + 주식 평가금액
InvestService.WalletInvest wi = inv.getOrDefault(w.getId(), new InvestService.WalletInvest());
assets += bal + wi.cashDelta + wi.stockEval;
} else if ("BANK".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, "이미 존재하는 태그입니다.");
}
AccountTag tag = AccountTag.builder().memberId(memberId).name(name).build();
tagMapper.insert(tag);
return AccountTagResponse.from(tag);
}
@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()));
}
/** 선택된 태그 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;
}
}