Files
fotospiel-app/app/Services/Packages/PackageLimitEvaluator.php

298 lines
9.5 KiB
PHP

<?php
namespace App\Services\Packages;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\Tenant;
class PackageLimitEvaluator
{
public function assessEventCreation(Tenant $tenant): ?array
{
if ($tenant->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<int|float|string> $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<int|string> $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(),
];
}
}