Update partner packages, copy, and demo switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-15 17:33:36 +01:00
parent 2f93271d94
commit ad829ae509
50 changed files with 1335 additions and 411 deletions

View File

@@ -26,7 +26,7 @@ class SeedDemoSwitcherTenants extends Command
{
protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}';
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + reseller profiles)';
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + partner profiles)';
public function __construct(private EventStorageManager $eventStorageManager)
{
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
$slugs = [
'starter' => 'Starter',
'standard' => 'Standard',
's-small-reseller' => 'Reseller S',
's-small-reseller' => 'Partner Start',
];
$packages = [];
@@ -232,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command
private function seedResellerActive(array $packages, array $eventTypes): void
{
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
$tenant = $this->upsertTenant(
slug: 'demo-reseller-active',
name: 'Demo Reseller Active',
contactEmail: 'reseller-active@demo.fotospiel',
name: 'Demo Partner Active',
contactEmail: 'partner-active@demo.fotospiel',
attributes: [
'subscription_tier' => 'reseller',
'subscription_status' => 'active',
],
);
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel');
$this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
@@ -279,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command
foreach ($events as $index => $config) {
$event = $this->upsertEvent(
tenant: $tenant,
package: $packages['standard'],
package: $eventPackage,
eventType: $config['type'],
attributes: [
'name' => $config['name'],
@@ -296,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command
private function seedResellerFull(array $packages, array $eventTypes): void
{
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
$tenant = $this->upsertTenant(
slug: 'demo-reseller-full',
name: 'Demo Reseller Voll',
contactEmail: 'reseller-full@demo.fotospiel',
name: 'Demo Partner Voll',
contactEmail: 'partner-full@demo.fotospiel',
attributes: [
'subscription_tier' => 'reseller',
'subscription_status' => 'active',
],
);
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel');
$this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
@@ -330,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command
foreach ($eventConfigs as $index => $config) {
$event = $this->upsertEvent(
tenant: $tenant,
package: $packages['standard'],
package: $eventPackage,
eventType: $config['type'],
attributes: [
'name' => $config['name'],
@@ -435,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command
return $event;
}
private function resolveIncludedPackage(Package $resellerPackage, array $packages): Package
{
$includedSlug = $resellerPackage->included_package_slug;
if ($includedSlug && isset($packages[$includedSlug])) {
return $packages[$includedSlug];
}
$fallback = $packages['starter'] ?? $packages['standard'] ?? null;
return $fallback ?? $resellerPackage;
}
private function fallbackEventType(): ?EventType
{
$fallback = EventType::first();

View File

@@ -277,13 +277,13 @@ class PackageController extends Controller
'purchased_at' => now(),
]);
} else {
// Reseller subscription
// Partner / reseller Event-Kontingent package
\App\Models\TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => $package->price,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'expires_at' => null,
'active' => true,
]);
}

View File

@@ -99,6 +99,9 @@ class EventController extends Controller
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']);
$requestedServiceSlug = $request->input('service_package_slug');
$requestedServiceSlug = is_string($requestedServiceSlug) && $requestedServiceSlug !== '' ? $requestedServiceSlug : null;
unset($validated['service_package_slug']);
$tenantPackage = $tenant->tenantPackages()
->with('package')
@@ -116,6 +119,18 @@ class EventController extends Controller
$package = $this->resolveOwnerPackage();
}
$billingTenantPackage = null;
if (! $package) {
$billingTenantPackage = $requestedServiceSlug
? $tenant->getActiveResellerPackageFor($requestedServiceSlug)
: $tenant->getActiveResellerPackage();
if ($billingTenantPackage && $billingTenantPackage->package) {
$package = $billingTenantPackage->package;
$requestedServiceSlug = $requestedServiceSlug ?: $package->included_package_slug;
}
}
if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
}
@@ -126,6 +141,11 @@ class EventController extends Controller
]);
}
$billingIsReseller = $package->isReseller();
$eventServicePackage = $billingIsReseller
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
: $package;
$requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
@@ -161,8 +181,8 @@ class EventController extends Controller
unset($eventData['features']);
}
$settings['branding_allowed'] = $package->branding_allowed !== false;
$settings['watermark_allowed'] = $package->watermark_allowed !== false;
$settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false;
$settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false;
$eventData['settings'] = $settings;
@@ -190,21 +210,23 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
$event = Event::create($eventData);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'package_id' => $eventServicePackage->id,
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
'purchased_at' => now(),
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
'gallery_expires_at' => $eventServicePackage->gallery_days
? now()->addDays($eventServicePackage->gallery_days)
: null,
]);
if ($package->isReseller() && ! $isSuperAdmin) {
if ($billingIsReseller && ! $isSuperAdmin) {
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
throw new HttpException(402, 'Insufficient package allowance.');
}
}
@@ -227,6 +249,47 @@ class EventController extends Controller
], 201);
}
private function resolveResellerDefaultEventPackage(): Package
{
return $this->resolveResellerEventPackageForSlug('standard');
}
private function resolveResellerEventPackageForSlug(?string $slug): Package
{
if (is_string($slug) && $slug !== '') {
$match = Package::query()
->where('type', 'endcustomer')
->where('slug', $slug)
->first();
if ($match) {
return $match;
}
}
$default = Package::query()
->where('type', 'endcustomer')
->where('slug', 'standard')
->first();
if ($default) {
return $default;
}
$fallback = Package::query()
->where('type', 'endcustomer')
->orderBy('price')
->first();
if (! $fallback) {
throw ValidationException::withMessages([
'package_id' => __('Aktuell ist kein Endkunden-Paket verfügbar. Bitte kontaktiere den Support.'),
]);
}
return $fallback;
}
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
{
return PackagePurchase::query()

View File

@@ -60,6 +60,7 @@ class TenantPackageController extends Controller
$pkg?->limits ?? [],
$this->buildUsageSnapshot($eventPackage),
[
'included_package_slug' => $pkg?->included_package_slug,
'branding_allowed' => $pkg?->branding_allowed,
'watermark_allowed' => $pkg?->watermark_allowed,
'features' => $pkg?->features ?? [],

View File

@@ -28,7 +28,12 @@ class CreditCheckMiddleware
}
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
$violation = $this->limitEvaluator->assessEventCreation($tenant);
$includedSlug = $request->input('service_package_slug');
$violation = $this->limitEvaluator->assessEventCreation(
$tenant,
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
);
if ($violation !== null) {
return ApiError::response(

View File

@@ -73,7 +73,12 @@ class PackageMiddleware
private function detectViolation(Request $request, Tenant $tenant): ?array
{
if ($request->routeIs('api.v1.tenant.events.store')) {
return $this->limitEvaluator->assessEventCreation($tenant);
$includedSlug = $request->input('service_package_slug');
return $this->limitEvaluator->assessEventCreation(
$tenant,
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
);
}
if ($request->routeIs('api.v1.tenant.events.photos.store')) {

View File

@@ -31,6 +31,12 @@ class EventStoreRequest extends FormRequest
'location' => ['nullable', 'string', 'max:255'],
'event_type_id' => ['required', 'exists:event_types,id'],
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
'service_package_slug' => [
'nullable',
'string',
'max:64',
Rule::exists('packages', 'slug')->where('type', 'endcustomer'),
],
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
'public_url' => ['nullable', 'url', 'max:500'],
'custom_domain' => ['nullable', 'string', 'max:255'],

View File

@@ -19,6 +19,7 @@ class Package extends Model
'name_translations',
'slug',
'type',
'included_package_slug',
'price',
'max_photos',
'max_guests',

View File

@@ -100,7 +100,14 @@ class Tenant extends Model
public function activeResellerPackage(): HasOne
{
return $this->hasOne(TenantPackage::class)->where('active', true);
return $this->hasOne(TenantPackage::class)
->where('active', true)
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
->orderBy('purchased_at')
->orderBy('id');
}
public function notificationLogs(): HasMany
@@ -151,6 +158,13 @@ class Tenant extends Model
return false;
}
public function hasEventAllowanceFor(?string $includedPackageSlug): bool
{
$package = $this->getActiveResellerPackageFor($includedPackageSlug);
return $package !== null && $package->canCreateEvent();
}
public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
{
$package = $this->getActiveResellerPackage();
@@ -183,13 +197,68 @@ class Tenant extends Model
return false;
}
public function consumeEventAllowanceFor(?string $includedPackageSlug, int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
{
$package = $this->getActiveResellerPackageFor($includedPackageSlug);
if ($package && $package->canCreateEvent()) {
$previousUsed = (int) $package->used_events;
$package->increment('used_events', $amount);
$package->refresh();
app(\App\Services\Packages\TenantUsageTracker::class)->recordEventUsage(
$package,
$previousUsed,
$amount
);
Log::info('Tenant package usage recorded', [
'tenant_id' => $this->id,
'tenant_package_id' => $package->id,
'used_events' => $package->used_events,
'amount' => $amount,
]);
return true;
}
Log::warning('Event allowance missing for tenant', [
'tenant_id' => $this->id,
'reason' => $reason,
'included_package_slug' => $includedPackageSlug,
]);
return false;
}
public function getActiveResellerPackage(): ?TenantPackage
{
return $this->activeResellerPackage()
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
return $this->activeResellerPackage()->with('package')->first();
}
public function getActiveResellerPackageFor(?string $includedPackageSlug): ?TenantPackage
{
$query = $this->tenantPackages()
->with('package')
->where('active', true)
->orderByDesc('expires_at')
->first();
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
->orderBy('purchased_at')
->orderBy('id');
if (is_string($includedPackageSlug) && $includedPackageSlug !== '') {
$query->whereHas('package', function ($query) use ($includedPackageSlug) {
$query->where('included_package_slug', $includedPackageSlug);
if ($includedPackageSlug === 'standard') {
$query->orWhereNull('included_package_slug');
}
});
}
return $query->first();
}
public function activeSubscription(): Attribute

View File

@@ -66,18 +66,30 @@ class TenantPackage extends Model
return false;
}
$maxEvents = $this->package->max_events_per_year ?? 0;
$maxEvents = $this->package->max_events_per_year;
if ($maxEvents === null) {
return true;
}
$maxEvents = max(0, (int) $maxEvents);
return $this->used_events < $maxEvents;
}
public function getRemainingEventsAttribute(): int
public function getRemainingEventsAttribute(): ?int
{
if (! $this->package->isReseller()) {
return 0;
}
$max = $this->package->max_events_per_year ?? 0;
$max = $this->package->max_events_per_year;
if ($max === null) {
return null;
}
$max = max(0, (int) $max);
return max(0, $max - $this->used_events);
}
@@ -94,9 +106,7 @@ class TenantPackage extends Model
$package = $tenantPackage->package;
if ($package && $package->isReseller()) {
if (! $tenantPackage->expires_at) {
$tenantPackage->expires_at = now()->addYear();
}
// Reseller packages represent prepaid Event-Kontingente and should not expire by default.
} elseif (! $tenantPackage->expires_at) {
$tenantPackage->expires_at = now()->addYear();
}

View File

@@ -94,18 +94,34 @@ class CheckoutAssignmentService
]
);
$tenantPackage = TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'price' => round($price, 2),
'active' => true,
'purchased_at' => now(),
'expires_at' => $this->resolveExpiry($package, $tenant),
]
);
if ($package->type === 'reseller') {
$tenantPackage = null;
if ($purchase->wasRecentlyCreated) {
$tenantPackage = TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => round($price, 2),
'active' => true,
'purchased_at' => now(),
'expires_at' => null,
'used_events' => 0,
]);
}
} else {
$tenantPackage = TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'price' => round($price, 2),
'active' => true,
'purchased_at' => now(),
'expires_at' => $this->resolveExpiry($package, $tenant),
]
);
}
if ($package->type !== 'reseller') {
$tenant->forceFill([
@@ -188,11 +204,7 @@ class CheckoutAssignmentService
protected function resolveExpiry(Package $package, Tenant $tenant)
{
if ($package->type === 'reseller') {
$hasActive = TenantPackage::where('tenant_id', $tenant->id)
->where('active', true)
->exists();
return $hasActive ? now()->addYear() : now()->addDays(14);
return null;
}
return now()->addYear();

View File

@@ -11,7 +11,7 @@ class PackageLimitEvaluator
{
public function __construct(private readonly TenantUsageService $tenantUsageService) {}
public function assessEventCreation(Tenant $tenant): ?array
public function assessEventCreation(Tenant $tenant, ?string $includedPackageSlug = null): ?array
{
$hasEndcustomerPackage = $tenant->tenantPackages()
->where('active', true)
@@ -22,17 +22,66 @@ class PackageLimitEvaluator
return null;
}
if ($tenant->hasEventAllowance()) {
if ($tenant->hasEventAllowanceFor($includedPackageSlug)) {
return null;
}
$package = $tenant->getActiveResellerPackage();
$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' => 'No package assigned',
'message' => 'Assign a package or addon to create events.',
'title' => __('api.packages.event_limit_missing.title'),
'message' => __('api.packages.event_limit_missing.message'),
'status' => 402,
'meta' => [
'scope' => 'events',
@@ -49,8 +98,8 @@ class PackageLimitEvaluator
return [
'code' => 'event_limit_exceeded',
'title' => 'Event quota reached',
'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.',
'title' => __('api.packages.event_limit_exceeded.title'),
'message' => __('api.packages.event_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'events',
@@ -74,8 +123,8 @@ class PackageLimitEvaluator
if (! $event) {
return [
'code' => 'event_not_found',
'title' => 'Event not accessible',
'message' => 'The selected event could not be found or belongs to another tenant.',
'title' => __('api.packages.event_not_found.title'),
'message' => __('api.packages.event_not_found.message'),
'status' => 404,
'meta' => [
'scope' => 'photos',
@@ -87,8 +136,8 @@ class PackageLimitEvaluator
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.',
'title' => __('api.packages.event_package_missing.title'),
'message' => __('api.packages.event_package_missing.message'),
'status' => 409,
'meta' => [
'scope' => 'photos',
@@ -102,8 +151,8 @@ class PackageLimitEvaluator
if ($maxPhotos !== null && $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.',
'title' => __('api.packages.photo_limit_exceeded.title'),
'message' => __('api.packages.photo_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'photos',
@@ -122,8 +171,8 @@ class PackageLimitEvaluator
if ($eventPackage->used_photos >= $tenantPhotoLimit) {
return [
'code' => 'tenant_photo_limit_exceeded',
'title' => 'Tenant photo limit reached',
'message' => 'This tenant has reached its photo allowance for the event.',
'title' => __('api.packages.tenant_photo_limit_exceeded.title'),
'message' => __('api.packages.tenant_photo_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'photos',
@@ -146,8 +195,8 @@ class PackageLimitEvaluator
if ($projectedBytes >= $storageLimitBytes) {
return [
'code' => 'tenant_storage_limit_exceeded',
'title' => 'Tenant storage limit reached',
'message' => 'This tenant has reached its storage allowance.',
'title' => __('api.packages.tenant_storage_limit_exceeded.title'),
'message' => __('api.packages.tenant_storage_limit_exceeded.message'),
'status' => 402,
'meta' => [
'scope' => 'storage',

View File

@@ -4,7 +4,6 @@ namespace App\Services\Packages;
use App\Events\Packages\TenantPackageEventLimitReached;
use App\Events\Packages\TenantPackageEventThresholdReached;
use App\Models\Tenant;
use App\Models\TenantPackage;
use Illuminate\Contracts\Events\Dispatcher;
@@ -63,6 +62,12 @@ class TenantUsageTracker
}
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
if ($tenantPackage->active) {
$tenantPackage->forceFill([
'active' => false,
])->save();
}
}
}
}

View File

@@ -43,6 +43,7 @@ trait PresentsPackages
'name' => $name,
'slug' => $package->slug,
'type' => $package->type,
'included_package_slug' => $package->included_package_slug,
'price' => $package->price,
'paddle_product_id' => $package->paddle_product_id,
'paddle_price_id' => $package->paddle_price_id,