Skip to content

feat: fire signup/checkout PostHog events from the backend - #277

Merged
paulocastellano merged 30 commits into
mainfrom
feat/backend-posthog-conversion-events
Aug 12, 2026
Merged

feat: fire signup/checkout PostHog events from the backend#277
paulocastellano merged 30 commits into
mainfrom
feat/backend-posthog-conversion-events

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

user.signed_up, checkout.started, checkout.completed only fired client-side (useTracking.tsposthog-js) — ad blockers and cut-short page unloads could drop them, same reliability problem the ad-platform click-id work (#276) fixed. This moves the PostHog side to the backend, and splits checkout.completed into three distinct billing-lifecycle events since it was firing on every subscription creation regardless of whether the customer was actually charged. It also fixes an unrelated bug found along the way: OAuth signups were silently dropping pending invites.

Signup/checkout events → backend

  • user.signed_upApp\Actions\User\CreateUser, right after SyncUser dispatch, gated on ! is_invite. auth_provider derived from google_id/github_id presence on the created $user.
  • checkout.startedWelcomeController::storeReferralSource, alongside the existing WelcomeEvent::Referral capture, right before checkout starts.
  • useTracking.ts is now fully dead (only GTM dataLayer.push calls remained after the PostHog side moved server-side, and those had no consumer left either) — deleted, along with its 3 call sites.

checkout.completed split into 3 events

Trypost has 3 checkout paths depending on billing config (REQUIRE_CARD_FOR_TRIAL, CASHIER_TRIAL_DAYS, STRIPE_FIRST_MONTH_COUPON_ID): a card-required trial (no charge at signup), a first-month-coupon checkout (charged immediately, discounted), or a plain full-price checkout (charged immediately). The old code fired checkout.completed for all three indiscriminately — a trial signup looked identical to a real payment.

  • trial.started (new)customer.subscription.created with status: trialing. No conversion_value (nothing charged yet); carries trial_ends_at instead.
  • checkout.completed — now only fires when customer.subscription.created has status: active (coupon or immediate full-price).
  • trial.converted (new) — the trial's first successful charge. Detected on customer.subscription.updated via Stripe's own previous_attributes.status transitioning trialingactive (doesn't depend on our own DB write ordering, since Cashier's WebhookController dispatches WebhookReceived before syncing the local subscription row).

TrackCheckoutCompleted, TrackTrialStarted, and TrackTrialConverted shared near-identical boilerplate (enabled/account/plan guard, capture() call, $tries/$timeout) — extracted into AbstractTrackStripeSubscriptionEvent, so each job only declares its event name and properties. App\Support\StripeSubscriptionConversion now exposes baseProperties() (plan_name/interval, shared by all three) and propertiesFor() (adds conversion_* on top, for the two charge-backed events).

Also dropped two properties that were redundant with data PostHog already had: persona on every Stripe event (already set as a person property via identify() during onboarding — joinable on any event without repeating it) and plan in TrackBilling (already auto-injected by PostHogService::capture() whenever an $account is passed — the manual key was silently overwritten by the identical value).

Deliberately out of scope (product decision): tracking trial-expired-without-converting (signups minus conversions already gives that number), and the async-payment-method incomplete status edge case (card/debit only — Stripe Checkout resolves 3DS inline before redirecting back, so incomplete shouldn't occur in this flow).

Bonus fix: OAuth signup was silently dropping invites (+ bypassing the self-hosted gate)

While gating user.signed_up on ! is_invite, found that is_invite was always false for Google/GitHub registrations — even when the person arrived from an invite link — because:

  1. SocialLogin.vue's OAuth buttons never forwarded invite, unlike the email form. Accepting an invite via "Sign up with Google" silently gave the person a brand-new independent account instead of joining the inviter's account; the invite itself sat unaccepted with zero feedback.
  2. /auth/google/redirect and /auth/github/redirect were never wrapped in the registration.enabled middleware that gates /register in self-hosted mode — so self-hosted installs could be signed up into via OAuth with no invite at all.

Fixed both, and along the way removed the client-supplied redirect URL entirely rather than validating around it: login/register/OAuth now only ever accept an invite id (DB-validated via Invite::fromId()), and the backend derives the return-to-invite route itself (route('app.invites.show', $invite)). No URL is ever taken from the client, so there's no open-redirect surface to guard — Invite::fromId() mirrors the same UUID pre-check Laravel's own HasUuids trait already does for route-model-bound invites, applied manually here since these values arrive as query params/session, never as a route segment.

  • PreservesInvite trait carries the invite id across the OAuth round-trip via session (mirrors PreservesAttributionParameters).
  • registerNewUser() (Google/GitHub) now sets is_invite correctly, and both registerNewUser()/loginExistingUser() redirect to the invite page when one is present.
  • The self-hosted gate is enforced in registerNewUser() itself, since /redirect is shared with login and can't tell new vs. returning users apart beforehand.

Test plan

  • tests/Feature/Jobs/PostHog/TrackTrialStartedTest.php, TrackTrialConvertedTest.php, TrackCheckoutCompletedTest.php, TrackBillingTest.php, tests/Unit/Support/StripeSubscriptionConversionTest.php.
  • tests/Feature/Listeners/StripeEventListenerTest.php — status-based branching, previous_attributes transition detection, PostHog-disabled no-ops.
  • tests/Feature/Actions/User/CreateUserTest.php, tests/Feature/Welcome/WelcomeControllerTest.phpuser.signed_up/checkout.started capture.
  • tests/Feature/SocialLoginControllerTest.php — OAuth invite completion, pending-invite redirect on login, self-hosted gate, for both Google and GitHub.
  • tests/Unit/Models/InviteTest.php.
  • Full suite: php artisan test --compact --parallel — 3504 passed.
  • vendor/bin/pint --dirty clean, vue-tsc/eslint clean on touched frontend files.

…the backend

These 3 PostHog conversion events only fired client-side (useTracking.ts),
so ad blockers and cut-short page unloads could drop them the same way
they were dropping the GTM/ad-platform click IDs. Moves the PostHog side
to the backend, same reliability rationale, same touchpoints already
established for the click-id work:

- user.signed_up: App\Actions\User\CreateUser, right after SyncUser is
  dispatched, gated on !is_invite. auth_provider derived from
  google_id/github_id presence, same values the frontend session-based
  flow used.
- checkout.started: WelcomeController::storeReferralSource, alongside the
  existing WelcomeEvent::Referral capture, right before checkout starts.
- checkout.completed: new TrackCheckoutCompleted job, dispatched from
  StripeEventListener::handleSubscriptionCreated (webhook-driven — more
  reliable than the old frontend flow, which depended on the user staying
  on billing/Processing.vue). Conversion value/currency/transaction_id
  read from the subscription webhook payload; transaction_id is the
  Stripe subscription id rather than the old Checkout Session id.

Two new enums (UserEvent, CheckoutEvent) follow the existing per-domain
PostHog event enum convention (WelcomeEvent, BillingEvent, PostEvent).

useTracking.ts keeps its GTM dataLayer pushes (untouched, separate
concern) and drops only the captureEvent(...) calls for these 3 events —
PostHog already had CreateUser/WelcomeController/StripeEventListener as
established backend touchpoints, so this reuses them instead of adding
new infrastructure.
All 3 conversion events (sign_up, begin_checkout, purchase) now go to
PostHog exclusively from the backend, and PostHog is the single source
feeding Meta/Google/LinkedIn/etc ad destinations (not GTM). The
dataLayer.push(...) calls in useTracking.ts had no consumer left, so the
composable is now fully dead — deleted, along with its 3 call sites.

Each call site's surrounding scaffolding that existed only to support the
tracking call was simplified alongside it: ReferralSource.vue's submit()
no longer needs the onStart/onError/onHttpException/onFinish dance (that
was only there to gate trackBeginCheckout), and Processing.vue's
completePurchase() no longer reads auth.plan just to pass it to
trackPurchase().

datalayer.ts is untouched — it only pushes context variables (user name/
email, account/workspace name) that Crisp reads, not events.
…d / trial.converted

checkout.completed used to fire on every customer.subscription.created
regardless of the resulting status, conflating two different business
events: a card-required trial starting (status trialing, no charge yet)
and an immediate paid subscription starting (status active — first-month
coupon or no trial). These are now separate PostHog events:

- trial.started: subscription created with status trialing. No
  conversion_value (nothing has been charged) — carries trial_ends_at
  instead.
- checkout.completed: subscription created with status active (coupon or
  immediate full-price checkout) — unchanged behavior, still carries
  conversion_value/currency/transaction_id.
- trial.converted (new): the trial's first successful charge, detected on
  customer.subscription.updated via Stripe's own previous_attributes.status
  transitioning from trialing to active. This is the Stripe-recommended way
  to detect what changed in an .updated webhook, and doesn't depend on our
  own DB write ordering — Cashier's WebhookController dispatches
  WebhookReceived before it syncs the local subscription row, so trusting
  our own stripe_status here would be fragile.

TrackCheckoutCompleted and the new TrackTrialConverted share their
plan/interval/persona/conversion_* property computation via
App\Support\StripeSubscriptionConversion (same shape, two different
moments in the billing lifecycle) instead of duplicating it.

Deliberately out of scope per product decision: trial-expired-without-
converting tracking (signups minus conversions already gives that number),
and the async-payment-method incomplete status edge case (card/debit only,
Stripe Checkout resolves 3DS inline before redirecting back — incomplete
essentially can't happen in this flow).
…nput array

$user already has google_id/github_id populated (they were passed straight
into User::create() a few lines above), so re-reading them from $data was
redundant — same information, extra indirection.
…f-hosted registration gate

Found while reviewing why CreateUser's `! $isInviteRegistration` PostHog
gate never actually excluded anyone via Google/GitHub — because is_invite
was always false for OAuth registrations, regardless of whether the
person arrived from an invite link. Two real, pre-existing bugs:

1. SocialLogin.vue's Google/GitHub buttons linked to the OAuth redirect
   routes with no query params at all — invite, redirect and email were
   silently dropped the moment someone clicked "Sign up with Google"
   instead of using the email form. The person got a brand-new
   independent account + workspace instead of joining the inviter's
   account; the invite itself sat unaccepted with zero feedback.
2. /auth/google/redirect and /auth/github/redirect were never wrapped in
   the `registration.enabled` middleware that gates /register in
   self-hosted mode — so self-hosted installs could be signed up into via
   OAuth with no invite at all, bypassing the intended lock.

Fix:
- New PreservesInviteRedirect trait carries `invite`/`redirect` across the
  OAuth round-trip via session (PreservesAttributionParameters' pattern,
  but kept separate since this isn't marketing data).
- SocialLogin.vue now forwards `redirect`/`invite` from its parent page
  onto the Google/GitHub links; Register.vue and Login.vue pass their
  props through.
- registerNewUser() now passes the same `is_invite` semantics
  RegisterRequest already uses for the email flow, and both
  registerNewUser()/loginExistingUser() honor the pending redirect (same
  target AcceptInvite.vue already sends the email flow to), so accepting
  via OAuth now lands back on the invite page authenticated, exactly like
  email/password does — no auto-accept, same explicit-consent UX.
- The self-hosted gate can only be enforced in registerNewUser() (after
  the callback resolves an identity) since /redirect is shared with
  login and can't tell new vs. returning users apart beforehand.

New App\Models\Invite::fromId() (safe UUID-checked lookup) and
App\Support\SafeInternalRedirect (same-app-path-only check) replace
duplicated inline logic in RegisterRequest, RegisteredUserController and
AuthenticatedSessionController, and are now shared with the OAuth path
too.
… invite redirect

Never trust a redirect URL from the client. Login/register/OAuth now only
accept an invite id (already validated via Invite::fromId()) and derive the
return-to-invite route server-side, eliminating the open-redirect surface
instead of validating around it.
Str::isNotEmpty()/toString() replace manual is_string/empty checks.
Also cut oversized inline comments down to one line each.
Mirrors the existing Google coverage — GitHubController has the same
invite-completion and self-hosted-gate logic but only Google had tests for it.
TrackCheckoutCompleted, TrackTrialStarted, and TrackTrialConverted shared
near-identical boilerplate (guard clause, capture call, tries/timeout).
Extracted AbstractTrackStripeSubscriptionEvent so each job only declares its
event name and properties. StripeSubscriptionConversion now exposes
baseProperties() (plan_name/interval/persona) shared by all three, with
propertiesFor() adding conversion_* on top for the two charge-backed events.
currentStatus()/wasTrialing()/isNowActive() replace inline data_get()
comparisons in trackSubscriptionStart() and trackTrialConversion().
Persona is already set as a person property via identify() during
onboarding, so it is joinable on every event without repeating it —
sending it again on every billing capture was dead weight.
PostHogService::capture() already injects 'plan' from $account when an
account is passed — the manual key was silently overwritten by the
identical value.
Lets capture()/identify()/groupIdentify() be verified from laravel.log
during local testing (e.g. signup, invite flows) without a real PostHog
API key configured. Logging is independent of isEnabled() — the actual
dispatch to PostHog stays gated on it as before.
- Fire checkout.started only after the price-ID guard, not before it, so a
  misconfigured plan can't record a phantom checkout.started for a checkout
  that never starts (WelcomeController).
- Reorder OAuth registerNewUser() so the destructive session pull of
  attribution parameters happens after the self-hosted invite gate, not
  before — a rejected attempt no longer discards UTM/click-id attribution
  (GoogleController, GitHubController).
- Delete the SignupSuccess page/controller/route entirely: it only ever
  displayed a 5s cosmetic transition before redirecting home, its tracking
  call was already removed, and app.calendar's own middleware handles
  onboarding redirects regardless of entry point. The 3 post-registration
  redirects now go straight to app.welcome (was silently dropped to
  app.home in an earlier pass of this cleanup — welcome is correct, that
  was the whole point of the intermediate page).
- Remove dead code left behind by the useTracking.ts removal: unused
  persona/conversion props (and the Stripe API call in BillingController
  that only existed to populate them), unused auth_provider session flash
  across 3 controllers, unused captureEvent() export in posthog.ts, and
  unused RegisterRequest::isInviteRegistration().
- Add missing test coverage: login with a valid/unknown invite param
  (AuthenticatedSessionController's invite-redirect branch had zero
  coverage), and a regression test locking in the checkout.started
  ordering fix.
…eak, null interval bug

- Reject OAuth registration (Google/GitHub) when the invite's email doesn't
  match the authenticated provider account's email, mirroring the check
  RegisterRequest already enforces for the web form. Previously an invite
  for one email could be completed by signing in with a different Google/
  GitHub account, leaving a permanently workspace-less orphaned account
  (AcceptInvite's WrongEmail path never runs the shell-account cleanup,
  since that only fires on Result::Accepted).
- Fix PreservesInvite::storeInvite() to always overwrite the session value
  (matching PreservesAttributionParameters, which it claimed to mirror but
  didn't). It previously only wrote when the invite param was present,
  so a stale invite id from an aborted OAuth attempt could leak into a
  later, unrelated login/registration in the same session.
- Fix StripeSubscriptionConversion::baseProperties() mislabeling a
  conversion as 'yearly' when both the webhook price id and the plan's
  stripe_yearly_price_id are null (null === null) — now requires the plan
  price id to be non-null before comparing, matching the equivalent guard
  in App\Support\BillingCycle::intervalMonths().
- Remove the fully dead fromCheckout/Cache::add mechanism in
  BillingController::processing() — its only consumer (the frontend
  trackPurchase call) was already deleted earlier in this PR.
- Drop the unused owner eager-load in AbstractTrackStripeSubscriptionEvent
  and TrackBilling — neither reads $account->owner, only owner_id.
…e via container

- CreateInvite::execute() now lowercases the invite email before storing it.
  Invite acceptance/decline/registration all compare it verbatim against
  User.email (itself always lowercase), so a mismatched-case invite created
  before this fix could otherwise never be accepted by its own recipient.
- CreateUser::execute() resolves PostHogService from the container instead
  of `new PostHogService`, matching the DI pattern used by every other
  PostHog call site added in this PR.
…der toggles server-side; count past_due recovery as a trial conversion

- EnsureRegistrationEnabled, GoogleController, and GitHubController now
  require the invite param to resolve to a real Invite (Invite::fromId())
  instead of just checking presence. Previously any random string/UUID
  satisfied the self-hosted "invite required" gate and produced a fully
  functional account with its own workspace, defeating the restriction
  entirely.
- google_auth_enabled/github_auth_enabled were only ever read on the
  frontend to show/hide the login button — the actual OAuth routes
  (GoogleController/GitHubController::redirect(), and the settings
  connect-provider endpoint) had no backend check, so a disabled provider
  could still be used end-to-end by hitting the URL directly. Both are now
  gated with abort_unless(..., 404). The settings Authentication page also
  stops rendering a "Connect" button for a disabled, not-yet-connected
  provider.
- StripeEventListener::trackTrialConversion now also fires trial.converted
  on a past_due -> active recovery (a trial's first charge attempt failing
  and then succeeding on retry), not just the immediate trialing -> active
  transition. Guarded by trial_end being set so a long-time paying
  customer's unrelated payment-method recovery is never miscounted as a
  trial conversion.
google/github were each hand-checked against config("trypost.{provider}_auth_enabled")
independently in GoogleController, GitHubController, AuthenticationController
(3 different shapes: hardcoded config key, in_array against a private const
array, and a duplicated string list for labels), plus a fourth copy of the
enabled flags in HandleInertiaRequests. Adding a provider meant touching all
of them by hand.

App\Enums\Auth\SocialAuthProvider is now the single source of truth: cases()
replaces the PROVIDERS const array everywhere it was iterated, label()
replaces the hand-written label map, and isEnabled() replaces every direct
config() call. AuthenticationController::connectProvider() collapses its two
abort_unless checks into one via tryFrom()?->isEnabled().
…nDisconnect()

The same "{$provider}_id" dynamic-property pattern was hand-written in three
places in AuthenticationController (disconnectProvider's column lookup,
getConnectedAccounts' connected flag, canDisconnect's loop). User::isConnectedTo()
centralizes it, and canDisconnect() now reads as a single collection pipeline
("is there some other connected provider or a password") instead of a
counter-then-compare loop. disconnectProvider() also switches to the
already-resolved SocialAuthProvider throughout instead of re-deriving from
the raw string, and its flash message now uses ->label() instead of
ucfirst($provider) (which mis-cased "github" as "Github" instead of "GitHub").
REDIRECT_DELAY_MS existed to give a client-side PostHog/ad-pixel capture
call time to flush before navigating away. That call was removed earlier in
this PR (checkout.completed now fires from the Stripe webhook, server-side,
independent of this page), so the delay had nothing left to wait for —
navigate immediately once the poll confirms subscriptionActive.
GoogleController/GitHubController flash OAuth failures (wrong invite email,
GitHub email unavailable) via redirect()->route('login')->withErrors([...]).
That lands as page.props.errors (Inertia's page-level error bag), not as
the <Form> component's own local submission errors — so the InputError
bound to errors.email never showed it, silently swallowing the redirect's
whole point. Falls back to usePageErrors() (already used elsewhere in the
app for this exact scenario) when the form's own errors are empty.
Pest feature tests can only assert session state, not what actually renders
— this drives a real browser through the OAuth invite-email-mismatch
redirect and asserts the error text is visible on /login. Confirmed it
fails without the Login.vue fix (assertSee fails at the expected point)
and passes with it restored.
…pre-checks

signup, trial, and billing events never reached PostHogService::capture()
locally because CreateUser and StripeEventListener short-circuited on
isEnabled() before the local-logging path in capture() could run. Added
shouldTrack() (isEnabled() || local environment) and applied it at every
dispatch/handle guard in the chain, while the real API call in SendEvent
stays gated on isEnabled() alone so production behavior is unchanged.
…er unrelated payment retry

convertedFromTrial() used trial_end being non-null to detect a past_due ->
active recovery as a trial conversion, but Stripe never clears trial_end
once set, so the guard could never actually exclude a long-time paying
customer's unrelated card-decline recovery months later — it would fire
trial.converted again, double-counting conversion_value. Now compares the
subscription item's current_period_start against trial_end, which only
match for the trial's own first billing period.

Also reverts the CreateInvite.php Str::lower() normalization added earlier
in this branch — invite emails are stored and compared as submitted, with
no manual casing normalization anywhere.

Adds a diagnostic log in trackTrialConversion() (unconditional, not gated
on shouldTrack()) to verify this against a real Stripe webhook payload via
a test-clock walkthrough.
…ctually exists; drop diagnostic logging

WelcomeController::storeReferralSource captured checkout.started before
calling StartSubscriptionCheckout::redirect(), so a failure creating the
Stripe session (e.g. the coupon/promo-code conflict ConfigureSubscription
Checkout throws on, or any Stripe API error) still left a false-positive
conversion event in PostHog. redirect() now runs first; the capture only
fires once the checkout session was actually created.

Also removes the unconditional Log::info() added to trackTrialConversion()
for the manual Stripe test-clock verification — the current_period_start
fix it was added to confirm has now been validated against a real webhook
payload, so it's no longer needed and shouldn't keep logging on every
production subscription.updated event.
…sInvite

GoogleController and GitHubController each duplicated the same self-hosted
registration gate and invite-email-mismatch check verbatim. Moved both into
resolveInviteForRegistration() and inviteEmailMismatchRedirect() on the
shared PreservesInvite trait so a future OAuth provider (or an edit to one
controller) can't silently drift from the other on these security-relevant
checks.
@paulocastellano
paulocastellano merged commit de54ea2 into main Aug 12, 2026
3 checks passed
@paulocastellano
paulocastellano deleted the feat/backend-posthog-conversion-events branch August 12, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant