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:
parent
30bbfa32a0
commit
60f9985313
12 changed files with 631 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue