feat(subscription): add upgrade and cancel subscription use cases
Deploy / Deploy Staging (push) Failing after 6s
Deploy / Deploy Production (push) Has been skipped
Tests / test (push) Failing after 6s
Tests / lint (push) Failing after 5s

- 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:
Xiaoxia AI
2026-06-17 07:20:34 +08:00
parent fa2c9968e6
commit 6e1b0786d8
3 changed files with 581 additions and 0 deletions
@@ -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)}"
+355
View File
@@ -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