feat(api): ChargebackCalculator + ChargebackGradeService + Controller (도메인 P1-3-c)

도메인 P1 마지막 항목 — 차등 환수 마무리.

ChargebackCalculator (pure @Component):
- calculate(contractDate, terminationDate, originalCommission, productCategory)
- monthsElapsed = ChronoUnit.MONTHS.between
- selectByMonths로 적용 비율 조회
- chargebackAmount = originalCommission × percent / 100 (HALF_UP, scale 0)
- grade 없으면 ZERO + "적용 환수 구간 없음"

ChargebackResult record:
- chargebackAmount, monthsElapsed, appliedPercent, reason

ChargebackGradeService:
- list/detail (readOnly)
- create/update (@Transactional, 중복 검증)
- deactivate (@Transactional)

ChargebackGradeController:
- GET/POST/PUT 표준 + /api/chargeback-grades/calculate (외부 호출용)
- 모든 보호 엔드포인트 @RequirePermission, CUD에 @DataChangeLog

ga-batch 정산 step 자동 통합은 마무리 통합 단계에서.
This commit is contained in:
GA Pro
2026-05-13 00:56:24 +09:00
parent 44dbbf37da
commit 3831eeaf89
5 changed files with 240 additions and 0 deletions
@@ -0,0 +1,24 @@
package com.ga.api.controller.chargeback;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class ChargebackCalculateReq {
@NotNull
private LocalDate contractDate;
@NotNull
private LocalDate terminationDate;
@NotNull
@Positive
private BigDecimal originalCommission;
private String productCategory;
}
@@ -0,0 +1,75 @@
package com.ga.api.controller.chargeback;
import com.ga.api.service.chargeback.ChargebackCalculator;
import com.ga.api.service.chargeback.ChargebackGradeService;
import com.ga.api.service.chargeback.ChargebackResult;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.chargeback.ChargebackGradeResp;
import com.ga.core.vo.chargeback.ChargebackGradeSaveReq;
import com.ga.core.vo.chargeback.ChargebackGradeSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "환수 등급 관리")
@RestController
@RequestMapping("/api/chargeback-grades")
@RequiredArgsConstructor
public class ChargebackGradeController {
private final ChargebackGradeService gradeService;
private final ChargebackCalculator calculator;
@GetMapping
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<PageResponse<ChargebackGradeResp>> list(ChargebackGradeSearchParam param) {
return ApiResponse.ok(gradeService.list(param));
}
@GetMapping("/{gradeId}")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<ChargebackGradeResp> detail(@PathVariable Long gradeId) {
return ApiResponse.ok(gradeService.detail(gradeId));
}
@PostMapping
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "CREATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
public ApiResponse<Void> create(@Valid @RequestBody ChargebackGradeSaveReq req) {
gradeService.create(req);
return ApiResponse.ok();
}
@PutMapping("/{gradeId}")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
public ApiResponse<Void> update(@PathVariable Long gradeId,
@Valid @RequestBody ChargebackGradeSaveReq req) {
gradeService.update(gradeId, req);
return ApiResponse.ok();
}
@PutMapping("/{gradeId}/deactivate")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
public ApiResponse<Void> deactivate(@PathVariable Long gradeId) {
gradeService.deactivate(gradeId);
return ApiResponse.ok();
}
@PostMapping("/calculate")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<ChargebackResult> calculate(@Valid @RequestBody ChargebackCalculateReq req) {
ChargebackResult result = calculator.calculate(
req.getContractDate(),
req.getTerminationDate(),
req.getOriginalCommission(),
req.getProductCategory()
);
return ApiResponse.ok(result);
}
}
@@ -0,0 +1,45 @@
package com.ga.api.service.chargeback;
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
import com.ga.core.vo.chargeback.ChargebackGradeVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
@Component
@RequiredArgsConstructor
public class ChargebackCalculator {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final ChargebackGradeMapper gradeMapper;
public ChargebackResult calculate(LocalDate contractDate, LocalDate terminationDate,
BigDecimal originalCommission, String productCategory) {
int monthsElapsed = (int) ChronoUnit.MONTHS.between(contractDate, terminationDate);
String today = LocalDate.now().format(DATE_FMT);
ChargebackGradeVO grade = gradeMapper.selectByMonths(productCategory, monthsElapsed, today);
if (grade == null) {
return new ChargebackResult(BigDecimal.ZERO, monthsElapsed, BigDecimal.ZERO,
"적용 환수 구간 없음");
}
BigDecimal chargebackAmount = originalCommission
.multiply(grade.getChargebackPercent())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
return new ChargebackResult(
chargebackAmount,
monthsElapsed,
grade.getChargebackPercent(),
String.format("경과월 %d개월 → 환수율 %s%%", monthsElapsed, grade.getChargebackPercent())
);
}
}
@@ -0,0 +1,86 @@
package com.ga.api.service.chargeback;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
import com.ga.core.vo.chargeback.ChargebackGradeResp;
import com.ga.core.vo.chargeback.ChargebackGradeSaveReq;
import com.ga.core.vo.chargeback.ChargebackGradeSearchParam;
import com.ga.core.vo.chargeback.ChargebackGradeVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Service
@RequiredArgsConstructor
public class ChargebackGradeService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final ChargebackGradeMapper gradeMapper;
@Transactional(readOnly = true)
public PageResponse<ChargebackGradeResp> list(ChargebackGradeSearchParam param) {
param.startPage();
return PageResponse.of(gradeMapper.selectList(param));
}
@Transactional(readOnly = true)
public ChargebackGradeResp detail(Long gradeId) {
ChargebackGradeResp resp = gradeMapper.selectDetailById(gradeId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public void create(ChargebackGradeSaveReq req) {
String today = LocalDate.now().format(DATE_FMT);
ChargebackGradeVO existing = gradeMapper.selectByMonths(
req.getProductCategory(), req.getMonthsFrom(), today);
if (existing != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
gradeMapper.insert(toVO(req));
}
@Transactional
public void update(Long gradeId, ChargebackGradeSaveReq req) {
ChargebackGradeVO existing = gradeMapper.selectById(gradeId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
ChargebackGradeVO vo = toVO(req);
vo.setGradeId(gradeId);
vo.setIsActive(existing.getIsActive());
gradeMapper.update(vo);
}
@Transactional
public void deactivate(Long gradeId) {
ChargebackGradeVO existing = gradeMapper.selectById(gradeId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (Boolean.FALSE.equals(existing.getIsActive())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
gradeMapper.updateStatus(gradeId, false);
}
@Transactional(readOnly = true)
public ChargebackGradeVO selectByMonths(String productCategory, Integer monthsElapsed, String baseDate) {
return gradeMapper.selectByMonths(productCategory, monthsElapsed, baseDate);
}
private ChargebackGradeVO toVO(ChargebackGradeSaveReq req) {
ChargebackGradeVO vo = new ChargebackGradeVO();
vo.setProductCategory(req.getProductCategory());
vo.setMonthsFrom(req.getMonthsFrom());
vo.setMonthsTo(req.getMonthsTo());
vo.setChargebackPercent(req.getChargebackPercent());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
vo.setIsActive(true);
return vo;
}
}
@@ -0,0 +1,10 @@
package com.ga.api.service.chargeback;
import java.math.BigDecimal;
public record ChargebackResult(
BigDecimal chargebackAmount,
int monthsElapsed,
BigDecimal appliedPercent,
String reason
) {}