seed(RolesAndPermissionsSeeder::class); }); function checkoutTestCustomer(): User { $user = User::factory()->create(); $user->assignRole('customer'); // Buchungs-Voraussetzung (12.06.2026): vollständige Rechnungsadresse. BillingAddress::factory()->create(['user_id' => $user->id]); // B2B-Gate (T1neu): Jeder Checkout verlangt eine zeitnah protokollierte // Unternehmer-Bestätigung (Pflicht-Checkbox im Buchungs-Modal). confirmEntrepreneur($user); return $user; } function confirmEntrepreneur(User $user): BookingConfirmation { return BookingConfirmation::query()->create([ 'user_id' => $user->id, 'booking_type' => 'subscription', 'booking_ref' => null, 'interval' => null, 'agb_version' => (string) config('legal.agb_version'), 'ip' => '127.0.0.1', 'confirmed_at' => now(), ]); } /** * Stripe-Checkout-Stub: Der Controller gibt das Checkout-Objekt zurück, * der Router ruft toResponse() — wir leiten auf eine Fake-Stripe-URL um. */ function fakeStripeCheckout(): Checkout { $checkout = Mockery::mock(Checkout::class); $checkout->shouldReceive('toResponse') ->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test')); return $checkout; } test('guests are redirected to the login', function () { /** @var TestCase $this */ $plan = Plan::factory()->create(); $this->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) ->assertRedirect(); }); test('an unknown plan or invalid interval responds 404', function () { /** @var TestCase $this */ $this->actingAs(checkoutTestCustomer()); $this->get('/admin/me/checkout/abo/gibts-nicht/monthly')->assertNotFound(); $plan = Plan::factory()->create(); $this->get("/admin/me/checkout/abo/{$plan->slug}/weekly")->assertNotFound(); }); test('a checkout without a complete billing address redirects to the profile', function () { /** @var TestCase $this */ $user = User::factory()->create(); $user->assignRole('customer'); $plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_addr']); config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm'); $this->actingAs($user) ->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) ->assertRedirect(route('me.profile')) ->assertSessionHas('checkout-notice'); $this->actingAs($user) ->get(route('me.checkout.single-pm')) ->assertRedirect(route('me.profile')) ->assertSessionHas('checkout-notice'); expect(SinglePurchase::count())->toBe(0); }); test('a checkout without a recent entrepreneur confirmation bounces back to the bookings page', function () { /** @var TestCase $this */ // B2B-Gate (T1neu): Rechnungsadresse vorhanden, aber keine (bzw. nur eine // abgelaufene) Unternehmer-Bestätigung → kein Stripe-Checkout. $user = User::factory()->create(); $user->assignRole('customer'); BillingAddress::factory()->create(['user_id' => $user->id]); $plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_b2b']); config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm'); $this->actingAs($user) ->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); // Abgelaufene Bestätigung zählt nicht. confirmEntrepreneur($user)->update(['confirmed_at' => now()->subMinutes(BookingConfirmation::VALID_MINUTES + 1)]); $this->actingAs($user) ->get(route('me.checkout.single-pm')) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); expect(SinglePurchase::count())->toBe(0); }); test('a plan without a synced stripe price redirects back with a notice', function () { /** @var TestCase $this */ $plan = Plan::factory()->create(['stripe_price_id_monthly' => null]); $this->actingAs(checkoutTestCustomer()) ->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); }); test('an already subscribed user is redirected instead of double-booking', function () { /** @var TestCase $this */ $user = checkoutTestCustomer(); $plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_1']); subscribeUserToPlan($user, $plan); $this->actingAs($user) ->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); }); test('the subscription checkout hands plan and interval to stripe', function () { /** @var TestCase $this */ $user = checkoutTestCustomer(); $plan = Plan::factory()->create(['stripe_price_id_yearly' => 'price_test_y_1']); $this->mock(StripeCheckoutService::class, function ($mock) use ($plan) { $mock->shouldReceive('forSubscription') ->once() ->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($plan) && $interval === 'yearly') ->andReturn(fakeStripeCheckout()); }); $this->actingAs($user) ->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'yearly'])) ->assertRedirect('https://checkout.stripe.com/c/pay/test'); }); test('the single pm checkout is disabled without a configured stripe price', function () { /** @var TestCase $this */ config()->set('billing.single_pm_stripe_price_id', null); $this->actingAs(checkoutTestCustomer()) ->get(route('me.checkout.single-pm')) ->assertRedirect(route('me.bookings.index')) ->assertSessionHas('checkout-notice'); expect(SinglePurchase::count())->toBe(0); }); test('the single pm checkout creates a pending purchase and hands it to stripe', function () { /** @var TestCase $this */ config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm'); config()->set('billing.single_pm_price_cents', 1900); $user = checkoutTestCustomer(); $this->mock(StripeCheckoutService::class, function ($mock) use ($user) { $mock->shouldReceive('forSinglePurchase') ->once() ->withArgs(fn (User $u, SinglePurchase $p) => $u->is($user) && $p->exists) ->andReturn(fakeStripeCheckout()); }); $this->actingAs($user) ->get(route('me.checkout.single-pm')) ->assertRedirect('https://checkout.stripe.com/c/pay/test'); $purchase = SinglePurchase::sole(); expect($purchase->user_id)->toBe($user->id); expect($purchase->type)->toBe(SinglePurchaseType::SinglePm); 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('isDowngrade')->once()->andReturnFalse(); $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('isDowngrade')->once()->andReturnFalse(); $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'); }); test('a downgrade swap is scheduled for the end of the paid period instead of running immediately', function () { /** @var TestCase $this */ $user = checkoutTestCustomer(); $currentPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap6a']); subscribeUserToPlan($user, $currentPlan); $targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_swap6b']); $this->mock(StripeCheckoutService::class, function ($mock) use ($targetPlan) { $mock->shouldReceive('isDowngrade')->once()->andReturnTrue(); $mock->shouldReceive('scheduleDowngrade') ->once() ->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($targetPlan) && $interval === 'monthly') ->andReturn(now()->addDays(12)); $mock->shouldNotReceive('swapSubscription'); }); $this->actingAs($user) ->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly'])) ->assertRedirect(route('me.bookings.index', ['checkout' => 'wechsel-geplant'])); }); test('the downgrade classification compares the period price of the booked interval', function ( string $currentInterval, int $currentMonthly, int $currentYearly, string $targetInterval, int $targetMonthly, int $targetYearly, bool $expected, ) { /** @var TestCase $this */ $user = checkoutTestCustomer(); $currentPlan = Plan::factory()->create([ 'monthly_price_cents' => $currentMonthly, 'yearly_price_cents' => $currentYearly, 'stripe_price_id_monthly' => 'price_test_m_cls_cur', 'stripe_price_id_yearly' => 'price_test_y_cls_cur', ]); subscribeUserToPlan($user, $currentPlan, $currentInterval); $targetPlan = Plan::factory()->create([ 'monthly_price_cents' => $targetMonthly, 'yearly_price_cents' => $targetYearly, 'stripe_price_id_monthly' => 'price_test_m_cls_tgt', 'stripe_price_id_yearly' => 'price_test_y_cls_tgt', ]); expect(app(StripeCheckoutService::class)->isDowngrade($user, $targetPlan, $targetInterval)) ->toBe($expected); })->with([ 'günstigerer Monatstarif' => ['monthly', 5900, 59000, 'monthly', 1900, 19000, true], 'teurerer Monatstarif' => ['monthly', 1900, 19000, 'monthly', 5900, 59000, false], 'Monats- auf Jahresabo (Jahrespreis über Monatspreis)' => ['monthly', 5900, 59000, 'yearly', 1900, 19000, false], 'Jahres- auf Monatsabo' => ['yearly', 1900, 19000, 'monthly', 5900, 59000, true], ]); test('an unknown current price is treated as a downgrade and therefore never invoiced immediately', function () { /** @var TestCase $this */ $user = checkoutTestCustomer(); // Abo mit Alt-Preis, der in keinem aktiven Tarif hinterlegt ist. $user->subscriptions()->create([ 'type' => 'default', 'stripe_id' => 'sub_test_legacy_price', 'stripe_status' => 'active', 'stripe_price' => 'price_legacy_unknown', 'quantity' => 1, ]); $targetPlan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_cls_unknown']); expect(app(StripeCheckoutService::class)->isDowngrade($user, $targetPlan, 'monthly'))->toBeTrue(); });