From de54ea24f958e7b71a02de94f3ad77fae4b07d06 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 12 Aug 2026 11:47:47 -0300 Subject: [PATCH] feat: fire signup/checkout PostHog events from the backend (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: fire user.signed_up, checkout.started, checkout.completed from 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. * chore: remove now-dead GTM dataLayer pushes from useTracking.ts 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. * feat: split checkout.completed into trial.started / checkout.completed / 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). * refactor: derive auth_provider from the created User model, not the input 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. * fix: OAuth signup silently drops pending invites and bypasses the self-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. * refactor: replace client-supplied redirect param with server-resolved 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. * refactor: use Request::string() for invite id, trim comments Str::isNotEmpty()/toString() replace manual is_string/empty checks. Also cut oversized inline comments down to one line each. * refactor: tighten Invite::fromId, drop redundant is_string check * test: cover GitHub invite acceptance and self-hosted gate scenarios Mirrors the existing Google coverage — GitHubController has the same invite-completion and self-hosted-gate logic but only Google had tests for it. * refactor: fold null-account check into owner_id guard via nullsafe operator * refactor: dedupe Stripe conversion tracking jobs and properties 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. * refactor: extract named status helpers in StripeEventListener currentStatus()/wasTrialing()/isNowActive() replace inline data_get() comparisons in trackSubscriptionStart() and trackTrialConversion(). * refactor: drop redundant persona from Stripe PostHog event properties 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. * refactor: drop redundant plan property in TrackBilling PostHogService::capture() already injects 'plan' from $account when an account is passed — the manual key was silently overwritten by the identical value. * feat: log PostHog payloads to laravel.log in local environment 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. * fix: cold-review pass — dead code, ordering bug, missing test coverage - 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. * fix: second cold-review pass — invite email mismatch, stale session leak, 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. * fix: normalize invite email casing at creation; resolve PostHogService 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. * fix: validate self-hosted invites against the DB; enforce OAuth provider 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. * refactor: merge the two connectProvider abort_unless checks into one * refactor: centralize social auth providers in a SocialAuthProvider enum 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(). * refactor: add User::isConnectedTo() and drop the manual foreach in canDisconnect() 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"). * refactor: remove the fixed 5s post-checkout redirect delay 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. * refactor: extract SocialProvider type instead of repeating the 'google' | 'github' union * fix: Login.vue never displayed session-flashed email errors 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
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. * test: add a browser test for the Login.vue flashed-error display fix 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. * fix: PostHog debug logging silently skipped by redundant isEnabled() 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. * fix: correctly guard past_due trial-conversion recovery against a later 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. * fix: don't fire checkout.started before the Stripe checkout session actually 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. * refactor: centralize OAuth invite-registration validation in PreservesInvite 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. --- app/Actions/User/CreateUser.php | 23 +- app/Enums/Auth/SocialAuthProvider.php | 24 + app/Enums/PostHog/CheckoutEvent.php | 11 + app/Enums/PostHog/TrialEvent.php | 11 + app/Enums/PostHog/UserEvent.php | 10 + .../Controllers/App/BillingController.php | 48 -- .../App/Settings/AuthenticationController.php | 61 +-- .../Controllers/App/WelcomeController.php | 20 +- .../Auth/AuthenticatedSessionController.php | 11 +- .../Auth/Concerns/PreservesInvite.php | 53 ++ .../Controllers/Auth/GitHubController.php | 29 +- .../Controllers/Auth/GoogleController.php | 29 +- .../Auth/RegisteredUserController.php | 14 +- .../Auth/SignupSuccessController.php | 20 - .../App/EnsureRegistrationEnabled.php | 5 +- .../Middleware/App/HandleInertiaRequests.php | 5 +- .../Requests/App/Auth/RegisterRequest.php | 19 +- .../AbstractTrackStripeSubscriptionEvent.php | 63 +++ app/Jobs/PostHog/SyncUser.php | 2 +- app/Jobs/PostHog/TrackBilling.php | 6 +- app/Jobs/PostHog/TrackCheckoutCompleted.php | 25 + app/Jobs/PostHog/TrackTrialConverted.php | 25 + app/Jobs/PostHog/TrackTrialStarted.php | 34 ++ app/Listeners/StripeEventListener.php | 116 ++++- app/Models/Invite.php | 14 + app/Models/User.php | 6 + app/Services/PostHogService.php | 64 ++- app/Support/StripeSubscriptionConversion.php | 55 +++ lang/ar/auth.php | 6 - lang/de/auth.php | 6 - lang/el/auth.php | 6 - lang/en/auth.php | 6 - lang/es/auth.php | 6 - lang/fr/auth.php | 6 - lang/it/auth.php | 6 - lang/ja/auth.php | 6 - lang/ko/auth.php | 6 - lang/nl/auth.php | 6 - lang/pl/auth.php | 6 - lang/pt-BR/auth.php | 6 - lang/ru/auth.php | 6 - lang/tr/auth.php | 6 - lang/uk/auth.php | 6 - lang/zh/auth.php | 6 - resources/js/components/auth/SocialLogin.vue | 16 +- resources/js/composables/useTracking.ts | 61 --- resources/js/pages/auth/AcceptInvite.vue | 11 +- resources/js/pages/auth/Login.vue | 14 +- resources/js/pages/auth/Register.vue | 4 +- resources/js/pages/auth/SignupSuccess.vue | 41 -- resources/js/pages/billing/Processing.vue | 51 +- .../pages/settings/profile/Authentication.vue | 12 +- resources/js/pages/welcome/ReferralSource.vue | 29 +- resources/js/posthog.ts | 13 - routes/auth.php | 3 - tests/Browser/LoginErrorMessageTest.php | 52 ++ tests/Feature/Actions/User/CreateUserTest.php | 112 +++++ tests/Feature/Auth/AuthenticationTest.php | 42 ++ tests/Feature/Auth/ConnectProviderTest.php | 11 + .../Auth/InviteRegistrationEmailTest.php | 5 +- tests/Feature/Auth/RegistrationTest.php | 35 +- .../Auth/SignupClickIdTrackingTest.php | 12 +- tests/Feature/Auth/SignupUtmTrackingTest.php | 53 +- tests/Feature/BillingControllerTest.php | 41 -- tests/Feature/GitHubAuthToggleTest.php | 27 +- tests/Feature/GoogleAuthToggleTest.php | 28 +- tests/Feature/Jobs/PostHog/SyncUserTest.php | 23 + .../Feature/Jobs/PostHog/TrackBillingTest.php | 35 +- .../PostHog/TrackCheckoutCompletedTest.php | 148 ++++++ .../Jobs/PostHog/TrackTrialConvertedTest.php | 152 ++++++ .../Jobs/PostHog/TrackTrialStartedTest.php | 175 +++++++ .../Listeners/StripeEventListenerTest.php | 376 +++++++++++++++ tests/Feature/SignupSuccessControllerTest.php | 37 -- tests/Feature/SocialLoginControllerTest.php | 456 +++++++++++++++++- .../Feature/Welcome/WelcomeControllerTest.php | 63 +++ .../Enums/Auth/SocialAuthProviderTest.php | 24 + tests/Unit/Models/InviteTest.php | 33 ++ tests/Unit/Models/UserTest.php | 8 + tests/Unit/PostHogServiceTest.php | 102 ++++ .../StripeSubscriptionConversionTest.php | 100 ++++ 80 files changed, 2694 insertions(+), 610 deletions(-) create mode 100644 app/Enums/Auth/SocialAuthProvider.php create mode 100644 app/Enums/PostHog/CheckoutEvent.php create mode 100644 app/Enums/PostHog/TrialEvent.php create mode 100644 app/Enums/PostHog/UserEvent.php create mode 100644 app/Http/Controllers/Auth/Concerns/PreservesInvite.php delete mode 100644 app/Http/Controllers/Auth/SignupSuccessController.php create mode 100644 app/Jobs/PostHog/AbstractTrackStripeSubscriptionEvent.php create mode 100644 app/Jobs/PostHog/TrackCheckoutCompleted.php create mode 100644 app/Jobs/PostHog/TrackTrialConverted.php create mode 100644 app/Jobs/PostHog/TrackTrialStarted.php create mode 100644 app/Support/StripeSubscriptionConversion.php delete mode 100644 resources/js/composables/useTracking.ts delete mode 100644 resources/js/pages/auth/SignupSuccess.vue create mode 100644 tests/Browser/LoginErrorMessageTest.php create mode 100644 tests/Feature/Jobs/PostHog/TrackCheckoutCompletedTest.php create mode 100644 tests/Feature/Jobs/PostHog/TrackTrialConvertedTest.php create mode 100644 tests/Feature/Jobs/PostHog/TrackTrialStartedTest.php delete mode 100644 tests/Feature/SignupSuccessControllerTest.php create mode 100644 tests/Unit/Enums/Auth/SocialAuthProviderTest.php create mode 100644 tests/Unit/Models/InviteTest.php create mode 100644 tests/Unit/Support/StripeSubscriptionConversionTest.php diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index d54e5a4f..d3a5303f 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -6,6 +6,7 @@ use App\Actions\Workspace\CreateWorkspace; use App\Enums\Plan\Slug; +use App\Enums\PostHog\UserEvent; use App\Jobs\PostHog\SyncUser; use App\Models\Account; use App\Models\Plan; @@ -21,8 +22,9 @@ class CreateUser */ public static function execute(array $data, array $attributionParameters = []): User { - $user = DB::transaction(function () use ($data, $attributionParameters): User { - $isInviteRegistration = data_get($data, 'is_invite', false); + $isInviteRegistration = (bool) data_get($data, 'is_invite', false); + + $user = DB::transaction(function () use ($data, $attributionParameters, $isInviteRegistration): User { $requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true); $accountAttributes = [ 'name' => data_get($data, 'name')."'s Account", @@ -56,8 +58,23 @@ public static function execute(array $data, array $attributionParameters = []): return $user; }); - if (PostHogService::isEnabled()) { + if (PostHogService::shouldTrack()) { SyncUser::dispatch((string) $user->id); + + if (! $isInviteRegistration) { + $authProvider = match (true) { + (bool) $user->google_id => 'google', + (bool) $user->github_id => 'github', + default => 'email', + }; + + app(PostHogService::class)->capture( + (string) $user->id, + UserEvent::SignedUp->value, + ['auth_provider' => $authProvider], + $user->account, + ); + } } return $user; diff --git a/app/Enums/Auth/SocialAuthProvider.php b/app/Enums/Auth/SocialAuthProvider.php new file mode 100644 index 00000000..ebb2179f --- /dev/null +++ b/app/Enums/Auth/SocialAuthProvider.php @@ -0,0 +1,24 @@ + 'Google', + self::GitHub => 'GitHub', + }; + } + + public function isEnabled(): bool + { + return (bool) config("trypost.{$this->value}_auth_enabled"); + } +} diff --git a/app/Enums/PostHog/CheckoutEvent.php b/app/Enums/PostHog/CheckoutEvent.php new file mode 100644 index 00000000..8e5f65f9 --- /dev/null +++ b/app/Enums/PostHog/CheckoutEvent.php @@ -0,0 +1,11 @@ +user(); $account = $user->accountOrFail(); - $sessionId = $request->string('session_id')->toString(); - - // 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 = filled($sessionId) - && Cache::add("checkout_tracked:{$sessionId}", true, now()->addDay()); $subscriptionActive = $account->subscribed(Account::SUBSCRIPTION_NAME); $redirectToOnboarding = $user->isAccountOwner() @@ -44,48 +34,10 @@ public function processing(Request $request): Response|RedirectResponse return Inertia::render('billing/Processing', [ 'subscriptionActive' => $subscriptionActive, - 'fromCheckout' => $fromCheckout, 'redirectToOnboarding' => $redirectToOnboarding, - 'persona' => $user->persona?->value, - 'conversion' => $fromCheckout && $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/app/Http/Controllers/App/Settings/AuthenticationController.php b/app/Http/Controllers/App/Settings/AuthenticationController.php index cb7d29be..24fd1bfa 100644 --- a/app/Http/Controllers/App/Settings/AuthenticationController.php +++ b/app/Http/Controllers/App/Settings/AuthenticationController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\App\Settings; +use App\Enums\Auth\SocialAuthProvider; use App\Http\Controllers\App\Controller; use App\Http\Requests\App\Settings\AuthenticationPasswordRequest; use App\Models\User; @@ -18,8 +19,6 @@ class AuthenticationController extends Controller { - private const array PROVIDERS = ['google', 'github']; - public function edit(Request $request): Response { $user = $request->user(); @@ -66,33 +65,36 @@ public function destroyOtherSessions(Request $request): RedirectResponse public function connectProvider(string $provider): RedirectResponse { - abort_unless(in_array($provider, self::PROVIDERS, true), 404); + $socialProvider = SocialAuthProvider::tryFrom($provider); - return match ($provider) { - 'google' => Socialite::driver('google-auth')->redirect(), - 'github' => Socialite::driver('github')->scopes(['read:user', 'user:email'])->redirect(), + abort_unless($socialProvider?->isEnabled(), 404); + + return match ($socialProvider) { + SocialAuthProvider::Google => Socialite::driver('google-auth')->redirect(), + SocialAuthProvider::GitHub => Socialite::driver('github')->scopes(['read:user', 'user:email'])->redirect(), }; } public function disconnectProvider(Request $request, string $provider): RedirectResponse { - abort_unless(in_array($provider, self::PROVIDERS, true), 404); + $socialProvider = SocialAuthProvider::tryFrom($provider); + + abort_unless($socialProvider !== null, 404); $user = $request->user(); - $column = "{$provider}_id"; - if (! $user->{$column}) { + if (! $user->isConnectedTo($socialProvider)) { return back(); } - if (! $this->canDisconnect($user, $provider)) { + if (! $this->canDisconnect($user, $socialProvider)) { return back()->with('flash.error', __('settings.authentication.providers.flash_cannot_disconnect')); } - $user->update([$column => null]); + $user->update(["{$socialProvider->value}_id" => null]); return back()->with('flash.success', __('settings.authentication.providers.flash_disconnected', [ - 'provider' => ucfirst($provider), + 'provider' => $socialProvider->label(), ])); } @@ -124,37 +126,22 @@ private function getSessions(Request $request): array */ private function getConnectedAccounts(User $user): array { - $labels = [ - 'google' => 'Google', - 'github' => 'GitHub', - ]; - - return collect(self::PROVIDERS)->map(fn (string $provider) => [ - 'provider' => $provider, - 'label' => $labels[$provider], - 'connected' => (bool) $user->{"{$provider}_id"}, - 'can_disconnect' => $user->{"{$provider}_id"} && $this->canDisconnect($user, $provider), + return collect(SocialAuthProvider::cases())->map(fn (SocialAuthProvider $provider) => [ + 'provider' => $provider->value, + 'label' => $provider->label(), + 'connected' => $user->isConnectedTo($provider), + 'can_disconnect' => $user->isConnectedTo($provider) && $this->canDisconnect($user, $provider), ])->values()->all(); } - private function canDisconnect(User $user, string $provider): bool + private function canDisconnect(User $user, SocialAuthProvider $provider): bool { - $remainingMethods = 0; - if ($user->password) { - $remainingMethods++; + return true; } - foreach (self::PROVIDERS as $other) { - if ($other === $provider) { - continue; - } - - if ($user->{"{$other}_id"}) { - $remainingMethods++; - } - } - - return $remainingMethods > 0; + return collect(SocialAuthProvider::cases()) + ->filter(fn (SocialAuthProvider $other) => $other !== $provider) + ->contains(fn (SocialAuthProvider $other) => $user->isConnectedTo($other)); } } diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index 1db42bc4..485e1c3a 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -6,6 +6,7 @@ use App\Actions\Billing\StartSubscriptionCheckout; use App\Enums\Plan\Slug; +use App\Enums\PostHog\CheckoutEvent; use App\Enums\PostHog\WelcomeEvent; use App\Enums\User\Goal; use App\Enums\User\Persona; @@ -144,24 +145,25 @@ public function storeReferralSource( $user->account, ); - return $this->startCheckout($request, $checkout); - } - - private function startCheckout( - Request $request, - StartSubscriptionCheckout $checkout, - ): Response|RedirectResponse { - $user = $request->user(); $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); $priceId = $plan->stripe_monthly_price_id; abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Monthly price is not configured.'); - return $checkout->redirect( + $response = $checkout->redirect( $user->account, $priceId, route('app.welcome.referral-source'), ); + + $postHog->capture( + $user->id, + CheckoutEvent::Started->value, + ['plan_name' => $plan->name, 'interval' => 'monthly'], + $user->account, + ); + + return $response; } public function subscriptionRequired(Request $request): InertiaResponse|RedirectResponse diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 423f408f..3d0598c3 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\App\Auth\LoginRequest; +use App\Models\Invite; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -22,7 +23,7 @@ public function create(Request $request): Response return Inertia::render('auth/Login', [ 'status' => session('status'), 'email' => $request->query('email'), - 'redirect' => $request->query('redirect'), + 'invite' => $request->query('invite'), ]); } @@ -35,12 +36,8 @@ public function store(LoginRequest $request): RedirectResponse $request->session()->regenerate(); - // Check for redirect param - if ($redirect = $request->input('redirect')) { - // Only allow internal redirects (paths starting with /) - if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) { - return redirect($redirect); - } + if ($invite = Invite::fromId($request->string('invite')->toString())) { + return redirect()->route('app.invites.show', $invite); } return redirect()->intended(route('app.calendar')); diff --git a/app/Http/Controllers/Auth/Concerns/PreservesInvite.php b/app/Http/Controllers/Auth/Concerns/PreservesInvite.php new file mode 100644 index 00000000..01b5b4c9 --- /dev/null +++ b/app/Http/Controllers/Auth/Concerns/PreservesInvite.php @@ -0,0 +1,53 @@ +session()->put('oauth_invite_id', $request->string('invite')->toString()); + } + + private function retrieveInvite(): ?string + { + return session()->pull('oauth_invite_id'); + } + + /** + * Mirrors the registration.enabled middleware: self-hosted requires a + * real invite to register. + */ + private function resolveInviteForRegistration(): ?Invite + { + $invite = Invite::fromId($this->retrieveInvite()); + + if ((bool) config('trypost.self_hosted') && ! $invite) { + throw new NotFoundHttpException; + } + + return $invite; + } + + private function inviteEmailMismatchRedirect(?Invite $invite, ?string $oauthEmail): ?RedirectResponse + { + if ($invite && $invite->email !== $oauthEmail) { + return redirect()->route('login')->withErrors([ + 'email' => __('settings.members.flash.wrong_email'), + ]); + } + + return null; + } +} diff --git a/app/Http/Controllers/Auth/GitHubController.php b/app/Http/Controllers/Auth/GitHubController.php index 5b11104d..2a04ca5e 100644 --- a/app/Http/Controllers/Auth/GitHubController.php +++ b/app/Http/Controllers/Auth/GitHubController.php @@ -5,8 +5,11 @@ namespace App\Http\Controllers\Auth; use App\Actions\User\CreateUser; +use App\Enums\Auth\SocialAuthProvider; use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters; +use App\Http\Controllers\Auth\Concerns\PreservesInvite; use App\Http\Controllers\Controller; +use App\Models\Invite; use App\Models\User; use Illuminate\Auth\Events\Registered; use Illuminate\Http\RedirectResponse; @@ -16,11 +19,14 @@ class GitHubController extends Controller { - use PreservesAttributionParameters; + use PreservesAttributionParameters, PreservesInvite; public function redirect(Request $request): RedirectResponse { + abort_unless(SocialAuthProvider::GitHub->isEnabled(), 404); + $this->storeAttributionParameters($request); + $this->storeInvite($request); return Socialite::driver('github') ->scopes(['read:user', 'user:email']) @@ -35,9 +41,7 @@ public function callback(): RedirectResponse return redirect()->route('login'); } - // The signup/login redirect is gated by the `guest` middleware and - // the connect-from-settings redirect by `auth`, so this is a safe - // signal for which flow we came from. + // `guest` middleware gates login/signup; `auth` gates the settings connect flow. if (Auth::check()) { return $this->connectToCurrentUser(Auth::user(), (string) $githubUser->getId()); } @@ -92,11 +96,21 @@ private function loginExistingUser(User $user, string $githubId): RedirectRespon $this->retrieveAttributionParameters(); + if ($invite = Invite::fromId($this->retrieveInvite())) { + return redirect()->route('app.invites.show', $invite); + } + return redirect()->route('app.home'); } private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): RedirectResponse { + $invite = $this->resolveInviteForRegistration(); + + if ($redirect = $this->inviteEmailMismatchRedirect($invite, $githubUser->getEmail())) { + return $redirect; + } + $attributionParameters = $this->retrieveAttributionParameters(); $user = CreateUser::execute([ @@ -104,6 +118,7 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): 'email' => $githubUser->getEmail(), 'github_id' => (string) $githubUser->getId(), 'email_verified_at' => now(), + 'is_invite' => $invite !== null, 'registration_ip' => request()->ip(), ], $attributionParameters); @@ -111,8 +126,10 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): Auth::login($user, remember: true); - session()->flash('auth_provider', 'github'); + if ($invite) { + return redirect()->route('app.invites.show', $invite); + } - return redirect()->route('register.success', $attributionParameters); + return redirect()->route('app.welcome'); } } diff --git a/app/Http/Controllers/Auth/GoogleController.php b/app/Http/Controllers/Auth/GoogleController.php index 378ab9b9..8cc59c1d 100644 --- a/app/Http/Controllers/Auth/GoogleController.php +++ b/app/Http/Controllers/Auth/GoogleController.php @@ -5,8 +5,11 @@ namespace App\Http\Controllers\Auth; use App\Actions\User\CreateUser; +use App\Enums\Auth\SocialAuthProvider; use App\Http\Controllers\Auth\Concerns\PreservesAttributionParameters; +use App\Http\Controllers\Auth\Concerns\PreservesInvite; use App\Http\Controllers\Controller; +use App\Models\Invite; use App\Models\User; use Illuminate\Auth\Events\Registered; use Illuminate\Http\RedirectResponse; @@ -16,11 +19,14 @@ class GoogleController extends Controller { - use PreservesAttributionParameters; + use PreservesAttributionParameters, PreservesInvite; public function redirect(Request $request): RedirectResponse { + abort_unless(SocialAuthProvider::Google->isEnabled(), 404); + $this->storeAttributionParameters($request); + $this->storeInvite($request); return Socialite::driver('google-auth')->redirect(); } @@ -33,9 +39,7 @@ public function callback(): RedirectResponse return redirect()->route('login'); } - // The signup/login redirect is gated by the `guest` middleware and - // the connect-from-settings redirect by `auth`, so this is a safe - // signal for which flow we came from. + // `guest` middleware gates login/signup; `auth` gates the settings connect flow. if (Auth::check()) { return $this->connectToCurrentUser(Auth::user(), $googleUser->getId()); } @@ -84,11 +88,21 @@ private function loginExistingUser(User $user, string $googleId): RedirectRespon $this->retrieveAttributionParameters(); + if ($invite = Invite::fromId($this->retrieveInvite())) { + return redirect()->route('app.invites.show', $invite); + } + return redirect()->route('app.home'); } private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): RedirectResponse { + $invite = $this->resolveInviteForRegistration(); + + if ($redirect = $this->inviteEmailMismatchRedirect($invite, $googleUser->getEmail())) { + return $redirect; + } + $attributionParameters = $this->retrieveAttributionParameters(); $user = CreateUser::execute([ @@ -96,6 +110,7 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): 'email' => $googleUser->getEmail(), 'google_id' => $googleUser->getId(), 'email_verified_at' => now(), + 'is_invite' => $invite !== null, 'registration_ip' => request()->ip(), ], $attributionParameters); @@ -103,8 +118,10 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): Auth::login($user, remember: true); - session()->flash('auth_provider', 'google'); + if ($invite) { + return redirect()->route('app.invites.show', $invite); + } - return redirect()->route('register.success', $attributionParameters); + return redirect()->route('app.welcome'); } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 273f9f56..0a0a46b6 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -25,7 +25,6 @@ public function create(Request $request): Response return Inertia::render('auth/Register', [ 'email' => $request->query('email'), - 'redirect' => $request->query('redirect'), 'invite' => $request->query('invite'), ]); } @@ -33,12 +32,13 @@ public function create(Request $request): Response public function store(RegisterRequest $request): RedirectResponse { $attributionParameters = $this->retrieveAttributionParameters(); + $invite = $request->invite(); $user = CreateUser::execute([ 'name' => $request->validated('name'), 'email' => $request->validated('email'), 'password' => $request->validated('password'), - 'is_invite' => $request->isInviteRegistration(), + 'is_invite' => $invite !== null, 'registration_ip' => $request->ip(), ], $attributionParameters); @@ -48,14 +48,10 @@ public function store(RegisterRequest $request): RedirectResponse $request->session()->forget('pending_invite_id'); - if ($redirect = $request->input('redirect')) { - if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) { - return redirect($redirect); - } + if ($invite) { + return redirect()->route('app.invites.show', $invite); } - session()->flash('auth_provider', 'email'); - - return redirect()->route('register.success', $attributionParameters); + return redirect()->route('app.welcome'); } } diff --git a/app/Http/Controllers/Auth/SignupSuccessController.php b/app/Http/Controllers/Auth/SignupSuccessController.php deleted file mode 100644 index d4236586..00000000 --- a/app/Http/Controllers/Auth/SignupSuccessController.php +++ /dev/null @@ -1,20 +0,0 @@ - $request->session()->get('auth_provider', 'email'), - ]); - } -} diff --git a/app/Http/Middleware/App/EnsureRegistrationEnabled.php b/app/Http/Middleware/App/EnsureRegistrationEnabled.php index 7df04d28..5767a5b5 100644 --- a/app/Http/Middleware/App/EnsureRegistrationEnabled.php +++ b/app/Http/Middleware/App/EnsureRegistrationEnabled.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware\App; +use App\Models\Invite; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -18,7 +19,9 @@ public function handle(Request $request, Closure $next): mixed // `query` covers the GET form; `input` covers the invite field posted // with the registration form (a hidden input, not a query param). - if ($inviteId = $request->query('invite') ?? $request->input('invite') ?? $request->session()->get('pending_invite_id')) { + $inviteId = $request->query('invite') ?? $request->input('invite') ?? $request->session()->get('pending_invite_id'); + + if (is_string($inviteId) && Invite::fromId($inviteId) !== null) { $request->session()->put('pending_invite_id', $inviteId); return $next($request); diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index fa2c8823..756ed7f8 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware\App; use App\Actions\Onboarding\ResolveOnboardingStatus; +use App\Enums\Auth\SocialAuthProvider; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource; use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource; @@ -64,8 +65,8 @@ public function share(Request $request): array ])->values()->all(), 'aiEnabled' => filled(config('ai.providers.'.config('ai.default').'.key')), 'selfHosted' => $isSelfHosted, - 'googleAuthEnabled' => config('trypost.google_auth_enabled'), - 'githubAuthEnabled' => config('trypost.github_auth_enabled'), + 'googleAuthEnabled' => SocialAuthProvider::Google->isEnabled(), + 'githubAuthEnabled' => SocialAuthProvider::GitHub->isEnabled(), ]; } diff --git a/app/Http/Requests/App/Auth/RegisterRequest.php b/app/Http/Requests/App/Auth/RegisterRequest.php index 28cfc62e..13b24f6e 100644 --- a/app/Http/Requests/App/Auth/RegisterRequest.php +++ b/app/Http/Requests/App/Auth/RegisterRequest.php @@ -8,7 +8,6 @@ use App\Models\User; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Str; use Illuminate\Validation\Rules; use Illuminate\Validation\Validator; @@ -36,25 +35,9 @@ public function rules(): array ]; } - /** - * A valid invite binds registration to the invited email — never a - * different one. Non-UUID invite params are self-hosted gate placeholders - * and are ignored. - */ public function invite(): ?Invite { - $inviteId = (string) $this->input('invite', ''); - - if ($inviteId === '' || ! Str::isUuid($inviteId)) { - return null; - } - - return Invite::query()->find($inviteId); - } - - public function isInviteRegistration(): bool - { - return $this->invite() !== null; + return Invite::fromId($this->string('invite')->toString()); } public function withValidator(Validator $validator): void diff --git a/app/Jobs/PostHog/AbstractTrackStripeSubscriptionEvent.php b/app/Jobs/PostHog/AbstractTrackStripeSubscriptionEvent.php new file mode 100644 index 00000000..884479ef --- /dev/null +++ b/app/Jobs/PostHog/AbstractTrackStripeSubscriptionEvent.php @@ -0,0 +1,63 @@ + $payload + */ + public function __construct( + public string $accountId, + public array $payload, + ) { + $this->onQueue('posthog'); + } + + public function handle(PostHogService $postHog): void + { + if (! PostHogService::shouldTrack()) { + return; + } + + $account = Account::with('plan')->find($this->accountId); + + if (! $account?->owner_id || ! $account->plan) { + return; + } + + $postHog->capture( + (string) $account->owner_id, + $this->event(), + $this->properties($account), + $account, + ); + } + + abstract protected function event(): string; + + /** + * @return array + */ + abstract protected function properties(Account $account): array; +} diff --git a/app/Jobs/PostHog/SyncUser.php b/app/Jobs/PostHog/SyncUser.php index 64816807..d2a880c5 100644 --- a/app/Jobs/PostHog/SyncUser.php +++ b/app/Jobs/PostHog/SyncUser.php @@ -28,7 +28,7 @@ public function __construct(public string $userId) public function handle(PostHogService $postHog): void { - if (! PostHogService::isEnabled()) { + if (! PostHogService::shouldTrack()) { return; } diff --git a/app/Jobs/PostHog/TrackBilling.php b/app/Jobs/PostHog/TrackBilling.php index 5994b0ed..2542268f 100644 --- a/app/Jobs/PostHog/TrackBilling.php +++ b/app/Jobs/PostHog/TrackBilling.php @@ -35,11 +35,11 @@ public function __construct( public function handle(PostHogService $postHog): void { - if (! PostHogService::isEnabled()) { + if (! PostHogService::shouldTrack()) { return; } - $account = Account::with(['plan', 'owner'])->find($this->accountId); + $account = Account::with('plan')->find($this->accountId); if (! $account || ! $account->owner_id) { return; @@ -50,10 +50,8 @@ public function handle(PostHogService $postHog): void $this->event->value, [ 'stripe_status' => data_get($this->payload, 'data.object.status'), - 'plan' => $account->plan?->name, 'plan_slug' => $account->plan?->slug->value, 'previous_plan' => $this->previousPlan, - 'persona' => $account->owner?->persona?->value, ], $account, ); diff --git a/app/Jobs/PostHog/TrackCheckoutCompleted.php b/app/Jobs/PostHog/TrackCheckoutCompleted.php new file mode 100644 index 00000000..4f577d18 --- /dev/null +++ b/app/Jobs/PostHog/TrackCheckoutCompleted.php @@ -0,0 +1,25 @@ +value; + } + + /** + * @return array + */ + protected function properties(Account $account): array + { + return StripeSubscriptionConversion::propertiesFor($account, $this->payload); + } +} diff --git a/app/Jobs/PostHog/TrackTrialConverted.php b/app/Jobs/PostHog/TrackTrialConverted.php new file mode 100644 index 00000000..ebf7a283 --- /dev/null +++ b/app/Jobs/PostHog/TrackTrialConverted.php @@ -0,0 +1,25 @@ +value; + } + + /** + * @return array + */ + protected function properties(Account $account): array + { + return StripeSubscriptionConversion::propertiesFor($account, $this->payload); + } +} diff --git a/app/Jobs/PostHog/TrackTrialStarted.php b/app/Jobs/PostHog/TrackTrialStarted.php new file mode 100644 index 00000000..0c0db097 --- /dev/null +++ b/app/Jobs/PostHog/TrackTrialStarted.php @@ -0,0 +1,34 @@ +value; + } + + /** + * @return array + */ + protected function properties(Account $account): array + { + $properties = StripeSubscriptionConversion::baseProperties($account, $this->payload); + + $trialEnd = data_get($this->payload, 'data.object.trial_end'); + + if (is_int($trialEnd)) { + $properties['trial_ends_at'] = Carbon::createFromTimestamp($trialEnd)->toIso8601String(); + } + + return $properties; + } +} diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 14e0cd19..ba734a39 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -6,9 +6,13 @@ use App\Enums\PostHog\BillingEvent; use App\Jobs\PostHog\TrackBilling; +use App\Jobs\PostHog\TrackCheckoutCompleted; +use App\Jobs\PostHog\TrackTrialConverted; +use App\Jobs\PostHog\TrackTrialStarted; use App\Models\Account; use App\Models\Plan; use App\Services\PostHogService; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookReceived; @@ -17,6 +21,10 @@ class StripeEventListener public function handle(WebhookReceived $event): void { try { + if ($this->alreadyProcessed(data_get($event->payload, 'id'))) { + return; + } + $type = data_get($event->payload, 'type'); $stripeCustomerId = data_get($event->payload, 'data.object.customer'); @@ -59,6 +67,7 @@ protected function handleSubscriptionCreated(Account $account, array $payload): } $this->trackPlanChange($account, BillingEvent::Created, $previousPlan, $payload); + $this->trackSubscriptionStart($account, $payload); } /** @@ -73,6 +82,7 @@ protected function handleSubscriptionUpdated(Account $account, array $payload): } $this->trackPlanChange($account, BillingEvent::Updated, $previousPlan, $payload); + $this->trackTrialConversion($account, $payload); } /** @@ -91,6 +101,22 @@ protected function handleSubscriptionDeleted(Account $account, array $payload): $this->trackPlanChange($account, BillingEvent::Cancelled, $previousPlan, $payload); } + /** + * Stripe redelivers the same event id on a timeout/non-2xx response (and + * it can otherwise reach us more than once, e.g. a duplicated local + * forwarding setup). Cache::add is atomic, so only the first delivery of + * a given event id returns false here — every side effect further down + * (plan_id updates, PostHog dispatches) only ever runs once per event. + */ + private function alreadyProcessed(?string $eventId): bool + { + if (! $eventId) { + return false; + } + + return ! Cache::add("stripe_webhook_event:{$eventId}", true, now()->addDay()); + } + /** * @param array $payload */ @@ -127,10 +153,98 @@ private function resolvePlanFromSubscriptionItems(array $payload, Account $accou */ private function trackPlanChange(Account $account, BillingEvent $event, ?string $previousPlan, array $payload): void { - if (! PostHogService::isEnabled()) { + if (! PostHogService::shouldTrack()) { return; } TrackBilling::dispatch((string) $account->id, $event, $payload, $previousPlan); } + + /** + * A subscription is born either `trialing` (card-required trial, no + * charge yet) or `active` (first-month coupon or no trial — charged + * immediately). These are different business events, not the same + * "checkout completed" moment — see App\Support\StripeSubscriptionConversion. + * + * @param array $payload + */ + private function trackSubscriptionStart(Account $account, array $payload): void + { + if (! PostHogService::shouldTrack()) { + return; + } + + match ($this->currentStatus($payload)) { + 'trialing' => TrackTrialStarted::dispatch((string) $account->id, $payload), + 'active' => TrackCheckoutCompleted::dispatch((string) $account->id, $payload), + default => null, + }; + } + + /** + * Fires on the trial's first successful charge, using Stripe's own + * `previous_attributes` rather than trusting local DB state — Cashier's + * WebhookController dispatches WebhookReceived before it updates the + * local subscription row, so relying on our own `stripe_status` here + * would be fragile if that internal ordering ever changes. + * + * @param array $payload + */ + private function trackTrialConversion(Account $account, array $payload): void + { + if (! PostHogService::shouldTrack()) { + return; + } + + if ($this->convertedFromTrial($payload) && $this->isNowActive($payload)) { + TrackTrialConverted::dispatch((string) $account->id, $payload); + } + } + + /** + * @param array $payload + */ + private function currentStatus(array $payload): mixed + { + return data_get($payload, 'data.object.status'); + } + + /** + * True for a transition originating from a trial: either the immediate + * `trialing` -> `active` charge, or a `past_due` -> `active` recovery + * after the trial's first charge attempt failed. `trial_end` is never + * cleared by Stripe once set, so it can't by itself distinguish that + * recovery from an unrelated `past_due` -> `active` recovery on a much + * later billing cycle (e.g. a long-time paying customer's card getting + * declined and then updated). The item's `current_period_start` is what + * actually pins this to the trial's own first billing period — it only + * equals `trial_end` for that first period, never for a later one. + * + * @param array $payload + */ + private function convertedFromTrial(array $payload): bool + { + $previousStatus = data_get($payload, 'data.previous_attributes.status'); + + if ($previousStatus === 'trialing') { + return true; + } + + if ($previousStatus !== 'past_due') { + return false; + } + + $trialEnd = data_get($payload, 'data.object.trial_end'); + $currentPeriodStart = data_get($payload, 'data.object.items.data.0.current_period_start'); + + return $trialEnd !== null && $currentPeriodStart === $trialEnd; + } + + /** + * @param array $payload + */ + private function isNowActive(array $payload): bool + { + return $this->currentStatus($payload) === 'active'; + } } diff --git a/app/Models/Invite.php b/app/Models/Invite.php index dd42966e..201d0411 100644 --- a/app/Models/Invite.php +++ b/app/Models/Invite.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Str; class Invite extends Model { @@ -43,4 +44,17 @@ public function invitedBy(): BelongsTo { return $this->belongsTo(User::class, 'invited_by'); } + + /** + * `id` is a native Postgres uuid column — find() on a non-UUID string + * raises a type-cast error rather than returning null. + */ + public static function fromId(?string $id): ?self + { + if (! $id || ! Str::isUuid($id)) { + return null; + } + + return self::find($id); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 95788b56..7557cb44 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\Auth\SocialAuthProvider; use App\Enums\Notification\Type as NotificationType; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; @@ -127,4 +128,9 @@ public function wantsEmailFor(NotificationType $type): bool default => true, }; } + + public function isConnectedTo(SocialAuthProvider $provider): bool + { + return (bool) $this->{"{$provider->value}_id"}; + } } diff --git a/app/Services/PostHogService.php b/app/Services/PostHogService.php index 54b38332..86ced557 100644 --- a/app/Services/PostHogService.php +++ b/app/Services/PostHogService.php @@ -17,15 +17,22 @@ public static function isEnabled(): bool && (bool) config('services.posthog.api_key'); } + /** + * Gate for call sites that pre-check before ever reaching capture()/ + * identify() — e.g. to skip dispatching a job at all when disabled. + * Also true in the local environment so that path still logs locally + * (via capture()'s own logLocally()) even without a real API key. + */ + public static function shouldTrack(): bool + { + return self::isEnabled() || app()->environment('local'); + } + /** * @param array $properties */ public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void { - if (! self::isEnabled()) { - return; - } - $payload = [ 'distinctId' => $distinctId, 'event' => $event, @@ -38,7 +45,11 @@ public function capture(string $distinctId, string $event, array $properties = [ $payload['properties']['plan'] = $account->plan?->name; } - $this->dispatch('capture', $payload); + $this->logLocally('capture', $payload); + + if (self::isEnabled()) { + $this->dispatch('capture', $payload); + } } /** @@ -46,14 +57,16 @@ public function capture(string $distinctId, string $event, array $properties = [ */ public function identify(string $distinctId, array $properties = []): void { - if (! self::isEnabled()) { - return; - } - - $this->dispatch('identify', [ + $payload = [ 'distinctId' => $distinctId, 'properties' => $properties, - ]); + ]; + + $this->logLocally('identify', $payload); + + if (self::isEnabled()) { + $this->dispatch('identify', $payload); + } } /** @@ -61,15 +74,32 @@ public function identify(string $distinctId, array $properties = []): void */ public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void { - if (! self::isEnabled()) { - return; - } - - $this->dispatch('groupIdentify', [ + $payload = [ 'groupType' => $groupType, 'groupKey' => $groupKey, 'properties' => $properties, - ]); + ]; + + $this->logLocally('groupIdentify', $payload); + + if (self::isEnabled()) { + $this->dispatch('groupIdentify', $payload); + } + } + + /** + * Local-only visibility into what would be sent to PostHog, so events can + * be verified from laravel.log without a real API key configured. + * + * @param array $payload + */ + private function logLocally(string $method, array $payload): void + { + if (! app()->environment('local')) { + return; + } + + Log::info("PostHogService: {$method}", $payload); } /** diff --git a/app/Support/StripeSubscriptionConversion.php b/app/Support/StripeSubscriptionConversion.php new file mode 100644 index 00000000..7e289c99 --- /dev/null +++ b/app/Support/StripeSubscriptionConversion.php @@ -0,0 +1,55 @@ + $payload + * @return array + */ + public static function baseProperties(Account $account, array $payload): array + { + $priceId = data_get($payload, 'data.object.items.data.0.price.id'); + $yearlyPriceId = $account->plan->stripe_yearly_price_id; + $isYearly = $yearlyPriceId !== null && $priceId === $yearlyPriceId; + + return [ + 'plan_name' => $account->plan->name, + 'interval' => $isYearly ? 'yearly' : 'monthly', + ]; + } + + /** + * baseProperties() plus conversion_* fields, for the two events backed by + * an actual charge: TrackCheckoutCompleted and TrackTrialConverted. + * + * @param array $payload + * @return array + */ + public static function propertiesFor(Account $account, array $payload): array + { + $properties = self::baseProperties($account, $payload); + + $unitAmount = data_get($payload, 'data.object.items.data.0.price.unit_amount'); + $currency = data_get($payload, 'data.object.items.data.0.price.currency'); + + if (is_int($unitAmount) && is_string($currency)) { + $properties['conversion_value'] = (float) ($unitAmount / 100); + $properties['conversion_currency'] = strtoupper($currency); + $properties['conversion_transaction_id'] = data_get($payload, 'data.object.id'); + } + + return $properties; + } +} diff --git a/lang/ar/auth.php b/lang/ar/auth.php index ac5f6858..180f90dd 100644 --- a/lang/ar/auth.php +++ b/lang/ar/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'التسجيل عبر GitHub', 'github_email_unavailable' => 'تعذر جلب بريدك الإلكتروني من GitHub. اجعل بريدك على GitHub عامًا أو امنح نطاق الوصول إلى البريد، ثم حاول مرة أخرى.', - 'signup_success' => [ - 'page_title' => 'مرحبًا', - 'title' => 'جارٍ إعداد حسابك', - 'description' => 'يستغرق هذا عادةً بضع ثوانٍ فقط...', - ], - 'login' => [ 'title' => 'تسجيل الدخول إلى حسابك', 'description' => 'أدخل بريدك الإلكتروني وكلمة المرور أدناه لتسجيل الدخول', diff --git a/lang/de/auth.php b/lang/de/auth.php index 75721ef6..33733248 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -61,12 +61,6 @@ 'github_signup' => 'Mit GitHub registrieren', 'github_email_unavailable' => 'Deine E-Mail-Adresse konnte nicht von GitHub abgerufen werden. Mache deine GitHub-E-Mail-Adresse öffentlich oder erteile die Berechtigung für den E-Mail-Zugriff und versuche es dann erneut.', - 'signup_success' => [ - 'page_title' => 'Willkommen', - 'title' => 'Dein Konto wird eingerichtet', - 'description' => 'Das dauert normalerweise nur wenige Sekunden...', - ], - 'login' => [ 'title' => 'Melde dich bei deinem Konto an', 'description' => 'Gib unten deine E-Mail-Adresse und dein Passwort ein, um dich anzumelden', diff --git a/lang/el/auth.php b/lang/el/auth.php index f128cc62..2b480cda 100644 --- a/lang/el/auth.php +++ b/lang/el/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'Εγγραφή με GitHub', 'github_email_unavailable' => 'Δεν ήταν δυνατή η ανάκτηση του email σας από το GitHub. Κάντε δημόσιο το email σας στο GitHub ή παραχωρήστε το scope email και δοκιμάστε ξανά.', - 'signup_success' => [ - 'page_title' => 'Καλώς ήρθατε', - 'title' => 'Ρύθμιση του λογαριασμού σας', - 'description' => 'Αυτό συνήθως διαρκεί μόνο λίγα δευτερόλεπτα...', - ], - 'login' => [ 'title' => 'Συνδεθείτε στον λογαριασμό σας', 'description' => 'Εισάγετε το email και τον κωδικό πρόσβασής σας παρακάτω για να συνδεθείτε', diff --git a/lang/en/auth.php b/lang/en/auth.php index 71fc5f38..637a9441 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'Sign up with GitHub', 'github_email_unavailable' => 'Unable to retrieve your email from GitHub. Make your GitHub email public or grant the email scope, then try again.', - 'signup_success' => [ - 'page_title' => 'Welcome', - 'title' => 'Setting up your account', - 'description' => 'This usually takes just a few seconds...', - ], - 'login' => [ 'title' => 'Log in to your account', 'description' => 'Enter your email and password below to log in', diff --git a/lang/es/auth.php b/lang/es/auth.php index 520f83f7..77445a99 100644 --- a/lang/es/auth.php +++ b/lang/es/auth.php @@ -47,12 +47,6 @@ 'github_signup' => 'Registrarse con GitHub', 'github_email_unavailable' => 'No fue posible obtener tu correo de GitHub. Haz tu correo público en GitHub o concede el permiso de correo y vuelve a intentar.', - 'signup_success' => [ - 'page_title' => 'Bienvenido', - 'title' => 'Configurando tu cuenta', - 'description' => 'Esto suele tardar solo unos segundos...', - ], - 'login' => [ 'title' => 'Inicia sesión en tu cuenta', 'description' => 'Introduce tu correo y contraseña para iniciar sesión', diff --git a/lang/fr/auth.php b/lang/fr/auth.php index cd85c421..904b4c1c 100644 --- a/lang/fr/auth.php +++ b/lang/fr/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'S\'inscrire avec GitHub', 'github_email_unavailable' => 'Impossible de récupérer votre e-mail depuis GitHub. Rendez votre e-mail GitHub public ou accordez l\'autorisation d\'accès à l\'e-mail, puis réessayez.', - 'signup_success' => [ - 'page_title' => 'Bienvenue', - 'title' => 'Configuration de votre compte', - 'description' => 'Cela ne prend généralement que quelques secondes...', - ], - 'login' => [ 'title' => 'Connectez-vous à votre compte', 'description' => 'Saisissez votre e-mail et votre mot de passe ci-dessous pour vous connecter', diff --git a/lang/it/auth.php b/lang/it/auth.php index 2a1674db..44511b14 100644 --- a/lang/it/auth.php +++ b/lang/it/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'Registrati con GitHub', 'github_email_unavailable' => 'Impossibile recuperare la tua email da GitHub. Rendi pubblica la tua email GitHub o concedi l\'ambito email, poi riprova.', - 'signup_success' => [ - 'page_title' => 'Benvenuto', - 'title' => 'Configurazione del tuo account', - 'description' => 'Di solito ci vogliono solo pochi secondi...', - ], - 'login' => [ 'title' => 'Accedi al tuo account', 'description' => 'Inserisci la tua email e la password qui sotto per accedere', diff --git a/lang/ja/auth.php b/lang/ja/auth.php index 8740a5ca..98df02db 100644 --- a/lang/ja/auth.php +++ b/lang/ja/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'GitHub で登録', 'github_email_unavailable' => 'GitHub からメールアドレスを取得できませんでした。GitHub のメールアドレスを公開するか、email スコープを許可してから、もう一度お試しください。', - 'signup_success' => [ - 'page_title' => 'ようこそ', - 'title' => 'アカウントを設定しています', - 'description' => '通常は数秒で完了します...', - ], - 'login' => [ 'title' => 'アカウントにログイン', 'description' => 'ログインするにはメールアドレスとパスワードを入力してください', diff --git a/lang/ko/auth.php b/lang/ko/auth.php index 251c8eec..cdf172d5 100644 --- a/lang/ko/auth.php +++ b/lang/ko/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'GitHub으로 가입하기', 'github_email_unavailable' => 'GitHub에서 이메일을 가져올 수 없습니다. GitHub 이메일을 공개로 설정하거나 이메일 권한을 부여한 후 다시 시도하세요.', - 'signup_success' => [ - 'page_title' => '환영합니다', - 'title' => '계정을 설정하는 중', - 'description' => '보통 몇 초면 완료됩니다...', - ], - 'login' => [ 'title' => '계정에 로그인', 'description' => '로그인하려면 아래에 이메일과 비밀번호를 입력하세요', diff --git a/lang/nl/auth.php b/lang/nl/auth.php index 93ee64b8..471c6801 100644 --- a/lang/nl/auth.php +++ b/lang/nl/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'Aanmelden met GitHub', 'github_email_unavailable' => 'Kan je e-mailadres niet ophalen van GitHub. Maak je GitHub-e-mailadres openbaar of verleen de e-mailscope en probeer het opnieuw.', - 'signup_success' => [ - 'page_title' => 'Welkom', - 'title' => 'Je account wordt ingesteld', - 'description' => 'Dit duurt meestal maar een paar seconden...', - ], - 'login' => [ 'title' => 'Log in op je account', 'description' => 'Voer hieronder je e-mailadres en wachtwoord in om in te loggen', diff --git a/lang/pl/auth.php b/lang/pl/auth.php index 13b6a9d3..1cce8921 100644 --- a/lang/pl/auth.php +++ b/lang/pl/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'Zarejestruj się przez GitHub', 'github_email_unavailable' => 'Nie udało się pobrać Twojego adresu e-mail z GitHuba. Ustaw swój adres e-mail w GitHubie jako publiczny lub przyznaj uprawnienie do e-maila, a następnie spróbuj ponownie.', - 'signup_success' => [ - 'page_title' => 'Witamy', - 'title' => 'Konfigurowanie Twojego konta', - 'description' => 'Zwykle zajmuje to tylko kilka sekund...', - ], - 'login' => [ 'title' => 'Zaloguj się na swoje konto', 'description' => 'Wprowadź poniżej swój e-mail i hasło, aby się zalogować', diff --git a/lang/pt-BR/auth.php b/lang/pt-BR/auth.php index 6122b630..2ba690c7 100644 --- a/lang/pt-BR/auth.php +++ b/lang/pt-BR/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'Cadastrar com GitHub', 'github_email_unavailable' => 'Não foi possível obter seu e-mail do GitHub. Torne seu e-mail público ou conceda a permissão de e-mail e tente novamente.', - 'signup_success' => [ - 'page_title' => 'Bem-vindo', - 'title' => 'Configurando sua conta', - 'description' => 'Isso geralmente leva apenas alguns segundos...', - ], - 'login' => [ 'title' => 'Entrar na sua conta', 'description' => 'Digite seu email e senha abaixo para entrar', diff --git a/lang/ru/auth.php b/lang/ru/auth.php index 3ba01801..e813ed65 100644 --- a/lang/ru/auth.php +++ b/lang/ru/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'Зарегистрироваться через GitHub', 'github_email_unavailable' => 'Не удалось получить ваш email из GitHub. Сделайте email в GitHub публичным или предоставьте доступ к email, затем попробуйте снова.', - 'signup_success' => [ - 'page_title' => 'Добро пожаловать', - 'title' => 'Настраиваем ваш аккаунт', - 'description' => 'Обычно это занимает всего несколько секунд...', - ], - 'login' => [ 'title' => 'Войдите в свой аккаунт', 'description' => 'Введите email и пароль, чтобы войти', diff --git a/lang/tr/auth.php b/lang/tr/auth.php index e19acf68..d11255b4 100644 --- a/lang/tr/auth.php +++ b/lang/tr/auth.php @@ -61,12 +61,6 @@ 'github_signup' => 'GitHub ile kayıt ol', 'github_email_unavailable' => 'GitHub\'dan e-postanız alınamadı. GitHub e-postanızı herkese açık yapın veya e-posta iznini verin, ardından tekrar deneyin.', - 'signup_success' => [ - 'page_title' => 'Hoş geldiniz', - 'title' => 'Hesabınız ayarlanıyor', - 'description' => 'Bu genellikle yalnızca birkaç saniye sürer...', - ], - 'login' => [ 'title' => 'Hesabınıza giriş yapın', 'description' => 'Giriş yapmak için e-posta ve parolanızı aşağıya girin', diff --git a/lang/uk/auth.php b/lang/uk/auth.php index d470f499..33918920 100644 --- a/lang/uk/auth.php +++ b/lang/uk/auth.php @@ -59,12 +59,6 @@ 'github_signup' => 'Зареєструватися через GitHub', 'github_email_unavailable' => 'Не вдалося отримати ваш email з GitHub. Зробіть email публічним або надайте доступ до email, потім спробуйте ще раз.', - 'signup_success' => [ - 'page_title' => 'Ласкаво просимо', - 'title' => 'Налаштовуємо ваш обліковий запис', - 'description' => 'Зазвичай це займає лише кілька секунд...', - ], - 'login' => [ 'title' => 'Увійдіть до облікового запису', 'description' => 'Введіть email і пароль нижче, щоб увійти', diff --git a/lang/zh/auth.php b/lang/zh/auth.php index 96b6bafc..4d3528fd 100644 --- a/lang/zh/auth.php +++ b/lang/zh/auth.php @@ -59,12 +59,6 @@ 'github_signup' => '使用 GitHub 注册', 'github_email_unavailable' => '无法从 GitHub 获取你的邮箱。请将你的 GitHub 邮箱设为公开,或授予邮箱权限后重试。', - 'signup_success' => [ - 'page_title' => '欢迎', - 'title' => '正在设置你的账户', - 'description' => '这通常只需几秒钟…', - ], - 'login' => [ 'title' => '登录你的账户', 'description' => '请在下方输入你的邮箱和密码以登录', diff --git a/resources/js/components/auth/SocialLogin.vue b/resources/js/components/auth/SocialLogin.vue index b6d196e6..c2159938 100644 --- a/resources/js/components/auth/SocialLogin.vue +++ b/resources/js/components/auth/SocialLogin.vue @@ -6,10 +6,11 @@ import { Button } from '@/components/ui/button'; import { redirect as githubRedirect } from '@/routes/auth/github'; import { redirect as googleRedirect } from '@/routes/auth/google'; -withDefaults( +const props = withDefaults( defineProps<{ mode: 'login' | 'signup'; hideDivider?: boolean; + invite?: string | null; }>(), { hideDivider: false }, ); @@ -18,17 +19,26 @@ const page = usePage(); const googleEnabled = computed(() => Boolean(page.props.googleAuthEnabled)); const githubEnabled = computed(() => Boolean(page.props.githubAuthEnabled)); const hasSocial = computed(() => googleEnabled.value || githubEnabled.value); + +const query = computed(() => { + const params: Record = {}; + if (props.invite) params.invite = props.invite; + return params; +}); + +const googleUrl = computed(() => googleRedirect.url({ query: query.value })); +const githubUrl = computed(() => githubRedirect.url({ query: query.value }));