authorizeManage($actor, $workspace); $result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey()); $this->resolver->clearCache(); $afterValue = $this->resolver->resolveValue($workspace, $domain, $key); $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceSettingUpdated->value, context: [ 'metadata' => [ 'scope' => 'workspace', 'domain' => $domain, 'key' => $key, 'before_value' => $result['before_value'], 'after_value' => $afterValue, ], ], actor: $actor, resourceType: 'workspace_setting', resourceId: $domain.'.'.$key, ); return $result['setting']; } public function updateWorkspaceCommercialLifecycle( PlatformUser $actor, Workspace $workspace, string $state, string $reason, ): void { $state = strtolower(trim($state)); $reason = trim($reason); if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) { throw new AuthorizationException('Missing commercial lifecycle manage capability.'); } if ($reason === '') { throw ValidationException::withMessages([ 'reason' => ['A rationale is required when changing commercial lifecycle state.'], ]); } DB::transaction(function () use ($actor, $workspace, $state, $reason): void { $stateResult = $this->persistWorkspaceSetting( workspace: $workspace, domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, value: $state, updatedByUserId: null, ); $reasonResult = $this->persistWorkspaceSetting( workspace: $workspace, domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON, value: $reason, updatedByUserId: null, ); $this->resolver->clearCache(); $afterState = $this->resolver->resolveValue( $workspace, WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, ); $afterReason = $this->resolver->resolveValue( $workspace, WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON, ); $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceSettingUpdated->value, context: [ 'metadata' => [ 'scope' => 'workspace', 'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN, 'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, 'before_state' => $stateResult['before_value'], 'after_state' => $afterState, 'before_reason' => $reasonResult['before_value'], 'after_reason' => $afterReason, ], ], actor: $actor, resourceType: 'workspace_setting', resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE, targetLabel: 'Commercial lifecycle state', ); }); } /** * @param array $attributes */ public function updateWorkspaceSubscription( PlatformUser $actor, Workspace $workspace, array $attributes, ): WorkspaceSubscription { $this->authorizeCommercialLifecycleManage($actor); $validator = Validator::make($attributes, [ 'state' => ['required', 'string', 'in:'.implode(',', WorkspaceSubscription::stateIds())], 'billing_reference' => ['nullable', 'string', 'max:191'], 'trial_ends_at' => ['nullable', 'date'], 'current_period_starts_at' => ['nullable', 'date'], 'current_period_ends_at' => ['nullable', 'date'], 'status_reason' => ['required', 'string', 'max:500'], ]); if ($validator->fails()) { throw ValidationException::withMessages($validator->errors()->toArray()); } $validated = $validator->validated(); $state = (string) $validated['state']; if ($state === WorkspaceSubscription::STATE_TRIAL && blank($validated['trial_ends_at'] ?? null)) { throw ValidationException::withMessages([ 'trial_ends_at' => ['A trial end date is required for trial subscriptions.'], ]); } if (in_array($state, [ WorkspaceSubscription::STATE_ACTIVE, WorkspaceSubscription::STATE_PAST_DUE, WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END, ], true) && blank($validated['current_period_starts_at'] ?? null)) { throw ValidationException::withMessages([ 'current_period_starts_at' => ['A current period start date is required for this subscription state.'], ]); } if (in_array($state, [ WorkspaceSubscription::STATE_ACTIVE, WorkspaceSubscription::STATE_PAST_DUE, WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END, WorkspaceSubscription::STATE_ENDED, ], true) && blank($validated['current_period_ends_at'] ?? null)) { throw ValidationException::withMessages([ 'current_period_ends_at' => ['A current period end date is required for this subscription state.'], ]); } return DB::transaction(function () use ($actor, $workspace, $validated): WorkspaceSubscription { $workspace->loadMissing('subscription'); $before = $workspace->subscription instanceof WorkspaceSubscription ? $this->workspaceSubscriptionAuditPayload($workspace->subscription) : null; $subscription = WorkspaceSubscription::query()->updateOrCreate( ['workspace_id' => (int) $workspace->getKey()], [ 'state' => (string) $validated['state'], 'billing_reference' => filled($validated['billing_reference'] ?? null) ? trim((string) $validated['billing_reference']) : null, 'trial_ends_at' => filled($validated['trial_ends_at'] ?? null) ? Carbon::parse((string) $validated['trial_ends_at']) : null, 'current_period_starts_at' => filled($validated['current_period_starts_at'] ?? null) ? Carbon::parse((string) $validated['current_period_starts_at']) : null, 'current_period_ends_at' => filled($validated['current_period_ends_at'] ?? null) ? Carbon::parse((string) $validated['current_period_ends_at']) : null, 'status_reason' => trim((string) $validated['status_reason']), ], ); $workspace->setRelation('subscription', $subscription->fresh()); $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceSubscriptionUpdated, context: [ 'metadata' => [ 'before' => $before, 'after' => $this->workspaceSubscriptionAuditPayload($subscription), ], ], actor: $actor, resourceType: 'workspace_subscription', resourceId: (string) $subscription->getKey(), targetLabel: 'Current workspace subscription', summary: 'Workspace subscription updated', ); return $subscription; }); } public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void { $this->authorizeManage($actor, $workspace); $this->requireDefinition($domain, $key); $existing = WorkspaceSetting::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('domain', $domain) ->where('key', $key) ->first(); $beforeValue = $existing instanceof WorkspaceSetting ? $this->decodeStoredValue($existing->getAttribute('value')) : null; if ($existing instanceof WorkspaceSetting) { $existing->delete(); } $this->resolver->clearCache(); $afterValue = $this->resolver->resolveValue($workspace, $domain, $key); $this->auditLogger->log( workspace: $workspace, action: AuditActionId::WorkspaceSettingReset->value, context: [ 'metadata' => [ 'scope' => 'workspace', 'domain' => $domain, 'key' => $key, 'before_value' => $beforeValue, 'after_value' => $afterValue, ], ], actor: $actor, resourceType: 'workspace_setting', resourceId: $domain.'.'.$key, ); } public function updateTenantSetting(User $actor, Workspace $workspace, Tenant $tenant, string $domain, string $key, mixed $value): TenantSetting { $this->authorizeManage($actor, $workspace); $this->assertTenantBelongsToWorkspace($workspace, $tenant); $definition = $this->requireDefinition($domain, $key); $normalizedValue = $this->validatedValue($definition, $value); $setting = TenantSetting::query()->updateOrCreate([ 'workspace_id' => (int) $workspace->getKey(), 'tenant_id' => (int) $tenant->getKey(), 'domain' => $domain, 'key' => $key, ], [ 'value' => $normalizedValue, 'updated_by_user_id' => (int) $actor->getKey(), ]); $this->resolver->clearCache(); return $setting; } public function resetTenantSetting(User $actor, Workspace $workspace, Tenant $tenant, string $domain, string $key): void { $this->authorizeManage($actor, $workspace); $this->assertTenantBelongsToWorkspace($workspace, $tenant); $this->requireDefinition($domain, $key); TenantSetting::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('tenant_id', (int) $tenant->getKey()) ->where('domain', $domain) ->where('key', $key) ->delete(); $this->resolver->clearCache(); } private function requireDefinition(string $domain, string $key): SettingDefinition { $definition = $this->registry->find($domain, $key); if ($definition instanceof SettingDefinition) { return $definition; } throw ValidationException::withMessages([ 'key' => [sprintf('Unknown setting key: %s.%s', $domain, $key)], ]); } /** * @return array{setting: WorkspaceSetting, before_value: mixed} */ private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array { $definition = $this->requireDefinition($domain, $key); $normalizedValue = $this->validatedValue($definition, $value); $existing = WorkspaceSetting::query() ->where('workspace_id', (int) $workspace->getKey()) ->where('domain', $domain) ->where('key', $key) ->first(); $beforeValue = $existing instanceof WorkspaceSetting ? $this->decodeStoredValue($existing->getAttribute('value')) : null; $setting = WorkspaceSetting::query()->updateOrCreate([ 'workspace_id' => (int) $workspace->getKey(), 'domain' => $domain, 'key' => $key, ], [ 'value' => $normalizedValue, 'updated_by_user_id' => $updatedByUserId, ]); return [ 'setting' => $setting, 'before_value' => $beforeValue, ]; } private function validatedValue(SettingDefinition $definition, mixed $value): mixed { $validator = Validator::make( data: ['value' => $value], rules: ['value' => $definition->rules], ); if ($validator->fails()) { throw ValidationException::withMessages($validator->errors()->toArray()); } return $definition->normalize($validator->validated()['value']); } private function authorizeManage(User $actor, Workspace $workspace): void { if (! $this->workspaceCapabilityResolver->isMember($actor, $workspace)) { throw new NotFoundHttpException('Workspace not found.'); } if (! $this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) { throw new AuthorizationException('Missing workspace settings manage capability.'); } } private function authorizeCommercialLifecycleManage(PlatformUser $actor): void { if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) { throw new AuthorizationException('Missing commercial lifecycle manage capability.'); } } private function assertTenantBelongsToWorkspace(Workspace $workspace, Tenant $tenant): void { if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) { throw new NotFoundHttpException('Tenant is outside the selected workspace scope.'); } } private function decodeStoredValue(mixed $value): mixed { if (! is_string($value)) { return $value; } $decoded = json_decode($value, true); return json_last_error() === JSON_ERROR_NONE ? $decoded : $value; } /** * @return array */ private function workspaceSubscriptionAuditPayload(WorkspaceSubscription $subscription): array { return [ 'state' => $subscription->state, 'billing_reference' => $subscription->billing_reference, 'trial_ends_at' => $subscription->trial_ends_at?->toAtomString(), 'current_period_starts_at' => $subscription->current_period_starts_at?->toAtomString(), 'current_period_ends_at' => $subscription->current_period_ends_at?->toAtomString(), 'status_reason' => $subscription->status_reason, ]; } }