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 67f6906b87
commit 425467bbec
4 changed files with 95 additions and 1 deletions

View file

@ -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],
]],
]);

View file

@ -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();

View file

@ -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],
]],
]);