From 6e1b0786d84cc86d2bd4bc94505210416a42f254 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Wed, 17 Jun 2026 07:20:34 +0800 Subject: [PATCH] feat(subscription): add upgrade and cancel subscription use cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/application/workspace/__init__.py | 12 + .../workspace/subscription_use_case.py | 214 +++++++++++ tests/unit/test_subscription_use_case.py | 355 ++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 packages/application/workspace/subscription_use_case.py create mode 100644 tests/unit/test_subscription_use_case.py diff --git a/packages/application/workspace/__init__.py b/packages/application/workspace/__init__.py index 1753f5176..b255ead91 100644 --- a/packages/application/workspace/__init__.py +++ b/packages/application/workspace/__init__.py @@ -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", ] diff --git a/packages/application/workspace/subscription_use_case.py b/packages/application/workspace/subscription_use_case.py new file mode 100644 index 000000000..c39ba6966 --- /dev/null +++ b/packages/application/workspace/subscription_use_case.py @@ -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)}" diff --git a/tests/unit/test_subscription_use_case.py b/tests/unit/test_subscription_use_case.py new file mode 100644 index 000000000..532dc5d14 --- /dev/null +++ b/tests/unit/test_subscription_use_case.py @@ -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