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>
223 lines
8.1 KiB
PHP
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}'.");
|
|
}
|
|
}
|