원화 오프램프 출금 기획·기술설계서 v0.9

파트너 유저에게 원화 지급 → 파트너 USDT 정산 · 출금팀 중개 · BSC/TRON 단일 체인 락업

크립토먼츠  |  2026-09-17  |  v0.8 기획 확정 → v0.9 기술설계 반영 (상태 6종 · 요청 시점 락업 · 코드베이스 제약 반영)

파트너가 원화 금액으로 유저 출금을 신청하면, 크립토먼츠가 신청 순간 빗썸 시세로 환산해 파트너 USDT를 단일 체인(BSC 우선 → TRON 폴백)에 즉시 락업하고 오더를 생성한다. 오더는 출금팀 페이지 + 텔레그램으로 전달되고, 출금팀이 승인 후 유저에게 원화를 지급한다. 지급이 보고·확인되면 락업 USDT를 출금팀·크립토먼츠에 정산하고 결과를 파트너 API로 반환한다.

01 확정 사항

구분항목확정
금액·환율 금액 기준원화(KRW) — 유저는 신청액 전액 수령. KRW는 BIGINT 원 단위 정수
환율접수 순간 빗썸 USDT 시세 각인 · 직전 대비 ±5% 초과 시 거절 · 캐시 5분 허용
소수점USDT 6자리 올림(ceil) — 내림하면 정산 분배가 모자란다
출금 한도단건 20만 ~ 500만원
수수료1% = 크립토먼츠 0.5% + 출금팀 0.5% · 파트너 부담 · 파트너별 요율 필드
락업 락업 시점요청(접수) 시점 — 접수 트랜잭션이 곧 락업 트랜잭션. 락업 실패 = 접수 실패
구현컬럼 없음. status IN ('REQUESTED','APPROVED') 파생 집계
체인BSC 메인 + TRON 폴백 · 접수 시 단일 체인 확정 · 분할 없음 · 회수도 동일 체인
동시성파트너 × 체인 비관적 락 — 기존 requestWithdrawal 프로토콜 재사용
오더 상태6종 — 요청 · 승인 · 완료 · 반려 · 회수 · 실패
유효시간1시간 · 초과 시 실패(EXPIRED) · TTL 연장 가능(팀 1회 / 운영자 무제한)
정렬접수순(FIFO)
출금팀 구성1팀 · 주야간 교대 · 정산은 팀 단일 계정 · 전 구간 단독 처리
지급 타이머20만~100만 30/60분 · 100만~500만 60/120분 (지연인출제도 반영)
보고증빙 필수 + 계좌 뒷4자리 대조 (불일치 시 차단)
예치금팀이 자기 계좌에 입금 기준 약 30% 자율 유지 — 시스템 관리 대상 아님
정산 처리원자적 3분할 (전부 or 전무) · 건별 즉시 내부 적립
팀 인출실시간 — 지급 확인 후 이상 없으면 즉시 인출 가능 (홀드 없음).
사후 반송 시에는 마이너스 상계로 회수 (다음 정산분에서 자동 차감)
가스비팀 인출 시 크립토먼츠 부담 (기존 Relayer 대납 재사용)
채널정보 분리 API = 사유 코드만 / 잔액·내역은 관리페이지 + 파트너별 텔레그램
제외범위 밖 LP ❌ · eKYC ❌ · 재배정 ❌ · 오더 선점락 ❌ · 고액 2인 확인 ❌

02 주체와 역할

주체내주는 것받는 것접점
파트너사 2개 → 확장 USDT (접수 시 락업)유저 출금 대행 완료신청·조회·가용성 API / webhook / 관리페이지 / 텔레그램
유저(수취인) 원화 신청액 전액파트너 앱·웹 (크립토먼츠와 직접 접점 없음)
출금팀 1팀 · 주야간 유저에게 원화 지급USDT + 0.5% (팀 계정 적립)출금팀 오더 페이지 + 텔레그램 단톡방
크립토먼츠 중개 · 환산 · 정산 · 회수 협조수수료 0.5%시스템 · 운영자 콘솔

출금팀은 partners 테이블의 내부 파트너로 등록한다 — 원장 적립과 인출이 기존 구조를 그대로 재사용한다.

03 금액 산정

① 신청 (원화)
1,000,000 원
÷
② 빗썸 시세 (접수 순간)
1,430 원
=
③ 환산 USDT (6자리 올림)
699.300700
+
④ 수수료 1%
6.993007
=
락업 총액
706.293707

원화 신청액 ÷ 빗썸 시세 = 락업 USDT · 유저는 1,000,000원 전액 수령
올림 이유 — 내림하면 정산 분배 시 극소량이 모자라 실패한다. 파트너가 아주 조금 더 부담하지만 절대 부족하지 않다. 남는 dust는 정산 시 파트너에게 원복한다.
락업 총액은 저장하지 않는다usdt_amount + fee_amount 로 유도 (“저장하지 않고 유도” 원칙).

금액 구간

구간지급 경고에스컬레이션비고
20만 ~ 100만 미만30분60분단독 처리
100만 ~ 500만60분120분은행 지연인출제도(100만↑ 30분 출금제한) 반영
범위 밖접수 거절KRW_AMOUNT_OUT_OF_RANGE(1112)
0.5%
크립토먼츠
0.5%
출금팀
1.0%
기본 요율 · 파트너 부담

04 메인 플로우 — 전체 도면

파트너사
크립토먼츠
출금팀
유저
API API 호출
TG 텔레그램
──▶ 실물 이동
┈▶ 거절·반려
파트너사Partner API 크립토먼츠System 출금팀오더 페이지 · 주야간 유저수취인 API [0] 가용성 조회 (사전) available · maxAmountKrw · reason ↳ 요청 전 확인용 — 파트너 선택 사항 (화면·버튼은 파트너 영역) API [1] 출금 신청 — 원화 금액 partner_id · user_id · order_id · krw_amount · 수취계좌(예금주) [2] 검증 · 환율 각인 · 체인 선택 · 멱등(order_id) · 서비스 게이트 · 금액 20만~500만 · 파트너/유저 한도 · 빗썸 시세 → 각인 (±5% 필터 · 캐시 5분) · 필요 USDT = 원화 ÷ 시세 (6자리 올림) · 체인 후보 스캔 (BSC 우선) — 락 밖 거절 반환 — 검증 실패 (사유 코드) [3] ★ 락업 — 접수 트랜잭션 🔒 파트너 × 체인 FOR UPDATE 연타 차단(60초) → 동시진행 상한 → 가용액 검증 INSERT → 저장 후 재검증 (음수면 롤백) 상태 · 요청 409 거절 — 잔액 부족 / 동시진행 상한 ↳ 롤백 — 오더 생성 안 됨 (orderCode 없음) TG [4] 텔레그램 단톡방 알림 ↳ 오더 페이지 리스트 (접수순 · 파트너 구분) API 오더 리스트 조회 ⏳ TTL 1시간 · 초과 → 실패(EXPIRED) · 연장 가능 (팀 1회 / 운영자 ∞) ALT · 출금팀 판단 API [5-A] 승인 — 잔액 검증 없음 상태 · 승인 [5-B] 반려 — 사유 필수 → 상태 【반려】 · hold 자동 해제 · 파트너 통지 [6] 원화 지급 💵 화면 표시 금액 그대로 이체 · 예금주 대조 ⏱ 승인 후 미지급 · 20만~100만 : 30분 경고 / 60분 에스컬 · 100만~500만 : 60분 / 120분 API [7] 지급완료 보고 증빙 필수 + 계좌 뒷4자리 대조 (불일치 시 차단) [8] ★ 정산 · 분배 (원자적) 상태 · 완료 출금팀 (내부 적립)USDT + 0.5% 크립토먼츠0.5% 파트너ledger DEBIT + dust 원복 hold 해제와 DEBIT 이 같은 트랜잭션 · 팀 인출 실시간 [9] 완료 반환 webhook eventId 포함 · 조회 API 가 정본 실패 경로 만료(EXPIRED) · 파트너/운영자 취소 · 지급확인 실패 · 정산 실패 상태 · 실패 → hold 자동 해제 회수 — 완료 이후 발생 계좌 지급정지·해지 등으로 며칠 뒤 반송 → 운영자 역정산 : 팀·시스템 회수 → 파트너 복구 상태 · 회수 ← 부족 시 마이너스 상계

05 오더 상태 6종 · 전이

🔒 hold 대상 요청락업 완료 승인지급 진행 완료정산 완료 회수역정산 보고 → 정산 이체 반송 실패 만료 · 취소 · 확인실패 · 정산실패 반려 출금팀 거절 만료 · 취소 출금팀 반려 확인·정산 실패 ※ 반려·실패·완료·회수로 바뀌는 순간 hold 집계에서 자동 제외 — “원복” 코드는 존재하지 않는다
상태진입hold다음
요청접수 + 락업 완료🔒 대상승인 / 반려 / 실패(만료·취소)
승인출금팀 승인 (잔액 검증 없음)🔒 대상완료 / 실패
완료지급 보고 + 정산 분배✅ 소멸회수 (사후 가능)
반려출금팀 거절 (사유 필수)↩️ 자동 해제종결
실패만료 · 취소 · 확인실패 · 정산실패↩️ 자동 해제종결
회수이체 반송 → 운영자 역정산↩️ 파트너 복구종결

06 ⭐ 락업 모델 — 파생 hold

이 시스템에는 잔액 컬럼이 없다

settlement_balances.frozen_amount2026-07-02 운영에서 DROP 됐다 (교차간섭 · 드리프트). freeze() 는 DB 쓰기가 없는 검증 전용이고 unfreeze()no-op 이다. hold 는 매번 레코드 status 에서 파생된다.

-- 파트너 USDT 가용잔액 (SettlementService.getAvailableBreakdown)
available = computeBalance(ledger_entries)        -- CREDIT+ADJUSTMENT − DEBIT−FEE
          − sumPendingGeneralWithdrawals          -- 기존: withdrawals 파생
          − sumPendingCollection                  -- 기존: collection_queue 파생sumPendingKrwOrderHold                  -- 신규 ← 이 한 항만 추가

-- KrwWithdrawOrderMapper.sumPendingHold
SELECT COALESCE(SUM(usdt_amount + fee_amount), 0)
FROM krw_withdraw_orders
WHERE partner_id = #{partnerId}
  AND currency_id = #{currencyId}
  AND network_id  = #{networkId}
  AND status IN ('REQUESTED', 'APPROVED');

축은 (partner_id, currency_id, network_id) 3키다. USDT는 체인마다 다른 currency_id 를 갖는다 (BSC=1 / POLYGON=2 / TRON=3) — “단일 체인 락업”이 기존 구조와 정확히 맞는다. BSC 부족 시 TRON 으로 폴백하되 한 오더는 한 체인만 쓴다.

☠️ 이중차감 방지 계약

-- 정산 트랜잭션 안에서 반드시 함께 일어나야 한다
 status : APPROVED → COMPLETED    -- hold 집계에서 빠짐
 ledger DEBIT 기록                -- 실감소 반영

-- 둘이 갈라지면
①만 반영 → 유령잔액 (묶이지도 차감되지도 않음)
②만 반영 → 이중차감 (hold + DEBIT 양쪽)

P2P 가 이 지점에서 두 번 사고를 냈다getSystemUnrealizedFeeBySource(2026-07-02 제거), p2p_partner_locks.locked_balance(2026-08-27 제거). 신규 항 추가 시 기존 3항과 이중차감이 없는지 반드시 검증할 것.

07 책임 경계 · 잔액 부족 시 API 응답

책임 경계 — 우리는 어디까지 하는가

영역담당내용
출금 화면 · 버튼 · 입력폼파트너사유저에게 보여주는 UI 전부. 크립토먼츠 관여 없음
유저 안내 문구 노출파트너사우리가 userMessage 를 내려주되, 노출 방식은 파트너 결정
출금 요청 접수크립토먼츠API 로 요청 수신 → 검증 · 환율 각인 · 락업 · 오더 생성
상태별 응답 반환크립토먼츠동기 응답(HTTP) · 조회 API · 결과 webhook
출금팀 처리 · 정산크립토먼츠오더 페이지 · 승인 · 지급 확인 · USDT 정산

구조는 단순하다 — 우리는 출금 요청 API 를 받고, 상태별로 API 를 반환한다. 그 앞단(유저가 무엇을 보는지)은 파트너사 영역이다.

☠️ 잔액 부족 = 오더를 만들지 않는다

락업이 접수 트랜잭션의 일부이므로, 락업이 실패하면 INSERT 자체가 롤백된다. 오더 레코드가 남지 않고, 오더 코드도 발급되지 않는다.

POST /api/v1/krw-withdrawals
   ↓
 ... 검증 · 환율 각인 · 체인 선택 ...
   ↓
 🔒 파트너 × 체인 FOR UPDATE
   ↓
 가용액 검증 → 부족트랜잭션 롤백 — 오더 생성 안 됨HTTP 409  KRW_PARTNER_BALANCE_SHORT (1117)

오더가 없으므로 조회해도 나오지 않고(404), webhook 도 발송되지 않는다. 파트너는 동기 응답만으로 실패를 알 수 있다 — 비동기 대기가 필요 없다.

응답 스펙 — 접수 거절

HTTP 409 Conflict
{
  "code": "1117",
  "reasonCode": "TEMPORARILY_UNAVAILABLE",
  "retryable": true,
  "retryAfterSeconds": 300,
  "userMessage": "현재 출금 신청이 일시 중단되었습니다. 잠시 후 다시 시도해 주세요."
}
// orderCode 없음 — 오더가 생성되지 않았다
reasonCodeHTTPretryable원인
TEMPORARILY_UNAVAILABLE409파트너 잔액 부족 · 동시 경합 · 동시진행 상한
SERVICE_CLOSED409점검시간 · 접수 중지 · 사전차단 버퍼
AMOUNT_OUT_OF_RANGE40020만 미만 / 500만 초과
DAILY_LIMIT_EXCEEDED409파트너·유저 일일 한도
RATE_UNAVAILABLE503시세 조회 실패 · 캐시 초과 · ±5% 이상치
DUPLICATE_ORDER_ID409멱등키 충돌 (다른 내용으로 재사용)

userMessage 를 우리가 내려주는 이유 — 파트너마다 제각각 번역하다 “테더 잔액 부족” 같은 내부 사정이 유저에게 새는 것을 막는다. 파트너는 이 문자열을 그대로 쓰면 되고, 쓸지 말지는 파트너 선택이다.

거절을 줄이는 장치 — 우리가 제공하는 것

장치형태내용
가용성 조회 API 신규API GET /availabilityavailable · maxAmountKrw. 파트너가 요청 전에 확인할 수 있게 제공한다 (활용 여부는 파트너 판단)
가용액 임계 경고텔레그램 low_balance_alert_usdt 이하 → 파트너 채널 즉시 알림 + 소진 예측. 부족해지기 전에 충전 유도
잔액 현황관리페이지 체인별 가용/락업중 · 최대 출금 가능액 · 부족 경고 (API 로는 주지 않음)
동시 진행 상한설정 concurrent_limit_krw — 락업 폭주로 잔액이 한 번에 소진되는 것을 방지

08 DDL — SECTION 10 신설

-- ═══════════════════════════════════════════════════════════════════
-- │  SECTION 10: 원화 오프램프 출금 (KRW Off-ramp Withdrawal)        │
-- │  v2.20 추가 (2026-09-17)                                        │
-- ═══════════════════════════════════════════════════════════════════

-- 10-1. 원화 출금 오더
-- ★ 락업은 접수 시점에 이뤄진다. 접수 트랜잭션이 곧 락업 트랜잭션이다.
-- ☠️ 잔액/락업 컬럼을 두지 않는다. frozen_amount 는 2026-07-02 DROP 됐다.
--    hold 는 status IN ('REQUESTED','APPROVED') 파생이며, 상태가 바뀌면
--    자동으로 빠진다. "원복" 이라는 별도 동작은 존재하지 않는다.
CREATE TABLE krw_withdraw_orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_code VARCHAR(30) NOT NULL          COMMENT '오더 코드 — kwo_{nanoid}',
    partner_id      BIGINT NOT NULL          COMMENT 'partners.id',
    partner_user_id VARCHAR(255) NOT NULL     COMMENT '파트너 회원 ID',

    -- 금액 (KRW 원 단위 정수)
    krw_amount BIGINT NOT NULL               COMMENT '신청 원화액 = 유저 수령액',

    -- 환율 각인 (접수 순간 고정)
    rate_krw DECIMAL(20,4) NOT NULL          COMMENT '빗썸 USDT 시세',
    rate_at  DATETIME(6)   NOT NULL          COMMENT '조회 시각 — 분쟁 근거',

    -- USDT 환산 (6자리 올림)
    usdt_amount DECIMAL(36,18) NOT NULL       COMMENT '환산 원금 (krw ÷ rate, 올림)',
    fee_rate    DECIMAL(10,6)  NOT NULL       COMMENT '적용 요율 — 퍼센트 (1.0 = 1%)',
    fee_amount  DECIMAL(36,18) NOT NULL       COMMENT '수수료 확정액 (파트너 부담)',
    -- ※ 락업 총액은 저장하지 않는다 — usdt_amount + fee_amount 로 유도

    -- ★ 락업 대상 — 접수 시 확정
    currency_id BIGINT NOT NULL              COMMENT 'BSC=1 / POLYGON=2 / TRON=3',
    network_id  BIGINT NOT NULL              COMMENT '회수도 동일 체인',

    -- 수취 계좌 (개인정보 — 목록 마스킹)
    bank_code      VARCHAR(20)  NOT NULL,
    account_no     VARCHAR(50)  NOT NULL,
    account_holder VARCHAR(100) NOT NULL     COMMENT '예금주 — 화면 강조, 육안 대조',

    partner_reference VARCHAR(255)           COMMENT '파트너 주문번호 — 멱등키 원본',
    partner_metadata  JSON                   COMMENT 'pass-through',

    -- 상태 6종
    status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED'
        COMMENT 'REQUESTED(요청) → APPROVED(승인) → COMPLETED(완료)
                 COMPLETED → REVERSED(회수)
                 분기: REJECTED(반려) / FAILED(실패)
                 ★ hold 대상 = REQUESTED, APPROVED',

    -- 처리자 (감사)
    approved_by VARCHAR(255),  approved_at DATETIME(6),

    -- 지급 보고 (상태 아님 — 승인 구간의 시각 기록)
    reported_by        VARCHAR(255),  reported_at DATETIME(6),
    proof_type         VARCHAR(20)   COMMENT 'IMAGE / TXNO',
    proof_ref          VARCHAR(500)  COMMENT '이체확인증 경로 또는 거래번호',
    paid_account_last4 VARCHAR(4)    COMMENT '실제 송금 계좌 뒷4자리 — 대조 근거',
    report_id          VARCHAR(64)   COMMENT '보고 멱등키',

    -- 정산
    settled_at DATETIME(6),
    settle_retry_count INT NOT NULL DEFAULT 0
                                        COMMENT '★정산 재시도 횟수 (최대 5회)',

    -- 회수 (완료 이후 사후)
    reversed_at DATETIME(6),  reverse_reason VARCHAR(500),

    -- 종결
    closed_at    DATETIME(6),
    close_reason VARCHAR(50)
        COMMENT 'AGENT_REJECTED / EXPIRED / CANCELLED_BY_PARTNER /
                 CANCELLED_BY_ADMIN / PAYMENT_MISMATCH / SETTLE_FAILED / REVERSED',
    incident_note TEXT                  COMMENT '사고 메모 — 회수는 시스템 밖 트랙',

    expire_at  DATETIME(6) NOT NULL         COMMENT '접수 + TTL',
    extend_count INT NOT NULL DEFAULT 0     COMMENT 'TTL 연장 횟수',

    created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
    updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),

    -- 멱등키 — withdrawals.idem_key 패턴 그대로
    -- ★ 종결 상태 목록을 애플리케이션에서 다시 열거하지 말 것. 이 CASE 가 유일한 정의다.
    idem_key VARCHAR(255) GENERATED ALWAYS AS (
        CASE WHEN status IN ('REJECTED','FAILED','REVERSED')
             THEN NULL ELSE partner_reference END
    ) STORED,

    UNIQUE KEY uk_kwo_code (order_code),
    UNIQUE KEY uk_kwo_partner_idem (partner_id, idem_key),
    KEY idx_kwo_hold (partner_id, currency_id, network_id, status),  -- hold 집계 전용
    KEY idx_kwo_status_created (status, created_at),
    KEY idx_kwo_expire (status, expire_at),
    KEY idx_kwo_settle_pending (status, reported_at, settled_at),  -- ★정산 대기 조회
    KEY idx_kwo_partner_user (partner_id, partner_user_id, created_at)
) COMMENT '원화 오프램프 출금 오더 — 락업은 접수 시점, hold 는 status 파생';


-- 10-2. 파트너별 원화 출금 설정
CREATE TABLE partner_krw_withdraw_configs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    partner_id BIGINT NOT NULL,
    is_enabled TINYINT(1) NOT NULL DEFAULT 0,
    fee_rate        DECIMAL(10,6) NOT NULL DEFAULT 1.000000  COMMENT '총 요율 %',
    system_fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0.500000  COMMENT '크립토먼츠 몫 %',
    min_amount_krw         BIGINT NULL   COMMENT 'NULL = 전역 설정',
    max_amount_krw         BIGINT NULL,
    daily_limit_krw        BIGINT NULL,
    user_daily_limit_krw   BIGINT NULL,
    user_daily_limit_count INT    NULL,
    concurrent_limit_krw   BIGINT NULL   COMMENT '★동시 진행(hold) 상한 — 락업 폭주 방지',
    low_balance_alert_usdt DECIMAL(36,18) NULL COMMENT '★가용액 임계 — 이하면 사전 경고',
    created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
    updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    UNIQUE KEY uk_pkwc_partner (partner_id)
) COMMENT '파트너별 원화 출금 설정 — 요율·한도·임계';

컨벤션 준수 — ENUM 미사용(VARCHAR+COMMENT) · FK 없음 · DATETIME(6) · KRW=BIGINT / USDT=DECIMAL(36,18) / 환율=DECIMAL(20,4) / 요율=DECIMAL(10,6) 퍼센트 · 코드 {prefix}_{nanoid} · 인덱스 uk_/idx_ 테이블 약어 접두.
원장 참조는 단일 축으로 고정reference_type='KRW_WITHDRAW' + reference_id=krw_withdraw_orders.id. p2p_settlements 는 두 ID 공간을 섞어 파트너 경계를 넘는 조인 사고를 냈다.

09 상태머신 구현 프로토콜

① 접수 = 락업 — 기존 requestWithdrawal 프로토콜 재사용

[락 밖]
 ① 멱등 검사 (partner_reference)            ← 최우선. 뒤에 두면 중복이 다른 오류로 먼저 실패
 ② 서비스 게이트 (점검시간 · 접수중지 · 사전차단 버퍼)
 ③ 금액 범위 (파트너 설정 > 전역 설정) · 일일/유저 한도
 ④ 시세 조회 → ±5% 이상치 필터 → 각인      ← 캐시 5분, 초과 시 거절. 임의 추정 금지
 ⑤ 체인 후보 스캔 (BSC 우선, 읽기 전용)       ← I/O 를 락 안에 넣으면 전 파트너가 직렬 대기

[락 안 — 선택된 1개 체인만]
 ⑥ 🔒 lockPartnerBalanceForWithdrawal(partner, currency, network)  FOR UPDATE
 ⑦ 연타 차단 (60초 창)                       ← 반드시 락 이후. 앞에 두면 동시 요청이 전부 통과
 ⑧ 동시 진행 상한 검사 (concurrent_limit_krw)                      (2026-05-21 사고: 1초 53건)
 ⑨ 가용액 검증 (자기 미포함) → 부족 시 409
 ⑩ INSERT (REQUESTED)                       ← save 를 재검증보다 먼저 (자기 레코드가 hold 에 포함되도록)
 ⑪ 저장 후 재검증 (자기 포함) → 음수면 롤백
 ⑫ 상태 이력 + 출금팀 텔레그램

② 승인 — 잔액 검증 없음

상태 가드(REQUESTED) + 만료 확인
  → UPDATE status=APPROVED,
           approved_by, approved_at
  → 상태 이력

이미 락업돼 있으므로 잔액을 보지 않는다. 승인은 “이 건을 처리하겠다”는 선언이다.

③ 지급완료 보고 — 상태 변화 없음

① 상태 가드 (APPROVED 만)
② report_id 멱등 — 재시도 시 기존 결과 반환
   (에러 던지면 또 누른다)
③ 증빙 필수 (proof_type + proof_ref)
④ 계좌 뒷4자리 대조 → 불일치 409
⑤ UPDATE reported_*, proof_*, last4
⑥ 곧바로 정산 트리거

④ 정산 → 완료 (원자적)

@Transactional  // 전부 or 전무
 ① 상태 가드 (APPROVED + reported_at NOT NULL)
 ② settlementService.debit (파트너,  usdt + fee,     KRW_WITHDRAW, order.id)
 ③ settlementService.credit(출금팀,  usdt + 팀몫,     KRW_WITHDRAW, order.id)
 ④ settlementService.recordFee(     시스템몫,        KRW_WITHDRAW, order.id)
 ⑤ UPDATE status=COMPLETED, settled_at        ← 팀 인출은 즉시 가능
 ⑥ (트랜잭션 밖) webhook + 텔레그램          ← 알림 실패가 자금 트랜잭션을 깨지 않게 try/catch

⚠ 정산 실패 시 — 트랜잭션이 롤백되어 상태는 APPROVED 로 남는다. 지급은 이미 끝났으므로 KrwSettleRetryJobstatus='APPROVED' AND reported_at IS NOT NULL AND settled_at IS NULL 을 주워 재시도한다.

⑤ 반려 · 실패 — status 변경 한 번

UPDATE status = REJECTED | FAILED,
       closed_at, close_reason
→ hold 집계에서 자동 제외
→ 별도 "원복" 코드는 존재하지 않는다
→ webhook + 파트너 텔레그램

⑥ 회수 (역정산) — 운영자 전용

@Transactional
 ① 가드 (COMPLETED + reversed_at IS NULL)
 ② 출금팀 DEBIT  (usdt + 팀몫 회수)
 ③ 시스템 수수료 반환 (ADJUSTMENT)
 ④ 파트너 CREDIT (전액 복구)
 ⑤ UPDATE status=REVERSED, reversed_at

10 API 스펙

A. open-api — 파트너 서버 연동 (HMAC 3헤더)

MethodPath설명
GET/api/v1/krw-withdrawals/availability 신규 가용성 조회available · maxAmountKrw · reason. 파트너가 요청 전 확인용으로 선택 사용. 잔액은 주지 않는다
POST/api/v1/krw-withdrawals 신청 → 201 / 멱등 재요청 200 / 잔액부족 409
GET/api/v1/krw-withdrawals/{orderCode}단건 조회 — 정본
GET/api/v1/krw-withdrawals?partnerReference= 응답 유실 복구404 = 접수 안 됨, 재시도 안전
GET/api/v1/krw-withdrawals목록 XPage
// POST 요청
{ "orderId": "P20260917-001",   // 멱등키 (빈문자 → null 정규화)
  "userId": "user_12345",
  "krwAmount": 1000000,          // 원 단위 정수
  "bankCode": "004", "accountNo": "12345678901234", "accountHolder": "홍길동",
  "metadata": "{...}" }          // pass-through

// 응답 — ❌ 잔액 정보 없음
{ "orderCode": "kwo_a1b2c3d4", "status": "PROCESSING",
  "krwAmount": 1000000, "estimatedMinutes": 30 }

상태 매핑 (내부 6종 → 파트너 3종) — 요청·승인PROCESSING / 완료COMPLETED / 반려·실패·회수FAILED + reasonCode

B. partner-api — 콘솔 (세션 + OTP)

MethodPath
GET/api/partner/krw-withdrawals 목록
GET/api/partner/krw-withdrawals/{code} 상세
POST.../{code}/cancel OTP요청 상태만
GET.../balance-status — 체인별 잔액·hold·최대 가능액

C. admin-api — 출금팀 · 운영자

Path권한
GET /api/admin/krw-withdraw-orders
GET .../dashboard
POST .../{code}/approve
POST .../{code}/reject팀 — 사유 필수
POST .../{code}/report팀 — 증빙 + 뒷4자리
POST .../{code}/extend 신규팀 1회 / 운영자 ∞
POST .../{code}/cancel운영자
POST .../{code}/reverse운영자 전용
GET .../reconcile운영자 — 대사

모든 매핑에 한글 name = + Javadoc @group/@auth/@response/@header — 이게 없으면 Axim REST 문서 생성기가 파트너 문서를 만들지 못한다. 목록은 XPage<T> + @XPaginationDefault(column="id") XPagination + from/to/status/search.

11 Webhook · 알림

eventTypeeventId트리거
KRW_WITHDRAWAL_COMPLETEDevt_kwo_{code}_ok정산 완료
KRW_WITHDRAWAL_FAILEDevt_kwo_{code}_fail반려·실패 (reason 구분)
KRW_WITHDRAWAL_REVERSEDevt_kwo_{code}_rev회수(역정산)
// 실패 페이로드 — 유저 문구를 우리가 내려준다
{ "eventType": "KRW_WITHDRAWAL_FAILED",
  "eventId": "evt_kwo_a1b2c3d4_fail",      // ★ 멱등 처리용 — 기존 출금엔 없는 필드
  "reasonCode": "TEMPORARILY_UNAVAILABLE",
  "retryable": true, "retryAfterSeconds": 300,
  "userMessage": "현재 출금 신청이 일시 중단되었습니다. 잠시 후 다시 시도해 주세요." }
reasonCoderetryable성격
TEMPORARILY_UNAVAILABLE잔액 부족 · 동시 경합
SERVICE_CLOSED점검시간 · 접수 중지
AGENT_REJECTED출금팀 반려
EXPIRED시간 초과
AMOUNT_OUT_OF_RANGE금액 수정 필요
DAILY_LIMIT_EXCEEDED내일 다시

구현 규칙 — 페이로드는 WebhookPayloadBuilder 안에서만 생성한다(2026-08-07 통합 이력, 호출부 JSON 조립 금지) · 서명식 partnerId|txHash|amount|timestamp 불변(txHash"") · 발송은 notificationService.send(...) 한 줄 + try/catch · 재시도 30s/60s/300s/900s/3600s 5회는 기존 인프라 그대로.
텔레그램 — 발송은 Spring 전담(Node 봇은 수신만). TelegramMessageFormatterkrwOrderRequested / Approved / Rejected / Paid / Settled / Reversed / LowBalance 추가. 출금팀 단톡방 + partner_telegram_configs 파트너별 채널.

12 에러 코드 · 설정 · 스케줄러

에러 코드 (1110~)

KRW_ORDER_NOT_FOUND           (1110)
KRW_ORDER_STATUS_INVALID      (1111)
KRW_AMOUNT_OUT_OF_RANGE       (1112)
KRW_SERVICE_CLOSED            (1113)
KRW_RATE_UNAVAILABLE          (1114)
KRW_RATE_DEVIATION            (1115)
KRW_ORDER_EXPIRED             (1116)
KRW_PARTNER_BALANCE_SHORT     (1117)  ← 접수 거절
KRW_PAYMENT_ACCOUNT_MISMATCH  (1118)
KRW_PROOF_REQUIRED            (1119)
KRW_CANCEL_NOT_ALLOWED        (1120)
KRW_REVERSE_NOT_ALLOWED       (1121)
KRW_DAILY_LIMIT_EXCEEDED      (1122)
KRW_CONCURRENT_LIMIT_EXCEEDED (1123)

360번대는 4개(366~369)만 남아 온체인 출금용으로 남긴다. 결번 재사용 금지 · 각 상수 Javadoc 에 사고 근거·날짜·유사 코드와의 구분 기재.

스케줄러 Job

Job주기역할
KrwOrderExpireJob1분TTL 초과 → 실패(EXPIRED)
KrwOrderRemindJob5분리마인드 · 지급 지연 경고/에스컬
KrwSettleRetryJob 신규5분정산 실패 건 재시도 (최대 5회) — 지급은 끝났는데 정산만 실패한 건
KrwLowBalanceJob 신규10분가용액 임계 경고 + 소진 예측 (스로틀 적용)
KrwReconcileJob일 1회대사 — 잔액변동 vs 정산합계 + hold 정합성

설정 키 — system_settings INSERT 블록에 함께 추가

├ krw_withdraw.enabled true ├ krw_withdraw.min_amount_krw 200000 ├ krw_withdraw.max_amount_krw 5000000 ├ krw_withdraw.order_ttl_minutes 60 ├ krw_withdraw.extend_minutes 30 (연장 단위) ├ krw_withdraw.rate_cache_seconds 300 ├ krw_withdraw.rate_deviation_percent 5 ├ krw_withdraw.high_amount_threshold_krw 1000000 ├ krw_withdraw.pay_warn_minutes 30 / _high 60 ├ krw_withdraw.pay_escalate_minutes 60 / _high 120 ├ krw_withdraw.remind_minutes 30 ├ krw_withdraw.settle_retry_max 5 ★ 정산 재시도 최대 (초과 시 관리자) ├ krw_withdraw.rapid_duplicate_window_seconds 60 ├ krw_withdraw.account_last4_check true ├ krw_withdraw.bank_maintenance 23:30-00:30 ├ krw_withdraw.pre_maintenance_block_minutes 60 ★ TTL 내 처리 불가 시간대 차단 ├ krw_withdraw.retry_after_seconds 300 ★ webhook retryAfter ├ krw_withdraw.chain_priority BSC,TRON (BSC 우선 → TRON 폴백) └ krw_withdraw.team_partner_id 0 (출금팀 내부 파트너)

⚠ 코드에서 조회하는데 DDL 씨드에 없는 키가 이미 7개 드리프트돼 있다. 신규 키는 반드시 DDL INSERT 블록에 함께 넣고, 코드에는 상수 + 폴백 기본값을 둔다.

13 화면 설계

화면은 cryptoments-admin repo 에 만든다 (본 repo 에는 추가하지 않는다). 설계서는 ADMIN_CONSOLE_UI_HANDOFF.md“엔드포인트 표 / 목록 컬럼 표 / 액션 표” 3단 포맷을 따른다.

A. 출금팀 오더 페이지 — admin-ui 신규 메뉴 「원화 출금」

대기 3건 · 대기 원화 2,300,000원 · 만료 임박 1건 ⚠ | 접수순(FIFO)
오더파트너원화액필요 USDT체인남은시간상태
kwo_a1b2A사1,000,000706.29BSC48분요청
kwo_c3d4B사500,000353.15BSC55분요청
kwo_e5f6A사800,000565.03BSC⚠ 8분승인

잔액부족 배지는 없다 — 접수 단계에서 이미 걸러진다. 대신 남은시간(TTL) 이 핵심 컬럼이다.
상세(SlideDrawer) : 계좌·금액 [복사] 버튼(수기 입력 제거) · 예금주명 강조 · 남은시간 · [승인] [반려] [시간 연장] · 지급완료 보고 폼(증빙 + 뒷4자리 입력·대조, 불일치 시 버튼 비활성)

B. 파트너 관리 페이지 — partner-ui

A사 USDT 잔액 현황
체인가용락업중(hold)합계
BSC494.00706.291,200.29
⚠ 가용액이 임계 이하입니다 — 충전 권장
현재 가용으로 최대 출금 가능: 약 700,000원

접수 거절이 나기 전에 미리 알리는 것이 이 화면의 핵심 목적이다. 메뉴는 router/index.tsconstants.tsMENU_ITEMS 둘 다 추가.

C. 운영자 콘솔 — admin-ui

화면기능
전체 오더 조회상태·파트너·기간 필터
회수(역정산)완료 → 반송 처리. ConfirmDialog 2단(금액·파트너 명시)
강제 취소운영자 권한
일일 대사잔액변동 vs 정산합계 + hold 집계 정합성
설정system_settings 키 편집
사고 메모incident_note

UI 규칙 — 목록은 useDataTable composable 로만 · 공통 컴포넌트 (DataTable/StatusBadge/AmountDisplay/DateDisplay/EmptyState/LoadingSkeleton) 사용 · 서비스 레이어는 얇은 URL 매핑만(에러는 인터셉터 위임, catch 에서 mock 반환 금지) · 20건/페이지, XPagination 1-based, 기본 created_at DESC · 로딩은 스피너가 아니라 스켈레톤 · 위험 액션은 ConfirmDialog 필수 · 계좌·예금주는 목록 마스킹(상세에서만 전체).

14 경우의 수

구간케이스처리상태
A
접수
·
락업
중복 order_id멱등 — 기존 오더 반환
금액 범위 밖 / 한도 초과거절 (사유 코드)
시세 조회 실패5분 캐시 허용, 초과 시 거절 · 임의 추정 금지
시세 이상치(스파이크)직전 대비 ±5% 초과 거절 + 알림
점검시간 / 접수중지 / 사전차단 버퍼SERVICE_CLOSED
잔액 부족409 + 롤백 — 오더 생성 안 됨. 조회 404 · webhook 미발송. retryable + userMessage 반환
동시 신청 경합파트너×체인 FOR UPDATE 직렬화 + save 후 재검증
락업 폭주concurrent_limit_krw 동시 진행 상한
B
대기
TTL 1시간 초과실패(EXPIRED) — hold 자동 해제
처리 중 시간 부족TTL 연장 (팀 1회 / 운영자 ∞)
파트너 취소요청 상태만 가능
C
처리
승인 → 지급 → 보고정상 경로
반려사유 필수 → hold 자동 해제 + 파트너 통지
승인 후 방치금액대별 경고 → 에스컬 → 수동 종결 (자동 실패 없음)
다른 계좌로 이체뒷4자리 대조로 보고 차단 → 미지급 유지 → 재송금. 출금팀 책임 + 크립토먼츠 오프라인 회수 협조
중복 보고멱등 3중(프론트·report_id·상태가드) — 기존 결과 반환
D
정산
·
회수
정상 정산원자적 3분할 + hold 해제 동시
이체 금액 불일치정산 보류 → 차액 이체 or 수동 실패. 자동 정산 금지
이체 반송 / 유저 미수령회수(역정산) — 팀 잔액 부족 시 마이너스 상계(다음 정산분에서 차감)
정산 중 장애트랜잭션 롤백 → 승인 상태 복귀 · 정체 감지 알림 + 재개
E
연동
webhook 유실조회 API 가 정본 — 연동문서에 명시
webhook 중복 수신eventId 로 파트너가 멱등 처리
파트너 USDT 충전기존 입금 플로우 재사용 · confirm 지연은 “확인 중” 표시

15 문제 보고 · 추가 제안

🔴 1. 「돈은 나갔는데 정산이 안 된」 건이 방치된다

상태를 6종으로 줄이면서 출금중 을 없앴다. 정산 트랜잭션이 실패하면 롤백되어 상태가 승인 으로 남는데 — 유저는 이미 원화를 받았다. 출금팀은 USDT 를 못 받고, 파트너 락도 안 풀린 채 무한 방치된다. 게다가 지급 지연 타이머가 또 울려 출금팀이 “이미 보냈는데 왜 경고가 오지” 하게 된다.

제안 (반영됨) — ① settle_retry_count 컬럼 + ② 정산 대기 판별 status='APPROVED' AND reported_at IS NOT NULL AND settled_at IS NULL + ③ KrwSettleRetryJob(5분, 최대 5회) 재시도 → 초과 시 관리자 에스컬레이션 + ④ 지급 지연 타이머 조건에 reported_at IS NULL 추가 (이게 빠지면 오경보)

🔴 2. 팀 인출이 실시간이면 회수할 재원이 없다

정산 직후 출금팀이 USDT 를 인출하는데, 은행 반송은 며칠 뒤 올 수 있다. 그때 역정산하려 해도 팀 잔액이 이미 비어 있으면 파트너에게 돌려줄 방법이 없다.

제안 (반영됨)마이너스 상계. 회수 시 팀 잔액이 모자라면 음수로 기록하고 다음 정산분에서 자동 차감한다. 추가로 ① 마이너스가 임계를 넘으면 신규 오더 배정 중단 ② 마이너스 발생 시 출금팀이 메꾼다는 것을 계약에 명시.

🔴 3. TTL 1시간이 은행 점검시간과 충돌한다

23:10 접수 → 락업 → 00:10 만료. 그런데 23:30부터 점검으로 송금 불가 → 유저는 못 받고 재신청.

제안 (반영됨)pre_maintenance_block_minutes(기본 60분). 점검시간 시작 60분 전부터 접수 차단해 TTL 안에 처리 불가능한 오더가 생기지 않게 한다.

🟡 4. 접수 시 락업이라 잔액이 빠르게 소진된다

승인 시 락업일 때는 처리되는 만큼만 묶였는데, 이제 접수되는 족족 1시간씩 묶인다.

제안 (반영됨)concurrent_limit_krw 동시 진행 상한. 초과 시 KRW_CONCURRENT_LIMIT_EXCEEDED 로 거절해 잔액을 보호한다.

🟡 5. 체인 폴백이 생기면 오더마다 체인이 갈린다

BSC + TRON 둘 다 사용으로 확정됐다. BSC 가 부족하면 TRON 으로 락업되므로, 같은 파트너인데 오더마다 체인이 다를 수 있다. 운영자가 “왜 이 건만 트론이지” 하고 헷갈린다.

제안 — ① 오더 목록·상세에 체인을 항상 표시 ② 파트너 관리페이지 잔액을 체인별로 분리 표시 ③ 대사도 체인별로 수행. 한 오더는 한 체인만(분할 없음)이라는 원칙은 유지.

🟡 6. 멱등키 재사용 규칙을 파트너가 모른다

반려·실패되면 idem_key 가 NULL 이 되어 같은 orderId 로 재신청이 가능하다. 그런데 orderCode 가 발급되고 환율도 새로 잡힌다. 파트너가 모르면 “같은 주문번호인데 왜 금액이 다르지” 한다.

제안 — 연동 문서에 명시 + 재신청 응답에 isRetry: true 와 새 환율을 함께 반환.

🟡 7. 취소 ↔ 승인 경합

파트너가 취소를 누르는 순간 출금팀이 승인할 수 있다. 설계에는 가드가 있지만 문서에 명시가 빠져 있었다.

제안 — 상태 가드로 선착순 처리. 승인이 먼저면 취소는 409 KRW_CANCEL_NOT_ALLOWED, 취소가 먼저면 승인은 409 KRW_ORDER_STATUS_INVALID.

🟢 8. 환율 시차 — 출금팀 체리피킹

접수 시 각인 → 출금팀이 나중에 승인. 시세가 불리하면 반려하면 되지만, 반려가 잦으면 유저 경험이 나빠진다.

제안반려율을 대시보드에 노출해 TTL·요율 조정의 근거로 삼는다.

🟢 9. 모니터링 지표가 없다

제안 — 운영자 대시보드 4종 : 409 거절율(사유별 — 잔액부족이 잦으면 파트너 충전 정책 조정) · 반려율(환율 시차·TTL 재검토 신호) · 평균 처리시간(접수→완료, estimatedMinutes 응답값의 근거) · 만료율(TTL 1시간이 현실적인지 판단)

🟢 10. 잔액 경고 알림 폭주

가용액이 임계 근처에서 오르내리면 텔레그램이 분당 수십 건 갈 수 있다.

제안 — 동일 파트너 동일 사유는 N분에 1회만. 기존 SystemAlertNotifier 의 스로틀을 재사용한다.

제안 요약

#제안상태이유
1정산 재시도 배치 신규반영 지급은 끝났는데 정산만 실패한 건을 주워 5회 재시도 → 초과 시 관리자
2마이너스 상계 신규반영 실시간 인출의 전제 — 반송 시 회수할 유일한 수단
3지급 타이머 조건 수정반영 reported_at IS NULL 추가 — 보고한 건에 오경보가 가지 않게
4가용성 조회 API반영 파트너가 요청 전 확인 — 불필요한 409 감소 (사용 여부는 파트너 판단)
5동시 진행 상한반영접수 시 락업의 부작용(잔액 소진) 차단
6가용액 임계 사전 경고반영거절 나기 전에 충전 유도
7점검시간 사전 차단 버퍼반영TTL 안에 처리 불가능한 시간대 접수 차단
8TTL 연장 액션반영만료로 죽이는 것보다 저렴
9webhook eventId반영기존 출금의 구멍(멱등 불가)을 반복하지 않는다
10userMessage 하달반영내부 사정(“테더 부족”)이 유저에게 새지 않게
11출금팀 = 내부 파트너 등록반영원장·인출을 기존 구조 그대로 재사용
12체인별 표시·대사검토BSC/TRON 폴백으로 오더마다 체인이 갈리므로
13isRetry 응답검토멱등키 재사용 시 새 환율 혼란 방지
14모니터링 지표 4종검토거절율·반려율·처리시간·만료율
15알림 스로틀검토잔액 경고 폭주 방지
16대사에 hold 정합성 포함반영파생 hold 모델의 유일한 감시 수단

16 구현 순서

단계내용비고
0DDL 확정 + 운영 적용SECTION 10 · 헤더 v2.20 · system_settings 씨드 동시
1common — Entity · Repository · Mapper · ErrorCodes컨벤션 준수 (XEntity/XColumn/XRepository)
2core — KrwWithdrawService + 가용잔액 산식 확장 위험 구간 — 기존 출금·P2P·집금과 이중차감 검증 필수
3admin-api — 출금팀 · 운영자 API
4open-api + partner-apiavailability API 포함
5Webhook · 텔레그램 포매터WebhookPayloadBuilder 확장 + eventId
6scheduler Job 4종
7UI — cryptoments-admin repoadmin-ui · partner-ui
8연동 문서 · 약관guide-ui EN 본문 + KO 사전 동시 갱신

17 관통 원칙

원칙적용
① 저장하지 않고 유도락업 총액·hold·정산 누적을 컬럼으로 만들지 않는다. 만들면 두 번째 진실이 된다
② 애매하면 사람이 판단분쟁·금액 불일치·이상거래는 자동 판정 금지 — 오판 비용이 훨씬 크다
③ 숫자는 시스템이, 실행은 사람이환율·원화액·필요 USDT 를 시스템이 확정 → 출금팀은 표시된 금액 그대로 이체
④ 값은 전부 설정으로시간·한도·요율·임계 — 코드 수정 없이 운영이 조정
⑤ 기존 것 재사용시세 · 텔레그램 · 원장 · webhook 파이프라인 · 상태이력 · Relayer 대납
⑥ 자동 실패는 락 없는 구간에만만료는 자동, 승인 이후는 전부 사람이 종결 (돈이 이미 나갔을 수 있다)
⑦ 내부 사정을 응답에 싣지 않는다잔액·체인 내역은 API 로 주지 않는다. 사유 코드 + userMessage 까지가 우리 몫
⑧ 책임 경계를 지킨다우리는 요청 접수 · 상태별 응답 · 처리 · 정산. 파트너 화면·버튼은 파트너 영역
원화 오프램프 출금 기획·기술설계서 v0.9 · 2026-09-17
상태 6종 · 요청 시점 락업 · 파생 hold · BSC/TRON 단일 체인 · 출금팀 중개