chore: commit all local changes
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 17m36s

This commit is contained in:
Ahmed Darrazi 2026-05-04 23:02:19 +02:00
parent ca61fa17dc
commit b7a587495f
27 changed files with 2976 additions and 47 deletions

View File

@ -10,6 +10,7 @@
use App\Support\Ai\AiPolicyMode;
use App\Support\Ai\AiUseCaseCatalog;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Localization\LocaleResolver;
@ -141,6 +142,11 @@ class WorkspaceSettings extends Page
*/
public array $entitlementSummary = [];
/**
* @var array<string, mixed>
*/
public array $commercialLifecycleSummary = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
@ -227,6 +233,24 @@ public function content(Schema $schema): Schema
->helperText(fn (): string => $this->localeDefaultHelperText())
->hintAction($this->makeResetAction('localization_default_locale')),
]),
Section::make('Commercial posture')
->description('Read-only subscription-backed or fallback-backed commercial posture for this workspace.')
->columns(2)
->schema([
Placeholder::make('commercial_posture_source')
->label('Commercial source')
->content(fn (): string => $this->commercialPostureSourceText()),
Placeholder::make('commercial_posture_state')
->label('Commercial state')
->content(fn (): string => $this->commercialPostureStateText()),
Placeholder::make('commercial_posture_timing')
->label('Commercial timing')
->content(fn (): string => $this->commercialPostureTimingText()),
Placeholder::make('commercial_posture_reason')
->label('Explanation')
->content(fn (): string => $this->commercialPostureReasonText())
->columnSpanFull(),
]),
Section::make('Workspace entitlements')
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
->columns(2)
@ -653,6 +677,7 @@ private function loadFormState(): void
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
$this->commercialLifecycleSummary = app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
$this->loadDomainLastModified();
}
@ -945,6 +970,43 @@ private function entitlementSourceLabel(array $decision): string
return 'plan profile default';
}
private function commercialPostureSourceText(): string
{
return ($this->commercialLifecycleSummary['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed';
}
private function commercialPostureStateText(): string
{
return (string) ($this->commercialLifecycleSummary['subscription_state_label']
?? $this->commercialLifecycleSummary['state_label']
?? 'Active paid');
}
private function commercialPostureTimingText(): string
{
$label = $this->commercialLifecycleSummary['subscription_key_date_label'] ?? null;
$date = $this->commercialLifecycleSummary['subscription_key_date'] ?? null;
if (is_string($label) && $label !== '' && $date instanceof Carbon) {
return sprintf('%s: %s', $label, $date->toDayDateTimeString());
}
return 'No scheduled commercial date recorded.';
}
private function commercialPostureReasonText(): string
{
$reason = $this->commercialLifecycleSummary['rationale'] ?? null;
if (is_string($reason) && $reason !== '') {
return $reason;
}
return ($this->commercialLifecycleSummary['fallback_status'] ?? true)
? 'No current subscription record is stored. The workspace is using fallback lifecycle truth.'
: 'No explicit commercial explanation recorded.';
}
private function helperTextFor(string $field): string
{
$resolved = $this->resolvedSettings[$field] ?? null;

View File

@ -4570,15 +4570,17 @@ private function completionSummaryEntitlementSummary(): string
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$commercialSourceLabel = $this->completionSummaryCommercialSourceLabel($decision);
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
return sprintf(
'%s - %s - %d active of %d allowed (%s)',
'%s - %s - %d active of %d allowed (%s, %s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$stateLabel,
$currentUsage,
$effectiveValue,
$sourceLabel,
$commercialSourceLabel,
);
}
@ -4640,6 +4642,16 @@ private function completionSummaryEntitlementSourceLabel(array $decision): strin
: 'plan profile default';
}
/**
* @param array<string, mixed> $decision
*/
private function completionSummaryCommercialSourceLabel(array $decision): string
{
return ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SUBSCRIPTION
? 'subscription-backed'
: 'fallback-backed';
}
private function completionActionTooltip(): ?string
{
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {

View File

@ -9,8 +9,10 @@
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSubscription;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
@ -19,10 +21,14 @@
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Collection;
class ViewWorkspace extends Page
@ -114,11 +120,123 @@ public function workspaceCommercialLifecycleSummary(): array
protected function getHeaderActions(): array
{
return [
Action::make('update_subscription_truth')
->label('Update subscription truth')
->icon('heroicon-o-credit-card')
->visible(fn (): bool => $this->canManageCommercialLifecycle())
->requiresConfirmation()
->modalHeading('Update subscription truth')
->modalDescription('This records the current subscription-backed commercial truth for the workspace and becomes the upstream lifecycle source while the record exists.')
->form([
Select::make('state')
->label('Subscription state')
->options(WorkspaceSubscriptionResolver::stateLabels())
->required()
->live()
->afterStateUpdated(function (Set $set, ?string $state): void {
$normalizedState = $this->normalizeSubscriptionState($state);
if ($normalizedState !== WorkspaceSubscription::STATE_TRIAL) {
$set('trial_ends_at', null);
}
if (! in_array($normalizedState, [
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
], true)) {
$set('current_period_starts_at', null);
}
if (! in_array($normalizedState, [
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
WorkspaceSubscription::STATE_ENDED,
], true)) {
$set('current_period_ends_at', null);
}
})
->helperText('Trial requires a trial end date. Active, past due, cancellation pending, and ended states require the matching current-period dates.')
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->state),
TextInput::make('billing_reference')
->label('Billing reference')
->maxLength(191)
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->billing_reference),
DateTimePicker::make('trial_ends_at')
->label('Trial ends at')
->required(fn (Get $get): bool => $this->selectedSubscriptionState($get) === WorkspaceSubscription::STATE_TRIAL)
->visible(fn (Get $get): bool => $this->selectedSubscriptionState($get) === WorkspaceSubscription::STATE_TRIAL)
->helperText('Required when the subscription state is Trial.')
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->trial_ends_at?->toDateTimeString()),
DateTimePicker::make('current_period_starts_at')
->label('Current period starts at')
->required(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
], true))
->visible(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
], true))
->helperText('Required for Active, Past due, and Cancellation pending subscriptions.')
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->current_period_starts_at?->toDateTimeString()),
DateTimePicker::make('current_period_ends_at')
->label('Current period ends at')
->required(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
WorkspaceSubscription::STATE_ENDED,
], true))
->visible(fn (Get $get): bool => in_array($this->selectedSubscriptionState($get), [
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
WorkspaceSubscription::STATE_ENDED,
], true))
->helperText('Required for Active, Past due, Cancellation pending, and Ended subscriptions.')
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->current_period_ends_at?->toDateTimeString()),
Textarea::make('status_reason')
->label('Status reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4)
->default(fn (): ?string => $this->currentWorkspaceSubscription()?->status_reason),
])
->action(function (array $data, SettingsWriter $settingsWriter): void {
$actor = auth('platform')->user();
if (! $actor instanceof PlatformUser) {
abort(403);
}
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
abort(403);
}
$settingsWriter->updateWorkspaceSubscription(
actor: $actor,
workspace: $this->workspace,
attributes: $data,
);
$this->workspace = $this->workspace->fresh()->loadCount('tenants');
Notification::make()
->title('Subscription truth updated')
->success()
->send();
}),
Action::make('change_commercial_state')
->label('Change commercial state')
->icon('heroicon-o-adjustments-horizontal')
->color('warning')
->visible(fn (): bool => $this->canManageCommercialLifecycle())
->visible(fn (): bool => $this->canManageCommercialLifecycle()
&& (bool) ($this->workspaceCommercialLifecycleSummary()['fallback_status'] ?? true))
->requiresConfirmation()
->modalHeading('Change commercial state')
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
@ -171,6 +289,27 @@ private function canManageCommercialLifecycle(): bool
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
}
private function currentWorkspaceSubscription(): ?WorkspaceSubscription
{
$this->workspace->loadMissing('subscription');
return $this->workspace->subscription;
}
private function selectedSubscriptionState(Get $get): string
{
return $this->normalizeSubscriptionState($get('state'));
}
private function normalizeSubscriptionState(mixed $state): string
{
$normalizedState = is_string($state) ? trim($state) : '';
return in_array($normalizedState, WorkspaceSubscription::stateIds(), true)
? $normalizedState
: WorkspaceSubscription::STATE_ACTIVE;
}
/**
* @return array{
* overall: array{label: string, color: string, icon: string|null},

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Workspace extends Model
{
@ -49,6 +50,14 @@ public function settings(): HasMany
return $this->hasMany(WorkspaceSetting::class);
}
/**
* @return HasOne<WorkspaceSubscription, $this>
*/
public function subscription(): HasOne
{
return $this->hasOne(WorkspaceSubscription::class);
}
/**
* @return HasMany<TenantSetting, $this>
*/

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WorkspaceSubscription extends Model
{
use HasFactory;
public const STATE_TRIAL = 'trial';
public const STATE_ACTIVE = 'active';
public const STATE_PAST_DUE = 'past_due';
public const STATE_CANCEL_AT_PERIOD_END = 'cancel_at_period_end';
public const STATE_ENDED = 'ended';
protected $guarded = [];
/**
* @return list<string>
*/
public static function stateIds(): array
{
return [
self::STATE_TRIAL,
self::STATE_ACTIVE,
self::STATE_PAST_DUE,
self::STATE_CANCEL_AT_PERIOD_END,
self::STATE_ENDED,
];
}
protected function casts(): array
{
return [
'trial_ends_at' => 'datetime',
'current_period_starts_at' => 'datetime',
'current_period_ends_at' => 'datetime',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}

View File

@ -8,7 +8,6 @@
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use Carbon\CarbonInterface;
@ -32,6 +31,8 @@ final class WorkspaceCommercialLifecycleResolver
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
public const SOURCE_WORKSPACE_SUBSCRIPTION = WorkspaceSubscriptionResolver::SOURCE_WORKSPACE_SUBSCRIPTION;
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
@ -55,8 +56,8 @@ final class WorkspaceCommercialLifecycleResolver
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
public function __construct(
private readonly SettingsResolver $settingsResolver,
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
private readonly WorkspaceSubscriptionResolver $workspaceSubscriptionResolver,
) {}
/**
@ -132,46 +133,37 @@ public function summary(Workspace $workspace): array
*/
public function resolve(Workspace $workspace): array
{
$stateSetting = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$subscriptionSummary = $this->workspaceSubscriptionResolver->summary($workspace);
$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,
);
$state = (string) $subscriptionSummary['derived_lifecycle_state'];
$source = (string) $subscriptionSummary['source'];
$labels = self::stateLabels();
$descriptions = self::stateDescriptions();
$lastChanged = $this->lastChangedMetadata($workspace);
$lastChanged = $this->lastChangedMetadata($workspace, $source);
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,
'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),
];
}
@ -231,7 +223,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
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.',
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(
@ -239,7 +231,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
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.',
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(
@ -247,7 +239,7 @@ private function managedTenantActivationDecision(Workspace $workspace, array $li
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Managed-tenant activation is available for this workspace commercial state.',
message: $this->lifecycleMessage($lifecycle, 'Managed-tenant activation is available for this workspace commercial state.'),
substrateDecision: $substrateDecision,
),
};
@ -281,7 +273,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $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.',
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(
@ -289,7 +281,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $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.',
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(
@ -297,7 +289,7 @@ private function reviewPackStartDecision(Workspace $workspace, array $lifecycle)
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Review-pack starts are available for this workspace commercial state.',
message: $this->lifecycleMessage($lifecycle, 'Review-pack starts are available for this workspace commercial state.'),
substrateDecision: $substrateDecision,
),
};
@ -315,7 +307,7 @@ private function readOnlyDecision(string $actionKey, array $lifecycle): array
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.',
message: $this->lifecycleMessage($lifecycle, 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.'),
substrateDecision: null,
);
}
@ -325,11 +317,29 @@ private function readOnlyDecision(string $actionKey, array $lifecycle): array
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Read-only history remains available under current RBAC.',
message: $this->lifecycleMessage($lifecycle, 'Read-only history remains available under current RBAC.'),
substrateDecision: null,
);
}
/**
* @param array<string, mixed> $lifecycle
*/
private function lifecycleMessage(array $lifecycle, string $message): string
{
return sprintf('%s Commercial source: %s.', $message, $this->commercialSourceDescriptor($lifecycle));
}
/**
* @param array<string, mixed> $lifecycle
*/
private function commercialSourceDescriptor(array $lifecycle): string
{
return ($lifecycle['source'] ?? null) === self::SOURCE_WORKSPACE_SUBSCRIPTION
? 'subscription-backed'
: 'fallback-backed';
}
/**
* @param array<string, mixed> $lifecycle
* @param array<string, mixed>|null $substrateDecision
@ -365,8 +375,34 @@ private function decision(
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
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)

View File

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\Workspace;
use App\Models\WorkspaceSubscription;
use App\Services\Settings\SettingsResolver;
use Carbon\CarbonInterface;
final class WorkspaceSubscriptionResolver
{
public const SOURCE_WORKSPACE_SUBSCRIPTION = 'workspace_subscription';
/**
* @return array<string, string>
*/
public static function stateLabels(): array
{
return [
WorkspaceSubscription::STATE_TRIAL => 'Trial',
WorkspaceSubscription::STATE_ACTIVE => 'Active',
WorkspaceSubscription::STATE_PAST_DUE => 'Past due',
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END => 'Cancellation pending',
WorkspaceSubscription::STATE_ENDED => 'Ended',
];
}
public function __construct(
private readonly SettingsResolver $settingsResolver,
) {}
/**
* @return array{
* workspace_id: int,
* subscription_present: bool,
* state: string|null,
* label: string|null,
* billing_reference: string|null,
* status_reason: string|null,
* key_date_label: string|null,
* key_date: CarbonInterface|null,
* needs_review: bool,
* source: string,
* fallback_status: bool,
* derived_lifecycle_state: string
* }
*/
public function summary(Workspace $workspace): array
{
$workspace->loadMissing('subscription');
$subscription = $workspace->subscription;
if ($subscription instanceof WorkspaceSubscription) {
return $this->subscriptionSummary($workspace, $subscription);
}
return $this->fallbackSummary($workspace);
}
/**
* @return array{
* workspace_id: int,
* subscription_present: bool,
* state: string,
* label: string,
* billing_reference: string|null,
* status_reason: string,
* key_date_label: string|null,
* key_date: CarbonInterface|null,
* needs_review: bool,
* source: string,
* fallback_status: bool,
* derived_lifecycle_state: string
* }
*/
private function subscriptionSummary(Workspace $workspace, WorkspaceSubscription $subscription): array
{
$state = in_array($subscription->state, WorkspaceSubscription::stateIds(), true)
? $subscription->state
: WorkspaceSubscription::STATE_ACTIVE;
$keyDate = $this->keyDateForSubscription($subscription, $state);
return [
'workspace_id' => (int) $workspace->getKey(),
'subscription_present' => true,
'state' => $state,
'label' => self::stateLabels()[$state],
'billing_reference' => $subscription->billing_reference,
'status_reason' => $subscription->status_reason,
'key_date_label' => $this->keyDateLabel($state),
'key_date' => $keyDate,
'needs_review' => $this->needsReview($state, $keyDate),
'source' => self::SOURCE_WORKSPACE_SUBSCRIPTION,
'fallback_status' => false,
'derived_lifecycle_state' => $this->derivedLifecycleState($state),
];
}
/**
* @return array{
* workspace_id: int,
* subscription_present: bool,
* state: null,
* label: null,
* billing_reference: null,
* status_reason: string|null,
* key_date_label: null,
* key_date: null,
* needs_review: bool,
* source: string,
* fallback_status: bool,
* derived_lifecycle_state: string
* }
*/
private function fallbackSummary(Workspace $workspace): array
{
$stateSetting = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$rawState = is_string($stateSetting['value'] ?? null)
? strtolower(trim((string) $stateSetting['value']))
: null;
$derivedLifecycleState = in_array($rawState, WorkspaceCommercialLifecycleResolver::stateIds(), true)
? $rawState
: WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID;
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
? WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING
: WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID;
$statusReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
return [
'workspace_id' => (int) $workspace->getKey(),
'subscription_present' => false,
'state' => null,
'label' => null,
'billing_reference' => null,
'status_reason' => is_string($statusReason) && trim($statusReason) !== '' ? trim($statusReason) : null,
'key_date_label' => null,
'key_date' => null,
'needs_review' => false,
'source' => $source,
'fallback_status' => true,
'derived_lifecycle_state' => $derivedLifecycleState,
];
}
private function derivedLifecycleState(string $state): string
{
return match ($state) {
WorkspaceSubscription::STATE_TRIAL => WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
WorkspaceSubscription::STATE_ACTIVE => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
WorkspaceSubscription::STATE_PAST_DUE => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
WorkspaceSubscription::STATE_ENDED => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
default => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
};
}
private function keyDateLabel(string $state): ?string
{
return match ($state) {
WorkspaceSubscription::STATE_TRIAL => 'Trial ends',
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
WorkspaceSubscription::STATE_ENDED => 'Current period ends',
default => null,
};
}
private function keyDateForSubscription(WorkspaceSubscription $subscription, string $state): ?CarbonInterface
{
return match ($state) {
WorkspaceSubscription::STATE_TRIAL => $subscription->trial_ends_at,
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
WorkspaceSubscription::STATE_ENDED => $subscription->current_period_ends_at,
default => null,
};
}
private function needsReview(string $state, ?CarbonInterface $keyDate): bool
{
if (! in_array($state, [WorkspaceSubscription::STATE_TRIAL, WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END], true)) {
return false;
}
return $keyDate instanceof CarbonInterface && $keyDate->isPast();
}
}

View File

@ -10,6 +10,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Models\WorkspaceSubscription;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
@ -19,6 +20,7 @@
use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
@ -135,6 +137,108 @@ public function updateWorkspaceCommercialLifecycle(
});
}
/**
* @param array<string, mixed> $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);
@ -288,6 +392,13 @@ private function authorizeManage(User $actor, Workspace $workspace): void
}
}
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()) {
@ -305,4 +416,19 @@ private function decodeStoredValue(mixed $value): mixed
return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
}
/**
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -61,6 +61,7 @@ enum AuditActionId: string
case WorkspaceSettingUpdated = 'workspace_setting.updated';
case WorkspaceSettingReset = 'workspace_setting.reset';
case WorkspaceSubscriptionUpdated = 'workspace_subscription.updated';
case BaselineProfileCreated = 'baseline_profile.created';
case BaselineProfileUpdated = 'baseline_profile.updated';
@ -218,6 +219,7 @@ private static function labels(): array
self::AlertRuleDisabled->value => 'Alert rule disabled',
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
self::WorkspaceSettingReset->value => 'Workspace setting reset',
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
self::BaselineProfileCreated->value => 'Baseline profile created',
self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived',
@ -325,6 +327,7 @@ private static function summaries(): array
self::PolicyProviderMissingCleared->value => 'Policy provider presence restored',
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
self::WorkspaceSettingReset->value => 'Workspace setting reset',
self::WorkspaceSubscriptionUpdated->value => 'Workspace subscription updated',
self::BaselineProfileCreated->value => 'Baseline profile created',
self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived',

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Workspace;
use App\Models\WorkspaceSubscription;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<WorkspaceSubscription>
*/
class WorkspaceSubscriptionFactory extends Factory
{
protected $model = WorkspaceSubscription::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$periodStartsAt = now()->subDays(1)->startOfMinute();
return [
'workspace_id' => Workspace::factory(),
'state' => WorkspaceSubscription::STATE_ACTIVE,
'billing_reference' => 'sub_'.fake()->bothify('????##'),
'trial_ends_at' => null,
'current_period_starts_at' => $periodStartsAt,
'current_period_ends_at' => $periodStartsAt->copy()->addDays(30),
'status_reason' => 'Subscription is current.',
];
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('workspace_subscriptions', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete()->unique();
$table->string('state');
$table->string('billing_reference', 191)->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('current_period_starts_at')->nullable();
$table->timestamp('current_period_ends_at')->nullable();
$table->text('status_reason');
$table->timestamps();
$table->index(['state', 'current_period_ends_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspace_subscriptions');
}
};

View File

@ -9,6 +9,7 @@
$runs = $this->recentRuns();
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
$commercialSourceDescriptor = ($commercialLifecycle['fallback_status'] ?? true) ? 'fallback-backed' : 'subscription-backed';
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
$reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null;
@ -56,7 +57,34 @@
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Current state</p>
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Workspace subscription</p>
<div class="mt-2 flex items-center gap-2">
<x-filament::badge :color="($commercialLifecycle['fallback_status'] ?? true) ? 'gray' : 'info'">
{{ $commercialSourceDescriptor }}
</x-filament::badge>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['subscription_state_label'] ?? 'No current subscription record' }}</span>
</div>
@if (($commercialLifecycle['subscription_key_date_label'] ?? null) !== null && ($commercialLifecycle['subscription_key_date'] ?? null) instanceof \Carbon\CarbonInterface)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ $commercialLifecycle['subscription_key_date_label'] }}: {{ $commercialLifecycle['subscription_key_date']->toDayDateTimeString() }}
</p>
@endif
@if (($commercialLifecycle['fallback_status'] ?? true) === false)
@if (($commercialLifecycle['subscription_needs_review'] ?? false) === true)
<p class="mt-2 text-sm text-warning-700 dark:text-warning-300">This subscription record needs review before the next commercial change is applied.</p>
@endif
@if (($commercialLifecycle['subscription_billing_reference'] ?? null) !== null)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Reference: {{ $commercialLifecycle['subscription_billing_reference'] }}</p>
@endif
@else
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No current subscription record is stored. The workspace still follows the explicit lifecycle fallback or the default active-paid posture.</p>
@endif
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Derived commercial lifecycle</p>
<div class="mt-2 flex items-center gap-2">
<x-filament::badge :color="$commercialBadge->color" :icon="$commercialBadge->icon">
{{ $commercialBadge->label }}
@ -64,11 +92,7 @@
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['source_label'] ?? 'default active paid' }}</span>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}</p>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Lifecycle rationale</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
<p class="mt-2 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $commercialLifecycle['last_changed_by'] ?? 'System default' }}
@if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface)

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Filament\System\Pages\Directory\ViewWorkspace;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\WorkspaceSubscription;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Workspaces\WorkspaceContext;
pest()->browser()->timeout(20_000);
it('smokes subscription truth mutation on the system page', function (): void {
$tenant = Tenant::factory()->create([
'name' => 'Browser Billing Truth Tenant',
]);
[$workspaceUser, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
auth('web')->logout();
$this->flushSession();
$this->actingAs($platformUser, 'platform');
$systemPage = visit(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]));
$systemPage
->waitForText('Workspace subscription')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('fallback-backed')
->assertSee('Update subscription truth')
->click('Update subscription truth')
->waitForText('This records the current subscription-backed commercial truth for the workspace and becomes the upstream lifecycle source while the record exists.');
$systemPage->script(<<<'JS'
(() => {
const field = (labelText) => {
const label = Array.from(document.querySelectorAll('label')).find((element) => element.textContent?.replace('*', '').trim() === labelText);
if (! label) {
return null;
}
const targetId = label.getAttribute('for');
if (targetId) {
return document.getElementById(targetId);
}
return label.parentElement?.querySelector('input, textarea, select') ?? null;
};
const state = field('Subscription state');
const billingReference = field('Billing reference');
const statusReason = field('Status reason');
if (! state || ! billingReference || ! statusReason) {
return false;
}
state.value = 'ended';
state.dispatchEvent(new Event('change', { bubbles: true }));
billingReference.value = 'sub_browser_truth_001';
billingReference.dispatchEvent(new Event('input', { bubbles: true }));
billingReference.dispatchEvent(new Event('change', { bubbles: true }));
statusReason.value = 'Browser smoke recorded subscription truth.';
statusReason.dispatchEvent(new Event('input', { bubbles: true }));
statusReason.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})();
JS);
$systemPage->script(<<<'JS'
(() => {
const confirmButton = Array.from(document.querySelectorAll('button')).find((element) => element.textContent?.trim() === 'Confirm');
confirmButton?.click();
})();
JS);
$systemPage
->waitForText('subscription-backed')
->assertSee('Ended')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
it('smokes the admin read-only commercial summary for subscription-backed truth', function (): void {
$tenant = Tenant::factory()->create([
'name' => 'Browser Billing Summary Tenant',
]);
[$workspaceUser, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
WorkspaceSubscription::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'state' => WorkspaceSubscription::STATE_ENDED,
'billing_reference' => 'sub_browser_truth_001',
'status_reason' => 'Browser smoke recorded subscription truth.',
]);
$this->actingAs($workspaceUser)->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
visit(WorkspaceSettings::getUrl(panel: 'admin'))
->waitForText('Commercial posture')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertSee('subscription-backed')
->assertSee('Ended')
->assertSee('Browser smoke recorded subscription truth.')
->assertDontSee('Update subscription truth');
});

View File

@ -6,6 +6,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Models\WorkspaceSubscription;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
@ -108,4 +109,28 @@ function entitlementSettingsManager(): array
->set('data.entitlements_review_pack_generation_override_reason', '')
->callAction('save')
->assertHasErrors(['data.entitlements_review_pack_generation_override_reason']);
});
it('shows a read-only subscription-backed commercial summary on workspace settings', function (): void {
[$workspace, $user] = entitlementSettingsManager();
$trialEndsAt = now()->addDays(14)->startOfMinute();
WorkspaceSubscription::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'state' => 'trial',
'billing_reference' => 'sub_trial_001',
'trial_ends_at' => $trialEndsAt,
'status_reason' => 'Trial access for onboarding.',
]);
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful()
->assertSee('Commercial posture')
->assertSee('subscription-backed')
->assertSee('Trial')
->assertSee('Trial access for onboarding.')
->assertSee('Trial ends')
->assertDontSee('Update subscription truth')
->assertDontSee('Change commercial state');
});

View File

@ -29,6 +29,7 @@ function readyOnboardingEntitlementContext(
?int $limitOverride = null,
?string $overrideReason = null,
?string $commercialState = null,
?array $subscription = null,
): array
{
Queue::fake();
@ -134,6 +135,21 @@ function readyOnboardingEntitlementContext(
);
}
if (is_array($subscription)) {
app(SettingsWriter::class)->updateWorkspaceSubscription(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $workspace,
attributes: $subscription,
);
}
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
@ -233,6 +249,28 @@ function readyOnboardingEntitlementContext(
->exists())->toBeTrue();
});
it('identifies subscription-backed commercial posture on the onboarding completion step', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
subscription: [
'state' => 'trial',
'billing_reference' => 'sub_trial_001',
'trial_ends_at' => now()->addDays(14)->startOfMinute()->toDateTimeString(),
'status_reason' => 'Trial access for onboarding.',
],
);
$context['component']
->assertSee('Activation entitlement')
->assertSee('Trial')
->assertSee('subscription-backed')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
});
it('blocks onboarding activation with a grace commercial-state reason before tenant mutation', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 0,
@ -242,6 +280,7 @@ function readyOnboardingEntitlementContext(
$context['component']
->assertSee('Activation entitlement')
->assertSee('Grace')
->assertSee('fallback-backed')
->assertSee('New managed-tenant activation is frozen while this workspace is in grace.')
->call('completeOnboarding');

View File

@ -129,6 +129,22 @@ function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, st
);
}
function setReviewPackSubscriptionState(Tenant $tenant, array $attributes): void
{
app(SettingsWriter::class)->updateWorkspaceSubscription(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
attributes: $attributes,
);
}
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
@ -245,7 +261,8 @@ function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, st
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Workspace is in grace. Review-pack starts remain available');
->assertSee('Workspace is in grace. Review-pack starts remain available')
->assertSee('Commercial source: fallback-backed.');
$pack = app(ReviewPackService::class)->generate($tenant, $user);
@ -256,6 +273,36 @@ function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, st
->exists())->toBeTrue();
});
it('labels subscription-backed review pack warnings when subscription truth drives grace behavior', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
setReviewPackSubscriptionState($tenant, [
'state' => 'past_due',
'billing_reference' => 'sub_past_due_001',
'current_period_starts_at' => now()->subDays(15)->startOfMinute()->toDateTimeString(),
'current_period_ends_at' => now()->addDays(15)->startOfMinute()->toDateTimeString(),
'status_reason' => 'Payment collection is pending.',
]);
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
expect($decision)
->toMatchArray([
'is_blocked' => false,
'is_warning' => true,
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SUBSCRIPTION,
])
->and($decision['warning_reason'])->toContain('Commercial source: subscription-backed.');
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Commercial source: subscription-backed.');
});
it('blocks suspended read-only review pack generation before creating a review pack or operation run and sends no run notifications', function (): void {
Notification::fake();

View File

@ -10,6 +10,7 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Models\WorkspaceSetting;
use App\Models\WorkspaceSubscription;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
@ -190,3 +191,120 @@
])
->assertHasActionErrors(['reason']);
});
it('creates subscription truth through the confirmed system action and renders subscription-backed detail', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Subscription Workspace']);
$operator = PlatformUser::factory()->create([
'name' => 'Platform Operator',
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
$trialEndsAt = now()->addDays(14)->startOfMinute();
Livewire::actingAs($operator, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->assertActionVisible('update_subscription_truth')
->assertActionExists('update_subscription_truth', fn (Action $action): bool => $action->getLabel() === 'Update subscription truth'
&& $action->isConfirmationRequired())
->callAction('update_subscription_truth', data: [
'state' => 'trial',
'billing_reference' => 'sub_trial_001',
'trial_ends_at' => $trialEndsAt->toDateTimeString(),
'current_period_starts_at' => null,
'current_period_ends_at' => null,
'status_reason' => 'Trial access for onboarding.',
])
->assertNotified('Subscription truth updated');
$subscription = WorkspaceSubscription::query()
->where('workspace_id', (int) $workspace->getKey())
->first();
expect($subscription)
->not->toBeNull()
->and($subscription?->state)->toBe('trial')
->and($subscription?->billing_reference)->toBe('sub_trial_001')
->and($subscription?->status_reason)->toBe('Trial access for onboarding.');
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace->fresh());
expect($summary)
->toMatchArray([
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SUBSCRIPTION,
'subscription_present' => true,
'subscription_state' => 'trial',
'subscription_state_label' => 'Trial',
'state' => WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
]);
$this->actingAs($operator, 'platform')
->get(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]))
->assertSuccessful()
->assertSee('Workspace subscription')
->assertSee('subscription-backed')
->assertSee('Trial access for onboarding.')
->assertSee('sub_trial_001')
->assertSee('Trial ends');
});
it('requires a trial end date before changing subscription truth to trial', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Trial Validation Workspace']);
$operator = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
Livewire::actingAs($operator, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->callAction('update_subscription_truth', data: [
'state' => 'trial',
'billing_reference' => 'sub_trial_missing_date',
'trial_ends_at' => null,
'current_period_starts_at' => null,
'current_period_ends_at' => null,
'status_reason' => 'Trial access needs an explicit end date.',
])
->assertHasActionErrors(['trial_ends_at']);
expect(WorkspaceSubscription::query()
->where('workspace_id', (int) $workspace->getKey())
->exists())->toBeFalse();
});
it('keeps manual lifecycle fallback controls only for fallback-backed workspaces', function (): void {
$workspace = Workspace::factory()->create();
$operator = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
Livewire::actingAs($operator, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace])
->assertActionVisible('update_subscription_truth')
->assertActionVisible('change_commercial_state');
WorkspaceSubscription::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'state' => 'active',
'current_period_starts_at' => now()->subDay(),
'current_period_ends_at' => now()->addDays(29),
'status_reason' => 'Annual plan is current.',
]);
Livewire::actingAs($operator, 'platform')
->test(ViewWorkspace::class, ['workspace' => $workspace->fresh()])
->assertActionVisible('update_subscription_truth')
->assertActionHidden('change_commercial_state');
});

View File

@ -7,6 +7,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Models\WorkspaceSubscription;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
@ -197,3 +198,30 @@ function setCommercialLifecycleState(Workspace $workspace, string $state, string
'is_warning' => false,
]);
});
it('prefers subscription truth over fallback lifecycle settings when a current subscription exists', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Legacy fallback state');
WorkspaceSubscription::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'state' => 'active',
'current_period_starts_at' => now()->subDay(),
'current_period_ends_at' => now()->addDays(29),
'status_reason' => 'Annual plan is current.',
]);
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
'source' => 'workspace_subscription',
'source_label' => 'workspace subscription',
])
->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);
});

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use App\Models\Workspace;
use App\Models\WorkspaceSubscription;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceSubscriptionResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('summarizes a trial subscription as subscription-backed truth', function (): void {
$workspace = Workspace::factory()->create();
$trialEndsAt = now()->addDays(14)->startOfMinute();
WorkspaceSubscription::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'state' => 'trial',
'trial_ends_at' => $trialEndsAt,
'status_reason' => 'Trial access for onboarding.',
]);
$summary = app(WorkspaceSubscriptionResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'workspace_id' => (int) $workspace->getKey(),
'subscription_present' => true,
'state' => 'trial',
'source' => 'workspace_subscription',
'fallback_status' => false,
'derived_lifecycle_state' => WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
'needs_review' => false,
'status_reason' => 'Trial access for onboarding.',
'key_date_label' => 'Trial ends',
])
->and($summary['key_date']?->toDateTimeString())
->toBe($trialEndsAt->toDateTimeString());
});
it('marks stale cancel-at-period-end subscriptions for explicit review', function (): void {
$workspace = Workspace::factory()->create();
$periodStartsAt = now()->subDays(30)->startOfMinute();
$periodEndsAt = now()->subDay()->startOfMinute();
WorkspaceSubscription::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'state' => 'cancel_at_period_end',
'current_period_starts_at' => $periodStartsAt,
'current_period_ends_at' => $periodEndsAt,
'status_reason' => 'Cancellation takes effect at period end.',
]);
$summary = app(WorkspaceSubscriptionResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'workspace_id' => (int) $workspace->getKey(),
'subscription_present' => true,
'state' => 'cancel_at_period_end',
'source' => 'workspace_subscription',
'fallback_status' => false,
'derived_lifecycle_state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
'needs_review' => true,
'status_reason' => 'Cancellation takes effect at period end.',
'key_date_label' => 'Current period ends',
])
->and($summary['key_date']?->toDateTimeString())
->toBe($periodEndsAt->toDateTimeString());
});

View File

@ -0,0 +1,54 @@
# Specification Quality Checklist: Billing & Subscription Truth Layer v1
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
**Created**: 2026-05-04
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The package stays on one bounded subscription truth follow-through over Specs 247 and 251 instead of inventing a billing engine, invoice layer, or customer portal.
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level implementation diff.
- [x] The package explicitly names the repo-real anchors it builds on: `WorkspaceEntitlementResolver`, `WorkspaceCommercialLifecycleResolver`, `ViewWorkspace`, `WorkspaceSettings`, onboarding activation, and review-pack generation.
- [x] Mandatory repo sections for scope, RBAC, shared-pattern reuse, testing, proportionality, and candidate rationale are completed.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain.
- [x] Requirements are testable and bounded to one current subscription record, one resolver, existing lifecycle gating, one system mutation surface, and one read-only admin summary.
- [x] The package makes fallback behavior explicit for workspaces without a subscription record.
- [x] The package forbids a second runtime gate and keeps onboarding or review-pack behavior on the existing lifecycle resolver.
- [x] Canonical proof commands match across `spec.md`, `plan.md`, `quickstart.md`, and `tasks.md`.
## Candidate Selection Gate
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and `docs/product/roadmap.md` as `Billing & Subscription Truth Layer v1`.
- [x] Related anchor specs were checked for completion or close-out signals and treated as context only: Specs 247 and 251 are already repo-real and implemented.
- [x] The chosen slice is smaller and more bounded than deferred alternatives such as provider sync, invoices, or a customer billing portal.
- [x] The selected slice explicitly closes the remaining manual-promotion gap between plan or lifecycle truth and durable subscription truth.
## Feature Readiness
- [x] The package justifies a new persisted entity and explains why more workspace-setting keys are insufficient.
- [x] The package keeps Filament on Livewire v4, provider registration unchanged in `apps/platform/bootstrap/providers.php`, global search unchanged, and assets unchanged.
- [x] The package keeps `/system` as the mutation plane and `/admin` read-only for subscription truth.
- [x] The package forbids provider-specific billing semantics, invoices, checkout, and portal scope.
## Test Governance
- [x] Planned proof stays bounded to one new `Unit` family plus focused extensions to existing `Feature` suites.
- [x] No new heavy-governance or browser family is introduced by default.
- [x] Fixture growth remains bounded to one new factory plus existing workspace, onboarding, and review-pack helpers.
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into `plan.md` and `tasks.md`.
## Notes
- Reviewed against `.specify/memory/constitution.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/247-plans-entitlements-billing-readiness/spec.md`, `specs/251-commercial-entitlements-billing-state/spec.md`, current entitlement and lifecycle code under `apps/platform`, and the active 274 prep artifacts on 2026-05-04.
- No application implementation was performed while preparing this package.
## Review Outcome
- **Outcome class**: `acceptable-special-case`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Reason**: The package promotes the remaining commercial manual-promotion gap as one bounded source-of-truth follow-through. It keeps lifecycle as the only runtime gate, adds only one current subscription entity, and stays off provider, invoice, and portal scope.
- **Workflow result**: Ready for implementation.

View File

@ -0,0 +1,481 @@
openapi: 3.0.3
info:
title: TenantPilot Admin/System - Workspace Billing & Subscription Truth (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the bounded billing and subscription truth follow-through
over existing entitlement and commercial lifecycle foundations.
NOTE: These routes are implemented as existing Filament pages, resources,
widgets, and Livewire-backed actions. Exact Livewire payload shapes are not
part of this contract. This file captures logical route boundaries, plane
separation, and the requirement that runtime gating still flows through the
shared commercial lifecycle resolver.
paths:
/directory/workspaces/{workspace}:
servers:
- url: /system
get:
summary: View current subscription truth and derived commercial posture in the system plane
parameters:
- $ref: '#/components/parameters/WorkspaceId'
responses:
'200':
description: System workspace detail rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/SystemWorkspaceSubscriptionView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/directory/workspaces/{workspace}/actions/update-subscription-truth:
servers:
- url: /system
post:
summary: Create or update the current workspace subscription truth from the system plane
description: |
Conceptual contract for the confirmation-protected system action that
creates or updates one current workspace subscription record.
Behavior:
- Platform user with directory visibility but without the dedicated
commercial-management capability: 403
- Wrong plane or non-platform actor: 404 semantics at the panel boundary
- Authorized platform user: writes the current subscription record and
audit trail, then updates the derived lifecycle source used by current
onboarding and review-pack flows
parameters:
- $ref: '#/components/parameters/WorkspaceId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateSubscriptionTruthCommand'
responses:
'204':
description: Current subscription truth updated successfully
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
$ref: '#/components/responses/ValidationError'
/directory/workspaces/{workspace}/actions/change-commercial-state:
servers:
- url: /system
post:
summary: Change the fallback manual commercial lifecycle state when no subscription record exists
description: |
Conceptual contract for the existing manual lifecycle action after this
slice lands.
Behavior:
- available only when the workspace has no current subscription record
- remains confirmation-protected
- preserves the current settings-backed fallback semantics from Spec 251
parameters:
- $ref: '#/components/parameters/WorkspaceId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- state
- reason
properties:
state:
type: string
reason:
type: string
responses:
'204':
description: Fallback lifecycle state changed successfully
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Current subscription truth exists, so fallback mutation is unavailable
/settings/workspace:
servers:
- url: /admin
get:
summary: View a read-only subscription-backed commercial summary on the admin workspace settings page
responses:
'200':
description: Workspace settings page rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/SettingsSubscriptionSummaryView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/onboarding/{onboardingDraft}:
servers:
- url: /admin
get:
summary: View onboarding workflow with subscription-backed lifecycle gating when a subscription exists
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'200':
description: Onboarding wizard rendered
content:
text/html:
schema:
type: string
x-logical-view-model:
$ref: '#/components/schemas/LifecycleDecisionView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/onboarding/{onboardingDraft}/actions/complete:
servers:
- url: /admin
post:
summary: Complete onboarding when entitlement substrate and derived lifecycle both allow it
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'204':
description: Onboarding completed
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/review-packs/actions/generate:
servers:
- url: /admin
post:
summary: Generate a review pack through the existing shared lifecycle gate
description: |
Behavior ordering:
1. authorization
2. underlying entitlement substrate decision
3. derived lifecycle decision, potentially sourced from current subscription truth
4. existing dedupe or queued-start flow when allowed
A blocked attempt creates no new `ReviewPack`, creates no new
`OperationRun`, and emits no queued or terminal review-pack notification.
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewPackGenerationCommand'
responses:
'202':
description: Generation accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/tenant-reviews/{tenantReview}/actions/export-executive-pack:
servers:
- url: /admin
post:
summary: Export an executive pack through the existing shared lifecycle gate
parameters:
- $ref: '#/components/parameters/TenantReviewId'
responses:
'202':
description: Export accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
/review-packs/{reviewPack}/actions/regenerate:
servers:
- url: /admin
post:
summary: Regenerate a review pack through the existing shared lifecycle gate
parameters:
- $ref: '#/components/parameters/ReviewPackId'
responses:
'202':
description: Regeneration accepted or deduped through the existing flow
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/BusinessStateBlocked'
components:
parameters:
WorkspaceId:
name: workspace
in: path
required: true
schema:
type: integer
OnboardingDraftId:
name: onboardingDraft
in: path
required: true
schema:
type: integer
TenantReviewId:
name: tenantReview
in: path
required: true
schema:
type: integer
ReviewPackId:
name: reviewPack
in: path
required: true
schema:
type: integer
responses:
Forbidden:
description: Actor is in-scope but missing the required capability
NotFound:
description: Wrong plane or non-member access is hidden as not found
ValidationError:
description: Submitted subscription truth is invalid for the chosen state
BusinessStateBlocked:
description: The actor is otherwise authorized, but the derived lifecycle blocks the action
schemas:
UpdateSubscriptionTruthCommand:
oneOf:
- $ref: '#/components/schemas/TrialSubscriptionCommand'
- $ref: '#/components/schemas/ActiveSubscriptionCommand'
- $ref: '#/components/schemas/PastDueSubscriptionCommand'
- $ref: '#/components/schemas/CancelAtPeriodEndSubscriptionCommand'
- $ref: '#/components/schemas/EndedSubscriptionCommand'
TrialSubscriptionCommand:
type: object
required:
- state
- trial_ends_at
- status_reason
properties:
state:
type: string
enum:
- trial
billing_reference:
type: string
nullable: true
trial_ends_at:
type: string
format: date-time
status_reason:
type: string
ActiveSubscriptionCommand:
type: object
required:
- state
- current_period_starts_at
- current_period_ends_at
- status_reason
properties:
state:
type: string
enum:
- active
billing_reference:
type: string
nullable: true
current_period_starts_at:
type: string
format: date-time
current_period_ends_at:
type: string
format: date-time
status_reason:
type: string
PastDueSubscriptionCommand:
type: object
required:
- state
- current_period_starts_at
- current_period_ends_at
- status_reason
properties:
state:
type: string
enum:
- past_due
billing_reference:
type: string
nullable: true
current_period_starts_at:
type: string
format: date-time
current_period_ends_at:
type: string
format: date-time
status_reason:
type: string
CancelAtPeriodEndSubscriptionCommand:
type: object
required:
- state
- current_period_starts_at
- current_period_ends_at
- status_reason
properties:
state:
type: string
enum:
- cancel_at_period_end
billing_reference:
type: string
nullable: true
current_period_starts_at:
type: string
format: date-time
current_period_ends_at:
type: string
format: date-time
status_reason:
type: string
EndedSubscriptionCommand:
type: object
required:
- state
- current_period_ends_at
- status_reason
properties:
state:
type: string
enum:
- ended
billing_reference:
type: string
nullable: true
current_period_ends_at:
type: string
format: date-time
status_reason:
type: string
WorkspaceSubscriptionSummary:
type: object
required:
- source
- fallback_status
- subscription_present
- derived_lifecycle_state
- needs_review
properties:
source:
type: string
enum:
- workspace_subscription
- workspace_setting
- default_active_paid
fallback_status:
type: boolean
subscription_present:
type: boolean
state:
type: string
nullable: true
label:
type: string
nullable: true
billing_reference:
type: string
nullable: true
status_reason:
type: string
nullable: true
key_date_label:
type: string
nullable: true
key_date:
type: string
format: date-time
nullable: true
needs_review:
type: boolean
derived_lifecycle_state:
type: string
SystemWorkspaceSubscriptionView:
allOf:
- $ref: '#/components/schemas/WorkspaceSubscriptionSummary'
- type: object
properties:
last_changed_at:
type: string
format: date-time
nullable: true
last_changed_by:
type: string
nullable: true
SettingsSubscriptionSummaryView:
type: object
required:
- source
- derived_lifecycle_state
- fallback_status
- state_label
- explanation
properties:
source:
type: string
enum:
- workspace_subscription
- workspace_setting
- default_active_paid
derived_lifecycle_state:
type: string
state_label:
type: string
explanation:
type: string
fallback_status:
type: boolean
key_date_label:
type: string
nullable: true
key_date:
type: string
format: date-time
nullable: true
LifecycleDecisionView:
type: object
required:
- lifecycle_state
- source
- outcome
- explanation
properties:
lifecycle_state:
type: string
source:
type: string
enum:
- workspace_subscription
- workspace_setting
- default_active_paid
outcome:
type: string
enum:
- allow
- warn
- block
explanation:
type: string
ReviewPackGenerationCommand:
type: object
additionalProperties: true

View File

@ -0,0 +1,172 @@
# Data Model: Billing & Subscription Truth Layer v1
**Date**: 2026-05-04
**Branch**: `274-billing-subscription-truth`
## Overview
This slice adds one new workspace-owned source of truth: a current subscription record. Existing plan profiles, entitlement overrides, and manual commercial lifecycle fallback remain in their current storage. The new record feeds the existing commercial lifecycle resolver when present.
## Persisted Truth
### 1. Workspace Subscription Aggregate
**Persistence**: New `workspace_subscriptions` table
**Ownership**: Workspace-owned
**Scope**: One current record per workspace
| Field | Type | Nullable | Validation | Notes |
|-------|------|----------|------------|-------|
| `id` | bigint | no | primary key | Internal record id |
| `workspace_id` | bigint | no | foreign key, unique | Enforces one current subscription record per workspace |
| `state` | string | no | must be one of `trial`, `active`, `past_due`, `cancel_at_period_end`, `ended` | Current subscription posture |
| `billing_reference` | string | yes | trimmed, max 191 chars | Optional contract, subscription, or invoice reference label |
| `trial_ends_at` | datetime | yes | required when `state=trial` | Current trial end date |
| `current_period_starts_at` | datetime | yes | required when `state` is `active`, `past_due`, or `cancel_at_period_end` | Current commercial period start |
| `current_period_ends_at` | datetime | yes | required when `state` is `active`, `past_due`, `cancel_at_period_end`, or `ended` | Current commercial period end or ended-on boundary |
| `status_reason` | text | no | required on every explicit mutation path | Operator-visible explanation |
| `created_at` | datetime | no | standard timestamps | Creation time |
| `updated_at` | datetime | no | standard timestamps | Latest mutation time |
**Write rules**:
- Mutation happens from the system plane only.
- `workspace_id` is immutable once the row exists.
- The record is updated in place in v1; no historical row chain is created.
- Audit history captures before and after values and actor attribution.
**Relationships**:
- `workspace_subscriptions.workspace_id` references `workspaces.id`.
- `Workspace` gains a singular subscription relationship.
## Existing Persisted Truth Reused
### 2. Workspace Entitlement Substrate
**Persistence**: Existing `workspace_settings` rows plus code-owned plan catalog
**Owner**: `WorkspaceEntitlementResolver`
This slice does not remodel:
- plan profile selection
- first-slice entitlement overrides
- first-slice entitlement usage summaries
These remain the substrate that lifecycle may restrict after subscription mapping.
### 3. Manual Lifecycle Fallback
**Persistence**: Existing `workspace_settings` rows from Spec 251
**Owner**: `WorkspaceCommercialLifecycleResolver`
Manual lifecycle state remains valid only as fallback when a workspace does not yet have a current subscription record.
## Code-Owned Truth
### 4. Subscription State Catalog Entry
**Persistence**: none, code-owned
**Ownership**: product runtime configuration
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | string | yes | Stable internal identifier |
| `label` | string | yes | Operator-facing label |
| `description` | string | yes | Short explanation for system and settings summaries |
| `derived_lifecycle_state` | string | yes | One of the existing Spec 251 lifecycle states |
| `needs_review_when_past_date` | bool | yes | Whether the record should surface explicit review-required wording when its key date is in the past |
**Behavior matrix**:
| Subscription state | Derived lifecycle state | Key date surfaced | Notes |
|--------------------|-------------------------|-------------------|-------|
| `trial` | `trial` | `trial_ends_at` | Current trial posture |
| `active` | `active_paid` | `current_period_ends_at` | Current paid period |
| `past_due` | `grace` | `current_period_ends_at` | Commercial grace posture |
| `cancel_at_period_end` | `active_paid` | `current_period_ends_at` | Still active, but cancellation is pending |
| `ended` | `suspended_read_only` | `current_period_ends_at` | Commercial access has ended |
## Derived Truth
### 5. Workspace Subscription Summary
**Persistence**: none, derived at runtime
**Owner**: `WorkspaceSubscriptionResolver`
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `workspace_id` | int | yes | Workspace being evaluated |
| `subscription_present` | bool | yes | Whether a current record exists |
| `state` | string | no | Current subscription state when present |
| `label` | string | no | Operator-facing state label |
| `billing_reference` | string | no | Optional reference |
| `status_reason` | string | no | Operator-visible explanation |
| `key_date_label` | string | no | `Trial ends` or `Current period ends` |
| `key_date` | datetime | no | Current relevant date |
| `needs_review` | bool | yes | True when a date-sensitive state is past its visible date |
| `source` | string | yes | One of `workspace_subscription`, `workspace_setting`, or `default_active_paid` |
| `fallback_status` | bool | yes | True when the summary is not backed by a current subscription record |
| `derived_lifecycle_state` | string | yes | Existing lifecycle state consumed downstream |
### 6. Effective Commercial Lifecycle Decision
**Persistence**: none, derived at runtime
**Owner**: existing `WorkspaceCommercialLifecycleResolver`
The lifecycle decision remains the shared gate shape from Spec 251, but its source changes:
- If a subscription record exists, the lifecycle source becomes `workspace_subscription`.
- If no subscription record exists, the current `workspace_setting` or `default_active_paid` source remains.
**Ordering rules**:
1. Resolve the underlying entitlement substrate.
2. Resolve the lifecycle source from subscription truth when present, otherwise from fallback manual lifecycle truth.
3. If the substrate already blocks the action, keep the substrate block.
4. If the substrate allows the action, apply the lifecycle outcome from the resolved lifecycle state.
## Supporting Derived View Models
### 7. System Workspace Subscription View Model
**Consumer**: `ViewWorkspace`
Contains:
- current subscription summary
- derived lifecycle summary
- fallback indicator when no subscription exists
- last-change attribution
- mutation affordance metadata for `Update subscription truth`
### 8. Workspace Settings Subscription Summary View Model
**Consumer**: `WorkspaceSettings`
Contains:
- current commercial posture
- whether it is subscription-backed or fallback-backed
- next relevant date
- concise explanation only
## State Transitions
There is no multi-row ledger in v1. State changes are explicit updates to the current workspace subscription record plus audit entries.
| From | To | Trigger | Consequence |
|------|----|---------|-------------|
| no record | any valid state | platform operator creates current subscription truth | workspace becomes subscription-backed |
| `trial` | `active` | platform operator transition | derived lifecycle moves from `trial` to `active_paid` |
| `active` | `past_due` | platform operator transition | derived lifecycle moves to `grace` |
| `active` | `cancel_at_period_end` | platform operator transition | derived lifecycle stays `active_paid`, but period end becomes important context |
| `past_due` | `ended` | platform operator transition | derived lifecycle moves to `suspended_read_only` |
| any state | any other valid state | platform operator update | current subscription truth changes in place and is auditable |
## Boundaries Explicitly Preserved
- No invoice, payment, or provider-sync persistence exists in this slice.
- No multi-record historical subscription ledger exists in this slice.
- No direct subscription gate shape exists on onboarding or review-pack surfaces; lifecycle remains the only gate.
- Existing view and download access to already-generated review packs, evidence, and review history stays governed by the current lifecycle and RBAC rules.

View File

@ -0,0 +1,309 @@
# Implementation Plan: Billing & Subscription Truth Layer v1
**Branch**: `274-billing-subscription-truth` | **Date**: 2026-05-04 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/274-billing-subscription-truth/spec.md`
## Summary
Prepare one bounded billing and subscription truth follow-through on top of the already-real Specs 247 and 251. The narrow implementation path is to introduce one workspace-owned current subscription record, feed it into the existing commercial lifecycle resolver when present, keep the current lifecycle resolver as the only runtime gate, and surface the resulting truth on the existing system workspace detail page plus a read-only summary on workspace settings.
This slice stays explicitly narrow. Filament remains v5 on Livewire v4, panel-provider registration stays in `apps/platform/bootstrap/providers.php`, no new globally searchable resource is introduced, and no asset registration change is expected. The plan does not reopen plan-profile or lifecycle foundations, does not introduce payment providers, invoices, checkout, a customer portal, or a second runtime gate, and does not add a new `OperationRun` family.
## Inherited Baseline / Explicit Delta
### Inherited baseline
- `WorkspacePlanProfileCatalog` and `WorkspaceEntitlementResolver` already provide bounded plan defaults, override logic, and the current managed-tenant or review-pack entitlement truth from Spec 247.
- `WorkspaceCommercialLifecycleResolver` already provides the current shared lifecycle decision, mapping into onboarding activation and review-pack start behavior from Spec 251.
- `ViewWorkspace` already exposes the current commercial and entitlement truth on the system workspace detail page and already owns the current bounded commercial mutation surface.
- `WorkspaceSettings` already provides the singleton admin-plane settings surface where commercial truth can be summarized without introducing a second management plane.
- `ManagedTenantOnboardingWizard` and `ReviewPackService` already consult shared commercial lifecycle truth before their high-impact start or activation flows.
- Existing tests under `tests/Unit/Entitlements/`, `tests/Feature/System/`, `tests/Feature/Filament/Settings/`, `tests/Feature/Onboarding/`, and `tests/Feature/ReviewPack/` already prove the current entitlement and lifecycle seams.
### Explicit delta in this plan
- Add one workspace-owned current subscription record with bounded state and current-period fields.
- Add one bounded `WorkspaceSubscriptionResolver` that exposes current subscription truth, next relevant date, fallback status, and lifecycle mapping.
- Refactor `WorkspaceCommercialLifecycleResolver` to prefer subscription truth when a subscription record exists and to preserve the current fallback path when it does not.
- Replace or narrow the current system detail mutation action so platform operators manage subscription truth rather than relying on manual lifecycle posture for subscription-backed workspaces.
- Add a read-only subscription summary to `WorkspaceSettings` so workspace operators can see the current commercial posture without gaining billing controls.
- Keep onboarding and review-pack surfaces on the existing shared lifecycle gate and only change the upstream source behind that decision.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `WorkspaceEntitlementResolver`, existing `WorkspaceCommercialLifecycleResolver`, existing workspace audit foundation, existing onboarding and review-pack flows
**Storage**: PostgreSQL via a new workspace-owned `workspace_subscriptions` table plus existing `workspace_settings`, `audit_logs`, and tenant-owned artifact tables
**Testing**: Pest v4 `Unit` plus focused `Feature` coverage
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in `apps/platform`, reusing the existing system and admin Filament pages
**Project Type**: Web application (Laravel monolith with Filament panels)
**Performance Goals**: no new queue family, no new Graph calls, and DB-only lifecycle or subscription resolution for current workspace pages
**Constraints**: no payment provider integration, no invoice or checkout flow, no customer portal, no second gate outside `WorkspaceCommercialLifecycleResolver`, no new panel, no global-search change, and no asset-registration change
**Scale/Scope**: 1 new workspace-owned entity, 1 new resolver, 2 existing page surfaces, and focused extensions to 4 existing runtime gate families
## Likely Affected Repo Surfaces
- `apps/platform/app/Models/Workspace.php` for the new workspace-owned subscription relationship.
- `apps/platform/app/Models/WorkspaceSubscription.php` as the new current subscription model.
- `apps/platform/database/migrations/*_create_workspace_subscriptions_table.php` for new persistence.
- `apps/platform/database/factories/WorkspaceSubscriptionFactory.php` for bounded test setup.
- `apps/platform/app/Services/Entitlements/WorkspaceSubscriptionResolver.php` as the new bounded commercial source resolver.
- `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` for subscription-first precedence and fallback retention.
- `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` for subscription detail rendering and the bounded mutation action.
- `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` for the read-only subscription summary.
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and current audit action IDs or metadata for subscription-change attribution.
- Existing tests under `apps/platform/tests/Unit/Entitlements/`, `apps/platform/tests/Feature/System/`, `apps/platform/tests/Feature/Filament/Settings/`, `apps/platform/tests/Feature/Onboarding/`, and `apps/platform/tests/Feature/ReviewPack/`.
## Commercial Truth Fit
- Treat the subscription record as a distinct current-release source of truth, not as more workspace-setting keys.
- Keep exactly one current record per workspace in v1. Historical change tracking comes from audit rather than a multi-row subscription ledger.
- Keep subscription state and lifecycle state distinct:
- subscription state answers the current commercial source truth
- lifecycle state remains the shared runtime gate for onboarding and review-pack flows
- Use subscription truth to derive lifecycle when present; otherwise preserve the current manual lifecycle fallback.
- Keep period-end and trial-end dates visible and explicit, but do not add timer-driven or webhook-driven automation in this slice.
## UI / Filament & Livewire Fit
- Existing operator-facing surfaces remain native Filament surfaces under Livewire v4, and this slice should stay inside those surfaces instead of introducing a new billing console.
- No new Filament resource or globally searchable resource is required; the current system workspace detail page and workspace settings singleton page are sufficient.
- The system page keeps one dominant action: update current subscription truth, and that action remains confirmation-protected. The current manual lifecycle action stays available only for fallback-backed workspaces and must remain explicitly secondary and bounded.
- Workspace settings gains a read-only summary only. No subscription mutation controls appear on `/admin`.
- Existing onboarding and review-pack action families remain in place and only change their shared upstream lifecycle source.
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, and no new asset strategy is planned. If any shared asset later becomes necessary, deployment remains the normal `cd apps/platform && php artisan filament:assets` path.
## RBAC / Policy Fit
- Workspace and tenant membership remain isolation boundaries. Wrong-plane or non-member access stays 404; in-scope actors missing capability stay 403.
- Platform-only subscription mutation should reuse the current dedicated commercial-management capability rather than adding a new capability family unless implementation proves a narrower rename is required.
- Workspace settings visibility remains read-only on the admin plane and reuses existing settings-view capability checks.
- Onboarding and review-pack surfaces keep their existing capability checks. Subscription truth must never bypass or replace those RBAC rules.
- Business-state blocking stays inside the lifecycle decision path and must remain distinguishable from authorization failure.
## Audit / Logging Fit
- Every subscription-truth create or update must write an audit event with old state, new state, actor, and status reason.
- Existing audit infrastructure should be reused rather than introducing a second billing audit subsystem.
- The system detail summary should derive last-change attribution from the current record plus audit truth.
- Blocked onboarding or review-pack attempts still do not need a new blocked-attempt audit family in this slice; existing behavior remains authoritative.
## Data & Query Fit
- `workspace_subscriptions.workspace_id` must be NOT NULL and unique so the slice stays on one current record per workspace.
- Keep the new table small and bounded. Current-release fields should be limited to:
- `state`
- `billing_reference`
- `trial_ends_at`
- `current_period_starts_at`
- `current_period_ends_at`
- `status_reason`
- timestamps
- Do not add invoice rows, event logs, sync cursors, provider account IDs, or multi-subscription history in v1.
- `WorkspaceCommercialLifecycleResolver` remains the single consumer-facing gate and should depend on the new resolver instead of scattering query logic into onboarding or review-pack code.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: system detail summaries, settings summaries, lifecycle messaging, action gating, audit-backed commercial truth
- **State layers in scope**: page, detail
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
- **Raw/support gating plan**: raw reference detail remains secondary and platform-only
- **One-primary-action / duplicate-truth control**: system detail keeps one dominant commercial mutation action; settings remains read-only; onboarding and review-pack surfaces show only the lifecycle result needed for the immediate action
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory now; future hard-stop candidate if a second billing surface or gate appears
- **Special surface test profiles**: standard-native-filament, shared-detail-family, monitoring-state-page
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none planned; any broader portal, provider sync, or browser-heavy proof demand is out-of-scope drift
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `WorkspaceCommercialLifecycleResolver`, `WorkspaceEntitlementResolver`, `ViewWorkspace`, `WorkspaceSettings`, onboarding, review-pack start paths, and audit logging
- **Shared abstractions reused**: current lifecycle resolver, current entitlement resolver, current audit path, current Filament action surfaces, and current review-pack start UX
- **New abstraction introduced? why?**: one bounded `WorkspaceSubscriptionResolver`, because current repo truth has no distinct subscription source yet and several existing surfaces need the same mapping or fallback semantics
- **Why the existing abstraction was sufficient or insufficient**: current lifecycle and entitlement abstractions are sufficient for runtime gating but insufficient for current subscription truth, period dates, fallback signaling, and future sync readiness
- **Bounded deviation / spread control**: no local or second subscription decision helper is allowed outside the shared resolver path
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, by reuse only
- **Central contract reused**: current review-pack start UX remains authoritative
- **Delegated UX behaviors**: queued toast, dedupe behavior, and canonical run links stay unchanged when the derived lifecycle allows the start action
- **Surface-owned behavior kept local**: the system page owns subscription edit inputs; workspace settings owns the read-only summary; onboarding and review-pack surfaces own only the immediate lifecycle explanation
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: none in this slice
- **Platform-core seams**: subscription truth, lifecycle mapping, fallback visibility, and commercial audit
- **Neutral platform terms / contracts preserved**: `subscription`, `commercial posture`, `trial`, `past due`, `cancel at period end`, `ended`, `fallback lifecycle`
- **Retained provider-specific semantics and why**: none
- **Bounded extraction or follow-up path**: future provider sync only if later explicitly specced
## Constitution Check
*GATE: Must pass before implementation begins and again before merge.*
- Inventory-first / snapshot truth: PASS. This slice adds workspace-owned commercial truth only and does not reinterpret tenant inventory or snapshot semantics.
- Read/write separation: PASS. The only mutation is bounded commercial metadata on the current system workspace detail surface.
- Graph contract path: PASS. No Graph calls or provider contract changes are introduced.
- Deterministic capabilities: PASS. Existing capability registries remain authoritative.
- Workspace and tenant isolation: PASS. Existing 404 and 403 semantics remain unchanged.
- RBAC-UX plane separation: PASS. Mutation stays on `/system`; `/admin` remains read-only or contextual.
- Destructive action discipline: PASS. No new destructive action is planned; any high-impact mutation stays confirmation-protected.
- Global search safety: PASS. No new searchable resource is introduced.
- OperationRun / Ops-UX: PASS by reuse only. No new run family or new run-triggering surface is added.
- Data minimization: PASS. Current subscription truth stays bounded and no provider payloads or payment data are stored.
- Test governance: PASS. Proof remains in focused unit plus feature lanes.
- Proportionality / no premature abstraction: PASS. One new entity and one new resolver are the narrowest path that avoids further truth duplication.
- Persisted truth: PASS. The new table represents real product truth with an independent lifecycle and audit need.
- Behavioral state: PASS. Subscription states change derived lifecycle and operator behavior.
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing Filament surfaces and the current lifecycle gate remain the shared path.
- Provider boundary: PASS. No provider-specific vocabulary is introduced.
- Filament / Laravel planning contract: PASS. Filament stays v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, no globally searchable resource is added, and no asset registration change is planned.
**Gate evaluation**: PASS.
**Post-design re-check**: PASS. `research.md`, `data-model.md`, `quickstart.md`, `contracts/workspace-billing-subscription-truth.logical.openapi.yaml`, `checklists/requirements.md`, and `tasks.md` are present and aligned with the package.
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for subscription validation, mapping, and fallback precedence; `Feature` for system detail, settings summary, onboarding, and review-pack runtime continuity
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: the slice reuses native Filament and current gate seams, so focused unit and feature tests can prove the new truth without browser automation
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; one new factory is needed, but current workspace, onboarding, review-pack, and system-user fixtures can stay reused
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none planned
- **Surface-class relief / special coverage rule**: standard-native-filament relief for system and settings surfaces; monitoring-state-page coverage for review-pack blocked-start semantics
- **Closing validation and reviewer handoff**: reviewers should rely on the exact commands above, confirm that no second runtime gate or second management plane appears, verify that blocked review-pack starts still create no run, and verify that existing review-pack view or download access remains unchanged
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
- **Review-stop questions**: did the slice add a second gate, did it widen into provider sync or invoices, did it leave fallback behavior ambiguous, and did it preserve 404 or 403 semantics
- **Escalation path**: `document-in-feature` for contained naming drift; `reject-or-split` if the slice widens into a billing engine, portal, or second gate
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: routine subscription-truth upkeep should stay inside this feature unless future provider sync or invoice history is explicitly promoted as a separate slice
- **Test-governance outcome**: keep
## Review Checklist Status
- **Review checklist artifact**: `checklists/requirements.md`
- **Review outcome class**: `acceptable-special-case`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Escalation rule**: if implementation adds provider sync, invoice persistence, or a second runtime gate, flip the workflow outcome to `split` before continuing
## Rollout Considerations
- Land the new entity and resolver first, then refactor lifecycle precedence, then update the system detail page, and only then add the read-only settings summary.
- Keep the current manual lifecycle path explicit as fallback-backed so workspaces without a subscription record do not regress.
- Keep onboarding and review-pack behavior untouched except for the new upstream lifecycle source.
- Keep Filament v5 on Livewire v4, keep provider registration in `apps/platform/bootstrap/providers.php`, keep global search unchanged, and keep assets unchanged.
## Risk Controls
- Reject any implementation that adds payment-provider adapters, webhook handlers, invoice rows, or a customer billing portal.
- Reject any implementation that adds direct subscription checks to onboarding or review-pack surfaces instead of routing through lifecycle.
- Reject any implementation that makes `/admin` a second commercial mutation plane.
- Reject any implementation that turns one current subscription record into a broad ledger or history console in this slice.
- Reject browser-heavy proof as the default validation lane.
## Research & Design Outputs
- `research.md` should resolve the key design choices: dedicated table versus settings keys, subscription-first lifecycle precedence, read-only admin summary, and strict non-goals around providers or invoices.
- `data-model.md` should record the new entity, field validation, state mapping, uniqueness constraints, and derived lifecycle summary shape.
- `quickstart.md` should provide the bounded implementation order, reviewer scenarios, explicit confirmation expectations for the system mutation, and focused validation commands.
- `contracts/workspace-billing-subscription-truth.logical.openapi.yaml` should capture the logical route and action boundaries for the current system and admin surfaces plus the unchanged downstream gate semantics.
- `checklists/requirements.md` should record the prep-time review outcome, workflow outcome, and test-governance outcome.
- `tasks.md` should keep the implementation bounded to the current surfaces and the current lifecycle gate.
## Project Structure
### Documentation (this feature)
```text
specs/274-billing-subscription-truth/
├── checklists/
│ └── requirements.md
├── contracts/
│ └── workspace-billing-subscription-truth.logical.openapi.yaml
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
└── tasks.md
```
### Source Code (expected implementation surfaces)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ └── Settings/
│ │ │ └── WorkspaceSettings.php
│ │ ├── System/Pages/Directory/
│ │ │ └── ViewWorkspace.php
│ │ └── Pages/Workspaces/
│ │ └── ManagedTenantOnboardingWizard.php
│ ├── Models/
│ │ ├── Workspace.php
│ │ └── WorkspaceSubscription.php
│ ├── Services/
│ │ ├── Audit/WorkspaceAuditLogger.php
│ │ └── Entitlements/
│ │ ├── WorkspaceCommercialLifecycleResolver.php
│ │ └── WorkspaceSubscriptionResolver.php
│ └── Support/
│ └── Auth/
│ └── PlatformCapabilities.php
├── database/
│ ├── factories/
│ │ └── WorkspaceSubscriptionFactory.php
│ └── migrations/
│ └── *_create_workspace_subscriptions_table.php
└── tests/
├── Unit/Entitlements/
│ └── WorkspaceSubscriptionResolverTest.php
└── Feature/
├── Filament/Settings/
│ └── WorkspaceEntitlementsSettingsPageTest.php
├── Onboarding/
│ └── ManagedTenantOnboardingEntitlementTest.php
├── ReviewPack/
│ ├── ReviewPackEntitlementEnforcementTest.php
│ ├── ReviewPackGenerationTest.php
│ └── ReviewPackDownloadTest.php
├── Reviews/
│ └── CustomerReviewWorkspacePackAccessTest.php
└── System/
├── Spec113/AuthorizationSemanticsTest.php
└── ViewWorkspaceEntitlementsTest.php
```
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New persisted subscription entity | Current commercial truth now needs its own lifecycle, dates, and audit trail | More settings keys would keep subscription truth blurred with fallback lifecycle and plan settings |
## Proportionality Review
- **Current operator problem**: support and commercial operators still cannot point to one durable source that explains why a workspace is trial, paid, overdue, cancellation-pending, or ended
- **Existing structure is insufficient because**: plan settings and manual lifecycle state together still do not provide a distinct subscription record with its own lifecycle and current-period fields
- **Narrowest correct implementation**: one current subscription record plus one resolver layered into the current lifecycle path
- **Ownership cost**: one new entity, one migration, one resolver, one read-only admin summary, one system mutation surface, and focused tests
- **Alternative intentionally rejected**: more workspace settings keys or a broad billing engine
- **Release truth**: current-release truth only; provider sync, invoices, and portal work remain follow-ups

View File

@ -0,0 +1,120 @@
# Quickstart: Billing & Subscription Truth Layer v1
**Date**: 2026-05-04
**Branch**: `274-billing-subscription-truth`
This quickstart is the intended reviewer flow after implementation. It stays bounded to current subscription truth, lifecycle derivation, and the existing onboarding or review-pack gates.
## Prerequisites
1. Start the local platform stack.
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
2. Ensure one platform user has directory visibility plus the current dedicated commercial-management capability.
3. Ensure one workspace member can view workspace settings, one operator can complete onboarding, and one reporting operator can manage review-pack generation.
4. Seed or factory-create:
- one workspace with no subscription record and no explicit manual lifecycle override
- one workspace with a current subscription record in `trial`
- one workspace with a current subscription record in `past_due`
- one onboarding draft in a subscription-backed workspace
- one tenant with an existing review and generated review pack in a subscription-backed workspace
## Scenario 1: Record or update current subscription truth from the system plane
1. Open `/system/directory/workspaces/{workspace}` as the authorized platform user.
2. Confirm the page shows:
- current subscription status or an explicit fallback indicator when no subscription record exists
- derived commercial posture
- next relevant date
- reference and last changed attribution
3. Use `Update subscription truth` to save a `trial` record with `trial_ends_at` and status reason.
4. Confirm the page updates immediately and the derived lifecycle resolves to `trial`.
5. Repeat with `active`, `past_due`, `cancel_at_period_end`, and `ended`.
6. Backdate a `trial` or `cancel_at_period_end` date and confirm the system page surfaces that the subscription record needs review without auto-transitioning state.
7. Confirm every explicit update requires status reason, explicit confirmation, and remains auditable.
## Scenario 2: Preserve fallback behavior when no subscription record exists
1. Use a workspace with no current subscription record.
2. Confirm the system detail page marks the commercial posture as fallback-backed.
3. If the workspace has an explicit manual lifecycle state from current repo truth, confirm that state still drives the derived lifecycle.
4. If the workspace has no explicit lifecycle state either, confirm the workspace still resolves to the current default posture of `active_paid`.
5. Confirm the fallback `Change commercial state` action remains available only while no subscription record exists.
6. Confirm no subscription-specific mutation or admin-plane readout leaks outside the current workspace and platform scopes.
## Scenario 3: Keep onboarding activation on the existing lifecycle gate
1. Open `/admin/onboarding/{onboardingDraft}` for a workspace with an `active` subscription and an allowed entitlement substrate.
2. Confirm the completion step allows `Complete onboarding` and that the commercial explanation is subscription-backed.
3. Switch the same workspace to `past_due` from the system plane.
4. Refresh the onboarding draft and confirm:
- the action remains visible for an otherwise authorized actor
- the step explains the grace posture as a business-state block
- no tenant activation occurs
5. Repeat with a workspace that has no subscription record but does have a fallback manual lifecycle block and confirm the step explains the fallback source rather than pretending a subscription exists.
## Scenario 4: Keep review-pack generation on the existing lifecycle gate
1. Use a workspace with an `active` subscription where the underlying review-pack entitlement allows generation.
2. Trigger the current start family from:
- the tenant dashboard review-pack card
- the review register export action
- the tenant review detail export action
- the review-pack detail regenerate action
3. Confirm the existing queued-start UX remains unchanged when allowed.
4. Change the same workspace to `ended` from the system plane.
5. Repeat the same start actions and confirm:
- each surface shows the same lifecycle-based reason
- no new `ReviewPack` row is created
- no new `OperationRun` row is created
- no queued or terminal review-pack notification is emitted for the blocked attempt
6. Confirm a workspace without a subscription record still follows its fallback lifecycle state exactly as before.
## Scenario 5: Preserve current review-pack and customer-workspace access
1. Keep a workspace in a state where review-pack generation is blocked.
2. Open an already-generated review pack through the current detail or download path.
3. Confirm the artifact is still viewable or downloadable under current RBAC.
4. Open the current customer-workspace pack access path for the same workspace.
5. Confirm current read-only pack access remains unchanged by the new subscription truth layer.
## Scenario 6: Show a read-only commercial summary on workspace settings
1. Open `/admin/settings/workspace` as an authorized workspace manager.
2. Confirm the page shows:
- the current commercial posture
- whether the posture is subscription-backed or fallback-backed
- the next relevant date when present
- a concise explanation without mutation controls
3. Switch the current subscription state from the system plane and refresh the settings page.
4. Confirm the read-only summary updates accordingly.
## RBAC and Plane Semantics Checks
1. Access subscription mutation from `/admin` and confirm there is no self-service control surface.
2. Access `/system/directory/workspaces/{workspace}` as a platform user lacking the dedicated capability and confirm authorization is enforced without leaking admin-plane truth.
3. Access onboarding or review-pack surfaces as a non-member or wrong-plane actor and confirm 404.
4. Access the same surfaces as an established-scope actor lacking the relevant capability and confirm 403.
5. Access the action as an otherwise authorized actor whose workspace is blocked by the derived lifecycle and confirm a truthful business-state block instead of 403 or 404.
## Targeted Validation Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Out of Scope Confirmations
While validating this slice, confirm that the implementation does not add or imply:
- payment-provider credentials, invoices, checkout, taxes, or public pricing UI
- customer-account, contract, or CRM models
- webhook or schedule-driven state transitions
- a second admin-plane commercial management surface
- a second runtime gate outside `WorkspaceCommercialLifecycleResolver`
- a multi-row subscription ledger or history browser

View File

@ -0,0 +1,59 @@
# Research: Billing & Subscription Truth Layer v1
**Date**: 2026-05-04
**Branch**: `274-billing-subscription-truth`
## Decision 1: Use a dedicated workspace-owned subscription table, not more workspace-setting keys
- **Decision**: Persist current subscription truth in a new `workspace_subscriptions` table with one current row per workspace.
- **Rationale**: Subscription truth now has its own lifecycle, date fields, audit trail, and future sync seam. Keeping it inside `WorkspaceSetting` would continue to blur fallback lifecycle truth, plan settings, and current subscription truth into one settings bucket.
- **Alternatives considered**:
- More keys under `entitlements.*`: rejected because the record now has independent lifecycle meaning and no longer behaves like a small plan override.
- Broad customer-account or billing model: rejected because the current release needs one current subscription record only, not a full billing domain.
## Decision 2: Keep `WorkspaceCommercialLifecycleResolver` as the only runtime gate
- **Decision**: `WorkspaceCommercialLifecycleResolver` remains the one gate consulted by onboarding and review-pack surfaces.
- **Rationale**: Specs 247 and 251 already proved the current gate shape. Introducing direct subscription checks on onboarding or review-pack surfaces would create a second gate and immediate drift.
- **Alternatives considered**:
- Direct subscription checks on each surface: rejected because that would duplicate lifecycle logic and blur business-state versus entitlement-state reasoning.
- Leaving lifecycle fully manual even after adding a subscription record: rejected because it would leave two competing commercial truths alive at once.
## Decision 3: Subscription truth becomes the upstream lifecycle source when present
- **Decision**: When a workspace has a current subscription record, lifecycle state is derived from that record. When no subscription record exists, the current manual lifecycle overlay remains the fallback source.
- **Rationale**: This keeps current behavior stable for untouched workspaces while letting new subscription-backed workspaces stop relying on manual lifecycle state.
- **Alternatives considered**:
- Force every workspace to get a subscription record immediately: rejected because the repo already has live manual lifecycle semantics and the narrow slice should not require a bulk migration workflow.
- Add subscription truth as read-only evidence without feeding lifecycle: rejected because it would preserve duplicated truths and force operators to reconcile them manually.
## Decision 4: Use one current record and audit as history
- **Decision**: V1 stores one current subscription record per workspace and relies on `AuditLog` for change history.
- **Rationale**: The current operator problem is understanding the current posture and driving the current lifecycle gate, not browsing historical subscription revisions in-product.
- **Alternatives considered**:
- Multi-row history table or event ledger: rejected because the current slice would become a billing-history feature rather than a source-of-truth follow-through.
- No history beyond the row itself: rejected because auditability is required for commercial truth changes.
## Decision 5: Keep the current mutation surface on the system workspace detail page
- **Decision**: Reuse `ViewWorkspace` for subscription mutation and keep `WorkspaceSettings` read-only.
- **Rationale**: Specs 247 and 251 already separated admin-plane self-understanding from platform-plane commercial control. This slice should preserve that separation.
- **Alternatives considered**:
- Add an admin-plane subscription form: rejected because it would create a second commercial control plane.
- Add a new system billing page: rejected because the existing workspace detail page is already the commercial-truth drilldown.
## Decision 6: No automatic timers, provider sync, or invoice logic in v1
- **Decision**: Period dates are visible and testable, but the product does not auto-transition subscription state, sync from an external system, or introduce invoices in this slice.
- **Rationale**: The current need is durable commercial truth and one shared runtime source, not an automation engine.
- **Alternatives considered**:
- Webhook or provider sync placeholders: rejected because they would widen the slice into provider-boundary work.
- Automatic transitions when trial or period end passes: rejected because the repo has no current scheduling or operator-review contract for that behavior.
## Final Research Outcome
- Current-release truth requires a dedicated current subscription record.
- Runtime gating remains on the existing lifecycle resolver.
- The implementation should stay on the current system and admin surfaces.
- No provider, invoice, portal, or automation work is needed for this slice.

View File

@ -0,0 +1,333 @@
# Feature Specification: Billing & Subscription Truth Layer v1
**Feature Branch**: `274-billing-subscription-truth`
**Created**: 2026-05-04
**Status**: Ready for implementation
**Input**: User description: "Promote the remaining commercial follow-through beyond Specs 247 and 251 into one bounded billing/subscription truth layer. Keep current plan profiles, entitlement gating, and commercial lifecycle UX intact where already real. Add one durable subscription source of truth that can explain why a workspace is trial, paid, overdue, cancellation-pending, or ended without introducing a billing engine, payment-provider integration, or customer portal."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has plan profiles, entitlement gates, and a manual commercial lifecycle overlay, but it still has no durable subscription record that explains why a workspace is trial, active paid, in grace, or suspended read-only.
- **Today's failure**: Platform operators can manually set lifecycle posture on the system workspace page, yet the product still cannot answer whether that posture is backed by a current subscription, what billing period is active, when trial ends, or when cancellation should take effect.
- **User-visible improvement**: Platform operators can record one current subscription truth once, workspace operators can inspect a calm read-only summary, and the existing onboarding and review-pack gates become subscription-backed where a subscription exists instead of relying only on manual lifecycle overlays.
- **Smallest enterprise-capable version**: Introduce one workspace-owned current subscription record, one bounded subscription-state family plus current-period fields, one shared resolver that maps subscription truth into the existing commercial lifecycle resolver, one platform mutation surface on the existing system workspace page, and one read-only workspace-settings summary.
- **Explicit non-goals**: No payment-provider integration, no invoices, no checkout, no taxes, no seat billing, no public pricing, no website work, no customer billing portal, no webhook automation, no recurring reminder engine, no multi-subscription history browser, no CRM/account domain, and no second runtime gate outside the existing lifecycle resolver.
- **Permanent complexity imported**: One new workspace-owned subscription entity, one bounded resolver layered into the current commercial lifecycle path, one migration, one system detail mutation action, one read-only admin summary, and focused unit plus feature coverage.
- **Why now**: Specs 247 and 251 are already repo-real and deliberately left billing/subscription truth open. Without this follow-through, later commercial work will either duplicate manual lifecycle state or keep living in founder memory and external notes.
- **Why not local**: The same subscription truth must inform the system directory, workspace admin understanding, and the current lifecycle-backed onboarding and review-pack decisions. Local notes or page-level fields would drift immediately.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New persisted truth, new state family, and multi-surface touchpoint. Defense: the slice stays on one current subscription record per workspace, keeps lifecycle as the only runtime gate, and avoids any billing engine, payment provider, or customer-account expansion.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/system/directory/workspaces/{workspace}` on `App\Filament\System\Pages\Directory\ViewWorkspace`
- `/admin/settings/workspace` on `App\Filament\Pages\Settings\WorkspaceSettings`
- `/admin/onboarding/{onboardingDraft}` on `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`
- current review-pack generation entry surfaces backed by `App\Services\ReviewPackService`
- **Data Ownership**: Subscription truth becomes one workspace-owned persisted record with its own lifecycle in a new `workspace_subscriptions` table. Existing plan profiles, entitlement overrides, and manual commercial lifecycle fallback remain workspace-owned through existing settings infrastructure. Tenant-owned review packs, evidence snapshots, and onboarding records stay unchanged.
- **RBAC**: Platform users with current directory visibility plus the existing dedicated commercial-management capability may inspect and update subscription truth on `/system`. Workspace members with `Capabilities::WORKSPACE_SETTINGS_VIEW` may inspect the read-only subscription summary on `/admin/settings/workspace`. Existing onboarding and review-pack capability checks remain authoritative. Non-members and wrong-plane actors continue to receive 404. In-scope actors missing capability continue to receive 403. Business-state blocking remains a product-state response for otherwise authorized actors.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - this slice adds no tenantless collection and no canonical tenant list.
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace and tenant isolation remain authoritative. Subscription truth is workspace-owned and must never reveal tenant-owned records outside existing authorized scope.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: system detail summaries, bounded header actions, settings summaries, status messaging, onboarding and review-pack action gating helper text
- **Systems touched**: `WorkspaceCommercialLifecycleResolver`, `WorkspaceEntitlementResolver`, `ViewWorkspace`, `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, `ReviewPackService`, `WorkspaceAuditLogger`, and the shared badge or status vocabulary
- **Existing pattern(s) to extend**: existing system workspace detail summary and bounded mutation pattern, existing workspace settings read-only summary pattern, existing lifecycle-gated onboarding and review-pack paths, and existing audit-backed commercial change semantics
- **Shared contract / presenter / builder / renderer to reuse**: the current commercial lifecycle decision path remains the shared runtime gate; this slice adds one bounded `WorkspaceSubscriptionResolver` and extends the current `WorkspaceCommercialLifecycleResolver` so downstream surfaces keep consuming one lifecycle decision instead of a second gate
- **Why the existing shared path is sufficient or insufficient**: The existing lifecycle resolver is sufficient as the one gate surface. It is insufficient as source truth because it currently relies on manual lifecycle state with no durable subscription record, no current billing-period context, and no bounded subscription-state vocabulary.
- **Allowed deviation and why**: none. The feature must not create a second billing panel, page-local subscription copy, or direct subscription gate checks on onboarding or review-pack surfaces.
- **Consistency impact**: Subscription state labels, lifecycle labels, source labels, fallback wording, and date wording must stay aligned across the system detail surface, workspace settings summary, and the existing lifecycle-backed action surfaces.
- **Review focus**: Reviewers must verify that subscription truth feeds the existing lifecycle resolver, that downstream surfaces still consume lifecycle rather than local subscription checks, and that fallback wording remains explicit when no subscription record exists.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes
- **Shared OperationRun UX contract/layer reused**: Existing review-pack generation continues to reuse `App\Services\ReviewPackService`, `App\Support\OpsUx\OperationUxPresenter`, and `App\Support\OperationRunLinks`. Subscription truth adds no new run family.
- **Delegated start/completion UX behaviors**: When the derived lifecycle allows review-pack generation, queued toast, run link, dedupe handling, and terminal notifications stay on the existing review-pack path. When derived lifecycle blocks, no new `OperationRun` is created and no queued or terminal lifecycle feedback is emitted.
- **Local surface-owned behavior that remains**: The system detail surface owns subscription edit inputs. Workspace settings owns the read-only summary block. Onboarding and review-pack surfaces own only the immediate business-state message for the current action.
- **Queued DB-notification policy**: unchanged
- **Terminal notification path**: unchanged central lifecycle mechanism for existing review-pack runs only
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider/platform boundary is changed. External billing providers remain out of scope. Subscription truth is platform-core commercial metadata owned by TenantPilot.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Platform workspace subscription truth section | yes | Native Filament system detail page | detail summary, header action, status messaging | detail page, header action, summary cards | no | Extends the existing workspace detail page instead of adding a second system console |
| Workspace settings read-only subscription summary | yes | Native Filament singleton settings page | settings summary, support text | page, section | no | Read-only only; no new admin-plane mutation surface |
| Managed tenant onboarding completion gate | yes | Native Filament wizard | action gating, helper text | completion step, confirmation action | no | Reuses the existing lifecycle-aware completion step |
| Review-pack generation entry family | yes | Native Filament widget/resource/page actions | operation-start gating, helper text, lifecycle messaging | widget action, detail action, list/header action | no | The slice keeps the existing start family and only changes the upstream lifecycle source |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Platform workspace subscription truth section | Primary Decision Surface | Platform operator decides what current subscription truth should back a workspace's commercial posture | Current subscription state, period dates, lifecycle mapping, fallback status, and last changed attribution | Existing entitlement summary and related workspace context remain secondary | Primary because this is the one commercial truth surface that changes the shared runtime source | Keeps commercial truth on one system page instead of support notes and ad hoc overrides | Removes guesswork about why a workspace is trial, overdue, or ending |
| Workspace settings read-only subscription summary | Secondary Context Surface | Workspace operator inspects current commercial posture without mutating it | Current subscription-backed or fallback-backed state, next relevant date, and concise explanation | No additional diagnostics in this slice | Not primary because workspace users do not change subscription truth here | Keeps self-serve understanding on the current settings page | Reduces support questions caused by invisible commercial posture |
| Managed tenant onboarding completion gate | Secondary Context Surface | Workspace operator decides whether onboarding may complete now | Current lifecycle outcome and why it is blocked or allowed | Broader subscription detail stays secondary | Not primary because onboarding still answers an activation question, not a billing-management question | Keeps the commercial explanation inside the current activation workflow | Prevents operators from confusing subscription-driven blocks with permission problems |
| Review-pack generation entry family | Secondary Context Surface | Reporting operator decides whether generation may start now | Current lifecycle outcome and concise reason | Broader subscription detail stays secondary | Not primary because the family exists to continue reporting work, not to manage subscription truth | Keeps the explanation inside the current start workflow | Prevents separate support lookup when generation blocks for commercial reasons |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Platform workspace subscription truth section | support-platform, operator-platform | Current subscription state, derived lifecycle state, period dates, fallback indicator, and concise reason | Reference identifier, last changed attribution, and inherited entitlement summary | No raw provider payloads or external-billing diagnostics | `Update subscription truth` | Future external-sync detail and any raw billing payload remain out of scope | The page states the commercial posture once and uses secondary detail for reference and attribution |
| Workspace settings read-only subscription summary | operator-MSP | Current commercial posture, whether it is subscription-backed or fallback-backed, next relevant date, and concise explanation | No additional diagnostics in this slice | none | none - read-only context only | Platform mutation controls and raw reference detail stay hidden | The settings page mirrors the derived truth and does not restate every downstream block reason |
| Managed tenant onboarding completion gate | operator-MSP | Current lifecycle outcome and concise business-state reason for the activation decision | Existing readiness diagnostics remain secondary | none | `Complete onboarding` when allowed | Full subscription detail stays off the onboarding step | One lifecycle message is reused instead of separate subscription prose |
| Review-pack generation entry family | operator-MSP | Current lifecycle outcome and concise business-state reason for the start decision | Existing run state and artifact status remain secondary | none | `Generate pack`, `Regenerate`, or `Export executive pack` when allowed | Full subscription detail stays off the start surfaces | The same lifecycle result is reused across current start-entry points |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace subscription truth section | System / Detail / Diagnostics | Read-only detail with bounded mutation action | Update the current subscription truth | Dedicated workspace detail page | forbidden | Existing admin-workspace and related navigation stay secondary | None in this slice; update is high-impact but not destructive | `/system/directory/workspaces` | `/system/directory/workspaces/{workspace}` | Platform workspace identity plus current commercial posture | Subscription truth | Subscription state, derived lifecycle, next relevant date, and fallback status | Existing system-detail exception remains bounded to one platform mutation surface |
| Workspace settings read-only subscription summary | Config / Settings / Singleton | Workspace settings page | Inspect the current commercial posture | In-page read-only section | forbidden | Existing settings navigation remains secondary | none | `/admin/settings/workspace` | `/admin/settings/workspace` | Active workspace context | Subscription summary | Subscription-backed or fallback-backed commercial posture and next relevant date | Existing singleton-settings exception remains valid |
| Managed tenant onboarding completion gate | Workflow / Guided action entry | Onboarding completion step | Complete onboarding or stop because commercial posture blocks activation | In-page completion section | forbidden | Existing back-navigation and tenant links remain secondary | Existing draft-cancel and draft-delete actions remain where they are today | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Active workspace plus current tenant | Onboarding commercial state | Activation allowed or blocked and why | Existing wizard exception remains valid |
| Review-pack generation entry family | Contextual action family | Widget/resource/page start actions | Start, retry, or export a review pack when allowed | Explicit action on the current tenant or review context | mixed - existing registry rows may still open detail, but start actions remain explicit | Existing `View` and `Download` remain secondary and stay outside the start gate | Existing destructive actions remain out of scope and keep their current placement | current tenant dashboard, `/admin/reviews`, and current review-pack collection surfaces | current tenant review and review-pack detail routes | Active workspace, active tenant, review or pack context | Review-pack generation | Start allowed or blocked and why | Existing grouped-action family remains authoritative |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace subscription truth section | Platform support or commercial operator | Record and inspect the current subscription truth for a workspace | System detail page | What subscription-backed commercial posture should this workspace be in now? | Subscription state, derived lifecycle, period dates, fallback status, and concise reason | Reference identifier, attribution, and inherited entitlement summary | subscription state, lifecycle state, fallback status | TenantPilot only | Update subscription truth | none |
| Workspace settings read-only subscription summary | Workspace owner or manager | Understand the current commercial posture without changing it | Singleton settings page | What commercial state is currently active for this workspace, and when does it change next? | Subscription-backed or fallback-backed state, next relevant date, and concise explanation | No additional diagnostics in this slice | subscription state, lifecycle state, fallback status | none | none | none |
| Managed tenant onboarding completion gate | Workspace owner or manager completing onboarding | Decide whether the current tenant may be activated now | Guided workflow step | Can I activate this tenant under the current commercial posture? | Current lifecycle outcome and concise reason | Existing readiness and verification detail | lifecycle state, entitlement availability | TenantPilot only for activation state | Complete onboarding | Cancel draft, Delete draft |
| Review-pack generation entry family | Workspace manager or reporting operator | Decide whether a new review-pack run may start now | Contextual start-action family | Can I start, retry, or export a pack from this context? | Current lifecycle outcome and concise reason | Existing run state and artifact status | lifecycle state, entitlement availability, run state | TenantPilot only until the current run starts | Generate pack, Regenerate, Export executive pack | Existing destructive actions remain unchanged and out of scope |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes - one workspace-owned current subscription record becomes the durable source behind commercial posture when present
- **New persisted entity/table/artifact?**: yes - one current workspace subscription record per workspace
- **New abstraction?**: yes - one bounded resolver that reads subscription truth and maps it into the existing lifecycle path
- **New enum/state/reason family?**: yes - one bounded subscription-state family
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators can set lifecycle posture today, but they still cannot truthfully explain whether that posture is backed by a real subscription, what billing period is active, or when trial or cancellation should take effect.
- **Existing structure is insufficient because**: Existing settings-backed plan profiles and manual lifecycle overlays do not provide a distinct subscription entity with its own lifecycle, dates, reference, or future sync seam.
- **Narrowest correct implementation**: Introduce one current subscription record per workspace, map it into the existing lifecycle resolver, keep lifecycle as the only runtime gate, reuse current system and settings surfaces, and keep history in audit rather than adding a broader subscription ledger.
- **Ownership cost**: One new table, one resolver, one migration, one system detail mutation surface, read-only summary upkeep, and focused tests for mapping plus fallback behavior.
- **Alternative intentionally rejected**: More workspace-setting keys were rejected because subscription truth now has an independent lifecycle, date fields, and future sync seam that deserve explicit persistence. A broader customer-account or billing engine was rejected as beyond current-release truth.
- **Release truth**: current-release truth with explicit follow-up room for later external billing sync and automation
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: Unit coverage proves state validation, lifecycle mapping, and fallback precedence. Focused feature coverage proves the existing system detail page, workspace settings summary, onboarding completion gate, and review-pack start gate without widening into browser or heavy-governance lanes.
- **New or expanded test families**: one new `Entitlements` unit family for subscription truth plus focused extensions to the existing system, settings, onboarding, and review-pack feature families
- **Fixture / helper cost impact**: Add only workspace, platform user, workspace member, onboarding draft, existing tenant review or review pack, and subscription-record fixtures required to prove current commercial decisions. Avoid browser harnesses, payment mocks, and external sync fixtures.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament, shared-detail-family, monitoring-state-page
- **Standard-native relief or required special coverage**: Standard Filament feature coverage is sufficient for the system detail mutation surface and workspace settings summary. Existing onboarding and review-pack feature families remain the proving lane for current runtime effects.
- **Reviewer handoff**: Reviewers must confirm that subscription-backed workspaces now derive lifecycle from the new record, that fallback behavior remains explicit for workspaces without a subscription, that onboarding and review-pack surfaces still consult lifecycle rather than direct subscription checks, that blocked review-pack starts create no run, and that existing review-pack view or download access remains unchanged.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Scope Boundaries *(required for this slice)*
### In Scope
- One current workspace-owned subscription record with a bounded state family and current-period context
- Exactly five subscription states: `trial`, `active`, `past_due`, `cancel_at_period_end`, and `ended`
- One shared resolver that maps subscription state into the existing commercial lifecycle decision when a subscription record exists
- Explicit fallback to the existing manual lifecycle overlay when no subscription record exists for a workspace
- One system-plane mutation surface on the existing workspace directory detail page
- One read-only workspace-settings summary for current commercial posture
- Audit logging for subscription truth changes
### Non-Goals
- Payment providers, invoices, taxes, checkout, public pricing, and website work
- Customer-account, contract, CRM, or broader billing-domain models
- Multiple current subscriptions per workspace or a historical subscription ledger UI
- Webhook sync, scheduled renewal or expiry jobs, dunning, or reminder automation
- Direct subscription checks on onboarding or review-pack surfaces outside the shared lifecycle resolver
- A second admin-plane commercial-management surface
## Assumptions
- One current subscription record per workspace is the narrowest current-release truth; historical state changes can stay in `AuditLog` for v1.
- Existing plan profile and entitlement override behavior from Spec 247 remains authoritative for what a workspace is allowed to do. Subscription truth only changes the upstream lifecycle source when present.
- Existing manual commercial lifecycle state from Spec 251 remains the fallback source for workspaces that do not yet have a subscription record.
- `cancel_at_period_end` does not auto-transition after the period-end date in v1. The date is visible so platform operators can perform an explicit review instead of relying on implicit automation.
- Existing review-pack runs that were already created before a later subscription or lifecycle change may complete unchanged because this slice only changes future start decisions.
## Risks
- Subscription-backed lifecycle and fallback manual lifecycle can drift if fallback visibility is not explicit when no subscription record exists.
- One current subscription record may be too narrow for later upsell or add-on scenarios, but it is the smallest correct current-release truth.
- Later external billing sync could replace the v1 mutation path, but pre-production posture allows the source to evolve without long compatibility shims.
- `cancel_at_period_end` can become stale if operators ignore the visible date, so the system detail surface must keep the date obvious even without automation.
## Deferred Adjacent Candidates
- External billing-provider sync and webhook-driven subscription updates
- Invoice or receipt storage and payment-collection flows
- Customer billing portal or workspace self-serve commercial controls
- Broader demo or seeded trial automation beyond the current commercial truth layer
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Record one current workspace subscription truth centrally (Priority: P1)
As a platform support or commercial operator, I want to record one current subscription truth for a workspace so the product can explain commercial posture from one durable source instead of manual notes and free-form lifecycle overrides.
**Why this priority**: Without a durable subscription record, every later commercial or support surface must guess whether the current lifecycle posture is truly backed by a paid subscription, trial, overdue state, or pending cancellation.
**Independent Test**: Open the existing system workspace detail page, create or update subscription truth with state, dates, reference, and status reason, and confirm the page shows the new derived commercial posture plus audit attribution without touching onboarding or review-pack code paths.
**Acceptance Scenarios**:
1. **Given** a workspace has no current subscription record, **When** an authorized platform operator saves a `trial` subscription with `trial_ends_at`, **Then** the workspace shows that subscription as the current commercial truth and the derived lifecycle resolves to `trial`.
2. **Given** a workspace currently has an `active` subscription, **When** the same operator updates it to `past_due` with status reason and period dates, **Then** the record is updated, the change is auditable, and the derived lifecycle resolves to `grace`.
3. **Given** a workspace currently has a subscription record, **When** an unauthorized or wrong-plane actor attempts to update it, **Then** authorization remains enforced through existing 404 and 403 rules and no subscription truth is changed.
---
### User Story 2 - Keep current lifecycle-backed runtime gates but source them from subscription truth when present (Priority: P1)
As an authorized workspace operator, I want the current onboarding and review-pack decisions to continue using one shared lifecycle gate so the product remains consistent while subscription-backed workspaces stop relying on manual lifecycle state.
**Why this priority**: The repo already gates onboarding and review-pack starts through the lifecycle resolver. The value of this slice is not a new gate but a more truthful upstream source.
**Independent Test**: Seed one workspace with a subscription record and one without. Confirm that the subscription-backed workspace derives lifecycle from subscription state, the fallback workspace still uses manual lifecycle state, and the existing onboarding or review-pack behavior follows the resulting lifecycle outcome in both cases.
**Acceptance Scenarios**:
1. **Given** a workspace has an `active` subscription and the underlying entitlement substrate allows activation, **When** an authorized operator reaches onboarding completion, **Then** the step allows completion and identifies the commercial posture as subscription-backed.
2. **Given** a workspace has a `past_due` subscription, **When** the same operator attempts onboarding completion, **Then** the step blocks before any activation mutation and explains the current grace posture as derived from subscription truth.
3. **Given** a workspace has no subscription record but does have an explicit manual lifecycle state of `suspended_read_only`, **When** an authorized operator attempts review-pack generation, **Then** the current lifecycle block still applies and the surface makes clear that the result comes from fallback lifecycle truth rather than a subscription record.
4. **Given** a workspace has a `cancel_at_period_end` subscription and the current review-pack entitlement allows generation, **When** an authorized operator starts review-pack generation, **Then** the existing run flow continues through the current lifecycle gate and no second subscription gate is introduced.
---
### User Story 3 - Let workspace operators inspect the current commercial posture without gaining billing controls (Priority: P2)
As a workspace owner or manager, I want to inspect the current subscription-backed or fallback-backed commercial posture on the existing settings page so I can understand why current actions are allowed or blocked without opening the platform plane.
**Why this priority**: Customer-safe self-understanding matters, but it must not create a second mutation plane or a broader billing UI.
**Independent Test**: Open `/admin/settings/workspace` as a workspace manager, confirm the page shows the current commercial posture, next relevant date, and fallback indicator, and verify there are no edit controls for subscription truth.
**Acceptance Scenarios**:
1. **Given** a workspace has a current `trial` subscription, **When** an authorized workspace manager opens the existing settings page, **Then** the page shows the current commercial posture, trial end date, and a concise explanation without exposing platform mutation controls.
2. **Given** a workspace has no subscription record and is still using fallback lifecycle truth, **When** the same manager opens the settings page, **Then** the page clearly marks the posture as fallback-backed rather than subscription-backed.
3. **Given** a non-member or wrong-plane actor attempts to inspect the same page, **When** the request is evaluated, **Then** existing isolation rules still apply and no workspace commercial truth is leaked.
### Edge Cases
- A workspace with no subscription record must continue to behave exactly as current Specs 247 and 251 require until a platform operator records subscription truth.
- A workspace with a subscription state of `cancel_at_period_end` and a past period-end date must remain explicitly review-required on the system detail surface without auto-transitioning in v1.
- If a workspace has a subscription state that maps to an allowed lifecycle but the underlying entitlement substrate blocks the action, the substrate block still wins and must remain distinguishable from subscription-backed lifecycle decisions.
- If a review-pack run was already created before a later subscription or lifecycle change, the existing run may complete; the new commercial posture affects future starts only.
- A workspace member lacking onboarding or review-pack capability must still receive 403 even if subscription-backed lifecycle would otherwise allow the action.
- A non-member or wrong-plane actor must not learn whether a workspace is trial, overdue, cancellation-pending, ended, or fallback-backed; those requests continue to resolve as 404.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds runtime-changing commercial truth and one new workspace-owned persisted entity, but it does not add Microsoft Graph calls, provider dispatch, payment-provider integrations, or a new queued workflow family. Existing review-pack `OperationRun` behavior is reused only when the current lifecycle gate allows the start action.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces a new persisted subscription record because the current-release operator problem now needs distinct commercial truth with its own lifecycle, dates, and audit trail rather than more settings keys. A narrower settings-only approach would continue to blur lifecycle fallback and subscription-backed truth.
**Constitution alignment (XCUT-001):** All in-scope surfaces must reuse the same derived lifecycle decision, whether it comes from a subscription record or fallback lifecycle state. No surface may invent local subscription-to-lifecycle rules or local block reasons.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The system detail page shows the subscription-backed decision first and deeper reference detail second. Workspace settings shows only the calm read-only summary needed for self-understanding. Onboarding and review-pack surfaces show only the immediate lifecycle outcome required for the action.
**Constitution alignment (PROV-001):** Subscription truth remains provider-neutral and platform-core. The feature must not import Stripe-, payment-gateway-, or vendor-specific semantics into shared commercial vocabulary.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit plus feature lanes. New fixtures remain limited to workspace, subscription record, platform operator, workspace member, onboarding draft, and review-pack-capable tenant context.
**Constitution alignment (OPS-UX):** This feature adds no new run family. Existing review-pack generation keeps the current queued toast, operation link, and terminal notification path when lifecycle allows it. Blocked starts create no run and no run lifecycle feedback.
**Constitution alignment (OPS-UX-START-001):** Subscription truth sits upstream of `WorkspaceCommercialLifecycleResolver`. The lifecycle resolver remains the only gate that review-pack entry surfaces consult before run creation.
**Constitution alignment (RBAC-UX):** Two authorization planes are involved: platform `/system` for subscription mutation and admin `/admin` for read-only or contextual lifecycle effects. Wrong-plane or non-member requests remain 404. Members missing capability remain 403. Business-state blocking remains distinct from authorization failure.
**Constitution alignment (BADGE-001):** If subscription state or derived lifecycle badges are rendered, their labels and visual semantics must come from one shared catalog or mapping rather than page-local color logic.
**Constitution alignment (UI-FIL-001):** The slice extends existing native Filament detail, settings, wizard, widget, and resource surfaces only. No custom billing panel, independent status language, or new design system is allowed.
**Constitution alignment (UI-NAMING-001):** Primary operator labels stay product-facing and specific: `Subscription status`, `Derived commercial posture`, `Trial ends`, `Current period ends`, `Update subscription truth`, `Complete onboarding`, `Generate pack`, `Regenerate`, and `Export executive pack`. Checkout or vendor terms remain out of scope.
**Constitution alignment (DECIDE-001):** The system workspace detail page is the one primary commercial-truth decision surface. Workspace settings remains a read-only context surface. Onboarding and review-pack surfaces remain contextual decision points that only expose the current lifecycle result.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature must preserve the existing system detail, singleton settings, onboarding wizard, and grouped review-pack action patterns. It may not add a second admin-plane commercial management surface, redundant inspect actions, or mixed catch-all action groups.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Subscription mutation stays on the system workspace detail page. Workspace settings remains read-only. Onboarding completion stays the primary activation action. Review-pack generation stays the primary reporting mutation where already present.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** One thin subscription resolver plus lifecycle mapping is justified because direct reads from settings and manual lifecycle state cannot express subscription-backed truth. Tests must prove business outcomes such as mapping, fallback, allowed versus blocked execution, and auditability rather than cosmetic rendering alone.
**Constitution alignment (Filament Action Surfaces):** The action-surface contract remains satisfied with the documented system detail exception for one bounded mutation action, the existing singleton settings exception for read-only context, the existing onboarding wizard exception, and the existing review-pack action family. No empty action groups or redundant inspect actions are introduced.
**Constitution alignment (UX-001 - Layout & Information Architecture):** The system detail page remains the one mutation surface, and workspace settings remains the read-only summary surface. The feature does not create a new commercial shell or duplicate summary page.
### Functional Requirements
- **FR-274-001 Workspace-owned subscription truth**: The system MUST persist one current subscription record per workspace in a dedicated workspace-owned table rather than as additional workspace-setting keys.
- **FR-274-002 Bounded subscription-state catalog**: The current subscription record MUST use exactly these state identifiers in v1: `trial`, `active`, `past_due`, `cancel_at_period_end`, and `ended`.
- **FR-274-003 Required commercial context fields**: The current subscription record MUST support a bounded set of commercial context fields: `billing_reference` (optional), `trial_ends_at` (required for `trial`), `current_period_starts_at` and `current_period_ends_at` (required for `active`, `past_due`, and `cancel_at_period_end`), `current_period_ends_at` (required for `ended`), and `status_reason` (required on every explicit mutation).
- **FR-274-004 Platform-managed mutation**: Only authorized platform users MAY create or update current subscription truth in this slice, the system-plane mutation MUST remain confirmation-protected, and workspace or tenant admin users MUST NOT gain self-service mutation controls.
- **FR-274-005 Auditability**: Every create or update of current subscription truth MUST record old state, new state, actor, and status reason through the existing audit foundation.
- **FR-274-006 Lifecycle derivation precedence**: `WorkspaceCommercialLifecycleResolver` MUST derive the effective lifecycle state from current subscription truth when a subscription record exists for the workspace.
- **FR-274-007 Lifecycle fallback**: If no current subscription record exists for a workspace, `WorkspaceCommercialLifecycleResolver` MUST preserve the current Spec 251 fallback behavior by using existing manual lifecycle state or default `active_paid`, and the existing system-plane `Change commercial state` action remains available only in that fallback condition.
- **FR-274-008 Deterministic lifecycle mapping**: The lifecycle resolver MUST map subscription states as follows: `trial` -> `trial`, `active` -> `active_paid`, `past_due` -> `grace`, `cancel_at_period_end` -> `active_paid`, and `ended` -> `suspended_read_only`.
- **FR-274-009 No second runtime gate**: Onboarding activation and review-pack generation MUST continue consulting the shared lifecycle resolver only. Subscription truth MUST NOT create a second direct gate on those surfaces.
- **FR-274-010 Stale review-required visibility**: If a `trial` record has a past `trial_ends_at` date or a `cancel_at_period_end` record has a past `current_period_ends_at` date, the system workspace detail page MUST surface that the subscription record needs review, but v1 MUST NOT auto-transition the state.
- **FR-274-011 System workspace visibility**: The existing system workspace detail page MUST show current subscription state, derived lifecycle state, next relevant date, fallback status, reference, and last changed attribution to authorized platform users.
- **FR-274-012 Workspace settings visibility**: The existing workspace settings page MUST show a read-only summary of the current commercial posture, explicitly indicating whether it is subscription-backed or fallback-backed.
- **FR-274-013 Onboarding activation continuity**: Managed-tenant onboarding activation MUST keep using the lifecycle decision after subscription mapping, stopping before tenant activation when the lifecycle outcome blocks the action.
- **FR-274-014 Review-pack start continuity**: `Generate pack`, `Regenerate`, and `Export executive pack` MUST keep using the lifecycle decision after subscription mapping, stopping before any new `ReviewPack` or `OperationRun` is created when the lifecycle outcome blocks the action.
- **FR-274-015 Existing artifact access unchanged**: This slice MUST NOT change existing view or download access to already-generated review packs, evidence, or review history that remain accessible under current RBAC and lifecycle rules.
- **FR-274-016 One current record only**: V1 MUST support exactly one current subscription record per workspace and rely on audit history rather than a multi-row subscription ledger or browsing surface.
- **FR-274-017 Bounded non-goals**: This slice MUST NOT introduce invoices, payment collection, taxes, checkout, website pricing, vendor-specific billing adapters, webhook automation, customer portals, or a second commercial control plane.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Platform workspace subscription truth section | `app/Filament/System/Pages/Directory/ViewWorkspace.php` | none on collection | dedicated detail route only | none | none | N/A | `Update subscription truth` and, only when no subscription record exists, fallback `Change commercial state`; both remain confirmation-protected | N/A | yes | Existing system-detail exception remains bounded to one platform mutation surface |
| Workspace settings read-only subscription summary | `app/Filament/Pages/Settings/WorkspaceSettings.php` | `Save` remains owned by settings, but subscription summary is read-only | N/A - singleton settings page | none | none | N/A | none | existing settings save or cancel behavior unchanged; no subscription edit controls | no new audit event; read-only only | Existing singleton-page exception remains valid |
| Managed tenant onboarding completion gate | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | existing back-navigation and tenant links | N/A - guided workflow | none | none | existing onboarding start state unchanged | `Complete onboarding` remains the primary action and stays lifecycle-gated | N/A | yes - existing onboarding audit semantics remain | Existing wizard exception remains valid |
| Review-pack generation entry family | current tenant dashboard, review register, tenant review detail, and review-pack detail or registry surfaces | existing `Generate pack`, `Regenerate`, and `Export executive pack` actions stay primary where already present | existing registry/detail affordances remain unchanged | existing `View` and `Download` shortcuts remain secondary where already present | none | existing `Generate` CTA remains where already present | existing start actions remain lifecycle-gated; `View` and `Download` stay outside the start gate | N/A | no new audit requirement for blocked attempts | Existing grouped action family remains authoritative |
### Key Entities *(include if feature involves data)*
- **WorkspaceSubscription**: One workspace-owned current subscription record containing bounded subscription state, current commercial dates, optional reference, and status reason.
- **WorkspaceSubscriptionSummary**: A derived read model that combines current subscription truth, fallback status, and next relevant date for system and admin surfaces.
- **EffectiveCommercialLifecycleDecision**: The existing shared lifecycle decision, now potentially sourced from current subscription truth before it reaches onboarding and review-pack entry surfaces.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Authorized platform operators can inspect and update a workspace's current subscription truth from one system workspace detail surface and see the updated derived commercial posture immediately afterward.
- **SC-002**: Authorized workspace operators can determine from the settings page in under 30 seconds whether the current commercial posture is subscription-backed or fallback-backed and what the next relevant date is.
- **SC-003**: 100% of onboarding and review-pack decisions for workspaces with a current subscription record follow the derived lifecycle mapping from that record rather than stale manual lifecycle state.
- **SC-004**: The feature ships without adding a second runtime gate, a new run family, a payment-provider integration, or a customer-facing billing surface.

View File

@ -0,0 +1,184 @@
---
description: "Task list for Billing & Subscription Truth Layer v1"
---
# Tasks: Billing & Subscription Truth Layer v1
**Input**: Design documents from `specs/274-billing-subscription-truth/`
**Prerequisites**: `specs/274-billing-subscription-truth/spec.md`, `specs/274-billing-subscription-truth/plan.md`, `specs/274-billing-subscription-truth/checklists/requirements.md`, `specs/274-billing-subscription-truth/research.md`, `specs/274-billing-subscription-truth/data-model.md`, `specs/274-billing-subscription-truth/quickstart.md`, `specs/274-billing-subscription-truth/contracts/workspace-billing-subscription-truth.logical.openapi.yaml`
**Tests**: REQUIRED (Pest). Keep proof bounded to one new `Unit` family under `tests/Unit/Entitlements/` plus focused extensions to current `Feature` families for system, settings, onboarding, and review-pack behavior.
**Operations**: Reuse the existing `WorkspaceCommercialLifecycleResolver` and current review-pack `OperationRun` path. No new run type, no queue family, and no direct subscription gate outside lifecycle are allowed.
**RBAC**: Non-members and wrong-plane actors remain `404`; in-scope actors missing capability remain `403`. `/system` owns mutation; `/admin` remains read-only or contextual for subscription truth.
**Shared Pattern Reuse**: Reuse `WorkspaceEntitlementResolver`, `WorkspaceCommercialLifecycleResolver`, `ViewWorkspace`, `WorkspaceSettings`, `ManagedTenantOnboardingWizard`, `ReviewPackService`, and the current audit foundation. Do not create a second commercial control plane.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, no new globally searchable resource, and no new asset strategy are allowed.
**Organization**: Tasks are grouped by user story so the source-of-truth entity, lifecycle continuity, and read-only admin summary remain independently implementable and testable. This package is a bounded follow-through over Specs 247 and 251, not a billing-engine rewrite.
**Review Outcome**: `acceptable-special-case`
**Workflow Outcome**: `keep`
**Test-governance Outcome**: `keep`
## Test Governance Checklist
- [x] Lane assignment stays `fast-feedback` and `confidence` and remains the narrowest sufficient proof.
- [x] New or changed tests stay in the existing `apps/platform/tests/Unit/Entitlements/`, `apps/platform/tests/Feature/System/`, `apps/platform/tests/Feature/Filament/Settings/`, `apps/platform/tests/Feature/Onboarding/`, and `apps/platform/tests/Feature/ReviewPack/` families.
- [x] Shared helpers stay cheap by default; only one new factory is expected.
- [x] Planned validation commands cover subscription truth, lifecycle precedence, admin summary, and gate continuity without widening into browser or heavy-governance lanes.
- [x] The declared surface test profile remains `standard-native-filament`, `shared-detail-family`, and `monitoring-state-page` only.
- [x] Any drift toward providers, invoices, a portal, or a second runtime gate resolves as `reject-or-split`, not hidden scope.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the current commercial truth seams before any implementation change.
- [x] T001 Review `specs/274-billing-subscription-truth/spec.md`, `specs/274-billing-subscription-truth/plan.md`, `specs/274-billing-subscription-truth/checklists/requirements.md`, `specs/274-billing-subscription-truth/research.md`, `specs/274-billing-subscription-truth/data-model.md`, `specs/274-billing-subscription-truth/quickstart.md`, `specs/247-plans-entitlements-billing-readiness/spec.md`, and `specs/251-commercial-entitlements-billing-state/spec.md` together so the slice stays on the current commercial foundations.
- [x] T002 [P] Confirm the current lifecycle and admin/system surface seams in `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` and `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php`.
- [x] T003 [P] Confirm the current runtime gate seams in `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, and `apps/platform/app/Services/ReviewPackService.php`.
- [x] T004 [P] Confirm the current audit and authorization seams in `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Auth/PlatformCapabilities.php`, and `apps/platform/app/Services/Settings/SettingsWriter.php`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Lock the new persisted source of truth and shared mapping before surface changes begin.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T005 [P] Add failing unit coverage in `apps/platform/tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php` and extend `apps/platform/tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php` to prove state validation, lifecycle mapping, fallback precedence, and review-required date behavior.
- [x] T006 [P] Extend `apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php`, `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php`, and `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` to lock the system mutation surface, admin read-only summary, and plane semantics.
- [x] T007 [P] Extend `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, and `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php` to prove subscription-backed lifecycle continuity and no-run behavior on blocked starts.
- [x] T008 Create `apps/platform/database/migrations/*_create_workspace_subscriptions_table.php`, `apps/platform/app/Models/WorkspaceSubscription.php`, `apps/platform/database/factories/WorkspaceSubscriptionFactory.php`, and the `Workspace` relation so one current subscription record exists per workspace.
- [x] T009 Implement `apps/platform/app/Services/Entitlements/WorkspaceSubscriptionResolver.php` to expose current subscription summary, fallback status, next relevant date, and derived lifecycle mapping.
- [x] T010 Refactor `apps/platform/app/Services/Entitlements/WorkspaceCommercialLifecycleResolver.php` so subscription truth becomes the upstream lifecycle source when present and existing manual lifecycle state remains the fallback when absent.
**Checkpoint**: One persisted current subscription source exists and the shared lifecycle resolver can consume it without changing any surface yet.
---
## Phase 3: User Story 1 - Record one current workspace subscription truth centrally (Priority: P1)
**Goal**: Authorized platform users can create or update one current subscription record from the existing system workspace detail page.
**Independent Test**: Open the system workspace detail page, save subscription truth, and verify summary plus audit output update without touching onboarding or review-pack flows.
### Tests for User Story 1
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/System/ViewWorkspaceEntitlementsTest.php` to prove create, update, validation, explicit confirmation, fallback visibility, and derived lifecycle rendering on the system page.
- [x] T012 [P] [US1] Extend `apps/platform/tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` to prove wrong-plane or non-member requests remain `404` and in-scope actors missing the dedicated capability remain `403`.
### Implementation for User Story 1
- [x] T013 [US1] Update `apps/platform/app/Filament/System/Pages/Directory/ViewWorkspace.php` so the page renders current subscription truth, derived lifecycle, next relevant date, stale-date needs-review visibility, and one bounded confirmation-protected `Update subscription truth` action.
- [x] T014 [US1] Extend `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` and related audit metadata so subscription changes are attributable with old state, new state, actor, and status reason.
- [x] T015 [US1] Narrow or hide the existing manual lifecycle mutation affordance on the system page whenever a current subscription record exists, preserving the existing confirmation-protected `Change commercial state` action only as explicit fallback behavior when no subscription record is present.
**Checkpoint**: The system workspace detail page becomes the one durable commercial source surface.
---
## Phase 4: User Story 2 - Keep current runtime gates but source them from subscription truth when present (Priority: P1)
**Goal**: Existing onboarding and review-pack behavior remains on one lifecycle gate while subscription-backed workspaces stop relying on manual lifecycle state.
**Independent Test**: Seed one subscription-backed workspace and one fallback workspace, then confirm both current gate families still use one lifecycle decision with the correct source and no second gate.
### Tests for User Story 2
- [x] T016 [P] [US2] Extend `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php` to prove subscription-backed lifecycle mapping, fallback continuity, and business-state messaging on the completion step.
- [x] T017 [P] [US2] Extend `apps/platform/tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` to prove subscription-backed lifecycle mapping, no-run behavior on blocked starts, suppressed queued or terminal notifications when blocked, unchanged queued-start UX when allowed, and unchanged current pack view or download access.
### Implementation for User Story 2
- [x] T018 [US2] Update `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` so the current completion summary identifies subscription-backed versus fallback lifecycle truth without adding direct subscription checks.
- [x] T019 [US2] Update `apps/platform/app/Services/ReviewPackService.php` and any current review-pack start helpers so the existing lifecycle gate consumes the new source but keeps current start semantics unchanged.
- [x] T020 [US2] Review all in-scope gate surfaces and remove any local or duplicate subscription-state checks so lifecycle remains the only runtime gate.
**Checkpoint**: Subscription truth changes the upstream lifecycle source only; runtime gates stay singular and unchanged in shape.
---
## Phase 5: User Story 3 - Show a read-only commercial summary on workspace settings (Priority: P2)
**Goal**: Workspace operators can inspect the current commercial posture without gaining billing controls.
**Independent Test**: Open workspace settings as an authorized member and verify the summary shows subscription-backed or fallback-backed truth, the next relevant date, and no mutation controls.
### Tests for User Story 3
- [x] T021 [P] [US3] Extend `apps/platform/tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` to prove the new read-only subscription summary, fallback indicator, and absence of admin-plane mutation controls.
### Implementation for User Story 3
- [x] T022 [US3] Update `apps/platform/app/Filament/Pages/Settings/WorkspaceSettings.php` so the existing page renders the read-only subscription summary using the shared resolver output.
- [x] T023 [US3] Confirm the admin-plane summary stays read-only and does not create a second commercial mutation surface or local commercial vocabulary.
**Checkpoint**: Workspace operators can understand the current posture without inheriting a new billing UI.
---
## Phase 6: Polish & Cross-Cutting Validation
**Purpose**: Validate the bounded slice and stop without widening scope.
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Entitlements/WorkspaceSubscriptionResolverTest.php tests/Unit/Entitlements/WorkspaceCommercialLifecycleResolverTest.php`.
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/ViewWorkspaceEntitlementsTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php`.
- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/ManagedTenantOnboardingEntitlementTest.php tests/Feature/ReviewPack/ReviewPackEntitlementEnforcementTest.php tests/Feature/ReviewPack/ReviewPackGenerationTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`.
- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T028 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no globally searchable resource is added, no asset strategy changes appear, and no second runtime gate slipped in.
- [x] T029 [P] Record the final guardrail and test-governance outcome in the active feature close-out without reopening provider sync, invoices, portal work, or a second control plane.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the durable source of truth.
- **Phase 4 (US2)**: depends on Phase 2 and should land with US1 so the new source and current runtime gates stay aligned.
- **Phase 5 (US3)**: depends on Phase 2 and should land after US1 so the admin summary consumes the finished shared resolver.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and delivers the central source-of-truth surface.
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so subscription truth actually changes runtime source.
- **US3 (P2)**: independently testable after Phase 2 and can follow US1 once the shared resolver output is stable.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap.
- Keep implementation inside the current model, resolver, system page, settings page, onboarding, review-pack, and audit seams named above.
- Re-run the narrowest relevant validation command after each story checkpoint before moving on.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2 together**. The feature is only useful when a current subscription source exists and the existing lifecycle gate actually consumes it.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 so the durable source and system mutation surface exist.
3. Deliver US2 so current gates become subscription-backed where appropriate.
4. Add US3 as the read-only admin summary.
5. Finish with the focused validation and drift-review tasks in Phase 6.
### Team Strategy
1. Settle persistence and resolver shape first.
2. Parallelize failing tests within each story before runtime edits.
3. Serialize merges around `ViewWorkspace`, `WorkspaceCommercialLifecycleResolver`, and `WorkspaceSettings` so the commercial vocabulary stays coherent.
---
## Deferred Follow-Ups / Non-Goals
- payment-provider or webhook synchronization
- invoice or payment ledger persistence
- customer-facing billing portal or workspace self-serve billing controls
- schedule-driven trial or period-end transitions
- a historical subscription browser or multi-record billing timeline