mivita/app/Services/Payment/Stripe/StripeWebhookHandler.php
Kevin d57687b4e3 Team-Statistik = Team-Struktur (Provisionsbaum, Umschalter) und Laufstand-Fix in user_sales_volumes
K11 aus dem September-Protokoll (Tickets C1 und C2 im Entwicklungsplan), live seit 14.09.2026.

C1 – Statistik rechnet wie die Provision
- Standard-Baum ist der Provisionsbaum (Mitglieder wie TreeCalcBot: Mitgliedsebene,
  Abrechnungskonto, active_date; ausgefilterte Knoten kappen ihren Unterbaum, max. 21 Linien).
  Gesamte Downline bleibt per Umschalter erreichbar, Wahl in der Session.
- Punkte-Definition: Provisionspunkte (TP + Shop), reine Karrierepunkte ausgewiesen statt
  eingerechnet, negative Monatssalden je User wie in der Provision auf 0 gekappt.
- Abgeschlossene Monate nehmen die Mitglieder je Linie aus dem gespeicherten Provisionslauf
  (user_businesses), Snapshots je Baum-Definition (Payload-Version 3, Migration).
- Struktur-Modal: Gesamt aus den Linien-Punkten mit Dezimalen statt der int-Spalte total_pp.
- Messlauf statistics:compare-trees (Downline / Provisionsbaum / Struktur je VIP, Laufstand-Drift).

C2 – Laufstand-Race (Provision zu niedrig bei zeitgleichen Buchungen)
- Neuberechnung liest die Zeilen des Beraters sperrend (FOR UPDATE), Index (user_id, year, month).
- Paid-Pfad der Payone- und Stripe-Webhooks als DB::transaction mit 3 Anlaeufen; Deadlock und
  Lock-Timeout aus der Rechnungserstellung werden nicht mehr geschluckt.
- Sicherheitsnetz sales:repair-running-totals taeglich 02:45 vor dem Provisionslauf;
  abgeschlossene Monate werden nicht angefasst (Entscheid 14.09.: keine Nachzahlung fuer 06/08).

Doku: Entwicklungsplan zum Protokoll, Deploy-Notiz 14.09. (inkl. Migrations-Stolperstein:
Migrationen tragen Werte als Literale, nicht als Konstanten aus dem Anwendungscode).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:31:32 +00:00

640 lines
25 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[]
*/
/**
* Anlaeufe fuer die Paid-Transaktion — wiederholt nur bei Deadlock /
* Lock-Timeout (Ticket C2).
*/
private const PAID_TRANSACTION_ATTEMPTS = 3;
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;
// Bis zu 3 Anlaeufe: die Punkte-Buchung sperrt den Berater, zwei parallele
// Webhooks desselben Beraters koennen sich in einem Deadlock treffen
// (Ticket C2) — der Verlierer wiederholt die ganze Transaktion.
try {
[$sendLink, $sendMail] = DB::transaction(function () use ($order, $payment, $sendLink, $sendMail) {
$lockedOrder = ShoppingOrder::where('id', $order->id)->lockForUpdate()->first();
if ($lockedOrder->paid) {
return [$sendLink, false];
}
return [Payment::paymentStatusPaidAction($lockedOrder, true, $payment), $sendMail];
}, self::PAID_TRANSACTION_ATTEMPTS);
} catch (\Exception $e) {
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;
}
}