Depublikation: abgebrochener Checkout blockiert nicht mehr

Der Klick auf "Kostenpflichtig depublizieren" legte sofort einen
Pending-Kauf an; wer bei Stripe abbrach, sah danach dauerhaft "liegt
bereits ein Depublikations-Vorgang vor". Jetzt sperrt nur ein bezahlter
Vorgang — ein offener Pending-Kauf wird beim nächsten Klick
wiederverwendet (kein Duplikat), und die Detailseite zeigt das Angebot
weiterhin an.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-07 15:07:32 +00:00
parent 9b55e11159
commit 1ef1e13c19
3 changed files with 50 additions and 13 deletions

View file

@ -206,19 +206,22 @@ class CheckoutController extends Controller
return $redirect;
}
// Doppelbuchungs-Bremse: eine offene oder bereits bezahlte
// Depublikation zu dieser PM reicht.
$alreadyRequested = SinglePurchase::query()
// Doppelbuchungs-Bremse: nur eine BEZAHLTE Depublikation blockiert.
// Ein offener Pending-Kauf (abgebrochener Stripe-Checkout) wird
// wiederverwendet, damit der Abbruch den Weg nicht dauerhaft
// versperrt (QA-Befund 07.07.).
$existing = SinglePurchase::query()
->where('press_release_id', $pressRelease->id)
->where('type', SinglePurchaseType::Depublication->value)
->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value])
->exists();
->latest('id')
->first();
if ($alreadyRequested) {
if ($existing && $existing->status !== SinglePurchaseStatus::Pending) {
return $this->backToBookings(__('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.'));
}
$purchase = SinglePurchase::query()->create([
$purchase = $existing ?? SinglePurchase::query()->create([
'user_id' => $user->id,
'type' => SinglePurchaseType::Depublication->value,
'status' => SinglePurchaseStatus::Pending->value,

View file

@ -238,10 +238,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
'statusLogs' => $pr->statusLogs,
// T12: Depublikation ist config-gated (ohne Preis kein Angebot).
'depublicationPriceCents' => (int) config('billing.depublication_price_cents'),
// Nur ein BEZAHLTER Vorgang blendet das Angebot aus — ein
// abgebrochener Checkout (Pending) wird beim nächsten Klick
// wiederverwendet (CheckoutController::depublication).
'depublicationRequested' => SinglePurchase::query()
->where('press_release_id', $pr->id)
->where('type', SinglePurchaseType::Depublication->value)
->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value])
->where('status', SinglePurchaseStatus::Paid->value)
->exists(),
];
}

View file

@ -94,7 +94,8 @@ test('the depublication checkout creates a pending purchase and hands it to stri
$checkout = Mockery::mock(Checkout::class);
$checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test'));
$this->mock(StripeCheckoutService::class, function ($mock) use ($checkout) {
$mock->shouldReceive('forDepublication')->once()->andReturn($checkout);
// Zweimal: der zweite Aufruf verwendet den offenen Kauf wieder.
$mock->shouldReceive('forDepublication')->twice()->andReturn($checkout);
});
$this->actingAs($user)
@ -107,10 +108,33 @@ test('the depublication checkout creates a pending purchase and hands it to stri
expect($purchase->price_cents)->toBe(19900);
expect($purchase->press_release_id)->toBe($release->id);
// Doppelbuchungs-Bremse: zweiter Aufruf legt keinen weiteren Kauf an.
// Abgebrochener Checkout blockiert nicht: erneuter Aufruf nutzt den
// Pending-Kauf wieder statt einen zweiten anzulegen.
$this->actingAs($user)
->get(route('me.checkout.depublication', $release->id))
->assertRedirect(route('me.bookings.index'));
->assertRedirect('https://checkout.stripe.com/c/pay/test');
expect(SinglePurchase::count())->toBe(1);
});
test('a paid depublication blocks a second checkout for the same release', function () {
/** @var TestCase $this */
$user = depublicationCustomer();
$release = publishedReleaseFor($user);
SinglePurchase::query()->create([
'user_id' => $user->id,
'type' => SinglePurchaseType::Depublication->value,
'status' => SinglePurchaseStatus::Paid->value,
'price_cents' => 19900,
'currency' => 'EUR',
'press_release_id' => $release->id,
]);
$this->actingAs($user)
->get(route('me.checkout.depublication', $release->id))
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
expect(SinglePurchase::count())->toBe(1);
});
@ -145,12 +169,12 @@ test('the detail page hides the depublication offer without a price and after a
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertDontSee('Depublikation (Service)');
// Mit laufendem Vorgang: Hinweis statt erneutem Kauf-Button.
// Mit BEZAHLTEM Vorgang: Hinweis statt erneutem Kauf-Button.
config()->set('billing.depublication_price_cents', 19900);
SinglePurchase::query()->create([
$purchase = SinglePurchase::query()->create([
'user_id' => $user->id,
'type' => SinglePurchaseType::Depublication->value,
'status' => SinglePurchaseStatus::Pending->value,
'status' => SinglePurchaseStatus::Paid->value,
'price_cents' => 19900,
'currency' => 'EUR',
'press_release_id' => $release->id,
@ -158,6 +182,13 @@ test('the detail page hides the depublication offer without a price and after a
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertSee('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.')
->assertDontSee('Kostenpflichtig depublizieren');
// Ein offener Pending-Kauf (abgebrochener Checkout) blockiert das
// Angebot dagegen nicht — der Button bleibt nutzbar.
$purchase->update(['status' => SinglePurchaseStatus::Pending->value]);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertSee('Kostenpflichtig depublizieren')
->assertDontSee('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.');
});
test('the paid webhook depublishes the release and the public page shows a tombstone', function () {