tenantPackages() ->where('active', true) ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer')) ->exists(); if ($hasEndcustomerPackage) { return null; } if ($tenant->hasEventAllowanceFor($includedPackageSlug)) { return null; } $package = $tenant->getActiveResellerPackageFor($includedPackageSlug); if (! $package) { if ($includedPackageSlug) { $hasAnyActive = $tenant->tenantPackages() ->where('active', true) ->where(function ($query) { $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); }) ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) ->exists(); if ($hasAnyActive) { return [ 'code' => 'event_tier_unavailable', 'title' => __('api.packages.event_tier_unavailable.title'), 'message' => __('api.packages.event_tier_unavailable.message'), 'status' => 402, 'meta' => [ 'scope' => 'events', 'requested_tier' => $includedPackageSlug, ], ]; } } $latestResellerPackage = $tenant->tenantPackages() ->with('package') ->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller')) ->orderByDesc('purchased_at') ->orderByDesc('id') ->first(); if ($latestResellerPackage && $latestResellerPackage->package) { $limit = $latestResellerPackage->package->max_events_per_year ?? 0; return [ 'code' => 'event_limit_exceeded', 'title' => __('api.packages.event_limit_exceeded.title'), 'message' => __('api.packages.event_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'events', 'used' => (int) $latestResellerPackage->used_events, 'limit' => $limit, 'remaining' => max(0, $limit - $latestResellerPackage->used_events), 'tenant_package_id' => $latestResellerPackage->id, 'package_id' => $latestResellerPackage->package_id, ], ]; } return [ 'code' => 'event_limit_missing', 'title' => __('api.packages.event_limit_missing.title'), 'message' => __('api.packages.event_limit_missing.message'), 'status' => 402, 'meta' => [ 'scope' => 'events', 'used' => 0, 'limit' => 0, 'remaining' => 0, 'tenant_package_id' => null, 'package_id' => null, ], ]; } $limit = $package->package->max_events_per_year ?? 0; return [ 'code' => 'event_limit_exceeded', 'title' => __('api.packages.event_limit_exceeded.title'), 'message' => __('api.packages.event_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'events', 'used' => (int) $package->used_events, 'limit' => $limit, 'remaining' => max(0, $limit - $package->used_events), 'tenant_package_id' => $package->id, 'package_id' => $package->package_id, ], ]; } public function assessPhotoUpload( Tenant $tenant, int $eventId, ?Event $preloadedEvent = null, ?int $incomingBytes = null ): ?array { [$event, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent); if (! $event) { return [ 'code' => 'event_not_found', 'title' => __('api.packages.event_not_found.title'), 'message' => __('api.packages.event_not_found.message'), 'status' => 404, 'meta' => [ 'scope' => 'photos', 'event_id' => $eventId, ], ]; } if (! $eventPackage || ! $eventPackage->package) { return [ 'code' => 'event_package_missing', 'title' => __('api.packages.event_package_missing.title'), 'message' => __('api.packages.event_package_missing.message'), 'status' => 409, 'meta' => [ 'scope' => 'photos', 'event_id' => $event->id, ], ]; } $maxPhotos = $eventPackage->effectivePhotoLimit(); if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) { return [ 'code' => 'photo_limit_exceeded', 'title' => __('api.packages.photo_limit_exceeded.title'), 'message' => __('api.packages.photo_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'photos', 'used' => (int) $eventPackage->used_photos, 'limit' => (int) $maxPhotos, 'remaining' => 0, 'event_id' => $event->id, 'package_id' => $eventPackage->package_id, ], ]; } $tenantPhotoLimit = $this->normalizeTenantLimit($tenant->max_photos_per_event); if ($tenantPhotoLimit !== null && ($maxPhotos === null || $tenantPhotoLimit < $maxPhotos)) { if ($eventPackage->used_photos >= $tenantPhotoLimit) { return [ 'code' => 'tenant_photo_limit_exceeded', 'title' => __('api.packages.tenant_photo_limit_exceeded.title'), 'message' => __('api.packages.tenant_photo_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'photos', 'used' => (int) $eventPackage->used_photos, 'limit' => (int) $tenantPhotoLimit, 'remaining' => max(0, (int) $tenantPhotoLimit - (int) $eventPackage->used_photos), 'event_id' => $event->id, 'limit_source' => 'tenant', ], ]; } } $storageLimitBytes = $this->tenantUsageService->storageLimitBytes($tenant); if ($storageLimitBytes !== null) { $usedBytes = $this->tenantUsageService->storageUsedBytes($tenant); $projectedBytes = $usedBytes + max(0, (int) ($incomingBytes ?? 0)); if ($projectedBytes >= $storageLimitBytes) { return [ 'code' => 'tenant_storage_limit_exceeded', 'title' => __('api.packages.tenant_storage_limit_exceeded.title'), 'message' => __('api.packages.tenant_storage_limit_exceeded.message'), 'status' => 402, 'meta' => [ 'scope' => 'storage', 'used_bytes' => $usedBytes, 'limit_bytes' => $storageLimitBytes, 'remaining_bytes' => max(0, $storageLimitBytes - $usedBytes), 'event_id' => $event->id, 'limit_source' => 'tenant', ], ]; } } return null; } public function resolveEventPackageForPhotoUpload( Tenant $tenant, int $eventId, ?Event $preloadedEvent = null ): ?EventPackage { [, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent); return $eventPackage; } public function summarizeEventPackage(EventPackage $eventPackage, ?int $tasksUsed = null): array { $limits = $eventPackage->effectiveLimits(); $photoSummary = $this->buildUsageSummary( (int) $eventPackage->used_photos, $limits['max_photos'], config('package-limits.photo_thresholds', []) ); $guestSummary = $this->applyGuestGrace( $this->buildUsageSummary( (int) $eventPackage->used_guests, $limits['max_guests'], config('package-limits.guest_thresholds', []) ), (int) $eventPackage->used_guests ); $gallerySummary = $this->buildGallerySummary( $eventPackage, config('package-limits.gallery_warning_days', []) ); $taskSummary = $tasksUsed === null ? null : $this->buildUsageSummary( $tasksUsed, $limits['max_tasks'], [] ); return [ 'photos' => $photoSummary, 'guests' => $guestSummary, 'gallery' => $gallerySummary, 'tasks' => $taskSummary, 'can_upload_photos' => $photoSummary['state'] !== 'limit_reached' && $gallerySummary['state'] !== 'expired', 'can_add_guests' => $guestSummary['state'] !== 'limit_reached', 'can_add_tasks' => $taskSummary ? $taskSummary['state'] !== 'limit_reached' : null, ]; } /** * @return array{0: ?Event, 1: ?\App\Models\EventPackage} */ private function resolveEventAndPackage( Tenant $tenant, int $eventId, ?Event $preloadedEvent = null ): array { $event = $preloadedEvent; if (! $event) { $event = Event::with(['eventPackage.package', 'eventPackages.package']) ->find($eventId); } if (! $event || $event->tenant_id !== $tenant->id) { return [null, null]; } $eventPackage = $event->eventPackage; if (! $eventPackage && method_exists($event, 'eventPackages')) { $eventPackage = $event->eventPackages() ->with('package') ->orderByDesc('purchased_at') ->orderByDesc('created_at') ->first(); } if ($eventPackage && ! $eventPackage->relationLoaded('package')) { $eventPackage->load('package'); } return [$event, $eventPackage]; } /** * @param array $rawThresholds */ private function buildUsageSummary(int $used, ?int $limit, array $rawThresholds): array { $thresholds = collect($rawThresholds) ->filter(fn ($value) => is_numeric($value) && $value > 0 && $value < 1) ->map(fn ($value) => round((float) $value, 4)) ->unique() ->sort() ->values() ->all(); if ($limit === null || $limit <= 0) { return [ 'limit' => null, 'used' => $used, 'remaining' => null, 'percentage' => null, 'state' => 'unlimited', 'threshold_reached' => null, 'next_threshold' => $thresholds[0] ?? null, 'thresholds' => $thresholds, ]; } $clampedLimit = max(1, (int) $limit); $ratio = $used / $clampedLimit; $percentage = round(min(1, $ratio), 4); $remaining = max(0, $clampedLimit - $used); $state = 'ok'; $thresholdReached = null; $nextThreshold = null; foreach ($thresholds as $threshold) { if ($percentage >= $threshold) { $thresholdReached = $threshold; if ($state !== 'limit_reached') { $state = 'warning'; } } elseif ($nextThreshold === null) { $nextThreshold = $threshold; } } if ($used >= $clampedLimit) { $state = 'limit_reached'; $thresholdReached = 1.0; $nextThreshold = null; } return [ 'limit' => $clampedLimit, 'used' => $used, 'remaining' => $remaining, 'percentage' => $percentage, 'state' => $state, 'threshold_reached' => $thresholdReached, 'next_threshold' => $nextThreshold, 'thresholds' => $thresholds, ]; } /** * @param array $warningDays */ private function buildGallerySummary(EventPackage $eventPackage, array $warningDays): array { $expiresAt = $eventPackage->gallery_expires_at; $warningValues = collect($warningDays) ->filter(fn ($value) => is_numeric($value) && $value >= 0) ->map(fn ($value) => (int) $value) ->unique() ->sort() ->values() ->all(); if (! $expiresAt) { return [ 'state' => 'unlimited', 'expires_at' => null, 'days_remaining' => null, 'warning_thresholds' => $warningValues, 'warning_triggered' => null, 'warning_sent_at' => null, 'expired_notified_at' => null, ]; } $daysRemaining = now()->diffInDays($expiresAt, false); $state = 'ok'; $warningTriggered = null; foreach ($warningValues as $threshold) { if ($daysRemaining <= $threshold && $daysRemaining >= 0) { $warningTriggered = $threshold; $state = 'warning'; break; } } if ($daysRemaining < 0) { $state = 'expired'; } return [ 'state' => $state, 'expires_at' => $expiresAt->toIso8601String(), 'days_remaining' => $daysRemaining, 'warning_thresholds' => $warningValues, 'warning_triggered' => $warningTriggered, 'warning_sent_at' => $eventPackage->gallery_warning_sent_at?->toIso8601String(), 'expired_notified_at' => $eventPackage->gallery_expired_notified_at?->toIso8601String(), ]; } private function normalizeTenantLimit(?int $value): ?int { if ($value === null) { return null; } $value = (int) $value; if ($value <= 0) { return null; } return $value; } /** * @param array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array} $summary * @return array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array} */ private function applyGuestGrace(array $summary, int $used): array { $limit = $summary['limit'] ?? null; if ($limit === null || $limit <= 0) { return $summary; } $grace = (int) config('package-limits.guest_grace', 10); $hardLimit = $limit + max(0, $grace); if ($used >= $hardLimit) { $summary['state'] = 'limit_reached'; $summary['threshold_reached'] = 1.0; $summary['next_threshold'] = null; $summary['remaining'] = 0; return $summary; } if ($used >= $limit) { $summary['state'] = 'warning'; $summary['threshold_reached'] = 1.0; $summary['next_threshold'] = null; $summary['remaining'] = 0; } return $summary; } }