← 문서 목록

DB 스키마 · schema.dbml

59개 테이블 설계 (DBML)

// =====================================================================
// 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), gift_links CLAIMED/EXPIRED 1년 후 아카이브 검토
//  삭제 수단: 파티션 테이블=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으로 분기(거래 단위로 쪼개지 않음).
//    "거래(거래타입, 상세PK) ← 거래상세": transactions.detail_id 가 거래타입별 상세 테이블 PK를 참조.
//  - 업무 상세만 거래타입별 테이블로 분리: pg_orders(PG결제)·gift_links(선물링크)·
//    deposit_notices(입금통지)·merchant_qrs(QR결제) 등. (type,subtype)이 상세 테이블을 결정.
//  - ledger_entries(복식 원장)는 그대로 단일 append-only. 상세 설계는 재논의 예정.
//  - 서비스 범위: 한국 내 전용(글로벌 미지원 확정) → 전 시스템 KST 단일.
//  - 매장 정산: 대기(+N일) 구조·로직은 구현 유지. 매장 기본 delay=0(즉시 정산),
//    향후 값만 바꾸면 +1일/+2일 대기 적용 가능(발주 2026-07-22).
// ---------------------------------------------------------------------
// 확정 정책 (발주 회신 반영 - 2026-07-22 이후)
//  - 결제·충전 채널: 발주사 자체 PG 사용 - 가상계좌 입금통지(웹훅) + 직접계좌 펌뱅킹 출금이체.
//    (금결원 오픈뱅킹 직접 이용기관 등록 대신 발주사 PG 규격 연동. 규격서 확정 후 상세 매핑)
//  - 본인인증: 쿠콘 또는 드림시큐리티. 1원인증·계좌실명조회: 쿠콘 또는 발주사 기존 계약분.
//  - 상품권 충전 UX·상품권사(컬처랜드·해피머니 등) 연동: 범위 제외(발주 확정, 미구현).
//  - 포인트 전환(외부 제휴 포인트 ↔ nestpay): 설계 유지·구현 보류(발주 2026-07-22).
//    실제 연동 요청 시 즉시 착수 가능하도록 스키마 완비 상태 유지:
//    exchange_providers(공급자·환율), EXCHANGE 시스템지갑/거래유형(EXCHANGE_IN·OUT)/수수료유형(USER_EXCHANGE),
//    point_lots.source=EXCHANGE, 복식분개 leg(queries 0.3)까지 정의. 구현은 어댑터(ConversionProvider)만 추가.
//    → 전환 유입분도 유상(DEPOSIT) 로트로 계상 → 환급은 소스 무관 전체 DEPOSIT 잔액 기준 유지(소스별 차등 없음).
//    ※ 전환 유입분의 선불충전금 별도관리 대상 여부는 법무 확인.
//  - 유상(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: '본인인증 결과. 성인 전용 검증']
  marketing_agree boolean    [not null, default: false, note: '광고성 푸시 수신동의(#20)']
  status        varchar(20)  [not null, default: 'ACTIVE', note: 'ACTIVE|SUSPENDED|WITHDRAWN']
  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: '대표자 본인인증']
  biz_type       varchar(20) [not null, note: 'CORP(법인)|SOLE(개인사업자)']
  category       varchar(50)
  address        varchar(300)
  biz_cert_path  varchar(300) [note: '사업자등록증 이미지(비공개 저장)']
  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|ACTIVE|REJECTED|SUSPENDED|CLOSED']
  reject_reason  varchar(500)
  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]
  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]
  Note: '마지막 1건 삭제 불가·본인 IP 삭제 경고는 앱 로직'
}

// --- 인증 부속 (계정 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'] }
}

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·방어 트리거'
}

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

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|REMOVED - 변경 시 REMOVED 처리, 이력 보존(#10 정책)']
  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. 카드·지갑 ============================

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년. 만료 시 재발행 절차 재사용']
  design_code   varchar(20) [not null, note: 'OCEAN|MIDNIGHT|SUNSET|PEARL(#10)']
  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(낙전)|GIFT_ESCROW(선물보류)|UNMATCHED(미매칭입금)|SETTLEMENT_CLEARING(대외청산)|FORFEITED(탈퇴 소액포기)|EXCHANGE_{제휴사}(포인트전환 청산, #49 구현보류)']
  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]
  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(선물수취)|REISSUE(재발행 이관)|EVENT(이벤트 적립)|EXCHANGE:{code}(포인트전환 유입, 구현보류) - 이력 메타(환급 판정에 미사용)']
  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(관리자 조정)|EXPIRE(소멸)']
  subtype         varchar(20) [not null, note: 'VACCT(가상계좌 입금)|FIRMBANK(펌뱅킹 이체)|REISSUE|GIFT_LINK|GIFT_PHONE|GIFT_QR|QR_STORE|QR_ORDER|CPM|PG_ONLINE|SETTLEMENT|FORFEIT(소액포기)|UNMATCHED_RETURN|EXCHANGE_IN|EXCHANGE_OUT(포인트전환, 구현보류) 등']
  status          varchar(12) [not null, note: 'PENDING|HOLD|UNKNOWN|CONFIRMED|FAILED|REVERSED|CANCELED - 선차감·리컨실러 상태머신(#3)']
  initiator_type  varchar(10) [not null, note: 'USER|MERCHANT|ADMIN|SYSTEM']
  initiator_id    bigint    [not null, default: 0]
  idempotency_key varchar(64) [note: 'UNIQUE(initiator_type, initiator_id, idempotency_key) - 사용자 스코프 멱등(#3)']
  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: '출금·정산 실행 대상 계좌 스냅샷 - 이후 계좌 변경해도 "어디로 나갔나" 불변(전수 대조에서 발견된 누락 보완)']
  detail_id       bigint    [note: '거래상세 PK 참조 (발주 답변 2026-07-22): 원장=단일 거래 테이블(type 분기), 업무 상세만 거래타입별 테이블로 분리 — "거래(거래타입,상세PK) ← 거래상세". 상세 테이블 = pg_orders(PG결제)/gift_links(선물링크)/deposit_notices(입금)/merchant_qrs(QR결제) 등, (type,subtype)이 어느 상세 테이블인지 결정. 상세 설계는 재논의 예정']
  pg_order_id     bigint    [note: 'PG 온라인 결제 주문 참조 (detail_id의 PG 사례 - 재편 시 detail_id로 통합)']
  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. 충전 입금 (발주사 PG) =======================
// #36: 발주사 PG 가상계좌 입금 + 펌뱅킹. 입금통지 = PG 웹훅(즉시 감지) + 펌뱅킹 조회(확정) 이중 소스.
// 발주 확정(2026-07-22): 발주사가 PG사 - 가상계좌 발급·입금통지 웹훅·펌뱅킹 출금이체 규격 사용.
// 식별 방식(가상계좌 값 규격)은 발주사 PG 규격서 회신 후 kind/value 고정.

Table deposit_identifiers {
  id         bigint      [pk, increment]
  user_id    bigint      [not null, ref: > users.id]
  kind       varchar(20) [not null, note: 'VIRTUAL_ACCOUNT|DEPOSITOR_CODE - 펌뱅킹 계약 확정 후 단일화']
  value      varchar(64) [not null, unique, note: '가상계좌 식별자 평문 여부는 법무 확인 대기 - 의무 적용 시 value_hash 전환(질의서 반영)']
  status     varchar(20) [not null, default: 'ACTIVE']
  created_at datetime    [not null]
}

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']
  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 시스템 지갑에 원장 계상 - 장부 밖 돈 없음'
}

// ============================== 5. 선물 ==============================

Table gift_links {
  id              bigint    [pk, increment]
  sender_card_id  bigint    [not null, ref: > cards.id]
  hold_txn_id     bigint    [not null, note: '선차감(GIFT_ESCROW 계상) 거래']
  token_hash      char(64)  [not null, unique, note: '클레임 토큰(1회용) - 원문 미저장']
  amount          decimal(15,0) [not null]
  message         varchar(50) [note: '금칙어 필터(2차 항목 8)']
  expires_at      datetime  [not null, note: 'TTL 24시간 확정 - 만료 시 자동 반환']
  status          varchar(20) [not null, default: 'CREATED', note: 'CREATED|CLAIMED|EXPIRED|CANCELED(송금인 회수)']
  claimed_card_id bigint    [ref: > cards.id, note: '수령 카드(수령자 대표 카드)']
  claimed_at      datetime
  Indexes {
    (status, expires_at) [name: 'ix_gift_expiry', note: '만료 반환 배치']
    (sender_card_id, status) [name: 'ix_gift_sender', note: '보낸 선물 목록·취소기간 미경과 탈퇴검증']
  }
}

// =========================== 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 서명 생성에 원문 필요(해시 불가, 감사 보완)']
  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: '체크리스트: 상태조회']
  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 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)
  qr_id       bigint      [ref: > merchant_qrs.id]
  txn_id      bigint      [note: '결제 성사 시 거래 참조']
  status      varchar(20) [not null, default: 'CREATED', note: 'CREATED|PAID|CANCELED|EXPIRED']
  is_sandbox  boolean     [not null, default: false, note: '테스트 주문 실원장 완전 분리']
  created_at  datetime    [not null]
  Indexes {
    (merchant_id, order_no) [unique, name: 'ux_pg_order']
    (status, created_at) [name: 'ix_pg_status', 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 고아 복구 스위퍼 기준']
  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|USER_EXCHANGE(포인트전환, 구현보류)']
  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(탈퇴 소액포기 상한) 등 단일값 설정']
  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)']
  title      varchar(200) [not null]
  body       text        [not null]
  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]
  image_path  varchar(300) [not null]
  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_path  varchar(300) [not null, note: '웹루트 밖 비공개 저장. 서버 재인코딩·EXIF 제거 후']
  created_at datetime    [not null]
  Note: '이미지만, 최대 5장/장당 10MB(#19 확정)'
}

Table policy_documents {
  id           bigint      [pk, increment]
  kind         varchar(10) [not null, note: 'TERMS|PRIVACY(#21)']
  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'] }
}

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 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|SELECTED - SELECTED 시 대상은 push_campaign_targets(JSON 금지 원칙)']
  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']
  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. 명세·정산 (월말 스냅샷) =====================

Table monthly_statements {
  id              bigint   [pk, increment]
  card_id         bigint   [not null, ref: > cards.id]
  yyyymm          char(6)  [not null]
  deposit_sum     decimal(15,0) [not null, default: 0]
  withdraw_sum    decimal(15,0) [not null, default: 0]
  payment_sum     decimal(15,0) [not null, default: 0]
  gift_sent_sum   decimal(15,0) [not null, default: 0]
  gift_recv_sum   decimal(15,0) [not null, default: 0]
  expire_sum      decimal(15,0) [not null, default: 0]
  fee_sum         decimal(15,0) [not null, default: 0]
  closing_balance decimal(15,0) [not null, note: '월말 잔액 - 월 단위 대사 겸용(#23)']
  created_at      datetime [not null]
  Indexes { (card_id, yyyymm) [unique, name: 'ux_stmt_card_month'] }
}

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 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']
  last_error   varchar(500)
  created_at   datetime    [not null]
  Indexes { (status, run_after) [name: 'ix_outbox_poll', note: '워커 폴링 주 쿼리'] }
  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]
  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 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로 연결'
}

Table merchant_daily_summaries {
  id           bigint   [pk, increment]
  merchant_id  bigint   [not null, ref: > merchants.id]
  summary_date date     [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]
  vat_sum      decimal(15,0) [not null, default: 0]
  fee_sum      decimal(15,0) [not null, default: 0]
  Indexes { (merchant_id, summary_date) [unique, name: 'ux_mds_merchant_day', note: '매장앱 일/주/월 차트의 원천 - 1년 범위도 range 365행'] }
  Note: '차트 집계 계층(감사 신설): 일 마감 배치가 최근 W일(=취소기한+출금대기 상한+1) 멱등 재계산(ODKU). 과거=본 테이블, 당일=ix_txn_merchant 실시간, 접합은 UNION(18장). 결측일 0채움은 Java'
}

Table hourly_summaries {
  id        bigint   [pk, increment]
  stat_hour datetime [not null, note: '정시 절단(KST)']
  metric    varchar(40) [not null]
  value     decimal(18,0) [not null]
  Indexes { (stat_hour, metric) [unique, name: 'ux_hourly'] }
  Note: 'admin 전역 당일 시간대 추이(감사 신설) - 시간 배치 적재, 90일 보존 DELETE'
}

// ================= 부록. 포인트 전환 (설계 유지·구현 보류) =================
// #49: 외부 제휴 포인트 ↔ nestpay 전환. 발주 2026-07-22 "설계는 해두고, 연동 요청 시 즉시 착수".
// 스키마·복식분개(queries 0.3 EXCHANGE leg)까지 정의 완료. 구현은 ConversionProvider 어댑터만 추가.
// 전환 유입분은 DEPOSIT 로트로 계상 → 환급은 소스 무관 전체 DEPOSIT 기준 유지.

Table exchange_providers {
  id         bigint      [pk, increment]
  code       varchar(20) [not null, unique, note: '제휴 포인트 공급자 코드']
  name       varchar(100) [not null]
  rate_in    decimal(10,4) [note: '외부 포인트 → nestpay 전환율']
  rate_out   decimal(10,4) [note: 'nestpay → 외부 포인트 역전환율(허용 시)']
  status     varchar(10) [not null, default: 'INACTIVE', note: 'INACTIVE|ACTIVE - 구현 보류, 연동 요청 시 활성']
  created_at datetime    [not null]
  Note: '#49 포인트 전환 - 설계 유지·구현 보류. 전환 유입분은 DEPOSIT 로트 계상, 별도관리 대상 여부 법무 확인'
}