refactor: namespace PostHog jobs + extract billing tracking, add tests

Reorganises PostHog plumbing under `App\Jobs\PostHog` and extracts the
Stripe billing capture out of `StripeEventListener` into its own job.
Adds the missing test coverage that was promised but not delivered in
the previous commit.

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

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

Suite: 1427 passed (+20 net new, including the previous round of
metrics-related tests).
This commit is contained in:
Paulo Castellano 2026-05-07 09:41:36 -03:00
parent e3538df2f0
commit 3e3c0b4c9d
14 changed files with 536 additions and 140 deletions

View file

@ -4,7 +4,7 @@
namespace App\Actions\User;
use App\Jobs\SyncUserToPostHog;
use App\Jobs\PostHog\SyncUser;
use App\Models\Account;
use App\Models\User;
use Illuminate\Support\Facades\DB;
@ -41,7 +41,7 @@ public static function execute(array $data, array $utmParameters = []): User
return $user;
});
SyncUserToPostHog::dispatch((string) $user->id);
SyncUser::dispatch((string) $user->id);
return $user;
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Jobs;
namespace App\Jobs\PostHog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -12,7 +12,17 @@
use Illuminate\Support\Facades\Log;
use PostHog\PostHog;
class SendPostHogEvent implements ShouldQueue
/**
* Low-level PostHog dispatch worker. Receives an array of pre-built API
* calls (`capture`, `identify`, `groupIdentify`) and forwards them 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_API_KEY` is unset so self-hosted installs are
* unaffected.
*/
class SendEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@ -40,7 +50,7 @@ public function handle(): void
'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')]),
default => Log::warning('PostHog\\SendEvent: unknown method', ['method' => data_get($call, 'method')]),
};
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Jobs;
namespace App\Jobs\PostHog;
use App\Models\User;
use App\Services\PostHogService;
@ -17,8 +17,8 @@
* 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/app.ts), so this job is
* not needed on every domain trigger.
* 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
@ -28,7 +28,7 @@
* No-op when POSTHOG_API_KEY is unset (PostHogService short-circuits), so
* self-hosted installs are unaffected.
*/
class SyncUserToPostHog implements ShouldQueue
class SyncUser implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Models\Account;
use App\Services\PostHogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* 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_API_KEY` is unset.
*/
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 string $event,
public array $payload,
) {
$this->onQueue('posthog');
}
public function handle(PostHogService $postHog): void
{
$account = Account::with('plan')->find($this->accountId);
if (! $account || ! $account->owner_id) {
return;
}
$postHog->capture(
(string) $account->owner_id,
$this->event,
[
'stripe_status' => data_get($this->payload, 'data.object.status'),
'plan' => $account->plan?->name,
'plan_slug' => $account->plan?->slug,
],
$account,
);
SyncUser::dispatch((string) $account->owner_id);
}
}

View file

@ -5,14 +5,19 @@
namespace App\Listeners;
use App\Events\SubscriptionCreated;
use App\Jobs\SyncUserToPostHog;
use App\Jobs\PostHog\TrackBilling;
use App\Models\Account;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
private const POSTHOG_EVENT_BY_TYPE = [
'customer.subscription.created' => 'subscription.created',
'customer.subscription.updated' => 'subscription.updated',
'customer.subscription.deleted' => 'subscription.cancelled',
];
public function handle(WebhookReceived $event): void
{
try {
@ -29,12 +34,13 @@ public function handle(WebhookReceived $event): void
return;
}
match ($type) {
'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,
};
if ($type === 'customer.subscription.created') {
SubscriptionCreated::dispatch($account);
}
if ($postHogEvent = self::POSTHOG_EVENT_BY_TYPE[$type] ?? null) {
TrackBilling::dispatch((string) $account->id, $postHogEvent, $event->payload);
}
} catch (\Exception $e) {
Log::error('Stripe webhook error: '.$e->getMessage(), [
'exception' => $e,
@ -42,59 +48,4 @@ public function handle(WebhookReceived $event): void
]);
}
}
/**
* @param array<string, mixed> $payload
*/
protected function handleSubscriptionCreated(Account $account, array $payload): void
{
SubscriptionCreated::dispatch($account);
$this->trackBilling($account, 'subscription.created', $payload);
}
/**
* @param array<string, mixed> $payload
*/
protected function handleSubscriptionUpdated(Account $account, array $payload): void
{
$this->trackBilling($account, 'subscription.updated', $payload);
}
/**
* @param array<string, mixed> $payload
*/
protected function handleSubscriptionDeleted(Account $account, array $payload): void
{
$this->trackBilling($account, 'subscription.cancelled', $payload);
}
/**
* Capture the lifecycle event on the account owner's profile and trigger
* a fresh `SyncUserToPostHog` so the account group properties (plan,
* has_active_subscription, is_on_trial) reflect the new Stripe state.
*
* @param array<string, mixed> $payload
*/
private function trackBilling(Account $account, string $event, array $payload): void
{
if (! $account->owner_id) {
return;
}
$properties = [
'stripe_status' => data_get($payload, 'data.object.status'),
'plan' => $account->plan?->name,
'plan_slug' => $account->plan?->slug,
];
app(PostHogService::class)->capture(
(string) $account->owner_id,
$event,
$properties,
$account,
);
SyncUserToPostHog::dispatch((string) $account->owner_id);
}
}

View file

@ -4,7 +4,7 @@
namespace App\Services;
use App\Jobs\SendPostHogEvent;
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
@ -75,7 +75,7 @@ public function groupIdentify(string $groupType, string $groupKey, array $proper
private function dispatch(string $method, array $payload): void
{
try {
SendPostHogEvent::dispatch([
SendEvent::dispatch([
['method' => $method, 'payload' => $payload],
]);
} catch (\Throwable $e) {

View file

@ -1,7 +1,6 @@
import '../css/app.css';
import { createInertiaApp, router } from '@inertiajs/vue3';
import type { Page } from '@inertiajs/core';
import { configureEcho } from '@laravel/echo-vue';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { i18nVue } from 'laravel-vue-i18n';
@ -10,54 +9,15 @@ import { createApp, h } from 'vue';
import { initializeDataLayer } from './datalayer';
import dayjs from './dayjs';
import posthog from './posthog';
import { capturePageview, syncPostHogContext } from './posthog';
import type { Auth } from './types';
interface Usage {
workspaceCount: number;
socialAccountCount: number;
memberCount: number;
pendingInviteCount: number;
postCount: number;
creditsUsed: number;
}
configureEcho({
broadcaster: 'reverb',
});
const appName = import.meta.env.VITE_APP_NAME || 'TryPost.it';
// Re-applies the PostHog account/workspace group context with fresh metrics.
// Called on every Inertia navigation so `usage` counts (workspaces, social
// accounts, posts, members) stay reactive — the backend already ships these
// counts on every Inertia request, so this leverages props that are already
// loaded without firing extra queries.
const syncPostHogContext = (page: Page): void => {
const auth = page.props.auth as Auth | undefined;
const usage = page.props.usage as Usage | null;
if (!auth?.user) return;
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,
});
}
};
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
resolve: (name) =>
@ -84,26 +44,16 @@ createInertiaApp({
props.initialPage.props.env as string,
);
if (auth?.user) {
posthog.identify(auth.user.id, {
$email: auth.user.email,
$name: auth.user.name,
});
}
// Initial group context + initial pageview. The backend mirrors this
// hierarchy in app/Jobs/SyncUserToPostHog.php so events emitted from
// the server land on the same person + group identifiers.
// 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.
syncPostHogContext(props.initialPage);
posthog.capture('$pageview', { $current_url: window.location.href });
capturePageview();
router.on('navigate', (event) => {
// Re-sync group context on every navigation: refreshes the count
// metrics on the `account` group AND covers workspace switches
// (which update auth.currentWorkspace via Inertia's prop refresh
// without triggering setup() again).
syncPostHogContext(event.detail.page);
posthog.capture('$pageview', { $current_url: window.location.href });
capturePageview();
});
createApp({ render: () => h(App, props) })

View file

@ -1,5 +1,8 @@
import type { Page } from '@inertiajs/core';
import posthog from 'posthog-js';
import type { Auth } 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;
@ -18,4 +21,68 @@ if (apiKey) {
});
}
interface Usage {
workspaceCount: number;
socialAccountCount: number;
memberCount: number;
pendingInviteCount: number;
postCount: number;
creditsUsed: number;
}
/**
* 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/SyncUserToPostHog.php`:
* person → User, group `account` → billing/plan parent,
* group `workspace` → collaboration child (carries `account_id`).
*/
export const syncPostHogContext = (page: Page): void => {
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 => {
posthog.capture('$pageview', { $current_url: window.location.href });
};
export default posthog;

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,18 @@
expect($user->email_verified_at)->not->toBeNull();
expect(Workspace::count())->toBe(0);
});
test('CreateUser dispatches SyncUser with the new user id', function () {
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,
);
});

View file

@ -2,10 +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([
$job = new SendEvent([
['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']],
]);
@ -15,7 +15,7 @@
test('job skips execution when api key is missing', function () {
config(['services.posthog.api_key' => null]);
$job = new SendPostHogEvent([
$job = new SendEvent([
['method' => 'capture', 'payload' => ['distinctId' => 'user-1', 'event' => 'test']],
]);
@ -26,7 +26,7 @@
});
test('job has correct retry and timeout settings', function () {
$job = new SendPostHogEvent([]);
$job = new SendEvent([]);
expect($job->tries)->toBe(3);
expect($job->timeout)->toBe(15);

View file

@ -0,0 +1,139 @@
<?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.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) {
$call = $job->calls[0];
return $call['method'] === 'identify'
&& $call['payload']['distinctId'] === (string) $this->user->id
&& $call['payload']['properties']['email'] === $this->user->email
&& $call['payload']['properties']['name'] === $this->user->name
&& isset($call['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) {
$accountCall = collect($job->calls)
->merge(collect(Queue::pushed(SendEvent::class))->flatMap(fn ($j) => $j->calls))
->first(fn ($c) => $c['method'] === 'groupIdentify' && $c['payload']['groupType'] === 'account');
if (! $accountCall) {
return false;
}
$props = $accountCall['payload']['properties'];
return $accountCall['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));
$allCalls = collect(Queue::pushed(SendEvent::class))->flatMap(fn ($j) => $j->calls);
$workspaceCall = $allCalls->first(
fn ($c) => $c['method'] === 'groupIdentify' && $c['payload']['groupType'] === 'workspace',
);
expect($workspaceCall)->not->toBeNull();
expect($workspaceCall['payload']['groupKey'])->toBe((string) $workspace->id);
expect($workspaceCall['payload']['properties']['account_id'])->toBe((string) $this->account->id);
expect($workspaceCall['payload']['properties']['social_accounts_count'])->toBe(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));
$allCalls = collect(Queue::pushed(SendEvent::class))->flatMap(fn ($j) => $j->calls);
$workspaceCall = $allCalls->first(
fn ($c) => $c['method'] === 'groupIdentify' && $c['payload']['groupType'] === 'workspace',
);
expect($workspaceCall)->toBeNull();
});
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,98 @@
<?php
declare(strict_types=1);
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.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, 'subscription.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, 'subscription.updated', $this->payload))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function ($job) {
$call = $job->calls[0];
return $call['method'] === 'capture'
&& $call['payload']['event'] === 'subscription.updated'
&& $call['payload']['distinctId'] === (string) $this->user->id
&& $call['payload']['properties']['$groups']['account'] === (string) $this->account->id
&& $call['payload']['properties']['stripe_status'] === 'active';
});
});
test('handle dispatches SyncUser for the account owner', function () {
Bus::fake([SyncUser::class]);
(new TrackBilling((string) $this->account->id, 'subscription.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', 'subscription.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, 'subscription.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, 'subscription.created', $this->payload))
->handle(app(PostHogService::class));
// SyncUser can still be queued but it would itself no-op when handled.
// The contract of this job is: no PostHog network call without a key.
Queue::assertNotPushed(SendEvent::class);
});

View file

@ -3,9 +3,11 @@
declare(strict_types=1);
use App\Events\SubscriptionCreated;
use App\Jobs\PostHog\TrackBilling;
use App\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\User;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
@ -126,7 +128,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 +143,61 @@
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 === 'subscription.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 === 'subscription.updated',
);
});
test('subscription deleted dispatches TrackBilling with subscription.cancelled event', function () {
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 === 'subscription.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);
});

View file

@ -2,10 +2,15 @@
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']);
@ -13,7 +18,7 @@
$service = new PostHogService;
$service->capture('user-123', 'test_event', ['foo' => 'bar']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->calls[0]['method'] === 'capture'
&& $job->calls[0]['payload']['event'] === 'test_event'
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
@ -37,7 +42,7 @@
$service = new PostHogService;
$service->identify('user-123', ['$email' => 'test@example.com']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->calls[0]['method'] === 'identify'
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
});
@ -60,7 +65,7 @@
$service = new PostHogService;
$service->groupIdentify('workspace', 'ws-123', ['name' => 'Test Workspace']);
Queue::assertPushed(SendPostHogEvent::class, function ($job) {
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->calls[0]['method'] === 'groupIdentify'
&& $job->calls[0]['payload']['groupType'] === 'workspace'
&& $job->calls[0]['payload']['groupKey'] === 'ws-123';
@ -76,3 +81,40 @@
Queue::assertNothingPushed();
});
test('capture auto-attaches account groups when account is supplied', function () {
Queue::fake();
config(['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->calls[0]['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.api_key' => 'phc_test_key']);
$service = new PostHogService;
$service->capture('user-123', 'page.viewed', ['url' => '/dashboard']);
Queue::assertPushed(SendEvent::class, function ($job) {
$properties = $job->calls[0]['payload']['properties'];
return ! array_key_exists('$groups', $properties)
&& ! array_key_exists('account_id', $properties)
&& ! array_key_exists('plan', $properties);
});
});