콘텐츠로 이동

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 = true
  • current_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 플로우로 리다이렉트:

if direction == DOWN && newPlan.Code == "FREE" {
    return s.cancelAtPeriodEnd(ctx, sub, byUserID)
}

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 의 다른 관리자가 요청
  • Detectionsub.PayerUserID != userID
  • Response — 403 Forbidden, "결제 수단 소유자만 변경 가능"

past_due 상태에서 plan 변경 시도

  • When — 결제 실패 중
  • Detectionsub.Status != 'active'
  • Response — 409 Conflict, "결제 문제를 먼저 해결해주세요"
  • User experience — 빌링키 재등록 또는 즉시 재결제 후 변경 가능

Upgrade 즉시 결제 실패

  • 이 flow 에서는 별도 결제 호출 없음 (다음 next_billing_at 에 결제)
  • Upgrade 자체는 성공, 실패는 다음 결제에서 일반 failure flow 로 처리

Downgrade 스케줄됐는데 period_end 전에 cancel

  • When — Enterprise → Pro 스케줄 중에 "구독 해지" 요청
  • Detectionpending_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 장애
  • Detectioncurrent_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.md
  • domain/licensing.md
  • flows/recurring-payment.md — 정상 결제
  • flows/payment-failure.md — 실패 처리
  • adr/0012-license-subscription-separation.md — 두 도메인 독립 lifecycle
  • adr/0011-hybrid-license-model.md — Hybrid 모델