Link tenant packages to events and show usage in billing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user