Licensing¶
길드에 부여된 권한을 관리하는 Core 도메인. Plan 과 Feature 를 정의하고, "이 길드가 이 기능을 사용할 수 있는가" 를 판단하는 단일 진실 공급원. Billing 과 분리된 lifecycle 을 가진다.
Bounded context¶
- Type — Core (full Hexagonal)
- Sibling contexts — Billing (결제 연동), Guild (적용 대상), Recovery (기능 게이팅)
- Location in codebase —
engine/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¶
- Persistence —
engine/licensing/adapter/persistence/sqlc/ - Event —
engine/licensing/adapter/event/(Outbox) - Cache —
engine/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 }
// ... 실제 로직
캐시 플로우:
- Redis
perm:guild:{guild_id}:feature:{feature}조회 - Hit → 값 반환
- Miss → DB 에서 License + Plan 조회, feature 포함 여부 계산, 캐시 (TTL 60s), 반환
- 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