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

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

View file

@ -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.') }}

View file

@ -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.',
];

View file

@ -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.',
];

View file

@ -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.',
];

View file

@ -21,7 +21,7 @@
</div>
<div class="col-md-3 mb-3">
<a class="btn btn-sm btn-secondary mt-2" href="{{route('admin_customer_edit', [$shopping_user->id])}}">{{ __('customer.edit_customer_data') }}</a>
<a class="btn btn-sm btn-danger mt-2" href="{{route('admin_customer_delete', [$shopping_user->id])}}" onclick="return confirm('{{ __('customer.delete_customer_confirm') }}')">{{ __('customer.delete_customer') }}</a>
<button type="button" class="btn btn-sm btn-danger mt-2" data-toggle="modal" data-target="#modals-customer-delete">{{ __('customer.delete_customer') }}</button>
</div>
</div>
@else

View file

@ -9,40 +9,102 @@
@include('admin.customer._detail')
<a href="{{route('admin_customers')}}" class="btn btn-sm btn-default mt-2 float-right">{{ __('back') }}</a>
@if (Auth::user()->isSuperAdmin() && !$shopping_user->auth_user_id)
<button type="button" class="btn btn-sm btn-danger mt-2" data-toggle="modal"
data-target="#modals-customer-gdpr-delete">{{ __('msg.gdpr_delete_customer_button') }}</button>
{{-- Kunden löschen: ein Modal für beide Wege (dev/_tickets/2026-09-08.md, Ticket #2) --}}
@php($canGdprDelete = Auth::user()->isSuperAdmin())
<div class="modal fade" id="modals-customer-delete">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ __('customer.delete_customer') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
</div>
<div class="modal-body">
@if ($deletion['is_member'])
<div class="alert alert-warning mb-0">{{ __('msg.gdpr_error_customer_is_member') }}</div>
@else
<p>
Der Kunde ist <b>{{ $deletion['rows']->count() }}-mal</b> angelegt
@if ($deletion['email'])
(E-Mail <b>{{ $deletion['email'] }}</b>, Kundennummer {{ $shopping_user->number ?: '-' }}).
@else
(Kundennummer {{ $shopping_user->number ?: '-' }}; keine gültige E-Mail zur Duplikat-Suche).
@endif
Alle folgenden Einträge werden gemeinsam behandelt:
</p>
<div class="table-responsive">
<table class="table table-sm table-bordered" id="customer-delete-rows">
<thead>
<tr>
<th>ID</th>
<th>Kd-Nr.</th>
<th>Name</th>
<th>Berater</th>
<th>Bestellungen</th>
<th>Angelegt</th>
</tr>
</thead>
<tbody>
@foreach ($deletion['rows'] as $row)
<tr class="{{ $row->id === $shopping_user->id ? 'font-weight-bold' : '' }}">
<td>{{ $row->id }}</td>
<td>{{ $row->number ?: '-' }}</td>
<td>{{ trim($row->billing_firstname.' '.$row->billing_lastname) }}
@if ($row->deleted_at) <span class="badge badge-secondary">gelöscht</span> @endif
</td>
<td>{{ $row->member->email ?? '-' }}</td>
<td>{{ $deletion['orders_by_row'][$row->id] ?? 0 }}</td>
<td>{{ $row->created_at ? $row->created_at->format('d.m.Y') : '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<!-- Modal DSGVO-Löschung -->
<div class="modal fade" id="modals-customer-gdpr-delete">
<div class="modal-dialog">
<form class="modal-content" action="{{ route('admin_customer_gdpr_delete', [$shopping_user->id]) }}"
method="post">
@csrf
<div class="modal-header">
<h5 class="modal-title">{{ __('msg.gdpr_delete_customer_button') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
</div>
<div class="modal-body">
<p>Alle personenbezogenen Daten dieses Kunden werden <b>unwiderruflich anonymisiert</b>
(alle Einträge mit der Kundennummer {{ $shopping_user->number }}).
Bei Bestellungen mit Rechnung bleiben Name und Rechnungsadresse wegen der
gesetzlichen Aufbewahrungspflichten erhalten; alle Kontaktdaten
(E-Mail, Telefon, Notizen, Newsletter) werden entfernt.
Aktive Abos müssen vorher gekündigt werden.</p>
<label class="custom-control custom-checkbox m-0">
<input type="checkbox" class="custom-control-input" name="gdpr_delete_customer" value="1">
<span class="custom-control-label">DSGVO-Löschung unwiderruflich ausführen?</span>
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
<button type="submit"
class="btn btn-danger">{{ __('msg.gdpr_delete_customer_button') }}</button>
</div>
</form>
@if ($deletion['orders_count'] > 0)
<div class="alert alert-warning">
Der Kunde hat schon <b>{{ $deletion['orders_count'] }} Bestellung(en)</b>. Es ist nur <b>DSGVO löschen</b> möglich:
Alle personenbezogenen Daten werden unwiderruflich anonymisiert. Bei Einträgen mit Bestellung bleiben Name und
Rechnungsadresse wegen der gesetzlichen Aufbewahrungspflichten erhalten; alle Kontaktdaten (E-Mail, Telefon,
Notizen, Newsletter) sowie die Portal-Logins werden entfernt.
@if ($deletion['active_abos'] > 0)
<br><b>{{ __('msg.gdpr_error_active_abo') }}</b>
@endif
</div>
@if ($canGdprDelete)
<form action="{{ route('admin_customer_gdpr_delete', [$shopping_user->id]) }}" method="post" id="form-customer-gdpr-delete">
@csrf
<label class="custom-control custom-checkbox mb-3">
<input type="checkbox" class="custom-control-input" name="gdpr_delete_customer" value="1">
<span class="custom-control-label">DSGVO-Löschung unwiderruflich ausführen?</span>
</label>
<button type="submit" class="btn btn-danger" {{ $deletion['active_abos'] > 0 ? 'disabled' : '' }}>{{ __('msg.gdpr_delete_customer_button') }}</button>
</form>
@else
<p class="text-muted mb-0">Die DSGVO-Löschung kann nur von einem Superadmin ausgeführt werden.</p>
@endif
@elseif ($deletion['active_abos'] > 0)
<div class="alert alert-warning mb-0">{{ __('msg.gdpr_error_active_abo') }}</div>
@else
<div class="alert alert-info">
Der Kunde hat <b>keine Bestellungen</b> und kann <b>komplett gelöscht</b> werden: Alle oben aufgeführten Einträge
werden vollständig anonymisiert und entfernt, inklusive Portal-Login und inaktiver Abos.
</div>
<form action="{{ route('admin_customer_delete', [$shopping_user->id]) }}" method="post" id="form-customer-delete">
@csrf
<label class="custom-control custom-checkbox mb-3">
<input type="checkbox" class="custom-control-input" name="delete_customer" value="1">
<span class="custom-control-label">Kunden mit allen {{ $deletion['rows']->count() }} Einträgen unwiderruflich löschen?</span>
</label>
<button type="submit" class="btn btn-danger">{{ __('msg.gdpr_purge_customer_button') }}</button>
</form>
@endif
@endif
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
</div>
</div>
</div>
@endif
</div>
@endsection

View file

@ -337,7 +337,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
// customers
Route::get('/admin/customers', 'CustomerController@index')->name('admin_customers');
Route::get('/admin/customer/detail/{id}', 'CustomerController@detail')->name('admin_customer_detail');
Route::get('/admin/customer/delete/{id}', 'CustomerController@delete')->name('admin_customer_delete');
Route::post('/admin/customer/delete/{id}', 'CustomerController@delete')->name('admin_customer_delete');
Route::post('/admin/customer/gdpr-delete/{id}', 'CustomerController@gdprDelete')->name('admin_customer_gdpr_delete');
Route::get('/admin/customer/edit/{id}', 'CustomerController@edit')->name('admin_customer_edit');
Route::post('/admin/customer/edit/{id}', 'CustomerController@store')->name('admin_customer_edit');

View file

@ -0,0 +1,282 @@
<?php
use App\Exceptions\GdprDeletionException;
use App\Models\Country;
use App\Models\Customer;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserShop;
use App\Services\GdprDeletionService;
use App\User;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class);
/**
* Ticket dev/_tickets/2026-09-08.md #2: Kunden löschen (komplett vs. DSGVO),
* Mehrfach-Anlagen werden über die E-Mail-Adresse zusammengefasst.
*
* Die Admin-Routen werden host-abhängig geladen, daher App-Neustart mit CRM-Host.
*/
beforeEach(function () {
$this->previousHttpHost = $_SERVER['HTTP_HOST'] ?? null;
Mail::fake();
$_SERVER['HTTP_HOST'] = config('domains.domains.crm.host');
$this->refreshApplication();
$this->artisan('migrate');
});
afterEach(function () {
// Host wieder zurücksetzen, sonst booten nachfolgende Tests mit den CRM-Routen.
if ($this->previousHttpHost === null) {
unset($_SERVER['HTTP_HOST']);
} else {
$_SERVER['HTTP_HOST'] = $this->previousHttpHost;
}
});
function deleteTestUser(array $attributes = []): User
{
return User::forceCreate(array_merge([
'email' => 'user-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'active' => 1,
'wizard' => 100,
'payment_account' => now()->addYear(),
], $attributes));
}
function deleteTestCustomer(User $member, string $email, array $attributes = []): ShoppingUser
{
$country = Country::first() ?? Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
return ShoppingUser::create(array_merge([
'auth_user_id' => 0,
'member_id' => $member->id,
'number' => random_int(1000, 99999),
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_address' => 'Musterweg 1',
'billing_zipcode' => '12345',
'billing_city' => 'Musterstadt',
'billing_phone' => '0123',
'billing_email' => $email,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
], $attributes));
}
function deleteTestOrder(ShoppingUser $customer): ShoppingOrder
{
$shop = UserShop::first() ?? UserShop::create(['user_id' => $customer->member_id, 'name' => 'Shop', 'slug' => 'shop-'.uniqid()]);
return ShoppingOrder::create([
'shopping_user_id' => $customer->id,
'auth_user_id' => 0,
'member_id' => $customer->member_id,
'user_shop_id' => $shop->id,
'country_id' => $customer->billing_country_id,
'total' => 10,
'txaction' => 'appointed',
'mode' => 'test',
]);
}
it('fasst alle Einträge eines Kunden über Kundennummer und E-Mail zusammen', function () {
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$sameNumber = deleteTestCustomer($member, 'anders@example.com', ['number' => 61009]);
$sameEmailOtherNumber = deleteTestCustomer($member, 'Erika@Example.com', ['number' => 61010]);
$consultantRow = deleteTestCustomer($member, 'erika@example.com', ['number' => 99, 'auth_user_id' => $member->id]);
$other = deleteTestCustomer($member, 'jemand@example.com', ['number' => 61011]);
$ids = app(GdprDeletionService::class)->getCustomerRows($first)->pluck('id')->all();
expect($ids)->toBe([$first->id, $sameNumber->id, $sameEmailOtherNumber->id])
->and($ids)->not->toContain($consultantRow->id)
->and($ids)->not->toContain($other->id);
});
it('gruppiert nicht über Platzhalter-Adressen', function () {
$member = deleteTestUser();
$faker = deleteTestCustomer($member, 'abc-faker@mivita.care', ['number' => 1, 'faker_mail' => true]);
$otherFaker = deleteTestCustomer($member, 'abc-faker@mivita.care', ['number' => 2, 'faker_mail' => true]);
$service = app(GdprDeletionService::class);
expect($service->groupingEmail($faker))->toBeNull()
->and($service->getCustomerRows($faker)->pluck('id')->all())->toBe([$faker->id])
->and($service->getCustomerRows($otherFaker)->pluck('id')->all())->toBe([$otherFaker->id]);
});
it('löscht einen Kunden ohne Bestellungen komplett, inklusive Mehrfach-Anlagen, Portal-Login und Abo-Daten', function () {
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
$abo = UserAbo::create([
'user_id' => null,
'member_id' => $member->id,
'shopping_user_id' => $duplicate->id,
'is_for' => 'ot',
'email' => 'erika@example.com',
'payone_userid' => 4711,
'clearingtype' => 'cc',
'carddata' => ['last4' => '4242'],
'active' => false,
'status' => 4,
'abo_interval' => 1,
]);
$portal = Customer::create(['email' => 'erika@example.com', 'shopping_user_id' => $first->id, 'member_id' => $member->id, 'number' => 61009]);
$summary = app(GdprDeletionService::class)->purgeShoppingUser($first, $member->id);
expect($summary['shopping_users_purged'])->toBe(2)
->and($summary['portal_customers_deleted'])->toBe(1)
->and($summary['abos_anonymized'])->toBe(1);
foreach ([$first->id, $duplicate->id] as $id) {
expect(ShoppingUser::find($id))->toBeNull();
$row = ShoppingUser::withTrashed()->find($id);
expect($row->deleted_at)->not->toBeNull()
->and($row->billing_firstname)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME)
->and($row->billing_lastname)->toBe(GdprDeletionService::ANONYMIZED_LASTNAME)
->and($row->billing_address)->toBeNull()
->and($row->billing_phone)->toBeNull()
->and($row->billing_email)->not->toContain('erika');
}
expect(Customer::find($portal->id))->toBeNull();
$abo = UserAbo::withTrashed()->find($abo->id);
expect($abo->email)->toBeNull()
->and($abo->carddata)->toBeNull()
->and((int) $abo->payone_userid)->toBe(0);
});
it('verweigert die Komplett-Löschung, sobald eine Mehrfach-Anlage eine Bestellung hat', function () {
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
deleteTestOrder($duplicate);
expect(fn () => app(GdprDeletionService::class)->purgeShoppingUser($first, $member->id))
->toThrow(GdprDeletionException::class, __('msg.gdpr_error_customer_has_orders'));
expect($first->fresh()->billing_email)->toBe('erika@example.com')
->and($duplicate->fresh()->deleted_at)->toBeNull();
});
it('verweigert die Komplett-Löschung bei Berater-Datensätzen und aktiven Abos', function () {
$member = deleteTestUser();
$consultantRow = deleteTestCustomer($member, 'berater@example.com', ['auth_user_id' => $member->id]);
expect(fn () => app(GdprDeletionService::class)->purgeShoppingUser($consultantRow, $member->id))
->toThrow(GdprDeletionException::class, __('msg.gdpr_error_customer_is_member'));
$customer = deleteTestCustomer($member, 'abo@example.com');
UserAbo::create([
'user_id' => null,
'member_id' => $member->id,
'shopping_user_id' => $customer->id,
'is_for' => 'ot',
'email' => 'abo@example.com',
'payone_userid' => 1,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 1,
]);
expect(fn () => app(GdprDeletionService::class)->purgeShoppingUser($customer, $member->id))
->toThrow(GdprDeletionException::class, __('msg.gdpr_error_active_abo'));
});
it('anonymisiert bei der DSGVO-Löschung auch Mehrfach-Anlagen mit anderer Kundennummer und entfernt den Portal-Login', function () {
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
deleteTestOrder($first);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
Customer::create(['email' => 'erika@example.com', 'shopping_user_id' => $duplicate->id, 'member_id' => $member->id]);
$summary = app(GdprDeletionService::class)->deleteShoppingUser($first, $member->id);
expect($summary['shopping_users_anonymized'])->toBe(2)
->and($summary['portal_customers_deleted'])->toBe(1);
$first = $first->fresh();
expect($first->billing_firstname)->toBe('Erika')
->and($first->billing_email)->toBe('geloescht_'.$first->id.'@dsgvo.invalid');
$duplicate = $duplicate->fresh();
expect($duplicate->billing_firstname)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME)
->and($duplicate->billing_email)->not->toContain('erika');
expect(Customer::where('email', 'erika@example.com')->count())->toBe(0);
});
it('bietet im Modal die Komplett-Löschung an, wenn keine Bestellung vorliegt, und führt sie nach Bestätigung aus', function () {
$admin = deleteTestUser(['admin' => 2]);
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
$this->actingAs($admin)->get(route('admin_customer_detail', $first->id))
->assertOk()
->assertSee('id="modals-customer-delete"', false)
->assertSee('2-mal')
->assertSee(__('msg.gdpr_purge_customer_button'))
->assertDontSee(__('msg.gdpr_delete_customer_button'));
$this->actingAs($admin)->post(route('admin_customer_delete', $first->id))
->assertRedirect(route('admin_customer_detail', $first->id))
->assertSessionHas('alert-error', __('msg.delete_confirm_checkbox_missing'));
expect($first->fresh())->not->toBeNull();
$this->actingAs($admin)->post(route('admin_customer_delete', $first->id), ['delete_customer' => 1])
->assertRedirect(route('admin_customers'))
->assertSessionHas('alert-success');
expect(ShoppingUser::find($first->id))->toBeNull()
->and(ShoppingUser::find($duplicate->id))->toBeNull();
});
it('bietet im Modal nur die DSGVO-Löschung an, wenn eine Bestellung vorliegt', function () {
$superadmin = deleteTestUser(['admin' => 3]);
$member = deleteTestUser();
$first = deleteTestCustomer($member, 'erika@example.com', ['number' => 61009]);
$duplicate = deleteTestCustomer($member, 'erika@example.com', ['number' => 61010]);
deleteTestOrder($duplicate);
$this->actingAs($superadmin)->get(route('admin_customer_detail', $first->id))
->assertOk()
->assertSee('Es ist nur')
->assertSee(__('msg.gdpr_delete_customer_button'))
->assertDontSee(__('msg.gdpr_purge_customer_button'));
$this->actingAs($superadmin)->post(route('admin_customer_delete', $first->id), ['delete_customer' => 1])
->assertRedirect(route('admin_customer_detail', $first->id))
->assertSessionHas('alert-error', __('msg.gdpr_error_customer_has_orders'));
expect($first->fresh()->billing_email)->toBe('erika@example.com');
$this->actingAs($superadmin)->post(route('admin_customer_gdpr_delete', $first->id), ['gdpr_delete_customer' => 1])
->assertRedirect(route('admin_customers'))
->assertSessionHas('alert-success');
expect($duplicate->fresh()->billing_email)->toBe('geloescht_'.$duplicate->id.'@dsgvo.invalid')
->and($first->fresh()->billing_firstname)->toBe(GdprDeletionService::ANONYMIZED_FIRSTNAME);
});
it('zeigt normalen Admins bei Bestellungen den Hinweis auf den Superadmin', function () {
$admin = deleteTestUser(['admin' => 2]);
$member = deleteTestUser();
$customer = deleteTestCustomer($member, 'erika@example.com');
deleteTestOrder($customer);
$this->actingAs($admin)->get(route('admin_customer_detail', $customer->id))
->assertOk()
->assertSee('nur von einem Superadmin')
->assertDontSee('id="form-customer-gdpr-delete"', false);
});

View file

@ -11,6 +11,7 @@ use App\Models\UserBusiness;
use App\Models\UserShop;
use App\Models\UserUpdateEmail;
use App\Services\GdprDeletionService;
use App\Services\Payment\Stripe\StripeCustomerService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
@ -369,3 +370,52 @@ it('entfernt Zahlungs-Tokens der Abos eines Endkunden', function () {
expect($abo->carddata)->toBeNull();
expect((int) $abo->payone_userid)->toBe(0);
});
it('löscht die Stripe-Customer-Objekte des Endkunden (SE1.6)', function () {
Mail::fake();
$country = gdprCountry();
$customer = ShoppingUser::create([
'auth_user_id' => 0,
'number' => 4714,
'billing_firstname' => 'Erika',
'billing_lastname' => 'Musterfrau',
'billing_email' => 'erika-stripe@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'stripe_customer_id' => 'cus_shop_1',
]);
UserAbo::create([
'shopping_user_id' => $customer->id,
'is_for' => 'ot',
'email' => 'erika-stripe@example.com',
'payone_userid' => 0,
'payment_provider' => 'stripe',
'stripe_customer_id' => 'cus_abo_1',
'stripe_payment_method_id' => 'pm_abo_1',
'clearingtype' => 'stripe',
'active' => false,
'status' => 6,
'abo_interval' => 1,
]);
$deleted = [];
$stripeCustomers = Mockery::mock(StripeCustomerService::class);
$stripeCustomers->shouldReceive('deleteCustomer')
->twice()
->andReturnUsing(function (string $customerId) use (&$deleted): bool {
$deleted[] = $customerId;
return true;
});
app()->instance(StripeCustomerService::class, $stripeCustomers);
$summary = app(GdprDeletionService::class)->deleteShoppingUser($customer, null);
sort($deleted);
expect($deleted)->toBe(['cus_abo_1', 'cus_shop_1'])
->and($summary['stripe_customers_deleted'])->toBe(2)
->and($customer->fresh()->stripe_customer_id)->toBeNull();
});