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:
parent
67f6906b87
commit
425467bbec
4 changed files with 95 additions and 1 deletions
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue