refactor: PostHog review polish

- New BillingEvent enum replaces 'subscription.{created,updated,cancelled}'
  strings across StripeEventListener, TrackBilling and tests.
- SendEvent now takes (method, payload) directly instead of an array of
  single-call shapes — overhead with no batching benefit.
- PostHogService consolidates the 3 api-key short-circuits into shouldSend().
- SyncUser eager-loads currentWorkspace.withCount('socialAccounts') and
  drops the redundant posts_count from the workspace group identify.
- Frontend Usage interface centralised in resources/js/types — was
  duplicated in posthog.ts and useFeatureAccess.ts.
- posthog.init moved out of module-import side-effect into
  initializePostHog() called explicitly from app.ts.
- SyncUserTest cleans up the convoluted assertion that merged
  $job->calls with Queue::pushed().
- Drop tests/Feature/StripeEventListenerTest.php (orphan, fully covered
  by tests/Feature/Listeners/StripeEventListenerTest.php).
- Revert .github/FUNDING.yml to match origin/main.
This commit is contained in:
Paulo Castellano 2026-05-07 10:41:53 -03:00
parent 1df59f09b4
commit b91bad7e6f
17 changed files with 124 additions and 198 deletions

2
.github/FUNDING.yml vendored
View file

@ -1 +1 @@
github: [paulocastellano]
github: [trypostit]

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

@ -13,11 +13,10 @@
use PostHog\PostHog;
/**
* 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.
* 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_API_KEY` is unset so self-hosted installs are
* unaffected.
@ -31,10 +30,11 @@ class SendEvent implements ShouldQueue
public int $timeout = 15;
/**
* @param array<array{method: string, payload: array<string, mixed>}> $calls
* @param array<string, mixed> $payload
*/
public function __construct(
public array $calls,
public string $method,
public array $payload,
) {
$this->onQueue('posthog');
}
@ -45,14 +45,12 @@ public function handle(): void
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('PostHog SendEvent: unknown method', ['method' => data_get($call, 'method')]),
};
}
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

@ -43,7 +43,10 @@ public function __construct(public string $userId)
public function handle(PostHogService $postHog): void
{
$user = User::with(['account.plan', 'currentWorkspace'])->find($this->userId);
$user = User::with([
'account.plan',
'currentWorkspace' => fn ($query) => $query->withCount('socialAccounts'),
])->find($this->userId);
if (! $user) {
return;
@ -78,8 +81,7 @@ public function handle(PostHogService $postHog): void
$postHog->groupIdentify('workspace', (string) $workspace->id, [
'name' => $workspace->name,
'account_id' => (string) $workspace->account_id,
'social_accounts_count' => $workspace->socialAccounts()->count(),
'posts_count' => $workspace->posts()->count(),
'social_accounts_count' => (int) $workspace->social_accounts_count,
'created_at' => $workspace->created_at?->toIso8601String(),
]);
}

View file

@ -4,6 +4,7 @@
namespace App\Jobs\PostHog;
use App\Enums\PostHog\BillingEvent;
use App\Models\Account;
use App\Services\PostHogService;
use Illuminate\Bus\Queueable;
@ -33,7 +34,7 @@ class TrackBilling implements ShouldQueue
*/
public function __construct(
public string $accountId,
public string $event,
public BillingEvent $event,
public array $payload,
public ?string $previousPlan = null,
) {
@ -50,7 +51,7 @@ public function handle(PostHogService $postHog): void
$postHog->capture(
(string) $account->owner_id,
$this->event,
$this->event->value,
[
'stripe_status' => data_get($this->payload, 'data.object.status'),
'plan' => $account->plan?->name,

View file

@ -4,6 +4,7 @@
namespace App\Listeners;
use App\Enums\PostHog\BillingEvent;
use App\Events\SubscriptionCreated;
use App\Jobs\PostHog\TrackBilling;
use App\Models\Account;
@ -49,15 +50,14 @@ public function handle(WebhookReceived $event): void
protected function handleSubscriptionCreated(Account $account, array $payload): void
{
$previousPlan = $account->plan?->name;
$plan = $this->resolvePlanFromSubscriptionItems($payload, $account);
if ($plan && $account->plan_id !== $plan->id) {
if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) {
$account->update(['plan_id' => $plan->id]);
}
SubscriptionCreated::dispatch($account);
$this->trackPlanChange($account, 'subscription.created', $previousPlan, $payload);
$this->trackPlanChange($account, BillingEvent::Created, $previousPlan, $payload);
}
/**
@ -72,13 +72,12 @@ protected function handleSubscriptionCreated(Account $account, array $payload):
protected function handleSubscriptionUpdated(Account $account, array $payload): void
{
$previousPlan = $account->plan?->name;
$plan = $this->resolvePlanFromSubscriptionItems($payload, $account);
if ($plan && $account->plan_id !== $plan->id) {
if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) {
$account->update(['plan_id' => $plan->id]);
}
$this->trackPlanChange($account, 'subscription.updated', $previousPlan, $payload);
$this->trackPlanChange($account, BillingEvent::Updated, $previousPlan, $payload);
}
/**
@ -93,11 +92,9 @@ protected function handleSubscriptionDeleted(Account $account, array $payload):
{
$previousPlan = $account->plan?->name;
if ($account->plan_id !== null) {
$account->update(['plan_id' => null]);
}
$account->update(['plan_id' => null]);
$this->trackPlanChange($account, 'subscription.cancelled', $previousPlan, $payload);
$this->trackPlanChange($account, BillingEvent::Cancelled, $previousPlan, $payload);
}
/**
@ -141,7 +138,7 @@ private function resolvePlanFromSubscriptionItems(array $payload, Account $accou
*
* @param array<string, mixed> $payload
*/
private function trackPlanChange(Account $account, string $event, ?string $previousPlan, array $payload): void
private function trackPlanChange(Account $account, BillingEvent $event, ?string $previousPlan, array $payload): void
{
TrackBilling::dispatch((string) $account->id, $event, $payload, $previousPlan);
}

View file

@ -19,7 +19,7 @@ class PostHogService
*/
public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void
{
if (! config('services.posthog.api_key')) {
if (! $this->shouldSend()) {
return;
}
@ -43,7 +43,7 @@ public function capture(string $distinctId, string $event, array $properties = [
*/
public function identify(string $distinctId, array $properties = []): void
{
if (! config('services.posthog.api_key')) {
if (! $this->shouldSend()) {
return;
}
@ -58,7 +58,7 @@ public function identify(string $distinctId, array $properties = []): void
*/
public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void
{
if (! config('services.posthog.api_key')) {
if (! $this->shouldSend()) {
return;
}
@ -69,15 +69,18 @@ public function groupIdentify(string $groupType, string $groupKey, array $proper
]);
}
private function shouldSend(): bool
{
return (bool) config('services.posthog.api_key');
}
/**
* @param array<string, mixed> $payload
*/
private function dispatch(string $method, array $payload): void
{
try {
SendEvent::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

@ -9,7 +9,7 @@ import { createApp, h } from 'vue';
import { initializeDataLayer } from './datalayer';
import dayjs from './dayjs';
import { capturePageview, syncPostHogContext } from './posthog';
import { capturePageview, initializePostHog, syncPostHogContext } from './posthog';
import type { Auth } from './types';
configureEcho({
@ -48,6 +48,7 @@ createInertiaApp({
// 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();

View file

@ -1,21 +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;
postCount: number;
creditsUsed: number;
}
interface Features {
workspaceLimit: number;
socialAccountLimit: number;

View file

@ -1,12 +1,16 @@
import type { Page } from '@inertiajs/core';
import posthog from 'posthog-js';
import type { Auth } from './types';
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;
if (apiKey) {
export const initializePostHog = (): void => {
if (!apiKey) {
return;
}
posthog.init(apiKey, {
api_host: host || 'https://us.i.posthog.com',
ui_host: 'https://us.posthog.com',
@ -19,16 +23,7 @@ if (apiKey) {
maskTextSelector: '.ph-no-capture',
},
});
}
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 +

View file

@ -35,6 +35,15 @@ export interface Auth {
currentPriceId: string | null;
}
export interface Usage {
workspaceCount: number;
socialAccountCount: number;
memberCount: number;
pendingInviteCount: number;
postCount: number;
creditsUsed: number;
}
export interface FlashData {
banner?: string;
bannerStyle?: 'success' | 'danger' | 'info' | 'warning';

View file

@ -5,9 +5,7 @@
use App\Jobs\PostHog\SendEvent;
test('job is queued on posthog queue', function () {
$job = new SendEvent([
['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 SendEvent([
['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 SendEvent([]);
$job = new SendEvent('capture', []);
expect($job->tries)->toBe(3);
expect($job->timeout)->toBe(15);

View file

@ -47,13 +47,11 @@
(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']);
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']);
});
});
@ -73,17 +71,13 @@
(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) {
if ($job->method !== 'groupIdentify' || $job->payload['groupType'] !== 'account') {
return false;
}
$props = $accountCall['payload']['properties'];
$props = $job->payload['properties'];
return $accountCall['payload']['groupKey'] === (string) $this->account->id
return $job->payload['groupKey'] === (string) $this->account->id
&& $props['workspaces_count'] === 1
&& $props['social_accounts_count'] === 2
&& $props['posts_count'] === 3
@ -106,15 +100,13 @@
(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);
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 () {
@ -124,12 +116,9 @@
(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();
Queue::assertNotPushed(SendEvent::class, function ($job) {
return $job->method === 'groupIdentify' && $job->payload['groupType'] === 'workspace';
});
});
test('job is queued on the posthog connection queue', function () {

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\PostHog\BillingEvent;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\SyncUser;
use App\Jobs\PostHog\TrackBilling;
@ -28,7 +29,7 @@
});
test('job is queued on the posthog queue', function () {
$job = new TrackBilling((string) $this->account->id, 'subscription.updated', $this->payload);
$job = new TrackBilling((string) $this->account->id, BillingEvent::Updated, $this->payload);
expect($job->queue)->toBe('posthog');
});
@ -36,36 +37,34 @@
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))
(new TrackBilling((string) $this->account->id, BillingEvent::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'
&& array_key_exists('previous_plan', $call['payload']['properties']);
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, 'subscription.updated', $this->payload, 'Starter'))
(new TrackBilling((string) $this->account->id, BillingEvent::Updated, $this->payload, 'Starter'))
->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->calls[0]['payload']['properties']['previous_plan'] === 'Starter';
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, 'subscription.cancelled', $this->payload))
(new TrackBilling((string) $this->account->id, BillingEvent::Cancelled, $this->payload))
->handle(app(PostHogService::class));
Bus::assertDispatched(
@ -78,7 +77,7 @@
Queue::fake();
Bus::fake([SyncUser::class]);
(new TrackBilling('00000000-0000-0000-0000-000000000000', 'subscription.created', $this->payload))
(new TrackBilling('00000000-0000-0000-0000-000000000000', BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
@ -90,7 +89,7 @@
Queue::fake();
Bus::fake([SyncUser::class]);
(new TrackBilling((string) $this->account->id, 'subscription.created', $this->payload))
(new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
Queue::assertNothingPushed();
@ -101,7 +100,7 @@
config(['services.posthog.api_key' => null]);
Queue::fake();
(new TrackBilling((string) $this->account->id, 'subscription.created', $this->payload))
(new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
->handle(app(PostHogService::class));
// SyncUser can still be queued but it would itself no-op when handled.

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\PostHog\BillingEvent;
use App\Events\SubscriptionCreated;
use App\Jobs\PostHog\TrackBilling;
use App\Listeners\StripeEventListener;
@ -160,7 +161,7 @@
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->accountId === (string) $this->account->id
&& $job->event === 'subscription.created',
&& $job->event === BillingEvent::Created,
);
});
@ -174,7 +175,7 @@
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === 'subscription.updated',
fn ($job) => $job->event === BillingEvent::Updated,
);
});
@ -188,7 +189,7 @@
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === 'subscription.cancelled',
fn ($job) => $job->event === BillingEvent::Cancelled,
);
});
@ -354,7 +355,7 @@
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === 'subscription.updated' && $job->previousPlan === $starter->name,
fn ($job) => $job->event === BillingEvent::Updated && $job->previousPlan === $starter->name,
);
});
@ -371,7 +372,7 @@
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === 'subscription.cancelled' && $job->previousPlan === $starter->name,
fn ($job) => $job->event === BillingEvent::Cancelled && $job->previousPlan === $starter->name,
);
});
@ -390,6 +391,6 @@
Bus::assertDispatched(
TrackBilling::class,
fn ($job) => $job->event === 'subscription.created' && $job->previousPlan === null,
fn ($job) => $job->event === BillingEvent::Created && $job->previousPlan === null,
);
});

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

@ -19,9 +19,9 @@
$service->capture('user-123', 'test_event', ['foo' => 'bar']);
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';
return $job->method === 'capture'
&& $job->payload['event'] === 'test_event'
&& $job->payload['distinctId'] === 'user-123';
});
});
@ -43,8 +43,8 @@
$service->identify('user-123', ['$email' => 'test@example.com']);
Queue::assertPushed(SendEvent::class, function ($job) {
return $job->calls[0]['method'] === 'identify'
&& $job->calls[0]['payload']['distinctId'] === 'user-123';
return $job->method === 'identify'
&& $job->payload['distinctId'] === 'user-123';
});
});
@ -66,9 +66,9 @@
$service->groupIdentify('workspace', 'ws-123', ['name' => 'Test Workspace']);
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';
return $job->method === 'groupIdentify'
&& $job->payload['groupType'] === 'workspace'
&& $job->payload['groupKey'] === 'ws-123';
});
});
@ -93,7 +93,7 @@
$service->capture('user-123', 'subscription.created', ['stripe_status' => 'active'], $account);
Queue::assertPushed(SendEvent::class, function ($job) use ($account, $plan) {
$payload = $job->calls[0]['payload'];
$payload = $job->payload;
return $payload['event'] === 'subscription.created'
&& $payload['properties']['$groups']['account'] === (string) $account->id
@ -111,7 +111,7 @@
$service->capture('user-123', 'page.viewed', ['url' => '/dashboard']);
Queue::assertPushed(SendEvent::class, function ($job) {
$properties = $job->calls[0]['payload']['properties'];
$properties = $job->payload['properties'];
return ! array_key_exists('$groups', $properties)
&& ! array_key_exists('account_id', $properties)