Commit graph

22 commits

Author SHA1 Message Date
Paulo Castellano
de54ea24f9
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.
2026-08-12 11:47:47 -03:00
Paulo Castellano
ca1e346227
feat: capture ad click IDs for ad-platform conversion attribution (#276)
* feat: capture ad click IDs for Meta/Google/LinkedIn/TikTok/Reddit/Pinterest attribution

Adds gclid, fbclid, li_fat_id, ttclid, rdt_cid, and epik columns to users,
captured the same way UTM parameters already are (query string -> session
-> persisted on signup, surviving the OAuth redirect round-trip via the
new PreservesClickIds trait).

Forwards them as first-touch ($set_once) PostHog person properties in
SyncUser, so PostHog's native ad-platform destinations (Meta Ads
Conversions API, Google Ads Conversions, LinkedIn Ads, TikTok Ads, Reddit
Ads, Pinterest) have first-party click IDs to match conversions back to
the originating ad click.

* refactor: unify PreservesUtmParameters and PreservesClickIds into one trait

Both traits captured a set of query-string keys into the session and
retrieved them at signup, with identical extract/store/retrieve logic and
every call site always using both together — the split added no real
separation, just duplicated the same mechanism twice.

PreservesAttributionParameters replaces both with a single ATTRIBUTION_KEYS
list and one session key. Adding a future ad network's click ID is now one
line in that list instead of a second trait.

* refactor: split UTM_KEYS and CLICK_ID_KEYS into separate constants

Same single trait, single session key, single extract/store/retrieve
mechanism — just two named arrays instead of one merged list, so it's
clear at a glance which key belongs to which category.

* fix: don't truncate ad click IDs to 255 chars, only UTM parameters

Ad platforms explicitly warn against assuming a fixed max length for
click IDs (Google: gclid has already grown from 26 to 100+ chars, and
their docs say never truncate or validate against a fixed length).
Truncating would silently corrupt the value into something that no
longer matches the real click ID, which is worse than not capturing it
at all.

Widens the click-id columns from string (VARCHAR 255) to text — safe to
edit the migration in place since it hasn't shipped to production yet.
UTM parameters still get truncated to 255, since those are ours (our own
campaign URLs) and the column stays VARCHAR(255).

* refactor: use Laravel collection/Str helpers, forward UTMs to PostHog too

- extractAttributionParameters now reads through collect()/Str::limit()
  instead of raw array_filter/array_map/mb_substr; storeAttributionParameters
  drops its now-redundant emptiness check since retrieveAttributionParameters
  already treats "absent" and "present-but-empty" the same via pull()'s
  default.
- SyncUser forwards utm_source/medium/campaign/term/content alongside the
  click ids as first-touch ($set_once) PostHog person properties. UTMs were
  never sent to PostHog before this, on any prior code — now that PostHog is
  the source of truth for ad-platform attribution, it should have the full
  picture, not just click ids.
- Adds the missing GitHub-existing-user click-id session test, mirroring
  the Google one (parity with the existing UTM coverage).

* fix: 3 issues found by review — empty-string leak, duplicated key list, comment style

- extractAttributionParameters no longer keeps an empty-string value (e.g.
  ?utm_source=&gclid=, which some ad/email templates always append even
  for unfilled slots). The refactor to collect()/Str::limit() a few
  commits back dropped the outer array_filter() that used to strip these,
  so they were slipping into User::create() as '' instead of staying
  null. Restored via a trailing ->filter() on the merged result, and
  extended the same protection to click ids (which never had it, even
  before that refactor).
- New App\Support\AttributionKeys centralizes the UTM_KEYS/CLICK_ID_KEYS
  lists that PreservesAttributionParameters and SyncUser each maintained
  independently. SyncUser previously hand-listed the same 11 field names
  as a second array with no shared source of truth — a future ad network
  added to the trait would silently never reach PostHog unless someone
  remembered to update this second copy too.
- Removed the // comment block from the click-id migration explaining the
  text-column rationale — CLAUDE.md's PHP rules reserve inline comments
  for exceptionally complex logic; the rationale already lives in the
  commit message that introduced it.
2026-08-11 15:07:23 -03:00
Paulo Castellano
3d01f3aab4 fix(signup): keep default-workspace creation inside the registration transaction
Reverts the CreateWorkspace-outside-transaction change from the previous commit.
That move was based on a mis-framed concern: forgetPlanFeatureCache() uses
Pennant's default 'database' store (no config/pennant.php), so it's a DB write,
not an external cache call — safe inside a DB transaction. Moving CreateWorkspace
out introduced two failure-path regressions: a stranded half-registered account
(user committed but not logged in, blocked from retrying by unique:users) and an
orphan workspace inflating the Stripe quantity. Back inside the transaction the
whole signup is atomic again.
2026-06-22 08:47:47 -03:00
Paulo Castellano
caad3d3e53 refactor: address third-pass review nits
- CreateUser: provision the default workspace OUTSIDE the signup DB transaction
  so CreateWorkspace's cache-forget / Stripe-quantity sync never runs inside a
  transaction (consistent with WorkspaceController::store); user+account stay
  atomic and a failed workspace create degrades to /workspaces/create.
- BillingController::swapToYearly: explicit null guard on the resolved
  subscription before dereferencing stripe_price.
- UsageController: reuse the already-loaded workspaces collection for the count
  instead of issuing a second query.
- Add a GitHub OAuth-callback test covering the default-workspace auto-create
  (mirrors the Google one).
2026-06-22 08:39:25 -03:00
Paulo Castellano
b7374c1adb feat(onboarding): auto-create a default workspace at signup
- CreateUser now provisions a default "{name}'s Workspace" for non-invite
  signups (email, Google, GitHub, seeder), with the owner as Admin and
  current_workspace_id set. Invite signups still skip it (they join the
  inviter's workspace on acceptance).
- UserSeeder drops its now-redundant explicit CreateWorkspace call.
- After Stripe checkout, billing/processing lands the user on the social
  accounts screen with the connect dialog open (was: calendar), so onboarding
  flows straight into connecting an account.

Quantity stays correct (1 workspace → checkout quantity 1). The first workspace
is created without brand details; brand setup remains available in Settings and
on the create-workspace screen for additional workspaces.
2026-06-22 08:18:19 -03:00
Paulo Castellano
0619f2a0f4 refactor: address code review for per-workspace pricing
Bug fix
- Surface the localized "network already connected" message on the
  Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
  callbacks: catch NetworkAlreadyConnectedException before the generic catch
  so the conflict no longer falls through to a generic error + Log::error.

Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
  (onboarding starts checkout directly); delete the now-dead DiscordWidget and
  useFeatureAccess composable; prune orphaned i18n keys left by removing the
  plan picker / upgrade dialog / count limits (billing.subscribe.*,
  accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
  the onboarding persona payload (labels come from i18n); remove
  Persona::options()/label().

Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
  WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
2026-06-21 21:28:47 -03:00
Paulo Castellano
cbf8fb283c feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
  workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
  on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
  (workspace/social/member) and the legacy plan tiers (single Workspace plan).

Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
  (Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
  (users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
  price. 8-day trial so Stripe displays 7.

Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
  banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.

System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
  (system feature, not the user's usage).

Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 20:40:03 -03:00
Paulo Castellano
3611e882b4 feat(billing): make trial card requirement configurable
Add a trypost config toggle to switch between card-required checkout trials and no-card signup trials, and wire signup, checkout, access gating, UI copy, and tests to both modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:02:38 -03:00
Paulo Castellano
26c738c0ba fix(billing): require card-backed trial again
Revert the no-card signup trial flow so access depends on a Stripe subscription trial started at checkout, preventing app access before a payment method is collected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 09:49:44 -03:00
Paulo Castellano
0b60fba277 chore: drop redundant fallback on config('cashier.trial_days') 2026-05-14 20:00:24 -03:00
Paulo Castellano
d71df05291 refactor: inline plan_id and trial_ends_at in CreateUser + drop docblock on activeTrialEndsAt 2026-05-14 19:59:50 -03:00
Paulo Castellano
177e7f8681 feat(signup): no-card 7-day trial on Starter plan
New signups land on a 7-day generic trial (Cashier trial_ends_at) without
a Stripe customer or subscription. Account is on Starter plan limits during
the trial. After 7 days, EnsureAccountReady redirects to /subscribe per the
existing flow.

- CreateUser sets account.trial_ends_at and plan_id = Starter
- EnsureAccountReady allows access when subscribed() OR onGenericTrial()
- Account::isOnTrial() includes generic trial check

Existing users unaffected: paying users have a subscription;
never-paid users continue redirecting to /subscribe.
2026-05-14 19:37:46 -03:00
Paulo Castellano
cd28ac4025 feat: explicit POSTHOG_ENABLED gate for self-hosted safety
Self-hosted installs that inherited POSTHOG_API_KEY from an example or
older deploy were still seeing SyncUser/SendEvent jobs run because the
gate was based on the api key alone. Switches the gate to an explicit
'services.posthog.enabled' flag (env: POSTHOG_ENABLED, default false)
and requires both enabled=true AND api_key for tracking to fire.

Backend gating:
- PostHogService::isEnabled() — single static helper used everywhere.
- AppServiceProvider::configurePostHog — skips PostHog::init when off.
- CreateUser::execute — does not enqueue SyncUser when off.
- SyncUser::handle, TrackBilling::handle, SendEvent::handle — early
  return before any DB query so the queue worker does no work.

Frontend gating:
- New VITE_POSTHOG_ENABLED env var mirrored from POSTHOG_ENABLED.
- initializePostHog, syncPostHogContext, capturePageview all gated.

Tests updated to set both flags on the happy path; adds explicit
'CreateUser does not dispatch SyncUser when PostHog is disabled'.

Deploy note: the trypost.it cloud .env must set POSTHOG_ENABLED=true
before this branch is merged or analytics will go dark.
2026-05-07 12:42:35 -03:00
Paulo Castellano
3e3c0b4c9d refactor: namespace PostHog jobs + extract billing tracking, add tests
Reorganises PostHog plumbing under `App\Jobs\PostHog` and extracts the
Stripe billing capture out of `StripeEventListener` into its own job.
Adds the missing test coverage that was promised but not delivered in
the previous commit.

Code changes:
- Move `app/Jobs/SendPostHogEvent.php` → `app/Jobs/PostHog/SendEvent.php`
  (low-level dispatcher).
- Move `app/Jobs/SyncUserToPostHog.php` → `app/Jobs/PostHog/SyncUser.php`
  (high-level user/account/workspace sync).
- New `app/Jobs/PostHog/TrackBilling.php` that owns the
  capture('subscription.*') + SyncUser re-dispatch flow. Receives
  account id + event name + payload, runs on the `posthog` queue.
- `StripeEventListener` slims down to a switch table mapping Stripe
  event types to PostHog event names and dispatches `TrackBilling`. No
  more inline tracking logic in the listener.
- `resources/js/posthog.ts` now owns `syncPostHogContext(page)` and
  `capturePageview()`. `resources/js/app.ts` imports them — no behaviour
  inlined in the bootstrap.
- `app/Services/PostHogService.php` and `app/Actions/User/CreateUser.php`
  updated to the new namespaces.

Tests added/updated:
- `tests/Feature/Jobs/PostHog/SyncUserTest.php` — identify/group payload
  shape, account metrics, workspace skip when none, queue assignment,
  no-op without api key.
- `tests/Feature/Jobs/PostHog/TrackBillingTest.php` — capture payload,
  SyncUser re-dispatch, missing-account/owner handling, api key gate.
- `tests/Feature/Jobs/PostHog/SendEventTest.php` — moved from
  `tests/Feature/SendPostHogEventTest.php` and updated to new namespace.
- `tests/Unit/PostHogServiceTest.php` — adds coverage for the
  account-aware capture (auto-attached `\$groups.account`, `account_id`,
  `plan`) and the no-account branch.
- `tests/Feature/Listeners/StripeEventListenerTest.php` — replaces the
  old inline-PostHog assertions with `Bus::fake([TrackBilling::class])`
  and verifies the listener dispatches TrackBilling with the right
  account id + event name for each subscription type, and skips
  non-subscription event types.
- `tests/Feature/Actions/User/CreateUserTest.php` — verifies signup
  dispatches `SyncUser` with the new user id.

Suite: 1427 passed (+20 net new, including the previous round of
metrics-related tests).
2026-05-07 09:41:36 -03:00
Paulo Castellano
e3538df2f0 feat: end-to-end PostHog tracking with reactive group metrics
Wire PostHog identify + dual-group context across the stack so events
land on the right person and account/workspace groups, with counts kept
fresh by Inertia navigations rather than per-domain triggers.

- New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised
  high-level sync — identifies the user and group-identifies their
  account + current workspace using `$account->usage()` so the metrics
  reuse the same source of truth Inertia ships in shared props.
- `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on
  signup instead of calling `PostHogService` inline. Keeps the action
  fast and routes everything through the queue.
- `app/Listeners/StripeEventListener.php`: webhook now captures
  `subscription.created`/`updated`/`cancelled` against the account
  owner profile (with `account` group auto-attached) and re-dispatches
  `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial
  refresh after Stripe state changes.
- `app/Services/PostHogService.php`: `capture()` accepts an optional
  `Account` that auto-attaches `$groups.account`, `account_id`, and
  `plan` properties. Each public method short-circuits when
  `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected.
- `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage
  shape (combined `withCount(['socialAccounts','posts'])` query) so
  posts count is part of the same payload Inertia already ships.
- `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the
  queued PostHog jobs actually drain in production.
- `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls
  it on boot AND on every Inertia navigation, reading the fresh
  `usage` props. This:
  - Refreshes account group counts (workspaces, social accounts,
    posts, members, credits) without per-domain triggers.
  - Resolves the workspace-switch case where `setup()` does not
    re-run but `navigate` fires with the new `auth.currentWorkspace`.
  - Captures the initial `$pageview` so the first page of a session
    is no longer dropped.
- `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on
  logout so a follow-up login on the same browser doesn't keep events
  attributed to the previous user.
- `resources/js/composables/useFeatureAccess.ts`: TS `Usage`
  interface gains `postCount`.
- `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new
  usage shape. Full suite: 1407 passed.

Hierarchy aligned with the domain model: person = User,
group `account` = billing/plan parent, group `workspace` =
collaboration child (carries `account_id` for drill-down).
2026-05-07 09:28:02 -03:00
Paulo Castellano
2e9e0f716b feat: capture signup UTMs/IP and add GitHub OAuth login
Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):

- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
  the register/redirect GET, retrieves them on the POST/callback —
  surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer

Adds GitHub as a second OAuth provider:

- GitHubController mirroring the Google one (now renamed from
  SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
  on Login/Register, rendering each enabled provider plus a single
  "or continue with" divider

UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
2026-05-04 18:42:25 -03:00
Paulo Castellano
34272a573f feat: implement consolidated authentication settings including session management and social provider integration 2026-05-03 17:42:44 -03:00
Paulo Castellano
b3b59b4d13 refactor: remove onboarding flow, implement brand analysis services, and replace setup middleware with account readiness checks 2026-04-16 23:05:51 -03:00
Paulo Castellano
ded1c998ec feat: redesign billing, onboarding, sidebar, and settings architecture
- Sidebar reorganized: Workspace group (connections, hashtags, labels,
  API keys, settings) and Account group (settings, usage, billing)
- Account group only visible to owner and hidden in self-hosted mode
- Onboarding simplified: role -> account (connect socials) -> completed
  -> redirect to /subscribe. Removed Subscription setup step.
- Subscribe page redesigned with 4 plan cards, monthly/yearly toggle,
  trial info, and per-plan features list
- Billing page redesigned following Sendkit layout (sections with
  sidebar labels)
- Processing page uses usePoll with immediate watch for subscription
  activation
- Cancel URL redirects directly to /subscribe
- Account settings page with name and billing_email (syncs with Stripe)
- Usage page with ring meters for all plan limits
- Settings layout tabs only for user pages (profile, password,
  notifications). Workspace/API keys/billing are standalone pages.
- GoogleAuthButton extracted as reusable component
- WorkspaceRole TypeScript enum for type-safe role checks in frontend
- Trial period changed to 7 days
- Fixed onboarding loop when user confirms email
- All 1101 tests passing
2026-04-15 00:33:38 -03:00
Paulo Castellano
2da15df96c feat: introduce Account entity as billing owner and refactor architecture
- Create Account model as Cashier Billable entity (stripe, plan, subscription)
- Account owns workspaces and has an owner_id (User)
- User belongs to one Account via account_id
- Workspace belongs to Account via account_id, no longer has billing fields
- Remove Brand model entirely (workspaces serve as grouping)
- Rename brand_limit to workspace_limit in plans
- Workspace roles simplified: admin/member/viewer (owner via Account)
- Invites now belong to Account with workspaces JSON array
- Pennant features scope changed from Workspace to Account
- EnsureSubscribed middleware checks Account subscription
- All controllers updated: BillingController, OnboardingController,
  WorkspaceInviteController, SocialController, StripeEventListener
- Frontend: extract GoogleAuthButton component, create WorkspaceRole
  enum for type-safe role checks, fix all views for new architecture
- All 1101 tests passing
2026-04-14 22:22:04 -03:00
Paulo Castellano
74c6442728 refactor: code review fixes — policies, enums, data_get, tests
- Refactor WorkspacePolicy to use pivot role instead of workspace.user_id
- Add manageBilling policy (owner only) to BillingController
- Fix ApiKeyController authorization (view → manageTeam for store/destroy)
- Fix WorkspaceInviteController using workspace.user_id for owner checks
- Fix WorkspaceController settings is_owner using workspace.user_id
- Create PostAction enum for UpdatePost/PostController action strings
- Create ApiToken\Status enum
- Add User::SUBSCRIPTION_NAME constant, replace all hardcoded 'default'
- Convert wantsEmailFor to accept NotificationType enum
- Convert all $data[] to data_get() across publishers, controllers, jobs
- Fix SocialLoginController callback missing try/catch
- Fix SocialController::toggleActive missing workspace null check
- Fix UpdatePost NPE on meta merge when postPlatform not found
- Remove HTML5 required attributes from form inputs
- Convert function declarations to arrow functions in Vue components
- Replace hardcoded URLs with Wayfinder route helpers
- Replace new Date() with dayjs
- Add 16 new test files covering policies, authorization, publishing
2026-03-31 00:40:18 -03:00
Paulo Castellano
843b3991ec chore: posthog, ui, features and more 2026-03-30 21:18:07 -03:00