# P9 — 환수채권 이월/미수금 관리 (Clawback Receivable Carry-forward) 작성: PL, 2026-06-04. 사용자 지시 "수수료 빠진 업무 찾아서 기획·설계 후 적용". 근거: incar CMS 메뉴(`환수이월관리`/`환수계약리스트`/`환수리스트`/`위촉중환수`) ↔ GA 부재 + 코드 갭 (`AggregateStep.java:197-199` — net<0 시 경고 로그만, 미회수 환수 채권화·차월상계·대손 생애주기 전무). 범위: 풀스택(DB→Core→API→Batch→Frontend). 패턴 100% 준수(template = 정착지원금 V90 / SettlingSupport*). Flyway: **V110이 최신 → V111부터.** 금액 NUMERIC(15,2), HALF_UP. **확정 FK**: agent(agent_id), settle_master(agent_id, settle_month). --- ## 1. 업무 정의 (표준 한국 GA 관행 — 데이터로 외부화) 조기해지 차등환수(chargeback)·공제가 당월 지급수수료(gross)를 초과하면 `net < 0`. 이 미회수분은 **설계사가 GA에 갚아야 할 미수채권(환수채권)** 이다. 표준 처리: 1. **이월(carryover)**: 당월 net<0 → 음수분(|net|)을 환수채권으로 신규 등록, 당월 실지급(payable)=0. 2. **상계 회수(recovery)**: 차월 이후 net>0이면 미결 채권을 **FIFO(오래된 origin월 우선)** 로 상계 회수. payable = net − 회수액. 3. **대손/직접상환(write-off / direct settle)**: 장기 미회수 채권은 관리자가 대손(WRITTEN_OFF) 또는 외부 직접상환(CLEARED) 처리. 표준 가정(회사 정책 다르면 데이터/룰만 수정, 코드 불변): - 상계 우선순위 = origin_settle_month ASC (FIFO). - 회수는 당월 양수 net 한도 내에서만(설계사를 음수로 만들지 않음). - 이자·연체료 없음(필요 시 후속). --- ## 2. 스키마 (V111) ### 2-1. clawback_receivable (환수채권 마스터) | 컬럼 | 타입 | 설명 | |---|---|---| | receivable_id | BIGSERIAL PK | | | agent_id | BIGINT NN FK agent | | | origin_settle_month | CHAR(6) NN | 미수 발생 정산월 | | original_amount | NUMERIC(15,2) NN | 발생액(양수, =당월 |net|) | | recovered_amount | NUMERIC(15,2) NN DEFAULT 0 | 누적 상계 회수액 | | balance | NUMERIC(15,2) NN | = original_amount − recovered_amount | | status | VARCHAR(20) NN DEFAULT 'OUTSTANDING' | OUTSTANDING/RECOVERING/CLEARED/WRITTEN_OFF | | last_recovery_month | CHAR(6) | 최근 회수 정산월 | | writeoff_reason | VARCHAR(300) | 대손/직접상환 사유 | | written_off_at | TIMESTAMP | | | written_off_by | BIGINT | | | created_at | TIMESTAMP NN DEFAULT now() | | | created_by | BIGINT | | | updated_at | TIMESTAMP | | - UNIQUE(agent_id, origin_settle_month) — 설계사·발생월당 1건(배치 upsert 멱등 키) - CHECK status IN('OUTSTANDING','RECOVERING','CLEARED','WRITTEN_OFF') - CHECK original_amount >= 0, balance >= 0 - idx: (agent_id, status), (status), (origin_settle_month) ### 2-2. clawback_recovery (상계 회수 이력) | 컬럼 | 타입 | 설명 | |---|---|---| | recovery_id | BIGSERIAL PK | | | receivable_id | BIGINT NN FK clawback_receivable | | | settle_month | CHAR(6) NN | 회수(상계) 정산월 | | recovered_amount | NUMERIC(15,2) NN | 이 월 이 채권에서 회수한 액 | | created_at | TIMESTAMP NN DEFAULT now() | | - idx: (receivable_id), (settle_month) - **멱등 키**: 배치 재실행 시 동일 settle_month 회수분을 reverse(삭제+balance 복원) 후 재적용. ### 2-3. settle_master 컬럼 추가 (기존 의미 불변) ```sql ALTER TABLE settle_master ADD COLUMN clawback_recovered NUMERIC(15,2) NOT NULL DEFAULT 0, -- 당월 채권 상계 회수액 ADD COLUMN clawback_carryover NUMERIC(15,2) NOT NULL DEFAULT 0, -- 당월 발생→이월된 미수액 ADD COLUMN payable_amount NUMERIC(15,2) NOT NULL DEFAULT 0; -- 실지급(이체대상) = max(0,net) − recovered ``` - `net_amount`(=gross−소득세−지방세+부가세−공제) 의미·계산 **불변**(무결성 Σ원장==Σsettle_master 보존). - 실제 이체/출금 가용액은 `payable_amount` 사용(후속: withdraw 한도 검증을 payable_amount 기준으로 전환은 본 P9 범위 외, 문서화만). ### 2-4. 메뉴 / 권한 / 공통코드 - 메뉴 `CLAWBACK_RECEIVABLE` (환수채권관리), 상위 GRP_COMMISSION, path `/commission/clawback-receivable`, component `commission/ClawbackReceivable`. - 권한 READ/UPDATE/EXPORT + 역할부여(SUPER_ADMIN·ADMIN 전체, MANAGER READ) — V54/V95 패턴. - 공통코드 `CLAWBACK_RCV_STATUS`: OUTSTANDING=미회수/RECOVERING=회수중/CLEARED=상환완료/WRITTEN_OFF=대손. --- ## 3. 배치 통합 (AggregateStep, Step 8) — **핵심** 설계사 루프에서 `rawNet = tax.netAmount() − deductionTotal` 계산 직후(현 196행), settle_master.upsert **이전**에 삽입: ``` priorBalance = receivableMapper.outstandingBalance(agentId, settleMonth) // SUM(balance) WHERE agent_id=? AND status IN('OUTSTANDING','RECOVERING') // AND origin_settle_month < settleMonth recovered = 0; carryover = 0; payable = 0 if (rawNet.signum() >= 0) { recovered = min(rawNet, priorBalance) // 양수 net에서 기존 채권 FIFO 상계 payable = rawNet - recovered } else { payable = 0 carryover = rawNet.negate() // 음수분 신규 채권화 } vo.setNetAmount(rawNet) // 불변 vo.setClawbackRecovered(recovered) vo.setClawbackCarryover(carryover) vo.setPayableAmount(payable) settleMapper.upsert(vo) // settle_master upsert 가드(status NOT IN CONFIRMED/PAID)와 동일 행만 채권 부수효과 적용 if (settle_master 행이 갱신가능 상태였다면) { clawbackService.applyCarryoverAndRecovery(agentId, settleMonth, carryover, recovered) } ``` **applyCarryoverAndRecovery (배치 멱등 보장)** — ga-batch 내 헬퍼 또는 Mapper 직접: 1. **reverse**: 이 settleMonth의 기존 clawback_recovery 행 조회 → 각 receivable.balance += recovered_amount, recovered_amount -= , status 재계산(CLEARED→RECOVERING/OUTSTANDING) → recovery 행 삭제. (재실행 안전) 2. **carryover**: carryover>0 → upsert clawback_receivable (agent_id, origin=settleMonth) {original_amount=carryover, balance=carryover, recovered_amount=0, status=OUTSTANDING}. (ON CONFLICT 갱신) 3. **recovery**: recovered>0 → outstanding 채권 origin_month ASC FIFO 순회: - take = min(recovered_remaining, rcv.balance); rcv.recovered_amount += take; rcv.balance -= take; status = balance==0 ? CLEARED : RECOVERING; last_recovery_month=settleMonth; INSERT clawback_recovery(receivable_id, settleMonth, take); recovered_remaining -= take; - recovered_remaining==0 종료. > reverse를 먼저 하므로 동일 월 재정산 시 회수 중복 없음. carryover는 UNIQUE upsert로 멱등. > **주의**: priorBalance 조회는 `origin_settle_month < settleMonth` 로 당월 발생분 자기상계 방지. 배치 Step 순서: AggregateStep(8) 내부에서 처리(별도 Step 불필요). 회계분개(8b)·세금은 net_amount 기준 불변. --- ## 4. API (ga-api) `ClawbackReceivableService` + `ClawbackReceivableController` (`/api/clawback-receivables`): - `GET /api/clawback-receivables` — 목록(agentId/status/originMonth 필터, PageHelper, @RequirePermission READ) - `GET /api/clawback-receivables/{id}` — 상세 + 회수이력(clawback_recovery 리스트) - `GET /api/clawback-receivables/summary` — 설계사별 미결 잔액 집계(status OUTSTANDING/RECOVERING) - `PUT /api/clawback-receivables/{id}/write-off` — 대손/직접상환 처리(@RequirePermission UPDATE, @DataChangeLog). body {action: WRITTEN_OFF|CLEARED, reason}. balance→0, status 전이, writeoff_* 기록. **OUTSTANDING/RECOVERING만 허용**(CLEARED/WRITTEN_OFF 재처리 E411). - `GET /api/clawback-receivables/export` — 엑셀(ExcelService, @Transactional). - 표준 ApiResponse/PageResponse, MapStruct, BaseMapper 상속. 회수(recovery)는 **배치만** 생성(수동 회수 엔드포인트 없음 — 정산 상계로만 발생). --- ## 5. Frontend (ga-frontend) - `src/api/clawbackReceivable.ts` — list/detail/summary/writeOff/export. - `src/pages/commission/ClawbackReceivable.tsx` — AG Grid(목록 100건+ / status 컬러칩 / 합계행: original/recovered/balance) + 행 클릭 상세 Drawer(회수이력 타임라인) + 대손/상환 처리 버튼(PermissionButton UPDATE) + 엑셀 export. - `App.tsx` 라우트 `commission/clawback-receivable` (lazy). --- ## 6. 검증 (PL 직접) 1. `./gradlew build`(test 포함) SUCCESSFUL, ga-frontend `npm run build` PASS. 2. Flyway V111 success, schema v111. 테이블 2개·컬럼 3개·메뉴 1·권한·공통코드 확인. 3. GET 3종 + write-off PUT 실호출 5xx 0. 4. **배치 e2e (핵심)**: 단일 트랜잭션 롤백 시나리오로 - (a) 특정 설계사에 chargeback>gross 인 net<0 상황 구성 → 202611 정산 → clawback_carryover 발생 + clawback_receivable OUTSTANDING 1건 + payable=0 확인. - (b) 익월 202612 정산(양수 net) → clawback_recovered 상계 + receivable balance 감소/CLEARED + clawback_recovery 행 + payable=net−recovered 확인. - (c) 동일월 재실행 → 회수 중복 없음(reverse 멱등) 확인. - 부산물 전부 롤백, pre-e2e 복원. --- ## 7. 후속 갭 (본 P9 범위 외, 기록) - 시상/인정실적 환수 ledger (incar `인정실적수수료환수`/`신인도입유치자시상환수`). - 지급대상건 구성·수정(수동 포함/제외 워크플로우, incar `지급대상건구성/수정/조회`). - 환수 보험사자료 업로드(incar `환수보험사자료 업로드` — 보험사 통보 기반 환수 인입). - withdraw 가용한도 검증을 net_amount→payable_amount 로 전환.