Subscription Change (Upgrade / Downgrade / Cancel)¶
이미 구독 중인 사용자가 Plan 을 변경하거나 해지하는 흐름. Upgrade 는 즉시 반영, Downgrade 와 Cancel 은
current_period_end까지 현재 플랜 유지 후 반영되는 Stripe 스타일 정책을 따른다.
Scenario¶
대시보드에서 사용자가 "Pro → Enterprise 업그레이드", "Enterprise → Pro 다운그레이드", 또는 "구독 해지" 를 요청한다. 요청 유형에 따라 Billing 이 즉시 반영 또는 예약 반영을 결정하고, Licensing 은 이벤트를 수신해 실제 권한을 조정한다.
Actors¶
- User (Payer) — Subscription 의
payer_user_id - umbra-web — 대시보드 UI
- umbra-api — 변경 요청 처리
- Billing domain — Subscription 상태 조정
- Licensing domain — Plan 변경 반영
- Notification — 변경 확인 DM
- Recovery — Plan 에 따른 기능 활성/비활성
Three scenarios¶
A. Upgrade — 즉시 반영¶
FREE → PRO, PRO → ENTERPRISE
- Plan 변경 즉시 적용
- 현재 주기 종료 시점까지 사용한 기존 플랜 대금 환불 없음 (no proration)
- 다음 결제부터 신규 플랜 가격
- 고급 기능 즉시 활성화 (예: Enterprise 의 AntiNuke auto-action opt-in 가능)
B. Downgrade — 기간 종료 시 반영¶
ENTERPRISE → PRO, PRO → FREE
cancel_at_period_end = true유사 구조. Subscription 은pending_downgrade_to필드에 대상 Plan 기록current_period_end까지 기존 플랜 혜택 유지- period_end 도달 시 Licensing 이 실제 downgrade 실행
- 즉시 요청으로 변경하는 경우는 없음 (이미 결제한 기간을 뺏지 않는다)
C. Cancel — 기간 종료 시 Free 로 downgrade¶
cancel_at_period_end = truecurrent_period_end까지 유지- period_end 도달 시 Subscription
canceled+ License Free 로 downgrade - 해지 예약 중에도 사용자가 취소 가능 ("해지 취소" 버튼)
Preconditions¶
- Subscription 이
active상태 (past_due 에서는 plan 변경 제한 — payment-failure 문서 참조) - User 가 Subscription 의
payer_user_id와 일치 - Target plan 이 다른 Plan (동일 plan 요청은 noop)
Postconditions (per scenario)¶
Upgrade (Pro → Enterprise)¶
- Subscription
plan_id = Enterprise.id - License
plan_id = Enterprise.id, 즉시 기능 반영 - 다음
next_billing_at에 Enterprise 가격으로 결제 (현재 주기는 청구 없음) PlanUpgraded이벤트 발행
Downgrade (Enterprise → Pro)¶
- Subscription
pending_downgrade_to = Pro.id,plan_id는 여전히 Enterprise - License
plan_id는 여전히 Enterprise - period_end 도달 시 별도 cron 이 처리 →
plan_id를 Pro 로 전환 PlanDowngraded이벤트 (effective_at = period_end)
Cancel¶
- Subscription
cancel_at_period_end = true - License
status='active',expires_at = current_period_end - period_end 도달 시 Subscription
canceled, License Free 로 downgrade SubscriptionCanceled이벤트 (사용자 요청 시점)SubscriptionCanceledPeriodEnd이벤트 (실제 종료 시점)
Sequence — Upgrade¶
sequenceDiagram
participant User
participant Web
participant API
participant Billing
participant Outbox
participant Poller
participant Licensing
participant Recovery
participant Notification
User->>Web: Click "Enterprise 업그레이드"
Web->>Web: 확인 다이얼로그
User->>Web: Confirm
Web->>API: POST /api/v1/subscriptions/{id}/change-plan
{ new_plan_code: "ENTERPRISE" }
API->>Billing: ChangePlan(subID, "ENTERPRISE", requestedByUser)
Billing->>Billing: Validate: payer, direction (upgrade)
Billing->>Billing: BEGIN TX
Billing->>Billing: UPDATE subscriptions
plan_id = Enterprise.id,
updated_at = NOW()
Billing->>Outbox: INSERT PlanUpgraded event
Billing->>Billing: COMMIT
Billing-->>API: { plan: "ENTERPRISE", effective_at: "now" }
API-->>Web: Success
Web->>User: "Enterprise 활성화 완료"
Poller->>Licensing: OnPlanUpgraded
Licensing->>Licensing: UPDATE licenses plan_id = Enterprise.id
Licensing->>Outbox: LicenseUpgraded
Poller->>Recovery: OnLicenseUpgraded
(AntiNuke auto-action 등 기능 준비)
Poller->>Notification: OnPlanUpgraded
DM "Enterprise 활성화"
Sequence — Downgrade¶
sequenceDiagram
participant User
participant API
participant Billing
participant Outbox
participant Poller
participant Licensing
participant Cron as asynq cron
(daily)
User->>API: POST change-plan { new_plan: "PRO" }
(from Enterprise)
API->>Billing: ChangePlan(subID, "PRO")
Billing->>Billing: BEGIN TX
Billing->>Billing: UPDATE subscriptions
pending_downgrade_to = Pro.id
(plan_id 는 그대로)
Billing->>Outbox: INSERT PlanDowngradeScheduled event
(effective_at = period_end)
Billing->>Billing: COMMIT
Billing-->>API: { plan: "ENTERPRISE", pending: "PRO", effective_at: period_end }
API-->>User: "Enterprise 는 {period_end} 까지 유지, 이후 Pro 로 변경됩니다"
Note over Cron: period_end 도달 이후 cron 또는
결제 성공 직전 event
Cron->>Billing: ApplyPendingDowngrade(sub)
Billing->>Billing: UPDATE subscriptions
plan_id = Pro.id, pending_downgrade_to = NULL
Billing->>Outbox: PlanDowngraded (effective)
Poller->>Licensing: OnPlanDowngraded
Licensing->>Licensing: UPDATE licenses plan_id = Pro.id
Licensing->>Outbox: LicenseDowngraded
Sequence — Cancel¶
sequenceDiagram
participant User
participant API
participant Billing
participant Outbox
participant Poller
participant Licensing
participant Notification
participant Cron
User->>API: POST /subscriptions/{id}/cancel
API->>Billing: CancelSubscription(subID)
Billing->>Billing: BEGIN TX
Billing->>Billing: UPDATE subscriptions
cancel_at_period_end = TRUE
Billing->>Outbox: INSERT SubscriptionCanceled event
(cancel_at_period_end=true)
Billing->>Billing: COMMIT
Billing-->>API: { canceled_at_period_end: true, active_until: period_end }
API-->>User: "구독이 {period_end} 에 해지됩니다"
Poller->>Notification: OnSubscriptionCanceled
DM "해지 예약 완료"
Note over Cron: period_end 도달
Cron->>Billing: FinalizeScheduledCancel(sub)
Billing->>Billing: UPDATE subscriptions status=canceled, canceled_at=NOW()
Billing->>Outbox: SubscriptionCanceledPeriodEnd
Poller->>Licensing: OnSubscriptionCanceledPeriodEnd
Licensing->>Licensing: DOWNGRADE to FREE
Licensing->>Outbox: LicenseDowngraded
Poller->>Notification: DM "구독이 종료되었습니다"
Step-by-step — Upgrade¶
1. 권한 체크¶
API 핸들러:
// apps/api/internal/handler/subscription_change.go
func (h *Handler) ChangePlan(c echo.Context) error {
userID := getUserID(c)
subID := c.Param("id")
var req struct{ NewPlanCode string }
c.Bind(&req)
sub, _ := h.billingSvc.GetSubscription(ctx, subID)
if sub.PayerUserID != userID {
return 403
}
if sub.Status != "active" {
return 409 "cannot change plan in current state"
}
return h.billingSvc.ChangePlan(ctx, subID, req.NewPlanCode, userID)
}
2. Direction 판별¶
Billing:
func (s *serviceImpl) ChangePlan(ctx, subID, newPlanCode, byUserID) error {
sub, _ := s.subs.GetByID(ctx, subID)
currentPlan, _ := s.plans.GetByID(ctx, sub.PlanID)
newPlan, _ := s.plans.GetByCode(ctx, newPlanCode)
direction := compareRank(currentPlan.Code, newPlan.Code)
switch direction {
case UP: return s.upgradePlan(ctx, sub, newPlan, byUserID)
case DOWN: return s.scheduleDowngrade(ctx, sub, newPlan, byUserID)
case SAME: return nil // noop
}
}
// Rank: FREE=0, PRO=1, ENTERPRISE=2
3. Upgrade — 즉시 반영¶
func (s *serviceImpl) upgradePlan(ctx, sub, newPlan, byUserID) error {
return s.tx.WithTx(ctx, func(tx TxRepos) error {
sub.PlanID = newPlan.ID
sub.UpdatedAt = time.Now()
tx.Subs.Update(ctx, sub)
return tx.Events.Publish(ctx, PlanUpgradedEvent{
SubscriptionID: sub.ID,
LicenseID: sub.LicenseID,
OldPlanCode: oldPlan.Code,
NewPlanCode: newPlan.Code,
EffectiveAt: time.Now(),
})
})
}
4. Licensing consumer¶
func (h *LicensingHandler) OnPlanUpgraded(ctx, event) error {
targetPlan, _ := h.plans.GetByCode(ctx, event.NewPlanCode)
return h.svc.Upgrade(ctx, event.LicenseID, targetPlan.ID)
}
License plan_id 즉시 변경. 권한 캐시 Invalidate(guild_id) 호출.
5. Recovery 등 다운스트림¶
LicenseUpgraded 이벤트 수신:
- Recovery: Pro → Enterprise 면 AntiNuke auto-action 선택 가능 상태로
- 대시보드: 추가 기능 표시
Step-by-step — Downgrade¶
1. 상태 마킹¶
func (s *serviceImpl) scheduleDowngrade(ctx, sub, newPlan, byUserID) error {
return s.tx.WithTx(ctx, func(tx TxRepos) error {
sub.PendingDowngradeTo = &newPlan.ID
sub.UpdatedAt = time.Now()
tx.Subs.Update(ctx, sub)
return tx.Events.Publish(ctx, PlanDowngradeScheduledEvent{
SubscriptionID: sub.ID,
LicenseID: sub.LicenseID,
OldPlanCode: currentPlan.Code,
NewPlanCode: newPlan.Code,
EffectiveAt: *sub.CurrentPeriodEnd,
})
})
}
2. period_end 도달 시 적용¶
Daily cron 이 pending_downgrade_to IS NOT NULL AND current_period_end <= NOW() 조건으로 scan:
func (s *serviceImpl) ApplyPendingDowngrade(ctx, subID) error {
sub, _ := s.subs.GetByID(ctx, subID)
if sub.PendingDowngradeTo == nil { return nil }
newPlan, _ := s.plans.GetByID(ctx, *sub.PendingDowngradeTo)
return s.tx.WithTx(ctx, func(tx TxRepos) error {
sub.PlanID = newPlan.ID
sub.PendingDowngradeTo = nil
tx.Subs.Update(ctx, sub)
return tx.Events.Publish(ctx, PlanDowngradedEvent{
SubscriptionID: sub.ID,
LicenseID: sub.LicenseID,
NewPlanCode: newPlan.Code,
EffectiveAt: time.Now(),
})
})
}
3. Downgrade to FREE 특수 케이스¶
PRO → FREE 는 effectively cancel. Billing 이 cancel 플로우로 리다이렉트:
즉 cancel_at_period_end = true 설정. 실질적으로 Cancel 시나리오와 동일.
Step-by-step — Cancel¶
1. 취소 예약¶
func (s *serviceImpl) CancelSubscription(ctx, subID, byUserID) error {
sub, _ := s.subs.GetByID(ctx, subID)
return s.tx.WithTx(ctx, func(tx TxRepos) error {
sub.CancelAtPeriodEnd = true
sub.UpdatedAt = time.Now()
tx.Subs.Update(ctx, sub)
return tx.Events.Publish(ctx, SubscriptionCanceledEvent{
SubscriptionID: sub.ID,
LicenseID: sub.LicenseID,
CancelAtPeriodEnd: true,
EffectiveAt: *sub.CurrentPeriodEnd,
RequestedByUser: byUserID,
})
})
}
2. 해지 예약 취소 (사용자가 마음 바꿈)¶
사용자가 period_end 전에 "해지 취소" 누르면:
func (s *serviceImpl) UncancelSubscription(ctx, subID, byUserID) error {
sub, _ := s.subs.GetByID(ctx, subID)
if !sub.CancelAtPeriodEnd { return nil }
return s.tx.WithTx(ctx, func(tx TxRepos) error {
sub.CancelAtPeriodEnd = false
tx.Subs.Update(ctx, sub)
return tx.Events.Publish(ctx, SubscriptionUncanceledEvent{...})
})
}
3. period_end 도달 시 최종 해지¶
cron:
func (s *serviceImpl) FinalizeScheduledCancel(ctx, subID) error {
sub, _ := s.subs.GetByID(ctx, subID)
if !sub.CancelAtPeriodEnd { return nil }
if time.Now().Before(*sub.CurrentPeriodEnd) { return nil }
return s.tx.WithTx(ctx, func(tx TxRepos) error {
sub.Status = "canceled"
sub.CanceledAt = ptr(time.Now())
sub.NextBillingAt = nil
tx.Subs.Update(ctx, sub)
return tx.Events.Publish(ctx, SubscriptionCanceledPeriodEndEvent{
SubscriptionID: sub.ID,
LicenseID: sub.LicenseID,
})
})
}
4. License → Free¶
Licensing:
func (h *LicensingHandler) OnSubscriptionCanceledPeriodEnd(ctx, event) error {
freePlan, _ := h.plans.GetByCode(ctx, "FREE")
return h.svc.Downgrade(ctx, event.LicenseID, freePlan.ID)
}
Billing cycle behavior¶
Upgrade 후 결제¶
- 다음
next_billing_at에 신규 가격 charge - 현재 주기는 무료 혜택 (이미 결제한 금액 유효)
- 예: Pro(₩9,900) → Enterprise(custom) 업그레이드 후 다음 결제는 Enterprise 금액
Downgrade 후 결제¶
period_end까지 Enterprise 유지, 이후 결제부터 Pro 금액- 예: Pro(₩9,900) → 2026-05-01 에
scheduleDowngrade→ period_end 2026-05-15 → 2026-05-15 부터 Free. 다음 결제는 없음 (Free 는 무결제) - Enterprise → Pro 의 경우 period_end 에 Enterprise 종료, 그 시점부터 Pro 결제 주기 시작
Cancel 후 결제¶
cancel_at_period_end = true상태에서는 재결제 없음next_billing_at이 있더라도 cron 에서 skip (canceled 또는 cancel_at_period_end 체크)
Failure cases¶
권한 없는 사용자의 plan 변경¶
- When — payer 가 아닌 Guild 의 다른 관리자가 요청
- Detection —
sub.PayerUserID != userID - Response — 403 Forbidden, "결제 수단 소유자만 변경 가능"
past_due 상태에서 plan 변경 시도¶
- When — 결제 실패 중
- Detection —
sub.Status != 'active' - Response — 409 Conflict, "결제 문제를 먼저 해결해주세요"
- User experience — 빌링키 재등록 또는 즉시 재결제 후 변경 가능
Upgrade 즉시 결제 실패¶
- 이 flow 에서는 별도 결제 호출 없음 (다음
next_billing_at에 결제) - Upgrade 자체는 성공, 실패는 다음 결제에서 일반 failure flow 로 처리
Downgrade 스케줄됐는데 period_end 전에 cancel¶
- When — Enterprise → Pro 스케줄 중에 "구독 해지" 요청
- Detection —
pending_downgrade_to NOT NULL AND cancel_at_period_end요청 - Response — Cancel 우선.
pending_downgrade_to는 의미 없음 (어차피 canceled 로 진행) - User experience — "예약된 Pro 전환 대신 구독이 완전히 종료됩니다"
Cancel 후 uncancel, 그 후 다시 cancel¶
- 각 요청마다
cancel_at_period_end토글 - 이벤트는 매번 발행 (Audit 에 이력 남음)
period_end 도달 시 cron 실행 지연¶
- When — Worker 장애
- Detection —
current_period_end <= NOW()인데status != 'canceled' - Response — cron 복구 후 즉시 처리. 사용자 체감 지연 수 분~수십 분
- Mitigation — License
expires_at이 지나도 권한 체크 시WHERE expires_at > NOW()조건으로 자연스럽게 차단됨 (grace 없이)
Licensing 이벤트 누락¶
- Licensing handler 가 idempotent → 누락 시 재전달로 복구
- License 실제
plan_id가 Subscription 과 일시적 불일치 가능하지만 캐시 TTL(60s) 내 복구
Edge cases¶
사용자가 Enterprise 지만 auto-action opt-out¶
- AntiNuke auto-action 은 Enterprise 기능이지만 opt-in. Plan 변경으로 Enterprise 된 직후에도 기본 off.
- Enterprise → Pro downgrade 시 auto-action 자동 off (DB constraint / licensing reader 에서 강제)
빌링키 없이 Enterprise 업그레이드¶
- 이미 active subscription 있으면 billing_key_id 존재 (첫 구독 시 발급됨)
- 예외 상황 (DB 일관성 깨짐) 시 422 반환
Enterprise 커스텀 가격 협의 중¶
- MVP 는 Enterprise 도 고정 가격 가정 (
price_krw있음) - Phase 2 에서 per-guild 커스텀 가격 테이블 도입 검토
연간 결제 변경 (Phase 2)¶
- monthly → yearly 는 upgrade 성격 (선지불 할인). 단 즉시 결제 발생 시나리오.
- Phase 2 에서 별도 flow 문서화.
Involved domains¶
| Domain | Role |
|---|---|
| Billing | Subscription 상태 전이 (writer) |
| Licensing | License plan 변경 반영 |
| Recovery | Plan 별 기능 on/off |
| Notification | 변경 확인 DM |
| Audit | 이력 기록 |
See also¶
domain/billing.mddomain/licensing.mdflows/recurring-payment.md— 정상 결제flows/payment-failure.md— 실패 처리adr/0012-license-subscription-separation.md— 두 도메인 독립 lifecycleadr/0011-hybrid-license-model.md— Hybrid 모델