*/ 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 { $subscriptionSummary = $this->workspaceSubscriptionResolver->summary($workspace); $state = (string) $subscriptionSummary['derived_lifecycle_state']; $source = (string) $subscriptionSummary['source']; $labels = self::stateLabels(); $descriptions = self::stateDescriptions(); $lastChanged = $this->lastChangedMetadata($workspace, $source); return [ 'workspace_id' => (int) $workspace->getKey(), 'state' => $state, 'state_label' => $labels[$state], 'source' => $source, 'source_label' => match ($source) { self::SOURCE_WORKSPACE_SUBSCRIPTION => 'workspace subscription', self::SOURCE_WORKSPACE_SETTING => 'workspace setting', default => 'default active paid', }, 'rationale' => $subscriptionSummary['status_reason'] ?? null, 'description' => $descriptions[$state], 'last_changed_at' => $lastChanged['last_changed_at'], 'last_changed_by' => $lastChanged['last_changed_by'], 'subscription_present' => (bool) ($subscriptionSummary['subscription_present'] ?? false), 'fallback_status' => (bool) ($subscriptionSummary['fallback_status'] ?? true), 'subscription_state' => $subscriptionSummary['state'] ?? null, 'subscription_state_label' => $subscriptionSummary['label'] ?? null, 'subscription_billing_reference' => $subscriptionSummary['billing_reference'] ?? null, 'subscription_key_date_label' => $subscriptionSummary['key_date_label'] ?? null, 'subscription_key_date' => $subscriptionSummary['key_date'] ?? null, 'subscription_needs_review' => (bool) ($subscriptionSummary['needs_review'] ?? false), ]; } /** * @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: $this->lifecycleMessage($lifecycle, '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->lifecycleMessage($lifecycle, '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: $this->lifecycleMessage($lifecycle, '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: $this->lifecycleMessage($lifecycle, '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->lifecycleMessage($lifecycle, '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: $this->lifecycleMessage($lifecycle, '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: $this->lifecycleMessage($lifecycle, '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: $this->lifecycleMessage($lifecycle, 'Read-only history remains available under current RBAC.'), substrateDecision: null, ); } /** * @param array $lifecycle */ private function lifecycleMessage(array $lifecycle, string $message): string { return sprintf('%s Commercial source: %s.', $message, $this->commercialSourceDescriptor($lifecycle)); } /** * @param array $lifecycle */ private function commercialSourceDescriptor(array $lifecycle): string { return ($lifecycle['source'] ?? null) === self::SOURCE_WORKSPACE_SUBSCRIPTION ? 'subscription-backed' : 'fallback-backed'; } /** * @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, string $source): array { if ($source === self::SOURCE_WORKSPACE_SUBSCRIPTION) { $audit = AuditLog::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('action', AuditActionId::WorkspaceSubscriptionUpdated->value) ->where('resource_type', 'workspace_subscription') ->latest('recorded_at') ->latest('id') ->first(); if ($audit instanceof AuditLog) { return [ 'last_changed_at' => $audit->recorded_at, 'last_changed_by' => $audit->actorDisplayLabel(), ]; } $workspace->loadMissing('subscription'); if ($workspace->subscription !== null) { return [ 'last_changed_at' => $workspace->subscription->updated_at, 'last_changed_by' => null, ]; } } $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, ]; } }