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:
parent
ca1e346227
commit
de54ea24f9
80 changed files with 2694 additions and 610 deletions
|
|
@ -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;
|
||||
|
|
|
|||
24
app/Enums/Auth/SocialAuthProvider.php
Normal file
24
app/Enums/Auth/SocialAuthProvider.php
Normal 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");
|
||||
}
|
||||
}
|
||||
11
app/Enums/PostHog/CheckoutEvent.php
Normal file
11
app/Enums/PostHog/CheckoutEvent.php
Normal 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';
|
||||
}
|
||||
11
app/Enums/PostHog/TrialEvent.php
Normal file
11
app/Enums/PostHog/TrialEvent.php
Normal 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';
|
||||
}
|
||||
10
app/Enums/PostHog/UserEvent.php
Normal file
10
app/Enums/PostHog/UserEvent.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\PostHog;
|
||||
|
||||
enum UserEvent: string
|
||||
{
|
||||
case SignedUp = 'user.signed_up';
|
||||
}
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
53
app/Http/Controllers/Auth/Concerns/PreservesInvite.php
Normal file
53
app/Http/Controllers/Auth/Concerns/PreservesInvite.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
63
app/Jobs/PostHog/AbstractTrackStripeSubscriptionEvent.php
Normal file
63
app/Jobs/PostHog/AbstractTrackStripeSubscriptionEvent.php
Normal 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;
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ public function __construct(public string $userId)
|
|||
|
||||
public function handle(PostHogService $postHog): void
|
||||
{
|
||||
if (! PostHogService::isEnabled()) {
|
||||
if (! PostHogService::shouldTrack()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
25
app/Jobs/PostHog/TrackCheckoutCompleted.php
Normal file
25
app/Jobs/PostHog/TrackCheckoutCompleted.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Jobs/PostHog/TrackTrialConverted.php
Normal file
25
app/Jobs/PostHog/TrackTrialConverted.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
app/Jobs/PostHog/TrackTrialStarted.php
Normal file
34
app/Jobs/PostHog/TrackTrialStarted.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
55
app/Support/StripeSubscriptionConversion.php
Normal file
55
app/Support/StripeSubscriptionConversion.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -59,12 +59,6 @@
|
|||
'github_signup' => 'التسجيل عبر GitHub',
|
||||
'github_email_unavailable' => 'تعذر جلب بريدك الإلكتروني من GitHub. اجعل بريدك على GitHub عامًا أو امنح نطاق الوصول إلى البريد، ثم حاول مرة أخرى.',
|
||||
|
||||
'signup_success' => [
|
||||
'page_title' => 'مرحبًا',
|
||||
'title' => 'جارٍ إعداد حسابك',
|
||||
'description' => 'يستغرق هذا عادةً بضع ثوانٍ فقط...',
|
||||
],
|
||||
|
||||
'login' => [
|
||||
'title' => 'تسجيل الدخول إلى حسابك',
|
||||
'description' => 'أدخل بريدك الإلكتروني وكلمة المرور أدناه لتسجيل الدخول',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 και τον κωδικό πρόσβασής σας παρακάτω για να συνδεθείτε',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -59,12 +59,6 @@
|
|||
'github_signup' => 'GitHub で登録',
|
||||
'github_email_unavailable' => 'GitHub からメールアドレスを取得できませんでした。GitHub のメールアドレスを公開するか、email スコープを許可してから、もう一度お試しください。',
|
||||
|
||||
'signup_success' => [
|
||||
'page_title' => 'ようこそ',
|
||||
'title' => 'アカウントを設定しています',
|
||||
'description' => '通常は数秒で完了します...',
|
||||
],
|
||||
|
||||
'login' => [
|
||||
'title' => 'アカウントにログイン',
|
||||
'description' => 'ログインするにはメールアドレスとパスワードを入力してください',
|
||||
|
|
|
|||
|
|
@ -59,12 +59,6 @@
|
|||
'github_signup' => 'GitHub으로 가입하기',
|
||||
'github_email_unavailable' => 'GitHub에서 이메일을 가져올 수 없습니다. GitHub 이메일을 공개로 설정하거나 이메일 권한을 부여한 후 다시 시도하세요.',
|
||||
|
||||
'signup_success' => [
|
||||
'page_title' => '환영합니다',
|
||||
'title' => '계정을 설정하는 중',
|
||||
'description' => '보통 몇 초면 완료됩니다...',
|
||||
],
|
||||
|
||||
'login' => [
|
||||
'title' => '계정에 로그인',
|
||||
'description' => '로그인하려면 아래에 이메일과 비밀번호를 입력하세요',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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ć',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 и пароль, чтобы войти',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -59,12 +59,6 @@
|
|||
'github_signup' => 'Зареєструватися через GitHub',
|
||||
'github_email_unavailable' => 'Не вдалося отримати ваш email з GitHub. Зробіть email публічним або надайте доступ до email, потім спробуйте ще раз.',
|
||||
|
||||
'signup_success' => [
|
||||
'page_title' => 'Ласкаво просимо',
|
||||
'title' => 'Налаштовуємо ваш обліковий запис',
|
||||
'description' => 'Зазвичай це займає лише кілька секунд...',
|
||||
],
|
||||
|
||||
'login' => [
|
||||
'title' => 'Увійдіть до облікового запису',
|
||||
'description' => 'Введіть email і пароль нижче, щоб увійти',
|
||||
|
|
|
|||
|
|
@ -59,12 +59,6 @@
|
|||
'github_signup' => '使用 GitHub 注册',
|
||||
'github_email_unavailable' => '无法从 GitHub 获取你的邮箱。请将你的 GitHub 邮箱设为公开,或授予邮箱权限后重试。',
|
||||
|
||||
'signup_success' => [
|
||||
'page_title' => '欢迎',
|
||||
'title' => '正在设置你的账户',
|
||||
'description' => '这通常只需几秒钟…',
|
||||
],
|
||||
|
||||
'login' => [
|
||||
'title' => '登录你的账户',
|
||||
'description' => '请在下方输入你的邮箱和密码以登录',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
} : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
52
tests/Browser/LoginErrorMessageTest.php
Normal file
52
tests/Browser/LoginErrorMessageTest.php
Normal 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'));
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
148
tests/Feature/Jobs/PostHog/TrackCheckoutCompletedTest.php
Normal file
148
tests/Feature/Jobs/PostHog/TrackCheckoutCompletedTest.php
Normal 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);
|
||||
});
|
||||
152
tests/Feature/Jobs/PostHog/TrackTrialConvertedTest.php
Normal file
152
tests/Feature/Jobs/PostHog/TrackTrialConvertedTest.php
Normal 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);
|
||||
});
|
||||
175
tests/Feature/Jobs/PostHog/TrackTrialStartedTest.php
Normal file
175
tests/Feature/Jobs/PostHog/TrackTrialStartedTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
24
tests/Unit/Enums/Auth/SocialAuthProviderTest.php
Normal file
24
tests/Unit/Enums/Auth/SocialAuthProviderTest.php
Normal 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();
|
||||
});
|
||||
33
tests/Unit/Models/InviteTest.php
Normal file
33
tests/Unit/Models/InviteTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
100
tests/Unit/Support/StripeSubscriptionConversionTest.php
Normal file
100
tests/Unit/Support/StripeSubscriptionConversionTest.php
Normal 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']);
|
||||
});
|
||||
Loading…
Reference in a new issue