diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 70c55c6a..62dd7219 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -149,6 +149,7 @@ class EventController extends Controller $eventServicePackage = $billingIsReseller ? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug) : $package; + $sourceTenantPackage = $billingIsReseller ? $billingTenantPackage : $tenantPackage; $requiresWaiver = $package->isEndcustomer(); $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; @@ -216,12 +217,13 @@ class EventController extends Controller $eventData = Arr::only($eventData, $allowed); - $event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) { + $event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin, $sourceTenantPackage) { $event = Event::create($eventData); EventPackage::create([ 'event_id' => $event->id, 'package_id' => $eventServicePackage->id, + 'tenant_package_id' => $sourceTenantPackage?->id, 'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price, 'purchased_at' => now(), 'gallery_expires_at' => $eventServicePackage->gallery_days diff --git a/app/Http/Controllers/Api/TenantPackageController.php b/app/Http/Controllers/Api/TenantPackageController.php index e9c998f1..11e6fd8a 100644 --- a/app/Http/Controllers/Api/TenantPackageController.php +++ b/app/Http/Controllers/Api/TenantPackageController.php @@ -31,10 +31,12 @@ class TenantPackageController extends Controller ->get(); $usageEventPackage = $this->resolveUsageEventPackage($tenant->id); + $linkedEventPackages = $this->resolveLinkedEventPackages($tenant->id, $packages->pluck('id')->all()); - $packages->each(function (TenantPackage $package) use ($usageEventPackage): void { + $packages->each(function (TenantPackage $package) use ($usageEventPackage, $linkedEventPackages): void { $eventPackage = $package->active ? $usageEventPackage : null; $this->hydratePackageSnapshot($package, $eventPackage); + $this->attachUsageEvents($package, $linkedEventPackages); }); $activePackage = $tenant->getActiveResellerPackage(); @@ -43,6 +45,7 @@ class TenantPackageController extends Controller $activePackage = $packages->firstWhere('active', true); } else { $this->hydratePackageSnapshot($activePackage, $usageEventPackage); + $this->attachUsageEvents($activePackage, $linkedEventPackages); } return response()->json([ @@ -52,6 +55,79 @@ class TenantPackageController extends Controller ]); } + /** + * @param array $tenantPackageIds + * @return array + */ + private function resolveLinkedEventPackages(int $tenantId, array $tenantPackageIds): array + { + if ($tenantPackageIds === []) { + return []; + } + + $eventPackages = EventPackage::query() + ->whereIn('tenant_package_id', $tenantPackageIds) + ->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId)) + ->with(['event:id,slug,name,date,status']) + ->orderByDesc('purchased_at') + ->orderByDesc('created_at') + ->get() + ->groupBy('tenant_package_id'); + + $result = []; + + foreach ($eventPackages as $tenantPackageId => $groupedPackages) { + $current = $groupedPackages + ->first(function (EventPackage $eventPackage) { + return $eventPackage->gallery_expires_at && $eventPackage->gallery_expires_at->isFuture(); + }); + + $result[(int) $tenantPackageId] = [ + 'current' => $current, + 'last' => $groupedPackages->first(), + 'count' => $groupedPackages->count(), + ]; + } + + return $result; + } + + /** + * @param array $linkedEventPackages + */ + private function attachUsageEvents(TenantPackage $package, array $linkedEventPackages): void + { + $usage = $linkedEventPackages[$package->id] ?? null; + + if (! $usage) { + $package->linked_events_count = 0; + $package->current_event = null; + $package->last_event = null; + + return; + } + + $package->linked_events_count = $usage['count']; + $package->current_event = $this->formatLinkedEvent($usage['current']); + $package->last_event = $this->formatLinkedEvent($usage['last']); + } + + private function formatLinkedEvent(?EventPackage $eventPackage): ?array + { + if (! $eventPackage || ! $eventPackage->event) { + return null; + } + + return [ + 'id' => $eventPackage->event->id, + 'slug' => $eventPackage->event->slug, + 'name' => $eventPackage->event->name, + 'status' => $eventPackage->event->status, + 'event_date' => $eventPackage->event->date?->toIso8601String(), + 'linked_at' => $eventPackage->purchased_at?->toIso8601String(), + ]; + } + private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void { $pkg = $package->package; diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index e9c997a2..8d14484f 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -89,6 +89,7 @@ class EventResource extends JsonResource 'qr_code_url' => null, 'package' => $eventPackage ? [ 'id' => $eventPackage->package_id, + 'tenant_package_id' => $eventPackage->tenant_package_id, 'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name, 'price' => $eventPackage->purchased_price, 'purchased_at' => $eventPackage->purchased_at?->toIso8601String(), diff --git a/app/Models/EventPackage.php b/app/Models/EventPackage.php index 335adcb9..11bedae0 100644 --- a/app/Models/EventPackage.php +++ b/app/Models/EventPackage.php @@ -16,6 +16,7 @@ class EventPackage extends Model protected $fillable = [ 'event_id', 'package_id', + 'tenant_package_id', 'purchased_price', 'purchased_at', 'used_photos', @@ -51,6 +52,11 @@ class EventPackage extends Model return $this->belongsTo(Package::class)->withTrashed(); } + public function tenantPackage(): BelongsTo + { + return $this->belongsTo(TenantPackage::class); + } + public function addons(): HasMany { return $this->hasMany(EventPackageAddon::class); diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php index 543c750d..c889988e 100644 --- a/app/Models/TenantPackage.php +++ b/app/Models/TenantPackage.php @@ -6,6 +6,7 @@ use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class TenantPackage extends Model { @@ -47,6 +48,11 @@ class TenantPackage extends Model return $this->belongsTo(Package::class)->withTrashed(); } + public function eventPackages(): HasMany + { + return $this->hasMany(EventPackage::class); + } + public function isActive(): bool { if ($this->package && $this->package->isEndcustomer()) { diff --git a/database/migrations/2026_02_06_124359_add_tenant_package_id_to_event_packages_table.php b/database/migrations/2026_02_06_124359_add_tenant_package_id_to_event_packages_table.php new file mode 100644 index 00000000..26fd456a --- /dev/null +++ b/database/migrations/2026_02_06_124359_add_tenant_package_id_to_event_packages_table.php @@ -0,0 +1,41 @@ +foreignId('tenant_package_id') + ->nullable() + ->after('package_id') + ->constrained('tenant_packages') + ->nullOnDelete(); + $table->index('tenant_package_id'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('event_packages')) { + return; + } + + Schema::table('event_packages', function (Blueprint $table) { + if (Schema::hasColumn('event_packages', 'tenant_package_id')) { + $table->dropForeign(['tenant_package_id']); + $table->dropIndex(['tenant_package_id']); + $table->dropColumn('tenant_package_id'); + } + }); + } +}; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index c895ca4d..9b31f3d3 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -506,6 +506,23 @@ export async function fetchHelpCenterArticle(slug: string, locale?: string): Pro } export type TenantPackageSummary = { + current_event?: { + id: number; + slug: string; + name: string | Record | null; + status: string | null; + event_date: string | null; + linked_at: string | null; + } | null; + last_event?: { + id: number; + slug: string; + name: string | Record | null; + status: string | null; + event_date: string | null; + linked_at: string | null; + } | null; + linked_events_count?: number; id: number; package_id: number; package_name: string; @@ -1109,6 +1126,50 @@ function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary { const packageData = pkg.package ?? {}; return { + current_event: + (pkg as any).current_event && typeof (pkg as any).current_event === 'object' + ? { + id: Number((pkg as any).current_event.id ?? 0), + slug: String((pkg as any).current_event.slug ?? ''), + name: ((pkg as any).current_event.name ?? null) as string | Record | null, + status: + typeof (pkg as any).current_event.status === 'string' + ? String((pkg as any).current_event.status) + : null, + event_date: + typeof (pkg as any).current_event.event_date === 'string' + ? String((pkg as any).current_event.event_date) + : null, + linked_at: + typeof (pkg as any).current_event.linked_at === 'string' + ? String((pkg as any).current_event.linked_at) + : null, + } + : null, + last_event: + (pkg as any).last_event && typeof (pkg as any).last_event === 'object' + ? { + id: Number((pkg as any).last_event.id ?? 0), + slug: String((pkg as any).last_event.slug ?? ''), + name: ((pkg as any).last_event.name ?? null) as string | Record | null, + status: + typeof (pkg as any).last_event.status === 'string' + ? String((pkg as any).last_event.status) + : null, + event_date: + typeof (pkg as any).last_event.event_date === 'string' + ? String((pkg as any).last_event.event_date) + : null, + linked_at: + typeof (pkg as any).last_event.linked_at === 'string' + ? String((pkg as any).last_event.linked_at) + : null, + } + : null, + linked_events_count: + (pkg as any).linked_events_count === undefined || (pkg as any).linked_events_count === null + ? 0 + : Number((pkg as any).linked_events_count), id: Number(pkg.id ?? 0), package_id: Number(pkg.package_id ?? packageData.id ?? 0), package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'), diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index d4e949ac..bdd5c7e7 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react'; +import { ChevronDown, ChevronUp, Package, Receipt, RefreshCcw, Sparkles } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; @@ -57,6 +57,7 @@ export default function MobileBillingPage() { const [addons, setAddons] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); + const [showPackageHistory, setShowPackageHistory] = React.useState(false); const [pendingCheckout, setPendingCheckout] = React.useState(() => loadPendingCheckout()); const [checkoutStatus, setCheckoutStatus] = React.useState(null); const [checkoutStatusReason, setCheckoutStatusReason] = React.useState(null); @@ -109,6 +110,13 @@ export default function MobileBillingPage() { storePendingCheckout(next); }, []); + const nonActivePackages = React.useMemo( + () => packages.filter((pkg) => !activePackage || pkg.id !== activePackage.id), + [activePackage, packages] + ); + const hiddenPackageCount = Math.max(0, nonActivePackages.length - 3); + const visiblePackageHistory = showPackageHistory ? nonActivePackages : nonActivePackages.slice(0, 3); + const handleReceiptDownload = React.useCallback( async (transaction: TenantBillingTransactionSummary) => { if (!transaction.receipt_url) { @@ -403,11 +411,37 @@ export default function MobileBillingPage() { onOpenShop={() => navigate(shopLink)} /> ) : null} - {packages - .filter((pkg) => !activePackage || pkg.id !== activePackage.id) - .map((pkg) => ( + {visiblePackageHistory.map((pkg) => ( ))} + {hiddenPackageCount > 0 ? ( + setShowPackageHistory((current) => !current)}> + + + + {t('billing.sections.packages.history.label', 'Paketverlauf')} + + + {showPackageHistory + ? t('billing.sections.packages.history.hide', 'Verlauf ausblenden') + : t('billing.sections.packages.history.show', 'Verlauf anzeigen ({{count}})', { + count: hiddenPackageCount, + })} + + + {showPackageHistory ? : } + + + ) : null} )} @@ -531,6 +565,7 @@ function PackageCard({ onOpenShop?: () => void; }) { const { t } = useTranslation('management'); + const navigate = useNavigate(); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const limits = (pkg.package_limits ?? null) as Record | null; const isPartnerPackage = pkg.package_type === 'reseller'; @@ -549,6 +584,7 @@ function PackageCard({ const usageStates = usageMetrics.map((metric) => getUsageState(metric)); const hasUsageWarning = usageStates.some((state) => state === 'warning' || state === 'danger'); const isDanger = usageStates.includes('danger'); + const [detailsCollapsed, setDetailsCollapsed] = React.useState(!isActive); const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents: pkg.remaining_events ?? null, usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null, @@ -559,6 +595,14 @@ function PackageCard({ limitMaxEvents, t ); + const currentLinkedEvent = pkg.current_event ?? null; + const lastLinkedEvent = !currentLinkedEvent ? pkg.last_event ?? null : null; + const linkedEvent = currentLinkedEvent ?? lastLinkedEvent; + const linkedEventName = linkedEvent ? resolveLinkedEventName(linkedEvent.name, t) : null; + const linkedEventPath = linkedEvent?.slug ? ADMIN_EVENT_VIEW_PATH(linkedEvent.slug) : null; + const linkedEventLabel = currentLinkedEvent + ? t('billing.sections.packages.card.currentEvent', 'Aktuell genutzt für') + : t('billing.sections.packages.card.lastEvent', 'Zuletzt genutzt für'); return ( ) : null} - {limitEntries.length ? ( - + {linkedEventName ? ( + - {t('mobileBilling.details.limitsTitle', 'Limits')} + {linkedEventLabel} - {limitEntries.map((entry) => ( - + {linkedEventPath ? ( + navigate(linkedEventPath)}> + + {linkedEventName} + + + ) : ( + + {linkedEventName} + + )} + {(pkg.linked_events_count ?? 0) > 1 ? ( + + {t('billing.sections.packages.card.linkedEventsCount', '{{count}} verknüpfte Events', { + count: pkg.linked_events_count ?? 0, + })} + + ) : null} + + ) : null} + setDetailsCollapsed((current) => !current)}> + + + + {t('billing.sections.packages.card.detailsLabel', 'Paketdetails')} + + + {detailsCollapsed + ? t('billing.sections.packages.card.showDetails', 'Details anzeigen') + : t('billing.sections.packages.card.hideDetails', 'Details ausblenden')} + + + {detailsCollapsed ? : } + + + {!detailsCollapsed ? ( + + {limitEntries.length ? ( + - {entry.label} + {t('mobileBilling.details.limitsTitle', 'Limits')} - - {entry.value} + {limitEntries.map((entry) => ( + + + {entry.label} + + + {entry.value} + + + ))} + + ) : null} + {featureKeys.length ? ( + + + {t('mobileBilling.details.featuresTitle', 'Features')} - - ))} + {featureKeys.map((feature) => ( + + + + {getPackageFeatureLabel(feature, t)} + + + ))} + + ) : null} + {usageMetrics.length ? ( + + {usageMetrics.map((metric) => ( + + ))} + + ) : null} + {isActive && hasUsageWarning ? ( + + ) : null} ) : null} - {featureKeys.length ? ( - - - {t('mobileBilling.details.featuresTitle', 'Features')} - - {featureKeys.map((feature) => ( - - - - {getPackageFeatureLabel(feature, t)} - - - ))} - - ) : null} - {usageMetrics.length ? ( - - {usageMetrics.map((metric) => ( - - ))} - - ) : null} - {isActive && hasUsageWarning ? ( - - ) : null} ); } +function resolveLinkedEventName( + name: string | Record | null | undefined, + t: (key: string, defaultValue?: string) => string +): string { + if (typeof name === 'string' && name.trim() !== '') { + return name; + } + + if (name && typeof name === 'object') { + return name.de ?? name.en ?? Object.values(name)[0] ?? t('events.placeholders.untitled', 'Untitled event'); + } + + return t('events.placeholders.untitled', 'Untitled event'); +} + function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) { const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key]; if (value === undefined || value === null) return null; diff --git a/resources/js/admin/mobile/__tests__/BillingPage.test.tsx b/resources/js/admin/mobile/__tests__/BillingPage.test.tsx index ef0348b4..54b410fc 100644 --- a/resources/js/admin/mobile/__tests__/BillingPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/BillingPage.test.tsx @@ -35,6 +35,8 @@ vi.mock('react-i18next', () => ({ })); vi.mock('lucide-react', () => ({ + ChevronDown: () => , + ChevronUp: () => , Package: () => , Receipt: () => , RefreshCcw: () => , @@ -113,7 +115,7 @@ vi.mock('../../lib/apiError', () => ({ })); vi.mock('../../constants', () => ({ - ADMIN_EVENT_VIEW_PATH: '/mobile/events', + ADMIN_EVENT_VIEW_PATH: (slug: string) => `/mobile/events/${slug}`, adminPath: (path: string) => path, })); @@ -165,8 +167,155 @@ vi.mock('../../api', () => ({ })); import MobileBillingPage from '../BillingPage'; +import * as api from '../../api'; describe('MobileBillingPage', () => { + it('shows linked event information for a package', async () => { + vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({ + activePackage: { + id: 11, + package_id: 501, + package_name: 'Pro Paket', + package_type: 'reseller', + included_package_slug: 'pro', + active: true, + used_events: 2, + remaining_events: 1, + price: 49, + currency: 'EUR', + purchased_at: '2024-01-01T00:00:00Z', + expires_at: null, + package_limits: {}, + linked_events_count: 2, + current_event: { + id: 77, + slug: 'sommerfest', + name: { de: 'Sommerfest' }, + status: 'published', + event_date: '2024-08-15T00:00:00Z', + linked_at: '2024-08-01T00:00:00Z', + }, + }, + packages: [], + }); + + render(); + + await screen.findByText('Aktuell genutzt für'); + expect(screen.getByText('Sommerfest')).toBeInTheDocument(); + expect(screen.getByText('2 verknüpfte Events')).toBeInTheDocument(); + }); + + it('shows only recent package history and can expand the rest', async () => { + vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({ + activePackage: { + id: 1, + package_id: 100, + package_name: 'Aktives Paket', + package_type: 'reseller', + included_package_slug: 'standard', + active: true, + used_events: 0, + remaining_events: 1, + price: 29, + currency: 'EUR', + purchased_at: '2024-01-01T00:00:00Z', + expires_at: null, + package_limits: {}, + }, + packages: [ + { + id: 1, + package_id: 100, + package_name: 'Aktives Paket', + package_type: 'reseller', + included_package_slug: 'standard', + active: true, + used_events: 0, + remaining_events: 1, + price: 29, + currency: 'EUR', + purchased_at: '2024-01-01T00:00:00Z', + expires_at: null, + package_limits: {}, + }, + { + id: 2, + package_id: 101, + package_name: 'Historie 1', + package_type: 'reseller', + included_package_slug: 'standard', + active: false, + used_events: 1, + remaining_events: 0, + price: 29, + currency: 'EUR', + purchased_at: '2023-12-01T00:00:00Z', + expires_at: null, + package_limits: {}, + }, + { + id: 3, + package_id: 102, + package_name: 'Historie 2', + package_type: 'reseller', + included_package_slug: 'standard', + active: false, + used_events: 1, + remaining_events: 0, + price: 29, + currency: 'EUR', + purchased_at: '2023-11-01T00:00:00Z', + expires_at: null, + package_limits: {}, + }, + { + id: 4, + package_id: 103, + package_name: 'Historie 3', + package_type: 'reseller', + included_package_slug: 'standard', + active: false, + used_events: 1, + remaining_events: 0, + price: 29, + currency: 'EUR', + purchased_at: '2023-10-01T00:00:00Z', + expires_at: null, + package_limits: {}, + }, + { + id: 5, + package_id: 104, + package_name: 'Historie 4', + package_type: 'reseller', + included_package_slug: 'standard', + active: false, + used_events: 1, + remaining_events: 0, + price: 29, + currency: 'EUR', + purchased_at: '2023-09-01T00:00:00Z', + expires_at: null, + package_limits: {}, + }, + ], + }); + + render(); + + await screen.findByText('Aktives Paket'); + expect(screen.getByText('Historie 1')).toBeInTheDocument(); + expect(screen.getByText('Historie 2')).toBeInTheDocument(); + expect(screen.getByText('Historie 3')).toBeInTheDocument(); + expect(screen.queryByText('Historie 4')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Verlauf anzeigen (1)')); + + expect(await screen.findByText('Historie 4')).toBeInTheDocument(); + expect(screen.getByText('Verlauf ausblenden')).toBeInTheDocument(); + }); + it('downloads receipts via the API helper', async () => { render(); diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php index 8c545304..5f2aee8f 100644 --- a/tests/Feature/EventControllerTest.php +++ b/tests/Feature/EventControllerTest.php @@ -54,9 +54,15 @@ class EventControllerTest extends TenantTestCase ]); $event = Event::latest()->first(); + $tenantPackageId = TenantPackage::query() + ->where('tenant_id', $tenant->id) + ->where('package_id', $package->id) + ->orderByDesc('id') + ->value('id'); $this->assertDatabaseHas('event_packages', [ 'event_id' => $event->id, 'package_id' => $package->id, + 'tenant_package_id' => $tenantPackageId, ]); $this->assertDatabaseHas('event_join_tokens', [ @@ -161,7 +167,7 @@ class EventControllerTest extends TenantTestCase 'gallery_days' => 30, ]); $package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]); - TenantPackage::factory()->create([ + $tenantPackage = TenantPackage::factory()->create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'used_events' => 0, @@ -184,6 +190,7 @@ class EventControllerTest extends TenantTestCase $this->assertDatabaseHas('event_packages', [ 'event_id' => $event->id, 'package_id' => $includedPackage->id, + 'tenant_package_id' => $tenantPackage->id, 'purchased_price' => 0.00, ]); diff --git a/tests/Feature/Tenant/TenantPackageOverviewTest.php b/tests/Feature/Tenant/TenantPackageOverviewTest.php index 9b0276bc..ddf02ced 100644 --- a/tests/Feature/Tenant/TenantPackageOverviewTest.php +++ b/tests/Feature/Tenant/TenantPackageOverviewTest.php @@ -95,4 +95,64 @@ class TenantPackageOverviewTest extends TenantTestCase $this->assertSame(['custom_branding'], $payload['active_package']['package_limits']['features']); $this->assertSame(1, $payload['active_package']['remaining_events']); } + + public function test_package_overview_includes_current_and_last_linked_event(): void + { + Carbon::setTestNow(Carbon::parse('2024-01-10 10:00:00')); + + $resellerPackage = Package::factory()->reseller()->create([ + 'max_events_per_year' => 10, + ]); + $servicePackage = Package::factory()->endcustomer()->create([ + 'slug' => 'standard', + 'gallery_days' => 30, + ]); + + $tenantPackage = TenantPackage::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'package_id' => $resellerPackage->id, + 'active' => true, + 'used_events' => 2, + ]); + + $olderEvent = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'name' => ['de' => 'Altes Event'], + 'slug' => 'altes-event', + ]); + $currentEvent = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'name' => ['de' => 'Aktuelles Event'], + 'slug' => 'aktuelles-event', + ]); + + EventPackage::create([ + 'event_id' => $olderEvent->id, + 'package_id' => $servicePackage->id, + 'tenant_package_id' => $tenantPackage->id, + 'purchased_price' => 0, + 'purchased_at' => now()->subDays(2), + 'gallery_expires_at' => now()->subDay(), + ]); + EventPackage::create([ + 'event_id' => $currentEvent->id, + 'package_id' => $servicePackage->id, + 'tenant_package_id' => $tenantPackage->id, + 'purchased_price' => 0, + 'purchased_at' => now()->subDay(), + 'gallery_expires_at' => now()->addDays(20), + ]); + + $request = Request::create('/api/v1/tenant/packages', 'GET'); + $request->attributes->set('tenant', $this->tenant); + + $response = app(TenantPackageController::class)->index($request); + $payload = $response->getData(true); + + $this->assertSame(2, $payload['active_package']['linked_events_count']); + $this->assertSame($currentEvent->id, $payload['active_package']['current_event']['id']); + $this->assertSame('aktuelles-event', $payload['active_package']['current_event']['slug']); + $this->assertSame($currentEvent->name, $payload['active_package']['current_event']['name']); + $this->assertSame($currentEvent->id, $payload['active_package']['last_event']['id']); + } }