diff --git a/.gitignore b/.gitignore
index 8995367..d7514e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,4 @@ ga-frontend/dist/
login.json
token.txt
__pycache__/
+.omc/
diff --git a/docs/DOMAIN_GAP_MOD1-8.md b/docs/DOMAIN_GAP_MOD1-8.md
new file mode 100644
index 0000000..3b49133
--- /dev/null
+++ b/docs/DOMAIN_GAP_MOD1-8.md
@@ -0,0 +1,168 @@
+# DOMAIN_GAP_MOD1-8 — 수수료 갭 8종 신규모듈 권위 스펙
+
+> 2026-06-06 갭분석 결과 "개념적으로 빠진" 수수료 모듈 8종. 업무 수치는 **데모 기본값**(운영 시 룰테이블 조정).
+> 모든 레이어 에이전트는 이 문서를 단일 진실원으로 따른다. 테이블/컬럼/Enum/경로/메뉴코드명을 **그대로** 사용.
+
+## 공통 규약 (기존 패턴 — V92/V95/V96 미러)
+- PK `BIGSERIAL`, 금액 `NUMERIC(15,2)`, 율 `NUMERIC(7,4)`(0~1 소수), 정산월 `CHAR(6)`(YYYYMM).
+- 감사: `created_at TIMESTAMP NOT NULL DEFAULT NOW()`, `created_by BIGINT`, `updated_at TIMESTAMP`.
+- 모든 테이블 `COMMENT ON TABLE/COLUMN`, 인덱스 `idx_<약어>_...`.
+- 메뉴: `menu`(parent=GRP_*, menu_type='PAGE', menu_level=2) + `menu_permission`(READ/CREATE/UPDATE/DELETE/EXPORT) + `role_menu_permission`(SUPER_ADMIN/ADMIN 전체, MANAGER READ/CREATE/UPDATE/EXPORT, AGENT READ). NOT EXISTS/ON CONFLICT 가드.
+- 공통코드: `common_code_group` + `common_code`.
+- core: `XxxVO`(전컬럼)/`XxxResp`(+조인명)/`XxxSaveReq`(입력)/`XxxSearchParam`(extends PageParam) + Mapper(.java/.xml, selectList/selectById/insert/update/delete + 도메인쿼리) + Enum(상태) + MapStruct(필요시). Mapper는 BaseMapper 상속 안 함(개별 정의, 기존 commission Mapper 미러).
+- api: `XxxService`(@Service @Transactional(readOnly=true), 쓰기 @Transactional) + `XxxController`(@RestController @RequestMapping("/api/...") @RequirePermission, @DataChangeLog 쓰기, ApiResponse/PageResponse 반환). 기존 commission Controller 미러.
+- frontend: `ga-frontend/src/pages//Xxx.tsx` + `api/.ts` 함수 + `App.tsx` 라우트. 100건↑/셀편집/합계는 AG Grid, 단순 CRUD는 Ant Design DataTable.
+- 마이그레이션 번호: MOD-1=V116, MOD-2=V117, MOD-3=V118, MOD-4=V119, MOD-5=V120, MOD-6=V121, MOD-7=V122, MOD-8=V123. (필요 시 모듈당 2파일 가능 — 예 V116_a 테이블 / 단일 파일에 메뉴·코드 포함 권장)
+
+---
+
+## MOD-1 — 슈퍼바이저/도입수수료 (recruiter/development commission) [V116]
+설계사를 GA에 **도입(리크루팅)**한 상위 설계사에게 지급하는 별도 트랙(오버라이드와 무관, recruiter 관계 기반).
+
+### DB V116
+- `ALTER TABLE agent ADD COLUMN recruiter_agent_id BIGINT REFERENCES agent(agent_id);` (도입자, NULL 허용)
+- `recruiter_commission_rule`(rule_id PK, recruiter_type VARCHAR(20) CHECK IN('NEW_RECRUIT','DEVELOPMENT','TRAINING'), calc_type VARCHAR(10) CHECK IN('RATE','FIXED'), commission_rate NUMERIC(7,4), fixed_amount NUMERIC(15,2), effective_from DATE NOT NULL, effective_to DATE, 감사컬럼). CHECK rate 0~1.
+- `recruiter_commission_ledger`(ledger_id PK, recruiter_agent_id BIGINT NOT NULL REFERENCES agent, recruit_agent_id BIGINT NOT NULL REFERENCES agent, settle_month CHAR(6) NOT NULL, recruiter_type VARCHAR(20) NOT NULL, base_amount NUMERIC(15,2) NOT NULL, commission_amount NUMERIC(15,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED' CHECK IN('CALCULATED','CONFIRMED','PAID','CANCELLED'), 감사). idx (recruiter_agent_id, settle_month).
+- 메뉴 `RECRUITER_COMM`('도입수수료', '/commission/recruiter', 'commission/RecruiterCommission', GRP_COMMISSION, sort 150) + 권한 + 공통코드 `RECRUITER_TYPE`(NEW_RECRUIT 신인도입/DEVELOPMENT 육성/TRAINING 교육).
+- 공식(데모): commission = base_amount × rule.commission_rate (RATE) / fixed_amount (FIXED). base_amount=도입 설계사의 당월 모집수수료 합(데모 입력 허용).
+
+### core/api
+- `RecruiterCommissionRule*` + `RecruiterCommissionLedger*` VO/Resp/SaveReq/SearchParam, Mapper(.java/.xml: selectList/selectById/insert/update/delete + `aggregateByAgent(settleMonth)` = SUM(commission_amount) GROUP BY recruiter_agent_id). Enum `RecruiterType`.
+- `RecruiterCommissionService`: rule CRUD + ledger list/detail/createLedger(룰 calc_type 권위, ReferralCommissionService 미러) + aggregate. Controller `/api/recruiter-commissions` (rules/ledgers).
+- **정산 반영**: `AggregateStep`에 recruiterCommissionMapper.aggregateByAgent 추가 → other_commission_total 합산(기존 9종 패턴 동일, recruiter_agent_id 기준 agentId). (지급성이므로 gross 가산.)
+
+### frontend
+- `pages/commission/RecruiterCommission.tsx`(룰 탭 + 원장 탭, 등록 모달), `api/commission.ts`에 함수 추가, App.tsx 라우트.
+
+---
+
+## MOD-2 — 선지급/차익정산 (advance payment recovery + delta settlement) [V117]
+신입/램프업 설계사에 미리 지급한 **선지급금**을 확정수수료로 우선 회수, 차액만 실지급.
+
+### DB V117 (GRP_SETTLE)
+- `advance_payment`(advance_id PK, agent_id BIGINT NOT NULL REFERENCES agent, advance_month CHAR(6) NOT NULL, principal_amount NUMERIC(15,2) NOT NULL CHECK>0, recovered_amount NUMERIC(15,2) NOT NULL DEFAULT 0, balance NUMERIC(15,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'OUTSTANDING' CHECK IN('OUTSTANDING','PARTIAL','CLEARED','WAIVED'), memo VARCHAR(200), 감사). CHECK balance>=0. idx(agent_id, status).
+- `advance_recovery_ledger`(recovery_id PK, advance_id BIGINT NOT NULL REFERENCES advance_payment, settle_month CHAR(6) NOT NULL, recovery_amount NUMERIC(15,2) NOT NULL CHECK>0, 감사). idx(advance_id), idx(settle_month).
+- 메뉴 `ADVANCE_PAYMENT`('선지급정산','/settle/advance-payment','settle/AdvancePayment', GRP_SETTLE, sort 적절) + 권한 + 공통코드 `ADVANCE_STATUS`(OUTSTANDING 미상환/PARTIAL 부분/CLEARED 완료/WAIVED 면제).
+
+### core/api/batch
+- `AdvancePayment*` + `AdvanceRecoveryLedger*` VO/Resp/SaveReq/SearchParam, Mapper. 도메인쿼리: `outstandingByAgent(agentId)`=SUM(balance) WHERE status IN('OUTSTANDING','PARTIAL'); aging은 advance_month 기준.
+- `AdvancePaymentService`: 선지급 등록(balance=principal)/조회/aging 리포트/면제(waive). Controller `/api/advance-payments`.
+- **정산 연계(AggregateStep)**: net(=tax후) 계산 직후, P9 환수상계와 **동일 위치/유사 패턴**으로 선지급 잔액 FIFO 우선상계. recovered_advance = min(payable, outstanding). 차익정산 delta = payable − recovered_advance. payable 최종 = delta. advance_recovery_ledger 기록 + advance_payment.recovered/balance/status 갱신. **멱등**: 당월 recovery reverse 후 재적용(P9 reverseClawbackRecovery 패턴). 환수상계와 선지급상계 순서: 환수(채권) → 선지급. 둘 다 payable 차감.
+- 주의: 기존 settle_master payable_amount 의미에 선지급 차감 반영. 새 컬럼 불필요(payable에 통합) — 단 advance_recovery_ledger로 추적.
+
+### frontend
+- `pages/settle/AdvancePayment.tsx`(선지급 목록+잔액/상태, 등록·면제 모달, 상계내역 Drawer), `api/settle.ts` 또는 `api/operation.ts` 함수.
+
+---
+
+## MOD-3 — 환수 분할상환 (installment clawback) [V118]
+대액 환수채권을 N개월 분할 납부 계획으로.
+
+### DB V118 (GRP_COMMISSION)
+- `clawback_installment_plan`(plan_id PK, receivable_id BIGINT NOT NULL REFERENCES clawback_receivable(receivable_id), installment_no INT NOT NULL, installment_amount NUMERIC(15,2) NOT NULL CHECK>0, due_month CHAR(6) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED' CHECK IN('SCHEDULED','RECOVERED','CANCELLED'), recovered_at TIMESTAMP, 감사). UNIQUE(receivable_id, installment_no). idx(due_month, status).
+- 메뉴 `CLAWBACK_INSTALLMENT`('환수분할상환','/commission/clawback-installment','commission/ClawbackInstallment', GRP_COMMISSION, sort) + 권한. 공통코드 `CB_INSTALLMENT_STATUS`(SCHEDULED 예정/RECOVERED 회수/CANCELLED 취소).
+
+### core/api
+- `ClawbackInstallmentPlan*` VO/Resp/SaveReq/SearchParam + Mapper(selectByReceivable, insertBatch, updateStatus).
+- `ClawbackInstallmentService`: receivable 1건 + 회차수 N → 균등분할(마지막 회차 잔차흡수, InstallmentPlanGenerator 잔차패턴) 계획 생성. due_month=기준월+회차. list/detail/generate/cancel. Controller `/api/clawback-installments`.
+- 월별 상계 연계는 데모 수준(계획 생성·조회·상태전이). (AggregateStep 통합은 선택 — 미연계 시 문서화.)
+
+### frontend
+- `pages/commission/ClawbackInstallment.tsx`(채권 선택→분할 생성, 회차 목록), `api/commission.ts`.
+
+---
+
+## MOD-4 — 익월 부활 재지급 (lapsed reinstatement repayment) [V119]
+실효(보류)된 계약이 익월 부활 시 보류했던 수수료를 make-good 재지급.
+
+### DB V119 (GRP_COMMISSION)
+- `lapsed_reinstatement_recovery`(recovery_id PK, contract_id BIGINT NOT NULL REFERENCES contract(contract_id), agent_id BIGINT NOT NULL REFERENCES agent, lapsed_month CHAR(6) NOT NULL, reinstated_month CHAR(6) NOT NULL, withheld_amount NUMERIC(15,2) NOT NULL CHECK>=0, repaid_amount NUMERIC(15,2) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK IN('PENDING','REPAID','CANCELLED'), 감사). idx(agent_id, reinstated_month), idx(contract_id).
+- 메뉴 `LAPSED_REINSTATEMENT`('익월부활재지급','/commission/lapsed-reinstatement','commission/LapsedReinstatement', GRP_COMMISSION, sort) + 권한. 공통코드 `LAPSE_RECOVERY_STATUS`(PENDING 대기/REPAID 재지급완료/CANCELLED 취소).
+
+### core/api
+- `LapsedReinstatementRecovery*` VO/Resp/SaveReq/SearchParam + Mapper(selectList/selectById/insert/updateStatus + aggregateByAgent=SUM(repaid_amount) WHERE status='REPAID' GROUP BY agent_id).
+- `LapsedReinstatementService`: 부활 등록(withheld_amount=보류액, 데모 입력)/repay(상태 REPAID, repaid=withheld)/cancel. Controller `/api/lapsed-reinstatements`.
+- **정산 반영(선택)**: aggregateByAgent를 AggregateStep other_commission_total에 가산(REPAID만). 미연계 시 문서화.
+
+### frontend
+- `pages/commission/LapsedReinstatement.tsx`, `api/commission.ts`.
+
+---
+
+## MOD-5 — 정산대사 입수 (carrier reconciliation IN) [V120]
+보험사 통보 정산자료 vs GA 원장 대사 차이 산출.
+
+### DB V120 (GRP_SETTLE)
+- `carrier_reconciliation_in`(recon_id PK, company_id BIGINT NOT NULL REFERENCES insurance_company(company_id), settle_month CHAR(6) NOT NULL, file_name VARCHAR(200), record_count INT NOT NULL DEFAULT 0, reported_total NUMERIC(15,2) NOT NULL DEFAULT 0, system_total NUMERIC(15,2) NOT NULL DEFAULT 0, variance_amount NUMERIC(15,2) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'RECEIVED' CHECK IN('RECEIVED','MATCHED','VARIANCE','APPROVED'), 감사). UNIQUE(company_id, settle_month). idx(settle_month).
+- `reconciliation_variance`(variance_id PK, recon_id BIGINT NOT NULL REFERENCES carrier_reconciliation_in, policy_no VARCHAR(50), expected_amount NUMERIC(15,2), reported_amount NUMERIC(15,2), variance_reason VARCHAR(100), 감사). idx(recon_id).
+- 메뉴 `CARRIER_RECON_IN`('정산대사입수','/settle/carrier-recon-in','settle/CarrierReconIn', GRP_SETTLE, sort) + 권한. 공통코드 `RECON_IN_STATUS`(RECEIVED 입수/MATCHED 일치/VARIANCE 차이/APPROVED 승인).
+- insurance_company PK 컬럼명 확인 후 사용(company_id 가정, 다르면 실제명).
+
+### core/api
+- `CarrierReconciliationIn*` + `ReconciliationVariance*` VO/Resp/SaveReq/SearchParam + Mapper.
+- `CarrierReconciliationInService`: 대사 등록(reported_total 입력)/reconcile(system_total=settle_master 또는 income_commission 집계, variance=reported−system, status 자동 MATCHED/VARIANCE)/approve. Controller `/api/carrier-recon-in`.
+
+### frontend
+- `pages/settle/CarrierReconIn.tsx`(대사 목록 + 차이내역 Drawer), `api/settle.ts`/`operation.ts`.
+
+---
+
+## MOD-6 — 환수 시효/소멸 (statute of limitations) [V121]
+오래된 미결 환수채권을 만료개월 경과 시 자동 대손(WRITTEN_OFF/EXPIRED).
+
+### DB V121 (GRP_COMMISSION)
+- `statute_config`(config_id PK, entity_type VARCHAR(30) NOT NULL UNIQUE CHECK IN('CLAWBACK_RECEIVABLE'), expiration_months INT NOT NULL CHECK>0, is_active CHAR(1) NOT NULL DEFAULT 'Y', 감사). 시드 1행: ('CLAWBACK_RECEIVABLE', 36, 'Y').
+- 메뉴 `STATUTE_CONFIG`('환수시효관리','/commission/statute','commission/StatuteConfig', GRP_COMMISSION, sort) + 권한.
+- (별도 status 코드 불필요 — 기존 clawback_receivable status에 WRITTEN_OFF 존재 가정. 없으면 사유코드만 추가.)
+
+### core/api
+- `StatuteConfig*` VO/Resp/SaveReq + Mapper. clawback_receivable에 만료대상 조회/대손처리 쿼리(`selectExpired(beforeMonth)`, `writeOffExpired`) 추가(기존 ClawbackReceivableMapper에).
+- `StatuteService`: config CRUD + `expireOverdue(baseMonth)` = origin_settle_month <= baseMonth−expiration_months AND status='OUTSTANDING' → WRITTEN_OFF(reason EXPIRED) + 카운트. Controller `/api/statute` (config + POST expire).
+
+### frontend
+- `pages/commission/StatuteConfig.tsx`(설정 + 만료실행 버튼 + 결과), `api/commission.ts`.
+
+---
+
+## MOD-7 — 환수 감면/면제 (chargeback waiver/reduction) [V122]
+재직 설계사 환수에 대한 감면 신청·승인 워크플로.
+
+### DB V122 (GRP_COMMISSION)
+- `chargeback_waiver_request`(request_id PK, contract_id BIGINT NOT NULL REFERENCES contract, agent_id BIGINT NOT NULL REFERENCES agent, settle_month CHAR(6) NOT NULL, original_chargeback NUMERIC(15,2) NOT NULL CHECK>=0, waiver_amount NUMERIC(15,2) NOT NULL CHECK>=0, reason VARCHAR(200), approval_status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED' CHECK IN('REQUESTED','APPROVED','REJECTED'), approver_id BIGINT, approved_at TIMESTAMP, 감사). CHECK waiver_amount<=original_chargeback. idx(agent_id, settle_month).
+- 메뉴 `CHARGEBACK_WAIVER`('환수감면신청','/commission/chargeback-waiver','commission/ChargebackWaiver', GRP_COMMISSION, sort) + 권한. 공통코드 `WAIVER_STATUS`(REQUESTED 신청/APPROVED 승인/REJECTED 반려).
+
+### core/api
+- `ChargebackWaiverRequest*` VO/Resp/SaveReq/SearchParam + Mapper.
+- `ChargebackWaiverService`: 신청(create)/approve(승인 시 approval_status, approver/approved_at; 환수액 조정은 데모 수준 — 승인분 추적)/reject. waiver<=original 검증. Controller `/api/chargeback-waivers`.
+
+### frontend
+- `pages/commission/ChargebackWaiver.tsx`(신청 목록 + 승인/반려 액션), `api/commission.ts`.
+
+---
+
+## MOD-8 — 채널별 수수료 차등 (channel-specific commission) [V123]
+판매채널(직판/방카/홈쇼핑/제휴)별 요율 차등. **하위호환 필수**(channel NULL = 기존 동작).
+
+### DB V123
+- `ALTER TABLE contract ADD COLUMN sales_channel VARCHAR(20);` (NULL 허용, NULL=DIRECT 취급)
+- 공통코드 `SALES_CHANNEL`(DIRECT 직판/BANCASSURANCE 방카/HOME_SHOPPING 홈쇼핑/PARTNERSHIP 제휴).
+- commission_rate / payout_rule 룰 테이블에 `sales_channel VARCHAR(20)` 컬럼 추가(NULL=모든채널). **실제 룰 테이블명·조회 SQL 확인 필수**(CommissionCalculator가 쓰는 rate 조회). 조회 우선순위: channel 일치 룰 우선, 없으면 channel IS NULL 룰 폴백.
+- 메뉴 신규 불필요(계약 화면에서 노출). 단 계약 등록/조회에 sales_channel 추가.
+
+### core/api/batch
+- ContractVO/Resp/SaveReq에 salesChannel. ContractMapper.xml insert/update/select에 sales_channel.
+- rate 조회 Mapper SQL에 channel 파라미터 추가(`AND (sales_channel = #{channel} OR sales_channel IS NULL) ORDER BY sales_channel NULLS LAST` 패턴으로 channel 우선). CommissionCalculator가 contract.salesChannel 전달.
+- **하위호환 회귀 필수 검증**: channel NULL 계약은 기존과 동일 rate. 기존 CommissionCalculatorTest 통과 유지.
+
+### frontend
+- 계약 페이지(`pages/contracts/...` 또는 product)에서 sales_channel select 노출(공통코드 SALES_CHANNEL).
+
+---
+
+## 실행 순서 (5-Wave, 레이어별 병렬)
+1. **DBA**: V116~V123 작성 (dba-architect). → SQL 파싱/리뷰.
+2. **CORE**: 8모듈 VO/Mapper/Enum/MapStruct (core-architect). → `:ga-core:compileJava` GREEN.
+3. **API+BATCH**: Service/Controller (api-developer) ‖ AggregateStep/배치 연계 (batch-engineer). → `:ga-api:compileJava :ga-batch:compileJava` GREEN.
+4. **FRONTEND**: 화면+api+라우트 (frontend-developer). → `tsc` GREEN.
+5. **INFRA/검증**: `./gradlew build`(test) GREEN + Flyway 적용 + 스모크 (PL + infra-reviewer).
+- 각 Wave 후 PL이 빌드로 직접 검증. 컬럼/이름 불일치는 PL이 즉시 수정.
diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java b/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java
new file mode 100644
index 0000000..0724cd6
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java
@@ -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> list(
+ @ModelAttribute AdvancePaymentSearchParam param) {
+ return ApiResponse.ok(service.list(param));
+ }
+
+ @GetMapping("/{advanceId}")
+ @RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ")
+ public ApiResponse 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 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 waive(@PathVariable Long advanceId) {
+ service.waive(advanceId);
+ return ApiResponse.ok();
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java b/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java
new file mode 100644
index 0000000..f52eaef
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java
@@ -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> list(
+ @ModelAttribute CarrierReconciliationInSearchParam param) {
+ return ApiResponse.ok(service.list(param));
+ }
+
+ @GetMapping("/{reconId}")
+ @RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ")
+ public ApiResponse 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 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 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 approve(@PathVariable Long reconId) {
+ service.approve(reconId);
+ return ApiResponse.ok();
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java
new file mode 100644
index 0000000..058474a
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java
@@ -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> list(
+ @ModelAttribute ChargebackWaiverRequestSearchParam param) {
+ return ApiResponse.ok(service.list(param));
+ }
+
+ @GetMapping("/{requestId}")
+ @RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ")
+ public ApiResponse 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 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 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 reject(@PathVariable Long requestId) {
+ service.reject(requestId);
+ return ApiResponse.ok();
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java
new file mode 100644
index 0000000..15bbdcc
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java
@@ -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> list(
+ @ModelAttribute ClawbackInstallmentPlanSearchParam param) {
+ return ApiResponse.ok(service.list(param));
+ }
+
+ @GetMapping("/{planId}")
+ @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
+ public ApiResponse detail(@PathVariable Long planId) {
+ return ApiResponse.ok(service.detail(planId));
+ }
+
+ @GetMapping("/by-receivable/{receivableId}")
+ @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
+ public ApiResponse> 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 generate(@PathVariable Long receivableId,
+ @RequestBody Map 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 cancel(@PathVariable Long planId) {
+ service.cancel(planId);
+ return ApiResponse.ok();
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java b/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java
new file mode 100644
index 0000000..d9a18b2
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java
@@ -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> list(
+ @ModelAttribute LapsedReinstatementRecoverySearchParam param) {
+ return ApiResponse.ok(service.list(param));
+ }
+
+ @GetMapping("/{recoveryId}")
+ @RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ")
+ public ApiResponse 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 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 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 cancel(@PathVariable Long recoveryId) {
+ service.cancel(recoveryId);
+ return ApiResponse.ok();
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java b/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java
new file mode 100644
index 0000000..aba7fb7
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java
@@ -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> listRules(
+ @ModelAttribute RecruiterCommissionRuleSearchParam param) {
+ return ApiResponse.ok(service.listRules(param));
+ }
+
+ @GetMapping("/rules/{ruleId}")
+ @RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
+ public ApiResponse 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 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 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 deleteRule(@PathVariable Long ruleId) {
+ service.deleteRule(ruleId);
+ return ApiResponse.ok();
+ }
+
+ // ── Ledger ────────────────────────────────────────────────────────────────
+
+ @GetMapping("/ledgers")
+ @RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
+ public ApiResponse> listLedgers(
+ @ModelAttribute RecruiterCommissionLedgerSearchParam param) {
+ return ApiResponse.ok(service.listLedgers(param));
+ }
+
+ @GetMapping("/ledgers/{ledgerId}")
+ @RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
+ public ApiResponse 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 createLedger(@Valid @RequestBody RecruiterCommissionLedgerSaveReq req) {
+ return ApiResponse.ok(service.createLedger(req));
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java b/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java
new file mode 100644
index 0000000..0d319d9
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java
@@ -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() {
+ return ApiResponse.ok(service.listConfigs());
+ }
+
+ @GetMapping("/{configId}")
+ @RequirePermission(menu = "STATUTE_CONFIG", perm = "READ")
+ public ApiResponse 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 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 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 expire(@RequestBody Map body) {
+ String baseMonth = body.get("baseMonth");
+ if (baseMonth == null || baseMonth.isBlank()) {
+ baseMonth = com.ga.common.util.DateUtil.getSettleMonth();
+ }
+ return ApiResponse.ok(service.expireOverdue(baseMonth));
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java b/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java
new file mode 100644
index 0000000..cddb6be
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java
@@ -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 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;
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java b/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java
new file mode 100644
index 0000000..e8bf239
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java
@@ -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 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;
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java b/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java
new file mode 100644
index 0000000..75f4a9b
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java
@@ -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 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());
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java
new file mode 100644
index 0000000..36f6f17
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java
@@ -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 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 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 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 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");
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java b/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java
new file mode 100644
index 0000000..6efe8df
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java
@@ -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 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);
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java b/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java
index 9117ef9..cf63c57 100644
--- a/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java
+++ b/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java
@@ -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);
}
diff --git a/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java b/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java
new file mode 100644
index 0000000..5d8020c
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java
@@ -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 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 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 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);
+ }
+}
diff --git a/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java b/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java
new file mode 100644
index 0000000..a3cc083
--- /dev/null
+++ b/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java
@@ -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 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");
+ }
+}
diff --git a/ga-api/src/test/java/com/ga/api/service/commission/PersistencyBonusServiceTest.java b/ga-api/src/test/java/com/ga/api/service/commission/PersistencyBonusServiceTest.java
new file mode 100644
index 0000000..c9c9ff4
--- /dev/null
+++ b/ga-api/src/test/java/com/ga/api/service/commission/PersistencyBonusServiceTest.java
@@ -0,0 +1,89 @@
+package com.ga.api.service.commission;
+
+import com.ga.common.exception.BizException;
+import com.ga.core.mapper.commission.PersistencyBonusMapper;
+import com.ga.core.mapper.commission.PersistencyBonusRuleMapper;
+import com.ga.core.vo.commission.PersistencyBonusRuleResp;
+import com.ga.core.vo.commission.PersistencyBonusSaveReq;
+import com.ga.core.vo.commission.PersistencyBonusVO;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PersistencyBonusServiceTest {
+
+ @Mock PersistencyBonusRuleMapper ruleMapper;
+ @Mock PersistencyBonusMapper bonusMapper;
+
+ @InjectMocks PersistencyBonusService service;
+
+ private PersistencyBonusRuleResp rule(String bonusType, String threshold, String bonusValue) {
+ PersistencyBonusRuleResp r = new PersistencyBonusRuleResp();
+ r.setRuleId(7L);
+ r.setBonusType(bonusType);
+ r.setRetentionRateThreshold(threshold != null ? new BigDecimal(threshold) : null);
+ r.setBonusValue(bonusValue != null ? new BigDecimal(bonusValue) : null);
+ return r;
+ }
+
+ private PersistencyBonusSaveReq req(String retentionRate, String bonusAmount) {
+ PersistencyBonusSaveReq req = new PersistencyBonusSaveReq();
+ req.setAgentId(1L);
+ req.setRuleId(7L);
+ req.setBaseMonth("202506");
+ req.setRetentionMonth(13);
+ req.setSettleMonth("202506");
+ req.setRetentionRate(retentionRate != null ? new BigDecimal(retentionRate) : null);
+ req.setBonusAmount(bonusAmount != null ? new BigDecimal(bonusAmount) : null);
+ return req;
+ }
+
+ @Test
+ @DisplayName("유지율이 임계 미달이면 지급 차단(BizException), 원장 미생성")
+ void belowThreshold_rejected() {
+ when(ruleMapper.selectById(7L)).thenReturn(rule("RATE", "0.90", null));
+
+ assertThatThrownBy(() -> service.upsertBonus(req("0.85", "30000")))
+ .isInstanceOf(BizException.class);
+ verify(bonusMapper, never()).upsert(any());
+ }
+
+ @Test
+ @DisplayName("유지율이 임계 이상이면 정상 지급(경계=이상)")
+ void atThreshold_ok() {
+ when(ruleMapper.selectById(7L)).thenReturn(rule("RATE", "0.90", null));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PersistencyBonusVO.class);
+ service.upsertBonus(req("0.90", "30000"));
+
+ verify(bonusMapper).upsert(captor.capture());
+ assertThat(captor.getValue().getBonusAmount()).isEqualByComparingTo("30000");
+ }
+
+ @Test
+ @DisplayName("FIXED형 룰이면 지급액을 룰의 bonus_value(정액)로 도출")
+ void fixedRule_derivesAmountFromRule() {
+ when(ruleMapper.selectById(7L)).thenReturn(rule("FIXED", "0.90", "50000"));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(PersistencyBonusVO.class);
+ // 요청 bonusAmount(999)는 무시되고 룰 정액 50000 으로 대체
+ service.upsertBonus(req("0.95", "999"));
+
+ verify(bonusMapper).upsert(captor.capture());
+ assertThat(captor.getValue().getBonusAmount()).isEqualByComparingTo("50000.00");
+ }
+}
diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java
index 50b6206..f88b7c8 100644
--- a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java
+++ b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java
@@ -50,7 +50,7 @@ public class CommissionCalculator {
if (agent == null) return null;
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
- BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate);
+ BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate, c.getSalesChannel());
if (companyRate == null) {
log.warn("[수수료율 누락] company_rate 없음 → 0 폴백(과소지급 위험): contractId={}, productId={}, year={}, baseDate={}",
c.getContractId(), c.getProductId(), year, baseDate);
@@ -58,7 +58,7 @@ public class CommissionCalculator {
}
BigDecimal payoutRate = ruleMapper.findPayoutRate(
- agent.getGradeId(), c.getInsuranceType(), year, baseDate);
+ agent.getGradeId(), c.getInsuranceType(), year, baseDate, c.getSalesChannel());
if (payoutRate == null) {
log.warn("[수수료율 누락] payout_rate 없음 → 0 폴백(과소지급 위험): contractId={}, gradeId={}, insuranceType={}, year={}, baseDate={}",
c.getContractId(), agent.getGradeId(), c.getInsuranceType(), year, baseDate);
diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java
index d5c436f..a761347 100644
--- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java
+++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java
@@ -40,6 +40,9 @@ public class BatchInstallmentPlanGenerator {
List plans = new ArrayList<>();
for (int i = 0; i < ratios.size(); i++) {
InstallmentRatioVO ratio = ratios.get(i);
+ // 마스터의 plan_month 가 NULL 이면 연 단위(첫 달 지급), NOT NULL 이면 그 회차(월) 그대로 사용.
+ int planMonth = ratio.getPlanMonth() != null ? ratio.getPlanMonth() : 1;
+
BigDecimal amount;
if (fullSchedule && i == ratios.size() - 1) {
amount = totalCommission.subtract(allocated);
@@ -52,8 +55,10 @@ public class BatchInstallmentPlanGenerator {
InstallmentPlanVO plan = new InstallmentPlanVO();
plan.setContractId(contractId);
plan.setPlanYear(ratio.getPlanYear());
- plan.setPlanMonth(1);
- plan.setSettleMonth(contractDate.plusYears(ratio.getPlanYear()).format(MONTH_FMT));
+ plan.setPlanMonth(planMonth);
+ plan.setSettleMonth(
+ contractDate.plusYears(ratio.getPlanYear()).plusMonths(planMonth - 1L).format(MONTH_FMT)
+ );
plan.setExpectedAmount(amount);
plan.setStatus(InstallmentStatus.SCHEDULED.code());
plans.add(plan);
diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java
index 63cd668..bd5f49a 100644
--- a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java
+++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java
@@ -4,13 +4,17 @@ import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.domain.BatchTaxCalculator;
import com.ga.batch.settlement.domain.TaxBreakdown;
import com.ga.common.util.MoneyUtil;
+import com.ga.core.mapper.commission.AdvancePaymentMapper;
+import com.ga.core.mapper.commission.AdvanceRecoveryLedgerMapper;
import com.ga.core.mapper.commission.AwardLedgerMapper;
import com.ga.core.mapper.commission.BranchMgrAllowanceLedgerMapper;
import com.ga.core.mapper.commission.ClawbackReceivableMapper;
import com.ga.core.mapper.commission.ClawbackRecoveryMapper;
import com.ga.core.mapper.commission.CollectionCommissionLedgerMapper;
+import com.ga.core.mapper.commission.LapsedReinstatementRecoveryMapper;
import com.ga.core.mapper.commission.LifeSupportLedgerMapper;
import com.ga.core.mapper.commission.PersistencyBonusMapper;
+import com.ga.core.mapper.commission.RecruiterCommissionLedgerMapper;
import com.ga.core.mapper.commission.ReferralCommissionLedgerMapper;
import com.ga.core.mapper.commission.ReinstatementCommissionLedgerMapper;
import com.ga.core.mapper.commission.RenewalCommissionLedgerMapper;
@@ -24,6 +28,8 @@ import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.mapper.settle.CommissionStatementMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
+import com.ga.core.vo.commission.AdvancePaymentVO;
+import com.ga.core.vo.commission.AdvanceRecoveryLedgerVO;
import com.ga.core.vo.commission.ClawbackReceivableResp;
import com.ga.core.vo.commission.ClawbackReceivableVO;
import com.ga.core.vo.commission.ClawbackRecoveryVO;
@@ -96,6 +102,14 @@ public class AggregateStep implements Tasklet {
private final LifeSupportLedgerMapper lifeSupportMapper;
private final BranchMgrAllowanceLedgerMapper branchMgrAllowanceMapper;
+ // MOD-1 도입수수료 / MOD-4 익월부활재지급
+ private final RecruiterCommissionLedgerMapper recruiterCommissionMapper;
+ private final LapsedReinstatementRecoveryMapper lapsedReinstatementMapper;
+
+ // MOD-2 선지급 상계
+ private final AdvancePaymentMapper advancePaymentMapper;
+ private final AdvanceRecoveryLedgerMapper advanceRecoveryMapper;
+
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth());
@@ -125,6 +139,11 @@ public class AggregateStep implements Tasklet {
Map lifeSupportMap = toMap(lifeSupportMapper.aggregateByAgent(ctx.getSettleMonth()));
Map branchMgrMap = toMap(branchMgrAllowanceMapper.aggregateByAgent(ctx.getSettleMonth()));
+ // MOD-1 도입수수료 (recruiter_agent_id 기준 agentId)
+ Map recruiterCommMap = toMap(recruiterCommissionMapper.aggregateByAgent(ctx.getSettleMonth()));
+ // MOD-4 익월부활재지급 (REPAID 상태만)
+ Map lapsedReinstatMap = toMap(lapsedReinstatementMapper.aggregateByAgent(ctx.getSettleMonth()));
+
// 합집합 agent_id
Set agentIds = new HashSet<>();
agentIds.addAll(recruitMap.keySet());
@@ -142,6 +161,8 @@ public class AggregateStep implements Tasklet {
agentIds.addAll(awardMap.keySet());
agentIds.addAll(lifeSupportMap.keySet());
agentIds.addAll(branchMgrMap.keySet());
+ agentIds.addAll(recruiterCommMap.keySet());
+ agentIds.addAll(lapsedReinstatMap.keySet());
// 활성 설계사 누락 보충
AgentSearchParam ap = new AgentSearchParam();
@@ -168,6 +189,9 @@ public class AggregateStep implements Tasklet {
// 동일 월 재실행 시 중복 회수가 발생하지 않도록 보장한다.
reverseClawbackRecovery(ctx.getSettleMonth());
+ // MOD-2 멱등 reverse: 선지급 상계 이력 역산 후 advance_payment balance 복원
+ reverseAdvanceRecovery(ctx.getSettleMonth());
+
for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
@@ -187,6 +211,8 @@ public class AggregateStep implements Tasklet {
otherCommission = MoneyUtil.add(otherCommission, awardMap.getOrDefault(agentId, BigDecimal.ZERO));
otherCommission = MoneyUtil.add(otherCommission, lifeSupportMap.getOrDefault(agentId, BigDecimal.ZERO));
otherCommission = MoneyUtil.add(otherCommission, branchMgrMap.getOrDefault(agentId, BigDecimal.ZERO));
+ otherCommission = MoneyUtil.add(otherCommission, recruiterCommMap.getOrDefault(agentId, BigDecimal.ZERO));
+ otherCommission = MoneyUtil.add(otherCommission, lapsedReinstatMap.getOrDefault(agentId, BigDecimal.ZERO));
BigDecimal gross = MoneyUtil.add(MoneyUtil.add(MoneyUtil.add(recruit, maintain), ov), ep)
.subtract(cb).subtract(em);
@@ -230,6 +256,17 @@ public class AggregateStep implements Tasklet {
log.info("[Step8 clawback] agentId={}, net={}, priorBalance={}, recovered={}, carryover={}, payable={}",
agentId, netAmount, priorBalance, recovered, carryover, payable);
+ // MOD-2 선지급 FIFO 상계: 환수채권 상계 후 payable에서 추가 차감
+ BigDecimal advanceOutstanding = advancePaymentMapper.outstandingByAgent(agentId);
+ if (advanceOutstanding == null) advanceOutstanding = BigDecimal.ZERO;
+ if (advanceOutstanding.signum() > 0 && payable.signum() > 0) {
+ BigDecimal recoveredAdvance = payable.min(advanceOutstanding);
+ payable = payable.subtract(recoveredAdvance);
+ applyAdvanceRecovery(agentId, ctx.getSettleMonth(), recoveredAdvance);
+ log.info("[Step8 advance] agentId={}, advanceOutstanding={}, recoveredAdvance={}, payable={}",
+ agentId, advanceOutstanding, recoveredAdvance, payable);
+ }
+
// settle_master UPSERT
SettleMasterVO vo = new SettleMasterVO();
vo.setAgentId(agentId);
@@ -312,6 +349,74 @@ public class AggregateStep implements Tasklet {
return RepeatStatus.FINISHED;
}
+ /**
+ * MOD-2 멱등 reverse: settleMonth에 기록된 advance_recovery_ledger를 조회하여
+ * 각 advance_payment의 balance/recoveredAmount/status를 복원한 뒤 이력을 삭제한다.
+ * P9 reverseClawbackRecovery와 동일 사상.
+ */
+ private void reverseAdvanceRecovery(String settleMonth) {
+ // 당월 상계 이력이 있는 선지급금만 월 단위로 역산(P9 reverseClawbackRecovery와 동일 사상).
+ for (Long advanceId : advanceRecoveryMapper.selectAdvanceIdsBySettleMonth(settleMonth)) {
+ List prevLedgers =
+ advanceRecoveryMapper.selectBySettleMonth(advanceId, settleMonth);
+ if (prevLedgers.isEmpty()) continue;
+
+ BigDecimal toRestore = prevLedgers.stream()
+ .map(com.ga.core.vo.commission.AdvanceRecoveryLedgerVO::getRecoveryAmount)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ com.ga.core.vo.commission.AdvancePaymentResp ap = advancePaymentMapper.selectById(advanceId);
+ if (ap == null) continue;
+
+ BigDecimal newRecovered = ap.getRecoveredAmount().subtract(toRestore);
+ BigDecimal newBalance = ap.getBalance().add(toRestore);
+ String newStatus = newRecovered.signum() == 0 ? "OUTSTANDING" : "PARTIAL";
+
+ AdvancePaymentVO updated = new AdvancePaymentVO();
+ updated.setAdvanceId(advanceId);
+ updated.setRecoveredAmount(newRecovered);
+ updated.setBalance(newBalance);
+ updated.setStatus(newStatus);
+ updated.setMemo(ap.getMemo());
+ advancePaymentMapper.update(updated);
+
+ advanceRecoveryMapper.deleteBySettleMonth(advanceId, settleMonth);
+ }
+ }
+
+ /**
+ * MOD-2 FIFO 선지급 상계 적용: 오래된 advance_month 순으로 balance를 차감하고
+ * advance_recovery_ledger 이력을 INSERT한다.
+ */
+ private void applyAdvanceRecovery(Long agentId, String settleMonth, BigDecimal recoveredTotal) {
+ List fifo = advancePaymentMapper.selectOutstandingFifo(agentId);
+ BigDecimal remaining = recoveredTotal;
+ for (AdvancePaymentVO ap : fifo) {
+ if (remaining.signum() == 0) break;
+
+ BigDecimal take = remaining.min(ap.getBalance());
+ BigDecimal newRecovered = ap.getRecoveredAmount().add(take);
+ BigDecimal newBalance = ap.getBalance().subtract(take);
+ String newStatus = newBalance.signum() == 0 ? "CLEARED" : "PARTIAL";
+
+ AdvancePaymentVO updated = new AdvancePaymentVO();
+ updated.setAdvanceId(ap.getAdvanceId());
+ updated.setRecoveredAmount(newRecovered);
+ updated.setBalance(newBalance);
+ updated.setStatus(newStatus);
+ updated.setMemo(ap.getMemo());
+ advancePaymentMapper.update(updated);
+
+ AdvanceRecoveryLedgerVO ledger = new AdvanceRecoveryLedgerVO();
+ ledger.setAdvanceId(ap.getAdvanceId());
+ ledger.setSettleMonth(settleMonth);
+ ledger.setRecoveryAmount(take);
+ advanceRecoveryMapper.insert(ledger);
+
+ remaining = remaining.subtract(take);
+ }
+ }
+
/**
* 멱등 reverse: settleMonth의 기존 clawback_recovery 이력을 조회하여
* 각 receivable의 balance/recoveredAmount/status를 복원한 뒤 회수 이력을 전량 삭제한다.
diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java
index 6cdd6b3..1d7d559 100644
--- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java
+++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java
@@ -75,32 +75,43 @@ public class CalcRecruitStep implements Tasklet {
LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth());
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
- // 1200%룰 검증 (1차년도 한도)
+ // 1200%룰 검증: 1차년 한도(FIRST_YEAR) + 누적 한도(TOTAL 1200%) 중
+ // 더 제한적인 한도로 당월 지급액을 clip, 초과분은 분급(7년)으로 이연.
if (c.getContractDate() != null) {
+ BigDecimal originalAmount = led.getAgentAmount();
+ BigDecimal allowed = originalAmount;
+
ViolationResult firstYearCheck = regulatoryChecker.checkFirstYear(
- c.getContractId(), led.getAgentAmount(), c.getContractDate());
-
+ c.getContractId(), originalAmount, c.getContractDate());
if (firstYearCheck.violated()) {
- violationCount++;
-
- // 1차년 한도 잔여분까지만 당월 지급, 초과분은 분급(7년)으로 이연.
- BigDecimal allowed = MoneyUtil.sub(
+ BigDecimal fyAllowed = MoneyUtil.sub(
firstYearCheck.limitAmount(), firstYearCheck.currentTotal());
- if (allowed.signum() < 0) allowed = BigDecimal.ZERO;
- BigDecimal excess = MoneyUtil.sub(led.getAgentAmount(), allowed);
+ if (fyAllowed.signum() < 0) fyAllowed = BigDecimal.ZERO;
+ allowed = allowed.min(fyAllowed);
+ }
- if (excess.signum() > 0) {
- // 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3)
- List plans = installmentGenerator.generate(
- c.getContractId(), excess,
- c.getContractDate(), c.getInsuranceType());
- installmentBatch.addAll(plans);
- // 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산
- led.setAgentAmount(allowed);
- led.setTaxAmount(MoneyUtil.tax(allowed));
- log.warn("[Step4] 1200%룰 1차년 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}",
- c.getContractId(), allowed, excess);
- }
+ ViolationResult totalCheck = regulatoryChecker.checkContractTotal(
+ c.getContractId(), originalAmount, c.getContractDate());
+ if (totalCheck.violated()) {
+ BigDecimal totAllowed = MoneyUtil.sub(
+ totalCheck.limitAmount(), totalCheck.currentTotal());
+ if (totAllowed.signum() < 0) totAllowed = BigDecimal.ZERO;
+ allowed = allowed.min(totAllowed);
+ }
+
+ BigDecimal excess = MoneyUtil.sub(originalAmount, allowed);
+ if (excess.signum() > 0) {
+ violationCount++;
+ // 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3)
+ List plans = installmentGenerator.generate(
+ c.getContractId(), excess,
+ c.getContractDate(), c.getInsuranceType());
+ installmentBatch.addAll(plans);
+ // 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산
+ led.setAgentAmount(allowed);
+ led.setTaxAmount(MoneyUtil.tax(allowed));
+ log.warn("[Step4] 1200%룰 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}, firstYear={}, total={}",
+ c.getContractId(), allowed, excess, firstYearCheck.violated(), totalCheck.violated());
}
}
diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/calc/CommissionCalculatorTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/calc/CommissionCalculatorTest.java
index 6f229e6..69c634e 100644
--- a/ga-batch/src/test/java/com/ga/batch/settlement/calc/CommissionCalculatorTest.java
+++ b/ga-batch/src/test/java/com/ga/batch/settlement/calc/CommissionCalculatorTest.java
@@ -54,9 +54,9 @@ class CommissionCalculatorTest {
@Test
@DisplayName("calcRecruitLedger: premium × company_rate × payout_rate, 세금은 3.3%")
void recruit_basic() {
- when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class)))
+ when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("7.5"));
- when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
+ when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("60.0"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
@@ -83,12 +83,30 @@ class CommissionCalculatorTest {
assertThat(led.getPayMethod()).isEqualTo("MONTHLY");
}
+ @Test
+ @DisplayName("MOD-8 채널요율: 계약 sales_channel 이 요율 조회로 전달된다 (채널별 룰 선택)")
+ void recruit_passesSalesChannel() {
+ contract.setSalesChannel("BANCASSURANCE");
+ // 채널 일치 요율(방카)만 stub — 채널이 전달되지 않으면 매칭 실패로 0 폴백됨
+ when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), eq("BANCASSURANCE")))
+ .thenReturn(new BigDecimal("5.0"));
+ when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), eq("BANCASSURANCE")))
+ .thenReturn(new BigDecimal("80.0"));
+
+ LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
+
+ assertThat(led.getCompanyRate()).isEqualByComparingTo("5.0");
+ assertThat(led.getPayoutRate()).isEqualByComparingTo("80.0");
+ // 1,000,000 × 5% = 50,000 → × 80% = 40,000 (채널 요율이 실제로 적용됨)
+ assertThat(led.getAgentAmount()).isEqualByComparingTo("40000.00");
+ }
+
@Test
@DisplayName("calcMaintainLedger: 회차에 따라 commission_year 가 다름")
void maintain_year3() {
- when(ruleMapper.findCompanyRate(eq(1L), eq(3), any(LocalDate.class)))
+ when(ruleMapper.findCompanyRate(eq(1L), eq(3), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("3.0"));
- when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(3), any(LocalDate.class)))
+ when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(3), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("50.0"));
LedgerVO led = calculator.calcMaintainLedger(contract, "202405", 3);
@@ -115,8 +133,8 @@ class CommissionCalculatorTest {
@Test
@DisplayName("규정 누락 시 0 으로 폴백 → 모든 금액 0")
void missingRates_fallbackZero() {
- when(ruleMapper.findCompanyRate(any(), any(), any())).thenReturn(null);
- when(ruleMapper.findPayoutRate(any(), any(), any(), any())).thenReturn(null);
+ when(ruleMapper.findCompanyRate(any(), any(), any(), any())).thenReturn(null);
+ when(ruleMapper.findPayoutRate(any(), any(), any(), any(), any())).thenReturn(null);
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
@@ -132,9 +150,9 @@ class CommissionCalculatorTest {
@DisplayName("contractDate 가 null 이면 오늘 날짜로 대체 (예외 없음)")
void nullContractDate_usesToday() {
contract.setContractDate(null);
- when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class)))
+ when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("5"));
- when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
+ when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("100"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
@@ -148,8 +166,8 @@ class CommissionCalculatorTest {
@DisplayName("premium 이 null 이면 0 으로 처리 (HALF_UP 0.00)")
void nullPremium() {
contract.setPremium(null);
- when(ruleMapper.findCompanyRate(any(), any(), any())).thenReturn(new BigDecimal("7.5"));
- when(ruleMapper.findPayoutRate(any(), any(), any(), any())).thenReturn(new BigDecimal("60"));
+ when(ruleMapper.findCompanyRate(any(), any(), any(), any())).thenReturn(new BigDecimal("7.5"));
+ when(ruleMapper.findPayoutRate(any(), any(), any(), any(), any())).thenReturn(new BigDecimal("60"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGeneratorTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGeneratorTest.java
new file mode 100644
index 0000000..6a25707
--- /dev/null
+++ b/ga-batch/src/test/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGeneratorTest.java
@@ -0,0 +1,73 @@
+package com.ga.batch.settlement.domain;
+
+import com.ga.core.mapper.installment.InstallmentRatioMapper;
+import com.ga.core.vo.installment.InstallmentPlanVO;
+import com.ga.core.vo.installment.InstallmentRatioVO;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * 분급 계획 생성기 — plan_month 반영 검증 (API InstallmentPlanGenerator와 동일 출력).
+ */
+@ExtendWith(MockitoExtension.class)
+class BatchInstallmentPlanGeneratorTest {
+
+ @Mock InstallmentRatioMapper ratioMapper;
+ @InjectMocks BatchInstallmentPlanGenerator generator;
+
+ static final LocalDate CONTRACT_DATE = LocalDate.of(2024, 1, 15);
+
+ private InstallmentRatioVO ratio(int year, Integer month, String pct) {
+ InstallmentRatioVO r = new InstallmentRatioVO();
+ r.setPlanYear(year);
+ r.setPlanMonth(month);
+ r.setRatioPercent(new BigDecimal(pct));
+ return r;
+ }
+
+ @Test
+ @DisplayName("plan_month 가 설정되면 settleMonth = 계약일 +N년 +(plan_month-1)월")
+ void usesPlanMonthInSettleMonth() {
+ // 1차년 4월차 100% (단일 회차)
+ when(ratioMapper.selectAllActiveRatios(anyString(), anyString()))
+ .thenReturn(List.of(ratio(1, 4, "100")));
+
+ List plans = generator.generate(
+ 100L, new BigDecimal("700000"), CONTRACT_DATE, "WHOLE_LIFE");
+
+ assertThat(plans).hasSize(1);
+ InstallmentPlanVO p = plans.get(0);
+ assertThat(p.getPlanMonth()).isEqualTo(4);
+ // 2024-01 +1년 +3개월 = 2025-04
+ assertThat(p.getSettleMonth()).isEqualTo("202504");
+ assertThat(p.getExpectedAmount()).isEqualByComparingTo("700000");
+ }
+
+ @Test
+ @DisplayName("plan_month 가 NULL 이면 연 단위(첫 달) — settleMonth = 계약일 +N년")
+ void nullPlanMonthDefaultsToYearStart() {
+ when(ratioMapper.selectAllActiveRatios(any(), anyString()))
+ .thenReturn(List.of(ratio(2, null, "100")));
+
+ List plans = generator.generate(
+ 100L, new BigDecimal("500000"), CONTRACT_DATE, null);
+
+ InstallmentPlanVO p = plans.get(0);
+ assertThat(p.getPlanMonth()).isEqualTo(1);
+ // 2024-01 +2년 +0개월 = 2026-01
+ assertThat(p.getSettleMonth()).isEqualTo("202601");
+ }
+}
diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java
index b057e7d..5b8b08d 100644
--- a/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java
+++ b/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java
@@ -129,9 +129,9 @@ class SettlementScenarioTest {
@Test
@DisplayName("① 보험 인입 → 모집수수료 원장: 1,000,000 ×60% ×75% = 450,000, 세금 14,850")
void s1_보험인입_모집수수료_원장계산() {
- when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(1), any(LocalDate.class)))
+ when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("60.0"));
- when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
+ when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("75.0"));
LedgerVO led = commissionCalculator.calcRecruitLedger(contract, "202401");
@@ -226,9 +226,9 @@ class SettlementScenarioTest {
@Test
@DisplayName("④ 유지수수료 2차년: 1,000,000 ×10% ×50% = 50,000")
void s4_유지수수료_2차년() {
- when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class)))
+ when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("10.0"));
- when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class)))
+ when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("50.0"));
LedgerVO led = commissionCalculator.calcMaintainLedger(contract, "202501", 2);
@@ -241,10 +241,10 @@ class SettlementScenarioTest {
@Test
@DisplayName("⑤ 계약 승계: 유지수수료는 후임(박승계 등급3 40%)에게 귀속, 모집은 원설계사 유지")
void s5_계약승계_유지수수료_후임귀속() {
- when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class)))
+ when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("10.0"));
// 후임 박승계 등급3 의 2차년 지급률 40%
- when(ruleMapper.findPayoutRate(eq(3L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class)))
+ when(ruleMapper.findPayoutRate(eq(3L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("40.0"));
LedgerVO led = commissionCalculator.calcMaintainLedger(contract, "202501", 2, PARK);
diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java
index a8272f3..42eb761 100644
--- a/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java
+++ b/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java
@@ -57,6 +57,30 @@ class CalcRecruitStepTest {
step = new CalcRecruitStep(ctx, contractMapper, recruitMapper,
calculator, regulatoryChecker, installmentGenerator, installmentPlanMapper);
when(ctx.getSettleMonth()).thenReturn(MONTH);
+ // 기본: 두 한도 모두 위반 아님(각 테스트에서 필요한 한도만 violated 로 오버라이드)
+ when(regulatoryChecker.checkFirstYear(any(), any(BigDecimal.class), any(LocalDate.class)))
+ .thenReturn(ViolationResult.ok(BigDecimal.ZERO, null, "FIRST_YEAR_RATIO"));
+ when(regulatoryChecker.checkContractTotal(any(), any(BigDecimal.class), any(LocalDate.class)))
+ .thenReturn(ViolationResult.ok(BigDecimal.ZERO, null, "TOTAL_RATIO"));
+ }
+
+ private ContractResp contract() {
+ ContractResp c = new ContractResp();
+ c.setContractId(CONTRACT_ID);
+ c.setAgentId(10L);
+ c.setInsuranceType("WHOLE_LIFE");
+ c.setContractDate(CONTRACT_DATE);
+ return c;
+ }
+
+ private LedgerVO recruitLedger(ContractResp c, String amount, String tax) {
+ LedgerVO led = new LedgerVO();
+ led.setContractId(CONTRACT_ID);
+ led.setAgentId(10L);
+ led.setAgentAmount(new BigDecimal(amount));
+ led.setTaxAmount(new BigDecimal(tax));
+ when(calculator.calcRecruitLedger(eq(c), eq(MONTH))).thenReturn(led);
+ return led;
}
@Test
@@ -100,4 +124,62 @@ class CalcRecruitStepTest {
assertThat(saved.getAgentAmount()).isEqualByComparingTo("350000");
assertThat(saved.getTaxAmount()).isEqualByComparingTo("11550.00");
}
+
+ @Test
+ @DisplayName("1200% 누적한도 잔여 300,000 < 모집수수료 450,000 → 300,000 지급 + 초과 150,000 분급 이연")
+ void contractTotalLimit_clipAndDefer() {
+ ContractResp c = contract();
+ when(contractMapper.selectList(any())).thenReturn(List.of(c));
+ recruitLedger(c, "450000", "14850");
+
+ // 1차년은 통과(기본), 누적 1200% 한도만 위반: 한도 12,000,000 / 기지급 11,700,000 → 잔여 300,000
+ when(regulatoryChecker.checkContractTotal(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
+ .thenReturn(ViolationResult.violated(
+ new BigDecimal("11700000"), new BigDecimal("12000000"), "TOTAL_RATIO", "초과"));
+ when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString()))
+ .thenReturn(List.of(new InstallmentPlanVO()));
+ ArgumentCaptor> ledgerCaptor = ArgumentCaptor.forClass(List.class);
+ when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
+
+ step.execute(null, null);
+
+ ArgumentCaptor excessCaptor = ArgumentCaptor.forClass(BigDecimal.class);
+ org.mockito.Mockito.verify(installmentGenerator)
+ .generate(eq(CONTRACT_ID), excessCaptor.capture(), any(LocalDate.class), anyString());
+ assertThat(excessCaptor.getValue()).isEqualByComparingTo("150000");
+
+ LedgerVO saved = ledgerCaptor.getValue().get(0);
+ assertThat(saved.getAgentAmount()).isEqualByComparingTo("300000");
+ assertThat(saved.getTaxAmount()).isEqualByComparingTo("9900.00");
+ }
+
+ @Test
+ @DisplayName("1차년·누적 둘 다 위반 → 더 제한적인 누적한도 잔여 300,000 으로 clip")
+ void bothLimits_mostRestrictiveApplies() {
+ ContractResp c = contract();
+ when(contractMapper.selectList(any())).thenReturn(List.of(c));
+ recruitLedger(c, "450000", "14850");
+
+ // 1차년 잔여 350,000, 누적 잔여 300,000 → min(450000, 350000, 300000) = 300,000
+ when(regulatoryChecker.checkFirstYear(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
+ .thenReturn(ViolationResult.violated(
+ BigDecimal.ZERO, new BigDecimal("350000"), "FIRST_YEAR_RATIO", "초과"));
+ when(regulatoryChecker.checkContractTotal(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
+ .thenReturn(ViolationResult.violated(
+ new BigDecimal("11700000"), new BigDecimal("12000000"), "TOTAL_RATIO", "초과"));
+ when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString()))
+ .thenReturn(List.of(new InstallmentPlanVO()));
+ ArgumentCaptor> ledgerCaptor = ArgumentCaptor.forClass(List.class);
+ when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
+
+ step.execute(null, null);
+
+ ArgumentCaptor excessCaptor = ArgumentCaptor.forClass(BigDecimal.class);
+ org.mockito.Mockito.verify(installmentGenerator)
+ .generate(eq(CONTRACT_ID), excessCaptor.capture(), any(LocalDate.class), anyString());
+ assertThat(excessCaptor.getValue()).isEqualByComparingTo("150000");
+
+ LedgerVO saved = ledgerCaptor.getValue().get(0);
+ assertThat(saved.getAgentAmount()).isEqualByComparingTo("300000");
+ }
}
diff --git a/ga-common/src/main/resources/db/migration/V116__도입수수료.sql b/ga-common/src/main/resources/db/migration/V116__도입수수료.sql
new file mode 100644
index 0000000..0a53ca9
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V116__도입수수료.sql
@@ -0,0 +1,156 @@
+-- V116: MOD-1 슈퍼바이저/도입수수료 (recruiter/development commission)
+-- agent.recruiter_agent_id 컬럼 추가
+-- recruiter_commission_rule / recruiter_commission_ledger 테이블
+-- 메뉴 RECRUITER_COMM (GRP_COMMISSION 하위)
+-- 공통코드 RECRUITER_TYPE
+
+-- ============================================================
+-- 1. agent 테이블에 도입자 컬럼 추가
+-- ============================================================
+
+ALTER TABLE agent ADD COLUMN IF NOT EXISTS recruiter_agent_id BIGINT REFERENCES agent(agent_id);
+
+COMMENT ON COLUMN agent.recruiter_agent_id IS '도입자 설계사 ID (NULL=도입자 없음)';
+
+-- ============================================================
+-- 2. recruiter_commission_rule (도입수수료 룰 마스터)
+-- ============================================================
+
+CREATE TABLE recruiter_commission_rule (
+ rule_id BIGSERIAL PRIMARY KEY,
+ recruiter_type VARCHAR(20) NOT NULL,
+ calc_type VARCHAR(10) NOT NULL,
+ commission_rate NUMERIC(7,4),
+ fixed_amount NUMERIC(15,2),
+ effective_from DATE NOT NULL,
+ effective_to DATE,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ updated_at TIMESTAMP,
+ CONSTRAINT chk_rcr_recruiter_type CHECK (recruiter_type IN ('NEW_RECRUIT','DEVELOPMENT','TRAINING')),
+ CONSTRAINT chk_rcr_calc_type CHECK (calc_type IN ('RATE','FIXED')),
+ CONSTRAINT chk_rcr_rate_range CHECK (commission_rate IS NULL OR (commission_rate >= 0 AND commission_rate <= 1))
+);
+
+COMMENT ON TABLE recruiter_commission_rule IS '도입수수료 룰 마스터';
+COMMENT ON COLUMN recruiter_commission_rule.rule_id IS 'PK';
+COMMENT ON COLUMN recruiter_commission_rule.recruiter_type IS '도입유형: NEW_RECRUIT/DEVELOPMENT/TRAINING';
+COMMENT ON COLUMN recruiter_commission_rule.calc_type IS '계산방식: RATE(정률)/FIXED(정액)';
+COMMENT ON COLUMN recruiter_commission_rule.commission_rate IS '정률 요율 (0~1)';
+COMMENT ON COLUMN recruiter_commission_rule.fixed_amount IS '정액 금액';
+COMMENT ON COLUMN recruiter_commission_rule.effective_from IS '적용 시작일';
+COMMENT ON COLUMN recruiter_commission_rule.effective_to IS '적용 종료일 (NULL=무기한)';
+
+CREATE INDEX idx_rcr_type_from ON recruiter_commission_rule(recruiter_type, effective_from);
+
+-- ============================================================
+-- 3. recruiter_commission_ledger (도입수수료 원장)
+-- ============================================================
+
+CREATE TABLE recruiter_commission_ledger (
+ ledger_id BIGSERIAL PRIMARY KEY,
+ recruiter_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
+ recruit_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
+ settle_month CHAR(6) NOT NULL,
+ recruiter_type VARCHAR(20) NOT NULL,
+ base_amount NUMERIC(15,2) NOT NULL,
+ commission_amount NUMERIC(15,2) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ updated_at TIMESTAMP,
+ CONSTRAINT chk_rcl_status CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED'))
+);
+
+COMMENT ON TABLE recruiter_commission_ledger IS '도입수수료 원장';
+COMMENT ON COLUMN recruiter_commission_ledger.ledger_id IS 'PK';
+COMMENT ON COLUMN recruiter_commission_ledger.recruiter_agent_id IS '도입자 설계사 ID';
+COMMENT ON COLUMN recruiter_commission_ledger.recruit_agent_id IS '피도입(신규) 설계사 ID';
+COMMENT ON COLUMN recruiter_commission_ledger.settle_month IS '정산월 YYYYMM';
+COMMENT ON COLUMN recruiter_commission_ledger.recruiter_type IS '도입유형';
+COMMENT ON COLUMN recruiter_commission_ledger.base_amount IS '기준금액 (피도입 당월 모집수수료 합)';
+COMMENT ON COLUMN recruiter_commission_ledger.commission_amount IS '도입수수료 금액';
+COMMENT ON COLUMN recruiter_commission_ledger.status IS '상태: CALCULATED/CONFIRMED/PAID/CANCELLED';
+
+CREATE INDEX idx_rcl_recruiter_month ON recruiter_commission_ledger(recruiter_agent_id, settle_month);
+CREATE INDEX idx_rcl_recruit_month ON recruiter_commission_ledger(recruit_agent_id, settle_month);
+
+-- ============================================================
+-- 4. 메뉴 INSERT: RECRUITER_COMM (GRP_COMMISSION 하위 PAGE)
+-- ============================================================
+
+INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
+SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
+FROM menu p
+JOIN (VALUES
+ ('RECRUITER_COMM', '도입수수료', '/commission/recruiter', 'commission/RecruiterCommission', 150)
+) AS x(code, name, path, cmpt, sort)
+ON p.menu_code = 'GRP_COMMISSION'
+WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
+
+-- ============================================================
+-- 5. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/EXPORT)
+-- ============================================================
+
+INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
+SELECT m.menu_id, p.code, p.name, p.sort
+FROM menu m
+CROSS JOIN (VALUES
+ ('READ', '조회', 10),
+ ('CREATE', '등록', 20),
+ ('UPDATE', '수정', 30),
+ ('DELETE', '삭제', 40),
+ ('EXPORT', '엑셀', 60)
+) AS p(code, name, sort)
+WHERE m.menu_code = 'RECRUITER_COMM'
+ON CONFLICT (menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 6. 역할별 권한 부여
+-- ============================================================
+
+-- SUPER_ADMIN / ADMIN — 전체 권한
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code IN ('SUPER_ADMIN','ADMIN')
+ AND m.menu_code = 'RECRUITER_COMM'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- MANAGER — READ/CREATE/UPDATE/EXPORT
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'MANAGER'
+ AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
+ AND m.menu_code = 'RECRUITER_COMM'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- AGENT — READ
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'AGENT'
+ AND mp.perm_code = 'READ'
+ AND m.menu_code = 'RECRUITER_COMM'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 7. 공통코드: RECRUITER_TYPE
+-- ============================================================
+
+INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
+ ('RECRUITER_TYPE', '도입수수료유형', 'NEW_RECRUIT=신인도입/DEVELOPMENT=육성/TRAINING=교육', 'Y', 460)
+ON CONFLICT (group_code) DO NOTHING;
+
+INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
+ ('RECRUITER_TYPE', 'NEW_RECRUIT', '신인도입', 10),
+ ('RECRUITER_TYPE', 'DEVELOPMENT', '육성', 20),
+ ('RECRUITER_TYPE', 'TRAINING', '교육', 30)
+ON CONFLICT (group_code, code) DO NOTHING;
diff --git a/ga-common/src/main/resources/db/migration/V117__선지급정산.sql b/ga-common/src/main/resources/db/migration/V117__선지급정산.sql
new file mode 100644
index 0000000..2f68615
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V117__선지급정산.sql
@@ -0,0 +1,142 @@
+-- V117: MOD-2 선지급/차익정산 (advance payment recovery + delta settlement)
+-- advance_payment / advance_recovery_ledger 테이블
+-- 메뉴 ADVANCE_PAYMENT (GRP_SETTLE 하위)
+-- 공통코드 ADVANCE_STATUS
+
+-- ============================================================
+-- 1. advance_payment (선지급금 마스터)
+-- ============================================================
+
+CREATE TABLE advance_payment (
+ advance_id BIGSERIAL PRIMARY KEY,
+ agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
+ advance_month CHAR(6) NOT NULL,
+ principal_amount NUMERIC(15,2) NOT NULL,
+ recovered_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
+ balance NUMERIC(15,2) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'OUTSTANDING',
+ memo VARCHAR(200),
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ updated_at TIMESTAMP,
+ CONSTRAINT chk_adv_principal_pos CHECK (principal_amount > 0),
+ CONSTRAINT chk_adv_balance_nn CHECK (balance >= 0),
+ CONSTRAINT chk_adv_status CHECK (status IN ('OUTSTANDING','PARTIAL','CLEARED','WAIVED'))
+);
+
+COMMENT ON TABLE advance_payment IS '선지급금 마스터';
+COMMENT ON COLUMN advance_payment.advance_id IS 'PK';
+COMMENT ON COLUMN advance_payment.agent_id IS '설계사 ID';
+COMMENT ON COLUMN advance_payment.advance_month IS '선지급 발생월 YYYYMM';
+COMMENT ON COLUMN advance_payment.principal_amount IS '원금';
+COMMENT ON COLUMN advance_payment.recovered_amount IS '회수 누계';
+COMMENT ON COLUMN advance_payment.balance IS '잔액 (principal - recovered)';
+COMMENT ON COLUMN advance_payment.status IS '상태: OUTSTANDING/PARTIAL/CLEARED/WAIVED';
+COMMENT ON COLUMN advance_payment.memo IS '메모';
+
+CREATE INDEX idx_adv_agent_status ON advance_payment(agent_id, status);
+CREATE INDEX idx_adv_month ON advance_payment(advance_month);
+
+-- ============================================================
+-- 2. advance_recovery_ledger (선지급 상계 이력)
+-- ============================================================
+
+CREATE TABLE advance_recovery_ledger (
+ recovery_id BIGSERIAL PRIMARY KEY,
+ advance_id BIGINT NOT NULL REFERENCES advance_payment(advance_id),
+ settle_month CHAR(6) NOT NULL,
+ recovery_amount NUMERIC(15,2) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ CONSTRAINT chk_arl_recovery_pos CHECK (recovery_amount > 0)
+);
+
+COMMENT ON TABLE advance_recovery_ledger IS '선지급 상계 이력';
+COMMENT ON COLUMN advance_recovery_ledger.recovery_id IS 'PK';
+COMMENT ON COLUMN advance_recovery_ledger.advance_id IS '선지급금 ID';
+COMMENT ON COLUMN advance_recovery_ledger.settle_month IS '상계 정산월 YYYYMM';
+COMMENT ON COLUMN advance_recovery_ledger.recovery_amount IS '당월 상계 금액';
+
+CREATE INDEX idx_arl_advance ON advance_recovery_ledger(advance_id);
+CREATE INDEX idx_arl_settle_month ON advance_recovery_ledger(settle_month);
+
+-- ============================================================
+-- 3. 메뉴 INSERT: ADVANCE_PAYMENT (GRP_SETTLE 하위 PAGE)
+-- ============================================================
+
+INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
+SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
+FROM menu p
+JOIN (VALUES
+ ('ADVANCE_PAYMENT', '선지급정산', '/settle/advance-payment', 'settle/AdvancePayment', 130)
+) AS x(code, name, path, cmpt, sort)
+ON p.menu_code = 'GRP_SETTLE'
+WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
+
+-- ============================================================
+-- 4. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/EXPORT)
+-- ============================================================
+
+INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
+SELECT m.menu_id, p.code, p.name, p.sort
+FROM menu m
+CROSS JOIN (VALUES
+ ('READ', '조회', 10),
+ ('CREATE', '등록', 20),
+ ('UPDATE', '수정', 30),
+ ('DELETE', '삭제', 40),
+ ('EXPORT', '엑셀', 60)
+) AS p(code, name, sort)
+WHERE m.menu_code = 'ADVANCE_PAYMENT'
+ON CONFLICT (menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 5. 역할별 권한 부여
+-- ============================================================
+
+-- SUPER_ADMIN / ADMIN — 전체 권한
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code IN ('SUPER_ADMIN','ADMIN')
+ AND m.menu_code = 'ADVANCE_PAYMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- MANAGER — READ/CREATE/UPDATE/EXPORT
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'MANAGER'
+ AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
+ AND m.menu_code = 'ADVANCE_PAYMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- AGENT — READ
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'AGENT'
+ AND mp.perm_code = 'READ'
+ AND m.menu_code = 'ADVANCE_PAYMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 6. 공통코드: ADVANCE_STATUS
+-- ============================================================
+
+INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
+ ('ADVANCE_STATUS', '선지급상태', 'OUTSTANDING=미상환/PARTIAL=부분/CLEARED=완료/WAIVED=면제', 'Y', 470)
+ON CONFLICT (group_code) DO NOTHING;
+
+INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
+ ('ADVANCE_STATUS', 'OUTSTANDING', '미상환', 10),
+ ('ADVANCE_STATUS', 'PARTIAL', '부분상환', 20),
+ ('ADVANCE_STATUS', 'CLEARED', '상환완료', 30),
+ ('ADVANCE_STATUS', 'WAIVED', '면제', 40)
+ON CONFLICT (group_code, code) DO NOTHING;
diff --git a/ga-common/src/main/resources/db/migration/V118__환수분할상환.sql b/ga-common/src/main/resources/db/migration/V118__환수분할상환.sql
new file mode 100644
index 0000000..5a5c926
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V118__환수분할상환.sql
@@ -0,0 +1,116 @@
+-- V118: MOD-3 환수 분할상환 (installment clawback)
+-- clawback_installment_plan 테이블
+-- 메뉴 CLAWBACK_INSTALLMENT (GRP_COMMISSION 하위)
+-- 공통코드 CB_INSTALLMENT_STATUS
+
+-- ============================================================
+-- 1. clawback_installment_plan (환수 분할상환 계획)
+-- ============================================================
+
+CREATE TABLE clawback_installment_plan (
+ plan_id BIGSERIAL PRIMARY KEY,
+ receivable_id BIGINT NOT NULL REFERENCES clawback_receivable(receivable_id),
+ installment_no INT NOT NULL,
+ installment_amount NUMERIC(15,2) NOT NULL,
+ due_month CHAR(6) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED',
+ recovered_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ updated_at TIMESTAMP,
+ CONSTRAINT chk_cip_amount_pos CHECK (installment_amount > 0),
+ CONSTRAINT chk_cip_status CHECK (status IN ('SCHEDULED','RECOVERED','CANCELLED')),
+ CONSTRAINT uq_cip_receivable_no UNIQUE (receivable_id, installment_no)
+);
+
+COMMENT ON TABLE clawback_installment_plan IS '환수 분할상환 계획';
+COMMENT ON COLUMN clawback_installment_plan.plan_id IS 'PK';
+COMMENT ON COLUMN clawback_installment_plan.receivable_id IS '환수채권 ID';
+COMMENT ON COLUMN clawback_installment_plan.installment_no IS '분할 회차 번호 (1부터)';
+COMMENT ON COLUMN clawback_installment_plan.installment_amount IS '회차 납부 금액';
+COMMENT ON COLUMN clawback_installment_plan.due_month IS '납부 예정월 YYYYMM';
+COMMENT ON COLUMN clawback_installment_plan.status IS '상태: SCHEDULED/RECOVERED/CANCELLED';
+COMMENT ON COLUMN clawback_installment_plan.recovered_at IS '실제 회수 일시';
+
+CREATE INDEX idx_cip_due_status ON clawback_installment_plan(due_month, status);
+CREATE INDEX idx_cip_receivable_id ON clawback_installment_plan(receivable_id);
+
+-- ============================================================
+-- 2. 메뉴 INSERT: CLAWBACK_INSTALLMENT (GRP_COMMISSION 하위 PAGE)
+-- ============================================================
+
+INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
+SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
+FROM menu p
+JOIN (VALUES
+ ('CLAWBACK_INSTALLMENT', '환수분할상환', '/commission/clawback-installment', 'commission/ClawbackInstallment', 160)
+) AS x(code, name, path, cmpt, sort)
+ON p.menu_code = 'GRP_COMMISSION'
+WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
+
+-- ============================================================
+-- 3. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/EXPORT)
+-- ============================================================
+
+INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
+SELECT m.menu_id, p.code, p.name, p.sort
+FROM menu m
+CROSS JOIN (VALUES
+ ('READ', '조회', 10),
+ ('CREATE', '등록', 20),
+ ('UPDATE', '수정', 30),
+ ('DELETE', '삭제', 40),
+ ('EXPORT', '엑셀', 60)
+) AS p(code, name, sort)
+WHERE m.menu_code = 'CLAWBACK_INSTALLMENT'
+ON CONFLICT (menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 4. 역할별 권한 부여
+-- ============================================================
+
+-- SUPER_ADMIN / ADMIN — 전체 권한
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code IN ('SUPER_ADMIN','ADMIN')
+ AND m.menu_code = 'CLAWBACK_INSTALLMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- MANAGER — READ/CREATE/UPDATE/EXPORT
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'MANAGER'
+ AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
+ AND m.menu_code = 'CLAWBACK_INSTALLMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- AGENT — READ
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'AGENT'
+ AND mp.perm_code = 'READ'
+ AND m.menu_code = 'CLAWBACK_INSTALLMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 5. 공통코드: CB_INSTALLMENT_STATUS
+-- ============================================================
+
+INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
+ ('CB_INSTALLMENT_STATUS', '환수분할상환상태', 'SCHEDULED=예정/RECOVERED=회수/CANCELLED=취소', 'Y', 480)
+ON CONFLICT (group_code) DO NOTHING;
+
+INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
+ ('CB_INSTALLMENT_STATUS', 'SCHEDULED', '예정', 10),
+ ('CB_INSTALLMENT_STATUS', 'RECOVERED', '회수', 20),
+ ('CB_INSTALLMENT_STATUS', 'CANCELLED', '취소', 30)
+ON CONFLICT (group_code, code) DO NOTHING;
diff --git a/ga-common/src/main/resources/db/migration/V119__익월부활재지급.sql b/ga-common/src/main/resources/db/migration/V119__익월부활재지급.sql
new file mode 100644
index 0000000..9e29b9e
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V119__익월부활재지급.sql
@@ -0,0 +1,117 @@
+-- V119: MOD-4 익월 부활 재지급 (lapsed reinstatement repayment)
+-- lapsed_reinstatement_recovery 테이블
+-- 메뉴 LAPSED_REINSTATEMENT (GRP_COMMISSION 하위)
+-- 공통코드 LAPSE_RECOVERY_STATUS
+
+-- ============================================================
+-- 1. lapsed_reinstatement_recovery (익월 부활 재지급 원장)
+-- ============================================================
+
+CREATE TABLE lapsed_reinstatement_recovery (
+ recovery_id BIGSERIAL PRIMARY KEY,
+ contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
+ agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
+ lapsed_month CHAR(6) NOT NULL,
+ reinstated_month CHAR(6) NOT NULL,
+ withheld_amount NUMERIC(15,2) NOT NULL,
+ repaid_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
+ status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ updated_at TIMESTAMP,
+ CONSTRAINT chk_lrr_withheld_nn CHECK (withheld_amount >= 0),
+ CONSTRAINT chk_lrr_status CHECK (status IN ('PENDING','REPAID','CANCELLED'))
+);
+
+COMMENT ON TABLE lapsed_reinstatement_recovery IS '익월 부활 재지급 원장';
+COMMENT ON COLUMN lapsed_reinstatement_recovery.recovery_id IS 'PK';
+COMMENT ON COLUMN lapsed_reinstatement_recovery.contract_id IS '계약 ID';
+COMMENT ON COLUMN lapsed_reinstatement_recovery.agent_id IS '설계사 ID';
+COMMENT ON COLUMN lapsed_reinstatement_recovery.lapsed_month IS '실효 발생월 YYYYMM';
+COMMENT ON COLUMN lapsed_reinstatement_recovery.reinstated_month IS '부활 확정월 YYYYMM';
+COMMENT ON COLUMN lapsed_reinstatement_recovery.withheld_amount IS '보류된 수수료 금액';
+COMMENT ON COLUMN lapsed_reinstatement_recovery.repaid_amount IS '재지급 완료 금액';
+COMMENT ON COLUMN lapsed_reinstatement_recovery.status IS '상태: PENDING/REPAID/CANCELLED';
+
+CREATE INDEX idx_lrr_agent_reinstated ON lapsed_reinstatement_recovery(agent_id, reinstated_month);
+CREATE INDEX idx_lrr_contract ON lapsed_reinstatement_recovery(contract_id);
+
+-- ============================================================
+-- 2. 메뉴 INSERT: LAPSED_REINSTATEMENT (GRP_COMMISSION 하위 PAGE)
+-- ============================================================
+
+INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
+SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
+FROM menu p
+JOIN (VALUES
+ ('LAPSED_REINSTATEMENT', '익월부활재지급', '/commission/lapsed-reinstatement', 'commission/LapsedReinstatement', 170)
+) AS x(code, name, path, cmpt, sort)
+ON p.menu_code = 'GRP_COMMISSION'
+WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
+
+-- ============================================================
+-- 3. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/EXPORT)
+-- ============================================================
+
+INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
+SELECT m.menu_id, p.code, p.name, p.sort
+FROM menu m
+CROSS JOIN (VALUES
+ ('READ', '조회', 10),
+ ('CREATE', '등록', 20),
+ ('UPDATE', '수정', 30),
+ ('DELETE', '삭제', 40),
+ ('EXPORT', '엑셀', 60)
+) AS p(code, name, sort)
+WHERE m.menu_code = 'LAPSED_REINSTATEMENT'
+ON CONFLICT (menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 4. 역할별 권한 부여
+-- ============================================================
+
+-- SUPER_ADMIN / ADMIN — 전체 권한
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code IN ('SUPER_ADMIN','ADMIN')
+ AND m.menu_code = 'LAPSED_REINSTATEMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- MANAGER — READ/CREATE/UPDATE/EXPORT
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'MANAGER'
+ AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
+ AND m.menu_code = 'LAPSED_REINSTATEMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- AGENT — READ
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'AGENT'
+ AND mp.perm_code = 'READ'
+ AND m.menu_code = 'LAPSED_REINSTATEMENT'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 5. 공통코드: LAPSE_RECOVERY_STATUS
+-- ============================================================
+
+INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
+ ('LAPSE_RECOVERY_STATUS', '부활재지급상태', 'PENDING=대기/REPAID=재지급완료/CANCELLED=취소', 'Y', 490)
+ON CONFLICT (group_code) DO NOTHING;
+
+INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
+ ('LAPSE_RECOVERY_STATUS', 'PENDING', '대기', 10),
+ ('LAPSE_RECOVERY_STATUS', 'REPAID', '재지급완료', 20),
+ ('LAPSE_RECOVERY_STATUS', 'CANCELLED', '취소', 30)
+ON CONFLICT (group_code, code) DO NOTHING;
diff --git a/ga-common/src/main/resources/db/migration/V120__정산대사입수.sql b/ga-common/src/main/resources/db/migration/V120__정산대사입수.sql
new file mode 100644
index 0000000..d37cff7
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V120__정산대사입수.sql
@@ -0,0 +1,146 @@
+-- V120: MOD-5 정산대사 입수 (carrier reconciliation IN)
+-- carrier_reconciliation_in / reconciliation_variance 테이블
+-- 메뉴 CARRIER_RECON_IN (GRP_SETTLE 하위)
+-- 공통코드 RECON_IN_STATUS
+-- insurance_company PK: company_id (SERIAL, V2 확인)
+
+-- ============================================================
+-- 1. carrier_reconciliation_in (보험사 정산대사 입수 마스터)
+-- ============================================================
+
+CREATE TABLE carrier_reconciliation_in (
+ recon_id BIGSERIAL PRIMARY KEY,
+ company_id BIGINT NOT NULL REFERENCES insurance_company(company_id),
+ settle_month CHAR(6) NOT NULL,
+ file_name VARCHAR(200),
+ record_count INT NOT NULL DEFAULT 0,
+ reported_total NUMERIC(15,2) NOT NULL DEFAULT 0,
+ system_total NUMERIC(15,2) NOT NULL DEFAULT 0,
+ variance_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
+ status VARCHAR(20) NOT NULL DEFAULT 'RECEIVED',
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ updated_at TIMESTAMP,
+ CONSTRAINT chk_cri_status CHECK (status IN ('RECEIVED','MATCHED','VARIANCE','APPROVED')),
+ CONSTRAINT uq_cri_company_month UNIQUE (company_id, settle_month)
+);
+
+COMMENT ON TABLE carrier_reconciliation_in IS '보험사 정산대사 입수 마스터';
+COMMENT ON COLUMN carrier_reconciliation_in.recon_id IS 'PK';
+COMMENT ON COLUMN carrier_reconciliation_in.company_id IS '보험사 ID';
+COMMENT ON COLUMN carrier_reconciliation_in.settle_month IS '정산월 YYYYMM';
+COMMENT ON COLUMN carrier_reconciliation_in.file_name IS '입수 파일명';
+COMMENT ON COLUMN carrier_reconciliation_in.record_count IS '보험사 통보 건수';
+COMMENT ON COLUMN carrier_reconciliation_in.reported_total IS '보험사 통보 금액 합계';
+COMMENT ON COLUMN carrier_reconciliation_in.system_total IS 'GA 시스템 집계 금액';
+COMMENT ON COLUMN carrier_reconciliation_in.variance_amount IS '차이 금액 (reported - system)';
+COMMENT ON COLUMN carrier_reconciliation_in.status IS '상태: RECEIVED/MATCHED/VARIANCE/APPROVED';
+
+CREATE INDEX idx_cri_settle_month ON carrier_reconciliation_in(settle_month);
+CREATE INDEX idx_cri_company_month ON carrier_reconciliation_in(company_id, settle_month);
+
+-- ============================================================
+-- 2. reconciliation_variance (대사 차이 상세)
+-- ============================================================
+
+CREATE TABLE reconciliation_variance (
+ variance_id BIGSERIAL PRIMARY KEY,
+ recon_id BIGINT NOT NULL REFERENCES carrier_reconciliation_in(recon_id),
+ policy_no VARCHAR(50),
+ expected_amount NUMERIC(15,2),
+ reported_amount NUMERIC(15,2),
+ variance_reason VARCHAR(100),
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT
+);
+
+COMMENT ON TABLE reconciliation_variance IS '대사 차이 상세';
+COMMENT ON COLUMN reconciliation_variance.variance_id IS 'PK';
+COMMENT ON COLUMN reconciliation_variance.recon_id IS '대사 입수 ID';
+COMMENT ON COLUMN reconciliation_variance.policy_no IS '증권번호';
+COMMENT ON COLUMN reconciliation_variance.expected_amount IS 'GA 시스템 예상 금액';
+COMMENT ON COLUMN reconciliation_variance.reported_amount IS '보험사 통보 금액';
+COMMENT ON COLUMN reconciliation_variance.variance_reason IS '차이 사유';
+
+CREATE INDEX idx_rv_recon_id ON reconciliation_variance(recon_id);
+
+-- ============================================================
+-- 3. 메뉴 INSERT: CARRIER_RECON_IN (GRP_SETTLE 하위 PAGE)
+-- ============================================================
+
+INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
+SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
+FROM menu p
+JOIN (VALUES
+ ('CARRIER_RECON_IN', '정산대사입수', '/settle/carrier-recon-in', 'settle/CarrierReconIn', 140)
+) AS x(code, name, path, cmpt, sort)
+ON p.menu_code = 'GRP_SETTLE'
+WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
+
+-- ============================================================
+-- 4. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/EXPORT)
+-- ============================================================
+
+INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
+SELECT m.menu_id, p.code, p.name, p.sort
+FROM menu m
+CROSS JOIN (VALUES
+ ('READ', '조회', 10),
+ ('CREATE', '등록', 20),
+ ('UPDATE', '수정', 30),
+ ('DELETE', '삭제', 40),
+ ('EXPORT', '엑셀', 60)
+) AS p(code, name, sort)
+WHERE m.menu_code = 'CARRIER_RECON_IN'
+ON CONFLICT (menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 5. 역할별 권한 부여
+-- ============================================================
+
+-- SUPER_ADMIN / ADMIN — 전체 권한
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code IN ('SUPER_ADMIN','ADMIN')
+ AND m.menu_code = 'CARRIER_RECON_IN'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- MANAGER — READ/CREATE/UPDATE/EXPORT
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'MANAGER'
+ AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
+ AND m.menu_code = 'CARRIER_RECON_IN'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- AGENT — READ
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'AGENT'
+ AND mp.perm_code = 'READ'
+ AND m.menu_code = 'CARRIER_RECON_IN'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 6. 공통코드: RECON_IN_STATUS
+-- ============================================================
+
+INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
+ ('RECON_IN_STATUS', '정산대사상태', 'RECEIVED=입수/MATCHED=일치/VARIANCE=차이/APPROVED=승인', 'Y', 500)
+ON CONFLICT (group_code) DO NOTHING;
+
+INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
+ ('RECON_IN_STATUS', 'RECEIVED', '입수', 10),
+ ('RECON_IN_STATUS', 'MATCHED', '일치', 20),
+ ('RECON_IN_STATUS', 'VARIANCE', '차이', 30),
+ ('RECON_IN_STATUS', 'APPROVED', '승인', 40)
+ON CONFLICT (group_code, code) DO NOTHING;
diff --git a/ga-common/src/main/resources/db/migration/V121__환수시효.sql b/ga-common/src/main/resources/db/migration/V121__환수시효.sql
new file mode 100644
index 0000000..e91e630
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V121__환수시효.sql
@@ -0,0 +1,102 @@
+-- V121: MOD-6 환수 시효/소멸 (statute of limitations)
+-- statute_config 테이블 + 시드 1행
+-- 메뉴 STATUTE_CONFIG (GRP_COMMISSION 하위)
+-- clawback_receivable.status CHECK: WRITTEN_OFF 이미 존재 (V111 확인) → 제약 변경 불필요
+
+-- ============================================================
+-- 1. statute_config (환수 시효 설정)
+-- ============================================================
+
+CREATE TABLE statute_config (
+ config_id BIGSERIAL PRIMARY KEY,
+ entity_type VARCHAR(30) NOT NULL,
+ expiration_months INT NOT NULL,
+ is_active CHAR(1) NOT NULL DEFAULT 'Y',
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ updated_at TIMESTAMP,
+ CONSTRAINT chk_sc_entity_type CHECK (entity_type IN ('CLAWBACK_RECEIVABLE')),
+ CONSTRAINT chk_sc_expiration_pos CHECK (expiration_months > 0),
+ CONSTRAINT chk_sc_is_active CHECK (is_active IN ('Y','N')),
+ CONSTRAINT uq_sc_entity_type UNIQUE (entity_type)
+);
+
+COMMENT ON TABLE statute_config IS '환수 시효 설정';
+COMMENT ON COLUMN statute_config.config_id IS 'PK';
+COMMENT ON COLUMN statute_config.entity_type IS '대상 엔티티 타입 (CLAWBACK_RECEIVABLE)';
+COMMENT ON COLUMN statute_config.expiration_months IS '시효 만료 개월수';
+COMMENT ON COLUMN statute_config.is_active IS '활성 여부 (Y/N)';
+
+-- ============================================================
+-- 2. 시드 데이터: CLAWBACK_RECEIVABLE 36개월
+-- ============================================================
+
+INSERT INTO statute_config (entity_type, expiration_months, is_active)
+VALUES ('CLAWBACK_RECEIVABLE', 36, 'Y')
+ON CONFLICT (entity_type) DO NOTHING;
+
+-- ============================================================
+-- 3. 메뉴 INSERT: STATUTE_CONFIG (GRP_COMMISSION 하위 PAGE)
+-- ============================================================
+
+INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
+SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
+FROM menu p
+JOIN (VALUES
+ ('STATUTE_CONFIG', '환수시효관리', '/commission/statute', 'commission/StatuteConfig', 180)
+) AS x(code, name, path, cmpt, sort)
+ON p.menu_code = 'GRP_COMMISSION'
+WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
+
+-- ============================================================
+-- 4. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/EXPORT)
+-- ============================================================
+
+INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
+SELECT m.menu_id, p.code, p.name, p.sort
+FROM menu m
+CROSS JOIN (VALUES
+ ('READ', '조회', 10),
+ ('CREATE', '등록', 20),
+ ('UPDATE', '수정', 30),
+ ('DELETE', '삭제', 40),
+ ('EXPORT', '엑셀', 60)
+) AS p(code, name, sort)
+WHERE m.menu_code = 'STATUTE_CONFIG'
+ON CONFLICT (menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 5. 역할별 권한 부여
+-- ============================================================
+
+-- SUPER_ADMIN / ADMIN — 전체 권한
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code IN ('SUPER_ADMIN','ADMIN')
+ AND m.menu_code = 'STATUTE_CONFIG'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- MANAGER — READ/CREATE/UPDATE/EXPORT
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'MANAGER'
+ AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
+ AND m.menu_code = 'STATUTE_CONFIG'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- AGENT — READ (시효 설정은 읽기만)
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'AGENT'
+ AND mp.perm_code = 'READ'
+ AND m.menu_code = 'STATUTE_CONFIG'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
diff --git a/ga-common/src/main/resources/db/migration/V122__환수감면.sql b/ga-common/src/main/resources/db/migration/V122__환수감면.sql
new file mode 100644
index 0000000..4ad9e9d
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V122__환수감면.sql
@@ -0,0 +1,123 @@
+-- V122: MOD-7 환수 감면/면제 (chargeback waiver/reduction)
+-- chargeback_waiver_request 테이블
+-- 메뉴 CHARGEBACK_WAIVER (GRP_COMMISSION 하위)
+-- 공통코드 WAIVER_STATUS
+
+-- ============================================================
+-- 1. chargeback_waiver_request (환수 감면 신청)
+-- ============================================================
+
+CREATE TABLE chargeback_waiver_request (
+ request_id BIGSERIAL PRIMARY KEY,
+ contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
+ agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
+ settle_month CHAR(6) NOT NULL,
+ original_chargeback NUMERIC(15,2) NOT NULL,
+ waiver_amount NUMERIC(15,2) NOT NULL,
+ reason VARCHAR(200),
+ approval_status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
+ approver_id BIGINT,
+ approved_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by BIGINT,
+ updated_at TIMESTAMP,
+ CONSTRAINT chk_cwr_original_nn CHECK (original_chargeback >= 0),
+ CONSTRAINT chk_cwr_waiver_nn CHECK (waiver_amount >= 0),
+ CONSTRAINT chk_cwr_waiver_lte_orig CHECK (waiver_amount <= original_chargeback),
+ CONSTRAINT chk_cwr_status CHECK (approval_status IN ('REQUESTED','APPROVED','REJECTED'))
+);
+
+COMMENT ON TABLE chargeback_waiver_request IS '환수 감면 신청';
+COMMENT ON COLUMN chargeback_waiver_request.request_id IS 'PK';
+COMMENT ON COLUMN chargeback_waiver_request.contract_id IS '계약 ID';
+COMMENT ON COLUMN chargeback_waiver_request.agent_id IS '설계사 ID';
+COMMENT ON COLUMN chargeback_waiver_request.settle_month IS '정산월 YYYYMM';
+COMMENT ON COLUMN chargeback_waiver_request.original_chargeback IS '원래 환수액';
+COMMENT ON COLUMN chargeback_waiver_request.waiver_amount IS '감면 신청 금액 (<=original_chargeback)';
+COMMENT ON COLUMN chargeback_waiver_request.reason IS '감면 사유';
+COMMENT ON COLUMN chargeback_waiver_request.approval_status IS '승인 상태: REQUESTED/APPROVED/REJECTED';
+COMMENT ON COLUMN chargeback_waiver_request.approver_id IS '승인자 사용자 ID';
+COMMENT ON COLUMN chargeback_waiver_request.approved_at IS '승인/반려 일시';
+
+CREATE INDEX idx_cwr_agent_month ON chargeback_waiver_request(agent_id, settle_month);
+CREATE INDEX idx_cwr_status ON chargeback_waiver_request(approval_status);
+
+-- ============================================================
+-- 2. 메뉴 INSERT: CHARGEBACK_WAIVER (GRP_COMMISSION 하위 PAGE)
+-- ============================================================
+
+INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
+SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
+FROM menu p
+JOIN (VALUES
+ ('CHARGEBACK_WAIVER', '환수감면신청', '/commission/chargeback-waiver', 'commission/ChargebackWaiver', 190)
+) AS x(code, name, path, cmpt, sort)
+ON p.menu_code = 'GRP_COMMISSION'
+WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
+
+-- ============================================================
+-- 3. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/EXPORT)
+-- ============================================================
+
+INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
+SELECT m.menu_id, p.code, p.name, p.sort
+FROM menu m
+CROSS JOIN (VALUES
+ ('READ', '조회', 10),
+ ('CREATE', '등록', 20),
+ ('UPDATE', '수정', 30),
+ ('DELETE', '삭제', 40),
+ ('EXPORT', '엑셀', 60)
+) AS p(code, name, sort)
+WHERE m.menu_code = 'CHARGEBACK_WAIVER'
+ON CONFLICT (menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 4. 역할별 권한 부여
+-- ============================================================
+
+-- SUPER_ADMIN / ADMIN — 전체 권한
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code IN ('SUPER_ADMIN','ADMIN')
+ AND m.menu_code = 'CHARGEBACK_WAIVER'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- MANAGER — READ/CREATE/UPDATE/EXPORT
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'MANAGER'
+ AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
+ AND m.menu_code = 'CHARGEBACK_WAIVER'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- AGENT — READ (본인 감면 신청 현황 조회)
+INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
+SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
+FROM role r
+CROSS JOIN menu_permission mp
+JOIN menu m ON m.menu_id = mp.menu_id
+WHERE r.role_code = 'AGENT'
+ AND mp.perm_code = 'READ'
+ AND m.menu_code = 'CHARGEBACK_WAIVER'
+ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
+
+-- ============================================================
+-- 5. 공통코드: WAIVER_STATUS
+-- ============================================================
+
+INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
+ ('WAIVER_STATUS', '환수감면상태', 'REQUESTED=신청/APPROVED=승인/REJECTED=반려', 'Y', 510)
+ON CONFLICT (group_code) DO NOTHING;
+
+INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
+ ('WAIVER_STATUS', 'REQUESTED', '신청', 10),
+ ('WAIVER_STATUS', 'APPROVED', '승인', 20),
+ ('WAIVER_STATUS', 'REJECTED', '반려', 30)
+ON CONFLICT (group_code, code) DO NOTHING;
diff --git a/ga-common/src/main/resources/db/migration/V123__채널수수료.sql b/ga-common/src/main/resources/db/migration/V123__채널수수료.sql
new file mode 100644
index 0000000..57024ee
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V123__채널수수료.sql
@@ -0,0 +1,51 @@
+-- V123: MOD-8 채널별 수수료 차등 (channel-specific commission)
+-- contract.sales_channel 컬럼 추가
+-- commission_rate.sales_channel 컬럼 추가 (V3 확인: product_id 기반 룰 테이블)
+-- payout_rule.sales_channel 컬럼 추가 (V3 확인: grade_id 기반 룰 테이블)
+-- 공통코드 SALES_CHANNEL
+-- 하위호환: sales_channel NULL = DIRECT 취급, NULL 룰이 폴백
+
+-- ============================================================
+-- 1. contract 테이블에 판매채널 컬럼 추가
+-- ============================================================
+
+ALTER TABLE contract ADD COLUMN IF NOT EXISTS sales_channel VARCHAR(20);
+
+COMMENT ON COLUMN contract.sales_channel IS '판매채널 (NULL=DIRECT 취급): DIRECT/BANCASSURANCE/HOME_SHOPPING/PARTNERSHIP';
+
+-- ============================================================
+-- 2. commission_rate 테이블에 sales_channel 컬럼 추가
+-- NULL = 모든 채널 적용 (폴백 룰)
+-- ============================================================
+
+ALTER TABLE commission_rate ADD COLUMN IF NOT EXISTS sales_channel VARCHAR(20);
+
+COMMENT ON COLUMN commission_rate.sales_channel IS '판매채널 조건 (NULL=모든 채널 폴백)';
+
+CREATE INDEX idx_cr_product_channel ON commission_rate(product_id, sales_channel, effective_from);
+
+-- ============================================================
+-- 3. payout_rule 테이블에 sales_channel 컬럼 추가
+-- NULL = 모든 채널 적용 (폴백 룰)
+-- ============================================================
+
+ALTER TABLE payout_rule ADD COLUMN IF NOT EXISTS sales_channel VARCHAR(20);
+
+COMMENT ON COLUMN payout_rule.sales_channel IS '판매채널 조건 (NULL=모든 채널 폴백)';
+
+CREATE INDEX idx_pr_grade_channel ON payout_rule(grade_id, insurance_type, sales_channel, effective_from);
+
+-- ============================================================
+-- 4. 공통코드: SALES_CHANNEL
+-- ============================================================
+
+INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
+ ('SALES_CHANNEL', '판매채널', 'DIRECT=직판/BANCASSURANCE=방카/HOME_SHOPPING=홈쇼핑/PARTNERSHIP=제휴', 'Y', 520)
+ON CONFLICT (group_code) DO NOTHING;
+
+INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
+ ('SALES_CHANNEL', 'DIRECT', '직판', 10),
+ ('SALES_CHANNEL', 'BANCASSURANCE', '방카', 20),
+ ('SALES_CHANNEL', 'HOME_SHOPPING', '홈쇼핑', 30),
+ ('SALES_CHANNEL', 'PARTNERSHIP', '제휴', 40)
+ON CONFLICT (group_code, code) DO NOTHING;
diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/AdvancePaymentMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/AdvancePaymentMapper.java
new file mode 100644
index 0000000..6bf7ef5
--- /dev/null
+++ b/ga-core/src/main/java/com/ga/core/mapper/commission/AdvancePaymentMapper.java
@@ -0,0 +1,39 @@
+package com.ga.core.mapper.commission;
+
+import com.ga.core.vo.commission.AdvancePaymentResp;
+import com.ga.core.vo.commission.AdvancePaymentSearchParam;
+import com.ga.core.vo.commission.AdvancePaymentVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * advance_payment Mapper — 선지급금 마스터 (V117)
+ */
+@Mapper
+public interface AdvancePaymentMapper {
+
+ AdvancePaymentResp selectById(@Param("advanceId") Long advanceId);
+
+ List selectList(AdvancePaymentSearchParam param);
+
+ int insert(AdvancePaymentVO vo);
+
+ int update(AdvancePaymentVO vo);
+
+ int delete(@Param("advanceId") Long advanceId);
+
+ /**
+ * 설계사의 미상환 잔액 합계.
+ * status IN ('OUTSTANDING','PARTIAL') 대상 SUM(balance).
+ */
+ BigDecimal outstandingByAgent(@Param("agentId") Long agentId);
+
+ /**
+ * 설계사의 FIFO 상계 대상 선지급금 목록.
+ * status IN ('OUTSTANDING','PARTIAL') ORDER BY advance_month ASC, advance_id ASC.
+ */
+ List selectOutstandingFifo(@Param("agentId") Long agentId);
+}
diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/AdvanceRecoveryLedgerMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/AdvanceRecoveryLedgerMapper.java
new file mode 100644
index 0000000..45ad63d
--- /dev/null
+++ b/ga-core/src/main/java/com/ga/core/mapper/commission/AdvanceRecoveryLedgerMapper.java
@@ -0,0 +1,34 @@
+package com.ga.core.mapper.commission;
+
+import com.ga.core.vo.commission.AdvanceRecoveryLedgerVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * advance_recovery_ledger Mapper — 선지급 상계 이력 (V117)
+ */
+@Mapper
+public interface AdvanceRecoveryLedgerMapper {
+
+ int insert(AdvanceRecoveryLedgerVO vo);
+
+ /**
+ * 배치 멱등용: 당월 상계 이력 삭제 (역산 후 재적용 패턴).
+ */
+ int deleteBySettleMonth(@Param("advanceId") Long advanceId,
+ @Param("settleMonth") String settleMonth);
+
+ /**
+ * 선지급금 ID + 정산월 기준 상계 이력 조회.
+ */
+ List selectBySettleMonth(@Param("advanceId") Long advanceId,
+ @Param("settleMonth") String settleMonth);
+
+ /**
+ * 배치 멱등용: 해당 정산월에 상계 이력이 있는 선지급금 ID 목록(중복 제거).
+ * reverse 단계에서 월 단위로 역산 대상을 식별한다.
+ */
+ List selectAdvanceIdsBySettleMonth(@Param("settleMonth") String settleMonth);
+}
diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/CarrierReconciliationInMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/CarrierReconciliationInMapper.java
new file mode 100644
index 0000000..fa9f230
--- /dev/null
+++ b/ga-core/src/main/java/com/ga/core/mapper/commission/CarrierReconciliationInMapper.java
@@ -0,0 +1,29 @@
+package com.ga.core.mapper.commission;
+
+import com.ga.core.vo.commission.CarrierReconciliationInResp;
+import com.ga.core.vo.commission.CarrierReconciliationInSearchParam;
+import com.ga.core.vo.commission.CarrierReconciliationInVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * carrier_reconciliation_in Mapper — 보험사 정산대사 입수 마스터 (V120)
+ */
+@Mapper
+public interface CarrierReconciliationInMapper {
+
+ CarrierReconciliationInResp selectById(@Param("reconId") Long reconId);
+
+ List selectList(CarrierReconciliationInSearchParam param);
+
+ int insert(CarrierReconciliationInVO vo);
+
+ int update(CarrierReconciliationInVO vo);
+
+ /**
+ * 보험사 + 정산월 기준 upsert — UNIQUE(company_id, settle_month) 충돌 시 갱신.
+ */
+ int upsert(CarrierReconciliationInVO vo);
+}
diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ChargebackWaiverRequestMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ChargebackWaiverRequestMapper.java
new file mode 100644
index 0000000..c8f40a8
--- /dev/null
+++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ChargebackWaiverRequestMapper.java
@@ -0,0 +1,31 @@
+package com.ga.core.mapper.commission;
+
+import com.ga.core.vo.commission.ChargebackWaiverRequestResp;
+import com.ga.core.vo.commission.ChargebackWaiverRequestSearchParam;
+import com.ga.core.vo.commission.ChargebackWaiverRequestVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * chargeback_waiver_request Mapper — 환수 감면 신청 (V122)
+ */
+@Mapper
+public interface ChargebackWaiverRequestMapper {
+
+ ChargebackWaiverRequestResp selectById(@Param("requestId") Long requestId);
+
+ List selectList(ChargebackWaiverRequestSearchParam param);
+
+ int insert(ChargebackWaiverRequestVO vo);
+
+ /**
+ * 승인/반려 처리 — approval_status, approver_id, approved_at, updated_at 갱신.
+ */
+ int updateApproval(@Param("requestId") Long requestId,
+ @Param("approvalStatus") String approvalStatus,
+ @Param("approverId") Long approverId,
+ @Param("approvedAt") LocalDateTime approvedAt);
+}
diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInstallmentPlanMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInstallmentPlanMapper.java
new file mode 100644
index 0000000..8d7024a
--- /dev/null
+++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInstallmentPlanMapper.java
@@ -0,0 +1,36 @@
+package com.ga.core.mapper.commission;
+
+import com.ga.core.vo.commission.ClawbackInstallmentPlanResp;
+import com.ga.core.vo.commission.ClawbackInstallmentPlanSearchParam;
+import com.ga.core.vo.commission.ClawbackInstallmentPlanVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * clawback_installment_plan Mapper — 환수 분할상환 계획 (V118)
+ */
+@Mapper
+public interface ClawbackInstallmentPlanMapper {
+
+ ClawbackInstallmentPlanResp selectById(@Param("planId") Long planId);
+
+ List selectList(ClawbackInstallmentPlanSearchParam param);
+
+ /**
+ * 채권 ID 기준 분할상환 계획 목록 — 회차순 정렬.
+ */
+ List selectByReceivable(@Param("receivableId") Long receivableId);
+
+ /**
+ * 분할상환 계획 일괄 등록.
+ */
+ int insertBatch(List plans);
+
+ /**
+ * 분할상환 회차 상태 갱신 (SCHEDULED→RECOVERED 또는 CANCELLED).
+ */
+ int updateStatus(@Param("planId") Long planId,
+ @Param("status") String status);
+}
diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java
index de3c343..0558c4c 100644
--- a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java
+++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java
@@ -77,4 +77,20 @@ public interface ClawbackReceivableMapper {
List