- 예상 수입: budget_income(연·월별) 로 변경, GET/PUT 에 year·month - 계좌: wallet.sort_order + reorder 엔드포인트, 생성 시 맨 뒤 배치 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
481 lines
21 KiB
Java
481 lines
21 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.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;
|
||
}
|
||
}
|