*/ public static function stateIds(): array { return [ self::STATE_TRIAL, self::STATE_GRACE, self::STATE_ACTIVE_PAID, self::STATE_SUSPENDED_READ_ONLY, ]; } /** * @return array */ public static function stateLabels(): array { return [ self::STATE_TRIAL => 'Trial', self::STATE_GRACE => 'Grace', self::STATE_ACTIVE_PAID => 'Active paid', self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only', ]; } /** * @return array */ public static function stateDescriptions(): array { return [ self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.', self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.', self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.', self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.', ]; } /** * @return array */ public function summary(Workspace $workspace): array { $lifecycle = $this->resolve($workspace); return $lifecycle + [ 'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace), 'action_decisions' => [ self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle), self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle), self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle), self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle), self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle), ], ]; } /** * @return array{ * workspace_id: int, * state: string, * state_label: string, * source: string, * source_label: string, * rationale: string|null, * description: string, * last_changed_at: CarbonInterface|null, * last_changed_by: string|null * } */ public function resolve(Workspace $workspace): array { $stateSetting = $this->settingsResolver->resolveDetailed( workspace: $workspace, domain: self::SETTING_DOMAIN, key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE, ); $rawState = is_string($stateSetting['value'] ?? null) ? strtolower(trim((string) $stateSetting['value'])) : null; $state = in_array($rawState, self::stateIds(), true) ? $rawState : self::STATE_ACTIVE_PAID; $source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null ? self::SOURCE_WORKSPACE_SETTING : self::SOURCE_DEFAULT_ACTIVE_PAID; $rationale = $this->settingsResolver->resolveValue( workspace: $workspace, domain: self::SETTING_DOMAIN, key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON, ); $labels = self::stateLabels(); $descriptions = self::stateDescriptions(); $lastChanged = $this->lastChangedMetadata($workspace); return [ 'workspace_id' => (int) $workspace->getKey(), 'state' => $state, 'state_label' => $labels[$state], 'source' => $source, 'source_label' => $source === self::SOURCE_WORKSPACE_SETTING ? 'workspace setting' : 'default active paid', 'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null, 'description' => $descriptions[$state], 'last_changed_at' => $lastChanged['last_changed_at'], 'last_changed_by' => $lastChanged['last_changed_by'], ]; } /** * @param array|null $lifecycle * @return array */ public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array { $lifecycle ??= $this->resolve($workspace); return match ($actionKey) { self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle), self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle), self::ACTION_REVIEW_HISTORY_READ, self::ACTION_EVIDENCE_READ, self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle), default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)), }; } /** * @return array */ public function reviewPackStartDecisionForTenant(Tenant $tenant): array { $tenant->loadMissing('workspace'); return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START); } /** * @param array $lifecycle * @return array */ private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array { $substrateDecision = $this->workspaceEntitlementResolver->resolve( $workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, ); if ((bool) ($substrateDecision['is_blocked'] ?? false)) { return $this->decision( lifecycle: $lifecycle, actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, outcome: self::OUTCOME_BLOCK, reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'), substrateDecision: $substrateDecision, ); } return match ($lifecycle['state']) { self::STATE_GRACE => $this->decision( lifecycle: $lifecycle, actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, outcome: self::OUTCOME_BLOCK, reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, message: 'New managed-tenant activation is frozen while this workspace is in grace.', substrateDecision: $substrateDecision, ), self::STATE_SUSPENDED_READ_ONLY => $this->decision( lifecycle: $lifecycle, actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, outcome: self::OUTCOME_BLOCK, reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.', substrateDecision: $substrateDecision, ), default => $this->decision( lifecycle: $lifecycle, actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION, outcome: self::OUTCOME_ALLOW, reasonFamily: null, message: 'Managed-tenant activation is available for this workspace commercial state.', substrateDecision: $substrateDecision, ), }; } /** * @param array $lifecycle * @return array */ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array { $substrateDecision = $this->workspaceEntitlementResolver->resolve( $workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED, ); if ((bool) ($substrateDecision['is_blocked'] ?? false)) { return $this->decision( lifecycle: $lifecycle, actionKey: self::ACTION_REVIEW_PACK_START, outcome: self::OUTCOME_BLOCK, reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE, message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'), substrateDecision: $substrateDecision, ); } return match ($lifecycle['state']) { self::STATE_GRACE => $this->decision( lifecycle: $lifecycle, actionKey: self::ACTION_REVIEW_PACK_START, outcome: self::OUTCOME_WARN, reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.', substrateDecision: $substrateDecision, ), self::STATE_SUSPENDED_READ_ONLY => $this->decision( lifecycle: $lifecycle, actionKey: self::ACTION_REVIEW_PACK_START, outcome: self::OUTCOME_BLOCK, reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.', substrateDecision: $substrateDecision, ), default => $this->decision( lifecycle: $lifecycle, actionKey: self::ACTION_REVIEW_PACK_START, outcome: self::OUTCOME_ALLOW, reasonFamily: null, message: 'Review-pack starts are available for this workspace commercial state.', substrateDecision: $substrateDecision, ), }; } /** * @param array $lifecycle * @return array */ private function readOnlyDecision(string $actionKey, array $lifecycle): array { if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) { return $this->decision( lifecycle: $lifecycle, actionKey: $actionKey, outcome: self::OUTCOME_ALLOW_READ_ONLY, reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE, message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.', substrateDecision: null, ); } return $this->decision( lifecycle: $lifecycle, actionKey: $actionKey, outcome: self::OUTCOME_ALLOW, reasonFamily: null, message: 'Read-only history remains available under current RBAC.', substrateDecision: null, ); } /** * @param array $lifecycle * @param array|null $substrateDecision * @return array */ private function decision( array $lifecycle, string $actionKey, string $outcome, ?string $reasonFamily, string $message, ?array $substrateDecision, ): array { return [ 'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0), 'action_key' => $actionKey, 'outcome' => $outcome, 'is_blocked' => $outcome === self::OUTCOME_BLOCK, 'is_warning' => $outcome === self::OUTCOME_WARN, 'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null, 'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null, 'message' => $message, 'reason_family' => $reasonFamily, 'state' => (string) $lifecycle['state'], 'state_label' => (string) $lifecycle['state_label'], 'source' => (string) $lifecycle['source'], 'source_label' => (string) $lifecycle['source_label'], 'rationale' => $lifecycle['rationale'] ?? null, 'entitlement_decision' => $substrateDecision, ]; } /** * @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} */ private function lastChangedMetadata(Workspace $workspace): array { $audit = AuditLog::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('action', AuditActionId::WorkspaceSettingUpdated->value) ->where('resource_type', 'workspace_setting') ->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE) ->latest('recorded_at') ->latest('id') ->first(); if ($audit instanceof AuditLog) { return [ 'last_changed_at' => $audit->recorded_at, 'last_changed_by' => $audit->actorDisplayLabel(), ]; } $record = WorkspaceSetting::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('domain', self::SETTING_DOMAIN) ->whereIn('key', [ self::SETTING_COMMERCIAL_LIFECYCLE_STATE, self::SETTING_COMMERCIAL_LIFECYCLE_REASON, ]) ->with('updatedByUser:id,name') ->latest('updated_at') ->latest('id') ->first(); if (! $record instanceof WorkspaceSetting) { return [ 'last_changed_at' => null, 'last_changed_by' => null, ]; } return [ 'last_changed_at' => $record->updated_at, 'last_changed_by' => $record->updatedByUser?->name, ]; } }