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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-07 12:58:32 +00:00
parent af156608b1
commit b3780e0fea
6 changed files with 289 additions and 8 deletions

View file

@ -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()) {