From 198be381d683be758e56e1cbc016ee8977383bca Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 6 Jul 2026 09:46:25 +0000 Subject: [PATCH] T12: Depublikations-Checkout mit Tombstone (config-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 + app/Enums/SinglePurchaseType.php | 2 + app/Http/Controllers/CheckoutController.php | 58 +++++++ .../Web/ReleaseDetailController.php | 12 +- app/Listeners/ProcessStripeWebhook.php | 37 ++++ app/Models/PressRelease.php | 2 + .../Billing/StripeCheckoutService.php | 20 +++ .../PressRelease/PressReleaseService.php | 19 +++ app/Services/Web/ReleaseDetailPageData.php | 15 ++ config/billing.php | 16 ++ ...depublished_at_to_press_releases_table.php | 28 +++ .../customer/press-releases/show.blade.php | 93 ++++++++++ resources/views/web/melden.blade.php | 26 ++- .../views/web/release-tombstone.blade.php | 33 ++++ routes/customer.php | 3 + tests/Feature/Billing/DepublicationTest.php | 160 ++++++++++++++++++ 16 files changed, 522 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_07_06_113811_add_depublished_at_to_press_releases_table.php create mode 100644 resources/views/web/release-tombstone.blade.php create mode 100644 tests/Feature/Billing/DepublicationTest.php diff --git a/.env.example b/.env.example index 1f4f2b88..7856867e 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/Enums/SinglePurchaseType.php b/app/Enums/SinglePurchaseType.php index 3c92f2dd..6dd223c1 100644 --- a/app/Enums/SinglePurchaseType.php +++ b/app/Enums/SinglePurchaseType.php @@ -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)', }; } diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index a9f4cbf7..68ced9fc 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -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. diff --git a/app/Http/Controllers/Web/ReleaseDetailController.php b/app/Http/Controllers/Web/ReleaseDetailController.php index af769bbd..95c0be78 100644 --- a/app/Http/Controllers/Web/ReleaseDetailController.php +++ b/app/Http/Controllers/Web/ReleaseDetailController.php @@ -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); } } diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 4f17476b..4e646e61 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -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, + ]); } } diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index d2ce68d6..2cee5409 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -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', diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index 4f8b485b..6d6b5c4d 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -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` diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 1d0b5556..26ce7357 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -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; diff --git a/app/Services/Web/ReleaseDetailPageData.php b/app/Services/Web/ReleaseDetailPageData.php index 49e151a6..b81bf30d 100644 --- a/app/Services/Web/ReleaseDetailPageData.php +++ b/app/Services/Web/ReleaseDetailPageData.php @@ -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); } diff --git a/config/billing.php b/config/billing.php index f0021108..3bc18f5b 100644 --- a/config/billing.php +++ b/config/billing.php @@ -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) diff --git a/database/migrations/2026_07_06_113811_add_depublished_at_to_press_releases_table.php b/database/migrations/2026_07_06_113811_add_depublished_at_to_press_releases_table.php new file mode 100644 index 00000000..6d06690c --- /dev/null +++ b/database/migrations/2026_07_06_113811_add_depublished_at_to_press_releases_table.php @@ -0,0 +1,28 @@ +timestamp('depublished_at')->nullable()->after('published_at'); + }); + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn('depublished_at'); + }); + } +}; diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index c7b675d4..0f7d9805 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -1,9 +1,14 @@ 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 @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 +
+
+ {{ __('Depublikation (Service)') }} +
+
+
+
+ +
+
+

{{ __('Aus der öffentlichen Ansicht entfernen') }}

+

+ {{ __('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]) }} +

+
+
+ + @if ($depublicationRequested) +
+ {{ __('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.') }} +
+ @else +
+ +

+ + {{ __('AGB (Version :version)', ['version' => \App\Models\LegalDocument::currentAgbVersion()]) }} + + · {{ __('Gesetzliche Ansprüche (z. B. DSGVO) bleiben davon unberührt und sind immer kostenlos.') }} +

+
+ @if ($depublicationConfirmed) + + {{ __('Kostenpflichtig depublizieren') }} + + @else + + {{ __('Kostenpflichtig depublizieren') }} + + @endif + @endif +
+
+ @endif + @if ($pr->status === PressReleaseStatus::Review)
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(); +});