diff --git a/src/main/java/com/sb/web/account/controller/BudgetController.java b/src/main/java/com/sb/web/account/controller/BudgetController.java index 401f94d..9e911ac 100644 --- a/src/main/java/com/sb/web/account/controller/BudgetController.java +++ b/src/main/java/com/sb/web/account/controller/BudgetController.java @@ -1,11 +1,14 @@ package com.sb.web.account.controller; +import com.sb.web.account.dto.BudgetOnetimeRequest; +import com.sb.web.account.dto.BudgetOnetimeResponse; 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.dto.ExpectedIncomeRequest; import com.sb.web.account.service.AccountSettingService; +import com.sb.web.account.service.BudgetOnetimeService; import com.sb.web.account.service.BudgetService; import com.sb.web.auth.dto.SessionUser; import com.sb.web.auth.web.AuthInterceptor; @@ -26,6 +29,7 @@ import java.util.List; public class BudgetController { private final BudgetService budgetService; + private final BudgetOnetimeService onetimeService; private final AccountSettingService settingService; /** 월 예상 수입 조회 (연·월별) */ @@ -110,4 +114,35 @@ public class BudgetController { budgetService.delete(id, current.getId()); return ResponseEntity.noContent().build(); } + + // ===== 1회성 예산 ===== + + @GetMapping("/onetime") + public List onetimeList( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return onetimeService.list(current.getId()); + } + + @PostMapping("/onetime") + public ResponseEntity onetimeCreate( + @Valid @RequestBody BudgetOnetimeRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return ResponseEntity.status(HttpStatus.CREATED).body(onetimeService.create(req, current.getId())); + } + + @PutMapping("/onetime/{id}") + public BudgetOnetimeResponse onetimeUpdate( + @PathVariable Long id, + @Valid @RequestBody BudgetOnetimeRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return onetimeService.update(id, req, current.getId()); + } + + @DeleteMapping("/onetime/{id}") + public ResponseEntity onetimeDelete( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + onetimeService.delete(id, current.getId()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/sb/web/account/domain/BudgetOnetime.java b/src/main/java/com/sb/web/account/domain/BudgetOnetime.java new file mode 100644 index 0000000..eeb3273 --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/BudgetOnetime.java @@ -0,0 +1,29 @@ +package com.sb.web.account.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 1회성 예산 — 특정 기간(start_date~end_date)에 단발 예산을 설정. + * category=null 이면 전체 지출 합산, 값 있으면 해당 카테고리만. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BudgetOnetime { + private Long id; + private Long memberId; + private String title; + private String category; // null = 전체 + private Long amount; + private LocalDate startDate; + private LocalDate endDate; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/sb/web/account/dto/BudgetOnetimeRequest.java b/src/main/java/com/sb/web/account/dto/BudgetOnetimeRequest.java new file mode 100644 index 0000000..bce589d --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/BudgetOnetimeRequest.java @@ -0,0 +1,30 @@ +package com.sb.web.account.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; + +@Data +public class BudgetOnetimeRequest { + + @NotBlank(message = "제목을 입력하세요.") + @Size(max = 100, message = "제목은 100자 이내여야 합니다.") + private String title; + + @Size(max = 50, message = "카테고리는 50자 이내여야 합니다.") + private String category; // null 또는 빈값 = 전체 지출 + + @NotNull(message = "금액을 입력하세요.") + @Positive(message = "금액은 0보다 커야 합니다.") + private Long amount; + + @NotNull(message = "시작일을 입력하세요.") + private LocalDate startDate; + + @NotNull(message = "종료일을 입력하세요.") + private LocalDate endDate; +} diff --git a/src/main/java/com/sb/web/account/dto/BudgetOnetimeResponse.java b/src/main/java/com/sb/web/account/dto/BudgetOnetimeResponse.java new file mode 100644 index 0000000..4ceaf5d --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/BudgetOnetimeResponse.java @@ -0,0 +1,41 @@ +package com.sb.web.account.dto; + +import com.sb.web.account.domain.BudgetOnetime; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@Builder +public class BudgetOnetimeResponse { + private Long id; + private String title; + private String category; // null = 전체 + private Long amount; + private LocalDate startDate; + private LocalDate endDate; + private long spent; + private long remaining; + private String status; // UPCOMING / ACTIVE / ENDED + + public static BudgetOnetimeResponse from(BudgetOnetime b, long spent) { + LocalDate today = LocalDate.now(); + String status; + if (today.isBefore(b.getStartDate())) status = "UPCOMING"; + else if (today.isAfter(b.getEndDate())) status = "ENDED"; + else status = "ACTIVE"; + + return BudgetOnetimeResponse.builder() + .id(b.getId()) + .title(b.getTitle()) + .category(b.getCategory()) + .amount(b.getAmount()) + .startDate(b.getStartDate()) + .endDate(b.getEndDate()) + .spent(spent) + .remaining(b.getAmount() - spent) + .status(status) + .build(); + } +} diff --git a/src/main/java/com/sb/web/account/dto/WalletRequest.java b/src/main/java/com/sb/web/account/dto/WalletRequest.java index a940aa1..ccb9af7 100644 --- a/src/main/java/com/sb/web/account/dto/WalletRequest.java +++ b/src/main/java/com/sb/web/account/dto/WalletRequest.java @@ -17,7 +17,7 @@ import java.time.LocalDate; public class WalletRequest { @NotBlank(message = "유형을 선택하세요.") - @Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST", message = "유형이 올바르지 않습니다.") + @Pattern(regexp = "BANK|CASH|CARD|LOAN|INVEST|MINUS", message = "유형이 올바르지 않습니다.") private String type; @NotBlank(message = "이름을 입력하세요.") diff --git a/src/main/java/com/sb/web/account/mapper/BudgetOnetimeMapper.java b/src/main/java/com/sb/web/account/mapper/BudgetOnetimeMapper.java new file mode 100644 index 0000000..9aaf9c4 --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/BudgetOnetimeMapper.java @@ -0,0 +1,27 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.BudgetOnetime; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDate; +import java.util.List; + +@Mapper +public interface BudgetOnetimeMapper { + + List findByMember(@Param("memberId") Long memberId); + + BudgetOnetime findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + + long sumExpenseInRange(@Param("memberId") Long memberId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("category") String category); + + void insert(BudgetOnetime b); + + void update(BudgetOnetime b); + + void delete(@Param("id") Long id, @Param("memberId") Long memberId); +} diff --git a/src/main/java/com/sb/web/account/service/BudgetOnetimeService.java b/src/main/java/com/sb/web/account/service/BudgetOnetimeService.java new file mode 100644 index 0000000..28b05f8 --- /dev/null +++ b/src/main/java/com/sb/web/account/service/BudgetOnetimeService.java @@ -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 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(); + } +} diff --git a/src/main/java/com/sb/web/account/service/CategoryService.java b/src/main/java/com/sb/web/account/service/CategoryService.java index 465d89f..802625e 100644 --- a/src/main/java/com/sb/web/account/service/CategoryService.java +++ b/src/main/java/com/sb/web/account/service/CategoryService.java @@ -17,7 +17,6 @@ import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; import java.util.List; import java.util.Map; - /** * 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기. */ diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 2f24f1c..f93be69 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -197,6 +197,21 @@ CREATE TABLE IF NOT EXISTS budget_income ( PRIMARY KEY (member_id, `year`, `month`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- 1회성 예산 (기간·제목별 단발 예산) +CREATE TABLE IF NOT EXISTS budget_onetime ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL COMMENT '소유자 member.id', + title VARCHAR(100) NOT NULL COMMENT '제목 (예: 7월 여행)', + category VARCHAR(50) NULL COMMENT '카테고리 (NULL=전체 지출)', + amount BIGINT NOT NULL COMMENT '총 예산', + start_date DATE NOT NULL COMMENT '기간 시작일', + end_date DATE NOT NULL COMMENT '기간 종료일', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_bot_member_date (member_id, start_date, end_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- 투자 보유 종목 (INVEST 지갑별 · 사용자별) CREATE TABLE IF NOT EXISTS invest_holding ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/src/main/resources/mapper/BudgetOnetimeMapper.xml b/src/main/resources/mapper/BudgetOnetimeMapper.xml new file mode 100644 index 0000000..c66c09d --- /dev/null +++ b/src/main/resources/mapper/BudgetOnetimeMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO budget_onetime (member_id, title, category, amount, start_date, end_date, created_at, updated_at) + VALUES (#{memberId}, #{title}, #{category}, #{amount}, #{startDate}, #{endDate}, NOW(), NOW()) + + + + UPDATE budget_onetime + SET title = #{title}, category = #{category}, amount = #{amount}, + start_date = #{startDate}, end_date = #{endDate}, updated_at = NOW() + WHERE id = #{id} AND member_id = #{memberId} + + + + DELETE FROM budget_onetime WHERE id = #{id} AND member_id = #{memberId} + + +