From 63c36555a51cb7684fcf8a34e416c1f4a8ff7390 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 8 Jul 2026 15:56:02 +0000 Subject: [PATCH] =?UTF-8?q?Widerruf:=20Gesetzliche=20Online-Widerrufsfunkt?= =?UTF-8?q?ion=20(Formular,=20Best=C3=A4tigungsmail,=20Admin-Verwaltung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ö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 --- .../Admin/OrderWithdrawalController.php | 102 ++++++++ .../Controllers/Web/WithdrawalController.php | 187 ++++++++++++++ .../Withdrawal/WithdrawalLookupRequest.php | 36 +++ .../Withdrawal/WithdrawalStoreRequest.php | 44 ++++ app/Mail/MailWithdrawalConfirmation.php | 31 +++ app/Models/OrderWithdrawal.php | 107 ++++++++ config/app.php | 238 ++++++++++++------ database/factories/OrderWithdrawalFactory.php | 40 +++ ..._113151_create_order_withdrawals_table.php | 41 +++ resources/lang/de/navigation.php | 1 + resources/lang/de/withdrawal.php | 63 +++++ resources/lang/en/navigation.php | 1 + resources/lang/en/withdrawal.php | 63 +++++ .../views/admin/withdrawal/detail.blade.php | 98 ++++++++ .../views/admin/withdrawal/index.blade.php | 57 +++++ .../emails/withdrawal_confirmation.blade.php | 131 ++++++++++ .../layouts/includes/layout-sidenav.blade.php | 8 +- .../portal/order/my_order_show.blade.php | 12 +- .../web/layouts/includes/footer.blade.php | 3 + .../web/templates/widerruf-final.blade.php | 49 ++++ .../views/web/templates/widerruf.blade.php | 160 ++++++++++++ .../user/layouts/includes/footer.blade.php | 3 + routes/shared/common.php | 32 ++- tests/Feature/OrderWithdrawalTest.php | 192 ++++++++++++++ 24 files changed, 1607 insertions(+), 92 deletions(-) create mode 100644 app/Http/Controllers/Admin/OrderWithdrawalController.php create mode 100644 app/Http/Controllers/Web/WithdrawalController.php create mode 100644 app/Http/Requests/Withdrawal/WithdrawalLookupRequest.php create mode 100644 app/Http/Requests/Withdrawal/WithdrawalStoreRequest.php create mode 100644 app/Mail/MailWithdrawalConfirmation.php create mode 100644 app/Models/OrderWithdrawal.php create mode 100644 database/factories/OrderWithdrawalFactory.php create mode 100644 database/migrations/2026_06_26_113151_create_order_withdrawals_table.php create mode 100644 resources/lang/de/withdrawal.php create mode 100644 resources/lang/en/withdrawal.php create mode 100644 resources/views/admin/withdrawal/detail.blade.php create mode 100644 resources/views/admin/withdrawal/index.blade.php create mode 100644 resources/views/emails/withdrawal_confirmation.blade.php create mode 100644 resources/views/web/templates/widerruf-final.blade.php create mode 100644 resources/views/web/templates/widerruf.blade.php create mode 100644 tests/Feature/OrderWithdrawalTest.php diff --git a/app/Http/Controllers/Admin/OrderWithdrawalController.php b/app/Http/Controllers/Admin/OrderWithdrawalController.php new file mode 100644 index 00000000..78e777cb --- /dev/null +++ b/app/Http/Controllers/Admin/OrderWithdrawalController.php @@ -0,0 +1,102 @@ +middleware('admin'); + } + + public function index(): View + { + return view('admin.withdrawal.index'); + } + + public function detail(int $id): View + { + $withdrawal = OrderWithdrawal::with('shopping_order.shopping_user', 'user_shop')->findOrFail($id); + + return view('admin.withdrawal.detail', [ + 'withdrawal' => $withdrawal, + 'statuses' => OrderWithdrawal::statuses(), + ]); + } + + public function update(Request $request, int $id): RedirectResponse + { + $withdrawal = OrderWithdrawal::findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'status' => ['required', 'in:'.implode(',', OrderWithdrawal::statuses())], + 'admin_note' => ['nullable', 'string', 'max:5000'], + ]); + + if ($validator->fails()) { + return back()->withErrors($validator)->withInput(); + } + + $withdrawal->update([ + 'status' => $request->input('status'), + 'admin_note' => $request->input('admin_note'), + ]); + + \Session()->flash('alert-success', __('Gespeichert!')); + + return back(); + } + + public function datatable() + { + $query = OrderWithdrawal::query()->select('order_withdrawals.*'); + + return \DataTables::eloquent($query) + ->addColumn('id', function (OrderWithdrawal $w): string { + return ' '.$w->id; + }) + ->addColumn('declared_at', function (OrderWithdrawal $w): string { + return $w->declared_at ? $w->declared_at->format('d.m.Y H:i') : ''; + }) + ->addColumn('order_reference', function (OrderWithdrawal $w): string { + return '#'.e($w->order_reference); + }) + ->addColumn('customer', function (OrderWithdrawal $w): string { + return e($w->customer_name).'
'.e($w->customer_email).''; + }) + ->addColumn('type', function (OrderWithdrawal $w): string { + $label = $w->isFullWithdrawal() ? __('withdrawal.scope_full') : __('withdrawal.scope_partial'); + $color = $w->isFullWithdrawal() ? 'dark' : 'info'; + + return ''.e($label).''; + }) + ->addColumn('status', function (OrderWithdrawal $w): string { + $colors = [ + OrderWithdrawal::STATUS_RECEIVED => 'warning', + OrderWithdrawal::STATUS_PROCESSING => 'info', + OrderWithdrawal::STATUS_DONE => 'success', + OrderWithdrawal::STATUS_REJECTED => 'danger', + ]; + $color = $colors[$w->status] ?? 'default'; + + return ''.e($w->status).''; + }) + ->addColumn('action', function (OrderWithdrawal $w): string { + return ''; + }) + ->filterColumn('customer', function ($query, $keyword): void { + $query->where('customer_email', 'LIKE', '%'.$keyword.'%') + ->orWhere('customer_name', 'LIKE', '%'.$keyword.'%'); + }) + ->orderColumn('declared_at', 'declared_at $1') + ->rawColumns(['id', 'customer', 'type', 'status', 'action']) + ->make(true); + } +} diff --git a/app/Http/Controllers/Web/WithdrawalController.php b/app/Http/Controllers/Web/WithdrawalController.php new file mode 100644 index 00000000..aceb5b42 --- /dev/null +++ b/app/Http/Controllers/Web/WithdrawalController.php @@ -0,0 +1,187 @@ +viewData([ + 'order' => null, + 'prefill_order' => request('order'), + 'prefill_email' => request('email'), + ])); + } + + public function lookup(WithdrawalLookupRequest $request): View|RedirectResponse + { + $order = $this->findOrder((string) $request->string('order_number'), (string) $request->string('email')); + + if (! $order) { + return back() + ->withInput() + ->with('alert-error', __('withdrawal.not_found')); + } + + return view('web.templates.widerruf', $this->viewData([ + 'order' => $order, + 'prefill_order' => (string) $request->string('order_number'), + 'prefill_email' => (string) $request->string('email'), + ])); + } + + public function store(WithdrawalStoreRequest $request): View|RedirectResponse + { + $order = $this->findOrder((string) $request->string('order_number'), (string) $request->string('email')); + + if (! $order) { + return redirect() + ->route('withdrawal.create') + ->with('alert-error', __('withdrawal.not_found')); + } + + $isPartial = $request->input('scope') === OrderWithdrawal::TYPE_PARTIAL; + $selectedItems = $this->collectSelectedItems($order, $isPartial ? (array) $request->input('items', []) : []); + + if ($isPartial && empty($selectedItems)) { + return back() + ->withInput() + ->with('alert-error', __('withdrawal.no_items_selected')); + } + + $withdrawal = OrderWithdrawal::create([ + 'shopping_order_id' => $order->id, + 'user_shop_id' => $order->user_shop_id, + 'type' => $isPartial ? OrderWithdrawal::TYPE_PARTIAL : OrderWithdrawal::TYPE_FULL, + 'status' => OrderWithdrawal::STATUS_RECEIVED, + 'order_reference' => (string) $order->id, + 'customer_email' => mb_strtolower((string) $request->string('email')), + 'customer_name' => trim(($order->shopping_user->billing_firstname ?? '').' '.($order->shopping_user->billing_lastname ?? '')) ?: null, + 'reason' => $request->input('reason'), + 'items' => $isPartial ? $selectedItems : $this->collectSelectedItems($order, $this->allItemQuantities($order)), + 'ip_address' => $request->ip(), + 'user_agent' => (string) $request->userAgent(), + 'declared_at' => now(), + ]); + + $this->sendConfirmation($withdrawal, $order); + + return view('web.templates.widerruf-final', $this->viewData([ + 'withdrawal' => $withdrawal, + ])); + } + + /** + * Findet eine Verbraucher-Shop-Bestellung anhand der vom Kunden bekannten Nummer + * (Bestell-ID, Zahlungsreferenz oder Rechnungsnummer) in Kombination mit der E-Mail-Adresse. + */ + private function findOrder(string $orderNumber, string $email): ?ShoppingOrder + { + $orderNumber = trim($orderNumber); + $email = mb_strtolower(trim($email)); + + if ($orderNumber === '' || $email === '') { + return null; + } + + return ShoppingOrder::query() + ->where('payment_for', self::SHOP_PAYMENT_FOR) + ->whereHas('shopping_user', function ($query) use ($email): void { + $query->whereRaw('LOWER(billing_email) = ?', [$email]); + }) + ->where(function ($query) use ($orderNumber): void { + if (ctype_digit($orderNumber)) { + $query->orWhere('id', (int) $orderNumber); + } + $query->orWhereHas('shopping_payments', function ($payment) use ($orderNumber): void { + $payment->where('reference', $orderNumber); + }); + $query->orWhereHas('user_invoices', function ($invoice) use ($orderNumber): void { + $invoice->where('full_number', $orderNumber); + }); + }) + ->with(['shopping_order_items.product', 'shopping_user']) + ->latest('id') + ->first(); + } + + /** + * @return array + */ + private function allItemQuantities(ShoppingOrder $order): array + { + return $order->shopping_order_items + ->mapWithKeys(fn ($item): array => [$item->id => (int) $item->qty]) + ->all(); + } + + /** + * Erstellt einen Snapshot der widerrufenen Positionen. + * + * @param array $quantities + * @return array + */ + private function collectSelectedItems(ShoppingOrder $order, array $quantities): array + { + $selected = []; + + foreach ($order->shopping_order_items as $item) { + $requestedQty = (int) ($quantities[$item->id] ?? 0); + + if ($requestedQty <= 0) { + continue; + } + + $qty = min($requestedQty, (int) $item->qty); + + $selected[] = [ + 'shopping_order_item_id' => $item->id, + 'product_id' => $item->product_id, + 'name' => $item->product?->getLang('name') ?? ('#'.$item->product_id), + 'qty' => $qty, + ]; + } + + return $selected; + } + + private function sendConfirmation(OrderWithdrawal $withdrawal, ShoppingOrder $order): void + { + $bcc = config('app.withdrawal_mail'); + + Mail::to($withdrawal->customer_email) + ->bcc($bcc) + ->locale($order->getLocale()) + ->send(new MailWithdrawalConfirmation($withdrawal, $order)); + } + + /** + * @param array $extra + * @return array + */ + private function viewData(array $extra = []): array + { + return array_merge([ + 'user_shop' => Util::getUserShop(), + 'mylangs' => Shop::getLangChange('webshop'), + 'yard_instance' => 'webshop', + ], $extra); + } +} diff --git a/app/Http/Requests/Withdrawal/WithdrawalLookupRequest.php b/app/Http/Requests/Withdrawal/WithdrawalLookupRequest.php new file mode 100644 index 00000000..99a8db9e --- /dev/null +++ b/app/Http/Requests/Withdrawal/WithdrawalLookupRequest.php @@ -0,0 +1,36 @@ +> + */ + public function rules(): array + { + return [ + 'order_number' => ['required', 'string', 'max:50'], + 'email' => ['required', 'email', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'order_number.required' => __('validation.required', ['attribute' => __('withdrawal.order_number')]), + 'email.required' => __('validation.required', ['attribute' => __('withdrawal.email')]), + 'email.email' => __('validation.email', ['attribute' => __('withdrawal.email')]), + ]; + } +} diff --git a/app/Http/Requests/Withdrawal/WithdrawalStoreRequest.php b/app/Http/Requests/Withdrawal/WithdrawalStoreRequest.php new file mode 100644 index 00000000..1a1c64c7 --- /dev/null +++ b/app/Http/Requests/Withdrawal/WithdrawalStoreRequest.php @@ -0,0 +1,44 @@ +> + */ + public function rules(): array + { + return [ + 'order_number' => ['required', 'string', 'max:50'], + 'email' => ['required', 'email', 'max:255'], + 'scope' => ['required', Rule::in([OrderWithdrawal::TYPE_FULL, OrderWithdrawal::TYPE_PARTIAL])], + 'items' => ['array'], + 'items.*' => ['integer', 'min:0'], + 'reason' => ['nullable', 'string', 'max:2000'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'order_number.required' => __('validation.required', ['attribute' => __('withdrawal.order_number')]), + 'email.required' => __('validation.required', ['attribute' => __('withdrawal.email')]), + 'email.email' => __('validation.email', ['attribute' => __('withdrawal.email')]), + 'scope.required' => __('validation.required', ['attribute' => __('withdrawal.choose_scope_hl')]), + 'scope.in' => __('validation.required', ['attribute' => __('withdrawal.choose_scope_hl')]), + ]; + } +} diff --git a/app/Mail/MailWithdrawalConfirmation.php b/app/Mail/MailWithdrawalConfirmation.php new file mode 100644 index 00000000..5a5f26e2 --- /dev/null +++ b/app/Mail/MailWithdrawalConfirmation.php @@ -0,0 +1,31 @@ +subject = __('withdrawal.mail_subject').' - mivita.care'; + } + + public function build(): self + { + return $this->view('emails.withdrawal_confirmation')->with([ + 'withdrawal' => $this->withdrawal, + 'shopping_order' => $this->shopping_order, + ]); + } +} diff --git a/app/Models/OrderWithdrawal.php b/app/Models/OrderWithdrawal.php new file mode 100644 index 00000000..d4d569dc --- /dev/null +++ b/app/Models/OrderWithdrawal.php @@ -0,0 +1,107 @@ + */ + use HasFactory, SoftDeletes; + + public const TYPE_FULL = 'full'; + + public const TYPE_PARTIAL = 'partial'; + + public const STATUS_RECEIVED = 'received'; + + public const STATUS_PROCESSING = 'processing'; + + public const STATUS_DONE = 'done'; + + public const STATUS_REJECTED = 'rejected'; + + protected $fillable = [ + 'shopping_order_id', + 'user_shop_id', + 'type', + 'status', + 'order_reference', + 'customer_email', + 'customer_name', + 'reason', + 'items', + 'ip_address', + 'user_agent', + 'admin_note', + 'declared_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'items' => 'array', + 'declared_at' => 'datetime', + ]; + } + + public function shopping_order(): BelongsTo + { + return $this->belongsTo(ShoppingOrder::class, 'shopping_order_id'); + } + + public function user_shop(): BelongsTo + { + return $this->belongsTo(UserShop::class, 'user_shop_id'); + } + + public function scopeReceived(Builder $query): Builder + { + return $query->where('status', self::STATUS_RECEIVED); + } + + /** + * @return array + */ + public static function statuses(): array + { + return [ + self::STATUS_RECEIVED, + self::STATUS_PROCESSING, + self::STATUS_DONE, + self::STATUS_REJECTED, + ]; + } + + public function isFullWithdrawal(): bool + { + return $this->type === self::TYPE_FULL; + } +} diff --git a/config/app.php b/config/app.php index b3feb02c..fdc8a09b 100755 --- a/config/app.php +++ b/config/app.php @@ -1,5 +1,80 @@ env('APP_URL_CHECKOUT', 'checkout.'), 'contact_mail' => env('APP_CONTACT_MAIL', 'kontakt@mivita.care'), + 'withdrawal_mail' => env('APP_WITHDRAWAL_MAIL', 'bestellungen@mivita.care'), 'checkout_mail' => env('APP_CHECKOUT_MAIL', 'no-reply@mivita.care'), 'checkout_test_mail' => env('APP_CHECKOUT_TEST_MAIL', 'devtest@mivita.care'), 'info_mail' => env('APP_INFO_MAIL', 'no-reply@mivita.care'), @@ -74,7 +150,6 @@ return [ 'ipinfo' => env('APP_IPINFO', false), - 'main_tax' => env('APP_MAIN_TAX', 1.19), 'main_tax_rate' => env('APP_MAIN_TAX_RATE', 19), 'shipping_tax' => env('APP_SHIPPING_TAX', 19), @@ -86,7 +161,6 @@ return [ 'url_backend' => env('APP_URL', 'http://mivita.local/'), */ - /* |-------------------------------------------------------------------------- | Application Timezone @@ -157,53 +231,53 @@ return [ /* * Laravel Framework Service Providers... */ - Illuminate\Auth\AuthServiceProvider::class, - Illuminate\Broadcasting\BroadcastServiceProvider::class, - Illuminate\Bus\BusServiceProvider::class, - Illuminate\Cache\CacheServiceProvider::class, - Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, - Illuminate\Cookie\CookieServiceProvider::class, - Illuminate\Database\DatabaseServiceProvider::class, - Illuminate\Encryption\EncryptionServiceProvider::class, - Illuminate\Filesystem\FilesystemServiceProvider::class, - Illuminate\Foundation\Providers\FoundationServiceProvider::class, - Illuminate\Hashing\HashServiceProvider::class, - Illuminate\Mail\MailServiceProvider::class, - Illuminate\Notifications\NotificationServiceProvider::class, - Illuminate\Pagination\PaginationServiceProvider::class, - Illuminate\Pipeline\PipelineServiceProvider::class, - Illuminate\Queue\QueueServiceProvider::class, - Illuminate\Redis\RedisServiceProvider::class, - Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, - Illuminate\Session\SessionServiceProvider::class, - Illuminate\Translation\TranslationServiceProvider::class, - Illuminate\Validation\ValidationServiceProvider::class, - Illuminate\View\ViewServiceProvider::class, - //Reliese\Coders\CodersServiceProvider::class, - //Intervention\Image\ImageServiceProvider::class, + AuthServiceProvider::class, + BroadcastServiceProvider::class, + BusServiceProvider::class, + CacheServiceProvider::class, + ConsoleSupportServiceProvider::class, + CookieServiceProvider::class, + DatabaseServiceProvider::class, + EncryptionServiceProvider::class, + FilesystemServiceProvider::class, + FoundationServiceProvider::class, + HashServiceProvider::class, + MailServiceProvider::class, + NotificationServiceProvider::class, + PaginationServiceProvider::class, + PipelineServiceProvider::class, + QueueServiceProvider::class, + RedisServiceProvider::class, + PasswordResetServiceProvider::class, + SessionServiceProvider::class, + TranslationServiceProvider::class, + ValidationServiceProvider::class, + ViewServiceProvider::class, + // Reliese\Coders\CodersServiceProvider::class, + // Intervention\Image\ImageServiceProvider::class, /* * Package Service Providers... */ - Laravel\Tinker\TinkerServiceProvider::class, + TinkerServiceProvider::class, /* * DHL Package Service Provider... */ - Acme\Dhl\DhlServiceProvider::class, + DhlServiceProvider::class, /* * Application Service Providers... */ - App\Providers\AppServiceProvider::class, + AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, - App\Providers\EventServiceProvider::class, - App\Providers\HorizonServiceProvider::class, - //App\Providers\DomainServiceProvider::class, - App\Providers\RouteServiceProvider::class, - Jenssegers\Date\DateServiceProvider::class, - Maatwebsite\Excel\ExcelServiceProvider::class, - Yajra\DataTables\DataTablesServiceProvider::class, - App\Providers\YardServiceProvider::class, - Alban\LaravelCollectiveSpatieHtmlParser\ServiceProvider::class, + EventServiceProvider::class, + HorizonServiceProvider::class, + // App\Providers\DomainServiceProvider::class, + RouteServiceProvider::class, + DateServiceProvider::class, + ExcelServiceProvider::class, + DataTablesServiceProvider::class, + YardServiceProvider::class, + ServiceProvider::class, ], /* @@ -220,50 +294,50 @@ return [ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, - 'Artisan' => Illuminate\Support\Facades\Artisan::class, - 'Auth' => Illuminate\Support\Facades\Auth::class, - 'Blade' => Illuminate\Support\Facades\Blade::class, - 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, - 'Bus' => Illuminate\Support\Facades\Bus::class, - 'Cache' => Illuminate\Support\Facades\Cache::class, - 'Config' => Illuminate\Support\Facades\Config::class, - 'Cookie' => Illuminate\Support\Facades\Cookie::class, - 'Crypt' => Illuminate\Support\Facades\Crypt::class, - 'DB' => Illuminate\Support\Facades\DB::class, - 'Eloquent' => Illuminate\Database\Eloquent\Model::class, - 'Event' => Illuminate\Support\Facades\Event::class, - 'File' => Illuminate\Support\Facades\File::class, - 'Gate' => Illuminate\Support\Facades\Gate::class, - 'Hash' => Illuminate\Support\Facades\Hash::class, - 'Lang' => Illuminate\Support\Facades\Lang::class, - 'Log' => Illuminate\Support\Facades\Log::class, - 'Mail' => Illuminate\Support\Facades\Mail::class, - 'Notification' => Illuminate\Support\Facades\Notification::class, - 'Password' => Illuminate\Support\Facades\Password::class, - 'Queue' => Illuminate\Support\Facades\Queue::class, - 'Redirect' => Illuminate\Support\Facades\Redirect::class, - 'Redis' => Illuminate\Support\Facades\Redis::class, - 'Request' => Illuminate\Support\Facades\Request::class, - 'Response' => Illuminate\Support\Facades\Response::class, - 'Route' => Illuminate\Support\Facades\Route::class, - 'Schema' => Illuminate\Support\Facades\Schema::class, - 'Session' => Illuminate\Support\Facades\Session::class, - 'Storage' => Illuminate\Support\Facades\Storage::class, - 'URL' => Illuminate\Support\Facades\URL::class, - 'Validator' => Illuminate\Support\Facades\Validator::class, - 'View' => Illuminate\Support\Facades\View::class, - //'Input' => Illuminate\Support\Facades\Input::class, - //'Image' => Intervention\Image\Facades\Image::class, - 'Carbon' => Carbon\Carbon::class, - 'Date' => Jenssegers\Date\Date::class, - 'HTMLHelper' => App\Services\HTMLHelper::class, - 'Util' => App\Services\Util::class, - 'Excel' => Maatwebsite\Excel\Facades\Excel::class, - 'DataTables' => Yajra\DataTables\Facades\DataTables::class, - 'Yard' => App\Services\Facade\Yard::class, - 'DHL' => Acme\Dhl\Facades\DHL::class, - 'Form' => Alban\LaravelCollectiveSpatieHtmlParser\FormFacade::class, - 'Html' => Alban\LaravelCollectiveSpatieHtmlParser\HtmlFacade::class, + 'Artisan' => Artisan::class, + 'Auth' => Auth::class, + 'Blade' => Blade::class, + 'Broadcast' => Broadcast::class, + 'Bus' => Bus::class, + 'Cache' => Cache::class, + 'Config' => Config::class, + 'Cookie' => Cookie::class, + 'Crypt' => Crypt::class, + 'DB' => DB::class, + 'Eloquent' => Model::class, + 'Event' => Event::class, + 'File' => File::class, + 'Gate' => Gate::class, + 'Hash' => Hash::class, + 'Lang' => Lang::class, + 'Log' => Log::class, + 'Mail' => Mail::class, + 'Notification' => Notification::class, + 'Password' => Password::class, + 'Queue' => Queue::class, + 'Redirect' => Redirect::class, + 'Redis' => Redis::class, + 'Request' => Request::class, + 'Response' => Response::class, + 'Route' => Route::class, + 'Schema' => Schema::class, + 'Session' => Session::class, + 'Storage' => Storage::class, + 'URL' => URL::class, + 'Validator' => Validator::class, + 'View' => View::class, + // 'Input' => Illuminate\Support\Facades\Input::class, + // 'Image' => Intervention\Image\Facades\Image::class, + 'Carbon' => Carbon\Carbon::class, + 'Date' => Date::class, + 'HTMLHelper' => HTMLHelper::class, + 'Util' => Util::class, + 'Excel' => Excel::class, + 'DataTables' => DataTables::class, + 'Yard' => Yard::class, + 'DHL' => DHL::class, + 'Form' => FormFacade::class, + 'Html' => HtmlFacade::class, ], ]; diff --git a/database/factories/OrderWithdrawalFactory.php b/database/factories/OrderWithdrawalFactory.php new file mode 100644 index 00000000..acf7e928 --- /dev/null +++ b/database/factories/OrderWithdrawalFactory.php @@ -0,0 +1,40 @@ + + */ +class OrderWithdrawalFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'shopping_order_id' => null, + 'user_shop_id' => null, + 'type' => OrderWithdrawal::TYPE_FULL, + 'status' => OrderWithdrawal::STATUS_RECEIVED, + 'order_reference' => (string) $this->faker->numberBetween(1000, 99999), + 'customer_email' => $this->faker->safeEmail(), + 'customer_name' => $this->faker->name(), + 'reason' => $this->faker->optional()->sentence(), + 'items' => null, + 'ip_address' => $this->faker->ipv4(), + 'user_agent' => $this->faker->userAgent(), + 'declared_at' => now(), + ]; + } + + public function partial(): self + { + return $this->state(fn (): array => ['type' => OrderWithdrawal::TYPE_PARTIAL]); + } +} diff --git a/database/migrations/2026_06_26_113151_create_order_withdrawals_table.php b/database/migrations/2026_06_26_113151_create_order_withdrawals_table.php new file mode 100644 index 00000000..5a27de43 --- /dev/null +++ b/database/migrations/2026_06_26_113151_create_order_withdrawals_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('shopping_order_id')->nullable()->index(); + $table->unsignedBigInteger('user_shop_id')->nullable()->index(); + $table->string('type')->default('full'); + $table->string('status')->default('received')->index(); + $table->string('order_reference')->nullable(); + $table->string('customer_email')->index(); + $table->string('customer_name')->nullable(); + $table->text('reason')->nullable(); + $table->json('items')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->text('admin_note')->nullable(); + $table->timestamp('declared_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_withdrawals'); + } +}; diff --git a/resources/lang/de/navigation.php b/resources/lang/de/navigation.php index 1dcd4e33..4fb34a42 100644 --- a/resources/lang/de/navigation.php +++ b/resources/lang/de/navigation.php @@ -68,6 +68,7 @@ return [ 'customerabo' => 'Kunden Abo', 'abo' => 'Abo', 'abos' => 'Abos', + 'withdrawals' => 'Widerrufe', 'payment_links' => 'Bezahllinks', 'dashboard' => 'Dashboard', 'shop' => 'Shop', diff --git a/resources/lang/de/withdrawal.php b/resources/lang/de/withdrawal.php new file mode 100644 index 00000000..b681222c --- /dev/null +++ b/resources/lang/de/withdrawal.php @@ -0,0 +1,63 @@ + 'Vertrag widerrufen', + 'nav_title' => 'Widerruf', + + // Identifikationsformular + 'page_title' => 'Vertrag widerrufen', + 'intro' => 'Hier kannst du deine online abgeschlossene Bestellung ganz einfach widerrufen. Bitte identifiziere zunächst deine Bestellung.', + 'identify_hl' => 'Bestellung identifizieren', + 'order_number' => 'Bestellnummer', + 'order_number_placeholder' => 'z. B. 12345', + 'order_number_help' => 'Die Bestellnummer findest du in deiner Bestellbestätigung.', + 'email' => 'E-Mail-Adresse der Bestellung', + 'email_placeholder' => 'deine@email.de', + 'find_order' => 'Bestellung suchen', + + // Bestellung gefunden / Auswahl + 'order_found_hl' => 'Deine Bestellung', + 'order_date' => 'Bestelldatum', + 'choose_scope_hl' => 'Was möchtest du widerrufen?', + 'scope_full' => 'Vollständiger Widerruf (gesamte Bestellung)', + 'scope_partial' => 'Teilweiser Widerruf (einzelne Artikel)', + 'select_items_hl' => 'Artikel auswählen', + 'product' => 'Artikel', + 'quantity' => 'Menge', + 'withdraw_quantity' => 'Zu widerrufende Menge', + 'reason' => 'Grund (optional)', + 'reason_placeholder' => 'Du musst keinen Grund angeben. Falls du möchtest, kannst du uns hier deinen Grund mitteilen.', + 'submit' => 'Widerruf verbindlich absenden', + 'back' => 'Zurück', + 'legal_note' => 'Mit dem Absenden erklärst du den Widerruf deines Vertrags gegenüber der mivita care gmbh, Leinfeld 2, 87755 Kirchhaslach. Du erhältst umgehend eine Eingangsbestätigung per E-Mail mit Datum und Uhrzeit.', + + // Fehler + 'not_found' => 'Wir konnten keine passende Bestellung zu diesen Angaben finden. Bitte prüfe Bestellnummer und E-Mail-Adresse.', + 'no_items_selected' => 'Bitte wähle mindestens einen Artikel für den teilweisen Widerruf aus.', + + // Erfolg / Eingangsbestätigung + 'success_hl' => 'Dein Widerruf ist bei uns eingegangen', + 'success_intro' => 'Vielen Dank. Wir haben deinen Widerruf erhalten und bearbeiten ihn schnellstmöglich.', + 'clock' => 'Uhr', + 'success_received_at' => 'Eingang deines Widerrufs', + 'success_reference' => 'Vorgangsnummer', + 'success_email_sent' => 'Eine Eingangsbestätigung haben wir zusätzlich an :email gesendet.', + 'success_full' => 'Du hast die gesamte Bestellung widerrufen.', + 'success_partial' => 'Du hast einzelne Artikel deiner Bestellung widerrufen.', + + // E-Mail + 'mail_subject' => 'Eingangsbestätigung deines Widerrufs', + 'mail_hello' => 'Hallo', + 'mail_intro' => 'wir bestätigen den Eingang deines Widerrufs zu folgender Bestellung. Dein Widerruf ist zum genannten Zeitpunkt bei uns eingegangen:', + 'mail_received_at' => 'Eingegangen am', + 'mail_order_number' => 'Bestellnummer', + 'mail_reference' => 'Vorgangsnummer', + 'mail_scope' => 'Umfang des Widerrufs', + 'mail_items_hl' => 'Widerrufene Artikel', + 'mail_outro' => 'Wir kümmern uns um die weitere Abwicklung und melden uns bei Bedarf bei dir.', + 'mail_greetings' => 'Beste Grüße', + 'mail_sender' => 'Dein mivita.care Team', + +]; diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index 33c619d3..6ba60daa 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -3,6 +3,7 @@ return [ 'abo' => 'Subscription', 'abos' => 'Subscriptions', + 'withdrawals' => 'Withdrawals', 'add' => 'add', 'attribute' => 'attributes', 'business' => 'business', diff --git a/resources/lang/en/withdrawal.php b/resources/lang/en/withdrawal.php new file mode 100644 index 00000000..37aa08ba --- /dev/null +++ b/resources/lang/en/withdrawal.php @@ -0,0 +1,63 @@ + 'Withdraw contract', + 'nav_title' => 'Withdrawals', + + // Identification form + 'page_title' => 'Withdraw your contract', + 'intro' => 'Here you can easily withdraw the contract for an order you concluded online. Please identify your order first.', + 'identify_hl' => 'Identify your order', + 'order_number' => 'Order number', + 'order_number_placeholder' => 'e.g. 12345', + 'order_number_help' => 'You can find the order number in your order confirmation.', + 'email' => 'E-mail address of the order', + 'email_placeholder' => 'your@email.com', + 'find_order' => 'Find order', + + // Order found / selection + 'order_found_hl' => 'Your order', + 'order_date' => 'Order date', + 'choose_scope_hl' => 'What would you like to withdraw?', + 'scope_full' => 'Full withdrawal (entire order)', + 'scope_partial' => 'Partial withdrawal (individual items)', + 'select_items_hl' => 'Select items', + 'product' => 'Item', + 'quantity' => 'Quantity', + 'withdraw_quantity' => 'Quantity to withdraw', + 'reason' => 'Reason (optional)', + 'reason_placeholder' => 'You do not have to provide a reason. If you like, you can let us know your reason here.', + 'submit' => 'Submit withdrawal', + 'back' => 'Back', + 'legal_note' => 'By submitting, you declare the withdrawal of your contract with mivita care gmbh, Leinfeld 2, 87755 Kirchhaslach, Germany. You will immediately receive a confirmation of receipt by e-mail including date and time.', + + // Errors + 'not_found' => 'We could not find a matching order for these details. Please check the order number and e-mail address.', + 'no_items_selected' => 'Please select at least one item for the partial withdrawal.', + + // Success / confirmation + 'success_hl' => 'We have received your withdrawal', + 'success_intro' => 'Thank you. We have received your withdrawal and will process it as soon as possible.', + 'clock' => '', + 'success_received_at' => 'Receipt of your withdrawal', + 'success_reference' => 'Reference number', + 'success_email_sent' => 'We have also sent a confirmation of receipt to :email.', + 'success_full' => 'You have withdrawn the entire order.', + 'success_partial' => 'You have withdrawn individual items of your order.', + + // E-mail + 'mail_subject' => 'Confirmation of receipt of your withdrawal', + 'mail_hello' => 'Hello', + 'mail_intro' => 'we confirm receipt of your withdrawal for the following order. Your withdrawal was received by us at the time stated:', + 'mail_received_at' => 'Received on', + 'mail_order_number' => 'Order number', + 'mail_reference' => 'Reference number', + 'mail_scope' => 'Scope of withdrawal', + 'mail_items_hl' => 'Withdrawn items', + 'mail_outro' => 'We will take care of the further processing and will contact you if necessary.', + 'mail_greetings' => 'Best regards', + 'mail_sender' => 'Your mivita.care team', + +]; diff --git a/resources/views/admin/withdrawal/detail.blade.php b/resources/views/admin/withdrawal/detail.blade.php new file mode 100644 index 00000000..3d3b9c0d --- /dev/null +++ b/resources/views/admin/withdrawal/detail.blade.php @@ -0,0 +1,98 @@ +@extends('layouts.layout-2') + +@section('content') +

+ {{ __('back') }} + {{ __('navigation.withdrawals') }} #{{ $withdrawal->id }} +

+ + @if (Session::has('alert-success')) +
{{ Session::get('alert-success') }}
+ @endif + @if (Session::has('alert-error')) +
{{ Session::get('alert-error') }}
+ @endif + +
+
+
+
+ + + + + + + + + + + + + + + + + + @if ($withdrawal->user_shop) + + + + + @endif + @if ($withdrawal->reason) + + + + + @endif +
{{ __('withdrawal.success_received_at') }}{{ optional($withdrawal->declared_at)->format('d.m.Y H:i') }} {{ __('withdrawal.clock') }}
{{ __('withdrawal.order_number') }} + #{{ $withdrawal->order_reference }} + @if ($withdrawal->shopping_order_id) + + @endif +
{{ __('withdrawal.choose_scope_hl') }} + @if ($withdrawal->isFullWithdrawal()) + {{ __('withdrawal.scope_full') }} + @else + {{ __('withdrawal.scope_partial') }} + @endif +
Kunde{{ $withdrawal->customer_name }}
{{ $withdrawal->customer_email }}
Shop{{ $withdrawal->user_shop->name }}
{{ __('withdrawal.reason') }}{{ $withdrawal->reason }}
+
+
+ + @if (! empty($withdrawal->items)) +
+
{{ __('withdrawal.mail_items_hl') }}
+
+
    + @foreach ($withdrawal->items as $item) +
  • {{ $item['qty'] }} × {{ $item['name'] }}
  • + @endforeach +
+
+
+ @endif +
+ +
+
+
Status & Notiz
+
+ {!! Form::open(['route' => ['admin_withdrawals_update', $withdrawal->id], 'method' => 'post']) !!} +
+ {!! Form::label('status', 'Status') !!} + {!! Form::select('status', collect($statuses)->mapWithKeys(fn ($s) => [$s => ucfirst($s)])->all(), $withdrawal->status, ['class' => 'form-control']) !!} +
+
+ {!! Form::label('admin_note', 'Interne Notiz') !!} + {!! Form::textarea('admin_note', $withdrawal->admin_note, ['class' => 'form-control', 'rows' => 5]) !!} +
+ + {!! Form::close() !!} +
+
+
+
+@endsection diff --git a/resources/views/admin/withdrawal/index.blade.php b/resources/views/admin/withdrawal/index.blade.php new file mode 100644 index 00000000..5716810e --- /dev/null +++ b/resources/views/admin/withdrawal/index.blade.php @@ -0,0 +1,57 @@ +@extends('layouts.layout-2') + +@section('content') +

+ {{ __('navigation.withdrawals') }} +

+
+
+
+
+ + + + + + + + + + + + + +
ID{{ __('withdrawal.success_received_at') }}{{ __('withdrawal.order_number') }}Kunde{{ __('withdrawal.choose_scope_hl') }}Status#
+
+
+
+
+ + +@endsection diff --git a/resources/views/emails/withdrawal_confirmation.blade.php b/resources/views/emails/withdrawal_confirmation.blade.php new file mode 100644 index 00000000..2d92e549 --- /dev/null +++ b/resources/views/emails/withdrawal_confirmation.blade.php @@ -0,0 +1,131 @@ + + + + + + mivita.care + + + + + + + +
+
+ + + + + + + + + + +
+ mivita.care +

+
+ + + + + + + + + + + @if (! empty($withdrawal->items)) + + + + + + + @endif + @if ($withdrawal->reason) + + + + @endif + + + +
+ {{ __('withdrawal.mail_hello') }} {{ $withdrawal->customer_name ?: '' }}, +
+ {{ __('withdrawal.mail_intro') }} +
+ + + + +
+ {{ __('withdrawal.mail_received_at') }}: + {{ optional($withdrawal->declared_at)->format('d.m.Y H:i') }} {{ __('withdrawal.clock') }}
+ {{ __('withdrawal.mail_order_number') }}: #{{ $withdrawal->order_reference }}
+ {{ __('withdrawal.mail_reference') }}: {{ $withdrawal->id }}
+ {{ __('withdrawal.mail_scope') }}: + @if ($withdrawal->isFullWithdrawal()) + {{ __('withdrawal.scope_full') }} + @else + {{ __('withdrawal.scope_partial') }} + @endif +
+

{{ __('withdrawal.mail_items_hl') }}
+ + @foreach ($withdrawal->items as $item) + + + + @endforeach +
{{ $item['qty'] }} × {{ $item['name'] }}
+
+
{{ $withdrawal->reason }} +
+
{{ __('withdrawal.mail_outro') }} +

+ {{ __('withdrawal.mail_greetings') }}
+ {{ __('withdrawal.mail_sender') }} +
+
+ mivita care gmbh • Leinfeld 2 • 87755 Kirchhaslach
+ www.mivita.care +
+
+ + diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index 23db1844..e68851b1 100755 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -315,7 +315,7 @@
{{ __('navigation.clients') }}
-
  • +
  • {{ __('navigation.orders') }}
    @@ -341,6 +341,12 @@
    {{ __('navigation.abos') }}
  • +
  • + +
    {{ __('navigation.withdrawals') }}
    +
    +
  • diff --git a/resources/views/portal/order/my_order_show.blade.php b/resources/views/portal/order/my_order_show.blade.php index dfdf994d..b4f5df03 100644 --- a/resources/views/portal/order/my_order_show.blade.php +++ b/resources/views/portal/order/my_order_show.blade.php @@ -16,6 +16,16 @@ {{ __('shop.order_customer') }} #{{$shopping_order->id}} @include('portal.order._detail') - {{ __('back') }} +
    + @if($shopping_order->payment_for === 6) + + {{ __('withdrawal.button_label') }} + + @else + + @endif + {{ __('back') }} +
    @endsection \ No newline at end of file diff --git a/resources/views/web/layouts/includes/footer.blade.php b/resources/views/web/layouts/includes/footer.blade.php index 585961b1..b6efa7e2 100644 --- a/resources/views/web/layouts/includes/footer.blade.php +++ b/resources/views/web/layouts/includes/footer.blade.php @@ -97,6 +97,9 @@
  • {{ __('website.cancellation_policy') }}
  • •
  • +
  • {{ __('withdrawal.button_label') }}
  • +
  • •
  • {{ __('website.privacy_policy') }}
  • •
  • diff --git a/resources/views/web/templates/widerruf-final.blade.php b/resources/views/web/templates/widerruf-final.blade.php new file mode 100644 index 00000000..2b317cd7 --- /dev/null +++ b/resources/views/web/templates/widerruf-final.blade.php @@ -0,0 +1,49 @@ +@extends($user_shop ? 'web.user.layouts.layout' : 'web.layouts.layout') + +@section('content') + + + +
    +
    +
    +
    +

    {{ __('withdrawal.success_hl') }}

    +

    {{ __('withdrawal.success_intro') }}

    +
    + +

    + {{ __('withdrawal.success_received_at') }}: + {{ optional($withdrawal->declared_at)->format('d.m.Y H:i') }} {{ __('withdrawal.clock') }}
    + {{ __('withdrawal.order_number') }}: #{{ $withdrawal->order_reference }}
    + {{ __('withdrawal.success_reference') }}: {{ $withdrawal->id }} +

    + +

    + @if ($withdrawal->isFullWithdrawal()) + {{ __('withdrawal.success_full') }} + @else + {{ __('withdrawal.success_partial') }} + @endif +

    + + @if (! empty($withdrawal->items)) +
      + @foreach ($withdrawal->items as $item) +
    • {{ $item['qty'] }} × {{ $item['name'] }}
    • + @endforeach +
    + @endif + +
    +

    {{ __('withdrawal.success_email_sent', ['email' => $withdrawal->customer_email]) }}

    +
    +
    +
    +
    + +@endsection diff --git a/resources/views/web/templates/widerruf.blade.php b/resources/views/web/templates/widerruf.blade.php new file mode 100644 index 00000000..a9b03f55 --- /dev/null +++ b/resources/views/web/templates/widerruf.blade.php @@ -0,0 +1,160 @@ +@extends($user_shop ? 'web.user.layouts.layout' : 'web.layouts.layout') + +@section('content') + + + + + +
    +
    +
    +

    {{ __('withdrawal.page_title') }}

    +

    {{ __('withdrawal.intro') }}

    + + @if (session('alert-error')) +
    {{ session('alert-error') }}
    + @endif + + @if (! $order) + {{-- Phase 1: Bestellung identifizieren --}} +
    +

    {{ __('withdrawal.identify_hl') }}

    + {!! Form::open(['route' => 'withdrawal.lookup', 'method' => 'post']) !!} +
    + {!! Form::label('order_number', __('withdrawal.order_number') . '*') !!} + {!! Form::text('order_number', old('order_number', $prefill_order), ['class' => 'form-control ' . ($errors->has('order_number') ? 'error is-invalid' : ''), 'placeholder' => __('withdrawal.order_number_placeholder')]) !!} + {{ __('withdrawal.order_number_help') }} + @if ($errors->has('order_number')) +
    {{ $errors->first('order_number') }}
    + @endif +
    +
    + {!! Form::label('email', __('withdrawal.email') . '*') !!} + {!! Form::email('email', old('email', $prefill_email), ['class' => 'form-control ' . ($errors->has('email') ? 'error is-invalid' : ''), 'placeholder' => __('withdrawal.email_placeholder')]) !!} + @if ($errors->has('email')) +
    {{ $errors->first('email') }}
    + @endif +
    + + {!! Form::close() !!} + @else + {{-- Phase 2: Umfang wählen und absenden --}} +
    +

    {{ __('withdrawal.order_found_hl') }}

    +

    + {{ __('withdrawal.order_number') }}: #{{ $order->id }}
    + {{ __('withdrawal.order_date') }}: {{ optional($order->created_at)->format('d.m.Y H:i') }} +

    + + {!! Form::open(['route' => 'withdrawal.store', 'method' => 'post']) !!} + {!! Form::hidden('order_number', $order->id) !!} + {!! Form::hidden('email', $prefill_email) !!} + +
    +

    {{ __('withdrawal.choose_scope_hl') }}

    +
    + + + @if ($errors->has('scope')) +
    {{ $errors->first('scope') }}
    + @endif +
    + + + +
    +
    + {!! Form::label('reason', __('withdrawal.reason')) !!} + {!! Form::textarea('reason', old('reason'), ['class' => 'form-control', 'rows' => 3, 'placeholder' => __('withdrawal.reason_placeholder')]) !!} +
    + + + +
    + + {{ __('withdrawal.back') }} + + +
    + {!! Form::close() !!} + @endif +
    +
    +
    + +@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/web/user/layouts/includes/footer.blade.php b/resources/views/web/user/layouts/includes/footer.blade.php index 7de8b4ce..46cdae1a 100644 --- a/resources/views/web/user/layouts/includes/footer.blade.php +++ b/resources/views/web/user/layouts/includes/footer.blade.php @@ -138,6 +138,9 @@
  • {{ __('website.cancellation_policy') }}
  • •
  • +
  • {{ __('withdrawal.button_label') }}
  • +
  • •
  • {{ __('website.privacy_policy') }}
  • •
  • diff --git a/routes/shared/common.php b/routes/shared/common.php index 9231d7e8..c91e0a46 100644 --- a/routes/shared/common.php +++ b/routes/shared/common.php @@ -1,5 +1,12 @@ 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'); diff --git a/tests/Feature/OrderWithdrawalTest.php b/tests/Feature/OrderWithdrawalTest.php new file mode 100644 index 00000000..759f91d9 --- /dev/null +++ b/tests/Feature/OrderWithdrawalTest.php @@ -0,0 +1,192 @@ + 0, + 'billing_firstname' => 'Max', + 'billing_lastname' => 'Mustermann', + 'billing_email' => $email, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'is_from' => 'shopping', + ]); + + $order = ShoppingOrder::create([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => 0, + 'user_shop_id' => 0, + 'payment_for' => 6, + 'country_id' => $country->id, + 'language' => 'de', + 'total' => 119, + 'txaction' => 'paid', + ]); + + $item = ShoppingOrderItem::create([ + 'shopping_order_id' => $order->id, + 'product_id' => $product->id, + 'qty' => 2, + 'price' => 59.5, + ]); + + return ['order' => $order, 'item' => $item]; +} + +it('zeigt die Widerrufsseite an', function () { + $this->get(route('withdrawal.create'))->assertOk(); +}); + +it('zeigt nach erfolgreicher Identifikation die Bestellung mit Absende-Button', function () { + ['order' => $order] = makeShopOrder('kunde@example.com'); + + $response = $this->post(route('withdrawal.lookup'), [ + 'order_number' => (string) $order->id, + 'email' => 'kunde@example.com', + ]); + + $response->assertOk() + ->assertSee('#'.$order->id) + ->assertSee(__('withdrawal.submit')); +}); + +it('legt bei vollständigem Widerruf einen Datensatz an und versendet die Eingangsbestätigung', function () { + Mail::fake(); + ['order' => $order] = makeShopOrder('kunde@example.com'); + + $response = $this->post(route('withdrawal.store'), [ + 'order_number' => (string) $order->id, + 'email' => 'kunde@example.com', + 'scope' => 'full', + ]); + + $response->assertOk(); + + $withdrawal = OrderWithdrawal::first(); + expect($withdrawal)->not->toBeNull() + ->and($withdrawal->shopping_order_id)->toBe($order->id) + ->and($withdrawal->type)->toBe(OrderWithdrawal::TYPE_FULL) + ->and($withdrawal->status)->toBe(OrderWithdrawal::STATUS_RECEIVED) + ->and($withdrawal->customer_email)->toBe('kunde@example.com') + ->and($withdrawal->declared_at)->not->toBeNull(); + + Mail::assertSent(MailWithdrawalConfirmation::class, function (MailWithdrawalConfirmation $mail) { + return $mail->hasTo('kunde@example.com') + && $mail->hasBcc(config('app.withdrawal_mail')); + }); +}); + +it('findet die Bestellung auch über Groß-/Kleinschreibung der E-Mail', function () { + Mail::fake(); + ['order' => $order] = makeShopOrder('kunde@example.com'); + + $this->post(route('withdrawal.store'), [ + 'order_number' => (string) $order->id, + 'email' => 'KUNDE@EXAMPLE.COM', + 'scope' => 'full', + ])->assertOk(); + + expect(OrderWithdrawal::count())->toBe(1); +}); + +it('speichert nur die ausgewählten Positionen beim teilweisen Widerruf', function () { + Mail::fake(); + ['order' => $order, 'item' => $item] = makeShopOrder(); + + $this->post(route('withdrawal.store'), [ + 'order_number' => (string) $order->id, + 'email' => 'kunde@example.com', + 'scope' => 'partial', + 'items' => [$item->id => 1], + ])->assertOk(); + + $withdrawal = OrderWithdrawal::first(); + expect($withdrawal->type)->toBe(OrderWithdrawal::TYPE_PARTIAL) + ->and($withdrawal->items)->toHaveCount(1) + ->and($withdrawal->items[0]['qty'])->toBe(1) + ->and($withdrawal->items[0]['shopping_order_item_id'])->toBe($item->id); +}); + +it('lehnt einen teilweisen Widerruf ohne ausgewählte Artikel ab', function () { + Mail::fake(); + ['order' => $order] = makeShopOrder(); + + $this->post(route('withdrawal.store'), [ + 'order_number' => (string) $order->id, + 'email' => 'kunde@example.com', + 'scope' => 'partial', + 'items' => [], + ])->assertRedirect(); + + expect(OrderWithdrawal::count())->toBe(0); + Mail::assertNothingSent(); +}); + +it('findet keine Bestellung bei falscher E-Mail', function () { + ['order' => $order] = makeShopOrder('kunde@example.com'); + + $this->post(route('withdrawal.lookup'), [ + 'order_number' => (string) $order->id, + 'email' => 'falsch@example.com', + ])->assertRedirect(); + + expect(OrderWithdrawal::count())->toBe(0); +}); + +it('widerruft keine Nicht-Shop-Bestellung (payment_for != 6)', function () { + makeShopEnv(); + $country = Country::first(); + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => 0, + 'billing_firstname' => 'Max', + 'billing_lastname' => 'Mustermann', + 'billing_email' => 'kunde@example.com', + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'is_from' => 'user_order', + ]); + $order = ShoppingOrder::create([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => 0, + 'user_shop_id' => 0, + 'payment_for' => 3, + 'country_id' => $country->id, + 'language' => 'de', + 'total' => 119, + ]); + + $this->post(route('withdrawal.store'), [ + 'order_number' => (string) $order->id, + 'email' => 'kunde@example.com', + 'scope' => 'full', + ])->assertRedirect(); + + expect(OrderWithdrawal::count())->toBe(0); +}); + +it('validiert Pflichtfelder', function () { + $this->post(route('withdrawal.store'), [ + 'order_number' => '', + 'email' => 'keine-mail', + 'scope' => 'full', + ])->assertSessionHasErrors(['order_number', 'email']); +});