feat(billing): attach persona to conversion tracking and polish annual banner

Send the user's persona on both the server-side subscription.created event and
the client-side checkout.completed/dataLayer purchase event, for ICP analysis.
Hold the processing screen ~5s before redirecting so PostHog and the ad pixels
(Google/Meta via GTM) reliably flush. Sharpen the annual-upgrade banner copy
(lead with '2 months free') and give its check icon a white tile.
This commit is contained in:
Paulo Castellano 2026-06-22 12:32:03 -03:00
parent b628f30f1b
commit bde8d4801e
9 changed files with 58 additions and 25 deletions

View file

@ -40,6 +40,7 @@ public function processing(Request $request): Response|RedirectResponse
return Inertia::render('billing/Processing', [
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
'fromCheckout' => $fromCheckout,
'persona' => $request->user()->persona?->value,
'conversion' => $fromCheckout && $account?->stripe_id
? fn () => $this->buildConversionData($account, $sessionId)
: null,

View file

@ -39,7 +39,7 @@ public function handle(PostHogService $postHog): void
return;
}
$account = Account::with('plan')->find($this->accountId);
$account = Account::with(['plan', 'owner'])->find($this->accountId);
if (! $account || ! $account->owner_id) {
return;
@ -53,6 +53,7 @@ public function handle(PostHogService $postHog): void
'plan' => $account->plan?->name,
'plan_slug' => $account->plan?->slug->value,
'previous_plan' => $this->previousPlan,
'persona' => $account->owner?->persona?->value,
],
$account,
);

View file

@ -10,8 +10,8 @@
],
'annual_banner' => [
'title' => 'Save 2 months with annual billing',
'description' => 'Switch to annual and get 2 months free — pay less every month.',
'title' => 'Get 2 months free',
'description' => 'Switch to annual billing and pay less every month — same plan, nothing else changes.',
'cta' => 'Upgrade to annual',
],

View file

@ -10,8 +10,8 @@
],
'annual_banner' => [
'title' => 'Ahorra 2 meses con la facturación anual',
'description' => 'Cambia al anual y obtén 2 meses gratis — paga menos cada mes.',
'title' => 'Consigue 2 meses gratis',
'description' => 'Cambia a la facturación anual y paga menos al mes — el mismo plan, sin cambios.',
'cta' => 'Cambiar a anual',
],

View file

@ -10,8 +10,8 @@
],
'annual_banner' => [
'title' => 'Economize 2 meses com a cobrança anual',
'description' => 'Mude para o anual e ganhe 2 meses grátis — pague menos por mês.',
'title' => 'Ganhe 2 meses grátis',
'description' => 'Mude para a cobrança anual e pague menos por mês — sem mudar nada do seu plano.',
'cta' => 'Mudar para anual',
],

View file

@ -33,10 +33,12 @@ export const useTracking = () => ({
trackPurchase: (
plan: { name: string; interval: string },
conversion?: { value: number; currency: string; transaction_id: string } | null,
persona?: string | null,
) => {
captureEvent('checkout.completed', {
plan_name: plan.name,
interval: plan.interval,
...(persona ? { persona } : {}),
...(conversion ? {
conversion_value: conversion.value,
conversion_currency: conversion.currency,
@ -48,6 +50,7 @@ export const useTracking = () => ({
event: 'purchase',
plan_name: plan.name,
plan_interval: plan.interval,
...(persona ? { persona } : {}),
...(conversion ? {
conversion_value: conversion.value,
conversion_currency: conversion.currency,

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Head, router, usePage, usePoll } from '@inertiajs/vue3';
import { IconLoader2 } from '@tabler/icons-vue';
import { onMounted, ref, watch } from 'vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useTracking } from '@/composables/useTracking';
import { accounts } from '@/routes/app';
@ -10,9 +10,15 @@ import type { Auth } from '@/types';
const props = defineProps<{
subscriptionActive: boolean;
fromCheckout: boolean;
persona?: string | null;
conversion?: { value: number; currency: string; transaction_id: string } | null;
}>();
// Hold on the processing screen after firing the purchase event so PostHog and
// the ad pixels (Google/Meta via dataLayer GTM) have time to send before we
// navigate away an immediate redirect can cut those requests off.
const REDIRECT_DELAY_MS = 5000;
const page = usePage();
// Polls `auth` alongside so `auth.plan.interval` is fresh once the Stripe
@ -25,9 +31,10 @@ const { stop } = usePoll(2000, {
const { trackPurchase } = useTracking();
const tracked = ref(false);
const finishing = ref(false);
let redirectTimer: ReturnType<typeof setTimeout> | null = null;
const goToAccounts = () => router.visit(accounts.url({ query: { openDialog: true } }));
const goToAccounts = () => router.visit(accounts.url());
// Fires `checkout.completed` exactly once for a real checkout. A trial-with-card
// subscription is already `subscribed()` (status `trialing`) by the time the
@ -36,24 +43,25 @@ const goToAccounts = () => router.visit(accounts.url({ query: { openDialog: true
// from whichever path runs first (immediate active state or poll transition),
// gated on `fromCheckout` so back-button/refresh visits don't over-count.
const completePurchase = () => {
if (finishing.value) {
return;
}
finishing.value = true;
stop();
if (! tracked.value && props.fromCheckout) {
tracked.value = true;
const plan = (page.props.auth as Auth | undefined)?.plan;
const plan = (page.props.auth as Auth | undefined)?.plan;
if (plan) {
trackPurchase(
{
name: plan.name,
interval: plan.interval,
},
props.conversion ?? null,
);
}
if (props.fromCheckout && plan) {
trackPurchase(
{ name: plan.name, interval: plan.interval },
props.conversion ?? null,
props.persona ?? null,
);
}
goToAccounts();
// Always hold for the same window before navigating, so PostHog and the ad
// pixels (Google/Meta via dataLayer GTM) reliably flush.
redirectTimer = setTimeout(goToAccounts, REDIRECT_DELAY_MS);
};
watch(
@ -70,6 +78,12 @@ onMounted(() => {
completePurchase();
}
});
onUnmounted(() => {
if (redirectTimer) {
clearTimeout(redirectTimer);
}
});
</script>
<template>

View file

@ -99,8 +99,8 @@ const upgradeToAnnual = (): void => {
v-if="showAnnualBanner"
class="flex flex-wrap items-center gap-4 rounded-2xl border-2 border-foreground bg-emerald-100 p-5 shadow-2xs"
>
<span class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-emerald-300 shadow-2xs">
<IconRosetteDiscountCheck class="size-6 text-foreground" stroke-width="2" />
<span class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
<IconRosetteDiscountCheck class="size-6 text-emerald-600" stroke-width="2.25" />
</span>
<div class="min-w-0 flex-1">
<p class="text-base font-bold text-foreground">

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\PostHog\BillingEvent;
use App\Enums\User\Persona;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\SyncUser;
use App\Jobs\PostHog\TrackBilling;
@ -50,6 +51,19 @@
});
});
test('handle forwards the owner persona as an event property', function () {
$this->user->update(['persona' => Persona::Agency->value]);
Queue::fake();
(new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(
SendEvent::class,
fn ($job) => ($job->payload['properties']['persona'] ?? null) === Persona::Agency->value,
);
});
test('handle forwards previousPlan as a property when supplied', function () {
Queue::fake();