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에 암호화된 빌링키 rowbilling.subscriptions에 active Subscriptionbilling.payment_attempts에 succeeded 레코드 (cycle=1, retry=0)licensing.licenses의 plan 이 PRO 로 업데이트,expires_at= current_period_endevents.outbox에BillingKeyIssued,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 호출:
서버는 다음 순서로 처리:
3a. 빌링키 발급
POST https://api.tosspayments.com/v1/billing/authorizations/issue
Authorization: Basic base64(secretKey:)
Body: { authKey, customerKey }
응답:
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 — 사용자가 다른 탭에서 동시에 가입 시도
- Detection —
UNIQUE (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'유지, PaymentAttemptstatus='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.mddomain/licensing.mdflows/recurring-payment.md— 다음 주기부터flows/subscription-change.md— 이미 Pro 인 경우 Enterprise 전환adr/0013-payment-toss-billing.mdadr/0022-jitter-payment-time.md