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>
This commit is contained in:
parent
30fb031d12
commit
68d230d4e5
31 changed files with 3612 additions and 65 deletions
|
|
@ -9,6 +9,7 @@ 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;
|
||||
|
|
@ -27,6 +28,8 @@ use Illuminate\Support\Facades\Storage;
|
|||
* 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
|
||||
{
|
||||
|
|
@ -54,6 +57,13 @@ class GdprDeletionService
|
|||
|
||||
$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];
|
||||
|
||||
|
|
@ -85,6 +95,8 @@ class GdprDeletionService
|
|||
return $summary;
|
||||
});
|
||||
|
||||
$summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds);
|
||||
|
||||
$this->logDeletion('gdpr-delete-user', $user->id, User::class, $executedByUserId, $originalEmail, $summary);
|
||||
|
||||
return $summary;
|
||||
|
|
@ -114,6 +126,8 @@ class GdprDeletionService
|
|||
|
||||
$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;
|
||||
|
|
@ -125,6 +139,8 @@ class GdprDeletionService
|
|||
return $summary;
|
||||
});
|
||||
|
||||
$summary['stripe_customers_deleted'] = $this->deleteStripeCustomers($stripeCustomerIds);
|
||||
|
||||
$this->logDeletion('gdpr-delete-customer', $shoppingUser->id, ShoppingUser::class, $executedByUserId, $originalEmail, $summary);
|
||||
|
||||
return $summary;
|
||||
|
|
@ -146,6 +162,7 @@ class GdprDeletionService
|
|||
|
||||
protected function anonymizeUserRow(User $user): void
|
||||
{
|
||||
$user->stripe_customer_id = null;
|
||||
$user->notes = null;
|
||||
$user->settings = null;
|
||||
$user->payment_methods = null;
|
||||
|
|
@ -306,6 +323,7 @@ class GdprDeletionService
|
|||
|
||||
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;
|
||||
|
|
@ -339,6 +357,69 @@ class GdprDeletionService
|
|||
$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.
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue