mivita/app/Services/Payment/Stripe/StripeWebhookHandler.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

632 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Payment\Stripe;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\StripeWebhookEvent;
use App\Models\UserAbo;
use App\Services\AboPaymentMethodService;
use App\Services\CheckoutFunnelTracker;
use App\Services\Payment;
use App\Services\Payment\Stripe\DTOs\StripeWebhookResult;
use App\Services\ShoppingUserService;
use App\Services\Util;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Event;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
/**
* Verarbeitet Stripe-Webhooks: Signaturprüfung, Idempotenz über die
* Event-ID (stripe_webhook_events), Transaktions-Protokollierung und der
* Paid-Pfad. Muster: PayoneWebhookHandler (txaction-Priorität, DB-Lock,
* zentrale Persistenz, Betragsabgleich).
*/
class StripeWebhookHandler
{
public const HANDLED_TYPES = [
'checkout.session.completed',
'checkout.session.expired',
'payment_intent.succeeded',
'payment_intent.processing',
'payment_intent.payment_failed',
'charge.succeeded',
'charge.refunded',
'charge.dispute.created',
'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.
*
* @var array<string, int>
*/
private const TXACTION_PRIORITY = [
'appointed' => 1,
'pending' => 2,
'failed' => 3,
'paid' => 10,
];
public function handle(string $payload, ?string $signatureHeader): StripeWebhookResult
{
$secret = (string) config('stripe.webhook_secret');
if (trim($secret) === '') {
Log::channel('stripe')->error('Stripe-Webhook empfangen, aber STRIPE_WEBHOOK_SECRET ist nicht gesetzt.');
return StripeWebhookResult::notConfigured();
}
try {
$event = Webhook::constructEvent($payload, (string) $signatureHeader, $secret);
} catch (SignatureVerificationException|\UnexpectedValueException $e) {
Log::channel('stripe')->warning('Stripe-Webhook abgewiesen (Signatur/Payload): '.$e->getMessage());
return StripeWebhookResult::invalidSignature($e->getMessage());
}
/*
* Idempotenz: Ein Event gilt als Duplikat, wenn es bereits VOLLSTÄNDIG
* verarbeitet wurde (processed_at) oder sein Typ keine Verarbeitung braucht.
* Ein gespeichertes, aber unverarbeitetes Event (Crash mitten in der
* Verarbeitung → non-2xx → Stripe-Retry) wird erneut verarbeitet.
*/
$storedEvent = StripeWebhookEvent::where('event_id', $event->id)->first();
if ($storedEvent && ($storedEvent->processed_at !== null || ! in_array($event->type, self::HANDLED_TYPES, true))) {
Log::channel('stripe')->info("Stripe-Webhook-Duplikat ignoriert: {$event->id} ({$event->type})");
return StripeWebhookResult::duplicate($event->id, $event->type);
}
$object = $event->data->object->toArray();
if (! $storedEvent) {
try {
$storedEvent = StripeWebhookEvent::create([
'event_id' => $event->id,
'type' => $event->type,
'api_version' => $event->api_version,
'payload' => StripeTransactionRecorder::redactPayload($object),
]);
} catch (QueryException) {
// Unique-Verletzung: paralleler Retry hat das Event gerade angelegt.
return StripeWebhookResult::duplicate($event->id, $event->type);
}
}
if (! in_array($event->type, self::HANDLED_TYPES, true)) {
Log::channel('stripe')->info("Stripe-Webhook protokolliert, Typ nicht behandelt: {$event->id} ({$event->type})");
return StripeWebhookResult::ignored($event->id, $event->type);
}
if (! $this->dispatchEvent($event->id, $event->type, $object)) {
return StripeWebhookResult::paymentNotYetVisible($event->id, $event->type);
}
$storedEvent->update(['processed_at' => now()]);
return StripeWebhookResult::processed($event->id, $event->type);
}
/**
* Verarbeitet ein gespeichertes, noch unverarbeitetes Event nach
* (stripe:process-pending-events): z. B. wenn der Webhook die Cron-
* DB-Transaktion überholt hat (503) und kein Stripe-Retry kommt
* (Stripe CLI leitet non-2xx nicht erneut zu). Die Signatur wurde
* bereits beim Empfang geprüft; der Payload liegt redaktiert vor.
*
* @return bool true = verarbeitet (processed_at gesetzt), false = weiterhin nicht zuordenbar
*/
public function processStoredEvent(StripeWebhookEvent $storedEvent): bool
{
if ($storedEvent->processed_at !== null || ! in_array($storedEvent->type, self::HANDLED_TYPES, true)) {
return false;
}
if (! $this->dispatchEvent($storedEvent->event_id, $storedEvent->type, $storedEvent->payload ?? [])) {
return false;
}
$storedEvent->update(['processed_at' => now()]);
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
*/
private function dispatchEvent(string $eventId, string $eventType, array $object): bool
{
/*
* Setup-Flow "Zahlungsart ändern" (Plan S5): trägt keine Zahlung,
* sondern ein Abo in der Metadata — vor der Zahlungs-Auflösung behandeln.
*/
if ($eventType === 'setup_intent.succeeded') {
$this->handleSetupIntentSucceeded($eventId, $object);
return true;
}
if ($eventType === 'checkout.session.completed' && ($object['mode'] ?? null) === 'setup') {
Log::channel('stripe')->info("Setup-Session abgeschlossen (Verankerung via setup_intent.succeeded): {$eventId}");
return true;
}
$payment = $this->resolveShoppingPayment($object);
if (! $payment) {
/*
* Trägt das Event unsere Metadata, existiert die Zahlung sicher —
* sie ist nur noch nicht sichtbar (Cron bucht innerhalb einer
* DB-Transaktion ab, der Webhook kann den Commit überholen).
* Dann Retry anfordern statt das Event als verarbeitet zu begraben.
*/
if (! empty($object['metadata']['shopping_payment_id'])) {
Log::channel('stripe')->warning(sprintf(
'Stripe-Webhook: Zahlung aus metadata.shopping_payment_id=%s (noch) nicht auffindbar — Retry angefordert: %s (%s)',
$object['metadata']['shopping_payment_id'],
$eventId,
$eventType,
));
return false;
}
/*
* 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,
$object['id'] ?? '?',
));
return true;
}
StripeTransactionRecorder::recordFromStripeObject($payment, $eventType, $object);
$this->rememberPaymentIntentReference($payment, $object);
match ($eventType) {
'checkout.session.completed' => $this->handleSessionCompleted($payment, $object),
'checkout.session.expired' => $this->handleSessionExpired($payment),
'payment_intent.succeeded' => $this->markPaid($payment, $object),
'payment_intent.processing' => $this->markPending($payment),
'payment_intent.payment_failed' => $this->markFailed($payment),
'charge.succeeded' => $this->storePaymentMethodDetails($payment, $object),
// charge.refunded / dispute / setup_intent → Business-Logik folgt in S5–S7 bzw. S9.
default => Log::channel('stripe')->info("Stripe-Webhook protokolliert (Verarbeitung folgt in späterer Phase): {$eventType}"),
};
return true;
}
/**
* Setup-Flow "Zahlungsart ändern" (Plan S5.2): neue PaymentMethod am Abo
* verankern. Sicherheitsnetz zum Return-Pfad — die Verankerung selbst
* (AboPaymentMethodService::applyStripePaymentMethod) ist idempotent.
*
* @param array<string, mixed> $object
*/
private function handleSetupIntentSucceeded(string $eventId, array $object): void
{
$userAboId = (int) ($object['metadata']['user_abo_id'] ?? 0);
if ($userAboId <= 0) {
Log::channel('stripe')->info("SetupIntent ohne Abo-Metadata protokolliert: {$eventId}");
return;
}
$userAbo = UserAbo::find($userAboId);
$paymentMethodId = is_string($object['payment_method'] ?? null) ? $object['payment_method'] : null;
$customerId = is_string($object['customer'] ?? null) ? $object['customer'] : null;
if (! $userAbo || ! $paymentMethodId || ! $customerId) {
Log::channel('stripe')->warning("SetupIntent nicht anwendbar (Abo/PaymentMethod/Customer fehlt): {$eventId}", [
'user_abo_id' => $userAboId,
'payment_method' => $paymentMethodId,
'customer' => $customerId,
]);
return;
}
$source = trim((string) ($object['metadata']['source'] ?? ''));
app(AboPaymentMethodService::class)->applyStripePaymentMethod(
$userAbo,
$customerId,
$paymentMethodId,
$source !== '' ? $source : 'stripe-webhook',
);
}
/**
* Merkt sich die PaymentIntent-ID an der Zahlung, sobald sie bekannt ist.
* Charge-Events tragen keine Metadata — sie werden über
* external_reference = PaymentIntent-ID zugeordnet.
*
* @param array<string, mixed> $object
*/
private function rememberPaymentIntentReference(ShoppingPayment $payment, array $object): void
{
$paymentIntentId = match ($object['object'] ?? null) {
'payment_intent' => $object['id'] ?? null,
default => $object['payment_intent'] ?? null,
};
if ($paymentIntentId && $payment->external_reference !== $paymentIntentId) {
$payment->external_reference = $paymentIntentId;
$payment->save();
}
}
/**
* Session abgeschlossen: bei `paid` sofort der Paid-Pfad; bei asynchronen
* Methoden (SEPA & Co., payment_status=unpaid) ist die Bestellung platziert
* und wartet auf payment_intent.succeeded → Status "appointed" wie PAYONE.
*
* @param array<string, mixed> $object
*/
private function handleSessionCompleted(ShoppingPayment $payment, array $object): void
{
if (($object['payment_status'] ?? null) === 'paid') {
$this->markPaid($payment, $object);
return;
}
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'appointed');
$order->setUserHistoryValue(['status' => 7]);
ShoppingUserService::snycOrdersByShoppingOrder($order);
Util::setInstanceStatusByPayment($payment, 4);
CheckoutFunnelTracker::confirmedPayment(
shoppingPaymentId: $payment->id,
txaction: 'appointed',
metadata: ['provider' => 'stripe', 'session' => $object['id'] ?? null],
);
Payment::paymentStatusSendMail($order, $payment, [
'txaction' => 'appointed',
'mode' => $payment->mode,
'send_link' => false,
]);
}
/**
* @param array<string, mixed> $object
*/
private function markPaid(ShoppingPayment $payment, array $object): void
{
if (! $this->amountMatches($payment, $object)) {
return;
}
$this->captureOffSessionPaymentMethod($payment, $object);
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'paid');
$sendLink = false;
$sendMail = true;
DB::beginTransaction();
try {
$lockedOrder = ShoppingOrder::where('id', $order->id)->lockForUpdate()->first();
if (! $lockedOrder->paid) {
$sendLink = Payment::paymentStatusPaidAction($lockedOrder, true, $payment);
DB::commit();
} else {
$sendMail = false;
DB::commit();
}
} catch (\Exception $e) {
DB::rollBack();
Log::channel('stripe')->error('Stripe-Paid-Pfad fehlgeschlagen (Stripe stellt erneut zu): '.$e->getMessage(), [
'shopping_order_id' => $order->id,
'shopping_payment_id' => $payment->id,
]);
throw $e;
}
CheckoutFunnelTracker::confirmedPayment(
shoppingPaymentId: $payment->id,
txaction: 'paid',
metadata: ['provider' => 'stripe', 'object' => $object['id'] ?? null, 'mode' => $payment->mode],
);
if ($sendMail) {
Payment::paymentStatusSendMail($order, $payment, [
'txaction' => 'paid',
'mode' => $payment->mode,
'send_link' => $sendLink,
]);
}
$this->backfillPaymentMethodDetails($payment);
}
/**
* Abo-/Recovery-Zahlungen (Plan S4): Customer- und PaymentMethod-ID an der
* Zahlung persistieren, BEVOR der Paid-Pfad läuft — createNewAbo() bzw.
* advanceAboAfterRecoveryPayment() übernehmen sie von dort ans Abo.
* Session-Events tragen keine payment_method: dann aus dem Event-Store
* (PaymentIntent-/Charge-Event) nachschlagen, als letzter Ausweg per API.
*
* @param array<string, mixed> $object
*/
private function captureOffSessionPaymentMethod(ShoppingPayment $payment, array $object): void
{
$order = $payment->shopping_order;
if (! $order->is_abo && empty($order->recovery_user_abo_id)) {
return;
}
if (! empty($payment->stripe_customer_id) && ! empty($payment->stripe_payment_method_id)) {
return;
}
$customerId = is_string($object['customer'] ?? null) ? $object['customer'] : null;
$paymentMethodId = ($object['object'] ?? null) === 'payment_intent' && is_string($object['payment_method'] ?? null)
? $object['payment_method']
: null;
$paymentIntentId = ($object['object'] ?? null) === 'payment_intent'
? ($object['id'] ?? null)
: ($object['payment_intent'] ?? $payment->external_reference);
if (! $paymentMethodId && $paymentIntentId) {
$storedEvent = StripeWebhookEvent::where('type', 'payment_intent.succeeded')
->where('payload->id', $paymentIntentId)
->latest('id')
->first()
?? StripeWebhookEvent::where('type', 'charge.succeeded')
->where('payload->payment_intent', $paymentIntentId)
->latest('id')
->first();
if ($storedEvent) {
$payload = $storedEvent->payload ?? [];
$paymentMethodId = is_string($payload['payment_method'] ?? null) ? $payload['payment_method'] : null;
$customerId = $customerId ?: (is_string($payload['customer'] ?? null) ? $payload['customer'] : null);
}
}
if ((! $paymentMethodId || ! $customerId) && $paymentIntentId) {
try {
$paymentIntent = app(StripeClientWrapper::class)->client()->paymentIntents->retrieve($paymentIntentId);
$paymentMethodId = $paymentMethodId ?: (is_string($paymentIntent->payment_method) ? $paymentIntent->payment_method : ($paymentIntent->payment_method->id ?? null));
$customerId = $customerId ?: (is_string($paymentIntent->customer) ? $paymentIntent->customer : ($paymentIntent->customer->id ?? null));
} catch (\Throwable $e) {
Log::channel('stripe')->error('PaymentIntent-Nachladen für Abo-Zahlungsmittel fehlgeschlagen: '.$e->getMessage(), [
'shopping_payment_id' => $payment->id,
'payment_intent' => $paymentIntentId,
]);
}
}
if ($customerId || $paymentMethodId) {
$payment->stripe_customer_id = $payment->stripe_customer_id ?: $customerId;
$payment->stripe_payment_method_id = $payment->stripe_payment_method_id ?: $paymentMethodId;
$payment->save();
}
}
/**
* Speichert womit gezahlt wurde (Kartenmarke/letzte 4/Wallet, PayPal, Klarna …)
* aus den payment_method_details des Charge-Objekts in carddata — für die
* Anzeige in Bestellungen/Mails (ShoppingPayment::getPaymentType()).
*
* @param array<string, mixed> $chargeObject
*/
private function storePaymentMethodDetails(ShoppingPayment $payment, array $chargeObject): void
{
$summary = self::paymentMethodSummary($chargeObject);
if ($summary !== null && empty($payment->carddata)) {
$payment->carddata = $summary;
$payment->save();
}
}
/**
* Webhook-Reihenfolge ist nicht garantiert: Kam charge.succeeded VOR dem
* PaymentIntent-Event, war es noch keiner Zahlung zuordenbar — es liegt
* aber im Event-Store. Von dort nachziehen, ganz ohne API-Call.
*/
private function backfillPaymentMethodDetails(ShoppingPayment $payment): void
{
if (! empty($payment->carddata) || empty($payment->external_reference)) {
return;
}
$chargeEvent = StripeWebhookEvent::where('type', 'charge.succeeded')
->where('payload->payment_intent', $payment->external_reference)
->latest('id')
->first();
if ($chargeEvent) {
$this->storePaymentMethodDetails($payment, $chargeEvent->payload ?? []);
}
}
/**
* @param array<string, mixed> $chargeObject
* @return array<string, mixed>|null
*/
public static function paymentMethodSummary(array $chargeObject): ?array
{
$details = $chargeObject['payment_method_details'] ?? null;
$type = $details['type'] ?? null;
if (! $type) {
return null;
}
$summary = ['type' => $type];
if ($type === 'card') {
$summary['brand'] = $details['card']['brand'] ?? null;
$summary['last4'] = $details['card']['last4'] ?? null;
$summary['wallet'] = $details['card']['wallet']['type'] ?? null;
$summary['exp'] = isset($details['card']['exp_month'], $details['card']['exp_year'])
? sprintf('%02d/%d', $details['card']['exp_month'], $details['card']['exp_year'])
: null;
}
if ($type === 'sepa_debit') {
$summary['last4'] = $details['sepa_debit']['last4'] ?? null;
}
return $summary;
}
/**
* Asynchrone Zahlung unterwegs (SEPA & Co.). Kein Mailversand — die
* Bestellbestätigung ging bereits bei checkout.session.completed raus.
*/
private function markPending(ShoppingPayment $payment): void
{
$this->upgradeTxaction($payment->shopping_order, $payment, 'pending');
}
/**
* Fehlgeschlagener Zahlungsversuch. Bewusst KEINE Mail: Auf der Hosted
* Checkout Session kann der Kunde direkt erneut zahlen — jeder Fehlversuch
* feuert dieses Event, eine Mail pro Versuch wäre Spam. Endgültiges
* Scheitern zeigt sich als checkout.session.expired bzw. Cancel-Return.
*/
private function markFailed(ShoppingPayment $payment): void
{
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'failed');
$order->setUserHistoryValue(['status' => 6]);
Util::setInstanceStatusByPayment($payment, 5);
}
/**
* Session nach 24 h abgelaufen, ohne Zahlung: Vorgang als abgebrochen
* markieren (wie Cancel-Return), Bestellstatus bleibt unangetastet.
*/
private function handleSessionExpired(ShoppingPayment $payment): void
{
if ($payment->shopping_order->paid) {
return;
}
$payment->status = 'expired';
$payment->save();
$payment->shopping_order->setUserHistoryValue(['status' => 22]);
Util::setInstanceStatusByPayment($payment, 6);
}
/**
* Betragsabgleich (Pendant zum PAYONE-Guard 2006): Der bestätigte Betrag
* muss dem initiierten entsprechen, sonst keine Paid-Verarbeitung.
*
* @param array<string, mixed> $object
*/
private function amountMatches(ShoppingPayment $payment, array $object): bool
{
$confirmed = $object['amount_total'] ?? $object['amount_received'] ?? $object['amount'] ?? null;
if ($confirmed === null || (int) $confirmed !== (int) $payment->amount) {
Log::channel('stripe')->error('Stripe-Webhook Betragsabweichung — Paid-Verarbeitung verweigert.', [
'shopping_payment_id' => $payment->id,
'expected_cents' => (int) $payment->amount,
'confirmed_cents' => $confirmed,
'object' => $object['id'] ?? null,
]);
return false;
}
return true;
}
private function upgradeTxaction(ShoppingOrder $order, ShoppingPayment $payment, string $txaction): void
{
$currentPriority = self::TXACTION_PRIORITY[$order->txaction] ?? 0;
$newPriority = self::TXACTION_PRIORITY[$txaction] ?? 0;
if ($newPriority > $currentPriority) {
$order->txaction = $txaction;
$order->save();
$payment->txaction = $txaction;
$payment->save();
}
}
/**
* Ordnet ein Stripe-Objekt einer Zahlung zu: über die von uns gesetzte
* Metadata (shopping_payment_id, Session UND PaymentIntent tragen sie)
* oder über external_reference (Session-/PaymentIntent-ID).
*
* @param array<string, mixed> $object
*/
private function resolveShoppingPayment(array $object): ?ShoppingPayment
{
$metadataId = $object['metadata']['shopping_payment_id'] ?? null;
if ($metadataId && ($payment = ShoppingPayment::find((int) $metadataId))) {
return $payment;
}
$references = array_filter([
$object['id'] ?? null,
$object['payment_intent'] ?? null,
]);
foreach ($references as $reference) {
$payment = ShoppingPayment::where('external_reference', $reference)->first();
if ($payment) {
return $payment;
}
}
return null;
}
}