E3: Rechtstext-Versionierung in der DB (legal_documents)

Jede veroeffentlichte Fassung eines Rechtstexts ist eine Zeile mit
type/version/effective_from — alte Fassungen sind unloeschbar, zukuenftige
werden erst ab Stichtag wirksam (LegalDocument::current). Seeder legt die
fuenf aktuellen Fassungen an (AGB 0.6, Datenschutz 0.3, Richtlinien 0.1,
Meldeverfahren 0.1, Impressum 0.1). Die Unternehmer-Bestaetigung (T1neu)
referenziert jetzt die konkrete Fassung (legal_document_id am
Buchungsdatensatz, restrictOnDelete); AGB-Seite und Buchungs-Modal ziehen
Version/Stand aus der DB. config/legal.php bleibt reiner Fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 09:21:52 +00:00
parent dc2b2b0d7d
commit 7510e7633e
12 changed files with 294 additions and 11 deletions

View file

@ -25,6 +25,7 @@ class BookingConfirmation extends Model
'booking_type', 'booking_type',
'booking_ref', 'booking_ref',
'interval', 'interval',
'legal_document_id',
'agb_version', 'agb_version',
'ip', 'ip',
'confirmed_at', 'confirmed_at',
@ -42,6 +43,15 @@ class BookingConfirmation extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/**
* Die akzeptierte AGB-Fassung (E3: "agb_version_id" am
* Buchungsdatensatz); `agb_version` hält den Klartext redundant fest.
*/
public function legalDocument(): BelongsTo
{
return $this->belongsTo(LegalDocument::class);
}
public function scopeRecentFor(Builder $query, User $user): Builder public function scopeRecentFor(Builder $query, User $user): Builder
{ {
return $query return $query

View file

@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Database\Factories\LegalDocumentFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Veröffentlichte Fassung eines Rechtstexts (E3-Versionierung).
*
* Fassungen werden nie gelöscht oder überschrieben — eine neue Fassung ist
* eine neue Zeile mit späterem `effective_from`. `current()` liefert die
* heute gültige Fassung; config/legal.php bleibt Fallback für Umgebungen,
* in denen der Seeder (noch) nicht gelaufen ist.
*/
class LegalDocument extends Model
{
/** @use HasFactory<LegalDocumentFactory> */
use HasFactory;
public const TYPE_AGB = 'agb';
public const TYPE_DATENSCHUTZ = 'datenschutz';
public const TYPE_RICHTLINIEN = 'richtlinien';
public const TYPE_MELDEVERFAHREN = 'meldeverfahren';
public const TYPE_IMPRESSUM = 'impressum';
protected $fillable = [
'type',
'version',
'effective_from',
'note',
];
protected function casts(): array
{
return [
'effective_from' => 'date',
];
}
/**
* Die heute gültige Fassung eines Rechtstexts (jüngstes effective_from
* in der Vergangenheit; zukünftige Fassungen zählen noch nicht).
*/
public static function current(string $type): ?self
{
return static::query()
->where('type', $type)
->where('effective_from', '<=', today())
->orderByDesc('effective_from')
->orderByDesc('id')
->first();
}
/**
* Versionsstring der gültigen AGB-Fassung — mit config/legal.php als
* Fallback (Übergangslösung, bis der Seeder überall gelaufen ist).
*/
public static function currentAgbVersion(): string
{
return static::current(self::TYPE_AGB)?->version
?? (string) config('legal.agb_version');
}
public function effectiveFromFormatted(): string
{
return Carbon::parse($this->effective_from)->locale('de')->isoFormat('D. MMMM YYYY');
}
}

View file

@ -7,10 +7,10 @@ return [
| Rechtstext-Versionen (Übergangslösung bis zum legal_documents-System, E3) | Rechtstext-Versionen (Übergangslösung bis zum legal_documents-System, E3)
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Zentrale Quelle für die aktuell veröffentlichte AGB-Fassung. Wird auf | Fallback für die aktuell veröffentlichte AGB-Fassung. Verbindliche
| der AGB-Seite angezeigt und bei jeder Unternehmer-Bestätigung im | Quelle ist seit 06.07. die legal_documents-Tabelle (E3-Versionierung,
| Checkout protokolliert (booking_confirmations.agb_version, T1neu). | LegalDocument::current('agb') / LegalDocumentSeeder); diese Werte
| Bei einer neuen AGB-Fassung hier Version + Stand aktualisieren. | greifen nur, solange der Seeder noch nicht gelaufen ist.
| |
*/ */

View file

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\LegalDocument;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<LegalDocument>
*/
class LegalDocumentFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'type' => LegalDocument::TYPE_AGB,
'version' => '0.'.fake()->unique()->numberBetween(1, 999),
'effective_from' => today()->subDays(fake()->numberBetween(1, 365)),
'note' => null,
];
}
public function agb(): static
{
return $this->state(['type' => LegalDocument::TYPE_AGB]);
}
public function effectiveFrom(string $date): static
{
return $this->state(['effective_from' => $date]);
}
}

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Rechtstext-Versionierung (E3): jede veröffentlichte Fassung eines
* Rechtstexts ist eine Zeile — alte Fassungen werden nie gelöscht, damit
* protokollierte Zustimmungen (booking_confirmations) referenzierbar bleiben.
* Der Seiteninhalt selbst wird weiterhin in den Blade-Views gepflegt; die
* Tabelle ist die verbindliche Quelle für Version + Gültigkeitsbeginn.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('legal_documents', function (Blueprint $table) {
$table->id();
// agb | datenschutz | richtlinien | meldeverfahren | impressum
$table->string('type', 40);
$table->string('version', 40);
$table->date('effective_from');
// Herkunft/Anmerkung, z. B. "docs/legal AGB v0.6, anwaltsreif"
$table->string('note')->nullable();
$table->timestamps();
$table->unique(['type', 'version']);
$table->index(['type', 'effective_from']);
});
}
public function down(): void
{
Schema::dropIfExists('legal_documents');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* E3: AGB-Zustimmung referenziert die konkrete Rechtstext-Fassung
* ("agb_version_id" am Buchungsdatensatz). Der bisherige Klartext
* `agb_version` bleibt als redundante, migrationsfeste Angabe erhalten.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('booking_confirmations', function (Blueprint $table) {
// restrictOnDelete: Fassungen, auf die Zustimmungen verweisen,
// sind unlöschbar (Rechtstext-Fassungen werden ohnehin nie gelöscht).
$table->foreignId('legal_document_id')
->nullable()
->after('interval')
->constrained('legal_documents')
->restrictOnDelete();
});
}
public function down(): void
{
Schema::table('booking_confirmations', function (Blueprint $table) {
$table->dropConstrainedForeignId('legal_document_id');
});
}
};

View file

@ -21,6 +21,7 @@ class DatabaseSeeder extends Seeder
AdminPresetSeeder::class, AdminPresetSeeder::class,
PlanSeeder::class, PlanSeeder::class,
PaymentOptionSeeder::class, PaymentOptionSeeder::class,
LegalDocumentSeeder::class,
// CategorySeeder::class, // CategorySeeder::class,
]); ]);

View file

@ -0,0 +1,33 @@
<?php
namespace Database\Seeders;
use App\Models\LegalDocument;
use Illuminate\Database\Seeder;
/**
* Aktuelle Rechtstext-Fassungen (Stand 03.07.2026, Quelle docs/legal).
* Neue Fassungen werden als neue Zeilen ergänzt (nie überschreiben) —
* updateOrCreate greift über [type, version], damit der Seeder idempotent
* bleibt, ohne bestehende Fassungen zu verändern.
*/
class LegalDocumentSeeder extends Seeder
{
public function run(): void
{
$documents = [
[LegalDocument::TYPE_AGB, '0.6', '2026-07-03', 'B2B-Fassung (E2), anwaltsreif; externe Vorprüfung 11/11 bestätigt'],
[LegalDocument::TYPE_DATENSCHUTZ, '0.3', '2026-07-03', 'B2B-angepasst; host-aware Bausteine Hub/Spokes'],
[LegalDocument::TYPE_RICHTLINIEN, '0.1', '2026-07-03', 'Rahmentext; Kategorien aus rejection_categories'],
[LegalDocument::TYPE_MELDEVERFAHREN, '0.1', '2026-07-03', 'Drei Wege: DSA / DSGVO / Depublikation'],
[LegalDocument::TYPE_IMPRESSUM, '0.1', '2026-07-03', 'DDG-korrigiert, Verbund-Absatz, DSA-Kontaktpunkt'],
];
foreach ($documents as [$type, $version, $effectiveFrom, $note]) {
LegalDocument::query()->updateOrCreate(
['type' => $type, 'version' => $version],
['effective_from' => $effectiveFrom, 'note' => $note],
);
}
}
}

View file

@ -7,6 +7,7 @@ use App\Enums\UserPaymentOptionStatus;
use App\Enums\VatTreatment; use App\Enums\VatTreatment;
use App\Exceptions\InsufficientCreditsException; use App\Exceptions\InsufficientCreditsException;
use App\Models\BookingConfirmation; use App\Models\BookingConfirmation;
use App\Models\LegalDocument;
use App\Models\Plan; use App\Models\Plan;
use App\Models\UserPaymentOption; use App\Models\UserPaymentOption;
use App\Services\Billing\CreditPricingService; use App\Services\Billing\CreditPricingService;
@ -58,12 +59,15 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
return; return;
} }
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
BookingConfirmation::query()->create([ BookingConfirmation::query()->create([
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'booking_type' => $this->booking['type'], 'booking_type' => $this->booking['type'],
'booking_ref' => $this->booking['ref'], 'booking_ref' => $this->booking['ref'],
'interval' => $this->booking['interval'], 'interval' => $this->booking['interval'],
'agb_version' => (string) config('legal.agb_version'), 'legal_document_id' => $agb?->id,
'agb_version' => $agb?->version ?? (string) config('legal.agb_version'),
'ip' => request()->ip(), 'ip' => request()->ip(),
'confirmed_at' => now(), 'confirmed_at' => now(),
]); ]);
@ -991,7 +995,7 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0 ps-0"> <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.') }} {{ __('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)]"> <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')]) }} {{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }}
</a> </a>
</p> </p>
</div> </div>

View file

@ -15,10 +15,14 @@
$hubUrl = rtrim((string) config('domains.domain_portal_url', config('app.url')), '/'); $hubUrl = rtrim((string) config('domains.domain_portal_url', config('app.url')), '/');
$hubLabel = str_replace(['https://', 'http://'], '', $hubUrl); $hubLabel = str_replace(['https://', 'http://'], '', $hubUrl);
// Zentrale Versionsangabe (config/legal.php) — dieselbe Version wird bei // Verbindliche Versionsangabe aus der Rechtstext-Versionierung (E3,
// der Unternehmer-Bestätigung im Checkout protokolliert (T1neu). // legal_documents) — dieselbe Fassung wird bei der Unternehmer-
$agbVersion = (string) config('legal.agb_version'); // Bestätigung im Checkout protokolliert (T1neu). config/legal.php
$agbStand = \Illuminate\Support\Carbon::parse((string) config('legal.agb_effective_from'))->locale('de')->isoFormat('D. MMMM YYYY'); // bleibt Fallback, falls der Seeder noch nicht gelaufen ist.
$agbDocument = \App\Models\LegalDocument::current(\App\Models\LegalDocument::TYPE_AGB);
$agbVersion = $agbDocument?->version ?? (string) config('legal.agb_version');
$agbStand = $agbDocument?->effectiveFromFormatted()
?? \Illuminate\Support\Carbon::parse((string) config('legal.agb_effective_from'))->locale('de')->isoFormat('D. MMMM YYYY');
@endphp @endphp
@section('content') @section('content')

View file

@ -3,12 +3,14 @@
use App\Enums\UserPaymentOptionStatus; use App\Enums\UserPaymentOptionStatus;
use App\Models\BillingAddress; use App\Models\BillingAddress;
use App\Models\BookingConfirmation; use App\Models\BookingConfirmation;
use App\Models\LegalDocument;
use App\Models\Plan; use App\Models\Plan;
use App\Models\SinglePurchase; use App\Models\SinglePurchase;
use App\Models\User; use App\Models\User;
use App\Models\UserPaymentOption; use App\Models\UserPaymentOption;
use App\Services\Billing\CreditWalletService; use App\Services\Billing\CreditWalletService;
use App\Services\Billing\StripeCheckoutService; use App\Services\Billing\StripeCheckoutService;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\RolesAndPermissionsSeeder; use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt; use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase; use Tests\TestCase;
@ -296,6 +298,8 @@ test('checking the entrepreneur box records a booking confirmation with the agb
/** @var TestCase $this */ /** @var TestCase $this */
// T1neu (AGB v0.6 § 1 Abs. 4): Die Pflicht-Checkbox protokolliert // T1neu (AGB v0.6 § 1 Abs. 4): Die Pflicht-Checkbox protokolliert
// Zeitpunkt + AGB-Version — das B2B-Gate stützt sich auf diesen Nachweis. // Zeitpunkt + AGB-Version — das B2B-Gate stützt sich auf diesen Nachweis.
// E3: Zusätzlich wird die konkrete Rechtstext-Fassung referenziert.
$this->seed(LegalDocumentSeeder::class);
$user = bookingsTestCustomer(); $user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]); BillingAddress::factory()->create(['user_id' => $user->id]);
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm'); config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
@ -307,10 +311,12 @@ test('checking the entrepreneur box records a booking confirmation with the agb
->set('entrepreneurConfirmed', true); ->set('entrepreneurConfirmed', true);
$confirmation = BookingConfirmation::query()->sole(); $confirmation = BookingConfirmation::query()->sole();
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
expect($confirmation->user_id)->toBe($user->id); expect($confirmation->user_id)->toBe($user->id);
expect($confirmation->booking_type)->toBe('single_pm'); expect($confirmation->booking_type)->toBe('single_pm');
expect($confirmation->agb_version)->toBe((string) config('legal.agb_version')); expect($confirmation->legal_document_id)->toBe($agb->id);
expect($confirmation->agb_version)->toBe($agb->version);
expect($confirmation->confirmed_at)->not->toBeNull(); expect($confirmation->confirmed_at)->not->toBeNull();
}); });

View file

@ -0,0 +1,42 @@
<?php
use App\Models\LegalDocument;
use Database\Seeders\LegalDocumentSeeder;
use Tests\TestCase;
test('current returns the newest effective version and ignores future ones', function () {
LegalDocument::factory()->agb()->create(['version' => '0.5', 'effective_from' => '2026-06-01']);
$current = LegalDocument::factory()->agb()->create(['version' => '0.6', 'effective_from' => '2026-07-03']);
// Zukünftige Fassung: veröffentlicht, aber noch nicht wirksam.
LegalDocument::factory()->agb()->create(['version' => '0.7', 'effective_from' => now()->addMonth()->toDateString()]);
expect(LegalDocument::current(LegalDocument::TYPE_AGB)?->id)->toBe($current->id);
expect(LegalDocument::currentAgbVersion())->toBe('0.6');
});
test('the agb version falls back to the config while the table is empty', function () {
config()->set('legal.agb_version', '9.9');
expect(LegalDocument::current(LegalDocument::TYPE_AGB))->toBeNull();
expect(LegalDocument::currentAgbVersion())->toBe('9.9');
});
test('the seeder registers all five legal documents idempotently', function () {
/** @var TestCase $this */
$this->seed(LegalDocumentSeeder::class);
$this->seed(LegalDocumentSeeder::class); // Wiederholung darf nichts duplizieren.
expect(LegalDocument::query()->count())->toBe(5);
expect(LegalDocument::currentAgbVersion())->toBe('0.6');
expect(LegalDocument::current(LegalDocument::TYPE_DATENSCHUTZ)?->version)->toBe('0.3');
});
test('the agb page renders version and effective date from the database', function () {
/** @var TestCase $this */
LegalDocument::factory()->agb()->create(['version' => '0.6', 'effective_from' => '2026-07-03']);
$this->get('https://pressekonto.test/de/agb')
->assertSuccessful()
->assertSeeText('Version 0.6')
->assertSeeText('Stand: 3. Juli 2026');
});