콘텐츠로 이동

Billing

결제를 담당하는 Core 도메인. Toss Payments 의 빌링키를 발급하고, 정기 결제를 실행하며, 실패 시 재시도와 자동 해지를 관리한다. Licensing 과 분리된 lifecycle 을 가지며 Outbox 이벤트로만 동기화된다.

Bounded context

  • TypeCore (full Hexagonal)
  • Sibling contexts — Licensing (권한 연동), Identity (Payer = User), Guild (결제 대상 = Guild), Webhook (Toss 콜백)
  • Location in codebaseengine/billing/

Why this domain exists

결제 SaaS 의 핵심이자 가장 위험이 큰 영역이다. 다음 세 가지 복잡도가 집중된다.

첫째, 외부 시스템 의존 — Toss Payments API 호출, 웹훅 수신, 빌링키 암호화 저장. 실패 모드가 다양하고 복구가 까다롭다.

둘째, 시간 기반 워크플로우 — 정기 결제 cron, ±15분 jitter, 실패 시 24h/48h/72h 재시도, 4차 실패 시 자동 해지. 정확성과 idempotency 가 결정적.

셋째, Hybrid 모델 반영 — 결제 주체(User) 와 적용 대상(Guild) 이 분리되어 있으며, License 와 lifecycle 이 다름. 잘못된 설계는 결제 사고로 이어짐.

이 도메인은 실패를 전제로 설계 되어야 한다. "성공 경로" 만 보면 간단해 보이지만, 실제 운영에서는 Toss 장애, 빌링키 만료, 사용자 해지, 카드 정지, 중복 결제 방지 등 예외 처리가 대부분을 차지한다.

Domain model

BillingKey

Toss 가 발급한 정기결제 토큰. User 에 귀속.

BillingKey
├─ id                       UUID v7      PK
├─ user_id                  UUID         → identity.users.id
├─ customer_key             TEXT         Toss 에 등록한 고객 키, UNIQUE
├─ encrypted_key            BYTEA        AES-256-GCM 암호화된 billingKey
├─ key_nonce                BYTEA        GCM nonce (12 bytes)
├─ card_company             TEXT         '신한카드', 'KB국민' 등 (표시용)
├─ card_last4               TEXT         마지막 4자리
├─ card_type                TEXT         'credit' | 'check'
├─ issued_at                TIMESTAMPTZ
├─ deleted_at               TIMESTAMPTZ  사용자 해지 시
├─ created_at               TIMESTAMPTZ
└─ updated_at               TIMESTAMPTZ

Subscription

특정 User 가 특정 Guild 를 대상으로 진행 중인 구독.

Subscription
├─ id                       UUID v7      PK
├─ license_id               UUID         → licensing.licenses.id
├─ payer_user_id            UUID         → identity.users.id
├─ guild_id                 UUID         → guild.guilds.id
├─ billing_key_id           UUID         → billing_keys.id
├─ plan_id                  UUID         → licensing.plans.id
├─ status                   TEXT         'pending' | 'active' | 'past_due' | 'canceled' | 'suspended'
├─ current_period_start     TIMESTAMPTZ
├─ current_period_end       TIMESTAMPTZ
├─ next_billing_at          TIMESTAMPTZ  jitter 적용된 실제 결제 예정 시각
├─ cycle_count              INTEGER      성공한 결제 회차
├─ retry_count              INTEGER      현재 주기 재시도 횟수
├─ cancel_at_period_end     BOOLEAN
├─ canceled_at              TIMESTAMPTZ
├─ suspended_at             TIMESTAMPTZ
├─ suspended_reason         TEXT
├─ created_at               TIMESTAMPTZ
└─ updated_at               TIMESTAMPTZ

PaymentAttempt

결제 시도 이력. 성공 또는 실패마다 row 생성.

PaymentAttempt
├─ id                       UUID v7      PK
├─ subscription_id          UUID         → subscriptions.id
├─ order_id                 TEXT         Toss order ID, UNIQUE
├─ amount_krw               INTEGER
├─ status                   TEXT         'pending' | 'succeeded' | 'failed'
├─ toss_payment_key         TEXT         Toss 응답 paymentKey
├─ toss_approved_at         TIMESTAMPTZ
├─ failure_code             TEXT         Toss 에러 코드
├─ failure_message          TEXT
├─ cycle                    INTEGER      몇 번째 결제 주기인지
├─ retry_number             INTEGER      0 = 1차 시도, 1 = 24h 재시도 …
├─ created_at               TIMESTAMPTZ
└─ completed_at             TIMESTAMPTZ

Aggregates

  • Subscription (root) — 자신의 PaymentAttempt 목록을 가짐
  • BillingKey — 독립 aggregate (여러 Subscription 이 참조)

Invariants

  • Guild 당 active/past_due Subscription 1개UNIQUE (guild_id) WHERE status IN ('active', 'past_due')
  • BillingKey customer_key 유일 — Toss 측 식별자 충돌 방지
  • Order ID 유일 — Toss 중복 결제 방지
  • orderId 형식sub_{subscription_id}_{cycle}_r{retry} (e.g. sub_01J0XX..._001_r0)
  • cycle_count 는 성공한 결제만 — 실패는 반영 안 함
  • retry_count 는 현재 주기 한정 — 다음 주기 진입 시 0으로 리셋
  • BillingKey 암호화encrypted_key 는 항상 AES-256-GCM 암호문

State machine

Subscription

stateDiagram-v2
    [*] --> Pending : Created (before first charge)
    Pending --> Active : First charge success
    Pending --> Canceled : First charge failed
    Active --> PastDue : Recurring charge failed (1-3rd)
    PastDue --> Active : Retry succeeded
    PastDue --> Canceled : 4th retry failed (auto cancel)
    Active --> Canceled : User cancel + period_end reached
    Active --> Suspended : Bot kicked / license suspended
    PastDue --> Suspended : Bot kicked
    Suspended --> Active : Bot reinstalled + license resumed
    Canceled --> [*]
  • Pending — 첫 결제 시도 직전
  • Active — 정상 활성
  • PastDue — 결제 실패 중이지만 grace 로 유지 중
  • Canceled — 영구 해지
  • Suspended — 봇 부재 등으로 일시 중지

Domain events

Published

Event Trigger Payload Subscribers
BillingKeyIssued 빌링키 발급 성공 user_id, billing_key_id, card_last4 Audit
BillingKeyDeleted 빌링키 삭제 user_id, billing_key_id Licensing, Billing (Subscription suspend), Audit
SubscriptionStarted 첫 결제 성공 subscription_id, guild_id, plan_code Licensing (grant), Notification, Audit
PaymentSucceeded 정기 결제 성공 subscription_id, attempt_id, new_period_end Licensing (extend), Audit
PaymentFailed 재시도 예정 실패 subscription_id, attempt_id, retry_number Notification, Audit
PaymentFailedFinal 4차 실패 → 자동 해지 subscription_id Licensing (downgrade), Notification, Audit
SubscriptionCanceled 사용자 해지 요청 subscription_id, cancel_at_period_end Notification, Audit
SubscriptionCanceledPeriodEnd period_end 도달 후 실제 해지 subscription_id Licensing (downgrade), Audit
SubscriptionSuspended 봇 강퇴 등으로 suspend subscription_id, reason Audit
SubscriptionResumed 재활성 subscription_id Audit
PlanUpgraded Plan 상향 subscription_id, old_plan, new_plan Licensing (upgrade), Notification, Audit
PlanDowngraded Plan 하향 subscription_id, old_plan, new_plan, effective_at Notification, Audit

Consumed

Source Event Action
BotKicked 해당 Guild 의 Subscription suspend
BotInstalled (재설치) suspended Subscription 이 있으면 resume 후보
GuildDeleted Subscription cancel
UserDeleted User 의 모든 BillingKey 삭제, Subscription suspend

Ports

Core 도메인이라 풀세트 Hexagonal.

Inbound (driving)

// engine/billing/port/service.go
type Service interface {
    // Billing key
    IssueBillingKey(ctx, userID, authKey) (*BillingKey, error)
    DeleteBillingKey(ctx, billingKeyID) error
    ListBillingKeys(ctx, userID) ([]*BillingKey, error)

    // Subscription lifecycle
    StartSubscription(ctx, payerUserID, guildID, planCode, billingKeyID) (*Subscription, error)
    CancelSubscription(ctx, subscriptionID) error
    ChangePlan(ctx, subscriptionID, newPlanCode) error

    // Recurring charge (asynq trigger)
    ProcessRecurringCharge(ctx, subscriptionID) error
    RetryFailedCharge(ctx, subscriptionID, retryNumber) error

    // Webhook
    HandleWebhook(ctx, event TossWebhookEvent) error

    // Suspension
    SuspendSubscription(ctx, subscriptionID, reason) error
    ResumeSubscription(ctx, subscriptionID) error
}

Outbound (driven)

// engine/billing/port/repository.go
type SubscriptionRepository interface {
    Insert(ctx, sub) error
    Update(ctx, sub) error
    GetByID(ctx, id) (*Subscription, error)
    GetActiveByGuildID(ctx, guildID) (*Subscription, error)
    ListActiveByUserID(ctx, userID) ([]*Subscription, error)
    ListDueForCharge(ctx, before time.Time) ([]*Subscription, error)
}

type BillingKeyRepository interface {
    Insert(ctx, key) error
    GetByID(ctx, id) (*BillingKey, error)
    ListActiveByUserID(ctx, userID) ([]*BillingKey, error)
    SoftDelete(ctx, id) error
}

type PaymentAttemptRepository interface {
    Insert(ctx, attempt) error
    Update(ctx, attempt) error
    GetByOrderID(ctx, orderID) (*PaymentAttempt, error)
}

type TossClient interface {
    IssueBillingKey(ctx, customerKey, authKey) (*IssueResult, error)
    Charge(ctx, billingKey, orderID, amount, customerKey, orderName) (*ChargeResult, error)
    CancelBillingKey(ctx, billingKey) error
}

type Crypto interface {
    Encrypt(ctx, plaintext []byte) (ciphertext, nonce []byte, err error)
    Decrypt(ctx, ciphertext, nonce []byte) (plaintext []byte, err error)
}

type EventPublisher interface {
    Publish(ctx, event DomainEvent) error
}

type Clock interface {
    Now() time.Time
    NextBillingTimeWithJitter(base time.Time) time.Time
}

App layer

type serviceImpl struct {
    subs      SubscriptionRepository
    keys      BillingKeyRepository
    attempts  PaymentAttemptRepository
    toss      TossClient
    crypto    Crypto
    events    EventPublisher
    clock     Clock
}

Adapters

  • Persistenceengine/billing/adapter/persistence/sqlc/
  • Tossengine/billing/adapter/toss/ — HTTP 클라이언트 + webhook parser
  • Cryptoengine/billing/adapter/crypto/ — AES-256-GCM wrapper (platform/crypto/)
  • Eventengine/billing/adapter/event/ — Outbox
  • Clockengine/billing/adapter/clock/ — jitter 포함 next_billing_at 계산

External IDs

Toss customerKey

User 식별자. Toss 에 저장되는 외부 키.

  • 형식: user_{uuid_v7}
  • 예: user_01J0XX...ABCDE
  • Discord User ID 를 직접 노출하지 않음

Toss orderId

결제 단위 식별자. 글로벌 유니크.

  • 형식: sub_{subscription_id}_{cycle_padded_3}_r{retry_number}
  • 예:
  • sub_01J0XX..._001_r0 — 구독의 첫 결제 (첫 시도)
  • sub_01J0XX..._002_r0 — 2회차 정기 결제 (첫 시도)
  • sub_01J0XX..._002_r1 — 2회차의 1차 재시도 (24h)
  • sub_01J0XX..._002_r2 — 2회차의 2차 재시도 (48h)
  • sub_01J0XX..._002_r3 — 2회차의 3차 재시도 (72h)

retry_number 가 매번 증가해야 Toss 가 중복 orderId 로 거부하지 않음.

Recurring charge workflow

Cron 스케줄링

sequenceDiagram
    participant Cron as asynq cron (per hour)
    participant SubRepo as Subscription Repo
    participant Worker as charge worker
    participant Toss
    participant DB
    participant Outbox

    Cron->>SubRepo: ListDueForCharge(now + 1h)
    SubRepo-->>Cron: subscriptions due
    loop for each subscription
        Cron->>Worker: Enqueue ProcessRecurringCharge(sub_id)
    end
    Worker->>Toss: Charge (with jitter delay)
    alt Success
        Worker->>DB: INSERT PaymentAttempt (succeeded)
        Worker->>DB: UPDATE Subscription (period rollover, retry_count=0)
        Worker->>Outbox: PaymentSucceeded
    else Failure
        Worker->>DB: INSERT PaymentAttempt (failed)
        Worker->>DB: UPDATE Subscription (status=past_due, retry_count++)
        Worker->>Outbox: PaymentFailed
        Worker->>Cron: Enqueue RetryFailedCharge in 24h
    end

Retry schedule

  • 1차 실패 → 24h 후 재시도 (retry_number = 1)
  • 2차 실패 → 48h 후 재시도 (retry_number = 2)
  • 3차 실패 → 72h 후 재시도 (retry_number = 3)
  • 4차 실패 → 자동 해지, PaymentFailedFinal 이벤트

Grace period

결제 실패 중이어도 License 는 active 로 유지 (PastDue subscription, active license). Licensing 은 PaymentFailedFinal 이벤트 수신 시점에만 Free 로 downgrade.

Plan change

Upgrade (Free → Pro / Pro → Enterprise)

  • 사용자 요청 즉시 PlanUpgraded 이벤트
  • 현재 주기 비례 환불 없음 — 다음 결제부터 신규 가격
  • 즉시 상향 플랜 혜택 사용 가능 (Licensing 이 즉시 반영)

Downgrade (Enterprise → Pro / Pro → Free)

  • cancel_at_period_end = true 와 유사하게 period_end 까지 현재 플랜 유지
  • period_end 도달 시 실제 downgrade 적용
  • Free 다운그레이드는 Subscription 종료 (새 구독 필요)

Cancel

  • cancel_at_period_end = true 설정
  • period_end 까지 현재 플랜 혜택 유지
  • period_end 도달 시 SubscriptionCanceledPeriodEnd → Licensing Free 로 downgrade

Webhook handling

Toss webhook 수신 흐름

  1. apps/apiPOST /webhooks/toss 수신
  2. HMAC 서명 검증 (Toss 시크릿 기반)
  3. Redis idem:webhook:{event_id} SET NX 로 idempotency 체크
  4. 이벤트 타입별 Billing service 호출:
  5. PAYMENT.DONE → 보조 검증 (이미 active 인지 재확인)
  6. PAYMENT.CANCELED → Subscription 영향 반영
  7. BILLING_KEY.DELETED → BillingKey 삭제 처리
  8. 2xx 응답

핵심: 결제 성공/실패 판단은 Toss Charge 응답을 일차 source 로 사용. Webhook 은 누락 대비 보조 확인.

Crypto design

BillingKey 는 AES-256-GCM 으로 암호화.

encrypted_key = AES-GCM(plaintext=billingKey, key=MEK, nonce=random_12_bytes, aad=customer_key)
  • MEK (Master Encryption Key) — 32 bytes, 환경변수 BILLING_KEY_ENCRYPTION_KEY (hex 또는 base64)
  • Nonce — 12 bytes, 매 암호화마다 새로 생성
  • AAD (Additional Authenticated Data) — customer_key 를 함께 인증 → 다른 User 레코드로 교체 공격 방지
  • Rotation — Phase 2 에서 KMS 전환 검토

Permission model

  • 빌링키 발급/삭제 — User 본인만
  • 구독 시작 — Guild 의 MANAGE_GUILD 권한자
  • Plan 변경 — Subscription 의 payer_user_id
  • 해지 — Subscription 의 payer_user_id

Guild 의 owner 가 바뀌어도 기존 Subscription 의 payer 는 유지 (ADR-0011).

Failure modes

  • Toss API 장애 — Charge 실패, PaymentAttempt failed 로 기록, 24h 후 재시도. 재시도 시 Toss 회복 안 됐으면 다시 failed
  • 중복 결제 시도 (Toss 가 orderId 중복 거부) — 재시도 시 retry_number 증가로 회피
  • 빌링키 만료/정지 — Toss 가 실패 응답, 사용자에게 재등록 요구
  • 웹훅 서명 검증 실패 — 401 반환, 공격 의심 로그
  • 암호화 키 변경 — 과거 BillingKey 복호화 불가 → migration 계획 필수
  • Subscription race — 동시에 같은 Guild 에 subscribe 시도 → DB UNIQUE partial index 가 거부
  • User 탈퇴 시 — BillingKey 삭제 + Subscription suspend. 결제 중단.
  • DB 와 Toss 상태 불일치 — Webhook 으로 보정, 의심 시 manual review cron 생성

See also

  • data/billing-schema.md — DB 스키마
  • domain/licensing.md — 결제-권한 분리
  • flows/first-subscription.md — 첫 구독 흐름
  • flows/recurring-payment.md — 정기 결제
  • flows/payment-failure.md — 실패 및 자동 해지
  • flows/subscription-change.md — Plan 변경
  • adr/0011-hybrid-license-model.md
  • adr/0012-license-subscription-separation.md
  • adr/0013-payment-toss-billing.md
  • adr/0022-jitter-payment-time.md
  • adr/0019-hexagonal-pragmatic.md