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 30bbfa32a0
commit 60f9985313
12 changed files with 631 additions and 88 deletions

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