billing: asynchrone Zahlarten (PayPal, SEPA) korrekt erfüllen

checkout.session.completed feuert bei asynchronen Zahlarten schon
nach dem Redirect mit payment_status=unpaid — der Listener hat
trotzdem sofort erfüllt (Credits gutgeschrieben / Kauf bezahlt),
auch wenn die Zahlung später scheitert. Erfüllung jetzt nur bei
payment_status paid/no_payment_required, zusätzlich reagiert der
Listener auf checkout.session.async_payment_succeeded; Fehlschläge
werden geloggt, der Kauf bleibt wiederholbar auf Pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-15 14:27:09 +00:00
parent 4cc74c256d
commit ff2eee0988
4 changed files with 95 additions and 1 deletions

View file

@ -49,7 +49,9 @@ class ProcessStripeWebhook
{
match ($event->payload['type'] ?? null) {
'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []),
'checkout.session.completed' => $this->fulfillCheckout($event->payload['data']['object'] ?? []),
'checkout.session.completed',
'checkout.session.async_payment_succeeded' => $this->fulfillCheckout($event->payload['data']['object'] ?? []),
'checkout.session.async_payment_failed' => $this->logFailedAsyncPayment($event->payload['data']['object'] ?? []),
'customer.subscription.updated' => $this->clearAppliedScheduledChange($event->payload['data']['object'] ?? []),
default => null,
};
@ -209,10 +211,40 @@ class ProcessStripeWebhook
*/
private function fulfillCheckout(array $session): void
{
// Asynchrone Zahlarten (PayPal, SEPA …): `checkout.session.completed`
// feuert dort schon nach dem Redirect mit payment_status=unpaid — das
// Ergebnis kommt erst mit `checkout.session.async_payment_succeeded/
// _failed`. Erfüllt wird deshalb nur bei bestätigter Zahlung; beide
// Erfüllungspfade sind idempotent, doppelte Events bleiben folgenlos.
if (! in_array($session['payment_status'] ?? null, ['paid', 'no_payment_required'], true)) {
Log::info('Checkout-Session noch unbezahlt — Erfüllung wartet auf async_payment_succeeded.', [
'session_id' => $session['id'] ?? null,
'payment_status' => $session['payment_status'] ?? null,
]);
return;
}
$this->fulfillSinglePurchase($session);
$this->fulfillCreditTopup($session);
}
/**
* Fehlgeschlagene asynchrone Zahlung: Kauf/Topup bleibt bewusst auf
* Pending (nichts wurde erfüllt, der Kunde kann erneut buchen) — aber
* die Betriebsspur muss sichtbar sein.
*
* @param array<string, mixed> $session
*/
private function logFailedAsyncPayment(array $session): void
{
Log::warning('Asynchrone Stripe-Zahlung fehlgeschlagen — keine Erfüllung.', [
'session_id' => $session['id'] ?? null,
'single_purchase_id' => $session['metadata']['single_purchase_id'] ?? null,
'credit_topup_id' => $session['metadata']['credit_topup_id'] ?? null,
]);
}
/**
* @param array<string, mixed> $session
*/