feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 23:40:03 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
|
|
|
|
use App\Enums\SocialAccount\Status;
|
|
|
|
|
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
|
|
|
|
use App\Models\SocialAccount;
|
|
|
|
|
use App\Models\Workspace;
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
config()->set('trypost.self_hosted', false);
|
|
|
|
|
$this->workspace = Workspace::factory()->create();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('blocks a second account of the same network', function () {
|
|
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'ig-a',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'ig-b',
|
|
|
|
|
]))->toThrow(NetworkAlreadyConnectedException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('collapses platform variants into one network', function () {
|
|
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::LinkedIn,
|
|
|
|
|
'platform_user_id' => 'li-profile',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::LinkedInPage,
|
|
|
|
|
'platform_user_id' => 'li-page',
|
|
|
|
|
]))->toThrow(NetworkAlreadyConnectedException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('allows different networks in the same workspace', function () {
|
|
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'ig-a',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$x = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::X,
|
|
|
|
|
'platform_user_id' => 'x-a',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($x->exists)->toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('allows the same network in different workspaces', function () {
|
|
|
|
|
$other = Workspace::factory()->create();
|
|
|
|
|
|
|
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'ig-a',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$second = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $other->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'ig-a',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($second->exists)->toBeTrue();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('reconnecting the same account via updateOrCreate is allowed', function () {
|
|
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'ig-a',
|
|
|
|
|
'username' => 'old',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->workspace->socialAccounts()->updateOrCreate(
|
|
|
|
|
['platform' => Platform::Instagram->value, 'platform_user_id' => 'ig-a'],
|
|
|
|
|
['username' => 'new', 'status' => Status::Connected],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
|
|
|
|
->and($this->workspace->socialAccounts()->first()->username)->toBe('new');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('self-hosted mode bypasses the one-per-network rule', function () {
|
|
|
|
|
config()->set('trypost.self_hosted', true);
|
|
|
|
|
|
|
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'ig-a',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$second = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'ig-b',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($second->exists)->toBeTrue();
|
|
|
|
|
});
|
2026-06-22 12:26:31 +00:00
|
|
|
|
|
|
|
|
test('blocks a same-id account connected via a different network variant', function () {
|
|
|
|
|
SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
'platform_user_id' => 'shared-ig-id',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::InstagramFacebook,
|
|
|
|
|
'platform_user_id' => 'shared-ig-id',
|
|
|
|
|
]))->toThrow(NetworkAlreadyConnectedException::class);
|
|
|
|
|
});
|