콘텐츠로 이동

First Subscription (Free → Pro)

Free License 를 가진 길드가 Pro 플랜으로 전환되는 흐름. Toss 빌링키 발급, 첫 결제 실행, License 업그레이드, 기능 활성화(AntiNuke workflow 시작)까지 end-to-end.

Scenario

사용자가 대시보드에서 "Pro 로 업그레이드" 버튼을 클릭한다. Toss 결제 위젯이 열려 카드를 등록하고 빌링키가 발급된다. 서버가 첫 결제를 즉시 실행하고, 성공하면 License 가 Pro 로 상향되며 대시보드에 활성화 메시지가 표시된다.

Actors

  • User (Guild Admin) — Pro 구독을 시작하는 Discord User (결제 주체)
  • umbra-web — React SPA 대시보드
  • Toss Payment Widget — 클라이언트 SDK
  • umbra-api — 빌링키 발급, 구독 시작 엔드포인트
  • Billing domain — 빌링키 저장, Subscription 생성, Toss 결제 호출
  • Toss Server — 빌링키 발급, 결제 승인
  • Licensing domain — License 를 Free → Pro 상향
  • Recovery (AntiNuke) — Pro 활성화로 workflow 시작
  • Notification — 결제 성공 DM
  • Audit — 전 흐름 기록

Preconditions

  • User 가 Discord OAuth2 로 Umbra 에 로그인 (세션 유효)
  • Target Guild 의 MANAGE_GUILD 권한 보유
  • Guild 에 active Free License 존재
  • Guild 에 active/past_due subscription 없음
  • Toss 가맹 승인 완료, 환경변수에 secret 설정
  • 사용자에게 유효한 카드 있음

Postconditions

  • billing.billing_keys 에 암호화된 빌링키 row
  • billing.subscriptions 에 active Subscription
  • billing.payment_attempts 에 succeeded 레코드 (cycle=1, retry=0)
  • licensing.licenses 의 plan 이 PRO 로 업데이트, expires_at = current_period_end
  • events.outboxBillingKeyIssued, SubscriptionStarted, PaymentSucceeded, LicenseUpgraded 이벤트
  • AntiNuke workflow 시작 (Temporal)
  • 결제 성공 DM 발송

Sequence

sequenceDiagram
    participant User
    participant Web as umbra-web
    participant Widget as Toss Widget
    participant API as umbra-api
    participant Billing
    participant Toss
    participant Outbox
    participant Poller
    participant Licensing
    participant Temporal
    participant Notification

    User->>Web: Click "Pro 업그레이드"
    Web->>API: GET /api/v1/billing/prepare
?guild_id=G&plan=PRO API->>API: Check permission (MANAGE_GUILD) API-->>Web: { customer_key, order_name, amount } Web->>Widget: requestBillingAuth(customerKey, ...) Widget->>User: 카드 입력 UI User->>Widget: 카드 정보 입력 + 승인 Widget->>Toss: Validate card Toss-->>Widget: authKey Widget-->>Web: { authKey, customerKey } Web->>API: POST /api/v1/billing/confirm
{ authKey, guild_id, plan } API->>Billing: StartSubscription(userID, guildID, PRO, authKey) Billing->>Toss: POST /v1/billing/authorizations/issue
{ authKey, customerKey } Toss-->>Billing: { billingKey, card info } Billing->>Billing: Encrypt billingKey (AES-256-GCM) Billing->>Billing: BEGIN TX Billing->>Billing: INSERT billing.billing_keys Billing->>Billing: INSERT billing.subscriptions (status=pending) Billing->>Outbox: INSERT BillingKeyIssued event Billing->>Billing: COMMIT Billing->>Toss: POST /v1/billing/{billingKey}
(first charge)
orderId = sub_{id}_001_r0 Toss-->>Billing: { paymentKey, approvedAt } Billing->>Billing: BEGIN TX Billing->>Billing: INSERT payment_attempts (succeeded) Billing->>Billing: UPDATE subscriptions
status=active, cycle_count=1
current_period_end=+1month
next_billing_at=+1month ± jitter Billing->>Outbox: INSERT SubscriptionStarted event Billing->>Outbox: INSERT PaymentSucceeded event Billing->>Billing: COMMIT Billing-->>API: Subscription{id, status=active, next_billing_at} API-->>Web: Success response Web->>User: "Pro 활성화 완료!" Note over Poller: ~2s later Poller->>Outbox: Fetch unpublished Poller->>Licensing: OnSubscriptionStarted Licensing->>Licensing: UPGRADE license FREE → PRO
expires_at = current_period_end Licensing->>Outbox: INSERT LicenseUpgraded event Poller->>Temporal: StartAntiNukeWorkflow(guildID)
(Licensing triggers it via separate consumer) Poller->>Notification: OnSubscriptionStarted Notification->>User: DM "Pro 플랜이 활성화되었습니다"

Step-by-step

1. Prepare — 결제 준비 정보 조회

대시보드에서 업그레이드 버튼 클릭 시 GET /api/v1/billing/prepare 호출:

  • 권한 체크 (MANAGE_GUILD)
  • Plan 정보 조회 (가격, 주기)
  • customerKey 생성 — user_{uuid_v7}
  • orderName 생성 — "Umbra Pro 구독 - {Guild 이름}"

응답:

{
  "customer_key": "user_01J0XX...",
  "order_name": "Umbra Pro 구독 - My Guild",
  "amount": 9900,
  "toss_client_key": "test_ck_..."
}

2. Toss Widget 호출

프론트엔드가 @tosspayments/payment-sdk 로 빌링 위젯 호출:

const tossPayments = await loadTossPayments(clientKey)
await tossPayments.requestBillingAuth('카드', {
  customerKey,
  successUrl: `${origin}/billing/success`,
  failUrl: `${origin}/billing/fail`,
})

사용자가 카드 입력 완료 시 successUrl 로 리다이렉트 + authKey, customerKey 쿼리 파라미터 전달.

3. Confirm — 빌링키 발급 + 첫 결제

프론트엔드가 POST /api/v1/billing/confirm 호출:

{
  "auth_key": "...",
  "customer_key": "user_01J0XX...",
  "guild_id": "018f...",
  "plan_code": "PRO"
}

서버는 다음 순서로 처리:

3a. 빌링키 발급

POST https://api.tosspayments.com/v1/billing/authorizations/issue
Authorization: Basic base64(secretKey:)
Body: { authKey, customerKey }

응답:

{
  "billingKey": "bill-...",
  "cardCompany": "신한",
  "cardNumber": "****-****-****-1234",
  ...
}

3b. 암호화 + 트랜잭션 저장

BEGIN;
  INSERT INTO billing.billing_keys (...encrypted..., customer_key, card_last4='1234', ...);
  INSERT INTO billing.subscriptions (status='pending', license_id, billing_key_id, plan_id, ...);
  INSERT INTO events.outbox (event_type='BillingKeyIssued', ...);
COMMIT;

4. 첫 결제 (즉시 실행)

같은 API 호출 안에서 이어서:

POST https://api.tosspayments.com/v1/billing/{billingKey}
Authorization: Basic base64(secretKey:)
Body: {
  customerKey,
  amount: 9900,
  orderId: "sub_01J0XX..._001_r0",
  orderName: "Umbra Pro 구독 - My Guild"
}

성공 응답 시:

BEGIN;
  INSERT payment_attempts (status='succeeded', toss_payment_key, toss_approved_at, cycle=1, retry_number=0);
  UPDATE subscriptions
    SET status='active',
        cycle_count=1,
        current_period_start=NOW(),
        current_period_end=NOW()+INTERVAL '1 month',
        next_billing_at=NOW()+INTERVAL '1 month' + random_jitter,
        updated_at=NOW()
    WHERE id=$sub_id;
  INSERT events.outbox (event_type='SubscriptionStarted', ...);
  INSERT events.outbox (event_type='PaymentSucceeded', ...);
COMMIT;

5. Response → 사용자 피드백

API 가 200 응답 반환, 대시보드는 "Pro 활성화 완료" 토스트 + 현재 플랜 표시 갱신.

중요: 이 시점에 License 는 아직 Free 상태. 이벤트 기반 비동기 업데이트라 다음 step 필요.

6. Outbox poller → Licensing upgrade

약 2초 후 Worker Poller 가 SubscriptionStarted 이벤트 dispatch → Licensing handler:

func (h *LicensingHandler) OnSubscriptionStarted(ctx, event) error {
    license := h.repo.GetActiveByGuildID(ctx, event.GuildID)
    targetPlan := h.plans.GetByCode(ctx, event.PlanCode)  // PRO

    return h.svc.Upgrade(ctx, license.ID, targetPlan.ID, event.CurrentPeriodEnd)
    // Internally publishes LicenseUpgraded
}

7. AntiNuke workflow 시작

LicenseUpgraded 이벤트 구독자로 Recovery 도메인:

func (h *RecoveryHandler) OnLicenseUpgraded(ctx, event) error {
    plan := h.plans.GetByCode(ctx, event.NewPlanCode)
    if contains(plan.Features, "ANTINUKE_DETECT") {
        return h.antinukeSvc.StartWorkflow(ctx, event.GuildID)
    }
    return nil
}

Temporal client 가 길드당 AntiNuke workflow 인스턴스 시작. 이후 Bot 프로세스가 이벤트를 signal 로 전달.

8. 사용자 알림

Notification consumer 가 SubscriptionStarted 수신:

  • Template: subscription.started
  • Payload: plan name, amount, next_billing_at
  • Discord DM (preference payment_notifications 존중)

Failure cases

Toss 빌링키 발급 실패

  • When — authKey 만료, 카드 유효성 실패
  • Detection — Toss 가 4xx 반환
  • Response — API 가 400 에러, 빌링키/구독 INSERT 롤백 (트랜잭션 안 함)
  • User experience — "카드 인증 실패, 다시 시도해주세요"

첫 결제 실패

  • When — 빌링키 발급은 성공했으나 즉시 결제에서 카드 한도 초과 등
  • Detection — Toss 가 4xx
  • Response
  • Subscription status='canceled' 로 UPDATE
  • PaymentAttempt status='failed' 기록
  • BillingKey 는 유지 (재시도 가능)
  • User experience — "결제 실패: {Toss 메시지}. 다른 카드로 재시도." 다른 카드는 새 authKey 필요

이미 active subscription 존재 (race)

  • When — 사용자가 다른 탭에서 동시에 가입 시도
  • DetectionUNIQUE (guild_id) WHERE status IN ('active', 'past_due') 위반
  • Response — 409 Conflict, "이미 활성 구독이 있습니다"
  • User experience — 대시보드 새로고침 유도

Licensing upgrade 지연

  • When — Outbox poller 지연
  • Detection — 사용자가 Pro 기능을 바로 시도했는데 licensing.Can 이 false
  • Response — 대시보드가 "Pro 활성화 중..." 표시 (polling 또는 SSE)
  • User experience — 최대 수 초 지연 후 정상

Toss 응답 timeout

  • When — Toss API 가 30초 내 응답 안 함
  • Detection — HTTP timeout
  • Response
  • Subscription status='pending' 유지, PaymentAttempt status='pending'
  • 운영자 alert + manual reconciliation
  • Toss webhook PAYMENT.DONE 수신 시 보정
  • User experience — "결제 처리 중입니다. 곧 알림을 받으실 겁니다" 메시지

AntiNuke workflow 시작 실패

  • When — Temporal Server 장애
  • Detection — workflow start error
  • Response — 재시도 (asynq). License 업그레이드는 이미 완료되어 결제/권한에 영향 없음
  • User experience — 투명 (AntiNuke 는 약간 지연 후 활성화)

BillingKey 암호화 키 환경변수 누락

  • When — 배포 실수로 BILLING_KEY_ENCRYPTION_KEY 없음
  • Detection — 암호화 호출 시 panic
  • Response — 애플리케이션 시작 시점에 환경변수 validation 으로 조기 감지. 프로덕션은 절대 발생 안 해야 함.
  • User experience — 500 에러 (서비스 불가, 긴급 대응 필요)

Edge cases

Guild 소유자가 아닌 Admin 이 결제

  • Hybrid 모델(ADR-0011)에서 MANAGE_GUILD 권한자면 가입 가능
  • payer_user_id = 이 Admin, guild_id = Guild. Owner 가 바뀌어도 구독은 유지.

사용자가 여러 길드 결제 (같은 BillingKey 재사용)

  • 첫 구독에서 빌링키 발급 → 다른 길드 구독 시 "기존 카드 사용" 버튼 표시
  • 같은 billing_key_id 로 새 Subscription INSERT
  • Toss 호출 시 orderId 형식은 sub_{다른_subscription_id}_...

과거 구독 이력 있는 길드

  • 이전에 Pro 였다가 canceled 된 길드가 재구독
  • 이전 Subscription 은 canceled 상태로 유지 (이력)
  • 새 Subscription row 생성, License 는 같은 row 업데이트

연간 결제 (Phase 2)

MVP 는 월간만. 연간은 Phase 2 에서 추가. 이 경우 billing_cycle='yearly' + 12개월치 1회 결제.

환불 요청

MVP 는 환불 기능 미제공. Toss 대시보드에서 수동 환불 가능하나 Umbra 는 이를 인지하지 못함. Phase 2 에서 환불 API + webhook 처리 추가.

Involved domains

Domain Role in this flow
Billing 빌링키 발급, Subscription 생성, 결제 실행 (writer)
Licensing License Free → Pro 상향
Recovery (AntiNuke) workflow 시작
Notification 결제 성공 DM
Audit 전 흐름 기록

See also

  • domain/billing.md
  • domain/licensing.md
  • flows/recurring-payment.md — 다음 주기부터
  • flows/subscription-change.md — 이미 Pro 인 경우 Enterprise 전환
  • adr/0013-payment-toss-billing.md
  • adr/0022-jitter-payment-time.md