Merge pull request #28 from trypostit/feat/google-ads-conversion-tracking

feat(tracking): push Google Ads conversion data to dataLayer on purchase
This commit is contained in:
Paulo Castellano 2026-05-12 15:27:07 -03:00 committed by GitHub
commit 3a3bdec7f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 84 additions and 6 deletions

View file

@ -12,6 +12,7 @@
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Throwable;
class BillingController extends Controller
{
@ -66,7 +67,7 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
->trialDays(config('cashier.trial_days'));
$checkoutSession = $subscription->checkout([
'success_url' => route('app.billing.processing'),
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('app.subscribe'),
]);
@ -80,12 +81,49 @@ public function processing(Request $request): Response|RedirectResponse
}
$account = $request->user()->account;
$sessionId = $request->query('session_id');
return Inertia::render('billing/Processing', [
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
'conversion' => is_string($sessionId) && $sessionId !== '' && $account?->stripe_id
? fn () => $this->buildConversionData($account, $sessionId)
: null,
]);
}
/**
* @return array{value: float, currency: string, transaction_id: string}|null
*/
private function buildConversionData(Account $account, string $sessionId): ?array
{
try {
$session = $account->stripe()->checkout->sessions->retrieve(
$sessionId,
['expand' => ['line_items.data.price']],
);
} catch (Throwable) {
return null;
}
if (data_get($session, 'customer') !== $account->stripe_id) {
return null;
}
$unitAmount = data_get($session, 'line_items.data.0.price.unit_amount');
$currency = data_get($session, 'line_items.data.0.price.currency');
$transactionId = data_get($session, 'id');
if (! is_int($unitAmount) || ! is_string($currency) || ! is_string($transactionId)) {
return null;
}
return [
'value' => $unitAmount / 100,
'currency' => strtoupper($currency),
'transaction_id' => $transactionId,
];
}
public function index(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {

View file

@ -30,16 +30,29 @@ export const useTracking = () => ({
});
},
trackPurchase: (plan: { name: string; interval: string }) => {
trackPurchase: (
plan: { name: string; interval: string },
conversion?: { value: number; currency: string; transaction_id: string } | null,
) => {
captureEvent('checkout.completed', {
plan_name: plan.name,
interval: plan.interval,
...(conversion ? {
conversion_value: conversion.value,
conversion_currency: conversion.currency,
conversion_transaction_id: conversion.transaction_id,
} : {}),
});
push({
event: 'purchase',
plan_name: plan.name,
plan_interval: plan.interval,
...(conversion ? {
conversion_value: conversion.value,
conversion_currency: conversion.currency,
conversion_transaction_id: conversion.transaction_id,
} : {}),
});
},
});

View file

@ -9,6 +9,7 @@ import type { Auth } from '@/types';
const props = defineProps<{
subscriptionActive: boolean;
conversion?: { value: number; currency: string; transaction_id: string } | null;
}>();
const page = usePage();
@ -40,10 +41,13 @@ watch(
const plan = (page.props.auth as Auth | undefined)?.plan;
if (plan) {
trackPurchase({
name: plan.name,
interval: plan.interval,
});
trackPurchase(
{
name: plan.name,
interval: plan.interval,
},
props.conversion ?? null,
);
}
goHome();

View file

@ -117,9 +117,32 @@
$response->assertInertia(fn ($page) => $page
->component('billing/Processing', false)
->has('subscriptionActive')
->where('conversion', null)
);
});
test('billing processing exposes null conversion when session_id query param is missing', function () {
config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)
->get(route('app.billing.processing', ['session_id' => '']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page->where('conversion', null));
});
test('billing processing exposes null conversion when account has no stripe_id', function () {
config(['trypost.self_hosted' => false]);
expect($this->account->stripe_id)->toBeNull();
$response = $this->actingAs($this->user)
->get(route('app.billing.processing', ['session_id' => 'cs_test_123']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page->where('conversion', null));
});
test('shared auth.plan exposes name slug and interval via AuthPlanResource', function () {
config(['trypost.self_hosted' => false]);