isConfigured()) { $this->error('STRIPE_SECRET ist nicht gesetzt.'); return self::FAILURE; } $payments = $this->resolvePayments(); if ($payments->isEmpty()) { $this->info('Keine passenden offenen Stripe-Zahlungen gefunden.'); return self::SUCCESS; } $dryRun = (bool) $this->option('dry-run'); $synced = 0; $unchanged = 0; $failed = 0; foreach ($payments as $payment) { try { $result = $this->syncPayment($stripe, $webhookHandler, $payment, $dryRun); $result ? $synced++ : $unchanged++; } catch (\Throwable $e) { $failed++; $this->error(sprintf('Zahlung #%d: %s', $payment->id, $e->getMessage())); Log::channel('stripe')->error('stripe:sync-payment-status fehlgeschlagen', [ 'shopping_payment_id' => $payment->id, 'error' => $e->getMessage(), ]); } } $this->info(sprintf( '%s: %d nachgezogen, %d unverändert, %d fehlgeschlagen.', $dryRun ? 'Probelauf' : 'Fertig', $synced, $unchanged, $failed, )); return $failed > 0 ? self::FAILURE : self::SUCCESS; } /** * @return Collection */ private function resolvePayments(): Collection { $target = $this->argument('target'); if ($target) { $payment = ShoppingPayment::where('external_reference', $target) ->orWhere('reference', $target) ->orWhere('id', is_numeric($target) ? (int) $target : 0) ->latest('id') ->first(); if (! $payment) { $this->error("Keine Zahlung zu '{$target}' gefunden."); return collect(); } return collect([$payment]); } if (! $this->option('pending')) { $this->error('Bitte eine Zahlung angeben oder --pending nutzen.'); return collect(); } return ShoppingPayment::where('payment_provider', 'stripe') ->whereNotNull('external_reference') ->where('created_at', '>=', now()->subDays((int) $this->option('days'))) // Abgelaufene Checkout-Sessions haben nie eine Zahlung getragen — // sie jede Stunde erneut bei Stripe abzufragen wäre reine Last. ->where(function ($query) { $query->whereNull('status')->orWhere('status', '!=', 'expired'); }) ->where(function ($query) { $query->whereNull('txaction')->orWhereNotIn('txaction', ['paid', 'cancelled']); }) ->whereHas('shopping_order', function ($query) { $query->where('paid', false); }) ->orderBy('id') ->get(); } private function syncPayment( StripeClientWrapper $stripe, StripeWebhookHandler $webhookHandler, ShoppingPayment $payment, bool $dryRun, ): bool { [$paymentIntent, $session] = $this->fetchStripeObjects($stripe, $payment); if (! $paymentIntent) { $this->warn(sprintf( 'Zahlung #%d (%s): kein PaymentIntent bei Stripe%s.', $payment->id, $payment->external_reference, $session ? ' — Session-Status: '.$session->status : '', )); if ($session && $session->status === 'expired' && ! $dryRun) { return $webhookHandler->syncFromApiObject('checkout.session.expired', $session->toArray(), 'api-sync'); } return false; } $eventType = match ($paymentIntent->status) { 'succeeded' => 'payment_intent.succeeded', 'processing' => 'payment_intent.processing', 'requires_payment_method', 'requires_action', 'requires_confirmation' => 'payment_intent.payment_failed', default => null, }; $this->line(sprintf( 'Zahlung #%d lokal: %-9s Stripe: %-22s %s', $payment->id, $payment->txaction ?: 'offen', $paymentIntent->status, $paymentIntent->id, )); if ($eventType === null) { $this->warn(" → Status '{$paymentIntent->status}' wird nicht verarbeitet."); return false; } if ($dryRun) { $this->comment(" → würde als '{$eventType}' verarbeitet."); return true; } $handled = $webhookHandler->syncFromApiObject($eventType, $paymentIntent->toArray(), 'api-sync'); // Zahlart (Kartenmarke/letzte 4, PayPal …) kommt nur am Charge — nach // dem PaymentIntent nachziehen, damit external_reference bereits sitzt. $charge = $paymentIntent->latest_charge ?? null; if ($handled && $charge && is_object($charge)) { $webhookHandler->syncFromApiObject('charge.succeeded', $charge->toArray(), 'api-sync'); } $this->info(sprintf(' → verarbeitet als %s%s', $eventType, $handled ? '' : ' (keiner Zahlung zuordenbar)')); return $handled; } /** * Holt PaymentIntent (und ggf. Session) zu einer Zahlung. `external_reference` * trägt je nach Zeitpunkt die Session- oder die PaymentIntent-ID. * * @return array{0: PaymentIntent|null, 1: Session|null} */ private function fetchStripeObjects(StripeClientWrapper $stripe, ShoppingPayment $payment): array { $reference = (string) $payment->external_reference; $client = $stripe->client(); if (str_starts_with($reference, 'pi_')) { return [$client->paymentIntents->retrieve($reference, ['expand' => ['latest_charge']]), null]; } if (str_starts_with($reference, 'cs_')) { $session = $client->checkout->sessions->retrieve($reference); $paymentIntentId = is_string($session->payment_intent) ? $session->payment_intent : ($session->payment_intent->id ?? null); return [ $paymentIntentId ? $client->paymentIntents->retrieve($paymentIntentId, ['expand' => ['latest_charge']]) : null, $session, ]; } throw new \RuntimeException("Unbekannte external_reference '{$reference}'."); } }