diff --git a/app/Http/Controllers/CustomerController.php b/app/Http/Controllers/CustomerController.php index 741aa8f4..e6f66633 100644 --- a/app/Http/Controllers/CustomerController.php +++ b/app/Http/Controllers/CustomerController.php @@ -45,6 +45,7 @@ class CustomerController extends Controller $shopping_user = ShoppingUser::findOrFail($id); $data = [ 'shopping_user' => $shopping_user, + 'deletion' => app(GdprDeletionService::class)->analyzeShoppingUser($shopping_user), 'isAdmin' => true, 'isView' => 'customer', ]; @@ -52,16 +53,26 @@ class CustomerController extends Controller return view('admin.customer.detail', $data); } + /** + * Komplett-Löschung (nur ohne Bestellungen), dev/_tickets/2026-09-08.md #2. + */ public function delete($id) { - $shopping_user = ShoppingUser::findOrFail($id); - $result = $this->customerRepository->deleteCustomer($shopping_user); - if (! $result) { - return back()->with('alert-error', 'Kunde hat Bestellungen. Löschen nicht möglich.'); - } else { - \Session()->flash('alert-success', 'Kunde wurde gelöscht'); + $shopping_user = ShoppingUser::withTrashed()->findOrFail($id); - return redirect(route('admin_customers')); + if (! Request::get('delete_customer')) { + \Session()->flash('alert-error', __('msg.delete_confirm_checkbox_missing')); + + return redirect(route('admin_customer_detail', [$shopping_user->id])); + } + + try { + $summary = app(GdprDeletionService::class)->purgeShoppingUser($shopping_user, Auth::id()); + \Session()->flash('alert-success', __('msg.gdpr_purge_customer_success', ['count' => $summary['shopping_users_purged']])); + } catch (GdprDeletionException $e) { + \Session()->flash('alert-error', $e->getMessage()); + + return redirect(route('admin_customer_detail', [$shopping_user->id])); } return redirect(route('admin_customers')); @@ -72,7 +83,7 @@ class CustomerController extends Controller $shopping_user = ShoppingUser::withTrashed()->findOrFail($id); if (! Request::get('gdpr_delete_customer')) { - \Session()->flash('alert-danger', __('msg.gdpr_confirm_checkbox_missing')); + \Session()->flash('alert-error', __('msg.gdpr_confirm_checkbox_missing')); return redirect(route('admin_customer_detail', [$shopping_user->id])); } @@ -81,7 +92,7 @@ class CustomerController extends Controller app(GdprDeletionService::class)->deleteShoppingUser($shopping_user, Auth::id()); \Session()->flash('alert-success', __('msg.gdpr_delete_customer_success')); } catch (GdprDeletionException $e) { - \Session()->flash('alert-danger', $e->getMessage()); + \Session()->flash('alert-error', $e->getMessage()); return redirect(route('admin_customer_detail', [$shopping_user->id])); } diff --git a/app/Repositories/CustomerRepository.php b/app/Repositories/CustomerRepository.php index a52ecbed..dfa30730 100644 --- a/app/Repositories/CustomerRepository.php +++ b/app/Repositories/CustomerRepository.php @@ -2,8 +2,6 @@ namespace App\Repositories; -use App\Models\ShoppingUser; - class CustomerRepository extends BaseRepository { public function __construct() @@ -11,44 +9,6 @@ class CustomerRepository extends BaseRepository // $this->model = $model; } - public function deleteCustomer(ShoppingUser $shopping_user) - { - if ($shopping_user->shopping_orders->count() > 0) { - return false; - } - $shopping_user->auth_user_id = null; - $shopping_user->member_id = null; - $shopping_user->billing_salutation = null; - $shopping_user->billing_company = null; - $shopping_user->billing_firstname = 'Deleted'; - $shopping_user->billing_lastname = 'User'; - $shopping_user->billing_address = 'Deleted'; - $shopping_user->billing_address_2 = null; - $shopping_user->billing_zipcode = '00000'; - $shopping_user->billing_city = 'Deleted'; - $shopping_user->billing_phone = null; - $shopping_user->billing_email = 'deleted_'.$shopping_user->id.'@deleted.com'; - - $shopping_user->shipping_salutation = null; - $shopping_user->shipping_company = null; - $shopping_user->shipping_firstname = 'Deleted'; - $shopping_user->shipping_lastname = 'User'; - $shopping_user->shipping_address = 'Deleted'; - $shopping_user->shipping_address_2 = null; - $shopping_user->shipping_zipcode = '00000'; - $shopping_user->shipping_city = 'Deleted'; - $shopping_user->shipping_phone = null; - $shopping_user->shipping_email = 'deleted_'.$shopping_user->id.'@deleted.com'; - - $shopping_user->remarks = null; - $shopping_user->notice = null; - $shopping_user->faker_mail = true; - - $shopping_user->save(); - - return true; - } - public function update($data) { diff --git a/app/Services/GdprDeletionService.php b/app/Services/GdprDeletionService.php index 3a2bf3c7..7a6e173b 100644 --- a/app/Services/GdprDeletionService.php +++ b/app/Services/GdprDeletionService.php @@ -3,6 +3,8 @@ namespace App\Services; use App\Exceptions\GdprDeletionException; +use App\Models\Customer; +use App\Models\ShoppingOrder; use App\Models\ShoppingUser; use App\Models\UserAbo; use App\Models\UserAccount; @@ -30,6 +32,12 @@ use Illuminate\Support\Facades\Storage; * denormalisierten Personendaten werden anonymisiert. * - Stripe-Customer-Objekte werden gelöscht (Phase SE1.6, dev/2026-08-26-stripe/); * die Charges bleiben bei Stripe erhalten — sie sind Buchungsbelege. + * + * Endkunden werden pro Bestellung neu angelegt (mehrere shopping_users-Zeilen). + * Alle Kunden-Operationen arbeiten deshalb auf der Gruppe aller Einträge mit + * derselben Kundennummer ODER derselben E-Mail-Adresse (dev/_tickets/2026-09-08.md, #2): + * - deleteShoppingUser(): DSGVO-Anonymisierung (Pflicht, sobald Bestellungen vorliegen) + * - purgeShoppingUser(): Komplett-Löschung, nur solange kein Eintrag eine Bestellung hat */ class GdprDeletionService { @@ -128,13 +136,14 @@ class GdprDeletionService $stripeCustomerIds = $this->collectStripeCustomerIds(null, $rows); - $summary = DB::transaction(function () use ($shoppingUser, $rows) { + $summary = DB::transaction(function () use ($shoppingUser, $rows, $originalEmail) { $summary = ['shopping_user_id' => $shoppingUser->id]; $summary['abos_anonymized'] = 0; foreach ($rows as $row) { $summary['abos_anonymized'] += $this->anonymizeUserAbos('shopping_user_id', $row->id); } $summary['shopping_users_anonymized'] = $this->anonymizeShoppingUserRows($rows); + $summary['portal_customers_deleted'] = $this->deletePortalCustomers($rows, $originalEmail); return $summary; }); @@ -147,17 +156,170 @@ class GdprDeletionService } /** - * Alle Datensätze eines Endkunden (gleiche Kundennummer), inkl. soft-deleted. + * Endkunden (ShoppingUser) komplett löschen — nur solange keiner seiner + * Einträge eine Bestellung hat (dann gibt es keine Aufbewahrungspflicht). + * + * Alle Einträge der Gruppe werden vollständig anonymisiert und soft-deleted, + * zugehörige (inaktive) Abos verlieren Kontakt- und Zahlungsdaten, die + * Portal-Logins (customers) werden entfernt. Ein Hard-Delete ist bewusst nicht + * vorgesehen: Fremdschlüssel (user_abos, checkout_funnel_events, Logs) zeigen + * auf shopping_users, und der Lösch-Nachweis bleibt so erhalten. + * + * @return array + * + * @throws GdprDeletionException wenn der Datensatz zu einem Berater gehört, Bestellungen oder aktive Abos bestehen + */ + public function purgeShoppingUser(ShoppingUser $shoppingUser, ?int $executedByUserId = null): array + { + $analysis = $this->analyzeShoppingUser($shoppingUser); + + if ($analysis['is_member']) { + throw new GdprDeletionException(__('msg.gdpr_error_customer_is_member')); + } + if ($analysis['orders_count'] > 0) { + throw new GdprDeletionException(__('msg.gdpr_error_customer_has_orders')); + } + if ($analysis['active_abos'] > 0) { + throw new GdprDeletionException(__('msg.gdpr_error_active_abo')); + } + + $rows = $analysis['rows']; + $originalEmail = $shoppingUser->billing_email; + $stripeCustomerIds = $this->collectStripeCustomerIds(null, $rows); + + $summary = DB::transaction(function () use ($shoppingUser, $rows, $originalEmail) { + $summary = ['shopping_user_id' => $shoppingUser->id]; + $summary['abos_anonymized'] = 0; + $summary['shopping_users_purged'] = 0; + foreach ($rows as $row) { + $summary['abos_anonymized'] += $this->anonymizeUserAbos('shopping_user_id', $row->id); + $this->anonymizeShoppingUserComplete($row); + $row->delete(); + $summary['shopping_users_purged']++; + } + $summary['portal_customers_deleted'] = $this->deletePortalCustomers($rows, $originalEmail); + + return $summary; + }); + + $summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds); + + $this->logDeletion('gdpr-purge-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary); + + return $summary; + } + + /** + * Analyse für das Lösch-Modal: Wie oft ist der Kunde angelegt, welche Einträge + * haben Bestellungen, und welche Löschart ist möglich? + * + * @return array{ + * rows: \Illuminate\Database\Eloquent\Collection, + * email: string|null, + * is_member: bool, + * orders_count: int, + * orders_by_row: array, + * active_abos: int, + * can_purge: bool + * } + */ + public function analyzeShoppingUser(ShoppingUser $shoppingUser): array + { + $rows = $this->getCustomerRows($shoppingUser); + $rowIds = $rows->pluck('id')->all(); + + $ordersByRow = ShoppingOrder::withTrashed() + ->whereIn('shopping_user_id', $rowIds) + ->selectRaw('shopping_user_id, count(*) as orders_count') + ->groupBy('shopping_user_id') + ->pluck('orders_count', 'shopping_user_id') + ->map(fn ($count) => (int) $count) + ->all(); + + $isMember = $rows->contains(fn (ShoppingUser $row) => (int) $row->auth_user_id > 0); + $ordersCount = array_sum($ordersByRow); + $activeAbos = UserAbo::whereIn('shopping_user_id', $rowIds)->where('active', true)->count(); + + return [ + 'rows' => $rows, + 'email' => $this->groupingEmail($shoppingUser), + 'is_member' => $isMember, + 'orders_count' => $ordersCount, + 'orders_by_row' => $ordersByRow, + 'active_abos' => $activeAbos, + 'can_purge' => ! $isMember && $ordersCount === 0 && $activeAbos === 0, + ]; + } + + /** + * Alle Datensätze eines Endkunden, inkl. soft-deleted: der Eintrag selbst, + * alle Einträge mit derselben Kundennummer und alle Endkunden-Einträge mit + * derselben E-Mail-Adresse (Kunden werden pro Bestellung neu angelegt und + * bekommen nicht immer dieselbe Kundennummer). * * @return \Illuminate\Database\Eloquent\Collection */ public function getCustomerRows(ShoppingUser $shoppingUser) { - if ($shoppingUser->number > 0) { - return ShoppingUser::withTrashed()->where('number', $shoppingUser->number)->get(); + $email = $this->groupingEmail($shoppingUser); + + return ShoppingUser::withTrashed() + ->where(function ($query) use ($shoppingUser, $email) { + $query->where('id', $shoppingUser->id); + if ($shoppingUser->number > 0) { + $query->orWhere('number', $shoppingUser->number); + } + if ($email !== null) { + $query->orWhere(function ($emailQuery) use ($email) { + $emailQuery->whereRaw('LOWER(billing_email) = ?', [$email]) + ->where(function ($endCustomer) { + $endCustomer->whereNull('auth_user_id')->orWhere('auth_user_id', 0); + }); + }); + } + }) + ->orderBy('id') + ->get(); + } + + /** + * E-Mail-Adresse, über die Mehrfach-Anlagen eines Kunden erkannt werden. + * Platzhalter-Adressen (Faker, bereits anonymisiert) taugen nicht zur + * Gruppierung — sie würden fremde Kunden zusammenziehen. + */ + public function groupingEmail(ShoppingUser $shoppingUser): ?string + { + $email = mb_strtolower(trim((string) $shoppingUser->billing_email)); + if ($email === '' || $shoppingUser->faker_mail) { + return null; + } + foreach (['faker@mivita.care', '@dsgvo.invalid', '@deleted.com'] as $placeholder) { + if (str_ends_with($email, $placeholder)) { + return null; + } } - return ShoppingUser::withTrashed()->where('id', $shoppingUser->id)->get(); + return $email; + } + + /** + * Portal-Logins (customers) des Kunden entfernen: Sie enthalten Name und + * E-Mail und sind reine Zugangsdaten ohne Aufbewahrungspflicht. + * + * @param Collection|\Illuminate\Database\Eloquent\Collection $rows + */ + protected function deletePortalCustomers($rows, ?string $email): int + { + $email = mb_strtolower(trim((string) $email)); + + return Customer::query() + ->where(function ($query) use ($rows, $email) { + $query->whereIn('shopping_user_id', collect($rows)->pluck('id')->all()); + if ($email !== '') { + $query->orWhereRaw('LOWER(email) = ?', [$email]); + } + }) + ->delete(); } protected function anonymizeUserRow(User $user): void diff --git a/resources/lang/de/msg.php b/resources/lang/de/msg.php index c1490fc8..31f4e916 100644 --- a/resources/lang/de/msg.php +++ b/resources/lang/de/msg.php @@ -48,6 +48,10 @@ return [ 'gdpr_error_is_admin' => 'DSGVO-Löschung nicht möglich: Der Account hat Admin-Rechte. Bitte entziehe zuerst die Admin-Rechte.', 'gdpr_error_active_abo' => 'DSGVO-Löschung nicht möglich: Es besteht noch ein aktives Abo. Bitte kündige das Abo zuerst.', 'gdpr_error_customer_is_member' => 'DSGVO-Löschung nicht möglich: Dieser Kundendatensatz gehört zu einem Berater-Account. Bitte nutze die DSGVO-Löschung in der User-Verwaltung.', + 'delete_confirm_checkbox_missing' => 'Bitte bestätige die Löschung über die Checkbox.', + 'gdpr_purge_customer_button' => 'Kunde komplett löschen', + 'gdpr_purge_customer_success' => 'Kunde wurde komplett gelöscht: :count Kundeneintrag/-einträge entfernt.', + 'gdpr_error_customer_has_orders' => 'Komplett löschen nicht möglich: Der Kunde hat bereits Bestellungen. Es ist nur eine DSGVO-Löschung möglich.', ]; /* {{ __('msg.') }} diff --git a/resources/lang/en/msg.php b/resources/lang/en/msg.php index 84d8847d..b550267e 100644 --- a/resources/lang/en/msg.php +++ b/resources/lang/en/msg.php @@ -50,4 +50,8 @@ return [ 'gdpr_error_is_admin' => 'GDPR deletion not possible: The account has admin rights. Please remove the admin rights first.', 'gdpr_error_active_abo' => 'GDPR deletion not possible: There is still an active subscription. Please cancel the subscription first.', 'gdpr_error_customer_is_member' => 'GDPR deletion not possible: This customer record belongs to a consultant account. Please use the GDPR deletion in the user administration.', + 'delete_confirm_checkbox_missing' => 'Please confirm the deletion via the checkbox.', + 'gdpr_purge_customer_button' => 'Delete customer completely', + 'gdpr_purge_customer_success' => 'Customer deleted completely: :count customer record(s) removed.', + 'gdpr_error_customer_has_orders' => 'Complete deletion not possible: The customer already has orders. Only a GDPR deletion is possible.', ]; diff --git a/resources/lang/es/msg.php b/resources/lang/es/msg.php index 6d9707af..ffc4aa2d 100644 --- a/resources/lang/es/msg.php +++ b/resources/lang/es/msg.php @@ -50,4 +50,8 @@ return [ 'gdpr_error_is_admin' => 'Eliminación RGPD no posible: La cuenta tiene derechos de administrador. Por favor retira primero los derechos de administrador.', 'gdpr_error_active_abo' => 'Eliminación RGPD no posible: Todavía existe una suscripción activa. Por favor cancela primero la suscripción.', 'gdpr_error_customer_is_member' => 'Eliminación RGPD no posible: Este registro de cliente pertenece a una cuenta de asesor. Por favor utiliza la eliminación RGPD en la administración de usuarios.', + 'delete_confirm_checkbox_missing' => 'Confirma la eliminación mediante la casilla.', + 'gdpr_purge_customer_button' => 'Eliminar cliente por completo', + 'gdpr_purge_customer_success' => 'Cliente eliminado por completo: :count registro(s) de cliente eliminado(s).', + 'gdpr_error_customer_has_orders' => 'Eliminación completa no posible: el cliente ya tiene pedidos. Solo es posible una eliminación RGPD.', ]; diff --git a/resources/lang/fr/msg.php b/resources/lang/fr/msg.php index 5b85e4a3..2d3cfb5d 100644 --- a/resources/lang/fr/msg.php +++ b/resources/lang/fr/msg.php @@ -48,4 +48,8 @@ return [ 'gdpr_error_is_admin' => "Suppression RGPD impossible : Le compte dispose de droits d'administrateur. Veuillez d'abord retirer les droits d'administrateur.", 'gdpr_error_active_abo' => "Suppression RGPD impossible : Un abonnement actif existe encore. Veuillez d'abord résilier l'abonnement.", 'gdpr_error_customer_is_member' => "Suppression RGPD impossible : Cette fiche client appartient à un compte de conseiller. Veuillez utiliser la suppression RGPD dans l'administration des utilisateurs.", + 'delete_confirm_checkbox_missing' => 'Veuillez confirmer la suppression via la case à cocher.', + 'gdpr_purge_customer_button' => 'Supprimer complètement le client', + 'gdpr_purge_customer_success' => 'Client supprimé complètement : :count enregistrement(s) client supprimé(s).', + 'gdpr_error_customer_has_orders' => 'Suppression complète impossible : le client a déjà des commandes. Seule une suppression RGPD est possible.', ]; diff --git a/resources/views/admin/customer/_detail.blade.php b/resources/views/admin/customer/_detail.blade.php index 7d7dc51a..a9f8fefc 100644 --- a/resources/views/admin/customer/_detail.blade.php +++ b/resources/views/admin/customer/_detail.blade.php @@ -21,7 +21,7 @@ @else diff --git a/resources/views/admin/customer/detail.blade.php b/resources/views/admin/customer/detail.blade.php index ab545c81..09c38880 100644 --- a/resources/views/admin/customer/detail.blade.php +++ b/resources/views/admin/customer/detail.blade.php @@ -9,40 +9,102 @@ @include('admin.customer._detail') {{ __('back') }} - @if (Auth::user()->isSuperAdmin() && !$shopping_user->auth_user_id) - + {{-- Kunden löschen: ein Modal für beide Wege (dev/_tickets/2026-09-08.md, Ticket #2) --}} + @php($canGdprDelete = Auth::user()->isSuperAdmin()) +