T1neu: Unternehmer-Pflicht-Checkbox im Checkout, protokolliert mit AGB-Version

B2B-Gate (AGB v0.6 par. 1 Abs. 4): Das Buchungs-Modal verlangt vor jedem
Stripe-Checkout die Bestaetigung 'Ich bestelle in Ausuebung meiner
gewerblichen oder selbstaendigen beruflichen Taetigkeit (par. 14 BGB)'.

- booking_confirmations: user, Buchungstyp/-ref, AGB-Version, IP,
  confirmed_at (Nachweis laut Datenschutz Abschnitt 7)
- Checkout-Link erscheint erst nach der Checkbox; CheckoutController
  verlangt zusaetzlich serverseitig eine zeitnahe Bestaetigung (30 Min.)
  fuer Abo/Einzel-PM/Credit-Topup — Direktaufrufe laufen ins Leere
- config/legal.php als zentrale AGB-Versionsquelle (Seite + Protokoll,
  Uebergangsloesung bis zum legal_documents-System E3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-03 16:17:59 +00:00
parent 99ab47ba1f
commit 8916bcce7c
9 changed files with 311 additions and 7 deletions

View file

@ -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

View file

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Protokollierte Unternehmer-Bestätigung vor einem Stripe-Checkout
* (T1neu, AGB v0.6 § 1 Abs. 4): Nachweis, dass der Kunde bei der Buchung
* bestätigt hat, als Unternehmer zu handeln — samt akzeptierter AGB-Version.
*/
class BookingConfirmation extends Model
{
/**
* Gültigkeitsfenster: Der Checkout muss zeitnah nach der Bestätigung
* starten, sonst wird sie erneut verlangt (Direktaufrufe der
* Checkout-Routen ohne Modal-Bestätigung laufen so ins Leere).
*/
public const VALID_MINUTES = 30;
protected $fillable = [
'user_id',
'booking_type',
'booking_ref',
'interval',
'agb_version',
'ip',
'confirmed_at',
];
protected function casts(): array
{
return [
'confirmed_at' => '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));
}
}

20
config/legal.php Normal file
View file

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Rechtstext-Versionen (Übergangslösung bis zum legal_documents-System, E3)
|--------------------------------------------------------------------------
|
| Zentrale Quelle für die aktuell veröffentlichte AGB-Fassung. Wird auf
| der AGB-Seite angezeigt und bei jeder Unternehmer-Bestätigung im
| Checkout protokolliert (booking_confirmations.agb_version, T1neu).
| Bei einer neuen AGB-Fassung hier Version + Stand aktualisieren.
|
*/
'agb_version' => '0.6',
'agb_effective_from' => '2026-07-03',
];

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Unternehmer-Bestätigung je Buchungsvorgang (T1neu, AGB v0.6 § 1 Abs. 4):
* Der Kunde bestätigt vor dem Stripe-Checkout, in Ausübung seiner
* gewerblichen oder selbständigen beruflichen Tätigkeit zu handeln.
* Protokolliert werden Zeitpunkt, IP und die akzeptierte AGB-Version —
* das B2B-Gate stützt sich auf diesen Nachweis.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('booking_confirmations', function (Blueprint $table) {
$table->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');
}
};

View file

@ -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
</flux:button>
</div>
@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. --}}
<div class="space-y-2">
<flux:switch
align="left"
wire:model.live="entrepreneurConfirmed"
:label="__('Ich bestelle in Ausübung meiner gewerblichen oder selbständigen beruflichen Tätigkeit (§ 14 BGB) und akzeptiere die AGB.')"
/>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0 ps-0">
{{ __('Das Angebot richtet sich ausschließlich an Unternehmen, Selbstständige und Agenturen.') }}
<a href="{{ route('agb', ['edition' => 'de']) }}" target="_blank" rel="noopener" class="underline underline-offset-2 hover:text-[color:var(--color-ink)]">
{{ __('AGB (Version :version)', ['version' => config('legal.agb_version')]) }}
</a>
</p>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" icon="lock-closed" href="{{ $booking['url'] }}">
{{ __('Kostenpflichtig buchen') }}
</flux:button>
@if ($entrepreneurConfirmed)
<flux:button variant="primary" icon="lock-closed" href="{{ $booking['url'] }}">
{{ __('Kostenpflichtig buchen') }}
</flux:button>
@else
<flux:button variant="primary" icon="lock-closed" disabled>
{{ __('Kostenpflichtig buchen') }}
</flux:button>
@endif
</div>
@endif
</div>

View file

@ -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 }}"
>
<div class="pm-body max-w-[760px]">
<h2>§ 1 Geltungsbereich und Vertragspartner</h2>
@ -443,7 +448,7 @@
<hr>
<p>
<strong>Stand:</strong> 3. Juli 2026 · <strong>Version:</strong> 0.6<br>
<strong>Stand:</strong> {{ $agbStand }} · <strong>Version:</strong> {{ $agbVersion }}<br>
Diese AGB gelten für Buchungen über pressekonto ({{ $hubLabel }}) mit Veröffentlichung auf
presseecho.de und businessportal24.com.
</p>

View file

@ -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);
});

View file

@ -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]);

View file

@ -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);