콘텐츠로 이동

Licensing

길드에 부여된 권한을 관리하는 Core 도메인. Plan 과 Feature 를 정의하고, "이 길드가 이 기능을 사용할 수 있는가" 를 판단하는 단일 진실 공급원. Billing 과 분리된 lifecycle 을 가진다.

Bounded context

  • TypeCore (full Hexagonal)
  • Sibling contexts — Billing (결제 연동), Guild (적용 대상), Recovery (기능 게이팅)
  • Location in codebaseengine/licensing/

Why this domain exists

Umbra 의 플랜 시스템은 권한 부여와 결제 수금을 별개 lifecycle 로 다루어야 한다 (ADR-0012). 예를 들어:

  • Free Plan 은 License 만 존재, Subscription 없음
  • 결제 실패 시 Grace period 동안 License 는 active, Subscription 은 past_due
  • 해지 요청 시 current_period_end 까지 License 는 유지, Subscription 은 canceled
  • 봇 강퇴 시 License 는 suspend, Billing 은 별도로 suspend

이 차이를 하나의 entity 로 뭉치면 상태 표현이 불분명해진다. Licensing 은 "권한 부여" 에만 집중하며, 실제 결제 상태는 Billing 이 담당한다.

동시에 Licensing 은 Umbra 전체의 권한 체크 단일 진입점 이다. "이 요청이 허용되는가" 질문의 답변자.

Domain model

Plan

플랜 정의. 마스터 데이터.

Plan
├─ id                   UUID v7   PK
├─ code                 TEXT      'FREE' | 'PRO' | 'ENTERPRISE', UNIQUE
├─ name                 TEXT      표시명
├─ price_krw            INTEGER   nullable (Enterprise 는 custom)
├─ billing_cycle        TEXT      'monthly' | 'yearly' | null
├─ features             TEXT[]    포함된 Feature 코드 목록
├─ limits               JSONB     { member_db: 50, restore_points: 0, ... }
├─ is_active            BOOLEAN
└─ created_at           TIMESTAMPTZ

Feature

기능 단위. 권한 체크 대상.

Feature (상수 정의, DB 테이블 없음)
  DASHBOARD
  RECOVERY_LIVE_SYNC
  RECOVERY_SNAPSHOT_MANUAL
  RECOVERY_SNAPSHOT_SCHEDULED
  RECOVERY_RESTORE
  RECOVERY_RESTORE_POINTS_MULTIPLE
  ANTINUKE_DETECT
  ANTINUKE_AUTO_ACTION
  WEB_JOIN
  MEMBER_DB_UP_TO_50
  MEMBER_DB_UP_TO_500
  MEMBER_DB_UNLIMITED

Feature 는 코드 상수 (engine/licensing/domain/features.go) 로 정의.

License

특정 길드에 부여된 권한.

License
├─ id                   UUID v7   PK
├─ guild_id             UUID      → guild.guilds.id, UNIQUE WHERE status IN ('active', 'suspended')
├─ plan_id              UUID      → plans.id
├─ status               TEXT      'active' | 'suspended' | 'canceled'
├─ granted_at           TIMESTAMPTZ
├─ expires_at           TIMESTAMPTZ  nullable (Free 는 무기한)
├─ suspended_at         TIMESTAMPTZ
├─ suspended_reason     TEXT
├─ canceled_at          TIMESTAMPTZ
├─ created_at           TIMESTAMPTZ
└─ updated_at           TIMESTAMPTZ

Aggregates

  • License — root
  • Plan — 마스터 데이터, 독립 aggregate

Invariants

  • Guild 당 active/suspended License 1개UNIQUE (guild_id) WHERE status IN ('active', 'suspended')
  • Free License 는 expires_at NULL (무기한)
  • Paid License 는 expires_at NOT NULL
  • suspended_at, canceled_at 은 status 와 동기
  • Plan code 불변FREE/PRO/ENTERPRISE 코드는 영구 유지 (외부 참조됨)

State machine

stateDiagram-v2
    [*] --> Active : Granted (bot install / payment success)
    Active --> Suspended : Bot kicked / payment failed final
    Suspended --> Active : Bot reinstalled / payment resumed
    Active --> Canceled : User cancels + period ended
    Suspended --> Canceled : Reinstall period expired
    Canceled --> [*]
  • Active — 권한 정상 사용 가능
  • Suspended — 일시 정지, 재활성 가능 (봇 재설치, 결제 재개)
  • Canceled — 영구 해지, 새 License 를 생성해야 함

Domain events

Published

Event Trigger Payload Subscribers
LicenseGranted 신규 License 생성 license_id, guild_id, plan_code Audit
LicenseExtended expires_at 연장 (결제 성공 반영) license_id, new_expires_at Audit
LicenseUpgraded Plan 상향 license_id, old_plan, new_plan Notification, Audit
LicenseDowngraded Plan 하향 (결제 실패 → Free) license_id, old_plan, new_plan Notification, Audit
LicenseSuspended suspend 전환 license_id, reason Notification, Audit
LicenseResumed suspend → active 복귀 license_id Audit
LicenseCanceled 영구 해지 license_id Audit

Consumed

Source Event Action
BotInstalled Free License 자동 생성
BotKicked License suspend
GuildDeleted License cancel
SubscriptionStarted Free → Pro/Enterprise 업그레이드, expires_at 설정
PaymentSucceeded expires_at 연장 (GREATEST(current, new))
PaymentFailedFinal License Free 로 downgrade
SubscriptionCanceled (period_end 도달) License Free 로 downgrade
PlanUpgraded License plan 업데이트, 즉시 반영
PlanDowngraded License plan 업데이트, period_end 이후 적용

Ports

Core 도메인이라 풀세트 Hexagonal 구조.

Inbound (driving)

// engine/licensing/port/service.go
type Service interface {
    Grant(ctx, guildID, planCode, expiresAt) (*License, error)
    Extend(ctx, licenseID, newExpiresAt) error
    Upgrade(ctx, licenseID, newPlanCode) error
    Downgrade(ctx, licenseID, newPlanCode) error
    Suspend(ctx, licenseID, reason) error
    Resume(ctx, licenseID) error
    Cancel(ctx, licenseID) error

    // 권한 체크 (단일 진입점)
    Can(ctx, guildID, feature Feature) (bool, error)
    GetActiveLicense(ctx, guildID) (*License, *Plan, error)
}

Outbound (driven)

// engine/licensing/port/repository.go
type LicenseRepository interface {
    Insert(ctx, license) error
    Update(ctx, license) error
    GetByID(ctx, licenseID) (*License, error)
    GetActiveByGuildID(ctx, guildID) (*License, error)
}

type PlanRepository interface {
    GetByCode(ctx, code PlanCode) (*Plan, error)
    ListActive(ctx) ([]*Plan, error)
}

type EventPublisher interface {
    Publish(ctx, event DomainEvent) error  // Outbox
}

type PermissionCache interface {
    Get(ctx, guildID, feature) (bool, bool, error)  // value, hit, error
    Set(ctx, guildID, feature, value) error
    Invalidate(ctx, guildID) error
}

App layer

// engine/licensing/app/service_impl.go
type serviceImpl struct {
    licenses LicenseRepository
    plans    PlanRepository
    events   EventPublisher
    cache    PermissionCache
}

Adapters

  • Persistenceengine/licensing/adapter/persistence/sqlc/
  • Eventengine/licensing/adapter/event/ (Outbox)
  • Cacheengine/licensing/adapter/cache/ (Redis, TTL 60s)

Permission check flow

Umbra 의 모든 핸들러가 권한 체크 시 Licensing 경유:

// apps/api/internal/handler/restore.go
allowed, err := h.licensing.Can(ctx, guildID, licensing.FeatureRecoveryRestore)
if err != nil { return err }
if !allowed { return ErrPermissionDenied }
// ... 실제 로직

캐시 플로우:

  1. Redis perm:guild:{guild_id}:feature:{feature} 조회
  2. Hit → 값 반환
  3. Miss → DB 에서 License + Plan 조회, feature 포함 여부 계산, 캐시 (TTL 60s), 반환
  4. License 상태 변경 시 Invalidate 호출 (guild 전체)

Feature catalog

Plan 별 Feature 매핑:

Feature Free Pro Enterprise
DASHBOARD
WEB_JOIN
RECOVERY_LIVE_SYNC
RECOVERY_SNAPSHOT_MANUAL ✅ (1) ✅ (3)
RECOVERY_SNAPSHOT_SCHEDULED ✅ (7d) ✅ (30d)
RECOVERY_RESTORE
RECOVERY_RESTORE_POINTS_MULTIPLE
ANTINUKE_DETECT
ANTINUKE_AUTO_ACTION ✅ (opt-in)
MEMBER_DB_UP_TO_50
MEMBER_DB_UP_TO_500
MEMBER_DB_UNLIMITED

숫자는 limits 에 저장 (e.g. snapshot_manual_max: 3).

Plan transitions

Upgrade (Free → Pro → Enterprise)

  • 즉시 반영 (현재 기간 비례 계산 없음)
  • 다음 사이클부터 신규 플랜 가격
  • 혜택 즉시 사용 가능 (예: Pro 업그레이드 즉시 Restore 사용 가능)

Downgrade (Enterprise → Pro → Free)

  • period_end 까지 기존 혜택 유지 (이미 결제한 만큼)
  • period_end 도달 시 실제 Plan 변경
  • Free 로 내려가면 License 는 유지되고 expires_at 만 NULL 로 설정

Cancel

  • cancel_at_period_end = true 설정 (Billing 측)
  • period_end 도달 시 Licensing 이 Free 로 downgrade

Failure modes

  • Plan 코드 불일치 — Event payload 의 plan_code 가 DB 에 없으면 error + manual intervention
  • Race: 동시 Grant 시도UNIQUE (guild_id) WHERE status IN ('active', 'suspended') 로 DB 가 거부
  • 캐시 TTL 중 Plan 변경 — 60초 최대 지연. 변경 시 Invalidate 즉시 호출로 완화
  • Event 순서 역전 — Outbox 는 BIGSERIAL 순서 보장 (단일 poller), License 업데이트는 GREATEST 로 idempotent

See also

  • data/licensing-schema.md — DB 스키마
  • domain/billing.md — 결제 연동
  • architecture/context-map.md — License 권한 체크 흐름
  • adr/0011-hybrid-license-model.md — Hybrid 모델
  • adr/0012-license-subscription-separation.md — Licensing/Billing 분리
  • adr/0019-hexagonal-pragmatic.md — Core 도메인 Hexagonal