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:
parent
dc2b2b0d7d
commit
7510e7633e
12 changed files with 294 additions and 11 deletions
|
|
@ -25,6 +25,7 @@ class BookingConfirmation extends Model
|
|||
'booking_type',
|
||||
'booking_ref',
|
||||
'interval',
|
||||
'legal_document_id',
|
||||
'agb_version',
|
||||
'ip',
|
||||
'confirmed_at',
|
||||
|
|
@ -42,6 +43,15 @@ class BookingConfirmation extends Model
|
|||
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
|
||||
{
|
||||
return $query
|
||||
|
|
|
|||
75
app/Models/LegalDocument.php
Normal file
75
app/Models/LegalDocument.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -7,10 +7,10 @@ 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.
|
||||
| Fallback für die aktuell veröffentlichte AGB-Fassung. Verbindliche
|
||||
| Quelle ist seit 06.07. die legal_documents-Tabelle (E3-Versionierung,
|
||||
| LegalDocument::current('agb') / LegalDocumentSeeder); diese Werte
|
||||
| greifen nur, solange der Seeder noch nicht gelaufen ist.
|
||||
|
|
||||
*/
|
||||
|
||||
|
|
|
|||
37
database/factories/LegalDocumentFactory.php
Normal file
37
database/factories/LegalDocumentFactory.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -21,6 +21,7 @@ class DatabaseSeeder extends Seeder
|
|||
AdminPresetSeeder::class,
|
||||
PlanSeeder::class,
|
||||
PaymentOptionSeeder::class,
|
||||
LegalDocumentSeeder::class,
|
||||
// CategorySeeder::class,
|
||||
]);
|
||||
|
||||
|
|
|
|||
33
database/seeders/LegalDocumentSeeder.php
Normal file
33
database/seeders/LegalDocumentSeeder.php
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ use App\Enums\UserPaymentOptionStatus;
|
|||
use App\Enums\VatTreatment;
|
||||
use App\Exceptions\InsufficientCreditsException;
|
||||
use App\Models\BookingConfirmation;
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\Plan;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Billing\CreditPricingService;
|
||||
|
|
@ -58,12 +59,15 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
|
|||
return;
|
||||
}
|
||||
|
||||
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
|
||||
|
||||
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'),
|
||||
'legal_document_id' => $agb?->id,
|
||||
'agb_version' => $agb?->version ?? (string) config('legal.agb_version'),
|
||||
'ip' => request()->ip(),
|
||||
'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">
|
||||
{{ __('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')]) }}
|
||||
{{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,14 @@
|
|||
$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');
|
||||
// Verbindliche Versionsangabe aus der Rechtstext-Versionierung (E3,
|
||||
// legal_documents) — dieselbe Fassung wird bei der Unternehmer-
|
||||
// Bestätigung im Checkout protokolliert (T1neu). config/legal.php
|
||||
// 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
|
||||
|
||||
@section('content')
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\BookingConfirmation;
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\Plan;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Billing\CreditWalletService;
|
||||
use App\Services\Billing\StripeCheckoutService;
|
||||
use Database\Seeders\LegalDocumentSeeder;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -296,6 +298,8 @@ test('checking the entrepreneur box records a booking confirmation with the agb
|
|||
/** @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.
|
||||
// E3: Zusätzlich wird die konkrete Rechtstext-Fassung referenziert.
|
||||
$this->seed(LegalDocumentSeeder::class);
|
||||
$user = bookingsTestCustomer();
|
||||
BillingAddress::factory()->create(['user_id' => $user->id]);
|
||||
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);
|
||||
|
||||
$confirmation = BookingConfirmation::query()->sole();
|
||||
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
|
||||
|
||||
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->legal_document_id)->toBe($agb->id);
|
||||
expect($confirmation->agb_version)->toBe($agb->version);
|
||||
expect($confirmation->confirmed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
|
|
|
|||
42
tests/Feature/Legal/LegalDocumentTest.php
Normal file
42
tests/Feature/Legal/LegalDocumentTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue