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

* 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 <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.

* 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.
This commit is contained in:
Paulo Castellano 2026-08-12 11:47:47 -03:00 committed by GitHub
parent ca1e346227
commit de54ea24f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 2694 additions and 610 deletions

View file

@ -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;

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Enums\Auth;
enum SocialAuthProvider: string
{
case Google = 'google';
case GitHub = 'github';
public function label(): string
{
return match ($this) {
self::Google => 'Google',
self::GitHub => 'GitHub',
};
}
public function isEnabled(): bool
{
return (bool) config("trypost.{$this->value}_auth_enabled");
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostHog;
enum CheckoutEvent: string
{
case Started = 'checkout.started';
case Completed = 'checkout.completed';
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostHog;
enum TrialEvent: string
{
case Started = 'trial.started';
case Converted = 'trial.converted';
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostHog;
enum UserEvent: string
{
case SignedUp = 'user.signed_up';
}

View file

@ -7,12 +7,10 @@
use App\Models\Account;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Throwable;
class BillingController extends Controller
{
@ -29,14 +27,6 @@ public function processing(Request $request): Response|RedirectResponse
$user = $request->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')) {

View file

@ -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));
}
}

View file

@ -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

View file

@ -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'));

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth\Concerns;
use App\Models\Invite;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Carries the invite id across the OAuth round-trip via session, mirroring
* PreservesAttributionParameters.
*/
trait PreservesInvite
{
private function storeInvite(Request $request): void
{
$request->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;
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class SignupSuccessController extends Controller
{
public function __invoke(Request $request): Response
{
return Inertia::render('auth/SignupSuccess', [
'authProvider' => $request->session()->get('auth_provider', 'email'),
]);
}
}

View file

@ -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);

View file

@ -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(),
];
}

View file

@ -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

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Models\Account;
use App\Services\PostHogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Shared PostHog capture pipeline for jobs backed by a Stripe subscription
* webhook payload. Subclasses provide the event name and its properties.
*/
abstract class AbstractTrackStripeSubscriptionEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
/**
* @param array<string, mixed> $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<string, mixed>
*/
abstract protected function properties(Account $account): array;
}

View file

@ -28,7 +28,7 @@ public function __construct(public string $userId)
public function handle(PostHogService $postHog): void
{
if (! PostHogService::isEnabled()) {
if (! PostHogService::shouldTrack()) {
return;
}

View file

@ -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,
);

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Enums\PostHog\CheckoutEvent;
use App\Models\Account;
use App\Support\StripeSubscriptionConversion;
class TrackCheckoutCompleted extends AbstractTrackStripeSubscriptionEvent
{
protected function event(): string
{
return CheckoutEvent::Completed->value;
}
/**
* @return array<string, mixed>
*/
protected function properties(Account $account): array
{
return StripeSubscriptionConversion::propertiesFor($account, $this->payload);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Enums\PostHog\TrialEvent;
use App\Models\Account;
use App\Support\StripeSubscriptionConversion;
class TrackTrialConverted extends AbstractTrackStripeSubscriptionEvent
{
protected function event(): string
{
return TrialEvent::Converted->value;
}
/**
* @return array<string, mixed>
*/
protected function properties(Account $account): array
{
return StripeSubscriptionConversion::propertiesFor($account, $this->payload);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Enums\PostHog\TrialEvent;
use App\Models\Account;
use App\Support\StripeSubscriptionConversion;
use Illuminate\Support\Carbon;
class TrackTrialStarted extends AbstractTrackStripeSubscriptionEvent
{
protected function event(): string
{
return TrialEvent::Started->value;
}
/**
* @return array<string, mixed>
*/
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;
}
}

View file

@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $payload
*/
private function isNowActive(array $payload): bool
{
return $this->currentStatus($payload) === 'active';
}
}

View file

@ -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);
}
}

View file

@ -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"};
}
}

View file

@ -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<string, mixed> $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<string, mixed> $payload
*/
private function logLocally(string $method, array $payload): void
{
if (! app()->environment('local')) {
return;
}
Log::info("PostHogService: {$method}", $payload);
}
/**

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Account;
final class StripeSubscriptionConversion
{
/**
* plan_name/interval shared by every PostHog capture backed by a Stripe
* subscription webhook payload trial.started, checkout.completed, and
* trial.converted all start from this shape. Persona is not included
* here: it is already set as a person property via identify() during
* onboarding, so it is joinable on every event without repeating it.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @return array<string, mixed>
*/
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;
}
}

View file

@ -59,12 +59,6 @@
'github_signup' => 'التسجيل عبر GitHub',
'github_email_unavailable' => 'تعذر جلب بريدك الإلكتروني من GitHub. اجعل بريدك على GitHub عامًا أو امنح نطاق الوصول إلى البريد، ثم حاول مرة أخرى.',
'signup_success' => [
'page_title' => 'مرحبًا',
'title' => 'جارٍ إعداد حسابك',
'description' => 'يستغرق هذا عادةً بضع ثوانٍ فقط...',
],
'login' => [
'title' => 'تسجيل الدخول إلى حسابك',
'description' => 'أدخل بريدك الإلكتروني وكلمة المرور أدناه لتسجيل الدخول',

View file

@ -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',

View file

@ -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 και τον κωδικό πρόσβασής σας παρακάτω για να συνδεθείτε',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -59,12 +59,6 @@
'github_signup' => 'GitHub で登録',
'github_email_unavailable' => 'GitHub からメールアドレスを取得できませんでした。GitHub のメールアドレスを公開するか、email スコープを許可してから、もう一度お試しください。',
'signup_success' => [
'page_title' => 'ようこそ',
'title' => 'アカウントを設定しています',
'description' => '通常は数秒で完了します...',
],
'login' => [
'title' => 'アカウントにログイン',
'description' => 'ログインするにはメールアドレスとパスワードを入力してください',

View file

@ -59,12 +59,6 @@
'github_signup' => 'GitHub으로 가입하기',
'github_email_unavailable' => 'GitHub에서 이메일을 가져올 수 없습니다. GitHub 이메일을 공개로 설정하거나 이메일 권한을 부여한 후 다시 시도하세요.',
'signup_success' => [
'page_title' => '환영합니다',
'title' => '계정을 설정하는 중',
'description' => '보통 몇 초면 완료됩니다...',
],
'login' => [
'title' => '계정에 로그인',
'description' => '로그인하려면 아래에 이메일과 비밀번호를 입력하세요',

View file

@ -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',

View file

@ -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ć',

View file

@ -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',

View file

@ -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 и пароль, чтобы войти',

View file

@ -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',

View file

@ -59,12 +59,6 @@
'github_signup' => 'Зареєструватися через GitHub',
'github_email_unavailable' => 'Не вдалося отримати ваш email з GitHub. Зробіть email публічним або надайте доступ до email, потім спробуйте ще раз.',
'signup_success' => [
'page_title' => 'Ласкаво просимо',
'title' => 'Налаштовуємо ваш обліковий запис',
'description' => 'Зазвичай це займає лише кілька секунд...',
],
'login' => [
'title' => 'Увійдіть до облікового запису',
'description' => 'Введіть email і пароль нижче, щоб увійти',

View file

@ -59,12 +59,6 @@
'github_signup' => '使用 GitHub 注册',
'github_email_unavailable' => '无法从 GitHub 获取你的邮箱。请将你的 GitHub 邮箱设为公开,或授予邮箱权限后重试。',
'signup_success' => [
'page_title' => '欢迎',
'title' => '正在设置你的账户',
'description' => '这通常只需几秒钟…',
],
'login' => [
'title' => '登录你的账户',
'description' => '请在下方输入你的邮箱和密码以登录',

View file

@ -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<string, string> = {};
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 }));
</script>
<template>
<template v-if="hasSocial">
<div class="flex flex-col gap-2">
<Button v-if="googleEnabled" variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
<Button v-if="googleEnabled" variant="outline" class="w-full" as="a" :href="googleUrl">
<img src="/images/social/google.svg" alt="Google" class="size-4" />
{{ mode === 'login' ? $t('auth.google_login') : $t('auth.google_signup') }}
</Button>
<Button v-if="githubEnabled" variant="outline" class="w-full" as="a" :href="githubRedirect.url()">
<Button v-if="githubEnabled" variant="outline" class="w-full" as="a" :href="githubUrl">
<img src="/images/social/github.svg" alt="GitHub" class="size-4 dark:invert" />
{{ mode === 'login' ? $t('auth.github_login') : $t('auth.github_signup') }}
</Button>

View file

@ -1,61 +0,0 @@
import { captureEvent } from '@/posthog';
const push = (data: Record<string, unknown>) => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(data);
};
export const useTracking = () => ({
trackSignUp: (authProvider: string) => {
captureEvent('user.signed_up', {
auth_provider: authProvider,
});
push({
event: 'sign_up',
method: authProvider,
});
},
trackBeginCheckout: (plan: { name: string; interval: string }) => {
captureEvent('checkout.started', {
plan_name: plan.name,
interval: plan.interval,
});
push({
event: 'begin_checkout',
plan_name: plan.name,
plan_interval: plan.interval,
});
},
trackPurchase: (
plan: { name: string; interval: string },
conversion?: { value: number; currency: string; transaction_id: string } | null,
persona?: string | null,
) => {
captureEvent('checkout.completed', {
plan_name: plan.name,
interval: plan.interval,
...(persona ? { persona } : {}),
...(conversion ? {
conversion_value: conversion.value,
conversion_currency: conversion.currency,
conversion_transaction_id: conversion.transaction_id,
} : {}),
});
push({
event: 'purchase',
plan_name: plan.name,
plan_interval: plan.interval,
...(persona ? { persona } : {}),
...(conversion ? {
conversion_value: conversion.value,
conversion_currency: conversion.currency,
conversion_transaction_id: conversion.transaction_id,
} : {}),
});
},
});

View file

@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { login, register } from '@/routes';
import { home } from '@/routes/app';
import { accept, decline, show } from '@/routes/app/invites';
import { accept, decline } from '@/routes/app/invites';
import { type SharedData } from '@/types';
const props = defineProps<{
@ -34,10 +34,6 @@ const page = usePage<SharedData>();
const user = computed(() => page.props.auth?.user);
const isLoggedIn = computed(() => !!user.value);
const inviteUrl = computed(() =>
props.invite ? show.url(props.invite.id) : home.url(),
);
const title = computed(() =>
props.expired
? trans('auth.accept_invite.expired_title')
@ -126,7 +122,7 @@ const description = computed(() =>
<Link
:href="
login({
query: { redirect: inviteUrl, email: invite.email },
query: { invite: invite.id, email: invite.email },
})
"
>
@ -138,9 +134,8 @@ const description = computed(() =>
:href="
register({
query: {
redirect: inviteUrl,
email: invite.email,
invite: invite.id,
email: invite.email,
},
})
"

View file

@ -17,6 +17,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { usePageErrors } from '@/composables/usePageErrors';
import AuthBase from '@/layouts/AuthLayout.vue';
import { register } from '@/routes';
import { store } from '@/routes/login';
@ -25,13 +26,14 @@ import { request } from '@/routes/password';
defineProps<{
status?: string;
email?: string | null;
redirect?: string | null;
invite?: string | null;
}>();
const showPassword = ref(false);
const page = usePage();
const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
const pageErrors = usePageErrors();
</script>
<template>
@ -49,7 +51,7 @@ const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
</div>
<div class="flex flex-col gap-6">
<SocialLogin mode="login" />
<SocialLogin mode="login" :invite="invite" />
<Form
v-bind="store.form()"
@ -58,10 +60,10 @@ const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
class="flex flex-col gap-6"
>
<input
v-if="redirect"
v-if="invite"
type="hidden"
name="redirect"
:value="redirect"
name="invite"
:value="invite"
/>
<div class="grid gap-6">
<div class="grid gap-2">
@ -76,7 +78,7 @@ const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
placeholder="email@example.com"
:default-value="email ?? ''"
/>
<InputError :message="errors.email" />
<InputError :message="errors.email || pageErrors.email" />
</div>
<div class="grid gap-2">

View file

@ -22,7 +22,6 @@ import { store } from '@/routes/register';
defineProps<{
email?: string | null;
redirect?: string | null;
invite?: string | null;
}>();
@ -51,7 +50,7 @@ const emailFormVisible = computed(() => !hasSocial.value || showEmailForm.value)
<div class="flex flex-col gap-6">
<div v-if="hasSocial" class="flex flex-col gap-2">
<SocialLogin mode="signup" hide-divider />
<SocialLogin mode="signup" hide-divider :invite="invite" />
<Button
v-if="!showEmailForm"
@ -72,7 +71,6 @@ const emailFormVisible = computed(() => !hasSocial.value || showEmailForm.value)
v-slot="{ errors, processing }"
class="flex flex-col gap-6"
>
<input v-if="redirect" type="hidden" name="redirect" :value="redirect" />
<input v-if="invite" type="hidden" name="invite" :value="invite" />
<div

View file

@ -1,41 +0,0 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { IconLoader2 } from '@tabler/icons-vue';
import { onMounted } from 'vue';
import { useTracking } from '@/composables/useTracking';
import AuthBase from '@/layouts/AuthLayout.vue';
import { home } from '@/routes/app';
const props = defineProps<{
authProvider: string;
}>();
const { trackSignUp } = useTracking();
onMounted(() => {
trackSignUp(props.authProvider);
setTimeout(() => {
router.visit(home.url());
}, 5000);
});
</script>
<template>
<Head :title="$t('auth.signup_success.page_title')" />
<AuthBase>
<div class="flex flex-col items-center gap-4 text-center">
<IconLoader2 class="size-8 animate-spin text-muted-foreground" />
<div>
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('auth.signup_success.title') }}
</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('auth.signup_success.description') }}
</p>
</div>
</div>
</AuthBase>
</template>

View file

@ -1,27 +1,15 @@
<script setup lang="ts">
import { Head, router, usePage, usePoll } from '@inertiajs/vue3';
import { Head, router, usePoll } from '@inertiajs/vue3';
import { IconLoader2 } from '@tabler/icons-vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useTracking } from '@/composables/useTracking';
import { accounts, onboarding } from '@/routes/app';
import type { Auth } from '@/types';
const props = defineProps<{
subscriptionActive: boolean;
fromCheckout: boolean;
redirectToOnboarding: boolean;
persona?: string | null;
conversion?: { value: number; currency: string; transaction_id: string } | null;
}>();
// Hold on the processing screen after firing the purchase event so PostHog and
// the ad pixels (Google/Meta via dataLayer GTM) have time to send before we
// navigate away an immediate redirect can cut those requests off.
const REDIRECT_DELAY_MS = 5000;
const page = usePage();
// Polls `auth` alongside so `auth.plan.interval` is fresh once the Stripe
// webhook creates the local Subscription row at /billing/processing's
// initial render that row doesn't exist yet, so the interval would default
@ -30,10 +18,7 @@ const { stop } = usePoll(2000, {
only: ['subscriptionActive', 'redirectToOnboarding', 'auth'],
});
const { trackPurchase } = useTracking();
const finishing = ref(false);
let redirectTimer: ReturnType<typeof setTimeout> | null = null;
const goNext = (): void => {
router.visit(
@ -41,32 +26,18 @@ const goNext = (): void => {
);
};
// Fires `checkout.completed` exactly once for a real checkout. A trial-with-card
// subscription is already `subscribed()` (status `trialing`) by the time the
// webhook lands, so the user frequently reaches this page already active the
// false true poll transition never happens. We therefore complete the purchase
// from whichever path runs first (immediate active state or poll transition),
// gated on `fromCheckout` so back-button/refresh visits don't over-count.
// A trial-with-card subscription is already `subscribed()` (status
// `trialing`) by the time the webhook lands, so the user frequently reaches
// this page already active the false true poll transition never
// happens. `checkout.completed` fires from the Stripe webhook server-side,
// independent of this page, so there's nothing to wait for once active.
const completePurchase = (): void => {
if (finishing.value) {
return;
}
finishing.value = true;
stop();
const plan = (page.props.auth as Auth | undefined)?.plan;
if (props.fromCheckout && plan) {
trackPurchase(
{ name: plan.name, interval: plan.interval },
props.conversion ?? null,
props.persona ?? null,
);
}
// Always hold for the same window before navigating, so PostHog and the ad
// pixels (Google/Meta via dataLayer GTM) reliably flush.
redirectTimer = setTimeout(goNext, REDIRECT_DELAY_MS);
goNext();
};
watch(
@ -83,12 +54,6 @@ onMounted(() => {
completePurchase();
}
});
onUnmounted(() => {
if (redirectTimer) {
clearTimeout(redirectTimer);
}
});
</script>
<template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Form, Head, usePage } from '@inertiajs/vue3';
import { IconDeviceDesktop, IconDeviceMobile } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
@ -38,8 +38,10 @@ type Session = {
is_current: boolean;
};
type SocialProvider = 'google' | 'github';
type ConnectedAccount = {
provider: 'google' | 'github';
provider: SocialProvider;
label: string;
connected: boolean;
can_disconnect: boolean;
@ -71,6 +73,10 @@ const passwordDescription = computed(() =>
);
const logoutDialogOpen = ref(false);
const page = usePage();
const providerEnabled = (provider: SocialProvider): boolean =>
Boolean(page.props[provider === 'google' ? 'googleAuthEnabled' : 'githubAuthEnabled']);
</script>
<template>
@ -321,7 +327,7 @@ const logoutDialogOpen = ref(false);
</Button>
</Form>
<Button
v-else-if="!account.connected"
v-else-if="!account.connected && providerEnabled(account.provider)"
variant="outline"
size="sm"
as="a"

View file

@ -24,7 +24,6 @@ import type { FunctionalComponent } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { useTracking } from '@/composables/useTracking';
import WelcomeLayout from '@/layouts/WelcomeLayout.vue';
import { store } from '@/routes/app/welcome/referral-source';
@ -38,8 +37,6 @@ const form = useForm<{ referral_source: string }>({
referral_source: props.selected ?? '',
});
const { trackBeginCheckout } = useTracking();
type SourceMeta = {
icon?: FunctionalComponent;
logo?: string;
@ -156,31 +153,7 @@ const submit = (): void => {
return;
}
let shouldTrackCheckout = false;
form.submit(store(), {
onStart: () => {
shouldTrackCheckout = true;
},
onError: () => {
shouldTrackCheckout = false;
},
onHttpException: () => {
shouldTrackCheckout = false;
},
onFinish: () => {
if (!shouldTrackCheckout) {
return;
}
// Inertia::location navigates away before onSuccess; onFinish still
// runs and dataLayer can accept the event before unload.
trackBeginCheckout({
name: props.plan.name,
interval: props.plan.interval,
});
},
});
form.submit(store());
};
</script>

View file

@ -90,17 +90,4 @@ export const capturePageview = (): void => {
posthog.capture('$pageview', { $current_url: window.location.href });
};
/**
* Gated wrapper around `posthog.capture` for arbitrary domain events. Use
* this from composables/components instead of calling `posthog.capture`
* directly so self-hosted (or otherwise disabled) installs never queue
* events into the SDK buffer.
*/
export const captureEvent = (event: string, properties?: Record<string, unknown>): void => {
if (!enabled) {
return;
}
posthog.capture(event, properties);
};
export default posthog;

View file

@ -11,7 +11,6 @@
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\SignupSuccessController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
@ -44,8 +43,6 @@
Route::get('/auth/github/callback', [GitHubController::class, 'callback'])->name('auth.github.callback');
Route::middleware(['auth'])->group(function () {
Route::get('/register/success', SignupSuccessController::class)->name('register.success');
Route::get('/verify-email', EmailVerificationPromptController::class)->name('verification.notice');
Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
test('login page displays the wrong-invite-email error flashed from the oauth callback', function () {
config([
'trypost.self_hosted' => false,
'trypost.google_auth_enabled' => true,
'services.google-auth.client_id' => 'test-client-id',
'services.google-auth.client_secret' => 'test-client-secret',
'services.google-auth.redirect' => 'https://app.trypost.test/auth/google/callback',
]);
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'intended-recipient@example.com',
'workspaces' => [$workspace->id],
]);
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'g-wrong-email',
'name' => 'Someone Else',
'email' => 'someone-else@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('redirect')->andReturn(redirect(route('auth.google.callback')));
$driver->shouldReceive('user')->andReturn($socialiteUser);
$page = visit(route('auth.google.redirect', ['invite' => $invite->id]));
$page->assertRoute('login')
->assertSee(__('settings.members.flash.wrong_email'));
});

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
use App\Actions\User\CreateUser;
use App\Enums\PostHog\UserEvent;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\SyncUser;
use App\Models\Account;
use App\Models\Workspace;
@ -77,3 +79,113 @@
Bus::assertNotDispatched(SyncUser::class);
});
test('CreateUser does not dispatch SyncUser when PostHog is disabled in production', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([SyncUser::class]);
CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane.posthog.disabled.production@example.com',
'password' => 'secret123',
]);
Bus::assertNotDispatched(SyncUser::class);
});
test('CreateUser dispatches SyncUser in the local environment even when PostHog is disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([SyncUser::class]);
$user = CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane.posthog.local@example.com',
'password' => 'secret123',
]);
Bus::assertDispatched(SyncUser::class, fn (SyncUser $job) => $job->userId === (string) $user->id);
});
test('CreateUser captures user.signed_up with the auth provider when PostHog is enabled', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
Bus::fake([SendEvent::class]);
$user = CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane.signup@example.com',
'password' => 'secret123',
'google_id' => 'google-123',
]);
Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'distinctId') === (string) $user->id
&& data_get($event->payload, 'event') === UserEvent::SignedUp->value
&& data_get($event->payload, 'properties.auth_provider') === 'google');
});
test('CreateUser defaults the auth provider to email when no OAuth id is present', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
Bus::fake([SendEvent::class]);
CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane.signup.email@example.com',
'password' => 'secret123',
]);
Bus::assertDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'properties.auth_provider') === 'email',
);
});
test('CreateUser does not capture user.signed_up for invite registrations', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
Bus::fake([SendEvent::class]);
CreateUser::execute([
'name' => 'Invited',
'email' => 'invited.signup@example.com',
'password' => 'secret123',
'is_invite' => true,
]);
// SyncUser still fires (it dispatches a SendEvent of its own, method
// 'identify', for every registration) — only the 'user.signed_up'
// capture must be skipped for invites.
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'event') === UserEvent::SignedUp->value,
);
});
test('CreateUser does not capture user.signed_up when PostHog is disabled', function () {
config(['services.posthog.enabled' => false]);
Bus::fake([SendEvent::class]);
CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane.signup.disabled@example.com',
'password' => 'secret123',
]);
Bus::assertNotDispatched(SendEvent::class);
});
test('CreateUser does not capture user.signed_up when PostHog is disabled in production', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([SendEvent::class, SyncUser::class]);
CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane.signup.disabled.production@example.com',
'password' => 'secret123',
]);
Bus::assertNotDispatched(SendEvent::class);
Bus::assertNotDispatched(SyncUser::class);
});

View file

@ -2,7 +2,10 @@
declare(strict_types=1);
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
@ -44,6 +47,45 @@
$response->assertRedirect(route('app.calendar', absolute: false));
});
test('login with a valid invite param redirects to the invite page instead of the calendar', function () {
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'invited-login@example.com',
'workspaces' => [$workspace->id],
]);
$user = User::factory()->create(['email' => 'invited-login@example.com']);
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
'invite' => $invite->id,
]);
$this->assertAuthenticated();
$response->assertRedirect(route('app.invites.show', $invite, absolute: false));
});
test('login with an unknown invite param falls back to the calendar redirect', function () {
$user = User::factory()->create();
$response = $this->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
'invite' => (string) Str::uuid(),
]);
$this->assertAuthenticated();
$response->assertRedirect(route('app.calendar', absolute: false));
});
test('users can not authenticate with invalid password', function () {
$user = User::factory()->create();

View file

@ -8,6 +8,7 @@
use Laravel\Socialite\Two\User as SocialiteUser;
test('authenticated user can hit the connect-provider route for github', function () {
config(['trypost.github_auth_enabled' => true]);
$user = User::factory()->create();
$driver = Mockery::mock(AbstractProvider::class);
@ -21,6 +22,7 @@
});
test('authenticated user can hit the connect-provider route for google', function () {
config(['trypost.google_auth_enabled' => true]);
$user = User::factory()->create();
$driver = Mockery::mock(AbstractProvider::class);
@ -40,6 +42,15 @@
->assertNotFound();
});
test('connect-provider route 404s when the provider is disabled', function () {
config(['trypost.github_auth_enabled' => false]);
$user = User::factory()->create();
$this->actingAs($user)
->get(route('app.authentication.connect-provider', 'github'))
->assertNotFound();
});
test('connect-provider route requires authentication', function () {
$this->get(route('app.authentication.connect-provider', 'github'))
->assertRedirect(route('login'));

View file

@ -29,7 +29,6 @@
$this->get(route('register', [
'email' => 'invitee@example.com',
'invite' => $this->invite->id,
'redirect' => route('app.invites.show', $this->invite),
]))
->assertOk()
->assertInertia(fn ($page) => $page
@ -45,7 +44,6 @@
'password' => 'password',
'password_confirmation' => 'password',
'invite' => $this->invite->id,
'redirect' => route('app.invites.show', $this->invite),
])->assertSessionHasErrors('email');
expect(User::where('email', 'other@example.com')->exists())->toBeFalse();
@ -58,8 +56,7 @@
'password' => 'password',
'password_confirmation' => 'password',
'invite' => $this->invite->id,
'redirect' => route('app.invites.show', $this->invite),
])->assertRedirect();
])->assertRedirect(route('app.invites.show', $this->invite));
expect(User::where('email', 'invitee@example.com')->exists())->toBeTrue();
});

View file

@ -9,6 +9,24 @@
beforeEach(fn () => config()->set('trypost.self_hosted', false));
function createTestInvite(string $email): Invite
{
$account = Account::factory()->create();
$owner = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $owner->id]);
$workspace = Workspace::factory()->create([
'account_id' => $account->id,
'user_id' => $owner->id,
]);
return Invite::factory()->create([
'account_id' => $account->id,
'invited_by' => $owner->id,
'email' => $email,
'workspaces' => [$workspace->id],
]);
}
test('registration screen can be rendered', function () {
$response = $this->get(route('register'));
@ -24,7 +42,7 @@
$response->assertSessionHasNoErrors();
$this->assertAuthenticated();
$response->assertRedirect(route('register.success', absolute: false));
$response->assertRedirect(route('app.welcome', absolute: false));
});
test('new users get a default workspace on registration', function () {
@ -75,7 +93,6 @@
'email' => 'test@example.com',
'password' => 'Password123!',
'invite' => $invite->id,
'redirect' => route('app.invites.show', $invite),
]);
$user = User::where('email', 'test@example.com')->first();
@ -106,9 +123,10 @@
test('register page renders when self_hosted but session has pending invite', function () {
config()->set('trypost.self_hosted', true);
$invite = createTestInvite('invitee@example.com');
$response = $this
->withSession(['pending_invite_id' => 'invite-abc'])
->withSession(['pending_invite_id' => $invite->id])
->get(route('register'));
$response->assertOk();
@ -116,17 +134,19 @@
test('register page renders when self_hosted with invite query param and persists it to session', function () {
config()->set('trypost.self_hosted', true);
$invite = createTestInvite('invitee@example.com');
$response = $this->get(route('register', ['invite' => 'invite-xyz']));
$response = $this->get(route('register', ['invite' => $invite->id]));
$response->assertOk();
$response->assertSessionHas('pending_invite_id', 'invite-xyz');
$response->assertSessionHas('pending_invite_id', $invite->id);
});
test('signup clears pending_invite_id from session', function () {
config()->set('trypost.self_hosted', true);
$invite = createTestInvite('invitee@example.com');
$this->withSession(['pending_invite_id' => 'invite-abc'])
$this->withSession(['pending_invite_id' => $invite->id])
->post(route('register.store'), [
'name' => 'Invitee',
'email' => 'invitee@example.com',
@ -139,8 +159,9 @@
test('register POST passes when self_hosted with invite query param even without prior session', function () {
config()->set('trypost.self_hosted', true);
$invite = createTestInvite('invitee@example.com');
$response = $this->post(route('register.store', ['invite' => 'invite-xyz']), [
$response = $this->post(route('register.store', ['invite' => $invite->id]), [
'name' => 'Invitee',
'email' => 'invitee@example.com',
'password' => 'Password123!',

View file

@ -6,7 +6,11 @@
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(fn () => config()->set('trypost.self_hosted', false));
beforeEach(fn () => config([
'trypost.self_hosted' => false,
'trypost.google_auth_enabled' => true,
'trypost.github_auth_enabled' => true,
]));
test('email registration saves ad click ids from the register page query string', function () {
$clickIds = [
@ -25,7 +29,7 @@
'email' => 'click@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', $clickIds, absolute: false));
->assertRedirect(route('app.welcome', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'click@example.com',
@ -85,7 +89,7 @@
->andReturn($socialiteUser);
$this->get(route('auth.google.callback'))
->assertRedirect(route('register.success', $clickIds, absolute: false));
->assertRedirect(route('app.welcome', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'google-click@example.com',
@ -114,7 +118,7 @@
->andReturn($socialiteUser);
$this->get(route('auth.github.callback'))
->assertRedirect(route('register.success', $clickIds, absolute: false));
->assertRedirect(route('app.welcome', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'github-click@example.com',

View file

@ -2,11 +2,18 @@
declare(strict_types=1);
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(fn () => config()->set('trypost.self_hosted', false));
beforeEach(fn () => config([
'trypost.self_hosted' => false,
'trypost.google_auth_enabled' => true,
'trypost.github_auth_enabled' => true,
]));
test('email registration saves utm parameters from the register page query string', function () {
$utms = [
@ -24,7 +31,7 @@
'email' => 'utm@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', $utms, absolute: false));
->assertRedirect(route('app.welcome', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'utm@example.com',
@ -32,13 +39,13 @@
]);
});
test('email registration without utm parameters saves null utm columns and redirects without query string', function () {
test('email registration without utm parameters saves null utm columns', function () {
$this->post(route('register.store'), [
'name' => 'No UTM User',
'email' => 'no-utm@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', absolute: false));
->assertRedirect(route('app.welcome', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'no-utm@example.com',
@ -50,7 +57,7 @@
]);
});
test('email registration strips non-utm query params from the success redirect', function () {
test('email registration ignores non-utm query params', function () {
$this->get(route('register', [
'utm_source' => 'peerlist',
'foo' => 'bar',
@ -61,8 +68,12 @@
'name' => 'Strip Test',
'email' => 'strip@example.com',
'password' => 'Password123!',
])
->assertRedirect(route('register.success', ['utm_source' => 'peerlist'], absolute: false));
]);
$this->assertDatabaseHas('users', [
'email' => 'strip@example.com',
'utm_source' => 'peerlist',
]);
});
test('google registration saves utm parameters captured before the oauth round-trip', function () {
@ -87,7 +98,7 @@
->andReturn($socialiteUser);
$this->get(route('auth.google.callback'))
->assertRedirect(route('register.success', $utms, absolute: false));
->assertRedirect(route('app.welcome', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'google-utm@example.com',
@ -115,7 +126,7 @@
->andReturn($socialiteUser);
$this->get(route('auth.google.callback'))
->assertRedirect(route('register.success', $utms, absolute: false));
->assertRedirect(route('app.welcome', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'cross-flow@example.com',
@ -149,16 +160,30 @@
expect(session()->get('attribution_parameters'))->toBeNull();
});
test('invitation registration does not include utm parameters in its redirect', function () {
$this->get(route('register', ['utm_source' => 'email']));
test('invitation registration redirects to the invite page instead of app.welcome', function () {
$account = Account::factory()->create();
$owner = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $owner->id]);
$workspace = Workspace::factory()->create([
'account_id' => $account->id,
'user_id' => $owner->id,
]);
$invite = Invite::factory()->create([
'account_id' => $account->id,
'invited_by' => $owner->id,
'email' => 'invited@example.com',
'workspaces' => [$workspace->id],
]);
$this->get(route('register', ['utm_source' => 'email', 'invite' => $invite->id]));
$this->post(route('register.store'), [
'name' => 'Invited User',
'email' => 'invited@example.com',
'password' => 'Password123!',
'redirect' => '/invites/some-token',
'invite' => $invite->id,
])
->assertRedirect('/invites/some-token');
->assertRedirect(route('app.invites.show', $invite));
});
test('utm values longer than 255 characters are truncated before being stored', function () {
@ -234,7 +259,7 @@
->andReturn($socialiteUser);
$this->get(route('auth.github.callback'))
->assertRedirect(route('register.success', $utms, absolute: false));
->assertRedirect(route('app.welcome', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'github-utm@example.com',

View file

@ -153,29 +153,10 @@
$response->assertInertia(fn ($page) => $page
->component('billing/Processing', false)
->has('subscriptionActive')
->where('fromCheckout', false)
->where('redirectToOnboarding', true)
->where('conversion', null)
);
});
test('billing processing exposes fromCheckout=true only the first time a session_id is seen', function () {
config(['trypost.self_hosted' => false]);
$sessionId = 'cs_test_'.fake()->uuid();
$first = $this->actingAs($this->user)
->get(route('app.billing.processing', ['session_id' => $sessionId]));
$first->assertOk();
$first->assertInertia(fn ($page) => $page->where('fromCheckout', true));
// A back-button / refresh to the same success URL must not re-fire the event.
$second = $this->actingAs($this->user)
->get(route('app.billing.processing', ['session_id' => $sessionId]));
$second->assertOk();
$second->assertInertia(fn ($page) => $page->where('fromCheckout', false));
});
test('billing processing skips onboarding when already completed', function () {
config(['trypost.self_hosted' => false]);
$this->user->account->forceFill(['onboarding_completed_at' => now()])->save();
@ -246,28 +227,6 @@
);
});
test('billing processing exposes null conversion when session_id query param is missing', function () {
config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)
->get(route('app.billing.processing', ['session_id' => '']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page->where('conversion', null));
});
test('billing processing exposes null conversion when account has no stripe_id', function () {
config(['trypost.self_hosted' => false]);
expect($this->account->stripe_id)->toBeNull();
$response = $this->actingAs($this->user)
->get(route('app.billing.processing', ['session_id' => 'cs_test_123']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page->where('conversion', null));
});
test('shared auth.plan exposes name slug and interval via AuthPlanResource', function () {
config(['trypost.self_hosted' => false]);

View file

@ -2,6 +2,11 @@
declare(strict_types=1);
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;
beforeEach(fn () => config()->set('trypost.self_hosted', false));
test('login page shares github auth enabled prop as false when disabled', function () {
@ -38,6 +43,7 @@
});
test('github auth redirect route exists', function () {
config(['trypost.github_auth_enabled' => true]);
config(['services.github.client_id' => 'test-id']);
config(['services.github.client_secret' => 'test-secret']);
config(['services.github.redirect' => 'https://app.trypost.test/auth/github/callback']);
@ -48,6 +54,12 @@
$response->assertRedirect();
});
test('github auth redirect route 404s when github auth is disabled', function () {
config(['trypost.github_auth_enabled' => false]);
$this->get(route('auth.github.redirect'))->assertNotFound();
});
test('github auth callback route exists', function () {
$response = $this->get(route('auth.github.callback'));
@ -59,8 +71,21 @@
config()->set('trypost.self_hosted', true);
config()->set('trypost.github_auth_enabled', true);
$account = Account::factory()->create();
$owner = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $owner->id]);
$workspace = Workspace::factory()->create([
'account_id' => $account->id,
'user_id' => $owner->id,
]);
$invite = Invite::factory()->create([
'account_id' => $account->id,
'invited_by' => $owner->id,
'workspaces' => [$workspace->id],
]);
$response = $this
->withSession(['pending_invite_id' => 'invite-abc'])
->withSession(['pending_invite_id' => $invite->id])
->get(route('register'));
$response->assertOk();

View file

@ -2,6 +2,11 @@
declare(strict_types=1);
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;
beforeEach(fn () => config()->set('trypost.self_hosted', false));
test('login page loads when google auth is disabled', function () {
@ -59,12 +64,20 @@
});
test('google auth redirect route exists', function () {
config(['trypost.google_auth_enabled' => true]);
$response = $this->get(route('auth.google.redirect'));
// Should redirect to Google OAuth, not 404
$response->assertRedirect();
});
test('google auth redirect route 404s when google auth is disabled', function () {
config(['trypost.google_auth_enabled' => false]);
$this->get(route('auth.google.redirect'))->assertNotFound();
});
test('google auth callback route exists', function () {
$response = $this->get(route('auth.google.callback'));
@ -76,8 +89,21 @@
config()->set('trypost.self_hosted', true);
config()->set('trypost.google_auth_enabled', true);
$account = Account::factory()->create();
$owner = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $owner->id]);
$workspace = Workspace::factory()->create([
'account_id' => $account->id,
'user_id' => $owner->id,
]);
$invite = Invite::factory()->create([
'account_id' => $account->id,
'invited_by' => $owner->id,
'workspaces' => [$workspace->id],
]);
$response = $this
->withSession(['pending_invite_id' => 'invite-abc'])
->withSession(['pending_invite_id' => $invite->id])
->get(route('register'));
$response->assertOk();

View file

@ -32,6 +32,29 @@
Queue::assertNothingPushed();
});
test('handle is a no-op when PostHog is disabled in production', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Queue::fake();
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle still identifies the user in the local environment even when PostHog is disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Queue::fake();
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
// The identify() call still runs (and logs locally), but since PostHog
// remains disabled, it must not queue an actual SendEvent to it.
Queue::assertPushed(SyncAccountUsage::class);
Queue::assertNotPushed(SendEvent::class);
});
test('handle returns silently when user does not exist', function () {
Queue::fake();

View file

@ -12,6 +12,7 @@
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
@ -51,7 +52,7 @@
});
});
test('handle forwards the owner persona as an event property', function () {
test('handle does not forward persona — it is already an identified person property', function () {
$this->user->update(['persona' => Persona::Agency->value]);
Queue::fake();
@ -60,7 +61,7 @@
Queue::assertPushed(
SendEvent::class,
fn ($job) => ($job->payload['properties']['persona'] ?? null) === Persona::Agency->value,
fn ($job) => ! array_key_exists('persona', $job->payload['properties']),
);
});
@ -121,3 +122,33 @@
// circuits before any DB query and no SendEvent reaches the queue.
Queue::assertNotPushed(SendEvent::class);
});
test('handle does not push a PostHog network call when disabled in production', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Queue::fake();
Bus::fake([SyncUser::class]);
(new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class);
Bus::assertNotDispatched(SyncUser::class);
});
test('handle logs locally but still does not push a PostHog network call in the local environment when disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Queue::fake();
Bus::fake([SyncUser::class]);
Log::shouldReceive('info')->once()->withArgs(
fn ($message) => $message === 'PostHogService: capture',
);
(new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class);
Bus::assertDispatched(SyncUser::class);
});

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use App\Enums\PostHog\CheckoutEvent;
use App\Enums\User\Persona;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\TrackCheckoutCompleted;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->plan = Plan::where('slug', 'workspace')->firstOrFail();
$this->plan->update([
'stripe_monthly_price_id' => 'price_workspace_monthly',
'stripe_yearly_price_id' => 'price_workspace_yearly',
]);
$this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
$this->user = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->user->id]);
$this->payload = [
'type' => 'customer.subscription.created',
'data' => ['object' => [
'id' => 'sub_test123',
'customer' => 'cus_test123',
'items' => ['data' => [[
'price' => [
'id' => 'price_workspace_monthly',
'unit_amount' => 2900,
'currency' => 'usd',
],
]]],
]],
];
});
test('job is queued on the posthog queue', function () {
$job = new TrackCheckoutCompleted((string) $this->account->id, $this->payload);
expect($job->queue)->toBe('posthog');
});
test('handle captures checkout.completed with plan, interval and conversion data', function () {
Queue::fake();
(new TrackCheckoutCompleted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job) {
return $job->method === 'capture'
&& $job->payload['event'] === CheckoutEvent::Completed->value
&& $job->payload['distinctId'] === (string) $this->user->id
&& $job->payload['properties']['$groups']['account'] === (string) $this->account->id
&& $job->payload['properties']['plan_name'] === $this->plan->name
&& $job->payload['properties']['interval'] === 'monthly'
&& $job->payload['properties']['conversion_value'] === 29.0
&& $job->payload['properties']['conversion_currency'] === 'USD'
&& $job->payload['properties']['conversion_transaction_id'] === 'sub_test123';
});
});
test('handle resolves the yearly interval from the price id', function () {
$this->payload['data']['object']['items']['data'][0]['price']['id'] = 'price_workspace_yearly';
Queue::fake();
(new TrackCheckoutCompleted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(
SendEvent::class,
fn (SendEvent $job) => $job->payload['properties']['interval'] === 'yearly',
);
});
test('handle does not forward persona — it is already an identified person property', function () {
$this->user->update(['persona' => Persona::Agency->value]);
Queue::fake();
(new TrackCheckoutCompleted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(
SendEvent::class,
fn (SendEvent $job) => ! array_key_exists('persona', $job->payload['properties']),
);
});
test('handle omits conversion fields when the webhook payload has no price amount', function () {
unset($this->payload['data']['object']['items']['data'][0]['price']['unit_amount']);
Queue::fake();
(new TrackCheckoutCompleted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job) {
$properties = $job->payload['properties'];
return ! array_key_exists('conversion_value', $properties)
&& ! array_key_exists('conversion_currency', $properties)
&& ! array_key_exists('conversion_transaction_id', $properties);
});
});
test('handle returns silently when account does not exist', function () {
Queue::fake();
(new TrackCheckoutCompleted('00000000-0000-0000-0000-000000000000', $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle returns silently when account has no owner', function () {
$this->account->update(['owner_id' => null]);
Queue::fake();
(new TrackCheckoutCompleted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle returns silently when account has no plan', function () {
$this->account->update(['plan_id' => null]);
Queue::fake();
(new TrackCheckoutCompleted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle does not push a PostHog network call when api key is unset', function () {
config(['services.posthog.api_key' => null]);
Queue::fake();
(new TrackCheckoutCompleted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class);
});

View file

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
use App\Enums\PostHog\TrialEvent;
use App\Enums\User\Persona;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\TrackTrialConverted;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->plan = Plan::where('slug', 'workspace')->firstOrFail();
$this->plan->update([
'stripe_monthly_price_id' => 'price_workspace_monthly',
'stripe_yearly_price_id' => 'price_workspace_yearly',
]);
$this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
$this->user = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->user->id]);
$this->payload = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'id' => 'sub_test123',
'customer' => 'cus_test123',
'status' => 'active',
'items' => ['data' => [[
'price' => [
'id' => 'price_workspace_monthly',
'unit_amount' => 2900,
'currency' => 'usd',
],
]]],
],
'previous_attributes' => ['status' => 'trialing'],
],
];
});
test('job is queued on the posthog queue', function () {
$job = new TrackTrialConverted((string) $this->account->id, $this->payload);
expect($job->queue)->toBe('posthog');
});
test('handle captures trial.converted with plan, interval and conversion data', function () {
Queue::fake();
(new TrackTrialConverted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job) {
return $job->method === 'capture'
&& $job->payload['event'] === TrialEvent::Converted->value
&& $job->payload['distinctId'] === (string) $this->user->id
&& $job->payload['properties']['$groups']['account'] === (string) $this->account->id
&& $job->payload['properties']['plan_name'] === $this->plan->name
&& $job->payload['properties']['interval'] === 'monthly'
&& $job->payload['properties']['conversion_value'] === 29.0
&& $job->payload['properties']['conversion_currency'] === 'USD'
&& $job->payload['properties']['conversion_transaction_id'] === 'sub_test123';
});
});
test('handle resolves the yearly interval from the price id', function () {
$this->payload['data']['object']['items']['data'][0]['price']['id'] = 'price_workspace_yearly';
Queue::fake();
(new TrackTrialConverted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(
SendEvent::class,
fn (SendEvent $job) => $job->payload['properties']['interval'] === 'yearly',
);
});
test('handle does not forward persona — it is already an identified person property', function () {
$this->user->update(['persona' => Persona::Agency->value]);
Queue::fake();
(new TrackTrialConverted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(
SendEvent::class,
fn (SendEvent $job) => ! array_key_exists('persona', $job->payload['properties']),
);
});
test('handle omits conversion fields when the webhook payload has no price amount', function () {
unset($this->payload['data']['object']['items']['data'][0]['price']['unit_amount']);
Queue::fake();
(new TrackTrialConverted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job) {
$properties = $job->payload['properties'];
return ! array_key_exists('conversion_value', $properties)
&& ! array_key_exists('conversion_currency', $properties)
&& ! array_key_exists('conversion_transaction_id', $properties);
});
});
test('handle returns silently when account does not exist', function () {
Queue::fake();
(new TrackTrialConverted('00000000-0000-0000-0000-000000000000', $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle returns silently when account has no owner', function () {
$this->account->update(['owner_id' => null]);
Queue::fake();
(new TrackTrialConverted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle returns silently when account has no plan', function () {
$this->account->update(['plan_id' => null]);
Queue::fake();
(new TrackTrialConverted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle does not push a PostHog network call when api key is unset', function () {
config(['services.posthog.api_key' => null]);
Queue::fake();
(new TrackTrialConverted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class);
});

View file

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
use App\Enums\PostHog\TrialEvent;
use App\Enums\User\Persona;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\TrackTrialStarted;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->plan = Plan::where('slug', 'workspace')->firstOrFail();
$this->plan->update([
'stripe_monthly_price_id' => 'price_workspace_monthly',
'stripe_yearly_price_id' => 'price_workspace_yearly',
]);
$this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
$this->user = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->user->id]);
$this->trialEnd = now()->addDays(8);
$this->payload = [
'type' => 'customer.subscription.created',
'data' => ['object' => [
'id' => 'sub_test123',
'customer' => 'cus_test123',
'status' => 'trialing',
'trial_end' => $this->trialEnd->timestamp,
'items' => ['data' => [[
'price' => ['id' => 'price_workspace_monthly'],
]]],
]],
];
});
test('job is queued on the posthog queue', function () {
$job = new TrackTrialStarted((string) $this->account->id, $this->payload);
expect($job->queue)->toBe('posthog');
});
test('handle captures trial.started with plan, interval and trial_ends_at', function () {
Queue::fake();
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job) {
return $job->method === 'capture'
&& $job->payload['event'] === TrialEvent::Started->value
&& $job->payload['distinctId'] === (string) $this->user->id
&& $job->payload['properties']['$groups']['account'] === (string) $this->account->id
&& $job->payload['properties']['plan_name'] === $this->plan->name
&& $job->payload['properties']['interval'] === 'monthly'
&& $job->payload['properties']['trial_ends_at'] === $this->trialEnd->toIso8601String();
});
});
test('handle never includes conversion properties — no charge has happened yet', function () {
Queue::fake();
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function (SendEvent $job) {
$properties = $job->payload['properties'];
return ! array_key_exists('conversion_value', $properties)
&& ! array_key_exists('conversion_currency', $properties)
&& ! array_key_exists('conversion_transaction_id', $properties);
});
});
test('handle resolves the yearly interval from the price id', function () {
$this->payload['data']['object']['items']['data'][0]['price']['id'] = 'price_workspace_yearly';
Queue::fake();
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(
SendEvent::class,
fn (SendEvent $job) => $job->payload['properties']['interval'] === 'yearly',
);
});
test('handle does not forward persona — it is already an identified person property', function () {
$this->user->update(['persona' => Persona::Agency->value]);
Queue::fake();
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(
SendEvent::class,
fn (SendEvent $job) => ! array_key_exists('persona', $job->payload['properties']),
);
});
test('handle returns silently when account does not exist', function () {
Queue::fake();
(new TrackTrialStarted('00000000-0000-0000-0000-000000000000', $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle returns silently when account has no owner', function () {
$this->account->update(['owner_id' => null]);
Queue::fake();
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle returns silently when account has no plan', function () {
$this->account->update(['plan_id' => null]);
Queue::fake();
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle does not push a PostHog network call when api key is unset', function () {
config(['services.posthog.api_key' => null]);
Queue::fake();
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class);
});
test('handle does not push a PostHog network call when disabled in production', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Queue::fake();
Log::shouldReceive('info')->never();
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class);
});
test('handle logs locally but still does not push a PostHog network call in the local environment when disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Queue::fake();
Log::shouldReceive('info')->once()->withArgs(
fn ($message) => $message === 'PostHogService: capture',
);
(new TrackTrialStarted((string) $this->account->id, $this->payload))
->handle(app(PostHogService::class));
// shouldTrack() lets handle() run (and capture() logs locally, asserted
// above), but the real PostHog dispatch stays gated on isEnabled() alone.
Queue::assertNotPushed(SendEvent::class);
});

View file

@ -5,6 +5,9 @@
use App\Enums\Plan\Slug;
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\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\Plan;
@ -216,6 +219,32 @@
Bus::assertNotDispatched(TrackBilling::class);
});
test('TrackBilling is not dispatched when PostHog is disabled in production', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
]));
Bus::assertNotDispatched(TrackBilling::class);
});
test('TrackBilling is dispatched in the local environment even when PostHog is disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
]));
Bus::assertDispatched(TrackBilling::class);
});
test('TrackBilling is not dispatched when api key is missing', function () {
config(['services.posthog.api_key' => null]);
Bus::fake([TrackBilling::class]);
@ -387,3 +416,350 @@
fn ($job) => $job->event === BillingEvent::Created && $job->previousPlan === null,
);
});
// ========================================
// checkout.completed / trial.started tracking
// ========================================
test('subscription created dispatches TrackCheckoutCompleted when status is active', function () {
Bus::fake([TrackCheckoutCompleted::class, TrackTrialStarted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'active',
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
]],
]));
Bus::assertDispatched(
TrackCheckoutCompleted::class,
fn ($job) => $job->accountId === (string) $this->account->id,
);
Bus::assertNotDispatched(TrackTrialStarted::class);
});
test('subscription created dispatches TrackTrialStarted when status is trialing', function () {
Bus::fake([TrackCheckoutCompleted::class, TrackTrialStarted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'trialing',
'trial_end' => now()->addDays(8)->timestamp,
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
]],
]));
Bus::assertDispatched(
TrackTrialStarted::class,
fn ($job) => $job->accountId === (string) $this->account->id,
);
Bus::assertNotDispatched(TrackCheckoutCompleted::class);
});
test('subscription created dispatches neither job for a status we do not track', function () {
Bus::fake([TrackCheckoutCompleted::class, TrackTrialStarted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'incomplete',
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
]],
]));
Bus::assertNotDispatched(TrackCheckoutCompleted::class);
Bus::assertNotDispatched(TrackTrialStarted::class);
});
test('subscription updated and deleted do not dispatch TrackCheckoutCompleted or TrackTrialStarted', function (string $type) {
Bus::fake([TrackCheckoutCompleted::class, TrackTrialStarted::class]);
$this->listener->handle(new WebhookReceived([
'type' => $type,
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active']],
]));
Bus::assertNotDispatched(TrackCheckoutCompleted::class);
Bus::assertNotDispatched(TrackTrialStarted::class);
})->with([
'updated' => 'customer.subscription.updated',
'deleted' => 'customer.subscription.deleted',
]);
test('TrackCheckoutCompleted is not dispatched when PostHog is disabled', function () {
config(['services.posthog.enabled' => false]);
Bus::fake([TrackCheckoutCompleted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'active',
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
]],
]));
Bus::assertNotDispatched(TrackCheckoutCompleted::class);
});
test('TrackCheckoutCompleted and TrackTrialStarted are not dispatched when PostHog is disabled in production', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([TrackCheckoutCompleted::class, TrackTrialStarted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'active',
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
]],
]));
Bus::assertNotDispatched(TrackCheckoutCompleted::class);
Bus::assertNotDispatched(TrackTrialStarted::class);
});
test('TrackCheckoutCompleted is dispatched in the local environment even when PostHog is disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([TrackCheckoutCompleted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'active',
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
]],
]));
Bus::assertDispatched(TrackCheckoutCompleted::class);
});
// ========================================
// trial.converted tracking
// ========================================
test('subscription updated dispatches TrackTrialConverted when trialing transitions to active', function () {
Bus::fake([TrackTrialConverted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'active',
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
],
'previous_attributes' => ['status' => 'trialing'],
],
]));
Bus::assertDispatched(
TrackTrialConverted::class,
fn ($job) => $job->accountId === (string) $this->account->id,
);
});
test('subscription updated dispatches TrackTrialConverted when a trial recovers from a failed first charge', function () {
Bus::fake([TrackTrialConverted::class]);
$trialEnd = now()->subDay()->timestamp;
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'active',
'trial_end' => $trialEnd,
'items' => ['data' => [[
'price' => ['id' => 'price_workspace_monthly'],
'current_period_start' => $trialEnd,
]]],
],
'previous_attributes' => ['status' => 'past_due'],
],
]));
Bus::assertDispatched(
TrackTrialConverted::class,
fn ($job) => $job->accountId === (string) $this->account->id,
);
});
test('subscription updated does not dispatch TrackTrialConverted for a past_due recovery on a subscription that never had a trial', function () {
Bus::fake([TrackTrialConverted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active'],
'previous_attributes' => ['status' => 'past_due'],
],
]));
Bus::assertNotDispatched(TrackTrialConverted::class);
});
test('subscription updated does not dispatch TrackTrialConverted for a later, unrelated past_due recovery on a long-converted subscription', function () {
Bus::fake([TrackTrialConverted::class]);
// trial_end is set (Stripe never clears it), but current_period_start is
// months ahead of it — this is a routine card-decline-then-recovery on an
// already-converted subscription, not the trial's own first charge retry.
$trialEnd = now()->subMonths(6)->timestamp;
$currentPeriodStart = now()->subDays(3)->timestamp;
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_test123',
'id' => 'sub_123',
'status' => 'active',
'trial_end' => $trialEnd,
'items' => ['data' => [[
'price' => ['id' => 'price_workspace_monthly'],
'current_period_start' => $currentPeriodStart,
]]],
],
'previous_attributes' => ['status' => 'past_due'],
],
]));
Bus::assertNotDispatched(TrackTrialConverted::class);
});
test('subscription updated does not dispatch TrackTrialConverted when the new status is not active', function () {
Bus::fake([TrackTrialConverted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'past_due'],
'previous_attributes' => ['status' => 'trialing'],
],
]));
Bus::assertNotDispatched(TrackTrialConverted::class);
});
test('TrackTrialConverted is not dispatched when PostHog is disabled', function () {
config(['services.posthog.enabled' => false]);
Bus::fake([TrackTrialConverted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active'],
'previous_attributes' => ['status' => 'trialing'],
],
]));
Bus::assertNotDispatched(TrackTrialConverted::class);
});
test('TrackTrialConverted is not dispatched when PostHog is disabled in production', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([TrackTrialConverted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active'],
'previous_attributes' => ['status' => 'trialing'],
],
]));
Bus::assertNotDispatched(TrackTrialConverted::class);
});
test('TrackTrialConverted is dispatched in the local environment even when PostHog is disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
Bus::fake([TrackTrialConverted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active'],
'previous_attributes' => ['status' => 'trialing'],
],
]));
Bus::assertDispatched(TrackTrialConverted::class);
});
// ========================================
// Idempotency (Stripe webhook redelivery)
// ========================================
test('redelivering the same stripe event id only processes it once', function () {
Bus::fake([TrackTrialConverted::class]);
$payload = [
'id' => 'evt_test_redelivered',
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active'],
'previous_attributes' => ['status' => 'trialing'],
],
];
$this->listener->handle(new WebhookReceived($payload));
$this->listener->handle(new WebhookReceived($payload));
Bus::assertDispatchedTimes(TrackTrialConverted::class, 1);
});
test('two different stripe event ids are both processed', function () {
Bus::fake([TrackTrialConverted::class]);
$this->listener->handle(new WebhookReceived([
'id' => 'evt_test_first',
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active'],
'previous_attributes' => ['status' => 'trialing'],
],
]));
$this->listener->handle(new WebhookReceived([
'id' => 'evt_test_second',
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active'],
'previous_attributes' => ['status' => 'trialing'],
],
]));
Bus::assertDispatchedTimes(TrackTrialConverted::class, 2);
});
test('an event without an id is still processed (no idempotency key available)', function () {
Bus::fake([TrackTrialConverted::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => [
'object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'active'],
'previous_attributes' => ['status' => 'trialing'],
],
]));
Bus::assertDispatchedTimes(TrackTrialConverted::class, 1);
});

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\User;
test('signup success page requires authentication', function () {
$response = $this->get(route('register.success'));
$response->assertRedirect(route('login'));
});
test('signup success page renders with default email provider', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('register.success'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('auth/SignupSuccess')
->where('authProvider', 'email')
);
});
test('signup success page renders with google provider from session', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->withSession(['auth_provider' => 'google'])
->get(route('register.success'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('auth/SignupSuccess')
->where('authProvider', 'google')
);
});

View file

@ -2,12 +2,18 @@
declare(strict_types=1);
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(function () {
config([
'trypost.self_hosted' => false,
'trypost.google_auth_enabled' => true,
'trypost.github_auth_enabled' => true,
'services.google-auth.client_id' => 'test-client-id',
'services.google-auth.client_secret' => 'test-client-secret',
'services.google-auth.redirect' => 'https://app.trypost.test/auth/google/callback',
@ -59,7 +65,7 @@
$response = $this->get(route('auth.google.callback'));
$response->assertRedirect(route('register.success'));
$response->assertRedirect(route('app.welcome'));
$user = User::where('email', 'new@example.com')->first();
expect($user)->not->toBeNull();
@ -87,7 +93,7 @@
$response = $this->get(route('auth.github.callback'));
$response->assertRedirect(route('register.success'));
$response->assertRedirect(route('app.welcome'));
$user = User::where('email', 'newdev@example.com')->first();
expect($user)->not->toBeNull();
@ -99,6 +105,452 @@
$this->assertAuthenticatedAs($user);
});
// ========================================
// Invite acceptance via OAuth
// ========================================
test('google registration with an invite param completes it instead of creating a default workspace', function () {
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'invited@example.com',
'workspaces' => [$workspace->id],
]);
$this->get(route('auth.google.redirect', [
'invite' => $invite->id,
]));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'g-invited',
'name' => 'Invited User',
'email' => 'invited@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.google.callback'));
$response->assertRedirect(route('app.invites.show', $invite, absolute: false));
$user = User::where('email', 'invited@example.com')->first();
expect($user)->not->toBeNull();
// is_invite skipped default workspace creation — CreateUser's own account
// shell exists, but no personal workspace was spun up under it.
expect($user->workspaces()->count())->toBe(0);
});
test('google login with a pending invite sends an existing user there instead of app.home', function () {
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'existing-invited@example.com',
'workspaces' => [$workspace->id],
]);
$existingUser = User::factory()->create(['email' => 'existing-invited@example.com']);
$this->get(route('auth.google.redirect', [
'invite' => $invite->id,
]));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'g-existing-invited',
'name' => 'Existing Invited',
'email' => 'existing-invited@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.google.callback'));
$response->assertRedirect(route('app.invites.show', $invite, absolute: false));
$this->assertAuthenticatedAs($existingUser);
});
test('github registration with an invite param completes it instead of creating a default workspace', function () {
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'gh-invited@example.com',
'workspaces' => [$workspace->id],
]);
$this->get(route('auth.github.redirect', [
'invite' => $invite->id,
]));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'gh-invited',
'name' => 'Invited User',
'email' => 'gh-invited@example.com',
]);
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.github.callback'));
$response->assertRedirect(route('app.invites.show', $invite, absolute: false));
$user = User::where('email', 'gh-invited@example.com')->first();
expect($user)->not->toBeNull();
expect($user->workspaces()->count())->toBe(0);
});
test('github login with a pending invite sends an existing user there instead of app.home', function () {
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'gh-existing-invited@example.com',
'workspaces' => [$workspace->id],
]);
$existingUser = User::factory()->create(['email' => 'gh-existing-invited@example.com']);
$this->get(route('auth.github.redirect', [
'invite' => $invite->id,
]));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'gh-existing-invited',
'name' => 'Existing Invited',
'email' => 'gh-existing-invited@example.com',
]);
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.github.callback'));
$response->assertRedirect(route('app.invites.show', $invite, absolute: false));
$this->assertAuthenticatedAs($existingUser);
});
test('google registration rejects an invite issued to a different email and creates no account', function () {
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'intended-recipient@example.com',
'workspaces' => [$workspace->id],
]);
$this->get(route('auth.google.redirect', ['invite' => $invite->id]));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'g-wrong-email',
'name' => 'Someone Else',
'email' => 'someone-else@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.google.callback'));
$response->assertRedirect(route('login'));
$response->assertSessionHasErrors('email');
expect(User::where('email', 'someone-else@example.com')->exists())->toBeFalse();
$this->assertGuest();
});
test('github registration rejects an invite issued to a different email and creates no account', function () {
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'gh-intended-recipient@example.com',
'workspaces' => [$workspace->id],
]);
$this->get(route('auth.github.redirect', ['invite' => $invite->id]));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'gh-wrong-email',
'name' => 'Someone Else',
'email' => 'gh-someone-else@example.com',
]);
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.github.callback'));
$response->assertRedirect(route('login'));
$response->assertSessionHasErrors('email');
expect(User::where('email', 'gh-someone-else@example.com')->exists())->toBeFalse();
$this->assertGuest();
});
test('a stale invite id from an aborted oauth attempt does not leak into a later invite-less signup', function () {
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'stale-invite-target@example.com',
'workspaces' => [$workspace->id],
]);
// First attempt starts with an invite param, but the round-trip is
// abandoned before the callback ever runs — the invite id is already in
// session at this point.
$this->get(route('auth.google.redirect', ['invite' => $invite->id]));
// A later, unrelated "Sign in with Google" attempt carries no invite param.
$this->get(route('auth.google.redirect'));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'g-unrelated',
'name' => 'Unrelated Signup',
'email' => 'unrelated@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.google.callback'));
$response->assertRedirect(route('app.welcome'));
$user = User::where('email', 'unrelated@example.com')->first();
expect($user)->not->toBeNull();
expect($user->workspaces()->count())->toBe(1);
});
// ========================================
// Self-hosted registration gate
// ========================================
test('google registration 404s in self-hosted mode without an invite param', function () {
config()->set('trypost.self_hosted', true);
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'g-no-invite',
'name' => 'No Invite',
'email' => 'no-invite@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$this->get(route('auth.google.callback'))->assertNotFound();
expect(User::where('email', 'no-invite@example.com')->exists())->toBeFalse();
});
test('google registration succeeds in self-hosted mode with an invite param', function () {
config()->set('trypost.self_hosted', true);
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'self-hosted-invite@example.com',
'workspaces' => [$workspace->id],
]);
$this->get(route('auth.google.redirect', ['invite' => $invite->id]));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'g-self-hosted-invite',
'name' => 'Self Hosted Invite',
'email' => 'self-hosted-invite@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$this->get(route('auth.google.callback'))
->assertRedirect(route('app.invites.show', $invite, absolute: false));
expect(User::where('email', 'self-hosted-invite@example.com')->exists())->toBeTrue();
});
test('google login for an existing user is never blocked by the self-hosted gate', function () {
config()->set('trypost.self_hosted', true);
$user = User::factory()->create(['email' => 'existing-self-hosted@example.com']);
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'g-existing-self-hosted',
'name' => 'Existing Self Hosted',
'email' => 'existing-self-hosted@example.com',
]);
Socialite::shouldReceive('driver')
->with('google-auth')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.google.callback'));
$response->assertRedirect(route('app.home'));
$this->assertAuthenticatedAs($user);
});
test('github registration 404s in self-hosted mode without an invite param', function () {
config()->set('trypost.self_hosted', true);
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'gh-no-invite',
'name' => 'No Invite',
'email' => 'gh-no-invite@example.com',
]);
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$this->get(route('auth.github.callback'))->assertNotFound();
expect(User::where('email', 'gh-no-invite@example.com')->exists())->toBeFalse();
});
test('github registration succeeds in self-hosted mode with an invite param', function () {
config()->set('trypost.self_hosted', true);
$inviterAccount = Account::factory()->create();
$inviter = User::factory()->create(['account_id' => $inviterAccount->id]);
$inviterAccount->update(['owner_id' => $inviter->id]);
$workspace = Workspace::factory()->create([
'account_id' => $inviterAccount->id,
'user_id' => $inviter->id,
]);
$invite = Invite::factory()->create([
'account_id' => $inviterAccount->id,
'invited_by' => $inviter->id,
'email' => 'gh-self-hosted-invite@example.com',
'workspaces' => [$workspace->id],
]);
$this->get(route('auth.github.redirect', ['invite' => $invite->id]));
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'gh-self-hosted-invite',
'name' => 'Self Hosted Invite',
'email' => 'gh-self-hosted-invite@example.com',
]);
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$this->get(route('auth.github.callback'))
->assertRedirect(route('app.invites.show', $invite, absolute: false));
expect(User::where('email', 'gh-self-hosted-invite@example.com')->exists())->toBeTrue();
});
test('github login for an existing user is never blocked by the self-hosted gate', function () {
config()->set('trypost.self_hosted', true);
$user = User::factory()->create(['email' => 'gh-existing-self-hosted@example.com']);
$socialiteUser = new SocialiteUser;
$socialiteUser->map([
'id' => 'gh-existing-self-hosted',
'name' => 'Existing Self Hosted',
'email' => 'gh-existing-self-hosted@example.com',
]);
Socialite::shouldReceive('driver')
->with('github')
->andReturn($driver = Mockery::mock());
$driver->shouldReceive('user')->andReturn($socialiteUser);
$response = $this->get(route('auth.github.callback'));
$response->assertRedirect(route('app.home'));
$this->assertAuthenticatedAs($user);
});
test('google callback marks unverified existing user as verified', function () {
$user = User::factory()->create([
'email' => 'unverified@example.com',

View file

@ -4,6 +4,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;
@ -228,6 +229,61 @@
&& data_get($event->payload, 'properties.referral_source') === ReferralSource::ProductHunt->value);
});
test('referral source store captures checkout.started with the plan name and interval', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
$plan->update(['stripe_monthly_price_id' => 'price_monthly_test']);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->once()
->andReturn(redirect('https://checkout.stripe.test/session'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
]);
Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture'
&& data_get($event->payload, 'event') === CheckoutEvent::Started->value
&& data_get($event->payload, 'properties.plan_name') === $plan->name
&& data_get($event->payload, 'properties.interval') === 'monthly');
});
test('referral source store does not capture checkout.started when Stripe checkout creation fails', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
]);
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->once()
->andThrow(new RuntimeException('Stripe checkout could not be created.'));
$this->actingAs($this->user->fresh())
->post(route('app.welcome.referral-source.store'), [
'referral_source' => ReferralSource::ProductHunt->value,
]);
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value,
);
});
test('welcome steps redirect to calendar for subscribed accounts', function (string $routeName, string $method, array $payload = []) {
subscribeAccount($this->user->account);
@ -392,6 +448,8 @@
});
test('referral source store fails loudly when the monthly price is not configured', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$this->user->update([
'persona' => Persona::Agency->value,
'goals' => [Goal::SaveTime->value],
@ -405,4 +463,9 @@
'referral_source' => ReferralSource::Google->value,
])
->assertServerError();
Bus::assertNotDispatched(
SendEvent::class,
fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value,
);
});

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use App\Enums\Auth\SocialAuthProvider;
test('social auth provider has values and labels', function () {
expect(SocialAuthProvider::Google->value)->toBe('google');
expect(SocialAuthProvider::Google->label())->toBe('Google');
expect(SocialAuthProvider::GitHub->value)->toBe('github');
expect(SocialAuthProvider::GitHub->label())->toBe('GitHub');
});
test('isEnabled reflects the matching config key', function () {
config(['trypost.google_auth_enabled' => true, 'trypost.github_auth_enabled' => false]);
expect(SocialAuthProvider::Google->isEnabled())->toBeTrue();
expect(SocialAuthProvider::GitHub->isEnabled())->toBeFalse();
});
test('tryFrom returns null for an unknown provider', function () {
expect(SocialAuthProvider::tryFrom('twitter'))->toBeNull();
});

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use Illuminate\Support\Str;
test('fromId resolves an existing invite by a valid uuid', function () {
$account = Account::factory()->create();
$owner = User::factory()->create(['account_id' => $account->id]);
$invite = Invite::factory()->create([
'account_id' => $account->id,
'invited_by' => $owner->id,
]);
expect(Invite::fromId($invite->id))->not->toBeNull()
->and(Invite::fromId($invite->id)->id)->toBe($invite->id);
});
test('fromId returns null for a well-formed but unknown uuid', function () {
expect(Invite::fromId((string) Str::uuid()))->toBeNull();
});
test('fromId returns null for a non-uuid string without touching the database', function () {
expect(Invite::fromId('not-a-uuid'))->toBeNull();
});
test('fromId returns null for an empty or missing value', function () {
expect(Invite::fromId(''))->toBeNull()
->and(Invite::fromId(null))->toBeNull();
});

View file

@ -2,8 +2,16 @@
declare(strict_types=1);
use App\Enums\Auth\SocialAuthProvider;
use App\Models\User;
test('isConnectedTo reflects whether the provider id column is set', function () {
$user = User::factory()->make(['google_id' => 'g-123', 'github_id' => null]);
expect($user->isConnectedTo(SocialAuthProvider::Google))->toBeTrue();
expect($user->isConnectedTo(SocialAuthProvider::GitHub))->toBeFalse();
});
test('firstName returns the first token of the display name', function (string $name, string $expected) {
expect(User::factory()->make(['name' => $name])->firstName())->toBe($expected);
})->with([

View file

@ -7,6 +7,7 @@
use App\Models\Plan;
use App\Services\PostHogService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@ -157,6 +158,59 @@
Queue::assertNothingPushed();
});
// ========================================
// Local debug logging
// ========================================
test('capture logs to laravel.log in the local environment even when disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.api_key' => null]);
Queue::fake();
Log::shouldReceive('info')->once()->withArgs(function ($message, $payload) {
return $message === 'PostHogService: capture'
&& $payload['event'] === 'test_event'
&& $payload['distinctId'] === 'user-123';
});
(new PostHogService)->capture('user-123', 'test_event', ['foo' => 'bar']);
Queue::assertNothingPushed();
});
test('identify logs to laravel.log in the local environment even when disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.api_key' => null]);
Log::shouldReceive('info')->once()->withArgs(function ($message, $payload) {
return $message === 'PostHogService: identify'
&& $payload['distinctId'] === 'user-123';
});
(new PostHogService)->identify('user-123', ['$email' => 'test@example.com']);
});
test('groupIdentify logs to laravel.log in the local environment even when disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.api_key' => null]);
Log::shouldReceive('info')->once()->withArgs(function ($message, $payload) {
return $message === 'PostHogService: groupIdentify'
&& $payload['groupType'] === 'workspace';
});
(new PostHogService)->groupIdentify('workspace', 'ws-123', ['name' => 'Test']);
});
test('capture does not log outside the local environment', function () {
app()->detectEnvironment(fn () => 'testing');
config(['services.posthog.api_key' => null]);
Log::shouldReceive('info')->never();
(new PostHogService)->capture('user-123', 'test_event');
});
test('isEnabled requires both enabled and api key', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => null]);
expect(PostHogService::isEnabled())->toBeFalse();
@ -167,3 +221,51 @@
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_x']);
expect(PostHogService::isEnabled())->toBeTrue();
});
// ========================================
// shouldTrack: the gate used by call sites that pre-check before ever
// reaching capture()/identify() (CreateUser, StripeEventListener, and the
// individual PostHog job handle() methods). Must never let a disabled,
// non-local (i.e. production) install actually track — that's the exact
// self-hosted/production contract isEnabled() already guarantees. It only
// adds an escape hatch for the local environment, so local dev can see what
// would be sent without a real API key.
// ========================================
test('shouldTrack is false when disabled outside the local environment (production contract)', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
expect(PostHogService::shouldTrack())->toBeFalse();
});
test('shouldTrack is false when disabled with an inherited api key outside the local environment', function () {
app()->detectEnvironment(fn () => 'production');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => 'phc_inherited_key']);
expect(PostHogService::shouldTrack())->toBeFalse();
});
test('shouldTrack is false when disabled in the testing environment (the default test env)', function () {
app()->detectEnvironment(fn () => 'testing');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
expect(PostHogService::shouldTrack())->toBeFalse();
});
test('shouldTrack is true when enabled, regardless of environment', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_x']);
app()->detectEnvironment(fn () => 'production');
expect(PostHogService::shouldTrack())->toBeTrue();
app()->detectEnvironment(fn () => 'local');
expect(PostHogService::shouldTrack())->toBeTrue();
});
test('shouldTrack is true in the local environment even when disabled', function () {
app()->detectEnvironment(fn () => 'local');
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => null]);
expect(PostHogService::shouldTrack())->toBeTrue();
});

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Support\StripeSubscriptionConversion;
beforeEach(function () {
$this->plan = Plan::where('slug', 'workspace')->firstOrFail();
$this->plan->update([
'stripe_monthly_price_id' => 'price_workspace_monthly',
'stripe_yearly_price_id' => 'price_workspace_yearly',
]);
$this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
$this->user = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->user->id]);
$this->account->load(['plan', 'owner']);
});
test('baseProperties includes plan name and interval but no persona or conversion data', function () {
$payload = [
'data' => ['object' => [
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
]],
];
$properties = StripeSubscriptionConversion::baseProperties($this->account, $payload);
expect($properties)->toBe([
'plan_name' => $this->plan->name,
'interval' => 'monthly',
]);
});
test('baseProperties defaults to monthly, not yearly, when neither the payload price id nor the plan yearly price id is set', function () {
$this->plan->update(['stripe_yearly_price_id' => null]);
$payload = [
'data' => ['object' => [
'items' => ['data' => [['price' => []]]],
]],
];
$properties = StripeSubscriptionConversion::baseProperties($this->account->fresh(['plan']), $payload);
expect($properties['interval'])->toBe('monthly');
});
test('propertiesFor includes plan name, interval and conversion data', function () {
$payload = [
'data' => ['object' => [
'id' => 'sub_123',
'items' => ['data' => [[
'price' => [
'id' => 'price_workspace_monthly',
'unit_amount' => 2900,
'currency' => 'usd',
],
]]],
]],
];
$properties = StripeSubscriptionConversion::propertiesFor($this->account, $payload);
expect($properties)->toBe([
'plan_name' => $this->plan->name,
'interval' => 'monthly',
'conversion_value' => 29.0,
'conversion_currency' => 'USD',
'conversion_transaction_id' => 'sub_123',
]);
});
test('propertiesFor resolves the yearly interval from the price id', function () {
$payload = [
'data' => ['object' => [
'id' => 'sub_123',
'items' => ['data' => [['price' => ['id' => 'price_workspace_yearly']]]],
]],
];
$properties = StripeSubscriptionConversion::propertiesFor($this->account, $payload);
expect($properties['interval'])->toBe('yearly');
});
test('propertiesFor omits conversion fields when there is no price amount', function () {
$payload = [
'data' => ['object' => [
'id' => 'sub_123',
'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]],
]],
];
$properties = StripeSubscriptionConversion::propertiesFor($this->account, $payload);
expect($properties)->not->toHaveKeys(['conversion_value', 'conversion_currency', 'conversion_transaction_id']);
});