diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 98b7f399..a9f4cbf7 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseType; +use App\Models\BookingConfirmation; use App\Models\Plan; use App\Models\SinglePurchase; use App\Services\Billing\CreditTopupService; @@ -33,6 +34,10 @@ class CheckoutController extends Controller return $this->backToProfile(); } + if ($redirect = $this->requireEntrepreneurConfirmation($request)) { + return $redirect; + } + if ($user->subscribed()) { return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.')); } @@ -54,6 +59,10 @@ class CheckoutController extends Controller return $this->backToProfile(); } + if ($redirect = $this->requireEntrepreneurConfirmation($request)) { + return $redirect; + } + if (! config('billing.single_pm_stripe_price_id')) { return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.')); } @@ -80,6 +89,10 @@ class CheckoutController extends Controller return $this->backToProfile(); } + if ($redirect = $this->requireEntrepreneurConfirmation($request)) { + return $redirect; + } + if (! $topups->findPack($pack)) { return $this->backToBookings(__('Dieses Credit-Paket ist nicht verfügbar.')); } @@ -111,6 +124,23 @@ class CheckoutController extends Controller ->with('checkout-notice', $notice); } + /** + * B2B-Gate (T1neu, AGB v0.6 § 1 Abs. 4): Jeder Stripe-Checkout setzt eine + * zeitnah protokollierte Unternehmer-Bestätigung voraus (Pflicht-Checkbox + * im Buchungs-Modal). Direktaufrufe der Checkout-Routen ohne Bestätigung + * werden auf die Buchungsseite zurückgeleitet. + */ + private function requireEntrepreneurConfirmation(Request $request): ?RedirectResponse + { + if (BookingConfirmation::query()->recentFor($request->user())->exists()) { + return null; + } + + return $this->backToBookings( + __('Bitte bestätigen Sie im Buchungsdialog, dass Sie als Unternehmer bestellen — das Angebot richtet sich ausschließlich an Unternehmen, Selbstständige und Agenturen.') + ); + } + /** * Buchungs-Voraussetzung (12.06.2026): ohne vollständige * Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf diff --git a/app/Models/BookingConfirmation.php b/app/Models/BookingConfirmation.php new file mode 100644 index 00000000..170b4ca3 --- /dev/null +++ b/app/Models/BookingConfirmation.php @@ -0,0 +1,51 @@ + 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeRecentFor(Builder $query, User $user): Builder + { + return $query + ->where('user_id', $user->id) + ->where('confirmed_at', '>=', now()->subMinutes(self::VALID_MINUTES)); + } +} diff --git a/config/legal.php b/config/legal.php new file mode 100644 index 00000000..703853ad --- /dev/null +++ b/config/legal.php @@ -0,0 +1,20 @@ + '0.6', + 'agb_effective_from' => '2026-07-03', + +]; diff --git a/database/migrations/2026_07_03_180922_create_booking_confirmations_table.php b/database/migrations/2026_07_03_180922_create_booking_confirmations_table.php new file mode 100644 index 00000000..88c5995b --- /dev/null +++ b/database/migrations/2026_07_03_180922_create_booking_confirmations_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + // subscription | single_pm | credit_pack + $table->string('booking_type', 40); + // Plan-Slug bzw. Pack-Key; null bei Einzel-PM. + $table->string('booking_ref', 80)->nullable(); + $table->string('interval', 20)->nullable(); + $table->string('agb_version', 40); + $table->string('ip', 45)->nullable(); + $table->timestamp('confirmed_at'); + $table->timestamps(); + + $table->index(['user_id', 'confirmed_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('booking_confirmations'); + } +}; diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 021d1d81..4a66d58e 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -6,6 +6,7 @@ use App\Enums\Tier; use App\Enums\UserPaymentOptionStatus; use App\Enums\VatTreatment; use App\Exceptions\InsufficientCreditsException; +use App\Models\BookingConfirmation; use App\Models\Plan; use App\Models\UserPaymentOption; use App\Services\Billing\CreditPricingService; @@ -33,11 +34,41 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte /** Im Bestätigungs-Modal angezeigte Buchung (inkl. USt.-Aufschlüsselung). */ public ?array $booking = null; + /** + * Unternehmer-Pflicht-Checkbox (T1neu, AGB v0.6 § 1 Abs. 4): erst nach + * der Bestätigung wird der Checkout-Link angeboten; die Bestätigung + * selbst wird mit Timestamp + AGB-Version protokolliert. + */ + public bool $entrepreneurConfirmed = false; + public function formatEuro(int $cents): string { return number_format($cents / 100, $cents % 100 === 0 ? 0 : 2, ',', '.') . ' €'; } + /** + * Protokolliert die Unternehmer-Bestätigung (T1neu) in dem Moment, in dem + * die Pflicht-Checkbox gesetzt wird — mit Zeitpunkt, IP und der aktuell + * veröffentlichten AGB-Version. Der Checkout-Controller verlangt eine + * zeitnahe Bestätigung, Direktaufrufe ohne Checkbox laufen ins Leere. + */ + public function updatedEntrepreneurConfirmed(bool $value): void + { + if (! $value || ! $this->booking) { + return; + } + + BookingConfirmation::query()->create([ + 'user_id' => auth()->id(), + 'booking_type' => $this->booking['type'], + 'booking_ref' => $this->booking['ref'], + 'interval' => $this->booking['interval'], + 'agb_version' => (string) config('legal.agb_version'), + 'ip' => request()->ip(), + 'confirmed_at' => now(), + ]); + } + /** * Öffnet das Bestätigungs-Modal für eine Buchung (Tarif-Abo, Einzel-PM * oder Credit-Paket) mit USt.-Aufschlüsselung anhand der Rechnungsadresse. @@ -68,6 +99,8 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte $vatCents = $resolver->taxCentsFor($netCents, $treatment); $this->booking = [ + 'type' => $type, + 'ref' => $ref, 'title' => $title, 'subtitle' => $subtitle, 'interval' => $interval, @@ -81,6 +114,9 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte 'needs_address' => ! $user->hasCompleteBillingAddress(), ]; + // Jede Buchung verlangt eine frische Unternehmer-Bestätigung. + $this->entrepreneurConfirmed = false; + Flux::modal('confirm-booking')->show(); } @@ -942,13 +978,37 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte @else + {{-- Unternehmer-Pflicht-Checkbox (T1neu, AGB § 1 Abs. 4): + Bestätigung wird serverseitig protokolliert (Timestamp, + IP, AGB-Version); ohne sie kein Checkout-Link — und der + Checkout-Controller verlangt sie zusätzlich. --}} +
+ +

+ {{ __('Das Angebot richtet sich ausschließlich an Unternehmen, Selbstständige und Agenturen.') }} + + {{ __('AGB (Version :version)', ['version' => config('legal.agb_version')]) }} + +

+
+
{{ __('Abbrechen') }} - - {{ __('Kostenpflichtig buchen') }} - + @if ($entrepreneurConfirmed) + + {{ __('Kostenpflichtig buchen') }} + + @else + + {{ __('Kostenpflichtig buchen') }} + + @endif
@endif diff --git a/resources/views/web/agb.blade.php b/resources/views/web/agb.blade.php index ce3c4d54..4e6b72dc 100644 --- a/resources/views/web/agb.blade.php +++ b/resources/views/web/agb.blade.php @@ -14,6 +14,11 @@ $contactEmail = $brand['contact_email'] ?? 'info@businessportal24.com'; $hubUrl = rtrim((string) config('domains.domain_portal_url', config('app.url')), '/'); $hubLabel = str_replace(['https://', 'http://'], '', $hubUrl); + + // Zentrale Versionsangabe (config/legal.php) — dieselbe Version wird bei + // der Unternehmer-Bestätigung im Checkout protokolliert (T1neu). + $agbVersion = (string) config('legal.agb_version'); + $agbStand = \Illuminate\Support\Carbon::parse((string) config('legal.agb_effective_from'))->locale('de')->isoFormat('D. MMMM YYYY'); @endphp @section('content') @@ -21,7 +26,7 @@ eyebrow="Rechtliches" title="Allgemeine Geschäftsbedingungen" breadcrumb="AGB" - updated="Stand: 3. Juli 2026 · Version 0.6" + updated="Stand: {{ $agbStand }} · Version {{ $agbVersion }}" >

§ 1 Geltungsbereich und Vertragspartner

@@ -443,7 +448,7 @@

- Stand: 3. Juli 2026 · Version: 0.6
+ Stand: {{ $agbStand }} · Version: {{ $agbVersion }}
Diese AGB gelten für Buchungen über pressekonto ({{ $hubLabel }}) mit Veröffentlichung auf presseecho.de und businessportal24.com.

diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php index acff1091..db468b4b 100644 --- a/tests/Feature/Billing/BookingsPageTest.php +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -2,6 +2,7 @@ use App\Enums\UserPaymentOptionStatus; use App\Models\BillingAddress; +use App\Models\BookingConfirmation; use App\Models\Plan; use App\Models\SinglePurchase; use App\Models\User; @@ -49,8 +50,11 @@ test('the bookings page renders the active plans with checkout links', function ->assertSee('max. 2 Veröffentlichungen pro Tag') ->assertSee('2 Monate gratis') ->assertDontSee('Versteckt') - // Buchung läuft über das Bestätigungs-Modal (selectBooking → Checkout-URL). + // Buchung läuft über das Bestätigungs-Modal; der Checkout-Link + // erscheint erst nach der Unternehmer-Bestätigung (T1neu). ->call('selectBooking', 'subscription', 'business', 'monthly') + ->assertDontSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'monthly']), false) + ->set('entrepreneurConfirmed', true) ->assertSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'monthly']), false); }); @@ -86,6 +90,7 @@ test('the single pm block links to its checkout', function () { ->call('selectBooking', 'single_pm') ->assertSee('Buchung bestätigen') ->assertSee('zzgl. 19 % USt.') + ->set('entrepreneurConfirmed', true) ->assertSee(route('me.checkout.single-pm'), false); }); @@ -207,8 +212,9 @@ test('the wallet block shows the balance and the credit pack topup links', funct ->assertSee('Preisliste') ->assertSee('Was kostet wie viel') ->assertDontSee('Prüfkontingent') // Phase 2 — vorerst ausgeblendet - // Paket-Kauf läuft über das Bestätigungs-Modal. + // Paket-Kauf läuft über das Bestätigungs-Modal (inkl. T1neu-Checkbox). ->call('selectBooking', 'credit_pack', 'p25') + ->set('entrepreneurConfirmed', true) ->assertSee(route('me.checkout.credit-topup', ['pack' => 'p25']), false); }); @@ -252,6 +258,8 @@ test('with a billing address the banner is gone and the booking modal offers the ->assertDontSee('Rechnungsadresse fehlt') ->call('selectBooking', 'single_pm') ->assertSee('Kostenpflichtig buchen') + ->assertSee('gewerblichen oder selbständigen beruflichen Tätigkeit') + ->set('entrepreneurConfirmed', true) ->assertSee(route('me.checkout.single-pm'), false); }); @@ -283,3 +291,40 @@ test('buying an extra pm without enough credits shows the mini checkout hint', f expect($user->singlePurchases()->count())->toBe(0); }); + +test('checking the entrepreneur box records a booking confirmation with the agb version', function () { + /** @var TestCase $this */ + // T1neu (AGB v0.6 § 1 Abs. 4): Die Pflicht-Checkbox protokolliert + // Zeitpunkt + AGB-Version — das B2B-Gate stützt sich auf diesen Nachweis. + $user = bookingsTestCustomer(); + BillingAddress::factory()->create(['user_id' => $user->id]); + config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm'); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->call('selectBooking', 'single_pm') + ->set('entrepreneurConfirmed', true); + + $confirmation = BookingConfirmation::query()->sole(); + + expect($confirmation->user_id)->toBe($user->id); + expect($confirmation->booking_type)->toBe('single_pm'); + expect($confirmation->agb_version)->toBe((string) config('legal.agb_version')); + expect($confirmation->confirmed_at)->not->toBeNull(); +}); + +test('selecting a new booking resets the entrepreneur confirmation checkbox', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + BillingAddress::factory()->create(['user_id' => $user->id]); + config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm'); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->call('selectBooking', 'single_pm') + ->set('entrepreneurConfirmed', true) + ->call('selectBooking', 'single_pm') + ->assertSet('entrepreneurConfirmed', false); +}); diff --git a/tests/Feature/Billing/CheckoutFlowTest.php b/tests/Feature/Billing/CheckoutFlowTest.php index a22a559b..0028db4f 100644 --- a/tests/Feature/Billing/CheckoutFlowTest.php +++ b/tests/Feature/Billing/CheckoutFlowTest.php @@ -3,6 +3,7 @@ use App\Enums\SinglePurchaseStatus; use App\Enums\SinglePurchaseType; use App\Models\BillingAddress; +use App\Models\BookingConfirmation; use App\Models\Plan; use App\Models\SinglePurchase; use App\Models\User; @@ -25,9 +26,26 @@ function checkoutTestCustomer(): User // 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. @@ -79,6 +97,32 @@ test('a checkout without a complete billing address redirects to the profile', f 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]); diff --git a/tests/Feature/CreditTopupTest.php b/tests/Feature/CreditTopupTest.php index 8883db09..656ca7f6 100644 --- a/tests/Feature/CreditTopupTest.php +++ b/tests/Feature/CreditTopupTest.php @@ -3,6 +3,7 @@ use App\Enums\SinglePurchaseStatus; use App\Listeners\ProcessStripeWebhook; use App\Models\BillingAddress; +use App\Models\BookingConfirmation; use App\Models\User; use App\Services\Billing\CreditTopupService; use App\Services\Billing\CreditWalletService; @@ -91,6 +92,15 @@ test('the topup checkout route starts a stripe checkout with a complete billing $user->assignRole('customer'); BillingAddress::factory()->create(['user_id' => $user->id]); + // B2B-Gate (T1neu): Checkout setzt eine zeitnahe Unternehmer-Bestätigung voraus. + BookingConfirmation::query()->create([ + 'user_id' => $user->id, + 'booking_type' => 'credit_pack', + 'booking_ref' => 'p25', + 'agb_version' => (string) config('legal.agb_version'), + 'confirmed_at' => now(), + ]); + $checkout = Mockery::mock(Checkout::class); $checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test')); $mock = Mockery::mock(StripeCheckoutService::class);