diff --git a/app/Models/User.php b/app/Models/User.php index 302ac87..b38ff79 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -80,6 +80,31 @@ class User extends Authenticatable ]; } + /** + * Adresse für die Stripe-Customer-Anlage (Cashier-Hook). Stripe Tax + * braucht eine gültige Kundenadresse — falls lokal eine + * Rechnungsadresse gepflegt ist, wird sie direkt mitgegeben; sonst + * speichert der Checkout die dort erfasste Adresse (customer_update). + * + * @return array|null + */ + public function stripeAddress(): ?array + { + $address = $this->billingAddress; + + if (! $address) { + return null; + } + + return [ + 'line1' => $address->address1, + 'line2' => $address->address2, + 'postal_code' => $address->postal_code, + 'city' => $address->city, + 'country' => $address->country_code, + ]; + } + /** * Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans` * gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo. diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index 693f9ad..188e516 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -29,10 +29,28 @@ class StripeCheckoutService return $user ->newSubscription('default', $priceId) - ->checkout([ - 'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']), - 'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']), - ]); + ->checkout($this->sessionOptions()); + } + + /** + * Gemeinsame Session-Optionen: Stripe Tax braucht eine gültige + * Kundenadresse — die im Checkout erfasste Rechnungsadresse (und der + * Name, Pflicht bei USt-ID-Abfrage) wird darum am Stripe-Customer + * gespeichert (`customer_update: auto`). + * + * @return array + */ + private function sessionOptions(): array + { + return [ + 'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']), + 'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']), + 'billing_address_collection' => 'required', + 'customer_update' => [ + 'address' => 'auto', + 'name' => 'auto', + ], + ]; } /** @@ -52,8 +70,7 @@ class StripeCheckoutService public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout { return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [ - 'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']), - 'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']), + ...$this->sessionOptions(), 'metadata' => ['single_purchase_id' => (string) $purchase->id], ]); } diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 6b95101..0798190 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -13,11 +13,10 @@ use Livewire\Volt\Component; * echte Buchungsdaten. Launch-Credits (Extra-PM, Boost, Nachweis-PDF) * folgen mit Phase 9I, das Credit-Wallet mit Phase 2. */ -new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component -{ +new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class extends Component { public function formatEuro(int $cents): string { - return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.').' €'; + return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.') . ' €'; } public function planIcon(Plan $plan): string @@ -39,9 +38,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte $currentInterval = null; if ($currentPlan && $subscription) { - $currentInterval = $subscription->stripe_price === $currentPlan->stripe_price_id_yearly - ? 'yearly' - : 'monthly'; + $currentInterval = $subscription->stripe_price === $currentPlan->stripe_price_id_yearly ? 'yearly' : 'monthly'; } return [ @@ -57,24 +54,14 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte // Bestandstarife: laufende Legacy-Vereinbarungen (MAN-Kreis, // unbegrenzte PMs — Entscheidung 12.06.2026). - 'legacyOptions' => $user->userPaymentOptions() - ->whereIn('status', [ - UserPaymentOptionStatus::Active->value, - UserPaymentOptionStatus::Grandfathered->value, - ]) + 'legacyOptions' => $user + ->userPaymentOptions() + ->whereIn('status', [UserPaymentOptionStatus::Active->value, UserPaymentOptionStatus::Grandfathered->value]) ->orderBy('current_period_end') ->get(), - 'openPurchases' => $user->singlePurchases() - ->grantingSubmission() - ->orderBy('paid_at') - ->get(), - 'consumedPurchases' => $user->singlePurchases() - ->where('status', SinglePurchaseStatus::Consumed->value) - ->with('pressRelease') - ->latest('consumed_at') - ->limit(10) - ->get(), + 'openPurchases' => $user->singlePurchases()->grantingSubmission()->orderBy('paid_at')->get(), + 'consumedPurchases' => $user->singlePurchases()->where('status', SinglePurchaseStatus::Consumed->value)->with('pressRelease')->latest('consumed_at')->limit(10)->get(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), 'quotaTotal' => $user->pressReleaseQuotaTotal(), @@ -87,16 +74,19 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
{{-- ============== CHECKOUT-RÜCKMELDUNG ============== --}} @if ($checkoutResult === 'erfolg') -
- {{ __('Vielen Dank für Ihre Buchung!') }} + {{ __('Vielen Dank für Ihre Buchung!') }} {{ __('Die Zahlung wird von Stripe bestätigt — die Buchung erscheint hier in wenigen Augenblicken. Die Rechnung finden Sie anschließend unter Rechnungen.') }}
@elseif ($checkoutResult === 'abbruch') -
@@ -106,7 +96,8 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @endif @if ($checkoutNotice) -
{{ $checkoutNotice }}
@@ -129,7 +120,8 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
- + {{ __('Rechnungen') }}
@@ -139,90 +131,94 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{-- Erscheint erst, wenn eine Buchung existiert — vorher würde hier nur „kein Tarif" stehen und das Kontingent wäre irreführend. --}} @if ($subscription || $legacyOptions->isNotEmpty() || $openPurchases->isNotEmpty()) -
-
- {{ __('Aktueller Tarif') }} - @if ($currentPlan) - {{ $currentPlan->name }} - @elseif ($legacyOptions->isNotEmpty()) - {{ __('Bestandstarif') }} - @else - {{ __('Einzel-PM') }} - @endif -
-
-
+
+
+ {{ __('Aktueller Tarif') }} @if ($currentPlan) -
- {{ $currentInterval === 'yearly' - ? $this->formatEuro($currentPlan->yearly_price_cents).' / '.__('Jahr') - : $this->formatEuro($currentPlan->monthly_price_cents).' / '.__('Monat') }} - {{ __('netto') }} -
-

- {{ __(':quota Pressemitteilungen pro Monat', ['quota' => $currentPlan->press_release_quota]) }} - @if ($currentPlan->daily_limit) - · {{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $currentPlan->daily_limit]) }} + {{ $currentPlan->name }} + @elseif ($legacyOptions->isNotEmpty()) + {{ __('Bestandstarif') }} + @else + {{ __('Einzel-PM') }} + @endif +

+
+
+ @if ($currentPlan) +
+ {{ $currentInterval === 'yearly' + ? $this->formatEuro($currentPlan->yearly_price_cents) . ' / ' . __('Jahr') + : $this->formatEuro($currentPlan->monthly_price_cents) . ' / ' . __('Monat') }} + {{ __('netto') }} +
+

+ {{ __(':quota Pressemitteilungen pro Monat', ['quota' => $currentPlan->press_release_quota]) }} + @if ($currentPlan->daily_limit) + · + {{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $currentPlan->daily_limit]) }} + @endif +

+ @if ($subscription?->onGracePeriod()) +

+ {{ __('Gekündigt — läuft am :date aus.', ['date' => $subscription->ends_at?->format('d.m.Y')]) }} +

@endif -

- @if ($subscription?->onGracePeriod()) -

- {{ __('Gekündigt — läuft am :date aus.', ['date' => $subscription->ends_at?->format('d.m.Y')]) }} + @elseif ($legacyOptions->isNotEmpty()) + @foreach ($legacyOptions as $option) +

+
+ {{ data_get($option->legacy_conditions, 'name') ?? ($option->paymentOption?->article_number ?? __('Bestehende Vereinbarung')) }} +
+

+ {{ __('Unbegrenzte Pressemitteilungen (Bestandsschutz).') }} + @if ($option->current_period_end) + {{ __('Nächste Rechnung am :date.', ['date' => $option->current_period_end->format('d.m.Y')]) }} + @endif +

+
+ @endforeach +

+ {{ __('Ihre bisherigen Konditionen gelten unverändert weiter; die Abrechnung erfolgt wie gewohnt per Rechnung.') }} +

+ @elseif ($openPurchases->isNotEmpty()) +
+ {{ trans_choice(':count Einzel-Pressemitteilung verfügbar|:count Einzel-Pressemitteilungen verfügbar', $openPurchases->count(), ['count' => $openPurchases->count()]) }} +
+

+ {{ __('Jeder Kauf berechtigt zu genau einer Veröffentlichung — eingelöst wird er erst, wenn die Pressemitteilung live geht.') }}

@endif - @elseif ($legacyOptions->isNotEmpty()) - @foreach ($legacyOptions as $option) -
-
- {{ data_get($option->legacy_conditions, 'name') ?? $option->paymentOption?->article_number ?? __('Bestehende Vereinbarung') }} -
-

- {{ __('Unbegrenzte Pressemitteilungen (Bestandsschutz).') }} - @if ($option->current_period_end) - {{ __('Nächste Rechnung am :date.', ['date' => $option->current_period_end->format('d.m.Y')]) }} - @endif -

-
- @endforeach -

- {{ __('Ihre bisherigen Konditionen gelten unverändert weiter; die Abrechnung erfolgt wie gewohnt per Rechnung.') }} -

- @elseif ($openPurchases->isNotEmpty()) -
- {{ trans_choice(':count Einzel-Pressemitteilung verfügbar|:count Einzel-Pressemitteilungen verfügbar', $openPurchases->count(), ['count' => $openPurchases->count()]) }} -
-

- {{ __('Jeder Kauf berechtigt zu genau einer Veröffentlichung — eingelöst wird er erst, wenn die Pressemitteilung live geht.') }} -

- @endif -
+
-
- {{-- Kontingent nur als echte Zahl — „unbegrenzt" wäre vor dem +
+ {{-- Kontingent nur als echte Zahl — „unbegrenzt" wäre vor dem Launch-Schalter inhaltlich falsch. --}} - @if (! is_null($quotaRemaining)) -
-
{{ __('PM-Kontingent diesen Monat') }}
-
- {{ $quotaRemaining }} / {{ $quotaTotal }} + @if (!is_null($quotaRemaining)) +
+
+ {{ __('PM-Kontingent diesen Monat') }}
+
+ {{ $quotaRemaining }} / {{ $quotaTotal }} +
+
+ {{ __('Wird erst bei Veröffentlichung verbraucht.') }} +
-
- {{ __('Wird erst bei Veröffentlichung verbraucht.') }} -
-
- @endif - @if ($subscription) - - {{ __('Abo verwalten') }} - -

- {{ __('Zahlungsmethode, Rechnungen und Kündigung — sicher über das Stripe-Kundenportal.') }} -

- @endif + @endif + @if ($subscription) + + {{ __('Abo verwalten') }} + +

+ {{ __('Zahlungsmethode, Rechnungen und Kündigung — sicher über das Stripe-Kundenportal.') }} +

+ @endif +
-
-
+
@endif {{-- ============== TARIF-RASTER ============== --}} @@ -238,15 +234,20 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte

-
+
@@ -255,15 +256,20 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
@foreach ($plans as $plan) @php($isCurrent = $currentPlan && $plan->is($currentPlan)) -
$isCurrent]) wire:key="plan-{{ $plan->slug }}"> +
$isCurrent, + ]) wire:key="plan-{{ $plan->slug }}">
-
-

{{ $plan->name }}

+

+ {{ $plan->name }}

@if ($isCurrent) {{ __('Aktuell') }} @@ -272,20 +278,28 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
- {{ $this->formatEuro($plan->monthly_price_cents) }} - / {{ __('Monat') }} + {{ $this->formatEuro($plan->monthly_price_cents) }} + / + {{ __('Monat') }}
- {{ $this->formatEuro($plan->yearly_price_cents) }} - / {{ __('Jahr') }} + {{ $this->formatEuro($plan->yearly_price_cents) }} + / + {{ __('Jahr') }}
-
{{ __('netto zzgl. USt.') }}
+
+ {{ __('netto zzgl. USt.') }}
-
    +
    • - {{ $plan->press_release_quota }} {{ __('Pressemitteilungen pro Monat') }} + {{ $plan->press_release_quota }} + {{ __('Pressemitteilungen pro Monat') }}
    • @@ -309,13 +323,13 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @else
      + href="{{ route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly']) }}"> {{ __('Monatlich buchen') }}
      + href="{{ route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'yearly']) }}"> {{ __('Jährlich buchen') }}
      @@ -327,7 +341,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte

- {{ __('Mehr als 60 Pressemitteilungen pro Monat, mehrere Teams oder Sonderkonditionen? Enterprise-Konditionen erhalten Sie auf Anfrage über den Support.') }} + {{ __('Mehr als 60 Pressemitteilungen pro Monat, mehrere Teams oder Sonderkonditionen? Enterprise-Konditionen erhalten Sie auf Anfrage über den Support. Inklusive KI-Prüfung und Veröffentlichung.info@pressekonto.com') }}

@@ -335,7 +349,8 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
-
+
@@ -343,7 +358,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Einzel-Pressemitteilung — ohne Abo') }}

- {{ __('Genau eine Veröffentlichung inklusive KI-Prüfung. Eingelöst wird der Kauf erst, wenn die Pressemitteilung live geht — Ablehnungen kosten nichts.') }} + {{ __('Genau eine Veröffentlichung inklusive Prüfung und Veröffentlichung. Eingelöst wird der Kauf erst, wenn die Pressemitteilung live geht — Ablehnungen kosten nichts.') }} @if ($openPurchases->isNotEmpty()) {{ trans_choice('Aktuell :count offener Kauf.|Aktuell :count offene Käufe.', $openPurchases->count(), ['count' => $openPurchases->count()]) }} @@ -357,9 +372,8 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte

{{ $singlePmPrice }}
{{ __('netto zzgl. USt.') }}
- + {{ __('Jetzt buchen') }}
@@ -385,7 +399,8 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @if ($consumedPurchases->isNotEmpty())
@foreach ($consumedPurchases as $purchase) -
+
{{ $purchase->pressRelease?->title ?? $purchase->type->label() }} @@ -400,7 +415,8 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
@else
-