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:
commit
3a3bdec7f5
4 changed files with 84 additions and 6 deletions
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
} : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue