# Guide #36: Withdrawal Policy API Implementation

**목표**: admin-api에 파트너 출금 정책 관리 엔드포인트 2개 구현
- `GET /api/admin/partners/{partnerId}/withdrawal-policy`
- `PUT /api/admin/partners/{partnerId}/withdrawal-policy` (upsert)

**적용 대상**: IntelliJ (admin-api 모듈)

**작성일**: 2026-03-22

---

## 1. 개요

파트너별 출금 정책(PartnerWithdrawalPolicy)을 관리하는 REST API를 구현한다. 정책은 한 번 설정되면 upsert 방식으로 업데이트되며, 다음 항목을 포함한다:

| 필드 | 설명 | 타입 |
|------|------|------|
| `single_limit` | 건당 최대 출금액 (USD). NULL/0 = 무제한 | DECIMAL(20,4) |
| `daily_limit` | 일일 최대 출금액 (USD). NULL/0 = 무제한 | DECIMAL(20,4) |
| `auto_approve_threshold` | 이 금액(USD) 이하면 자동 APPROVED. NULL = 수동 승인 | DECIMAL(20,4) |
| `address_whitelist_enabled` | TRUE면 사전 등록된 주소로만 출금 가능 | BOOLEAN |

---

## 2. 데이터 흐름

```
┌─────────────────────────────────────────────────────────┐
│ Admin Console (UI)                                      │
└────────────────────┬────────────────────────────────────┘
                     │
         ┌───────────▼───────────┐
         │ PartnerManagementCtrl │
         └───────────┬───────────┘
                     │
         ┌───────────▼──────────────────┐
         │ PartnerManagementService     │
         │ - getWithdrawalPolicy()      │
         │ - upsertWithdrawalPolicy()   │
         └───────────┬──────────────────┘
                     │
      ┌──────────────▼──────────────┐
      │PartnerWithdrawalPolicyRepo  │
      │- findByPartnerId()          │
      │- save()                     │
      └──────────────┬──────────────┘
                     │
             ┌───────▼────────┐
             │partner_withdraw│
             │_policies 테이블│
             └────────────────┘
```

---

## 3. 구현 체크리스트

### 3-1. 엔티티 & 리포지토리 검증

#### Entity: PartnerWithdrawalPolicy.java
- ✅ 이미 구현됨 (`common` 모듈)
- 필드 확인:
  - `id` (PK)
  - `partnerId` (FK to partners)
  - `singleLimit` (DECIMAL → BigDecimal)
  - `dailyLimit` (DECIMAL → BigDecimal)
  - `autoApproveThreshold` (DECIMAL → BigDecimal)
  - `addressWhitelistEnabled` (BOOLEAN)
  - `createdAt`, `updatedAt`

#### Repository: PartnerWithdrawalPolicyRepository.java
- ✅ 이미 구현됨 (`common` 모듈)
- 필수 메서드:
  ```java
  PartnerWithdrawalPolicy findByPartnerId(Long partnerId);
  ```
  - 반환: nullable (Optional 아님)

---

### 3-2. Request/Response DTOs 작성

#### 파일 위치
- Request: `admin-api/src/main/java/com/cryptoments/adminapi/dto/request/PartnerWithdrawalPolicyUpdateRequest.java` (새로 생성)
- Response: `admin-api/src/main/java/com/cryptoments/adminapi/dto/response/PartnerWithdrawalPolicyResponse.java` (새로 생성)

#### PartnerWithdrawalPolicyUpdateRequest.java

```java
package com.cryptoments.adminapi.dto.request;

import lombok.*;
import java.math.BigDecimal;

/**
 * 파트너 출금 정책 수정 요청
 * - 건당/일일 한도, 자동 승인 임계값, 화이트리스트 설정 포함
 */
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PartnerWithdrawalPolicyUpdateRequest {

    /**
     * 건당 최대 출금액 (USD)
     * - NULL 또는 0: 무제한
     * - 양수: 이 금액 이상의 출금 요청은 REJECTED
     */
    private BigDecimal singleLimit;

    /**
     * 일일 최대 출금액 (USD)
     * - NULL 또는 0: 무제한
     * - 양수: 당일 누적 출금액이 이를 초과하면 REJECTED
     */
    private BigDecimal dailyLimit;

    /**
     * 자동 승인 임계값 (USD)
     * - NULL: 모든 출금 요청 수동 승인 (REQUESTED 상태)
     * - 양수: 이 금액 이하인 출금은 자동 APPROVED
     * - 주의: auto_approve_threshold <= single_limit 필수
     */
    private BigDecimal autoApproveThreshold;

    /**
     * 화이트리스트 필수 여부
     * - TRUE: 사전 등록된 주소로만 출금 가능
     * - FALSE: 모든 주소 허용 (기본값)
     */
    private Boolean addressWhitelistEnabled;

}
```

#### PartnerWithdrawalPolicyResponse.java

```java
package com.cryptoments.adminapi.dto.response;

import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 파트너 출금 정책 조회 응답
 * - 현재 설정된 정책 전체 반환
 */
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PartnerWithdrawalPolicyResponse {

    /**
     * 정책 ID
     */
    private Long id;

    /**
     * 파트너 ID
     */
    private Long partnerId;

    /**
     * 건당 최대 출금액 (USD)
     */
    private BigDecimal singleLimit;

    /**
     * 일일 최대 출금액 (USD)
     */
    private BigDecimal dailyLimit;

    /**
     * 자동 승인 임계값 (USD)
     */
    private BigDecimal autoApproveThreshold;

    /**
     * 화이트리스트 필수 여부
     */
    private Boolean addressWhitelistEnabled;

    /**
     * 생성 시각
     */
    private LocalDateTime createdAt;

    /**
     * 수정 시각
     */
    private LocalDateTime updatedAt;

}
```

---

### 3-3. Service 메서드 추가

#### 파일: PartnerManagementService.java

**메서드 1: 정책 조회**

```java
/**
 * 파트너 출금 정책 조회
 * 
 * @param partnerId 파트너 ID
 * @return 조회된 정책 (없으면 기본값으로 새 정책 객체 반환)
 * @throws NotFoundException 파트너 존재하지 않음
 */
public PartnerWithdrawalPolicyResponse getWithdrawalPolicy(Long partnerId) {
    // 파트너 존재성 검증
    Partner partner = partnerRepository.findById(partnerId);
    if (partner == null) {
        throw new NotFoundException(ErrorCodes.PARTNER_NOT_FOUND);
    }

    // 정책 조회 (없으면 기본값)
    PartnerWithdrawalPolicy policy = partnerWithdrawalPolicyRepository
            .findByPartnerId(partnerId);

    if (policy == null) {
        // 정책이 없으면 기본값 반환 (DB 저장 안 함)
        return PartnerWithdrawalPolicyResponse.builder()
                .partnerId(partnerId)
                .singleLimit(null)
                .dailyLimit(null)
                .autoApproveThreshold(null)
                .addressWhitelistEnabled(false)
                .build();
    }

    return PartnerWithdrawalPolicyResponse.builder()
            .id(policy.getId())
            .partnerId(policy.getPartnerId())
            .singleLimit(policy.getSingleLimit())
            .dailyLimit(policy.getDailyLimit())
            .autoApproveThreshold(policy.getAutoApproveThreshold())
            .addressWhitelistEnabled(policy.getAddressWhitelistEnabled())
            .createdAt(policy.getCreatedAt())
            .updatedAt(policy.getUpdatedAt())
            .build();
}
```

**메서드 2: 정책 수정 (Upsert)**

```java
/**
 * 파트너 출금 정책 수정 또는 생성 (Upsert)
 * 
 * 검증 규칙:
 * 1. 한도는 NULL 또는 양수만 허용 (음수 불가)
 * 2. autoApproveThreshold > singleLimit인 경우 에러
 * 3. 일일/건당 한도 모두 0이면 무제한 의미
 * 
 * @param partnerId 파트너 ID
 * @param request 정책 업데이트 요청
 * @return 저장된 정책
 * @throws NotFoundException 파트너 존재하지 않음
 * @throws ValidationException 유효성 검사 실패
 */
public PartnerWithdrawalPolicyResponse upsertWithdrawalPolicy(
        Long partnerId,
        PartnerWithdrawalPolicyUpdateRequest request) {

    // 파트너 존재성 검증
    Partner partner = partnerRepository.findById(partnerId);
    if (partner == null) {
        throw new NotFoundException(ErrorCodes.PARTNER_NOT_FOUND);
    }

    // 입력 검증
    validatePolicyRequest(request);

    // 기존 정책 조회 또는 새 정책 생성
    PartnerWithdrawalPolicy policy = partnerWithdrawalPolicyRepository
            .findByPartnerId(partnerId);

    if (policy == null) {
        // 신규 생성
        policy = PartnerWithdrawalPolicy.builder()
                .partnerId(partnerId)
                .singleLimit(request.getSingleLimit())
                .dailyLimit(request.getDailyLimit())
                .autoApproveThreshold(request.getAutoApproveThreshold())
                .addressWhitelistEnabled(
                    request.getAddressWhitelistEnabled() != null 
                        ? request.getAddressWhitelistEnabled() 
                        : false
                )
                .build();
    } else {
        // 기존 정책 업데이트
        policy.setSingleLimit(request.getSingleLimit());
        policy.setDailyLimit(request.getDailyLimit());
        policy.setAutoApproveThreshold(request.getAutoApproveThreshold());
        policy.setAddressWhitelistEnabled(
            request.getAddressWhitelistEnabled() != null 
                ? request.getAddressWhitelistEnabled() 
                : policy.getAddressWhitelistEnabled()
        );
    }

    // 저장
    Long policyId = partnerWithdrawalPolicyRepository.save(policy);
    policy.setId(policyId);

    // 감사 로그 기록 (선택 사항)
    auditLogRepository.save(AuditLog.builder()
            .action(AuditAction.UPDATE)
            .tableName("partner_withdrawal_policies")
            .recordId(policyId)
            .changedBy(getCurrentAdmin()) // 세션에서 가져오기
            .changeDetails("출금 정책 수정: " + request.toString())
            .build());

    return PartnerWithdrawalPolicyResponse.builder()
            .id(policy.getId())
            .partnerId(policy.getPartnerId())
            .singleLimit(policy.getSingleLimit())
            .dailyLimit(policy.getDailyLimit())
            .autoApproveThreshold(policy.getAutoApproveThreshold())
            .addressWhitelistEnabled(policy.getAddressWhitelistEnabled())
            .createdAt(policy.getCreatedAt())
            .updatedAt(policy.getUpdatedAt())
            .build();
}

/**
 * 정책 요청 유효성 검증
 * 
 * 검증 규칙:
 * 1. singleLimit: NULL 또는 양수 (음수 불가)
 * 2. dailyLimit: NULL 또는 양수
 * 3. autoApproveThreshold: NULL 또는 양수
 * 4. autoApproveThreshold <= singleLimit (singleLimit이 NULL/0이면 무제한이므로 통과)
 * 
 * @param request 검증할 요청
 * @throws ValidationException 유효성 검사 실패
 */
private void validatePolicyRequest(PartnerWithdrawalPolicyUpdateRequest request) {
    // singleLimit 검증
    if (request.getSingleLimit() != null 
            && request.getSingleLimit().compareTo(BigDecimal.ZERO) < 0) {
        throw new ValidationException(
            ErrorCodes.INVALID_LIMIT,
            "singleLimit은 0 이상이어야 합니다"
        );
    }

    // dailyLimit 검증
    if (request.getDailyLimit() != null 
            && request.getDailyLimit().compareTo(BigDecimal.ZERO) < 0) {
        throw new ValidationException(
            ErrorCodes.INVALID_LIMIT,
            "dailyLimit은 0 이상이어야 합니다"
        );
    }

    // autoApproveThreshold 검증
    if (request.getAutoApproveThreshold() != null 
            && request.getAutoApproveThreshold().compareTo(BigDecimal.ZERO) < 0) {
        throw new ValidationException(
            ErrorCodes.INVALID_LIMIT,
            "autoApproveThreshold는 0 이상이어야 합니다"
        );
    }

    // autoApproveThreshold <= singleLimit 검증
    // (singleLimit이 NULL/0이면 무제한이므로 검증 스킵)
    if (request.getAutoApproveThreshold() != null 
            && request.getSingleLimit() != null 
            && request.getSingleLimit().compareTo(BigDecimal.ZERO) > 0) {
        if (request.getAutoApproveThreshold().compareTo(request.getSingleLimit()) > 0) {
            throw new ValidationException(
                ErrorCodes.INVALID_POLICY_CONFIG,
                "autoApproveThreshold는 singleLimit 이하여야 합니다"
            );
        }
    }
}
```

---

### 3-4. Controller 메서드 추가

#### 파일: PartnerManagementController.java

```java
/**
 * 파트너 출금 정책 조회
 * 
 * @param partnerId 파트너 ID
 * @return 정책 정보
 */
@GetMapping("/{partnerId}/withdrawal-policy")
public ResponseEntity<PartnerWithdrawalPolicyResponse> getWithdrawalPolicy(
        @PathVariable Long partnerId) {
    PartnerWithdrawalPolicyResponse response = partnerManagementService
            .getWithdrawalPolicy(partnerId);
    return ResponseEntity.ok(response);
}

/**
 * 파트너 출금 정책 수정 (Upsert)
 * 
 * 정책이 없으면 새로 생성, 있으면 업데이트
 * 
 * @param partnerId 파트너 ID
 * @param request 정책 수정 요청
 * @return 저장된 정책
 */
@PutMapping("/{partnerId}/withdrawal-policy")
public ResponseEntity<PartnerWithdrawalPolicyResponse> upsertWithdrawalPolicy(
        @PathVariable Long partnerId,
        @RequestBody PartnerWithdrawalPolicyUpdateRequest request) {
    PartnerWithdrawalPolicyResponse response = partnerManagementService
            .upsertWithdrawalPolicy(partnerId, request);
    return ResponseEntity.ok(response);
}
```

---

### 3-5. 에러코드 추가

#### ErrorCodes.java에 다음 상수 추가

```java
public static final ErrorCode INVALID_LIMIT = 
    new ErrorCode("4120", "유효하지 않은 한도 금액입니다. 0 이상의 양수만 허용됩니다.");

public static final ErrorCode INVALID_POLICY_CONFIG = 
    new ErrorCode("4121", "자동 승인 임계값은 건당 한도 이하여야 합니다.");
```

---

## 4. WithdrawalService 통합

기존 코드가 이미 정책을 사용하는 방식:

```java
// WithdrawalService.requestWithdrawal()
PartnerWithdrawalPolicy policy = policyRepository.findByPartnerId(withdrawal.getPartnerId());

validatePolicy(withdrawal, policy);  // 건당/일일 한도 검증

// determineInitialStatus()
WithdrawalStatus initialStatus = determineInitialStatus(policy, withdrawal.getAmount());
// auto_approve_threshold 이하면 즉시 APPROVED, 아니면 REQUESTED
```

**이 가이드의 API 구현은 정책 관리만 담당하며, 정책 적용은 이미 구현되어 있음.**

---

## 5. API 사용 예시

### 5-1. GET 요청 (정책 조회)

```bash
curl -X GET \
  http://localhost:8080/api/admin/partners/1/withdrawal-policy \
  -H "Authorization: Bearer {token}"
```

**응답 (200 OK)**:
```json
{
  "id": 42,
  "partnerId": 1,
  "singleLimit": "10000.0000",
  "dailyLimit": "50000.0000",
  "autoApproveThreshold": "5000.0000",
  "addressWhitelistEnabled": true,
  "createdAt": "2026-03-15T10:30:00.000000",
  "updatedAt": "2026-03-22T14:20:00.000000"
}
```

### 5-2. PUT 요청 (정책 수정)

```bash
curl -X PUT \
  http://localhost:8080/api/admin/partners/1/withdrawal-policy \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "singleLimit": "20000.0000",
    "dailyLimit": "100000.0000",
    "autoApproveThreshold": "10000.0000",
    "addressWhitelistEnabled": true
  }'
```

**응답 (200 OK)**:
```json
{
  "id": 42,
  "partnerId": 1,
  "singleLimit": "20000.0000",
  "dailyLimit": "100000.0000",
  "autoApproveThreshold": "10000.0000",
  "addressWhitelistEnabled": true,
  "createdAt": "2026-03-15T10:30:00.000000",
  "updatedAt": "2026-03-22T15:45:30.000000"
}
```

### 5-3. 에러 응답 예시

**요청**: autoApproveThreshold > singleLimit

```bash
curl -X PUT \
  http://localhost:8080/api/admin/partners/1/withdrawal-policy \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "singleLimit": "5000.0000",
    "dailyLimit": "50000.0000",
    "autoApproveThreshold": "10000.0000"
  }'
```

**응답 (400 Bad Request)**:
```json
{
  "code": "4121",
  "message": "자동 승인 임계값은 건당 한도 이하여야 합니다.",
  "timestamp": "2026-03-22T15:46:00Z"
}
```

---

## 6. 의존성 주입 설정

#### PartnerManagementService 생성자

추가 의존성 주입:

```java
@Service
@RequiredArgsConstructor
public class PartnerManagementService {
    
    private final PartnerRepository partnerRepository;
    private final PartnerWithdrawalPolicyRepository partnerWithdrawalPolicyRepository;
    private final AuditLogRepository auditLogRepository;
    // ... 기타 의존성
    
}
```

---

## 7. 테스트 체크리스트

### 단위 테스트 (PartnerManagementService)

- [ ] `getWithdrawalPolicy()` — 정책 존재할 때
- [ ] `getWithdrawalPolicy()` — 정책 없을 때 (기본값 반환)
- [ ] `getWithdrawalPolicy()` — 파트너 없을 때 (NotFoundException)
- [ ] `upsertWithdrawalPolicy()` — 신규 생성
- [ ] `upsertWithdrawalPolicy()` — 기존 정책 업데이트
- [ ] `validatePolicyRequest()` — 음수 한도 (ValidationException)
- [ ] `validatePolicyRequest()` — autoApproveThreshold > singleLimit (ValidationException)
- [ ] `validatePolicyRequest()` — 유효한 요청 (통과)

### 통합 테스트 (Controller)

- [ ] `GET /api/admin/partners/1/withdrawal-policy` (200)
- [ ] `PUT /api/admin/partners/1/withdrawal-policy` (200, 정책 생성)
- [ ] `PUT /api/admin/partners/1/withdrawal-policy` (200, 정책 업데이트)
- [ ] `PUT /api/admin/partners/999/withdrawal-policy` (404, 파트너 없음)
- [ ] `PUT /api/admin/partners/1/withdrawal-policy` with invalid data (400)

### 비즈니스 로직 검증

- [ ] WithdrawalService가 정책을 올바르게 로드하는지 확인
- [ ] 건당 한도 초과 시 출금 요청 REJECTED 되는지 확인
- [ ] 일일 한도 초과 시 출금 요청 REJECTED 되는지 확인
- [ ] 자동 승인 임계값 이하의 출금은 즉시 APPROVED 되는지 확인
- [ ] 화이트리스트 활성화 시 미등록 주소 출금 REJECTED 되는지 확인

---

## 8. 컨벤션 & 주의사항

### Lombok 사용

- `@Getter`, `@Setter`, `@Builder` 필수
- `@Data` 금지 (Entity/DTO에는 명시적 Getter/Setter 사용)
- DTO 필드에는 반드시 JavaDoc 주석 추가

### MyBatis 패턴

- Repository는 반드시 interface + `@XRepository`
- XML 매퍼 금지 — 모든 쿼리를 interface 메서드로 작성
- `IXRepository` 상속하면 기본 CRUD 자동 생성

### 에러 처리

- 도메인 예외 사용 (`NotFoundException`, `ValidationException`, `ConflictException`)
- ErrorCodes 상수 참조
- HTTP 상태 코드는 Axim Framework이 자동 매핑

### 트랜잭션

- 정책 저장 후 감사 로그도 같은 트랜잭션 내에서 기록
- `@Transactional` 사용 (이미 Service에 적용됨)

---

## 9. 참고 문서

- `CRYPTOMENTS_V2_DDL.sql` — `partner_withdrawal_policies` 테이블 정의
- `CRYPTOMENTS_WITHDRAWAL_PROCESS.md` — 출금 프로세스 및 정책 적용 방식
- `ADMIN_CONSOLE_UI_HANDOFF.md` — Admin Console 아키텍처
- `SPRING_CHANGES_2026_03_18.md` — 최근 Spring Boot 변경 사항

---

## 10. 다음 단계

1. ✅ 이 가이드 검토 및 이해
2. DTOs 생성 (`PartnerWithdrawalPolicyUpdateRequest`, `PartnerWithdrawalPolicyResponse`)
3. Service 메서드 추가 (`getWithdrawalPolicy`, `upsertWithdrawalPolicy`, `validatePolicyRequest`)
4. Controller 메서드 추가 (`GET /withdrawal-policy`, `PUT /withdrawal-policy`)
5. 에러코드 추가 (`INVALID_LIMIT`, `INVALID_POLICY_CONFIG`)
6. 단위 테스트 작성
7. 통합 테스트 작성 (Controller)
8. Admin Console UI에 "출금 정책" 탭 추가 (별도 작업)

---

**Guide #36 완료**

작성자: Cowork (v2 설계팀)
