From 232302eb6fdab84e4f4f42d2974f9fc7afab8743 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 6 Jan 2026 14:17:27 +0100 Subject: [PATCH] Improve package usage visibility --- .../Commands/SeedDemoSwitcherTenants.php | 13 ++- .../Api/TenantPackageController.php | 81 ++++++++++++++++++- .../js/admin/i18n/locales/de/management.json | 9 ++- .../js/admin/i18n/locales/en/management.json | 9 ++- resources/js/admin/mobile/BillingPage.tsx | 78 +++++++++++++++--- resources/js/admin/mobile/DashboardPage.tsx | 2 +- .../mobile/__tests__/billingUsage.test.ts | 32 +++++++- resources/js/admin/mobile/billingUsage.ts | 66 ++++++++++++++- .../js/admin/mobile/components/Primitives.tsx | 7 +- .../admin/mobile/lib/packageSummary.test.ts | 7 +- .../js/admin/mobile/lib/packageSummary.ts | 72 ++++++++++++++++- .../Tenant/TenantPackageOverviewTest.php | 25 ++++++ 12 files changed, 370 insertions(+), 31 deletions(-) diff --git a/app/Console/Commands/SeedDemoSwitcherTenants.php b/app/Console/Commands/SeedDemoSwitcherTenants.php index 55a3617..2b2943f 100644 --- a/app/Console/Commands/SeedDemoSwitcherTenants.php +++ b/app/Console/Commands/SeedDemoSwitcherTenants.php @@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command $this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel'); + TenantPackage::updateOrCreate( + ['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id], + [ + 'price' => $packages['standard']->price, + 'purchased_at' => Carbon::now()->subDays(1), + 'expires_at' => Carbon::now()->addMonths(12), + 'used_events' => 0, + 'active' => true, + ] + ); + $event = $this->upsertEvent( tenant: $tenant, - package: $packages['starter'], + package: $packages['standard'], eventType: $eventTypes['wedding'] ?? null, attributes: [ 'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'], diff --git a/app/Http/Controllers/Api/TenantPackageController.php b/app/Http/Controllers/Api/TenantPackageController.php index 14d0d29..a905994 100644 --- a/app/Http/Controllers/Api/TenantPackageController.php +++ b/app/Http/Controllers/Api/TenantPackageController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\EventPackage; use App\Models\TenantPackage; use App\Support\ApiError; use Illuminate\Http\JsonResponse; @@ -29,12 +30,17 @@ class TenantPackageController extends Controller ->orderBy('created_at', 'desc') ->get(); - $packages->each(fn (TenantPackage $package) => $this->hydratePackageSnapshot($package)); + $usageEventPackage = $this->resolveUsageEventPackage($tenant->id); + + $packages->each(function (TenantPackage $package) use ($usageEventPackage): void { + $eventPackage = $package->active ? $usageEventPackage : null; + $this->hydratePackageSnapshot($package, $eventPackage); + }); $activePackage = $tenant->activeResellerPackage?->load('package'); if ($activePackage instanceof TenantPackage) { - $this->hydratePackageSnapshot($activePackage); + $this->hydratePackageSnapshot($activePackage, $usageEventPackage); } return response()->json([ @@ -44,7 +50,7 @@ class TenantPackageController extends Controller ]); } - private function hydratePackageSnapshot(TenantPackage $package): void + private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void { $pkg = $package->package; @@ -52,6 +58,7 @@ class TenantPackageController extends Controller $package->remaining_events = $maxEvents === null ? null : max($maxEvents - $package->used_events, 0); $package->package_limits = array_merge( $pkg?->limits ?? [], + $this->buildUsageSnapshot($eventPackage), [ 'branding_allowed' => $pkg?->branding_allowed, 'watermark_allowed' => $pkg?->watermark_allowed, @@ -59,4 +66,72 @@ class TenantPackageController extends Controller ] ); } + + /** + * @return Collection + */ + private function resolveUsageEventPackage(int $tenantId): ?EventPackage + { + $baseQuery = EventPackage::query() + ->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId)) + ->with('package') + ->orderByDesc('purchased_at') + ->orderByDesc('created_at'); + + $activeEventPackage = (clone $baseQuery) + ->whereNotNull('gallery_expires_at') + ->where('gallery_expires_at', '>=', now()) + ->first(); + + return $activeEventPackage ?? $baseQuery->first(); + } + + private function buildUsageSnapshot(?EventPackage $eventPackage): array + { + if (! $eventPackage) { + return []; + } + + $limits = $eventPackage->effectiveLimits(); + $maxPhotos = $this->normalizeLimit($limits['max_photos'] ?? null); + $maxGuests = $this->normalizeLimit($limits['max_guests'] ?? null); + $galleryDays = $this->normalizeLimit($limits['gallery_days'] ?? null); + + $usedPhotos = (int) $eventPackage->used_photos; + $usedGuests = (int) $eventPackage->used_guests; + + $remainingPhotos = $maxPhotos === null ? null : max(0, $maxPhotos - $usedPhotos); + $remainingGuests = $maxGuests === null ? null : max(0, $maxGuests - $usedGuests); + + $remainingGalleryDays = null; + $usedGalleryDays = null; + if ($galleryDays !== null && $eventPackage->gallery_expires_at) { + $remainingGalleryDays = max(0, now()->diffInDays($eventPackage->gallery_expires_at, false)); + $usedGalleryDays = max(0, $galleryDays - $remainingGalleryDays); + } + + return array_filter([ + 'used_photos' => $maxPhotos === null ? null : $usedPhotos, + 'remaining_photos' => $remainingPhotos, + 'used_guests' => $maxGuests === null ? null : $usedGuests, + 'remaining_guests' => $remainingGuests, + 'used_gallery_days' => $usedGalleryDays, + 'remaining_gallery_days' => $remainingGalleryDays, + ], static fn ($value) => $value !== null); + } + + private function normalizeLimit(?int $value): ?int + { + if ($value === null) { + return null; + } + + $value = (int) $value; + + if ($value <= 0) { + return null; + } + + return $value; + } } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index cd14b98..6ce7fd2 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2705,6 +2705,7 @@ "mobileBilling": { "packageFallback": "Paket", "remainingEvents": "{{count}} Events", + "remainingEventsOf": "{{remaining}} von {{limit}} Events übrig", "remainingEventsZero": "Keine Events mehr verfügbar", "eventsCreated": "{{used}} von {{limit}} Events angelegt", "openEvent": "Event öffnen", @@ -2716,9 +2717,15 @@ "events": "Events", "guests": "Gäste", "photos": "Fotos", + "gallery": "Galerietage", "value": "{{used}} / {{limit}}", "limit": "Limit {{limit}}", - "remaining": "Verbleibend {{count}}" + "remaining": "Verbleibend {{count}}", + "remainingOf": "{{remaining}} von {{limit}} übrig", + "statusWarning": "Knapp", + "statusDanger": "Limit erreicht", + "ctaWarning": "Mehr Kapazität sichern", + "ctaDanger": "Paket upgraden" }, "status": { "completed": "Abgeschlossen", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index b4e4ce0..c9c2c32 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2709,6 +2709,7 @@ "mobileBilling": { "packageFallback": "Package", "remainingEvents": "{{count}} events", + "remainingEventsOf": "{{remaining}} of {{limit}} events remaining", "remainingEventsZero": "No events remaining", "eventsCreated": "{{used}} of {{limit}} events created", "openEvent": "Open event", @@ -2720,9 +2721,15 @@ "events": "Events", "guests": "Guests", "photos": "Photos", + "gallery": "Gallery days", "value": "{{used}} / {{limit}}", "limit": "Limit {{limit}}", - "remaining": "Remaining {{count}}" + "remaining": "Remaining {{count}}", + "remainingOf": "{{remaining}} of {{limit}} remaining", + "statusWarning": "Low", + "statusDanger": "Limit reached", + "ctaWarning": "Secure more capacity", + "ctaDanger": "Upgrade package" }, "status": { "completed": "Completed", diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index fa9d414..d9ebf2b 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -18,7 +18,7 @@ import { import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants'; -import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage'; +import { buildPackageUsageMetrics, getUsageState, PackageUsageMetric, usagePercent } from './billingUsage'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { @@ -154,6 +154,8 @@ export default function MobileBillingPage() { pkg={activePackage} label={t('billing.sections.packages.card.statusActive', 'Aktiv')} isActive + onOpenPortal={openPortal} + portalBusy={portalBusy} /> ) : null} {packages @@ -257,22 +259,46 @@ export default function MobileBillingPage() { ); } -function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) { +function PackageCard({ + pkg, + label, + isActive = false, + onOpenPortal, + portalBusy, +}: { + pkg: TenantPackageSummary; + label?: string; + isActive?: boolean; + onOpenPortal?: () => void; + portalBusy?: boolean; +}) { const { t } = useTranslation('management'); const { border, primary, accentSoft, textStrong, muted } = useAdminTheme(); const limits = (pkg.package_limits ?? null) as Record | null; - const remaining = pkg.remaining_events ?? (limits?.max_events_per_year as number | undefined) ?? 0; + const limitMaxEvents = typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null; + const remaining = pkg.remaining_events ?? limitMaxEvents ?? 0; const remainingText = remaining === 0 ? t('mobileBilling.remainingEventsZero', 'No events remaining') - : t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining }); + : limitMaxEvents + ? t('mobileBilling.remainingEventsOf', '{{remaining}} of {{limit}} events remaining', { + remaining, + limit: limitMaxEvents, + }) + : t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining }); const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null; const usageMetrics = buildPackageUsageMetrics(pkg); - const limitEntries = getPackageLimitEntries(limits, t); + const usageStates = usageMetrics.map((metric) => getUsageState(metric)); + const hasUsageWarning = usageStates.some((state) => state === 'warning' || state === 'danger'); + const isDanger = usageStates.includes('danger'); + const limitEntries = getPackageLimitEntries(limits, t, { + remainingEvents: pkg.remaining_events ?? null, + usedEvents: typeof pkg.used_events === 'number' ? pkg.used_events : null, + }); const featureKeys = collectPackageFeatures(pkg); const eventUsageText = formatEventUsage( typeof pkg.used_events === 'number' ? pkg.used_events : null, - typeof limits?.max_events_per_year === 'number' ? (limits?.max_events_per_year as number) : null, + limitMaxEvents, t ); return ( @@ -345,6 +371,18 @@ function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSumma ))} ) : null} + {isActive && hasUsageWarning && onOpenPortal ? ( + + ) : null} ); } @@ -358,25 +396,38 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe function UsageBar({ metric }: { metric: PackageUsageMetric }) { const { t } = useTranslation('management'); - const { muted, textStrong, border, primary, subtle } = useAdminTheme(); + const { muted, textStrong, border, primary, subtle, warningText, danger } = useAdminTheme(); const labelMap: Record = { events: t('mobileBilling.usage.events', 'Events'), guests: t('mobileBilling.usage.guests', 'Guests'), photos: t('mobileBilling.usage.photos', 'Photos'), + gallery: t('mobileBilling.usage.gallery', 'Gallery days'), }; if (!metric.limit) { return null; } + const status = getUsageState(metric); const hasUsage = metric.used !== null; const valueText = hasUsage ? t('mobileBilling.usage.value', { used: metric.used, limit: metric.limit }) : t('mobileBilling.usage.limit', { limit: metric.limit }); const remainingText = metric.remaining !== null - ? t('mobileBilling.usage.remaining', { count: metric.remaining }) + ? t('mobileBilling.usage.remainingOf', { + remaining: metric.remaining, + limit: metric.limit, + defaultValue: 'Remaining {{remaining}} of {{limit}}', + }) : null; const fill = usagePercent(metric); + const statusLabel = + status === 'danger' + ? t('mobileBilling.usage.statusDanger', 'Limit reached') + : status === 'warning' + ? t('mobileBilling.usage.statusWarning', 'Low') + : null; + const fillColor = status === 'danger' ? danger : status === 'warning' ? warningText : primary; return ( @@ -384,12 +435,15 @@ function UsageBar({ metric }: { metric: PackageUsageMetric }) { {labelMap[metric.key]} - - {valueText} - + + {statusLabel ? {statusLabel} : null} + + {valueText} + + - + {remainingText ? ( diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 0810807..a8fc82f 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -511,7 +511,7 @@ function PackageSummarySheet({ branding_allowed: (limits as any)?.branding_allowed ?? null, watermark_allowed: (limits as any)?.watermark_allowed ?? null, } as any); - const limitEntries = getPackageLimitEntries(limits, t); + const limitEntries = getPackageLimitEntries(limits, t, { remainingEvents }); const hasFeatures = resolvedFeatures.length > 0; const formatDate = (value?: string | null) => formatEventDate(value, locale) ?? t('mobileDashboard.packageSummary.unknown', 'Unknown'); diff --git a/resources/js/admin/mobile/__tests__/billingUsage.test.ts b/resources/js/admin/mobile/__tests__/billingUsage.test.ts index a9287bb..2dbca2e 100644 --- a/resources/js/admin/mobile/__tests__/billingUsage.test.ts +++ b/resources/js/admin/mobile/__tests__/billingUsage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { TenantPackageSummary } from '../../api'; -import { buildPackageUsageMetrics, usagePercent } from '../billingUsage'; +import { buildPackageUsageMetrics, getUsageState, usagePercent } from '../billingUsage'; const basePackage: TenantPackageSummary = { id: 1, @@ -17,6 +17,8 @@ const basePackage: TenantPackageSummary = { max_events_per_year: 5, max_guests: 150, max_photos: 1000, + gallery_days: 90, + remaining_gallery_days: 30, }, branding_allowed: true, watermark_allowed: true, @@ -27,12 +29,25 @@ describe('buildPackageUsageMetrics', () => { it('builds usage metrics for event, guest, and photo limits', () => { const metrics = buildPackageUsageMetrics(basePackage); const keys = metrics.map((metric) => metric.key); - expect(keys).toEqual(['events', 'guests', 'photos']); + expect(keys).toEqual(['events', 'guests', 'photos', 'gallery']); const eventMetric = metrics.find((metric) => metric.key === 'events'); expect(eventMetric?.used).toBe(2); expect(eventMetric?.limit).toBe(5); expect(eventMetric?.remaining).toBe(3); + + const galleryMetric = metrics.find((metric) => metric.key === 'gallery'); + expect(galleryMetric?.remaining).toBe(30); + }); + + it('derives remaining when only usage is known', () => { + const metrics = buildPackageUsageMetrics({ + ...basePackage, + remaining_events: null, + package_limits: { max_events_per_year: 10, gallery_days: 60 }, + used_events: 4, + }); + expect(metrics[0].remaining).toBe(6); }); it('filters metrics without limits', () => { @@ -62,3 +77,16 @@ describe('usagePercent', () => { expect(usagePercent(metrics[0])).toBe(100); }); }); + +describe('getUsageState', () => { + it('returns warning and danger based on thresholds', () => { + const metrics = buildPackageUsageMetrics({ + ...basePackage, + used_events: 8, + remaining_events: null, + package_limits: { max_events_per_year: 10 }, + }); + expect(getUsageState(metrics[0])).toBe('warning'); + expect(getUsageState({ ...metrics[0], used: 10, remaining: 0 })).toBe('danger'); + }); +}); diff --git a/resources/js/admin/mobile/billingUsage.ts b/resources/js/admin/mobile/billingUsage.ts index 6b96142..e2420ee 100644 --- a/resources/js/admin/mobile/billingUsage.ts +++ b/resources/js/admin/mobile/billingUsage.ts @@ -1,6 +1,6 @@ import type { TenantPackageSummary } from '../api'; -export type PackageUsageMetricKey = 'events' | 'guests' | 'photos'; +export type PackageUsageMetricKey = 'events' | 'guests' | 'photos' | 'gallery'; export type PackageUsageMetric = { key: PackageUsageMetricKey; @@ -9,6 +9,8 @@ export type PackageUsageMetric = { remaining: number | null; }; +export type PackageUsageState = 'ok' | 'warning' | 'danger'; + const toNumber = (value: unknown): number | null => { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -48,25 +50,56 @@ const deriveUsedFromRemaining = (limit: number | null, remaining: number | null) return Math.max(limit - remaining, 0); }; +const deriveRemainingFromUsed = (limit: number | null, used: number | null): number | null => { + if (limit === null || used === null) { + return null; + } + + return Math.max(limit - used, 0); +}; + +const normalizeCount = (value: number | null, precision: 'round' | 'floor' = 'round'): number | null => { + if (value === null) { + return null; + } + + const safe = Number.isFinite(value) ? value : null; + if (safe === null) { + return null; + } + + return Math.max(0, precision === 'floor' ? Math.floor(safe) : Math.round(safe)); +}; + export const buildPackageUsageMetrics = (pkg: TenantPackageSummary): PackageUsageMetric[] => { const limits = pkg.package_limits ?? {}; const eventLimit = resolveLimitValue(limits, 'max_events_per_year'); const eventRemaining = resolveUsageValue(pkg.remaining_events); const eventUsed = resolveUsageValue(pkg.used_events) ?? deriveUsedFromRemaining(eventLimit, eventRemaining); + const resolvedEventRemaining = eventRemaining ?? deriveRemainingFromUsed(eventLimit, eventUsed); const guestLimit = resolveLimitValue(limits, 'max_guests'); const guestRemaining = resolveUsageValue(limits['remaining_guests']); const guestUsed = resolveUsageValue(limits['used_guests']) ?? deriveUsedFromRemaining(guestLimit, guestRemaining); + const resolvedGuestRemaining = guestRemaining ?? deriveRemainingFromUsed(guestLimit, guestUsed); const photoLimit = resolveLimitValue(limits, 'max_photos'); const photoRemaining = resolveUsageValue(limits['remaining_photos']); const photoUsed = resolveUsageValue(limits['used_photos']) ?? deriveUsedFromRemaining(photoLimit, photoRemaining); + const resolvedPhotoRemaining = photoRemaining ?? deriveRemainingFromUsed(photoLimit, photoUsed); + + const galleryLimit = resolveLimitValue(limits, 'gallery_days'); + const galleryRemaining = resolveUsageValue(limits['remaining_gallery_days']); + const galleryUsed = resolveUsageValue(limits['used_gallery_days']) ?? deriveUsedFromRemaining(galleryLimit, galleryRemaining); + const resolvedGalleryRemaining = normalizeCount(galleryRemaining ?? deriveRemainingFromUsed(galleryLimit, galleryUsed)); + const resolvedGalleryUsed = normalizeCount(galleryUsed); return [ - { key: 'events', limit: eventLimit, used: eventUsed, remaining: eventRemaining }, - { key: 'guests', limit: guestLimit, used: guestUsed, remaining: guestRemaining }, - { key: 'photos', limit: photoLimit, used: photoUsed, remaining: photoRemaining }, + { key: 'events', limit: eventLimit, used: eventUsed, remaining: resolvedEventRemaining }, + { key: 'guests', limit: guestLimit, used: guestUsed, remaining: resolvedGuestRemaining }, + { key: 'photos', limit: photoLimit, used: photoUsed, remaining: resolvedPhotoRemaining }, + { key: 'gallery', limit: galleryLimit, used: resolvedGalleryUsed, remaining: resolvedGalleryRemaining }, ].filter((metric) => metric.limit !== null); }; @@ -81,3 +114,28 @@ export const usagePercent = (metric: PackageUsageMetric): number => { return Math.min(100, Math.max(0, Math.round((metric.used / metric.limit) * 100))); }; + +export const getUsageState = (metric: PackageUsageMetric): PackageUsageState => { + if (!metric.limit || metric.limit <= 0) { + return 'ok'; + } + + if (metric.remaining !== null && metric.remaining <= 0) { + return 'danger'; + } + + if (metric.used === null) { + return 'ok'; + } + + const percent = usagePercent(metric); + if (percent >= 95) { + return 'danger'; + } + + if (percent >= 80) { + return 'warning'; + } + + return 'ok'; +}; diff --git a/resources/js/admin/mobile/components/Primitives.tsx b/resources/js/admin/mobile/components/Primitives.tsx index 88088a1..9216cbf 100644 --- a/resources/js/admin/mobile/components/Primitives.tsx +++ b/resources/js/admin/mobile/components/Primitives.tsx @@ -34,7 +34,7 @@ export function PillBadge({ tone = 'muted', children, }: { - tone?: 'success' | 'warning' | 'muted'; + tone?: 'success' | 'warning' | 'danger' | 'muted'; children: React.ReactNode; }) { const { theme } = useAdminTheme(); @@ -49,6 +49,11 @@ export function PillBadge({ text: String(theme.yellow11?.val ?? '#92400e'), border: String(theme.yellow6?.val ?? '#fef3c7'), }, + danger: { + bg: String(theme.red3?.val ?? '#FEE2E2'), + text: String(theme.red11?.val ?? '#B91C1C'), + border: String(theme.red6?.val ?? '#FCA5A5'), + }, muted: { bg: String(theme.gray3?.val ?? '#f3f4f6'), text: String(theme.gray11?.val ?? '#374151'), diff --git a/resources/js/admin/mobile/lib/packageSummary.test.ts b/resources/js/admin/mobile/lib/packageSummary.test.ts index a0956fb..a2d687c 100644 --- a/resources/js/admin/mobile/lib/packageSummary.test.ts +++ b/resources/js/admin/mobile/lib/packageSummary.test.ts @@ -14,7 +14,8 @@ const t = (key: string, options?: Record | string) => { const template = (options?.defaultValue as string | undefined) ?? key; return template .replace('{{used}}', String(options?.used ?? '{{used}}')) - .replace('{{limit}}', String(options?.limit ?? '{{limit}}')); + .replace('{{limit}}', String(options?.limit ?? '{{limit}}')) + .replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}')); }; describe('packageSummary helpers', () => { @@ -46,10 +47,10 @@ describe('packageSummary helpers', () => { }); it('returns labeled limit entries', () => { - const result = getPackageLimitEntries({ max_photos: 120 }, t); + const result = getPackageLimitEntries({ max_photos: 120, remaining_photos: 30 }, t); expect(result[0].label).toBe('Photos'); - expect(result[0].value).toBe('120'); + expect(result[0].value).toBe('30 of 120 remaining'); }); it('formats event usage copy', () => { diff --git a/resources/js/admin/mobile/lib/packageSummary.ts b/resources/js/admin/mobile/lib/packageSummary.ts index cb546f2..ce59272 100644 --- a/resources/js/admin/mobile/lib/packageSummary.ts +++ b/resources/js/admin/mobile/lib/packageSummary.ts @@ -2,6 +2,11 @@ import type { TenantPackageSummary } from '../../api'; type Translate = (key: string, options?: Record | string) => string; +type LimitUsageOverrides = { + remainingEvents?: number | null; + usedEvents?: number | null; +}; + const FEATURE_LABELS: Record = { priority_support: { key: 'mobileDashboard.packageSummary.feature.priority_support', @@ -111,6 +116,38 @@ export type PackageLimitEntry = { value: string; }; +const toNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return null; +}; + +const formatLimitWithRemaining = (limit: number | null, remaining: number | null, t: Translate): string => { + if (limit === null || limit === undefined) { + return t('mobileDashboard.packageSummary.unlimited', 'Unlimited'); + } + + if (remaining !== null && remaining >= 0) { + const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining; + return t('mobileBilling.usage.remainingOf', { + remaining: normalizedRemaining, + limit, + defaultValue: '{{remaining}} of {{limit}} remaining', + }); + } + + return String(limit); +}; + export function getPackageFeatureLabel(feature: string, t: Translate): string { const entry = FEATURE_LABELS[feature]; if (entry) { @@ -128,7 +165,11 @@ export function formatPackageLimit(value: number | null | undefined, t: Translat return String(value); } -export function getPackageLimitEntries(limits: Record | null, t: Translate): PackageLimitEntry[] { +export function getPackageLimitEntries( + limits: Record | null, + t: Translate, + usageOverrides: LimitUsageOverrides = {} +): PackageLimitEntry[] { if (!limits) { return []; } @@ -136,10 +177,37 @@ export function getPackageLimitEntries(limits: Record | null, t return LIMIT_LABELS.map(({ key, labelKey, fallback }) => ({ key, label: t(labelKey, fallback), - value: formatPackageLimit((limits as Record)[key], t), + value: formatLimitWithRemaining( + toNumber((limits as Record)[key]), + resolveRemainingForKey(limits, key, usageOverrides), + t + ), })); } +const resolveRemainingForKey = ( + limits: Record, + key: string, + usageOverrides: LimitUsageOverrides +): number | null => { + if (key === 'max_events_per_year') { + return toNumber(usageOverrides.remainingEvents ?? limits.remaining_events); + } + + const map: Record = { + max_photos: 'remaining_photos', + max_guests: 'remaining_guests', + gallery_days: 'remaining_gallery_days', + }; + + const remainingKey = map[key]; + if (!remainingKey) { + return null; + } + + return toNumber(limits[remainingKey]); +}; + export function collectPackageFeatures(pkg: TenantPackageSummary): string[] { const features = new Set(); const direct = Array.isArray(pkg.features) ? pkg.features : []; diff --git a/tests/Feature/Tenant/TenantPackageOverviewTest.php b/tests/Feature/Tenant/TenantPackageOverviewTest.php index 3e442fe..d355a3c 100644 --- a/tests/Feature/Tenant/TenantPackageOverviewTest.php +++ b/tests/Feature/Tenant/TenantPackageOverviewTest.php @@ -3,14 +3,19 @@ namespace Tests\Feature\Tenant; use App\Http\Controllers\Api\TenantPackageController; +use App\Models\Event; +use App\Models\EventPackage; use App\Models\Package; use App\Models\TenantPackage; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; class TenantPackageOverviewTest extends TenantTestCase { public function test_active_package_contains_limits_and_features(): void { + Carbon::setTestNow(Carbon::parse('2024-01-01 10:00:00')); + $package = Package::factory()->reseller()->create([ 'max_photos' => 1500, 'max_guests' => 250, @@ -29,6 +34,20 @@ class TenantPackageOverviewTest extends TenantTestCase 'used_events' => 3, ]); + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + ]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => 199.00, + 'purchased_at' => now()->subDay(), + 'used_photos' => 700, + 'used_guests' => 120, + 'gallery_expires_at' => now()->addDays(20), + ]); + $request = Request::create('/api/v1/tenant/packages', 'GET'); $request->attributes->set('tenant', $this->tenant); @@ -43,6 +62,12 @@ class TenantPackageOverviewTest extends TenantTestCase $this->assertTrue($payload['active_package']['package_limits']['branding_allowed']); $this->assertFalse($payload['active_package']['package_limits']['watermark_allowed']); $this->assertSame(['custom_branding', 'reseller_dashboard'], $payload['active_package']['package_limits']['features']); + $this->assertSame(700, $payload['active_package']['package_limits']['used_photos']); + $this->assertSame(800, $payload['active_package']['package_limits']['remaining_photos']); + $this->assertSame(120, $payload['active_package']['package_limits']['used_guests']); + $this->assertSame(130, $payload['active_package']['package_limits']['remaining_guests']); + $this->assertSame(20, $payload['active_package']['package_limits']['remaining_gallery_days']); + $this->assertSame(10, $payload['active_package']['package_limits']['used_gallery_days']); $this->assertSame(9, $payload['active_package']['remaining_events']); } }