Widerruf: Gesetzliche Online-Widerrufsfunktion (Formular, Bestätigungsmail, Admin-Verwaltung)

- Öffentliches Widerrufsformular /widerruf mit Bestell-Lookup (throttled)
- Bestätigungsmail an Kunde + Weiterleitung an bestellungen@ (config app.withdrawal_mail)
- Admin-Übersicht/-Detail unter /admin/withdrawals inkl. Sidenav-Eintrag
- Widerruf-Button in Portal-Bestellansicht und Footer-Links
- Model OrderWithdrawal + Migration + Factory + Tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-08 15:56:02 +00:00
parent 9dc138e45b
commit 83ef5f1e52
24 changed files with 1607 additions and 92 deletions

View file

@ -1,5 +1,12 @@
<?php
use App\Domain\EarlyDomainParser;
use App\Models\IqImage;
use App\Models\ProductImage;
use App\Models\UserShop;
use App\Models\UserShopOnSite;
use App\Services\LocaleGuard;
/*
|--------------------------------------------------------------------------
| Geteilte Routen (Shared Routes)
@ -12,7 +19,7 @@
// Rechtliche und Kontakt-Routen - Umleitung zur Shop-Domain für checkout.*
// Verwendet EarlyDomainParser für domain-awareness
if (\App\Domain\EarlyDomainParser::getCurrentDomainType() === 'checkout') {
if (EarlyDomainParser::getCurrentDomainType() === 'checkout') {
// Shop-Domain aus Config laden
$shopHost = config('domains.domains.shop.host');
$protocol = config('domains.protocol', 'https://');
@ -63,7 +70,7 @@ Route::post('/change_website_lang', 'Web\SiteController@changeLang')->name('lang
// Route für den Sprachwechsel (aus alter utility.php wiederhergestellt)
Route::get('translation/{locale}', function ($locale) {
$normalized = \App\Services\LocaleGuard::normalize($locale);
$normalized = LocaleGuard::normalize($locale);
if ($normalized === null) {
return redirect()->back();
}
@ -84,7 +91,7 @@ Route::get('translation/{locale}', function ($locale) {
// Bild-Routen (aus alter utility.php wiederhergestellt)
Route::get('/product/image/{slug}', function ($slug = null) {
if ($image = \App\Models\ProductImage::where('slug', 'like', $slug)->first()) {
if ($image = ProductImage::where('slug', 'like', $slug)->first()) {
$path = storage_path('app/public').'/images/product'.'/'.$image->product_id.'/'.$image->filename;
if (file_exists($path)) {
return response()->file($path);
@ -95,7 +102,7 @@ Route::get('/product/image/{slug}', function ($slug = null) {
Route::get('storage/images/{from}/{slug}', function ($from = null, $slug = null) {
if ($from == 'shop') {
if ($image = \App\Models\UserShop::where('filename', $slug)->first()) {
if ($image = UserShop::where('filename', $slug)->first()) {
$path = storage_path('app/public').'/images/shop'.'/'.$image->filename;
if (file_exists($path)) {
return Response::file($path);
@ -105,7 +112,7 @@ Route::get('storage/images/{from}/{slug}', function ($from = null, $slug = null)
})->name('storage_images');
Route::get('/iq/image/{slug}', function ($slug = null) {
if ($image = \App\Models\IqImage::where('slug', $slug)->first()) {
if ($image = IqImage::where('slug', $slug)->first()) {
$path = storage_path('app/public').'/images/iq_images/'.$image->filename;
if (file_exists($path)) {
return Response::file($path);
@ -114,7 +121,7 @@ Route::get('/iq/image/{slug}', function ($slug = null) {
})->name('iq_image');
Route::get('/user_shop/image/{slug}', function ($slug = null) {
if ($image = \App\Models\UserShopOnSite::where('slug', $slug)->first()) {
if ($image = UserShopOnSite::where('slug', $slug)->first()) {
$path = storage_path('app/public').'/images/user_shop'.'/'.$image->user_shop_id.'/'.$image->filename;
if (file_exists($path)) {
return Response::file($path);
@ -123,7 +130,7 @@ Route::get('/user_shop/image/{slug}', function ($slug = null) {
})->name('user_shop_image');
Route::get('/shop/product/image/{slug}', function ($slug = null) {
if ($image = \App\Models\ProductImage::where('slug', $slug)->first()) {
if ($image = ProductImage::where('slug', $slug)->first()) {
$path = storage_path('app/public').'/images/product'.'/'.$image->product_id.'/'.$image->filename;
if (file_exists($path)) {
return Response::file($path);
@ -134,7 +141,7 @@ Route::get('/shop/product/image/{slug}', function ($slug = null) {
// Alias für /home, damit auch /home erreichbar ist
// Checkout-Weiterleitung nur für Domains, die NICHT checkout.* sind
if (\App\Domain\EarlyDomainParser::getCurrentDomainType() !== 'checkout') {
if (EarlyDomainParser::getCurrentDomainType() !== 'checkout') {
// Checkout-Domain aus Config laden
$checkoutHost = config('domains.domains.checkout.host');
$protocol = config('domains.protocol', 'https://');
@ -169,3 +176,12 @@ if (\App\Domain\EarlyDomainParser::getCurrentDomainType() !== 'checkout') {
}
Route::get('/register/verify/{confirmationCode}', 'HomeController@verify')->name('register_verify');
// Gesetzliche Online-Widerrufsfunktion (Widerrufs-Button-Pflicht ab 19.06.2026)
Route::get('/widerruf', 'Web\WithdrawalController@create')->name('withdrawal.create');
Route::post('/widerruf/pruefen', 'Web\WithdrawalController@lookup')
->middleware('throttle:20,1')
->name('withdrawal.lookup');
Route::post('/widerruf/absenden', 'Web\WithdrawalController@store')
->middleware('throttle:10,1')
->name('withdrawal.store');