mivita/app/Services/GdprDeletionService.php
Kevin 2e71173c66 Stripe: Kundendaten, Rechnungsnummer, manueller Zahlungseingang
Rueckmeldung der Buchhaltung und des Stripe-Calls (dev/2026-08-26-stripe/):
Stripe kannte bisher nur die E-Mail des Kunden, Zahlungen waren im Dashboard
weder ueber die Bestell- noch ueber die Rechnungsnummer auffindbar, und ein
Zahlungseingang mit falschem Verwendungszweck zwang die Buchhaltung dazu, die
Rechnung von Hand zu schreiben.

- SE1: Der Checkout haengt einen Stripe-Customer mit Name, E-Mail und
  Rechnungsanschrift an die Session (customer statt customer_email); die ID
  liegt an shopping_users und wird wiederverwendet. Namensformat "Vorname
  Nachname (Firma)", reine Firmenkunden unter der Firma. Faellt die
  Customer-Anlage aus, laeuft der Checkout auf dem bisherigen Weg weiter.
  DSGVO-Loeschungen entfernen jetzt auch das Customer-Objekt; die Charges
  bleiben als Buchungsbeleg bestehen.
- SE2: StripeMetadataBuilder als einzige Quelle fuer Metadaten und die
  description (Bestellnummer, Referenz, Kundenname) - damit greift die
  Dashboard-Suche, die bisher nichts Lesbares zu fassen bekam.
- SE3: Die Rechnungsnummer entsteht erst nach der Zahlung und wird daher per
  Job am PaymentIntent nachgetragen (Vorab-Reservierung wuerde Luecken im
  Nummernkreis erzeugen). stripe:backfill-invoice-metadata traegt sie
  rueckwirkend ab dem Go-Live 2026-07-10 nach.
- SE4: Neuer Admin-Button "Zahlungseingang erfassen" an jeder unbezahlten
  Bestellung - bisher gab es ihn nur bei Rechnungskauf, und er setzte nur
  paid, ohne Rechnung, Punkte, Abo-Aktivierung oder Mail. Der neue Weg laeuft
  durch paymentStatusPaidAction, verlangt den Verwendungszweck als Beleg und
  laesst einen abweichenden Betrag nur mit ausdruecklicher Bestaetigung zu.
- S6.4 (aus dem Juli-Plan vorgezogen): stripe:sync-payment-status gleicht
  offene Zahlungen aktiv gegen die API ab. Aufgefallen beim Testlauf - ein nie
  zugestellter Webhook war bisher nicht reparierbar, das Geld lag bei Stripe
  waehrend die Bestellung auf offen stand. Laeuft stuendlich im Scheduler.
- 110 Tests (Pest) fuer alle Pfade, Stripe-Client durchgehend gemockt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:04:28 +00:00

438 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Exceptions\GdprDeletionException;
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\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.
*/
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) {
$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);
return $summary;
});
$summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds);
$this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary);
return $summary;
}
/**
* Alle Datensätze eines Endkunden (gleiche Kundennummer), inkl. soft-deleted.
*
* @return \Illuminate\Database\Eloquent\Collection<int, ShoppingUser>
*/
public function getCustomerRows(ShoppingUser $shoppingUser)
{
if ($shoppingUser->number > 0) {
return ShoppingUser::withTrashed()->where('number', $shoppingUser->number)->get();
}
return ShoppingUser::withTrashed()->where('id', $shoppingUser->id)->get();
}
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->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));
}
}