From 0497418d5c66d20693751e68be066260eda3f37f Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Sat, 5 Sep 2026 13:01:34 +0100 Subject: [PATCH] Rework UTM attribution tracking and admin attribution report (#1338) --- .../Enums/AttributionGroupBy.php | 16 ++ .../Enums/AttributionSourceType.php | 12 ++ backend/app/Exports/AnswersExport.php | 3 +- .../GetUtmAttributionStatsAction.php | 14 +- .../Admin/GetUtmAttributionStatsRequest.php | 23 ++ .../Eloquent/AccountAttributionRepository.php | 159 ++++++++++---- .../AccountAttributionRepositoryInterface.php | 3 +- .../Handlers/Account/CreateAccountHandler.php | 57 +---- .../Admin/GetUtmAttributionStatsHandler.php | 20 +- .../Account/AttributionSourceClassifier.php | 102 +++++++++ ...ssify_account_attribution_source_types.php | 38 ++++ .../AccountAttributionRepositoryTest.php | 147 +++++++++++++ .../tests/Unit/Exports/AnswersExportTest.php | 24 +++ .../GetUtmAttributionStatsHandlerTest.php | 54 +++++ .../AttributionSourceClassifierTest.php | 127 +++++++++++ frontend/src/api/admin.client.ts | 6 +- .../components/layouts/AuthLayout/index.tsx | 7 +- .../routes/admin/Attribution/index.tsx | 200 ++++++++++++------ .../components/routes/auth/Register/index.tsx | 4 +- frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 132 +++++++----- frontend/src/locales/el.js | 2 +- frontend/src/locales/el.po | 132 +++++++----- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 132 +++++++----- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 132 +++++++----- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 132 +++++++----- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 132 +++++++----- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 132 +++++++----- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 132 +++++++----- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 132 +++++++----- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 132 +++++++----- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 132 +++++++----- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 132 +++++++----- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 132 +++++++----- frontend/src/locales/sk.js | 2 +- frontend/src/locales/sk.po | 132 +++++++----- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 132 +++++++----- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 132 +++++++----- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 132 +++++++----- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 132 +++++++----- frontend/src/utilites/utm.ts | 42 ++-- 56 files changed, 2232 insertions(+), 1238 deletions(-) create mode 100644 backend/app/DomainObjects/Enums/AttributionGroupBy.php create mode 100644 backend/app/DomainObjects/Enums/AttributionSourceType.php create mode 100644 backend/app/Http/Request/Admin/GetUtmAttributionStatsRequest.php create mode 100644 backend/app/Services/Domain/Account/AttributionSourceClassifier.php create mode 100644 backend/database/migrations/2026_09_05_000001_reclassify_account_attribution_source_types.php create mode 100644 backend/tests/Feature/Repository/Eloquent/AccountAttributionRepositoryTest.php create mode 100644 backend/tests/Unit/Exports/AnswersExportTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandlerTest.php create mode 100644 backend/tests/Unit/Services/Domain/Account/AttributionSourceClassifierTest.php diff --git a/backend/app/DomainObjects/Enums/AttributionGroupBy.php b/backend/app/DomainObjects/Enums/AttributionGroupBy.php new file mode 100644 index 0000000000..09049298f8 --- /dev/null +++ b/backend/app/DomainObjects/Enums/AttributionGroupBy.php @@ -0,0 +1,16 @@ +minimumAllowedRole(Role::SUPERADMIN); $dto = GetUtmAttributionStatsDTO::from([ - 'group_by' => $request->query('group_by', 'source'), - 'date_from' => $request->query('date_from'), - 'date_to' => $request->query('date_to'), - 'per_page' => (int) $request->query('per_page', 20), - 'page' => (int) $request->query('page', 1), + 'group_by' => $request->validated('group_by') ?? 'source', + 'date_from' => $request->validated('date_from'), + 'date_to' => $request->validated('date_to'), + 'per_page' => (int) ($request->validated('per_page') ?? 20), + 'page' => (int) ($request->validated('page') ?? 1), ]); $result = $this->handler->handle($dto); diff --git a/backend/app/Http/Request/Admin/GetUtmAttributionStatsRequest.php b/backend/app/Http/Request/Admin/GetUtmAttributionStatsRequest.php new file mode 100644 index 0000000000..74a0ea32d3 --- /dev/null +++ b/backend/app/Http/Request/Admin/GetUtmAttributionStatsRequest.php @@ -0,0 +1,23 @@ + ['nullable', 'string', Rule::in(AttributionGroupBy::valuesArray())], + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date', 'after_or_equal:date_from'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/backend/app/Repository/Eloquent/AccountAttributionRepository.php b/backend/app/Repository/Eloquent/AccountAttributionRepository.php index 8855867eb4..4c86a8ca52 100644 --- a/backend/app/Repository/Eloquent/AccountAttributionRepository.php +++ b/backend/app/Repository/Eloquent/AccountAttributionRepository.php @@ -5,9 +5,12 @@ namespace HiEvents\Repository\Eloquent; use HiEvents\DomainObjects\AccountAttributionDomainObject; +use HiEvents\DomainObjects\Enums\AttributionGroupBy; +use HiEvents\DomainObjects\Enums\AttributionSourceType; use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\Models\AccountAttribution; use HiEvents\Repository\Interfaces\AccountAttributionRepositoryInterface; +use Illuminate\Database\Query\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -16,6 +19,8 @@ */ class AccountAttributionRepository extends BaseRepository implements AccountAttributionRepositoryInterface { + private const NOT_SET = '(not set)'; + protected function getModel(): string { return AccountAttribution::class; @@ -27,77 +32,65 @@ public function getDomainObject(): string } public function getAttributionStats( - string $groupBy, + AttributionGroupBy $groupBy, ?string $dateFrom, ?string $dateTo, int $perPage, int $page ): LengthAwarePaginator { - $groupByMap = [ - 'source' => 'utm_source', - 'campaign' => 'utm_campaign', - 'medium' => 'utm_medium', - 'source_type' => 'source_type', - ]; - - $groupColumn = $groupByMap[$groupBy] ?? 'utm_source'; + $groupExpression = $this->groupExpression($groupBy); $liveStatus = EventStatus::LIVE->name; - $query = DB::table('account_attributions as aa') + $stats = $this->attributedAccountsQuery($dateFrom, $dateTo) ->select([ - DB::raw("COALESCE(aa.{$groupColumn}, '(not set)') as attribution_value"), + DB::raw("{$groupExpression} as attribution_value"), DB::raw('COUNT(DISTINCT aa.account_id) as total_accounts'), DB::raw('COUNT(DISTINCT e.id) as total_events'), DB::raw("COUNT(DISTINCT CASE WHEN e.status = '{$liveStatus}' THEN e.id END) as live_events"), DB::raw('COUNT(DISTINCT CASE WHEN EXISTS (SELECT 1 FROM organizers o2 JOIN organizer_stripe_platforms osp2 ON osp2.organizer_id = o2.id WHERE o2.account_id = aa.account_id AND o2.deleted_at IS NULL AND osp2.deleted_at IS NULL AND osp2.stripe_setup_completed_at IS NOT NULL) THEN aa.account_id END) as stripe_connected'), DB::raw('COUNT(DISTINCT CASE WHEN a.is_manually_verified = true THEN aa.account_id END) as verified_accounts'), - DB::raw('COALESCE(SUM(es.sales_total_gross), 0) as total_revenue'), DB::raw('COALESCE(SUM(es.orders_created), 0) as total_orders'), ]) - ->join('accounts as a', 'aa.account_id', '=', 'a.id') ->leftJoin('events as e', function ($join) { $join->on('a.id', '=', 'e.account_id') ->whereNull('e.deleted_at'); }) - ->leftJoin('event_statistics as es', 'e.id', '=', 'es.event_id') - ->whereNull('a.deleted_at'); - - if ($dateFrom) { - $query->where('aa.created_at', '>=', $dateFrom); - } - - if ($dateTo) { - $query->where('aa.created_at', '<=', $dateTo); - } + ->leftJoin('event_statistics as es', function ($join) { + $join->on('e.id', '=', 'es.event_id') + ->whereNull('es.deleted_at'); + }) + ->groupBy(DB::raw($groupExpression)) + ->orderByDesc('total_accounts') + ->orderBy('attribution_value') + ->paginate( + perPage: min($perPage, $this->maxPerPage), + page: $page + ); + + $revenueByValue = $this->revenueByCurrency( + groupExpression: $groupExpression, + attributionValues: $stats->getCollection()->pluck('attribution_value')->all(), + dateFrom: $dateFrom, + dateTo: $dateTo, + ); - $query->groupBy(DB::raw("COALESCE(aa.{$groupColumn}, '(not set)')")) - ->orderByDesc('total_accounts'); + return $stats->through(function (object $row) use ($revenueByValue) { + $row->revenue_by_currency = (object) ($revenueByValue[$row->attribution_value] ?? []); - return $query->paginate( - perPage: min($perPage, $this->maxPerPage), - page: $page - ); + return $row; + }); } public function getAttributionSummary(?string $dateFrom, ?string $dateTo): array { - $attributedQuery = DB::table('account_attributions as aa') + $attributed = $this->attributedAccountsQuery($dateFrom, $dateTo) ->select([ - DB::raw("COUNT(DISTINCT CASE WHEN aa.source_type = 'paid' THEN aa.account_id END) as paid_accounts"), - DB::raw("COUNT(DISTINCT CASE WHEN aa.source_type = 'organic' THEN aa.account_id END) as organic_accounts"), - DB::raw("COUNT(DISTINCT CASE WHEN aa.source_type = 'referral' THEN aa.account_id END) as referral_accounts"), + DB::raw($this->countBySourceType(AttributionSourceType::PAID, 'paid_accounts')), + DB::raw($this->countBySourceType(AttributionSourceType::ORGANIC, 'organic_accounts')), + DB::raw($this->countBySourceType(AttributionSourceType::REFERRAL, 'referral_accounts')), DB::raw('COUNT(DISTINCT aa.account_id) as attributed_accounts'), - ]); - - if ($dateFrom) { - $attributedQuery->where('aa.created_at', '>=', $dateFrom); - } - - if ($dateTo) { - $attributedQuery->where('aa.created_at', '<=', $dateTo); - } - - $attributed = $attributedQuery->first(); + ]) + ->first(); $totalQuery = DB::table('accounts') ->whereNull('deleted_at'); @@ -122,4 +115,82 @@ public function getAttributionSummary(?string $dateFrom, ?string $dateTo): array 'total_accounts' => $totalAccounts, ]; } + + private function attributedAccountsQuery(?string $dateFrom, ?string $dateTo): Builder + { + $query = DB::table('account_attributions as aa') + ->join('accounts as a', 'aa.account_id', '=', 'a.id') + ->whereNull('a.deleted_at'); + + if ($dateFrom) { + $query->where('a.created_at', '>=', $dateFrom); + } + + if ($dateTo) { + $query->where('a.created_at', '<=', $dateTo); + } + + return $query; + } + + /** + * @return array> + */ + private function revenueByCurrency( + string $groupExpression, + array $attributionValues, + ?string $dateFrom, + ?string $dateTo, + ): array { + if ($attributionValues === []) { + return []; + } + + $rows = $this->attributedAccountsQuery($dateFrom, $dateTo) + ->select([ + DB::raw("{$groupExpression} as attribution_value"), + 'e.currency', + DB::raw('SUM(es.sales_total_gross) as revenue'), + ]) + ->join('events as e', function ($join) { + $join->on('a.id', '=', 'e.account_id') + ->whereNull('e.deleted_at'); + }) + ->join('event_statistics as es', function ($join) { + $join->on('e.id', '=', 'es.event_id') + ->whereNull('es.deleted_at'); + }) + ->whereIn(DB::raw($groupExpression), $attributionValues) + ->groupBy(DB::raw($groupExpression), 'e.currency') + ->having(DB::raw('SUM(es.sales_total_gross)'), '>', 0) + ->get(); + + $revenue = []; + + foreach ($rows as $row) { + $revenue[$row->attribution_value][$row->currency] = (float) $row->revenue; + } + + return $revenue; + } + + private function groupExpression(AttributionGroupBy $groupBy): string + { + $column = match ($groupBy) { + AttributionGroupBy::SOURCE => 'aa.utm_source', + AttributionGroupBy::MEDIUM => 'aa.utm_medium', + AttributionGroupBy::CAMPAIGN => 'aa.utm_campaign', + AttributionGroupBy::CONTENT => 'aa.utm_content', + AttributionGroupBy::TERM => 'aa.utm_term', + AttributionGroupBy::CTA => "aa.utm_raw->>'ref'", + AttributionGroupBy::SOURCE_TYPE => 'aa.source_type', + }; + + return sprintf("COALESCE(%s, '%s')", $column, self::NOT_SET); + } + + private function countBySourceType(AttributionSourceType $type, string $alias): string + { + return sprintf("COUNT(DISTINCT CASE WHEN aa.source_type = '%s' THEN aa.account_id END) as %s", $type->value, $alias); + } } diff --git a/backend/app/Repository/Interfaces/AccountAttributionRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountAttributionRepositoryInterface.php index e6668d27d9..11b636e971 100644 --- a/backend/app/Repository/Interfaces/AccountAttributionRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/AccountAttributionRepositoryInterface.php @@ -5,6 +5,7 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\AccountAttributionDomainObject; +use HiEvents\DomainObjects\Enums\AttributionGroupBy; use Illuminate\Pagination\LengthAwarePaginator; /** @@ -13,7 +14,7 @@ interface AccountAttributionRepositoryInterface extends RepositoryInterface { public function getAttributionStats( - string $groupBy, + AttributionGroupBy $groupBy, ?string $dateFrom, ?string $dateTo, int $perPage, diff --git a/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php index 86e39df589..0416462c9f 100644 --- a/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php +++ b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php @@ -19,6 +19,7 @@ use HiEvents\Services\Application\Handlers\Account\Exceptions\AccountConfigurationDoesNotExist; use HiEvents\Services\Application\Handlers\Account\Exceptions\AccountRegistrationDisabledException; use HiEvents\Services\Domain\Account\AccountUserAssociationService; +use HiEvents\Services\Domain\Account\AttributionSourceClassifier; use HiEvents\Services\Domain\User\EmailConfirmationService; use Illuminate\Config\Repository; use Illuminate\Database\DatabaseManager; @@ -40,6 +41,7 @@ public function __construct( private readonly AccountUserRepositoryInterface $accountUserRepository, private readonly AccountConfigurationRepositoryInterface $accountConfigurationRepository, private readonly AccountAttributionRepositoryInterface $accountAttributionRepository, + private readonly AttributionSourceClassifier $attributionSourceClassifier, private readonly LoggerInterface $logger, ) {} @@ -98,7 +100,13 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject 'landing_page' => $accountData->landing_page, 'gclid' => $accountData->gclid, 'fbclid' => $accountData->fbclid, - 'source_type' => $this->classifySourceType($accountData), + 'source_type' => $this->attributionSourceClassifier->classify( + utmMedium: $accountData->utm_medium, + referrerUrl: $accountData->referrer_url, + gclid: $accountData->gclid, + fbclid: $accountData->fbclid, + utmRaw: $accountData->utm_raw, + )->value, 'utm_raw' => $accountData->utm_raw, ]); } @@ -212,55 +220,12 @@ private function hasUtmData(CreateAccountDTO $data): bool return $data->utm_source !== null || $data->utm_medium !== null || $data->utm_campaign !== null - || $data->gclid !== null - || $data->fbclid !== null; - } - - private function classifySourceType(CreateAccountDTO $data): string - { - if ($data->gclid !== null) { - return 'paid'; - } - - if ($data->fbclid !== null) { - return 'paid'; - } - - $paidMediums = ['cpc', 'ppc', 'paid', 'paidsocial', 'display', 'retargeting']; - $normalizedMedium = $this->normalizeUtmValue($data->utm_medium); - - if ($normalizedMedium !== null && in_array($normalizedMedium, $paidMediums, true)) { - return 'paid'; - } - - if ($data->referrer_url !== null && ! $this->isInternalReferrer($data->referrer_url)) { - return 'referral'; - } - - return 'organic'; - } - - private function isInternalReferrer(?string $referrer): bool - { - if ($referrer === null || trim($referrer) === '') { - return false; - } - - $appUrl = $this->config->get('app.url'); - - if ($appUrl === null) { - return false; - } - - $appHost = parse_url($appUrl, PHP_URL_HOST); - $referrerHost = parse_url($referrer, PHP_URL_HOST); - - return $appHost === $referrerHost; + || $data->fbclid !== null + || $this->attributionSourceClassifier->hasPaidClickId($data->gclid, $data->utm_raw); } private function getDefaultMessagingTierId(): int { - // Self-hosted instances get Premium tier, SaaS gets Untrusted return $this->config->get('app.is_hi_events') ? 1 : 3; } } diff --git a/backend/app/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandler.php b/backend/app/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandler.php index d56195e927..8129f096f6 100644 --- a/backend/app/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandler.php +++ b/backend/app/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandler.php @@ -2,6 +2,8 @@ namespace HiEvents\Services\Application\Handlers\Admin; +use Carbon\Carbon; +use HiEvents\DomainObjects\Enums\AttributionGroupBy; use HiEvents\Repository\Interfaces\AccountAttributionRepositoryInterface; use HiEvents\Services\Application\Handlers\Admin\DTO\GetUtmAttributionStatsDTO; @@ -13,17 +15,20 @@ public function __construct( public function handle(GetUtmAttributionStatsDTO $dto): array { + $dateFrom = $this->toUtcDateTimeString($dto->date_from); + $dateTo = $this->toUtcDateTimeString($dto->date_to); + $stats = $this->attributionRepository->getAttributionStats( - groupBy: $dto->group_by, - dateFrom: $dto->date_from, - dateTo: $dto->date_to, + groupBy: AttributionGroupBy::from($dto->group_by), + dateFrom: $dateFrom, + dateTo: $dateTo, page: $dto->page, perPage: $dto->per_page, ); $summary = $this->attributionRepository->getAttributionSummary( - dateFrom: $dto->date_from, - dateTo: $dto->date_to, + dateFrom: $dateFrom, + dateTo: $dateTo, ); return [ @@ -31,4 +36,9 @@ public function handle(GetUtmAttributionStatsDTO $dto): array 'summary' => $summary, ]; } + + private function toUtcDateTimeString(?string $date): ?string + { + return $date === null ? null : Carbon::parse($date, 'UTC')->utc()->toDateTimeString(); + } } diff --git a/backend/app/Services/Domain/Account/AttributionSourceClassifier.php b/backend/app/Services/Domain/Account/AttributionSourceClassifier.php new file mode 100644 index 0000000000..2d940d72e6 --- /dev/null +++ b/backend/app/Services/Domain/Account/AttributionSourceClassifier.php @@ -0,0 +1,102 @@ +hasPaidClickId($gclid, $utmRaw)) { + return AttributionSourceType::PAID; + } + + $normalizedMedium = $utmMedium === null ? null : strtolower(trim($utmMedium)); + + if ($normalizedMedium !== null && in_array($normalizedMedium, self::PAID_MEDIUMS, true)) { + return AttributionSourceType::PAID; + } + + if ($fbclid !== null) { + return AttributionSourceType::REFERRAL; + } + + $referrerHost = $this->normalizeHost($referrerUrl === null ? null : parse_url($referrerUrl, PHP_URL_HOST)); + + if ($referrerHost === null || $this->isInternalHost($referrerHost) || $this->isSearchEngineHost($referrerHost)) { + return AttributionSourceType::ORGANIC; + } + + return AttributionSourceType::REFERRAL; + } + + public function hasPaidClickId(?string $gclid, ?array $utmRaw): bool + { + if ($gclid !== null) { + return true; + } + + foreach (self::PAID_CLICK_IDS as $clickId) { + if (! empty($utmRaw[$clickId])) { + return true; + } + } + + return false; + } + + private function isInternalHost(string $referrerHost): bool + { + foreach ([$this->config->get('app.url'), $this->config->get('app.frontend_url')] as $internalUrl) { + $internalHost = $this->normalizeHost(is_string($internalUrl) ? parse_url($internalUrl, PHP_URL_HOST) : null); + + if ($internalHost !== null && $this->isSameSite($referrerHost, $internalHost)) { + return true; + } + } + + return false; + } + + private function isSearchEngineHost(string $referrerHost): bool + { + return (bool) preg_match(self::SEARCH_ENGINE_HOST_PATTERN, $referrerHost); + } + + private function isSameSite(string $hostA, string $hostB): bool + { + return $hostA === $hostB + || str_ends_with($hostA, '.'.$hostB) + || str_ends_with($hostB, '.'.$hostA); + } + + private function normalizeHost(mixed $host): ?string + { + if (! is_string($host) || $host === '') { + return null; + } + + $host = strtolower($host); + + return str_starts_with($host, 'www.') ? substr($host, 4) : $host; + } +} diff --git a/backend/database/migrations/2026_09_05_000001_reclassify_account_attribution_source_types.php b/backend/database/migrations/2026_09_05_000001_reclassify_account_attribution_source_types.php new file mode 100644 index 0000000000..4ae750d0ec --- /dev/null +++ b/backend/database/migrations/2026_09_05_000001_reclassify_account_attribution_source_types.php @@ -0,0 +1,38 @@ +select(['id', 'utm_medium', 'referrer_url', 'gclid', 'fbclid', 'source_type', 'utm_raw']) + ->orderBy('id') + ->chunkById(500, function ($rows) use ($classifier) { + foreach ($rows as $row) { + $sourceType = $classifier->classify( + utmMedium: $row->utm_medium, + referrerUrl: $row->referrer_url, + gclid: $row->gclid, + fbclid: $row->fbclid, + utmRaw: $row->utm_raw === null ? null : json_decode($row->utm_raw, true), + )->value; + + if ($sourceType !== $row->source_type) { + DB::table('account_attributions') + ->where('id', $row->id) + ->update(['source_type' => $sourceType]); + } + } + }); + } + + public function down(): void {} +}; diff --git a/backend/tests/Feature/Repository/Eloquent/AccountAttributionRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/AccountAttributionRepositoryTest.php new file mode 100644 index 0000000000..60a3478c63 --- /dev/null +++ b/backend/tests/Feature/Repository/Eloquent/AccountAttributionRepositoryTest.php @@ -0,0 +1,147 @@ +repository = $this->app->make(AccountAttributionRepository::class); + } + + public function test_revenue_is_split_by_currency(): void + { + $accountId = $this->createAttributedAccount('google', 'paid'); + $this->createEventWithRevenue($accountId, 'USD', 100.00); + $this->createEventWithRevenue($accountId, 'EUR', 40.50); + $this->createEventWithRevenue($accountId, 'GBP', 0); + + $row = $this->statsRow(AttributionGroupBy::SOURCE, 'google'); + + $this->assertSame(1, (int) $row->total_accounts); + $this->assertSame(3, (int) $row->total_events); + $this->assertEquals(['USD' => 100.0, 'EUR' => 40.5], (array) $row->revenue_by_currency); + } + + public function test_accounts_without_revenue_have_empty_revenue_map(): void + { + $this->createAttributedAccount('newsletter', 'organic'); + + $this->assertSame([], (array) $this->statsRow(AttributionGroupBy::SOURCE, 'newsletter')->revenue_by_currency); + } + + public function test_cta_grouping_reads_ref_from_utm_raw(): void + { + $this->createAttributedAccount('hi.events', 'organic', ['ref' => 'hero-cta']); + $this->createAttributedAccount('hi.events', 'organic', ['ref' => 'hero-cta']); + $this->createAttributedAccount('hi.events', 'organic'); + + $this->assertSame(2, (int) $this->statsRow(AttributionGroupBy::CTA, 'hero-cta')->total_accounts); + $this->assertSame(1, (int) $this->statsRow(AttributionGroupBy::CTA, '(not set)')->total_accounts); + } + + public function test_soft_deleted_accounts_are_excluded_from_stats_and_summary(): void + { + $liveAccountId = $this->createAttributedAccount('podcast', 'referral'); + $deletedAccountId = $this->createAttributedAccount('podcast', 'referral'); + DB::table('accounts')->where('id', $deletedAccountId)->update(['deleted_at' => now()]); + + $this->assertSame(1, (int) $this->statsRow(AttributionGroupBy::SOURCE, 'podcast')->total_accounts); + + $summary = $this->repository->getAttributionSummary(dateFrom: self::WINDOW_START, dateTo: self::WINDOW_END); + + $this->assertSame(1, $summary['total_accounts']); + $this->assertSame(1, $summary['attributed_accounts']); + $this->assertSame(1, $summary['referral_accounts']); + $this->assertSame(0, $summary['unattributed_accounts']); + $this->assertSame($liveAccountId, (int) DB::table('accounts')->where('created_at', '2019-06-01 12:00:00')->whereNull('deleted_at')->value('id')); + } + + private function statsRow(AttributionGroupBy $groupBy, string $value): object + { + $stats = $this->repository->getAttributionStats( + groupBy: $groupBy, + dateFrom: self::WINDOW_START, + dateTo: self::WINDOW_END, + perPage: 100, + page: 1, + ); + + return $stats->getCollection()->firstWhere('attribution_value', $value); + } + + private function createAttributedAccount(string $utmSource, string $sourceType, ?array $utmRaw = null): int + { + $user = User::factory()->withAccount()->create(); + $accountId = $user->accounts()->first()->id; + + DB::table('accounts')->where('id', $accountId)->update(['created_at' => '2019-06-01 12:00:00']); + + DB::table('account_attributions')->insert([ + 'account_id' => $accountId, + 'utm_source' => $utmSource, + 'source_type' => $sourceType, + 'utm_raw' => $utmRaw === null ? null : json_encode($utmRaw), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $accountId; + } + + private function createEventWithRevenue(int $accountId, string $currency, float $revenue): void + { + $user = DB::table('account_users')->where('account_id', $accountId)->value('user_id'); + + $organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $accountId, + 'name' => 'Organizer', + 'email' => 'organizer@example.test', + 'currency' => $currency, + 'timezone' => 'UTC', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $eventId = DB::table('events')->insertGetId([ + 'title' => 'Event', + 'account_id' => $accountId, + 'user_id' => $user, + 'organizer_id' => $organizerId, + 'currency' => $currency, + 'timezone' => 'UTC', + 'short_id' => 'evt_'.uniqid(), + 'status' => EventStatus::LIVE->name, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('event_statistics')->insert([ + 'event_id' => $eventId, + 'sales_total_gross' => $revenue, + 'orders_created' => $revenue > 0 ? 1 : 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/backend/tests/Unit/Exports/AnswersExportTest.php b/backend/tests/Unit/Exports/AnswersExportTest.php new file mode 100644 index 0000000000..7c4056b841 --- /dev/null +++ b/backend/tests/Unit/Exports/AnswersExportTest.php @@ -0,0 +1,24 @@ +assertInstanceOf(Export::class, $export); + + $sheets = $export->withData(collect())->sheets(); + + $this->assertCount(3, $sheets); + $this->assertContainsOnlyInstancesOf(Export::class, $sheets); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandlerTest.php new file mode 100644 index 0000000000..35fd1e4466 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandlerTest.php @@ -0,0 +1,54 @@ +assertDatesPassedToRepository( + dateFrom: '2026-09-04 10:30:00', + dateTo: '2026-09-05T10:30:00+02:00', + expectedFrom: '2026-09-04 10:30:00', + expectedTo: '2026-09-05 08:30:00', + ); + } + + public function test_missing_dates_stay_null(): void + { + $this->assertDatesPassedToRepository(null, null, null, null); + } + + private function assertDatesPassedToRepository(?string $dateFrom, ?string $dateTo, ?string $expectedFrom, ?string $expectedTo): void + { + $repository = Mockery::mock(AccountAttributionRepositoryInterface::class); + $paginator = new LengthAwarePaginator([], 0, 20); + + $repository->shouldReceive('getAttributionStats') + ->once() + ->withArgs(fn (AttributionGroupBy $groupBy, ?string $from, ?string $to) => $groupBy === AttributionGroupBy::CAMPAIGN && $from === $expectedFrom && $to === $expectedTo) + ->andReturn($paginator); + + $repository->shouldReceive('getAttributionSummary') + ->once() + ->with($expectedFrom, $expectedTo) + ->andReturn(['total_accounts' => 0]); + + $result = (new GetUtmAttributionStatsHandler($repository))->handle(new GetUtmAttributionStatsDTO( + group_by: 'campaign', + date_from: $dateFrom, + date_to: $dateTo, + )); + + $this->assertSame($paginator, $result['data']); + $this->assertSame(['total_accounts' => 0], $result['summary']); + } +} diff --git a/backend/tests/Unit/Services/Domain/Account/AttributionSourceClassifierTest.php b/backend/tests/Unit/Services/Domain/Account/AttributionSourceClassifierTest.php new file mode 100644 index 0000000000..fa2bf692b3 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Account/AttributionSourceClassifierTest.php @@ -0,0 +1,127 @@ +classifier = new AttributionSourceClassifier(new Repository([ + 'app' => [ + 'url' => 'https://api.hi.events', + 'frontend_url' => 'https://app.hi.events', + ], + ])); + } + + public function test_gclid_is_paid(): void + { + $this->assertSame(AttributionSourceType::PAID, $this->classify(gclid: 'abc')); + } + + public function test_google_ads_privacy_click_ids_are_paid(): void + { + $this->assertSame(AttributionSourceType::PAID, $this->classify(utmRaw: ['gbraid' => 'x'])); + $this->assertSame(AttributionSourceType::PAID, $this->classify(utmRaw: ['wbraid' => 'x'])); + } + + public function test_paid_medium_is_paid_regardless_of_case(): void + { + $this->assertSame(AttributionSourceType::PAID, $this->classify(utmMedium: ' CPC ')); + $this->assertSame(AttributionSourceType::PAID, $this->classify(utmMedium: 'paid_social')); + } + + public function test_fbclid_alone_is_referral(): void + { + $this->assertSame(AttributionSourceType::REFERRAL, $this->classify(fbclid: 'abc')); + $this->assertSame(AttributionSourceType::REFERRAL, $this->classify(fbclid: 'abc', referrerUrl: 'https://hi.events/')); + } + + public function test_fbclid_with_paid_medium_is_paid(): void + { + $this->assertSame(AttributionSourceType::PAID, $this->classify(utmMedium: 'paid_social', fbclid: 'abc')); + } + + public function test_marketing_site_referrer_is_organic(): void + { + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify( + utmMedium: 'website', + referrerUrl: 'https://hi.events/pricing', + )); + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify( + referrerUrl: 'https://www.hi.events/', + )); + } + + public function test_app_referrer_is_organic(): void + { + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify( + referrerUrl: 'https://app.hi.events/auth/login', + )); + } + + public function test_search_engine_referrer_is_organic(): void + { + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify(referrerUrl: 'https://www.google.com/')); + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify(referrerUrl: 'https://www.google.co.uk/search?q=x')); + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify(referrerUrl: 'https://duckduckgo.com/')); + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify(referrerUrl: 'https://search.brave.com/')); + } + + public function test_external_referrer_is_referral(): void + { + $this->assertSame(AttributionSourceType::REFERRAL, $this->classify( + referrerUrl: 'https://github.com/HiEventsDev/hi.events', + )); + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify(referrerUrl: 'https://www.google.com.au/')); + $this->assertSame(AttributionSourceType::REFERRAL, $this->classify(referrerUrl: 'https://notgoogle.example/')); + $this->assertSame(AttributionSourceType::REFERRAL, $this->classify(referrerUrl: 'https://google.evil.com/')); + $this->assertSame(AttributionSourceType::REFERRAL, $this->classify(referrerUrl: 'https://mail.google.anything.io/')); + } + + public function test_lookalike_domain_is_referral(): void + { + $this->assertSame(AttributionSourceType::REFERRAL, $this->classify( + referrerUrl: 'https://nothi.events/', + )); + } + + public function test_has_paid_click_id_covers_gclid_and_raw_click_ids(): void + { + $this->assertTrue($this->classifier->hasPaidClickId('abc', null)); + $this->assertTrue($this->classifier->hasPaidClickId(null, ['wbraid' => 'x'])); + $this->assertFalse($this->classifier->hasPaidClickId(null, ['fbclid' => 'x', 'ref' => 'hero-cta'])); + $this->assertFalse($this->classifier->hasPaidClickId(null, null)); + } + + public function test_no_referrer_is_organic(): void + { + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify(utmMedium: 'website')); + $this->assertSame(AttributionSourceType::ORGANIC, $this->classify(referrerUrl: ' ')); + } + + private function classify( + ?string $utmMedium = null, + ?string $referrerUrl = null, + ?string $gclid = null, + ?string $fbclid = null, + ?array $utmRaw = null, + ): AttributionSourceType { + return $this->classifier->classify( + utmMedium: $utmMedium, + referrerUrl: $referrerUrl, + gclid: $gclid, + fbclid: $fbclid, + utmRaw: $utmRaw, + ); + } +} diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index decec85c6b..a4edcd1ec2 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -311,7 +311,7 @@ export interface UtmAttributionStats { live_events: number; stripe_connected: number; verified_accounts: number; - total_revenue: number; + revenue_by_currency: Record; total_orders: number; } @@ -324,8 +324,10 @@ export interface UtmAttributionSummary { total_accounts: number; } +export type AttributionGroupBy = 'source' | 'medium' | 'campaign' | 'content' | 'term' | 'cta' | 'source_type'; + export interface GetUtmAttributionStatsParams { - group_by?: 'source' | 'campaign' | 'medium' | 'source_type'; + group_by?: AttributionGroupBy; date_from?: string; date_to?: string; page?: number; diff --git a/frontend/src/components/layouts/AuthLayout/index.tsx b/frontend/src/components/layouts/AuthLayout/index.tsx index 418ef7b829..f6bd0cfd7c 100644 --- a/frontend/src/components/layouts/AuthLayout/index.tsx +++ b/frontend/src/components/layouts/AuthLayout/index.tsx @@ -4,10 +4,11 @@ import {t} from "@lingui/macro"; import {useGetMe} from "../../../queries/useGetMe.ts"; import {PoweredByFooter} from "../../common/PoweredByFooter"; import {LanguageSwitcher} from "../../common/LanguageSwitcher"; -import {useCallback, useRef} from "react"; +import {useCallback, useEffect, useRef} from "react"; import {getConfig} from "../../../utilites/config.ts"; import {isHiEvents} from "../../../utilites/helpers.ts"; import {showInfo} from "../../../utilites/notifications.tsx"; +import {captureUtmData} from "../../../utilites/utm.ts"; const tickerFeatures = [ t`Recurring events`, @@ -127,6 +128,10 @@ const AuthLayout = () => { const clickCountRef = useRef(0); const clickTimerRef = useRef | undefined>(undefined); + useEffect(() => { + captureUtmData(); + }, []); + const handleLogoClick = useCallback(() => { clickCountRef.current += 1; clearTimeout(clickTimerRef.current); diff --git a/frontend/src/components/routes/admin/Attribution/index.tsx b/frontend/src/components/routes/admin/Attribution/index.tsx index 89369183f0..5e3fa60974 100644 --- a/frontend/src/components/routes/admin/Attribution/index.tsx +++ b/frontend/src/components/routes/admin/Attribution/index.tsx @@ -1,19 +1,71 @@ -import {Container, Title, Text, Paper, Stack, SimpleGrid, SegmentedControl, Table, Skeleton, Pagination, Group} from "@mantine/core"; +import {Container, Title, Text, Paper, Stack, SimpleGrid, SegmentedControl, Table, Skeleton, Pagination, Group, Select} from "@mantine/core"; +import {DatePickerInput} from "@mantine/dates"; +import {IconCalendar} from "@tabler/icons-react"; import {t, Trans} from "@lingui/macro"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; import {useGetUtmAttributionStats} from "../../../../queries/useGetUtmAttributionStats"; -import {useState} from "react"; +import {AttributionGroupBy} from "../../../../api/admin.client"; +import {useMemo, useState} from "react"; import {formatCurrency} from "../../../../utilites/currency"; import tableStyles from "../../../../styles/admin-table.module.scss"; +dayjs.extend(utc); + +type Period = '24h' | '7d' | '30d' | '90d' | 'all' | 'custom'; + +const PERIOD_STARTS: Record, () => dayjs.Dayjs> = { + '24h': () => dayjs().subtract(24, 'hour'), + '7d': () => dayjs().subtract(7, 'day'), + '30d': () => dayjs().subtract(30, 'day'), + '90d': () => dayjs().subtract(90, 'day'), +}; + +const toUtcDateTime = (date: dayjs.Dayjs) => date.utc().format('YYYY-MM-DD HH:mm:ss'); + +const RevenueByCurrency = ({revenue}: { revenue: Record }) => { + const entries = Object.entries(revenue); + + if (entries.length === 0) { + return ; + } + + return ( + + {entries.map(([currency, amount]) => ( + {formatCurrency(amount, currency)} + ))} + + ); +}; + const Attribution = () => { - const [groupBy, setGroupBy] = useState<'source' | 'campaign' | 'medium' | 'source_type'>('source'); + const [groupBy, setGroupBy] = useState('source'); + const [period, setPeriod] = useState('30d'); + const [customRange, setCustomRange] = useState<[Date | null, Date | null]>([null, null]); const [page, setPage] = useState(1); const perPage = 20; + const dateFilter = useMemo(() => { + if (period === 'all') { + return {}; + } + + if (period === 'custom') { + const [from, to] = customRange; + return from && to + ? {date_from: toUtcDateTime(dayjs(from).startOf('day')), date_to: toUtcDateTime(dayjs(to).endOf('day'))} + : {}; + } + + return {date_from: toUtcDateTime(PERIOD_STARTS[period]())}; + }, [period, customRange]); + const {data, isLoading} = useGetUtmAttributionStats({ group_by: groupBy, page, - per_page: perPage + per_page: perPage, + ...dateFilter, }); const summary = data?.summary; @@ -21,73 +73,80 @@ const Attribution = () => { const stats = paginatedData?.data || []; const totalPages = paginatedData?.last_page || 1; + const periods = [ + {value: '24h', label: t`Last 24 hours`}, + {value: '7d', label: t`Last 7 days`}, + {value: '30d', label: t`Last 30 days`}, + {value: '90d', label: t`Last 90 days`}, + {value: 'all', label: t`All time`}, + {value: 'custom', label: t`Custom Range`}, + ]; + + const summaryCards = summary ? [ + {label: t`Paid Accounts`, value: summary.paid_accounts}, + {label: t`Organic Accounts`, value: summary.organic_accounts}, + {label: t`Referral Accounts`, value: summary.referral_accounts}, + {label: t`Unattributed Accounts`, value: summary.unattributed_accounts}, + ] : []; + return ( -
- - <Trans>Attribution Analytics</Trans> - - - Track account growth and performance by attribution source - - - Statistics are based on account creation date - -
+ +
+ + <Trans>Attribution Analytics</Trans> + + + Track account growth and performance by attribution source + + + Statistics are based on account creation date + +
+ +