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
|
$eventServicePackage = $billingIsReseller
|
||||||
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
||||||
: $package;
|
: $package;
|
||||||
|
$sourceTenantPackage = $billingIsReseller ? $billingTenantPackage : $tenantPackage;
|
||||||
|
|
||||||
$requiresWaiver = $package->isEndcustomer();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
@@ -216,12 +217,13 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$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);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $eventServicePackage->id,
|
'package_id' => $eventServicePackage->id,
|
||||||
|
'tenant_package_id' => $sourceTenantPackage?->id,
|
||||||
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'gallery_expires_at' => $eventServicePackage->gallery_days
|
'gallery_expires_at' => $eventServicePackage->gallery_days
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ class TenantPackageController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
|
$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;
|
$eventPackage = $package->active ? $usageEventPackage : null;
|
||||||
$this->hydratePackageSnapshot($package, $eventPackage);
|
$this->hydratePackageSnapshot($package, $eventPackage);
|
||||||
|
$this->attachUsageEvents($package, $linkedEventPackages);
|
||||||
});
|
});
|
||||||
|
|
||||||
$activePackage = $tenant->getActiveResellerPackage();
|
$activePackage = $tenant->getActiveResellerPackage();
|
||||||
@@ -43,6 +45,7 @@ class TenantPackageController extends Controller
|
|||||||
$activePackage = $packages->firstWhere('active', true);
|
$activePackage = $packages->firstWhere('active', true);
|
||||||
} else {
|
} else {
|
||||||
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
||||||
|
$this->attachUsageEvents($activePackage, $linkedEventPackages);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
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
|
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
|
||||||
{
|
{
|
||||||
$pkg = $package->package;
|
$pkg = $package->package;
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class EventResource extends JsonResource
|
|||||||
'qr_code_url' => null,
|
'qr_code_url' => null,
|
||||||
'package' => $eventPackage ? [
|
'package' => $eventPackage ? [
|
||||||
'id' => $eventPackage->package_id,
|
'id' => $eventPackage->package_id,
|
||||||
|
'tenant_package_id' => $eventPackage->tenant_package_id,
|
||||||
'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name,
|
'name' => $eventPackage->package?->getNameForLocale(app()->getLocale()) ?? $eventPackage->package?->name,
|
||||||
'price' => $eventPackage->purchased_price,
|
'price' => $eventPackage->purchased_price,
|
||||||
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
|
'purchased_at' => $eventPackage->purchased_at?->toIso8601String(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class EventPackage extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'event_id',
|
'event_id',
|
||||||
'package_id',
|
'package_id',
|
||||||
|
'tenant_package_id',
|
||||||
'purchased_price',
|
'purchased_price',
|
||||||
'purchased_at',
|
'purchased_at',
|
||||||
'used_photos',
|
'used_photos',
|
||||||
@@ -51,6 +52,11 @@ class EventPackage extends Model
|
|||||||
return $this->belongsTo(Package::class)->withTrashed();
|
return $this->belongsTo(Package::class)->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tenantPackage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TenantPackage::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function addons(): HasMany
|
public function addons(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(EventPackageAddon::class);
|
return $this->hasMany(EventPackageAddon::class);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use Carbon\CarbonInterface;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class TenantPackage extends Model
|
class TenantPackage extends Model
|
||||||
{
|
{
|
||||||
@@ -47,6 +48,11 @@ class TenantPackage extends Model
|
|||||||
return $this->belongsTo(Package::class)->withTrashed();
|
return $this->belongsTo(Package::class)->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function eventPackages(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EventPackage::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function isActive(): bool
|
public function isActive(): bool
|
||||||
{
|
{
|
||||||
if ($this->package && $this->package->isEndcustomer()) {
|
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 = {
|
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;
|
id: number;
|
||||||
package_id: number;
|
package_id: number;
|
||||||
package_name: string;
|
package_name: string;
|
||||||
@@ -1109,6 +1126,50 @@ function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null
|
|||||||
export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
|
||||||
const packageData = pkg.package ?? {};
|
const packageData = pkg.package ?? {};
|
||||||
return {
|
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),
|
id: Number(pkg.id ?? 0),
|
||||||
package_id: Number(pkg.package_id ?? packageData.id ?? 0),
|
package_id: Number(pkg.package_id ?? packageData.id ?? 0),
|
||||||
package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'),
|
package_name: String(packageData.name ?? pkg.package_name ?? 'Unbekanntes Package'),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
@@ -57,6 +57,7 @@ export default function MobileBillingPage() {
|
|||||||
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [showPackageHistory, setShowPackageHistory] = React.useState(false);
|
||||||
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
|
||||||
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
|
||||||
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
|
||||||
@@ -109,6 +110,13 @@ export default function MobileBillingPage() {
|
|||||||
storePendingCheckout(next);
|
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(
|
const handleReceiptDownload = React.useCallback(
|
||||||
async (transaction: TenantBillingTransactionSummary) => {
|
async (transaction: TenantBillingTransactionSummary) => {
|
||||||
if (!transaction.receipt_url) {
|
if (!transaction.receipt_url) {
|
||||||
@@ -403,11 +411,37 @@ export default function MobileBillingPage() {
|
|||||||
onOpenShop={() => navigate(shopLink)}
|
onOpenShop={() => navigate(shopLink)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{packages
|
{visiblePackageHistory.map((pkg) => (
|
||||||
.filter((pkg) => !activePackage || pkg.id !== activePackage.id)
|
|
||||||
.map((pkg) => (
|
|
||||||
<PackageCard key={pkg.id} pkg={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>
|
</YStack>
|
||||||
)}
|
)}
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
@@ -531,6 +565,7 @@ function PackageCard({
|
|||||||
onOpenShop?: () => void;
|
onOpenShop?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
const navigate = useNavigate();
|
||||||
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
|
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
|
||||||
const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null;
|
const limits = (pkg.package_limits ?? null) as Record<string, unknown> | null;
|
||||||
const isPartnerPackage = pkg.package_type === 'reseller';
|
const isPartnerPackage = pkg.package_type === 'reseller';
|
||||||
@@ -549,6 +584,7 @@ function PackageCard({
|
|||||||
const usageStates = usageMetrics.map((metric) => getUsageState(metric));
|
const usageStates = usageMetrics.map((metric) => getUsageState(metric));
|
||||||
const hasUsageWarning = usageStates.some((state) => state === 'warning' || state === 'danger');
|
const hasUsageWarning = usageStates.some((state) => state === 'warning' || state === 'danger');
|
||||||
const isDanger = usageStates.includes('danger');
|
const isDanger = usageStates.includes('danger');
|
||||||
|
const [detailsCollapsed, setDetailsCollapsed] = React.useState(!isActive);
|
||||||
const limitEntries = getPackageLimitEntries(limits, t, {
|
const limitEntries = getPackageLimitEntries(limits, t, {
|
||||||
remainingEvents: pkg.remaining_events ?? null,
|
remainingEvents: pkg.remaining_events ?? null,
|
||||||
usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null,
|
usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null,
|
||||||
@@ -559,6 +595,14 @@ function PackageCard({
|
|||||||
limitMaxEvents,
|
limitMaxEvents,
|
||||||
t
|
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 (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
borderColor={isActive ? primary : border}
|
borderColor={isActive ? primary : border}
|
||||||
@@ -591,6 +635,57 @@ function PackageCard({
|
|||||||
{eventUsageText}
|
{eventUsageText}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{linkedEventName ? (
|
||||||
|
<YStack gap="$1" marginTop="$1.5">
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{linkedEventLabel}
|
||||||
|
</Text>
|
||||||
|
{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 ? (
|
{limitEntries.length ? (
|
||||||
<YStack gap="$1.5" marginTop="$2">
|
<YStack gap="$1.5" marginTop="$2">
|
||||||
<Text fontSize="$xs" color={muted}>
|
<Text fontSize="$xs" color={muted}>
|
||||||
@@ -641,10 +736,27 @@ function PackageCard({
|
|||||||
tone={isDanger ? 'danger' : 'primary'}
|
tone={isDanger ? 'danger' : 'primary'}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</YStack>
|
||||||
|
) : null}
|
||||||
</MobileCard>
|
</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) {
|
function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) {
|
||||||
const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key];
|
const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key];
|
||||||
if (value === undefined || value === null) return null;
|
if (value === undefined || value === null) return null;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ vi.mock('react-i18next', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('lucide-react', () => ({
|
vi.mock('lucide-react', () => ({
|
||||||
|
ChevronDown: () => <span />,
|
||||||
|
ChevronUp: () => <span />,
|
||||||
Package: () => <span />,
|
Package: () => <span />,
|
||||||
Receipt: () => <span />,
|
Receipt: () => <span />,
|
||||||
RefreshCcw: () => <span />,
|
RefreshCcw: () => <span />,
|
||||||
@@ -113,7 +115,7 @@ vi.mock('../../lib/apiError', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../constants', () => ({
|
vi.mock('../../constants', () => ({
|
||||||
ADMIN_EVENT_VIEW_PATH: '/mobile/events',
|
ADMIN_EVENT_VIEW_PATH: (slug: string) => `/mobile/events/${slug}`,
|
||||||
adminPath: (path: string) => path,
|
adminPath: (path: string) => path,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -165,8 +167,155 @@ vi.mock('../../api', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import MobileBillingPage from '../BillingPage';
|
import MobileBillingPage from '../BillingPage';
|
||||||
|
import * as api from '../../api';
|
||||||
|
|
||||||
describe('MobileBillingPage', () => {
|
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 () => {
|
it('downloads receipts via the API helper', async () => {
|
||||||
render(<MobileBillingPage />);
|
render(<MobileBillingPage />);
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,15 @@ class EventControllerTest extends TenantTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$event = Event::latest()->first();
|
$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', [
|
$this->assertDatabaseHas('event_packages', [
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
|
'tenant_package_id' => $tenantPackageId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('event_join_tokens', [
|
$this->assertDatabaseHas('event_join_tokens', [
|
||||||
@@ -161,7 +167,7 @@ class EventControllerTest extends TenantTestCase
|
|||||||
'gallery_days' => 30,
|
'gallery_days' => 30,
|
||||||
]);
|
]);
|
||||||
$package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
|
$package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
|
||||||
TenantPackage::factory()->create([
|
$tenantPackage = TenantPackage::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'used_events' => 0,
|
'used_events' => 0,
|
||||||
@@ -184,6 +190,7 @@ class EventControllerTest extends TenantTestCase
|
|||||||
$this->assertDatabaseHas('event_packages', [
|
$this->assertDatabaseHas('event_packages', [
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $includedPackage->id,
|
'package_id' => $includedPackage->id,
|
||||||
|
'tenant_package_id' => $tenantPackage->id,
|
||||||
'purchased_price' => 0.00,
|
'purchased_price' => 0.00,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -95,4 +95,64 @@ class TenantPackageOverviewTest extends TenantTestCase
|
|||||||
$this->assertSame(['custom_branding'], $payload['active_package']['package_limits']['features']);
|
$this->assertSame(['custom_branding'], $payload['active_package']['package_limits']['features']);
|
||||||
$this->assertSame(1, $payload['active_package']['remaining_events']);
|
$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