diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 492d5266..c84f5828 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -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 $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 $session */ diff --git a/tests/Feature/Billing/DepublicationTest.php b/tests/Feature/Billing/DepublicationTest.php index 042ec6c6..0f774510 100644 --- a/tests/Feature/Billing/DepublicationTest.php +++ b/tests/Feature/Billing/DepublicationTest.php @@ -213,6 +213,7 @@ test('the paid webhook depublishes the release and the public page shows a tombs 'data' => ['object' => [ 'id' => 'cs_test_depub', 'payment_intent' => 'pi_test_depub', + 'payment_status' => 'paid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ]], ]); diff --git a/tests/Feature/Billing/StripeWebhookProcessingTest.php b/tests/Feature/Billing/StripeWebhookProcessingTest.php index b891f3c0..923600b3 100644 --- a/tests/Feature/Billing/StripeWebhookProcessingTest.php +++ b/tests/Feature/Billing/StripeWebhookProcessingTest.php @@ -133,6 +133,7 @@ test('checkout completion marks the referenced single purchase as paid', functio 'object' => [ 'id' => 'cs_test_1', 'payment_intent' => 'pi_test_1', + 'payment_status' => 'paid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ], ], @@ -156,6 +157,7 @@ test('an already paid purchase is not touched again', function () { 'object' => [ 'id' => 'cs_test_2', 'payment_intent' => 'pi_new', + 'payment_status' => 'paid', 'metadata' => ['single_purchase_id' => (string) $purchase->id], ], ], @@ -164,6 +166,64 @@ test('an already paid purchase is not touched again', function () { expect($purchase->fresh()->stripe_payment_intent_id)->toBe('pi_original'); }); +test('an unpaid async checkout session is not fulfilled yet', function () { + // PayPal/SEPA & Co.: `checkout.session.completed` kommt schon nach dem + // Redirect mit payment_status=unpaid — erfüllt wird erst mit + // `async_payment_succeeded`. + $purchase = SinglePurchase::factory()->create(); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'id' => 'cs_test_async', + 'payment_intent' => 'pi_test_async', + 'payment_status' => 'unpaid', + 'metadata' => ['single_purchase_id' => (string) $purchase->id], + ], + ], + ])); + + expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Pending); +}); + +test('a succeeded async payment fulfills the purchase', function () { + $purchase = SinglePurchase::factory()->create(); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'checkout.session.async_payment_succeeded', + 'data' => [ + 'object' => [ + 'id' => 'cs_test_async', + 'payment_intent' => 'pi_test_async', + 'payment_status' => 'paid', + 'metadata' => ['single_purchase_id' => (string) $purchase->id], + ], + ], + ])); + + $fresh = $purchase->fresh(); + expect($fresh->status)->toBe(SinglePurchaseStatus::Paid); + expect($fresh->stripe_checkout_session_id)->toBe('cs_test_async'); +}); + +test('a failed async payment leaves the purchase pending', function () { + $purchase = SinglePurchase::factory()->create(); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'checkout.session.async_payment_failed', + 'data' => [ + 'object' => [ + 'id' => 'cs_test_async', + 'payment_status' => 'unpaid', + 'metadata' => ['single_purchase_id' => (string) $purchase->id], + ], + ], + ])); + + expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Pending); +}); + test('the listener is wired to the cashier webhook event', function () { Event::fake(); diff --git a/tests/Feature/CreditTopupTest.php b/tests/Feature/CreditTopupTest.php index cfdd5381..c2af15fe 100644 --- a/tests/Feature/CreditTopupTest.php +++ b/tests/Feature/CreditTopupTest.php @@ -89,6 +89,7 @@ test('the checkout webhook credits the wallet from session metadata', function ( 'data' => ['object' => [ 'id' => 'cs_test_hook', 'payment_intent' => 'pi_test_hook', + 'payment_status' => 'paid', 'metadata' => ['credit_topup_id' => (string) $topup->id], ]], ]);