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

@ -3,12 +3,19 @@
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). Idempotent:
* eine vorhandene ID wird nie überschrieben.
* 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
{
@ -33,4 +40,139 @@ class StripeCustomerService
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,
];
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Jobs\SyncStripeInvoiceReference;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\UserInvoice;
use App\Services\Invoice;
use Illuminate\Support\Facades\Log;
/**
* Trägt die MIVITA-Rechnungsnummer nachträglich an der Stripe-Zahlung ein
* (Plan dev/2026-08-26-stripe/, Phase SE3).
*
* Warum nachträglich: Die Rechnungsnummer kommt aus einem fortlaufenden
* Nummernkreis ({@see Invoice::makeNextInvoiceNumber}) und wird
* erst NACH erfolgreicher Zahlung vergeben. Eine Vorab-Reservierung würde bei
* jedem abgebrochenen Checkout eine Lücke im Rechnungsnummernkreis erzeugen —
* das ist buchhalterisch (GoBD) nicht zulässig.
*
* Stripe erlaubt es, Metadaten und `description` eines PaymentIntents
* jederzeit zu ändern. Genau das passiert hier — damit die Buchhaltung die
* Zahlung im Dashboard über die Rechnungsnummer findet und die Nummer als
* Spalte in den Bilanz-/Auszahlungsberichten exportieren kann.
*/
class StripeInvoiceReferenceService
{
public function __construct(private readonly StripeClientWrapper $stripe) {}
/**
* Stößt die Übertragung für eine Bestellung an — ohne den Aufrufer zu
* gefährden. Wird aus dem Paid-Pfad und aus der manuellen
* Rechnungserstellung im Admin aufgerufen.
*/
public static function dispatchForOrder(ShoppingOrder $shoppingOrder, ?ShoppingPayment $shoppingPayment = null): void
{
try {
$userInvoice = $shoppingOrder->user_invoice;
if (! $userInvoice) {
return;
}
$payment = $shoppingPayment ?: $shoppingOrder->shopping_payments()
->where('payment_provider', 'stripe')
->latest('id')
->first();
if (! self::isSyncable($payment)) {
return;
}
SyncStripeInvoiceReference::dispatch($payment, $userInvoice);
} catch (\Throwable $e) {
// Der Paid-Pfad darf hieran niemals scheitern.
Log::channel('stripe')->warning('Rechnungsnummer-Sync konnte nicht eingeplant werden: '.$e->getMessage(), [
'shopping_order_id' => $shoppingOrder->id,
]);
}
}
/**
* Nur Stripe-Zahlungen mit bekanntem PaymentIntent lassen sich ergänzen.
* Solange nur die Session-ID (`cs_…`) bekannt ist, gibt es kein Objekt,
* an dem die Nummer dauerhaft sichtbar wäre.
*/
public static function isSyncable(?ShoppingPayment $payment): bool
{
return $payment !== null
&& $payment->payment_provider === 'stripe'
&& str_starts_with((string) $payment->external_reference, 'pi_');
}
/**
* Schreibt Rechnungsnummer und -datum in die Metadaten des PaymentIntents
* und ergänzt die `description` um die Rechnungsnummer.
*
* Idempotent: steht die Nummer schon dort, passiert nichts.
*/
public function sync(ShoppingPayment $shoppingPayment, UserInvoice $userInvoice): bool
{
if (! self::isSyncable($shoppingPayment)) {
return false;
}
$client = $this->stripe->client();
$paymentIntent = $client->paymentIntents->retrieve($shoppingPayment->external_reference);
$existing = $paymentIntent->metadata?->toArray() ?? [];
if (($existing['invoice_number'] ?? null) === $userInvoice->full_number) {
return false;
}
$order = $shoppingPayment->shopping_order;
$client->paymentIntents->update($shoppingPayment->external_reference, [
'metadata' => StripeMetadataBuilder::withInvoice($existing, $userInvoice),
'description' => StripeMetadataBuilder::description(
$order,
(string) $shoppingPayment->reference,
$order?->shopping_user,
),
]);
Log::channel('stripe')->info('Rechnungsnummer an Stripe übertragen.', [
'shopping_payment_id' => $shoppingPayment->id,
'payment_intent' => $shoppingPayment->external_reference,
'invoice_number' => $userInvoice->full_number,
]);
return true;
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserInvoice;
/**
* Einzige Quelle für die Metadaten und die `description`, die MIVITA an
* Stripe-Objekte hängt (Phase SE2, dev/2026-08-26-stripe/).
*
* Zweck (Mail Buchhaltung + Stripe 2026-08-26): Jede Zahlung muss im
* Stripe-Dashboard über Bestellnummer, Referenz oder Rechnungsnummer
* auffindbar und als CSV-Spalte exportierbar sein.
*
* - **Metadaten** sind über die Dashboard-Suche findbar (`metadata:<wert>`)
* und lassen sich als Spalten in Bilanz-/Auszahlungsberichte aufnehmen.
* - **`description`** ist das Feld, das die Zahlungsliste anzeigt und das die
* Standardsuche ohne Filter durchsucht.
*
* Stripe-Limits: max. 50 Schlüssel, Wert max. 500 Zeichen. Leere Werte werden
* entfernt, damit keine "null"-Strings in den Berichten landen.
*/
class StripeMetadataBuilder
{
public const MAX_VALUE_LENGTH = 500;
public const MAX_DESCRIPTION_LENGTH = 350;
public const TYPE_SHOP = 'shop';
public const TYPE_ABO = 'abo';
public const TYPE_ABO_RECOVERY = 'abo_recovery';
public const TYPE_ABO_CHARGE = 'abo_charge';
public const TYPE_PAYMENT_METHOD_CHANGE = 'payment_method_change';
/**
* Einmalzahlung im Checkout (Session + PaymentIntent).
*
* @return array<string, string>
*/
public static function forCheckout(
ShoppingOrder $shoppingOrder,
ShoppingPayment $shoppingPayment,
ShoppingUser $shoppingUser,
string $reference,
): array {
return self::sanitize([
'shopping_order_id' => $shoppingOrder->id,
'shopping_payment_id' => $shoppingPayment->id,
'reference' => $reference,
'mode' => $shoppingOrder->mode,
'order_type' => self::orderType($shoppingOrder),
'user_abo_id' => $shoppingOrder->recovery_user_abo_id,
'customer_name' => StripeCustomerService::customerNameFor($shoppingUser),
'customer_number' => $shoppingUser->number,
'member_id' => $shoppingUser->member_id,
'invoice_number' => self::invoiceNumber($shoppingOrder),
]);
}
/**
* Wiederkehrende Abo-Abbuchung (off-session PaymentIntent).
*
* @return array<string, string>
*/
public static function forAboCharge(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingPayment $shoppingPayment,
?ShoppingUser $shoppingUser,
): array {
return self::sanitize([
'shopping_order_id' => $shoppingOrder->id,
'shopping_payment_id' => $shoppingPayment->id,
'reference' => $shoppingPayment->reference,
'mode' => $shoppingOrder->mode,
'order_type' => self::TYPE_ABO_CHARGE,
'user_abo_id' => $userAbo->id,
'customer_name' => $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null,
'customer_number' => $shoppingUser?->number,
'member_id' => $shoppingUser?->member_id,
]);
}
/**
* Setup-Session "Zahlungsart ändern". Die Schlüssel `user_abo_id` und
* `source` wertet der Webhook aus (StripeWebhookHandler) — sie dürfen
* nicht umbenannt werden.
*
* @return array<string, string>
*/
public static function forSetupSession(UserAbo $userAbo, ?string $source): array
{
$shoppingUser = $userAbo->shopping_user;
return self::sanitize([
'user_abo_id' => $userAbo->id,
'source' => $source,
'order_type' => self::TYPE_PAYMENT_METHOD_CHANGE,
'customer_name' => $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null,
]);
}
/**
* Lesbarer Text für die Stripe-Zahlungsliste (und die Standardsuche).
*/
public static function description(ShoppingOrder $shoppingOrder, string $reference, ?ShoppingUser $shoppingUser): string
{
$parts = ['Bestellung #'.$shoppingOrder->id, $reference];
$name = $shoppingUser ? StripeCustomerService::customerNameFor($shoppingUser) : null;
if ($name) {
$parts[] = $name;
}
$invoiceNumber = self::invoiceNumber($shoppingOrder);
if ($invoiceNumber) {
$parts[] = 'Rechnung '.$invoiceNumber;
}
return mb_substr(implode(' · ', $parts), 0, self::MAX_DESCRIPTION_LENGTH);
}
/**
* Ergänzt ein bestehendes Metadaten-Set um die Rechnungsdaten
* (Phase SE3: die Rechnungsnummer entsteht erst nach der Zahlung).
*
* @param array<string, mixed> $metadata
* @return array<string, string>
*/
public static function withInvoice(array $metadata, UserInvoice $userInvoice): array
{
return self::sanitize($metadata + [
'invoice_number' => $userInvoice->full_number,
'invoice_date' => $userInvoice->getDateRaw(),
]);
}
/**
* Wirft leere Werte weg, castet auf String und kappt auf das Stripe-Limit.
*
* @param array<string, mixed> $metadata
* @return array<string, string>
*/
public static function sanitize(array $metadata): array
{
$result = [];
foreach ($metadata as $key => $value) {
if ($value === null || $value === '' || $value === 0) {
continue;
}
$result[$key] = mb_substr((string) $value, 0, self::MAX_VALUE_LENGTH);
}
return $result;
}
private static function orderType(ShoppingOrder $shoppingOrder): string
{
if (! empty($shoppingOrder->recovery_user_abo_id)) {
return self::TYPE_ABO_RECOVERY;
}
return $shoppingOrder->is_abo ? self::TYPE_ABO : self::TYPE_SHOP;
}
private static function invoiceNumber(ShoppingOrder $shoppingOrder): ?string
{
return $shoppingOrder->user_invoice?->full_number;
}
}

View file

@ -11,6 +11,7 @@ use App\Services\CheckoutFunnelTracker;
use App\Services\Payment\DTOs\AboChargeResult;
use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult;
use App\Services\Util;
use Illuminate\Support\Facades\Log;
use Stripe\Checkout\Session;
use Stripe\Exception\ApiErrorException;
@ -27,7 +28,10 @@ use Stripe\Exception\ApiErrorException;
*/
class StripePaymentService
{
public function __construct(private readonly StripeClientWrapper $stripe) {}
public function __construct(
private readonly StripeClientWrapper $stripe,
private readonly StripeCustomerService $customerService,
) {}
/**
* Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung.
@ -70,17 +74,12 @@ class StripePaymentService
amountCents: $amountCents,
);
$metadata = [
'shopping_order_id' => (string) $shoppingOrder->id,
'shopping_payment_id' => (string) $shoppingPayment->id,
'reference' => $reference,
'mode' => (string) $shoppingOrder->mode,
];
$metadata = StripeMetadataBuilder::forCheckout($shoppingOrder, $shoppingPayment, $shoppingUser, $reference);
$description = StripeMetadataBuilder::description($shoppingOrder, $reference, $shoppingUser);
$sessionParams = [
'mode' => 'payment',
'client_reference_id' => $reference,
'customer_email' => $shoppingUser->billing_email ?: null,
'line_items' => [[
'quantity' => 1,
'price_data' => [
@ -92,13 +91,28 @@ class StripePaymentService
],
]],
'metadata' => $metadata,
'payment_intent_data' => ['metadata' => $metadata],
'payment_intent_data' => [
'metadata' => $metadata,
'description' => $description,
],
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
];
$customerId = $this->resolveCheckoutCustomer($shoppingUser);
if ($customerId !== null) {
$sessionParams['customer'] = $customerId;
} else {
// Fallback wie vor SE1: ohne Customer-Objekt bleibt wenigstens die
// E-Mail erhalten, und Abos bekommen ihren Customer von Stripe.
$sessionParams['customer_email'] = $shoppingUser->billing_email ?: null;
if ($this->requiresReusablePaymentMethod($shoppingOrder)) {
$sessionParams['customer_creation'] = 'always';
}
}
if ($this->requiresReusablePaymentMethod($shoppingOrder)) {
$sessionParams['customer_creation'] = 'always';
$sessionParams['payment_intent_data']['setup_future_usage'] = 'off_session';
}
@ -121,6 +135,28 @@ class StripePaymentService
);
}
/**
* Stripe-Customer für den Checkout (Phase SE1): trägt Rechnungsname und
* -anschrift, damit die Buchhaltung den Kunden in Zahlungsliste und
* CSV-Berichten sieht.
*
* Bewusst fehlertolerant: Ist Stripe beim Anlegen des Customers gerade
* nicht erreichbar, darf der Checkout NICHT scheitern — der Aufrufer
* fällt dann auf den bisherigen Weg (customer_email) zurück.
*/
private function resolveCheckoutCustomer(ShoppingUser $shoppingUser): ?string
{
try {
return $this->customerService->customerIdForShoppingUser($shoppingUser);
} catch (\Throwable $e) {
Log::channel('stripe')->warning('Stripe-Customer für Checkout nicht verfügbar — Fallback auf customer_email: '.$e->getMessage(), [
'shopping_user_id' => $shoppingUser->id,
]);
return null;
}
}
/**
* Abo-/Recovery-Bestellungen brauchen ein wiederverwendbares Zahlungsmittel
* (Erstzahlung eines neuen Abos bzw. Nachholzahlung, die das Abo auf
@ -170,13 +206,7 @@ class StripePaymentService
'stripe_payment_method_id' => $userAbo->stripe_payment_method_id,
]);
$metadata = [
'shopping_order_id' => (string) $shoppingOrder->id,
'shopping_payment_id' => (string) $shoppingPayment->id,
'user_abo_id' => (string) $userAbo->id,
'reference' => (string) $shoppingPayment->reference,
'mode' => (string) $shoppingOrder->mode,
];
$metadata = StripeMetadataBuilder::forAboCharge($userAbo, $shoppingOrder, $shoppingPayment, $shoppingUser);
try {
$paymentIntent = $this->stripe->client()->paymentIntents->create([
@ -187,6 +217,7 @@ class StripePaymentService
'off_session' => true,
'confirm' => true,
'metadata' => $metadata,
'description' => StripeMetadataBuilder::description($shoppingOrder, (string) $shoppingPayment->reference, $shoppingUser),
], [
'idempotency_key' => 'abo-'.$userAbo->id.'-order-'.$shoppingOrder->id,
]);
@ -243,10 +274,7 @@ class StripePaymentService
): Session {
$customerId = $this->ensureAboStripeCustomer($userAbo);
$metadata = [
'user_abo_id' => (string) $userAbo->id,
'source' => (string) ($source ?? ''),
];
$metadata = StripeMetadataBuilder::forSetupSession($userAbo, $source);
return $this->stripe->client()->checkout->sessions->create([
'mode' => 'setup',
@ -274,13 +302,15 @@ class StripePaymentService
}
$shoppingUser = $userAbo->shopping_user;
$customer = $this->stripe->client()->customers->create([
'email' => $userAbo->email ?: null,
'name' => $shoppingUser
? (trim(($shoppingUser->billing_firstname ?? '').' '.($shoppingUser->billing_lastname ?? '')) ?: null)
: null,
'metadata' => ['mivita_type' => 'user_abo', 'mivita_id' => (string) $userAbo->id],
]);
$payload = $shoppingUser
? $this->customerService->customerPayloadFor($shoppingUser)
: ['name' => null, 'metadata' => []];
$payload['email'] = $userAbo->email ?: ($payload['email'] ?? null);
$payload['metadata'] = ['mivita_type' => 'user_abo', 'mivita_id' => (string) $userAbo->id]
+ ($payload['metadata'] ?? []);
$customer = $this->stripe->client()->customers->create($payload);
$userAbo->forceFill(['stripe_customer_id' => $customer->id])->save();

View file

@ -39,6 +39,17 @@ class StripeWebhookHandler
'setup_intent.succeeded',
];
/**
* Eventtypen, zu denen es regulaer keine Zahlung bei uns geben muss.
* Ein abgebrochener Checkout laeuft bei Stripe ab, ohne dass jemals eine
* Zahlung entstanden ist - das ist kein Fehlerfall.
*
* @var string[]
*/
private const EVENT_TYPES_WITHOUT_PAYMENT = [
'checkout.session.expired',
];
/**
* Gleiche txaction-Taxonomie wie PAYONE, damit alle nachgelagerten
* Ansichten/Services (Order-Status, Abo-Logik) unverändert funktionieren.
@ -137,6 +148,27 @@ class StripeWebhookHandler
return true;
}
/**
* Verarbeitet ein aktiv von der Stripe-API geholtes Objekt so, als wäre es
* per Webhook gekommen (Plan S6.4, `stripe:sync-payment-status`).
*
* Nötig für Events, die NIE zugestellt wurden — dafür greift weder der
* Stripe-Retry noch `stripe:process-pending-events`, das nur auf
* gespeicherten Events arbeitet. Die Seiteneffekte sind dieselben und
* bleiben idempotent: der Paid-Pfad prüft `paid` unter `lockForUpdate()`.
*
* @param array<string, mixed> $object Stripe-Objekt als Array
* @return bool false = keiner Zahlung zuordenbar
*/
public function syncFromApiObject(string $eventType, array $object, string $source = 'api-sync'): bool
{
if (! in_array($eventType, self::HANDLED_TYPES, true)) {
return false;
}
return $this->dispatchEvent($source, $eventType, $object);
}
/**
* @param array<string, mixed> $object
* @return bool false = Zahlung mit unserer Metadata (noch) nicht sichtbar → Stripe-Retry anfordern
@ -178,7 +210,14 @@ class StripeWebhookHandler
return false;
}
Log::channel('stripe')->warning(sprintf(
/*
* Abgebrochene Checkouts laufen bei Stripe irgendwann ab. Zu so einer
* Session hat es nie eine Zahlung gegeben — das ist der Normalfall und
* keine Warnung wert. Alle anderen Eventtypen setzen eine Zahlung voraus.
*/
$isExpectedWithoutPayment = in_array($eventType, self::EVENT_TYPES_WITHOUT_PAYMENT, true);
Log::channel('stripe')->{$isExpectedWithoutPayment ? 'info' : 'warning'}(sprintf(
'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)',
$eventId,
$eventType,