NestPay 선불 지갑 플랫폼 · 실제 코드/DB(nestpay, Flyway V1~V70 적용) 기준 재확인
문서 목적 — 본 문서는 NestPay 서버(paynest-v1)의 배포 구성, 애플리케이션 계층 구조, 그리고 돈이 오가는 주요 업무의 처리 순서와 원장 복식분개(DR/CR/FEE)를 실제 소스 코드와 운영 DB 스키마로 재확인하여 정리한 설계 산출물입니다.
근거: apps/api/docker-compose.yml, apps/api/nginx/*, apps/api/src/main/java/kr/nestpay/api/**, application.yml, build.gradle, Flyway 마이그레이션(V1~V70) 및 운영 DB(nestpay) 조회. 다이어그램은 외부 스크립트 없이 텍스트로 그렸습니다.
| 구분 | 채택 기술 | 버전 / 근거 | |
|---|---|---|---|
| 언어 · 런타임 | Java | 17 (build.gradle toolchain JavaLanguageVersion.of(17) — 발주사 확정) | |
| 프레임워크 | Spring Boot | 3.3.5 (web · validation · actuator) | |
| SQL 매핑 | MyBatis | mybatis-spring-boot-starter 3.0.3 (SQL 을 XML 매퍼에 직접 작성) | |
| 스키마 관리 | Flyway | flyway-core + flyway-mysql, 부팅 시 V1~V70 자동 적용(빈 서버에 70건 전부 성공 확인) | |
| 데이터베이스 | MariaDB | 10.3 (utf8mb4 · event-scheduler ON — V35 파티션 수명관리) | |
| 파일 보관 | WAS 공유 폴더 | 별도 라이브러리 없음 | — 오브젝트 스토리지 사용 불가로 확정(2026-08-11), 자바 표준 파일 API 사용 |
| API 문서 | Springdoc(Swagger) | 4개 그룹(app/store/admin/pg), 운영은 내부 스펙 SWAGGER_ENABLED=false | |
| 리버스 프록시 | nginx | 1.27-alpine — server_name(서브도메인) 기반 신뢰 경계 분리 |
운영은 L4 → WEB(공개망) → WAS(폐쇄망) → DB 구조입니다(발주사 확정 2026-08-11). WEB·WAS 모두 동일 구성 2대씩이며 무상태(stateless)입니다. 두 WAS 가 하나의 공유 DB와 하나의 공유 파일 폴더를 바라보므로, 요청이 어느 쪽으로 가도 결과가 같습니다. WEB 의 nginx 하나가 3개 호스트(npwp-api·npwp-pg·npwp-admin)를 server_name 으로 동시에 서비스합니다(서버를 주소별로 나누지 않음).
[ 인터넷 / 외부 매장 서버 / 발주사 서버 ]
│
┌────────┴────────┐
│ L4 로드밸런서 │ /health 로 각 WEB 생사 확인
└────────┬────────┘
┌────────────────────────┴────────────────────────┐
│ 공개망 (DMZ) │
┌─────────┴──────────┐ ┌───────────┴────────┐
│ WEB #1 │ ······· 동일 ··········· │ WEB #2 │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ nginx 1.27 │ │ │ │ nginx 1.27 │ │
│ │ server_name │ │ 주소(npwp-api·pg·admin) │ │ server_name │ │
│ │ 가상호스트 │ │ 로 갈라 줌 │ │ 가상호스트 │ │
│ │ + 정적 파일 │ │ 관리자 화면·문서은 │ │ + 정적 파일 │ │
│ └──────┬───────┘ │ 여기서 직접 응답 │ └──────┬───────┘ │
└─────────┼──────────┘ └─────────┼──────────┘
│ 폐쇄망 (외부에서 직접 접속 불가) │
┌─────────┴──────────┐ ┌─────────┴──────────┐
│ WAS #1 (무상태) │ ······· 동일 ··········· │ WAS #2 (무상태) │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Spring Boot │ │ │ │ Spring Boot │ │
│ │ API :8080 │ │ │ │ API :8080 │ │
│ │ (@Scheduled │ │ │ │ (@Scheduled │ │
│ │ 워커 포함) │ │ │ │ 워커 포함) │ │
│ └──────┬───────┘ │ │ └──────┬───────┘ │
│ ┌──────┴───────┐ │ │ │ │
│ │ 파일 공유폴더│◀─┼───── 마운트(WAS #2가 함께 바라봄) ──┘ │
│ │ STORAGE_DIR │ │ │ │
│ └──────────────┘ │ │ │
└─────────┬──────────┘ └─────────┬──────────┘
└────────────────────┬─────────────────────────┘
┌────────┴────────┐
│ MariaDB 10.3 │ 원장·지갑·거래 (1대, 공유)
└─────────────────┘
※ 파일 실물: 오브젝트 스토리지를 쓸 수 없어(발주사 확정 2026-08-11), WAS #1 의 폴더를
WAS #2 가 함께 마운트합니다. 폴더가 연결돼 있지 않으면 서버가 아예 켜지지 않습니다.
※ 워커 동시성: 서버 2대가 같은 @Scheduled 를 돌려도, 각 작업은 claim_token(원자적 UPDATE) /
FOR UPDATE / 상태 가드 쿼리로 "한 건은 한 번만" 처리되도록 DB 에서 보호합니다(중복 처리 방지).
server_name 분기하나의 nginx 가 서브도메인별로 "허용 경로만" 열어 신뢰 경계를 나눕니다. 나머지 경로는 모두 404 로 막아, 관리자 API·내부 Swagger 스펙이 공개(api/pg) 호스트로 새지 않게 합니다.
| 호스트 | 운영 도메인 | 테스트 도메인 | 개발 포트 | 허용 경로 (conf.d) | 보호 방식 |
|---|---|---|---|---|---|
| www | npwp-www.nestpay.co.kr | npwp-twww.nestpay.co.kr | 6443 | 회사·서비스 소개 정적 사이트($uri.html 미러) | 공개(인증 없음) |
| app | npwp-api.nestpay.co.kr | npwp-tapi.nestpay.co.kr | 8443 | /app · /store · /health + 내부 Swagger(app/store/admin, 사내 IP) | 토큰 + 사내 IP(문서) |
| admin | npwp-admin.nestpay.co.kr | npwp-tadmin.nestpay.co.kr | 9443 | 관리자 정적 SPA + /api/* → 백엔드 /admin | 화이트 IP + 관리자 토큰(내부 전용) |
| pg | npwp-pg.nestpay.co.kr | npwp-tpg.nestpay.co.kr | 7443 | /pg/** + 외부 공개 Swagger(pg 그룹만) | HMAC 서명 + 매장 화이트 IP |
· 운영(443): 하나의 서버가 위 4개 서브도메인(www/app/admin/pg)을 server_name 으로 동시 서비스, 알 수 없는 도메인(직접 IP 접근 등)은 default_server 로 404. · 테스트 서버는 같은 도메인에 앞글자 t 를 붙입니다(twww/tapp/tadmin/tpg). 설정 파일은 한 벌(nginx/templates/nginx.conf.template)이고 환경변수 NP_HOST_PREFIX(운영="" · 테스트="t")로 갈립니다. · 앱 API 가 api. 가 아니라 app. 인 이유: api.nestpay.co.kr·tapi.nestpay.co.kr 는 발주사의 기존 결제 시스템(CyrexPay)이 이미 사용 중이라 사용할 수 없습니다. · 도메인 맨 앞을 뺀 nestpay.co.kr 접속은 www 로 301 이동. · 개발(로컬): DNS 가 없어 포트(6443/8443/9443/7443)로 같은 입구에 접속. · MariaDB(3307) 와 WAS 는 인터넷에 노출하지 않습니다(폐쇄망).
docker compose)| 서비스 | 이미지 | 역할 | 기동 조건 |
|---|---|---|---|
db | mariadb:10.3 | 스키마·기본데이터(Flyway) 저장 | healthcheck(mysqladmin ping) |
storage | minio (2024-10) | 파일·이미지 실물 | healthcheck(mc ready) |
api | 멀티스테이지 빌드 | Spring Boot API + 워커 | db · storage service_healthy 후 기동 |
web | nginx:1.27-alpine | 443/6443/7443/8443/9443 입구 | api 기동 후 |
표준 계층형 구조입니다. Controller(입출력·검증) → Service(업무 판단·트랜잭션 경계) → Mapper(MyBatis XML SQL) → MariaDB. 여러 흐름이 공유하는 계산·조회는 util 패키지의 순수 함수로 뽑아 중복을 제거했습니다(수수료·로트·지급시각 등).
┌──────────────────────────────────────────────────────────────────────────┐ │ Controller (kr.nestpay.api.controller) │ │ App*(회원앱) Store*(매장앱) Admin*(관리자) Pg*(외부PG) BankWebhook │ │ · 요청 DTO 검증(jakarta.validation) · ApiResponse표준 응답 │ └───────────────────────────────┬────────────────────────────────────────────┘ │ (권한·토큰은 아래 보안 필터에서 이미 판별) ┌───────────────────────────────┴────────────────────────────────────────────┐ │ Service (@Transactional 경계 = 한 업무 = 한 트랜잭션) │ │ AppPayment · AppWithdraw · AppGift · AppDeposit · StoreSettle · PgPayment │ │ ┌──────────────── 공용 유틸(util) — 중복 제거 ────────────────┐ │ │ │ Fees(수수료) Lots(로트 소비) LedgerLookups(시스템지갑·만료) │ │ │ │ PayoutSchedules(지급시각) PushPayloads(알림) Masks(마스킹) │ │ │ │ Hashes(SHA-256) Pagination BankVerify BankMaintenance │ │ │ └──────────────────────────────────────────────────────────────┘ │ └───────────────────────────────┬────────────────────────────────────────────┘ ┌───────────────────────────────┴────────────────────────────────────────────┐ │ Mapper (MyBatis · resources/mapper/*.xml) — 원장/지갑/거래 단일 원장 SQL │ └───────────────────────────────┬────────────────────────────────────────────┘ ┌───────────────────────────────┴────────────────────────────────────────────┐ │ MariaDB 10.3 — wallets · lots · transactions · ledger_entries · … │ └──────────────────────────────────────────────────────────────────────────────┘ [외부 연동은 인터페이스로 격리 — 계약 전 스텁 / 계약 후 실연동 교체] BankVerifier ← StubBankVerifier (실명조회·1원인증) IdentityVerifier ← StubIdentityVerifier (본인인증 PASS 등)
| 유틸 | 역할 | 계산/규약(코드 원문) |
|---|---|---|
Fees.calc | 수수료 계산(전 흐름 공통) | floor(금액 × rate% ÷ 100) + fixed_amount. 정책 없으면 0. (과거 충전만 floorDiv 로 달랐던 버그를 통일) |
Lots.consume | 유상 포인트 로트 소비(배분·차감) | DR 분개 1줄에 대해 오래된 로트부터 insertLotAllocation(배분 기록=취소 복원 근거) + decreaseLotRemaining |
LedgerLookups | 시스템지갑 조회 · 로트 만료개월 | systemWallet(code), expiryMonths() |
PayoutSchedules.from | 출금·정산 지급 예정 시각 | delay_days ≤ 0 → 즉시(now), 아니면 오늘+delay_days 일의 execute_time 시각 |
PushPayloads.of | 알림 payload(JSON) 생성 | kind·principalType·principalId·ntype·title·body·deeplink |
Masks / Hashes | 이름 마스킹 · SHA-256 지문 | 전화·카드번호는 sha256Hex 지문으로 조회(원문 미저장) |
가장 바깥은 nginx(호스트·경로 화이트리스트)이고, 그 안에서 Spring 서블릿 필터가 순서대로 검사합니다. 값싼 검사(IP)를 먼저, 비용이 큰 검사(서명·토큰)를 뒤에 둡니다. 필터 등록·URL 패턴은 SecurityFilterConfig + application.yml 에서 확인.
요청 ─► [ nginx ] host(server_name)+경로 화이트리스트 (그 외 404)
│
├─ /admin/* ─► ① IpWhitelistFilter (order 1) 등록 IP 만 통과(없으면 bootstrap-allow)
│ ─► ③ AdminAuthFilter (order 3) 관리자 토큰 + 관리자별 개인 허용 IP
│
├─ /pg/* ─► ② HmacAuthFilter (order 2) X-Client-Id/Api-Key/Timestamp/Signature
│ · 시각 오차 300초 · client_id APPROVED · 키 해시대조(교체유예)
│ · HMAC-SHA256(method\npath\nts\nbody) 시간차 없는 비교
│ · 서명 통과 후 그 매장의 승인 화이트IP 인지 추가 확인(V7)
│
├─ /store/* ─► ④ StoreAuthFilter (order 4) 매장 출입증(토큰)
│
├─ /app/me/* ─► ⑤ UserAuthFilter (order 5) 회원 출입증(토큰)
│ (/app 의 가입·로그인·공개콘텐츠는 무인증)
│
└─ /webhooks/bank/* ─► X-Internal-Key(=INTERNAL_API_KEY) 상수시간 비교 (은행/PG 전용)
· 진짜 사용자 IP 판별: ClientIpResolver 가 신뢰 프록시(nginx)에서 온 X-Forwarded-For 만 신뢰.
· 내부 Swagger 스펙(app/store/admin): docsIpWhitelistFilter(order 1)로 사내 IP 에서만 열람.
| 필터 | order | 대상 경로 | 검사 내용 |
|---|---|---|---|
| IpWhitelistFilter | 1 | /admin/* | DB 등록 IP(없으면 bootstrap-allow) |
| docsIpWhitelistFilter | 1 | /v3/api-docs/{app,store,admin} | 내부 문서 스펙 사내 IP 제한 |
| HmacAuthFilter | 2 | /pg/* | HMAC 서명 + 매장 화이트 IP |
| AdminAuthFilter | 3 | /admin/* | 관리자 토큰 + 관리자별 IP |
| StoreAuthFilter | 4 | /store/* | 매장 토큰 |
| UserAuthFilter | 5 | /app/me/* | 회원 토큰 |
| 가드 | 실행 시점 | 차단 조건 (app.env ≠ dev 일 때) |
|---|---|---|
SecretsGuard(EnvironmentPostProcessor) | DB 연결·Flyway 이전(최초기) | Swagger 미차단 또는 개발 기본값 잔존: APP_CRYPTO_KEY · ADMIN_TOKEN_SECRET · INTERNAL_API_KEY · DB_PASSWORD |
StubGuard(ApplicationRunner) | 컨텍스트 기동 직후 | 외부 연동이 스텁 그대로: StubBankVerifier(실명조회·1원인증) · StubIdentityVerifier(본인인증) |
· 위 두 클래스가 실제 소스에 존재하는 부팅 가드입니다(config/SecretsGuard, config/StubGuard). 조건 미충족 시 IllegalStateException 으로 서버 기동을 중단합니다. 외부 연동(펌뱅킹·본인인증·은행 실명조회)은 계약 전 스텁 상태이며, 실연동 구현으로 교체해야만 운영 기동이 됩니다.
| 워커 | 주기 | 역할 | 동시성 보호 |
|---|---|---|---|
DepositNoticeWorker | 10초 | 입금 통지 처리(적립/미매칭) | FOR UPDATE 클레임 + 상태 가드(guardNoticeMatched/Unmatched) |
WebhookSender | 20초 | PG 매장서버로 결제완료 웹훅 발송 | webhook_claim_token(V34) |
OutboxWorker | 30초 | 알림함 적재 · 푸시 캠페인 · 고아 회수 | claim_token 원자적 UPDATE(V11) — MariaDB 10.3 엔 SKIP LOCKED 없음 |
PgExpirySweeper | 30초 | 만료된 PG 주문·QR 정리 | 상태 전이 가드 |
WithdrawWorker | 60초 | 예정 출금·정산 이체 제출(페이솔루스 출금요청 API → PENDING, 완료는 출금통지 웹훅) | claimWithdrawal(HOLD→PENDING) 원자 전이 |
DepositRequestExpiryWorker | 5분 | 입금통지 미도착 충전신청 만료(PENDING→EXPIRED) | 조건부 벌크 UPDATE(멱등 — 상태 전이만이라 claim 불필요) |
RateLimitService | 1시간 | 레이트리밋 낡은 카운터 청소 | — |
ReconcileWorker | 1시간 | 금액 무결성 대사(지갑잔액=원장순액·전역ΣDR=ΣCR·거래별 균형·수수료 기입) + 일별 집계·스냅샷 | integrity_runs.run_key UNIQUE + INSERT IGNORE 원자 클레임(V38) |
금액 무결성 상시 검증(대사) — ReconcileWorker가 매시간 네 규칙(①비시스템 지갑 balance == ΣCR-ΣDR ②전역 ΣDR==ΣCR ③거래별 ΣDR==ΣCR ④수수료 거래별 fee_amount == FEE_REVENUE 기입액)을 대조해 어긋난 값만 integrity_findings에 남기고, 하루 한 번 전날 daily_summaries(충전/결제/출금/정산/선물/수수료 합계)·daily_wallet_snapshots를 채운다. 관리자 #/integrity 화면에서 실행 이력·발견을 보고 조사중/해결/오탐으로 처리하며 [지금 검사]로 즉시 실행도 가능하다. 검사 결과는 영구 저장된다(integrity_check_runs 실행 이력 + integrity_findings 발견 + 일별 집계·스냅샷).
매장 매출 무결성 — 매장별로 "매장 지갑 잔액 == 받은 결제(순, 수수료 제외) − 취소 환불 − 정산 지급"을 대조한다(GET /store/integrity=매장앱 자기조회, GET /admin/integrity/merchant/{id}=관리자 매장상세). 매장앱 매출 화면과 관리자 매장 상세에 정상/점검 필요 배지 + 구성 내역으로 표시된다. 전역 대사가 이미 매장 지갑을 포함하므로, 이 매장별 뷰는 같은 원장을 매장 관점으로 풀어 보여 주는 것이다.
모든 자금 이동은 transactions 1건 + ledger_entries 여러 줄로 기록되는 단일 원장(single ledger) 구조입니다. 각 줄은 direction(DR 차변 / CR 대변) · amount · balance_after 를 가지며, 한 거래의 DR 합 = CR 합(균형)입니다. 수수료는 항상 별도 CR 줄로 시스템 수수료 지갑에 적립합니다.
시스템 지갑(wallets.system_code) | 용도 |
|---|---|
| SETTLEMENT_CLEARING | 대외 청산(충전 유입·출금/정산 유출의 상대 계정) |
| FEE_REVENUE | 플랫폼 수수료 수익 |
| SEIZED | 압류 보관 — 사고 조사 중인 돈이 머무는 곳(아직 주인이 정해지지 않음) |
| SEIZE_BURNED | 압류 소각 — 회원에게 돌아가지 않기로 확정된 돈 |
| UNMATCHED | 주인 못 찾은 입금 보관(관리자 수동 매칭/반환) |
| EXPIRED / FORFEITED | 만료·소멸 포인트 귀속(운영 DB 확인) |
· 운영 DB 조회 결과 시스템 지갑 7종 확인: SETTLEMENT_CLEARING · UNMATCHED · FEE_REVENUE · EXPIRED · FORFEITED · SEIZED · SEIZE_BURNED. · 회원 지갑에서 돈이 나갈 때는 Lots.consume 로 유상 로트를 소비하고 배분을 남겨 취소·실패 시 정확히 복원합니다.
QR 은 서버가 QR 번호를 AES-GCM 으로 잠근 문자열(NPQR1.+암호문)입니다. 위조 불가·매장정보 은닉. QR 종류 3가지를 하나의 결제 로직으로 처리합니다.
| QR 종류 | 금액 | 유효시간 | 결제 subtype |
|---|---|---|---|
| STORE_STATIC(고정 스티커) | 회원이 입력 | 무제한 | QR_STORE |
| ORDER(1회용, 금액 지정) | QR 금액 고정 | 기본 5분 | QR_ORDER |
| ORDER(1회용, 금액 미설정) | 회원이 입력 | 기본 5분 | QR_ORDER |
회원앱 API (AppPaymentService.pay) DB
│ 스캔 QR + 금액 + pinPass ─────►
│ 0) requirePinPass (PIN 인증 직후 발급된 1회용 표 검증)
│ 1) loadQr 복호화·존재·활성·만료·매장정상 검증(5.1)
│ 2) ORDER형이면 burnOrderQr ← QR 선(先)소각(동시 결제 차단)
│ 3) 회원지갑·매장지갑 번호순 잠금(데드락 방지) ─► FOR UPDATE
│ 4) 수수료 스냅샷(Fees.calc) + 부가세 분리(TAXED)
│ 취소가능기한(cancelable_until) 계산(3.5, 기록용 스냅샷)
│ ※ 취소 권한: 매장·관리자만(회원 불가, 2026-07-27 정책) — 기한 강제 없음
│ 5) 로트 잠금(무상 우선) — 가용 합 < 금액이면 결제 불가
│ 6) transactions INSERT (멱등키 = PAY:{pinSig})
│ 7) ledger_entries 분개(아래 표)
│ 8) Lots.consume 회원 DR 줄에 로트 배분·차감(0.4)
│ 9) 지갑 잔액 갱신 + 양쪽 알림(outbox) + PG주문 연결
│ ◄─ 결제완료(txnId·잔액)
| 분개 줄 | 지갑 | DR/CR | 금액 |
|---|---|---|---|
| 회원 차감 | 회원 지갑 | DR | 결제금액(payAmount) |
| 매장 적립 | 매장 지갑 | CR | 결제금액 − 수수료 (순액) |
| 수수료 수익(수수료>0일 때만) | FEE_REVENUE | CR | 수수료(feeAmount) |
· 멱등: 멱등키 PAY:{pinSig} 를 ux_txn_idem 이 막아 같은 인증표로 두 번 결제 불가. · 수수료는 매장 부담(MERCHANT_PAYMENT 정책 스냅샷). · PG 오픈 API 주문이면 pgOrderService.onQrPaid 로 주문 PAID 연결 + 매장서버 웹훅 예약(돈 이동은 위 단일 원장에만 기록).
출금은 신청 즉시 지갑에서 선차감(HOLD) 하고, 예정 시각이 되면 워커가 페이솔루스 출금요청 API로 제출하며, 이후 출금통지 웹훅으로 완료(또는 거절)가 확정됩니다. 출금은 유상(DEPOSIT) 로트만 가능(무상 적립금 출금 불가 — 0.5). 은행 점검시간에는 신청을 막습니다(BankMaintenanceGuard).
회원앱 ─ 계좌·금액·pinPass ─► request()
· bankGuard.ensureOpen() (은행 점검시간 차단)
· requirePinPass · 계좌 존재 · 계좌변경 24h 냉각(cooldown) 확인
· 지갑 잠금 · 유상로트 가용합 ≥ 금액 확인
· 수수료(USER_WITHDRAW) → 순액 = 금액 − 수수료 (순액 ≤ 0 이면 거부)
· 예정시각 = PayoutSchedules.from(정책)
· transactions INSERT (status HOLD, 멱등키 WDR:{pinSig})
· 분개 + Lots.consume(유상만) + 지갑 갱신 + 알림
| 단계 | 분개 줄 | 지갑 | DR/CR | 금액 |
|---|---|---|---|---|
| 신청(HOLD) | 회원 차감 | 회원 지갑 | DR | 출금액(amount) |
| 청산 대기 | SETTLEMENT_CLEARING | CR | 순액(netAmount) | |
| 수수료(>0) | FEE_REVENUE | CR | 수수료 |
WithdrawWorker.tick (60s)
│ scanDue() 예정시각 지난 HOLD 목록(최대 100, 짧은 TX)
└─ 건별 executeOne() [REQUIRES_NEW]
· claimWithdrawal HOLD→PENDING (0건이면 타 서버가 이미 집음 → skip)
· ★페이솔루스 출금요청 API 제출(PaySolusPayoutApiClient.submit, 계약 전 스텁)
→ 접수번호 bankTranRef = "PNST-{txnId}", 상태 PENDING 유지(동기 확정 안 함)
출금통지 웹훅 ─ X-Internal-Key ─► /webhooks/bank/withdraw-notice (완료/거절 확정)
· SUCCESS → confirmWithdrawal → CONFIRMED + "출금 완료" 알림
· FAIL(거절) → 신청취소 · 선차감 복원(아래 실패 복원)
(웹훅 미도착 등 결과불명은 PENDING 으로 남아 리컨실러(8.4)가 정리)
실패 시 restoreFailed() [REQUIRES_NEW] — 역분개 + 로트 승계 복원
· failWithdrawal (상태 전이 가드)
· 역분개(복원 입금 거래, 멱등키 WDRBACK:{txnId}):
| 분개 줄(실패 복원) | 지갑 | DR/CR | 금액 |
|---|---|---|---|
| 청산 회수 | SETTLEMENT_CLEARING | DR | 순액 |
| 수수료 회수(>0) | FEE_REVENUE | DR | 수수료 |
| 회원 환급 | 회원 지갑 | CR | 출금액(총액) |
· 실패 복원 시 restoreLots 로 원래 쓰던 로트의 유형·만료일을 그대로 승계(유효기간 손실 방지 — 8.5). · 멱등키 WDR:{pinSig} 로 이중 신청 차단.
보낼 수 있는 것은 유상 포인트뿐(무상 적립금 선물 불가 — 0.5). 발신인이 수수료 부담(USER_GIFT). 모든 실행은 pinPass 필수. 받는 사람에게는 받은 날부터 새 유효기간의 유상 로트가 생깁니다(insertReceiveLot).
sendDirect() · 수신자 조회(전화 or 카드번호 지문) · 양쪽 지갑 번호순 잠금 · 수수료 → 순액(≤0 거부) · 유상로트 가용 확인 · transactions(GIFT_DIRECT, CONFIRMED)
| 분개 줄 | 지갑 | DR/CR | 금액 |
|---|---|---|---|
| 발신 차감 | 발신 지갑 | DR | 선물액(총액) |
| 수신 적립 | 수신 지갑 | CR | 순액 → 수신자 새 로트 생성 |
| 수수료(>0) | FEE_REVENUE | CR | 수수료 |
매장이 쌓인 매장 지갑 잔액을 자기 계좌로 빼는 기능. 매장 지갑은 로트가 없어 회원 출금보다 단순합니다(잔액에서 바로 차감). 기본 정책은 즉시 정산(delay 0). 실행·복원은 회원 출금과 같은 WithdrawWorker 가 처리(같은 규약).
매장앱 ─ 금액 + idempotencyKey ─► request()
· 정산계좌·잔액 확인 · 수수료(MERCHANT_PAYOUT) → 순액(≤0 거부)
· 예정시각(PayoutSchedules) · transactions(멱등키 SETTLE:{앱제공키})
· 분개(아래) + 지갑 갱신 + 알림 → 이후 WithdrawWorker 가 이체 실행
| 분개 줄 | 지갑 | DR/CR | 금액 |
|---|---|---|---|
| 매장 차감 | 매장 지갑 | DR | 정산액(총액) |
| 청산 대기 | SETTLEMENT_CLEARING | CR | 순액 |
| 수수료(>0) | FEE_REVENUE | CR | 수수료 |
주의 앱이 idempotencyKey 를 주면 ux_txn_idem 이 재시도 중복을 막지만, 구버전 앱이 안 주면 새 값이 생성되어 중복 차단이 되지 않습니다(코드 주석 명시).
AdminSettlementService.closeMonth(yyyymm) · 월 형식·미래월 검증 → 그 달 [시작, 다음달 시작) 범위로 월 정산서 통째 재계산(17.14) · 같은 달을 몇 번 눌러도 값이 다시 계산되어 안전(멱등 — 18장 규약) · 감사로그 기록
· 월 마감은 집계·리포트 성격이라 원장 분개를 만들지 않습니다(자금 이동 없음). 자금 이동은 10.1 정산 출금에서만 발생.
회원이 앱에서 충전 신청을 하면 페이솔루스 입금요청 API로 신청건(deposit_requests)이 만들어지고, 그 신청에만 유효한 입금코드(NC+8자리)가 발급됩니다. 회원이 신청한 금액 그대로 그 코드를 은행 이체 입금자명에 적어 보내면, 은행/PG 입금통지가 웹훅으로 들어오고, 워커가 코드로 신청건을 찾아 신청 금액과 정확히 일치할 때만 적립하며 그 외(무신청·금액 불일치)는 미매칭 보관합니다. 페이솔루스 입금요청 API는 계약 전 스텁입니다.
· 충전 규칙: 무신청 충전 불가 · 회원당 진행 중 신청은 1건(새 신청 시 이전 PENDING 자동취소) · 신청 금액과 정확 일치할 때만 적립 · 입금통지 미도착 시 만료 워커가 PENDING→EXPIRED 처리(단 만료·취소 후 늦게 입금돼도 코드+금액이 맞으면 적립 — 환불이 어려움) · 충전(1회/1일/월)·보유 한도는 신청 시점에 검사.
은행/PG ─ X-Internal-Key ─► BankWebhookController /webhooks/bank/deposit-notice
· 내부키 상수시간 비교 · receiveNotice():
dedupKey = SHA256(source:bankTranRef) ← 펌뱅킹(거래식별자 있음)
= SHA256(source:rawText) ← SMS(원문 지문)
INSERT 시 DuplicateKeyException(1062) → 이미 받은 통지면 조용히 멱등 처리(INSERT IGNORE 금지)
DepositNoticeWorker.tick (10s) claimNewNotices(FOR UPDATE, 최대 100)
└─ 건별 processNotice() [REQUIRES_NEW]
1) 신청건 매칭: 입금코드 → deposit_requests(진행 중) → 회원 → 충전 대상 카드(대표 우선), 신청 금액 정확 일치 확인
2) 충전 강제 규약: 회원이 "인증계좌(실명+1원)" 보유해야만 자동 적립
3) 재발행 카드면 새 카드로 갈아탐(REISSUED → reissued_to)
| 분기 | DR | CR | 후속 |
|---|---|---|---|
| 4.3 확정(신청건·금액 일치, 인증계좌 있음) | SETTLEMENT_CLEARING 총액 | 회원 지갑 순액 · FEE_REVENUE 수수료(>0) | 유상 로트 생성(DEPOSIT 만료개월) · 지갑 갱신 · 충전완료 알림 |
| 4.4 미매칭(무신청·금액 불일치·인증계좌 없음) | SETTLEMENT_CLEARING 금액 | UNMATCHED 금액 | 미매칭 큐 적재 → 관리자 수동 매칭/반환 |
· 멱등키 DEP:{source}:{dedupKey} 로 같은 통지의 이중 적립을 차단(DuplicateKeyException → TX 롤백). · 워커는 통지별 독립 TX 라 한 건 실패가 다른 건에 영향 없음(실패 통지는 NEW 로 남아 다음 바퀴 재시도).
사고(도용·분쟁·수사 협조)가 나면 그 돈이 빠져나가지 못하게 묶습니다. "쓸 수 있는 금액"을 따로 계산하지 않고 돈을 실제로 옮깁니다 — 돈 쓰는 곳(결제·출금·선물·정산)마다 검사를 넣는 방식은 한 곳만 빠뜨려도 묶은 돈이 새어 나가기 때문입니다.
seize() 지갑 → SEIZED 로 옮김. 잔액이 줄어 어디서도 쓸 수 없음 └ 대상 DR 금액 / SEIZED CR 금액 (+회원은 만료 임박 로트부터 차감) [seizures.status HELD] refund() 조사 결과 정상 → 되돌림 └ SEIZED DR 금액 / 대상 CR 금액 (+원래 유효기간을 이어받은 새 로트 = SEIZE_RESTORE) burn() 사기 확정 → 소각(회원에게 돌아가지 않음) └ SEIZED DR 금액 / SEIZE_BURNED CR 금액 (시스템 지갑끼리라 잔액 체인 없음)
| 이벤트 | DR | CR | 로트 |
|---|---|---|---|
| 묶기 | 대상 지갑 | SEIZED | 만료 임박 순 차감(매장은 로트 없음) |
| 반환 | SEIZED | 대상 지갑 | 원 유효기간 승계 복원(SEIZE_RESTORE) |
| 소각 | SEIZED | SEIZE_BURNED | 없음(이미 차감됨) |
· 거래는 ADJUST 계열 — SEIZE · SEIZE_RETURN · SEIZE_BURN.
· 전부 아니면 전무(부분 반환·부분 소각 없음) — 나눠 처리해야 하면 압류를 나눠서 겁니다.
· 앱에는 묶인 금액만 표시하고 관리자가 적은 사유는 내부용입니다(GET /app/me/seizure·/store/seizure).
· 중복 처리 방지: lockOne 으로 잠근 뒤 HELD 인 것만 끝냅니다.
NestPay 산출물 · (주)페이네스트 · 작성일 2026-07-27 · 실제 코드/DB 기준 · 외부연동(펌뱅킹·본인인증·은행 실명조회)은 계약 전 스텁 상태임을 명시