T12: Depublikations-Checkout mit Tombstone (config-gated)

Kunden koennen eigene veroeffentlichte PMs kostenpflichtig aus der
oeffentlichen Ansicht nehmen: Panel auf der PM-Detailseite (inkl.
T1neu-Unternehmer-Checkbox und Doppelausweis netto/brutto), Stripe-
Ad-hoc-Checkout wie Credit-Topups, Erfuellung im Webhook — die PM
wechselt auf archived + depublished_at, die oeffentliche URL zeigt
dauerhaft einen Tombstone (410 Gone, ohne Inhalte, mit ehrlichem
Drittarchive-Hinweis). Doppelbuchungs-Bremse pro PM; Status-Log mit
Quelle "depublication". Gate: ohne BILLING_DEPUBLICATION_PRICE_CENTS
bleibt der Weg deaktiviert und /melden zeigt die neutrale
Preisformulierung [EXTERN: Preis festlegen, O6].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 09:46:25 +00:00
parent 2a9be7a9af
commit 198be381d6
16 changed files with 522 additions and 7 deletions

View file

@ -188,6 +188,11 @@ CONTACT_EMAIL_PRESSEKONTO=
CONTACT_EMAIL_PRESSEECHO=
CONTACT_EMAIL_BUSINESSPORTAL=
# Depublikation (T12): Netto-Preis in Cent fuer die freiwillige Entfernung
# einer veroeffentlichten PM aus der oeffentlichen Ansicht (Tombstone bleibt).
# Ohne Wert ist der Depublikations-Checkout deaktiviert. [EXTERN: Preis O6]
BILLING_DEPUBLICATION_PRICE_CENTS=
# Selbst gehostete Umami-Webanalyse (sys.adametz.media; IDs je Brand in
# config/domains.php). Erst beim Go-Live der Webseiten auf true setzen —
# die Datenschutzerklärung (Abschnitt 5) beschreibt den Einsatz bereits.

View file

@ -8,6 +8,7 @@ enum SinglePurchaseType: string
case ExtraPm = 'extra_pm';
case Boost = 'boost';
case ProofPdf = 'proof_pdf';
case Depublication = 'depublication';
public function label(): string
{
@ -16,6 +17,7 @@ enum SinglePurchaseType: string
self::ExtraPm => 'Extra-PM',
self::Boost => 'Boost / Platzierung',
self::ProofPdf => 'Veröffentlichungsnachweis (PDF)',
self::Depublication => 'Depublikation (Entfernung aus der öffentlichen Ansicht)',
};
}

View file

@ -2,10 +2,12 @@
namespace App\Http\Controllers;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\BookingConfirmation;
use App\Models\Plan;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\StripeCheckoutService;
@ -102,6 +104,62 @@ class CheckoutController extends Controller
return $this->checkout->forCreditTopup($request->user(), $topup);
}
/**
* Depublikation einer eigenen, veröffentlichten PM (T12, Meldeverfahren
* Weg 3). Config-gated: ohne konfigurierten Preis ist der Weg
* deaktiviert. Die Entfernung selbst übernimmt der Webhook nach
* Zahlungseingang (Tombstone bleibt öffentlich sichtbar).
*/
public function depublication(Request $request, int $pressReleaseId): Checkout|RedirectResponse
{
$user = $request->user();
$priceCents = (int) config('billing.depublication_price_cents');
if ($priceCents <= 0) {
return $this->backToBookings(__('Die Depublikation ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')]));
}
$pressRelease = PressRelease::query()
->accessibleBy($user)
->whereKey($pressReleaseId)
->firstOrFail();
if ($pressRelease->status !== PressReleaseStatus::Published) {
return $this->backToBookings(__('Nur veröffentlichte Pressemitteilungen können depubliziert werden.'));
}
if (! $user->hasCompleteBillingAddress()) {
return $this->backToProfile();
}
if ($redirect = $this->requireEntrepreneurConfirmation($request)) {
return $redirect;
}
// Doppelbuchungs-Bremse: eine offene oder bereits bezahlte
// Depublikation zu dieser PM reicht.
$alreadyRequested = SinglePurchase::query()
->where('press_release_id', $pressRelease->id)
->where('type', SinglePurchaseType::Depublication->value)
->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value])
->exists();
if ($alreadyRequested) {
return $this->backToBookings(__('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.'));
}
$purchase = SinglePurchase::query()->create([
'user_id' => $user->id,
'type' => SinglePurchaseType::Depublication->value,
'status' => SinglePurchaseStatus::Pending->value,
'price_cents' => $priceCents,
'currency' => config('billing.default_currency'),
'press_release_id' => $pressRelease->id,
]);
return $this->checkout->forDepublication($user, $purchase);
}
/**
* Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode,
* Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll.

View file

@ -6,13 +6,21 @@ use App\Http\Controllers\Controller;
use App\Services\Web\ReleaseDetailPageData;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Response;
class ReleaseDetailController extends Controller
{
// $edition stammt aus dem Gruppen-Prefix {edition} und steht positionsbedingt
// vor {slug}; es wird hier nicht gebraucht, muss aber in der Signatur stehen.
public function __invoke(string $edition, string $slug, ReleaseDetailPageData $pageData): View
public function __invoke(string $edition, string $slug, ReleaseDetailPageData $pageData): View|Response
{
return view('web.release-detail', $pageData->build(WebPortalContext::fromRequest(), $slug));
$data = $pageData->build(WebPortalContext::fromRequest(), $slug);
// T12: depublizierte PM → Tombstone mit 410 Gone (ohne Inhalte).
if (isset($data['tombstone'])) {
return response()->view('web.release-tombstone', [], 410);
}
return view('web.release-detail', $data);
}
}

View file

@ -3,7 +3,9 @@
namespace App\Listeners;
use App\Enums\InvoiceStatus;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\CreditTopup;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
@ -11,6 +13,7 @@ use App\Models\SinglePurchase;
use App\Models\User;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\InvoiceNumberGenerator;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
@ -182,5 +185,39 @@ class ProcessStripeWebhook
]);
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
$this->fulfillDepublication($purchase);
}
/**
* T12: Nach bezahltem Depublikations-Kauf die PM sofort aus der
* öffentlichen Ansicht nehmen (Tombstone bleibt). Replays sind über den
* Pending-Status des Kaufs abgefangen; der Kauf gilt mit der
* Depublikation als eingelöst.
*/
private function fulfillDepublication(SinglePurchase $purchase): void
{
if ($purchase->type !== SinglePurchaseType::Depublication) {
return;
}
$pressRelease = $purchase->pressRelease;
if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) {
Log::warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [
'single_purchase_id' => $purchase->id,
'press_release_id' => $purchase->press_release_id,
]);
return;
}
app(PressReleaseService::class)->depublish($pressRelease);
$purchase->update(['consumed_at' => now()]);
Log::info('Pressemitteilung depubliziert.', [
'single_purchase_id' => $purchase->id,
'press_release_id' => $pressRelease->id,
]);
}
}

View file

@ -81,6 +81,7 @@ class PressRelease extends Model
'teaser_end',
'no_export',
'published_at',
'depublished_at',
'scheduled_at',
'embargo_at',
'withdrawal_waiver_consented_at',
@ -104,6 +105,7 @@ class PressRelease extends Model
'teaser_end' => 'integer',
'no_export' => 'boolean',
'published_at' => 'datetime',
'depublished_at' => 'datetime',
'scheduled_at' => 'datetime',
'embargo_at' => 'datetime',
'withdrawal_waiver_consented_at' => 'datetime',

View file

@ -121,6 +121,26 @@ class StripeCheckoutService
]);
}
/**
* Stripe-Checkout für eine Depublikation (T12). Ad-hoc-Charge über den
* konfigurierten Netto-Preis (wie Credit-Topups, keine Stripe-Price-ID);
* der Webhook depubliziert die PM nach Zahlungseingang.
*/
public function forDepublication(User $user, SinglePurchase $purchase): Checkout
{
$this->syncTaxIdFromBillingAddress($user);
return $user->checkoutCharge(
$purchase->price_cents,
'Depublikation einer Pressemitteilung',
1,
[
...$this->sessionOptions(),
'metadata' => ['single_purchase_id' => (string) $purchase->id],
],
);
}
/**
* Stripe-Checkout für eine Wallet-Aufladung. Ad-hoc-Charge über den
* Netto-Paketpreis (Stripe Tax ergänzt die USt.); die `credit_topup_id`

View file

@ -321,6 +321,25 @@ class PressReleaseService
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, null, 'admin');
}
/**
* Depublikation (T12): kostenpflichtige Entfernung aus der öffentlichen
* Ansicht auf Wunsch des Absenders. Die PM wechselt auf "archived" und
* erhält den Depublikations-Marker — die öffentliche Detail-URL zeigt
* fortan einen Tombstone (410).
*/
public function depublish(PressRelease $pressRelease): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Published]);
$previous = $pressRelease->status;
$pressRelease->update([
'status' => PressReleaseStatus::Archived->value,
'depublished_at' => now(),
]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, 'Depublikation auf Wunsch des Absenders (kostenpflichtige Serviceleistung)', 'depublication');
}
public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void
{
$previous = $pressRelease->status;

View file

@ -41,6 +41,21 @@ class ReleaseDetailPageData
->first();
if (! $release) {
// T12-Tombstone: depublizierte PMs (kostenpflichtige Entfernung
// auf Wunsch des Absenders) melden 410 statt 404 — die URL gab
// es, der Inhalt wurde bewusst entfernt.
$depublished = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($portalValues)
->forLanguage($language)
->whereNotNull('depublished_at')
->where('slug', $slug)
->exists();
if ($depublished) {
return ['tombstone' => true];
}
abort(404);
}

View file

@ -54,6 +54,22 @@ return [
'single_pm_stripe_price_id' => env('STRIPE_PRICE_SINGLE_PM'),
/*
|--------------------------------------------------------------------------
| Depublikation (T12, Meldeverfahren Weg 3)
|--------------------------------------------------------------------------
|
| Netto-Preis der freiwilligen Entfernung einer rechtmäßigen, bereits
| veröffentlichten PM aus der öffentlichen Ansicht (Tombstone bleibt).
| Ohne Wert ist der Depublikations-Checkout deaktiviert und /melden
| zeigt weiterhin die neutrale Preisformulierung.
| [EXTERN: Preis festlegen (O6); Anzeige als Doppelausweis netto/brutto]
| Ad-hoc-Charge wie Credit-Topups — keine Stripe-Price-ID nötig.
|
*/
'depublication_price_cents' => env('BILLING_DEPUBLICATION_PRICE_CENTS'),
/*
|--------------------------------------------------------------------------
| USt-Behandlung (Entscheidung 12.06.2026)

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* T12 (Depublikation): Zeitpunkt der kostenpflichtigen Entfernung aus der
* öffentlichen Ansicht. Depublizierte PMs wechseln auf Status "archived";
* dieser Marker unterscheidet sie von sonstigen Archivierungen und steuert
* die öffentliche Tombstone-Seite (410 statt 404).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('press_releases', function (Blueprint $table) {
$table->timestamp('depublished_at')->nullable()->after('published_at');
});
}
public function down(): void
{
Schema::table('press_releases', function (Blueprint $table) {
$table->dropColumn('depublished_at');
});
}
};

View file

@ -1,9 +1,14 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Exceptions\BoostNotAllowedException;
use App\Exceptions\InsufficientCreditsException;
use App\Models\BookingConfirmation;
use App\Models\LegalDocument;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\ProofPdfService;
@ -28,6 +33,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public ?string $shareExpiresAt = null;
public bool $depublicationConfirmed = false;
public function mount(int $id): void
{
$this->id = $id;
@ -142,6 +149,30 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
}
}
/**
* Unternehmer-Pflicht-Checkbox vor dem Depublikations-Checkout (T1neu):
* protokolliert wie im Buchungs-Modal — der Checkout-Controller verlangt
* eine zeitnahe Bestätigung zusätzlich serverseitig.
*/
public function updatedDepublicationConfirmed(bool $value): void
{
if (! $value) {
return;
}
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
BookingConfirmation::query()->create([
'user_id' => auth()->id(),
'booking_type' => 'depublication',
'booking_ref' => (string) $this->id,
'legal_document_id' => $agb?->id,
'agb_version' => $agb?->version ?? (string) config('legal.agb_version'),
'ip' => request()->ip(),
'confirmed_at' => now(),
]);
}
public function with(): array
{
$pr = $this->getMyPR();
@ -189,6 +220,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
'latestRejection' => $latestRejection,
'contacts' => $pr->contacts,
'statusLogs' => $pr->statusLogs,
// T12: Depublikation ist config-gated (ohne Preis kein Angebot).
'depublicationPriceCents' => (int) config('billing.depublication_price_cents'),
'depublicationRequested' => SinglePurchase::query()
->where('press_release_id', $pr->id)
->where('type', SinglePurchaseType::Depublication->value)
->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value])
->exists(),
];
}
@ -505,6 +543,61 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</article>
@endif
{{-- ============== DEPUBLIKATION (T12, Meldeverfahren Weg 3) ============== --}}
@if ($pr->status === PressReleaseStatus::Published && $depublicationPriceCents > 0)
@php
$depubNet = number_format($depublicationPriceCents / 100, 2, ',', '.');
$depubGross = number_format($depublicationPriceCents * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.');
@endphp
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Depublikation (Service)') }}</span>
</div>
<div class="p-5 space-y-3">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0 bg-[color:var(--color-hub-soft)] text-[color:var(--color-hub)]">
<flux:icon.eye-slash class="size-[18px]" />
</div>
<div class="min-w-0">
<h3 class="text-[14px] font-bold text-[color:var(--color-ink)] m-0">{{ __('Aus der öffentlichen Ansicht entfernen') }}</h3>
<p class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1 mb-0">
{{ __('Freiwillige Entfernung dieser veröffentlichten Mitteilung — die öffentliche URL zeigt danach dauerhaft einen Entfernt-Hinweis. Kopien in Suchmaschinen oder Drittarchiven können zeitweise weiter verfügbar sein. Preis: :net € zzgl. USt. (:gross € inkl. 19 % USt.)', ['net' => $depubNet, 'gross' => $depubGross]) }}
</p>
</div>
</div>
@if ($depublicationRequested)
<div class="rounded-[6px] border border-[color:var(--color-ok)]/30 bg-[color:var(--color-ok-soft)] px-3 py-2 text-[12.5px] text-[color:var(--color-gain-deep)]">
{{ __('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.') }}
</div>
@else
<div class="space-y-2">
<flux:switch
align="left"
wire:model.live="depublicationConfirmed"
:label="__('Ich bestelle in Ausübung meiner gewerblichen oder selbständigen beruflichen Tätigkeit (§ 14 BGB) und akzeptiere die AGB.')"
/>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] m-0">
<a href="{{ route('agb', ['edition' => 'de']) }}" target="_blank" rel="noopener" class="underline underline-offset-2 hover:text-[color:var(--color-ink)]">
{{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }}
</a>
· {{ __('Gesetzliche Ansprüche (z. B. DSGVO) bleiben davon unberührt und sind immer kostenlos.') }}
</p>
</div>
@if ($depublicationConfirmed)
<flux:button size="sm" variant="primary" icon="lock-closed" href="{{ route('me.checkout.depublication', $pr->id) }}">
{{ __('Kostenpflichtig depublizieren') }}
</flux:button>
@else
<flux:button size="sm" variant="primary" icon="lock-closed" disabled>
{{ __('Kostenpflichtig depublizieren') }}
</flux:button>
@endif
@endif
</div>
</article>
@endif
@if ($pr->status === PressReleaseStatus::Review)
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px; background:var(--color-warn-soft);">
<div class="panel-head">

View file

@ -155,11 +155,27 @@
<li>Eine vollständige Löschung der URL erfolgt nicht.</li>
<li>Nicht enthalten ist die Entfernung aus Suchmaschinen-Caches, Archiven Dritter oder Weiterveröffentlichungen auf anderen Portalen, auf die wir keinen Zugriff haben.</li>
</ul>
<p>
Der Preis wird Ihnen vor Beauftragung angezeigt.
<strong>Beauftragung:</strong> über Ihr Kundenkonto oder per E-Mail an
<a href="mailto:{{ $reportEmail }}">{{ $reportEmail }}</a>.
</p>
@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)
<p>
Der Preis beträgt
<strong>{{ number_format($depublicationPriceCents / 100, 2, ',', '.') }} € zzgl. USt.</strong>
({{ number_format($depublicationPriceCents * (1 + (float) config('billing.vat_rate')) / 100, 2, ',', '.') }} € inkl. 19&nbsp;% USt.).
<strong>Beauftragung:</strong> über Ihr Kundenkonto (Detailseite der
Pressemitteilung) oder per E-Mail an
<a href="mailto:{{ $reportEmail }}">{{ $reportEmail }}</a>.
</p>
@else
<p>
Der Preis wird Ihnen vor Beauftragung angezeigt.
<strong>Beauftragung:</strong> über Ihr Kundenkonto oder per E-Mail an
<a href="mailto:{{ $reportEmail }}">{{ $reportEmail }}</a>.
</p>
@endif
<p>
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 —

View file

@ -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')
<x-web.static-page
eyebrow="Hinweis"
title="Diese Pressemitteilung wurde entfernt"
breadcrumb="Entfernt"
>
<div class="pm-body max-w-[680px]">
<p>
Die Pressemitteilung unter dieser Adresse wurde auf Wunsch des Absenders dauerhaft
aus der öffentlichen Ansicht entfernt.
</p>
<p>
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.
</p>
<p>
Wenn Sie ein rechtliches Anliegen zu Inhalten auf diesem Portal haben, nutzen Sie
bitte unser <a href="{{ route('melden') }}">Meldeverfahren</a> — Meldungen sind kostenlos.
</p>
</div>
</x-web.static-page>
@endsection

View file

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

View file

@ -0,0 +1,160 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Listeners\ProcessStripeWebhook;
use App\Models\BillingAddress;
use App\Models\BookingConfirmation;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Services\Billing\StripeCheckoutService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\RedirectResponse;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Events\WebhookReceived;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
$this->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();
});