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 eac923968d
commit 63c36555a5
24 changed files with 1607 additions and 92 deletions

View file

@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\OrderWithdrawal;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class OrderWithdrawalController extends Controller
{
public function __construct()
{
$this->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 '<a href="'.route('admin_withdrawals_detail', [$w->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-eye"></span></a> '.$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).'<br><small>'.e($w->customer_email).'</small>';
})
->addColumn('type', function (OrderWithdrawal $w): string {
$label = $w->isFullWithdrawal() ? __('withdrawal.scope_full') : __('withdrawal.scope_partial');
$color = $w->isFullWithdrawal() ? 'dark' : 'info';
return '<span class="badge badge-pill badge-'.$color.'">'.e($label).'</span>';
})
->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 '<span class="badge badge-pill badge-'.$color.'">'.e($w->status).'</span>';
})
->addColumn('action', function (OrderWithdrawal $w): string {
return '<a href="'.route('admin_withdrawals_detail', [$w->id]).'" class="btn btn-sm btn-primary"><i class="fa fa-eye"></i></a>';
})
->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);
}
}

View file

@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\Withdrawal\WithdrawalLookupRequest;
use App\Http\Requests\Withdrawal\WithdrawalStoreRequest;
use App\Mail\MailWithdrawalConfirmation;
use App\Models\OrderWithdrawal;
use App\Models\ShoppingOrder;
use App\Services\Shop;
use App\Services\Util;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Mail;
class WithdrawalController extends Controller
{
/**
* Bestelltyp einer B2C-Shop-Bestellung (Verbraucher mit Warenkorb/Checkout).
*/
private const SHOP_PAYMENT_FOR = 6;
public function create(): View
{
return view('web.templates.widerruf', $this->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<int, int>
*/
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<int|string, mixed> $quantities
* @return array<int, array{shopping_order_item_id: int, product_id: int|null, name: string, qty: int}>
*/
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<string, mixed> $extra
* @return array<string, mixed>
*/
private function viewData(array $extra = []): array
{
return array_merge([
'user_shop' => Util::getUserShop(),
'mylangs' => Shop::getLangChange('webshop'),
'yard_instance' => 'webshop',
], $extra);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Withdrawal;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawalLookupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'order_number' => ['required', 'string', 'max:50'],
'email' => ['required', 'email', 'max:255'],
];
}
/**
* @return array<string, string>
*/
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')]),
];
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\Withdrawal;
use App\Models\OrderWithdrawal;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class WithdrawalStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
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<string, string>
*/
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')]),
];
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Mail;
use App\Models\OrderWithdrawal;
use App\Models\ShoppingOrder;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class MailWithdrawalConfirmation extends Mailable
{
use Queueable, SerializesModels;
public $subject;
public function __construct(
public OrderWithdrawal $withdrawal,
public ?ShoppingOrder $shopping_order = null
) {
$this->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,
]);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Models;
use Database\Factories\OrderWithdrawalFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
/**
* Online-Widerruf einer Verbraucherbestellung (gesetzliche Widerrufs-Button-Pflicht).
*
* @property int $id
* @property int|null $shopping_order_id
* @property int|null $user_shop_id
* @property string $type
* @property string $status
* @property string|null $order_reference
* @property string $customer_email
* @property string|null $customer_name
* @property string|null $reason
* @property array|null $items
* @property string|null $ip_address
* @property string|null $user_agent
* @property string|null $admin_note
* @property Carbon|null $declared_at
*/
class OrderWithdrawal extends Model
{
/** @use HasFactory<OrderWithdrawalFactory> */
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<string, string>
*/
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<int, string>
*/
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;
}
}

View file

@ -1,5 +1,80 @@
<?php <?php
use Acme\Dhl\DhlServiceProvider;
use Acme\Dhl\Facades\DHL;
use Alban\LaravelCollectiveSpatieHtmlParser\FormFacade;
use Alban\LaravelCollectiveSpatieHtmlParser\HtmlFacade;
use Alban\LaravelCollectiveSpatieHtmlParser\ServiceProvider;
use App\Providers\AppServiceProvider;
use App\Providers\EventServiceProvider;
use App\Providers\HorizonServiceProvider;
use App\Providers\RouteServiceProvider;
use App\Providers\YardServiceProvider;
use App\Services\Facade\Yard;
use App\Services\HTMLHelper;
use App\Services\Util;
use Illuminate\Auth\AuthServiceProvider;
use Illuminate\Auth\Passwords\PasswordResetServiceProvider;
use Illuminate\Broadcasting\BroadcastServiceProvider;
use Illuminate\Bus\BusServiceProvider;
use Illuminate\Cache\CacheServiceProvider;
use Illuminate\Cookie\CookieServiceProvider;
use Illuminate\Database\DatabaseServiceProvider;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Encryption\EncryptionServiceProvider;
use Illuminate\Filesystem\FilesystemServiceProvider;
use Illuminate\Foundation\Providers\ConsoleSupportServiceProvider;
use Illuminate\Foundation\Providers\FoundationServiceProvider;
use Illuminate\Hashing\HashServiceProvider;
use Illuminate\Mail\MailServiceProvider;
use Illuminate\Notifications\NotificationServiceProvider;
use Illuminate\Pagination\PaginationServiceProvider;
use Illuminate\Pipeline\PipelineServiceProvider;
use Illuminate\Queue\QueueServiceProvider;
use Illuminate\Redis\RedisServiceProvider;
use Illuminate\Session\SessionServiceProvider;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\View;
use Illuminate\Translation\TranslationServiceProvider;
use Illuminate\Validation\ValidationServiceProvider;
use Illuminate\View\ViewServiceProvider;
use Jenssegers\Date\Date;
use Jenssegers\Date\DateServiceProvider;
use Laravel\Tinker\TinkerServiceProvider;
use Maatwebsite\Excel\ExcelServiceProvider;
use Maatwebsite\Excel\Facades\Excel;
use Yajra\DataTables\DataTablesServiceProvider;
use Yajra\DataTables\Facades\DataTables;
return [ return [
/* /*
@ -64,6 +139,7 @@ return [
'checkout_url' => env('APP_URL_CHECKOUT', 'checkout.'), 'checkout_url' => env('APP_URL_CHECKOUT', 'checkout.'),
'contact_mail' => env('APP_CONTACT_MAIL', 'kontakt@mivita.care'), '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_mail' => env('APP_CHECKOUT_MAIL', 'no-reply@mivita.care'),
'checkout_test_mail' => env('APP_CHECKOUT_TEST_MAIL', 'devtest@mivita.care'), 'checkout_test_mail' => env('APP_CHECKOUT_TEST_MAIL', 'devtest@mivita.care'),
'info_mail' => env('APP_INFO_MAIL', 'no-reply@mivita.care'), 'info_mail' => env('APP_INFO_MAIL', 'no-reply@mivita.care'),
@ -74,7 +150,6 @@ return [
'ipinfo' => env('APP_IPINFO', false), 'ipinfo' => env('APP_IPINFO', false),
'main_tax' => env('APP_MAIN_TAX', 1.19), 'main_tax' => env('APP_MAIN_TAX', 1.19),
'main_tax_rate' => env('APP_MAIN_TAX_RATE', 19), 'main_tax_rate' => env('APP_MAIN_TAX_RATE', 19),
'shipping_tax' => env('APP_SHIPPING_TAX', 19), 'shipping_tax' => env('APP_SHIPPING_TAX', 19),
@ -86,7 +161,6 @@ return [
'url_backend' => env('APP_URL', 'http://mivita.local/'), 'url_backend' => env('APP_URL', 'http://mivita.local/'),
*/ */
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Timezone | Application Timezone
@ -157,53 +231,53 @@ return [
/* /*
* Laravel Framework Service Providers... * Laravel Framework Service Providers...
*/ */
Illuminate\Auth\AuthServiceProvider::class, AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class, BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class, BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class, CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class, CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class, DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class, EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class, FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class, FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class, HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class, MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class, NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class, PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class, PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class, QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class, RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class, SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class, TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class, ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class, ViewServiceProvider::class,
//Reliese\Coders\CodersServiceProvider::class, // Reliese\Coders\CodersServiceProvider::class,
//Intervention\Image\ImageServiceProvider::class, // Intervention\Image\ImageServiceProvider::class,
/* /*
* Package Service Providers... * Package Service Providers...
*/ */
Laravel\Tinker\TinkerServiceProvider::class, TinkerServiceProvider::class,
/* /*
* DHL Package Service Provider... * DHL Package Service Provider...
*/ */
Acme\Dhl\DhlServiceProvider::class, DhlServiceProvider::class,
/* /*
* Application Service Providers... * Application Service Providers...
*/ */
App\Providers\AppServiceProvider::class, AppServiceProvider::class,
App\Providers\AuthServiceProvider::class, App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class, // App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class, EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class, HorizonServiceProvider::class,
//App\Providers\DomainServiceProvider::class, // App\Providers\DomainServiceProvider::class,
App\Providers\RouteServiceProvider::class, RouteServiceProvider::class,
Jenssegers\Date\DateServiceProvider::class, DateServiceProvider::class,
Maatwebsite\Excel\ExcelServiceProvider::class, ExcelServiceProvider::class,
Yajra\DataTables\DataTablesServiceProvider::class, DataTablesServiceProvider::class,
App\Providers\YardServiceProvider::class, YardServiceProvider::class,
Alban\LaravelCollectiveSpatieHtmlParser\ServiceProvider::class, ServiceProvider::class,
], ],
/* /*
@ -220,50 +294,50 @@ return [
'aliases' => [ 'aliases' => [
'App' => Illuminate\Support\Facades\App::class, 'App' => Illuminate\Support\Facades\App::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Artisan' => Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class, 'Auth' => Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class, 'Blade' => Blade::class,
'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Broadcast' => Broadcast::class,
'Bus' => Illuminate\Support\Facades\Bus::class, 'Bus' => Bus::class,
'Cache' => Illuminate\Support\Facades\Cache::class, 'Cache' => Cache::class,
'Config' => Illuminate\Support\Facades\Config::class, 'Config' => Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Cookie' => Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class, 'Crypt' => Crypt::class,
'DB' => Illuminate\Support\Facades\DB::class, 'DB' => DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Eloquent' => Model::class,
'Event' => Illuminate\Support\Facades\Event::class, 'Event' => Event::class,
'File' => Illuminate\Support\Facades\File::class, 'File' => File::class,
'Gate' => Illuminate\Support\Facades\Gate::class, 'Gate' => Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class, 'Hash' => Hash::class,
'Lang' => Illuminate\Support\Facades\Lang::class, 'Lang' => Lang::class,
'Log' => Illuminate\Support\Facades\Log::class, 'Log' => Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class, 'Mail' => Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class, 'Notification' => Notification::class,
'Password' => Illuminate\Support\Facades\Password::class, 'Password' => Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class, 'Queue' => Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redirect' => Redirect::class,
'Redis' => Illuminate\Support\Facades\Redis::class, 'Redis' => Redis::class,
'Request' => Illuminate\Support\Facades\Request::class, 'Request' => Request::class,
'Response' => Illuminate\Support\Facades\Response::class, 'Response' => Response::class,
'Route' => Illuminate\Support\Facades\Route::class, 'Route' => Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class, 'Schema' => Schema::class,
'Session' => Illuminate\Support\Facades\Session::class, 'Session' => Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class, 'Storage' => Storage::class,
'URL' => Illuminate\Support\Facades\URL::class, 'URL' => URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class, 'Validator' => Validator::class,
'View' => Illuminate\Support\Facades\View::class, 'View' => View::class,
//'Input' => Illuminate\Support\Facades\Input::class, // 'Input' => Illuminate\Support\Facades\Input::class,
//'Image' => Intervention\Image\Facades\Image::class, // 'Image' => Intervention\Image\Facades\Image::class,
'Carbon' => Carbon\Carbon::class, 'Carbon' => Carbon\Carbon::class,
'Date' => Jenssegers\Date\Date::class, 'Date' => Date::class,
'HTMLHelper' => App\Services\HTMLHelper::class, 'HTMLHelper' => HTMLHelper::class,
'Util' => App\Services\Util::class, 'Util' => Util::class,
'Excel' => Maatwebsite\Excel\Facades\Excel::class, 'Excel' => Excel::class,
'DataTables' => Yajra\DataTables\Facades\DataTables::class, 'DataTables' => DataTables::class,
'Yard' => App\Services\Facade\Yard::class, 'Yard' => Yard::class,
'DHL' => Acme\Dhl\Facades\DHL::class, 'DHL' => DHL::class,
'Form' => Alban\LaravelCollectiveSpatieHtmlParser\FormFacade::class, 'Form' => FormFacade::class,
'Html' => Alban\LaravelCollectiveSpatieHtmlParser\HtmlFacade::class, 'Html' => HtmlFacade::class,
], ],
]; ];

View file

@ -0,0 +1,40 @@
<?php
namespace Database\Factories;
use App\Models\OrderWithdrawal;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<OrderWithdrawal>
*/
class OrderWithdrawalFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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]);
}
}

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('order_withdrawals', function (Blueprint $table) {
$table->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');
}
};

View file

@ -68,6 +68,7 @@ return [
'customerabo' => 'Kunden Abo', 'customerabo' => 'Kunden Abo',
'abo' => 'Abo', 'abo' => 'Abo',
'abos' => 'Abos', 'abos' => 'Abos',
'withdrawals' => 'Widerrufe',
'payment_links' => 'Bezahllinks', 'payment_links' => 'Bezahllinks',
'dashboard' => 'Dashboard', 'dashboard' => 'Dashboard',
'shop' => 'Shop', 'shop' => 'Shop',

View file

@ -0,0 +1,63 @@
<?php
return [
// Buttons / Navigation
'button_label' => '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',
];

View file

@ -3,6 +3,7 @@
return [ return [
'abo' => 'Subscription', 'abo' => 'Subscription',
'abos' => 'Subscriptions', 'abos' => 'Subscriptions',
'withdrawals' => 'Withdrawals',
'add' => 'add', 'add' => 'add',
'attribute' => 'attributes', 'attribute' => 'attributes',
'business' => 'business', 'business' => 'business',

View file

@ -0,0 +1,63 @@
<?php
return [
// Buttons / Navigation
'button_label' => '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',
];

View file

@ -0,0 +1,98 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">
<a href="{{ route('admin_withdrawals') }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a>
{{ __('navigation.withdrawals') }} <span class="text-muted">#{{ $withdrawal->id }}</span>
</h4>
@if (Session::has('alert-success'))
<div class="alert alert-success p-2 mt-2">{{ Session::get('alert-success') }}</div>
@endif
@if (Session::has('alert-error'))
<div class="alert alert-danger p-2 mt-2">{{ Session::get('alert-error') }}</div>
@endif
<div class="row">
<div class="col-md-7">
<div class="card mb-4">
<div class="card-body">
<table class="table table-borderless mb-0">
<tr>
<th>{{ __('withdrawal.success_received_at') }}</th>
<td>{{ optional($withdrawal->declared_at)->format('d.m.Y H:i') }} {{ __('withdrawal.clock') }}</td>
</tr>
<tr>
<th>{{ __('withdrawal.order_number') }}</th>
<td>
#{{ $withdrawal->order_reference }}
@if ($withdrawal->shopping_order_id)
<a href="{{ route('admin_sales_customers_detail', [$withdrawal->shopping_order->shopping_user_id ?? 0]) }}"
class="btn btn-xs btn-outline-primary ml-1"><i class="fa fa-external-link-alt"></i></a>
@endif
</td>
</tr>
<tr>
<th>{{ __('withdrawal.choose_scope_hl') }}</th>
<td>
@if ($withdrawal->isFullWithdrawal())
{{ __('withdrawal.scope_full') }}
@else
{{ __('withdrawal.scope_partial') }}
@endif
</td>
</tr>
<tr>
<th>Kunde</th>
<td>{{ $withdrawal->customer_name }}<br><small>{{ $withdrawal->customer_email }}</small></td>
</tr>
@if ($withdrawal->user_shop)
<tr>
<th>Shop</th>
<td>{{ $withdrawal->user_shop->name }}</td>
</tr>
@endif
@if ($withdrawal->reason)
<tr>
<th>{{ __('withdrawal.reason') }}</th>
<td>{{ $withdrawal->reason }}</td>
</tr>
@endif
</table>
</div>
</div>
@if (! empty($withdrawal->items))
<div class="card mb-4">
<div class="card-header font-weight-bold">{{ __('withdrawal.mail_items_hl') }}</div>
<div class="card-body">
<ul class="mb-0">
@foreach ($withdrawal->items as $item)
<li>{{ $item['qty'] }} &times; {{ $item['name'] }}</li>
@endforeach
</ul>
</div>
</div>
@endif
</div>
<div class="col-md-5">
<div class="card mb-4">
<div class="card-header font-weight-bold">Status &amp; Notiz</div>
<div class="card-body">
{!! Form::open(['route' => ['admin_withdrawals_update', $withdrawal->id], 'method' => 'post']) !!}
<div class="form-group">
{!! Form::label('status', 'Status') !!}
{!! Form::select('status', collect($statuses)->mapWithKeys(fn ($s) => [$s => ucfirst($s)])->all(), $withdrawal->status, ['class' => 'form-control']) !!}
</div>
<div class="form-group">
{!! Form::label('admin_note', 'Interne Notiz') !!}
{!! Form::textarea('admin_note', $withdrawal->admin_note, ['class' => 'form-control', 'rows' => 5]) !!}
</div>
<button type="submit" class="btn btn-primary"><i class="fa fa-save"></i> {{ __('save') }}</button>
{!! Form::close() !!}
</div>
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,57 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">
{{ __('navigation.withdrawals') }}
</h4>
<div class="row">
<div class="col-12">
<div class="card mb-4">
<div class="card-datatable table-responsive">
<table class="datatables-withdrawals table table-striped table-bordered" id="datatables-withdrawals">
<thead>
<tr>
<th>ID</th>
<th>{{ __('withdrawal.success_received_at') }}</th>
<th>{{ __('withdrawal.order_number') }}</th>
<th>Kunde</th>
<th>{{ __('withdrawal.choose_scope_hl') }}</th>
<th>Status</th>
<th>#</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$('#datatables-withdrawals').DataTable({
"processing": true,
"serverSide": true,
ajax: {
url: '{!! route('admin_withdrawals_datatable') !!}',
data: function (d) {}
},
"order": [[0, "desc"]],
"columns": [
{data: 'id', name: 'id'},
{data: 'declared_at', name: 'declared_at'},
{data: 'order_reference', name: 'order_reference'},
{data: 'customer', name: 'customer'},
{data: 'type', name: 'type', searchable: false, orderable: false},
{data: 'status', name: 'status'},
{data: 'action', name: 'action', searchable: false, orderable: false},
],
"bLengthChange": false,
"iDisplayLength": 100,
"language": {
"url": "/js/datatables-{{ \App::getLocale() }}.json"
}
});
});
</script>
@endsection

View file

@ -0,0 +1,131 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>mivita.care</title>
<style type="text/css">
body {
-webkit-font-smoothing: antialiased;
width: 100%;
color: #37302d;
background: #f8f8f8;
font-size: 15px;
line-height: 26px;
}
td, h1, h2, h3 {
font-family: Helvetica, Verdana, Arial, sans-serif;
font-weight: 400;
}
.headline {
color: #37302d;
font-size: 18px;
}
.sub_headline {
color: #788662;
font-size: 15px;
}
hr {
border: none;
border-top: 1px solid #b7c59e;
}
.info-box {
padding: 20px;
border: 1px solid #eee;
background-color: #f6fdf5;
line-height: 1.6em;
}
</style>
</head>
<body style="padding:0; margin:0; background:#f8f8f8;" bgcolor="#f8f8f8">
<table align="left" cellpadding="0" cellspacing="0" width="100%" height="100%">
<tr>
<td align="left" valign="top" bgcolor="#f8f8f8" width="100%">
<br>
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="700">
<tr>
<td align="center" valign="top">
<img src="https://my.mivita.care/images/logo_mivita.png" alt="mivita.care" style="border:none" width="230">
<br><br>
</td>
</tr>
<tr>
<td>
<table cellpadding="10" style="padding: 10px;" cellspacing="0" border="0" width="100%" bgcolor="#ffffff">
<tr>
<td class="headline">
<b>{{ __('withdrawal.mail_hello') }} {{ $withdrawal->customer_name ?: '' }},</b>
</td>
</tr>
<tr>
<td style="color:#37302d; line-height:1.6em;">
{{ __('withdrawal.mail_intro') }}
</td>
</tr>
<tr>
<td>
<table class="info-box" cellpadding="2" cellspacing="0" width="100%">
<tr>
<td>
<b>{{ __('withdrawal.mail_received_at') }}:</b>
{{ optional($withdrawal->declared_at)->format('d.m.Y H:i') }} {{ __('withdrawal.clock') }}<br>
<b>{{ __('withdrawal.mail_order_number') }}:</b> #{{ $withdrawal->order_reference }}<br>
<b>{{ __('withdrawal.mail_reference') }}:</b> {{ $withdrawal->id }}<br>
<b>{{ __('withdrawal.mail_scope') }}:</b>
@if ($withdrawal->isFullWithdrawal())
{{ __('withdrawal.scope_full') }}
@else
{{ __('withdrawal.scope_partial') }}
@endif
</td>
</tr>
</table>
</td>
</tr>
@if (! empty($withdrawal->items))
<tr>
<td class="sub_headline"><hr><b>{{ __('withdrawal.mail_items_hl') }}</b></td>
</tr>
<tr>
<td>
<table class="info-box" cellpadding="2" cellspacing="0" width="100%">
@foreach ($withdrawal->items as $item)
<tr>
<td>{{ $item['qty'] }} &times; {{ $item['name'] }}</td>
</tr>
@endforeach
</table>
</td>
</tr>
@endif
@if ($withdrawal->reason)
<tr>
<td style="color:#37302d; line-height:1.6em;">
<hr>{{ $withdrawal->reason }}
</td>
</tr>
@endif
<tr>
<td style="color:#37302d; line-height:1.6em;">
<hr>{{ __('withdrawal.mail_outro') }}
<br><br>
{{ __('withdrawal.mail_greetings') }}<br>
<b>{{ __('withdrawal.mail_sender') }}</b>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="color:#7B7B7E; font-size:12px; text-align: center; padding-top: 16px;" align="center">
mivita care gmbh &bull; Leinfeld 2 &bull; 87755 Kirchhaslach<br>
<a href="https://www.mivita.care" style="color: #7B7B7E;">www.mivita.care</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -315,7 +315,7 @@
<div>{{ __('navigation.clients') }}</div> <div>{{ __('navigation.clients') }}</div>
</a> </a>
</li> </li>
<li class="sidenav-item @if (Request::is('admin/sales/*', 'admin/abos', 'admin/abos/*')) open @endif"> <li class="sidenav-item @if (Request::is('admin/sales/*', 'admin/abos', 'admin/abos/*', 'admin/withdrawals', 'admin/withdrawals/*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle"> <a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-ios-cart"></i> <i class="sidenav-icon ion ion-ios-cart"></i>
<div>{{ __('navigation.orders') }}</div> <div>{{ __('navigation.orders') }}</div>
@ -341,6 +341,12 @@
<div>{{ __('navigation.abos') }}</div> <div>{{ __('navigation.abos') }}</div>
</a> </a>
</li> </li>
<li class="sidenav-item{{ Request::is('admin/withdrawals', 'admin/withdrawals/*') ? ' active' : '' }}">
<a href="{{ route('admin_withdrawals') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-undo"></i>
<div>{{ __('navigation.withdrawals') }}</div>
</a>
</li>
</ul> </ul>
</li> </li>
<li class="sidenav-item{{ Request::is('admin/dhl/*') ? ' active' : '' }}"> <li class="sidenav-item{{ Request::is('admin/dhl/*') ? ' active' : '' }}">

View file

@ -16,6 +16,16 @@
{{ __('shop.order_customer') }} <span class="text-muted">#{{$shopping_order->id}}</span> {{ __('shop.order_customer') }} <span class="text-muted">#{{$shopping_order->id}}</span>
</h4> </h4>
@include('portal.order._detail') @include('portal.order._detail')
<a href="{{route('portal.my_orders')}}" class="btn btn-sm btn-default mt-2 float-right">{{ __('back') }}</a> <div class="mt-2 d-flex justify-content-between align-items-center">
@if($shopping_order->payment_for === 6)
<a href="{{ route('withdrawal.create', ['order' => $shopping_order->id, 'email' => optional($shopping_order->shopping_user)->billing_email]) }}"
class="btn btn-sm btn-outline-danger">
<i class="fa fa-undo"></i> {{ __('withdrawal.button_label') }}
</a>
@else
<span></span>
@endif
<a href="{{route('portal.my_orders')}}" class="btn btn-sm btn-default">{{ __('back') }}</a>
</div>
@endsection @endsection

View file

@ -97,6 +97,9 @@
<li class=""><a target="_blank" <li class=""><a target="_blank"
href="{{ asset('download/mivita_widerruf_formular.pdf') }}">{{ __('website.cancellation_policy') }} </a></li> href="{{ asset('download/mivita_widerruf_formular.pdf') }}">{{ __('website.cancellation_policy') }} </a></li>
<li>&bull;</li> <li>&bull;</li>
<li class="{{ Request::is('widerruf') ? ' active' : '' }}"><a
href="{{ route('withdrawal.create') }}">{{ __('withdrawal.button_label') }} </a></li>
<li>&bull;</li>
<li class="{{ Request::is('datenschutz') ? ' active' : '' }}"><a <li class="{{ Request::is('datenschutz') ? ' active' : '' }}"><a
href="{{ url('/datenschutz') }}">{{ __('website.privacy_policy') }} </a></li> href="{{ url('/datenschutz') }}">{{ __('website.privacy_policy') }} </a></li>
<li>&bull;</li> <li>&bull;</li>

View file

@ -0,0 +1,49 @@
@extends($user_shop ? 'web.user.layouts.layout' : 'web.layouts.layout')
@section('content')
<section class="page-header page-header-xlg parallax parallax-3"
style="background-image:url('/assets/images/vision-min.jpg')">
<div class="overlay dark-1"></div>
<div class="container"></div>
</section>
<section>
<div class="container">
<div class="panel panel-default" style="max-width: 760px; margin: 0 auto;">
<div class="panel-body">
<h3><i class="fa fa-check-circle text-success"></i> {{ __('withdrawal.success_hl') }}</h3>
<p>{{ __('withdrawal.success_intro') }}</p>
<hr>
<p>
<strong>{{ __('withdrawal.success_received_at') }}:</strong>
{{ optional($withdrawal->declared_at)->format('d.m.Y H:i') }} {{ __('withdrawal.clock') }}<br>
<strong>{{ __('withdrawal.order_number') }}:</strong> #{{ $withdrawal->order_reference }}<br>
<strong>{{ __('withdrawal.success_reference') }}:</strong> {{ $withdrawal->id }}
</p>
<p>
@if ($withdrawal->isFullWithdrawal())
{{ __('withdrawal.success_full') }}
@else
{{ __('withdrawal.success_partial') }}
@endif
</p>
@if (! empty($withdrawal->items))
<ul>
@foreach ($withdrawal->items as $item)
<li>{{ $item['qty'] }} &times; {{ $item['name'] }}</li>
@endforeach
</ul>
@endif
<hr>
<p>{{ __('withdrawal.success_email_sent', ['email' => $withdrawal->customer_email]) }}</p>
</div>
</div>
</div>
</section>
@endsection

View file

@ -0,0 +1,160 @@
@extends($user_shop ? 'web.user.layouts.layout' : 'web.layouts.layout')
@section('content')
<section class="page-header page-header-xlg parallax parallax-3"
style="background-image:url('/assets/images/vision-min.jpg')">
<div class="overlay dark-1"></div>
<div class="container"></div>
</section>
<style type="text/css">
.withdrawal-box {
max-width: 760px;
margin: 0 auto;
}
.withdrawal-box .alert.error {
font-weight: bold;
}
.withdrawal-scope label {
display: block;
font-size: 15px;
margin-bottom: 8px;
}
.withdrawal-items .qty-input {
max-width: 90px;
}
.legal-note {
font-size: 13px;
color: #6b7758;
}
</style>
<section>
<div class="container">
<div class="withdrawal-box box-static box-transparent box-bordered padding-20">
<h1>{{ __('withdrawal.page_title') }}</h1>
<p>{{ __('withdrawal.intro') }}</p>
@if (session('alert-error'))
<div class="alert alert-danger error">{{ session('alert-error') }}</div>
@endif
@if (! $order)
{{-- Phase 1: Bestellung identifizieren --}}
<hr>
<h3>{{ __('withdrawal.identify_hl') }}</h3>
{!! Form::open(['route' => 'withdrawal.lookup', 'method' => 'post']) !!}
<div class="form-group">
{!! 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')]) !!}
<small class="form-text text-muted">{{ __('withdrawal.order_number_help') }}</small>
@if ($errors->has('order_number'))
<div class="alert alert-danger error">{{ $errors->first('order_number') }}</div>
@endif
</div>
<div class="form-group">
{!! 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'))
<div class="alert alert-danger error">{{ $errors->first('email') }}</div>
@endif
</div>
<button type="submit" class="btn btn-primary btn-text-500">
<i class="fa fa-search"></i> {{ __('withdrawal.find_order') }}
</button>
{!! Form::close() !!}
@else
{{-- Phase 2: Umfang wählen und absenden --}}
<hr>
<h3>{{ __('withdrawal.order_found_hl') }}</h3>
<p>
<strong>{{ __('withdrawal.order_number') }}:</strong> #{{ $order->id }}<br>
<strong>{{ __('withdrawal.order_date') }}:</strong> {{ optional($order->created_at)->format('d.m.Y H:i') }}
</p>
{!! Form::open(['route' => 'withdrawal.store', 'method' => 'post']) !!}
{!! Form::hidden('order_number', $order->id) !!}
{!! Form::hidden('email', $prefill_email) !!}
<hr>
<h4>{{ __('withdrawal.choose_scope_hl') }}</h4>
<div class="withdrawal-scope">
<label class="radio">
{!! Form::radio('scope', \App\Models\OrderWithdrawal::TYPE_FULL, old('scope', 'full') === 'full', ['id' => 'scope_full']) !!}
<i></i> {{ __('withdrawal.scope_full') }}
</label>
<label class="radio">
{!! Form::radio('scope', \App\Models\OrderWithdrawal::TYPE_PARTIAL, old('scope') === 'partial', ['id' => 'scope_partial']) !!}
<i></i> {{ __('withdrawal.scope_partial') }}
</label>
@if ($errors->has('scope'))
<div class="alert alert-danger error">{{ $errors->first('scope') }}</div>
@endif
</div>
<div id="withdrawal_items" class="withdrawal-items" style="display: none;">
<hr>
<h4>{{ __('withdrawal.select_items_hl') }}</h4>
<table class="table table-striped">
<thead>
<tr>
<th>{{ __('withdrawal.product') }}</th>
<th class="text-center">{{ __('withdrawal.quantity') }}</th>
<th class="text-center">{{ __('withdrawal.withdraw_quantity') }}</th>
</tr>
</thead>
<tbody>
@foreach ($order->shopping_order_items as $item)
<tr>
<td>{{ $item->product?->getLang('name') ?? ('#' . $item->product_id) }}</td>
<td class="text-center">{{ (int) $item->qty }}</td>
<td class="text-center">
{!! Form::number('items[' . $item->id . ']', old('items.' . $item->id, 0), ['class' => 'form-control qty-input mx-auto', 'min' => 0, 'max' => (int) $item->qty]) !!}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<hr>
<div class="form-group">
{!! Form::label('reason', __('withdrawal.reason')) !!}
{!! Form::textarea('reason', old('reason'), ['class' => 'form-control', 'rows' => 3, 'placeholder' => __('withdrawal.reason_placeholder')]) !!}
</div>
<p class="legal-note">{{ __('withdrawal.legal_note') }}</p>
<div class="mt-3">
<a href="{{ route('withdrawal.create') }}" class="btn btn-default btn-text-500">
<i class="fa fa-angle-left"></i> {{ __('withdrawal.back') }}
</a>
<button type="submit" class="btn btn-primary btn-text-500">
<i class="fa fa-check"></i> {{ __('withdrawal.submit') }}
</button>
</div>
{!! Form::close() !!}
@endif
</div>
</div>
</section>
@endsection
@section('scripts')
<script>
$(document).ready(function () {
function toggleItems() {
if ($('#scope_partial').is(':checked')) {
$('#withdrawal_items').show();
} else {
$('#withdrawal_items').hide();
}
}
$('input[type=radio][name=scope]').on('change', toggleItems);
toggleItems();
});
</script>
@endsection

View file

@ -138,6 +138,9 @@
<li class=""><a target="_blank" <li class=""><a target="_blank"
href="{{ asset('download/mivita_widerruf_formular.pdf') }}">{{ __('website.cancellation_policy') }}</a></li> href="{{ asset('download/mivita_widerruf_formular.pdf') }}">{{ __('website.cancellation_policy') }}</a></li>
<li>&bull;</li> <li>&bull;</li>
<li class="{{ Request::is('widerruf') ? ' active' : '' }}"><a
href="{{ route('withdrawal.create') }}">{{ __('withdrawal.button_label') }}</a></li>
<li>&bull;</li>
<li class="{{ Request::is('datenschutz') ? ' active' : '' }}"><a <li class="{{ Request::is('datenschutz') ? ' active' : '' }}"><a
href="{{ legal_url('/datenschutz') }}">{{ __('website.privacy_policy') }}</a></li> href="{{ legal_url('/datenschutz') }}">{{ __('website.privacy_policy') }}</a></li>
<li>&bull;</li> <li>&bull;</li>

View file

@ -1,5 +1,12 @@
<?php <?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) | Geteilte Routen (Shared Routes)
@ -12,7 +19,7 @@
// Rechtliche und Kontakt-Routen - Umleitung zur Shop-Domain für checkout.* // Rechtliche und Kontakt-Routen - Umleitung zur Shop-Domain für checkout.*
// Verwendet EarlyDomainParser für domain-awareness // Verwendet EarlyDomainParser für domain-awareness
if (\App\Domain\EarlyDomainParser::getCurrentDomainType() === 'checkout') { if (EarlyDomainParser::getCurrentDomainType() === 'checkout') {
// Shop-Domain aus Config laden // Shop-Domain aus Config laden
$shopHost = config('domains.domains.shop.host'); $shopHost = config('domains.domains.shop.host');
$protocol = config('domains.protocol', 'https://'); $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 für den Sprachwechsel (aus alter utility.php wiederhergestellt)
Route::get('translation/{locale}', function ($locale) { Route::get('translation/{locale}', function ($locale) {
$normalized = \App\Services\LocaleGuard::normalize($locale); $normalized = LocaleGuard::normalize($locale);
if ($normalized === null) { if ($normalized === null) {
return redirect()->back(); return redirect()->back();
} }
@ -84,7 +91,7 @@ Route::get('translation/{locale}', function ($locale) {
// Bild-Routen (aus alter utility.php wiederhergestellt) // Bild-Routen (aus alter utility.php wiederhergestellt)
Route::get('/product/image/{slug}', function ($slug = null) { 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; $path = storage_path('app/public').'/images/product'.'/'.$image->product_id.'/'.$image->filename;
if (file_exists($path)) { if (file_exists($path)) {
return response()->file($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) { Route::get('storage/images/{from}/{slug}', function ($from = null, $slug = null) {
if ($from == 'shop') { 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; $path = storage_path('app/public').'/images/shop'.'/'.$image->filename;
if (file_exists($path)) { if (file_exists($path)) {
return Response::file($path); return Response::file($path);
@ -105,7 +112,7 @@ Route::get('storage/images/{from}/{slug}', function ($from = null, $slug = null)
})->name('storage_images'); })->name('storage_images');
Route::get('/iq/image/{slug}', function ($slug = null) { 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; $path = storage_path('app/public').'/images/iq_images/'.$image->filename;
if (file_exists($path)) { if (file_exists($path)) {
return Response::file($path); return Response::file($path);
@ -114,7 +121,7 @@ Route::get('/iq/image/{slug}', function ($slug = null) {
})->name('iq_image'); })->name('iq_image');
Route::get('/user_shop/image/{slug}', function ($slug = null) { 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; $path = storage_path('app/public').'/images/user_shop'.'/'.$image->user_shop_id.'/'.$image->filename;
if (file_exists($path)) { if (file_exists($path)) {
return Response::file($path); return Response::file($path);
@ -123,7 +130,7 @@ Route::get('/user_shop/image/{slug}', function ($slug = null) {
})->name('user_shop_image'); })->name('user_shop_image');
Route::get('/shop/product/image/{slug}', function ($slug = null) { 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; $path = storage_path('app/public').'/images/product'.'/'.$image->product_id.'/'.$image->filename;
if (file_exists($path)) { if (file_exists($path)) {
return Response::file($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 // Alias für /home, damit auch /home erreichbar ist
// Checkout-Weiterleitung nur für Domains, die NICHT checkout.* sind // 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 // Checkout-Domain aus Config laden
$checkoutHost = config('domains.domains.checkout.host'); $checkoutHost = config('domains.domains.checkout.host');
$protocol = config('domains.protocol', 'https://'); $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'); 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');

View file

@ -0,0 +1,192 @@
<?php
use App\Mail\MailWithdrawalConfirmation;
use App\Models\Country;
use App\Models\OrderWithdrawal;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* @return array{order: ShoppingOrder, item: ShoppingOrderItem}
*/
function makeShopOrder(string $email = 'kunde@example.com'): array
{
makeShopEnv();
$country = Country::first();
$product = makeProduct(['1']);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => 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']);
});