diff --git a/app/Http/Controllers/Web/WithdrawalController.php b/app/Http/Controllers/Web/WithdrawalController.php new file mode 100644 index 00000000..7147ab89 --- /dev/null +++ b/app/Http/Controllers/Web/WithdrawalController.php @@ -0,0 +1,93 @@ +input('website')) || filled($request->input('homepage'))) { + return $this->done(); + } + + // Eigener Error-Bag, damit sich Widerrufs- und Kontakt-Modal auf + // derselben Seite nicht gegenseitig öffnen. + $validated = $request->validateWithBag('withdrawal', [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'order_reference' => ['nullable', 'string', 'max:255'], + ]); + + $this->ensureNotRateLimited($request); + RateLimiter::hit($this->throttleKey($request), 3600); + + $withdrawalRequest = WithdrawalRequest::query()->create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'order_reference' => $validated['order_reference'] ?? null, + 'host' => $request->getHost(), + 'requester_ip' => $request->ip(), + ]); + + // Gesetzlich vorgeschrieben: sofortige automatische Eingangsbestätigung. + Mail::to($withdrawalRequest->email)->send(new WithdrawalRequestReceived($withdrawalRequest)); + Mail::to($this->operatorRecipient())->send(new WithdrawalRequestSubmitted($withdrawalRequest)); + + return $this->done(); + } + + private function done(): RedirectResponse + { + return back()->with('withdrawal-status', __('Ihr Widerruf ist bei uns eingegangen. Sie erhalten umgehend eine automatische Eingangsbestätigung per E-Mail.')); + } + + private function operatorRecipient(): string + { + if (str_contains(request()->getHost(), 'pressekonto')) { + $brand = config('domains.domains.pressekonto.brand', []); + } else { + $brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? []; + } + + return $brand['contact_email'] ?? config('mail.from.address'); + } + + private function ensureNotRateLimited(Request $request): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) { + return; + } + + $seconds = RateLimiter::availableIn($this->throttleKey($request)); + + throw ValidationException::withMessages([ + 'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [ + 'minutes' => max(1, (int) ceil($seconds / 60)), + ]), + ])->errorBag('withdrawal'); + } + + private function throttleKey(Request $request): string + { + return 'withdrawal-form|'.$request->ip(); + } +} diff --git a/app/Mail/WithdrawalRequestReceived.php b/app/Mail/WithdrawalRequestReceived.php new file mode 100644 index 00000000..580abbe4 --- /dev/null +++ b/app/Mail/WithdrawalRequestReceived.php @@ -0,0 +1,35 @@ +withdrawalRequest->created_at->format('d.m.Y'), + ); + } + + public function content(): Content + { + return new Content(view: 'emails.withdrawal.received'); + } +} diff --git a/app/Mail/WithdrawalRequestSubmitted.php b/app/Mail/WithdrawalRequestSubmitted.php new file mode 100644 index 00000000..7864fe18 --- /dev/null +++ b/app/Mail/WithdrawalRequestSubmitted.php @@ -0,0 +1,34 @@ +withdrawalRequest->id.' über '.$this->withdrawalRequest->host, + ); + } + + public function content(): Content + { + return new Content(view: 'emails.withdrawal.submitted'); + } +} diff --git a/app/Models/WithdrawalRequest.php b/app/Models/WithdrawalRequest.php new file mode 100644 index 00000000..c97169f6 --- /dev/null +++ b/app/Models/WithdrawalRequest.php @@ -0,0 +1,35 @@ + 'datetime', + ]; + } + + public function scopeOpen(Builder $query): Builder + { + return $query->where('status', 'open'); + } +} diff --git a/database/migrations/2026_07_03_115818_create_withdrawal_requests_table.php b/database/migrations/2026_07_03_115818_create_withdrawal_requests_table.php new file mode 100644 index 00000000..5aa13adc --- /dev/null +++ b/database/migrations/2026_07_03_115818_create_withdrawal_requests_table.php @@ -0,0 +1,42 @@ +id(); + + $table->string('name'); + $table->string('email'); + $table->string('order_reference')->nullable(); + + $table->string('host')->nullable(); + $table->string('requester_ip', 45)->nullable(); + + $table->string('status', 40)->default('open'); + $table->timestamp('resolved_at')->nullable(); + + $table->timestamps(); + + $table->index(['status', 'created_at']); + $table->index('email'); + }); + } + + public function down(): void + { + Schema::dropIfExists('withdrawal_requests'); + } +}; diff --git a/resources/views/emails/withdrawal/received.blade.php b/resources/views/emails/withdrawal/received.blade.php new file mode 100644 index 00000000..22f97519 --- /dev/null +++ b/resources/views/emails/withdrawal/received.blade.php @@ -0,0 +1,46 @@ + + + + + Eingangsbestätigung Ihres Widerrufs + + + +

Guten Tag {{ $withdrawalRequest->name }},

+ +

+ hiermit bestätigen wir den Eingang Ihres Widerrufs. Ihre Erklärung ist bei uns + am {{ $withdrawalRequest->created_at->format('d.m.Y') }} + um {{ $withdrawalRequest->created_at->format('H:i') }} Uhr eingegangen. +

+ +
+
Name
+
{{ $withdrawalRequest->name }}
+
E-Mail
+
{{ $withdrawalRequest->email }}
+ @if ($withdrawalRequest->order_reference) +
Bestell-/Rechnungsnummer
+
{{ $withdrawalRequest->order_reference }}
+ @endif +
Vorgangsnummer
+
W-{{ $withdrawalRequest->id }}
+
+ +

+ Wir ordnen Ihren Widerruf dem zugehörigen Vertrag zu und melden uns + schnellstmöglich mit den weiteren Schritten (z. B. zur Rückabwicklung) bei Ihnen. +

+ +

+ Diese Bestätigung wurde automatisch erstellt und dokumentiert den Zeitpunkt + des Eingangs Ihrer Widerrufserklärung. Bitte bewahren Sie sie auf. +

+ + diff --git a/resources/views/emails/withdrawal/submitted.blade.php b/resources/views/emails/withdrawal/submitted.blade.php new file mode 100644 index 00000000..b9ade2f8 --- /dev/null +++ b/resources/views/emails/withdrawal/submitted.blade.php @@ -0,0 +1,36 @@ + + + + + Neuer Online-Widerruf + + + +

Über den Widerrufsbutton auf {{ $withdrawalRequest->host }} ist ein neuer Widerruf eingegangen:

+ +
+
Vorgangsnummer
+
W-{{ $withdrawalRequest->id }}
+
Name
+
{{ $withdrawalRequest->name }}
+
E-Mail
+
{{ $withdrawalRequest->email }}
+
Bestell-/Rechnungsnummer
+
{{ $withdrawalRequest->order_reference ?: '— nicht angegeben —' }}
+
Eingegangen
+
{{ $withdrawalRequest->created_at->format('d.m.Y H:i') }} Uhr
+
+ +

+ Die Eingangsbestätigung an den Verbraucher wurde automatisch versendet. + Bitte den Vorgang dem Vertrag zuordnen und die Rückabwicklung anstoßen + (gesetzliche Frist beachten). +

+ + diff --git a/routes/legal.php b/routes/legal.php index cf179239..8e3509b7 100644 --- a/routes/legal.php +++ b/routes/legal.php @@ -1,6 +1,7 @@ middleware('throttle:10,1') ->name('legal-request.store'); + +// Gesetzlicher Widerrufsbutton: Online-Widerruf ohne Login (zweistufig im +// Frontend). Host-agnostisch — der Controller ordnet den Betreiber-Empfänger +// über den Host zu. +Route::post('/widerruf', WithdrawalController::class) + ->middleware('throttle:10,1') + ->name('widerruf.store'); diff --git a/tests/Feature/Web/WithdrawalRequestTest.php b/tests/Feature/Web/WithdrawalRequestTest.php new file mode 100644 index 00000000..8eac6132 --- /dev/null +++ b/tests/Feature/Web/WithdrawalRequestTest.php @@ -0,0 +1,100 @@ +get('https://pressekonto.test/')->assertSuccessful(); + $content = $response->getContent(); + + expect($content) + ->toContain('Vertrag widerrufen') + ->toContain('open-withdrawal-modal') + ->toContain('action="'.route('widerruf.store').'"') + ->toContain('Jetzt widerrufen') + // Kein Widerrufsgrund-Feld — darf gesetzlich nicht verlangt werden. + ->not->toContain('name="reason"'); +}); + +test('a withdrawal is persisted and both mails are sent immediately', function () { + /** @var TestCase $this */ + Mail::fake(); + config()->set('domains.domains.pressekonto.brand.contact_email', 'kontakt@pressekonto.de'); + + $this->from('https://pressekonto.test/') + ->post('https://pressekonto.test/widerruf', [ + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'order_reference' => 'STR-2026-0042', + ]) + ->assertRedirect('https://pressekonto.test/') + ->assertSessionHas('withdrawal-status'); + + $request = WithdrawalRequest::query()->firstOrFail(); + + expect($request->name)->toBe('Paula Presse'); + expect($request->email)->toBe('paula@example.test'); + expect($request->order_reference)->toBe('STR-2026-0042'); + expect($request->host)->toBe('pressekonto.test'); + expect($request->status)->toBe('open'); + + // Gesetzlich vorgeschrieben: sofortige automatische Eingangsbestätigung. + Mail::assertSent(WithdrawalRequestReceived::class, fn (WithdrawalRequestReceived $mail) => $mail->hasTo('paula@example.test')); + Mail::assertSent(WithdrawalRequestSubmitted::class, fn (WithdrawalRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de')); +}); + +test('the order reference is optional', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://pressekonto.test/widerruf', [ + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + ])->assertSessionHas('withdrawal-status'); + + expect(WithdrawalRequest::query()->firstOrFail()->order_reference)->toBeNull(); +}); + +test('withdrawal validation errors land in the withdrawal error bag', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://pressekonto.test/widerruf', [ + 'name' => '', + 'email' => 'keine-mail', + ])->assertSessionHasErrorsIn('withdrawal', ['name', 'email']); + + expect(WithdrawalRequest::query()->count())->toBe(0); + Mail::assertNothingSent(); +}); + +test('honeypot submissions are silently accepted without persisting or mailing', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://pressekonto.test/widerruf', [ + 'name' => 'Bot', + 'email' => 'bot@example.test', + 'website' => 'https://spam.example', + ])->assertSessionHas('withdrawal-status'); + + expect(WithdrawalRequest::query()->count())->toBe(0); + Mail::assertNothingSent(); +}); + +test('a withdrawal from a spoke domain goes to that brand address', function () { + /** @var TestCase $this */ + Mail::fake(); + config()->set('domains.domains.presseecho.brand.contact_email', 'redaktion@presseecho.de'); + + $this->post('https://presseecho.test/widerruf', [ + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + ])->assertSessionHas('withdrawal-status'); + + Mail::assertSent(WithdrawalRequestSubmitted::class, fn (WithdrawalRequestSubmitted $mail) => $mail->hasTo('redaktion@presseecho.de')); +});