From 4efc6f467d2bee77906f9af2065854a97fd06da8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 12 May 2026 13:31:00 -0300 Subject: [PATCH 1/2] feat(tracking): push Google Ads conversion data to dataLayer on purchase Wire `value`, `currency`, and `transaction_id` from Stripe Checkout Session into the `purchase` dataLayer event so GTM can fire Google Ads Conversion Tracking with accurate per-plan revenue and deduped transaction IDs. - `BillingController::checkout` adds `{CHECKOUT_SESSION_ID}` to success_url - `BillingController::processing` retrieves session lazily (closure prop, so polling partial reloads don't re-hit Stripe) and exposes `conversion` with `value`/`currency`/`transaction_id` - `Processing.vue` forwards `conversion` to `trackPurchase` - `useTracking.trackPurchase` pushes `conversion_value`, `conversion_currency`, `conversion_transaction_id` to dataLayer + PostHog --- .../Controllers/App/BillingController.php | 40 ++++++++++++++++++- resources/js/composables/useTracking.ts | 15 ++++++- resources/js/pages/billing/Processing.vue | 12 ++++-- tests/Feature/BillingControllerTest.php | 23 +++++++++++ 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 3926bac5..71bd36b3 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Gate; use Inertia\Inertia; use Inertia\Response; +use Stripe\Exception\ApiErrorException as StripeApiErrorException; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; 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 (StripeApiErrorException) { + 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')) { diff --git a/resources/js/composables/useTracking.ts b/resources/js/composables/useTracking.ts index c9fb4a78..c12b472a 100644 --- a/resources/js/composables/useTracking.ts +++ b/resources/js/composables/useTracking.ts @@ -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, + } : {}), }); }, }); diff --git a/resources/js/pages/billing/Processing.vue b/resources/js/pages/billing/Processing.vue index 60c5ca0e..878bde10 100644 --- a/resources/js/pages/billing/Processing.vue +++ b/resources/js/pages/billing/Processing.vue @@ -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(); diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index 78be2a60..aaa09251 100644 --- a/tests/Feature/BillingControllerTest.php +++ b/tests/Feature/BillingControllerTest.php @@ -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]); From eebe510a3107e8e6c3e4b64d59d13d731a3c0e9a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 12 May 2026 13:38:37 -0300 Subject: [PATCH 2/2] fix(billing): widen tracking exception catch so post-payment page never crashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$account->stripe()` can throw `\Stripe\Exception\InvalidArgumentException` (descends from PHP's `InvalidArgumentException`, not `ApiErrorException`) when the Stripe key is missing/malformed. Since the conversion data is purely for tracking, any failure must degrade silently — not break the post-payment success page. --- app/Http/Controllers/App/BillingController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 71bd36b3..d955b6ea 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -11,8 +11,8 @@ use Illuminate\Support\Facades\Gate; use Inertia\Inertia; use Inertia\Response; -use Stripe\Exception\ApiErrorException as StripeApiErrorException; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Throwable; class BillingController extends Controller { @@ -101,7 +101,7 @@ private function buildConversionData(Account $account, string $sessionId): ?arra $sessionId, ['expand' => ['line_items.data.price']], ); - } catch (StripeApiErrorException) { + } catch (Throwable) { return null; }