Link tenant packages to events and show usage in billing
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-02-06 12:54:33 +01:00
parent fa114ac0dc
commit 0291d537fb
11 changed files with 572 additions and 51 deletions

View File

@@ -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

View File

@@ -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<int, int> $tenantPackageIds
* @return array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}>
*/
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<int, array{current: ?EventPackage, last: ?EventPackage, count: int}> $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;

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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()) {

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('event_packages')) {
return;
}
Schema::table('event_packages', function (Blueprint $table) {
if (! Schema::hasColumn('event_packages', 'tenant_package_id')) {
$table->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');
}
});
}
};

View File

@@ -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<string, string> | null;
status: string | null;
event_date: string | null;
linked_at: string | null;
} | null;
last_event?: {
id: number;
slug: string;
name: string | Record<string, string> | 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<string, string> | 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<string, string> | 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'),

View File

@@ -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<TenantAddonHistoryEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [showPackageHistory, setShowPackageHistory] = React.useState(false);
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(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) => (
<PackageCard key={pkg.id} pkg={pkg} />
))}
{hiddenPackageCount > 0 ? (
<Pressable onPress={() => setShowPackageHistory((current) => !current)}>
<XStack
alignItems="center"
justifyContent="space-between"
borderWidth={1}
borderColor={primary}
backgroundColor={accentSoft}
borderRadius={14}
paddingHorizontal="$3"
paddingVertical="$2"
>
<YStack gap="$0.5">
<Text fontSize="$xs" color={muted} fontWeight="700">
{t('billing.sections.packages.history.label', 'Paketverlauf')}
</Text>
<Text fontSize="$sm" color={textStrong} fontWeight="800">
{showPackageHistory
? t('billing.sections.packages.history.hide', 'Verlauf ausblenden')
: t('billing.sections.packages.history.show', 'Verlauf anzeigen ({{count}})', {
count: hiddenPackageCount,
})}
</Text>
</YStack>
{showPackageHistory ? <ChevronUp size={18} color={primary} /> : <ChevronDown size={18} color={primary} />}
</XStack>
</Pressable>
) : null}
</YStack>
)}
</MobileCard>
@@ -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<string, unknown> | 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 (
<MobileCard
borderColor={isActive ? primary : border}
@@ -591,60 +635,128 @@ function PackageCard({
{eventUsageText}
</Text>
) : null}
{limitEntries.length ? (
<YStack gap="$1.5" marginTop="$2">
{linkedEventName ? (
<YStack gap="$1" marginTop="$1.5">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.limitsTitle', 'Limits')}
{linkedEventLabel}
</Text>
{limitEntries.map((entry) => (
<XStack key={entry.key} alignItems="center" justifyContent="space-between">
{linkedEventPath ? (
<Pressable onPress={() => navigate(linkedEventPath)}>
<Text fontSize="$sm" color={primary} fontWeight="700">
{linkedEventName}
</Text>
</Pressable>
) : (
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{linkedEventName}
</Text>
)}
{(pkg.linked_events_count ?? 0) > 1 ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.packages.card.linkedEventsCount', '{{count}} verknüpfte Events', {
count: pkg.linked_events_count ?? 0,
})}
</Text>
) : null}
</YStack>
) : null}
<Pressable onPress={() => setDetailsCollapsed((current) => !current)}>
<XStack
alignItems="center"
justifyContent="space-between"
borderWidth={1}
borderColor={isActive ? primary : border}
backgroundColor={isActive ? accentSoft : undefined}
borderRadius={12}
paddingHorizontal="$2.5"
paddingVertical="$1.5"
>
<YStack gap="$0.5">
<Text fontSize="$xs" color={muted} fontWeight="700">
{t('billing.sections.packages.card.detailsLabel', 'Paketdetails')}
</Text>
<Text fontSize="$sm" color={textStrong} fontWeight="800">
{detailsCollapsed
? t('billing.sections.packages.card.showDetails', 'Details anzeigen')
: t('billing.sections.packages.card.hideDetails', 'Details ausblenden')}
</Text>
</YStack>
{detailsCollapsed ? <ChevronDown size={16} color={primary} /> : <ChevronUp size={16} color={primary} />}
</XStack>
</Pressable>
{!detailsCollapsed ? (
<YStack gap="$2">
{limitEntries.length ? (
<YStack gap="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{entry.label}
{t('mobileBilling.details.limitsTitle', 'Limits')}
</Text>
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{entry.value}
{limitEntries.map((entry) => (
<XStack key={entry.key} alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" color={muted}>
{entry.label}
</Text>
<Text fontSize="$xs" color={textStrong} fontWeight="700">
{entry.value}
</Text>
</XStack>
))}
</YStack>
) : null}
{featureKeys.length ? (
<YStack gap="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.featuresTitle', 'Features')}
</Text>
</XStack>
))}
{featureKeys.map((feature) => (
<XStack key={feature} alignItems="center" gap="$2">
<Sparkles size={14} color={primary} />
<Text fontSize="$xs" color={textStrong}>
{getPackageFeatureLabel(feature, t)}
</Text>
</XStack>
))}
</YStack>
) : null}
{usageMetrics.length ? (
<YStack gap="$2" marginTop="$2">
{usageMetrics.map((metric) => (
<UsageBar key={metric.key} metric={metric} />
))}
</YStack>
) : null}
{isActive && hasUsageWarning ? (
<CTAButton
label={
isDanger
? t('mobileBilling.usage.ctaDanger', 'Upgrade package')
: t('mobileBilling.usage.ctaWarning', 'Secure more capacity')
}
onPress={onOpenShop}
tone={isDanger ? 'danger' : 'primary'}
/>
) : null}
</YStack>
) : null}
{featureKeys.length ? (
<YStack gap="$1.5" marginTop="$2">
<Text fontSize="$xs" color={muted}>
{t('mobileBilling.details.featuresTitle', 'Features')}
</Text>
{featureKeys.map((feature) => (
<XStack key={feature} alignItems="center" gap="$2">
<Sparkles size={14} color={primary} />
<Text fontSize="$xs" color={textStrong}>
{getPackageFeatureLabel(feature, t)}
</Text>
</XStack>
))}
</YStack>
) : null}
{usageMetrics.length ? (
<YStack gap="$2" marginTop="$2">
{usageMetrics.map((metric) => (
<UsageBar key={metric.key} metric={metric} />
))}
</YStack>
) : null}
{isActive && hasUsageWarning ? (
<CTAButton
label={
isDanger
? t('mobileBilling.usage.ctaDanger', 'Upgrade package')
: t('mobileBilling.usage.ctaWarning', 'Secure more capacity')
}
onPress={onOpenShop}
tone={isDanger ? 'danger' : 'primary'}
/>
) : null}
</MobileCard>
);
}
function resolveLinkedEventName(
name: string | Record<string, string> | 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;

View File

@@ -35,6 +35,8 @@ vi.mock('react-i18next', () => ({
}));
vi.mock('lucide-react', () => ({
ChevronDown: () => <span />,
ChevronUp: () => <span />,
Package: () => <span />,
Receipt: () => <span />,
RefreshCcw: () => <span />,
@@ -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(<MobileBillingPage />);
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(<MobileBillingPage />);
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(<MobileBillingPage />);

View File

@@ -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,
]);

View File

@@ -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']);
}
}