# 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 로 전환.
</content>
</invoke>
