Zusammenfassung der ausgeführten Schritte * * @throws GdprDeletionException wenn Sperrgründe vorliegen (Admin-Account, aktives Abo) */ public function deleteUser(User $user, ?int $executedByUserId = null): array { if ($user->admin >= 2) { throw new GdprDeletionException(__('msg.gdpr_error_is_admin')); } $activeAbos = UserAbo::where('user_id', $user->id)->where('active', true)->count(); if ($activeAbos > 0) { throw new GdprDeletionException(__('msg.gdpr_error_active_abo')); } $originalEmail = $user->email; // Vor der Transaktion einsammeln: die IDs sind danach genullt, und der // Stripe-Aufruf gehört nicht in eine offene DB-Transaktion. $stripeCustomerIds = $this->collectStripeCustomerIds( $user, ShoppingUser::withTrashed()->where('auth_user_id', $user->id)->get() ); $summary = DB::transaction(function () use ($user) { $summary = ['user_id' => $user->id]; // MLM-Struktur erhalten: Downline und Kunden auf den nächsten aktiven Sponsor // übertragen. Bewusst beim Sponsor starten, nicht beim User selbst — ein noch // aktiver User würde sonst sich selbst als neuen Sponsor erhalten. $active_sponsor = UserUtil::findNextActiveSponsor($user->m_sponsor ?: $user->pre_sponsor); if ($active_sponsor && $active_sponsor->id !== $user->id) { UserUtil::setNewSponsorToChilds($user->id, $active_sponsor->id); UserUtil::setShoppingUserToNewMember($user->id, $active_sponsor->id); $summary['new_sponsor_id'] = $active_sponsor->id; } // Bestehende Komplett-Löschung: E-Mail randomisieren, Zugangsdaten entwerten, // Account + Shop soft-deleten UserUtil::deleteUser($user, true); $this->anonymizeUserRow($user); $this->anonymizeUserAccount($user); $this->anonymizeUserShop($user); $summary['files_deleted'] = $this->deleteUserFiles($user); $summary['update_emails_deleted'] = UserUpdateEmail::where('user_id', $user->id)->delete(); $summary['abos_anonymized'] = $this->anonymizeUserAbos('user_id', $user->id); $summary['business_rows_anonymized'] = $this->anonymizeUserBusinessRows($user); $summary['shopping_users_anonymized'] = $this->anonymizeShoppingUserRows( ShoppingUser::withTrashed()->where('auth_user_id', $user->id)->get() ); return $summary; }); $summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds); $this->logDeletion('gdpr-delete-user', $user->id, User::class, $executedByUserId, $originalEmail, $summary); return $summary; } /** * Endkunden (ShoppingUser) DSGVO-konform löschen. * * Anonymisiert alle Datensätze des Kunden (Einträge mit gleicher Kundennummer). * * @return array * * @throws GdprDeletionException wenn der Datensatz zu einem Berater gehört oder ein aktives Abo besteht */ public function deleteShoppingUser(ShoppingUser $shoppingUser, ?int $executedByUserId = null): array { if ($shoppingUser->auth_user_id) { throw new GdprDeletionException(__('msg.gdpr_error_customer_is_member')); } $rows = $this->getCustomerRows($shoppingUser); $activeAbos = UserAbo::whereIn('shopping_user_id', $rows->pluck('id'))->where('active', true)->count(); if ($activeAbos > 0) { throw new GdprDeletionException(__('msg.gdpr_error_active_abo')); } $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; 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; }); $summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds); $this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary); return $summary; } /** * 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 * * @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, * email: string|null, * is_member: bool, * orders_count: int, * orders_by_row: array, * active_abos: int, * can_purge: bool, * gdpr_deleted_rows: int, * gdpr_deleted_at: Carbon|null, * all_gdpr_deleted: 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(); $gdprDeletedRows = $rows->filter(fn (ShoppingUser $row) => $row->isGdprDeleted()); 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, '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 * derselben E-Mail-Adresse (Kunden werden pro Bestellung neu angelegt und * bekommen nicht immer dieselbe Kundennummer). * * @return \Illuminate\Database\Eloquent\Collection */ public function getCustomerRows(ShoppingUser $shoppingUser) { $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 $email; } /** * Portal-Logins (customers) des Kunden entfernen: Sie enthalten Name und * E-Mail und sind reine Zugangsdaten ohne Aufbewahrungspflicht. * * @param Collection|\Illuminate\Database\Eloquent\Collection $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 { $user->stripe_customer_id = null; $user->notes = null; $user->settings = null; $user->payment_methods = null; $user->last_login = null; $user->save(); } protected function anonymizeUserAccount(User $user): void { if (! $user->account_id) { return; } $account = UserAccount::withTrashed()->find($user->account_id); if (! $account) { return; } $account->m_salutation = null; $account->m_first_name = self::ANONYMIZED_FIRSTNAME; $account->m_last_name = self::ANONYMIZED_LASTNAME; $account->m_notes = null; $account->company = null; $account->salutation = null; $account->first_name = self::ANONYMIZED_FIRSTNAME; $account->last_name = self::ANONYMIZED_LASTNAME; $account->address = null; $account->address_2 = null; $account->zipcode = null; $account->city = null; $account->pre_phone_id = null; $account->phone = null; $account->pre_mobil_id = null; $account->mobil = null; $account->tax_number = null; $account->tax_identification_number = null; $account->shipping_salutation = null; $account->shipping_company = null; $account->shipping_firstname = null; $account->shipping_lastname = null; $account->shipping_address = null; $account->shipping_address_2 = null; $account->shipping_zipcode = null; $account->shipping_city = null; $account->shipping_pre_phone_id = null; $account->shipping_phone = null; $account->shipping_postnumber = null; $account->birthday = null; $account->website = null; $account->facebook = null; $account->facebook_fanpage = null; $account->instagram = null; $account->bank_owner = null; $account->bank_iban = null; $account->bank_bic = null; $account->payment_data = null; $account->notice = null; if ($account->profile_image) { Storage::disk('public')->delete('images/profile/'.$account->profile_image); } $account->profile_image = null; $account->save(); } protected function anonymizeUserShop(User $user): void { $shop = UserShop::withTrashed()->where('user_id', $user->id)->first(); if (! $shop) { return; } if ($shop->filename) { Storage::disk('public')->delete('images/shop/'.$shop->filename); } $shop->title = null; $shop->contact = null; $shop->accessibility = null; $shop->about = null; $shop->trans = null; $shop->filename = null; $shop->originalname = null; $shop->ext = null; $shop->mine = null; $shop->size = null; $shop->save(); } protected function deleteUserFiles(User $user): int { $count = 0; foreach ($user->files()->get() as $file) { Storage::disk('user')->delete($file->dir.$file->filename); $file->delete(); $count++; } return $count; } /** * Zahlungs-Tokens und Kontaktdaten aller Abos entfernen (auch soft-deleted). */ protected function anonymizeUserAbos(string $column, int $id): int { $count = 0; $abos = UserAbo::withTrashed()->where($column, $id)->get(); foreach ($abos as $abo) { $abo->email = null; $abo->carddata = null; $abo->payone_userid = 0; $abo->stripe_customer_id = null; $abo->stripe_payment_method_id = null; $abo->active = false; $abo->save(); $count++; } return $count; } /** * Denormalisierte Personendaten in den monatlichen Provisions-Snapshots * anonymisieren; die Finanzdaten (Umsätze, Provisionen) bleiben erhalten. */ protected function anonymizeUserBusinessRows(User $user): int { return UserBusiness::where('user_id', $user->id)->update([ 'email' => null, 'first_name' => self::ANONYMIZED_FIRSTNAME, 'last_name' => self::ANONYMIZED_LASTNAME, 'user_birthday' => null, 'user_phone' => null, ]); } /** * Kunden-Datensätze anonymisieren. Datensätze mit Bestellungen behalten * Name und Rechnungsadresse (Aufbewahrungspflicht Buchungsbeleg), * verlieren aber alle Kontaktdaten. Datensätze ohne Bestellung werden * vollständig anonymisiert. * * @param Collection $rows */ protected function anonymizeShoppingUserRows($rows): int { $count = 0; foreach ($rows as $row) { $hasOrders = $row->shopping_orders()->withTrashed()->count() > 0; if ($hasOrders) { $this->anonymizeShoppingUserContactData($row); } else { $this->anonymizeShoppingUserComplete($row); } $count++; } return $count; } protected function anonymizeShoppingUserContactData(ShoppingUser $row): void { $row->stripe_customer_id = null; $row->billing_phone = null; $row->billing_email = 'geloescht_'.$row->id.'@dsgvo.invalid'; $row->shipping_phone = null; $row->shipping_email = null; $row->remarks = ''; $row->notice = []; $row->subscribed = false; $row->faker_mail = true; $row->gdpr_deleted_at ??= now(); $row->save(); } protected function anonymizeShoppingUserComplete(ShoppingUser $row): void { $row->billing_salutation = null; $row->billing_company = null; $row->billing_firstname = self::ANONYMIZED_FIRSTNAME; $row->billing_lastname = self::ANONYMIZED_LASTNAME; $row->billing_address = null; $row->billing_address_2 = null; $row->billing_zipcode = null; $row->billing_city = null; $row->shipping_salutation = null; $row->shipping_company = null; $row->shipping_firstname = null; $row->shipping_lastname = null; $row->shipping_address = null; $row->shipping_address_2 = null; $row->shipping_zipcode = null; $row->shipping_city = null; $row->shipping_postnumber = null; $this->anonymizeShoppingUserContactData($row); } /** * Alle Stripe-Customer-IDs des Betroffenen einsammeln (Berater, Kunden- * Datensätze und deren Abos) — Phase SE1.6. * * @param Collection|\Illuminate\Database\Eloquent\Collection $shoppingUsers * @return array */ protected function collectStripeCustomerIds(?User $user, $shoppingUsers): array { $ids = []; if ($user && ! empty($user->stripe_customer_id)) { $ids[] = $user->stripe_customer_id; } foreach ($shoppingUsers as $row) { if (! empty($row->stripe_customer_id)) { $ids[] = $row->stripe_customer_id; } } $aboQuery = UserAbo::withTrashed()->whereNotNull('stripe_customer_id'); $aboQuery->where(function ($query) use ($user, $shoppingUsers) { $query->whereIn('shopping_user_id', collect($shoppingUsers)->pluck('id')->all()); if ($user) { $query->orWhere('user_id', $user->id); } }); foreach ($aboQuery->pluck('stripe_customer_id') as $customerId) { $ids[] = $customerId; } return array_values(array_unique(array_filter($ids))); } /** * Stripe-Customer-Objekte löschen (Art. 17 DSGVO). Die zugehörigen Charges * bleiben bei Stripe bestehen (Aufbewahrungspflicht). Fehler werden * protokolliert, brechen die Löschung im System aber nicht ab — der * Datenbestand ist zu diesem Zeitpunkt bereits anonymisiert. * * @param array $customerIds * @return int Anzahl der bei Stripe tatsächlich gelöschten Customer */ protected function deleteStripeCustomers(array $customerIds): int { if ($customerIds === []) { return 0; } $service = app(StripeCustomerService::class); $deleted = 0; foreach ($customerIds as $customerId) { if ($service->deleteCustomer($customerId)) { $deleted++; } } return $deleted; } /** * Nachweis der Löschung (Art. 5 Abs. 2 DSGVO) ohne personenbezogene Daten. * * @param array $summary */ protected function logDeletion(string $action, int $modelId, string $modelClass, ?int $executedByUserId, ?string $originalEmail, array $summary): void { SysLog::action($action, 'gdpr', 4) ->setUserId($executedByUserId) ->setModel($modelId, $modelClass) ->setMessage('DSGVO-Löschung ausgeführt. Umfang: '.json_encode($summary)) ->save(); Log::channel('cleanup')->info($action.' id:'.$modelId.' email-hash:'.hash('sha256', (string) $originalEmail).' summary:'.json_encode($summary)); } }