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:
parent
af156608b1
commit
b3780e0fea
6 changed files with 289 additions and 8 deletions
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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.') }}
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($checkoutResult === 'wechsel')
|
||||
<div
|
||||
class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
|
||||
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
|
||||
<div class="flex-1">
|
||||
<span
|
||||
class="font-semibold text-[color:var(--color-ink)]">{{ __('Ihr Tarifwechsel wurde durchgeführt!') }}</span>
|
||||
{{ __('Der neue Tarif gilt ab sofort. Bereits gezahlte Beträge wurden anteilig verrechnet — die Rechnung finden Sie unter Rechnungen.') }}
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($checkoutResult === 'abbruch')
|
||||
<div
|
||||
class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
|
||||
|
|
@ -523,7 +544,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
{{ __('Den passenden Tarif wählen') }}
|
||||
</h2>
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] max-w-[640px] m-0">
|
||||
{{ __('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.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -617,9 +638,33 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
|
||||
<div class="pt-2">
|
||||
@if ($subscription)
|
||||
{{-- Tarifwechsel: Swap des bestehenden Abos statt
|
||||
neuem Checkout — pro Intervall, der aktuell
|
||||
gebuchte Tarif ist deaktiviert. --}}
|
||||
<div x-show="interval === 'monthly'">
|
||||
@if ($isCurrent && $currentInterval === 'monthly')
|
||||
<flux:button variant="filled" class="w-full" disabled>
|
||||
{{ $isCurrent ? __('Ihr aktueller Tarif') : __('Wechsel über „Abo verwalten"') }}
|
||||
{{ __('Ihr aktueller Tarif') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button variant="primary" class="w-full"
|
||||
wire:click="selectBooking('subscription', '{{ $plan->slug }}', 'monthly')">
|
||||
{{ __('Zu diesem Tarif wechseln') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
<div x-show="interval === 'yearly'" x-cloak>
|
||||
@if ($isCurrent && $currentInterval === 'yearly')
|
||||
<flux:button variant="filled" class="w-full" disabled>
|
||||
{{ __('Ihr aktueller Tarif') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:button variant="primary" class="w-full"
|
||||
wire:click="selectBooking('subscription', '{{ $plan->slug }}', 'yearly')">
|
||||
{{ __('Zu diesem Tarif wechseln') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div x-show="interval === 'monthly'">
|
||||
<flux:button variant="primary" class="w-full"
|
||||
|
|
@ -930,7 +975,9 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
@if ($booking)
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Buchung bestätigen') }}</flux:heading>
|
||||
<flux:heading size="lg">
|
||||
{{ ($booking['is_swap'] ?? false) ? __('Tarifwechsel bestätigen') : __('Buchung bestätigen') }}
|
||||
</flux:heading>
|
||||
<flux:subheading>{{ $booking['title'] }}</flux:subheading>
|
||||
@if ($booking['subtitle'])
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] mt-2 m-0">{{ $booking['subtitle'] }}</p>
|
||||
|
|
@ -971,6 +1018,15 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Die endgültige Steuer berechnet Stripe anhand Ihrer Rechnungsadresse. Die Abrechnung erfolgt sicher über Stripe.') }}
|
||||
</p>
|
||||
|
||||
@if ($booking['is_swap'] ?? false)
|
||||
<p class="text-[11.5px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Der Wechsel gilt sofort: Bereits gezahlte Beträge werden anteilig gutgeschrieben, die Differenz wird umgehend in Rechnung gestellt.') }}
|
||||
@if ($booking['resumes_cancellation'] ?? false)
|
||||
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.') }}</span>
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($booking['needs_address'])
|
||||
|
|
@ -1008,7 +1064,22 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
<flux:modal.close>
|
||||
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
|
||||
</flux:modal.close>
|
||||
@if ($booking['is_swap'] ?? false)
|
||||
{{-- Tarifwechsel mutiert das Abo direkt (kein
|
||||
Stripe-Redirect) — darum POST statt Link. --}}
|
||||
@if ($entrepreneurConfirmed)
|
||||
<form method="POST" action="{{ $booking['url'] }}" class="m-0">
|
||||
@csrf
|
||||
<flux:button type="submit" variant="primary" icon="arrow-path">
|
||||
{{ __('Kostenpflichtig wechseln') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
@else
|
||||
<flux:button variant="primary" icon="arrow-path" disabled>
|
||||
{{ __('Kostenpflichtig wechseln') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
@elseif ($entrepreneurConfirmed)
|
||||
<flux:button variant="primary" icon="lock-closed" href="{{ $booking['url'] }}">
|
||||
{{ __('Kostenpflichtig buchen') }}
|
||||
</flux:button>
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue