From 3c6190099faa8d2ecaa7eb69860176c01c59f6e2 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 17 Jun 2026 15:26:04 +0000 Subject: [PATCH] UI-Feinschliff Buchungen: Tarife oben, Credits darunter, Preisliste, USt.-Modal - Reihenfolge: Tarife -> Einzel-PM -> Credit-Wallet & Add-ons -> Preisliste - Buchungs-Bestaetigungs-Modal mit USt.-Aufschluesselung (netto/USt./gesamt, Reverse-Charge & steuerbefreit) vor Stripe; greift fuer Tarife, Einzel-PM und Credit-Pakete (selectBooking) - Proaktiver Banner oben, wenn die Rechnungsadresse fehlt (Link ins Profil); im Modal Adress-Gate statt Stripe-Link - Extra-PM-Karte nur bei Guthaben > 0 oder aktivem Abo (sonst Preisliste) - Pruefkontingent ausgeblendet (Phase 2) - Preisliste: Extra-PM nach Tarif (eigener Tarif markiert), Boost-Laufzeiten, Nachweis-PDF Co-Authored-By: Claude Opus 4.8 --- .../livewire/customer/bookings.blade.php | 558 +++++++++++++----- tests/Feature/Billing/BookingsPageTest.php | 72 ++- 2 files changed, 463 insertions(+), 167 deletions(-) diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 46612f6..8429f17 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -2,15 +2,17 @@ use App\Enums\Portal; use App\Enums\SinglePurchaseStatus; +use App\Enums\Tier; use App\Enums\UserPaymentOptionStatus; +use App\Enums\VatTreatment; use App\Exceptions\InsufficientCreditsException; use App\Models\Plan; use App\Models\UserPaymentOption; use App\Services\Billing\CreditPricingService; use App\Services\Billing\CreditTopupService; -use App\Services\Billing\CreditWalletService; use App\Services\Billing\ExtraPmPurchaseService; -use App\Services\PressRelease\ReviewCheckService; +use App\Services\Billing\VatResolver; +use Flux\Flux; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Volt\Component; @@ -18,8 +20,9 @@ use Livewire\Volt\Component; /** * Buchungen & Add-ons (Phase 9F): Tarif-Raster mit Stripe-Checkout, * Einzel-PM als separater No-Abo-Block, Bestandstarife (MAN-Kreis) und - * echte Buchungsdaten. Die Credit-Wallet (Topup, Extra-PM, Prüfkontingent) - * bildet den Add-on-Block laut Decision-Update (Rev. 4) ab. + * echte Buchungsdaten. Die Credit-Wallet (Topup, Extra-PM) bildet den + * Add-on-Block laut Decision-Update (Rev. 4) ab. Jede Buchung läuft über ein + * Bestätigungs-Modal mit USt.-Aufschlüsselung. Prüfkontingent folgt mit Phase 2. */ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component { /** Erfolgs-/Fehlerhinweis des Wallet-Blocks (Extra-PM-Kauf). */ @@ -27,11 +30,104 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte public ?string $walletError = null; + /** Im Bestätigungs-Modal angezeigte Buchung (inkl. USt.-Aufschlüsselung). */ + public ?array $booking = null; + public function formatEuro(int $cents): string { return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.') . ' €'; } + /** + * Öffnet das Bestätigungs-Modal für eine Buchung (Tarif-Abo, Einzel-PM + * oder Credit-Paket) mit USt.-Aufschlüsselung anhand der Rechnungsadresse. + * Bestätigt wird per Weiterleitung auf die jeweilige Checkout-Route. + */ + public function selectBooking(string $type, ?string $ref = null, ?string $interval = null): void + { + $user = auth()->user(); + + [$title, $subtitle, $netCents, $url] = match ($type) { + 'subscription' => $this->subscriptionBooking($ref, $interval), + 'single_pm' => [ + __('Einzel-Pressemitteilung'), + __('Genau eine Veröffentlichung inkl. Prüfung — eingelöst erst bei Veröffentlichung.'), + (int) config('billing.single_pm_price_cents'), + route('me.checkout.single-pm'), + ], + 'credit_pack' => $this->creditPackBooking($ref), + default => [null, null, 0, null], + }; + + if (! $url) { + return; + } + + $resolver = app(VatResolver::class); + $treatment = $resolver->resolve($user->billingAddress?->country_code, $user->billingAddress?->vat_id); + $vatCents = $resolver->taxCentsFor($netCents, $treatment); + + $this->booking = [ + 'title' => $title, + 'subtitle' => $subtitle, + 'interval' => $interval, + 'net' => $this->formatEuro($netCents), + 'vat_percent' => (int) round($resolver->rateFor($treatment) * 100), + 'vat' => $this->formatEuro($vatCents), + 'gross' => $this->formatEuro($netCents + $vatCents), + 'reverse_charge' => $treatment === VatTreatment::ReverseCharge, + 'tax_exempt' => $treatment->isTaxExempt(), + 'url' => $url, + 'needs_address' => ! $user->hasCompleteBillingAddress(), + ]; + + Flux::modal('confirm-booking')->show(); + } + + /** + * @return array{0: ?string, 1: ?string, 2: int, 3: ?string} + */ + private function subscriptionBooking(?string $slug, ?string $interval): array + { + $plan = Plan::query()->active()->where('slug', $slug)->first(); + + if (! $plan) { + return [null, null, 0, null]; + } + + $isYearly = $interval === 'yearly'; + + return [ + $plan->name . ' — ' . ($isYearly ? __('Jährlich') : __('Monatlich')), + __(':quota Pressemitteilungen pro Monat, Prüfung und Veröffentlichung inklusive.', ['quota' => $plan->press_release_quota]), + $isYearly ? (int) $plan->yearly_price_cents : (int) $plan->monthly_price_cents, + route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => $isYearly ? 'yearly' : 'monthly']), + ]; + } + + /** + * @return array{0: ?string, 1: ?string, 2: int, 3: ?string} + */ + private function creditPackBooking(?string $key): array + { + $pack = app(CreditTopupService::class)->findPack((string) $key); + + if (! $pack) { + return [null, null, 0, null]; + } + + $bonus = $pack['credits'] - intdiv($pack['price_cents'], 100); + + return [ + __(':credits Credits Guthaben', ['credits' => $pack['credits']]), + $bonus > 0 + ? __('Enthält :bonus Bonus-Credits. 1 Credit = 1 € beim Einlösen.', ['bonus' => $bonus]) + : __('1 Credit = 1 € beim Einlösen.'), + (int) $pack['price_cents'], + route('me.checkout.credit-topup', ['pack' => $pack['key']]), + ]; + } + /** * Extra-PM aus der Wallet nachkaufen (tier-gestaffelter Preis). Reicht das * Guthaben nicht, zeigt der kontextuelle Mini-Checkout den Fehlbetrag. @@ -91,6 +187,8 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte $user = auth()->user(); $subscription = $user->subscription(); $currentPlan = $user->currentPlan(); + $currentTier = $user->currentTier(); + $pricing = app(CreditPricingService::class); $currentInterval = null; if ($currentPlan && $subscription) { @@ -125,14 +223,23 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte 'singlePmAvailable' => (bool) config('billing.single_pm_stripe_price_id'), // Credit-Wallet & Add-ons (Decision-Update Rev. 4). + 'hasCompleteBillingAddress' => $user->hasCompleteBillingAddress(), 'creditBalance' => $user->creditBalance(), 'creditPacks' => app(CreditTopupService::class)->packs(), - 'extraPmPrice' => app(CreditPricingService::class)->extraPmCreditsFor($user), - 'reviewFreeRemaining' => app(ReviewCheckService::class)->freeRemaining($user), - 'reviewFreeQuota' => app(CreditPricingService::class)->reviewFreeQuota($user->currentTier()), - 'reviewUsedToday' => app(ReviewCheckService::class)->usedToday($user), - 'reviewDailyLimit' => app(CreditPricingService::class)->reviewDailyLimit(), + 'extraPmPrice' => $pricing->extraPmCreditsFor($user), + // Extra-PM nur anbieten, wenn man Guthaben hat oder im Abo ist — + // sonst ergibt sich der Preis aus der Preisliste (Decision-Update). + 'showExtraPm' => $user->creditBalance() > 0 || (bool) $subscription, 'recentTransactions' => $user->creditTransactions()->latest()->limit(6)->get(), + + // Preisliste (Add-on-Credits). + 'extraPmStaffel' => collect(Tier::cases())->map(fn (Tier $t): array => [ + 'label' => $t->label(), + 'credits' => $pricing->extraPmCredits($t), + 'isCurrent' => $t === $currentTier, + ])->all(), + 'boostStaffel' => $pricing->boostOptions(), + 'proofPrice' => $pricing->proofPdfCredits(), ]; } }; ?> @@ -170,6 +277,23 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @endif + {{-- ============== RECHNUNGSADRESSE FEHLT ============== --}} + {{-- Proaktiver Hinweis: jede Buchung setzt eine vollständige + Rechnungsadresse voraus (sonst leitet der Checkout-Guard ins Profil). --}} + @if (! $hasCompleteBillingAddress) +
+ +
+ {{ __('Rechnungsadresse fehlt.') }} + {{ __('Für jede Buchung ist eine vollständige Rechnungsadresse nötig — sie wird an Stripe übergeben.') }} + {{ __('Jetzt hinterlegen') }} +
+
+ @endif + {{-- ============== PAGE HEADER ============== --}}