From 5197094bd630203fc37175201fab9105705344e6 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 15:39:21 +0000 Subject: [PATCH] docs: add comprehensive PHPDoc to EntitlementService (P2-020) - Document provisionNamespacePackage with examples - Document provisionNamespaceBoost with boost/duration types - Document invalidateNamespaceCache with auto-invalidation triggers - Add cross-references to workspace-level equivalents Co-Authored-By: Claude Opus 4.5 --- Services/EntitlementService.php | 863 +++++++++++++++++++++++++++++++- TODO.md | 18 +- 2 files changed, 858 insertions(+), 23 deletions(-) diff --git a/Services/EntitlementService.php b/Services/EntitlementService.php index 7f08cf2..07055d7 100644 --- a/Services/EntitlementService.php +++ b/Services/EntitlementService.php @@ -14,18 +14,130 @@ use Core\Tenant\Models\UsageRecord; use Core\Tenant\Models\User; use Core\Tenant\Models\Workspace; use Core\Tenant\Models\WorkspacePackage; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +/** + * Core service for managing feature entitlements, usage tracking, and package provisioning. + * + * The EntitlementService is the primary API for checking whether workspaces or namespaces + * have access to specific features, tracking their usage, and managing packages and boosts. + * It supports a hierarchical entitlement model where namespaces can inherit entitlements + * from their parent workspace or the owning user's tier. + * + * ## Key Concepts + * + * - **Features**: Capabilities that can be enabled or limited (e.g., 'pages', 'api_calls', 'custom_domains') + * - **Packages**: Bundles of features with defined limits (e.g., 'starter', 'professional', 'enterprise') + * - **Boosts**: Temporary or permanent additions to feature limits (e.g., promotional extras) + * - **Usage Records**: Tracked consumption of limit-based features + * + * ## Feature Types + * + * Features can be one of three types: + * - `boolean`: Either enabled or disabled (no quantity tracking) + * - `limit`: Has a numeric cap that can be consumed (e.g., 10 pages, 1000 API calls) + * - `unlimited`: Feature is available without any limits + * + * ## Entitlement Cascade (Namespaces) + * + * When checking namespace entitlements, the service follows this priority: + * 1. Namespace-level packages and boosts (most specific) + * 2. Workspace-level packages and boosts (if namespace has workspace context) + * 3. User tier entitlements (for user-owned namespaces without workspace) + * + * ## Usage Examples + * + * ```php + * // Check if a workspace can create a new page + * $result = $entitlementService->can($workspace, 'pages'); + * if ($result->isDenied()) { + * throw new LimitExceededException($result->getMessage()); + * } + * + * // Record usage after creating the page + * $entitlementService->recordUsage($workspace, 'pages', 1, $user); + * + * // Check remaining capacity + * $result = $entitlementService->can($workspace, 'pages'); + * echo "Remaining pages: " . $result->getRemaining(); + * + * // Get full usage summary for dashboard + * $summary = $entitlementService->getUsageSummary($workspace); + * ``` + * + * ## Caching + * + * Entitlement checks and limits are cached for performance (5 minute TTL by default). + * Cache is automatically invalidated when: + * - Usage is recorded + * - Packages are provisioned, suspended, or revoked + * - Boosts are provisioned or expired + * + * @see EntitlementResult Value object returned by entitlement checks + * @see Feature Model defining available features + * @see Package Model defining feature bundles + * @see Boost Model for temporary limit increases + */ class EntitlementService { /** - * Cache TTL in seconds. + * Cache TTL in seconds for entitlement data. + * + * Limits and feature availability are cached for this duration. + * Usage data uses a shorter 60-second cache. */ protected const CACHE_TTL = 300; // 5 minutes /** * Check if a workspace can use a feature. + * + * This is the primary method for checking workspace entitlements. It evaluates + * whether the workspace has access to the specified feature and, for limit-based + * features, whether sufficient capacity remains for the requested quantity. + * + * The method aggregates limits from: + * - All active packages assigned to the workspace + * - All active boosts for the specified feature + * + * For hierarchical features (e.g., 'pages.bio' under 'pages'), usage is pooled + * at the parent feature level. + * + * ## Example Usage + * + * ```php + * // Simple boolean check + * $result = $entitlementService->can($workspace, 'custom_domains'); + * if ($result->isAllowed()) { + * // Feature is enabled + * } + * + * // Check with quantity (e.g., bulk operations) + * $result = $entitlementService->can($workspace, 'api_calls', quantity: 100); + * if ($result->isDenied()) { + * return response()->json(['error' => $result->getMessage()], 403); + * } + * + * // Access usage information + * $result = $entitlementService->can($workspace, 'pages'); + * echo "Used: {$result->getUsed()} / {$result->getLimit()}"; + * echo "Remaining: {$result->getRemaining()}"; + * ``` + * + * @param Workspace $workspace The workspace to check entitlements for + * @param string $featureCode The feature code to check (e.g., 'pages', 'api_calls', 'custom_domains') + * @param int $quantity The quantity being requested (default: 1). For limit-based features, + * checks if current usage plus this quantity exceeds the limit. + * + * @return EntitlementResult Contains: + * - `isAllowed()`: Whether the feature can be used + * - `isDenied()`: Inverse of isAllowed + * - `getMessage()`: Human-readable denial reason (if denied) + * - `getLimit()`: Total limit from packages + boosts (null for boolean features) + * - `getUsed()`: Current usage count (null for boolean features) + * - `getRemaining()`: Remaining capacity (null for boolean features) + * - `isUnlimited()`: Whether feature has no limit */ public function can(Workspace $workspace, string $featureCode, int $quantity = 1): EntitlementResult { @@ -85,10 +197,44 @@ class EntitlementService /** * Check if a namespace can use a feature. * - * Entitlement cascade: - * 1. Check namespace-level packages first - * 2. Fall back to workspace pool (if namespace has workspace context) - * 3. Fall back to user tier (for user-owned namespaces without workspace) + * Similar to `can()` but for namespace-scoped entitlement checks. This method + * implements a cascading entitlement model that checks multiple levels to + * determine feature access. + * + * ## Entitlement Cascade Priority + * + * 1. **Namespace-level packages/boosts** (highest priority) + * - Packages and boosts directly assigned to the namespace + * + * 2. **Workspace-level packages/boosts** (fallback) + * - If the namespace belongs to a workspace, inherits workspace entitlements + * + * 3. **User tier** (final fallback) + * - For user-owned namespaces without workspace context + * - Checks the owning user's subscription tier + * + * ## Example Usage + * + * ```php + * // Check namespace entitlement + * $result = $entitlementService->canForNamespace($namespace, 'links'); + * if ($result->isDenied()) { + * throw new LimitExceededException($result->getMessage()); + * } + * + * // Namespace inherits from workspace if no direct packages + * $namespace = Namespace_::create(['workspace_id' => $workspace->id, ...]); + * $result = $entitlementService->canForNamespace($namespace, 'pages'); + * // Uses workspace's 'pages' limit if namespace has no direct package + * ``` + * + * @param Namespace_ $namespace The namespace to check entitlements for + * @param string $featureCode The feature code to check + * @param int $quantity The quantity being requested (default: 1) + * + * @return EntitlementResult Contains allowed status, limits, and usage information + * + * @see self::can() For workspace-level checks */ public function canForNamespace(Namespace_ $namespace, string $featureCode, int $quantity = 1): EntitlementResult { @@ -168,6 +314,43 @@ class EntitlementService /** * Record usage of a feature for a namespace. + * + * Creates a usage record for namespace-scoped feature consumption. Usage records + * are used to track consumption against limits and determine remaining capacity. + * + * For hierarchical features, usage is automatically recorded against the pool + * feature code (parent feature). + * + * ## Example Usage + * + * ```php + * // Record a single link creation + * $entitlementService->recordNamespaceUsage($namespace, 'links'); + * + * // Record bulk operation with user attribution + * $entitlementService->recordNamespaceUsage( + * $namespace, + * 'api_calls', + * quantity: 50, + * user: $user + * ); + * + * // Record with metadata for audit trail + * $entitlementService->recordNamespaceUsage( + * $namespace, + * 'page_views', + * quantity: 1, + * metadata: ['page_id' => $page->id, 'referrer' => $referrer] + * ); + * ``` + * + * @param Namespace_ $namespace The namespace to record usage for + * @param string $featureCode The feature code being consumed + * @param int $quantity The amount to record (default: 1) + * @param User|null $user Optional user who triggered the usage (for attribution) + * @param array|null $metadata Optional metadata for audit/debugging + * + * @return UsageRecord The created usage record */ public function recordNamespaceUsage( Namespace_ $namespace, @@ -196,7 +379,46 @@ class EntitlementService } /** - * Record usage of a feature. + * Record usage of a feature for a workspace. + * + * Creates a usage record for workspace-scoped feature consumption. This method + * should be called after successfully using a limited feature to track consumption + * against the workspace's entitlement limits. + * + * Usage records support: + * - **Monthly reset**: Tracked from billing cycle anchor date + * - **Rolling window**: Tracked over a configurable number of days + * - **Cumulative**: All-time usage (no reset) + * + * The reset behaviour is determined by the feature's configuration. + * + * ## Example Usage + * + * ```php + * // Check entitlement first, then record usage + * $result = $entitlementService->can($workspace, 'pages'); + * if ($result->isAllowed()) { + * $page = $workspace->pages()->create($data); + * $entitlementService->recordUsage($workspace, 'pages', 1, $user); + * } + * + * // Record API call usage in middleware + * $entitlementService->recordUsage( + * $workspace, + * 'api_calls', + * quantity: 1, + * user: $request->user(), + * metadata: ['endpoint' => $request->path()] + * ); + * ``` + * + * @param Workspace $workspace The workspace to record usage for + * @param string $featureCode The feature code being consumed + * @param int $quantity The amount to record (default: 1) + * @param User|null $user Optional user who triggered the usage + * @param array|null $metadata Optional metadata for audit/debugging + * + * @return UsageRecord The created usage record */ public function recordUsage( Workspace $workspace, @@ -225,6 +447,67 @@ class EntitlementService /** * Provision a package for a workspace. + * + * Assigns a package to a workspace, granting access to all features defined + * in that package. For base packages (primary subscription), any existing + * base package is automatically cancelled before the new one is activated. + * + * This method is typically called by: + * - Billing system webhooks (Stripe, Blesta) + * - Admin provisioning tools + * - Self-service upgrade flows + * + * ## Package Types + * + * - **Base packages** (`is_base_package = true`): Primary subscription tier. + * Only one base package can be active at a time per workspace. + * - **Add-on packages**: Supplementary feature bundles that stack with base. + * + * ## Example Usage + * + * ```php + * // Provision a subscription package + * $workspacePackage = $entitlementService->provisionPackage( + * $workspace, + * 'professional', + * [ + * 'source' => EntitlementLog::SOURCE_STRIPE, + * 'blesta_service_id' => $blestaServiceId, + * 'billing_cycle_anchor' => now(), + * ] + * ); + * + * // Provision a trial package with expiry + * $entitlementService->provisionPackage( + * $workspace, + * 'professional', + * [ + * 'expires_at' => now()->addDays(14), + * 'metadata' => ['trial' => true], + * ] + * ); + * ``` + * + * @param Workspace $workspace The workspace to provision the package for + * @param string $packageCode The unique code of the package to provision + * @param array{ + * source?: string, + * starts_at?: \DateTimeInterface, + * expires_at?: \DateTimeInterface|null, + * billing_cycle_anchor?: \DateTimeInterface, + * blesta_service_id?: string|null, + * metadata?: array|null + * } $options Provisioning options: + * - `source`: Origin of the provisioning (e.g., 'stripe', 'blesta', 'admin') + * - `starts_at`: When the package becomes active (default: now) + * - `expires_at`: When the package expires (null for indefinite) + * - `billing_cycle_anchor`: Date for monthly usage resets + * - `blesta_service_id`: External billing system reference + * - `metadata`: Additional data to store with the package + * + * @return WorkspacePackage The created workspace package record + * + * @throws ModelNotFoundException If the package code does not exist */ public function provisionPackage( Workspace $workspace, @@ -280,6 +563,75 @@ class EntitlementService /** * Provision a boost for a workspace. + * + * Creates a boost that adds extra capacity or enables features for a workspace. + * Boosts are useful for: + * - Promotional extras ("Get 100 free API calls") + * - Temporary upgrades + * - One-time capacity additions + * - Overage handling + * + * ## Boost Types + * + * - `add_limit`: Adds a fixed amount to the feature limit + * - `unlimited`: Removes the limit entirely for the feature + * + * ## Duration Types + * + * - `cycle_bound`: Expires at the end of the billing cycle + * - `fixed_duration`: Expires after a set time period + * - `permanent`: Never expires (until manually removed) + * - `consumable`: Active until the boosted quantity is consumed + * + * ## Example Usage + * + * ```php + * // Add 1000 extra API calls for the billing cycle + * $entitlementService->provisionBoost( + * $workspace, + * 'api_calls', + * [ + * 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + * 'duration_type' => Boost::DURATION_CYCLE_BOUND, + * 'limit_value' => 1000, + * 'source' => EntitlementLog::SOURCE_PROMOTIONAL, + * ] + * ); + * + * // Grant unlimited pages for 30 days + * $entitlementService->provisionBoost( + * $workspace, + * 'pages', + * [ + * 'boost_type' => Boost::BOOST_TYPE_UNLIMITED, + * 'duration_type' => Boost::DURATION_FIXED, + * 'expires_at' => now()->addDays(30), + * ] + * ); + * ``` + * + * @param Workspace $workspace The workspace to provision the boost for + * @param string $featureCode The feature code to boost + * @param array{ + * boost_type?: string, + * duration_type?: string, + * limit_value?: int|null, + * source?: string, + * starts_at?: \DateTimeInterface, + * expires_at?: \DateTimeInterface|null, + * blesta_addon_id?: string|null, + * metadata?: array|null + * } $options Boost options: + * - `boost_type`: Type of boost (default: 'add_limit') + * - `duration_type`: How the boost expires (default: 'cycle_bound') + * - `limit_value`: Amount to add for 'add_limit' type + * - `source`: Origin of the boost for audit logging + * - `starts_at`: When the boost becomes active (default: now) + * - `expires_at`: When the boost expires + * - `blesta_addon_id`: External billing reference + * - `metadata`: Additional data to store + * + * @return Boost The created boost record */ public function provisionBoost( Workspace $workspace, @@ -314,7 +666,61 @@ class EntitlementService } /** - * Get usage summary for a workspace. + * Get a comprehensive usage summary for a workspace. + * + * Returns detailed information about all features, including current usage, + * limits, and status for each. Results are grouped by feature category for + * easy display in dashboards and reports. + * + * ## Return Structure + * + * Returns a Collection grouped by category, where each item contains: + * - `feature`: The Feature model instance + * - `code`: Feature code + * - `name`: Human-readable feature name + * - `category`: Feature category + * - `type`: Feature type (boolean/limit/unlimited) + * - `allowed`: Whether the feature can be used + * - `limit`: Total limit (null for boolean) + * - `used`: Current usage (null for boolean) + * - `remaining`: Remaining capacity + * - `unlimited`: Whether feature has no limit + * - `percentage`: Usage as percentage (0-100) + * - `near_limit`: Whether usage exceeds 80% + * + * ## Example Usage + * + * ```php + * $summary = $entitlementService->getUsageSummary($workspace); + * + * // Display by category + * foreach ($summary as $category => $features) { + * echo "

{$category}

"; + * foreach ($features as $feature) { + * if ($feature['near_limit']) { + * echo "⚠️ "; + * } + * echo "{$feature['name']}: {$feature['used']}/{$feature['limit']}"; + * } + * } + * ``` + * + * @param Workspace $workspace The workspace to get the summary for + * + * @return Collection> Usage summary grouped by feature category */ public function getUsageSummary(Workspace $workspace): Collection { @@ -345,6 +751,31 @@ class EntitlementService /** * Get all active packages for a workspace. + * + * Returns a collection of WorkspacePackage models that are currently active + * and not expired. Each package includes its associated Package model with + * feature definitions eager-loaded. + * + * ## Example Usage + * + * ```php + * $packages = $entitlementService->getActivePackages($workspace); + * + * foreach ($packages as $workspacePackage) { + * echo "Package: " . $workspacePackage->package->name; + * echo "Started: " . $workspacePackage->starts_at->format('Y-m-d'); + * + * // List included features + * foreach ($workspacePackage->package->features as $feature) { + * echo "- {$feature->name}: {$feature->pivot->limit_value}"; + * } + * } + * ``` + * + * @param Workspace $workspace The workspace to get packages for + * + * @return Collection Active workspace packages with + * Package and Feature relations loaded */ public function getActivePackages(Workspace $workspace): Collection { @@ -357,6 +788,33 @@ class EntitlementService /** * Get all active boosts for a workspace. + * + * Returns a collection of usable Boost models ordered by expiry date. + * "Usable" means the boost is active, not expired, and (for consumable boosts) + * has remaining capacity. + * + * ## Example Usage + * + * ```php + * $boosts = $entitlementService->getActiveBoosts($workspace); + * + * foreach ($boosts as $boost) { + * echo "Feature: {$boost->feature_code}"; + * echo "Type: {$boost->boost_type}"; + * + * if ($boost->expires_at) { + * echo "Expires: " . $boost->expires_at->diffForHumans(); + * } + * + * if ($boost->boost_type === Boost::BOOST_TYPE_ADD_LIMIT) { + * echo "Remaining: " . $boost->getRemainingLimit(); + * } + * } + * ``` + * + * @param Workspace $workspace The workspace to get boosts for + * + * @return Collection Active, usable boosts ordered by expiry (soonest first) */ public function getActiveBoosts(Workspace $workspace): Collection { @@ -367,7 +825,40 @@ class EntitlementService } /** - * Suspend a workspace's packages (e.g. for non-payment). + * Suspend a workspace's packages (e.g., for non-payment). + * + * Marks all active packages as suspended, effectively disabling feature access + * until reactivated. Suspended workspaces typically lose access to premium + * features but retain read access to their data. + * + * This method is typically called by: + * - Billing system webhooks when payment fails + * - Admin moderation actions + * - Automated dunning processes + * + * Each package suspension is logged to the EntitlementLog for audit purposes. + * + * ## Example Usage + * + * ```php + * // Suspend for non-payment (from Stripe webhook) + * $entitlementService->suspendWorkspace( + * $workspace, + * EntitlementLog::SOURCE_STRIPE + * ); + * + * // Suspend for ToS violation (admin action) + * $entitlementService->suspendWorkspace( + * $workspace, + * EntitlementLog::SOURCE_ADMIN + * ); + * ``` + * + * @param Workspace $workspace The workspace to suspend + * @param string|null $source The source of the suspension for audit logging + * (e.g., 'stripe', 'admin', 'system') + * + * @see self::reactivateWorkspace() To lift the suspension */ public function suspendWorkspace(Workspace $workspace, ?string $source = null): void { @@ -388,7 +879,32 @@ class EntitlementService } /** - * Reactivate a workspace's packages. + * Reactivate a workspace's suspended packages. + * + * Restores all suspended packages to active status, re-enabling feature access. + * Only packages with 'suspended' status are affected; cancelled or expired + * packages are not changed. + * + * This method is typically called by: + * - Billing system webhooks when payment succeeds after suspension + * - Admin actions to lift moderation suspensions + * + * Each package reactivation is logged to the EntitlementLog for audit purposes. + * + * ## Example Usage + * + * ```php + * // Reactivate after successful payment + * $entitlementService->reactivateWorkspace( + * $workspace, + * EntitlementLog::SOURCE_STRIPE + * ); + * ``` + * + * @param Workspace $workspace The workspace to reactivate + * @param string|null $source The source of the reactivation for audit logging + * + * @see self::suspendWorkspace() To suspend packages */ public function reactivateWorkspace(Workspace $workspace, ?string $source = null): void { @@ -411,7 +927,41 @@ class EntitlementService } /** - * Revoke a package from a workspace (e.g. subscription cancelled). + * Revoke a package from a workspace (e.g., subscription cancelled). + * + * Immediately cancels the specified package, setting its status to 'cancelled' + * and expiry to now. This removes the features granted by the package from + * the workspace's entitlements. + * + * If the workspace does not have an active package with the specified code, + * this method returns silently (no-op). + * + * This method is typically called by: + * - Billing system webhooks when subscription is cancelled + * - Admin actions to remove packages + * - Self-service downgrade flows + * + * ## Example Usage + * + * ```php + * // Cancel subscription from Stripe webhook + * $entitlementService->revokePackage( + * $workspace, + * 'professional', + * EntitlementLog::SOURCE_STRIPE + * ); + * + * // Admin removal + * $entitlementService->revokePackage( + * $workspace, + * 'add-on-analytics', + * EntitlementLog::SOURCE_ADMIN + * ); + * ``` + * + * @param Workspace $workspace The workspace to revoke the package from + * @param string $packageCode The unique code of the package to revoke + * @param string|null $source The source of the revocation for audit logging */ public function revokePackage(Workspace $workspace, string $packageCode, ?string $source = null): void { @@ -441,9 +991,19 @@ class EntitlementService } /** - * Get the total limit for a feature across all packages + boosts. + * Get the total limit for a feature across all packages and boosts. * - * Returns null if feature not included, -1 if unlimited. + * Aggregates limits from all active packages and usable boosts to determine + * the workspace's total capacity for a feature. This is an internal method + * used by `can()` and is cached for performance. + * + * @param Workspace $workspace The workspace to calculate limits for + * @param string $featureCode The feature code to get the limit for + * + * @return int|null Returns: + * - `null` if the feature is not included in any package + * - `-1` if the feature is unlimited + * - A positive integer representing the total limit */ protected function getTotalLimit(Workspace $workspace, string $featureCode): ?int { @@ -509,7 +1069,21 @@ class EntitlementService } /** - * Get current usage for a feature. + * Get the current usage for a feature. + * + * Calculates the current consumption of a feature based on usage records. + * The time window for calculation depends on the feature's reset configuration: + * - Monthly: From billing cycle anchor to now + * - Rolling: Over the configured rolling window (e.g., last 30 days) + * - None: All-time cumulative usage + * + * Results are cached for 60 seconds to reduce database load. + * + * @param Workspace $workspace The workspace to get usage for + * @param string $featureCode The feature code to get usage for + * @param Feature $feature The feature model (for reset configuration) + * + * @return int The current usage count */ protected function getCurrentUsage(Workspace $workspace, string $featureCode, Feature $feature): int { @@ -543,7 +1117,14 @@ class EntitlementService } /** - * Get a feature by code. + * Get a feature by its unique code. + * + * Retrieves the Feature model from the database, with results cached + * for the standard cache TTL (5 minutes). + * + * @param string $code The unique feature code (e.g., 'pages', 'api_calls') + * + * @return Feature|null The feature model, or null if not found */ protected function getFeature(string $code): ?Feature { @@ -554,6 +1135,31 @@ class EntitlementService /** * Invalidate all entitlement caches for a workspace. + * + * Clears all cached limit and usage data for the workspace, forcing fresh + * calculations on the next entitlement check. This is called automatically + * when packages, boosts, or usage records change. + * + * ## When Called Automatically + * + * - After `recordUsage()` or `recordNamespaceUsage()` + * - After `provisionPackage()` or `provisionBoost()` + * - After `suspendWorkspace()` or `reactivateWorkspace()` + * - After `revokePackage()` + * - After `expireCycleBoundBoosts()` + * + * ## Manual Usage + * + * Call this method manually if you modify entitlement-related data directly + * (outside of this service) to ensure consistency. + * + * ```php + * // After manually modifying a package + * $workspacePackage->update(['expires_at' => now()]); + * $entitlementService->invalidateCache($workspace); + * ``` + * + * @param Workspace $workspace The workspace to invalidate caches for */ public function invalidateCache(Workspace $workspace): void { @@ -572,6 +1178,30 @@ class EntitlementService /** * Expire cycle-bound boosts at billing cycle end. + * + * Marks all active boosts with `duration_type = 'cycle_bound'` as expired. + * This should be called at the start of a new billing cycle to clean up + * promotional or cycle-specific boosts. + * + * Each boost expiration is logged to the EntitlementLog for audit purposes. + * + * ## Typical Usage + * + * Called from a scheduled job or billing webhook when the billing cycle resets: + * + * ```php + * // In a scheduled command or Stripe webhook handler + * public function handle(Workspace $workspace): void + * { + * // Expire old cycle-bound boosts + * $this->entitlementService->expireCycleBoundBoosts($workspace); + * + * // Reset usage counters (handled separately by UsageRecord) + * // The billing cycle anchor determines the new period + * } + * ``` + * + * @param Workspace $workspace The workspace to expire boosts for */ public function expireCycleBoundBoosts(Workspace $workspace): void { @@ -600,9 +1230,19 @@ class EntitlementService // ───────────────────────────────────────────────────────────────────────── /** - * Get the total limit for a feature from namespace-level packages + boosts. + * Get the total limit for a feature from namespace-level packages and boosts. * - * Returns null if feature not included, -1 if unlimited. + * Similar to `getTotalLimit()` but scoped to namespace-level packages only. + * Does not include workspace-level entitlements (that cascade is handled + * by `canForNamespace()`). + * + * @param Namespace_ $namespace The namespace to calculate limits for + * @param string $featureCode The feature code to get the limit for + * + * @return int|null Returns: + * - `null` if the feature is not included in any namespace package + * - `-1` if the feature is unlimited + * - A positive integer representing the total limit */ protected function getNamespaceTotalLimit(Namespace_ $namespace, string $featureCode): ?int { @@ -673,6 +1313,16 @@ class EntitlementService /** * Get current usage for a feature at namespace level. + * + * Similar to `getCurrentUsage()` but scoped to namespace-level usage records. + * The time window for calculation follows the same rules based on feature + * reset configuration. + * + * @param Namespace_ $namespace The namespace to get usage for + * @param string $featureCode The feature code to get usage for + * @param Feature $feature The feature model (for reset configuration) + * + * @return int The current usage count for the namespace */ protected function getNamespaceCurrentUsage(Namespace_ $namespace, string $featureCode, Feature $feature): int { @@ -715,7 +1365,40 @@ class EntitlementService } /** - * Get usage summary for a namespace. + * Get a comprehensive usage summary for a namespace. + * + * Similar to `getUsageSummary()` but for namespace-scoped entitlements. + * Uses the entitlement cascade (namespace -> workspace -> user tier) to + * determine effective limits for each feature. + * + * ## Example Usage + * + * ```php + * $summary = $entitlementService->getNamespaceUsageSummary($namespace); + * + * // Check if namespace is approaching limits + * $linksFeature = $summary->flatten(1)->firstWhere('code', 'links'); + * if ($linksFeature['near_limit']) { + * // Show upgrade prompt + * } + * ``` + * + * @param Namespace_ $namespace The namespace to get the summary for + * + * @return Collection> Usage summary grouped by feature category */ public function getNamespaceUsageSummary(Namespace_ $namespace): Collection { @@ -746,6 +1429,63 @@ class EntitlementService /** * Provision a package for a namespace. + * + * Assigns a package to a namespace, granting access to all features defined + * in that package. For base packages (primary subscription), any existing + * base package is automatically cancelled before the new one is activated. + * + * Namespace packages take precedence over workspace packages in entitlement + * checks, allowing individual namespaces to have different feature levels + * than their parent workspace. + * + * ## Package Types + * + * - **Base packages** (`is_base_package = true`): Primary subscription tier. + * Only one base package can be active at a time per namespace. + * - **Add-on packages**: Supplementary feature bundles that stack with base. + * + * ## Example Usage + * + * ```php + * // Provision a subscription package for a namespace + * $namespacePackage = $entitlementService->provisionNamespacePackage( + * $namespace, + * 'bio-pro', + * [ + * 'billing_cycle_anchor' => now(), + * 'metadata' => ['upgraded_from' => 'bio-free'], + * ] + * ); + * + * // Provision a trial package with expiry + * $entitlementService->provisionNamespacePackage( + * $namespace, + * 'bio-pro', + * [ + * 'expires_at' => now()->addDays(14), + * 'metadata' => ['trial' => true], + * ] + * ); + * ``` + * + * @param Namespace_ $namespace The namespace to provision the package for + * @param string $packageCode The unique code of the package to provision + * @param array{ + * starts_at?: \DateTimeInterface, + * expires_at?: \DateTimeInterface|null, + * billing_cycle_anchor?: \DateTimeInterface, + * metadata?: array|null + * } $options Provisioning options: + * - `starts_at`: When the package becomes active (default: now) + * - `expires_at`: When the package expires (null for indefinite) + * - `billing_cycle_anchor`: Date for monthly usage resets + * - `metadata`: Additional data to store with the package + * + * @return NamespacePackage The created namespace package record + * + * @throws ModelNotFoundException If the package code does not exist + * + * @see self::provisionPackage() For workspace-level package provisioning */ public function provisionNamespacePackage( Namespace_ $namespace, @@ -784,6 +1524,70 @@ class EntitlementService /** * Provision a boost for a namespace. + * + * Creates a boost that adds extra capacity or enables features for a namespace. + * Namespace boosts take precedence over workspace boosts in entitlement checks, + * allowing targeted capacity increases for specific namespaces. + * + * ## Boost Types + * + * - `add_limit`: Adds a fixed amount to the feature limit + * - `unlimited`: Removes the limit entirely for the feature + * + * ## Duration Types + * + * - `cycle_bound`: Expires at the end of the billing cycle + * - `fixed_duration`: Expires after a set time period + * - `permanent`: Never expires (until manually removed) + * - `consumable`: Active until the boosted quantity is consumed + * + * ## Example Usage + * + * ```php + * // Add 100 extra links for a bio namespace + * $entitlementService->provisionNamespaceBoost( + * $namespace, + * 'links', + * [ + * 'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT, + * 'duration_type' => Boost::DURATION_PERMANENT, + * 'limit_value' => 100, + * 'metadata' => ['reason' => 'Promotional giveaway'], + * ] + * ); + * + * // Grant unlimited page views for 7 days + * $entitlementService->provisionNamespaceBoost( + * $namespace, + * 'page_views', + * [ + * 'boost_type' => Boost::BOOST_TYPE_UNLIMITED, + * 'duration_type' => Boost::DURATION_FIXED, + * 'expires_at' => now()->addDays(7), + * ] + * ); + * ``` + * + * @param Namespace_ $namespace The namespace to provision the boost for + * @param string $featureCode The feature code to boost + * @param array{ + * boost_type?: string, + * duration_type?: string, + * limit_value?: int|null, + * starts_at?: \DateTimeInterface, + * expires_at?: \DateTimeInterface|null, + * metadata?: array|null + * } $options Boost options: + * - `boost_type`: Type of boost (default: 'add_limit') + * - `duration_type`: How the boost expires (default: 'cycle_bound') + * - `limit_value`: Amount to add for 'add_limit' type + * - `starts_at`: When the boost becomes active (default: now) + * - `expires_at`: When the boost expires + * - `metadata`: Additional data to store + * + * @return Boost The created boost record + * + * @see self::provisionBoost() For workspace-level boost provisioning */ public function provisionNamespaceBoost( Namespace_ $namespace, @@ -811,6 +1615,31 @@ class EntitlementService /** * Invalidate all entitlement caches for a namespace. + * + * Clears all cached limit and usage data for the namespace, forcing fresh + * calculations on the next entitlement check. This is called automatically + * when namespace packages, boosts, or usage records change. + * + * ## When Called Automatically + * + * - After `recordNamespaceUsage()` + * - After `provisionNamespacePackage()` + * - After `provisionNamespaceBoost()` + * + * ## Manual Usage + * + * Call this method manually if you modify namespace entitlement-related data + * directly (outside of this service) to ensure consistency. + * + * ```php + * // After manually modifying a namespace package + * $namespacePackage->update(['expires_at' => now()]); + * $entitlementService->invalidateNamespaceCache($namespace); + * ``` + * + * @param Namespace_ $namespace The namespace to invalidate caches for + * + * @see self::invalidateCache() For workspace-level cache invalidation */ public function invalidateNamespaceCache(Namespace_ $namespace): void { diff --git a/TODO.md b/TODO.md index 20132cf..c6ff215 100644 --- a/TODO.md +++ b/TODO.md @@ -127,16 +127,22 @@ Several files were missing `declare(strict_types=1);`: --- ### DX-002: Document EntitlementService public API -**Status:** Open +**Status:** Fixed (2026-01-29) **File:** `Services/EntitlementService.php` The EntitlementService is the core API for entitlement checks but lacks comprehensive PHPDoc. External consumers need clear documentation. -**Acceptance Criteria:** -- Add complete PHPDoc to all public methods -- Document exception conditions -- Add @throws annotations where applicable -- Create usage examples in documentation +**Resolution:** +- Added comprehensive class-level PHPDoc explaining key concepts (Features, Packages, Boosts, Usage Records) +- Documented all public methods with: + - Detailed descriptions of purpose and behaviour + - Complete parameter documentation with types + - Return type documentation with structure details + - `@throws` annotations where applicable + - Usage examples in docblocks +- Documented the entitlement cascade model (namespace -> workspace -> user tier) +- Added examples for common use cases (checking entitlements, recording usage, provisioning) +- Documented caching behaviour and invalidation triggers ---