diff --git a/.env.example b/.env.example
index 245563c9..76bba651 100644
--- a/.env.example
+++ b/.env.example
@@ -77,6 +77,10 @@ DB_LOCK_WAIT_TIMEOUT=10
# Bestimmt zugleich die Tiefe der Verlaufsanzeige. 395 = 13 Monate.
PRESS_RELEASE_EVENTS_RETENTION_DAYS=395
+# Aufbewahrung der Konversions-Ereignisse in Tagen (siehe config/conversion.php).
+# Bewusst derselbe Horizont wie oben. 395 = 13 Monate.
+CONVERSION_EVENTS_RETENTION_DAYS=395
+
# ---------------------------------------------------------------------------
# Legacy-Datenbanken (Alt-Systeme, read-only für Migration/Abgleich).
# Die businessportal-Verbindung ist ein Snapshot-Dump, nicht live.
diff --git a/app/Console/Commands/PurgeConversionEvents.php b/app/Console/Commands/PurgeConversionEvents.php
new file mode 100644
index 00000000..346b35b2
--- /dev/null
+++ b/app/Console/Commands/PurgeConversionEvents.php
@@ -0,0 +1,73 @@
+option('days') ?? config('conversion.events.retention_days'));
+
+ if ($days < 1) {
+ $this->error('Aufbewahrungszeitraum muss mindestens einen Tag betragen.');
+
+ return self::FAILURE;
+ }
+
+ $cutoff = now()->subDays($days);
+ $chunk = max(100, (int) $this->option('chunk'));
+
+ $expired = ConversionEvent::query()->where('created_at', '<', $cutoff);
+
+ if ($this->option('dry-run')) {
+ $this->info(sprintf(
+ '[Dry-Run] %d Ereignisse älter als %s.',
+ (clone $expired)->count(),
+ $cutoff->format('d.m.Y'),
+ ));
+
+ return self::SUCCESS;
+ }
+
+ $deleted = 0;
+
+ while (true) {
+ $removed = (clone $expired)->limit($chunk)->delete();
+
+ if ($removed === 0) {
+ break;
+ }
+
+ $deleted += $removed;
+ }
+
+ $this->info(sprintf(
+ 'Konversions-Ereignisse bereinigt: %d Einträge vor dem %s gelöscht (Aufbewahrung %d Tage).',
+ $deleted,
+ $cutoff->format('d.m.Y'),
+ $days,
+ ));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/ReportConversionFunnel.php b/app/Console/Commands/ReportConversionFunnel.php
new file mode 100644
index 00000000..3a287ccf
--- /dev/null
+++ b/app/Console/Commands/ReportConversionFunnel.php
@@ -0,0 +1,80 @@
+option('to') !== null ? Carbon::parse((string) $this->option('to')) : Carbon::now();
+ $from = $this->option('from') !== null
+ ? Carbon::parse((string) $this->option('from'))
+ : $to->copy()->subMonths(max(1, (int) $this->option('months')));
+
+ $data = $report->build($from, $to);
+
+ if ($this->option('json')) {
+ $this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+
+ return self::SUCCESS;
+ }
+
+ $this->info("Konversionstrichter — {$data['from']} bis {$data['to']}");
+
+ if ($data['sources'] === []) {
+ $this->newLine();
+ $this->warn('Keine Ereignisse im Zeitraum. Herkunftsdaten lassen sich nicht rückwirkend erzeugen — '
+ .'wenn hier dauerhaft nichts steht, greift eine Schreibstelle nicht.');
+
+ return self::SUCCESS;
+ }
+
+ foreach ($data['sources'] as $source) {
+ $this->newLine();
+ $this->line("{$source['label']}");
+
+ $rows = [];
+
+ foreach ($source['steps'] as $step) {
+ $rows[] = [
+ $step['step'],
+ $step['label'],
+ $step['firmen'],
+ $step['uebergang'] === null ? '–' : $step['uebergang'].' %',
+ $step['ohne_firma'] > 0 ? $step['ohne_firma'] : '',
+ $step['diagnosis'],
+ ];
+ }
+
+ $this->table(
+ ['#', 'Stufe', 'Firmen', 'Übergang', 'ohne Firma', 'Ein Abbruch hier bedeutet'],
+ $rows,
+ );
+ }
+
+ $this->newLine();
+ $this->line(' Gezählt werden eindeutige Firmen je Stufe — ein doppelter Klick ist kein doppelter Interessent.');
+ $this->line(' „ohne Firma" steht daneben statt darin: Diese Ereignisse gehören nicht in eine Quote, deren Nenner Firmen sind.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Enums/ConversionEventType.php b/app/Enums/ConversionEventType.php
new file mode 100644
index 00000000..8b549206
--- /dev/null
+++ b/app/Enums/ConversionEventType.php
@@ -0,0 +1,81 @@
+ 1,
+ self::MagicLinkConsumed => 2,
+ self::NewsroomEdited => 3,
+ self::AccountCreated => 4,
+ self::FirstBooking => 5,
+ };
+ }
+
+ public function label(): string
+ {
+ return match ($this) {
+ self::MailLinkClicked => 'Mail-Link geklickt',
+ self::MagicLinkConsumed => 'Magic Link eingelöst',
+ self::NewsroomEdited => 'Newsroom bearbeitet',
+ self::AccountCreated => 'Konto angelegt',
+ self::FirstBooking => 'Erste Buchung',
+ };
+ }
+
+ /**
+ * Was ein Abbruch auf dieser Stufe bedeutet — die Diagnose, wegen der die
+ * Leiter überhaupt als Leiter geführt wird.
+ */
+ public function diagnosis(): string
+ {
+ return match ($this) {
+ self::MailLinkClicked => 'Betreff oder Anlass trägt nicht',
+ self::MagicLinkConsumed => 'Der Verwaltungszugang wird nicht verstanden',
+ self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen',
+ self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar',
+ self::FirstBooking => 'Preis oder Angebot passt nicht',
+ };
+ }
+
+ /**
+ * Die Leiter in ihrer Reihenfolge.
+ *
+ * @return list
+ */
+ public static function ladder(): array
+ {
+ $cases = self::cases();
+
+ usort($cases, static fn (self $a, self $b): int => $a->step() <=> $b->step());
+
+ return $cases;
+ }
+}
diff --git a/app/Enums/ConversionSource.php b/app/Enums/ConversionSource.php
new file mode 100644
index 00000000..03ac7faf
--- /dev/null
+++ b/app/Enums/ConversionSource.php
@@ -0,0 +1,46 @@
+ 'Veröffentlichungsmail',
+ self::MailReject => 'Ablehnungsmail',
+ self::ProfileClaim => 'Hinweis auf der Newsroom-Seite',
+ self::Organic => 'ohne zugeordnete Herkunft',
+ };
+ }
+
+ /**
+ * Auflösung eines Werts aus einer URL oder einem Token.
+ *
+ * Unbekannte Werte werden zu `organic` statt zu einem Fehler: Der Parameter
+ * steht in fremder Hand (Mailclient, Weiterleitung, manuell getippt) und
+ * darf niemals einen Request scheitern lassen.
+ */
+ public static function parse(?string $value): self
+ {
+ return self::tryFrom((string) $value) ?? self::Organic;
+ }
+}
diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php
index e65bb1bf..fb6615b7 100644
--- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php
+++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php
@@ -2,15 +2,19 @@
namespace App\Http\Controllers\Auth;
+use App\Enums\ConversionEventType;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
+use App\Services\Conversion\ConversionRecorder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MagicLinkConsumeController extends Controller
{
+ public function __construct(private readonly ConversionRecorder $recorder) {}
+
public function __invoke(Request $request, string $token): RedirectResponse
{
$magicLink = MagicLink::query()
@@ -46,6 +50,14 @@ class MagicLinkConsumeController extends Controller
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
}
+ // Stufe 2 der Konversionsleiter. Die Herkunft kommt vom Token, nicht
+ // aus der Session — die ist beim Weg über Mail und Domainwechsel
+ // längst gerissen (Roadmap 0.4).
+ $this->recorder->record(
+ ConversionEventType::MagicLinkConsumed,
+ $this->recorder->sourceOf($magicLink),
+ );
+
$user = $magicLink->user;
// 2FA aktiv: nicht direkt einloggen, sondern – wie der Passwort-Login –
diff --git a/app/Http/Middleware/RecordMailLinkClick.php b/app/Http/Middleware/RecordMailLinkClick.php
new file mode 100644
index 00000000..a673e948
--- /dev/null
+++ b/app/Http/Middleware/RecordMailLinkClick.php
@@ -0,0 +1,78 @@
+` und, wo es um eine
+ * konkrete Meldung geht, `&pr=`. Beides wird hier einmal beim Eintritt
+ * mitgeschrieben.
+ *
+ * Zur ID im Klartext: Sie ist fälschbar. Das ist hier vertretbar, weil sie
+ * nichts freischaltet — sie ordnet ein Ereignis in der internen Statistik
+ * einer Firma zu. Wer sie fälscht, verfälscht ausschließlich unsere eigene
+ * Auswertung. Sobald der Verwaltungszugang über Magic Link steht (1b.2),
+ * tragen die Mail-Links ohnehin ein Token, das Herkunft UND Meldung
+ * fälschungssicher mitführt; der `pr`-Parameter entfällt dann.
+ *
+ * Nur Herkünfte aus Mails lösen aus. `organic` würde sonst bei jedem
+ * getippten Parameter ein Ereignis erzeugen.
+ */
+class RecordMailLinkClick
+{
+ public function __construct(private readonly ConversionRecorder $recorder) {}
+
+ public function handle(Request $request, Closure $next): Response
+ {
+ $source = ConversionSource::parse($request->query('src') !== null ? (string) $request->query('src') : null);
+
+ if (! in_array($source, [ConversionSource::MailPublish, ConversionSource::MailReject], true)) {
+ return $next($request);
+ }
+
+ // Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links
+ // vorab per HEAD; das wäre kein Klick, sondern eine Zustellung.
+ if (! $request->isMethod('GET')) {
+ return $next($request);
+ }
+
+ $pressRelease = $this->pressRelease($request);
+
+ $this->recorder->record(
+ ConversionEventType::MailLinkClicked,
+ $source,
+ $this->recorder->companyOf($pressRelease),
+ $pressRelease,
+ );
+
+ return $next($request);
+ }
+
+ /**
+ * Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf
+ * einem der beiden Spokes, der Portal-Scope würde die Meldung dort
+ * wegfiltern.
+ */
+ private function pressRelease(Request $request): ?PressRelease
+ {
+ $id = (int) $request->query('pr');
+
+ if ($id <= 0) {
+ return null;
+ }
+
+ return PressRelease::query()
+ ->withoutGlobalScope(PortalScope::class)
+ ->find($id);
+ }
+}
diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php
index effee61e..d09642c8 100644
--- a/app/Listeners/ProcessStripeWebhook.php
+++ b/app/Listeners/ProcessStripeWebhook.php
@@ -23,6 +23,7 @@ use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\InvoiceNumberGenerator;
+use App\Services\Conversion\ConversionRecorder;
use App\Services\PressRelease\CorrectionService;
use App\Services\PressRelease\CorrectionTooLargeException;
use App\Services\PressRelease\PressReleaseService;
@@ -55,6 +56,7 @@ class ProcessStripeWebhook
public function __construct(
private readonly InvoiceNumberGenerator $numbers,
private readonly ActivityLog $activityLog,
+ private readonly ConversionRecorder $conversions,
) {}
public function handle(WebhookReceived $event): void
@@ -245,6 +247,12 @@ class ProcessStripeWebhook
return;
}
+ // Stufe 5 der Konversionsleiter: die erste bezahlte Rechnung eines
+ // Kontos. Deckt Abos und alle über Rechnung abgewickelten Zahlungen ab;
+ // Einmalkäufe melden zusätzlich in fulfillSinglePurchase(). Die Sperre
+ // je Firma sorgt dafür, dass trotzdem nur die erste Buchung zählt.
+ $this->conversions->recordFirstBooking($user);
+
try {
$invoice = DB::transaction(function () use ($user, $stripeInvoice, $stripeInvoiceId, $subtotal, $tax, $total): ?Invoice {
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
@@ -452,6 +460,11 @@ class ProcessStripeWebhook
if ($result['marked_paid']) {
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
+
+ // Stufe 5 der Konversionsleiter. recordFirstBooking() zählt je
+ // Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade
+ // melden, ohne sich gegenseitig zu verfälschen.
+ $this->conversions->recordFirstBooking($purchase->user);
}
if (($changedRelease = $result['changed_release']) !== null) {
diff --git a/app/Listeners/RecordAuthActivity.php b/app/Listeners/RecordAuthActivity.php
index 805c00d3..01eaeae4 100644
--- a/app/Listeners/RecordAuthActivity.php
+++ b/app/Listeners/RecordAuthActivity.php
@@ -3,8 +3,11 @@
namespace App\Listeners;
use App\Enums\ActivityType;
+use App\Enums\ConversionEventType;
+use App\Enums\ConversionSource;
use App\Models\User;
use App\Services\ActivityLog;
+use App\Services\Conversion\ConversionRecorder;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
@@ -30,7 +33,10 @@ class RecordAuthActivity
'impersonation' => 'Admin-Impersonation',
];
- public function __construct(private readonly ActivityLog $activityLog) {}
+ public function __construct(
+ private readonly ActivityLog $activityLog,
+ private readonly ConversionRecorder $recorder,
+ ) {}
public function recordRegistered(Registered $event): void
{
@@ -46,6 +52,11 @@ class RecordAuthActivity
user: $user,
meta: ['way' => 'form'],
);
+
+ // Stufe 4 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des
+ // Kontos steht noch keine fest; die Zuordnung entsteht erst, wenn ein
+ // Newsroom beansprucht wird.
+ $this->recorder->record(ConversionEventType::AccountCreated, ConversionSource::Organic);
}
public function recordLogin(Login $event): void
diff --git a/app/Mail/PressReleasePublished.php b/app/Mail/PressReleasePublished.php
index 546841e0..00e38c1d 100644
--- a/app/Mail/PressReleasePublished.php
+++ b/app/Mail/PressReleasePublished.php
@@ -2,6 +2,7 @@
namespace App\Mail;
+use App\Enums\ConversionSource;
use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease;
@@ -38,11 +39,20 @@ class PressReleasePublished extends Mailable
?string $recipientName = null,
) {
$this->recipientName = $recipientName ?? $user->name;
- $this->showUrl = $user->canAccessAdmin()
- ? route('admin.press-releases.show', $pressRelease->id)
- : route('me.press-releases.show', $pressRelease->id);
+ $this->showUrl = self::tagged(
+ $user->canAccessAdmin()
+ ? route('admin.press-releases.show', $pressRelease->id)
+ : route('me.press-releases.show', $pressRelease->id),
+ $pressRelease->id,
+ );
- $this->portalLinks = $pressRelease->publicPortalLinks();
+ $this->portalLinks = array_map(
+ fn (array $link): array => [
+ ...$link,
+ 'url' => self::tagged($link['url'], $pressRelease->id),
+ ],
+ $pressRelease->publicPortalLinks(),
+ );
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
}
@@ -58,4 +68,18 @@ class PressReleasePublished extends Mailable
{
return new Content(view: 'emails.press-release.published');
}
+
+ /**
+ * Hängt Herkunft und Meldung an einen Mail-Link (Roadmap 0.4).
+ *
+ * Ohne diesen Schritt weiß die Auswertung am Ende, dass jemand gebucht hat,
+ * aber nicht, ob er aus dieser Mail kam — und genau das ist die Frage.
+ */
+ public static function tagged(string $url, int $pressReleaseId): string
+ {
+ return $url.(str_contains($url, '?') ? '&' : '?').http_build_query([
+ 'src' => ConversionSource::MailPublish->value,
+ 'pr' => $pressReleaseId,
+ ]);
+ }
}
diff --git a/app/Mail/PressReleaseRejected.php b/app/Mail/PressReleaseRejected.php
index fc94d68c..86ca074e 100644
--- a/app/Mail/PressReleaseRejected.php
+++ b/app/Mail/PressReleaseRejected.php
@@ -2,6 +2,7 @@
namespace App\Mail;
+use App\Enums\ConversionSource;
use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease;
@@ -31,10 +32,17 @@ class PressReleaseRejected extends Mailable
?string $recipientName = null,
) {
$this->recipientName = $recipientName ?? $user->name;
- $this->editUrl = $user->canAccessAdmin()
+ $editUrl = $user->canAccessAdmin()
? route('admin.press-releases.edit', $pressRelease->id)
: route('me.press-releases.edit', $pressRelease->id);
+ // Herkunft und Meldung an den Link (Roadmap 0.4) — sonst ist später
+ // nicht unterscheidbar, ob eine Wiedereinreichung aus dieser Mail kam.
+ $this->editUrl = $editUrl.(str_contains($editUrl, '?') ? '&' : '?').http_build_query([
+ 'src' => ConversionSource::MailReject->value,
+ 'pr' => $pressRelease->id,
+ ]);
+
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
}
diff --git a/app/Models/ConversionEvent.php b/app/Models/ConversionEvent.php
new file mode 100644
index 00000000..a6d08f0f
--- /dev/null
+++ b/app/Models/ConversionEvent.php
@@ -0,0 +1,55 @@
+
+ */
+ protected function casts(): array
+ {
+ return [
+ 'type' => ConversionEventType::class,
+ 'source' => ConversionSource::class,
+ ];
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function company(): BelongsTo
+ {
+ return $this->belongsTo(Company::class);
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function pressRelease(): BelongsTo
+ {
+ return $this->belongsTo(PressRelease::class);
+ }
+}
diff --git a/app/Models/MagicLink.php b/app/Models/MagicLink.php
index 2931b6c8..609dfa0b 100644
--- a/app/Models/MagicLink.php
+++ b/app/Models/MagicLink.php
@@ -11,6 +11,7 @@ class MagicLink extends Model
'user_id',
'token_hash',
'purpose',
+ 'source',
'payload',
'expires_at',
'consumed_at',
diff --git a/app/Services/Auth/MagicLinkGenerator.php b/app/Services/Auth/MagicLinkGenerator.php
index b081bb23..60d4f8a6 100644
--- a/app/Services/Auth/MagicLinkGenerator.php
+++ b/app/Services/Auth/MagicLinkGenerator.php
@@ -2,6 +2,7 @@
namespace App\Services\Auth;
+use App\Enums\ConversionSource;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
@@ -13,8 +14,12 @@ class MagicLinkGenerator
/**
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
*/
- public function createLoginLink(User $user, ?string $requestedIp = null, int $ttlMinutes = 15): array
- {
+ public function createLoginLink(
+ User $user,
+ ?string $requestedIp = null,
+ int $ttlMinutes = 15,
+ ?ConversionSource $source = null,
+ ): array {
$plainToken = Str::random(64);
$tokenHash = hash('sha256', $plainToken);
$expiresAt = now()->addMinutes($ttlMinutes);
@@ -31,6 +36,10 @@ class MagicLinkGenerator
$magicLink = $user->magicLinks()->create([
'token_hash' => $tokenHash,
'purpose' => 'login',
+ // Herkunft gehört auf das Token, nicht in die Session: Die Strecke
+ // führt über zwei Domains und einen Mailclient, die Sitzung reißt
+ // dabei genau an der interessanten Stelle (Roadmap 0.4).
+ 'source' => $source?->value,
'expires_at' => $expiresAt,
'ip_requested' => $requestedIp,
]);
diff --git a/app/Services/Conversion/ConversionFunnelReport.php b/app/Services/Conversion/ConversionFunnelReport.php
new file mode 100644
index 00000000..fec3b72a
--- /dev/null
+++ b/app/Services/Conversion/ConversionFunnelReport.php
@@ -0,0 +1,94 @@
+,
+ * }>,
+ * }
+ */
+ public function build(Carbon $from, ?Carbon $to = null): array
+ {
+ $to ??= Carbon::now();
+
+ $rows = ConversionEvent::query()
+ ->whereBetween('created_at', [$from, $to])
+ ->get(['type', 'source', 'company_id']);
+
+ $sources = [];
+
+ foreach (ConversionSource::cases() as $source) {
+ $forSource = $rows->where('source', $source);
+
+ if ($forSource->isEmpty()) {
+ continue;
+ }
+
+ $steps = [];
+ $previous = null;
+
+ foreach (ConversionEventType::ladder() as $type) {
+ $forType = $forSource->where('type', $type);
+
+ $companies = $forType
+ ->pluck('company_id')
+ ->filter(static fn ($id): bool => $id !== null)
+ ->unique()
+ ->count();
+
+ $steps[] = [
+ 'step' => $type->step(),
+ 'type' => $type->value,
+ 'label' => $type->label(),
+ 'diagnosis' => $type->diagnosis(),
+ 'firmen' => $companies,
+ // Übergang von der VORIGEN Stufe. Die erste Stufe hat
+ // keinen Vorgänger und damit keine Quote.
+ 'uebergang' => $previous === null || $previous === 0
+ ? null
+ : round($companies / $previous * 100, 1),
+ 'ohne_firma' => $forType->whereNull('company_id')->count(),
+ ];
+
+ $previous = $companies;
+ }
+
+ $sources[] = [
+ 'source' => $source->value,
+ 'label' => $source->label(),
+ 'steps' => $steps,
+ ];
+ }
+
+ return [
+ 'from' => $from->toDateString(),
+ 'to' => $to->toDateString(),
+ 'sources' => $sources,
+ ];
+ }
+}
diff --git a/app/Services/Conversion/ConversionRecorder.php b/app/Services/Conversion/ConversionRecorder.php
new file mode 100644
index 00000000..b85ab49d
--- /dev/null
+++ b/app/Services/Conversion/ConversionRecorder.php
@@ -0,0 +1,138 @@
+create([
+ 'type' => $type,
+ 'source' => $source,
+ 'company_id' => $company?->getKey(),
+ 'press_release_id' => $pressRelease?->getKey(),
+ ]);
+ } catch (\Throwable $e) {
+ report($e);
+ }
+ }
+
+ /**
+ * Wie record(), aber höchstens einmal je Firma und Stufe.
+ *
+ * Für Stufen, deren Name eine Einmaligkeit behauptet — „Konto angelegt",
+ * „Erste Buchung". Eine zweite Buchung ist keine erste, und ein Ereignis,
+ * das bei jeder Zahlung mitschreibt, würde die Stufe unbrauchbar machen.
+ *
+ * Ohne Firmenbezug greift die Sperre nicht: Dann fehlt der Schlüssel, gegen
+ * den geprüft würde. Solche Ereignisse landen ohne Prüfung im Log und
+ * werden im Trichter ohnehin nur als Randnotiz geführt.
+ */
+ public function recordOnce(
+ ConversionEventType $type,
+ ConversionSource $source,
+ ?Company $company = null,
+ ?PressRelease $pressRelease = null,
+ ): void {
+ try {
+ if ($company !== null && $this->alreadyRecorded($type, $company)) {
+ return;
+ }
+ } catch (\Throwable $e) {
+ report($e);
+
+ return;
+ }
+
+ $this->record($type, $source, $company, $pressRelease);
+ }
+
+ /**
+ * Herkunft eines Magic-Link-Tokens.
+ *
+ * Genau hier zahlt sich die Entscheidung aus, den Wert auf dem Token statt
+ * in der Session zu führen: Der Domainwechsel zwischen Mail, Spoke und Hub
+ * hat die Sitzung längst zerrissen, das Token trägt die Herkunft trotzdem
+ * noch.
+ */
+ public function sourceOf(MagicLink $magicLink): ConversionSource
+ {
+ return ConversionSource::parse($magicLink->source);
+ }
+
+ /**
+ * Firma zu einer Meldung — scope-frei, weil die Schreibstellen auch aus
+ * Jobs und Webhooks ohne passenden Portal-Host laufen.
+ */
+ public function companyOf(?PressRelease $pressRelease): ?Company
+ {
+ if ($pressRelease?->company_id === null) {
+ return null;
+ }
+
+ return Company::query()
+ ->withoutGlobalScope(PortalScope::class)
+ ->find($pressRelease->company_id);
+ }
+
+ /**
+ * Stufe 5 der Konversionsleiter.
+ *
+ * Der Trichter läuft über die FIRMA, gezahlt wird aber vom Konto. Die
+ * Brücke ist die Firma des Kontos — bevorzugt die eigene, sonst die erste
+ * zugeordnete. Ohne Firma bleibt der Bezug leer; das Ereignis ist dann
+ * für die Trichter-Auswertung wertlos, aber immer noch eine ehrliche Zeile
+ * im Log.
+ */
+ public function recordFirstBooking(?User $user): void
+ {
+ if ($user === null) {
+ return;
+ }
+
+ try {
+ $company = $user->ownedCompanies()->withoutGlobalScope(PortalScope::class)->first()
+ ?? $user->companies()->withoutGlobalScope(PortalScope::class)->first();
+ } catch (\Throwable $e) {
+ report($e);
+
+ return;
+ }
+
+ $this->recordOnce(ConversionEventType::FirstBooking, ConversionSource::Organic, $company);
+ }
+
+ private function alreadyRecorded(ConversionEventType $type, Company $company): bool
+ {
+ return ConversionEvent::query()
+ ->where('type', $type->value)
+ ->where('company_id', $company->getKey())
+ ->exists();
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 240c54e2..8a202b07 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -2,6 +2,7 @@
use App\Http\Middleware\BasicAuthMiddleware;
use App\Http\Middleware\LogApiUsage;
+use App\Http\Middleware\RecordMailLinkClick;
use App\Http\Middleware\RedirectWwwToApex;
use App\Http\Middleware\RejectLegacyApiKeys;
use App\Http\Middleware\SecurityHeaders;
@@ -40,6 +41,12 @@ return Application::configure(basePath: dirname(__DIR__))
// Portal-Kontext nach dem Theme-Provider setzen (liest config('app.theme'))
$middleware->append(SetCurrentPortal::class);
+ // Stufe 1 der Konversionsleiter: Klick aus einer unserer Mails
+ // (`?src=`). Bewusst global statt je Route — die Mail-Links zeigen auf
+ // Hub, Spokes und öffentliche Detailseiten gleichermaßen, und eine
+ // vergessene Route wäre eine unsichtbare Lücke in der Messung.
+ $middleware->append(RecordMailLinkClick::class);
+
// Ausgabe/Sprache (de|en) aus dem URL-Präfix ableiten und URL-Defaults setzen.
$middleware->append(SetEdition::class);
diff --git a/config/conversion.php b/config/conversion.php
new file mode 100644
index 00000000..6fc7ac0b
--- /dev/null
+++ b/config/conversion.php
@@ -0,0 +1,27 @@
+ [
+ 'retention_days' => (int) env('CONVERSION_EVENTS_RETENTION_DAYS', 395),
+ ],
+
+];
diff --git a/database/migrations/2026_09_01_100000_create_conversion_events_table.php b/database/migrations/2026_09_01_100000_create_conversion_events_table.php
new file mode 100644
index 00000000..e0fa66ab
--- /dev/null
+++ b/database/migrations/2026_09_01_100000_create_conversion_events_table.php
@@ -0,0 +1,52 @@
+id();
+ $table->string('type', 32);
+ $table->string('source', 32);
+ $table->foreignId('company_id')->nullable()->constrained()->nullOnDelete();
+ $table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete();
+ $table->timestamp('created_at')->useCurrent();
+
+ // Der Trichter zählt je Stufe die eindeutigen Firmen im Zeitraum,
+ // aufgeschlüsselt nach Herkunft — genau diese drei Spalten.
+ $table->index(['type', 'source', 'created_at'], 'conversion_events_type_source_created_idx');
+ // Der Weg einer einzelnen Firma durch die Leiter.
+ $table->index(['company_id', 'type'], 'conversion_events_company_type_idx');
+ // Aufräumen läuft über das Alter.
+ $table->index('created_at', 'conversion_events_created_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('conversion_events');
+ }
+};
diff --git a/database/migrations/2026_09_01_100100_add_source_to_magic_links_table.php b/database/migrations/2026_09_01_100100_add_source_to_magic_links_table.php
new file mode 100644
index 00000000..1cee88af
--- /dev/null
+++ b/database/migrations/2026_09_01_100100_add_source_to_magic_links_table.php
@@ -0,0 +1,42 @@
+string('source', 32)->nullable()->after('purpose');
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasColumn('magic_links', 'source')) {
+ Schema::table('magic_links', function (Blueprint $table) {
+ $table->dropColumn('source');
+ });
+ }
+ }
+};
diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md
index 4786d0c2..e227951f 100644
--- a/dev/2026-09-01/umsetzung-protokoll.md
+++ b/dev/2026-09-01/umsetzung-protokoll.md
@@ -423,3 +423,127 @@ Zusammen mit Schritt 6 erledigt, siehe dort.
| Aufbewahrung des Ereignis-Logs: 395 Tage | **Meine Festlegung, deine Entscheidung.** Bestimmt die Tiefe von Roadmap 1c.1 und ist nur *vor* dem ersten Lauf frei wählbar. |
| Die 166 Baseline-Einträge | Kein Fehler, aber auch keine Sichtprüfung wert — Zusammensetzung und Begründung siehe Schritt 5. Wer sie weiter senken will, fängt bei den Closure-Generics in `app/Services/Web/` an. |
| Roadmap-Entscheidungen E1–E6 | Unberührt — das sind fachliche Fragen, keine Code-Befunde. |
+
+---
+
+# Teil 2 — Roadmap Block 0 (Messbarkeit)
+
+## 0.1 und 0.2 — Basiswerte
+
+Zwei Auswertungen als Artisan-Befehle, Ergebnisse und Ableitungen in
+**[`basiswerte-block0.md`](basiswerte-block0.md)**.
+
+```
+php artisan press-releases:view-distribution [--months=3] [--from=] [--to=] [--json]
+php artisan press-releases:rejection-rate [--months=3] [--from=] [--to=] [--json]
+```
+
+**Zwei Befunde, die den Plan verändert haben:**
+
+1. **Der Gesamtmedian taugt nicht als Basiswert.** `hits` ist ein mitlaufender
+ Zähler, und die Aufrufe wachsen nach der Veröffentlichung wochenlang weiter
+ (⌀ 75 → 128 → 179 → 231 in den ersten vier Wochen, ohne Plateau). Über ein
+ Drei-Monats-Fenster misst man damit die Altersmischung, nicht die Reichweite.
+ Der Report weist deshalb zusätzlich **Alterskohorten** aus; der Basiswert ist
+ die Kohorte „28–55 Tage", nicht die Gesamtzahl.
+2. **openPR ist keine zweite Quelle.** Es ist der Presse-API-Verteiler von
+ connektar, also derselbe Zulauf. In den Daten hat genau ein API-Token je
+ einen Zugriff gemacht. Roadmap-Text korrigiert.
+
+**E2 ist damit entschieden: Rohzahlen.** Begründung in `basiswerte-block0.md`.
+Kurzfassung: kein Bodensatz (unter 25 Aufrufen liegen 2 % bzw. 0 %),
+Verteilungen nahezu symmetrisch, und eine Einordnung gegen den
+Portaldurchschnitt wäre wegen der Wachstumskurve sogar falsch.
+
+---
+
+## 0.3 bis 0.7 — Konversionsleiter
+
+### Warum eine eigene Tabelle statt Umami
+
+Die zu messende Strecke führt über zwei Domains und einen Magic Link. Ein
+cookiefreies Analytics-Tool verliert die Sitzung genau an der Stelle, an der
+die Konversion passiert. Claim, Registrierung und Buchung sind außerdem keine
+Seitenaufrufe, sondern Zustandsänderungen in der Datenbank, bei denen die
+Identität ohnehin bekannt ist.
+
+**Rollenteilung bleibt:** Ereignistabelle für den Trichter (Block 1), Umami für
+Seitenaufrufe und Suchbegriffe (Block 2). Keine Vermischung.
+
+### 0.3 — `conversion_events`
+
+Spalten: `type`, `source`, `company_id`, `press_release_id`, `created_at`.
+Keine Personen, keine Klartextadressen, keine IP, kein User-Agent. Der rote
+Faden durch den Trichter ist die **Firma**, nicht der Mensch — für „an welcher
+Stufe bricht es ab" reicht das vollständig.
+
+Die Leiter als Enum `ConversionEventType`, mit Stufe **und** Diagnose am Fall:
+
+| # | Stufe | Ein Abbruch hier bedeutet |
+|---|---|---|
+| 1 | Mail-Link geklickt | Betreff oder Anlass trägt nicht |
+| 2 | Magic Link eingelöst | Der Verwaltungszugang wird nicht verstanden |
+| 3 | Newsroom bearbeitet | Kein Anreiz, das Profil zu pflegen |
+| 4 | Konto angelegt | Der Nutzen des Kontos ist nicht erkennbar |
+| 5 | Erste Buchung | Preis oder Angebot passt nicht |
+
+### 0.4 — Herkunft auf dem Token
+
+Neue Spalte `magic_links.source` (bewusst eine Spalte statt eines Eintrags in
+`payload`: Die Herkunft wird gruppiert und gefiltert, nicht nur gelesen).
+`MagicLinkGenerator::createLoginLink()` nimmt sie entgegen.
+
+Die Mail-Links tragen `?src=&pr=`. `ConversionSource::parse()`
+fällt bei unbekannten Werten auf `organic` zurück statt zu scheitern — der
+Parameter steht in fremder Hand.
+
+**Zur Meldungs-ID im Klartext:** Sie ist fälschbar. Vertretbar, weil sie nichts
+freischaltet, sondern ein Ereignis in der internen Statistik einer Firma
+zuordnet; wer sie fälscht, verfälscht nur die eigene Auswertung. Sobald der
+Verwaltungszugang über Magic Link steht (1b.2), tragen die Mail-Links ein
+Token, das Herkunft und Meldung fälschungssicher mitführt — der `pr`-Parameter
+entfällt dann.
+
+### 0.5 — Schreibstellen
+
+| Stufe | Wo |
+|---|---|
+| 1 | `RecordMailLinkClick` — globale Middleware. Bewusst global statt je Route: Die Mail-Links zeigen auf Hub, Spokes und öffentliche Detailseiten, eine vergessene Route wäre eine unsichtbare Lücke. Nur `mail_*`-Herkünfte lösen aus, nur GET. |
+| 2 | `MagicLinkConsumeController`, Herkunft vom Token |
+| 3 | **Enum und Auswertung stehen, Schreibstelle folgt mit 1b.3** (abgesprochen) |
+| 4 | `RecordAuthActivity::recordRegistered()` |
+| 5 | `ProcessStripeWebhook` an beiden Zahlungspfaden (`mirrorPaidInvoice`, `fulfillSinglePurchase`), dedupliziert über `recordOnce()` je Firma |
+
+Zwei Regeln für alle Schreibstellen: **Fehler werden geschluckt** (die Messung
+begleitet einen Vorgang, sie ist nicht der Vorgang) und **kein Personenbezug**.
+
+### 0.6 — Auswertung
+
+```
+php artisan conversion:funnel [--months=3] [--from=] [--to=] [--json]
+```
+
+Zählt je Stufe die **eindeutigen Firmen**, nicht die Ereignisse: Ein doppelter
+Klick ist kein doppelter Interessent, und nur mit derselben Einheit auf allen
+Stufen ist ein Übergang überhaupt eine Quote. Ereignisse ohne Firmenbezug
+stehen in einer eigenen Spalte daneben statt im Nenner.
+
+### 0.7 — Aufbewahrung
+
+**395 Tage**, derselbe Horizont wie beim Aufruf-Ereignis-Log (Entscheidung
+01.09.2026). `config/conversion.php`, Befehl `conversion:purge-events`, täglich
+03:25 im Scheduler.
+
+> **Offen und bewusst nicht von mir geschrieben:** Der Absatz in der
+> Datenschutzerklärung. Rechtstext gehört nicht aus einer Codeänderung heraus
+> formuliert. Inhaltlich zu führen ist: Zweck (interne Reichweiten- und
+> Konversionsstatistik), Umfang (Ereignistyp, Herkunft, Firmen- und
+> Meldungsbezug — kein Personenbezug, keine IP), Aufbewahrung (395 Tage),
+> Rechtsgrundlage.
+
+### Angepasster Bestandstest
+
+`EmailBrandingTest` verglich die Mail-URL roh gegen das gerenderte HTML. Die
+Links tragen jetzt zwei Parameter, Blade escapt das trennende `&` korrekt zu
+`&` — der Vergleich läuft nun über `e()`. Verhalten unverändert richtig,
+nur die Erwartung war zu eng.
diff --git a/routes/console.php b/routes/console.php
index 853552da..2cb024e2 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -3,6 +3,7 @@
use App\Console\Commands\AggregatePressReleaseViews;
use App\Console\Commands\GenerateManualInvoices;
use App\Console\Commands\PublishScheduledPressReleases;
+use App\Console\Commands\PurgeConversionEvents;
use App\Console\Commands\PurgeExpiredPressReleaseDrafts;
use App\Console\Commands\PurgeMagicLinks;
use App\Console\Commands\PurgePressReleaseEvents;
@@ -92,6 +93,14 @@ Schedule::command(PurgePressReleaseEvents::class)
->runInBackground()
->onFailure($alertOnFailure('press-releases:purge-events'));
+// Konversions-Ereignisse auf den Aufbewahrungszeitraum begrenzen (täglich 03:25).
+// Frist zusammen mit der Tabelle festgelegt (395 Tage), siehe config/conversion.php.
+Schedule::command(PurgeConversionEvents::class)
+ ->dailyAt('03:25')
+ ->withoutOverlapping()
+ ->runInBackground()
+ ->onFailure($alertOnFailure('conversion:purge-events'));
+
// ========================================
// Manueller Rechnungskreis (MAN-) — Legacy-Zahlungen
// ========================================
diff --git a/tests/Feature/ConversionFunnelTest.php b/tests/Feature/ConversionFunnelTest.php
new file mode 100644
index 00000000..39a25a5e
--- /dev/null
+++ b/tests/Feature/ConversionFunnelTest.php
@@ -0,0 +1,260 @@
+create([
+ 'type' => $type,
+ 'source' => $source,
+ 'company_id' => $company?->getKey(),
+ ]);
+}
+
+// ------------------------------------------------- 0.4 Herkunft am Token
+
+test('a mail link carries its origin and the press release', function () {
+ /** @var TestCase $this */
+ $release = PressRelease::factory()->published()->create();
+ $user = $release->user;
+
+ $mail = new PressReleasePublished($user, $release);
+
+ expect($mail->showUrl)->toContain('src='.ConversionSource::MailPublish->value)
+ ->and($mail->showUrl)->toContain('pr='.$release->id);
+});
+
+test('the rejection mail marks its own origin', function () {
+ /** @var TestCase $this */
+ $release = PressRelease::factory()->create();
+
+ $mail = new PressReleaseRejected($release->user, $release, 'Grund');
+
+ // Ohne eigenen Herkunftswert wäre später nicht unterscheidbar, ob eine
+ // Wiedereinreichung aus der Ablehnungsmail kam.
+ expect($mail->editUrl)->toContain('src='.ConversionSource::MailReject->value);
+});
+
+test('the origin is stored on the magic link token, not in a session', function () {
+ /** @var TestCase $this */
+ $user = User::factory()->create();
+
+ $link = app(MagicLinkGenerator::class)->createLoginLink($user, null, 15, ConversionSource::MailPublish);
+
+ // Genau das ist der Punkt: Die Strecke führt über zwei Domains und einen
+ // Mailclient. Eine Session reißt dabei, das Token nicht.
+ expect($link['magic_link']->source)->toBe(ConversionSource::MailPublish->value)
+ ->and(app(ConversionRecorder::class)->sourceOf($link['magic_link']))
+ ->toBe(ConversionSource::MailPublish);
+});
+
+test('an unknown origin falls back to organic instead of failing', function () {
+ /** @var TestCase $this */
+ // Der Parameter steht in fremder Hand — Mailclient, Weiterleitung, manuell
+ // getippt. Er darf niemals einen Request scheitern lassen.
+ expect(ConversionSource::parse('kaputt'))->toBe(ConversionSource::Organic)
+ ->and(ConversionSource::parse(null))->toBe(ConversionSource::Organic);
+});
+
+// --------------------------------------------------- 0.5 Schreibstellen
+
+test('a mail link click is recorded with its company', function () {
+ /** @var TestCase $this */
+ $company = Company::factory()->create(['portal' => Portal::Businessportal24]);
+ $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
+ ->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
+
+ $this->get('https://businessportal24.test/de/press-release/'.$release->slug
+ .'?src='.ConversionSource::MailPublish->value.'&pr='.$release->id)->assertOk();
+
+ $event = ConversionEvent::query()->sole();
+
+ expect($event->type)->toBe(ConversionEventType::MailLinkClicked)
+ ->and($event->source)->toBe(ConversionSource::MailPublish)
+ ->and($event->company_id)->toBe($company->id)
+ ->and($event->press_release_id)->toBe($release->id);
+});
+
+test('a page view without an origin parameter records nothing', function () {
+ /** @var TestCase $this */
+ $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
+ ->create(['published_at' => now()->subDay()]);
+
+ $this->get('https://businessportal24.test/de/press-release/'.$release->slug)->assertOk();
+
+ expect(ConversionEvent::query()->count())->toBe(0);
+});
+
+test('an organic origin parameter does not fake a mail click', function () {
+ /** @var TestCase $this */
+ $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
+ ->create(['published_at' => now()->subDay()]);
+
+ $this->get('https://businessportal24.test/de/press-release/'.$release->slug.'?src=organic')->assertOk();
+
+ // Sonst erzeugte jeder getippte Parameter ein Ereignis.
+ expect(ConversionEvent::query()->count())->toBe(0);
+});
+
+test('consuming a magic link records step two with the token origin', function () {
+ /** @var TestCase $this */
+ Mail::fake();
+ $user = User::factory()->create(['is_active' => true]);
+ $link = app(MagicLinkGenerator::class)->createLoginLink($user, null, 15, ConversionSource::MailReject);
+
+ $this->get(route('magic-links.consume', ['token' => $link['plain_token']]));
+
+ $event = ConversionEvent::query()
+ ->where('type', ConversionEventType::MagicLinkConsumed->value)
+ ->sole();
+
+ expect($event->source)->toBe(ConversionSource::MailReject);
+});
+
+test('registering records step four', function () {
+ /** @var TestCase $this */
+ event(new Registered(User::factory()->create()));
+
+ expect(ConversionEvent::query()
+ ->where('type', ConversionEventType::AccountCreated->value)
+ ->count())->toBe(1);
+});
+
+test('the first booking is recorded once per company', function () {
+ /** @var TestCase $this */
+ $user = User::factory()->create();
+ $company = Company::factory()->create(['owner_user_id' => $user->id]);
+ $recorder = app(ConversionRecorder::class);
+
+ $recorder->recordFirstBooking($user);
+ $recorder->recordFirstBooking($user);
+
+ // Eine zweite Buchung ist keine erste. Beide Zahlungspfade dürfen melden,
+ // ohne die Stufe zu verfälschen.
+ expect(ConversionEvent::query()
+ ->where('type', ConversionEventType::FirstBooking->value)
+ ->where('company_id', $company->id)
+ ->count())->toBe(1);
+});
+
+test('a broken recording never breaks the request it accompanies', function () {
+ /** @var TestCase $this */
+ Schema::drop('conversion_events');
+
+ // Die Messung begleitet einen Vorgang, sie ist nicht der Vorgang.
+ app(ConversionRecorder::class)->record(ConversionEventType::AccountCreated, ConversionSource::Organic);
+})->throwsNoExceptions();
+
+// ------------------------------------------------------ 0.6 Auswertung
+
+test('the funnel counts distinct companies per step', function () {
+ /** @var TestCase $this */
+ $a = Company::factory()->create();
+ $b = Company::factory()->create();
+
+ funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a);
+ funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a);
+ funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $b);
+ funnelEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a);
+
+ $data = app(ConversionFunnelReport::class)->build(now()->subMonth());
+ $steps = collect($data['sources'])->firstWhere('source', ConversionSource::MailPublish->value)['steps'];
+
+ // Ein doppelter Klick ist kein doppelter Interessent.
+ expect($steps[0]['firmen'])->toBe(2)
+ ->and($steps[1]['firmen'])->toBe(1)
+ ->and($steps[1]['uebergang'])->toBe(50.0)
+ ->and($steps[0]['uebergang'])->toBeNull();
+});
+
+test('the funnel keeps events without a company out of the quota', function () {
+ /** @var TestCase $this */
+ $company = Company::factory()->create();
+
+ funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $company);
+ funnelEvent(ConversionEventType::AccountCreated, ConversionSource::MailPublish, null);
+
+ $data = app(ConversionFunnelReport::class)->build(now()->subMonth());
+ $steps = collect(collect($data['sources'])->firstWhere('source', ConversionSource::MailPublish->value)['steps'])
+ ->keyBy('type');
+
+ // „Konto angelegt" hat naturgemäß keine Firma — das gehört ausgewiesen,
+ // nicht in einen Nenner, dessen Einheit Firmen sind.
+ expect($steps[ConversionEventType::AccountCreated->value]['firmen'])->toBe(0)
+ ->and($steps[ConversionEventType::AccountCreated->value]['ohne_firma'])->toBe(1);
+});
+
+test('the funnel separates the origins', function () {
+ /** @var TestCase $this */
+ funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, Company::factory()->create());
+ funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailReject, Company::factory()->create());
+
+ $data = app(ConversionFunnelReport::class)->build(now()->subMonth());
+
+ // Ohne diese Trennung wüsste man, dass jemand gebucht hat, aber nicht,
+ // ob er aus einer Ablehnungsmail kam.
+ expect(collect($data['sources'])->pluck('source')->all())
+ ->toContain(ConversionSource::MailPublish->value, ConversionSource::MailReject->value);
+});
+
+test('the funnel command says plainly when nothing was recorded', function () {
+ /** @var TestCase $this */
+ $this->artisan('conversion:funnel')
+ ->expectsOutputToContain('Keine Ereignisse im Zeitraum.')
+ ->assertSuccessful();
+});
+
+// ------------------------------------------------------ 0.7 Aufbewahrung
+
+test('the purge drops events past the retention window', function () {
+ /** @var TestCase $this */
+ $old = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish);
+ $old->forceFill(['created_at' => now()->subDays(400)])->save();
+ $fresh = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish);
+
+ $this->artisan('conversion:purge-events')->assertSuccessful();
+
+ expect(ConversionEvent::query()->find($old->id))->toBeNull()
+ ->and(ConversionEvent::query()->find($fresh->id))->not->toBeNull();
+});
+
+test('the purge dry run counts without deleting', function () {
+ /** @var TestCase $this */
+ $old = funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish);
+ $old->forceFill(['created_at' => now()->subDays(400)])->save();
+
+ $this->artisan('conversion:purge-events', ['--dry-run' => true])
+ ->expectsOutputToContain('[Dry-Run] 1 Ereignisse')
+ ->assertSuccessful();
+
+ expect(ConversionEvent::query()->find($old->id))->not->toBeNull();
+});
+
+test('the ladder is complete and ordered', function () {
+ /** @var TestCase $this */
+ $steps = array_map(fn (ConversionEventType $t): int => $t->step(), ConversionEventType::ladder());
+
+ // Eine Leiter ohne Sprossen sagt nicht, wo es hakt.
+ expect($steps)->toBe([1, 2, 3, 4, 5])
+ ->and(ConversionEventType::NewsroomEdited->diagnosis())->toBe('Kein Anreiz, das Profil zu pflegen');
+});
diff --git a/tests/Feature/Mail/EmailBrandingTest.php b/tests/Feature/Mail/EmailBrandingTest.php
index a18b2c83..0dea94c1 100644
--- a/tests/Feature/Mail/EmailBrandingTest.php
+++ b/tests/Feature/Mail/EmailBrandingTest.php
@@ -110,7 +110,10 @@ test('the published mail links the account area below the portal links with a lo
$publicUrl = config('domains.domain_presseecho_url').'/de/press-release/'.$pressRelease->slug;
expect($html)
- ->toContain($mailable->showUrl)
+ // Escapt verglichen: Die Mail-Links tragen seit der Konversionsmessung
+ // zwei Parameter (`src`, `pr`), und Blade escapt das trennende `&` im
+ // HTML korrekt zu `&`.
+ ->toContain(e($mailable->showUrl))
->toContain('Pressemitteilung im pressekonto öffnen')
->toContain('Login-Bereich')
->toContain('mit Ihrem Passwort oder per Magic-Link');