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
728dde4c76
commit
90c2188ffa
12 changed files with 631 additions and 88 deletions
|
|
@ -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]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -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