feat: 수수료 공식 갭 보완 + 8종 신규모듈 풀스택 (V116~V123)

수수료 공식 갭분석(5 병렬감사) 후 확정 버그 3건 수정 + 개념적으로 빠진 모듈 8종 풀스택 구현.

[확정 버그 3건]
- 1200% 누적한도(checkContractTotal)를 배치 CalcRecruitStep에 연결(기존 1차년 한도만 적용
  → 두 한도 중 더 제한적인 값으로 clip+이연). +CalcRecruitStepTest
- BatchInstallmentPlanGenerator planMonth 반영(하드코딩 1 제거, API판과 동일). +단위테스트
- PersistencyBonus 임계유지율 게이트 + FIXED 정액 룰 도출. +PersistencyBonusServiceTest

[신규 모듈 8종]
- MOD-1 도입수수료(recruiter/development) / MOD-2 선지급·차익정산 / MOD-3 환수 분할상환
- MOD-4 익월부활 재지급 / MOD-5 정산대사 입수 / MOD-6 환수 시효 / MOD-7 환수 감면
- MOD-8 채널별 수수료차등(sales_channel)
각 DB(테이블+메뉴+권한+공통코드)→VO/Mapper/Enum→Service/Controller→화면 풀스택.
배치 정산연계: AggregateStep에 도입/익월부활 가산(11종), 선지급 FIFO 상계(멱등 reverse),
MOD-8 채널 우선 요율조회(NULL 폴백 하위호환).

[검증]
- 전체 ./gradlew build(전 모듈+전 테스트) GREEN
- Flyway V116~V123 운영DB 적용(schema v115→v123)
- 라이브 스모크 8 GET=200, MOD-1 RATE공식 50000 실측, 액션 POST 5xx 0건
- 적대적 코드리뷰 APPROVED(MOD-8 계산기 미연결 버그 발견·수정)

스펙: docs/DOMAIN_GAP_MOD1-8.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-06 17:32:25 +09:00
parent 00ead64c4c
commit ff6d399dd3
114 changed files with 6419 additions and 53 deletions
@@ -0,0 +1,51 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.AdvancePaymentService;
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.commission.AdvancePaymentResp;
import com.ga.core.vo.commission.AdvancePaymentSaveReq;
import com.ga.core.vo.commission.AdvancePaymentSearchParam;
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/advance-payments")
@RequiredArgsConstructor
public class AdvancePaymentController {
private final AdvancePaymentService service;
@GetMapping
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ")
public ApiResponse<PageResponse<AdvancePaymentResp>> list(
@ModelAttribute AdvancePaymentSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{advanceId}")
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ")
public ApiResponse<AdvancePaymentResp> detail(@PathVariable Long advanceId) {
return ApiResponse.ok(service.detail(advanceId));
}
@PostMapping
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "CREATE")
@DataChangeLog(menu = "ADVANCE_PAYMENT", table = "advance_payment")
public ApiResponse<Long> create(@Valid @RequestBody AdvancePaymentSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{advanceId}/waive")
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "ADVANCE_PAYMENT", table = "advance_payment")
public ApiResponse<Void> waive(@PathVariable Long advanceId) {
service.waive(advanceId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.CarrierReconciliationInService;
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.commission.CarrierReconciliationInResp;
import com.ga.core.vo.commission.CarrierReconciliationInSaveReq;
import com.ga.core.vo.commission.CarrierReconciliationInSearchParam;
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/carrier-recon-in")
@RequiredArgsConstructor
public class CarrierReconciliationInController {
private final CarrierReconciliationInService service;
@GetMapping
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ")
public ApiResponse<PageResponse<CarrierReconciliationInResp>> list(
@ModelAttribute CarrierReconciliationInSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{reconId}")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ")
public ApiResponse<CarrierReconciliationInResp> detail(@PathVariable Long reconId) {
return ApiResponse.ok(service.detail(reconId));
}
@PostMapping
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "CREATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Long> create(@Valid @RequestBody CarrierReconciliationInSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{reconId}/reconcile")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "UPDATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Void> reconcile(@PathVariable Long reconId) {
service.reconcile(reconId);
return ApiResponse.ok();
}
@PostMapping("/{reconId}/approve")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "UPDATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Void> approve(@PathVariable Long reconId) {
service.approve(reconId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ChargebackWaiverService;
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.commission.ChargebackWaiverRequestResp;
import com.ga.core.vo.commission.ChargebackWaiverRequestSaveReq;
import com.ga.core.vo.commission.ChargebackWaiverRequestSearchParam;
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-waivers")
@RequiredArgsConstructor
public class ChargebackWaiverController {
private final ChargebackWaiverService service;
@GetMapping
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ")
public ApiResponse<PageResponse<ChargebackWaiverRequestResp>> list(
@ModelAttribute ChargebackWaiverRequestSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{requestId}")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ")
public ApiResponse<ChargebackWaiverRequestResp> detail(@PathVariable Long requestId) {
return ApiResponse.ok(service.detail(requestId));
}
@PostMapping
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "CREATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Long> create(@Valid @RequestBody ChargebackWaiverRequestSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{requestId}/approve")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Void> approve(@PathVariable Long requestId) {
service.approve(requestId);
return ApiResponse.ok();
}
@PostMapping("/{requestId}/reject")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Void> reject(@PathVariable Long requestId) {
service.reject(requestId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,68 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ClawbackInstallmentService;
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.commission.ClawbackInstallmentPlanResp;
import com.ga.core.vo.commission.ClawbackInstallmentPlanSearchParam;
import com.ga.core.vo.commission.ClawbackInstallmentPlanVO;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "환수분할상환")
@RestController
@RequestMapping("/api/clawback-installments")
@RequiredArgsConstructor
public class ClawbackInstallmentController {
private final ClawbackInstallmentService service;
@GetMapping
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<PageResponse<ClawbackInstallmentPlanResp>> list(
@ModelAttribute ClawbackInstallmentPlanSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{planId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<ClawbackInstallmentPlanResp> detail(@PathVariable Long planId) {
return ApiResponse.ok(service.detail(planId));
}
@GetMapping("/by-receivable/{receivableId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<List<ClawbackInstallmentPlanVO>> listByReceivable(
@PathVariable Long receivableId) {
return ApiResponse.ok(service.listByReceivable(receivableId));
}
/**
* 분할상환 계획 생성.
* Request body: { "count": N, "baseMonth": "YYYYMM" }
*/
@PostMapping("/generate/{receivableId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "CREATE")
@DataChangeLog(menu = "CLAWBACK_INSTALLMENT", table = "clawback_installment_plan")
public ApiResponse<Void> generate(@PathVariable Long receivableId,
@RequestBody Map<String, Object> body) {
int count = Integer.parseInt(body.get("count").toString());
String baseMonth = body.get("baseMonth").toString();
service.generate(receivableId, count, baseMonth);
return ApiResponse.ok();
}
@PostMapping("/{planId}/cancel")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "UPDATE")
@DataChangeLog(menu = "CLAWBACK_INSTALLMENT", table = "clawback_installment_plan")
public ApiResponse<Void> cancel(@PathVariable Long planId) {
service.cancel(planId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.LapsedReinstatementService;
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.commission.LapsedReinstatementRecoveryResp;
import com.ga.core.vo.commission.LapsedReinstatementRecoverySaveReq;
import com.ga.core.vo.commission.LapsedReinstatementRecoverySearchParam;
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/lapsed-reinstatements")
@RequiredArgsConstructor
public class LapsedReinstatementController {
private final LapsedReinstatementService service;
@GetMapping
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ")
public ApiResponse<PageResponse<LapsedReinstatementRecoveryResp>> list(
@ModelAttribute LapsedReinstatementRecoverySearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{recoveryId}")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ")
public ApiResponse<LapsedReinstatementRecoveryResp> detail(@PathVariable Long recoveryId) {
return ApiResponse.ok(service.detail(recoveryId));
}
@PostMapping
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "CREATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Long> create(@Valid @RequestBody LapsedReinstatementRecoverySaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{recoveryId}/repay")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "UPDATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Void> repay(@PathVariable Long recoveryId) {
service.repay(recoveryId);
return ApiResponse.ok();
}
@PostMapping("/{recoveryId}/cancel")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "UPDATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Void> cancel(@PathVariable Long recoveryId) {
service.cancel(recoveryId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,82 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.RecruiterCommissionService;
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.commission.*;
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/recruiter-commissions")
@RequiredArgsConstructor
public class RecruiterCommissionController {
private final RecruiterCommissionService service;
// ── Rule ──────────────────────────────────────────────────────────────────
@GetMapping("/rules")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<PageResponse<RecruiterCommissionRuleResp>> listRules(
@ModelAttribute RecruiterCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<RecruiterCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
@PostMapping("/rules")
@RequirePermission(menu = "RECRUITER_COMM", perm = "CREATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody RecruiterCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "UPDATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody RecruiterCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "DELETE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger ────────────────────────────────────────────────────────────────
@GetMapping("/ledgers")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<PageResponse<RecruiterCommissionLedgerResp>> listLedgers(
@ModelAttribute RecruiterCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<RecruiterCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
@PostMapping("/ledgers")
@RequirePermission(menu = "RECRUITER_COMM", perm = "CREATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody RecruiterCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,68 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.StatuteService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.core.vo.commission.StatuteConfigResp;
import com.ga.core.vo.commission.StatuteConfigSaveReq;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "환수시효관리")
@RestController
@RequestMapping("/api/statute")
@RequiredArgsConstructor
public class StatuteController {
private final StatuteService service;
@GetMapping
@RequirePermission(menu = "STATUTE_CONFIG", perm = "READ")
public ApiResponse<List<StatuteConfigResp>> list() {
return ApiResponse.ok(service.listConfigs());
}
@GetMapping("/{configId}")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "READ")
public ApiResponse<StatuteConfigResp> detail(@PathVariable Long configId) {
return ApiResponse.ok(service.detailConfig(configId));
}
@PostMapping
@RequirePermission(menu = "STATUTE_CONFIG", perm = "CREATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "statute_config")
public ApiResponse<Long> create(@Valid @RequestBody StatuteConfigSaveReq req) {
return ApiResponse.ok(service.createConfig(req));
}
@PutMapping("/{configId}")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "UPDATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "statute_config")
public ApiResponse<Void> update(@PathVariable Long configId,
@Valid @RequestBody StatuteConfigSaveReq req) {
service.updateConfig(configId, req);
return ApiResponse.ok();
}
/**
* 만료 대상 환수채권 일괄 대손 처리.
* Request body: { "baseMonth": "YYYYMM" }
*/
@PostMapping("/expire")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "UPDATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "clawback_receivable")
public ApiResponse<Integer> expire(@RequestBody Map<String, String> body) {
String baseMonth = body.get("baseMonth");
if (baseMonth == null || baseMonth.isBlank()) {
baseMonth = com.ga.common.util.DateUtil.getSettleMonth();
}
return ApiResponse.ok(service.expireOverdue(baseMonth));
}
}
@@ -0,0 +1,81 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.AdvancePaymentMapper;
import com.ga.core.vo.commission.AdvancePaymentResp;
import com.ga.core.vo.commission.AdvancePaymentSaveReq;
import com.ga.core.vo.commission.AdvancePaymentSearchParam;
import com.ga.core.vo.commission.AdvancePaymentVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdvancePaymentService {
private final AdvancePaymentMapper mapper;
public PageResponse<AdvancePaymentResp> list(AdvancePaymentSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public AdvancePaymentResp detail(Long advanceId) {
AdvancePaymentResp resp = mapper.selectById(advanceId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 선지급 등록 — balance = principal, status = OUTSTANDING.
*/
@Transactional
public Long create(AdvancePaymentSaveReq req) {
AdvancePaymentVO vo = new AdvancePaymentVO();
vo.setAgentId(req.getAgentId());
vo.setAdvanceMonth(req.getAdvanceMonth());
vo.setPrincipalAmount(req.getPrincipalAmount());
vo.setRecoveredAmount(BigDecimal.ZERO);
vo.setBalance(req.getPrincipalAmount());
vo.setStatus("OUTSTANDING");
vo.setMemo(req.getMemo());
mapper.insert(vo);
return vo.getAdvanceId();
}
/**
* 선지급 면제 — status = WAIVED, balance = 0.
*/
@Transactional
public void waive(Long advanceId) {
AdvancePaymentResp resp = mapper.selectById(advanceId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("CLEARED".equals(resp.getStatus()) || "WAIVED".equals(resp.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
AdvancePaymentVO vo = new AdvancePaymentVO();
vo.setAdvanceId(advanceId);
vo.setAgentId(resp.getAgentId());
vo.setAdvanceMonth(resp.getAdvanceMonth());
vo.setPrincipalAmount(resp.getPrincipalAmount());
vo.setRecoveredAmount(resp.getRecoveredAmount());
vo.setBalance(BigDecimal.ZERO);
vo.setStatus("WAIVED");
vo.setMemo(resp.getMemo());
mapper.update(vo);
}
/**
* 설계사의 미상환 잔액 합계 (OUTSTANDING + PARTIAL).
*/
public BigDecimal outstandingByAgent(Long agentId) {
BigDecimal total = mapper.outstandingByAgent(agentId);
return total != null ? total : BigDecimal.ZERO;
}
}
@@ -0,0 +1,104 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.commission.CarrierReconciliationInMapper;
import com.ga.core.mapper.commission.IncomeCommissionLedgerMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CarrierReconciliationInService {
private final CarrierReconciliationInMapper mapper;
private final IncomeCommissionLedgerMapper incomeMapper;
public PageResponse<CarrierReconciliationInResp> list(CarrierReconciliationInSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public CarrierReconciliationInResp detail(Long reconId) {
CarrierReconciliationInResp resp = mapper.selectById(reconId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 대사 등록 — reported_total 입력, status = RECEIVED.
*/
@Transactional
public Long create(CarrierReconciliationInSaveReq req) {
CarrierReconciliationInVO vo = new CarrierReconciliationInVO();
vo.setCompanyId(req.getCompanyId());
vo.setSettleMonth(req.getSettleMonth());
vo.setFileName(req.getFileName());
vo.setRecordCount(req.getRecordCount() != null ? req.getRecordCount() : 0);
vo.setReportedTotal(req.getReportedTotal());
vo.setSystemTotal(BigDecimal.ZERO);
vo.setVarianceAmount(BigDecimal.ZERO);
vo.setStatus("RECEIVED");
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
mapper.insert(vo);
return vo.getReconId();
}
/**
* 대사 실행 — system_total = income_commission 집계, variance = reported system.
* |variance| < 1 이면 MATCHED, 아니면 VARIANCE.
*/
@Transactional
public void reconcile(Long reconId) {
CarrierReconciliationInResp resp = mapper.selectById(reconId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("APPROVED".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
BigDecimal systemTotal = incomeMapper.sumByCarrierAndMonth(
resp.getCompanyId(), resp.getSettleMonth());
if (systemTotal == null) systemTotal = BigDecimal.ZERO;
BigDecimal variance = resp.getReportedTotal().subtract(systemTotal);
String newStatus = variance.abs().compareTo(BigDecimal.ONE) < 0 ? "MATCHED" : "VARIANCE";
CarrierReconciliationInVO vo = buildVO(resp);
vo.setSystemTotal(systemTotal);
vo.setVarianceAmount(variance);
vo.setStatus(newStatus);
mapper.update(vo);
}
/**
* 승인 처리 — status = APPROVED.
*/
@Transactional
public void approve(Long reconId) {
CarrierReconciliationInResp resp = mapper.selectById(reconId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("RECEIVED".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
CarrierReconciliationInVO vo = buildVO(resp);
vo.setStatus("APPROVED");
mapper.update(vo);
}
private CarrierReconciliationInVO buildVO(CarrierReconciliationInResp resp) {
CarrierReconciliationInVO vo = new CarrierReconciliationInVO();
vo.setReconId(resp.getReconId());
vo.setCompanyId(resp.getCompanyId());
vo.setSettleMonth(resp.getSettleMonth());
vo.setFileName(resp.getFileName());
vo.setRecordCount(resp.getRecordCount());
vo.setReportedTotal(resp.getReportedTotal());
vo.setSystemTotal(resp.getSystemTotal());
vo.setVarianceAmount(resp.getVarianceAmount());
vo.setStatus(resp.getStatus());
return vo;
}
}
@@ -0,0 +1,75 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.commission.ChargebackWaiverRequestMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChargebackWaiverService {
private final ChargebackWaiverRequestMapper mapper;
public PageResponse<ChargebackWaiverRequestResp> list(ChargebackWaiverRequestSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public ChargebackWaiverRequestResp detail(Long requestId) {
ChargebackWaiverRequestResp resp = mapper.selectById(requestId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 감면 신청 등록 — waiver_amount <= original_chargeback 검증.
*/
@Transactional
public Long create(ChargebackWaiverRequestSaveReq req) {
if (req.getWaiverAmount().compareTo(req.getOriginalChargeback()) > 0) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
ChargebackWaiverRequestVO vo = new ChargebackWaiverRequestVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setOriginalChargeback(req.getOriginalChargeback());
vo.setWaiverAmount(req.getWaiverAmount());
vo.setReason(req.getReason());
vo.setApprovalStatus("REQUESTED");
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
mapper.insert(vo);
return vo.getRequestId();
}
/**
* 승인 처리 — approval_status = APPROVED, approver_id = 현재 사용자, approved_at = now.
*/
@Transactional
public void approve(Long requestId) {
ChargebackWaiverRequestResp resp = mapper.selectById(requestId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"REQUESTED".equals(resp.getApprovalStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
mapper.updateApproval(requestId, "APPROVED", SecurityUtil.getCurrentUserId(), LocalDateTime.now());
}
/**
* 반려 처리 — approval_status = REJECTED.
*/
@Transactional
public void reject(Long requestId) {
ChargebackWaiverRequestResp resp = mapper.selectById(requestId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"REQUESTED".equals(resp.getApprovalStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
mapper.updateApproval(requestId, "REJECTED", SecurityUtil.getCurrentUserId(), LocalDateTime.now());
}
}
@@ -0,0 +1,91 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.DateUtil;
import com.ga.core.mapper.commission.ClawbackInstallmentPlanMapper;
import com.ga.core.mapper.commission.ClawbackReceivableMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ClawbackInstallmentService {
private final ClawbackInstallmentPlanMapper planMapper;
private final ClawbackReceivableMapper receivableMapper;
public PageResponse<ClawbackInstallmentPlanResp> list(ClawbackInstallmentPlanSearchParam param) {
param.startPage();
return PageResponse.of(planMapper.selectList(param));
}
public ClawbackInstallmentPlanResp detail(Long planId) {
ClawbackInstallmentPlanResp resp = planMapper.selectById(planId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<ClawbackInstallmentPlanVO> listByReceivable(Long receivableId) {
return planMapper.selectByReceivable(receivableId);
}
/**
* 환수채권을 N회차로 균등 분할상환 계획 생성.
* 마지막 회차는 잔차(총액 - 앞 회차 합계) 흡수.
* 기준월: baseMonth(YYYYMM), due_month = baseMonth + (회차-1)
*/
@Transactional
public void generate(Long receivableId, int count, String baseMonth) {
if (count < 1) throw new BizException(ErrorCode.INVALID_PARAMETER);
// 이미 SCHEDULED 계획이 있으면 중복 방지
List<ClawbackInstallmentPlanVO> existing = planMapper.selectByReceivable(receivableId);
boolean hasScheduled = existing.stream()
.anyMatch(p -> "SCHEDULED".equals(p.getStatus()));
if (hasScheduled) throw new BizException(ErrorCode.DUPLICATE_DATA);
ClawbackReceivableResp receivableResp = receivableMapper.selectById(receivableId);
if (receivableResp == null) throw new BizException(ErrorCode.NOT_FOUND);
BigDecimal total = receivableResp.getBalance();
if (total == null || total.compareTo(BigDecimal.ZERO) <= 0)
throw new BizException(ErrorCode.INVALID_PARAMETER);
BigDecimal unit = total.divide(BigDecimal.valueOf(count), 2, RoundingMode.DOWN);
BigDecimal sumBefore = unit.multiply(BigDecimal.valueOf(count - 1));
BigDecimal last = total.subtract(sumBefore);
List<ClawbackInstallmentPlanVO> plans = new ArrayList<>();
for (int i = 1; i <= count; i++) {
ClawbackInstallmentPlanVO vo = new ClawbackInstallmentPlanVO();
vo.setReceivableId(receivableId);
vo.setInstallmentNo(i);
vo.setInstallmentAmount(i == count ? last : unit);
vo.setDueMonth(DateUtil.addMonth(baseMonth, i - 1));
vo.setStatus("SCHEDULED");
plans.add(vo);
}
planMapper.insertBatch(plans);
}
/**
* 분할상환 계획 취소 (SCHEDULED → CANCELLED).
*/
@Transactional
public void cancel(Long planId) {
ClawbackInstallmentPlanResp resp = planMapper.selectById(planId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"SCHEDULED".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
planMapper.updateStatus(planId, "CANCELLED");
}
}
@@ -0,0 +1,70 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.LapsedReinstatementRecoveryMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LapsedReinstatementService {
private final LapsedReinstatementRecoveryMapper mapper;
public PageResponse<LapsedReinstatementRecoveryResp> list(LapsedReinstatementRecoverySearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public LapsedReinstatementRecoveryResp detail(Long recoveryId) {
LapsedReinstatementRecoveryResp resp = mapper.selectById(recoveryId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 부활 등록 — withheld_amount 입력, status = PENDING.
*/
@Transactional
public Long create(LapsedReinstatementRecoverySaveReq req) {
LapsedReinstatementRecoveryVO vo = new LapsedReinstatementRecoveryVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setLapsedMonth(req.getLapsedMonth());
vo.setReinstatedMonth(req.getReinstatedMonth());
vo.setWithheldAmount(req.getWithheldAmount());
vo.setRepaidAmount(java.math.BigDecimal.ZERO);
vo.setStatus("PENDING");
mapper.insert(vo);
return vo.getRecoveryId();
}
/**
* 재지급 처리 — status = REPAID, repaid_amount = withheld_amount.
*/
@Transactional
public void repay(Long recoveryId) {
LapsedReinstatementRecoveryResp resp = mapper.selectById(recoveryId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"PENDING".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
mapper.updateStatus(recoveryId, "REPAID", resp.getWithheldAmount());
}
/**
* 취소 — status = CANCELLED.
*/
@Transactional
public void cancel(Long recoveryId) {
LapsedReinstatementRecoveryResp resp = mapper.selectById(recoveryId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("REPAID".equals(resp.getStatus()) || "CANCELLED".equals(resp.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
mapper.updateStatus(recoveryId, "CANCELLED", java.math.BigDecimal.ZERO);
}
}
@@ -10,6 +10,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@@ -86,6 +89,24 @@ public class PersistencyBonusService {
*/
@Transactional
public void upsertBonus(PersistencyBonusSaveReq req) {
BigDecimal bonusAmount = req.getBonusAmount();
// 지속수당 지급 게이트: 룰이 지정되면 유지율이 임계(retention_rate_threshold) 이상이어야 지급.
// FIXED형 룰이면 지급액을 룰의 bonus_value(정액)로 도출한다.
if (req.getRuleId() != null) {
PersistencyBonusRuleResp rule = ruleMapper.selectById(req.getRuleId());
if (rule == null) throw new BizException(ErrorCode.NOT_FOUND);
if (rule.getRetentionRateThreshold() != null
&& (req.getRetentionRate() == null
|| req.getRetentionRate().compareTo(rule.getRetentionRateThreshold()) < 0)) {
throw new BizException(ErrorCode.INVALID_PARAMETER,
"유지율이 지급 임계(" + rule.getRetentionRateThreshold() + ")에 미달합니다.");
}
if ("FIXED".equals(rule.getBonusType()) && rule.getBonusValue() != null) {
bonusAmount = rule.getBonusValue().setScale(2, RoundingMode.HALF_UP);
}
}
PersistencyBonusVO vo = new PersistencyBonusVO();
vo.setAgentId(req.getAgentId());
vo.setRuleId(req.getRuleId());
@@ -93,7 +114,7 @@ public class PersistencyBonusService {
vo.setRetentionMonth(req.getRetentionMonth());
vo.setSettleMonth(req.getSettleMonth());
vo.setRetentionRate(req.getRetentionRate());
vo.setBonusAmount(req.getBonusAmount());
vo.setBonusAmount(bonusAmount);
vo.setStatus("CALCULATED");
bonusMapper.upsert(vo);
}
@@ -0,0 +1,147 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.RecruiterCommissionLedgerMapper;
import com.ga.core.mapper.commission.RecruiterCommissionRuleMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RecruiterCommissionService {
private final RecruiterCommissionRuleMapper ruleMapper;
private final RecruiterCommissionLedgerMapper ledgerMapper;
// ── Rule ──────────────────────────────────────────────────────────────────
public PageResponse<RecruiterCommissionRuleResp> listRules(RecruiterCommissionRuleSearchParam param) {
param.startPage();
return PageResponse.of(ruleMapper.selectList(param));
}
public RecruiterCommissionRuleResp detailRule(Long ruleId) {
RecruiterCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createRule(RecruiterCommissionRuleSaveReq req) {
RecruiterCommissionRuleVO vo = new RecruiterCommissionRuleVO();
vo.setRecruiterType(req.getRecruiterType());
vo.setCalcType(req.getCalcType());
vo.setCommissionRate(req.getCommissionRate());
vo.setFixedAmount(req.getFixedAmount());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.insert(vo);
return vo.getRuleId();
}
@Transactional
public void updateRule(Long ruleId, RecruiterCommissionRuleSaveReq req) {
RecruiterCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
RecruiterCommissionRuleVO vo = new RecruiterCommissionRuleVO();
vo.setRuleId(ruleId);
vo.setRecruiterType(req.getRecruiterType());
vo.setCalcType(req.getCalcType());
vo.setCommissionRate(req.getCommissionRate());
vo.setFixedAmount(req.getFixedAmount());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.update(vo);
}
@Transactional
public void deleteRule(Long ruleId) {
RecruiterCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
ruleMapper.delete(ruleId);
}
// ── Ledger ────────────────────────────────────────────────────────────────
public PageResponse<RecruiterCommissionLedgerResp> listLedgers(RecruiterCommissionLedgerSearchParam param) {
param.startPage();
return PageResponse.of(ledgerMapper.selectList(param));
}
public RecruiterCommissionLedgerResp detailLedger(Long ledgerId) {
RecruiterCommissionLedgerResp resp = ledgerMapper.selectById(ledgerId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 도입수수료 원장 생성.
* 활성 룰 목록 중 recruiterType + 오늘 기준 유효한 룰 우선 적용:
* RATE → commissionAmount = baseAmount × commissionRate
* FIXED → commissionAmount = fixedAmount
* 룰 없음 → 요청 commissionAmount 사용 (수동 등록 호환).
*/
@Transactional
public Long createLedger(RecruiterCommissionLedgerSaveReq req) {
RecruiterCommissionRuleVO activeRule = findActiveRule(req.getRecruiterType());
BigDecimal commissionAmount;
if (activeRule != null) {
if ("FIXED".equals(activeRule.getCalcType())) {
if (activeRule.getFixedAmount() == null) throw new BizException(ErrorCode.NOT_FOUND);
commissionAmount = activeRule.getFixedAmount().setScale(2, RoundingMode.HALF_UP);
} else { // RATE
BigDecimal rate = activeRule.getCommissionRate();
if (rate == null) throw new BizException(ErrorCode.NOT_FOUND);
commissionAmount = req.getBaseAmount().multiply(rate).setScale(2, RoundingMode.HALF_UP);
}
} else {
commissionAmount = req.getCommissionAmount();
if (commissionAmount == null) throw new BizException(ErrorCode.NOT_FOUND);
}
RecruiterCommissionLedgerVO vo = new RecruiterCommissionLedgerVO();
vo.setRecruiterAgentId(req.getRecruiterAgentId());
vo.setRecruitAgentId(req.getRecruitAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setRecruiterType(req.getRecruiterType());
vo.setBaseAmount(req.getBaseAmount());
vo.setCommissionAmount(commissionAmount);
vo.setStatus("CALCULATED");
ledgerMapper.insert(vo);
return vo.getLedgerId();
}
/** recruiterType + 오늘 날짜 기준 유효 룰 첫 번째 반환. */
private RecruiterCommissionRuleVO findActiveRule(String recruiterType) {
RecruiterCommissionRuleSearchParam param = new RecruiterCommissionRuleSearchParam();
param.setRecruiterType(recruiterType);
List<RecruiterCommissionRuleResp> list = ruleMapper.selectList(param);
java.time.LocalDate today = java.time.LocalDate.now();
return list.stream()
.filter(r -> !today.isBefore(r.getEffectiveFrom())
&& (r.getEffectiveTo() == null || !today.isAfter(r.getEffectiveTo())))
.findFirst()
.map(r -> {
RecruiterCommissionRuleVO vo = new RecruiterCommissionRuleVO();
vo.setRuleId(r.getRuleId());
vo.setRecruiterType(r.getRecruiterType());
vo.setCalcType(r.getCalcType());
vo.setCommissionRate(r.getCommissionRate());
vo.setFixedAmount(r.getFixedAmount());
vo.setEffectiveFrom(r.getEffectiveFrom());
vo.setEffectiveTo(r.getEffectiveTo());
return vo;
})
.orElse(null);
}
}
@@ -0,0 +1,71 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.DateUtil;
import com.ga.core.mapper.commission.ClawbackReceivableMapper;
import com.ga.core.mapper.commission.StatuteConfigMapper;
import com.ga.core.vo.commission.StatuteConfigResp;
import com.ga.core.vo.commission.StatuteConfigSaveReq;
import com.ga.core.vo.commission.StatuteConfigVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StatuteService {
private final StatuteConfigMapper configMapper;
private final ClawbackReceivableMapper receivableMapper;
public List<StatuteConfigResp> listConfigs() {
return configMapper.selectList();
}
public StatuteConfigResp detailConfig(Long configId) {
StatuteConfigResp resp = configMapper.selectById(configId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createConfig(StatuteConfigSaveReq req) {
StatuteConfigVO vo = new StatuteConfigVO();
vo.setEntityType(req.getEntityType());
vo.setExpirationMonths(req.getExpirationMonths());
vo.setIsActive(req.getIsActive() != null ? req.getIsActive() : "Y");
configMapper.insert(vo);
return vo.getConfigId();
}
@Transactional
public void updateConfig(Long configId, StatuteConfigSaveReq req) {
StatuteConfigResp resp = configMapper.selectById(configId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
StatuteConfigVO vo = new StatuteConfigVO();
vo.setConfigId(configId);
vo.setEntityType(req.getEntityType());
vo.setExpirationMonths(req.getExpirationMonths());
vo.setIsActive(req.getIsActive() != null ? req.getIsActive() : resp.getIsActive());
configMapper.update(vo);
}
/**
* 만료 대상 채권 일괄 대손 처리.
* beforeMonth = baseMonth - expiration_months (YYYYMM 연산).
* 처리 건수 반환.
*/
@Transactional
public int expireOverdue(String baseMonth) {
StatuteConfigResp config = configMapper.selectByEntityType("CLAWBACK_RECEIVABLE");
if (config == null || !"Y".equals(config.getIsActive())) {
throw new BizException(ErrorCode.NOT_FOUND);
}
String beforeMonth = DateUtil.addMonth(baseMonth, -config.getExpirationMonths());
return receivableMapper.writeOffExpired(beforeMonth, "EXPIRED");
}
}