diff --git a/app/Models/BookingConfirmation.php b/app/Models/BookingConfirmation.php index 170b4ca3..40263f0d 100644 --- a/app/Models/BookingConfirmation.php +++ b/app/Models/BookingConfirmation.php @@ -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 diff --git a/app/Models/LegalDocument.php b/app/Models/LegalDocument.php new file mode 100644 index 00000000..459ada01 --- /dev/null +++ b/app/Models/LegalDocument.php @@ -0,0 +1,75 @@ + */ + 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'); + } +} diff --git a/config/legal.php b/config/legal.php index d3c77648..9cc472fb 100644 --- a/config/legal.php +++ b/config/legal.php @@ -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. | */ diff --git a/database/factories/LegalDocumentFactory.php b/database/factories/LegalDocumentFactory.php new file mode 100644 index 00000000..cb8b4698 --- /dev/null +++ b/database/factories/LegalDocumentFactory.php @@ -0,0 +1,37 @@ + + */ +class LegalDocumentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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]); + } +} diff --git a/database/migrations/2026_07_06_111754_create_legal_documents_table.php b/database/migrations/2026_07_06_111754_create_legal_documents_table.php new file mode 100644 index 00000000..8fe358e9 --- /dev/null +++ b/database/migrations/2026_07_06_111754_create_legal_documents_table.php @@ -0,0 +1,38 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_06_111755_add_legal_document_id_to_booking_confirmations_table.php b/database/migrations/2026_07_06_111755_add_legal_document_id_to_booking_confirmations_table.php new file mode 100644 index 00000000..09e3f6bc --- /dev/null +++ b/database/migrations/2026_07_06_111755_add_legal_document_id_to_booking_confirmations_table.php @@ -0,0 +1,33 @@ +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'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 26afb5ed..f19346db 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,6 +21,7 @@ class DatabaseSeeder extends Seeder AdminPresetSeeder::class, PlanSeeder::class, PaymentOptionSeeder::class, + LegalDocumentSeeder::class, // CategorySeeder::class, ]); diff --git a/database/seeders/LegalDocumentSeeder.php b/database/seeders/LegalDocumentSeeder.php new file mode 100644 index 00000000..e1144c69 --- /dev/null +++ b/database/seeders/LegalDocumentSeeder.php @@ -0,0 +1,33 @@ +updateOrCreate( + ['type' => $type, 'version' => $version], + ['effective_from' => $effectiveFrom, 'note' => $note], + ); + } + } +} diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 36a70484..a872b44a 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -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

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

diff --git a/resources/views/web/agb.blade.php b/resources/views/web/agb.blade.php index e4319fbe..0435750a 100644 --- a/resources/views/web/agb.blade.php +++ b/resources/views/web/agb.blade.php @@ -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') diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php index db468b4b..8dcd8d07 100644 --- a/tests/Feature/Billing/BookingsPageTest.php +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -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(); }); diff --git a/tests/Feature/Legal/LegalDocumentTest.php b/tests/Feature/Legal/LegalDocumentTest.php new file mode 100644 index 00000000..78a0f87e --- /dev/null +++ b/tests/Feature/Legal/LegalDocumentTest.php @@ -0,0 +1,42 @@ +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'); +});