diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 3926bac5..d955b6ea 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -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')) { 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]);