diff --git a/resources/views/web/melden.blade.php b/resources/views/web/melden.blade.php
index 9df8a326..ae1b4ad8 100644
--- a/resources/views/web/melden.blade.php
+++ b/resources/views/web/melden.blade.php
@@ -155,11 +155,27 @@
Eine vollständige Löschung der URL erfolgt nicht.
Nicht enthalten ist die Entfernung aus Suchmaschinen-Caches, Archiven Dritter oder Weiterveröffentlichungen auf anderen Portalen, auf die wir keinen Zugriff haben.
-
- Der Preis wird Ihnen vor Beauftragung angezeigt.
- Beauftragung: über Ihr Kundenkonto oder per E-Mail an
- {{ $reportEmail }}.
-
+ @php
+ // T12: Preis erscheint erst, wenn er konfiguriert ist (O6) —
+ // Doppelausweis netto/brutto, da auch Laien mitlesen (O-B).
+ $depublicationPriceCents = (int) config('billing.depublication_price_cents');
+ @endphp
+ @if ($depublicationPriceCents > 0)
+
+ Der Preis beträgt
+ {{ number_format($depublicationPriceCents / 100, 2, ',', '.') }} € zzgl. USt.
+ ({{ number_format($depublicationPriceCents * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.') }} € inkl. 19 % USt.).
+ Beauftragung: über Ihr Kundenkonto (Detailseite der
+ Pressemitteilung) oder per E-Mail an
+ {{ $reportEmail }}.
+
+ @else
+
+ Der Preis wird Ihnen vor Beauftragung angezeigt.
+ Beauftragung: über Ihr Kundenkonto oder per E-Mail an
+ {{ $reportEmail }}.
+
+ @endif
Wichtig: Dieser Service ist keine Voraussetzung für Ihre gesetzlichen Rechte. Die Meldung
rechtswidriger Inhalte und die Ausübung gesetzlicher Rechte sind und bleiben kostenlos —
diff --git a/resources/views/web/release-tombstone.blade.php b/resources/views/web/release-tombstone.blade.php
new file mode 100644
index 00000000..8d1924fc
--- /dev/null
+++ b/resources/views/web/release-tombstone.blade.php
@@ -0,0 +1,33 @@
+@extends('web.layouts.web-master')
+
+@section('title', 'Pressemitteilung entfernt')
+@section('meta_description', 'Diese Pressemitteilung wurde aus der öffentlichen Ansicht entfernt.')
+
+{{--
+ T12-Tombstone (410 Gone): Die PM wurde auf Wunsch des Absenders
+ kostenpflichtig depubliziert. Bewusst ohne Titel/Inhalte — der Zweck
+ der Depublikation ist die Entfernung; ehrlicher Hinweis auf Grenzen
+ (Suchmaschinen-Caches, Drittarchive) statt stillem 404.
+--}}
+@section('content')
+
+
+
+ Die Pressemitteilung unter dieser Adresse wurde auf Wunsch des Absenders dauerhaft
+ aus der öffentlichen Ansicht entfernt.
+
+
+ Bitte beachten Sie: Kopien in Suchmaschinen-Caches oder Archiven Dritter können
+ unabhängig davon zeitweise weiter verfügbar sein — darauf haben wir keinen Einfluss.
+
+
+ Wenn Sie ein rechtliches Anliegen zu Inhalten auf diesem Portal haben, nutzen Sie
+ bitte unser Meldeverfahren — Meldungen sind kostenlos.
+
+
+
+@endsection
diff --git a/routes/customer.php b/routes/customer.php
index 59e64a1f..f49bf5ca 100644
--- a/routes/customer.php
+++ b/routes/customer.php
@@ -44,6 +44,9 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
->name('checkout.single-pm');
Route::get('checkout/credits/{pack}', [CheckoutController::class, 'creditTopup'])
->name('checkout.credit-topup');
+ Route::get('checkout/depublikation/{pressReleaseId}', [CheckoutController::class, 'depublication'])
+ ->where('pressReleaseId', '[0-9]+')
+ ->name('checkout.depublication');
Route::get('checkout/abo-verwalten', [CheckoutController::class, 'billingPortal'])
->name('checkout.billing-portal');
Volt::route('invoices', 'customer.invoices')->name('invoices.index');
diff --git a/tests/Feature/Billing/DepublicationTest.php b/tests/Feature/Billing/DepublicationTest.php
new file mode 100644
index 00000000..75ad8b1a
--- /dev/null
+++ b/tests/Feature/Billing/DepublicationTest.php
@@ -0,0 +1,160 @@
+seed(RolesAndPermissionsSeeder::class);
+ config()->set('billing.depublication_price_cents', 19900);
+});
+
+function depublicationCustomer(): User
+{
+ $user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]);
+ $user->assignRole('customer');
+ BillingAddress::factory()->create(['user_id' => $user->id]);
+
+ // B2B-Gate (T1neu): zeitnahe Unternehmer-Bestätigung.
+ BookingConfirmation::query()->create([
+ 'user_id' => $user->id,
+ 'booking_type' => 'depublication',
+ 'agb_version' => (string) config('legal.agb_version'),
+ 'confirmed_at' => now(),
+ ]);
+
+ return $user;
+}
+
+function publishedReleaseFor(User $user): PressRelease
+{
+ return PressRelease::factory()
+ ->published()
+ ->forPortal(Portal::Businessportal24)
+ ->create(['user_id' => $user->id, 'published_at' => now()->subDay()]);
+}
+
+test('the depublication checkout is disabled without a configured price', function () {
+ /** @var TestCase $this */
+ config()->set('billing.depublication_price_cents', null);
+ $user = depublicationCustomer();
+ $release = publishedReleaseFor($user);
+
+ $this->actingAs($user)
+ ->get(route('me.checkout.depublication', $release->id))
+ ->assertRedirect(route('me.bookings.index'))
+ ->assertSessionHas('checkout-notice');
+
+ expect(SinglePurchase::count())->toBe(0);
+});
+
+test('only own published releases can be depublished', function () {
+ /** @var TestCase $this */
+ $user = depublicationCustomer();
+
+ // Fremde PM → 404 (accessibleBy-Scope).
+ $foreign = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create();
+ $this->actingAs($user)
+ ->get(route('me.checkout.depublication', $foreign->id))
+ ->assertNotFound();
+
+ // Eigener Entwurf → Hinweis statt Checkout.
+ $draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create([
+ 'user_id' => $user->id,
+ 'status' => PressReleaseStatus::Draft,
+ ]);
+ $this->actingAs($user)
+ ->get(route('me.checkout.depublication', $draft->id))
+ ->assertRedirect(route('me.bookings.index'))
+ ->assertSessionHas('checkout-notice');
+
+ expect(SinglePurchase::count())->toBe(0);
+});
+
+test('the depublication checkout creates a pending purchase and hands it to stripe', function () {
+ /** @var TestCase $this */
+ $user = depublicationCustomer();
+ $release = publishedReleaseFor($user);
+
+ $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);
+ });
+
+ $this->actingAs($user)
+ ->get(route('me.checkout.depublication', $release->id))
+ ->assertRedirect('https://checkout.stripe.com/c/pay/test');
+
+ $purchase = SinglePurchase::sole();
+ expect($purchase->type)->toBe(SinglePurchaseType::Depublication);
+ expect($purchase->status)->toBe(SinglePurchaseStatus::Pending);
+ expect($purchase->price_cents)->toBe(19900);
+ expect($purchase->press_release_id)->toBe($release->id);
+
+ // Doppelbuchungs-Bremse: zweiter Aufruf legt keinen weiteren Kauf an.
+ $this->actingAs($user)
+ ->get(route('me.checkout.depublication', $release->id))
+ ->assertRedirect(route('me.bookings.index'));
+
+ expect(SinglePurchase::count())->toBe(1);
+});
+
+test('the paid webhook depublishes the release and the public page shows a tombstone', function () {
+ /** @var TestCase $this */
+ $user = depublicationCustomer();
+ $release = publishedReleaseFor($user);
+
+ $purchase = SinglePurchase::query()->create([
+ 'user_id' => $user->id,
+ 'type' => SinglePurchaseType::Depublication->value,
+ 'status' => SinglePurchaseStatus::Pending->value,
+ 'price_cents' => 19900,
+ 'currency' => 'EUR',
+ 'press_release_id' => $release->id,
+ ]);
+
+ $event = new WebhookReceived([
+ 'type' => 'checkout.session.completed',
+ 'data' => ['object' => [
+ 'id' => 'cs_test_depub',
+ 'payment_intent' => 'pi_test_depub',
+ 'metadata' => ['single_purchase_id' => (string) $purchase->id],
+ ]],
+ ]);
+
+ app(ProcessStripeWebhook::class)->handle($event);
+ app(ProcessStripeWebhook::class)->handle($event); // Replay bleibt folgenlos.
+
+ $release->refresh();
+ expect($release->status)->toBe(PressReleaseStatus::Archived);
+ expect($release->depublished_at)->not->toBeNull();
+ expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Paid);
+ expect($purchase->fresh()->consumed_at)->not->toBeNull();
+ expect($release->statusLogs()->count())->toBeGreaterThan(0);
+
+ // Öffentliche URL: Tombstone mit 410 Gone statt 404, ohne Inhalte.
+ $this->get('https://businessportal24.test/de/press-release/'.$release->slug)
+ ->assertStatus(410)
+ ->assertSeeText('Diese Pressemitteilung wurde entfernt')
+ ->assertSeeText('auf Wunsch des Absenders')
+ ->assertDontSeeText($release->title);
+
+ // Unbekannte Slugs bleiben 404.
+ $this->get('https://businessportal24.test/de/press-release/gibt-es-nicht')
+ ->assertNotFound();
+});