Merge pull request #20 from trypostit/feature/posthog-tracking

feat: end-to-end PostHog tracking with reactive group metrics
This commit is contained in:
Paulo Castellano 2026-05-07 14:21:27 -03:00 committed by GitHub
commit a829553a24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1520 additions and 386 deletions

View file

@ -193,7 +193,8 @@ GIPHY_API_KEY=
# Google Tag Manager (optional - analytics)
GTM_ID=
# PostHog (optional - analytics)
# PostHog (optional - analytics; off by default, self-hosted installs can ignore)
POSTHOG_ENABLED=false
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
@ -203,5 +204,6 @@ VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
VITE_POSTHOG_ENABLED="${POSTHOG_ENABLED}"
VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}"
VITE_POSTHOG_HOST="${POSTHOG_HOST}"

View file

@ -4,8 +4,10 @@
namespace App\Actions\User;
use App\Jobs\PostHog\SyncUser;
use App\Models\Account;
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\DB;
class CreateUser
@ -16,7 +18,7 @@ class CreateUser
*/
public static function execute(array $data, array $utmParameters = []): User
{
return DB::transaction(function () use ($data, $utmParameters): User {
$user = DB::transaction(function () use ($data, $utmParameters): User {
$isInviteRegistration = data_get($data, 'is_invite', false);
$account = Account::create([
@ -39,5 +41,11 @@ public static function execute(array $data, array $utmParameters = []): User
return $user;
});
if (PostHogService::isEnabled()) {
SyncUser::dispatch((string) $user->id);
}
return $user;
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostHog;
enum BillingEvent: string
{
case Created = 'subscription.created';
case Updated = 'subscription.updated';
case Cancelled = 'subscription.cancelled';
}

View file

@ -70,8 +70,6 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
'cancel_url' => route('app.subscribe'),
]);
$account->update(['plan_id' => $plan->id]);
return Inertia::location($checkoutSession->url);
}
@ -145,16 +143,6 @@ public function swap(Request $request, Plan $plan): RedirectResponse
'Invalid price for this plan',
);
$currentPlan = $account->plan;
$isOnYearly = $currentPlan && $subscription->stripe_price === $currentPlan->stripe_yearly_price_id;
$isTargetMonthly = $priceId === $plan->stripe_monthly_price_id;
abort_if(
$isOnYearly && $isTargetMonthly,
422,
'Cannot downgrade from yearly to monthly billing.',
);
$authorization = Gate::inspect('swapPlan', [$account, $plan]);
if ($authorization->denied()) {
@ -163,6 +151,7 @@ public function swap(Request $request, Plan $plan): RedirectResponse
$subscription->swap($priceId);
$account->update(['plan_id' => $plan->id]);
$account->forgetPlanFeatureCache();
return redirect()->route('app.billing.index')
->with('flash.success', __('billing.flash.plan_changed', ['plan' => $plan->name]));

View file

@ -112,7 +112,7 @@ public function store(Request $request): View|RedirectResponse
],
);
return $this->popupCallback(true, 'Bluesky account connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Bluesky connection error', [
'error' => $e->getMessage(),

View file

@ -64,13 +64,13 @@ public function callback(Request $request): View|RedirectResponse
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -87,7 +87,7 @@ public function callback(Request $request): View|RedirectResponse
$pages = $this->fetchPages($socialUser->token);
if (empty($pages)) {
return $this->popupCallback(false, 'No Facebook Pages found. You need to be an admin of at least one page.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_pages'), $this->platform->value);
}
// If only one page, connect directly
@ -119,7 +119,7 @@ public function callback(Request $request): View|RedirectResponse
],
);
return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
}
// Multiple pages - store data and show selection
@ -138,7 +138,7 @@ public function callback(Request $request): View|RedirectResponse
'trace' => $e->getTraceAsString(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
@ -183,20 +183,20 @@ public function select(Request $request): View
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
$selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('id', $request->page_id);
if (! $selectedPage) {
return $this->popupCallback(false, 'Page not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.page_not_found'), $this->platform->value);
}
$avatarPath = uploadFromUrl(data_get($selectedPage, 'picture'));
@ -226,7 +226,7 @@ public function select(Request $request): View
session()->forget(['facebook_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, 'Facebook Page reconnected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value);
}
}
@ -256,13 +256,13 @@ public function select(Request $request): View
session()->forget(['facebook_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Facebook page selection error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, 'Error connecting page. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_page'), $this->platform->value);
}
}

View file

@ -59,13 +59,13 @@ public function callback(Request $request): View
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -100,14 +100,14 @@ public function callback(Request $request): View
],
);
return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Instagram OAuth Error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
}

View file

@ -67,13 +67,13 @@ public function callback(Request $request): View|RedirectResponse
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
$reconnectId = session('social_reconnect_id');
@ -94,7 +94,7 @@ public function callback(Request $request): View|RedirectResponse
$pages = $this->fetchPagesWithInstagram($socialUser->token);
if (empty($pages)) {
return $this->popupCallback(false, 'No Facebook Pages with linked Instagram accounts found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_instagram_pages'), $this->platform->value);
}
if (count($pages) === 1) {
@ -117,7 +117,7 @@ public function callback(Request $request): View|RedirectResponse
'trace' => $e->getTraceAsString(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
@ -159,13 +159,13 @@ public function select(Request $request): View
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
$reconnectId = data_get($oauthData, 'reconnect_id');
@ -175,7 +175,7 @@ public function select(Request $request): View
$selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('page_id', $request->page_id);
if (! $selectedPage) {
return $this->popupCallback(false, 'Page not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.page_not_found'), $this->platform->value);
}
$result = $this->connectInstagramAccount($workspace, $selectedPage, $existingAccount);
@ -186,7 +186,7 @@ public function select(Request $request): View
} catch (\Exception $e) {
Log::error('Instagram via Facebook page selection error', ['error' => $e->getMessage()]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
@ -215,7 +215,7 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
session()->forget('social_reconnect_id');
return $this->popupCallback(true, 'Instagram account reconnected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value);
}
$account = $workspace->socialAccounts()->updateOrCreate(
@ -232,7 +232,7 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
$isOnboarding = session('social_connect_onboarding', false);
return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value, $isOnboarding);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value, $isOnboarding);
}
private function fetchPagesWithInstagram(string $userToken): array

View file

@ -50,13 +50,13 @@ public function callback(Request $request): View
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -95,13 +95,13 @@ public function callback(Request $request): View
// Sync tokens to LinkedIn Page if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
return $this->popupCallback(true, 'LinkedIn account connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn OAuth Error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}

View file

@ -69,13 +69,13 @@ public function callback(Request $request): View|RedirectResponse
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -90,7 +90,7 @@ public function callback(Request $request): View|RedirectResponse
$organizations = $this->fetchOrganizations($socialUser->token);
if (empty($organizations)) {
return $this->popupCallback(false, 'You are not an administrator of any LinkedIn page.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.not_linkedin_admin'), $this->platform->value);
}
// Store data in session and redirect to selection page
@ -114,7 +114,7 @@ public function callback(Request $request): View|RedirectResponse
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
@ -156,13 +156,13 @@ public function select(Request $request): View
$pendingData = session('linkedin_page_pending');
if (! $pendingData) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($pendingData['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -195,7 +195,7 @@ public function select(Request $request): View
session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']);
return $this->popupCallback(true, 'LinkedIn Page reconnected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value);
}
}
@ -227,13 +227,13 @@ public function select(Request $request): View
session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']);
return $this->popupCallback(true, 'LinkedIn Page connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn Page selection error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, 'Error connecting page. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_page'), $this->platform->value);
}
}

View file

@ -129,13 +129,13 @@ public function callback(Request $request): View
if (! $workspaceId || ! $instance) {
$this->clearMastodonSession();
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
if ($request->state !== $savedState) {
$this->clearMastodonSession();
return $this->popupCallback(false, 'Invalid state. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.invalid_state'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
@ -143,7 +143,7 @@ public function callback(Request $request): View
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
$this->clearMastodonSession();
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -163,7 +163,7 @@ public function callback(Request $request): View
]);
$this->clearMastodonSession();
return $this->popupCallback(false, 'Failed to authenticate.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.failed_to_authenticate'), $this->platform->value);
}
$tokenData = $tokenResponse->json();
@ -176,7 +176,7 @@ public function callback(Request $request): View
if ($profileResponse->failed()) {
$this->clearMastodonSession();
return $this->popupCallback(false, 'Failed to get profile.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.failed_to_get_profile'), $this->platform->value);
}
$profile = $profileResponse->json();
@ -215,7 +215,7 @@ public function callback(Request $request): View
$this->clearMastodonSession();
return $this->popupCallback(true, 'Mastodon account connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Mastodon callback error', [
'error' => $e->getMessage(),
@ -223,7 +223,7 @@ public function callback(Request $request): View
]);
$this->clearMastodonSession();
return $this->popupCallback(false, 'Error connecting account.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}

View file

@ -48,13 +48,13 @@ public function callback(Request $request): View
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -83,14 +83,14 @@ public function callback(Request $request): View
'status' => Status::Connected,
]);
return $this->popupCallback(true, 'Pinterest account connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Pinterest OAuth Error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
}

View file

@ -167,13 +167,13 @@ protected function handleCallback(
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $platform->value);
}
try {
@ -200,14 +200,14 @@ protected function handleCallback(
],
);
return $this->popupCallback(true, 'Account connected!', $platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $platform->value);
} catch (\Exception $e) {
Log::error('Social OAuth Error', [
'platform' => $platform->value,
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $platform->value);
}
}

View file

@ -65,13 +65,13 @@ public function callback(Request $request): View
if (! $workspaceId) {
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
if ($request->state !== $savedState) {
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'Invalid state. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.invalid_state'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
@ -79,7 +79,7 @@ public function callback(Request $request): View
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -157,7 +157,7 @@ public function callback(Request $request): View
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(true, 'Threads account connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Threads OAuth Error', [
'error' => $e->getMessage(),
@ -166,7 +166,7 @@ public function callback(Request $request): View
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
}

View file

@ -51,13 +51,13 @@ public function callback(Request $request): View
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -96,13 +96,13 @@ public function callback(Request $request): View
session()->forget('social_reconnect_id');
return $this->popupCallback(true, 'TikTok account connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('TikTok OAuth Error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
}

View file

@ -56,13 +56,13 @@ public function callback(Request $request): View|RedirectResponse
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -72,7 +72,7 @@ public function callback(Request $request): View|RedirectResponse
$channels = $this->fetchChannels($socialUser->token);
if (empty($channels)) {
return $this->popupCallback(false, 'No YouTube channels found. Please create a channel first.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.no_youtube_channels'), $this->platform->value);
}
// If only one channel, connect directly (most common case)
@ -103,7 +103,7 @@ public function callback(Request $request): View|RedirectResponse
],
);
return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
}
// Multiple channels - store data and show selection screen
@ -123,7 +123,7 @@ public function callback(Request $request): View|RedirectResponse
'trace' => $e->getTraceAsString(),
]);
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
@ -178,13 +178,13 @@ public function select(Request $request): View
$workspaceId = session('social_connect_workspace');
if (! $oauthData || ! $workspaceId) {
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
@ -192,7 +192,7 @@ public function select(Request $request): View
$selectedChannel = collect($channels)->firstWhere('id', $request->channel_id);
if (! $selectedChannel) {
return $this->popupCallback(false, 'Channel not found.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.channel_not_found'), $this->platform->value);
}
$avatarPath = uploadFromUrl(data_get($selectedChannel, 'thumbnail'));
@ -221,7 +221,7 @@ public function select(Request $request): View
session()->forget(['youtube_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, 'YouTube channel reconnected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value);
}
}
@ -250,13 +250,13 @@ public function select(Request $request): View
session()->forget(['youtube_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (\Exception $e) {
Log::error('YouTube channel selection error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, 'Error connecting channel. Please try again.', $this->platform->value);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_channel'), $this->platform->value);
}
}

View file

@ -8,7 +8,6 @@
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
use App\Models\Account;
use App\Models\Plan;
use Illuminate\Http\Request;
use Inertia\Middleware;
@ -45,7 +44,6 @@ public function share(Request $request): array
'account' => $account ? AuthAccountResource::make($account) : null,
'plan' => $account && $account->plan ? AuthPlanResource::make($account, $account->plan) : null,
'hasActiveSubscription' => $account ? $account->hasActiveSubscription() : false,
'currentPriceId' => $account?->subscription(Account::SUBSCRIPTION_NAME)?->stripe_price,
],
'usage' => $account && ! $isSelfHosted ? $account->usage() : null,
'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null,

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
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;
use Illuminate\Support\Facades\Log;
use PostHog\PostHog;
/**
* Low-level PostHog dispatch worker. Forwards a single API call (`capture`,
* `identify`, `groupIdentify`) to the PostHog SDK. Higher-level jobs in this
* namespace (`SyncUser`, `TrackBilling`) build the payloads and queue this
* job to do the network work.
*
* No-op when `POSTHOG_ENABLED` is false (or `POSTHOG_API_KEY` is unset),
* via `PostHogService::isEnabled()`, so self-hosted installs never reach
* the SDK.
*/
class SendEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 15;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public string $method,
public array $payload,
) {
$this->onQueue('posthog');
}
public function handle(): void
{
if (! PostHogService::isEnabled()) {
return;
}
match ($this->method) {
'capture' => PostHog::capture($this->payload),
'identify' => PostHog::identify($this->payload),
'groupIdentify' => PostHog::groupIdentify($this->payload),
default => Log::warning('PostHog SendEvent: unknown method', ['method' => $this->method]),
};
PostHog::flush();
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Models\User;
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;
/**
* Centralised PostHog sync for a single user. Dispatched from places the
* frontend can't observe (signup, Stripe webhooks) so the person profile
* and account/workspace groups carry up-to-date properties without blocking
* the calling request. Inertia navigations refresh group counts on the
* client (see `syncPostHogContext` in resources/js/posthog.ts), so this job
* is not needed on every domain trigger.
*
* Hierarchy mirrors the domain model:
* - person → User
* - group `account` → Account (billing/plan, parent of workspaces)
* - group `workspace` → Workspace (collaboration unit, child of account)
*
* No-op when PostHog is disabled (`POSTHOG_ENABLED=false` or no API key)
* via `PostHogService::isEnabled()`, so self-hosted installs are unaffected.
*/
class SyncUser implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public string $userId)
{
$this->onQueue('posthog');
}
public function handle(PostHogService $postHog): void
{
if (! PostHogService::isEnabled()) {
return;
}
$user = User::with([
'account.plan',
'currentWorkspace' => fn ($query) => $query->withCount('socialAccounts'),
])->find($this->userId);
if (! $user) {
return;
}
$postHog->identify((string) $user->id, [
'$email' => $user->email,
'$name' => $user->name,
'$set_once' => ['signed_up_at' => $user->created_at?->toIso8601String()],
]);
if ($account = $user->account) {
$usage = $account->usage();
$postHog->groupIdentify('account', (string) $account->id, [
'name' => $account->name,
'plan' => $account->plan?->name,
'plan_slug' => $account->plan?->slug->value,
'has_active_subscription' => $account->hasActiveSubscription(),
'is_on_trial' => $account->isOnTrial(),
'workspaces_count' => $usage['workspaceCount'],
'members_count' => $usage['memberCount'],
'social_accounts_count' => $usage['socialAccountCount'],
'posts_count' => $usage['postCount'],
'pending_invites_count' => $usage['pendingInviteCount'],
'credits_used' => $usage['creditsUsed'],
'created_at' => $account->created_at?->toIso8601String(),
]);
}
if ($workspace = $user->currentWorkspace) {
$postHog->groupIdentify('workspace', (string) $workspace->id, [
'name' => $workspace->name,
'account_id' => (string) $workspace->account_id,
'social_accounts_count' => (int) $workspace->social_accounts_count,
'created_at' => $workspace->created_at?->toIso8601String(),
]);
}
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Enums\PostHog\BillingEvent;
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;
/**
* Captures a Stripe billing lifecycle event on the account owner profile
* with the `account` group attached, then re-queues `SyncUser` so the
* group properties (plan, has_active_subscription, is_on_trial) reflect
* the new Stripe state. Dispatched by `StripeEventListener`.
*
* No-op when PostHog is disabled (`POSTHOG_ENABLED=false` or no API key).
*/
class TrackBilling 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 BillingEvent $event,
public array $payload,
public ?string $previousPlan = null,
) {
$this->onQueue('posthog');
}
public function handle(PostHogService $postHog): void
{
if (! PostHogService::isEnabled()) {
return;
}
$account = Account::with('plan')->find($this->accountId);
if (! $account || ! $account->owner_id) {
return;
}
$postHog->capture(
(string) $account->owner_id,
$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,
],
$account,
);
SyncUser::dispatch((string) $account->owner_id);
}
}

View file

@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use PostHog\PostHog;
class SendPostHogEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 15;
/**
* @param array<array{method: string, payload: array<string, mixed>}> $calls
*/
public function __construct(
public array $calls,
) {
$this->onQueue('posthog');
}
public function handle(): void
{
if (! config('services.posthog.api_key')) {
return;
}
foreach ($this->calls as $call) {
match (data_get($call, 'method')) {
'capture' => PostHog::capture(data_get($call, 'payload')),
'identify' => PostHog::identify(data_get($call, 'payload')),
'groupIdentify' => PostHog::groupIdentify(data_get($call, 'payload')),
default => Log::warning('SendPostHogEvent: unknown method', ['method' => data_get($call, 'method')]),
};
}
PostHog::flush();
}
}

View file

@ -4,8 +4,12 @@
namespace App\Listeners;
use App\Enums\PostHog\BillingEvent;
use App\Events\SubscriptionCreated;
use App\Jobs\PostHog\TrackBilling;
use App\Models\Account;
use App\Models\Plan;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
@ -21,16 +25,16 @@ public function handle(WebhookReceived $event): void
return;
}
$workspace = Account::where('stripe_id', $stripeCustomerId)->first();
$account = Account::where('stripe_id', $stripeCustomerId)->first();
if (! $workspace) {
if (! $account) {
return;
}
match ($type) {
'customer.subscription.created' => $this->handleSubscriptionCreated($workspace, $event->payload),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($workspace, $event->payload),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($workspace, $event->payload),
'customer.subscription.created' => $this->handleSubscriptionCreated($account, $event->payload),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($account, $event->payload),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($account, $event->payload),
default => null,
};
} catch (\Exception $e) {
@ -41,18 +45,118 @@ public function handle(WebhookReceived $event): void
}
}
/**
* @param array<string, mixed> $payload
*/
protected function handleSubscriptionCreated(Account $account, array $payload): void
{
$previousPlan = $account->plan?->name;
if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) {
$account->update(['plan_id' => $plan->id]);
$account->forgetPlanFeatureCache();
}
SubscriptionCreated::dispatch($account);
$this->trackPlanChange($account, BillingEvent::Created, $previousPlan, $payload);
}
protected function handleSubscriptionUpdated(Account $workspace, array $payload): void
/**
* Plan changes coming from Stripe (billing portal, dashboard, or our own
* `BillingController@swap`) re-arrive here. Re-resolving the plan from
* the price ids guarantees the local state matches Stripe even when a
* change happens out-of-band.
*
* @param array<string, mixed> $payload
*/
protected function handleSubscriptionUpdated(Account $account, array $payload): void
{
//
$previousPlan = $account->plan?->name;
if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) {
$account->update(['plan_id' => $plan->id]);
$account->forgetPlanFeatureCache();
}
$this->trackPlanChange($account, BillingEvent::Updated, $previousPlan, $payload);
}
protected function handleSubscriptionDeleted(Account $workspace, array $payload): void
/**
* Subscription fully terminated (period ended after cancellation, or
* cancelled immediately). Clear the local plan so the UI and
* authorisation checks (`Account::hasActiveSubscription`) reflect
* "no plan" instead of keeping the previous one attached.
*
* @param array<string, mixed> $payload
*/
protected function handleSubscriptionDeleted(Account $account, array $payload): void
{
//
// Stripe re-delivers webhooks on transient failures; if the plan is
// already cleared we skip the rest so the cancellation event isn't
// captured twice and the (idempotent but pointless) cache flush
// doesn't run again.
if ($account->plan_id === null) {
return;
}
$previousPlan = $account->plan?->name;
$account->update(['plan_id' => null]);
$account->forgetPlanFeatureCache();
$this->trackPlanChange($account, BillingEvent::Cancelled, $previousPlan, $payload);
}
/**
* Resolve the matching `Plan` for a Stripe subscription payload by
* looking up the items' price ids against the local plans table.
* Logs a warning when no match is found so price/plan drift between
* Stripe and the local DB is visible.
*
* @param array<string, mixed> $payload
*/
private function resolvePlanFromSubscriptionItems(array $payload, Account $account): ?Plan
{
$priceIds = collect(data_get($payload, 'data.object.items.data', []))
->pluck('price.id')
->filter()
->all();
if (empty($priceIds)) {
return null;
}
$plan = Plan::query()
->where(function ($query) use ($priceIds): void {
$query->whereIn('stripe_monthly_price_id', $priceIds)
->orWhereIn('stripe_yearly_price_id', $priceIds);
})
->first();
if (! $plan) {
Log::warning('Stripe webhook: no matching plan found in subscription items', [
'account_id' => $account->id,
'price_ids' => $priceIds,
]);
}
return $plan;
}
/**
* Hand the lifecycle event off to the queue so the listener stays fast.
* Skip dispatch when PostHog is disabled — `TrackBilling::handle` would
* no-op anyway, but enqueuing it still costs queue worker cycles.
*
* @param array<string, mixed> $payload
*/
private function trackPlanChange(Account $account, BillingEvent $event, ?string $previousPlan, array $payload): void
{
if (! PostHogService::isEnabled()) {
return;
}
TrackBilling::dispatch((string) $account->id, $event, $payload, $previousPlan);
}
}

View file

@ -32,18 +32,21 @@ class Account extends Model
'plan_id',
];
protected static function booted(): void
/**
* Drop the cached Pennant feature values for the plan-scoped limits so
* the next read resolves against the current plan. Call this right after
* any `plan_id` change. Centralised here (instead of as a model hook) so
* self-hosted installs — where Stripe webhooks never fire and the billing
* controllers redirect away — never trigger this path.
*/
public function forgetPlanFeatureCache(): void
{
static::updated(function (Account $account): void {
if ($account->wasChanged('plan_id')) {
Feature::for($account)->forget([
WorkspaceLimit::class,
SocialAccountLimit::class,
MemberLimit::class,
MonthlyCreditsLimit::class,
]);
}
});
Feature::for($this)->forget([
WorkspaceLimit::class,
SocialAccountLimit::class,
MemberLimit::class,
MonthlyCreditsLimit::class,
]);
}
public function owner(): BelongsTo

View file

@ -10,6 +10,8 @@
use App\Features\WorkspaceLimit;
use App\Models\AiUsageLog;
use App\Models\Invite;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
use Laravel\Pennant\Feature;
/**
@ -22,20 +24,30 @@
trait HasUsage
{
/**
* @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int, creditsUsed: int}
* Cache TTL for the per-account post count. Posts are unbounded by plan
* limits and not used for any quota gating, so a few minutes of staleness
* is acceptable in exchange for skipping a potentially heavy aggregate
* query on every authenticated request.
*/
private const POST_COUNT_CACHE_TTL = 300;
/**
* @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int, postCount: int, creditsUsed: int}
*/
public function usage(): array
{
$workspaces = $this->workspaces()
->withCount('socialAccounts')
->get();
return [
'workspaceCount' => $this->workspaces()->count(),
'socialAccountCount' => $this->workspaces()
->withCount('socialAccounts')
->get()
->sum('social_accounts_count'),
'workspaceCount' => $workspaces->count(),
'socialAccountCount' => (int) $workspaces->sum('social_accounts_count'),
'memberCount' => $this->users()->count(),
'pendingInviteCount' => Invite::where('account_id', $this->id)
->whereNull('accepted_at')
->count(),
'postCount' => $this->cachedPostCount($workspaces->pluck('id')->all()),
'creditsUsed' => AiUsageLog::monthlyCredits($this->id),
];
}
@ -52,4 +64,20 @@ public function featureLimits(): array
'monthlyCreditsLimit' => Feature::for($this)->value(MonthlyCreditsLimit::class),
];
}
/**
* @param array<int, string> $workspaceIds
*/
private function cachedPostCount(array $workspaceIds): int
{
if (empty($workspaceIds)) {
return 0;
}
return Cache::remember(
"account:{$this->id}:posts_count",
self::POST_COUNT_CACHE_TTL,
fn () => Post::whereIn('workspace_id', $workspaceIds)->count(),
);
}
}

View file

@ -37,6 +37,7 @@
use App\Models\WorkspaceInvite;
use App\Models\WorkspaceLabel;
use App\Models\WorkspaceSignature;
use App\Services\PostHogService;
use App\Services\PostTemplate\Registry as PostTemplateRegistry;
use App\Socialite\InstagramProvider;
use App\Socialite\LinkedInPageExtendSocialite;
@ -187,13 +188,13 @@ protected function configureMorphMap(): void
protected function configurePostHog(): void
{
$apiKey = config('services.posthog.api_key');
if ($apiKey) {
PostHog::init($apiKey, [
'host' => config('services.posthog.host'),
]);
if (! PostHogService::isEnabled()) {
return;
}
PostHog::init(config('services.posthog.api_key'), [
'host' => config('services.posthog.host'),
]);
}
protected function configureRateLimiting(): void

View file

@ -4,21 +4,50 @@
namespace App\Services;
use App\Jobs\SendPostHogEvent;
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
class PostHogService
{
/**
* Whether PostHog tracking should run at all. Controlled by the explicit
* `POSTHOG_ENABLED` env var so self-hosted installs that inherited an
* example `POSTHOG_API_KEY` from a config snippet do not silently send
* events upstream. The SaaS deployment opts in via `POSTHOG_ENABLED=true`.
*/
public static function isEnabled(): bool
{
return (bool) config('services.posthog.enabled')
&& (bool) config('services.posthog.api_key');
}
/**
* Capture an event for the given distinct id. When `$account` is supplied,
* the workspace/plan group is auto-attached so the event is filterable in
* PostHog by `$groups.account` and the `account_id` / `plan` properties.
*
* @param array<string, mixed> $properties
*/
public function capture(string $distinctId, string $event, array $properties = []): void
public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void
{
$this->dispatch('capture', [
if (! self::isEnabled()) {
return;
}
$payload = [
'distinctId' => $distinctId,
'event' => $event,
'properties' => $properties,
]);
];
if ($account) {
$payload['properties']['$groups'] = ['account' => (string) $account->id];
$payload['properties']['account_id'] = (string) $account->id;
$payload['properties']['plan'] = $account->plan?->name;
}
$this->dispatch('capture', $payload);
}
/**
@ -26,6 +55,10 @@ public function capture(string $distinctId, string $event, array $properties = [
*/
public function identify(string $distinctId, array $properties = []): void
{
if (! self::isEnabled()) {
return;
}
$this->dispatch('identify', [
'distinctId' => $distinctId,
'properties' => $properties,
@ -37,6 +70,10 @@ 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', [
'groupType' => $groupType,
'groupKey' => $groupKey,
@ -49,14 +86,8 @@ public function groupIdentify(string $groupType, string $groupKey, array $proper
*/
private function dispatch(string $method, array $payload): void
{
if (! config('services.posthog.api_key')) {
return;
}
try {
SendPostHogEvent::dispatch([
['method' => $method, 'payload' => $payload],
]);
SendEvent::dispatch($method, $payload);
} catch (\Throwable $e) {
Log::warning('PostHogService: failed to dispatch event', ['method' => $method, 'error' => $e->getMessage()]);
}

View file

@ -214,7 +214,7 @@
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'queue' => ['default', 'posthog'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,

View file

@ -116,6 +116,7 @@
],
'posthog' => [
'enabled' => (bool) env('POSTHOG_ENABLED', false),
'api_key' => env('POSTHOG_API_KEY'),
'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'),
],

View file

@ -115,4 +115,27 @@
'already_connected' => 'This platform is already connected.',
'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.',
],
'popup_callback' => [
'title_success' => 'Connected',
'title_error' => 'Error',
'closing' => 'This window will close automatically...',
'close_now' => 'You can close this window now.',
'connected' => 'Account connected!',
'reconnected' => 'Account reconnected!',
'error_connecting' => 'Error connecting account. Please try again.',
'error_connecting_page' => 'Error connecting page. Please try again.',
'error_connecting_channel' => 'Error connecting channel. Please try again.',
'session_expired' => 'Session expired. Please try again.',
'workspace_not_found' => 'Workspace not found.',
'invalid_state' => 'Invalid state. Please try again.',
'failed_to_authenticate' => 'Failed to authenticate.',
'failed_to_get_profile' => 'Failed to get profile.',
'page_not_found' => 'Page not found.',
'channel_not_found' => 'Channel not found.',
'no_facebook_pages' => 'No Facebook Pages found. You need to be an admin of at least one page.',
'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.',
'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.',
'not_linkedin_admin' => 'You are not an administrator of any LinkedIn page.',
],
];

View file

@ -115,4 +115,27 @@
'already_connected' => 'Esta plataforma ya está conectada.',
'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.',
],
'popup_callback' => [
'title_success' => 'Conectado',
'title_error' => 'Error',
'closing' => 'Esta ventana se cerrará automáticamente...',
'close_now' => 'Puedes cerrar esta ventana ahora.',
'connected' => '¡Cuenta conectada!',
'reconnected' => '¡Cuenta reconectada!',
'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.',
'error_connecting_page' => 'Error al conectar la página. Inténtalo de nuevo.',
'error_connecting_channel' => 'Error al conectar el canal. Inténtalo de nuevo.',
'session_expired' => 'Sesión expirada. Inténtalo de nuevo.',
'workspace_not_found' => 'Workspace no encontrado.',
'invalid_state' => 'Estado inválido. Inténtalo de nuevo.',
'failed_to_authenticate' => 'Falló la autenticación.',
'failed_to_get_profile' => 'Falló al obtener el perfil.',
'page_not_found' => 'Página no encontrada.',
'channel_not_found' => 'Canal no encontrado.',
'no_facebook_pages' => 'No se encontraron páginas de Facebook. Debes ser administrador de al menos una página.',
'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.',
'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.',
'not_linkedin_admin' => 'No eres administrador de ninguna página de LinkedIn.',
],
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -115,4 +115,27 @@
'already_connected' => 'Esta plataforma já está conectada.',
'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.',
],
'popup_callback' => [
'title_success' => 'Conectado',
'title_error' => 'Erro',
'closing' => 'Esta janela será fechada automaticamente...',
'close_now' => 'Você pode fechar esta janela agora.',
'connected' => 'Conta conectada!',
'reconnected' => 'Conta reconectada!',
'error_connecting' => 'Erro ao conectar conta. Por favor, tente novamente.',
'error_connecting_page' => 'Erro ao conectar página. Por favor, tente novamente.',
'error_connecting_channel' => 'Erro ao conectar canal. Por favor, tente novamente.',
'session_expired' => 'Sessão expirada. Por favor, tente novamente.',
'workspace_not_found' => 'Workspace não encontrado.',
'invalid_state' => 'Estado inválido. Por favor, tente novamente.',
'failed_to_authenticate' => 'Falha na autenticação.',
'failed_to_get_profile' => 'Falha ao obter perfil.',
'page_not_found' => 'Página não encontrada.',
'channel_not_found' => 'Canal não encontrado.',
'no_facebook_pages' => 'Nenhuma página do Facebook encontrada. Você precisa ser administrador de pelo menos uma página.',
'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.',
'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.',
'not_linkedin_admin' => 'Você não é administrador de nenhuma página do LinkedIn.',
],
];

View file

@ -9,7 +9,7 @@ import { createApp, h } from 'vue';
import { initializeDataLayer } from './datalayer';
import dayjs from './dayjs';
import posthog from './posthog';
import { capturePageview, initializePostHog, syncPostHogContext } from './posthog';
import type { Auth } from './types';
configureEcho({
@ -44,23 +44,17 @@ createInertiaApp({
props.initialPage.props.env as string,
);
if (auth?.user) {
posthog.identify(auth.user.id, {
$email: auth.user.email,
$name: auth.user.name,
});
// Initial PostHog identify + dual-group context + first pageview.
// The same hooks fire on every Inertia navigation below so the
// account group counts stay reactive and workspace switches
// re-attach the right workspace group.
initializePostHog();
syncPostHogContext(props.initialPage);
capturePageview();
if (auth.currentWorkspace) {
posthog.group('workspace', auth.currentWorkspace.id, {
name: auth.currentWorkspace.name,
});
}
}
router.on('navigate', () => {
posthog.capture('$pageview', {
$current_url: window.location.href,
});
router.on('navigate', (event) => {
syncPostHogContext(event.detail.page);
capturePageview();
});
createApp({ render: () => h(App, props) })

View file

@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { checkout, swap } from '@/routes/app/billing';
import type { AuthPlan } from '@/types';
type PlanSlug = 'starter' | 'plus' | 'pro' | 'max';
@ -34,21 +35,17 @@ const { usage } = useFeatureAccess();
const page = usePage();
const plans = computed<Plan[]>(() => (page.props.plans as Plan[]) ?? []);
const currentPlan = computed<Plan | null>(() => (page.props.auth as { plan: Plan | null }).plan ?? null);
const currentPriceId = computed<string | null>(() => (page.props.auth as { currentPriceId?: string | null }).currentPriceId ?? null);
const currentPlan = computed<AuthPlan | null>(() => (page.props.auth as { plan: AuthPlan | null }).plan ?? null);
const hasActiveSubscription = computed<boolean>(() => Boolean((page.props.auth as { hasActiveSubscription?: boolean }).hasActiveSubscription));
const isOnYearly = computed(() => {
if (!currentPriceId.value || !currentPlan.value) return false;
return currentPriceId.value === currentPlan.value.stripe_yearly_price_id;
});
const isOnYearly = computed(() => currentPlan.value?.interval === 'yearly');
const isYearly = ref(false);
const isYearly = ref(true);
const processing = ref<string | null>(null);
watch(open, (isOpen) => {
if (isOpen) {
isYearly.value = isOnYearly.value;
isYearly.value = true;
}
});
@ -175,8 +172,8 @@ const onOpenChange = (value: boolean) => {
</DialogDescription>
</div>
<!-- Billing toggle pill (hidden when user is already on yearly) -->
<div v-if="!isOnYearly" class="flex justify-center pt-6 pb-2">
<!-- Billing toggle pill -->
<div class="flex justify-center pt-6 pb-2">
<div class="relative">
<div
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground bg-card p-1 shadow-xs"

View file

@ -23,6 +23,7 @@ import {
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import dayjs from '@/dayjs';
import posthog from '@/posthog';
import { logout } from '@/routes';
import { edit } from '@/routes/app/profile';
import type { User } from '@/types';
@ -59,6 +60,7 @@ const switchLanguage = (code: string) => {
};
const handleLogout = () => {
posthog.reset();
router.flushAll();
};
</script>

View file

@ -1,20 +1,14 @@
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import type { Usage } from '@/types';
interface Plan {
id: string;
slug: string;
name: string;
}
interface Usage {
workspaceCount: number;
socialAccountCount: number;
memberCount: number;
pendingInviteCount: number;
creditsUsed: number;
}
interface Features {
workspaceLimit: number;
socialAccountLimit: number;

View file

@ -1,4 +1,4 @@
import posthog from '@/posthog';
import { captureEvent } from '@/posthog';
const push = (data: Record<string, unknown>) => {
window.dataLayer = window.dataLayer || [];
@ -7,7 +7,7 @@ const push = (data: Record<string, unknown>) => {
export const useTracking = () => ({
trackSignUp: (authProvider: string) => {
posthog.capture('user.signed_up', {
captureEvent('user.signed_up', {
auth_provider: authProvider,
});
@ -18,7 +18,7 @@ export const useTracking = () => ({
},
trackBeginCheckout: (plan: { name: string; interval: string }) => {
posthog.capture('checkout.started', {
captureEvent('checkout.started', {
plan_name: plan.name,
interval: plan.interval,
});
@ -31,7 +31,7 @@ export const useTracking = () => ({
},
trackPurchase: (plan: { name: string; interval: string }) => {
posthog.capture('checkout.completed', {
captureEvent('checkout.completed', {
plan_name: plan.name,
interval: plan.interval,
});

View file

@ -118,8 +118,11 @@ const handleToggle = (accountId: string) => {
router.put(toggleAccount.url(accountId), {}, { preserveScroll: true });
};
const handleDisconnect = (accountId: string) => {
deleteModal.value?.open({ url: disconnectAccount.url(accountId) });
const handleDisconnect = (account: SocialAccount) => {
deleteModal.value?.open({
url: disconnectAccount.url(account.id),
confirmText: account.username,
});
};
</script>
@ -257,7 +260,7 @@ const handleDisconnect = (accountId: string) => {
<DropdownMenuSeparator v-if="isDisconnected(account)" />
<DropdownMenuItem
variant="destructive"
@click="handleDisconnect(account.id)"
@click="handleDisconnect(account)"
>
<IconTrash class="size-4" />
{{ $t('accounts.disconnect') }}

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Head, usePage } from '@inertiajs/vue3';
import { IconCreditCard, IconDownload, IconFileText, IconSparkles } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
@ -16,6 +16,7 @@ import { settings as settingsHub } from '@/routes/app';
import { edit as accountEdit } from '@/routes/app/account';
import { index as billingIndex, portal } from '@/routes/app/billing';
import { index as usageIndex } from '@/routes/app/usage';
import type { AuthPlan } from '@/types';
interface Plan {
name: string;
@ -59,9 +60,14 @@ const tabs = computed(() => [
{ name: 'billing', label: trans('settings.account.tabs.billing'), href: billingIndex().url },
]);
const monthlyPrice = (slug: string | undefined): string => {
const page = usePage();
const authPlan = computed<AuthPlan | null>(() => (page.props.auth as { plan: AuthPlan | null }).plan ?? null);
const isYearly = computed(() => authPlan.value?.interval === 'yearly');
const displayPrice = (slug: string | undefined): string => {
if (! slug) return 'Free';
return trans(`billing.subscribe.prices.${slug}.monthly`);
const key = isYearly.value ? 'yearly_per_month' : 'monthly';
return trans(`billing.subscribe.prices.${slug}.${key}`);
};
const { openUpgrade } = useUpgradeDialog();
@ -106,9 +112,12 @@ const { openUpgrade } = useUpgradeDialog();
<Badge v-else-if="subscription?.ends_at" variant="secondary">{{ $t('billing.plan.cancelling') }}</Badge>
</div>
<p class="text-base text-foreground/70">
<span class="text-2xl font-bold tabular-nums text-foreground">{{ monthlyPrice(plan?.slug) }}</span>
<span class="text-2xl font-bold tabular-nums text-foreground">{{ displayPrice(plan?.slug) }}</span>
<span class="ml-1">/{{ $t('billing.plan.month') }}</span>
</p>
<p v-if="plan" class="text-xs font-medium text-foreground/60">
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
</p>
<p
v-if="onTrial && trialEndsAt"
class="text-sm font-semibold text-foreground/70"

View file

@ -1,10 +1,20 @@
import type { Page } from '@inertiajs/core';
import posthog from 'posthog-js';
import type { Auth, Usage } from './types';
const apiKey = import.meta.env.VITE_POSTHOG_API_KEY as string | undefined;
const host = import.meta.env.VITE_POSTHOG_HOST as string | undefined;
// Vite stringifies env vars, so explicit equality with 'true' avoids the
// gotcha where 'false' would be truthy. Mirrors backend `POSTHOG_ENABLED`.
const enabled = import.meta.env.VITE_POSTHOG_ENABLED === 'true' && !!apiKey;
if (apiKey) {
posthog.init(apiKey, {
export const initializePostHog = (): void => {
if (!enabled) {
return;
}
posthog.init(apiKey as string, {
api_host: host || 'https://us.i.posthog.com',
ui_host: 'https://us.posthog.com',
capture_pageview: false,
@ -16,6 +26,81 @@ if (apiKey) {
maskTextSelector: '.ph-no-capture',
},
});
}
};
/**
* Identify the current person and refresh the dual group context (account +
* workspace) using the metrics already shipped by Inertia in shared props.
*
* Called once at boot and on every Inertia navigation so:
* - Counts on the `account` group (workspaces, social accounts, posts,
* members, credits) stay reactive without per-domain backend triggers.
* - Workspace switches re-attach the right `workspace` group (the
* navigation event carries the fresh `auth.currentWorkspace`).
*
* Hierarchy mirrors the backend `app/Jobs/PostHog/SyncUser.php`:
* person → User, group `account` → billing/plan parent,
* group `workspace` → collaboration child (carries `account_id`).
*/
export const syncPostHogContext = (page: Page): void => {
if (!enabled) {
return;
}
const auth = page.props.auth as Auth | undefined;
const usage = page.props.usage as Usage | null | undefined;
if (!auth?.user) {
return;
}
posthog.identify(auth.user.id, {
$email: auth.user.email,
$name: auth.user.name,
});
if (auth.account) {
posthog.group('account', auth.account.id, {
name: auth.account.name,
workspaces_count: usage?.workspaceCount,
social_accounts_count: usage?.socialAccountCount,
members_count: usage?.memberCount,
posts_count: usage?.postCount,
credits_used: usage?.creditsUsed,
});
}
if (auth.currentWorkspace) {
posthog.group('workspace', auth.currentWorkspace.id, {
name: auth.currentWorkspace.name,
account_id: auth.account?.id,
});
}
};
/**
* Capture an Inertia-driven pageview. The base SDK is configured with
* `capture_pageview: false`, so this needs to be invoked explicitly on
* every navigation (and once at boot for the first page).
*/
export const capturePageview = (): void => {
if (!enabled) {
return;
}
posthog.capture('$pageview', { $current_url: window.location.href });
};
/**
* Gated wrapper around `posthog.capture` for arbitrary domain events. Use
* this from composables/components instead of calling `posthog.capture`
* directly so self-hosted (or otherwise disabled) installs never queue
* events into the SDK buffer.
*/
export const captureEvent = (event: string, properties?: Record<string, unknown>): void => {
if (!enabled) {
return;
}
posthog.capture(event, properties);
};
export default posthog;

View file

@ -32,7 +32,15 @@ export interface Auth {
account: AuthAccount | null;
plan: AuthPlan | null;
hasActiveSubscription: boolean;
currentPriceId: string | null;
}
export interface Usage {
workspaceCount: number;
socialAccountCount: number;
memberCount: number;
pendingInviteCount: number;
postCount: number;
creditsUsed: number;
}
export interface FlashData {

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ $success ? 'Connected' : 'Error' }}</title>
<title>{{ $success ? __('accounts.popup_callback.title_success') : __('accounts.popup_callback.title_error') }}</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
@ -35,7 +35,7 @@
<div class="container">
<div class="icon">{{ $success ? '✓' : '✕' }}</div>
<div class="message">{{ $message }}</div>
<div class="submessage">This window will close automatically...</div>
<div class="submessage">{{ __('accounts.popup_callback.closing') }}</div>
</div>
<script>
@ -80,7 +80,7 @@ function closeWindow() {
// If still open after 2 seconds, show manual close message
setTimeout(function() {
if (!window.closed) {
document.querySelector('.submessage').textContent = 'You can close this window now.';
document.querySelector('.submessage').textContent = @json(__('accounts.popup_callback.close_now'));
}
}, 2000);
})();

View file

@ -3,8 +3,10 @@
declare(strict_types=1);
use App\Actions\User\CreateUser;
use App\Jobs\PostHog\SyncUser;
use App\Models\Account;
use App\Models\Workspace;
use Illuminate\Support\Facades\Bus;
test('CreateUser creates user with account but no workspace and no current_workspace_id', function () {
$user = CreateUser::execute([
@ -42,3 +44,32 @@
expect($user->email_verified_at)->not->toBeNull();
expect(Workspace::count())->toBe(0);
});
test('CreateUser dispatches SyncUser with the new user id when PostHog is enabled', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
Bus::fake([SyncUser::class]);
$user = CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane.posthog@example.com',
'password' => 'secret123',
]);
Bus::assertDispatched(
SyncUser::class,
fn ($job) => $job->userId === (string) $user->id,
);
});
test('CreateUser does not dispatch SyncUser when PostHog is disabled', function () {
config(['services.posthog.enabled' => false]);
Bus::fake([SyncUser::class]);
CreateUser::execute([
'name' => 'Jane Doe',
'email' => 'jane.posthog.disabled@example.com',
'password' => 'secret123',
]);
Bus::assertNotDispatched(SyncUser::class);
});

View file

@ -244,33 +244,6 @@
]));
});
test('swap blocks yearly to monthly downgrade', function () {
config(['trypost.self_hosted' => false]);
$plan = Plan::where('slug', 'max')->first();
$plan->update([
'stripe_monthly_price_id' => 'price_monthly',
'stripe_yearly_price_id' => 'price_yearly',
]);
$this->account->update(['plan_id' => $plan->id]);
$this->user->unsetRelation('account');
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_yearly',
]);
$response = $this->actingAs($this->user)
->post(route('app.billing.swap', $plan), [
'price_id' => 'price_monthly',
]);
$response->assertStatus(422);
});
test('swap rejects invalid price_id for plan', function () {
config(['trypost.self_hosted' => false]);

View file

@ -2,12 +2,10 @@
declare(strict_types=1);
use App\Jobs\SendPostHogEvent;
use App\Jobs\PostHog\SendEvent;
test('job is queued on posthog queue', function () {
$job = new SendPostHogEvent([
['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']],
]);
$job = new SendEvent('capture', ['distinctId' => 'user-1', 'event' => 'test']);
expect($job->queue)->toBe('posthog');
});
@ -15,9 +13,7 @@
test('job skips execution when api key is missing', function () {
config(['services.posthog.api_key' => null]);
$job = new SendPostHogEvent([
['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']],
]);
$job = new SendEvent('capture', ['distinctId' => 'user-1', 'event' => 'test']);
// Should not throw - silently skips
$job->handle();
@ -26,7 +22,7 @@
});
test('job has correct retry and timeout settings', function () {
$job = new SendPostHogEvent([]);
$job = new SendEvent('capture', []);
expect($job->tries)->toBe(3);
expect($job->timeout)->toBe(15);

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\SyncUser;
use App\Models\Account;
use App\Models\Plan;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->account = Account::factory()->create([
'plan_id' => Plan::query()->where('slug', 'starter')->first()?->id,
]);
$this->user = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->user->id]);
});
test('handle is a no-op when api key is unset', function () {
config(['services.posthog.api_key' => null]);
Queue::fake();
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle returns silently when user does not exist', function () {
Queue::fake();
(new SyncUser((string) Str::uuid()))->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle identifies the user with email, name and signed_up_at', function () {
Queue::fake();
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->method === 'identify'
&& $job->payload['distinctId'] === (string) $this->user->id
&& $job->payload['properties']['$email'] === $this->user->email
&& $job->payload['properties']['$name'] === $this->user->name
&& isset($job->payload['properties']['$set_once']['signed_up_at']);
});
});
test('handle group-identifies the account with usage metrics', function () {
$workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
SocialAccount::factory()->count(2)->create(['workspace_id' => $workspace->id]);
Post::factory()->count(3)->create([
'workspace_id' => $workspace->id,
'user_id' => $this->user->id,
]);
Queue::fake();
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function ($job) {
if ($job->method !== 'groupIdentify' || $job->payload['groupType'] !== 'account') {
return false;
}
$props = $job->payload['properties'];
return $job->payload['groupKey'] === (string) $this->account->id
&& $props['workspaces_count'] === 1
&& $props['social_accounts_count'] === 2
&& $props['posts_count'] === 3
&& $props['members_count'] === 1
&& array_key_exists('plan', $props)
&& array_key_exists('has_active_subscription', $props)
&& array_key_exists('is_on_trial', $props);
});
});
test('handle group-identifies the current workspace when set', function () {
$workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
$this->user->update(['current_workspace_id' => $workspace->id]);
Queue::fake();
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function ($job) use ($workspace) {
return $job->method === 'groupIdentify'
&& $job->payload['groupType'] === 'workspace'
&& $job->payload['groupKey'] === (string) $workspace->id
&& $job->payload['properties']['account_id'] === (string) $this->account->id
&& $job->payload['properties']['social_accounts_count'] === 1;
});
});
test('handle skips workspace group identify when user has no current workspace', function () {
$this->user->update(['current_workspace_id' => null]);
Queue::fake();
(new SyncUser((string) $this->user->id))->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class, function ($job) {
return $job->method === 'groupIdentify' && $job->payload['groupType'] === 'workspace';
});
});
test('job is queued on the posthog connection queue', function () {
$job = new SyncUser((string) $this->user->id);
expect($job->queue)->toBe('posthog');
});

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use App\Enums\PostHog\BillingEvent;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\SyncUser;
use App\Jobs\PostHog\TrackBilling;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->account = Account::factory()->create([
'plan_id' => Plan::query()->where('slug', 'starter')->first()?->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' => ['customer' => 'cus_test', 'status' => 'active']],
];
});
test('job is queued on the posthog queue', function () {
$job = new TrackBilling((string) $this->account->id, BillingEvent::Updated, $this->payload);
expect($job->queue)->toBe('posthog');
});
test('handle captures event on the owner profile with account group attached', function () {
Queue::fake();
(new TrackBilling((string) $this->account->id, BillingEvent::Updated, $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->method === 'capture'
&& $job->payload['event'] === BillingEvent::Updated->value
&& $job->payload['distinctId'] === (string) $this->user->id
&& $job->payload['properties']['$groups']['account'] === (string) $this->account->id
&& $job->payload['properties']['stripe_status'] === 'active'
&& array_key_exists('previous_plan', $job->payload['properties']);
});
});
test('handle forwards previousPlan as a property when supplied', function () {
Queue::fake();
(new TrackBilling((string) $this->account->id, BillingEvent::Updated, $this->payload, 'Starter'))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->payload['properties']['previous_plan'] === 'Starter';
});
});
test('handle dispatches SyncUser for the account owner', function () {
Bus::fake([SyncUser::class]);
(new TrackBilling((string) $this->account->id, BillingEvent::Cancelled, $this->payload))
->handle(app(PostHogService::class));
Bus::assertDispatched(
SyncUser::class,
fn ($job) => $job->userId === (string) $this->user->id,
);
});
test('handle returns silently when account does not exist', function () {
Queue::fake();
Bus::fake([SyncUser::class]);
(new TrackBilling('00000000-0000-0000-0000-000000000000', BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
Bus::assertNotDispatched(SyncUser::class);
});
test('handle returns silently when account has no owner', function () {
$this->account->update(['owner_id' => null]);
Queue::fake();
Bus::fake([SyncUser::class]);
(new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
Bus::assertNotDispatched(SyncUser::class);
});
test('handle does not push a PostHog network call when api key is unset', function () {
config(['services.posthog.api_key' => null]);
Queue::fake();
(new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
// The contract of this job is: when PostHog is disabled, handle short-
// circuits before any DB query and no SendEvent reaches the queue.
Queue::assertNotPushed(SendEvent::class);
});

View file

@ -2,15 +2,24 @@
declare(strict_types=1);
use App\Enums\PostHog\BillingEvent;
use App\Events\SubscriptionCreated;
use App\Features\WorkspaceLimit;
use App\Jobs\PostHog\TrackBilling;
use App\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Pennant\Feature;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->account = Account::factory()->create(['stripe_id' => 'cus_test123']);
$this->user = User::factory()->create([
'account_id' => $this->account->id,
@ -126,7 +135,6 @@
->once()
->withArgs(fn ($msg) => str_contains($msg, 'Stripe webhook error'));
// Force an exception by making Event::dispatch throw
Event::listen(SubscriptionCreated::class, fn () => throw new Exception('Simulated error'));
$this->listener->handle(new WebhookReceived([
@ -142,3 +150,335 @@
expect(true)->toBeTrue();
});
// ========================================
// PostHog tracking — listener delegates to TrackBilling
// ========================================
test('subscription created dispatches TrackBilling with subscription.created event', function () {
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123', 'status' => 'trialing']],
]));
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->accountId === (string) $this->account->id
&& $job->event === BillingEvent::Created,
);
});
test('subscription updated dispatches TrackBilling with subscription.updated event', function () {
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => ['customer' => 'cus_test123', 'status' => 'active']],
]));
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === BillingEvent::Updated,
);
});
test('subscription deleted dispatches TrackBilling with subscription.cancelled event', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.deleted',
'data' => ['object' => ['customer' => 'cus_test123', 'status' => 'canceled']],
]));
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === BillingEvent::Cancelled,
);
});
test('unknown event types do not dispatch TrackBilling', function () {
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'invoice.payment_succeeded',
'data' => ['object' => ['customer' => 'cus_test123']],
]));
Bus::assertNotDispatched(TrackBilling::class);
});
test('TrackBilling is not dispatched when PostHog is disabled', function () {
config(['services.posthog.enabled' => false]);
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 not dispatched when api key is missing', function () {
config(['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);
});
// ========================================
// Plan sync — domain logic
// ========================================
//
// Tests below override the seeded plans' Stripe price ids with deterministic
// values so the assertions don't depend on `.env.testing` having
// `STRIPE_*_MONTHLY/YEARLY` set.
beforeEach(function () {
Plan::query()->where('slug', 'starter')->update([
'stripe_monthly_price_id' => 'price_starter_monthly',
'stripe_yearly_price_id' => 'price_starter_yearly',
]);
Plan::query()->where('slug', 'pro')->update([
'stripe_monthly_price_id' => 'price_pro_monthly',
'stripe_yearly_price_id' => 'price_pro_yearly',
]);
});
test('subscription updated swaps account plan_id when price matches a different plan', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$pro = Plan::query()->where('slug', 'pro')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [
['price' => ['id' => 'price_pro_monthly']],
]],
]],
]));
expect($this->account->fresh()->plan_id)->toBe($pro->id);
});
test('subscription updated leaves plan_id alone when price already matches current plan', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [
['price' => ['id' => 'price_starter_monthly']],
]],
]],
]));
expect($this->account->fresh()->plan_id)->toBe($starter->id);
});
test('subscription updated ignores unknown price ids without erroring', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [
['price' => ['id' => 'price_unknown_xyz']],
]],
]],
]));
expect($this->account->fresh()->plan_id)->toBe($starter->id);
});
test('subscription updated matches yearly price ids too', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$pro = Plan::query()->where('slug', 'pro')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [
['price' => ['id' => 'price_pro_yearly']],
]],
]],
]));
expect($this->account->fresh()->plan_id)->toBe($pro->id);
});
test('subscription created syncs plan from price ids on first activation', function () {
$pro = Plan::query()->where('slug', 'pro')->firstOrFail();
$this->account->update(['plan_id' => null]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [
['price' => ['id' => 'price_pro_monthly']],
]],
]],
]));
expect($this->account->fresh()->plan_id)->toBe($pro->id);
});
test('subscription deleted clears the account plan_id', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.deleted',
'data' => ['object' => ['customer' => 'cus_test123', 'status' => 'canceled']],
]));
expect($this->account->fresh()->plan_id)->toBeNull();
});
test('subscription deleted is idempotent when plan_id is already null', function () {
$this->account->update(['plan_id' => null]);
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.deleted',
'data' => ['object' => ['customer' => 'cus_test123']],
]));
expect($this->account->fresh()->plan_id)->toBeNull();
// Re-delivery of a cancellation must not produce a duplicate
// 'subscription.cancelled' event downstream.
Bus::assertNotDispatched(TrackBilling::class);
});
// ========================================
// previousPlan propagation
// ========================================
test('subscription updated forwards the previous plan name to TrackBilling', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]],
]],
]));
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === BillingEvent::Updated && $job->previousPlan === $starter->name,
);
});
test('subscription deleted forwards the previous plan name to TrackBilling', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.deleted',
'data' => ['object' => ['customer' => 'cus_test123']],
]));
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === BillingEvent::Cancelled && $job->previousPlan === $starter->name,
);
});
test('subscription created forwards a null previous plan when account had none', function () {
$this->account->update(['plan_id' => null]);
Bus::fake([TrackBilling::class]);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.created',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [['price' => ['id' => 'price_starter_monthly']]]],
]],
]));
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === BillingEvent::Created && $job->previousPlan === null,
);
});
// ========================================
// Pennant feature cache invalidation
// ========================================
test('subscription updated flushes the pennant cache when the plan changes', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$pro = Plan::query()->where('slug', 'pro')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
// Prime the Pennant cache against the starter limit.
expect(Feature::for($this->account)->value(WorkspaceLimit::class))
->toBe($starter->workspace_limit);
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBeGreaterThan(0);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]],
]],
]));
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBe(0);
expect(Feature::for($this->account->fresh())->value(WorkspaceLimit::class))
->toBe($pro->workspace_limit);
});
test('subscription deleted flushes the pennant cache', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
Feature::for($this->account)->value(WorkspaceLimit::class);
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBeGreaterThan(0);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.deleted',
'data' => ['object' => ['customer' => 'cus_test123']],
]));
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBe(0);
});

View file

@ -11,43 +11,31 @@
use Illuminate\Support\Facades\DB;
use Laravel\Pennant\Feature;
test('booted hook flushes pennant cache when plan_id changes', function () {
test('forgetPlanFeatureCache drops the cached plan-scoped features', function () {
$starter = Plan::where('slug', 'starter')->first();
$plus = Plan::where('slug', 'plus')->first();
$account = Account::factory()->create(['plan_id' => $starter->id]);
expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($starter->workspace_limit);
expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($starter->social_account_limit);
expect(Feature::for($account)->value(MemberLimit::class))->toBe($starter->member_limit);
expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($starter->monthly_credits_limit);
// Prime the Pennant cache against the starter plan.
Feature::for($account)->value(WorkspaceLimit::class);
Feature::for($account)->value(SocialAccountLimit::class);
Feature::for($account)->value(MemberLimit::class);
Feature::for($account)->value(MonthlyCreditsLimit::class);
expect(DB::table('features')->where('scope', 'account|'.$account->id)->count())
->toBe(4);
// Move the account to a plan with different limits and forget the cache.
$account->update(['plan_id' => $plus->id]);
$account->forgetPlanFeatureCache();
$account->load('plan');
expect(DB::table('features')->where('scope', 'account|'.$account->id)->count())
->toBe(0);
expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($plus->workspace_limit);
expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($plus->social_account_limit);
expect(Feature::for($account)->value(MemberLimit::class))->toBe($plus->member_limit);
expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($plus->monthly_credits_limit);
});
test('booted hook does not flush pennant when other fields change', function () {
$plan = Plan::where('slug', 'plus')->first();
$account = Account::factory()->create(['plan_id' => $plan->id]);
Feature::for($account)->value(WorkspaceLimit::class);
$cachedRow = DB::table('features')
->where('scope', 'account|'.$account->id)
->first();
expect($cachedRow)->not->toBeNull();
$account->update(['name' => 'Updated Name']);
$stillCachedRow = DB::table('features')
->where('scope', 'account|'.$account->id)
->first();
expect($stillCachedRow->id)->toBe($cachedRow->id);
});

View file

@ -5,9 +5,11 @@
use App\Models\Account;
use App\Models\Invite;
use App\Models\Plan;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
$this->account = Account::factory()->create();
@ -36,6 +38,7 @@
'socialAccountCount' => 3,
'memberCount' => 3,
'pendingInviteCount' => 2,
'postCount' => 0,
'creditsUsed' => 0,
]);
});
@ -67,3 +70,37 @@
expect($this->account->usage()['pendingInviteCount'])->toBe(1);
});
test('postCount is cached and survives new posts within the TTL', function () {
$workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
Post::factory()->count(2)->create([
'workspace_id' => $workspace->id,
'user_id' => $this->owner->id,
]);
// First call primes the cache.
expect($this->account->usage()['postCount'])->toBe(2);
// A new post is created mid-window. The cached value should win.
Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $this->owner->id,
]);
expect($this->account->usage()['postCount'])->toBe(2);
// Forgetting the cache key returns the fresh count.
Cache::forget("account:{$this->account->id}:posts_count");
expect($this->account->usage()['postCount'])->toBe(3);
});
test('postCount returns zero without querying when account has no workspaces', function () {
expect($this->account->usage()['postCount'])->toBe(0);
// No cache entry should be written for the empty case.
expect(Cache::has("account:{$this->account->id}:posts_count"))->toBeFalse();
});

View file

@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Events\SubscriptionCreated;
use App\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookReceived;
beforeEach(function () {
config(['trypost.self_hosted' => true]);
$this->account = Account::factory()->create([
'stripe_id' => 'cus_test_'.fake()->uuid(),
]);
$this->user = User::factory()->create([
'account_id' => $this->account->id,
]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('webhook dispatches subscription created event', function () {
Event::fake([SubscriptionCreated::class]);
$payload = [
'type' => 'customer.subscription.created',
'data' => [
'object' => [
'customer' => $this->account->stripe_id,
'id' => 'sub_test_'.fake()->uuid(),
'status' => 'active',
],
],
];
$listener = new StripeEventListener;
$listener->handle(new WebhookReceived($payload));
Event::assertDispatched(SubscriptionCreated::class);
});
test('webhook ignores unknown stripe customer', function () {
Event::fake([SubscriptionCreated::class]);
$payload = [
'type' => 'customer.subscription.created',
'data' => [
'object' => [
'customer' => 'cus_unknown_'.fake()->uuid(),
'id' => 'sub_test_'.fake()->uuid(),
'status' => 'active',
],
],
];
$listener = new StripeEventListener;
$listener->handle(new WebhookReceived($payload));
Event::assertNotDispatched(SubscriptionCreated::class);
});

View file

@ -2,21 +2,26 @@
declare(strict_types=1);
use App\Jobs\SendPostHogEvent;
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use App\Models\Plan;
use App\Services\PostHogService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
test('capture dispatches job when api key is configured', function () {
Queue::fake();
config(['services.posthog.api_key' => 'phc_test_key']);
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$service = new PostHogService;
$service->capture('user-123', 'test_event', ['foo' => 'bar']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
return $job->calls[0]['method'] === 'capture'
&& $job->calls[0]['payload']['event'] === 'test_event'
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->method === 'capture'
&& $job->payload['event'] === 'test_event'
&& $job->payload['distinctId'] === 'user-123';
});
});
@ -32,14 +37,14 @@
test('identify dispatches job when api key is configured', function () {
Queue::fake();
config(['services.posthog.api_key' => 'phc_test_key']);
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$service = new PostHogService;
$service->identify('user-123', ['$email' => 'test@example.com']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
return $job->calls[0]['method'] === 'identify'
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->method === 'identify'
&& $job->payload['distinctId'] === 'user-123';
});
});
@ -55,15 +60,15 @@
test('group identify dispatches job when api key is configured', function () {
Queue::fake();
config(['services.posthog.api_key' => 'phc_test_key']);
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$service = new PostHogService;
$service->groupIdentify('workspace', 'ws-123', ['name' => 'Test Workspace']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
return $job->calls[0]['method'] === 'groupIdentify'
&& $job->calls[0]['payload']['groupType'] === 'workspace'
&& $job->calls[0]['payload']['groupKey'] === 'ws-123';
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->method === 'groupIdentify'
&& $job->payload['groupType'] === 'workspace'
&& $job->payload['groupKey'] === 'ws-123';
});
});
@ -76,3 +81,89 @@
Queue::assertNothingPushed();
});
test('capture auto-attaches account groups when account is supplied', function () {
Queue::fake();
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$plan = Plan::query()->where('slug', 'starter')->first();
$account = Account::factory()->create(['plan_id' => $plan?->id]);
$service = new PostHogService;
$service->capture('user-123', 'subscription.created', ['stripe_status' => 'active'], $account);
Queue::assertPushed(SendEvent::class, function ($job) use ($account, $plan) {
$payload = $job->payload;
return $payload['event'] === 'subscription.created'
&& $payload['properties']['$groups']['account'] === (string) $account->id
&& $payload['properties']['account_id'] === (string) $account->id
&& $payload['properties']['plan'] === $plan?->name
&& $payload['properties']['stripe_status'] === 'active';
});
});
test('capture without account does not attach group properties', function () {
Queue::fake();
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$service = new PostHogService;
$service->capture('user-123', 'page.viewed', ['url' => '/dashboard']);
Queue::assertPushed(SendEvent::class, function ($job) {
$properties = $job->payload['properties'];
return ! array_key_exists('$groups', $properties)
&& ! array_key_exists('account_id', $properties)
&& ! array_key_exists('plan', $properties);
});
});
// ========================================
// Self-hosted contract: enabled=false dominates regardless of api key
// ========================================
//
// These cover the exact scenario the open-source gate exists to defend
// against: a self-hosted install that inherited POSTHOG_API_KEY from an
// example env file but did NOT explicitly opt in via POSTHOG_ENABLED.
test('capture is a no-op when enabled is false even with an api key set', function () {
Queue::fake();
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => 'phc_inherited_key']);
$service = new PostHogService;
$service->capture('user-123', 'test_event');
Queue::assertNothingPushed();
});
test('identify is a no-op when enabled is false even with an api key set', function () {
Queue::fake();
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => 'phc_inherited_key']);
$service = new PostHogService;
$service->identify('user-123', ['$email' => 'test@example.com']);
Queue::assertNothingPushed();
});
test('groupIdentify is a no-op when enabled is false even with an api key set', function () {
Queue::fake();
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => 'phc_inherited_key']);
$service = new PostHogService;
$service->groupIdentify('account', 'acc-123', ['name' => 'Test']);
Queue::assertNothingPushed();
});
test('isEnabled requires both enabled and api key', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => null]);
expect(PostHogService::isEnabled())->toBeFalse();
config(['services.posthog.enabled' => false, 'services.posthog.api_key' => 'phc_x']);
expect(PostHogService::isEnabled())->toBeFalse();
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_x']);
expect(PostHogService::isEnabled())->toBeTrue();
});