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>
674 lines
26 KiB
PHP
674 lines
26 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Models\UserAccount;
|
|
use App\Models\UserBusiness;
|
|
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;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* DSGVO-Löschung (Art. 17 DSGVO) für Berater (User) und Endkunden (ShoppingUser).
|
|
*
|
|
* Personenbezogene Daten werden unwiderruflich anonymisiert. Daten, die unter
|
|
* gesetzliche Aufbewahrungspflichten fallen (Rechnungen, Gutschriften,
|
|
* DATEV-Exporte, Provisionsabrechnungen, Bestellungen mit Beleg), bleiben
|
|
* gemäß Art. 17 Abs. 3 lit. b DSGVO erhalten:
|
|
* - Rechnungs-/Gutschrift-PDFs und deren DB-Zeilen werden nicht verändert.
|
|
* - Bei Kunden-Datensätzen (shopping_users) mit Bestellungen bleiben Name und
|
|
* Rechnungsadresse erhalten (Buchungsbeleg), Kontaktdaten werden entfernt.
|
|
* - Provisions-Snapshots (user_businesses) behalten die Zahlen, die
|
|
* denormalisierten Personendaten werden anonymisiert.
|
|
* - Stripe-Customer-Objekte werden gelöscht (Phase SE1.6, dev/2026-08-26-stripe/);
|
|
* die Charges bleiben bei Stripe erhalten — sie sind Buchungsbelege.
|
|
*
|
|
* Endkunden werden pro Bestellung neu angelegt (mehrere shopping_users-Zeilen).
|
|
* Alle Kunden-Operationen arbeiten deshalb auf der Gruppe aller Einträge mit
|
|
* derselben Kundennummer ODER derselben E-Mail-Adresse (dev/_tickets/2026-09-08.md, #2):
|
|
* - deleteShoppingUser(): DSGVO-Anonymisierung (Pflicht, sobald Bestellungen vorliegen)
|
|
* - purgeShoppingUser(): Komplett-Löschung, nur solange kein Eintrag eine Bestellung hat
|
|
*/
|
|
class GdprDeletionService
|
|
{
|
|
public const ANONYMIZED_FIRSTNAME = 'Gelöscht';
|
|
|
|
public const ANONYMIZED_LASTNAME = 'DSGVO';
|
|
|
|
/**
|
|
* Berater (User) DSGVO-konform löschen.
|
|
*
|
|
* @return array<string, int|string> 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<string, int|string>
|
|
*
|
|
* @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<string, int|string>
|
|
*
|
|
* @throws GdprDeletionException wenn der Datensatz zu einem Berater gehört, Bestellungen oder aktive Abos bestehen
|
|
*/
|
|
public function purgeShoppingUser(ShoppingUser $shoppingUser, ?int $executedByUserId = null): array
|
|
{
|
|
$analysis = $this->analyzeShoppingUser($shoppingUser);
|
|
|
|
if ($analysis['is_member']) {
|
|
throw new GdprDeletionException(__('msg.gdpr_error_customer_is_member'));
|
|
}
|
|
if ($analysis['orders_count'] > 0) {
|
|
throw new GdprDeletionException(__('msg.gdpr_error_customer_has_orders'));
|
|
}
|
|
if ($analysis['active_abos'] > 0) {
|
|
throw new GdprDeletionException(__('msg.gdpr_error_active_abo'));
|
|
}
|
|
|
|
$rows = $analysis['rows'];
|
|
$originalEmail = $shoppingUser->billing_email;
|
|
$stripeCustomerIds = $this->collectStripeCustomerIds(null, $rows);
|
|
|
|
$summary = DB::transaction(function () use ($shoppingUser, $rows, $originalEmail) {
|
|
$summary = ['shopping_user_id' => $shoppingUser->id];
|
|
$summary['abos_anonymized'] = 0;
|
|
$summary['shopping_users_purged'] = 0;
|
|
foreach ($rows as $row) {
|
|
$summary['abos_anonymized'] += $this->anonymizeUserAbos('shopping_user_id', $row->id);
|
|
$this->anonymizeShoppingUserComplete($row);
|
|
$row->delete();
|
|
$summary['shopping_users_purged']++;
|
|
}
|
|
$summary['portal_customers_deleted'] = $this->deletePortalCustomers($rows, $originalEmail);
|
|
|
|
return $summary;
|
|
});
|
|
|
|
$summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds);
|
|
|
|
$this->logDeletion('gdpr-purge-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary);
|
|
|
|
return $summary;
|
|
}
|
|
|
|
/**
|
|
* Analyse für das Lösch-Modal: Wie oft ist der Kunde angelegt, welche Einträge
|
|
* haben Bestellungen, und welche Löschart ist möglich?
|
|
*
|
|
* @return array{
|
|
* rows: \Illuminate\Database\Eloquent\Collection<int, ShoppingUser>,
|
|
* email: string|null,
|
|
* is_member: bool,
|
|
* orders_count: int,
|
|
* orders_by_row: array<int, int>,
|
|
* active_abos: int,
|
|
* can_purge: bool,
|
|
* 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<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
|
|
* derselben E-Mail-Adresse (Kunden werden pro Bestellung neu angelegt und
|
|
* bekommen nicht immer dieselbe Kundennummer).
|
|
*
|
|
* @return \Illuminate\Database\Eloquent\Collection<int, ShoppingUser>
|
|
*/
|
|
public function getCustomerRows(ShoppingUser $shoppingUser)
|
|
{
|
|
$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<int, ShoppingUser>|\Illuminate\Database\Eloquent\Collection<int, ShoppingUser> $rows
|
|
*/
|
|
protected function deletePortalCustomers($rows, ?string $email): int
|
|
{
|
|
$email = mb_strtolower(trim((string) $email));
|
|
|
|
return Customer::query()
|
|
->where(function ($query) use ($rows, $email) {
|
|
$query->whereIn('shopping_user_id', collect($rows)->pluck('id')->all());
|
|
if ($email !== '') {
|
|
$query->orWhereRaw('LOWER(email) = ?', [$email]);
|
|
}
|
|
})
|
|
->delete();
|
|
}
|
|
|
|
protected function anonymizeUserRow(User $user): void
|
|
{
|
|
$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<int, ShoppingUser> $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<int, ShoppingUser>|\Illuminate\Database\Eloquent\Collection<int, ShoppingUser> $shoppingUsers
|
|
* @return array<int, string>
|
|
*/
|
|
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<int, string> $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<string, int|string> $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));
|
|
}
|
|
}
|