diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 14506ed8..76b3e823 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -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, diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 0c020e4b..706601b9 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -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(), ]; } diff --git a/tests/Feature/Billing/DepublicationTest.php b/tests/Feature/Billing/DepublicationTest.php index b1d2d9c4..e1da428a 100644 --- a/tests/Feature/Billing/DepublicationTest.php +++ b/tests/Feature/Billing/DepublicationTest.php @@ -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 () {