mivita/app/Console/Commands/StripeSyncPaymentStatus.php
Kevin 68d230d4e5 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

223 lines
8.1 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\ShoppingPayment;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Stripe\Checkout\Session;
use Stripe\PaymentIntent;
/**
* Gleicht offene Stripe-Zahlungen aktiv gegen die Stripe-API ab und zieht den
* Status nach (Plan dev/2026-07-08-stripe/, S6.4 — vorgezogen am 2026-08-26).
*
* Abgrenzung zu `stripe:process-pending-events`: Jenes arbeitet auf Events,
* die bei uns GESPEICHERT sind (Webhook kam an, Verarbeitung scheiterte).
* Dieses Command deckt den anderen Fall ab — der Webhook wurde **nie
* zugestellt** (Listener am falschen Konto, Endpoint down, Stripe hat nach
* ~3 Tagen Retry aufgegeben). Dann ist das Geld bei Stripe eingegangen,
* während die Bestellung bei uns auf "offen" stehen bleibt.
*
* Die Seiteneffekte laufen über denselben Pfad wie ein echter Webhook
* (StripeWebhookHandler) und sind idempotent — eine bereits bezahlte
* Bestellung wird nicht erneut verarbeitet.
*/
class StripeSyncPaymentStatus extends Command
{
protected $signature = 'stripe:sync-payment-status
{target? : PaymentIntent-ID (pi_…), Session-ID (cs_…), Zahlungsreferenz oder shopping_payment-ID}
{--pending : Alle offenen Stripe-Zahlungen der letzten Tage prüfen}
{--days=7 : Zeitfenster für --pending}
{--dry-run : Nur anzeigen, was Stripe sagt — nichts verarbeiten}';
protected $description = 'Zieht den Status offener Stripe-Zahlungen aktiv über die API nach (verlorene Webhooks)';
public function handle(StripeClientWrapper $stripe, StripeWebhookHandler $webhookHandler): int
{
if (! $stripe->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<int, ShoppingPayment>
*/
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}'.");
}
}