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:
parent
2a9be7a9af
commit
198be381d6
16 changed files with 522 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 % 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 —
|
||||
|
|
|
|||
33
resources/views/web/release-tombstone.blade.php
Normal file
33
resources/views/web/release-tombstone.blade.php
Normal 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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
160
tests/Feature/Billing/DepublicationTest.php
Normal file
160
tests/Feature/Billing/DepublicationTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue