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>
178 lines
6.1 KiB
PHP
178 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payment\Stripe;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\ShoppingUser;
|
|
use App\User;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Legt pro User/Kunde genau einen Stripe-Customer an und persistiert die
|
|
* ID (users.stripe_customer_id / customers.stripe_customer_id /
|
|
* shopping_users.stripe_customer_id). Idempotent: eine vorhandene ID wird
|
|
* nie überschrieben.
|
|
*
|
|
* Phase SE1 (dev/2026-08-26-stripe/): Der Checkout hängt den Customer an die
|
|
* Session, damit Name und Anschrift in Stripe-Zahlungsliste und CSV-Berichten
|
|
* stehen (Stripe-Empfehlung "Option A", Mail Mehmet Ergün 2026-08-26).
|
|
*/
|
|
class StripeCustomerService
|
|
{
|
|
public function __construct(private readonly StripeClientWrapper $stripe) {}
|
|
|
|
public function customerIdFor(User|Customer $owner): string
|
|
{
|
|
if (! empty($owner->stripe_customer_id)) {
|
|
return $owner->stripe_customer_id;
|
|
}
|
|
|
|
$customer = $this->stripe->client()->customers->create([
|
|
'email' => $owner->email,
|
|
'name' => trim(($owner->firstname ?? '').' '.($owner->lastname ?? '')) ?: null,
|
|
'metadata' => [
|
|
'mivita_type' => $owner instanceof Customer ? 'customer' : 'user',
|
|
'mivita_id' => (string) $owner->getKey(),
|
|
],
|
|
]);
|
|
|
|
$owner->forceFill(['stripe_customer_id' => $customer->id])->save();
|
|
|
|
return $customer->id;
|
|
}
|
|
|
|
/**
|
|
* Stripe-Customer des Endkunden aus dem Checkout — mit Rechnungsname und
|
|
* -anschrift. Ein vorhandener Customer wird wiederverwendet und dabei mit
|
|
* den aktuellen Rechnungsdaten aktualisiert (der Kunde kann seine Anschrift
|
|
* zwischen zwei Bestellungen geändert haben).
|
|
*/
|
|
public function customerIdForShoppingUser(ShoppingUser $shoppingUser): string
|
|
{
|
|
$payload = $this->customerPayloadFor($shoppingUser);
|
|
|
|
if (! empty($shoppingUser->stripe_customer_id)) {
|
|
$this->updateCustomer($shoppingUser->stripe_customer_id, $payload);
|
|
|
|
return $shoppingUser->stripe_customer_id;
|
|
}
|
|
|
|
$customer = $this->stripe->client()->customers->create($payload);
|
|
|
|
$shoppingUser->forceFill(['stripe_customer_id' => $customer->id])->save();
|
|
|
|
return $customer->id;
|
|
}
|
|
|
|
/**
|
|
* Aktualisiert Name/Anschrift eines bestehenden Customers — best effort.
|
|
* Ein Fehler hier darf einen laufenden Checkout niemals kippen; die
|
|
* Zahlung funktioniert auch mit veralteten Stammdaten am Customer.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
public function updateCustomer(string $customerId, array $payload): void
|
|
{
|
|
try {
|
|
$this->stripe->client()->customers->update($customerId, $payload);
|
|
} catch (\Throwable $e) {
|
|
Log::channel('stripe')->warning('Stripe-Customer konnte nicht aktualisiert werden: '.$e->getMessage(), [
|
|
'customer' => $customerId,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Löscht einen Stripe-Customer (DSGVO Art. 17, Phase SE1.6). Die Charges
|
|
* bleiben bei Stripe erhalten — sie unterliegen der Aufbewahrungspflicht.
|
|
* Best effort: ein Fehler darf die Löschung im System nicht blockieren.
|
|
*/
|
|
public function deleteCustomer(string $customerId): bool
|
|
{
|
|
try {
|
|
$this->stripe->client()->customers->delete($customerId);
|
|
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
Log::channel('stripe')->warning('Stripe-Customer konnte nicht gelöscht werden: '.$e->getMessage(), [
|
|
'customer' => $customerId,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stripe-Customer-Payload aus den Rechnungsdaten eines Endkunden.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function customerPayloadFor(ShoppingUser $shoppingUser): array
|
|
{
|
|
$payload = [
|
|
'name' => self::customerNameFor($shoppingUser),
|
|
'email' => $shoppingUser->billing_email ?: null,
|
|
'metadata' => [
|
|
'mivita_type' => 'shopping_user',
|
|
'mivita_id' => (string) $shoppingUser->id,
|
|
],
|
|
];
|
|
|
|
if ($shoppingUser->member_id) {
|
|
$payload['metadata']['mivita_member_id'] = (string) $shoppingUser->member_id;
|
|
}
|
|
if ($shoppingUser->number) {
|
|
$payload['metadata']['mivita_customer_number'] = (string) $shoppingUser->number;
|
|
}
|
|
if ($shoppingUser->billing_phone) {
|
|
$payload['phone'] = $shoppingUser->billing_phone;
|
|
}
|
|
|
|
$address = self::billingAddressFor($shoppingUser);
|
|
if ($address !== null) {
|
|
$payload['address'] = $address;
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* Anzeigename für Stripe: "Vorname Nachname (Firma)".
|
|
*
|
|
* Der Personenname steht vorn, weil im System und in der Kommunikation
|
|
* über den Namen gearbeitet wird — Firmennamen kennt kaum jemand
|
|
* (Stakeholder-Entscheid 2026-08-26). Reine Firmenkunden ohne
|
|
* Personennamen erscheinen unter der Firma.
|
|
*/
|
|
public static function customerNameFor(ShoppingUser $shoppingUser): ?string
|
|
{
|
|
$company = trim((string) $shoppingUser->billing_company);
|
|
$person = trim(($shoppingUser->billing_firstname ?? '').' '.($shoppingUser->billing_lastname ?? ''));
|
|
|
|
if ($person === '') {
|
|
return $company !== '' ? $company : null;
|
|
}
|
|
|
|
return $company !== '' ? $person.' ('.$company.')' : $person;
|
|
}
|
|
|
|
/**
|
|
* Rechnungsanschrift im Stripe-Format; null, wenn keine Straße hinterlegt ist.
|
|
*
|
|
* @return array<string, string|null>|null
|
|
*/
|
|
private static function billingAddressFor(ShoppingUser $shoppingUser): ?array
|
|
{
|
|
if (empty($shoppingUser->billing_address)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'line1' => $shoppingUser->billing_address,
|
|
'line2' => $shoppingUser->billing_address_2 ?: null,
|
|
'postal_code' => $shoppingUser->billing_zipcode ?: null,
|
|
'city' => $shoppingUser->billing_city ?: null,
|
|
'country' => $shoppingUser->billing_country->code ?? null,
|
|
];
|
|
}
|
|
}
|