feat(budget): 1회성 예산 API 추가

- BudgetOnetime 도메인/DTO/Mapper/Service 신규
- BudgetController: GET/POST/PUT/DELETE /api/account/budgets/onetime 엔드포인트 추가
- account.sql: budget_onetime 테이블 CREATE TABLE IF NOT EXISTS 추가
- WalletRequest: MINUS 유형 허용 추가
- CategoryService: importDefaults, validateParent, ensureCategory 추가

※ 최초 배포 시 서버 .env에 SQL_INIT_MODE=always 추가 후 재시작 필요 (budget_onetime 테이블 생성)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-05 16:15:15 +09:00
co-authored by Claude Sonnet 4.6
parent 24858f23e8
commit a0509099f7
10 changed files with 320 additions and 2 deletions
@@ -0,0 +1,82 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.BudgetOnetime;
import com.sb.web.account.dto.BudgetOnetimeRequest;
import com.sb.web.account.dto.BudgetOnetimeResponse;
import com.sb.web.account.mapper.BudgetOnetimeMapper;
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;
@Service
@RequiredArgsConstructor
public class BudgetOnetimeService {
private final BudgetOnetimeMapper mapper;
public List<BudgetOnetimeResponse> list(Long memberId) {
return mapper.findByMember(memberId).stream()
.map(b -> {
long spent = mapper.sumExpenseInRange(
memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
})
.toList();
}
@Transactional
public BudgetOnetimeResponse create(BudgetOnetimeRequest req, Long memberId) {
validate(req);
BudgetOnetime b = BudgetOnetime.builder()
.memberId(memberId)
.title(req.getTitle().trim())
.category(blankToNull(req.getCategory()))
.amount(req.getAmount())
.startDate(req.getStartDate())
.endDate(req.getEndDate())
.build();
mapper.insert(b);
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
}
@Transactional
public BudgetOnetimeResponse update(Long id, BudgetOnetimeRequest req, Long memberId) {
validate(req);
BudgetOnetime b = mustFind(id, memberId);
b.setTitle(req.getTitle().trim());
b.setCategory(blankToNull(req.getCategory()));
b.setAmount(req.getAmount());
b.setStartDate(req.getStartDate());
b.setEndDate(req.getEndDate());
mapper.update(b);
long spent = mapper.sumExpenseInRange(memberId, b.getStartDate(), b.getEndDate(), b.getCategory());
return BudgetOnetimeResponse.from(b, spent);
}
@Transactional
public void delete(Long id, Long memberId) {
mustFind(id, memberId);
mapper.delete(id, memberId);
}
private void validate(BudgetOnetimeRequest req) {
if (req.getEndDate().isBefore(req.getStartDate())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "종료일이 시작일보다 빠를 수 없습니다.");
}
}
private BudgetOnetime mustFind(Long id, Long memberId) {
BudgetOnetime b = mapper.findByIdAndMember(id, memberId);
if (b == null) throw new ApiException(HttpStatus.NOT_FOUND, "1회성 예산을 찾을 수 없습니다.");
return b;
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}