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. --}}
+
+
{{ __('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);