← 문서 목록

DB 스키마 · schema.dbml

70개 테이블 설계 (DBML · BASE 54 + SYSTEM VERSIONED 16)

// =====================================================================
// nestpay 데이터베이스 설계서 (schema.dbml)
// DB: MariaDB (버전 확정 대기 - LTS 기준) / 시간대: KST 단일 / 문자셋: utf8mb4
// ---------------------------------------------------------------------
// 설계 원칙 (요구사항 #29 원장 대원칙)
//  1. 원장(ledger_entries)·거래(transactions)는 append-only.
//     앱 DB 계정에 UPDATE/DELETE 권한 미부여 + 방어 트리거(routines.sql).
//  2. 모든 거래는 복식: 거래별 Σ차변 = Σ대변. 검증은 Java + 일 대사 배치.
//  3. 비즈니스 로직은 전부 Java. DB에는 선언적 제약(PK/FK/UNIQUE/CHECK),
//     파티셔닝, 시스템 버전 테이블, "로직 없는 방어 트리거"만 둔다.
//     (유지보수 최우선 - 로직의 DB/앱 분산 금지)
//  4. 가변 마스터 테이블(users, merchants, cards, 정책류)은
//     SYSTEM VERSIONING 적용(변경 이력 자동 보존) - 마이그레이션에서 지정.
//  5. 금액은 DECIMAL(15,0) KRW 정수. 시각은 DATETIME(KST).
//  6. 개인정보: *_enc = AES-256 암호문, *_hash = 검색/UNIQUE용 HMAC-SHA256.
// ---------------------------------------------------------------------
// 데이터 분류 정책 (필드 단위 기준 - 본 DBML이 개발 기준)
//  [암호화 저장 *_enc (AES-256-GCM, 키는 KMS 상당 관리)]
//   - 법적 암호화 의무: 계좌번호(account_enc), 카드번호(number_enc·CVC cvc_enc 준용)
//   - 자체 상향: 전화번호(phone_enc), OTP 시크릿(otp_secret_enc), 패스키 공개키 외 비밀값 없음
//  [단방향 해시 *_hash (HMAC-SHA256, char(64) 단일 규격)]
//   - 검색·UNIQUE 필요하지만 원문 불요: ci/di, phone_hash(선물 대상 검색),
//     account_hash(중복 검사), number_hash(재사용 차단), token_hash(선물 클레임)
//   - 비밀번호·PIN·API시크릿: Argon2id (varchar(255), 파라미터 포함 문자열)
//  [평문 저장 (암호화 의무 비대상 + 조회·표시 빈번)]
//   - 이름/예금주명(+정규화본: 매칭 연산 필요), 생년월일, 주소, 사업자번호(공개정보),
//     은행코드, 금액·상태·시각 전부. 근거: 고유식별정보·계좌·카드·비밀번호만 암호화 의무.
//  [JSON(text) 허용 기준 - 아래 3조건 전부 충족 시에만]
//   ① SQL WHERE/JOIN으로 내용을 조회하지 않는다 ② write-once(증거·설정 스냅샷)
//   ③ 행마다 스키마가 달라 컬럼화가 부자연스럽다
//   → 해당: fds_rules.params(룰별 파라미터 상이), fds_alerts.detail·reject_logs.context·
//     audit_logs.detail·integrity_findings.detail(증거 스냅샷), outbox_jobs.payload(작업 인자)
//   → 금지: 위 조건을 하나라도 깨면 반드시 컬럼 또는 연결 테이블
//     (예: 푸시 대상 회원 목록은 JSON 금지 → push_campaign_targets 연결 테이블)
//  [연결 테이블 기준 - N:M 또는 1:N 반복값은 무조건 별도 테이블]
//   → lot_allocations(차감:로트 N:M), policy_agreements, inquiry_attachments,
//     integrity_findings, push_campaign_targets, admin_allowed_ips
// ---------------------------------------------------------------------
// DBML 표현 한계로 마이그레이션에서만 정의하는 것:
//  - 월별 RANGE 파티셔닝: ledger_entries, notification_inbox, external_api_logs,
//    reject_logs, app_error_logs, login_histories, pii_access_logs (복합 PK)
//    ※ transactions·deposit_notices는 비파티션 확정: MariaDB는 파티션 테이블의
//      모든 UNIQUE 키에 파티션 컬럼 포함을 강제하는데, 멱등성 UNIQUE(멱등키·수신참조)에
//      created_at을 넣으면 멱등 보장이 깨짐 → 무결성 > 파티셔닝 (실DB 검증으로 확정)
//  - SYSTEM VERSIONING, CHECK 제약 일부, 방어 트리거/이벤트(routines.sql)
// ---------------------------------------------------------------------
// 보존 매트릭스 (100만건+·5년 보관 요구 반영 - 전수 감사 2026-07-21 확정)
//  [영구/법정 5년+] transactions, ledger_entries, lot_allocations, point_lots,
//    audit_logs, policy_agreements, users·cards·merchants(+버전 이력), unmatched_deposits
//  [5년 후 파티션 DROP] external_api_logs, reject_logs, app_error_logs,
//    login_histories, pii_access_logs, deposit_notices(아카이브), daily_wallet_snapshots
//  [단기] notification_inbox 30일(파티션 DROP), outbox_jobs DONE/EXHAUSTED 90일(DELETE),
//    webhook_deliveries DELIVERED 180일(DELETE)
//  삭제 수단: 파티션 테이블=DROP PARTITION(O(1)), 비파티션 소형=배치 DELETE.
//  transactions·deposit_notices(비파티션 대형)=연 단위 아카이브 런북(routines.sql 6절).
// ---------------------------------------------------------------------
// 복식 분개 규약 (거래 유형별 leg 표 - queries.sql 0장에 전체 표)
//  시스템 지갑(SYSTEM)은 핫로우 방지를 위해 ①FOR UPDATE 잠금 제외 ②balance 동기 갱신 제외
//  ③원장 balance_after = NULL (일 마감 배치가 SUM으로 balance 파생·검증).
//  사용자·매장 지갑만 잠금·balance_after 체인 유지. SYSTEM 지갑 잔액은 음수 허용
//  (SETTLEMENT_CLEARING = 은행 실계좌 대사 계정).
// ---------------------------------------------------------------------
// 운영 규칙 (감사 확정)
//  - 커서 페이징 표준: 정렬키+id 복합 키셋 (created_at < :ts OR (= AND id < :id)). OFFSET 금지.
//  - audit_logs.detail·reject_logs.context에 개인정보 원문 금지(마스킹/PK 참조만).
//  - *_enc 암호문은 [키버전 1B][nonce][ciphertext][tag] 직렬화 - 키 로테이션 대비(별도 컬럼 없음).
//  - 진행중 상태 집합 상수 = ('PENDING','HOLD','UNKNOWN') - 한도·탈퇴·계좌변경 검증 공통.
//  - 재발행 상호참조 = 입금→출금 단방향 저장 + ix_txn_related 역조회로 충족(트리거 불변 유지).
// ---------------------------------------------------------------------
// 거래상세 구조 (발주 답변 2026-07-22)
//  - 원장 변동은 단일 transactions 테이블에 type/subtype으로 분기(거래 단위로 쪼개지 않음).
//  - 업무 상세만 거래타입별 테이블로 분리: pg_orders(PG결제)·deposit_requests(충전신청)·
//    deposit_notices(입금통지)·merchant_qrs(QR결제) 등.
//    ※ 연결 방향: 상세 테이블이 transactions.id 를 참조합니다(pg_orders.txn_id 등).
//      역방향 참조용으로 두었던 transactions.detail_id 는 한 번도 쓰이지 않아 제거(V58).
//  - ledger_entries(복식 원장)는 그대로 단일 append-only.
//  - 종류×방식×상태 조합은 DB CHECK 3종이 강제(V59·V60) — 코드 실수로 이상한 거래가 남지 않도록.
//  - 서비스 범위: 한국 내 전용(글로벌 미지원 확정) → 전 시스템 KST 단일.
//  - 매장 정산: 대기(+N일) 구조·로직은 구현 유지. 매장 기본 delay=0(즉시 정산),
//    향후 값만 바꾸면 +1일/+2일 대기 적용 가능(발주 2026-07-22).
// ---------------------------------------------------------------------
// 확정 정책 (발주 회신 반영 - 2026-07-22 이후)
//  - 결제·충전 채널: 발주사 자체 PG 사용 - 가상계좌 입금통지(웹훅) + 직접계좌 펌뱅킹 출금이체.
//    (금결원 오픈뱅킹 직접 이용기관 등록 대신 발주사 PG 규격 연동. 규격서 확정 후 상세 매핑)
//  - 본인인증: 쿠콘 또는 드림시큐리티. 1원인증·계좌실명조회: 쿠콘 또는 발주사 기존 계약분.
//  - 상품권 충전 UX·상품권사(컬처랜드·해피머니 등) 연동: 범위 제외(발주 확정, 미구현).
//  - 포인트 전환(외부 제휴 포인트 ↔ nestpay): 범위 제외. 미리 만들어 둔 스키마
//    (exchange_providers 표·EXCHANGE 계열 값)는 한 번도 쓰이지 않아 전부 제거(V59·V60).
//    실제 연동 요청이 오면 그때 요구사항에 맞춰 새로 설계합니다 —
//    빈 스키마를 남겨 두면 다음 사람이 "채워야 하나" 헤매고, 새로 만들 때 옛것과 겹칩니다.
//  - 유상(DEPOSIT)/무상(REWARD) 2구분: REWARD는 결제만(환급·출금 불가), DEPOSIT는 결제·선물·출금·환급.
//  - 환급: 전체 충전(DEPOSIT) 잔액 기준(전자금융거래법 잔액 환급 규정 - 최종 비율·약관은 법무 확정).
//  - 회원간 송금: 선불수단 내부 원장 이체(두 지갑 원자적 차감/증액). 양수도 방식 기준 -
//    전자자금이체업 채택 여부는 법률자문 확정 대상(원장 골격 동일, 한도·약관·실명확인만 상이).
//  - 용어: DB 명칭 DEPOSIT = 충전(관리 편의). 사용자 대면 UI는 '충전'으로 표기. 은행 입금통지 이벤트는 deposit_notices(별도 테이블).
// =====================================================================

// ============================== 1. 계정 ==============================
// 요구 #35: 사용자/매장/관리자 계정 완전 분리 (단일 회원 테이블 금지)

Table users {
  id            bigint     [pk, increment]
  login_id      varchar(20)  [not null, unique, note: '영숫자 4~20, 소문자 정규화 저장(#45)']
  password_hash varchar(255) [not null, note: 'Argon2id']
  name          varchar(100) [not null, note: '본인인증 실명']
  name_norm     varchar(100) [not null, note: '이름 비교용 정규화(공백·특수문자 제거, 로마자화)']
  ci_hash       char(64)     [not null, note: '본인인증 CI HMAC']
  active_ci     char(64)     [note: 'status=ACTIVE일 때만 ci_hash 복사, 그 외 NULL - UNIQUE로 1인 1활성계정 강제(#7)']
  di_hash       char(64)
  phone_enc     varbinary(64)  [not null]
  phone_hash    char(64)     [not null, note: '선물 대상 검색용(#5·#6)']
  birth_date    date         [note: '본인인증 결과. 성인 전용 검증']
  gender        varchar(1)   [note: '성별 M|F (본인인증 결과, V12)']
  avatar_file_id bigint      [ref: > files.id, note: '프로필 사진(중앙 파일 대장 참조, V13). NULL=기본 아이콘']
  marketing_agree boolean    [not null, default: false, note: '광고성 푸시 수신동의(#20)']
  status        varchar(20)  [not null, default: 'ACTIVE', note: 'ACTIVE|SUSPENDED|WITHDRAWN']
  kyc_status    varchar(20)  [not null, default: 'VERIFIED', note: 'VERIFIED|PENDING - 관리자 사전생성 회원은 PENDING(앱서 본인인증 완료 시 VERIFIED 승격), V30']
  withdrawn_at  datetime     [note: '탈퇴 시각 - 재가입 대기(admin 설정) 판정 기준']
  destroy_due_at date        [note: '개인정보 파기 예정일 = 탈퇴+법정보존. 파기 배치 대상 선별']
  anonymized_at datetime     [note: '파기(익명화) 완료 시각 - phone_enc·name·birth_date 무효화']
  created_at    datetime     [not null]
  Indexes {
    active_ci [unique, name: 'ux_users_active_ci']
    ci_hash [name: 'ix_users_ci', note: '재가입 대기 판정(탈퇴 계정 포함 CI 조회)']
    phone_hash [name: 'ix_users_phone_hash']
    (status, created_at) [name: 'ix_users_status']
  }
  Note: 'SYSTEM VERSIONING. 탈퇴해도 행 유지(#39, 법정 보존)'
}

Table merchants {
  id             bigint    [pk, increment]
  biz_no         varchar(10) [not null, unique, note: '사업자등록번호(국세청 진위확인 #28)']
  biz_name       varchar(200) [not null]
  ceo_name       varchar(100) [not null]
  ceo_name_norm  varchar(100) [not null]
  ceo_ci_hash    char(64)  [not null, note: '대표자 본인인증(CI 해시)']
  ceo_di_hash    char(64)  [note: '대표자 본인인증(DI 해시) - V40. 값 존재=본인인증 완료']
  ceo_birth_date char(10)  [note: '대표자 생년월일(본인인증 결과) - V40']
  ceo_gender     varchar(1) [note: '대표자 성별 M|F(본인인증 결과) - V40']
  ceo_phone_enc  varbinary(256) [note: '대표자 휴대폰(암호화 저장) - V40']
  ceo_phone_hash char(64)  [note: '대표자 휴대폰(해시, 조회·중복확인용) - V40']
  source         varchar(10) [note: '가입 경로 WWW(홈페이지)|APP(매장앱)|ADMIN(관리자 직접) - V40']
  biz_type       varchar(10) [not null, note: 'CORP(법인)|SOLE(개인사업자) - 이 둘만(chk_merchant_biz_type, V66)']
  category       varchar(50)
  address        varchar(300)
  // (V33) biz_cert_path 제거 — 사업자등록증은 merchant_documents(file_id → files) 로 관리(중앙 파일 대장 원칙)
  vat_mode       varchar(10) [not null, default: 'TAXED', note: 'TAXED|EXEMPT - 매장이 설정(#24)']
  vat_rate       decimal(5,2) [not null, default: 10.00, note: '결제 시점 스냅샷의 원천']
  status         varchar(20) [not null, default: 'PENDING', note: 'PENDING(심사중)|SUPPLEMENT(보완대기)|ACTIVE(정상)|REJECTED(반려)|SUSPENDED(정지)|CLOSED(해지) — 보완 제출 시 PENDING 복귀(왕복 가능). ★ 상태별로 할 수 있는 일이 다르며 문지기(StoreAuthFilter)가 강제합니다: ACTIVE=전부 · PENDING/SUPPLEMENT=상태확인+서류·PG준비 · REJECTED/SUSPENDED/CLOSED=상태확인만(앱은 안내 화면 하나). CLOSED 는 되돌릴 수 없으며, 남은 정산금·진행 중 정산이 있으면 처리되지 않습니다(꺼낼 수 없는 돈이 갇히지 않도록)']
  reject_reason  varchar(500)
  supplement_note varchar(500) [note: '자료 보충 요청 사유(V7) - SUPPLEMENT 상태일 때 매장에게 표시']
  approved_by    bigint    [ref: > admin_accounts.id, note: '승인 관리자(#28)']
  approved_at    datetime
  created_at     datetime  [not null]
  Indexes { status [name: 'ix_merchants_status', note: 'admin 심사 큐(PENDING)·상태별 목록'] }
  Note: 'SYSTEM VERSIONING. 대표자 변경=재심사(2차 항목 3)'
}

Table merchant_accounts {
  id            bigint     [pk, increment]
  merchant_id   bigint     [not null, unique, ref: > merchants.id, note: '매장 1계정(하위계정 미도입)']
  login_id      varchar(20)  [not null, unique]
  password_hash varchar(255) [not null]
  status        varchar(20)  [not null, default: 'ACTIVE']
  created_at    datetime   [not null]
}

Table admin_accounts {
  id             bigint     [pk, increment]
  login_id       varchar(20)  [not null, unique]
  password_hash  varchar(255) [not null]
  name           varchar(100) [not null]
  is_root        boolean    [not null, default: false, note: '계정 생성·삭제·OTP 초기화는 root만(#9 정책)']
  otp_secret_enc varbinary(128) [note: 'TOTP 시크릿. 전 계정 의무화 - 미설정 시 기능 제한']
  otp_enabled    boolean    [not null, default: false]
  otp_fail_count int        [not null, default: 0]
  status         varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|LOCKED|DISABLED']
  created_at     datetime   [not null]
}

Table admin_allowed_ips {
  id               bigint      [pk, increment]
  admin_account_id bigint      [ref: > admin_accounts.id, note: '적용 대상 관리자. NULL=모든 관리자 공통 규칙, 값=그 관리자 전용 규칙(개인 규칙 보유 시 그 규칙에 맞아야만 접근 가능) - V3 추가(관리자별 접근 제한 요구)']
  cidr             varchar(50) [not null, note: '단건 IP 또는 CIDR(#33)']
  memo             varchar(200)
  created_by       bigint      [not null, ref: > admin_accounts.id]
  created_at       datetime    [not null]
  Indexes { admin_account_id [name: 'ix_allowed_ips_admin'] }
  Note: '마지막 공통규칙 삭제 불가·본인 IP 삭제 경고는 앱 로직. 규칙 관리(등록·삭제)는 root만'
}

// --- 인증 부속 (계정 3종 공용: principal_type = USER|MERCHANT|ADMIN) ---

Table auth_pins {
  id             bigint    [pk, increment]
  principal_type varchar(10) [not null]
  principal_id   bigint    [not null]
  pin_hash       varchar(255) [not null, note: 'Argon2id. 간편로그인+거래인증 겸용(#16)']
  fail_count     int       [not null, default: 0, note: '5회 초과 시 전체 로그인 강등']
  locked_until   datetime
  updated_at     datetime  [not null]
  Indexes { (principal_type, principal_id) [unique, name: 'ux_pin_principal'] }
}

Table auth_passkeys {
  id             bigint    [pk, increment]
  principal_type varchar(10) [not null]
  principal_id   bigint    [not null]
  credential_id  varchar(255) [not null, unique, note: 'FIDO2/WebAuthn']
  public_key     varbinary(512) [not null]
  device_label   varchar(100)
  created_at     datetime  [not null]
  Indexes { (principal_type, principal_id) [name: 'ix_passkey_principal'] }
}

// 패스키 챌린지 1회용 소진(V23) — 등록·로그인·거래 서명 replay 방지.
Table used_challenges {
  challenge  varchar(64) [pk, note: '이미 사용한 패스키 챌린지(1회용)']
  expires_at datetime    [not null, note: '만료 정리 기준']
  created_at datetime    [not null]
  Indexes { expires_at [name: 'ix_used_challenges_expires'] }
}

Table auth_devices {
  id             bigint    [pk, increment]
  principal_type varchar(10) [not null]
  principal_id   bigint    [not null]
  device_uid     varchar(128) [not null, note: '앱 설치 식별자 - PIN 기기 바인딩']
  platform       varchar(10) [not null, note: 'IOS|ANDROID']
  model          varchar(100)
  status         varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|REVOKED - 동시 1기기: 새 기기 등록 시 기존 REVOKED(2차 항목 4)']
  last_login_at  datetime
  created_at     datetime  [not null]
  Indexes {
    (principal_type, principal_id, status) [name: 'ix_device_principal']
    (principal_type, principal_id, device_uid) [unique, name: 'ux_device_uid']
    device_uid [name: 'ix_device_uid', note: 'FDS 다계정기기 역조회(동일 기기→복수 계정)']
  }
}

Table login_histories {
  id             bigint    [pk, increment]
  principal_type varchar(10) [not null]
  principal_id   bigint    [not null]
  method         varchar(20) [not null, note: 'PASSWORD|PIN|PASSKEY|OTP']
  ip             varchar(45)
  device_uid     varchar(128)
  result         varchar(20) [not null, note: 'SUCCESS|FAIL_PW|FAIL_OTP|BLOCKED_IP 등']
  created_at     datetime  [not null]
  Indexes { (principal_type, principal_id, created_at) [name: 'ix_login_hist'] }
  Note: '사용자 로그인 이력 화면(2차 항목 9) + 보안 감사. 월 파티션(실PK id,created_at)·5년 DROP·방어 트리거'
}

// --- 이용제한(Rate limit) — 동작별 호출 빈도 제한(무차별 차단, V14) ---

Table rate_limit_rules {
  action_key  varchar(50)  [pk, note: '동작 키(login, signup.submit, withdraw.create 등)']
  description varchar(200) [not null]
  window_sec  int          [not null, note: '집계 창(초)']
  max_count   int          [not null, note: '창 안 최대 허용 횟수']
  basis       varchar(10)  [not null, note: 'IP | USER']
  enabled     boolean      [not null, default: true]
  updated_at  datetime     [not null]
  Note: '관리자 조정(바로 적용). NestPay 실제 동작만 시드(상품권 전환·텔레그램 제외)'
}

Table rate_limit_counters {
  action_key   varchar(50)  [not null]
  subject      varchar(100) [not null, note: 'IP 또는 회원 식별자']
  window_start bigint       [not null, note: '창 시작 epoch초']
  cnt          int          [not null, default: 0]
  Indexes {
    (action_key, subject, window_start) [pk]
    window_start [name: 'ix_ratelimit_window']
  }
  Note: '고정창 카운터. 초과 시 429. 오래된 창은 배치 정리(purgeBefore)'
}

// --- 은행 계좌 (사용자·매장 공용, 소유자 구분) ---

Table bank_accounts {
  id             bigint    [pk, increment]
  owner_type     varchar(10) [not null, note: 'USER|MERCHANT']
  owner_id       bigint    [not null]
  bank_code      varchar(10) [not null]
  account_enc    varbinary(128) [not null]
  account_hash   char(64)  [not null, note: '중복 등록 검사용']
  holder_name    varchar(100) [not null, note: '실명인증 예금주']
  holder_name_norm varchar(100) [not null, note: '정규화 prefix 비교(#15 확장 규칙)']
  verified_at    datetime  [note: '실명+1원인증 완료 시각. 순서: 실명일치→1원인증(#15)']
  status         varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|PENDING(매장 자가등록 승인대기)|REMOVED - 변경 시 REMOVED 처리, 이력 보존(#10 정책)']
  registrant_type varchar(10) [note: '정산계좌 등록 주체 MERCHANT(매장 자가등록,실명+1원→승인대기)|ADMIN(관리자 직접등록,바로 활성) - V40']
  cooldown_until datetime  [note: '계좌 변경 후 출금 냉각 24h']
  created_at     datetime  [not null]
  Indexes { (owner_type, owner_id, status) [name: 'ix_bank_owner'] }
  Note: '출금은 ACTIVE 계좌 1건으로만(#36). ACTIVE 1건 보장은 앱 트랜잭션. 매장 정산계좌는 2경로: 매장 자가등록(PENDING→관리자 승인) 또는 관리자 직접등록(ACTIVE) - V40'
}

// ============================ 2. 카드·지갑 ============================

Table cards {
  id            bigint    [pk, increment]
  user_id       bigint    [not null, ref: > users.id]
  number_hash   char(64)  [not null, unique, note: '재사용 영구 차단의 단일 진실(#2). 삭제 금지']
  number_enc    varbinary(64) [not null, note: 'BIN 972963 + 랜덤9 + Luhn, 16자리']
  cvc_enc       varbinary(32) [not null, note: '고정 CVC 확정. 표시용, 서버 암호화 보관']
  issued_at     datetime  [not null]
  expires_on    date      [not null, note: '발급+5년의 그 달 말일(LAST_DAY, 카드 월말 기준). 만료 시 재발행 절차 재사용']
  design_code   varchar(20) [not null, note: 'OCEAN|SUNSET|FOREST|MIDNIGHT|CORAL|EMBER|VIOLET|ROYAL(#10). 옛 카드 PEARL|AURORA|LAVENDER 호환 표시']
  color_code    varchar(20) [note: '사용자 선택 색상(#7)']
  is_primary    boolean   [not null, default: false, note: '대표 카드 = 기본 수취(#5 정책)']
  status        varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|SUSPENDED|REISSUED|EXPIRED']
  reissued_to   bigint    [ref: > cards.id, note: '재발행 체인 참조(#2)']
  created_at    datetime  [not null]
  Indexes { (user_id, status) [name: 'ix_cards_user'] }
  Note: 'SYSTEM VERSIONING. 발급 수량 카운트 = ACTIVE만(#43). 카드번호 = 지갑 식별자(#41)'
}

Table wallets {
  id           bigint     [pk, increment]
  owner_type   varchar(12) [not null, note: 'USER_CARD|MERCHANT|SYSTEM']
  card_id      bigint     [unique, ref: > cards.id, note: 'USER_CARD일 때만, 카드 1:1']
  merchant_id  bigint     [unique, ref: > merchants.id, note: 'MERCHANT일 때만. 정산 출금·취소 반환 전용(#8 정책)']
  system_code  varchar(30) [unique, note: 'SYSTEM일 때만: FEE_REVENUE(수수료수익)|EXPIRED(낙전)|UNMATCHED(미매칭입금)|SETTLEMENT_CLEARING(대외청산)|FORFEITED(탈퇴 소액포기)|SEIZED(압류 보관)|SEIZE_BURNED(압류 소각) - 7개']
  balance      decimal(15,0) [not null, default: 0, note: 'CHECK: SYSTEM 제외 balance>=0. 사용자·매장=FOR UPDATE 잠금 지점, SYSTEM=일 배치 파생(핫로우 방지 규약)']
  created_at   datetime   [not null]
  Indexes { owner_type [name: 'ix_wallets_owner_type', note: '시스템 지갑 현황·유형별 집계'] }
  Note: 'CHECK: owner_type별 해당 참조 컬럼만 NOT NULL(마이그레이션 정의)'
}

Table point_lots {
  id               bigint   [pk, increment]
  txn_id           bigint   [not null, ref: > transactions.id, note: '이 포인트 덩어리를 만든 거래 - 출처 추적·소명 근거(V61). 거래 없이 생긴 포인트는 만들 수 없습니다(NOT NULL). 반대 방향(어디에 썼나)은 lot_allocations 가 담당']
  wallet_id        bigint   [not null, ref: > wallets.id]
  lot_type         varchar(10) [not null, note: 'DEPOSIT(유상 충전·포인트전환 유입: 결제·선물·출금·환급 가능)|REWARD(무상 적립: 결제만, 환급·출금 불가) - 유상/무상 2구분. 환급은 소스 무관 전체 DEPOSIT 잔액 기준']
  source           varchar(30) [not null, note: 'DEPOSIT(충전)|GIFT(선물수취)|CANCEL_RESTORE(결제취소 복원)|WITHDRAW_RESTORE(출금실패 복원) - 이 4개가 전부(넣는 코드 기준). 이력 메타이며 환급 판정에는 lot_type=DEPOSIT 을 씁니다']
  amount_init      decimal(15,0) [not null]
  amount_remaining decimal(15,0) [not null, note: 'CHECK(0 <= remaining <= init). 감소만 허용(방어 트리거)']
  expires_at       datetime [not null, note: '모든 포인트 유효기간 필수(#42). 재발행 이관은 승계, 선물 수취는 리셋(승인 정책)']
  origin_lot_id    bigint   [ref: > point_lots.id, note: '재발행 승계 시 원 로트 참조']
  created_at       datetime [not null]
  Indexes {
    (wallet_id, expires_at) [name: 'ix_lots_fifo', note: 'FIFO 소진(만료 임박 우선) 조회']
    (expires_at, amount_remaining) [name: 'ix_lots_expiry', note: '만료 배치 스캔(low-watermark 하한 병용)']
    (lot_type, amount_remaining) [name: 'ix_lots_type_remaining', note: '별도관리 대사(DEPOSIT 잔여 합산)']
  }
}

// ============================== 3. 원장 ==============================

Table transactions {
  id              bigint    [pk, increment]
  txn_uid         char(26)  [not null, unique, note: '대외 노출용 ULID']
  type            varchar(12) [not null, note: 'DEPOSIT(충전)|WITHDRAW(출금·정산)|GIFT(선물)|PAYMENT(결제)|CANCEL(취소)|ADJUST(관리자 조정) - CHECK 로 강제(V59·V60)']
  subtype         varchar(20) [not null, note: 'DEPOSIT:VACCT | WITHDRAW:FIRMBANK·SETTLEMENT·UNMATCHED_RETURN·WITHDRAW_RESTORE | GIFT:GIFT_DIRECT | PAYMENT:QR_ORDER·QR_STORE | CANCEL:QR_CANCEL·PG_REFUND | ADJUST:FORFEIT — 조합을 chk_txn_type_subtype 이 강제']
  status          varchar(12) [not null, note: 'PENDING(이체중)|HOLD(선차감 후 대기)|CONFIRMED(완료)|FAILED(실패)|CANCELED(취소·회수) - 이 5개가 전부. 종류별 허용 상태는 chk_txn_type_status 가 강제(예: PAYMENT 에 PENDING 불가, GIFT 는 CONFIRMED 뿐)']
  initiator_type  varchar(10) [not null, note: 'USER|MERCHANT|ADMIN|SYSTEM']
  initiator_id    bigint    [not null, default: 0]
  idempotency_key varchar(100) [note: 'UNIQUE(initiator_type, initiator_id, idempotency_key) - 사용자 스코프 멱등(#3). V6: 4.4 규약 키(DEP:소스:해시64=최대76자) 수용 위해 64→100 확장(실측 잘림 발견 교정)']
  card_id         bigint    [ref: > cards.id]
  counterparty_card_id bigint [ref: > cards.id, note: '선물 수신 카드 - 받은선물 내역·gift_recv 집계·FDS 선물집중의 키(감사 보완)']
  merchant_id     bigint    [ref: > merchants.id]
  amount          decimal(15,0) [not null]
  fee_amount      decimal(15,0) [not null, default: 0]
  fee_rate_snap   decimal(7,4) [note: '적용 정률 스냅샷(#25)']
  fee_fixed_snap  decimal(15,0) [note: '적용 정액 스냅샷']
  vat_amount      decimal(15,0) [note: '결제 시점 부가세 스냅샷(#24)']
  cancelable_until datetime [note: '결제 시점 취소기한 스냅샷']
  scheduled_at    datetime  [note: '출금·정산 실행 예정 시각 - 신청 시점 payout_policies(+N일 HH시) 스냅샷. 정책 변경 소급 방지']
  related_txn_id  bigint    [note: '역분개·이관·취소의 원거래 참조 - 추적 플로우차트(#32)의 간선']
  bank_tran_ref   varchar(64) [note: '펌뱅킹 거래 식별자 - 리컨실러 조회 키']
  bank_account_id bigint    [ref: > bank_accounts.id, note: '출금·정산 실행 대상 계좌 스냅샷 - 이후 계좌 변경해도 "어디로 나갔나" 불변(전수 대조에서 발견된 누락 보완)']
  fail_reason     varchar(30) [note: 'MERCHANT_INSUFFICIENT_BALANCE|CANCEL_WINDOW_EXPIRED 등 코드']
  memo            varchar(200) [note: '선물 메시지(50자·금칙어 필터) 등']
  created_at      datetime  [not null]
  confirmed_at    datetime
  Indexes {
    (card_id, created_at) [name: 'ix_txn_card', note: '이용내역(#13)·월명세(#23) 주 쿼리']
    (merchant_id, created_at) [name: 'ix_txn_merchant', note: '매장 매출 조회(#24)']
    (status, created_at) [name: 'ix_txn_status', note: '리컨실러·장기체류 감시(#30)']
    (initiator_type, initiator_id, idempotency_key) [unique, name: 'ux_txn_idem']
    (initiator_type, initiator_id, type, created_at) [name: 'ix_txn_initiator_date', note: '한도 일/월 합산(#37 사용자 전 카드 합산)·통합 내역의 주 인덱스']
    bank_tran_ref [name: 'ix_txn_bank_ref']
    created_at [name: 'ix_txn_created', note: 'admin 기간 검색·일 마감 집계·FDS 배치 스캔(비파티션 보완)']
    related_txn_id [name: 'ix_txn_related', note: '추적 CTE·역분개 역조회·재발행 상호참조']
    (type, status, scheduled_at) [name: 'ix_txn_withdraw_sched', note: '출금 실행 배치 - filesort·과잠금 제거']
    (initiator_type, initiator_id, created_at) [name: 'ix_txn_initiator_created', note: '통합 내역(type 무관 기간 조회)']
    (counterparty_card_id, created_at) [name: 'ix_txn_counterparty', note: '받은 선물 조회']
  }
  Note: 'append-only(취소=CANCEL 신규 행). 비파티션(UNIQUE 멱등 보장 우선) - 장기 증가 대응은 연 단위 아카이브 절차로'
}

Table ledger_entries {
  id            bigint    [pk, increment, note: '실PK는 (id, created_at) - 월 파티셔닝']
  txn_id        bigint    [not null, note: 'FK(transactions) - 파티션 간 FK는 논리 참조(앱 강제)']
  wallet_id     bigint    [not null, ref: > wallets.id]
  direction     char(2)   [not null, note: 'DR|CR. 거래별 ΣDR=ΣCR (복식·Java 검증+일 대사)']
  amount        decimal(15,0) [not null, note: 'CHECK(amount > 0)']
  balance_after decimal(15,0) [note: '사용자·매장 지갑=NOT NULL 체인(#29). SYSTEM 지갑 leg=NULL(핫로우 규약, 일 배치 SUM 검증) - NULL 규칙은 CHECK+대사로 강제']
  created_at    datetime  [not null]
  Indexes {
    (wallet_id, id) [name: 'ix_ledger_wallet_chain', note: '잔액 체인 검증 순서']
    txn_id [name: 'ix_ledger_txn']
  }
  Note: 'append-only 절대(#29): UPDATE/DELETE 방어 트리거 + 권한 미부여. 월 파티션'
}

Table lot_allocations {
  id              bigint   [pk, increment]
  ledger_entry_id bigint   [not null, note: '차감(DR) 엔트리']
  lot_id          bigint   [not null, ref: > point_lots.id]
  amount          decimal(15,0) [not null]
  Indexes {
    lot_id [name: 'ix_alloc_lot']
    ledger_entry_id [name: 'ix_alloc_entry', note: '취소 시 원 소진 로트 역추적 - 풀스캔 방지(감사 보완)']
  }
  Note: '한 차감이 복수 로트에 걸친 배분 기록 - 유효기간 FIFO 소진·취소 복원 근거(환급은 소스 무관 전체 DEPOSIT 잔액 기준이라 유형판정 미사용)'
}

// ======================= 4. 충전 입금 (충전신청 기반) =======================
// 충전 프로세스(발주사 확정): 회원앱 충전신청 → 페이솔루스 입금요청 API → 응답기반 충전요청(deposit_requests)
//   생성(입금대기) → 입금통지 웹훅(PGVACCT/FIRMBANK) → 금액일치 처리 → 충전완료.
// 규약: (1)무신청 충전 불가 (2)회원당 진행 중 신청 1건(새 신청 시 이전 대기 자동취소)
//       (3)신청 금액과 정확 일치할 때만 충전 (4)입금통지 미도착 시 만료(EXPIRED) —
//       단 만료/취소 후 늦게 입금이 와도 코드+금액 일치면 충전(환불 어려움).
// deposit_identifiers 는 레거시(회원당 고정 입금자코드 자동적립) — 충전신청 도입으로 미사용.

// 충전신청 - 무신청 충전 불가 강제. 신청마다 페이솔루스가 배정한 입금계좌(스냅샷)+고유코드,
// 신청 금액과 정확 일치 시에만 충전(V39).
Table deposit_requests {
  id                 bigint       [pk, increment]
  user_id            bigint       [not null, ref: > users.id, note: '신청 회원']
  card_id            bigint       [note: '충전 대상 카드(선택 - 비면 확정 시 대표카드)']
  amount             decimal(15,0) [not null, note: '신청(선언) 금액 - 이 금액 그대로 입금돼야 충전']
  payer_name         varchar(60)  [note: '신청 시점 인증계좌의 예금주명(스냅샷) - 이 이름으로 들어온 입금만 인정(V67)']
  payer_name_norm    varchar(60)  [note: '예금주명 비교용(공백·기호 제거+소문자) - 은행마다 표기가 조금씩 달라 정규화']
  deposit_account_id bigint       [note: '배정 회사 입금계좌(deposit_accounts.id 참조 - 스냅샷은 아래 3열)']
  account_bank       varchar(40)  [not null, note: '배정 계좌 은행 스냅샷']
  account_no         varchar(40)  [not null, note: '배정 계좌번호 스냅샷']
  account_holder     varchar(60)  [not null, note: '배정 예금주 스냅샷']
  status             varchar(16)  [not null, default: 'PENDING', note: 'PENDING(입금대기)|MATCHED(충전완료)|EXPIRED(만료)|CANCELED(취소)']
  expires_at         datetime     [not null, note: '신청 만료 시각(신청 +5분) - 지나면 자동취소. 단 신청 +10분까지 도착한 입금은 인정(V67)']
  matched_notice_id  bigint       [note: '매칭된 입금통지(deposit_notices.id)']
  matched_txn_id     bigint       [note: '충전 확정 거래(transactions.id)']
  matched_at         datetime
  created_at         datetime     [not null]
  alive_key          varchar(160) [note: '생성 칸(VIRTUAL) - PENDING 일 때만 `이름:금액:계좌` 값. 같은 조합 동시 대기를 막습니다']
  Indexes {
    alive_key [unique, name: 'ux_depreq_alive']
    (user_id, status, created_at) [name: 'ix_depreq_user']
    (status, expires_at) [name: 'ix_depreq_status']
    (payer_name_norm, amount, deposit_account_id, status) [name: 'ix_depreq_match']
  }
  Note: '무신청 충전 불가 · 회원당 진행 중 1건 · [예금주명+금액+배정계좌+시각(-1분~+10분)] 이 모두 맞아야 충전(V67) · 대기 5분'
}

Table payout_accounts {
  id         bigint      [pk, increment]
  bank_code  varchar(3)  [not null, note: '은행 코드(banks.code) - 화면에는 이름으로 보여 줍니다']
  account_no varchar(40) [not null, note: '계좌번호']
  holder     varchar(60) [not null, note: '예금주(우리 회사 이름)']
  label      varchar(40) [not null, note: '구분용 이름표 - 예) 출금 전용']
  sort_order int         [not null, default: 1, note: '여러 개일 때 쓰는 차례']
  status     varchar(10) [not null, default: 'ACTIVE', note: 'ACTIVE=사용함 | HIDDEN=쓰지 않음']
  created_at datetime    [not null]
  alive_key  varchar(10) [note: '생성 칸(VIRTUAL) - ACTIVE 일 때만 값. 활성 계좌가 하나뿐임을 보장']
  Indexes {
    (bank_code, account_no) [unique, name: 'ux_payout_acct']
    alive_key [unique, name: 'ux_payout_active_only_one']
    (status, sort_order) [name: 'ix_payout_list']
  }
  Note: '회사 출금계좌(V67) - 회원 출금·매장 정산을 보낼 때 돈이 나가는 우리 계좌. 활성은 항상 하나'
}

Table deposit_notices {
  id             bigint    [pk, increment]
  source         varchar(10) [not null, note: 'PGVACCT(발주사 PG 가상계좌 입금통지 웹훅)|FIRMBANK(펌뱅킹 조회 확정)|SMS(보조 감지)']
  raw_text       text      [note: 'SMS 원문 보존(#29 증거 보존) - 절단 방지 TEXT']
  dedup_key      char(64)  [not null, note: 'SMS=HMAC(수신번호+원문+수신분), 펌뱅킹=bank_tran_ref 사본 - 소스별 결정적 중복키(SMS는 ref 부재)']
  bank_tran_ref  varchar(64) [note: '펌뱅킹 거래 식별자']
  parsed_amount  decimal(15,0)
  parsed_name    varchar(100)
  parsed_name_norm varchar(100) [note: '정규화 prefix 매칭용']
  identifier_value varchar(64) [note: '가상계좌/입금자코드 파싱값']
  status         varchar(20) [not null, default: 'NEW', note: 'NEW|MATCHED|UNMATCHED|IGNORED']
  attempts       int         [not null, default: 0, note: '처리 시도 횟수 - 한계 도달 시 status=FAILED 로 격리(무한 재시도 방지, V44)']
  matched_txn_id bigint    [note: '매칭 성사 시 DEPOSIT(충전) 거래 참조']
  received_at    datetime  [not null]
  Indexes {
    (source, dedup_key) [unique, name: 'ux_notice_ref', note: 'INSERT IGNORE 금지 - 1062만 앱에서 멱등 처리']
    (status, received_at) [name: 'ix_notice_status']
  }
}

Table unmatched_deposits {
  id            bigint    [pk, increment]
  notice_id     bigint    [not null, ref: > deposit_notices.id]
  amount        decimal(15,0) [not null]
  status        varchar(20) [not null, default: 'PENDING', note: 'PENDING|MATCHED|RETURNED|HOLD - 관리자 재량 처리(#48)']
  resolved_by   bigint    [ref: > admin_accounts.id]
  resolved_at   datetime
  resolution    varchar(20) [note: 'MANUAL_MATCH|RETURN|HOLD']
  resolution_note varchar(500) [note: '사유 필수 - 원장 역분개와 이중 기록']
  return_method varchar(30)  [note: '반환 방법(펌뱅킹 이체 등)']
  return_bank_code varchar(10)
  return_account_enc varbinary(128) [note: '반환 계좌 AES-256 - 계좌번호 암호화 의무(감사 보완)']
  created_at    datetime  [not null]
  Note: '입금 즉시 UNMATCHED 시스템 지갑에 원장 계상 - 장부 밖 돈 없음'
}

// =========================== 6. QR·PG 결제 ===========================

Table merchant_qrs {
  id          bigint      [pk, increment]
  merchant_id bigint      [not null, ref: > merchants.id]
  qr_type     varchar(15) [not null, note: 'STORE_STATIC|STORE_DYNAMIC|ORDER - USER_PAY(CPM)·USER_RECEIVE는 Redis 단명 토큰(DB 미저장)']
  label       varchar(50) [note: '카운터1 등 - 매장당 복수 QR(#24)']
  amount      decimal(15,0) [note: 'ORDER형만']
  key_version int         [not null, default: 1, note: 'AES-GCM 키 버전']
  status      varchar(20) [not null, default: 'ACTIVE', note: 'ACTIVE|REVOKED|USED(ORDER 1회용)']
  expires_at  datetime    [note: 'DYNAMIC/ORDER TTL']
  created_at  datetime    [not null]
  Indexes { (merchant_id, status) [name: 'ix_qr_merchant'] }
}

Table merchant_api_credentials {
  id                  bigint    [pk, increment]
  merchant_id         bigint    [not null, unique, ref: > merchants.id]
  client_id           varchar(32) [not null, unique]
  api_key_hash        varchar(255) [not null, note: '시크릿 해시만 보관(발급 시 1회 표시)']
  prev_key_hash       varchar(255) [note: '키 롤 유예(24h 신구 동시 유효)']
  prev_key_expires_at datetime
  webhook_url         varchar(500) [note: '매장앱에서 입력(#26)']
  webhook_secret_enc  varbinary(512) [note: 'AES-256-GCM 암호화 - 발신 웹훅 HMAC 서명 생성에 원문 필요(해시 불가, 감사 보완)']
  mall_domain         varchar(255) [note: 'PG 신청 - 쇼핑몰(웹사이트) 도메인 - V40']
  escrow_file_id      bigint    [ref: > files.id, note: 'PG 신청 - 에스크로 확인증 파일(옵션) - V40']
  insurance_file_id   bigint    [ref: > files.id, note: 'PG 신청 - 이행보증보험증권 파일(옵션) - V40']
  status              varchar(15) [not null, default: 'REQUESTED', note: 'REQUESTED(신청)|APPROVED|REJECTED|REVOKED - admin 승인 큐(#28)']
  requested_at        datetime  [not null]
  approved_by         bigint    [ref: > admin_accounts.id]
  mode                varchar(10) [not null, default: 'SANDBOX', note: 'SANDBOX|LIVE - 테스트 통과 후 매장이 전환(#26·#28)']
  test_create_ok_at   datetime  [note: '체크리스트: 결제 생성']
  test_webhook_ok_at  datetime  [note: '체크리스트: 웹훅 2xx']
  test_query_ok_at    datetime  [note: '체크리스트: 상태조회']
  test_signature_ok_at datetime [note: '체크리스트: 웹훅 서명 검증(틀린 서명을 4xx로 거절) - V72']
  created_at          datetime  [not null]
  Indexes { (status, requested_at) [name: 'ix_apicred_queue', note: 'admin 신청 심사 큐'] }
  Note: 'LIVE 전환 조건 = status=APPROVED + 3개 test_*_ok_at 모두 존재(앱 강제). SYSTEM VERSIONING(웹훅 URL·모드 변경 이력)'
}

Table merchant_api_allowed_ips {
  id          bigint      [pk, increment]
  merchant_id bigint      [not null, ref: > merchants.id]
  cidr        varchar(50) [not null, note: '허용할 매장 서버 IP(단건 또는 CIDR)']
  memo        varchar(200)
  status      varchar(15) [not null, default: 'REQUESTED', note: 'REQUESTED(매장 신청)|APPROVED(관리자 승인)|REJECTED - 승인분만 open API 허용, 승인 0건 매장은 호출 거부(V7 확정 요구)']
  approved_by bigint      [ref: > admin_accounts.id]
  approved_at datetime
  created_at  datetime    [not null]
  Indexes { (merchant_id, status) [name: 'ix_mip_merchant'] }
  Note: '매장 open API(/pg/**) 화이트IP - 매장앱에서 신청, 관리자 승인 필수(V7)'
}

Table pg_orders {
  id          bigint      [pk, increment]
  merchant_id bigint      [not null, ref: > merchants.id]
  order_no    varchar(64) [not null, note: '쇼핑몰 주문번호 - UNIQUE(merchant_id, order_no) 멱등']
  amount      decimal(15,0) [not null]
  supply_amount decimal(15,0) [note: '공급가액']
  vat_amount  decimal(15,0) [not null, note: 'PG API로 수신·보관(#24)']
  item_name   varchar(200)
  items_json  text        [note: '품목 스냅샷 JSON [{name,qty,unitPrice,amount,taxFree,supplyAmount,vatAmount}] - 다품목·수량·품목별 과세구분 원본 보존(V41). 과세·면세 혼합 시 주문 공급가/부가세=품목 합. NULL=단일 상품명 주문']
  return_info varchar(500) [note: '쇼핑몰 임의 리턴정보(주문코드 등) - 조회·웹훅에 그대로 echo, 서버는 해석 안 함(V41)']
  qr_id       bigint      [ref: > merchant_qrs.id]
  txn_id      bigint      [note: '결제 성사 시 거래 참조']
  status      varchar(20) [not null, default: 'PENDING', note: 'PENDING|PAID|CANCELED|EXPIRED (생성 시 PENDING) - V43']
  is_sandbox  boolean     [not null, default: false, note: '테스트 주문 실원장 완전 분리']
  expires_at  datetime    [note: '유효시간 만료 시각 - 지나면 만료 처리(V27, 관리자 PG_ORDER_TTL_MINUTES)']
  paid_at     datetime    [note: '결제 완료 시각(V27)']
  canceled_at datetime    [note: '취소·환불 시각(V27)']
  created_at  datetime    [not null]
  Indexes {
    (merchant_id, order_no) [unique, name: 'ux_pg_order']
    (status, created_at) [name: 'ix_pg_status', note: '미결제 주문 만료 스윕']
    (status, expires_at) [name: 'ix_pg_expire', note: '유효시간 지난 PENDING 주문 만료 스위퍼']
    txn_id [name: 'ix_pg_txn', note: '결제/취소 시 거래→주문 역참조']
  }
}

Table webhook_deliveries {
  id            bigint    [pk, increment]
  merchant_id   bigint    [not null, ref: > merchants.id]
  event_type    varchar(30) [not null, note: 'PAYMENT_COMPLETED|PAYMENT_CANCELED|TEST 등']
  pg_order_id   bigint    [ref: > pg_orders.id]
  payload       text      [not null]
  target_url    varchar(500) [not null]
  attempts      int       [not null, default: 0, note: '지수 백오프 최대 10회/24h']
  next_retry_at datetime
  last_http_code int
  claimed_at    datetime  [note: '발송 클레임 시각 - SENDING 고아 복구 스위퍼 기준']
  claim_token   varchar(64) [note: '발송 워커의 집기 표식(UUID) - 2대 서버 중복 발송 방지(V34)']
  status        varchar(20) [not null, default: 'PENDING', note: 'PENDING|SENDING(클레임-커밋 후 발송)|DELIVERED|EXHAUSTED(#30 감시)']
  created_at    datetime  [not null]
  Indexes { (status, next_retry_at) [name: 'ix_webhook_retry'] }
}

// ========================= 7. 정책 (admin 설정) =======================
// 공통 패턴: 전역 기본(scope=GLOBAL_*, target_id NULL) + 개별 오버라이드.
// 전부 SYSTEM VERSIONING - "그 시점 정책" 증빙. 거래엔 스냅샷 저장.

Table fee_policies {
  id           bigint      [pk, increment]
  fee_type     varchar(20) [not null, note: 'MERCHANT_PAYMENT|MERCHANT_PAYOUT|USER_DEPOSIT|USER_WITHDRAW|USER_GIFT']
  scope        varchar(15) [not null, note: 'GLOBAL|USER|MERCHANT']
  target_id    bigint
  rate         decimal(7,4)  [not null, default: 0, note: '정률% - 정액과 합산 조합(#25)']
  fixed_amount decimal(15,0) [not null, default: 0]
  Indexes { (fee_type, scope, target_id) [unique, name: 'ux_fee_policy'] }
}

Table limit_policies {
  id         bigint      [pk, increment]
  limit_type varchar(15) [not null, note: 'DEPOSIT|BALANCE|WITHDRAW|GIFT|PAYMENT(#37)']
  window     varchar(10) [not null, note: 'PER_TXN|DAILY|MONTHLY|CAP(보유)']
  scope      varchar(15) [not null]
  target_id  bigint
  amount     decimal(15,0) [not null, note: 'BALANCE는 법정 상한 초과 설정 차단(앱 검증)']
  Indexes { (limit_type, window, scope, target_id) [unique, name: 'ux_limit_policy'] }
  Note: '집행은 사용자 전 카드 합산(#37)'
}

Table payout_policies {
  id           bigint      [pk, increment]
  scope        varchar(20) [not null, note: 'GLOBAL_USER|GLOBAL_MERCHANT|USER|MERCHANT']
  target_id    bigint
  delay_days   int         [not null, note: '신청 +N일. **매장(GLOBAL_MERCHANT) 기본 0 = 즉시 정산**(발주 확정 2026-07-22, 가맹점 출금요청 시 즉각). 사용자 출금만 대기 가능']
  execute_time time        [not null, note: '실행 시각 HH:mm (KST). delay_days=0이면 무시(즉시)']
  Indexes { (scope, target_id) [unique, name: 'ux_payout_policy'] }
  Note: '매장 정산 대기(+N일) 구조·로직 완전 구현 유지(제거 금지 - 향후 추가 비용 방지, 발주 2026-07-22). 매장 기본 delay=0(즉시 정산). 값만 바꾸면 +1일/+2일 대기 즉시 적용. scheduled_at = 신청 + delay일 HH시(delay 0이면 NOW → 배치가 바로 집행)'
}

Table cancel_policies {
  id        bigint      [pk, increment]
  scope     varchar(15) [not null, note: 'GLOBAL|MERCHANT']
  target_id bigint
  days      int         [not null, note: '결제 후 N일 - 거래에 cancelable_until 스냅샷']
  Indexes { (scope, target_id) [unique, name: 'ux_cancel_policy'] }
}

Table card_quota_policies {
  id        bigint      [pk, increment]
  scope     varchar(15) [not null, note: 'GLOBAL|USER(#43)']
  target_id bigint
  max_cards int         [not null, note: 'ACTIVE 카드 기준']
  Indexes { (scope, target_id) [unique, name: 'ux_quota_policy'] }
}

Table point_expiry_policies {
  id       bigint      [pk, increment]
  lot_type varchar(10) [not null, unique, note: 'DEPOSIT|REWARD']
  months   int         [not null, note: '충전성 값은 법무 확인 후 설정(#42)']
}

Table global_settings {
  id         bigint       [pk, increment]
  skey       varchar(50)  [not null, unique, note: 'REJOIN_WAIT_DAYS|CPM_CONFIRM_THRESHOLD|MAINTENANCE_ON|MAINTENANCE_MSG|MAINTENANCE_UNTIL|MIN_APP_VERSION_IOS|MIN_APP_VERSION_ANDROID|BANK_COOLDOWN_HOURS|FORFEIT_MAX_AMOUNT(탈퇴 소액포기 상한)|PUSH_FCM_PROJECT_ID|PUSH_FCM_KEY_FILE_ID(files 참조 - 서비스계정 JSON)|PUSH_ENABLED 등 단일값 설정']
  svalue     varchar(500) [not null]
  updated_by bigint       [ref: > admin_accounts.id]
  updated_at datetime     [not null]
  Note: 'SYSTEM VERSIONING - 설정 변경 이력 자동 보존'
}

// ============================== 8. FDS ===============================

Table fds_rules {
  id          bigint      [pk, increment]
  code        varchar(30) [not null, unique, note: 'PASSTHROUGH|GIFT_CONCENTRATION|SELF_PAYMENT|CANCEL_ABUSE|POST_CHANGE_WITHDRAW|ENUMERATION|MULTI_ACCOUNT_DEVICE|NIGHT_LARGE(#38 8종)']
  name        varchar(100) [not null]
  params      text        [not null, note: 'JSON: 임계값·시간창 - admin에서 조정(배포 불필요)']
  action      varchar(10) [not null, note: 'BLOCK|HOLD|ALERT|MONITOR']
  enabled     boolean     [not null, default: true]
  updated_at  datetime    [not null]
  Note: 'SYSTEM VERSIONING'
}

Table fds_alerts {
  id             bigint    [pk, increment]
  rule_id        bigint    [not null, ref: > fds_rules.id]
  principal_type varchar(10) [not null]
  principal_id   bigint    [not null]
  txn_id         bigint    [note: '관련 거래']
  detail         text      [note: '탐지 근거 JSON(증거 보존)']
  status         varchar(20) [not null, default: 'OPEN', note: 'OPEN|REVIEWED|ACTIONED|DISMISSED']
  reviewed_by    bigint    [ref: > admin_accounts.id]
  reviewed_at    datetime
  review_note    varchar(500)
  created_at     datetime  [not null]
  Indexes { (status, created_at) [name: 'ix_fds_queue'] }
}

// ======================= 9. 콘텐츠·고객지원·약관 ======================

Table notices {
  id            bigint      [pk, increment]
  kind          varchar(10) [not null, note: 'NOTICE|EVENT(#18)']
  audience      varchar(10) [not null, default: 'ALL', note: 'ALL|USER|STORE - 노출 대상 앱(V20)']
  force_show    tinyint     [not null, default: 0, note: '1=앱 메인 강제 팝업(확인해야 사용)(V20)']
  title         varchar(200) [not null]
  body          text        [not null]
  image_file_id bigint      [ref: > files.id, note: '이벤트 이미지 배너(V15) - 중앙 파일 대장 참조(FK 강제 V29)']
  link_url      varchar(500) [note: '이벤트 외부 링크(V15)']
  starts_at     datetime
  ends_at       datetime
  status        varchar(10) [not null, default: 'DRAFT', note: 'DRAFT|PUBLISHED|HIDDEN']
  created_by    bigint      [ref: > admin_accounts.id]
  created_at    datetime    [not null]
  Indexes { (kind, status, starts_at) [name: 'ix_notice_pub'] }
}

Table banners {
  id            bigint    [pk, increment]
  position      varchar(20) [not null, default: 'USER_HOME', note: 'USER_HOME|GUEST|USER_CHARGE|STORE_HOME - 앱 노출 위치(V21). 같은 위치 여러개면 롤링']
  image_file_id bigint    [not null, ref: > files.id, note: '배너 이미지 - 중앙 파일 대장 참조(V8 원칙 교정: 경로 직접 보관 금지)']
  link_type   varchar(10) [not null, note: 'NOTICE|EVENT|URL|NONE - 외부 URL 허용(승인 정책)']
  link_target varchar(500)
  sort_order  int         [not null, default: 0]
  starts_at   datetime
  ends_at     datetime
  status      varchar(10) [not null, default: 'DRAFT']
}

Table faqs {
  id         bigint      [pk, increment]
  category   varchar(30) [not null]
  question   varchar(300) [not null]
  answer     text        [not null]
  sort_order int         [not null, default: 0]
  status     varchar(10) [not null, default: 'PUBLISHED']
}

Table inquiries {
  id             bigint    [pk, increment]
  principal_type varchar(10) [not null, note: 'USER|MERCHANT']
  principal_id   bigint    [not null]
  title          varchar(200) [not null]
  body           text      [not null]
  status         varchar(10) [not null, default: 'OPEN', note: 'OPEN|ANSWERED|CLOSED']
  answer         text
  answered_by    bigint    [ref: > admin_accounts.id]
  answered_at    datetime  [note: '답변 시 푸시 통지']
  created_at     datetime  [not null]
  Indexes {
    (principal_type, principal_id, created_at) [name: 'ix_inquiry_owner']
    (status, created_at) [name: 'ix_inquiry_queue', note: 'admin 미답변 큐']
  }
}

Table inquiry_attachments {
  id         bigint      [pk, increment]
  inquiry_id bigint      [not null, ref: > inquiries.id]
  file_id    bigint      [not null, ref: > files.id, note: '첨부 이미지 - 중앙 파일 대장 참조(V8 원칙 교정). 실물은 두 WAS 가 함께 바라보는 공유 폴더에 저장(발주사 확정 2026-08-11)']
  created_at datetime    [not null]
  Note: '이미지만, 최대 5장/장당 10MB(#19 확정)'
}

Table policy_documents {
  id           bigint      [pk, increment]
  kind         varchar(20) [not null, note: 'TERMS|PRIVACY|WITHDRAW_GUIDE(탈퇴 안내)(#21,V17)']
  version      varchar(20) [not null]
  body         text        [not null]
  effective_at datetime    [not null, note: '시행일 - 사전 고지·예약 게시']
  requires_reconsent boolean [not null, default: false, note: '개정 건별 재동의 여부 admin 선택(승인 정책)']
  status       varchar(10) [not null, default: 'DRAFT', note: 'DRAFT|ACTIVE|ARCHIVED - 과거 버전 영구 보존']
  Indexes { (kind, version) [unique, name: 'ux_policy_ver'] }
}

// 회원 탈퇴 신청·승인 대장(V18). 신청(PENDING) → 관리자 승인(APPROVED=탈퇴 확정)/반려(REJECTED).
// 승인 순간 users.status='WITHDRAWN' + 카드 CLOSED, 보유 포인트는 소멸(소멸 금액 기록).
Table account_closure_requests {
  id                bigint        [pk, increment]
  user_id           bigint        [not null, note: '신청 회원']
  status            varchar(20)   [not null, default: 'PENDING', note: 'PENDING|APPROVED|REJECTED']
  reason            varchar(200)  [note: '회원이 남긴 탈퇴 사유(선택)']
  points_at_request decimal(15,0) [not null, default: 0, note: '신청 시점 보유 포인트(소멸 예정 안내)']
  points_forfeited  decimal(15,0) [note: '승인 시점 실제 소멸 포인트']
  requested_at      datetime      [not null]
  processed_by      bigint        [note: '승인·반려한 관리자']
  processed_at      datetime
  Indexes {
    user_id [name: 'ix_closure_user']
    status  [name: 'ix_closure_status']
  }
}

Table policy_agreements {
  id             bigint   [pk, increment]
  principal_type varchar(10) [not null]
  principal_id   bigint   [not null]
  policy_id      bigint   [not null, ref: > policy_documents.id]
  agreed_at      datetime [not null, note: '분쟁 대비 증빙']
  Indexes { (principal_type, principal_id, policy_id) [unique, name: 'ux_agreement'] }
}

// =========================== 10. 알림·푸시 ===========================

Table notification_settings {
  event_key   varchar(40)  [pk, note: '알림 이벤트 키(enqueue payload 의 kind 와 일치, V16)']
  description varchar(200) [not null]
  enabled     boolean      [not null, default: true, note: '발송 여부(끄면 이 이벤트 알림 안 감)']
  channel     varchar(10)  [not null, default: 'BOTH', note: 'INBOX(앱 알림함) | PUSH(폰 푸시) | BOTH']
  severity    varchar(10)  [not null, default: 'INFO', note: 'INFO|WARN|CRITICAL(표시용)']
  updated_at  datetime     [not null]
  Note: '이벤트별 알림 발송 설정(관리자 조정). OutboxWorker 가 발송 시 참조. NestPay 실제 이벤트만 시드'
}

Table push_tokens {
  id             bigint    [pk, increment]
  principal_type varchar(10) [not null]
  principal_id   bigint    [not null]
  platform       varchar(10) [not null]
  token          varchar(255) [not null, unique, note: 'FCM(#20)']
  updated_at     datetime  [not null]
  Indexes { (principal_type, principal_id) [name: 'ix_push_principal'] }
}

Table push_campaigns {
  id           bigint     [pk, increment]
  title        varchar(100) [not null]
  body         varchar(500) [not null]
  target       varchar(20) [not null, note: 'ALL(회원 전체)|IOS|ANDROID|MERCHANT_ALL(매장 전체)|SELECTED(개별 - 대상은 push_campaign_targets, JSON 금지 원칙). 매장 푸시 확정 요구로 MERCHANT_ALL 추가']
  is_ad        boolean    [not null, note: '광고성=수신동의자만+야간(21~08) 차단 자동']
  scheduled_at datetime
  sent_count   int        [default: 0]
  fail_count   int        [default: 0]
  status       varchar(10) [not null, default: 'DRAFT', note: 'DRAFT|QUEUED(발송 대기)|SENDING|SENT']
  created_by   bigint     [ref: > admin_accounts.id]
  created_at   datetime   [not null]
}

Table push_campaign_targets {
  id             bigint    [pk, increment]
  campaign_id    bigint    [not null, ref: > push_campaigns.id]
  principal_type varchar(10) [not null]
  principal_id   bigint    [not null]
  sent_at        datetime
  result         varchar(20) [note: 'SENT|FAILED|NO_TOKEN|OPTED_OUT']
  Indexes { (campaign_id, principal_type, principal_id) [unique, name: 'ux_campaign_target'] }
  Note: '지정 발송 대상 + 개별 발송 결과 - 도달 분석'
}

Table notification_inbox {
  id             bigint    [pk, increment, note: '실PK (id, created_at) 월 파티션 - 30일 보존 배치 삭제(#46 확정)']
  principal_type varchar(10) [not null]
  principal_id   bigint    [not null]
  ntype          varchar(20) [not null, note: 'TXN|GIFT|SETTLEMENT|INQUIRY|CAMPAIGN 등']
  title          varchar(100) [not null]
  body           varchar(500)
  deeplink       varchar(200)
  read_at        datetime
  created_at     datetime  [not null]
  Indexes { (principal_type, principal_id, created_at) [name: 'ix_inbox_owner'] }
  Note: '푸시 발송과 동일 outbox 작업에서 원자적 기록'
}

// ==================== 11. 명세·정산 (월말 스냅샷) =====================

// [제거됨 V46] monthly_statements(회원 월명세 사전집계) — 실시간 계산(AppStatementService)으로 대체되어 미사용.

Table merchant_monthly_statements {
  id            bigint   [pk, increment]
  merchant_id   bigint   [not null, ref: > merchants.id]
  yyyymm        char(6)  [not null]
  sales_count   int      [not null, default: 0]
  sales_sum     decimal(15,0) [not null, default: 0]
  cancel_count  int      [not null, default: 0]
  cancel_sum    decimal(15,0) [not null, default: 0]
  supply_sum    decimal(15,0) [not null, default: 0, note: '공급가액(#24 부가세 분리)']
  vat_sum       decimal(15,0) [not null, default: 0]
  fee_sum       decimal(15,0) [not null, default: 0, note: '결제+정산 수수료']
  payout_sum    decimal(15,0) [not null, default: 0]
  closing_balance decimal(15,0) [not null]
  created_at    datetime [not null]
  Indexes { (merchant_id, yyyymm) [unique, name: 'ux_mstmt_month'] }
  Note: '월 정산서(#24-5) - 카드사 동일 수준, 부가세 포함'
}

// ===================== 12. 비동기(outbox)·감사·운영 ===================

Table files {
  id            bigint       [pk, increment]
  original_name varchar(300) [not null, note: '사용자가 올린 원래 파일 이름']
  stored_name   varchar(100) [not null, unique, note: '서버 저장 파일명(무작위 - 겹침·경로조작 방지)']
  content_type  varchar(100) [note: '파일 형식(application/pdf 등)']
  size_bytes    bigint       [not null]
  uploaded_by   bigint       [not null, note: '올린 주체의 번호(관리자 또는 매장계정)']
  uploader_type varchar(10)  [not null, default: 'ADMIN', note: 'ADMIN(관리자)|MERCHANT(매장) - V7 주체 구분']
  created_at    datetime     [not null]
  Note: '중앙 파일 대장(V4) - 모든 업로드 파일은 이 한 테이블로만 관리, 타 테이블은 file_id 참조(확정 원칙). 실물은 두 WAS 가 함께 바라보는 공유 폴더(app.storage.dir)에 저장, stored_name=폴더 안 파일 이름'
}

Table merchant_documents {
  id           bigint       [pk, increment]
  merchant_id  bigint       [not null, ref: > merchants.id]
  doc_name     varchar(100) [not null, note: '서류 이름(예: 사업자등록증, 통장 사본)']
  doc_type     varchar(30)  [note: '서류 종류코드 BIZ_CERT|CEO_ID|SETTLE_BANKBOOK|CEO_SEAL|CORP_SEAL|CORP_REGISTRY|SHAREHOLDERS - V40. 가입 시 개인/법인별로 자동 생성']
  file_id      bigint       [ref: > files.id, note: 'NULL=요구만 됨(미제출), 값=제출 완료']
  requested_by bigint       [ref: > admin_accounts.id, note: 'V40 NULL 허용: 가입 시 자동 생성 기본서류는 NULL, 관리자 추가 요구분은 관리자 id']
  submitted_at datetime     [note: '제출(업로드) 시각']
  created_at   datetime     [not null]
  Indexes {
    (merchant_id, doc_name) [unique, name: 'ux_mdoc_merchant_doc', note: '같은 서류명 중복 요구 방지']
    merchant_id [name: 'ix_mdoc_merchant']
  }
  Note: '매장 필수서류(V4) - 요구 서류가 전부 제출(file_id 채움)되어야 승인 가능(서버 강제). 매장 승인은 관리자 필수. 가입 시 개인 4종/법인 6종 자동 생성(V40)'
}

Table outbox_jobs {
  id           bigint      [pk, increment]
  job_type     varchar(30) [not null, note: 'PUSH|WEBHOOK|RECONCILE|GIFT_EXPIRE|LOT_EXPIRE|SETTLEMENT|STATEMENT|PARTITION 등 - 큐 미사용(#22) 단일 패턴']
  payload      text        [not null]
  run_after    datetime    [not null]
  attempts     int         [not null, default: 0]
  max_attempts int         [not null, default: 10]
  status       varchar(10) [not null, default: 'PENDING', note: 'PENDING|RUNNING|DONE|EXHAUSTED']
  claimed_at   datetime    [note: '워커가 RUNNING으로 집어간 시각 - 고아 스위퍼 기준(13장, V10 정본모순 교정)']
  claim_token  char(36)    [note: '집기 표 - UPDATE로 붙이고 자기 것만 SELECT. SKIP LOCKED가 10.3 미지원이라 대체(V11)']
  last_error   varchar(500)
  created_at   datetime    [not null]
  Indexes {
    (status, run_after) [name: 'ix_outbox_poll', note: '워커 폴링 주 쿼리']
    claim_token [name: 'ix_outbox_claim', note: '내 표 붙은 작업 찾기(10.3 호환 클레임)']
  }
  Note: '원장 트랜잭션과 같은 커밋에 INSERT - 후속작업 유실 불가(#29)'
}

Table audit_logs {
  id          bigint      [pk, increment]
  actor_type  varchar(10) [not null, note: 'ADMIN|SYSTEM']
  actor_id    bigint
  action      varchar(50) [not null, note: 'MERCHANT_APPROVE|FORCE_SUSPEND|POLICY_CHANGE|MANUAL_MATCH|OTP_RESET|IP_ADD 등']
  target_type varchar(30)
  target_id   bigint
  detail      text        [note: '변경 전후 값 JSON']
  reason      varchar(500) [note: '강제 조치·조정은 사유 필수(#40·#48)']
  ip          varchar(45)
  created_at  datetime    [not null]
  Indexes {
    (actor_type, actor_id, created_at) [name: 'ix_audit_actor']
    (target_type, target_id) [name: 'ix_audit_target']
  }
  Note: 'append-only. admin 전 조회·변경 행위 기록'
}

Table pii_access_logs {
  id          bigint    [pk, increment]
  actor_type   varchar(10) [not null, note: 'ADMIN|MEMBER - 개인정보를 누가 열람했는지']
  admin_id    bigint    [not null, ref: > admin_accounts.id]
  target_type varchar(10) [not null]
  target_id   bigint    [not null]
  fields      varchar(200) [not null, note: '열람 항목(전화번호·계좌 등)']
  purpose     varchar(200)
  created_at  datetime  [not null]
  Indexes {
    (admin_id, created_at) [name: 'ix_pii_admin', note: '관리자별 열람 조사']
    (target_type, target_id, created_at) [name: 'ix_pii_target', note: '특정 고객 열람 조사(유출 대응)']
  }
  Note: '개인신용정보 접근기록(감독규정). 월 파티션(실PK id,created_at)·5년 DROP·방어 트리거. 기록 지점=*_enc 복호화 표시 API 전부(카탈로그 17.9b 규칙)'
}

// ==================== 13. 운영 로그·분석·대사 이력 ====================
// 로그 계층 원칙(유지보수 우선):
//  - "판단·운영·분쟁 대응에 쓰는 구조적 로그" = DB (아래 테이블)
//  - "대량 원시 로그"(HTTP 액세스 전건·디버그) = 파일 로그(로테이션·보존·수집)
//    → 원장 DB를 로그로 비대화시키지 않으면서 분석 가능성은 확보

Table external_api_logs {
  id            bigint    [pk, increment, note: '실PK (id, created_at) 월 파티션']
  provider      varchar(30) [not null, note: 'FIRMBANK|NICE|NTS(국세청)|FCM|WEBHOOK_OUT 등']
  operation     varchar(50) [not null, note: 'TRANSFER|BALANCE_QUERY|VERIFY_NAME|SEND_PUSH 등']
  txn_id        bigint    [note: '관련 거래 - 리컨실러·분쟁 추적의 핵심 연결고리']
  request_body  text      [note: '민감정보 마스킹 후 저장']
  response_body text      [note: '계좌번호·CI류만 선별 마스킹 후 보존(결과코드·금액·거래참조는 원문 - 증거 가치 유지, 감사 확정)']
  http_status   int
  result_code   varchar(30) [note: '기관 응답 코드']
  latency_ms    int
  created_at    datetime  [not null]
  Indexes {
    (provider, created_at) [name: 'ix_extapi_provider']
    txn_id [name: 'ix_extapi_txn']
    (result_code, created_at) [name: 'ix_extapi_result', note: '기관별 오류율 분석']
  }
  Note: '모든 대외 호출의 요청·응답 전건 기록 - "은행은 됐다는데 우리는 왜 실패?"의 판정 근거'
}

Table pg_api_logs {
  id            bigint      [pk, increment, note: '실PK (id, created_at) 월 파티션']
  merchant_id   bigint      [note: '인증을 통과했을 때만 채움 - 문 앞에서 막힌 요청은 NULL']
  client_id     varchar(64) [note: '요청이 들고 온 매장코드 - 인증에 실패해도 남김(누가 시도했는지 실마리)']
  mode          varchar(10) [note: 'SANDBOX(테스트)|LIVE(실거래) - 인증 통과 시']
  ip            varchar(45) [not null, note: '요청이 들어온 주소']
  method        varchar(10) [not null]
  path          varchar(200) [not null, note: '예: /pg/payments']
  query         varchar(500)
  request_body  text        [note: '요청 원문(JSON) - 16000자 초과분만 절단 표시']
  response_body text        [note: '응답 원문(JSON)']
  http_status   int         [not null]
  outcome       varchar(20) [not null, note: 'OK|AUTH_FAIL(문 앞에서 막힘)|ERROR(처리 중 실패)']
  reject_reason varchar(200) [note: '막힌 이유 한 줄 - 목록에서 원문을 펼치지 않고 확인']
  latency_ms    int
  created_at    datetime    [not null]
  Indexes {
    (merchant_id, created_at) [name: 'ix_pgapi_merchant', note: '관리자 매장상세 조회 경로']
    (outcome, created_at) [name: 'ix_pgapi_outcome']
    (http_status, created_at) [name: 'ix_pgapi_status']
    (client_id, created_at) [name: 'ix_pgapi_client', note: '인증 실패 건은 매장코드로만 찾을 수 있음']
  }
  Note: '쇼핑몰(매장 서버)이 부른 PG API 전건 기록 - 연동 테스트 추적·장애 판정 근거 (append-only · 월 파티션 · 5년)'
}

Table reject_logs {
  id             bigint    [pk, increment, note: '실PK (id, created_at) 월 파티션']
  principal_type varchar(10) [not null, note: 'USER|MERCHANT|ANON(비인증 시도)']
  principal_id   bigint    [note: '비인증 시도는 NULL(감사 보완)']
  ip             varchar(45) [note: 'FDS 열거(ENUMERATION) 룰 - IP 기준 탐지']
  action         varchar(30) [not null, note: 'PAYMENT|WITHDRAW|GIFT|DEPOSIT_MATCH|QR_VERIFY|LOGIN|CARD_ISSUE 등']
  reject_code    varchar(40) [not null, note: 'LIMIT_EXCEEDED|INSUFFICIENT_BALANCE|FDS_BLOCK|QR_EXPIRED|CANCEL_WINDOW_EXPIRED|PIN_LOCKED 등']
  context        text      [note: '판정 근거 JSON(한도값·잔액·룰ID 등 당시 상태)']
  created_at     datetime  [not null]
  Indexes {
    (principal_type, principal_id, created_at) [name: 'ix_reject_principal']
    (reject_code, created_at) [name: 'ix_reject_code', note: '거절 사유 통계 - UX·정책 개선 판단']
    (ip, created_at) [name: 'ix_reject_ip', note: 'IP 열거 공격 탐지']
  }
  Note: '거래가 생성되기 전 거절된 시도의 기록 - transactions에 없는 "안 된 일"의 분석용. CS 문의("왜 안 돼요") 즉답 근거'
}

Table integrity_check_runs {
  id           bigint    [pk, increment]
  check_type   varchar(30) [not null, note: 'DOUBLE_ENTRY|BALANCE_CHAIN|WALLET_CACHE|SEGREGATION|STALE_TXN|OUTBOX_HEALTH|ORPHAN_LEDGER(고아 원장)|SUMMARY_RECON(집계-원장 대사)|SYSTEM_WALLET(시스템 지갑 SUM 검증)']
  scope        varchar(100) [note: '검사 범위(일자·파티션)']
  status       varchar(10) [not null, note: 'PASS|FAIL|RUNNING']
  checked_count bigint    [note: '검사 행 수']
  anomaly_count int       [not null, default: 0]
  started_at   datetime  [not null]
  finished_at  datetime
  Indexes { (check_type, started_at) [name: 'ix_check_history'] }
  Note: '#30 모니터링 대시보드의 데이터 원천 - 검증 실행 자체의 이력(언제 무엇을 검사했고 결과가 무엇이었나)'
}

Table integrity_findings {
  id          bigint    [pk, increment]
  run_id      bigint    [not null, ref: > integrity_check_runs.id]
  target_type varchar(30) [not null, note: 'WALLET|TXN|LOT 등']
  target_id   bigint    [not null]
  detail      text      [not null, note: '기대값 vs 실제값']
  status      varchar(20) [not null, default: 'OPEN', note: 'OPEN|INVESTIGATING|RESOLVED(조정 역분개 참조)|FALSE_POSITIVE']
  resolved_by bigint    [ref: > admin_accounts.id]
  resolved_at datetime
  resolution_note varchar(500)
  Indexes { (status) [name: 'ix_finding_open'] }
  Note: '이상 건 개별 추적 - 발견부터 해소(역분개 조정)까지의 생애주기 기록'
}

Table daily_wallet_snapshots {
  id          bigint    [pk, increment]
  snap_date   date      [not null]
  wallet_id   bigint    [not null, ref: > wallets.id]
  balance     decimal(15,0) [not null]
  last_ledger_id bigint [not null, note: '스냅샷 시점의 마지막 원장 행 - 체인 검증 재개점']
  Indexes {
    (snap_date, wallet_id) [unique, name: 'ux_snap_day_wallet']
    (wallet_id, snap_date) [name: 'ix_snap_wallet_latest', note: '지갑별 최신 스냅샷 조회(희소 방식)']
  }
  Note: '희소 스냅샷(감사 확정): 당일 원장 변동 지갑만 기록 + 월 1회 전량 베이스라인. 과거일 잔액 = 해당일 이전 최신 스냅샷. ODKU 멱등 적재'
}

Table daily_summaries {
  id           bigint   [pk, increment]
  summary_date date     [not null]
  metric       varchar(40) [not null, note: 'DEPOSIT_SUM|WITHDRAW_SUM|PAYMENT_SUM|GIFT_SUM|CANCEL_SUM|FEE_SUM|EXPIRE_SUM|NEW_USERS|NEW_MERCHANTS|ACTIVE_USERS|FDS_ALERTS|REJECTS 등']
  value        decimal(18,0) [not null]
  Indexes { (summary_date, metric) [unique, name: 'ux_summary_day_metric'] }
  Note: 'admin 운영 대시보드·추이 그래프의 원천 - 원장 재집계 없이 즉시 조회'
}

Table app_error_logs {
  id          bigint    [pk, increment, note: '실PK (id, created_at) 월 파티션']
  severity    varchar(10) [not null, note: 'ERROR|CRITICAL']
  source      varchar(30) [not null, note: 'API|WORKER|BATCH']
  error_code  varchar(50)
  message     varchar(1000) [not null]
  trace_id    varchar(64) [note: '파일 로그(전체 스택)와의 연결 키']
  txn_id      bigint
  created_at  datetime  [not null]
  Indexes { (severity, created_at) [name: 'ix_error_severity'] }
  Note: '서버 예외 요약(운영자 화면·알림용). 전체 스택트레이스는 파일 로그 - trace_id로 연결'
}

// 사용자 충전 입금통장(회사 수납 계좌) 관리(V25) — 관리자 등록·노출, 앱 충전화면 안내.
Table deposit_accounts {
  id         bigint      [pk, increment]
  bank_name  varchar(40) [not null]
  account_no varchar(40) [not null]
  holder     varchar(60) [not null]
  label      varchar(60)
  sort_order int         [not null, default: 0]
  status     varchar(10) [not null, default: 'ACTIVE', note: 'ACTIVE|HIDDEN']
  created_at datetime    [not null]
  Indexes { (status, sort_order) [name: 'ix_deposit_account_status'] }
}

// ================= 14. 발주사(외부 PG) 연동 대장·현금영수증 ================
// 발주사 API 를 부를 때마다 "무엇을 요청했고 결과가 무엇인지"를 남기는 대장입니다.
// 결과를 못 받은 건(UNKNOWN)은 자동 재시도하지 않고 관리자 [미결 건] 화면에서
// 사람이 발주사에 확인한 뒤 종결합니다 — 세금 자료·송금은 두 번 처리되면 안 되기 때문입니다.

Table vendor_requests {
  id             bigint      [pk, increment, note: '내부 일련번호']
  track_id       varchar(50) [not null, unique, note: '우리가 발주사에 보낸 거래고유번호(재사용 금지 — PVAC.3500 방지)']
  operation      varchar(50) [not null, note: '요청 종류 (identity/send · bank/owner-check · payout/transfer · cash/issue 등)']
  subject_type   varchar(20) [not null, note: 'USER(회원) | MERCHANT(매장) | GUEST(가입 전)']
  subject_id     bigint      [note: '회원/매장 번호 (가입 전에는 아직 없어 비어 있음)']
  vendor_trx_id  varchar(50) [note: '발주사 거래고유번호(trxId)']
  vendor_seq_no  varchar(50) [note: '인증 거래번호(TX_SEQ_NO) — 재전송·확인 단계에서 사용']
  verify_tr_dt   varchar(8)  [note: '1원인증 검증 거래일자(8자리) — 확인 단계 필수 보관']
  verify_tr_no   varchar(20) [note: '1원인증 검증 거래번호 — 확인 단계 필수 보관']
  verify_txt     varchar(20) [note: '통장 적요에 찍히는 한글 단어(사용자 안내용)']
  result_enc     varbinary(1024) [note: '본인인증 결과를 우리 열쇠로 잠근 값(이름·생년월일·성별·통신사·내외국인·CI). 사용 후 삭제']
  result_at      datetime    [note: '알림으로 결과가 도착한 시각']
  status         varchar(20) [not null, note: 'REQUESTED=요청함 | SUCCEEDED=성공 | FAILED=실패 | UNKNOWN=결과확인불가(사람 확인 필요)']
  result_code    varchar(30) [note: '발주사 결과코드 (예: A000, DV50, PVAC.3500)']
  result_message varchar(200) [note: '발주사 결과 메시지(개인정보 제외)']
  created_at     datetime    [not null, note: '요청 시각']
  updated_at     datetime    [note: '결과 반영 시각']
  Note: '발주사 요청 대장 — 결과 미확정(UNKNOWN)은 자동 재시도 금지, 관리자가 확인 후 종결'
}

// 결제한 손님에게 매장이 발행하는 현금영수증입니다. 부가세 유무와 무관하게 발행합니다.
// 관리자도 조회·대리 취소할 수 있고, 대리 발행은 [거래 관리]에서 결제 건을 골라 진행합니다.

Table cash_receipts {
  id              bigint      [pk, increment]
  txn_id          bigint      [not null, note: '대상 결제 거래 번호(transactions.id)']
  merchant_id     bigint      [not null, note: '발행한 매장 번호 - 남의 매장 건을 발행하지 못하게 대조용']
  track_id        varchar(50) [not null, unique, note: '발행 요청 시 우리가 만든 거래고유번호(재사용 금지)']
  cancel_track_id varchar(50) [note: '취소 요청 시 우리가 만든 거래고유번호(발행과 달라야 함)']
  vendor_trx_id   varchar(50) [note: '발주사 거래고유번호(trxId) - 취소할 때 rootTrxId 로 넣습니다']
  auth_code       varchar(50) [note: '발급기관 승인번호(authCd) - 고객에게 보여 주는 값']
  auth_type       varchar(2)  [not null, note: '인증구분 03=개인(휴대폰) · 04=법인(사업자번호)']
  identity_enc    varbinary(255) [not null, note: '인증값(휴대폰번호/사업자번호) - 개인정보라 암호화 보관']
  identity_hash   char(64)    [note: '인증값 SHA-256 - 원문 없이 같은 값을 찾기 위한 검색용']
  usage_type      varchar(20) [not null, note: '발행용도 소득공제용 | 지출증빙용 | 자진발급']
  cust_name       varchar(100) [not null, note: '고객명(발주사 필수 항목)']
  product_name    varchar(200) [not null, note: '상품명(pdtName)']
  amount          decimal(15,0) [not null, note: '총 결제금액 - 취소할 때도 이 금액을 그대로 보냅니다']
  supply_amount   decimal(15,0) [note: '공급가액(발주사 회신값)']
  vat_amount      decimal(15,0) [note: '부가세(발주사 회신값). 면세 매장이면 0 이거나 비어 있습니다']
  tax_type        varchar(20) [note: '과세구분(발주사 회신값) 예: 과세 · 면세']
  status          varchar(20) [not null, note: 'UNKNOWN=결과 모름 · ISSUED=발행완료 · FAILED=발행실패 · CANCELED=취소완료 · CANCEL_FAILED=취소실패']
  result_code     varchar(30) [note: '발주사 결과코드(예: 0000)']
  result_message  varchar(200) [note: '발주사 결과 메시지']
  issued_by       bigint      [note: '발행을 누른 매장 계정 번호. 자동 취소 건은 비어 있습니다']
  issued_at       datetime    [note: '발행 완료 시각']
  canceled_at     datetime    [note: '취소 완료 시각']
  created_at      datetime    [not null]
  updated_at      datetime    [not null]
  alive_key       bigint      [unique, note: '생성 칸(VIRTUAL): status 가 UNKNOWN·ISSUED·CANCEL_FAILED 일 때만 txn_id 를 갖습니다. 이 유니크 하나로 "결제 1건당 살아있는 영수증 최대 1장"이 DB 차원에서 보장됩니다(동시요청 실측 검증)']
  Note: '현금영수증 - 결제 1건당 살아있는 영수증은 최대 1장(alive_key 유니크). 결과 미확정(UNKNOWN)은 재발행 차단'
}

// ============================ 15. 은행 목록 ===========================
// 앱·매장앱·관리자가 계좌 등록 화면의 "은행 고르기"에 쓰는 목록입니다.
//
// 왜 표로 두나요?
//  예전에는 앱(kBankNames)과 관리자(BANK_NAMES)에 따로 적혀 있었습니다. 그 결과
//   · 앱에만 없는 은행(우체국·새마을금고 등)을 쓰는 사람은 계좌 등록 자체가 불가능했고,
//   · 같은 코드인데 이름이 달랐으며(003 = 'IBK기업' / '기업'),
//   · 은행이 하나 늘 때마다 두 곳을 고치고 앱을 새로 배포해야 했습니다.
//  이제 이 표가 유일한 출처이고, 모두 GET /app/content/banks 로 받아 씁니다.
//
// ★ 코드는 화면에 보여 주지 않습니다. 사람에게는 이름만 보이고, 고른 항목의 코드를 서버로 보냅니다.

Table banks {
  code       varchar(3)  [pk, note: '금융결제원 표준 은행 코드 3자리 - 시스템 내부 값(화면에 노출 금지)']
  name       varchar(40) [not null, note: '사람에게 보여 줄 은행 이름']
  sort_order int         [not null, default: 0, note: '선택 목록에 나오는 차례(작을수록 위). 자주 쓰는 은행을 앞에 둡니다']
  status     varchar(10) [not null, default: 'ACTIVE', note: 'ACTIVE=목록에 보임 | HIDDEN=목록에서 감춤. 합병·폐업 은행은 지우지 않고 감춥니다 — 이미 그 은행으로 등록된 계좌의 코드가 살아 있어야 과거 기록이 깨지지 않습니다']
  created_at datetime    [not null]
  Indexes { (status, sort_order) [name: 'ix_banks_list', note: '목록 조회 — 정렬까지 인덱스로 해결'] }
  Note: '은행 목록(단일 출처) - 앱·매장앱·관리자가 모두 이 표를 받아 씁니다'
}

// ========================= 16. 기능별 이용제한 ========================
// 지금까지는 "전부 열림(ACTIVE)" 아니면 "전부 닫힘(SUSPENDED)" 둘뿐이었습니다.
// 실제 운영에서는 이런 경우가 생깁니다.
//   · 출금만 막고 결제는 계속 (계좌 도용 의심)
//   · 선물만 막고 싶다 (선물 되팔이 의심)
//   · 매장 정산만 멈추고 결제는 받게 (정산 계좌 확인 중)
//
// ★ "전체 정지"는 여기에 넣지 않습니다 — users.status / merchants.status 가 이미 합니다.
//   같은 일을 두 곳에 두면 어느 쪽이 진짜인지 알 수 없게 됩니다. 이 표는 개별 기능만 다룹니다.
// ★ 해제해도 행을 지우지 않습니다(released_at 에 시각 기록) — 소명 자료로 남아야 합니다.

Table account_restrictions {
  id             bigint      [pk, increment]
  principal_type varchar(10) [not null, note: 'USER(회원) | MERCHANT(매장)']
  principal_id   bigint      [not null, note: '회원 번호 또는 매장 번호']
  feature        varchar(20) [not null, note: '막을 기능 — 회원: DEPOSIT·WITHDRAW·GIFT·PAYMENT / 매장: PAYMENT·SETTLEMENT. 조합은 chk_restriction_feature 가 강제(회원에게 정산 제한 불가)']
  reason         varchar(200) [not null, note: '막은 이유 - 필수. 나중에 왜 막았는지 설명할 수 있어야 합니다']
  created_by     bigint      [not null, note: '막은 관리자']
  created_at     datetime    [not null]
  released_by    bigint      [note: '푼 관리자(아직 막혀 있으면 비어 있음)']
  released_at    datetime    [note: '푼 시각. 비어 있으면 지금도 막힌 상태']
  release_reason varchar(200) [note: '푼 이유']
  alive_key      varchar(40) [unique, note: '생성 칸(VIRTUAL): 아직 안 푼 것만 principal_type:id:feature 값을 갖습니다. 이 유니크로 "같은 대상·기능에 살아 있는 제한은 하나"가 DB 차원에서 보장됩니다 — 두 번 걸면 한 번 풀어도 안 풀립니다']
  Indexes {
    (principal_type, principal_id, released_at) [name: 'ix_restriction_lookup', note: '거래 직전 문지기 조회 — EXPLAIN 확인(type=ref)']
  }
  Note: '기능별 이용제한 - 전체 정지는 users.status/merchants.status 담당, 이 표는 개별 기능만'
}

// ========================== 17. 포인트 압류 ==========================
// 사고(도용·분쟁·수사 협조)가 나면 그 돈이 빠져나가지 못하게 묶어 둡니다.
// 조사 결과에 따라 돌려주거나(반환) 없앱니다(소각).
//
// ★ 어떻게 묶나요 — 돈을 실제로 옮깁니다.
//   회원/매장 지갑 → SEIZED 시스템 지갑. 잔액이 그만큼 줄어 어디에서도 쓸 수 없습니다.
//   ("쓸 수 있는 금액"을 따로 계산하는 방식은 돈 쓰는 곳마다 검사를 넣어야 하고,
//    한 곳만 빠뜨려도 묶어 둔 돈이 새어 나갑니다)
//
// ★ 전부 아니면 전무 — 부분 반환·부분 소각은 없습니다.
//   나눠 처리해야 하면 압류를 나눠서 겁니다. 돈 계산에 갈래가 늘수록 어긋날 자리가 늘어납니다.
//
// ★ 회원은 포인트 덩어리(point_lots)도 함께 다룹니다.
//   묶을 때 만료가 가까운 것부터 덜어 내고, 돌려줄 때 원래 유효기간을 이어받아 되살립니다.
//   매장 지갑에는 덩어리가 없어 잔액만 옮깁니다.
//
// 거래는 ADJUST 계열입니다 — SEIZE(묶기) · SEIZE_RETURN(반환) · SEIZE_BURN(소각).

Table seizures {
  id             bigint      [pk, increment]
  principal_type varchar(10) [not null, note: 'USER(회원) | MERCHANT(매장)']
  principal_id   bigint      [not null, note: '회원 번호 또는 매장 번호']
  wallet_id      bigint      [not null, ref: > wallets.id, note: '묶은 지갑(회원은 카드 지갑, 매장은 매장 지갑)']
  amount         decimal(15,0) [not null, note: 'CHECK(amount > 0). 묶은 금액']
  reason         varchar(200) [not null, note: '묶은 이유 - 필수. 관리자 내부용이며 앱에는 보여 주지 않습니다']
  status         varchar(10) [not null, default: 'HELD', note: 'HELD(보관중)|RETURNED(돌려줌)|BURNED(소각함) - CHECK 로 강제']
  seize_txn_id   bigint      [not null, ref: > transactions.id, note: '묶을 때 만든 거래(ADJUST/SEIZE)']
  resolve_txn_id bigint      [ref: > transactions.id, note: '반환·소각 거래(ADJUST/SEIZE_RETURN·SEIZE_BURN)']
  created_by     bigint      [not null, note: '묶은 관리자']
  created_at     datetime    [not null]
  resolved_by    bigint      [note: '끝낸 관리자']
  resolved_at    datetime    [note: '끝낸 시각. 비어 있으면 아직 조사 중']
  resolve_reason varchar(200) [note: '반환·소각한 이유']
  Indexes {
    (principal_type, principal_id, status) [name: 'ix_seizure_holder', note: '앱·관리자의 "지금 묶인 돈" 조회']
  }
  Note: '포인트 압류 대장 - 앱에는 묶인 금액만 보여 주고 사유는 내부용'
}