create(); $user = User::factory()->create(); WorkspaceMembership::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey(), 'role' => 'manager', ]); return [$workspace, $user]; } function commercialLifecyclePlatformOperator(): PlatformUser { return PlatformUser::factory()->create([ 'capabilities' => [ PlatformCapabilities::ACCESS_SYSTEM_PANEL, PlatformCapabilities::DIRECTORY_VIEW, PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE, ], 'is_active' => true, ]); } function setCommercialLifecycleState(Workspace $workspace, string $state, string $reason = 'Unit test commercial state change'): void { app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( actor: commercialLifecyclePlatformOperator(), workspace: $workspace, state: $state, reason: $reason, ); } it('falls back to active paid when no explicit commercial lifecycle setting exists', function (): void { [$workspace] = commercialLifecycleWorkspaceManager(); $summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace); expect($summary) ->toMatchArray([ 'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'state_label' => 'Active paid', 'source' => WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID, 'source_label' => 'default active paid', 'rationale' => null, ]) ->and($summary['last_changed_at'])->toBeNull() ->and($summary['last_changed_by'])->toBeNull() ->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome']) ->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW) ->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome']) ->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW); }); it('resolves explicit stored commercial lifecycle states with source rationale and platform attribution', function (string $state, string $expectedLabel): void { [$workspace] = commercialLifecycleWorkspaceManager(); $operator = commercialLifecyclePlatformOperator(); app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle( actor: $operator, workspace: $workspace, state: $state, reason: 'Support approved commercial lifecycle transition', ); $summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace); expect($summary) ->toMatchArray([ 'state' => $state, 'state_label' => $expectedLabel, 'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING, 'source_label' => 'workspace setting', 'rationale' => 'Support approved commercial lifecycle transition', 'last_changed_by' => $operator->name, ]) ->and($summary['last_changed_at'])->not->toBeNull(); })->with([ 'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial'], 'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace'], 'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid'], 'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only'], ]); it('blocks activation but warns review pack starts during grace', function (): void { [$workspace] = commercialLifecycleWorkspaceManager(); setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Payment collection pending'); $resolver = app(WorkspaceCommercialLifecycleResolver::class); $activation = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION); $reviewPackStart = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START); expect($activation) ->toMatchArray([ 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, 'is_blocked' => true, 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, 'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE, ]) ->and($activation['block_reason'])->toContain('grace') ->and($reviewPackStart) ->toMatchArray([ 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN, 'is_blocked' => false, 'is_warning' => true, 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, 'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE, ]) ->and($reviewPackStart['warning_reason'])->toContain('grace'); }); it('blocks new starts but allows read-only history during suspended read-only', function (): void { [$workspace] = commercialLifecycleWorkspaceManager(); setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Commercial suspension'); $resolver = app(WorkspaceCommercialLifecycleResolver::class); expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION)) ->toMatchArray([ 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, 'is_blocked' => true, 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, ]) ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START)) ->toMatchArray([ 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, 'is_blocked' => true, 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, ]) ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_EVIDENCE_READ)) ->toMatchArray([ 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW_READ_ONLY, 'is_blocked' => false, 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE, ]); }); it('preserves entitlement substrate blocks ahead of lifecycle outcomes', function (): void { [$workspace, $manager] = commercialLifecycleWorkspaceManager(); setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace should not bypass substrate'); Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'status' => Tenant::STATUS_ACTIVE, ]); app(SettingsWriter::class)->updateWorkspaceSetting( actor: $manager, workspace: $workspace, domain: WorkspaceEntitlementResolver::SETTING_DOMAIN, key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE, value: 1, ); app(SettingsWriter::class)->updateWorkspaceSetting( actor: $manager, workspace: $workspace, domain: WorkspaceEntitlementResolver::SETTING_DOMAIN, key: WorkspaceEntitlementResolver::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE, value: false, ); $resolver = app(WorkspaceCommercialLifecycleResolver::class); expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION)) ->toMatchArray([ 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, ]) ->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START)) ->toMatchArray([ 'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK, 'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, 'is_warning' => false, ]); });