Billing¶
결제를 담당하는 Core 도메인. Toss Payments 의 빌링키를 발급하고, 정기 결제를 실행하며, 실패 시 재시도와 자동 해지를 관리한다. Licensing 과 분리된 lifecycle 을 가지며 Outbox 이벤트로만 동기화된다.
Bounded context¶
- Type — Core (full Hexagonal)
- Sibling contexts — Licensing (권한 연동), Identity (Payer = User), Guild (결제 대상 = Guild), Webhook (Toss 콜백)
- Location in codebase —
engine/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¶
- Persistence —
engine/billing/adapter/persistence/sqlc/ - Toss —
engine/billing/adapter/toss/— HTTP 클라이언트 + webhook parser - Crypto —
engine/billing/adapter/crypto/— AES-256-GCM wrapper (platform/crypto/) - Event —
engine/billing/adapter/event/— Outbox - Clock —
engine/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 수신 흐름¶
apps/api가POST /webhooks/toss수신- HMAC 서명 검증 (Toss 시크릿 기반)
- Redis
idem:webhook:{event_id}SET NX로 idempotency 체크 - 이벤트 타입별 Billing service 호출:
PAYMENT.DONE→ 보조 검증 (이미 active 인지 재확인)PAYMENT.CANCELED→ Subscription 영향 반영BILLING_KEY.DELETED→ BillingKey 삭제 처리- 2xx 응답
핵심: 결제 성공/실패 판단은 Toss Charge 응답을 일차 source 로 사용. Webhook 은 누락 대비 보조 확인.
Crypto design¶
BillingKey 는 AES-256-GCM 으로 암호화.
- 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
UNIQUEpartial 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.mdadr/0012-license-subscription-separation.mdadr/0013-payment-toss-billing.mdadr/0022-jitter-payment-time.mdadr/0019-hexagonal-pragmatic.md