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 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-10-06 12:36:02 +00:00
parent 083d643220
commit 3d0864713b
19 changed files with 635 additions and 90 deletions

View file

@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Services\GdprDeletionService;
use Illuminate\Console\Command;
class GdprRepairCustomers extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'gdpr:repair-customers
{--execute : Änderungen wirklich ausführen (ohne Option nur Vorschau)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Zieht protokollierte DSGVO-Löschungen von Endkunden nach: Einträge, deren Kontaktdaten nach der Löschung wieder befüllt wurden, werden erneut anonymisiert und gesperrt.';
/**
* Execute the console command.
*/
public function handle(GdprDeletionService $gdprDeletionService): int
{
$execute = (bool) $this->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;
}
}

View file

@ -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' ? ' <span class="badge badge-warning">dev</span>' : '');
return get_active_badge($ShoppingUser->auth_user_id).($ShoppingUser->mode === 'dev' ? ' <span class="badge badge-warning">dev</span>' : '')
.($ShoppingUser->isGdprDeleted() ? ' <span class="badge badge-secondary">DSGVO-gelöscht</span>' : '');
})
->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]));
}
}

View file

@ -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]));
}
}

View file

@ -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);
}
/**

View file

@ -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<int, string>
*/
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<ShoppingUser> $query
*/
public function scopeWithoutGdprDeleted($query): void
{
$query->whereNull($query->qualifyColumn('gdpr_deleted_at'));
}
// can null
public function member()
{

View file

@ -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<int, int>,
* 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<int, array{id: int, number: int|null, deletion_logged_at: string}>
*/
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();
}

View file

@ -1,78 +1,82 @@
<?php
namespace App\Services;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\User;
use Yard;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
class ShoppingUserService
{
/**
* Gibt alle Bestellungen eines Mitglieds zurück
*
* @param ShoppingUser $shopping_user Das Mitglied, dessen Bestellungen zurückgegeben werden sollen
*
* @param ShoppingUser $shopping_user Das Mitglied, dessen Bestellungen zurückgegeben werden sollen
* @return Collection Die Bestellungen des Mitglieds
*/
public static function getAllOrdersByMember($shopping_user){
public static function getAllOrdersByMember($shopping_user)
{
$users = ShoppingUser::where('billing_email', '=', $shopping_user->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]);
}
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Markierung für DSGVO-gelöschte Kundeneinträge: Einträge mit Bestellung bleiben
* wegen der Aufbewahrungspflicht bestehen, werden aber ausgeblendet und gegen
* erneutes Bearbeiten gesperrt.
*
* Bestand: Alle bereits anonymisierten Einträge (Platzhalter-E-Mail) werden mit
* ihrem letzten Änderungsdatum markiert.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shopping_users', function (Blueprint $table) {
$table->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');
});
}
};

View file

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

View file

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

View file

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

View file

@ -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.",

View file

@ -1,6 +1,13 @@
<div class="card">
<!-- Info -->
<div class="card-body pb-1">
@if($shopping_user->isGdprDeleted())
<div class="alert alert-secondary">
<b>DSGVO-gelöscht am {{ $shopping_user->gdpr_deleted_at->format('d.m.Y H:i') }}.</b>
Kontaktdaten wurden entfernt. Name und Rechnungsadresse bleiben wegen der gesetzlichen
Aufbewahrungspflicht an den Bestellungen erhalten. Der Eintrag kann nicht mehr bearbeitet werden.
</div>
@endif
@if($isAdmin)
<div class="row">
<div class="col-md-3 mb-3">
@ -20,7 +27,9 @@
{{$shopping_user->created_at->format("d.m.Y H:i")}}
</div>
<div class="col-md-3 mb-3">
@if(!$shopping_user->isGdprDeleted())
<a class="btn btn-sm btn-secondary mt-2" href="{{route('admin_customer_edit', [$shopping_user->id])}}">{{ __('customer.edit_customer_data') }}</a>
@endif
<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>
@ -44,7 +53,7 @@
</div>
<div class="col-md-3 mb-3">
@if(!$shopping_user->is_like)
@if(!$shopping_user->is_like && !$shopping_user->isGdprDeleted())
<a class="btn btn-sm btn-secondary mt-2" href="{{route('user_customer_edit', [$shopping_user->id])}}">{{ __('customer.edit_customer_data') }}</a>
@endif
</div>

View file

@ -49,7 +49,8 @@
<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
@if ($row->deleted_at) <span class="badge badge-secondary">gelöscht</span>
@elseif ($row->isGdprDeleted()) <span class="badge badge-secondary">DSGVO-gelöscht</span> @endif
</td>
<td>{{ $row->member->email ?? '-' }}</td>
<td>{{ $deletion['orders_by_row'][$row->id] ?? 0 }}</td>
@ -60,12 +61,24 @@
</table>
</div>
@if ($deletion['orders_count'] > 0)
@if ($deletion['all_gdpr_deleted'])
<div class="alert alert-success mb-0">
Der Kunde wurde bereits am <b>{{ $deletion['gdpr_deleted_at']->format('d.m.Y H:i') }}</b> 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.
</div>
@elseif ($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.
Alle Kontaktdaten (E-Mail, Telefon, Notizen, Newsletter) sowie die Portal-Logins werden unwiderruflich entfernt.
<b>Name und Rechnungsadresse bleiben</b> 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)
<br>{{ $deletion['gdpr_deleted_rows'] }} von {{ $deletion['rows']->count() }} Einträgen sind bereits gelöscht.
@endif
@if ($deletion['active_abos'] > 0)
<br><b>{{ __('msg.gdpr_error_active_abo') }}</b>
@endif

View file

@ -10,7 +10,7 @@
<div class="card">
<div class="card-header">
<div class="form-row align-items-center">
<div class="col-sm-10 mb-2">
<div class="col-sm-7 mb-2">
<label class="form-label" for="filter_user_shop_id">Filter zugewiesener Berater</label>
<select class="selectpicker" data-style="btn-default" name="filter_member_id" id="filter_member_id" data-live-search="true">
<option value="">Filter aus</option>
@ -19,6 +19,14 @@
@endforeach
</select>
</div>
<div class="col-sm-3 mb-2">
<label class="form-label" for="filter_gdpr_deleted">DSGVO-gelöschte Kunden</label>
<select class="custom-select" name="filter_gdpr_deleted" id="filter_gdpr_deleted">
<option value="">ausblenden</option>
<option value="all" @if(get_user_attr('filter_gdpr_deleted') === 'all') selected @endif>mit anzeigen</option>
<option value="only" @if(get_user_attr('filter_gdpr_deleted') === 'only') selected @endif>nur gelöschte</option>
</select>
</div>
<div class="col-2 col-sm-2 mb-2 mt-4">
<a href="{{ route('admin_customers') }}?reset=filter" data-toggle="tooltip" data-placement="top" title="Reset Filter" class="btn icon-btn btn-sm btn-outline-dark float-right">
<span class="fa fa-sync"></span>
@ -73,6 +81,7 @@
data: function(d) {
d.filter_member_id = $('select[name=filter_member_id]').val();
d.filter_customer_member = $('select[name=filter_customer_member]').val();
d.filter_gdpr_deleted = $('select[name=filter_gdpr_deleted]').val();
}
},
"order": [[0, "desc" ]],
@ -105,6 +114,9 @@
$('#filter_customer_member').on('change', function(){
oTable.draw();
});
$('#filter_gdpr_deleted').on('change', function(){
oTable.draw();
});
});
</script>

View file

@ -111,6 +111,19 @@
font-size: 0.8em;
font-style: italic;
}
.order-source-highlight {
background: #f3f7f0;
border: 1px solid #97b085;
border-left: 4px solid #97b085;
border-radius: 4px;
padding: 12px 15px;
}
.order-source-highlight label {
font-weight: 600;
color: #5f7a4e;
}
select.form-control:not([size]):not([multiple]) {
height: 38px;
color: #6f6f6f;
@ -554,9 +567,10 @@
</div>
<div class="row mt-3">
<div class="col-md-12 col-sm-12">
<div class="form-group {{($errors->has('customer_order_source_comment') ? 'error' : '')}}">
<label class="mt-0 fs-14 fw-400" for="customer_order_source_comment">Wie bist du auf uns aufmerksam geworden? *</label>
{!! 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']) !!}
<div class="form-group order-source-highlight {{($errors->has('customer_order_source_comment') ? 'error' : '')}}">
<label class="mt-0 fs-14" for="customer_order_source_comment"><i class="fa fa-heart"></i> Wie bist du auf uns aufmerksam geworden?</label>
<span class="info-small mb-2" style="display: block;">Freiwillig – hilft uns aber sehr, dich der richtigen Person zuzuordnen.</span>
{!! 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'))
<label for="customer_order_source_comment" class="error text-danger small" style="display: block;">{{ $errors->first('customer_order_source_comment') }}</label>
@endif

View file

@ -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');

View file

@ -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 () {

View file

@ -0,0 +1,214 @@
<?php
use App\Exceptions\GdprDeletionException;
use App\Models\Country;
use App\Models\Logger;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserShop;
use App\Services\GdprDeletionService;
use App\Services\ShoppingUserService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Mail::fake();
$this->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([]);
});