* 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.
* Allow owners and admins to delete workspaces from settings.
Expose a danger zone with name confirmation, sync Stripe quantity on SaaS, and skip billing constraints in self-hosted mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant canDelete prop from workspace settings.
The settings page is already gated by update (owner/admin), which matches delete.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract workspace delete danger zone into DeleteWorkspace component.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify workspace delete billing copy across locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match workspace delete card to the delete-account settings pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden workspace and account deletion around shared members.
Enforce owner-only workspace creation, rehome stranded members to a personal account, warn about member access loss, and clarify the only-workspace SaaS exit paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden workspace delete: owner-only billing impact and safer member rehome.
Restrict delete to account owners, rehome stranded members transactionally with account-scoped fallbacks, and clean up the danger-zone UI/copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix workspace delete review findings.
Prune pending invites and media on delete, lock the account for the
last-workspace guard, fall back to account-owned workspaces for owners,
redirect self-hosted last deletes to create, cancel Stripe after local
cleanup, align personal-account trials, and gate Index create for owners.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Harden invite accept and account delete edge cases.
Stop invite accept from demoting existing roles, expire dead invites on
show, preserve flash by avoiding calendar bounces, move media file I/O
outside locked delete transactions, and finish account deletion even if
Stripe cancel fails.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix remaining invite redirect and media cleanup edge cases.
Distinguish already-accepted invites from gone workspaces, rehome
members removed from their last shared workspace, capture media paths
inside the delete lock, extract orphaned-file cleanup, and use Wayfinder
for the expired-invite home link.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix invite current-workspace and account-delete edge cases.
Switch invitees onto an invite-account workspace when accepting, prefer
same-account fallbacks when removing members, abort account deletion if
Stripe cancel fails, and clear avatar media on profile delete.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix Stripe-failure media leak and invite cross-account redirect.
Flush workspace media files before billing cancel can abort account
delete, and rehome stranded non-owners before picking an invite redirect
fallback so current workspace never points across accounts.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Never set cross-account current workspace on member rehome.
Keep RemoveMember and account-delete member fallbacks same-account
only, clarify the billing-failure flash that workspaces were already
removed, and assert storage deletion in media cleanup tests.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Sync Stripe workspace quantity when account delete billing fails.
After local workspaces are wiped, a stuck cancelNow must still drop
seat quantity so the subscription cannot keep billing the old count.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Prune account invites when owner delete wipes workspaces.
Pending and accepted invites are removed with the workspaces so a
Stripe cancel failure cannot leave unique email/account rows that block
re-invites to a gutted account.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Extract DeleteWorkspaceMedia to purge workspace media rows.
Call sites capture returned paths inside the lock and still flush
orphaned storage files after commit via DeleteOrphanedMediaFiles.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Redirect to calendar after deleting a workspace with a fallback.
When DeleteWorkspace already sets another current workspace, sending
the owner to the workspace picker is unnecessary — take them back into
the app instead.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Use Wayfinder for invite redirect and logo home links.
Replace hardcoded /invites/{id} and / hrefs in AcceptInvite with
show.url() and home() route helpers.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Use Wayfinder home() for AcceptInvite logo link.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Extract AcceptInvite title and description into computeds.
Keeps the expired/active copy logic out of the template and matches
the existing trans() pattern used elsewhere.
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Fix lazy-loading crash when deleting a workspace.
isAccountOwner() no longer touches the account relation unless it is
already loaded, and delete/rehome queries eager-load account when they
need ownership checks under Model::shouldBeStrict().
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Avoid isAccountOwner during workspace delete fallback.
Compare against the already-loaded account owner_id so current-workspace
reassignment cannot touch the account relation under shouldBeStrict().
Co-authored-by: Paulo Castellano <hello@paulocastellano.com>
* Add tests for DeleteWorkspace functionality
Introduce comprehensive tests for the DeleteWorkspace action, covering scenarios such as deleting stranded members, handling multiple workspaces, restoring members with personal workspaces, and managing invites. Ensure that workspace media files are deleted and verify behavior when the last workspace is blocked by SaaS settings. This enhances the reliability of workspace deletion processes and ensures proper account management during deletions.
* Refactor member removal process to delete or restore stranded members
Updated the RemoveMember action to utilize the new DeleteOrRestoreStrandedMember class, which handles the deletion of stranded members or restoration to personal accounts. This change improves the management of user accounts when members are removed from workspaces, ensuring that non-owner members are properly handled based on their account status. Additionally, tests have been updated to reflect these changes, ensuring that the functionality works as intended.
* Enhance member removal and media management during account deletion
Updated the RemoveMember action to collect media paths for orphaned files when removing members. Integrated the DeleteOrphanedMediaFiles action to ensure that any media associated with deleted users is properly purged. Additionally, refactored the DeleteOrRestoreStrandedMember class to return media paths for cleanup, improving overall resource management during user account deletions. This change ensures that all orphaned media files are handled efficiently, maintaining system integrity.
* Enhance user account deletion process with force delete option
Updated the DeleteOrRestoreStrandedMember class to include a forceDelete parameter, allowing for immediate deletion of members and their associated personal accounts and workspaces. This change ensures that when an account is forcefully deleted, all remnants of the user's data are purged, improving data integrity and resource management. Additionally, updated related methods and tests to accommodate this new functionality, ensuring comprehensive coverage and correct behavior during account deletions.
* Extract shared delete/invite actions out of fat controllers.
Centralize workspace/account/user teardown and invite accept/decline so ProfileController and AcceptInviteController stay thin HTTP wrappers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden delete/invite invariants and replace invite string outcomes.
Block cross-account workspace listing/switching, cancel Stripe on owned accounts before purge, lock RemoveMember, fold owner fallback into ReassignCurrentWorkspace, and type invite results with an enum.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Polish delete/invite teardown APIs and cancel Stripe on empty accounts.
Extract DeleteEmptyOwnedAccounts, rename settle-after-invite, and expose
clearer stranded-member entry points so cancel never races the invite lock.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Finish stranded teardown craft: settle outside locks, clearer names.
Defer empty-account Stripe cancel until after the account lock, rename
stranded handling to SettleStrandedMember, and extract AccountsRequiringCancel.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden multi-account Stripe cancel order and typed stranded settlements.
Cancel member personals before the shared account, introduce CancelAccounts
and StrandedSettlement::flush so partial Stripe failures leave billing intact.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Reuse strandedMemberOnSharedAccount across delete/invite feature tests.
Expand the Pest helper for shared workspaces and owner injection so
stranded-member fixtures stop being hand-rolled in every suite.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Lock the account row during owner account teardown.
Serialize DeleteAccount with DeleteWorkspace/RemoveMember so concurrent
stranded restores cannot move members off the account before force-delete.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop personal-account restore when leaving a shared account.
Invitees abandon their previous personal account on accept, and stranded
members are always deleted — matching the real product flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Close the account model and consolidate teardown actions.
Block invites to emails that already belong to a registered user — accounts
are closed (one user, one account), so members never own a personal account.
This removes the whole leftover/restore surface.
Consolidate: fold AccountsRequiringCancel/CancelAccounts into
CancelAccountSubscription, drop DeleteEmptyOwnedAccounts/DeleteOwnedAccount/
PurgeOwnedAccounts, and fold DeleteAccount into DeleteUser. 23 -> 15 new
action files.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove orphaned members.errors.already_member translation key.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Block invitees from creating a workspace on the invite shell.
A pending invitee could open workspaces/create (outside EnsureHasWorkspace)
and add a workspace (then billing) on their empty signup shell before accept.
Accept only tears down an empty shell, so this left an abandoned, billable
account. Deny create/store while an invite is pending — the invitee joins via
the invite instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Tighten stranded-member fixtures to the closed-account model.
Drop the member's empty signup shell in strandedMemberOnSharedAccount and the
billing-abort profile test so the setup matches what accept actually leaves
(member owns nothing). Remove the never-overridden attachOwner param.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bind invite registration to the invited email.
The register form shows the invited email as read-only when an invite id is
present, and store() rejects a different email for a valid invite. Also fixes
a latent bug: EnsureRegistrationEnabled only read the invite id from the query
string, so the self-hosted invite registration POST always 404'd.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move register validation into RegisterRequest.
Inline $request->validate() and the invite-email check move into
App\Http\Requests\App\Auth\RegisterRequest (withValidator). Invite detection
no longer sniffs a /invites/ redirect string — it resolves the invite id
directly; the invite registration test now uses a real invite.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Changing the language re-renders the UI in the new locale already, so the
'Language updated' banner was redundant. Remove the flash and its orphaned
i18n key.
Credit allotment is now derived directly from BillingCycle::for($account)->creditAllotment()
instead of a cached Pennant feature, removing the dynamic cache-invalidation footgun
(forgetPlanFeatureCache) that had to be called from every subscription/workspace mutation.
- 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).
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.
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.
Members joining via workspace invite share the owner's account_id.
ProfileController::destroy was unconditionally calling $account->delete()
in every profile-deletion path, so any member could wipe the whole
organization (cascade: workspaces, posts, social accounts, signatures,
labels) just by clicking Delete on their own profile.
Gate the account/subscription teardown behind isAccountOwner(). For
members the path now only detaches them from workspaces and deletes
the user row — owner's data is untouched.
Tested in both SELF_HOSTED=true and false.
Drops the dedicated /settings/authentication/providers/{provider}/callback
route added in the previous refactor — registering a second callback URL
in each OAuth app is more ops cost than the trade is worth.
Back to one callback URL per provider, with a small `Auth::check()`
branch in the auth controllers' callbacks. The check is safe because
the redirects that initiate the round-trip enforce the right
middleware (signup/login is `guest`-only, connect is `auth`-only),
so the auth state at callback time matches the flow's intent.
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.
- routes/auth.php returns to its original state — redirect + callback
both back inside the `guest` group.
- routes/app.php gains a paired callback route at
/settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
connectProviderCallback override Socialite's redirectUrl so the
round-trip stays on the connect-flow URL. The Auth::check() branch
in the auth controllers is gone.
The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
The Connect button on /settings/authentication pointed at the
auth.{provider}.redirect routes that live behind `guest` middleware,
so authenticated users were bounced to /app/home before reaching
Socialite. The OAuth callback also needed to handle two flows
(signup/login vs link to current user) but had no branch for the
second case — meaning a different-email GitHub account would have
been registered as a new user, logging the original session out.
Splits the flows by intent:
- New `app.authentication.connect-provider` route in the auth group,
handled by the settings controller (where it sits next to
disconnect-provider). Replaces the OAuth signup link as the
Connect button's target.
- Auth callbacks moved out of the guest group (still one URL per
provider, since OAuth apps only register one) and gain a single
Auth::check() branch that calls connectToCurrentUser().
- connectToCurrentUser() rejects if the provider id already belongs
to a different user; otherwise sets it on the current user and
redirects back to settings with a flash message.
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.
AiUsageLogTest and UsageController still passed string 'image'/'video'
arguments to monthlyCount() after the enum migration. Update them to
pass UsageType::Image and UsageType::Video.
- Block all billing/usage/subscribe routes in self-hosted mode (redirect to calendar)
- Hide billing_email field in account settings when self-hosted
- Migrate deprecated Facebook Page Insights metrics to new Media Views API (v25.0)
- Fix X analytics token refresh to use Basic Auth (matching XPublisher/ConnectionVerifier)
- Prevent duplicate social accounts by using updateOrCreate across all OAuth controllers
- 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
- 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
- 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
Auth pages:
- Create AuthSplitLayout with animated feature slides (6 slides, 3 languages)
- All auth pages use split layout (form left, visual right)
- Add show/hide password toggle with tooltip on Register
- Legal footer only shown on Register via showLegal prop
Subscribe page:
- Redesign to match auth card pattern (centered, clean)
- Platform icons, feature checklist, dynamic trial days (trialDays - 1)
- Add "Switch workspace" link
- Full i18n (en, es, pt-BR)
Onboarding:
- Rename URLs: step1 -> role, step2 -> connect
- Add enforceStep() to prevent skipping/going back steps
- Redirect /onboarding to /onboarding/role
- Redesign Step2 with AuthSplitLayout and compact platform list
- 21 tests covering all step enforcement scenarios
Workspaces page:
- Redesign with AuthSplitLayout (list with avatars, current badge)
Language system:
- Move locale from DB to cookie (forever, unencrypted, session.domain)
- Create SetLocale middleware (sets cookie if missing, validates against config)
- Rename lang/pt-br to lang/pt-BR
- Add dayjs es locale
Other:
- Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard)
- ConfirmDeleteModal with text confirmation (sendkit pattern)
- i18n for ConfirmDeleteModal internal strings (common.php)
- EmptyState component for posts index
- Exact match for "All" posts in sidebar
- Posts breadcrumbs show current status filter
- DialogFooter buttons aligned left
- API Keys page redesign with Table, DropdownMenu, EmptyState
- Extract CreateApiKeyDialog and InviteMemberDialog to components
- Remove API Keys from sidebar
- DropdownMenuItem destructive variant for Remove action
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
- Fix useActiveUrl to use prefix matching (matching Sendkit pattern) so
sidebar and settings nav tabs properly highlight the active page
- Make user reactive (computed) in Profile.vue and NavUser.vue so photo
upload/delete reflects immediately without page reload
- Update Avatar component to use simple v-if/v-else instead of Reka UI
AvatarImage/AvatarFallback which cached internal loading state
- Remove rounded="full" from PhotoUpload usage to match Sendkit (rounded-lg)
- Add workspace logo upload/delete routes and controller methods
- Replace workspace logo attribute (array) with has_logo/logo_url accessors
- Pass logo_url in AuthWorkspaceResource so sidebar shows workspace logo
- Extract business logic from controllers into Action classes:
Post/, Workspace/, Hashtag/, Label/, Invite/, ApiKey/
- Create subdomain routing: app.trypost.test (Inertia dashboard),
api.trypost.test (REST API with token auth)
- Add ApiToken model with tp_ prefix, token_lookup/hash auth
- Add AuthenticateApiToken middleware for API authentication
- Create Api controllers with JSON Resources for all entities
- Create App controllers that use Actions + Inertia responses
- Organize Form Requests into Api/ and App/ directories
- Add api_tokens migration
- Update all route names with app. prefix
- Update all tests to use new route names (684 passing)