hasEventAllowance()) { return null; } $package = $tenant->getActiveResellerPackage(); if ($package) { $limit = $package->package->max_events_per_year ?? 0; return [ 'code' => 'event_limit_exceeded', 'title' => 'Event quota reached', 'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.', '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, ], ]; } return [ 'code' => 'event_credits_exhausted', 'title' => 'No event credits remaining', 'message' => 'You have no event credits remaining. Purchase additional credits or a package to create new events.', 'status' => 402, 'meta' => [ 'scope' => 'credits', 'balance' => (int) ($tenant->event_credits_balance ?? 0), ], ]; } public function assessPhotoUpload(Tenant $tenant, int $eventId, ?Event $preloadedEvent = null): ?array { [$event, $eventPackage] = $this->resolveEventAndPackage($tenant, $eventId, $preloadedEvent); if (! $event) { return [ 'code' => 'event_not_found', 'title' => 'Event not accessible', 'message' => 'The selected event could not be found or belongs to another tenant.', 'status' => 404, 'meta' => [ 'scope' => 'photos', 'event_id' => $eventId, ], ]; } if (! $eventPackage || ! $eventPackage->package) { return [ 'code' => 'event_package_missing', 'title' => 'Event package missing', 'message' => 'No package is attached to this event. Assign a package to enable uploads.', 'status' => 409, 'meta' => [ 'scope' => 'photos', 'event_id' => $event->id, ], ]; } $maxPhotos = $eventPackage->effectivePhotoLimit(); if ($maxPhotos === null) { return null; } if ($eventPackage->used_photos >= $maxPhotos) { return [ 'code' => 'photo_limit_exceeded', 'title' => 'Photo upload limit reached', 'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.', 'status' => 402, 'meta' => [ 'scope' => 'photos', 'used' => (int) $eventPackage->used_photos, 'limit' => (int) $maxPhotos, 'remaining' => 0, 'event_id' => $event->id, 'package_id' => $eventPackage->package_id, ], ]; } 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): array { $limits = $eventPackage->effectiveLimits(); $photoSummary = $this->buildUsageSummary( (int) $eventPackage->used_photos, $limits['max_photos'], config('package-limits.photo_thresholds', []) ); $guestSummary = $this->buildUsageSummary( (int) $eventPackage->used_guests, $limits['max_guests'], config('package-limits.guest_thresholds', []) ); $gallerySummary = $this->buildGallerySummary( $eventPackage, config('package-limits.gallery_warning_days', []) ); return [ 'photos' => $photoSummary, 'guests' => $guestSummary, 'gallery' => $gallerySummary, 'can_upload_photos' => $photoSummary['state'] !== 'limit_reached' && $gallerySummary['state'] !== 'expired', 'can_add_guests' => $guestSummary['state'] !== 'limit_reached', ]; } /** * @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(), ]; } }