conversion: Trichter misst Verwaltungsseite, Anforderung und Firma bei der Einlösung

Roadmap 6.16. Bisher stand jede Einlösung ohne Firma im Trichter (Stufe
"Magic Link eingelöst" immer 0 Firmen), zwischen Mail-Klick und Einlösung
fehlten "Verwaltungsseite geöffnet" und "Anmeldelink angefordert", und
die Erstbuchung stand immer auf organic. Die Leiter hat jetzt sieben Stufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-13 14:55:29 +00:00
parent f5e3f1d1fe
commit e8cf684f02
10 changed files with 211 additions and 52 deletions

View file

@ -12,11 +12,21 @@ namespace App\Enums;
* Die Wiedereinreichung nach Ablehnung gehört ausdrücklich NICHT hierher. Sie
* misst etwas anderes, nämlich ob die Infoseite (1d) wirkt, und wird gegen den
* Basiswert aus 0.2 separat geführt (`press-releases:rejection-rate`).
*
* Stufen 2 und 3 seit 13.10.2026 (Roadmap 6.16): Vorher sprang die Leiter vom
* Klick auf IRGENDEINEN Mail-Link direkt zur Einlösung. Ob jemand die
* Verwaltungsseite überhaupt geöffnet und den Link angefordert hatte, war
* nicht zu sehen — und damit nicht, ob es am Hinweis, an der Seite oder an
* der Zustellung hakt.
*/
enum ConversionEventType: string
{
case MailLinkClicked = 'mail_link_clicked';
case ManagePageOpened = 'manage_page_opened';
case AccessRequested = 'access_requested';
case MagicLinkConsumed = 'magic_link_consumed';
case NewsroomEdited = 'newsroom_edited';
@ -32,10 +42,12 @@ enum ConversionEventType: string
{
return match ($this) {
self::MailLinkClicked => 1,
self::MagicLinkConsumed => 2,
self::NewsroomEdited => 3,
self::AccountCreated => 4,
self::FirstBooking => 5,
self::ManagePageOpened => 2,
self::AccessRequested => 3,
self::MagicLinkConsumed => 4,
self::NewsroomEdited => 5,
self::AccountCreated => 6,
self::FirstBooking => 7,
};
}
@ -43,6 +55,8 @@ enum ConversionEventType: string
{
return match ($this) {
self::MailLinkClicked => 'Mail-Link geklickt',
self::ManagePageOpened => 'Verwaltungsseite geöffnet',
self::AccessRequested => 'Anmeldelink angefordert',
self::MagicLinkConsumed => 'Magic Link eingelöst',
self::NewsroomEdited => 'Newsroom bearbeitet',
self::AccountCreated => 'Konto angelegt',
@ -58,7 +72,9 @@ enum ConversionEventType: string
{
return match ($this) {
self::MailLinkClicked => 'Betreff oder Anlass trägt nicht',
self::MagicLinkConsumed => 'Der Verwaltungszugang wird nicht verstanden',
self::ManagePageOpened => 'Der Hinweis auf die Verwaltung wird übersehen',
self::AccessRequested => 'Der Verwaltungszugang wird nicht verstanden',
self::MagicLinkConsumed => 'Der Link kommt nicht an oder wird nicht geöffnet',
self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen',
self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar',
self::FirstBooking => 'Preis oder Angebot passt nicht',

View file

@ -2,7 +2,6 @@
namespace App\Http\Controllers\Auth;
use App\Enums\ConversionEventType;
use App\Http\Controllers\Controller;
use App\Models\MagicLink;
use App\Models\PressRelease;
@ -75,13 +74,10 @@ 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
// Stufe 4 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),
);
$this->recorder->recordMagicLinkConsumed($magicLink);
$user = $magicLink->user;
@ -123,10 +119,7 @@ class MagicLinkConsumeController extends Controller
]);
if ($claimed > 0) {
$this->recorder->record(
ConversionEventType::MagicLinkConsumed,
$this->recorder->sourceOf($magicLink),
);
$this->recorder->recordMagicLinkConsumed($magicLink);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Web;
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Http\Controllers\Controller;
use App\Models\Company;
@ -9,6 +10,7 @@ use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Company\MergedNewsroomLookup;
use App\Services\Company\NewsroomClaimService;
use App\Services\Conversion\ConversionRecorder;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@ -29,9 +31,10 @@ class NewsroomClaimController extends Controller
public function __construct(
private readonly NewsroomClaimService $claims,
private readonly MergedNewsroomLookup $mergedNewsrooms,
private readonly ConversionRecorder $recorder,
) {}
public function show(string $edition, string $slug): View|Response|RedirectResponse
public function show(Request $request, string $edition, string $slug): View|Response|RedirectResponse
{
$context = WebPortalContext::fromRequest();
$company = $this->claims->findClaimable($context->portalValues, $slug);
@ -40,6 +43,8 @@ class NewsroomClaimController extends Controller
return $this->resolveMissing($context, $edition, $slug);
}
$this->recorder->recordManagePageOpened($request, $company, $this->sourceOf($request), $this->landingPressRelease($request, $company));
return view('web.newsroom-claim', [
'company' => $company,
'recipients' => $this->claims->maskedRecipients($company),
@ -70,7 +75,8 @@ class NewsroomClaimController extends Controller
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar.
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company));
$landing = $this->landingPressRelease($request, $company);
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $landing?->getKey());
// Der Firmenzähler zählt nur tatsächlich versendete Links — sonst
// sperrten Dritte den Inhaber mit Fehlversuchen 24 h aus
@ -78,6 +84,10 @@ class NewsroomClaimController extends Controller
// Drossel oben.
if ($sent > 0) {
RateLimiter::hit($this->companyKey($company->getKey()), 86400);
// Stufe 3 der Konversionsleiter (Roadmap 6.16): nur, wenn
// wirklich ein Link verschickt wurde.
$this->recorder->record(ConversionEventType::AccessRequested, $this->sourceOf($request), $company, $landing);
}
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
@ -88,7 +98,7 @@ class NewsroomClaimController extends Controller
* Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen
* (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört.
*/
private function landingPressReleaseId(Request $request, Company $company): ?int
private function landingPressRelease(Request $request, Company $company): ?PressRelease
{
$id = (int) $request->input('pr', 0);
@ -96,7 +106,7 @@ class NewsroomClaimController extends Controller
return null;
}
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->first();
}
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse

View file

@ -7,7 +7,6 @@ use App\Enums\ConversionSource;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Conversion\ConversionRecorder;
use App\Support\CrawlerIdentifier;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@ -49,7 +48,7 @@ class RecordMailLinkClick
// Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links
// vorab per HEAD oder mit eigener Kennung; das wäre kein Klick,
// sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08).
if (! $request->isMethod('GET') || $this->isScanner($request->userAgent())) {
if (! $request->isMethod('GET') || $this->recorder->isAutomatedVisit($request->userAgent())) {
return $next($request);
}
@ -81,23 +80,6 @@ class RecordMailLinkClick
return $next($request);
}
private function isScanner(?string $userAgent): bool
{
$userAgent = mb_strtolower(trim((string) $userAgent));
if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) {
return true;
}
foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) {
if (str_contains($userAgent, mb_strtolower((string) $pattern))) {
return true;
}
}
return false;
}
/**
* 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

View file

@ -248,7 +248,7 @@ class ProcessStripeWebhook
return;
}
// Stufe 5 der Konversionsleiter: die erste bezahlte Rechnung eines
// Stufe 7 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.
@ -469,7 +469,7 @@ class ProcessStripeWebhook
if ($result['marked_paid']) {
Log::channel('billing')->info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
// Stufe 5 der Konversionsleiter. recordFirstBooking() zählt je
// Stufe 7 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);

View file

@ -69,7 +69,7 @@ class RecordAuthActivity
meta: ['way' => 'form'],
);
// Stufe 4 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des
// Stufe 6 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);

View file

@ -10,6 +10,9 @@ use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Support\CrawlerIdentifier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
/**
* Schreibt die Ereignisse der Konversionsleiter (Roadmap 0.5).
@ -73,6 +76,83 @@ class ConversionRecorder
$this->record($type, $source, $company, $pressRelease);
}
/**
* Stufe 2: die Seite „Diesen Newsroom verwalten“ wurde geöffnet.
*
* Aus einer Mail (`src`) oder über den Hinweis auf der Newsroom- bzw.
* Meldungsseite. Wie beim Mail-Klick zählen nur echte Aufrufe, und
* dieselbe Adresse zählt je Firma höchstens einmal im Zeitfenster.
*/
public function recordManagePageOpened(Request $request, Company $company, ConversionSource $source, ?PressRelease $pressRelease = null): void
{
try {
if (! $request->isMethod('GET') || $this->isAutomatedVisit($request->userAgent())) {
return;
}
$key = 'manage-page:'.hash('sha256', (string) $request->ip()).':'.$company->getKey();
$decay = max(1, (int) config('conversion.mail_clicks.throttle_minutes', 30)) * 60;
if (! RateLimiter::attempt($key, 1, static fn (): bool => true, $decay)) {
return;
}
} catch (\Throwable $e) {
report($e);
return;
}
$this->record(ConversionEventType::ManagePageOpened, $source, $company, $pressRelease);
}
/**
* Stufe 4: Magic Link eingelöst — mit der Firma und Meldung, die das
* Token trägt („Diesen Newsroom verwalten“, Veröffentlichungsmail).
* Ohne diesen Bezug landete jede Einlösung in „ohne Firma“, und die
* Stufe zeigte bis 13.10.2026 immer null Firmen (Roadmap 6.16).
*/
public function recordMagicLinkConsumed(MagicLink $magicLink): void
{
try {
$companyId = (int) ($magicLink->payload['company_id'] ?? 0);
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
$company = $companyId > 0
? Company::query()->withoutGlobalScope(PortalScope::class)->find($companyId)
: null;
$pressRelease = $pressReleaseId > 0
? PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressReleaseId)
: null;
} catch (\Throwable $e) {
report($e);
$company = null;
$pressRelease = null;
}
$this->record(ConversionEventType::MagicLinkConsumed, $this->sourceOf($magicLink), $company, $pressRelease);
}
/**
* Mailserver-Scanner, Vorschau-Dienste und Crawler — kein Mensch, also
* kein Schritt in der Leiter (Umsetzungsaudit 29.09.2026, R-08).
*/
public function isAutomatedVisit(?string $userAgent): bool
{
$userAgent = mb_strtolower(trim((string) $userAgent));
if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) {
return true;
}
foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) {
if (str_contains($userAgent, mb_strtolower((string) $pattern))) {
return true;
}
}
return false;
}
/**
* Herkunft eines Magic-Link-Tokens.
*
@ -102,13 +182,17 @@ class ConversionRecorder
}
/**
* Stufe 5 der Konversionsleiter.
* Stufe 7 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.
*
* Die Herkunft ist wie bei Stufe 5 die des letzten eingelösten Magic
* Links; bis 13.10.2026 stand hier immer `organic`, auch nach einem
* Claim aus der Veröffentlichungsmail (Roadmap 6.16).
*/
public function recordFirstBooking(?User $user): void
{
@ -125,11 +209,11 @@ class ConversionRecorder
return;
}
$this->recordOnce(ConversionEventType::FirstBooking, ConversionSource::Organic, $company);
$this->recordOnce(ConversionEventType::FirstBooking, $this->lastMagicLinkSource($user), $company);
}
/**
* Stufe 3 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
* Stufe 5 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
*
* Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic
* Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail
@ -138,8 +222,17 @@ class ConversionRecorder
*/
public function recordNewsroomEdited(Company $company, ?User $user): void
{
$this->recordOnce(ConversionEventType::NewsroomEdited, $this->lastMagicLinkSource($user), $company);
}
private function lastMagicLinkSource(?User $user): ConversionSource
{
if ($user === null) {
return ConversionSource::Organic;
}
try {
$source = $user === null ? null : MagicLink::query()
$source = MagicLink::query()
->where('user_id', $user->getKey())
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->whereNotNull('consumed_at')
@ -150,7 +243,7 @@ class ConversionRecorder
$source = null;
}
$this->recordOnce(ConversionEventType::NewsroomEdited, ConversionSource::parse($source), $company);
return ConversionSource::parse($source);
}
private function alreadyRecorded(ConversionEventType $type, Company $company): bool

View file

@ -192,7 +192,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
$this->resetCompanyForm();
// Stufe 3 der Konversionsleiter: Newsroom gepflegt (einmal je Firma).
// Stufe 5 der Konversionsleiter: Newsroom gepflegt (einmal je Firma).
$conversions->recordNewsroomEdited($company, auth()->user());
// Der öffentliche Newsroom zeigt Name, Logo und Beschreibung — die

View file

@ -140,7 +140,7 @@ test('consuming a magic link records step two with the token origin', function (
expect($event->source)->toBe(ConversionSource::MailReject);
});
test('registering records step four', function () {
test('registering records the account step', function () {
/** @var TestCase $this */
event(new Registered(User::factory()->create()));
@ -166,6 +166,21 @@ test('the first booking is recorded once per company', function () {
->count())->toBe(1);
});
test('the first booking carries the origin of the last redeemed link', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$company = Company::factory()->create(['owner_user_id' => $user->id]);
$link = app(MagicLinkGenerator::class)->createLoginLink($user, null, 15, ConversionSource::MailPublish);
$link['magic_link']->forceFill(['consumed_at' => now()])->save();
app(ConversionRecorder::class)->recordFirstBooking($user);
// Bis 13.10.2026 stand hier immer `organic` — auch wer über die
// Veröffentlichungsmail kam und dann buchte (Roadmap 6.16).
expect(ConversionEvent::query()->where('type', ConversionEventType::FirstBooking->value)->sole()->source)
->toBe(ConversionSource::MailPublish);
});
test('a broken recording never breaks the request it accompanies', function () {
/** @var TestCase $this */
Schema::drop('conversion_events');
@ -184,7 +199,7 @@ test('the funnel counts distinct companies per step', function () {
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a);
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $a);
funnelEvent(ConversionEventType::MailLinkClicked, ConversionSource::MailPublish, $b);
funnelEvent(ConversionEventType::MagicLinkConsumed, ConversionSource::MailPublish, $a);
funnelEvent(ConversionEventType::ManagePageOpened, ConversionSource::MailPublish, $a);
$data = app(ConversionFunnelReport::class)->build(now()->subMonth());
$steps = collect($data['sources'])->firstWhere('source', ConversionSource::MailPublish->value)['steps'];
@ -264,7 +279,7 @@ test('the ladder is complete and ordered', function () {
$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])
expect($steps)->toBe([1, 2, 3, 4, 5, 6, 7])
->and(ConversionEventType::NewsroomEdited->diagnosis())->toBe('Kein Anreiz, das Profil zu pflegen');
});

View file

@ -81,8 +81,58 @@ test('requesting the link creates the account, sends the login link with profile
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'))
->assertRedirect(route('me.press-kits.show', ['id' => $company->id], absolute: false));
expect(ConversionEvent::query()->where('type', ConversionEventType::MagicLinkConsumed->value)->first()->source)
->toBe(ConversionSource::ProfileClaim);
$consumed = ConversionEvent::query()->where('type', ConversionEventType::MagicLinkConsumed->value)->sole();
expect($consumed->source)->toBe(ConversionSource::ProfileClaim)
->and($consumed->company_id)->toBe($company->id);
});
test('the way from the publication mail is recorded step by step with the company', function () {
/** @var TestCase $this */
$company = claimCompany();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)
->create(['company_id' => $company->id, 'published_at' => now()->subDay()]);
$query = '?src='.ConversionSource::MailPublish->value.'&pr='.$release->id;
// Roadmap 6.16: Vorher sprang die Leiter vom Klick auf irgendeinen
// Mail-Link direkt zur Einlösung, und die Einlösung hatte keine Firma.
$this->get(claimUrl().$query)->assertOk();
$this->post(claimUrl(), ['src' => ConversionSource::MailPublish->value, 'pr' => $release->id])
->assertSessionHas('newsroom-claim-status');
$plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH));
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'));
$events = ConversionEvent::query()->get()->keyBy(fn (ConversionEvent $event): string => $event->type->value);
foreach ([ConversionEventType::ManagePageOpened, ConversionEventType::AccessRequested, ConversionEventType::MagicLinkConsumed] as $type) {
expect($events->get($type->value))->not->toBeNull()
->and($events[$type->value]->source)->toBe(ConversionSource::MailPublish)
->and($events[$type->value]->company_id)->toBe($company->id)
->and($events[$type->value]->press_release_id)->toBe($release->id);
}
});
test('opening the page from the newsroom counts as profile claim, once per visitor and never for scanners', function () {
/** @var TestCase $this */
$company = claimCompany();
$browser = ['User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Safari/605.1.15'];
$this->withHeaders(['User-Agent' => 'Mozilla/5.0 (compatible; Barracuda Sentinel)'])->get(claimUrl())->assertOk();
$this->withHeaders($browser)->get(claimUrl())->assertOk();
$this->withHeaders($browser)->get(claimUrl())->assertOk();
$opened = ConversionEvent::query()->where('type', ConversionEventType::ManagePageOpened->value)->sole();
expect($opened->source)->toBe(ConversionSource::ProfileClaim)
->and($opened->company_id)->toBe($company->id);
});
test('a request that sends nothing is not step three', function () {
/** @var TestCase $this */
claimCompany(['email' => null]);
$this->post(claimUrl())->assertSessionHas('newsroom-claim-none');
expect(ConversionEvent::query()->where('type', ConversionEventType::AccessRequested->value)->exists())->toBeFalse();
});
test('without a company email the link goes to the press contacts', function () {