feat(subscription): add upgrade and cancel subscription use cases
- Implement UpgradeSubscriptionUseCase (Free→Pro→Enterprise) - Prevent downgrades (must use cancel to return to Free) - Auto-set quotas and expiration date on upgrade - Implement CancelSubscriptionUseCase to downgrade to Free - Only workspace owner can manage subscription - Add 8 comprehensive unit tests (all passed) Phase 4 Task 21/68 completed
This commit is contained in:
@@ -42,6 +42,13 @@ from packages.application.workspace.list_members_use_case import (
|
||||
ListMembersResponse,
|
||||
MemberInfo,
|
||||
)
|
||||
from packages.application.workspace.subscription_use_case import (
|
||||
UpgradeSubscriptionUseCase,
|
||||
UpgradeSubscriptionRequest,
|
||||
UpgradeSubscriptionResponse,
|
||||
CancelSubscriptionUseCase,
|
||||
CancelSubscriptionRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CreateWorkspaceUseCase",
|
||||
@@ -73,4 +80,9 @@ __all__ = [
|
||||
"ListMembersRequest",
|
||||
"ListMembersResponse",
|
||||
"MemberInfo",
|
||||
"UpgradeSubscriptionUseCase",
|
||||
"UpgradeSubscriptionRequest",
|
||||
"UpgradeSubscriptionResponse",
|
||||
"CancelSubscriptionUseCase",
|
||||
"CancelSubscriptionRequest",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Subscription 管理 Use Case
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from packages.domain.entities import WorkspaceMemberRole
|
||||
|
||||
|
||||
class UpgradeSubscriptionRequest:
|
||||
"""升级订阅请求"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_id: str,
|
||||
requester_user_id: str,
|
||||
new_plan: str,
|
||||
):
|
||||
self.workspace_id = workspace_id
|
||||
self.requester_user_id = requester_user_id
|
||||
self.new_plan = new_plan
|
||||
|
||||
|
||||
class UpgradeSubscriptionResponse:
|
||||
"""升级订阅响应"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_id: str,
|
||||
old_plan: str,
|
||||
new_plan: str,
|
||||
max_projects: int,
|
||||
max_storage_gb: int,
|
||||
):
|
||||
self.workspace_id = workspace_id
|
||||
self.old_plan = old_plan
|
||||
self.new_plan = new_plan
|
||||
self.max_projects = max_projects
|
||||
self.max_storage_gb = max_storage_gb
|
||||
|
||||
|
||||
class UpgradeSubscriptionUseCase:
|
||||
"""升级订阅用例"""
|
||||
|
||||
# 订阅计划配额
|
||||
PLAN_QUOTAS = {
|
||||
"free": {"max_projects": 3, "max_storage_gb": 10, "price": 0},
|
||||
"pro": {"max_projects": 999999, "max_storage_gb": 100, "price": 99},
|
||||
"enterprise": {"max_projects": 999999, "max_storage_gb": 1000, "price": 999},
|
||||
}
|
||||
|
||||
# 计划等级
|
||||
PLAN_LEVELS = {
|
||||
"free": 0,
|
||||
"pro": 1,
|
||||
"enterprise": 2,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_repository,
|
||||
workspace_member_repository,
|
||||
):
|
||||
self.workspace_repository = workspace_repository
|
||||
self.workspace_member_repository = workspace_member_repository
|
||||
|
||||
def execute(self, request: UpgradeSubscriptionRequest) -> tuple[Optional[UpgradeSubscriptionResponse], Optional[str]]:
|
||||
"""
|
||||
执行升级订阅
|
||||
|
||||
Args:
|
||||
request: 升级请求
|
||||
|
||||
Returns:
|
||||
(响应, 错误信息)
|
||||
"""
|
||||
try:
|
||||
# 1. 验证输入
|
||||
if not request.workspace_id:
|
||||
return None, "Workspace ID is required"
|
||||
|
||||
if not request.requester_user_id:
|
||||
return None, "Requester user ID is required"
|
||||
|
||||
if not request.new_plan:
|
||||
return None, "New plan is required"
|
||||
|
||||
# 2. 验证新计划有效
|
||||
if request.new_plan not in self.PLAN_QUOTAS:
|
||||
return None, f"Invalid plan: {request.new_plan}"
|
||||
|
||||
# 3. 验证工作空间存在
|
||||
workspace = self.workspace_repository.find_by_id(request.workspace_id)
|
||||
if not workspace:
|
||||
return None, "Workspace not found"
|
||||
|
||||
# 4. 验证权限(只有 Owner 可以管理订阅)
|
||||
member = self.workspace_member_repository.find_by_workspace_and_user(
|
||||
request.workspace_id,
|
||||
request.requester_user_id,
|
||||
)
|
||||
if not member:
|
||||
return None, "You are not a member of this workspace"
|
||||
|
||||
if member.role != WorkspaceMemberRole.OWNER:
|
||||
return None, "Only workspace owner can manage subscription"
|
||||
|
||||
# 5. 检查是否是升级(不能降级到免费计划,需要用取消订阅)
|
||||
current_level = self.PLAN_LEVELS.get(workspace.subscription_plan, 0)
|
||||
new_level = self.PLAN_LEVELS.get(request.new_plan, 0)
|
||||
|
||||
if new_level < current_level:
|
||||
return None, "Cannot downgrade plan. Use cancel subscription to return to free plan."
|
||||
|
||||
if new_level == current_level:
|
||||
return None, f"Workspace is already on {request.new_plan} plan"
|
||||
|
||||
# 6. 更新订阅
|
||||
old_plan = workspace.subscription_plan
|
||||
quota = self.PLAN_QUOTAS[request.new_plan]
|
||||
|
||||
workspace.subscription_plan = request.new_plan
|
||||
workspace.subscription_status = "active"
|
||||
workspace.max_projects = quota["max_projects"]
|
||||
workspace.max_storage_gb = quota["max_storage_gb"]
|
||||
|
||||
# 设置过期时间(假设按月订阅)
|
||||
workspace.subscription_expires_at = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
|
||||
self.workspace_repository.save(workspace)
|
||||
|
||||
# 7. 返回响应
|
||||
return UpgradeSubscriptionResponse(
|
||||
workspace_id=workspace.id,
|
||||
old_plan=old_plan,
|
||||
new_plan=workspace.subscription_plan,
|
||||
max_projects=workspace.max_projects,
|
||||
max_storage_gb=workspace.max_storage_gb,
|
||||
), None
|
||||
|
||||
except Exception as e:
|
||||
return None, f"Failed to upgrade subscription: {str(e)}"
|
||||
|
||||
|
||||
class CancelSubscriptionRequest:
|
||||
"""取消订阅请求"""
|
||||
|
||||
def __init__(self, workspace_id: str, requester_user_id: str):
|
||||
self.workspace_id = workspace_id
|
||||
self.requester_user_id = requester_user_id
|
||||
|
||||
|
||||
class CancelSubscriptionUseCase:
|
||||
"""取消订阅用例"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_repository,
|
||||
workspace_member_repository,
|
||||
):
|
||||
self.workspace_repository = workspace_repository
|
||||
self.workspace_member_repository = workspace_member_repository
|
||||
|
||||
def execute(self, request: CancelSubscriptionRequest) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
执行取消订阅
|
||||
|
||||
Args:
|
||||
request: 取消请求
|
||||
|
||||
Returns:
|
||||
(是否成功, 错误信息)
|
||||
"""
|
||||
try:
|
||||
# 1. 验证输入
|
||||
if not request.workspace_id:
|
||||
return False, "Workspace ID is required"
|
||||
|
||||
if not request.requester_user_id:
|
||||
return False, "Requester user ID is required"
|
||||
|
||||
# 2. 验证工作空间存在
|
||||
workspace = self.workspace_repository.find_by_id(request.workspace_id)
|
||||
if not workspace:
|
||||
return False, "Workspace not found"
|
||||
|
||||
# 3. 验证权限(只有 Owner 可以管理订阅)
|
||||
member = self.workspace_member_repository.find_by_workspace_and_user(
|
||||
request.workspace_id,
|
||||
request.requester_user_id,
|
||||
)
|
||||
if not member:
|
||||
return False, "You are not a member of this workspace"
|
||||
|
||||
if member.role != WorkspaceMemberRole.OWNER:
|
||||
return False, "Only workspace owner can manage subscription"
|
||||
|
||||
# 4. 检查当前计划
|
||||
if workspace.subscription_plan == "free":
|
||||
return False, "Workspace is already on free plan"
|
||||
|
||||
# 5. 降级到 free 计划
|
||||
workspace.subscription_plan = "free"
|
||||
workspace.subscription_status = "active"
|
||||
workspace.subscription_expires_at = None
|
||||
workspace.max_projects = 3
|
||||
workspace.max_storage_gb = 10
|
||||
|
||||
self.workspace_repository.save(workspace)
|
||||
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Failed to cancel subscription: {str(e)}"
|
||||
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Subscription 管理 Use Case 测试
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from packages.application.workspace.subscription_use_case import (
|
||||
UpgradeSubscriptionUseCase,
|
||||
UpgradeSubscriptionRequest,
|
||||
CancelSubscriptionUseCase,
|
||||
CancelSubscriptionRequest,
|
||||
)
|
||||
from packages.domain.entities import (
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberRole,
|
||||
)
|
||||
|
||||
|
||||
class TestUpgradeSubscriptionUseCase:
|
||||
"""升级订阅测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_workspace_repo(self):
|
||||
repo = Mock()
|
||||
repo.find_by_id = Mock(return_value=None)
|
||||
repo.save = Mock()
|
||||
return repo
|
||||
|
||||
@pytest.fixture
|
||||
def mock_member_repo(self):
|
||||
repo = Mock()
|
||||
repo.find_by_workspace_and_user = Mock(return_value=None)
|
||||
return repo
|
||||
|
||||
@pytest.fixture
|
||||
def use_case(self, mock_workspace_repo, mock_member_repo):
|
||||
return UpgradeSubscriptionUseCase(
|
||||
workspace_repository=mock_workspace_repo,
|
||||
workspace_member_repository=mock_member_repo,
|
||||
)
|
||||
|
||||
def test_upgrade_from_free_to_pro(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
):
|
||||
"""测试从 Free 升级到 Pro"""
|
||||
workspace = Workspace(
|
||||
id="workspace-123",
|
||||
name="Test Workspace",
|
||||
owner_user_id="owner-id",
|
||||
subscription_plan="free",
|
||||
)
|
||||
mock_workspace_repo.find_by_id.return_value = workspace
|
||||
|
||||
owner_member = WorkspaceMember(
|
||||
id="member-1",
|
||||
workspace_id="workspace-123",
|
||||
user_id="owner-id",
|
||||
role=WorkspaceMemberRole.OWNER,
|
||||
)
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
|
||||
|
||||
request = UpgradeSubscriptionRequest(
|
||||
workspace_id="workspace-123",
|
||||
requester_user_id="owner-id",
|
||||
new_plan="pro",
|
||||
)
|
||||
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.old_plan == "free"
|
||||
assert response.new_plan == "pro"
|
||||
assert response.max_projects == 999999
|
||||
assert response.max_storage_gb == 100
|
||||
|
||||
# 验证更新了 workspace
|
||||
assert workspace.subscription_plan == "pro"
|
||||
assert workspace.max_projects == 999999
|
||||
assert workspace.subscription_expires_at is not None
|
||||
|
||||
def test_upgrade_from_pro_to_enterprise(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
):
|
||||
"""测试从 Pro 升级到 Enterprise"""
|
||||
workspace = Workspace(
|
||||
id="workspace-123",
|
||||
name="Test Workspace",
|
||||
owner_user_id="owner-id",
|
||||
subscription_plan="pro",
|
||||
max_projects=999999,
|
||||
max_storage_gb=100,
|
||||
)
|
||||
mock_workspace_repo.find_by_id.return_value = workspace
|
||||
|
||||
owner_member = WorkspaceMember(
|
||||
id="member-1",
|
||||
workspace_id="workspace-123",
|
||||
user_id="owner-id",
|
||||
role=WorkspaceMemberRole.OWNER,
|
||||
)
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
|
||||
|
||||
request = UpgradeSubscriptionRequest(
|
||||
workspace_id="workspace-123",
|
||||
requester_user_id="owner-id",
|
||||
new_plan="enterprise",
|
||||
)
|
||||
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.old_plan == "pro"
|
||||
assert response.new_plan == "enterprise"
|
||||
assert response.max_storage_gb == 1000
|
||||
|
||||
def test_upgrade_cannot_downgrade(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
):
|
||||
"""测试不能降级"""
|
||||
workspace = Workspace(
|
||||
id="workspace-123",
|
||||
name="Test Workspace",
|
||||
owner_user_id="owner-id",
|
||||
subscription_plan="pro",
|
||||
)
|
||||
mock_workspace_repo.find_by_id.return_value = workspace
|
||||
|
||||
owner_member = WorkspaceMember(
|
||||
id="member-1",
|
||||
workspace_id="workspace-123",
|
||||
user_id="owner-id",
|
||||
role=WorkspaceMemberRole.OWNER,
|
||||
)
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
|
||||
|
||||
request = UpgradeSubscriptionRequest(
|
||||
workspace_id="workspace-123",
|
||||
requester_user_id="owner-id",
|
||||
new_plan="free",
|
||||
)
|
||||
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert response is None
|
||||
assert "Cannot downgrade" in error
|
||||
|
||||
def test_upgrade_already_on_plan(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
):
|
||||
"""测试已经是该计划"""
|
||||
workspace = Workspace(
|
||||
id="workspace-123",
|
||||
name="Test Workspace",
|
||||
owner_user_id="owner-id",
|
||||
subscription_plan="pro",
|
||||
)
|
||||
mock_workspace_repo.find_by_id.return_value = workspace
|
||||
|
||||
owner_member = WorkspaceMember(
|
||||
id="member-1",
|
||||
workspace_id="workspace-123",
|
||||
user_id="owner-id",
|
||||
role=WorkspaceMemberRole.OWNER,
|
||||
)
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
|
||||
|
||||
request = UpgradeSubscriptionRequest(
|
||||
workspace_id="workspace-123",
|
||||
requester_user_id="owner-id",
|
||||
new_plan="pro",
|
||||
)
|
||||
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert response is None
|
||||
assert "already on pro plan" in error
|
||||
|
||||
def test_upgrade_only_owner_can_upgrade(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
):
|
||||
"""测试只有 Owner 可以升级"""
|
||||
workspace = Workspace(
|
||||
id="workspace-123",
|
||||
name="Test Workspace",
|
||||
owner_user_id="owner-id",
|
||||
subscription_plan="free",
|
||||
)
|
||||
mock_workspace_repo.find_by_id.return_value = workspace
|
||||
|
||||
admin_member = WorkspaceMember(
|
||||
id="member-1",
|
||||
workspace_id="workspace-123",
|
||||
user_id="admin-id",
|
||||
role=WorkspaceMemberRole.ADMIN,
|
||||
)
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = admin_member
|
||||
|
||||
request = UpgradeSubscriptionRequest(
|
||||
workspace_id="workspace-123",
|
||||
requester_user_id="admin-id",
|
||||
new_plan="pro",
|
||||
)
|
||||
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert response is None
|
||||
assert "Only workspace owner" in error
|
||||
|
||||
|
||||
class TestCancelSubscriptionUseCase:
|
||||
"""取消订阅测试"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_workspace_repo(self):
|
||||
repo = Mock()
|
||||
repo.find_by_id = Mock(return_value=None)
|
||||
repo.save = Mock()
|
||||
return repo
|
||||
|
||||
@pytest.fixture
|
||||
def mock_member_repo(self):
|
||||
repo = Mock()
|
||||
repo.find_by_workspace_and_user = Mock(return_value=None)
|
||||
return repo
|
||||
|
||||
@pytest.fixture
|
||||
def use_case(self, mock_workspace_repo, mock_member_repo):
|
||||
return CancelSubscriptionUseCase(
|
||||
workspace_repository=mock_workspace_repo,
|
||||
workspace_member_repository=mock_member_repo,
|
||||
)
|
||||
|
||||
def test_cancel_subscription_success(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
):
|
||||
"""测试取消订阅成功"""
|
||||
workspace = Workspace(
|
||||
id="workspace-123",
|
||||
name="Test Workspace",
|
||||
owner_user_id="owner-id",
|
||||
subscription_plan="pro",
|
||||
max_projects=999999,
|
||||
max_storage_gb=100,
|
||||
)
|
||||
mock_workspace_repo.find_by_id.return_value = workspace
|
||||
|
||||
owner_member = WorkspaceMember(
|
||||
id="member-1",
|
||||
workspace_id="workspace-123",
|
||||
user_id="owner-id",
|
||||
role=WorkspaceMemberRole.OWNER,
|
||||
)
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
|
||||
|
||||
request = CancelSubscriptionRequest(
|
||||
workspace_id="workspace-123",
|
||||
requester_user_id="owner-id",
|
||||
)
|
||||
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
|
||||
# 验证降级到 free
|
||||
assert workspace.subscription_plan == "free"
|
||||
assert workspace.max_projects == 3
|
||||
assert workspace.max_storage_gb == 10
|
||||
assert workspace.subscription_expires_at is None
|
||||
|
||||
def test_cancel_already_free(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
):
|
||||
"""测试已经是免费计划"""
|
||||
workspace = Workspace(
|
||||
id="workspace-123",
|
||||
name="Test Workspace",
|
||||
owner_user_id="owner-id",
|
||||
subscription_plan="free",
|
||||
)
|
||||
mock_workspace_repo.find_by_id.return_value = workspace
|
||||
|
||||
owner_member = WorkspaceMember(
|
||||
id="member-1",
|
||||
workspace_id="workspace-123",
|
||||
user_id="owner-id",
|
||||
role=WorkspaceMemberRole.OWNER,
|
||||
)
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
|
||||
|
||||
request = CancelSubscriptionRequest(
|
||||
workspace_id="workspace-123",
|
||||
requester_user_id="owner-id",
|
||||
)
|
||||
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert "already on free plan" in error
|
||||
|
||||
def test_cancel_only_owner_can_cancel(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
):
|
||||
"""测试只有 Owner 可以取消"""
|
||||
workspace = Workspace(
|
||||
id="workspace-123",
|
||||
name="Test Workspace",
|
||||
owner_user_id="owner-id",
|
||||
subscription_plan="pro",
|
||||
)
|
||||
mock_workspace_repo.find_by_id.return_value = workspace
|
||||
|
||||
admin_member = WorkspaceMember(
|
||||
id="member-1",
|
||||
workspace_id="workspace-123",
|
||||
user_id="admin-id",
|
||||
role=WorkspaceMemberRole.ADMIN,
|
||||
)
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = admin_member
|
||||
|
||||
request = CancelSubscriptionRequest(
|
||||
workspace_id="workspace-123",
|
||||
requester_user_id="admin-id",
|
||||
)
|
||||
|
||||
success, error = use_case.execute(request)
|
||||
|
||||
assert success is False
|
||||
assert "Only workspace owner" in error
|
||||
Reference in New Issue
Block a user