From 9e76e240141c2473ad13340284a62679c12d53f3 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 7 Jul 2026 12:58:32 +0000 Subject: [PATCH] Tarifwechsel im Buchungs-Modal: Abo-Swap mit anteiliger Verrechnung Tarif-Karten bieten bei bestehendem Abo jetzt 'Zu diesem Tarif wechseln' (pro Intervall, aktueller Tarif deaktiviert). Der Wechsel laeuft ueber das bekannte Bestaetigungs-Modal (T1neu-Checkbox) als POST auf die neue checkout.swap-Route: Cashier swapAndInvoice stellt sofort um, verrechnet anteilig und hebt eine vorgemerkte Kuendigung auf. SCA-Faelle leiten auf die Cashier-Bezahlseite. QA-Befund 07.07. Co-Authored-By: Claude Fable 5 --- app/Http/Controllers/CheckoutController.php | 62 +++++++++++++- .../Billing/StripeCheckoutService.php | 21 +++++ .../livewire/customer/bookings.blade.php | 85 +++++++++++++++++-- routes/customer.php | 3 + tests/Feature/Billing/BookingsPageTest.php | 47 ++++++++++ tests/Feature/Billing/CheckoutFlowTest.php | 79 +++++++++++++++++ 6 files changed, 289 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 68ced9fc..acb0ff12 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -13,7 +13,9 @@ use App\Services\Billing\CreditTopupService; use App\Services\Billing\StripeCheckoutService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Laravel\Cashier\Checkout; +use Laravel\Cashier\Exceptions\IncompletePayment; /** * Einstieg in die Stripe-Checkouts (Phase 9E): Tarif-Abo und Einzel-PM. @@ -41,7 +43,7 @@ class CheckoutController extends Controller } if ($user->subscribed()) { - return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.')); + return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Nutzen Sie den Tarifwechsel direkt an der gewünschten Tarif-Karte.')); } $priceId = $interval === 'yearly' @@ -55,6 +57,64 @@ class CheckoutController extends Controller return $this->checkout->forSubscription($user, $plan, $interval); } + /** + * Tarifwechsel eines aktiven Abos (POST aus dem Buchungs-Modal) — kein + * neuer Stripe-Checkout: Stripe stellt das bestehende Abo sofort um und + * verrechnet anteilig (swapAndInvoice). Eine vorgemerkte Kündigung wird + * durch den Wechsel aufgehoben. + */ + public function swap(Request $request, string $planSlug, string $interval): RedirectResponse + { + $plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail(); + $user = $request->user(); + + if (! $user->hasCompleteBillingAddress()) { + return $this->backToProfile(); + } + + if ($redirect = $this->requireEntrepreneurConfirmation($request)) { + return $redirect; + } + + $subscription = $user->subscription(); + + if (! $subscription || ! $subscription->valid()) { + return $this->backToBookings(__('Es besteht kein aktives Abo — bitte buchen Sie den gewünschten Tarif direkt.')); + } + + $priceId = $interval === 'yearly' + ? $plan->stripe_price_id_yearly + : $plan->stripe_price_id_monthly; + + if (! $priceId) { + return $this->backToBookings(__('Dieser Tarif ist noch nicht buchbar. Bitte versuchen Sie es später erneut.')); + } + + if ($subscription->stripe_price === $priceId) { + return $this->backToBookings(__('Dieser Tarif ist bereits Ihr aktueller Tarif.')); + } + + try { + $this->checkout->swapSubscription($user, $plan, $interval); + } catch (IncompletePayment $exception) { + return redirect()->route('cashier.payment', [ + $exception->payment->id, + 'redirect' => route('me.bookings.index', ['checkout' => 'wechsel']), + ]); + } catch (\Throwable $exception) { + Log::error('Tarifwechsel fehlgeschlagen.', [ + 'user_id' => $user->id, + 'plan' => $plan->slug, + 'interval' => $interval, + 'error' => $exception->getMessage(), + ]); + + return $this->backToBookings(__('Der Tarifwechsel konnte nicht durchgeführt werden. Bitte versuchen Sie es erneut oder wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')])); + } + + return redirect()->route('me.bookings.index', ['checkout' => 'wechsel']); + } + public function singlePm(Request $request): Checkout|RedirectResponse { if (! $request->user()->hasCompleteBillingAddress()) { diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index 6dcd9e62..13d6f57b 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -8,6 +8,7 @@ use App\Models\SinglePurchase; use App\Models\User; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Checkout; +use Laravel\Cashier\Exceptions\IncompletePayment; /** * Dünner Wrapper um die Cashier-Checkout-Erzeugung (Phase 9E). @@ -38,6 +39,26 @@ class StripeCheckoutService ->checkout($this->sessionOptions($user)); } + /** + * Tarifwechsel eines bestehenden Abos (QA-Befund 07.07.) — ohne neuen + * Checkout: Stripe stellt das Abo sofort um und stellt die anteilige + * Differenz umgehend in Rechnung (swapAndInvoice). Cashier hebt dabei + * eine vorgemerkte Kündigung auf (cancel_at_period_end = false). + * + * @throws IncompletePayment wenn die Zahlung + * eine Bestätigung braucht (SCA) + */ + public function swapSubscription(User $user, Plan $plan, string $interval): void + { + $priceId = $interval === 'yearly' + ? $plan->stripe_price_id_yearly + : $plan->stripe_price_id_monthly; + + $this->syncCustomerFromBillingAddress($user); + + $user->subscription('default')->swapAndInvoice($priceId); + } + /** * Stripe-Customer vor jedem Checkout auf den Stand der lokal gepflegten * Rechnungsadresse bringen: Name/Firma und Adresse (stripeName/ diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 5d8c0df0..617a6921 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -102,7 +102,14 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte $treatment = $resolver->resolve($user->billingAddress?->country_code, $user->billingAddress?->vat_id); $vatCents = $resolver->taxCentsFor($netCents, $treatment); + // Tarif-Auswahl mit bestehendem Abo = Tarifwechsel (Swap statt + // neuem Checkout) — das Modal erklärt die anteilige Verrechnung. + $subscription = $user->subscription(); + $isSwap = $type === 'subscription' && (bool) $subscription?->valid(); + $this->booking = [ + 'is_swap' => $isSwap, + 'resumes_cancellation' => $isSwap && $subscription->onGracePeriod(), 'type' => $type, 'ref' => $ref, 'title' => $title, @@ -136,12 +143,15 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte } $isYearly = $interval === 'yearly'; + $routeName = auth()->user()->subscription()?->valid() + ? 'me.checkout.swap' + : 'me.checkout.subscription'; 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']), + route($routeName, ['planSlug' => $plan->slug, 'interval' => $isYearly ? 'yearly' : 'monthly']), ]; } @@ -297,6 +307,17 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Die Zahlung wird von Stripe bestätigt — die Buchung erscheint hier in wenigen Augenblicken. Die Rechnung finden Sie anschließend unter Rechnungen.') }} + @elseif ($checkoutResult === 'wechsel') +
+ +
+ {{ __('Ihr Tarifwechsel wurde durchgeführt!') }} + {{ __('Der neue Tarif gilt ab sofort. Bereits gezahlte Beträge wurden anteilig verrechnet — die Rechnung finden Sie unter Rechnungen.') }} +
+
@elseif ($checkoutResult === 'abbruch')
- {{ __('Monatlich kündbar. Im Jahrestarif sind 2 Monate gratis — Sie zahlen 10 von 12 Monaten.') }} + {{ __('Monatstarife sind monatlich kündbar. Im Jahrestarif sind 2 Monate gratis — Sie zahlen 10 von 12 Monaten, das Abo verlängert sich um jeweils zwölf Monate und ist zum Ende der Laufzeit kündbar.') }}

@@ -617,9 +638,33 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
@if ($subscription) - - {{ $isCurrent ? __('Ihr aktueller Tarif') : __('Wechsel über „Abo verwalten"') }} - + {{-- Tarifwechsel: Swap des bestehenden Abos statt + neuem Checkout — pro Intervall, der aktuell + gebuchte Tarif ist deaktiviert. --}} +
+ @if ($isCurrent && $currentInterval === 'monthly') + + {{ __('Ihr aktueller Tarif') }} + + @else + + {{ __('Zu diesem Tarif wechseln') }} + + @endif +
+
+ @if ($isCurrent && $currentInterval === 'yearly') + + {{ __('Ihr aktueller Tarif') }} + + @else + + {{ __('Zu diesem Tarif wechseln') }} + + @endif +
@else
- {{ __('Buchung bestätigen') }} + + {{ ($booking['is_swap'] ?? false) ? __('Tarifwechsel bestätigen') : __('Buchung bestätigen') }} + {{ $booking['title'] }} @if ($booking['subtitle'])

{{ $booking['subtitle'] }}

@@ -971,6 +1018,15 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte

{{ __('Die endgültige Steuer berechnet Stripe anhand Ihrer Rechnungsadresse. Die Abrechnung erfolgt sicher über Stripe.') }}

+ + @if ($booking['is_swap'] ?? false) +

+ {{ __('Der Wechsel gilt sofort: Bereits gezahlte Beträge werden anteilig gutgeschrieben, die Differenz wird umgehend in Rechnung gestellt.') }} + @if ($booking['resumes_cancellation'] ?? false) + {{ __('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.') }} + @endif +

+ @endif
@if ($booking['needs_address']) @@ -1008,7 +1064,22 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Abbrechen') }} - @if ($entrepreneurConfirmed) + @if ($booking['is_swap'] ?? false) + {{-- Tarifwechsel mutiert das Abo direkt (kein + Stripe-Redirect) — darum POST statt Link. --}} + @if ($entrepreneurConfirmed) +
+ @csrf + + {{ __('Kostenpflichtig wechseln') }} + +
+ @else + + {{ __('Kostenpflichtig wechseln') }} + + @endif + @elseif ($entrepreneurConfirmed) {{ __('Kostenpflichtig buchen') }} diff --git a/routes/customer.php b/routes/customer.php index 6ed342c1..de2d95b2 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -41,6 +41,9 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin Route::get('checkout/abo/{planSlug}/{interval}', [CheckoutController::class, 'subscription']) ->whereIn('interval', ['monthly', 'yearly']) ->name('checkout.subscription'); + Route::post('checkout/tarifwechsel/{planSlug}/{interval}', [CheckoutController::class, 'swap']) + ->whereIn('interval', ['monthly', 'yearly']) + ->name('checkout.swap'); Route::get('checkout/einzel-pm', [CheckoutController::class, 'singlePm']) ->name('checkout.single-pm'); Route::get('checkout/credits/{pack}', [CheckoutController::class, 'creditTopup']) diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php index 8dcd8d07..75a7d173 100644 --- a/tests/Feature/Billing/BookingsPageTest.php +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -334,3 +334,50 @@ test('selecting a new booking resets the entrepreneur confirmation checkbox', fu ->call('selectBooking', 'single_pm') ->assertSet('entrepreneurConfirmed', false); }); + +test('a subscriber sees swap buttons on other plans and the swap confirmation modal', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + BillingAddress::factory()->create(['user_id' => $user->id]); + + $currentPlan = Plan::factory()->create([ + 'name' => 'Starter', + 'slug' => 'starter', + 'stripe_price_id_monthly' => 'price_test_m_starter', + ]); + $targetPlan = Plan::factory()->create([ + 'name' => 'Business', + 'slug' => 'business', + 'stripe_price_id_monthly' => 'price_test_m_business', + ]); + subscribeUserToPlan($user, $currentPlan); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('Zu diesem Tarif wechseln') + ->assertSee('Ihr aktueller Tarif') + ->call('selectBooking', 'subscription', $targetPlan->slug, 'monthly') + ->assertSet('booking.is_swap', true) + ->assertSet('booking.url', route('me.checkout.swap', ['planSlug' => 'business', 'interval' => 'monthly'])) + ->assertSee('Tarifwechsel bestätigen') + ->assertSee('Kostenpflichtig wechseln'); +}); + +test('a swap booking for a subscriber on grace period announces the revoked cancellation', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + BillingAddress::factory()->create(['user_id' => $user->id]); + + $currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_grace']); + $targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_grace2']); + subscribeUserToPlan($user, $currentPlan); + $user->subscriptions()->first()->update(['ends_at' => now()->addMonth()]); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->call('selectBooking', 'subscription', $targetPlan->slug, 'monthly') + ->assertSet('booking.resumes_cancellation', true) + ->assertSee('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.'); +}); diff --git a/tests/Feature/Billing/CheckoutFlowTest.php b/tests/Feature/Billing/CheckoutFlowTest.php index 0028db4f..3f84b3e2 100644 --- a/tests/Feature/Billing/CheckoutFlowTest.php +++ b/tests/Feature/Billing/CheckoutFlowTest.php @@ -198,3 +198,82 @@ test('the single pm checkout creates a pending purchase and hands it to stripe', expect($purchase->status)->toBe(SinglePurchaseStatus::Pending); expect($purchase->price_cents)->toBe(1900); }); + +test('a plan swap without an active subscription bounces back to the bookings page', function () { + /** @var TestCase $this */ + $plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap1']); + + $this->actingAs(checkoutTestCustomer()) + ->post(route('me.checkout.swap', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) + ->assertRedirect(route('me.bookings.index')) + ->assertSessionHas('checkout-notice'); +}); + +test('a plan swap to the already active price is rejected with a notice', function () { + /** @var TestCase $this */ + $user = checkoutTestCustomer(); + $plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap2']); + subscribeUserToPlan($user, $plan); + + $this->mock(StripeCheckoutService::class, function ($mock) { + $mock->shouldNotReceive('swapSubscription'); + }); + + $this->actingAs($user) + ->post(route('me.checkout.swap', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) + ->assertRedirect(route('me.bookings.index')) + ->assertSessionHas('checkout-notice'); +}); + +test('a plan swap without a recent entrepreneur confirmation bounces back', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $user->assignRole('customer'); + BillingAddress::factory()->create(['user_id' => $user->id]); + + $currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap3a']); + subscribeUserToPlan($user, $currentPlan); + $targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap3b']); + + $this->actingAs($user) + ->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly'])) + ->assertRedirect(route('me.bookings.index')) + ->assertSessionHas('checkout-notice'); +}); + +test('a plan swap hands plan and interval to stripe and reports success', function () { + /** @var TestCase $this */ + $user = checkoutTestCustomer(); + $currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap4a']); + subscribeUserToPlan($user, $currentPlan); + $targetPlan = Plan::factory()->create(['stripe_price_id_yearly' => 'price_test_y_swap4b']); + + $this->mock(StripeCheckoutService::class, function ($mock) use ($targetPlan) { + $mock->shouldReceive('swapSubscription') + ->once() + ->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($targetPlan) && $interval === 'yearly'); + }); + + $this->actingAs($user) + ->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'yearly'])) + ->assertRedirect(route('me.bookings.index', ['checkout' => 'wechsel'])); +}); + +test('a failing plan swap reports a friendly notice instead of an error page', function () { + /** @var TestCase $this */ + $user = checkoutTestCustomer(); + $currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap5a']); + subscribeUserToPlan($user, $currentPlan); + $targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap5b']); + + $this->mock(StripeCheckoutService::class, function ($mock) { + $mock->shouldReceive('swapSubscription') + ->once() + ->andThrow(new RuntimeException('Stripe nicht erreichbar')); + }); + + $this->actingAs($user) + ->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly'])) + ->assertRedirect(route('me.bookings.index')) + ->assertSessionHas('checkout-notice'); +});