feat: 가계부·게시판 백엔드 API 구현

- com.sb.web 패키지로 재구성: account / auth / board / user / admin / common
- 가계부(account): 내역(필터), 계좌·순자산, 예산, 분류, 정기 거래, 투자 포트폴리오
  (종목·매매 이력, 이동평균 평단·실현/평가손익)
- MyBatis + MariaDB + Redis 세션, BCrypt
- 스키마 자동 초기화(db/*.sql, 멱등), 사용자별 데이터 격리(member_id)
- 문서: docs/BACKEND.md, .env.example

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-05-31 15:43:09 +09:00
co-authored by Claude Opus 4.8
parent 9243a41385
commit 2a2f81dc32
121 changed files with 5899 additions and 31 deletions
@@ -0,0 +1,465 @@
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);
walletMapper.insert(wallet);
return WalletResponse.from(wallet, wallet.getOpeningBalance()); // 신규 → 잔액=초기잔액
}
@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;
}
}
@@ -0,0 +1,230 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.Budget;
import com.sb.web.account.dto.BudgetPeriodStat;
import com.sb.web.account.dto.BudgetRequest;
import com.sb.web.account.dto.BudgetResponse;
import com.sb.web.account.dto.BudgetStatus;
import com.sb.web.account.mapper.AccountEntryMapper;
import com.sb.web.account.mapper.BudgetMapper;
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.Year;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
/**
* 예산 서비스. 사용자별 카테고리 예산 CRUD + 월 예산(실제 일수 반영) 대비 지출 현황.
*/
@Service
@RequiredArgsConstructor
public class BudgetService {
private final BudgetMapper budgetMapper;
private final AccountEntryMapper entryMapper;
public List<BudgetResponse> list(Long memberId) {
return budgetMapper.findByMember(memberId).stream().map(BudgetResponse::from).toList();
}
@Transactional
public BudgetResponse create(BudgetRequest req, Long memberId) {
String category = req.getCategory().trim();
if (budgetMapper.countByCategory(memberId, category, null) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
}
Budget budget = toBudget(req, category);
budget.setMemberId(memberId);
budgetMapper.insert(budget);
return BudgetResponse.from(budget);
}
@Transactional
public BudgetResponse update(Long id, BudgetRequest req, Long memberId) {
mustFind(id, memberId);
String category = req.getCategory().trim();
if (budgetMapper.countByCategory(memberId, category, id) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 예산이 설정된 카테고리입니다.");
}
Budget budget = toBudget(req, category);
budget.setId(id);
budget.setMemberId(memberId);
budgetMapper.update(budget);
return BudgetResponse.from(budget);
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
budgetMapper.delete(id, memberId);
}
/** 해당 월의 예산 대비 지출 현황 (고정 예산은 그 달 실제 일수 반영) */
public List<BudgetStatus> status(Long memberId, int year, int month) {
int daysInMonth = YearMonth.of(year, month).lengthOfMonth();
Map<String, Long> spentByCategory = new HashMap<>();
for (Map<String, Object> row : entryMapper.sumExpenseByCategory(memberId, year, month)) {
Object cat = row.get("category");
long total = ((Number) row.get("total")).longValue();
spentByCategory.put(cat == null ? "" : cat.toString(), total);
}
List<BudgetStatus> result = new ArrayList<>();
for (Budget b : budgetMapper.findByMember(memberId)) {
long monthlyBudget = monthlyBudget(b, daysInMonth);
long spent = spentByCategory.getOrDefault(b.getCategory(), 0L);
result.add(BudgetStatus.builder()
.id(b.getId())
.category(b.getCategory())
.fixed(Boolean.TRUE.equals(b.getFixed()))
.monthlyBudget(monthlyBudget)
.spent(spent)
.remaining(monthlyBudget - spent)
.build());
}
return result;
}
/** 기간 버킷(일/주/월/년)별 예산 대비 지출 (대시보드 막대그래프용) */
public List<BudgetPeriodStat> periodStats(Long memberId, String unitRaw, Integer year, Integer month) {
String unit = switch (unitRaw == null ? "" : unitRaw.toUpperCase()) {
case "DAY", "WEEK", "YEAR" -> unitRaw.toUpperCase();
default -> "MONTH";
};
int y = year != null ? year : Year.now().getValue();
int m = month != null ? month : 1;
List<Budget> budgets = budgetMapper.findByMember(memberId);
Map<String, Long> expense = new HashMap<>();
for (Map<String, Object> row : entryMapper.statsByPeriod(memberId, unit, year, month)) {
expense.put(String.valueOf(((Number) row.get("bucket")).longValue()),
((Number) row.get("expense")).longValue());
}
List<BudgetPeriodStat> result = new ArrayList<>();
switch (unit) {
case "DAY" -> {
int days = YearMonth.of(y, m).lengthOfMonth();
long dayBudget = sum(budgets, b -> dayBudget(b, y, m));
for (int d = 1; d <= days; d++) result.add(stat(String.valueOf(d), dayBudget, expense));
}
case "WEEK" -> {
int days = YearMonth.of(y, m).lengthOfMonth();
int weeks = (int) Math.ceil(days / 7.0);
long weekBudget = sum(budgets, b -> weekBudget(b, y, m));
for (int w = 1; w <= weeks; w++) result.add(stat(String.valueOf(w), weekBudget, expense));
}
case "YEAR" -> {
TreeSet<Integer> years = new TreeSet<>();
for (String k : expense.keySet()) years.add(Integer.parseInt(k));
if (years.isEmpty()) years.add(y);
for (int yr : years) {
long yb = sum(budgets, b -> yearBudget(b, yr));
result.add(stat(String.valueOf(yr), yb, expense));
}
}
default -> {
for (int mm = 1; mm <= 12; mm++) {
final int month0 = mm;
long mb = sum(budgets, b -> monthlyBudget(b, YearMonth.of(y, month0).lengthOfMonth()));
result.add(stat(String.valueOf(mm), mb, expense));
}
}
}
return result;
}
private interface BudgetFn {
long apply(Budget b);
}
private long sum(List<Budget> budgets, BudgetFn fn) {
long total = 0;
for (Budget b : budgets) total += fn.apply(b);
return total;
}
private BudgetPeriodStat stat(String bucket, long budget, Map<String, Long> expense) {
return BudgetPeriodStat.builder().bucket(bucket).budget(budget).expense(expense.getOrDefault(bucket, 0L)).build();
}
private double dailyRate(Budget b) {
long base = b.getBaseAmount() != null ? b.getBaseAmount() : 0L;
return (double) base / baseDays(b.getBaseUnit());
}
private long dayBudget(Budget b, int year, int month) {
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b));
if (b.getDaily() != null) return b.getDaily();
if (b.getMonthly() != null) return Math.round((double) b.getMonthly() / YearMonth.of(year, month).lengthOfMonth());
if (b.getWeekly() != null) return Math.round(b.getWeekly() / 7.0);
return 0;
}
private long weekBudget(Budget b, int year, int month) {
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b) * 7);
if (b.getWeekly() != null) return b.getWeekly();
if (b.getDaily() != null) return b.getDaily() * 7;
if (b.getMonthly() != null) return Math.round((double) b.getMonthly() / YearMonth.of(year, month).lengthOfMonth() * 7);
return 0;
}
private long yearBudget(Budget b, int year) {
if (Boolean.TRUE.equals(b.getFixed())) return Math.round(dailyRate(b) * Year.of(year).length());
if (b.getYearly() != null) return b.getYearly();
if (b.getMonthly() != null) return b.getMonthly() * 12;
return 0;
}
/** 이 달 예산: 고정이면 일예산×해당월 일수, 비고정이면 monthly 값 */
private long monthlyBudget(Budget b, int daysInMonth) {
if (Boolean.TRUE.equals(b.getFixed())) {
long baseAmount = b.getBaseAmount() != null ? b.getBaseAmount() : 0L;
double dailyRate = (double) baseAmount / baseDays(b.getBaseUnit());
return Math.round(dailyRate * daysInMonth);
}
return b.getMonthly() != null ? b.getMonthly() : 0L;
}
private int baseDays(String unit) {
if ("WEEK".equals(unit)) return 7;
if ("MONTH".equals(unit)) return 30;
return 1; // DAY
}
private Budget toBudget(BudgetRequest req, String category) {
if (req.isFixed()) {
return Budget.builder()
.category(category)
.fixed(true)
.baseUnit(req.getBaseUnit())
.baseAmount(req.getBaseAmount())
.build();
}
return Budget.builder()
.category(category)
.fixed(false)
.daily(req.getDaily())
.weekly(req.getWeekly())
.monthly(req.getMonthly())
.yearly(req.getYearly())
.build();
}
private Budget mustFind(Long id, Long memberId) {
Budget b = budgetMapper.findByIdAndMember(id, memberId);
if (b == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "예산을 찾을 수 없습니다.");
}
return b;
}
}
@@ -0,0 +1,89 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.AccountCategory;
import com.sb.web.account.dto.CategoryRequest;
import com.sb.web.account.dto.CategoryResponse;
import com.sb.web.account.mapper.AccountEntryMapper;
import com.sb.web.account.mapper.BudgetMapper;
import com.sb.web.account.mapper.CategoryMapper;
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.util.List;
/**
* 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기존 분류 불러오기.
*/
@Service
@RequiredArgsConstructor
public class CategoryService {
private final CategoryMapper categoryMapper;
private final AccountEntryMapper entryMapper;
private final BudgetMapper budgetMapper;
public List<CategoryResponse> list(Long memberId) {
return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList();
}
@Transactional
public CategoryResponse create(CategoryRequest req, Long memberId) {
String name = req.getName().trim();
if (categoryMapper.countByName(memberId, req.getType(), name, null) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
}
AccountCategory c = AccountCategory.builder()
.memberId(memberId).type(req.getType()).name(name).sortOrder(0).build();
categoryMapper.insert(c);
return CategoryResponse.from(c);
}
/** 이름만 변경 가능. 변경 시 기존 내역·예산의 분류명도 함께 갱신 */
@Transactional
public CategoryResponse update(Long id, CategoryRequest req, Long memberId) {
AccountCategory c = mustFind(id, memberId);
String newName = req.getName().trim();
if (categoryMapper.countByName(memberId, c.getType(), newName, id) > 0) {
throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다.");
}
String oldName = c.getName();
c.setName(newName);
categoryMapper.update(c);
if (!oldName.equals(newName)) {
entryMapper.renameCategory(memberId, c.getType(), oldName, newName);
if ("EXPENSE".equals(c.getType())) {
budgetMapper.renameCategory(memberId, oldName, newName);
}
}
return CategoryResponse.from(c);
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
categoryMapper.delete(id, memberId);
}
/** 기존 내역에 쓰인 분류명을 분류 목록으로 가져오기 (중복 무시) */
@Transactional
public List<CategoryResponse> importFromEntries(Long memberId) {
for (String type : new String[]{"INCOME", "EXPENSE"}) {
for (String name : entryMapper.findDistinctCategories(memberId, type)) {
categoryMapper.insertIgnore(AccountCategory.builder()
.memberId(memberId).type(type).name(name).sortOrder(0).build());
}
}
return list(memberId);
}
private AccountCategory mustFind(Long id, Long memberId) {
AccountCategory c = categoryMapper.findByIdAndMember(id, memberId);
if (c == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "분류를 찾을 수 없습니다.");
}
return c;
}
}
@@ -0,0 +1,235 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.InvestHolding;
import com.sb.web.account.domain.InvestTrade;
import com.sb.web.account.domain.Wallet;
import com.sb.web.account.dto.HoldingRequest;
import com.sb.web.account.dto.HoldingResponse;
import com.sb.web.account.dto.TradeRequest;
import com.sb.web.account.dto.TradeResponse;
import com.sb.web.account.mapper.InvestMapper;
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.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 투자 포트폴리오(C). 보유종목·매매이력 관리와 평단/실현·평가손익 산출.
* 모든 데이터는 memberId(소유자)로 격리한다.
*/
@Service
@RequiredArgsConstructor
public class InvestService {
private final InvestMapper investMapper;
private final WalletMapper walletMapper;
/* ===================== 보유 종목 ===================== */
public List<HoldingResponse> listHoldings(Long memberId, Long walletId) {
requireInvestWallet(walletId, memberId);
Map<Long, List<InvestTrade>> tradesByHolding = tradesByHolding(memberId);
return investMapper.findHoldingsByWallet(memberId, walletId).stream()
.map(h -> toResponse(h, tradesByHolding.getOrDefault(h.getId(), List.of())))
.toList();
}
@Transactional
public HoldingResponse createHolding(HoldingRequest req, Long memberId) {
requireInvestWallet(req.getWalletId(), memberId);
InvestHolding h = InvestHolding.builder()
.memberId(memberId)
.walletId(req.getWalletId())
.name(req.getName().trim())
.ticker(blankToNull(req.getTicker()))
.currentPrice(req.getCurrentPrice())
.build();
investMapper.insertHolding(h);
return toResponse(h, List.of());
}
@Transactional
public HoldingResponse updateHolding(Long id, HoldingRequest req, Long memberId) {
InvestHolding h = mustHolding(id, memberId);
h.setName(req.getName().trim());
h.setTicker(blankToNull(req.getTicker()));
h.setCurrentPrice(req.getCurrentPrice());
investMapper.updateHolding(h);
return toResponse(h, investMapper.findTradesByHolding(id, memberId));
}
@Transactional
public void deleteHolding(Long id, Long memberId) {
mustHolding(id, memberId);
investMapper.deleteTradesByHolding(id, memberId);
investMapper.deleteHolding(id, memberId);
}
/* ===================== 매매 ===================== */
public List<TradeResponse> listTrades(Long holdingId, Long memberId) {
mustHolding(holdingId, memberId);
return investMapper.findTradesByHolding(holdingId, memberId).stream()
.map(TradeResponse::from)
.toList();
}
@Transactional
public TradeResponse addTrade(Long holdingId, TradeRequest req, Long memberId) {
mustHolding(holdingId, memberId);
String type = "SELL".equals(req.getTradeType()) ? "SELL" : "BUY";
if ("SELL".equals(type)) {
Calc c = calc(investMapper.findTradesByHolding(holdingId, memberId));
if (req.getQuantity() > c.quantity()) {
throw new ApiException(HttpStatus.BAD_REQUEST,
"매도 수량이 보유수량(" + c.quantity() + ")을 초과합니다.");
}
}
InvestTrade t = InvestTrade.builder()
.memberId(memberId)
.holdingId(holdingId)
.tradeType(type)
.tradeDate(req.getTradeDate())
.quantity(req.getQuantity())
.price(req.getPrice())
.fee(req.getFee() != null ? req.getFee() : 0L)
.build();
investMapper.insertTrade(t);
return TradeResponse.from(t);
}
@Transactional
public void deleteTrade(Long id, Long memberId) {
if (investMapper.findTradeByIdAndMember(id, memberId) == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "매매 내역을 찾을 수 없습니다.");
}
investMapper.deleteTrade(id, memberId);
}
/* ===================== 지갑 단위 평가 (AccountService 연동) ===================== */
/** 투자(INVEST) 지갑별 {예수금 증감(cashDelta), 주식 평가금액(stockEval)} */
public Map<Long, WalletInvest> valuationByWallet(Long memberId) {
Map<Long, List<InvestTrade>> tradesByHolding = tradesByHolding(memberId);
Map<Long, WalletInvest> result = new HashMap<>();
for (InvestHolding h : investMapper.findHoldingsByMember(memberId)) {
List<InvestTrade> trades = tradesByHolding.getOrDefault(h.getId(), List.of());
Calc c = calc(trades);
long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : c.avgPrice();
WalletInvest wi = result.computeIfAbsent(h.getWalletId(), k -> new WalletInvest());
wi.cashDelta += cashDelta(trades);
wi.stockEval += c.quantity() * evalPrice;
}
return result;
}
/** 투자 지갑별 가치 holder */
public static class WalletInvest {
public long cashDelta; // 매매로 인한 예수금 증감 (Σ매도대금 − Σ매수금액)
public long stockEval; // 주식 평가금액 (Σ수량×평가단가)
}
/* ===================== 내부 ===================== */
private Map<Long, List<InvestTrade>> tradesByHolding(Long memberId) {
Map<Long, List<InvestTrade>> map = new HashMap<>();
for (InvestTrade t : investMapper.findTradesByMember(memberId)) {
map.computeIfAbsent(t.getHoldingId(), k -> new java.util.ArrayList<>()).add(t);
}
return map;
}
/** 매매이력(시간순)으로 보유수량·원가·실현손익 산출 */
private Calc calc(List<InvestTrade> trades) {
long qty = 0, costBasis = 0, realized = 0;
for (InvestTrade t : trades) {
long amount = t.getQuantity() * t.getPrice();
long fee = t.getFee() != null ? t.getFee() : 0L;
if ("BUY".equals(t.getTradeType())) {
qty += t.getQuantity();
costBasis += amount + fee;
} else { // SELL
long costRemoved = (qty > 0 && t.getQuantity() < qty)
? Math.round(costBasis * (double) t.getQuantity() / qty)
: costBasis; // 전량 매도 시 잔여 원가 전부 제거(반올림 잔차 방지)
long proceeds = amount - fee;
realized += proceeds - costRemoved;
costBasis -= costRemoved;
qty -= t.getQuantity();
if (qty <= 0) {
qty = 0;
costBasis = 0;
}
}
}
return new Calc(qty, costBasis, realized);
}
private record Calc(long quantity, long costBasis, long realized) {
long avgPrice() {
return quantity > 0 ? Math.round(costBasis / (double) quantity) : 0;
}
}
/** 매매로 인한 예수금 증감 = −Σ매수(금액+수수료) + Σ매도(금액−수수료) */
private long cashDelta(List<InvestTrade> trades) {
long cashDelta = 0;
for (InvestTrade t : trades) {
long amount = t.getQuantity() * t.getPrice();
long fee = t.getFee() != null ? t.getFee() : 0L;
cashDelta += "BUY".equals(t.getTradeType()) ? -(amount + fee) : (amount - fee);
}
return cashDelta;
}
private HoldingResponse toResponse(InvestHolding h, List<InvestTrade> trades) {
Calc c = calc(trades);
long avgPrice = c.avgPrice();
long evalPrice = h.getCurrentPrice() != null ? h.getCurrentPrice() : avgPrice;
long evalValue = c.quantity() * evalPrice;
long evalGain = evalValue - c.costBasis();
Double returnPct = c.costBasis() > 0 ? Math.round(evalGain * 1000.0 / c.costBasis()) / 10.0 : null;
return HoldingResponse.builder()
.id(h.getId())
.walletId(h.getWalletId())
.name(h.getName())
.ticker(h.getTicker())
.currentPrice(h.getCurrentPrice())
.quantity(c.quantity())
.avgPrice(avgPrice)
.costBasis(c.costBasis())
.evalValue(evalValue)
.evalGain(evalGain)
.returnPct(returnPct)
.realizedPL(c.realized)
.build();
}
private void requireInvestWallet(Long walletId, Long memberId) {
Wallet w = walletMapper.findByIdAndMember(walletId, memberId);
if (w == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "투자계좌가 올바르지 않습니다.");
}
if (!"INVEST".equals(w.getType())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "투자(INVEST) 계좌만 종목을 보유할 수 있습니다.");
}
}
private InvestHolding mustHolding(Long id, Long memberId) {
InvestHolding h = investMapper.findHoldingByIdAndMember(id, memberId);
if (h == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "종목을 찾을 수 없습니다.");
}
return h;
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}
@@ -0,0 +1,178 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.AccountEntry;
import com.sb.web.account.domain.Recurring;
import com.sb.web.account.dto.RecurringRequest;
import com.sb.web.account.dto.RecurringResponse;
import com.sb.web.account.mapper.AccountEntryMapper;
import com.sb.web.account.mapper.RecurringMapper;
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.List;
/**
* 정기/반복 거래 서비스. CRUD + 밀린 회차 자동 생성(중복 없이).
*/
@Service
@RequiredArgsConstructor
public class RecurringService {
private final RecurringMapper recurringMapper;
private final AccountEntryMapper entryMapper;
public List<RecurringResponse> list(Long memberId) {
return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList();
}
@Transactional
public RecurringResponse create(RecurringRequest req, Long memberId) {
Recurring r = toEntity(req);
r.setMemberId(memberId);
recurringMapper.insert(r);
return toResponse(recurringMapper.findByIdAndMember(r.getId(), memberId));
}
@Transactional
public RecurringResponse update(Long id, RecurringRequest req, Long memberId) {
mustFind(id, memberId);
Recurring r = toEntity(req);
r.setId(id);
r.setMemberId(memberId);
recurringMapper.update(r);
return toResponse(recurringMapper.findByIdAndMember(id, memberId));
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
recurringMapper.delete(id, memberId);
}
/** 활성 규칙의 밀린 회차를 오늘까지 생성 (중복 없음). 생성 건수 반환 */
@Transactional
public int run(Long memberId) {
LocalDate today = LocalDate.now();
int count = 0;
for (Recurring r : recurringMapper.findActiveByMember(memberId)) {
LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1);
LocalDate next = nextOccurrence(r, cursor);
LocalDate generatedUpTo = null;
int guard = 0;
while (next != null && !next.isAfter(today)
&& (r.getEndDate() == null || !next.isAfter(r.getEndDate()))
&& guard++ < 1000) {
entryMapper.insert(buildEntry(r, next, memberId));
generatedUpTo = next;
count++;
next = nextOccurrence(r, next);
}
if (generatedUpTo != null) {
recurringMapper.updateLastRun(r.getId(), generatedUpTo);
}
}
return count;
}
/* ===================== 내부 ===================== */
private AccountEntry buildEntry(Recurring r, LocalDate date, Long memberId) {
boolean transfer = "TRANSFER".equals(r.getType());
return AccountEntry.builder()
.memberId(memberId)
.entryDate(date)
.type(r.getType())
.category(transfer ? null : r.getCategory())
.amount(r.getAmount())
.memo(r.getMemo())
.walletId(r.getWalletId())
.toWalletId(transfer ? r.getToWalletId() : null)
.build();
}
/** from 이후 첫 발생일 (없으면 null) */
private LocalDate nextOccurrence(Recurring r, LocalDate from) {
return switch (r.getFrequency()) {
case "DAILY" -> from.plusDays(1);
case "WEEKLY" -> {
int dow = r.getDayOfWeek() != null ? r.getDayOfWeek() : 1;
LocalDate d = from.plusDays(1);
while (d.getDayOfWeek().getValue() != dow) d = d.plusDays(1);
yield d;
}
case "MONTHLY" -> {
int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1;
YearMonth ym = YearMonth.from(from);
LocalDate c = clamp(ym, dom);
if (!c.isAfter(from)) c = clamp(ym.plusMonths(1), dom);
yield c;
}
case "YEARLY" -> {
int moy = r.getMonthOfYear() != null ? r.getMonthOfYear() : 1;
int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1;
LocalDate c = clamp(YearMonth.of(from.getYear(), moy), dom);
if (!c.isAfter(from)) c = clamp(YearMonth.of(from.getYear() + 1, moy), dom);
yield c;
}
default -> null;
};
}
private LocalDate clamp(YearMonth ym, int dom) {
return ym.atDay(Math.min(dom, ym.lengthOfMonth()));
}
private Recurring toEntity(RecurringRequest req) {
return Recurring.builder()
.title(req.getTitle().trim())
.type(req.getType())
.amount(req.getAmount())
.category("TRANSFER".equals(req.getType()) ? null : blankToNull(req.getCategory()))
.memo(blankToNull(req.getMemo()))
.walletId(req.getWalletId())
.toWalletId("TRANSFER".equals(req.getType()) ? req.getToWalletId() : null)
.frequency(req.getFrequency())
.dayOfMonth(req.getDayOfMonth())
.dayOfWeek(req.getDayOfWeek())
.monthOfYear(req.getMonthOfYear())
.startDate(req.getStartDate())
.endDate(req.getEndDate())
.active(req.isActive())
.build();
}
private RecurringResponse toResponse(Recurring r) {
LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1);
LocalDate next = nextOccurrence(r, cursor);
if (next != null && r.getEndDate() != null && next.isAfter(r.getEndDate())) {
next = null;
}
return RecurringResponse.builder()
.id(r.getId()).title(r.getTitle()).type(r.getType()).amount(r.getAmount())
.category(r.getCategory()).memo(r.getMemo())
.walletId(r.getWalletId()).walletName(r.getWalletName())
.toWalletId(r.getToWalletId()).toWalletName(r.getToWalletName())
.frequency(r.getFrequency()).dayOfMonth(r.getDayOfMonth())
.dayOfWeek(r.getDayOfWeek()).monthOfYear(r.getMonthOfYear())
.startDate(r.getStartDate()).endDate(r.getEndDate()).lastRunDate(r.getLastRunDate())
.nextDate(next).active(Boolean.TRUE.equals(r.getActive()))
.build();
}
private Recurring mustFind(Long id, Long memberId) {
Recurring r = recurringMapper.findByIdAndMember(id, memberId);
if (r == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "정기 거래를 찾을 수 없습니다.");
}
return r;
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}