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:
Kevin 2026-08-28 09:04:28 +00:00
parent 30fb031d12
commit 68d230d4e5
31 changed files with 3612 additions and 65 deletions

View file

@ -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.
*