433 lines
15 KiB
PHP
433 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Packages;
|
|
|
|
use App\Models\Event;
|
|
use App\Models\EventPackage;
|
|
use App\Models\Tenant;
|
|
use App\Services\Tenant\TenantUsageService;
|
|
|
|
class PackageLimitEvaluator
|
|
{
|
|
public function __construct(private readonly TenantUsageService $tenantUsageService) {}
|
|
|
|
public function assessEventCreation(Tenant $tenant, ?string $includedPackageSlug = null): ?array
|
|
{
|
|
$hasEndcustomerPackage = $tenant->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->buildUsageSummary(
|
|
(int) $eventPackage->used_guests,
|
|
$limits['max_guests'],
|
|
config('package-limits.guest_thresholds', [])
|
|
);
|
|
|
|
$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<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(),
|
|
];
|
|
}
|
|
|
|
private function normalizeTenantLimit(?int $value): ?int
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
$value = (int) $value;
|
|
|
|
if ($value <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|