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)
+
+ @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');
+});