mivita/app/Http/Controllers/Web/WithdrawalController.php
Kevin 63c36555a5 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>
2026-07-08 15:56:02 +00:00

187 lines
6.6 KiB
PHP

<?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);
}
}