Implement package limit notification system
This commit is contained in:
@@ -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
|
||||
|
||||
151
app/Services/Packages/PackageLimitEvaluator.php
Normal file
151
app/Services/Packages/PackageLimitEvaluator.php
Normal 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];
|
||||
}
|
||||
}
|
||||
87
app/Services/Packages/PackageUsageTracker.php
Normal file
87
app/Services/Packages/PackageUsageTracker.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Services/Packages/TenantNotificationPreferences.php
Normal file
37
app/Services/Packages/TenantNotificationPreferences.php
Normal 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;
|
||||
}
|
||||
}
|
||||
97
app/Services/Packages/TenantUsageTracker.php
Normal file
97
app/Services/Packages/TenantUsageTracker.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user