diff --git a/app/Services/Customer/CustomerStatusSummary.php b/app/Services/Customer/CustomerStatusSummary.php new file mode 100644 index 00000000..4706ce80 --- /dev/null +++ b/app/Services/Customer/CustomerStatusSummary.php @@ -0,0 +1,124 @@ +currentPlan(); + $subscription = $user->subscription(); + $hasSubscription = (bool) $subscription?->valid(); + + $billingInterval = null; + if ($plan instanceof Plan && $subscription?->stripe_price) { + $billingInterval = $subscription->stripe_price === $plan->stripe_price_id_yearly + ? 'yearly' + : 'monthly'; + } + + $hasGrandfathered = $user->userPaymentOptions() + ->whereIn('status', [ + UserPaymentOptionStatus::Active->value, + UserPaymentOptionStatus::Grandfathered->value, + ]) + ->exists(); + + return new self( + tier: $user->currentTier(), + planName: $plan?->name, + billingInterval: $billingInterval, + hasActiveBooking: $user->hasActiveBooking(), + hasSubscription: $hasSubscription, + hasGrandfathered: $hasGrandfathered, + openSinglePurchases: $user->singlePurchases()->grantingSubmission()->count(), + unlimitedQuota: $user->hasUnlimitedPressReleaseQuota(), + quotaTotal: $user->pressReleaseQuotaTotal(), + quotaRemaining: $user->pressReleaseQuotaRemaining(), + creditBalance: $user->creditBalance(), + ); + } + + public function hasCredits(): bool + { + return $this->creditBalance > 0; + } + + /** + * Menschlich lesbares Etikett der aktuellen Buchung. + */ + public function bookingLabel(): string + { + if ($this->planName !== null) { + return $this->planName; + } + + if ($this->hasGrandfathered) { + return __('Bestandstarif'); + } + + if ($this->openSinglePurchases > 0) { + return trans_choice( + '{1}:count Einzelbuchung|[2,*]:count Einzelbuchungen', + $this->openSinglePurchases, + ['count' => $this->openSinglePurchases], + ); + } + + return __('Kein Abo'); + } + + public function billingIntervalLabel(): ?string + { + return match ($this->billingInterval) { + 'monthly' => __('monatlich'), + 'yearly' => __('jährlich'), + default => null, + }; + } + + /** + * Kompakter Text für das Trigger-Badge in der Topbar. + */ + public function badgeLabel(): string + { + $parts = []; + + $parts[] = $this->unlimitedQuota + ? __(':icon PM', ['icon' => '∞']) + : trans_choice('{0}:count PM|{1}:count PM|[2,*]:count PM', (int) $this->quotaRemaining, ['count' => (int) $this->quotaRemaining]); + + if ($this->hasCredits()) { + $parts[] = __(':count Cr', ['count' => $this->creditBalance]); + } + + return implode(' · ', $parts); + } +} diff --git a/resources/views/components/customer/status-menu.blade.php b/resources/views/components/customer/status-menu.blade.php new file mode 100644 index 00000000..e6264fdd --- /dev/null +++ b/resources/views/components/customer/status-menu.blade.php @@ -0,0 +1,84 @@ +@props(['user' => null]) + +@php + $user ??= auth()->user(); + $status = $user ? \App\Services\Customer\CustomerStatusSummary::fromUser($user) : null; +@endphp + +@if ($status) + + {{-- Trigger: kompaktes Status-Badge in der Topbar --}} + + + + {{-- Aktuelle Buchung --}} +
+
+ {{ __('Aktuelle Buchung') }} +
+
+ + {{ $status->bookingLabel() }} + @if ($status->billingIntervalLabel()) + · {{ $status->billingIntervalLabel() }} + @endif + + @if ($status->hasActiveBooking) + + {{ __('aktiv') }} + + @else + {{ __('keine') }} + @endif +
+
+ + + + {{-- Kennzahlen: PM-Kontingent & Credits --}} +
+
+
+ {{ __('PMs frei') }} +
+
+ @if ($status->unlimitedQuota) + ∞ + @else + {{ $status->quotaRemaining }} / {{ $status->quotaTotal }} + @endif +
+
+
+
+ {{ __('Guthaben') }} +
+
+ {{ $status->creditBalance }} Cr +
+
+
+ + @unless ($status->hasActiveBooking) +
+ + {{ __('Zum Veröffentlichen brauchst du eine Buchung.') }} +
+ @endunless + + + + + {{ __('Buchungen & Add-ons') }} + +
+
+@endif diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 1adbedc1..9c092b5d 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -307,6 +307,15 @@ + {{-- Desktop-Topbar: immer verfügbarer Status (Buchung, freie PMs, Credits) + oben rechts. Nur für Panel-User mit Kundenbereich. --}} + @if ($canCustomer) + + @endif + @@ -317,6 +326,9 @@ + @if ($canCustomer) + + @endif diff --git a/tests/Feature/CustomerStatusSummaryTest.php b/tests/Feature/CustomerStatusSummaryTest.php new file mode 100644 index 00000000..b9f57256 --- /dev/null +++ b/tests/Feature/CustomerStatusSummaryTest.php @@ -0,0 +1,101 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +test('a user without any booking and the gate off counts as unlimited and active', function () { + config()->set('billing.enforce_booking', false); + + $user = User::factory()->create(); + + $status = CustomerStatusSummary::fromUser($user); + + expect($status->tier)->toBe(Tier::Einzel); + expect($status->hasActiveBooking)->toBeTrue(); + expect($status->unlimitedQuota)->toBeTrue(); + expect($status->quotaRemaining)->toBeNull(); + expect($status->bookingLabel())->toBe(__('Kein Abo')); + expect($status->badgeLabel())->toContain('∞'); +}); + +test('a user without booking and the gate enforced has no active booking and zero quota', function () { + config()->set('billing.enforce_booking', true); + + $user = User::factory()->create(); + + $status = CustomerStatusSummary::fromUser($user); + + expect($status->hasActiveBooking)->toBeFalse(); + expect($status->unlimitedQuota)->toBeFalse(); + expect($status->quotaRemaining)->toBe(0); + expect($status->quotaTotal)->toBe(0); +}); + +test('a grandfathered bestandskunde is labelled and counts as active', function () { + config()->set('billing.enforce_booking', true); + + $user = User::factory()->create(); + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'stripe_subscription_id' => null, + ]); + + $status = CustomerStatusSummary::fromUser($user->fresh()); + + expect($status->hasActiveBooking)->toBeTrue(); + expect($status->hasGrandfathered)->toBeTrue(); + expect($status->bookingLabel())->toBe(__('Bestandstarif')); +}); + +test('credit balance is surfaced and shown in the badge', function () { + config()->set('billing.enforce_booking', true); + + $user = User::factory()->create(); + app(CreditWalletService::class)->credit($user, 27, CreditTransactionType::Topup, 'Paket 25'); + + $status = CustomerStatusSummary::fromUser($user->fresh()); + + expect($status->creditBalance)->toBe(27); + expect($status->hasCredits())->toBeTrue(); + expect($status->badgeLabel())->toContain('27'); +}); + +test('a user without credits hides the credit segment in the badge', function () { + config()->set('billing.enforce_booking', false); + + $user = User::factory()->create(); + + $status = CustomerStatusSummary::fromUser($user); + + expect($status->hasCredits())->toBeFalse(); + expect($status->badgeLabel())->not->toContain('Cr'); +}); + +test('the status menu renders inside the customer layout header', function () { + /** @var TestCase $this */ + config()->set('billing.enforce_booking', true); + + $user = User::factory()->create(['is_active' => true]); + $user->assignRole('customer'); + + $this->actingAs($user); + + $this->get(route('me.dashboard')) + ->assertOk() + ->assertSee(__('Aktuelle Buchung')) + ->assertSee(__('PMs frei')) + ->assertSee(route('me.bookings.index'), false); +});