Kunden loeschen: Modal mit Komplett- oder DSGVO-Loeschung ueber alle Dubletten

Ticket dev/_tickets/2026-09-08.md #2:
- "Kunde loeschen" oeffnet ein Modal statt direkt per GET zu loeschen. Es
  zeigt alle Eintraege desselben Kunden (Kundennummer oder E-Mail, ohne
  Platzhalter-Adressen) mit Anzahl Bestellungen.
- Ohne Bestellungen: Komplett-Loeschung (anonymisiert und soft-geloescht,
  Portal-Logins und Stripe-Kunden entfernt, Zahlungs-Tokens inaktiver Abos
  geleert). Mit Bestellungen nur DSGVO-Loeschung (Superadmin); die arbeitet
  jetzt ebenfalls auf der E-Mail-Gruppe und entfernt Portal-Logins.
- Bei aktivem Abo oder Berater-Datensatz wird abgelehnt.
- Die alte "Loeschung" (CustomerRepository::deleteCustomer) entfaellt.
- Fehlermeldungen nutzen alert-error; alert-danger zeigte kein Layout an.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-23 15:43:35 +00:00
parent 728dde4c76
commit 90c2188ffa
12 changed files with 631 additions and 88 deletions

View file

@ -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]));
}

View file

@ -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)
{

View file

@ -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<string, int|string>
*
* @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<int, ShoppingUser>,
* email: string|null,
* is_member: bool,
* orders_count: int,
* orders_by_row: array<int, int>,
* 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<int, ShoppingUser>
*/
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<int, ShoppingUser>|\Illuminate\Database\Eloquent\Collection<int, ShoppingUser> $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