Implement package limit notification system

This commit is contained in:
Codex Agent
2025-11-01 13:19:07 +01:00
parent 81cdee428e
commit 2c14493604
87 changed files with 4557 additions and 290 deletions

View File

@@ -58,6 +58,25 @@ class EventJoinTokenService
public function incrementUsage(EventJoinToken $joinToken): void
{
$joinToken->increment('usage_count');
$event = $joinToken->event()
->with(['eventPackage.package', 'eventPackages.package', 'tenant'])
->first();
if ($event && $event->tenant) {
$usageTracker = app(\App\Services\Packages\PackageUsageTracker::class);
$limitEvaluator = app(\App\Services\Packages\PackageLimitEvaluator::class);
$eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event);
if ($eventPackage && $eventPackage->package?->max_guests !== null) {
$previous = (int) $eventPackage->used_guests;
$eventPackage->increment('used_guests');
$eventPackage->refresh();
$usageTracker->recordGuestUsage($eventPackage, $previous, 1);
}
}
}
public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken

View File

@@ -0,0 +1,151 @@
<?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->package->max_photos;
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;
}
/**
* @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];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services\Packages;
use App\Events\Packages\EventPackageGuestLimitReached;
use App\Events\Packages\EventPackageGuestThresholdReached;
use App\Events\Packages\EventPackagePhotoLimitReached;
use App\Events\Packages\EventPackagePhotoThresholdReached;
use App\Models\EventPackage;
use Illuminate\Contracts\Events\Dispatcher;
class PackageUsageTracker
{
public function __construct(private readonly Dispatcher $dispatcher) {}
public function recordPhotoUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
{
$limit = $eventPackage->package?->max_photos;
if ($limit === null || $limit <= 0) {
return;
}
$newUsed = $eventPackage->used_photos;
$thresholds = collect(config('package-limits.photo_thresholds', []))
->filter(fn (float $value) => $value > 0 && $value < 1)
->sort()
->values();
if ($limit > 0) {
$previousRatio = $previousUsed / $limit;
$newRatio = $newUsed / $limit;
foreach ($thresholds as $threshold) {
if ($previousRatio < $threshold && $newRatio >= $threshold) {
$this->dispatcher->dispatch(new EventPackagePhotoThresholdReached(
$eventPackage,
$threshold,
$limit,
$newUsed,
));
}
}
}
if ($newUsed >= $limit && ($previousUsed < $limit)) {
$this->dispatcher->dispatch(new EventPackagePhotoLimitReached($eventPackage, $limit));
}
}
public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
{
$limit = $eventPackage->package?->max_guests;
if ($limit === null || $limit <= 0) {
return;
}
$newUsed = $eventPackage->used_guests;
$thresholds = collect(config('package-limits.guest_thresholds', []))
->filter(fn (float $value) => $value > 0 && $value < 1)
->sort()
->values();
if ($limit > 0) {
$previousRatio = $previousUsed / $limit;
$newRatio = $newUsed / $limit;
foreach ($thresholds as $threshold) {
if ($previousRatio < $threshold && $newRatio >= $threshold) {
$this->dispatcher->dispatch(new EventPackageGuestThresholdReached(
$eventPackage,
$threshold,
$limit,
$newUsed,
));
}
}
}
if ($newUsed >= $limit && ($previousUsed < $limit)) {
$this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $limit));
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Services\Packages;
use App\Models\Tenant;
class TenantNotificationPreferences
{
private const DEFAULTS = [
'photo_thresholds' => true,
'photo_limits' => true,
'guest_thresholds' => true,
'guest_limits' => true,
'gallery_warnings' => true,
'gallery_expired' => true,
'event_thresholds' => true,
'event_limits' => true,
'package_expiring' => true,
'package_expired' => true,
'credits_low' => true,
];
public function shouldNotify(Tenant $tenant, string $preferenceKey): bool
{
$preferences = $tenant->notification_preferences ?? [];
if (! is_array($preferences)) {
$preferences = [];
}
if (array_key_exists($preferenceKey, $preferences)) {
return (bool) $preferences[$preferenceKey];
}
return self::DEFAULTS[$preferenceKey] ?? true;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Services\Packages;
use App\Events\Packages\TenantCreditsLow;
use App\Events\Packages\TenantPackageEventLimitReached;
use App\Events\Packages\TenantPackageEventThresholdReached;
use App\Models\Tenant;
use App\Models\TenantPackage;
use Illuminate\Contracts\Events\Dispatcher;
class TenantUsageTracker
{
public function __construct(private readonly Dispatcher $dispatcher) {}
public function recordEventUsage(TenantPackage $tenantPackage, int $previousUsed, int $delta = 1): void
{
$limit = $tenantPackage->package?->max_events_per_year;
if ($limit === null || $limit <= 0) {
return;
}
$newUsed = (int) $tenantPackage->used_events;
$thresholds = collect(config('package-limits.event_thresholds', []))
->filter(fn (float $value) => $value > 0 && $value < 1)
->sort()
->values();
if ($limit > 0) {
$previousRatio = $previousUsed / $limit;
$newRatio = $newUsed / $limit;
$currentThreshold = $tenantPackage->event_warning_threshold ?? null;
foreach ($thresholds as $threshold) {
if ($previousRatio < $threshold && $newRatio >= $threshold) {
if ($currentThreshold !== null && $currentThreshold >= $threshold) {
continue;
}
$tenantPackage->forceFill([
'event_warning_sent_at' => now(),
'event_warning_threshold' => $threshold,
])->save();
$this->dispatcher->dispatch(new TenantPackageEventThresholdReached(
$tenantPackage,
$threshold,
$limit,
$newUsed,
));
$currentThreshold = $threshold;
}
}
}
if ($newUsed >= $limit && $previousUsed < $limit) {
if (! $tenantPackage->event_limit_notified_at) {
$tenantPackage->forceFill([
'event_limit_notified_at' => now(),
])->save();
}
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
}
}
public function recordCreditBalance(Tenant $tenant, int $previousBalance, int $newBalance): void
{
$thresholds = collect(config('package-limits.credit_thresholds', []))
->filter(fn ($value) => is_numeric($value) && $value >= 0)
->map(fn ($value) => (int) $value)
->sortDesc()
->values();
$currentThreshold = $tenant->credit_warning_threshold ?? null;
foreach ($thresholds as $threshold) {
if ($previousBalance > $threshold && $newBalance <= $threshold) {
if ($currentThreshold !== null && $threshold >= $currentThreshold) {
continue;
}
$tenant->forceFill([
'credit_warning_sent_at' => now(),
'credit_warning_threshold' => $threshold,
])->save();
$this->dispatcher->dispatch(new TenantCreditsLow($tenant, $newBalance, $threshold));
break;
}
}
}
}