콘텐츠로 이동

Billing Schema

billing schema 는 빌링키, 구독, 결제 시도 이력을 저장한다. 결제 SaaS 의 가장 민감한 데이터가 위치하며, 빌링키는 AES-256-GCM 으로 암호화 저장된다.

Schema purpose

  • PostgreSQL schemabilling
  • Corresponding domainengine/billing/
  • sqlc packagedb/queries/billing/

3개 테이블로 구성: billing_keys, subscriptions, payment_attempts.

Tables

billing.billing_keys

Toss 빌링키 암호화 저장.

CREATE TABLE billing.billing_keys (
    id                UUID PRIMARY KEY,
    user_id           UUID NOT NULL REFERENCES identity.users(id),
    customer_key      TEXT NOT NULL,
    encrypted_key     BYTEA NOT NULL,
    key_nonce         BYTEA NOT NULL,
    card_company      TEXT NOT NULL,
    card_last4        TEXT NOT NULL,
    card_type         TEXT NOT NULL CHECK (card_type IN ('credit', 'check')),
    issued_at         TIMESTAMPTZ NOT NULL,
    deleted_at        TIMESTAMPTZ,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT billing_keys_customer_key_unique UNIQUE (customer_key),
    CONSTRAINT billing_keys_nonce_size CHECK (length(key_nonce) = 12),
    CONSTRAINT billing_keys_card_last4_format CHECK (card_last4 ~ '^[0-9]{4}$')
);

Columns

Column Type Constraints Description
id UUID v7 PK 빌링키 내부 ID
user_id UUID FK, NOT NULL identity.users.id
customer_key TEXT UNIQUE, NOT NULL Toss 고객 키 (user_{uuid})
encrypted_key BYTEA NOT NULL AES-256-GCM 암호문
key_nonce BYTEA NOT NULL, 12 bytes GCM nonce
card_company TEXT NOT NULL 표시용 카드사
card_last4 TEXT CHECK 4자리 숫자
card_type TEXT CHECK credit / check
issued_at TIMESTAMPTZ NOT NULL Toss 발급 시각
deleted_at TIMESTAMPTZ Soft delete
created_at TIMESTAMPTZ NOT NULL
updated_at TIMESTAMPTZ NOT NULL

Indexes

Index Columns Purpose
billing_keys_pkey (id) PK
billing_keys_customer_key_unique (customer_key) Toss 키 조회
billing_keys_user_active_idx (user_id) WHERE deleted_at IS NULL User 의 활성 빌링키

Foreign keys

Column References On delete
user_id identity.users(id) RESTRICT (soft delete 원칙)

Invariants

  • encrypted_key 는 항상 AES-256-GCM 암호문 (평문 저장 금지 — 애플리케이션 레벨)
  • key_nonce 는 정확히 12 bytes (CHECK 제약)
  • customer_keyuser_{uuid} 형식 (애플리케이션 레벨 검증)
  • 삭제는 soft delete — 과거 PaymentAttempt 참조 유지

billing.subscriptions

구독.

CREATE TABLE billing.subscriptions (
    id                     UUID PRIMARY KEY,
    license_id             UUID NOT NULL REFERENCES licensing.licenses(id),
    payer_user_id          UUID NOT NULL REFERENCES identity.users(id),
    guild_id               UUID NOT NULL REFERENCES guild.guilds(id),
    billing_key_id         UUID NOT NULL REFERENCES billing.billing_keys(id),
    plan_id                UUID NOT NULL REFERENCES licensing.plans(id),
    status                 TEXT NOT NULL CHECK (status IN ('pending', 'active', 'past_due', 'canceled', 'suspended')),
    current_period_start   TIMESTAMPTZ,
    current_period_end     TIMESTAMPTZ,
    next_billing_at        TIMESTAMPTZ,
    cycle_count            INTEGER NOT NULL DEFAULT 0,
    retry_count            INTEGER NOT NULL DEFAULT 0,
    cancel_at_period_end   BOOLEAN NOT NULL DEFAULT FALSE,
    canceled_at            TIMESTAMPTZ,
    suspended_at           TIMESTAMPTZ,
    suspended_reason       TEXT,
    created_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT subscriptions_retry_range CHECK (retry_count >= 0 AND retry_count <= 4),
    CONSTRAINT subscriptions_cycle_nonneg CHECK (cycle_count >= 0)
);

CREATE UNIQUE INDEX subscriptions_guild_active_unique
ON billing.subscriptions (guild_id)
WHERE status IN ('active', 'past_due');

Columns

Column Type Constraints Description
id UUID v7 PK 구독 내부 ID
license_id UUID FK, NOT NULL licensing.licenses.id
payer_user_id UUID FK, NOT NULL identity.users.id (결제 주체)
guild_id UUID FK, NOT NULL guild.guilds.id (적용 대상)
billing_key_id UUID FK, NOT NULL billing_keys.id
plan_id UUID FK, NOT NULL licensing.plans.id
status TEXT CHECK Subscription 상태
current_period_start TIMESTAMPTZ 현재 주기 시작
current_period_end TIMESTAMPTZ 현재 주기 종료
next_billing_at TIMESTAMPTZ 실제 결제 예정 시각 (jitter 포함)
cycle_count INTEGER NOT NULL 성공한 결제 횟수
retry_count INTEGER CHECK 0..4 현재 주기 재시도 횟수
cancel_at_period_end BOOLEAN NOT NULL 해지 예정 플래그
canceled_at TIMESTAMPTZ 실제 해지 시각
suspended_at TIMESTAMPTZ Suspend 시각
suspended_reason TEXT Suspend 사유
created_at TIMESTAMPTZ NOT NULL
updated_at TIMESTAMPTZ NOT NULL

Indexes

Index Columns Purpose
subscriptions_pkey (id) PK
subscriptions_guild_active_unique (guild_id) WHERE status IN ('active', 'past_due') Guild 당 active 유일
subscriptions_next_billing_idx (next_billing_at) WHERE status IN ('active', 'past_due') cron scan (due 조회)
subscriptions_payer_idx (payer_user_id, status) 사용자 대시보드
subscriptions_license_idx (license_id) License 역참조
subscriptions_billing_key_idx (billing_key_id) 빌링키 삭제 시 영향 조회

Foreign keys

Column References On delete
license_id licensing.licenses(id) RESTRICT
payer_user_id identity.users(id) RESTRICT
guild_id guild.guilds(id) RESTRICT
billing_key_id billing.billing_keys(id) RESTRICT
plan_id licensing.plans(id) RESTRICT

Invariants

  • Guild 당 active/past_due 유일 (partial unique index)
  • status = 'active' OR 'past_due'next_billing_at IS NOT NULL
  • cancel_at_period_end = true 이면 status = 'active' (과거 결제 중 취소 예약)
  • retry_count 는 0~4, 4 도달 시 PaymentFailedFinal 트리거
  • canceled_at IS NOT NULL 이면 status = 'canceled'

billing.payment_attempts

결제 시도 이력.

CREATE TABLE billing.payment_attempts (
    id                  UUID PRIMARY KEY,
    subscription_id     UUID NOT NULL REFERENCES billing.subscriptions(id),
    order_id            TEXT NOT NULL,
    amount_krw          INTEGER NOT NULL CHECK (amount_krw > 0),
    status              TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')),
    toss_payment_key    TEXT,
    toss_approved_at    TIMESTAMPTZ,
    failure_code        TEXT,
    failure_message     TEXT,
    cycle               INTEGER NOT NULL,
    retry_number        INTEGER NOT NULL CHECK (retry_number >= 0 AND retry_number <= 3),
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    completed_at        TIMESTAMPTZ,
    CONSTRAINT payment_attempts_order_id_unique UNIQUE (order_id),
    CONSTRAINT payment_attempts_succeeded_has_key CHECK (
        (status = 'succeeded' AND toss_payment_key IS NOT NULL AND toss_approved_at IS NOT NULL)
        OR status != 'succeeded'
    ),
    CONSTRAINT payment_attempts_failed_has_code CHECK (
        (status = 'failed' AND failure_code IS NOT NULL)
        OR status != 'failed'
    )
);

Columns

Column Type Constraints Description
id UUID v7 PK 시도 ID
subscription_id UUID FK, NOT NULL subscriptions.id
order_id TEXT UNIQUE, NOT NULL Toss orderId
amount_krw INTEGER CHECK > 0 결제 금액 (원)
status TEXT CHECK pending / succeeded / failed
toss_payment_key TEXT 성공 시 Toss paymentKey
toss_approved_at TIMESTAMPTZ Toss 승인 시각
failure_code TEXT 실패 시 Toss 에러 코드
failure_message TEXT 실패 메시지
cycle INTEGER NOT NULL 결제 주기 번호 (1부터)
retry_number INTEGER CHECK 0..3 재시도 번호 (0 = 1차 시도)
created_at TIMESTAMPTZ NOT NULL 시도 시작 시각
completed_at TIMESTAMPTZ 완료 시각

Indexes

Index Columns Purpose
payment_attempts_pkey (id) PK
payment_attempts_order_id_unique (order_id) Toss 결제 조회
payment_attempts_subscription_idx (subscription_id, created_at DESC) 구독별 이력
payment_attempts_status_idx (status, created_at DESC) 성공/실패 통계

Foreign keys

Column References On delete
subscription_id billing.subscriptions(id) RESTRICT

Invariants

  • order_idsub_{subscription_id}_{cycle:03d}_r{retry_number} 형식
  • status = 'succeeded' 이면 toss_payment_keytoss_approved_at NOT NULL (CHECK)
  • status = 'failed' 이면 failure_code NOT NULL (CHECK)
  • 같은 (subscription_id, cycle, retry_number) 조합은 유일 — order_id UNIQUE 로 자연 강제

Relationships

erDiagram
    USERS ||--o{ BILLING_KEYS : owns
    USERS ||--o{ SUBSCRIPTIONS : pays
    GUILDS ||--o{ SUBSCRIPTIONS : "target of"
    LICENSES ||--o| SUBSCRIPTIONS : "backed by"
    BILLING_KEYS ||--o{ SUBSCRIPTIONS : "charged via"
    PLANS ||--o{ SUBSCRIPTIONS : "subscribed to"
    SUBSCRIPTIONS ||--o{ PAYMENT_ATTEMPTS : "has"

Cross-schema references

From To Semantics
billing.billing_keys.user_id identity.users.id 빌링키 소유자
billing.subscriptions.payer_user_id identity.users.id 결제 주체
billing.subscriptions.guild_id guild.guilds.id 적용 대상
billing.subscriptions.license_id licensing.licenses.id 권한 연결
billing.subscriptions.plan_id licensing.plans.id 구독 플랜
billing.subscriptions.billing_key_id billing.billing_keys.id 결제 수단

Query patterns

Hot path: ListDueForCharge

결제 대상 조회 (매 정각 cron).

-- ListDueForCharge
SELECT s.*
FROM billing.subscriptions s
WHERE s.status IN ('active', 'past_due')
  AND s.next_billing_at <= $1    -- now + lead_time
ORDER BY s.next_billing_at ASC
LIMIT $2;

InsertPaymentAttempt (결제 시작)

INSERT INTO billing.payment_attempts (
    id, subscription_id, order_id, amount_krw, status, cycle, retry_number
) VALUES ($1, $2, $3, $4, 'pending', $5, $6);

MarkAttemptSucceeded

UPDATE billing.payment_attempts
SET status = 'succeeded',
    toss_payment_key = $1,
    toss_approved_at = $2,
    completed_at = NOW()
WHERE id = $3;

AdvanceSubscriptionPeriod (결제 성공 시)

UPDATE billing.subscriptions
SET current_period_start = current_period_end,
    current_period_end = $1,      -- 새 주기 종료 (jitter 포함)
    next_billing_at = $2,         -- 다음 결제 시각
    cycle_count = cycle_count + 1,
    retry_count = 0,
    status = 'active',
    updated_at = NOW()
WHERE id = $3;

MarkAttemptFailedAndScheduleRetry

UPDATE billing.subscriptions
SET status = 'past_due',
    retry_count = retry_count + 1,
    next_billing_at = $1,         -- now + (24/48/72h)
    updated_at = NOW()
WHERE id = $2;

Retry timing

retry_number delay from previous description
0 정기 결제 (next_billing_at 정각)
1 24h 1차 재시도
2 48h 2차 재시도
3 72h 3차 재시도
4 자동 해지 (no attempt)

Data retention

  • BillingKeys — soft delete 후 90일 보관, 이후 encrypted_key, key_nonce 는 NULL 로 wipe (삭제 후 복구 불가)
  • Subscriptions — 영구 보관
  • PaymentAttempts — 영구 보관 (감사, 세무 목적)

Encryption notes

  • MEK: 32 bytes, 환경변수 BILLING_KEY_ENCRYPTION_KEY (hex 또는 base64)
  • Algorithm: AES-256-GCM
  • Nonce: 12 bytes 랜덤, 매 암호화마다 새로
  • AAD: customer_key 를 AAD 로 바인딩 (교체 공격 방지)
  • Rotation: Phase 2 에서 KMS 로 이전, 기존 키로 재암호화 migration

Migration history

Date Change Rationale
2026-04-xx 초기 스키마 MVP

See also

  • domain/billing.md — Billing 도메인
  • data/licensing-schema.md — License 연결
  • data/identity-schema.md — User 참조
  • data/guild-schema.md — Guild 참조
  • adr/0013-payment-toss-billing.md — Toss 채택
  • adr/0022-jitter-payment-time.md — jitter
  • adr/0012-license-subscription-separation.md — 분리 원칙