Fire checkout.completed reliably for trial-with-card checkouts
A trial-with-card subscription is already subscribed() (status trialing) by the time the webhook lands, so /billing/processing usually mounts already-active and the false->true poll transition the event depended on never happened — only 3 of 66 real subscriptions emitted checkout.completed. Complete the purchase from whichever path runs first (onMounted when already active, or the poll transition), de-duplicated per checkout session via a one-time Cache::add gate on session_id so back-button/refresh can't re-fire.
This commit is contained in:
parent
8e334cd676
commit
f223cf14ce
3 changed files with 53 additions and 15 deletions
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\Plan;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
|
@ -88,9 +89,17 @@ public function processing(Request $request): Response|RedirectResponse
|
|||
$account = $request->user()->account;
|
||||
$sessionId = $request->query('session_id');
|
||||
|
||||
// Consume the checkout session once: `fromCheckout` is true only the first
|
||||
// time this session_id is seen, so a back-button/refresh to the success URL
|
||||
// can't re-fire `checkout.completed`. `Cache::add` is atomic — it returns
|
||||
// true only when the key didn't exist yet.
|
||||
$fromCheckout = is_string($sessionId) && $sessionId !== ''
|
||||
&& Cache::add("checkout_tracked:{$sessionId}", true, now()->addDay());
|
||||
|
||||
return Inertia::render('billing/Processing', [
|
||||
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
|
||||
'conversion' => is_string($sessionId) && $sessionId !== '' && $account?->stripe_id
|
||||
'fromCheckout' => $fromCheckout,
|
||||
'conversion' => $fromCheckout && $account?->stripe_id
|
||||
? fn () => $this->buildConversionData($account, $sessionId)
|
||||
: null,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router, usePage, usePoll } from '@inertiajs/vue3';
|
||||
import { IconLoader2 } from '@tabler/icons-vue';
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useTracking } from '@/composables/useTracking';
|
||||
import { home } from '@/routes/app';
|
||||
|
|
@ -9,6 +9,7 @@ import type { Auth } from '@/types';
|
|||
|
||||
const props = defineProps<{
|
||||
subscriptionActive: boolean;
|
||||
fromCheckout: boolean;
|
||||
conversion?: { value: number; currency: string; transaction_id: string } | null;
|
||||
}>();
|
||||
|
||||
|
|
@ -24,20 +25,21 @@ const { stop } = usePoll(2000, {
|
|||
|
||||
const { trackPurchase } = useTracking();
|
||||
|
||||
const tracked = ref(false);
|
||||
|
||||
const goHome = () => router.visit(home.url());
|
||||
|
||||
// `watch` (without `immediate`) only fires on transition false → true, which
|
||||
// is exactly the purchase moment. The `onMounted` fallback covers the case
|
||||
// where the user lands here with an already-active subscription (back button,
|
||||
// refresh after the redirect) — we just bounce them home, no extra event.
|
||||
watch(
|
||||
() => props.subscriptionActive,
|
||||
(active) => {
|
||||
if (! active) {
|
||||
return;
|
||||
}
|
||||
// Fires `checkout.completed` exactly once for a real checkout. A trial-with-card
|
||||
// subscription is already `subscribed()` (status `trialing`) by the time the
|
||||
// webhook lands, so the user frequently reaches this page already active — the
|
||||
// false → true poll transition never happens. We therefore complete the purchase
|
||||
// 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 = () => {
|
||||
stop();
|
||||
|
||||
stop();
|
||||
if (! tracked.value && props.fromCheckout) {
|
||||
tracked.value = true;
|
||||
|
||||
const plan = (page.props.auth as Auth | undefined)?.plan;
|
||||
if (plan) {
|
||||
|
|
@ -49,14 +51,23 @@ watch(
|
|||
props.conversion ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
goHome();
|
||||
goHome();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.subscriptionActive,
|
||||
(active) => {
|
||||
if (active) {
|
||||
completePurchase();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.subscriptionActive) {
|
||||
goHome();
|
||||
completePurchase();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -183,10 +183,28 @@
|
|||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Processing', false)
|
||||
->has('subscriptionActive')
|
||||
->where('fromCheckout', false)
|
||||
->where('conversion', null)
|
||||
);
|
||||
});
|
||||
|
||||
test('billing processing exposes fromCheckout=true only the first time a session_id is seen', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$sessionId = 'cs_test_'.fake()->uuid();
|
||||
|
||||
$first = $this->actingAs($this->user)
|
||||
->get(route('app.billing.processing', ['session_id' => $sessionId]));
|
||||
$first->assertOk();
|
||||
$first->assertInertia(fn ($page) => $page->where('fromCheckout', true));
|
||||
|
||||
// A back-button / refresh to the same success URL must not re-fire the event.
|
||||
$second = $this->actingAs($this->user)
|
||||
->get(route('app.billing.processing', ['session_id' => $sessionId]));
|
||||
$second->assertOk();
|
||||
$second->assertInertia(fn ($page) => $page->where('fromCheckout', false));
|
||||
});
|
||||
|
||||
test('billing processing exposes null conversion when session_id query param is missing', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue