From 3d0864713b528e72757a065eb359e2e47a12e104 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 6 Oct 2026 12:36:02 +0000 Subject: [PATCH] Kunden loeschen: DSGVO-geloeschte Kunden ausblenden und sperren; Checkout-Herkunft freiwillig Kunden loeschen (Rueckmeldung "es wird nichts geloescht"): - DSGVO-geloeschte Eintraege blieben mit Name in den Listen stehen; Kunde 61009 wurde deshalb achtmal geloescht, bei Eintrag 45013 wurde die E-Mail danach ueber ein offenes Bearbeiten-Formular zurueckgeschrieben - neue Spalte shopping_users.gdpr_deleted_at (Bestand per Migration markiert) - Admin-Kundenliste blendet geloeschte Kunden aus (Filter: ausblenden / mit anzeigen / nur geloeschte), Berater-Kundenliste immer - Bearbeiten/Speichern im Admin und beim Berater gesperrt; ShoppingUser verweigert das Zurueckschreiben von E-Mail und Telefon - Modal und Erfolgsmeldung (de/en/es/fr) sagen klar, dass Name und Rechnungsadresse wegen der Aufbewahrungspflicht bleiben - gdpr:repair-customers [--execute] zieht protokollierte Loeschungen nach - DSGVO-Loeschung prueft Superadmin serverseitig; Berater duerfen eine Kunden-E-Mail nur beim eigenen Kunden nachtragen - setFakerMail setzt faker_mail bei geloeschten Eintraegen nicht zurueck Checkout: "Wie bist du auf uns aufmerksam geworden?" ist kein Pflichtfeld mehr und wird farbig hervorgehoben. Deploy: php artisan migrate && php artisan gdpr:repair-customers --execute Co-Authored-By: Claude Opus 5.5 --- app/Console/Commands/GdprRepairCustomers.php | 50 ++++ app/Http/Controllers/CustomerController.php | 33 ++- .../Controllers/User/CustomerController.php | 26 ++- .../Controllers/Web/CheckoutController.php | 6 +- app/Models/ShoppingUser.php | 52 +++++ app/Services/GdprDeletionService.php | 76 ++++++- app/Services/ShoppingUserService.php | 132 ++++++----- ...dpr_deleted_at_to_shopping_users_table.php | 37 +++ resources/lang/de/msg.php | 3 +- resources/lang/en/msg.php | 3 +- resources/lang/es/msg.php | 3 +- resources/lang/fr/msg.php | 3 +- .../views/admin/customer/_detail.blade.php | 11 +- .../views/admin/customer/detail.blade.php | 23 +- .../views/admin/customer/index.blade.php | 14 +- .../views/web/templates/checkout.blade.php | 20 +- .../BackofficeStatisticsAccessTest.php | 4 +- .../CheckoutOrderSourceValidationTest.php | 15 +- tests/Feature/GdprCustomerLockTest.php | 214 ++++++++++++++++++ 19 files changed, 635 insertions(+), 90 deletions(-) create mode 100644 app/Console/Commands/GdprRepairCustomers.php create mode 100644 database/migrations/2026_10_06_095331_add_gdpr_deleted_at_to_shopping_users_table.php create mode 100644 tests/Feature/GdprCustomerLockTest.php diff --git a/app/Console/Commands/GdprRepairCustomers.php b/app/Console/Commands/GdprRepairCustomers.php new file mode 100644 index 00000000..54b969e1 --- /dev/null +++ b/app/Console/Commands/GdprRepairCustomers.php @@ -0,0 +1,50 @@ +option('execute'); + $rows = $gdprDeletionService->repairLoggedShoppingUserDeletions(! $execute); + + if ($rows === []) { + $this->info('Keine Einträge zu reparieren.'); + + return 0; + } + + $this->table(['ShoppingUser-ID', 'Kd-Nr.', 'Löschung protokolliert am'], array_map( + fn (array $row) => [$row['id'], $row['number'] ?: '-', $row['deletion_logged_at']], + $rows + )); + + $this->info($execute + ? count($rows).' Eintrag/Einträge erneut anonymisiert und gesperrt.' + : count($rows).' Eintrag/Einträge betroffen. Vorschau – mit --execute ausführen.'); + + return 0; + } +} diff --git a/app/Http/Controllers/CustomerController.php b/app/Http/Controllers/CustomerController.php index e6f66633..f447cb36 100644 --- a/app/Http/Controllers/CustomerController.php +++ b/app/Http/Controllers/CustomerController.php @@ -10,6 +10,7 @@ use App\Services\GdprDeletionService; use App\Services\HTMLHelper; use App\Services\Shop; use Auth; +use Illuminate\Http\RedirectResponse; use Request; use Validator; @@ -29,6 +30,7 @@ class CustomerController extends Controller if (Request::get('reset') === 'filter') { set_user_attr('filter_member_id', null); set_user_attr('filter_customer_member', null); + set_user_attr('filter_gdpr_deleted', null); return redirect(route('admin_customers')); } @@ -82,6 +84,10 @@ class CustomerController extends Controller { $shopping_user = ShoppingUser::withTrashed()->findOrFail($id); + if (! Auth::user()->isSuperAdmin()) { + abort(403); + } + if (! Request::get('gdpr_delete_customer')) { \Session()->flash('alert-error', __('msg.gdpr_confirm_checkbox_missing')); @@ -89,8 +95,8 @@ class CustomerController extends Controller } try { - app(GdprDeletionService::class)->deleteShoppingUser($shopping_user, Auth::id()); - \Session()->flash('alert-success', __('msg.gdpr_delete_customer_success')); + $summary = app(GdprDeletionService::class)->deleteShoppingUser($shopping_user, Auth::id()); + \Session()->flash('alert-success', __('msg.gdpr_delete_customer_success', ['count' => $summary['shopping_users_anonymized']])); } catch (GdprDeletionException $e) { \Session()->flash('alert-error', $e->getMessage()); @@ -107,6 +113,9 @@ class CustomerController extends Controller $shopping_user->id = 'new'; } else { $shopping_user = ShoppingUser::findOrFail($id); + if ($shopping_user->isGdprDeleted()) { + return $this->redirectGdprLocked($shopping_user); + } } $data = [ 'shopping_user' => $shopping_user, @@ -168,6 +177,9 @@ class CustomerController extends Controller } $shopping_user = ShoppingUser::findOrFail($id); + if ($shopping_user->isGdprDeleted()) { + return $this->redirectGdprLocked($shopping_user); + } $data['language'] = isset($data['language']) ? $data['language'] : \App::getLocale(); $data['has_buyed'] = isset($data['has_buyed']) ? true : false; @@ -196,6 +208,13 @@ class CustomerController extends Controller { $query = ShoppingUser::select('shopping_users.*')->where('auth_user_id', '=', null); + set_user_attr('filter_gdpr_deleted', Request::get('filter_gdpr_deleted')); + if (Request::get('filter_gdpr_deleted') === 'only') { + $query->whereNotNull('gdpr_deleted_at'); + } elseif (Request::get('filter_gdpr_deleted') !== 'all') { + $query->withoutGdprDeleted(); + } + set_user_attr('filter_member_id', Request::get('filter_member_id')); if (Request::get('filter_member_id') != '') { $query->where('member_id', '=', Request::get('filter_member_id')); @@ -224,7 +243,8 @@ class CustomerController extends Controller return $ShoppingUser->billing_country ? $ShoppingUser->billing_country->getLocated() : ''; }) ->addColumn('isMember', function (ShoppingUser $ShoppingUser) { - return get_active_badge($ShoppingUser->auth_user_id).($ShoppingUser->mode === 'dev' ? ' dev' : ''); + return get_active_badge($ShoppingUser->auth_user_id).($ShoppingUser->mode === 'dev' ? ' dev' : '') + .($ShoppingUser->isGdprDeleted() ? ' DSGVO-gelöscht' : ''); }) ->addColumn('member_id', function (ShoppingUser $ShoppingUser) { if ($ShoppingUser->is_like) { @@ -263,4 +283,11 @@ class CustomerController extends Controller ->rawColumns(['id', 'subscribed', 'isMember', 'member_id']) ->make(true); } + + protected function redirectGdprLocked(ShoppingUser $shopping_user): RedirectResponse + { + \Session()->flash('alert-error', __('msg.gdpr_customer_locked')); + + return redirect(route('admin_customer_detail', [$shopping_user->id])); + } } diff --git a/app/Http/Controllers/User/CustomerController.php b/app/Http/Controllers/User/CustomerController.php index b6b10ff4..d8320c57 100644 --- a/app/Http/Controllers/User/CustomerController.php +++ b/app/Http/Controllers/User/CustomerController.php @@ -11,6 +11,7 @@ use App\Services\HTMLHelper; use App\Services\Shop; use App\Services\ShoppingUserService; use App\User; +use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\DB; use Request; use Validator; @@ -61,6 +62,9 @@ class CustomerController extends Controller if ($shopping_user->member_id != \Auth::user()->id) { abort(404); } + if ($shopping_user->isGdprDeleted()) { + return $this->redirectGdprLocked($shopping_user); + } $data = [ 'shopping_user' => $shopping_user, 'isAdmin' => false, @@ -81,6 +85,9 @@ class CustomerController extends Controller if ($shopping_user->member_id != \Auth::user()->id) { abort(404); } + if ($shopping_user->isGdprDeleted()) { + return $this->redirectGdprLocked($shopping_user); + } } $billing_email = null; @@ -142,6 +149,12 @@ class CustomerController extends Controller if ($action === 'save') { $shopping_user = ShoppingUser::findOrFail($id); + if ($shopping_user->member_id != \Auth::user()->id) { + abort(404); + } + if ($shopping_user->isGdprDeleted()) { + return $this->redirectGdprLocked($shopping_user); + } $shopping_user->faker_mail = false; $shopping_user->billing_email = Request::get($email); $shopping_user->save(); @@ -213,6 +226,9 @@ class CustomerController extends Controller if ($shopping_user->member_id != \Auth::user()->id) { abort(404); } + if ($shopping_user->isGdprDeleted()) { + return $this->redirectGdprLocked($shopping_user); + } CustomerPriority::checkChangeOne($shopping_user, $data, true); $shopping_user->fill($data); $shopping_user->save(); @@ -261,7 +277,8 @@ class CustomerController extends Controller $query->select(DB::raw('MAX(id)')) ->from('shopping_users') ->groupBy('billing_email'); - })->where('shopping_users.member_id', '=', $user->id)->where('shopping_users.auth_user_id', '=', null); + })->where('shopping_users.member_id', '=', $user->id)->where('shopping_users.auth_user_id', '=', null) + ->withoutGdprDeleted(); if (Request::get('isfor') === 'ot-member') { // Bestellung für Kunden } @@ -327,4 +344,11 @@ class CustomerController extends Controller ->rawColumns(['send_to', 'id', 'subscribed', 'extras', 'status']) ->make(true); } + + protected function redirectGdprLocked(ShoppingUser $shopping_user): RedirectResponse + { + \Session()->flash('alert-error', __('msg.gdpr_customer_locked')); + + return redirect(route('user_customer_detail', [$shopping_user->id])); + } } diff --git a/app/Http/Controllers/Web/CheckoutController.php b/app/Http/Controllers/Web/CheckoutController.php index 3250d6bb..05a5aa08 100644 --- a/app/Http/Controllers/Web/CheckoutController.php +++ b/app/Http/Controllers/Web/CheckoutController.php @@ -273,7 +273,7 @@ class CheckoutController extends Controller ]; if (Request::get('is_from') === 'shopping') { - $rules['customer_order_source_comment'] = 'required|string|max:500'; + $rules['customer_order_source_comment'] = 'nullable|string|max:500'; } if (Request::get('same_as_billing')) { @@ -287,9 +287,7 @@ class CheckoutController extends Controller ]); } - return Validator::make(Request::all(), $rules, [ - 'customer_order_source_comment.required' => __('order.source_comment_required'), - ]); + return Validator::make(Request::all(), $rules); } /** diff --git a/app/Models/ShoppingUser.php b/app/Models/ShoppingUser.php index d734978b..f41ecaf5 100644 --- a/app/Models/ShoppingUser.php +++ b/app/Models/ShoppingUser.php @@ -2,8 +2,10 @@ namespace App\Models; +use App\Exceptions\GdprDeletionException; use App\Services\ShoppingUserService; use App\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -122,6 +124,7 @@ use Illuminate\Support\Carbon; * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereMode($value) * * @property bool|null $faker_mail + * @property Carbon|null $gdpr_deleted_at * @property string|null $shipping_email * @property string|null $is_for * @property string|null $is_from @@ -215,8 +218,57 @@ class ShoppingUser extends Model 'has_buyed' => 'bool', 'subscribed' => 'bool', 'wp_order_number' => 'int', + 'gdpr_deleted_at' => 'datetime', ]; + /** + * Kontaktfelder, die nach einer DSGVO-Löschung nicht wieder befüllt werden dürfen. + * + * @var array + */ + public const GDPR_LOCKED_CONTACT_FIELDS = ['billing_email', 'billing_phone', 'shipping_email', 'shipping_phone']; + + /** + * Schutz gegen das Zurückschreiben von Kontaktdaten in DSGVO-gelöschte Einträge, + * z. B. durch ein noch offenes Bearbeiten-Formular (dev/_tickets/2026-10-06). + */ + protected static function booted(): void + { + static::updating(function (ShoppingUser $shoppingUser) { + if ($shoppingUser->getOriginal('gdpr_deleted_at') === null) { + return; + } + + foreach (self::GDPR_LOCKED_CONTACT_FIELDS as $field) { + if ($shoppingUser->isDirty($field) && ! self::isGdprPlaceholderValue($field, $shoppingUser->{$field})) { + throw new GdprDeletionException(__('msg.gdpr_customer_locked')); + } + } + }); + } + + protected static function isGdprPlaceholderValue(string $field, mixed $value): bool + { + if ($value === null || $value === '') { + return true; + } + + return $field === 'billing_email' && str_ends_with((string) $value, '@dsgvo.invalid'); + } + + public function isGdprDeleted(): bool + { + return $this->gdpr_deleted_at !== null; + } + + /** + * @param Builder $query + */ + public function scopeWithoutGdprDeleted($query): void + { + $query->whereNull($query->qualifyColumn('gdpr_deleted_at')); + } + // can null public function member() { diff --git a/app/Services/GdprDeletionService.php b/app/Services/GdprDeletionService.php index 7a6e173b..011baa95 100644 --- a/app/Services/GdprDeletionService.php +++ b/app/Services/GdprDeletionService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Exceptions\GdprDeletionException; use App\Models\Customer; +use App\Models\Logger; use App\Models\ShoppingOrder; use App\Models\ShoppingUser; use App\Models\UserAbo; @@ -13,6 +14,7 @@ use App\Models\UserShop; use App\Models\UserUpdateEmail; use App\Services\Payment\Stripe\StripeCustomerService; use App\User; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -220,7 +222,10 @@ class GdprDeletionService * orders_count: int, * orders_by_row: array, * active_abos: int, - * can_purge: bool + * can_purge: bool, + * gdpr_deleted_rows: int, + * gdpr_deleted_at: Carbon|null, + * all_gdpr_deleted: bool * } */ public function analyzeShoppingUser(ShoppingUser $shoppingUser): array @@ -239,6 +244,7 @@ class GdprDeletionService $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(); + $gdprDeletedRows = $rows->filter(fn (ShoppingUser $row) => $row->isGdprDeleted()); return [ 'rows' => $rows, @@ -248,9 +254,76 @@ class GdprDeletionService 'orders_by_row' => $ordersByRow, 'active_abos' => $activeAbos, 'can_purge' => ! $isMember && $ordersCount === 0 && $activeAbos === 0, + 'gdpr_deleted_rows' => $gdprDeletedRows->count(), + 'gdpr_deleted_at' => $gdprDeletedRows->max('gdpr_deleted_at'), + 'all_gdpr_deleted' => $rows->isNotEmpty() && $gdprDeletedRows->count() === $rows->count(), ]; } + /** + * Protokollierte DSGVO-Löschungen von Endkunden nachziehen: Einträge, die vor + * der Löschung angelegt waren und deren Kontaktdaten danach wieder befüllt + * wurden (z. B. durch ein offenes Bearbeiten-Formular), werden erneut + * anonymisiert und markiert. Später angelegte Einträge (neue Bestellung nach + * der Löschung) bleiben unberührt. + * + * @return array + */ + public function repairLoggedShoppingUserDeletions(bool $dryRun = true): array + { + $deletions = Logger::query() + ->whereIn('action', ['gdpr-delete-customer', 'gdpr-purge-customer']) + ->where('model', ShoppingUser::class) + ->selectRaw('model_id, MAX(created_at) as deleted_at') + ->groupBy('model_id') + ->get(); + + $repaired = []; + foreach ($deletions as $deletion) { + $target = ShoppingUser::withTrashed()->find($deletion->model_id); + if (! $target || (int) $target->auth_user_id > 0) { + continue; + } + + $rows = ShoppingUser::withTrashed() + ->where(function ($query) use ($target) { + $query->where('id', $target->id); + if ($target->number > 0) { + $query->orWhere('number', $target->number); + } + }) + ->where(function ($query) { + $query->whereNull('auth_user_id')->orWhere('auth_user_id', 0); + }) + ->where('created_at', '<=', $deletion->deleted_at) + ->get() + ->filter(fn (ShoppingUser $row) => ! $row->isGdprDeleted() || ! str_ends_with((string) $row->billing_email, '@dsgvo.invalid')) + ->reject(fn (ShoppingUser $row) => isset($repaired[$row->id])); + + if ($rows->isEmpty()) { + continue; + } + + foreach ($rows as $row) { + $repaired[$row->id] = [ + 'id' => $row->id, + 'number' => $row->number, + 'deletion_logged_at' => (string) $deletion->deleted_at, + ]; + } + + if (! $dryRun) { + DB::transaction(fn () => $this->anonymizeShoppingUserRows($rows)); + $this->logDeletion('gdpr-repair-customer', $target->id, ShoppingUser::class, null, null, [ + 'shopping_user_id' => $target->id, + 'shopping_users_anonymized' => $rows->count(), + ]); + } + } + + return array_values($repaired); + } + /** * Alle Datensätze eines Endkunden, inkl. soft-deleted: der Eintrag selbst, * alle Einträge mit derselben Kundennummer und alle Endkunden-Einträge mit @@ -494,6 +567,7 @@ class GdprDeletionService $row->notice = []; $row->subscribed = false; $row->faker_mail = true; + $row->gdpr_deleted_at ??= now(); $row->save(); } diff --git a/app/Services/ShoppingUserService.php b/app/Services/ShoppingUserService.php index 4fedb291..16b3447c 100644 --- a/app/Services/ShoppingUserService.php +++ b/app/Services/ShoppingUserService.php @@ -1,78 +1,82 @@ billing_email)->where('member_id', '=', $shopping_user->member_id)->get(); - return $users->flatMap(function($user) { + + return $users->flatMap(function ($user) { return $user->shopping_orders; - })->sortByDesc('created_at'); + })->sortByDesc('created_at'); } public static function syncOrdersByEmail($user) { $maxAttempts = 3; $attempt = 1; - + while ($attempt <= $maxAttempts) { try { DB::beginTransaction(); - + $shopping_user = ShoppingUser::where('member_id', $user->id) ->first(); - if (!$shopping_user) { + if (! $shopping_user) { DB::commit(); + return; } $shopping_users = ShoppingUser::where('billing_email', '=', $shopping_user->billing_email) - ->whereHas('shopping_order', function($q) { + ->whereHas('shopping_order', function ($q) { $q->where('txaction', 'paid') - ->orWhere('txaction', 'appointed') - ->orWhere('txaction', 'extern') - ->orWhere('txaction', 'invoice_open') - ->orWhere('txaction', 'invoice_paid'); + ->orWhere('txaction', 'appointed') + ->orWhere('txaction', 'extern') + ->orWhere('txaction', 'invoice_open') + ->orWhere('txaction', 'invoice_paid'); }) ->lockForUpdate() ->get(); $order_count = $shopping_users->count(); - foreach($shopping_users as $user) { + foreach ($shopping_users as $user) { $user->orders = $order_count; $user->save(); } DB::commit(); + return; // Erfolgreicher Fall } catch (QueryException $e) { DB::rollBack(); - + // Wenn es ein Deadlock ist, versuchen wir es erneut if ($e->getCode() == 40001 && $attempt < $maxAttempts) { $sleepTime = pow(2, $attempt) * 100000; // Exponentielles Backoff in Mikrosekunden usleep($sleepTime); $attempt++; + continue; } - + throw $e; // Andere Fehler oder zu viele Versuche } } @@ -91,52 +95,55 @@ class ShoppingUserService }*/ /** * Synchronisiert die Bestellungen eines ShoppingOrders mit den Bestellungen des zugehörigen ShoppingUsers - * - * @param ShoppingOrder $shopping_order Der zu synchronisierende ShoppingOrder - */ - public static function snycOrdersByShoppingOrder(ShoppingOrder $shopping_order) { + * + * @param ShoppingOrder $shopping_order Der zu synchronisierende ShoppingOrder + */ + public static function snycOrdersByShoppingOrder(ShoppingOrder $shopping_order) + { $shopping_user = $shopping_order->shopping_user ? $shopping_order->shopping_user : null; - if($shopping_user){ + if ($shopping_user) { self::snycOrdersByShoppingUser($shopping_user); } } /** * Synchronisiert die Bestellungen eines ShoppingUsers mit den Bestellungen des zugehörigen Benutzers - * - * @param ShoppingUser $shopping_user Der zu synchronisierende ShoppingUser - */ - public static function snycOrdersByShoppingUser(ShoppingUser $shopping_user) { + * + * @param ShoppingUser $shopping_user Der zu synchronisierende ShoppingUser + */ + public static function snycOrdersByShoppingUser(ShoppingUser $shopping_user) + { // Aktualisiere alle Benutzer mit einer einzigen Datenbankabfrage ShoppingUser::where('billing_email', $shopping_user->billing_email) - ->update([ - 'orders' => ShoppingUser::where('billing_email', $shopping_user->billing_email) - ->whereHas('shopping_order', function($q) { - $q->whereIn('txaction', ['paid', 'appointed', 'extern', 'invoice_open', 'invoice_paid']); - }) - ->count() - ]); + ->update([ + 'orders' => ShoppingUser::where('billing_email', $shopping_user->billing_email) + ->whereHas('shopping_order', function ($q) { + $q->whereIn('txaction', ['paid', 'appointed', 'extern', 'invoice_open', 'invoice_paid']); + }) + ->count(), + ]); } - - public static function snycOrdersByNumber($number){ - if($number <= 0) { + + public static function snycOrdersByNumber($number) + { + if ($number <= 0) { return; } // Aktualisiere alle Benutzer mit einer einzigen Datenbankabfrage ShoppingUser::where('number', '=', $number) - ->update([ - 'orders' => ShoppingUser::where('number', '=', $number) - ->whereHas('shopping_order', function($q) { - $q->whereIn('txaction', ['paid', 'appointed', 'extern', 'invoice_open', 'invoice_paid']); - }) - ->count() - ]); + ->update([ + 'orders' => ShoppingUser::where('number', '=', $number) + ->whereHas('shopping_order', function ($q) { + $q->whereIn('txaction', ['paid', 'appointed', 'extern', 'invoice_open', 'invoice_paid']); + }) + ->count(), + ]); // Setze orders auf NULL für alle anderen ShoppingUser::where('number', '=', $number) - ->whereDoesntHave('shopping_order', function($q) { + ->whereDoesntHave('shopping_order', function ($q) { $q->whereIn('txaction', ['paid', 'appointed', 'extern', 'extern_paid']); }) ->update(['orders' => null]); @@ -144,22 +151,23 @@ class ShoppingUserService /** * Synchronisiert die Nummern aller ShoppingUser eines Benutzers anhand der E-Mails - * - * @param User $user Der Benutzer, dessen ShoppingUser synchronisiert werden sollen - */ - public static function syncNumbersByEmail($user) { + * + * @param User $user Der Benutzer, dessen ShoppingUser synchronisiert werden sollen + */ + public static function syncNumbersByEmail($user) + { // Hole alle ShoppingUser mit E-Mail gruppiert $emailGroups = ShoppingUser::where('member_id', $user->id) ->whereNotNull('billing_email') ->where('billing_email', '!=', '') ->get() ->groupBy('billing_email'); - - foreach($emailGroups as $email => $users) { + + foreach ($emailGroups as $email => $users) { // Suche ob es bereits eine Nummer für diese E-Mail gibt $existingNumber = ($users->whereNotNull('number')->first()) ? $users->whereNotNull('number')->first()->number : null; - - if($existingNumber) { + + if ($existingNumber) { // Wenn eine Nummer existiert, nutze diese für alle User mit der E-Mail ShoppingUser::where('member_id', $user->id) ->where('billing_email', $email) @@ -169,20 +177,21 @@ class ShoppingUserService // Wenn keine Nummer existiert, generiere eine neue $maxNumber = ShoppingUser::max('number') ?: 1000; $newNumber = $maxNumber + 1; - + ShoppingUser::where('member_id', $user->id) ->where('billing_email', $email) ->update(['number' => $newNumber]); } } } - + /** * Setzt alle Faker-E-Mails für einen Benutzer auf 1 - * - * @param User $user Der Benutzer, dessen Faker-E-Mails gesetzt werden sollen - */ - public static function setFakerMail($user){ + * + * @param User $user Der Benutzer, dessen Faker-E-Mails gesetzt werden sollen + */ + public static function setFakerMail($user) + { // Aktualisiere alle Faker-E-Mails für den Benutzer in einem einzigen Update // Schneller da nur ein einzelner SQL Query ausgeführt wird ShoppingUser::where('member_id', $user->id) @@ -193,6 +202,7 @@ class ShoppingUserService // Setze alle anderen auf NULL zurück ShoppingUser::where('member_id', $user->id) ->where('billing_email', 'NOT LIKE', '%faker@mivita.care') + ->whereNull('gdpr_deleted_at') ->update(['faker_mail' => null]); } -} \ No newline at end of file +} diff --git a/database/migrations/2026_10_06_095331_add_gdpr_deleted_at_to_shopping_users_table.php b/database/migrations/2026_10_06_095331_add_gdpr_deleted_at_to_shopping_users_table.php new file mode 100644 index 00000000..575e09eb --- /dev/null +++ b/database/migrations/2026_10_06_095331_add_gdpr_deleted_at_to_shopping_users_table.php @@ -0,0 +1,37 @@ +timestamp('gdpr_deleted_at')->nullable()->index()->after('deleted_at'); + }); + + DB::table('shopping_users') + ->where('billing_email', 'like', 'geloescht\_%@dsgvo.invalid') + ->whereNull('gdpr_deleted_at') + ->update(['gdpr_deleted_at' => DB::raw('COALESCE(updated_at, CURRENT_TIMESTAMP)')]); + } + + public function down(): void + { + Schema::table('shopping_users', function (Blueprint $table) { + $table->dropIndex(['gdpr_deleted_at']); + $table->dropColumn('gdpr_deleted_at'); + }); + } +}; diff --git a/resources/lang/de/msg.php b/resources/lang/de/msg.php index 3f831997..a9db95f1 100644 --- a/resources/lang/de/msg.php +++ b/resources/lang/de/msg.php @@ -42,7 +42,8 @@ return [ 'cart_product_not_allowed_for_order_type' => 'Der Warenkorb enthält Artikel, die für diese Bestellart nicht vorgesehen sind. Bitte leere den Warenkorb und wähle nur Produkte, die zu dieser Bestellung passen.', 'cart_max_weight_reached' => 'Das maximale Versandgewicht ist erreicht. Es können keine weiteren Produkte hinzugefügt werden.', 'gdpr_delete_success' => 'DSGVO-Löschung ausgeführt: Alle personenbezogenen Daten wurden unwiderruflich anonymisiert.', - 'gdpr_delete_customer_success' => 'DSGVO-Löschung ausgeführt: Alle personenbezogenen Daten des Kunden wurden unwiderruflich anonymisiert.', + 'gdpr_delete_customer_success' => 'DSGVO-Löschung ausgeführt: :count Kundeneintrag/-einträge bearbeitet. Kontaktdaten und Portal-Logins sind entfernt; Name und Rechnungsadresse bleiben wegen der Aufbewahrungspflicht an den Bestellungen. Die Einträge sind in der Kundenliste ausgeblendet und gesperrt.', + 'gdpr_customer_locked' => 'Dieser Kunde wurde DSGVO-gelöscht und kann nicht mehr bearbeitet werden.', 'gdpr_delete_customer_button' => 'Kunde DSGVO-löschen', 'gdpr_confirm_checkbox_missing' => 'Bitte bestätige die DSGVO-Löschung über die Checkbox.', 'gdpr_error_is_admin' => 'DSGVO-Löschung nicht möglich: Der Account hat Admin-Rechte. Bitte entziehe zuerst die Admin-Rechte.', diff --git a/resources/lang/en/msg.php b/resources/lang/en/msg.php index 3dfddfde..550f204f 100644 --- a/resources/lang/en/msg.php +++ b/resources/lang/en/msg.php @@ -44,7 +44,8 @@ return [ 'cart_product_not_allowed_for_order_type' => 'Your cart contains items that are not allowed for this order type. Please clear the cart and only add products that match this order.', 'cart_max_weight_reached' => 'The maximum shipping weight has been reached. No further products can be added.', 'gdpr_delete_success' => 'GDPR deletion executed: All personal data has been irreversibly anonymized.', - 'gdpr_delete_customer_success' => 'GDPR deletion executed: All personal data of the customer has been irreversibly anonymized.', + 'gdpr_delete_customer_success' => 'GDPR deletion executed: :count customer record(s) processed. Contact data and portal logins have been removed; name and billing address are kept on the orders due to the statutory retention obligation. The records are hidden in the customer list and locked.', + 'gdpr_customer_locked' => 'This customer has been deleted under GDPR and can no longer be edited.', 'gdpr_delete_customer_button' => 'GDPR-delete customer', 'gdpr_confirm_checkbox_missing' => 'Please confirm the GDPR deletion via the checkbox.', 'gdpr_error_is_admin' => 'GDPR deletion not possible: The account has admin rights. Please remove the admin rights first.', diff --git a/resources/lang/es/msg.php b/resources/lang/es/msg.php index 677188f7..3be500d4 100644 --- a/resources/lang/es/msg.php +++ b/resources/lang/es/msg.php @@ -44,7 +44,8 @@ return [ ], 'cart_product_not_allowed_for_order_type' => 'El carrito contiene artículos que no están previstos para este tipo de pedido. Vacía el carrito y elige solo productos que correspondan a este pedido.', 'gdpr_delete_success' => 'Eliminación RGPD ejecutada: Todos los datos personales han sido anonimizados de forma irreversible.', - 'gdpr_delete_customer_success' => 'Eliminación RGPD ejecutada: Todos los datos personales del cliente han sido anonimizados de forma irreversible.', + 'gdpr_delete_customer_success' => 'Eliminación RGPD ejecutada: :count registro(s) de cliente procesado(s). Se han eliminado los datos de contacto y los accesos al portal; el nombre y la dirección de facturación se conservan en los pedidos por la obligación legal de conservación. Los registros están ocultos en la lista de clientes y bloqueados.', + 'gdpr_customer_locked' => 'Este cliente ha sido eliminado según el RGPD y ya no se puede editar.', 'gdpr_delete_customer_button' => 'Eliminar cliente (RGPD)', 'gdpr_confirm_checkbox_missing' => 'Por favor confirma la eliminación RGPD mediante la casilla de verificación.', 'gdpr_error_is_admin' => 'Eliminación RGPD no posible: La cuenta tiene derechos de administrador. Por favor retira primero los derechos de administrador.', diff --git a/resources/lang/fr/msg.php b/resources/lang/fr/msg.php index 1692b6e5..194d4956 100644 --- a/resources/lang/fr/msg.php +++ b/resources/lang/fr/msg.php @@ -42,7 +42,8 @@ return [ 'cart_product_not_allowed_for_order_type' => 'Le panier contient des articles qui ne sont pas prévus pour ce type de commande. Veuillez vider le panier et sélectionner uniquement les produits adaptés à cette commande.', 'cart_max_weight_reached' => "Le poids d'expédition maximal est atteint. Aucun autre produit ne peut être ajouté.", 'gdpr_delete_success' => 'Suppression RGPD exécutée : Toutes les données personnelles ont été anonymisées de manière irréversible.', - 'gdpr_delete_customer_success' => 'Suppression RGPD exécutée : Toutes les données personnelles du client ont été anonymisées de manière irréversible.', + 'gdpr_delete_customer_success' => 'Suppression RGPD exécutée : :count fiche(s) client traitée(s). Les coordonnées et les accès au portail ont été supprimés ; le nom et l’adresse de facturation sont conservés sur les commandes en raison de l’obligation légale de conservation. Les fiches sont masquées dans la liste des clients et verrouillées.', + 'gdpr_customer_locked' => 'Ce client a été supprimé conformément au RGPD et ne peut plus être modifié.', 'gdpr_delete_customer_button' => 'Supprimer le client (RGPD)', 'gdpr_confirm_checkbox_missing' => 'Veuillez confirmer la suppression RGPD via la case à cocher.', 'gdpr_error_is_admin' => "Suppression RGPD impossible : Le compte dispose de droits d'administrateur. Veuillez d'abord retirer les droits d'administrateur.", diff --git a/resources/views/admin/customer/_detail.blade.php b/resources/views/admin/customer/_detail.blade.php index a9f8fefc..0cdf71ce 100644 --- a/resources/views/admin/customer/_detail.blade.php +++ b/resources/views/admin/customer/_detail.blade.php @@ -1,6 +1,13 @@
+ @if($shopping_user->isGdprDeleted()) +
+ DSGVO-gelöscht am {{ $shopping_user->gdpr_deleted_at->format('d.m.Y H:i') }}. + Kontaktdaten wurden entfernt. Name und Rechnungsadresse bleiben wegen der gesetzlichen + Aufbewahrungspflicht an den Bestellungen erhalten. Der Eintrag kann nicht mehr bearbeitet werden. +
+ @endif @if($isAdmin)
@@ -20,7 +27,9 @@ {{$shopping_user->created_at->format("d.m.Y H:i")}}
+ @if(!$shopping_user->isGdprDeleted()) {{ __('customer.edit_customer_data') }} + @endif
@@ -44,7 +53,7 @@
- @if(!$shopping_user->is_like) + @if(!$shopping_user->is_like && !$shopping_user->isGdprDeleted()) {{ __('customer.edit_customer_data') }} @endif
diff --git a/resources/views/admin/customer/detail.blade.php b/resources/views/admin/customer/detail.blade.php index 09c38880..b9efde50 100644 --- a/resources/views/admin/customer/detail.blade.php +++ b/resources/views/admin/customer/detail.blade.php @@ -49,7 +49,8 @@ {{ $row->id }} {{ $row->number ?: '-' }} {{ trim($row->billing_firstname.' '.$row->billing_lastname) }} - @if ($row->deleted_at) gelöscht @endif + @if ($row->deleted_at) gelöscht + @elseif ($row->isGdprDeleted()) DSGVO-gelöscht @endif {{ $row->member->email ?? '-' }} {{ $deletion['orders_by_row'][$row->id] ?? 0 }} @@ -60,12 +61,24 @@
- @if ($deletion['orders_count'] > 0) + @if ($deletion['all_gdpr_deleted']) +
+ Der Kunde wurde bereits am {{ $deletion['gdpr_deleted_at']->format('d.m.Y H:i') }} DSGVO-gelöscht. + Alle Kontaktdaten (E-Mail, Telefon, Notizen, Newsletter) und Portal-Logins sind entfernt. Name und + Rechnungsadresse bleiben wegen der gesetzlichen Aufbewahrungspflicht an den Bestellungen erhalten. + Die Einträge sind in der Kundenliste ausgeblendet (Filter „DSGVO-gelöschte Kunden“) und gegen + Bearbeiten gesperrt. +
+ @elseif ($deletion['orders_count'] > 0)
Der Kunde hat schon {{ $deletion['orders_count'] }} Bestellung(en). Es ist nur DSGVO löschen 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. + Alle Kontaktdaten (E-Mail, Telefon, Notizen, Newsletter) sowie die Portal-Logins werden unwiderruflich entfernt. + Name und Rechnungsadresse bleiben bei Einträgen mit Bestellung wegen der gesetzlichen + Aufbewahrungspflicht erhalten. Die Einträge werden danach in der Kundenliste ausgeblendet und gegen + Bearbeiten gesperrt. + @if ($deletion['gdpr_deleted_rows'] > 0) +
{{ $deletion['gdpr_deleted_rows'] }} von {{ $deletion['rows']->count() }} Einträgen sind bereits gelöscht. + @endif @if ($deletion['active_abos'] > 0)
{{ __('msg.gdpr_error_active_abo') }} @endif diff --git a/resources/views/admin/customer/index.blade.php b/resources/views/admin/customer/index.blade.php index 06e1826c..889efb46 100644 --- a/resources/views/admin/customer/index.blade.php +++ b/resources/views/admin/customer/index.blade.php @@ -10,7 +10,7 @@
-
+
+
+ + +
-
- - {!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500, 'required' => 'required', 'placeholder' => 'Bitte konkret: z. B. Name der Person, die dich empfohlen hat, oder die genaue Messe/Veranstaltung']) !!} +
+ + Freiwillig – hilft uns aber sehr, dich der richtigen Person zuzuordnen. + {!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500, 'placeholder' => 'z. B. Name der Person, die dich empfohlen hat, oder die genaue Messe/Veranstaltung']) !!} @if ($errors->has('customer_order_source_comment')) @endif diff --git a/tests/Feature/BackofficeStatisticsAccessTest.php b/tests/Feature/BackofficeStatisticsAccessTest.php index 365e1896..0201926b 100644 --- a/tests/Feature/BackofficeStatisticsAccessTest.php +++ b/tests/Feature/BackofficeStatisticsAccessTest.php @@ -387,12 +387,12 @@ it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () { $checkoutRepository = file_get_contents(app_path('Repositories/CheckoutRepository.php')); $orderDetail = file_get_contents(resource_path('views/admin/sales/_detail.blade.php')); - // Kundenentscheid F6: nur noch Freitext-Pflichtfeld, kein Dropdown mehr + // Kundenentscheid F6: nur noch Freitext, kein Dropdown mehr; seit 10/2026 freiwillig expect($checkoutView)->toContain('customer_order_source_comment'); expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?'); expect($checkoutView)->not->toContain('name="customer_order_source"'); expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'"); - expect($checkoutController)->toContain("\$rules['customer_order_source_comment'] = 'required|string|max:500'"); + expect($checkoutController)->toContain("\$rules['customer_order_source_comment'] = 'nullable|string|max:500'"); expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\''); expect($checkoutRepository)->toContain('customer_order_source_comment'); expect($orderDetail)->toContain('getCustomerOrderSourceLabel'); diff --git a/tests/Feature/CheckoutOrderSourceValidationTest.php b/tests/Feature/CheckoutOrderSourceValidationTest.php index 38ef8f14..23ed0136 100644 --- a/tests/Feature/CheckoutOrderSourceValidationTest.php +++ b/tests/Feature/CheckoutOrderSourceValidationTest.php @@ -37,12 +37,19 @@ function cosvBaseCheckoutInput(array $overrides = []): array ], $overrides); } -it('verlangt den Herkunfts-Freitext bei Shop-Kundenbestellungen', function () { +it('akzeptiert Shop-Kundenbestellungen ohne Herkunfts-Freitext', function () { $validator = cosvValidateCheckout(cosvBaseCheckoutInput()); - expect($validator->fails())->toBeTrue(); - expect($validator->errors()->has('customer_order_source_comment'))->toBeTrue(); - expect($validator->errors()->first('customer_order_source_comment'))->toContain('aufmerksam geworden'); + expect($validator->fails())->toBeFalse(); + expect($validator->errors()->has('customer_order_source_comment'))->toBeFalse(); +}); + +it('akzeptiert einen leeren Herkunfts-Freitext', function () { + $validator = cosvValidateCheckout(cosvBaseCheckoutInput([ + 'customer_order_source_comment' => null, + ])); + + expect($validator->fails())->toBeFalse(); }); it('akzeptiert den Checkout ohne das frühere Herkunfts-Dropdown', function () { diff --git a/tests/Feature/GdprCustomerLockTest.php b/tests/Feature/GdprCustomerLockTest.php new file mode 100644 index 00000000..d882a405 --- /dev/null +++ b/tests/Feature/GdprCustomerLockTest.php @@ -0,0 +1,214 @@ +country = Country::create([ + 'code' => 'DE', + 'phone' => '49', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Alemania', + 'fr' => 'Allemagne', + 'it' => 'Germania', + 'ru' => 'Германия', + ]); + + $this->member = User::forceCreate([ + 'email' => 'gcl-member-'.uniqid('', true).'@example.com', + 'password' => Hash::make('secret'), + 'lang' => 'de', + ]); + + $this->userShop = UserShop::create([ + 'user_id' => $this->member->id, + 'name' => 'GCL'.substr(uniqid('', true), 0, 8), + 'slug' => 'gcl-'.uniqid(), + 'active' => true, + ]); +}); + +function gclCustomer(array $attributes = []): ShoppingUser +{ + return ShoppingUser::create(array_merge([ + 'auth_user_id' => 0, + 'member_id' => test()->member->id, + 'number' => 5100, + 'billing_firstname' => 'Susann', + 'billing_lastname' => 'Voigt', + 'billing_address' => 'Hauptstraße 1', + 'billing_zipcode' => '19243', + 'billing_city' => 'Wittenburg', + 'billing_phone' => '0123', + 'billing_email' => 'susann@example.com', + 'billing_country_id' => test()->country->id, + 'shipping_country_id' => test()->country->id, + ], $attributes)); +} + +function gclOrderFor(ShoppingUser $customer): ShoppingOrder +{ + return ShoppingOrder::create([ + 'shopping_user_id' => $customer->id, + 'auth_user_id' => 0, + 'country_id' => test()->country->id, + 'user_shop_id' => test()->userShop->id, + 'payment_for' => 6, + 'total' => 100, + 'subtotal' => 90, + ]); +} + +it('markiert alle Einträge bei der DSGVO-Löschung und meldet den Status im Modal', function () { + $first = gclCustomer(); + $second = gclCustomer(); + gclOrderFor($first); + + $service = app(GdprDeletionService::class); + $before = $service->analyzeShoppingUser($first); + expect($before['all_gdpr_deleted'])->toBeFalse() + ->and($before['gdpr_deleted_rows'])->toBe(0); + + $service->deleteShoppingUser($first, null); + + expect($first->fresh()->isGdprDeleted())->toBeTrue() + ->and($second->fresh()->isGdprDeleted())->toBeTrue() + ->and($first->fresh()->billing_lastname)->toBe('Voigt'); + + $after = $service->analyzeShoppingUser($first->fresh()); + expect($after['all_gdpr_deleted'])->toBeTrue() + ->and($after['gdpr_deleted_rows'])->toBe(2) + ->and($after['gdpr_deleted_at'])->not->toBeNull(); +}); + +it('blendet DSGVO-gelöschte Einträge über den Scope aus', function () { + $deleted = gclCustomer(); + gclOrderFor($deleted); + app(GdprDeletionService::class)->deleteShoppingUser($deleted, null); + + $other = gclCustomer(['number' => 5200, 'billing_email' => 'andere@example.com']); + + $visibleIds = ShoppingUser::query()->withoutGdprDeleted()->pluck('id')->all(); + + expect($visibleIds)->toContain($other->id) + ->not->toContain($deleted->id); +}); + +it('verhindert, dass Kontaktdaten in einen DSGVO-gelöschten Eintrag zurückgeschrieben werden', function (string $field, string $value) { + $customer = gclCustomer(); + gclOrderFor($customer); + app(GdprDeletionService::class)->deleteShoppingUser($customer, null); + + $customer = $customer->fresh(); + $customer->{$field} = $value; + $customer->save(); +})->with([ + 'E-Mail' => ['billing_email', 'susann@example.com'], + 'Telefon' => ['billing_phone', '0123'], + 'Liefer-E-Mail' => ['shipping_email', 'susann@example.com'], + 'Liefer-Telefon' => ['shipping_phone', '0456'], +])->throws(GdprDeletionException::class); + +it('erlaubt an DSGVO-gelöschten Einträgen weiterhin Änderungen ohne Kontaktdaten', function () { + $customer = gclCustomer(); + gclOrderFor($customer); + app(GdprDeletionService::class)->deleteShoppingUser($customer, null); + + $newMember = User::forceCreate([ + 'email' => 'gcl-new-member-'.uniqid('', true).'@example.com', + 'password' => Hash::make('secret'), + 'lang' => 'de', + ]); + + $customer = $customer->fresh(); + $customer->member_id = $newMember->id; + $customer->save(); + + expect($customer->fresh()->member_id)->toBe($newMember->id); +}); + +it('lässt eine erneute DSGVO-Löschung eines bereits gelöschten Kunden zu', function () { + $customer = gclCustomer(); + gclOrderFor($customer); + $service = app(GdprDeletionService::class); + + $service->deleteShoppingUser($customer, null); + $firstDeletedAt = $customer->fresh()->gdpr_deleted_at; + + $service->deleteShoppingUser($customer->fresh(), null); + + expect($customer->fresh()->gdpr_deleted_at->equalTo($firstDeletedAt))->toBeTrue(); +}); + +it('markiert auch komplett gelöschte Kunden ohne Bestellung', function () { + $customer = gclCustomer(); + + app(GdprDeletionService::class)->purgeShoppingUser($customer, null); + + $row = ShoppingUser::withTrashed()->find($customer->id); + expect($row->trashed())->toBeTrue() + ->and($row->isGdprDeleted())->toBeTrue(); +}); + +it('setzt faker_mail bei DSGVO-gelöschten Einträgen nicht zurück', function () { + $customer = gclCustomer(); + gclOrderFor($customer); + app(GdprDeletionService::class)->deleteShoppingUser($customer, null); + + ShoppingUserService::setFakerMail($this->member); + + expect($customer->fresh()->faker_mail)->toBeTrue(); +}); + +it('zieht protokollierte Löschungen nach, deren Kontaktdaten wieder befüllt wurden', function () { + $restored = gclCustomer(); + $sibling = gclCustomer(); + gclOrderFor($restored); + gclOrderFor($sibling); + + $service = app(GdprDeletionService::class); + $service->deleteShoppingUser($sibling, null); + + // Zustand wie bei Kunde 45013: Kontaktdaten vor Einführung der Sperre wieder befüllt + ShoppingUser::whereKey($restored->id)->update([ + 'billing_email' => 'susann@example.com', + 'billing_phone' => '0123', + 'faker_mail' => false, + 'gdpr_deleted_at' => null, + ]); + + $deletionLoggedAt = Logger::where('action', 'gdpr-delete-customer')->value('created_at'); + $this->travelTo($deletionLoggedAt->copy()->addDay()); + $lateCustomer = gclCustomer(['billing_email' => 'susann-neu@example.com']); + gclOrderFor($lateCustomer); + + $preview = $service->repairLoggedShoppingUserDeletions(true); + expect(array_column($preview, 'id'))->toBe([$restored->id]) + ->and($restored->fresh()->billing_email)->toBe('susann@example.com'); + + $service->repairLoggedShoppingUserDeletions(false); + + $restored = $restored->fresh(); + expect($restored->billing_email)->toEndWith('@dsgvo.invalid') + ->and($restored->billing_phone)->toBeNull() + ->and($restored->isGdprDeleted())->toBeTrue() + ->and($lateCustomer->fresh()->billing_email)->toBe('susann-neu@example.com') + ->and($lateCustomer->fresh()->isGdprDeleted())->toBeFalse() + ->and($service->repairLoggedShoppingUserDeletions(true))->toBe([]); +});