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.
This commit is contained in:
Paulo Castellano 2026-06-21 20:40:03 -03:00
parent 0a407c4d3b
commit cbf8fb283c
102 changed files with 1644 additions and 1596 deletions

View file

@ -193,6 +193,8 @@ STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
# Set to false to allow generic signup trial without requiring a card.
REQUIRE_CARD_FOR_TRIAL=true
# Free trial length in days (Stripe Checkout shows one day fewer).
CASHIER_TRIAL_DAYS=8
# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
STRIPE_STARTER_MONTHLY=
@ -203,6 +205,8 @@ STRIPE_PRO_MONTHLY=
STRIPE_PRO_YEARLY=
STRIPE_MAX_MONTHLY=
STRIPE_MAX_YEARLY=
STRIPE_WORKSPACE_MONTHLY=
STRIPE_WORKSPACE_YEARLY=
# Laravel Nightwatch (production telemetry — disabled by default in dev)
NIGHTWATCH_ENABLED=false

View file

@ -241,6 +241,13 @@ ## AI agents (`app/Ai/Agents`)
- Put system/instruction text in Blade under `resources/views/prompts/` (e.g. `prompts.post_content.generator`, `prompts.post_image.regenerator`).
- In `instructions()`, return `view('prompts....', [...])->render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`.
## System AI (always allowed, never metered)
- The brand analyzer / workspace autofill (`App\Services\Brand\BrandAnalyzerRunner`, `App\Actions\Ai\AutofillBrand`, `WorkspaceController::autofillBrand`) is a **system** feature, not the user's AI usage. It runs during workspace creation, before the user has AI access.
- It MUST always be allowed: NEVER gate it behind the `useAi` policy, an active subscription, or a credit check.
- It MUST NOT deduct anything: NEVER call `RecordAiUsage` (or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's.
- Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered.
## Icons (@tabler/icons-vue)
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.

View file

@ -4,7 +4,6 @@
namespace App\Actions\Ai;
use App\Models\Workspace;
use App\Services\Brand\BrandAnalyzerRunner;
use App\Services\Brand\BrandMetadata;
use App\Services\Brand\HomepageMetaExtractor;
@ -20,7 +19,7 @@ public function __construct(
private readonly LogoColorExtractor $logoColors,
) {}
public function __invoke(string $url, ?Workspace $workspace = null, ?string $userId = null): BrandMetadata
public function __invoke(string $url): BrandMetadata
{
$url = $this->fetcher->normalizeUrl($url);
@ -54,7 +53,7 @@ public function __invoke(string $url, ?Workspace $workspace = null, ?string $use
return $metadata;
}
$analysis = $this->analyzer->analyze($this->extractor->extractBodyHtml($html), $workspace, $userId);
$analysis = $this->analyzer->analyze($this->extractor->extractBodyHtml($html));
return $analysis === null
? $metadata

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Actions\Billing;
use App\Models\Account;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class StartSubscriptionCheckout
{
/**
* Create a Stripe Checkout session for the given price and return an Inertia
* redirect to it. Quantity tracks the account's workspace count; a trial is
* attached when the instance requires a card up front.
*/
public function redirect(Account $account, string $priceId, string $cancelUrl): Response
{
$account->createOrGetStripeCustomer([
'email' => $account->stripeEmail(),
'name' => $account->stripeName(),
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->quantity(max(1, $account->workspaces()->count()))
->allowPromotionCodes();
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
$subscription->trialDays(config('cashier.trial_days'));
}
$session = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $cancelUrl,
]);
return Inertia::location($session->url);
}
}

View file

@ -7,13 +7,11 @@
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Events\TelegramChannelConnected;
use App\Features\SocialAccountLimit;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Laravel\Pennant\Feature;
use Throwable;
class ConnectTelegramChannel
@ -30,13 +28,12 @@ public static function execute(Workspace $workspace, array $chat, string $nonce)
$chatId = (string) data_get($chat, 'id');
$username = data_get($chat, 'username');
// Block only brand-new accounts against the plan limit, never reconnects.
$isNewAccount = ! $workspace->socialAccounts()
->where('platform', Platform::Telegram->value)
->where('platform_user_id', $chatId)
->exists();
if ($isNewAccount && self::workspaceAtAccountLimit($workspace)) {
if ($isNewAccount && self::networkAlreadyConnected($workspace, $chatId)) {
return null;
}
@ -103,14 +100,15 @@ private static function fetchChannelAvatar(string $chatId): ?string
}
}
private static function workspaceAtAccountLimit(Workspace $workspace): bool
private static function networkAlreadyConnected(Workspace $workspace, string $chatId): bool
{
if (config('trypost.self_hosted')) {
return false;
}
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
return $workspace->socialAccounts()->count() >= $limit;
return $workspace->socialAccounts()
->whereIn('platform', Platform::Telegram->networkPlatformValues())
->where('platform_user_id', '!=', $chatId)
->exists();
}
}

View file

@ -29,7 +29,7 @@ public static function execute(array $data, array $utmParameters = []): User
];
if (! $requiresCardForTrial) {
$accountAttributes['plan_id'] = Plan::where('slug', Slug::Starter)->value('id');
$accountAttributes['plan_id'] = Plan::where('slug', Slug::Workspace)->value('id');
$accountAttributes['trial_ends_at'] = now()->addDays(config('cashier.trial_days', 7));
}

View file

@ -37,6 +37,9 @@ public static function execute(User $user, array $data): Workspace
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
$user->switchWorkspace($workspace);
$user->account?->forgetPlanFeatureCache();
$user->account?->syncWorkspaceQuantity();
return $workspace;
}
}

View file

@ -15,10 +15,14 @@ public static function execute(User $user, Workspace $workspace): void
{
User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]);
$account = $workspace->account;
$accountId = (string) $workspace->account_id;
$workspace->delete();
$account?->forgetPlanFeatureCache();
$account?->syncWorkspaceQuantity();
if (PostHogService::isEnabled()) {
SyncAccountUsage::dispatch($accountId, null);
}

View file

@ -10,6 +10,7 @@ enum Slug: string
case Plus = 'plus';
case Pro = 'pro';
case Max = 'max';
case Workspace = 'workspace';
public function label(): string
{
@ -18,6 +19,7 @@ public function label(): string
self::Plus => 'Plus',
self::Pro => 'Pro',
self::Max => 'Max',
self::Workspace => 'Workspace',
};
}
}

View file

@ -23,6 +23,35 @@ enum Platform: string
case Telegram = 'telegram';
case Discord = 'discord';
/**
* The social network this platform belongs to. Variants that represent the
* same network (LinkedIn profile vs. company page, Instagram standalone vs.
* Facebook-linked) collapse to one key so a workspace may connect only one
* account per network.
*/
public function network(): string
{
return match ($this) {
self::LinkedIn, self::LinkedInPage => 'linkedin',
self::Instagram, self::InstagramFacebook => 'instagram',
default => $this->value,
};
}
/**
* All platform values that share this platform's network, used to enforce
* the one-account-per-network rule across variants.
*
* @return array<int, string>
*/
public function networkPlatformValues(): array
{
return array_values(array_map(
fn (self $platform): string => $platform->value,
array_filter(self::cases(), fn (self $platform): bool => $platform->network() === $this->network()),
));
}
public function label(): string
{
return match ($this) {

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Enums\User;
enum Persona: string
{
case Creator = 'creator';
case Freelancer = 'freelancer';
case Startup = 'startup';
case Agency = 'agency';
case SmallBusiness = 'small_business';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Creator => 'Creator',
self::Freelancer => 'Freelancer',
self::Startup => 'Startup',
self::Agency => 'Agency',
self::SmallBusiness => 'Small business',
self::Other => 'Other',
};
}
/**
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
fn (self $persona): array => ['value' => $persona->value, 'label' => $persona->label()],
self::cases(),
);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\SocialAccount;
use App\Enums\SocialAccount\Platform;
use RuntimeException;
class NetworkAlreadyConnectedException extends RuntimeException
{
public function __construct(public readonly Platform $platform)
{
parent::__construct("This workspace already has a {$platform->network()} account connected.");
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class MemberLimit
{
public string $name = 'member-limit';
public function resolve(Account $scope): int
{
return $scope->plan?->member_limit ?? 1;
}
}

View file

@ -5,6 +5,7 @@
namespace App\Features;
use App\Models\Account;
use App\Support\BillingCycle;
class MonthlyCreditsLimit
{
@ -12,6 +13,6 @@ class MonthlyCreditsLimit
public function resolve(Account $scope): int
{
return $scope->plan?->monthly_credits_limit ?? 1000;
return BillingCycle::for($scope)->creditAllotment();
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class SocialAccountLimit
{
public string $name = 'social-account-limit';
public function resolve(Account $scope): int
{
return $scope->plan?->social_account_limit ?? 5;
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class WorkspaceLimit
{
public string $name = 'workspace-limit';
public function resolve(Account $scope): int
{
return $scope->plan?->workspace_limit ?? 1;
}
}

View file

@ -4,6 +4,7 @@
namespace App\Http\Controllers\App;
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Models\Account;
use App\Models\Plan;
use Illuminate\Http\RedirectResponse;
@ -17,27 +18,12 @@
class BillingController extends Controller
{
public function subscribe(Request $request): Response|RedirectResponse
public function subscribe(): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$account = $request->user()->account;
if ($account && $account->hasActiveSubscription()) {
return redirect()->route('app.billing.index');
}
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
return Inertia::render('billing/Subscribe', [
'plans' => Plan::active()->orderBy('sort')->get(),
'trialDays' => $requiresCardForTrial ? config('cashier.trial_days') : null,
]);
return redirect()->route('app.onboarding');
}
public function checkout(Request $request, Plan $plan): SymfonyResponse|RedirectResponse
public function checkout(Request $request, Plan $plan, StartSubscriptionCheckout $checkout): SymfonyResponse|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
@ -47,6 +33,7 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
$account = $user->account;
abort_unless($user->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
abort_if($plan->is_archived, SymfonyResponse::HTTP_NOT_FOUND);
$request->validate([
'price_id' => ['required', 'string'],
@ -60,24 +47,7 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
'Invalid price for this plan',
);
$account->createOrGetStripeCustomer([
'email' => $account->stripeEmail(),
'name' => $account->stripeName(),
]);
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->allowPromotionCodes();
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
$subscription->trialDays(config('cashier.trial_days'));
}
$checkoutSession = $subscription->checkout([
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('app.subscribe'),
]);
return Inertia::location($checkoutSession->url);
return $checkout->redirect($account, $priceId, route('app.onboarding'));
}
public function processing(Request $request): Response|RedirectResponse
@ -159,7 +129,7 @@ public function index(Request $request): Response|RedirectResponse
'ends_at',
]),
'plan' => $account->plan,
'plans' => Plan::active()->orderBy('sort')->get(),
'workspaceCount' => $account->workspaces()->count(),
'invoices' => $account->invoices()->map(fn ($invoice) => [
'id' => $invoice->id,
'date' => $invoice->date(),
@ -171,7 +141,7 @@ public function index(Request $request): Response|RedirectResponse
]);
}
public function swap(Request $request, Plan $plan): RedirectResponse
public function swapToYearly(Request $request): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
@ -180,20 +150,18 @@ public function swap(Request $request, Plan $plan): RedirectResponse
$account = $request->user()->account;
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), 422, 'No active subscription');
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription');
$request->validate([
'price_id' => ['required', 'string'],
]);
$plan = $account->plan;
$yearlyPriceId = $plan?->stripe_yearly_price_id;
abort_if($yearlyPriceId === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No annual price configured');
$priceId = $request->input('price_id');
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);
abort_unless(
$priceId === $plan->stripe_monthly_price_id || $priceId === $plan->stripe_yearly_price_id,
422,
'Invalid price for this plan',
);
if ($subscription->stripe_price === $yearlyPriceId) {
return redirect()->route('app.billing.index');
}
$authorization = Gate::inspect('swapPlan', [$account, $plan]);
@ -201,12 +169,11 @@ public function swap(Request $request, Plan $plan): RedirectResponse
return back()->with('flash.error', $authorization->message());
}
$subscription->swap($priceId);
$account->update(['plan_id' => $plan->id]);
$subscription->swap($yearlyPriceId);
$account->forgetPlanFeatureCache();
return redirect()->route('app.billing.index')
->with('flash.success', __('billing.flash.plan_changed', ['plan' => $plan->name]));
->with('flash.success', __('billing.flash.switched_to_yearly'));
}
public function portal(Request $request): RedirectResponse

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
use App\Enums\User\Persona;
use App\Http\Requests\App\Onboarding\StoreOnboardingRequest;
use App\Models\Account;
use App\Models\Plan;
use App\Services\PostHogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class OnboardingController extends Controller
{
public function index(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
return Inertia::render('onboarding/Index', [
'personas' => Persona::options(),
'selected' => $user->persona?->value,
]);
}
public function store(
StoreOnboardingRequest $request,
PostHogService $postHog,
StartSubscriptionCheckout $checkout,
): SymfonyResponse|RedirectResponse {
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
$account = $user->account;
$persona = (string) $request->validated('persona');
$user->update(['persona' => $persona]);
$postHog->identify($user->id, [
'persona' => $persona,
]);
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
return $checkout->redirect(
$account,
(string) $plan->stripe_monthly_price_id,
route('app.onboarding'),
);
}
}

View file

@ -4,12 +4,9 @@
namespace App\Http\Controllers\App\Settings;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Http\Controllers\App\Controller;
use App\Models\AiUsageLog;
use App\Support\BillingCycle;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -40,12 +37,9 @@ public function index(Request $request): Response|RedirectResponse
'plan' => $account->plan,
'usage' => [
'workspaceCount' => $account->workspaces()->count(),
'workspaceLimit' => Feature::for($account)->value(WorkspaceLimit::class),
'socialAccountCount' => $totalSocialAccounts,
'socialAccountLimit' => Feature::for($account)->value(SocialAccountLimit::class),
'memberCount' => $totalMembers,
'memberLimit' => Feature::for($account)->value(MemberLimit::class),
'creditsUsed' => AiUsageLog::monthlyCredits($account->id),
'creditsUsed' => BillingCycle::for($account)->usedCredits(),
'monthlyCreditsLimit' => Feature::for($account)->value(MonthlyCreditsLimit::class),
],
]);

View file

@ -13,14 +13,12 @@
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
use App\Http\Resources\App\WorkspaceMemberResource;
use App\Models\Account;
use App\Models\Workspace;
use App\Services\Brand\LogoAttacher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
@ -70,15 +68,13 @@ public function create(Request $request): Response|RedirectResponse
{
$user = $request->user();
if ($user->ownedWorkspacesCount() > 0 && ! $user->account?->hasActiveSubscription()) {
if (! config('trypost.self_hosted')
&& $user->ownedWorkspacesCount() > 0
&& ! $user->account?->hasActiveSubscription()) {
return redirect()->route('app.billing.index')
->with('message', 'Subscribe to create more workspaces.');
}
if ($this->hasReachedWorkspaceLimit($user->account)) {
return back()->with('flash.error', __('workspaces.limit_reached'));
}
return Inertia::render('workspaces/Create', [
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
@ -92,15 +88,8 @@ public function autofillBrand(Request $request, AutofillBrand $autofill): JsonRe
'url' => ['required', 'string', 'max:255'],
]);
$user = $request->user();
$gate = Gate::inspect('useAi', $user->account);
if ($gate->denied()) {
return response()->json(['message' => $gate->message()], SymfonyResponse::HTTP_PAYMENT_REQUIRED);
}
try {
$metadata = $autofill(data_get($validated, 'url'), $user->currentWorkspace, $user->id);
$metadata = $autofill(data_get($validated, 'url'));
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
}
@ -112,10 +101,6 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
{
$user = $request->user();
if ($this->hasReachedWorkspaceLimit($user->account)) {
abort(SymfonyResponse::HTTP_FORBIDDEN, __('workspaces.limit_reached'));
}
$validated = $request->validated();
$workspace = CreateWorkspace::execute($user, $validated);
@ -235,28 +220,13 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons
$user = $request->user();
if (! config('trypost.self_hosted') && $workspace->account->workspaces()->count() <= 1) {
return back()->with('flash.error', __('workspaces.cannot_delete_last'));
}
DeleteWorkspace::execute($user, $workspace);
return redirect()->route('app.workspaces.index')
->with('flash.success', __('workspaces.flash.deleted'));
}
/**
* Check whether the account has hit its plan's workspace limit.
* Returns false in self-hosted mode (no plan limits apply).
*/
private function hasReachedWorkspaceLimit(?Account $account): bool
{
if (config('trypost.self_hosted')) {
return false;
}
if (! $account) {
return false;
}
$limit = (int) ($account->plan?->workspace_limit ?? 1);
return $account->workspaces()->count() >= $limit;
}
}

View file

@ -30,7 +30,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
return Inertia::render('accounts/BlueskyConnect', [
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],

View file

@ -42,7 +42,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,

View file

@ -38,7 +38,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,

View file

@ -44,7 +44,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,

View file

@ -35,7 +35,6 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,

View file

@ -36,7 +36,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
return Inertia::render('accounts/MastodonConnect', [
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],

View file

@ -8,7 +8,7 @@
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Features\SocialAccountLimit;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Http\Controllers\Controller;
use App\Http\Resources\App\SocialAccountResource;
use App\Models\SocialAccount;
@ -19,7 +19,6 @@
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
@ -34,19 +33,6 @@ protected function ensurePlatformEnabled(): void
}
}
protected function ensureSocialAccountLimit(Workspace $workspace): void
{
if (config('trypost.self_hosted')) {
return;
}
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
if ($workspace->socialAccounts()->count() >= $limit) {
abort(SymfonyResponse::HTTP_FORBIDDEN, __('accounts.limit_reached'));
}
}
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
@ -146,8 +132,6 @@ protected function redirectToProvider(Request $request, string $driver, array $s
return redirect()->route('app.workspaces.create');
}
$this->ensureSocialAccountLimit($workspace);
session(['social_connect_workspace' => $workspace->id]);
session(['social_connect_onboarding' => $request->boolean('onboarding')]);
@ -201,6 +185,8 @@ protected function handleCallback(
);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $platform->value);
} catch (\Exception $e) {
Log::error('Social OAuth Error', [
'platform' => $platform->value,

View file

@ -28,7 +28,6 @@ public function connect(Request $request): JsonResponse
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
$expiresAt = now()->addMinutes(15);
$code = TelegramConnectCode::issue($workspace->id, $expiresAt);

View file

@ -36,7 +36,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,

View file

@ -40,7 +40,6 @@ public function connect(Request $request): Response|RedirectResponse
}
$this->authorize('manageAccounts', $workspace);
$this->ensureSocialAccountLimit($workspace);
session([
'social_connect_workspace' => $workspace->id,

View file

@ -32,7 +32,7 @@ public function handle(Request $request, Closure $next): Response
);
if (! $hasAccess) {
return redirect()->route('app.subscribe');
return redirect()->route('app.onboarding');
}
}

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\Plan;
use Illuminate\Http\Request;
use Inertia\Middleware;
@ -48,7 +47,6 @@ public function share(Request $request): array
],
'usage' => $account && ! $isSelfHosted ? $account->usage() : null,
'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null,
'plans' => $isSelfHosted ? [] : Plan::active()->orderBy('sort')->get(),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'flash' => $request->session()->get('flash', []),
'applicationUrl' => config('app.url'),

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Onboarding;
use App\Enums\User\Persona;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreOnboardingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'persona' => ['required', Rule::enum(Persona::class)],
];
}
}

View file

@ -4,10 +4,7 @@
namespace App\Models;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Traits\HasUsage;
use Carbon\CarbonInterface;
use Database\Factories\AccountFactory;
@ -16,8 +13,10 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Billable;
use Laravel\Pennant\Feature;
use Throwable;
class Account extends Model
{
@ -50,9 +49,6 @@ public static function postsCountCacheKey(string $accountId): string
public function forgetPlanFeatureCache(): void
{
Feature::for($this)->forget([
WorkspaceLimit::class,
SocialAccountLimit::class,
MemberLimit::class,
MonthlyCreditsLimit::class,
]);
}
@ -91,6 +87,33 @@ public function hasActiveSubscription(): bool
return $this->subscribed(self::SUBSCRIPTION_NAME);
}
/**
* Align the Stripe subscription quantity with the number of workspaces the
* account owns. Each workspace is a billed unit. No-op in self-hosted mode
* or when there is no active subscription (e.g. during onboarding).
*/
public function syncWorkspaceQuantity(): void
{
if (config('trypost.self_hosted')) {
return;
}
$subscription = $this->subscription(self::SUBSCRIPTION_NAME);
if (! $subscription || ! $subscription->active()) {
return;
}
try {
$subscription->updateQuantity($this->workspaces()->count());
} catch (Throwable $e) {
Log::warning('Failed to sync workspace quantity to Stripe', [
'account_id' => $this->id,
'error' => $e->getMessage(),
]);
}
}
public function isPastDue(): bool
{
if (config('trypost.self_hosted')) {

View file

@ -5,6 +5,7 @@
namespace App\Models;
use App\Enums\Ai\UsageType;
use Carbon\CarbonInterface;
use Database\Factories\AiUsageLogFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -50,10 +51,11 @@ public function account(): BelongsTo
return $this->belongsTo(Account::class);
}
public static function monthlyCredits(string $accountId): int
public static function creditsUsedBetween(string $accountId, CarbonInterface $start, CarbonInterface $end): int
{
return (int) static::where('account_id', $accountId)
->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])
->where('created_at', '>=', $start)
->where('created_at', '<', $end)
->sum('credits');
}
}

View file

@ -22,9 +22,6 @@ class Plan extends Model
'name',
'stripe_monthly_price_id',
'stripe_yearly_price_id',
'social_account_limit',
'member_limit',
'workspace_limit',
'monthly_credits_limit',
'sort',
'is_archived',
@ -35,9 +32,6 @@ protected function casts(): array
return [
'slug' => Slug::class,
'is_archived' => 'boolean',
'social_account_limit' => 'integer',
'member_limit' => 'integer',
'workspace_limit' => 'integer',
'monthly_credits_limit' => 'integer',
'sort' => 'integer',
];

View file

@ -4,23 +4,20 @@
namespace App\Models\Traits;
use App\Features\MemberLimit;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Invite;
use App\Models\Post;
use App\Support\BillingCycle;
use Illuminate\Support\Facades\Cache;
use Laravel\Pennant\Feature;
/**
* Provides account-level usage counts and plan-resolved feature limits.
*
* The `featureLimits()` call resolves Pennant features for the account,
* which writes to the features cache table on first access. Pennant cache
* is invalidated automatically when `plan_id` changes (see Account::booted).
* The `featureLimits()` call resolves the Pennant credit feature for the account,
* which writes to the features cache table on first access. The cache is invalidated
* explicitly via `Account::forgetPlanFeatureCache()` on plan and workspace changes.
*/
trait HasUsage
{
@ -49,19 +46,16 @@ public function usage(): array
->whereNull('accepted_at')
->count(),
'postCount' => $this->cachedPostCount($workspaces->pluck('id')->all()),
'creditsUsed' => AiUsageLog::monthlyCredits($this->id),
'creditsUsed' => BillingCycle::for($this)->usedCredits(),
];
}
/**
* @return array{workspaceLimit: int, socialAccountLimit: int, memberLimit: int, monthlyCreditsLimit: int}
* @return array{monthlyCreditsLimit: int}
*/
public function featureLimits(): array
{
return [
'workspaceLimit' => Feature::for($this)->value(WorkspaceLimit::class),
'socialAccountLimit' => Feature::for($this)->value(SocialAccountLimit::class),
'memberLimit' => Feature::for($this)->value(MemberLimit::class),
'monthlyCreditsLimit' => Feature::for($this)->value(MonthlyCreditsLimit::class),
];
}

View file

@ -5,6 +5,7 @@
namespace App\Models;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\User\Persona;
use App\Models\Traits\HasMedia;
use App\Models\Traits\HasWorkspace;
use Database\Factories\UserFactory;
@ -42,6 +43,7 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable
'utm_term',
'utm_content',
'registration_ip',
'persona',
];
/**
@ -75,6 +77,7 @@ protected function casts(): array
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'persona' => Persona::class,
];
}

View file

@ -4,12 +4,44 @@
namespace App\Observers;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Jobs\PostHog\SyncAccountUsage;
use App\Models\SocialAccount;
use App\Services\PostHogService;
class SocialAccountObserver
{
/**
* Enforce one connected account per social network per workspace. Variants
* of the same network (LinkedIn profile/page, Instagram standalone/Facebook)
* collapse via Platform::network(). Reconnecting an existing account goes
* through updateOrCreate's update path and never reaches this hook. Bypassed
* in self-hosted mode, which has no per-workspace limits.
*/
public function creating(SocialAccount $socialAccount): void
{
if (config('trypost.self_hosted')) {
return;
}
$platform = $socialAccount->platform;
if (! $platform instanceof Platform) {
return;
}
$conflict = SocialAccount::query()
->where('workspace_id', $socialAccount->workspace_id)
->whereIn('platform', $platform->networkPlatformValues())
->where('platform_user_id', '!=', $socialAccount->platform_user_id)
->exists();
if ($conflict) {
throw new NetworkAlreadyConnectedException($platform);
}
}
public function created(SocialAccount $socialAccount): void
{
$this->syncUsage($socialAccount);

View file

@ -4,13 +4,11 @@
namespace App\Policies;
use App\Features\MonthlyCreditsLimit;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Plan;
use App\Models\User;
use App\Support\BillingCycle;
use Illuminate\Auth\Access\Response;
use Laravel\Pennant\Feature;
class AccountPolicy
{
@ -25,9 +23,9 @@ public function manageBilling(User $user, Account $account): bool
}
/**
* Authorize using AI features. Allows when the account still has monthly
* credits remaining. Manual post creation is unaffected — only AI calls
* are gated by this check.
* Authorize using AI features. Requires an active subscription (or trial)
* and remaining monthly credits. Manual post creation is unaffected — only
* AI calls are gated by this check.
*/
public function useAi(User $user, Account $account): Response
{
@ -35,10 +33,19 @@ public function useAi(User $user, Account $account): Response
return Response::allow();
}
$limit = (int) Feature::for($account)->value(MonthlyCreditsLimit::class);
$used = AiUsageLog::monthlyCredits($account->id);
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
if ($used >= $limit) {
$hasAccess = $account->subscribed(Account::SUBSCRIPTION_NAME)
|| (! $requiresCardForTrial && $account->isOnTrial());
if (! $hasAccess) {
return Response::deny(__('billing.flash.subscription_required'));
}
$cycle = BillingCycle::for($account);
$limit = $cycle->creditAllotment();
if ($cycle->usedCredits() >= $limit) {
return Response::deny(__('billing.flash.credits_exhausted', [
'limit' => (string) $limit,
]));
@ -48,10 +55,9 @@ public function useAi(User $user, Account $account): Response
}
/**
* Authorize swapping the account's subscription to the given plan.
*
* Denies if the account's current usage exceeds any of the target plan's
* limits (workspaces, social accounts, members + pending invites).
* Authorize swapping the account's subscription billing interval. Only the
* account owner may change billing; per-workspace pricing has no plan tiers
* to downgrade between, so there are no usage-based restrictions.
*/
public function swapPlan(User $user, Account $account, Plan $plan): Response
{
@ -59,33 +65,6 @@ public function swapPlan(User $user, Account $account, Plan $plan): Response
return Response::deny(__('billing.flash.cannot_manage'));
}
$usage = $account->usage();
$checks = [
'workspaces' => [
'count' => $usage['workspaceCount'],
'limit' => (int) $plan->workspace_limit,
],
'social_accounts' => [
'count' => $usage['socialAccountCount'],
'limit' => (int) $plan->social_account_limit,
],
'members' => [
'count' => $usage['memberCount'] + $usage['pendingInviteCount'],
'limit' => (int) $plan->member_limit,
],
];
foreach ($checks as $type => $data) {
if ($data['count'] > $data['limit']) {
return Response::deny(__('billing.flash.cannot_downgrade.'.$type, [
'plan' => $plan->name,
'count' => (string) $data['count'],
'limit' => (string) $data['limit'],
]));
}
}
return Response::allow();
}
}

View file

@ -5,10 +5,8 @@
namespace App\Policies;
use App\Enums\UserWorkspace\Role;
use App\Features\MemberLimit;
use App\Models\User;
use App\Models\Workspace;
use Laravel\Pennant\Feature;
class WorkspacePolicy
{
@ -68,17 +66,7 @@ public function createPost(User $user, Workspace $workspace): bool
public function inviteMember(User $user, Workspace $workspace): bool
{
if (! $this->isOwnerOrWorkspaceAdmin($user, $workspace)) {
return false;
}
if (config('trypost.self_hosted')) {
return true;
}
$limit = Feature::for($user->account)->value(MemberLimit::class);
return $workspace->members()->count() < $limit;
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
}
public function manageBilling(User $user, Workspace $workspace): bool

View file

@ -5,8 +5,6 @@
namespace App\Services\Brand;
use App\Ai\Agents\BrandAnalyzer;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
@ -25,7 +23,7 @@ public function isAvailable(): bool
};
}
public function analyze(string $bodyHtml, ?Workspace $workspace = null, ?string $userId = null): ?LlmBrandAnalysis
public function analyze(string $bodyHtml): ?LlmBrandAnalysis
{
$markdown = $this->htmlToMarkdown($bodyHtml);
@ -41,21 +39,6 @@ public function analyze(string $bodyHtml, ?Workspace $workspace = null, ?string
return null;
}
// Brand analysis can run during onboarding before the user has a
// workspace; skip usage tracking in that case (the cost is small and
// one-shot). Other AI flows always carry a workspace.
if ($workspace !== null) {
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
userId: $userId,
metadata: ['agent' => 'brand_analyzer'],
);
}
return LlmBrandAnalysis::fromResponse($response);
}

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Account;
use App\Models\AiUsageLog;
use Carbon\CarbonImmutable;
use Laravel\Cashier\Subscription;
/**
* Resolves an account's current AI credit cycle: the allotment it is entitled to
* and the time window usage is measured against. The window follows the Stripe
* billing cycle (monthly or yearly), anchored on the subscription date — so an
* annual subscriber receives twelve months of credits upfront and resets on
* their renewal date, while a monthly subscriber resets each anniversary day.
*/
class BillingCycle
{
private function __construct(private readonly Account $account) {}
public static function for(Account $account): self
{
return new self($account);
}
public function intervalMonths(): int
{
$subscription = $this->subscription();
$plan = $this->account->plan;
if ($subscription !== null
&& $plan?->stripe_yearly_price_id !== null
&& $subscription->stripe_price === $plan->stripe_yearly_price_id
) {
return 12;
}
return 1;
}
public function creditAllotment(): int
{
$base = (int) ($this->account->plan?->monthly_credits_limit ?? 0);
$months = $this->onTrial() ? 1 : $this->intervalMonths();
return $base * $this->account->workspaces()->count() * $months;
}
public function usedCredits(): int
{
return AiUsageLog::creditsUsedBetween(
(string) $this->account->id,
$this->periodStart(),
$this->periodEnd(),
);
}
public function periodStart(): CarbonImmutable
{
return $this->window()[0];
}
public function periodEnd(): CarbonImmutable
{
return $this->window()[1];
}
/**
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
*/
private function window(): array
{
$subscription = $this->subscription();
if ($subscription !== null && $subscription->onTrial()) {
return [
CarbonImmutable::parse($subscription->created_at),
CarbonImmutable::parse($subscription->trial_ends_at),
];
}
$anchor = $this->anchor();
$now = CarbonImmutable::now();
$step = $this->intervalMonths();
$periods = 0;
while ($anchor->addMonthsNoOverflow(($periods + 1) * $step)->lessThanOrEqualTo($now)) {
$periods++;
}
return [
$anchor->addMonthsNoOverflow($periods * $step),
$anchor->addMonthsNoOverflow(($periods + 1) * $step),
];
}
private function anchor(): CarbonImmutable
{
$subscription = $this->subscription();
$anchor = $subscription?->trial_ends_at
?? $subscription?->created_at
?? $this->account->created_at
?? CarbonImmutable::now();
return CarbonImmutable::parse($anchor);
}
private function onTrial(): bool
{
return (bool) $this->subscription()?->onTrial();
}
private function subscription(): ?Subscription
{
return $this->account->subscription(Account::SUBSCRIPTION_NAME);
}
}

View file

@ -25,10 +25,7 @@ public function definition(): array
'name' => fake()->word(),
'stripe_monthly_price_id' => null,
'stripe_yearly_price_id' => null,
'social_account_limit' => fake()->randomElement([3, 10, 25, 100]),
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
'workspace_limit' => fake()->randomElement([1, 3, 10, 50]),
'monthly_credits_limit' => fake()->randomElement([1000, 2000, 5000, 15000]),
'monthly_credits_limit' => 2500,
'sort' => 0,
'is_archived' => false,
];

View file

@ -46,6 +46,7 @@ public function definition(): array
'utm_term' => null,
'utm_content' => null,
'registration_ip' => null,
'persona' => null,
];
}

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plans', function (Blueprint $table) {
$table->dropColumn(['social_account_limit', 'member_limit', 'workspace_limit']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plans', function (Blueprint $table) {
$table->integer('social_account_limit')->default(0);
$table->integer('member_limit')->default(0);
$table->integer('workspace_limit')->default(0);
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('persona')->nullable()->after('registration_ip');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('persona');
});
}
};

View file

@ -15,58 +15,16 @@ class PlanSeeder extends Seeder
*/
public function run(): void
{
$plans = [
Plan::updateOrCreate(
['slug' => Slug::Workspace],
[
'slug' => Slug::Starter,
'name' => 'Starter',
'stripe_monthly_price_id' => env('STRIPE_STARTER_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_STARTER_YEARLY'),
'social_account_limit' => 5,
'member_limit' => 1,
'workspace_limit' => 1,
'monthly_credits_limit' => 1000,
'name' => 'Workspace',
'stripe_monthly_price_id' => env('STRIPE_WORKSPACE_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_WORKSPACE_YEARLY'),
'monthly_credits_limit' => 2500,
'sort' => 1,
'is_archived' => false,
],
[
'slug' => Slug::Plus,
'name' => 'Plus',
'stripe_monthly_price_id' => env('STRIPE_PLUS_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_PLUS_YEARLY'),
'social_account_limit' => 10,
'member_limit' => 5,
'workspace_limit' => 5,
'monthly_credits_limit' => 2000,
'sort' => 2,
],
[
'slug' => Slug::Pro,
'name' => 'Pro',
'stripe_monthly_price_id' => env('STRIPE_PRO_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_PRO_YEARLY'),
'social_account_limit' => 30,
'member_limit' => 15,
'workspace_limit' => 15,
'monthly_credits_limit' => 5000,
'sort' => 3,
],
[
'slug' => Slug::Max,
'name' => 'Max',
'stripe_monthly_price_id' => env('STRIPE_MAX_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_MAX_YEARLY'),
'social_account_limit' => 100,
'member_limit' => 20,
'workspace_limit' => 50,
'monthly_credits_limit' => 15000,
'sort' => 4,
],
];
foreach ($plans as $plan) {
Plan::updateOrCreate(
['slug' => $plan['slug']],
$plan,
);
}
);
}
}

View file

@ -142,6 +142,7 @@
'connected' => 'Account connected!',
'reconnected' => 'Account reconnected!',
'error_connecting' => 'Error connecting account. Please try again.',
'network_taken' => 'This workspace already has an account for this network. Disconnect it first.',
'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.',

View file

@ -9,23 +9,10 @@
'cta' => 'Update payment',
],
'upgrade_dialog' => [
'title' => 'Upgrade your plan',
'description' => 'Pick a plan that fits your needs.',
'current_plan' => 'Current plan',
'current_short' => 'Current',
'current_badge' => 'Current',
'subscribe' => 'Subscribe',
'switch' => 'Switch to this plan',
'switch_short' => 'Switch',
'switch_to_yearly' => 'Switch to yearly',
'switch_to_monthly' => 'Switch to monthly',
'unavailable' => 'Unavailable',
'reasons' => [
'workspace_limit' => 'You\'ve reached the workspace limit on your current plan. Upgrade to create more workspaces.',
'social_account_limit' => 'You\'ve reached the social account limit on your current plan. Upgrade to connect more accounts.',
'member_limit' => 'You\'ve reached the team member limit on your current plan. Upgrade to invite more people.',
],
'annual_banner' => [
'title' => 'Save 2 months with annual billing',
'description' => 'Switch to annual and get 2 months free — pay less every month.',
'cta' => 'Upgrade to annual',
],
'subscribe' => [
@ -47,30 +34,25 @@
'start_trial' => 'Start :days-day free trial',
'subscribe_cta' => 'Subscribe',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
'features' => [
'social_accounts' => ':count social accounts',
'workspaces' => ':count workspaces',
'members' => ':count team members',
'credits' => ':count AI credits/mo',
'per_workspace_credits' => ':count AI credits per workspace/mo',
'one_per_network' => 'One account per social network',
'unlimited_members' => 'Unlimited team members',
'all_platforms' => 'Publish to every supported platform',
],
'credit_tooltips' => [
'starter' => 'Roughly 150 medium-length posts plus 5 AI images per month.',
'plus' => 'Roughly 300 medium-length posts plus 10 AI images per month.',
'pro' => 'Roughly 700 medium-length posts plus 30 AI images per month.',
'max' => 'Roughly 2,000 medium-length posts plus 100 AI images per month.',
'workspace' => 'Each workspace includes 2,500 AI credits per month — about 160 AI images or 280 AI text generations.',
],
],
'plan' => [
'title' => 'Plan',
'description' => 'Manage your subscription plan.',
'change' => 'Change plan',
'label' => 'Plan',
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
'per_workspace' => 'per workspace',
'price' => 'Price',
'month' => 'month',
'trial' => 'Trial',
@ -99,13 +81,10 @@
'flash' => [
'plan_changed' => 'You are now on the :plan plan.',
'switched_to_yearly' => 'You\'re now on annual billing.',
'cannot_manage' => 'Only the account owner can manage billing.',
'cannot_downgrade' => [
'workspaces' => 'Cannot switch to :plan: you have :count workspaces but the plan only allows :limit.',
'social_accounts' => 'Cannot switch to :plan: you have :count social accounts but the plan only allows :limit.',
'members' => 'Cannot switch to :plan: you have :count team members (including invites) but the plan only allows :limit.',
],
'credits_exhausted' => 'Out of AI credits — your monthly :limit allowance has been used. Upgrade your plan or wait until next month.',
'subscription_required' => 'An active subscription is required to use AI features.',
],
'processing' => [

18
lang/en/onboarding.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'title' => 'Welcome to TryPost',
'description' => 'Tell us what best describes you so we can tailor your experience.',
'continue' => 'Continue',
'trial_note' => 'Try it free for 7 days, no commitment. You won\'t be charged during your trial and can cancel anytime.',
'personas' => [
'creator' => 'Creator',
'freelancer' => 'Freelancer',
'startup' => 'Startup',
'agency' => 'Agency',
'small_business' => 'Small business',
'other' => 'Other',
],
];

View file

@ -4,7 +4,7 @@
'title' => 'Usage',
'section_account' => 'Account',
'section_account_description' => 'Quotas and limits for your :plan plan.',
'section_account_description' => 'Quotas and limits for your plan.',
'section_ai' => 'AI Credits',
'section_ai_description' => 'Credits are debited as AI features are used. They reset on the first of every month.',

View file

@ -43,6 +43,7 @@
],
'limit_reached' => 'You have reached your plan limit for workspaces.',
'cannot_delete_last' => 'You cannot delete your only workspace. Cancel your subscription in billing settings to close your account.',
'flash' => [
'deleted' => 'Workspace deleted successfully.',

View file

@ -142,6 +142,7 @@
'connected' => '¡Cuenta conectada!',
'reconnected' => '¡Cuenta reconectada!',
'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.',
'network_taken' => 'Este workspace ya tiene una cuenta para esta red. Desconéctala primero.',
'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.',

View file

@ -9,23 +9,10 @@
'cta' => 'Actualizar pago',
],
'upgrade_dialog' => [
'title' => 'Actualiza tu plan',
'description' => 'Elige un plan que se adapte a tus necesidades.',
'current_plan' => 'Plan actual',
'current_short' => 'Actual',
'current_badge' => 'Actual',
'subscribe' => 'Suscribirse',
'switch' => 'Cambiar a este plan',
'switch_short' => 'Cambiar',
'switch_to_yearly' => 'Cambiar a anual',
'switch_to_monthly' => 'Cambiar a mensual',
'unavailable' => 'No disponible',
'reasons' => [
'workspace_limit' => 'Has alcanzado el límite de workspaces de tu plan. Actualiza para crear más.',
'social_account_limit' => 'Has alcanzado el límite de cuentas sociales de tu plan. Actualiza para conectar más.',
'member_limit' => 'Has alcanzado el límite de miembros de tu plan. Actualiza para invitar a más personas.',
],
'annual_banner' => [
'title' => 'Ahorra 2 meses con la facturación anual',
'description' => 'Cambia al anual y obtén 2 meses gratis — paga menos cada mes.',
'cta' => 'Cambiar a anual',
],
'subscribe' => [
@ -47,30 +34,25 @@
'start_trial' => 'Comenzar prueba de :days días',
'subscribe_cta' => 'Suscribirse',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
'features' => [
'social_accounts' => ':count cuentas sociales',
'workspaces' => ':count workspaces',
'members' => ':count miembros del equipo',
'credits' => ':count créditos IA/mes',
'per_workspace_credits' => ':count créditos de IA por workspace/mes',
'one_per_network' => 'Una cuenta por red social',
'unlimited_members' => 'Miembros del equipo ilimitados',
'all_platforms' => 'Publica en todas las plataformas compatibles',
],
'credit_tooltips' => [
'starter' => 'En promedio 150 posts de largo medio + 5 imágenes IA por mes.',
'plus' => 'En promedio 300 posts de largo medio + 10 imágenes IA por mes.',
'pro' => 'En promedio 700 posts de largo medio + 30 imágenes IA por mes.',
'max' => 'En promedio 2.000 posts de largo medio + 100 imágenes IA por mes.',
'workspace' => 'Cada workspace incluye 2.500 créditos de IA al mes — alrededor de 160 imágenes o 280 textos generados con IA.',
],
],
'plan' => [
'title' => 'Plan',
'description' => 'Gestiona tu plan de suscripción.',
'change' => 'Cambiar plan',
'label' => 'Plan',
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
'per_workspace' => 'por workspace',
'price' => 'Precio',
'month' => 'mes',
'trial' => 'Prueba',
@ -99,13 +81,10 @@
'flash' => [
'plan_changed' => 'Ahora estás en el plan :plan.',
'switched_to_yearly' => 'Ahora tienes facturación anual.',
'cannot_manage' => 'Solo el propietario de la cuenta puede gestionar la facturación.',
'cannot_downgrade' => [
'workspaces' => 'No puedes cambiar a :plan: tienes :count workspaces pero el plan solo permite :limit.',
'social_accounts' => 'No puedes cambiar a :plan: tienes :count cuentas sociales pero el plan solo permite :limit.',
'members' => 'No puedes cambiar a :plan: tienes :count miembros (incluyendo invitaciones) pero el plan solo permite :limit.',
],
'credits_exhausted' => 'Sin créditos de IA — has usado tus :limit créditos mensuales. Mejora tu plan o espera hasta el próximo mes.',
'subscription_required' => 'Se requiere una suscripción activa para usar las funciones de IA.',
],
'processing' => [

18
lang/es/onboarding.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'title' => 'Bienvenido a TryPost',
'description' => 'Cuéntanos qué te describe mejor para personalizar tu experiencia.',
'continue' => 'Continuar',
'trial_note' => 'Pruébalo gratis por 7 días, sin compromiso. No se te cobrará durante la prueba y puedes cancelar cuando quieras.',
'personas' => [
'creator' => 'Creador',
'freelancer' => 'Freelancer',
'startup' => 'Startup',
'agency' => 'Agencia',
'small_business' => 'Pequeña empresa',
'other' => 'Otro',
],
];

View file

@ -4,7 +4,7 @@
'title' => 'Uso',
'section_account' => 'Cuenta',
'section_account_description' => 'Cuotas y límites de tu plan :plan.',
'section_account_description' => 'Cuotas y límites de tu plan.',
'section_ai' => 'Créditos AI',
'section_ai_description' => 'Los créditos se debitan a medida que usas las funciones de AI. Se renuevan el día 1 de cada mes.',

View file

@ -43,6 +43,7 @@
],
'limit_reached' => 'Has alcanzado el límite de workspaces de tu plan.',
'cannot_delete_last' => 'No puedes eliminar tu único workspace. Cancela tu suscripción en la configuración de facturación para cerrar tu cuenta.',
'flash' => [
'deleted' => 'Workspace eliminado correctamente.',

View file

@ -142,6 +142,7 @@
'connected' => 'Conta conectada!',
'reconnected' => 'Conta reconectada!',
'error_connecting' => 'Erro ao conectar conta. Por favor, tente novamente.',
'network_taken' => 'Este workspace já tem uma conta para esta rede. Desconecte-a primeiro.',
'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.',

View file

@ -9,23 +9,10 @@
'cta' => 'Atualizar pagamento',
],
'upgrade_dialog' => [
'title' => 'Faça upgrade do seu plano',
'description' => 'Escolha um plano que se encaixe nas suas necessidades.',
'current_plan' => 'Plano atual',
'current_short' => 'Atual',
'current_badge' => 'Atual',
'subscribe' => 'Assinar',
'switch' => 'Mudar para este plano',
'switch_short' => 'Mudar',
'switch_to_yearly' => 'Mudar para anual',
'switch_to_monthly' => 'Mudar para mensal',
'unavailable' => 'Indisponível',
'reasons' => [
'workspace_limit' => 'Você atingiu o limite de workspaces do seu plano. Faça upgrade pra criar mais.',
'social_account_limit' => 'Você atingiu o limite de contas sociais do seu plano. Faça upgrade pra conectar mais.',
'member_limit' => 'Você atingiu o limite de membros do seu plano. Faça upgrade pra convidar mais pessoas.',
],
'annual_banner' => [
'title' => 'Economize 2 meses com a cobrança anual',
'description' => 'Mude para o anual e ganhe 2 meses grátis — pague menos por mês.',
'cta' => 'Mudar para anual',
],
'subscribe' => [
@ -47,30 +34,25 @@
'start_trial' => 'Iniciar teste de :days dias',
'subscribe_cta' => 'Assinar',
'prices' => [
'starter' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79', 'yearly' => 'R$ 950'],
'plus' => ['monthly' => 'R$ 145', 'yearly_per_month' => 'R$ 121', 'yearly' => 'R$ 1450'],
'pro' => ['monthly' => 'R$ 245', 'yearly_per_month' => 'R$ 204', 'yearly' => 'R$ 2450'],
'max' => ['monthly' => 'R$ 495', 'yearly_per_month' => 'R$ 413', 'yearly' => 'R$ 4950'],
'workspace' => ['monthly' => 'R$ 60', 'yearly_per_month' => 'R$ 50', 'yearly' => 'R$ 600'],
],
'features' => [
'social_accounts' => ':count contas sociais',
'workspaces' => ':count workspaces',
'members' => ':count membros da equipe',
'credits' => ':count créditos IA/mês',
'per_workspace_credits' => ':count créditos de IA por workspace/mês',
'one_per_network' => 'Uma conta por rede social',
'unlimited_members' => 'Membros da equipe ilimitados',
'all_platforms' => 'Publique em todas as plataformas suportadas',
],
'credit_tooltips' => [
'starter' => 'Em média 150 posts de tamanho médio + 5 imagens de IA por mês.',
'plus' => 'Em média 300 posts de tamanho médio + 10 imagens de IA por mês.',
'pro' => 'Em média 700 posts de tamanho médio + 30 imagens de IA por mês.',
'max' => 'Em média 2.000 posts de tamanho médio + 100 imagens de IA por mês.',
'workspace' => 'Cada workspace inclui 2.500 créditos de IA por mês — cerca de 160 imagens ou 280 textos gerados por IA.',
],
],
'plan' => [
'title' => 'Plano',
'description' => 'Gerencie seu plano de assinatura.',
'change' => 'Mudar plano',
'label' => 'Plano',
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
'per_workspace' => 'por workspace',
'price' => 'Preço',
'month' => 'mês',
'trial' => 'Trial',
@ -99,13 +81,10 @@
'flash' => [
'plan_changed' => 'Você está agora no plano :plan.',
'switched_to_yearly' => 'Você está agora na cobrança anual.',
'cannot_manage' => 'Apenas o owner da conta pode gerenciar a cobrança.',
'cannot_downgrade' => [
'workspaces' => 'Não é possível mudar para :plan: você tem :count workspaces mas o plano só permite :limit.',
'social_accounts' => 'Não é possível mudar para :plan: você tem :count contas sociais mas o plano só permite :limit.',
'members' => 'Não é possível mudar para :plan: você tem :count membros (incluindo convites) mas o plano só permite :limit.',
],
'credits_exhausted' => 'Sem créditos de IA — você usou seus :limit créditos mensais. Faça upgrade do plano ou aguarde até o próximo mês.',
'subscription_required' => 'É necessária uma assinatura ativa para usar os recursos de IA.',
],
'processing' => [

18
lang/pt-BR/onboarding.php Normal file
View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'title' => 'Bem-vindo ao TryPost',
'description' => 'Conte o que melhor te descreve para personalizarmos sua experiência.',
'continue' => 'Continuar',
'trial_note' => 'Experimente grátis por 7 dias, sem compromisso. Você não será cobrado durante o teste e pode cancelar quando quiser.',
'personas' => [
'creator' => 'Criador',
'freelancer' => 'Freelancer',
'startup' => 'Startup',
'agency' => 'Agência',
'small_business' => 'Pequena empresa',
'other' => 'Outro',
],
];

View file

@ -4,7 +4,7 @@
'title' => 'Uso',
'section_account' => 'Conta',
'section_account_description' => 'Cotas e limites do seu plano :plan.',
'section_account_description' => 'Cotas e limites do seu plano.',
'section_ai' => 'Créditos AI',
'section_ai_description' => 'Os créditos são debitados conforme você usa os recursos de AI. Eles são renovados no dia 1 de cada mês.',

View file

@ -43,6 +43,7 @@
],
'limit_reached' => 'Você atingiu o limite de workspaces do seu plano.',
'cannot_delete_last' => 'Você não pode excluir seu único workspace. Cancele sua assinatura nas configurações de cobrança para encerrar sua conta.',
'flash' => [
'deleted' => 'Workspace excluído com sucesso.',

View file

@ -43,12 +43,10 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app';
import { portal } from '@/routes/app/billing';
import { index as assets } from '@/routes/app/assets';
import { index as automations } from '@/routes/app/automations';
import { portal } from '@/routes/app/billing';
import { index as labels } from '@/routes/app/labels';
import { index as signatures } from '@/routes/app/signatures';
import { create as createWorkspaceRoute, switchMethod } from '@/routes/app/workspaces';
@ -137,15 +135,9 @@ const switchWorkspace = (workspaceId: string) => {
});
};
const { canCreateWorkspace } = useFeatureAccess();
const { openUpgrade } = useUpgradeDialog();
const { urlIsActive } = useActiveUrl();
const handleCreateWorkspace = () => {
if (!canCreateWorkspace.value) {
openUpgrade(trans('billing.upgrade_dialog.reasons.workspace_limit'));
return;
}
router.visit(createWorkspaceRoute.url());
};
</script>

View file

@ -1,319 +0,0 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { IconCheck, IconInfoCircle, IconLoader2 } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
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';
interface Plan {
id: string;
slug: PlanSlug;
name: string;
stripe_monthly_price_id: string | null;
stripe_yearly_price_id: string | null;
social_account_limit: number;
member_limit: number;
workspace_limit: number;
monthly_credits_limit: number;
}
interface Highlight {
text: string;
tooltip?: string;
}
const { open, reason, closeUpgrade } = useUpgradeDialog();
const { usage } = useFeatureAccess();
const page = usePage();
const plans = computed<Plan[]>(() => (page.props.plans as Plan[]) ?? []);
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(() => currentPlan.value?.interval === 'yearly');
const isYearly = ref(true);
const processing = ref<string | null>(null);
watch(open, (isOpen) => {
if (isOpen) {
isYearly.value = true;
}
});
// Mirrors Subscribe.vue: headline price = the per-month figure regardless
// of toggle. When yearly is selected, we read the pre-computed per-month
// rate so the price stays comparable visually.
const getDisplayPrice = (plan: Plan): string => {
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly_per_month' : 'monthly'}`;
return trans(key);
};
const isSamePlan = (plan: Plan): boolean => currentPlan.value?.id === plan.id;
const isCurrent = (plan: Plan): boolean => {
if (!isSamePlan(plan)) return false;
return isYearly.value === isOnYearly.value;
};
const isPopular = (plan: Plan): boolean => plan.slug === 'pro';
const inheritsFrom: Partial<Record<PlanSlug, PlanSlug>> = {
plus: 'starter',
pro: 'plus',
max: 'pro',
};
const planNameBySlug = (list: Plan[], slug: PlanSlug): string =>
list.find((plan) => plan.slug === slug)?.name ?? slug;
const planTones: Record<PlanSlug, string> = {
starter: 'bg-amber-50',
plus: 'bg-rose-50',
pro: 'bg-violet-100',
max: 'bg-emerald-50',
};
const highlights = (plan: Plan): Highlight[] => [
{ text: trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }) },
{
text: trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
tooltip: trans(`billing.subscribe.credit_tooltips.${plan.slug}`),
},
{ text: trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }) },
{ text: trans('billing.subscribe.features.members', { count: String(plan.member_limit) }) },
];
const downgradeBlocker = (plan: Plan): string | null => {
if (!usage.value) return null;
if (usage.value.workspaceCount > plan.workspace_limit) {
return trans('billing.flash.cannot_downgrade.workspaces', {
plan: plan.name,
count: String(usage.value.workspaceCount),
limit: String(plan.workspace_limit),
});
}
if (usage.value.socialAccountCount > plan.social_account_limit) {
return trans('billing.flash.cannot_downgrade.social_accounts', {
plan: plan.name,
count: String(usage.value.socialAccountCount),
limit: String(plan.social_account_limit),
});
}
const totalMembers = usage.value.memberCount + usage.value.pendingInviteCount;
if (totalMembers > plan.member_limit) {
return trans('billing.flash.cannot_downgrade.members', {
plan: plan.name,
count: String(totalMembers),
limit: String(plan.member_limit),
});
}
return null;
};
const isBlocked = (plan: Plan): boolean => !isCurrent(plan) && downgradeBlocker(plan) !== null;
const ctaLabel = (plan: Plan): string => {
if (isCurrent(plan)) return trans('billing.upgrade_dialog.current_plan');
if (isBlocked(plan)) return trans('billing.upgrade_dialog.unavailable');
if (isSamePlan(plan)) {
return isYearly.value
? trans('billing.upgrade_dialog.switch_to_yearly')
: trans('billing.upgrade_dialog.switch_to_monthly');
}
if (hasActiveSubscription.value) return trans('billing.upgrade_dialog.switch');
return trans('billing.upgrade_dialog.subscribe');
};
const handleSelect = (plan: Plan) => {
if (isCurrent(plan)) return;
processing.value = plan.id;
const priceId = isYearly.value ? plan.stripe_yearly_price_id : plan.stripe_monthly_price_id;
const url = hasActiveSubscription.value ? swap.url(plan.id) : checkout.url(plan.id);
router.post(url, { price_id: priceId }, {
onSuccess: () => {
closeUpgrade();
},
onFinish: () => {
processing.value = null;
},
});
};
const onOpenChange = (value: boolean) => {
if (!value) closeUpgrade();
};
</script>
<template>
<Dialog :open="open" @update:open="onOpenChange">
<DialogContent class="w-[95vw] gap-0 p-0 sm:max-w-6xl">
<TooltipProvider :delay-duration="150">
<div class="px-6 pt-6 text-center">
<DialogTitle
class="text-3xl font-semibold leading-tight"
style="font-family: var(--font-display)"
>
{{ $t('billing.upgrade_dialog.title') }}
</DialogTitle>
<DialogDescription class="mx-auto mt-2 max-w-2xl text-balance text-base text-foreground/70">
{{ reason ?? $t('billing.upgrade_dialog.description') }}
</DialogDescription>
</div>
<!-- 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"
role="tablist"
>
<button
type="button"
role="tab"
:aria-selected="!isYearly"
:class="[
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
!isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
]"
@click="isYearly = false"
>
{{ $t('billing.subscribe.monthly') }}
</button>
<button
type="button"
role="tab"
:aria-selected="isYearly"
:class="[
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
]"
@click="isYearly = true"
>
{{ $t('billing.subscribe.yearly') }}
</button>
</div>
<span
:class="[
'pointer-events-none absolute top-1/2 left-full ml-3 -translate-y-1/2 -rotate-3 inline-flex items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-amber-200 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest text-foreground shadow-xs transition-all',
isYearly ? 'opacity-100' : 'opacity-30 grayscale',
]"
>
<span>←</span>
{{ $t('billing.subscribe.save_months') }}
</span>
</div>
</div>
<!-- Plan cards -->
<div class="grid grid-cols-1 gap-5 px-6 pt-8 pb-6 md:grid-cols-2 xl:grid-cols-4">
<div
v-for="plan in plans"
:key="plan.id"
:class="[
planTones[plan.slug],
'relative flex flex-col rounded-2xl border-2 border-foreground p-6 transition-shadow',
isPopular(plan) ? 'shadow-xl' : 'shadow-sm hover:shadow-md',
]"
>
<span
v-if="isPopular(plan)"
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-foreground px-3 py-1 text-[11px] font-black uppercase tracking-widest text-background shadow-2xs"
>
⭐ {{ $t('billing.subscribe.popular') }}
</span>
<span
v-else-if="isCurrent(plan)"
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center whitespace-nowrap rounded-md border-2 border-foreground bg-card px-3 py-1 text-[11px] font-black uppercase tracking-widest text-foreground shadow-2xs"
>
{{ $t('billing.upgrade_dialog.current_badge') }}
</span>
<div class="mb-4">
<h3 class="text-xl font-bold tracking-tight text-foreground">
{{ plan.name }}
</h3>
</div>
<div class="mb-2">
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black tabular-nums text-foreground">
{{ getDisplayPrice(plan) }}
</span>
<span class="text-sm font-semibold text-foreground/60">
{{ $t('billing.subscribe.per_month') }}
</span>
</div>
<p class="mt-1 text-xs leading-tight text-foreground/60">
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
</p>
</div>
<button
type="button"
:disabled="processing !== null || isCurrent(plan) || isBlocked(plan)"
:title="downgradeBlocker(plan) ?? undefined"
:class="[
'my-5 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border-2 border-foreground px-4 py-2.5 text-sm font-semibold shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60',
isPopular(plan) ? 'bg-foreground text-background' : 'bg-card text-foreground',
]"
@click="handleSelect(plan)"
>
<IconLoader2 v-if="processing === plan.id" class="size-4 animate-spin" />
{{ ctaLabel(plan) }}
</button>
<div class="flex flex-col gap-3">
<p class="text-sm font-bold leading-snug text-foreground">
<template v-if="inheritsFrom[plan.slug]">
{{ trans('billing.subscribe.everything_in', { plan: planNameBySlug(plans, inheritsFrom[plan.slug]!) }) }}
</template>
<template v-else>
{{ $t('billing.subscribe.features_included') }}
</template>
</p>
<div
v-for="highlight in highlights(plan)"
:key="highlight.text"
class="flex items-start gap-2.5"
>
<div class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-foreground bg-card shadow-2xs">
<IconCheck class="size-3 text-foreground" stroke-width="3" />
</div>
<span class="inline-flex items-center gap-1.5 text-sm leading-snug text-foreground/80">
{{ highlight.text }}
<Tooltip v-if="highlight.tooltip">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex items-center justify-center text-foreground/40 transition-colors hover:text-foreground"
:aria-label="highlight.tooltip"
>
<IconInfoCircle class="size-3.5" stroke-width="2" />
</button>
</TooltipTrigger>
<TooltipContent class="max-w-xs text-sm leading-snug">
{{ highlight.tooltip }}
</TooltipContent>
</Tooltip>
</span>
</div>
</div>
</div>
</div>
</TooltipProvider>
</DialogContent>
</Dialog>
</template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconClock, IconDots, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
@ -23,11 +22,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { WorkspaceRole } from '@/types/workspace-role';
import { destroy as destroyInvite } from '@/routes/app/invites';
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
import { WorkspaceRole } from '@/types/workspace-role';
interface Member {
id: string;
@ -51,14 +48,7 @@ const inviteDialogOpen = ref(false);
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const { canInviteMember } = useFeatureAccess();
const { openUpgrade } = useUpgradeDialog();
const handleInviteClick = () => {
if (!canInviteMember.value) {
openUpgrade(trans('billing.upgrade_dialog.reasons.member_limit'));
return;
}
inviteDialogOpen.value = true;
};

View file

@ -10,9 +10,6 @@ interface Plan {
}
interface Features {
workspaceLimit: number;
socialAccountLimit: number;
memberLimit: number;
monthlyCreditsLimit: number;
}
@ -24,28 +21,11 @@ export const useFeatureAccess = () => {
const usage = computed<Usage | null>(() => (page.props.usage as Usage | null) ?? null);
const features = computed<Features | null>(() => (page.props.features as Features | null) ?? null);
const workspaceLimit = computed(() => features.value?.workspaceLimit ?? 1);
const socialAccountLimit = computed(() => features.value?.socialAccountLimit ?? 1);
const memberLimit = computed(() => features.value?.memberLimit ?? 1);
const monthlyCreditsLimit = computed(() => features.value?.monthlyCreditsLimit ?? 0);
const canCreateWorkspace = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.workspaceCount < workspaceLimit.value;
});
const canConnectSocialAccount = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.socialAccountCount < socialAccountLimit.value;
});
const canInviteMember = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.memberCount + usage.value.pendingInviteCount < memberLimit.value;
});
const canCreateWorkspace = computed(() => true);
const canConnectSocialAccount = computed(() => true);
const canInviteMember = computed(() => true);
const hasCreditsLeft = computed(() => {
if (isSelfHosted.value) return true;
@ -58,9 +38,6 @@ export const useFeatureAccess = () => {
usage,
features,
isSelfHosted,
workspaceLimit,
socialAccountLimit,
memberLimit,
monthlyCreditsLimit,
canCreateWorkspace,
canConnectSocialAccount,

View file

@ -1,17 +0,0 @@
import { ref } from 'vue';
const open = ref(false);
const reason = ref<string | null>(null);
export const useUpgradeDialog = () => ({
open,
reason,
openUpgrade: (cause?: string) => {
reason.value = cause ?? null;
open.value = true;
},
closeUpgrade: () => {
open.value = false;
reason.value = null;
},
});

View file

@ -4,10 +4,8 @@ import { onBeforeUnmount, onMounted } from 'vue';
import AppHeader from '@/components/AppHeader.vue';
import AppSidebar from '@/components/AppSidebar.vue';
import DiscordWidget from '@/components/DiscordWidget.vue';
import Toast from '@/components/Toast.vue';
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import UpgradeDialog from '@/components/UpgradeDialog.vue';
import { heartbeat as heartbeatRoute } from '@/routes/app/presence';
const page = usePage();
@ -75,7 +73,5 @@ onBeforeUnmount(() => {
</div>
</SidebarInset>
</SidebarProvider>
<UpgradeDialog />
<Toast />
<DiscordWidget />
</template>

View file

@ -9,7 +9,6 @@ import {
IconSearch,
IconTrash,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Auth/SocialController';
@ -38,9 +37,7 @@ import {
TableLoadMore,
TableRow,
} from '@/components/ui/table';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import date from '@/date';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
@ -79,14 +76,7 @@ const isAddDialogOpen = ref(props.openDialog);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const searchQuery = ref(props.filters.search);
const { canConnectSocialAccount } = useFeatureAccess();
const { openUpgrade } = useUpgradeDialog();
const handleAddClick = () => {
if (!canConnectSocialAccount.value) {
openUpgrade(trans('billing.upgrade_dialog.reasons.social_account_limit'));
return;
}
isAddDialogOpen.value = true;
};

View file

@ -1,287 +0,0 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { IconArrowRight, IconCheck, IconInfoCircle } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useTracking } from '@/composables/useTracking';
import { checkout } from '@/routes/app/billing';
type PlanSlug = 'starter' | 'plus' | 'pro' | 'max';
interface Plan {
id: string;
slug: PlanSlug;
name: string;
stripe_monthly_price_id: string | null;
stripe_yearly_price_id: string | null;
social_account_limit: number;
member_limit: number;
workspace_limit: number;
monthly_credits_limit: number;
}
interface Highlight {
text: string;
tooltip?: string;
}
const { plans, trialDays } = defineProps<{
plans: Plan[];
trialDays: number | null;
}>();
const isYearly = ref(true);
const processing = ref<string | null>(null);
const { trackBeginCheckout } = useTracking();
// Headline price = the per-month figure, regardless of toggle. When
// yearly is selected we read the pre-computed per-month rate from
// `lang/*/billing.php` (already with the right currency) so the price
// stays comparable visually across toggles.
const getDisplayPrice = (plan: Plan): string =>
trans(`billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly_per_month' : 'monthly'}`);
const selectPlan = (plan: Plan) => {
processing.value = plan.id;
const interval = isYearly.value ? 'yearly' : 'monthly';
const priceId = isYearly.value ? plan.stripe_yearly_price_id : plan.stripe_monthly_price_id;
trackBeginCheckout({
name: plan.name,
interval,
});
router.post(checkout.url(plan.id), {
price_id: priceId,
});
};
// Mirrors the marketing site: each tier shows only the deltas vs the
// previous one, plus a header telling the reader the lower tier is
// already included. Starter shows the full "What's included" list.
const inheritsFrom: Partial<Record<PlanSlug, PlanSlug>> = {
plus: 'starter',
pro: 'plus',
max: 'pro',
};
const planNameBySlug = (plans: Plan[], slug: PlanSlug): string =>
plans.find((plan) => plan.slug === slug)?.name ?? slug;
const highlights = (plan: Plan): Highlight[] => [
{ text: trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }) },
{
text: trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
tooltip: trans(`billing.subscribe.credit_tooltips.${plan.slug}`),
},
{ text: trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }) },
{ text: trans('billing.subscribe.features.members', { count: String(plan.member_limit) }) },
];
const isPopular = (plan: Plan): boolean => plan.slug === 'pro';
const planTones: Record<PlanSlug, string> = {
starter: 'bg-amber-50',
plus: 'bg-rose-50',
pro: 'bg-violet-100',
max: 'bg-emerald-50',
};
</script>
<template>
<Head :title="$t('billing.subscribe.page_title')" />
<TooltipProvider :delay-duration="150">
<section class="relative min-h-screen overflow-hidden bg-background">
<!-- Dot pattern overlay (subtle ink dots) -->
<div
class="pointer-events-none absolute inset-0 opacity-[0.06]"
style="background-image: radial-gradient(circle, #0a0a0a 1px, transparent 1px); background-size: 28px 28px;"
/>
<!-- Soft violet glow blobs -->
<div class="pointer-events-none absolute -top-20 right-0 size-[560px] rounded-full bg-violet-200/50 blur-3xl" />
<div class="pointer-events-none absolute top-1/3 -left-32 size-[440px] rounded-full bg-fuchsia-200/30 blur-3xl" />
<!-- Hero -->
<div class="relative mx-auto max-w-7xl px-6 pt-14 pb-4 lg:pt-20">
<div class="mx-auto max-w-3xl space-y-4 text-center">
<span class="-rotate-1 inline-block rounded-md border-2 border-foreground bg-violet-200 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-foreground shadow-2xs">
{{ $t('billing.subscribe.eyebrow') }}
</span>
<h1
class="text-balance text-3xl font-normal leading-[1.1] tracking-tight text-foreground sm:text-5xl lg:text-6xl"
style="font-family: var(--font-display);"
>
{{ $t('billing.subscribe.title') }}
</h1>
<p class="mx-auto max-w-2xl text-balance text-base text-muted-foreground sm:text-lg">
{{ $t('billing.subscribe.description') }}
</p>
<p v-if="trialDays !== null" class="text-sm font-semibold text-foreground/70">
{{ trans('billing.subscribe.trial_info', { days: String(trialDays) }) }}
</p>
</div>
</div>
<!-- Plans -->
<div class="relative mx-auto max-w-7xl px-4 pb-20">
<!-- Billing toggle pill -->
<div class="flex justify-center pt-8 pb-10">
<div class="relative">
<div
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground bg-card p-1 shadow-xs"
role="tablist"
>
<button
type="button"
role="tab"
:aria-selected="!isYearly"
:class="[
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
!isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
]"
@click="isYearly = false"
>
{{ $t('billing.subscribe.monthly') }}
</button>
<button
type="button"
role="tab"
:aria-selected="isYearly"
:class="[
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
]"
@click="isYearly = true"
>
{{ $t('billing.subscribe.yearly') }}
</button>
</div>
<span
:class="[
'pointer-events-none absolute -top-6 -right-12 inline-flex items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-amber-200 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest text-foreground shadow-xs transition-all sm:top-1/2 sm:right-auto sm:left-full sm:ml-3 sm:-translate-y-1/2 rotate-6 sm:-rotate-3',
isYearly ? 'opacity-100' : 'opacity-30 grayscale',
]"
>
<span class="hidden sm:inline">←</span>
{{ $t('billing.subscribe.save_months') }}
</span>
</div>
</div>
<!-- Plan cards -->
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
<div
v-for="plan in plans"
:key="plan.id"
:class="[
planTones[plan.slug],
'relative flex flex-col rounded-2xl border-2 border-foreground p-7 transition-shadow sm:p-8',
isPopular(plan) ? 'shadow-xl' : 'shadow-sm hover:shadow-md',
]"
>
<span
v-if="isPopular(plan)"
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-foreground px-3 py-1 text-[11px] font-black uppercase tracking-widest text-background shadow-2xs"
>
⭐ {{ $t('billing.subscribe.popular') }}
</span>
<div class="mb-5">
<h3 class="text-2xl font-bold tracking-tight text-foreground">
{{ plan.name }}
</h3>
</div>
<div class="mb-2">
<div class="flex items-baseline gap-1">
<span class="relative inline-block overflow-hidden leading-none">
<Transition
enter-active-class="motion-safe:transition-transform motion-safe:duration-[350ms] motion-safe:ease-out"
leave-active-class="motion-safe:transition-transform motion-safe:duration-[350ms] motion-safe:ease-out absolute top-0 left-0"
enter-from-class="motion-safe:translate-y-full"
leave-to-class="motion-safe:-translate-y-full"
>
<span :key="getDisplayPrice(plan)" class="block text-5xl font-black tabular-nums text-foreground">
{{ getDisplayPrice(plan) }}
</span>
</Transition>
</span>
<span class="text-sm font-semibold text-foreground/60">
{{ $t('billing.subscribe.per_month') }}
</span>
</div>
<p class="mt-1 text-xs leading-tight text-foreground/60">
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
</p>
</div>
<button
type="button"
:disabled="processing !== null"
:class="[
'my-5 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border-2 border-foreground px-4 py-2.5 text-sm font-semibold shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60',
isPopular(plan) ? 'bg-foreground text-background' : 'bg-card text-foreground',
]"
@click="selectPlan(plan)"
>
<template v-if="trialDays !== null">
{{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }}
</template>
<template v-else>
{{ $t('billing.subscribe.subscribe_cta') }}
</template>
<IconArrowRight class="size-4" />
</button>
<div class="flex flex-col gap-3">
<p class="text-sm font-bold leading-snug text-foreground">
<template v-if="inheritsFrom[plan.slug]">
{{ trans('billing.subscribe.everything_in', { plan: planNameBySlug(plans, inheritsFrom[plan.slug]!) }) }}
</template>
<template v-else>
{{ $t('billing.subscribe.features_included') }}
</template>
</p>
<div
v-for="highlight in highlights(plan)"
:key="highlight.text"
class="flex items-start gap-2.5"
>
<div
class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-foreground bg-card shadow-2xs"
>
<IconCheck class="size-3 text-foreground" stroke-width="3" />
</div>
<span class="inline-flex items-center gap-1.5 text-sm leading-snug text-foreground/80">
{{ highlight.text }}
<Tooltip v-if="highlight.tooltip">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex items-center justify-center text-foreground/40 transition-colors hover:text-foreground"
:aria-label="highlight.tooltip"
>
<IconInfoCircle class="size-3.5" stroke-width="2" />
</button>
</TooltipTrigger>
<TooltipContent class="max-w-xs text-sm leading-snug">
{{ highlight.tooltip }}
</TooltipContent>
</Tooltip>
</span>
</div>
</div>
</div>
</div>
</div>
</section>
</TooltipProvider>
</template>

View file

@ -0,0 +1,119 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import {
IconArrowRight,
IconBriefcase,
IconBuildingSkyscraper,
IconBuildingStore,
IconCheck,
IconDots,
IconRocket,
IconUser,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import type { FunctionalComponent } from 'vue';
import { store } from '@/routes/app/onboarding';
interface Persona {
value: string;
label: string;
}
const { selected } = defineProps<{
personas: Persona[];
selected?: string | null;
}>();
const form = useForm({ persona: selected ?? '' });
const icons: Record<string, FunctionalComponent> = {
creator: IconUser,
freelancer: IconBriefcase,
startup: IconRocket,
agency: IconBuildingSkyscraper,
small_business: IconBuildingStore,
other: IconDots,
};
const personaLabel = (value: string): string => trans(`onboarding.personas.${value}`);
const select = (value: string): void => {
form.persona = value;
};
const submit = (): void => {
if (!form.persona || form.processing) {
return;
}
form.post(store.url());
};
</script>
<template>
<Head :title="$t('onboarding.title')" />
<section class="relative min-h-screen overflow-hidden bg-background">
<div
class="pointer-events-none absolute inset-0 opacity-[0.06]"
style="background-image: radial-gradient(circle, #0a0a0a 1px, transparent 1px); background-size: 28px 28px;"
/>
<div class="pointer-events-none absolute -top-20 right-0 size-[560px] rounded-full bg-violet-200/50 blur-3xl" />
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col justify-center px-6 py-12">
<div class="mx-auto mb-10 max-w-xl space-y-3 text-center">
<h1
class="text-balance text-3xl font-normal leading-[1.1] tracking-tight text-foreground sm:text-4xl"
style="font-family: var(--font-display);"
>
{{ $t('onboarding.title') }}
</h1>
<p class="text-balance text-base text-muted-foreground">
{{ $t('onboarding.description') }}
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="persona in personas"
:key="persona.value"
type="button"
:class="[
'relative flex cursor-pointer flex-col items-start gap-3 rounded-2xl border-2 border-foreground p-5 text-left shadow-2xs transition-shadow hover:shadow-md',
form.persona === persona.value ? 'bg-violet-100' : 'bg-card',
]"
@click="select(persona.value)"
>
<span class="inline-flex size-10 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
<component :is="icons[persona.value] ?? IconDots" class="size-5 text-foreground" stroke-width="2" />
</span>
<span class="text-base font-bold tracking-tight text-foreground">
{{ personaLabel(persona.value) }}
</span>
<span
v-if="form.persona === persona.value"
class="absolute right-4 top-4 inline-flex size-5 items-center justify-center rounded-full border-2 border-foreground bg-foreground"
>
<IconCheck class="size-3 text-background" stroke-width="3" />
</span>
</button>
</div>
<div class="mx-auto mt-10 flex w-full max-w-sm flex-col items-center gap-3">
<button
type="button"
:disabled="!form.persona || form.processing"
class="inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-full border-2 border-foreground bg-foreground px-4 py-3 text-sm font-semibold text-background shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60"
@click="submit"
>
{{ $t('onboarding.continue') }}
<IconArrowRight class="size-4" />
</button>
<p class="text-center text-xs text-foreground/60">
{{ $t('onboarding.trial_note') }}
</p>
</div>
</div>
</section>
</template>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Head, usePage } from '@inertiajs/vue3';
import { IconCreditCard, IconDownload, IconFileText, IconSparkles } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { Head, useForm, usePage } from '@inertiajs/vue3';
import { IconCreditCard, IconDownload, IconFileText, IconRosetteDiscountCheck, IconSparkles } from '@tabler/icons-vue';
import { trans, transChoice } from 'laravel-vue-i18n';
import { computed } from 'vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
@ -9,11 +9,10 @@ import PageHeader from '@/components/PageHeader.vue';
import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as accountEdit } from '@/routes/app/account';
import { index as billingIndex, portal } from '@/routes/app/billing';
import { index as billingIndex, portal, swapToYearly } from '@/routes/app/billing';
import { index as usageIndex } from '@/routes/app/usage';
import type { AuthPlan } from '@/types';
@ -42,13 +41,13 @@ interface Invoice {
invoice_pdf: string;
}
defineProps<{
const props = defineProps<{
hasSubscription: boolean;
onTrial: boolean;
trialEndsAt: string | null;
subscription: Subscription | null;
plan: Plan | null;
plans: Plan[];
workspaceCount: number;
invoices: Invoice[];
defaultPaymentMethod: PaymentMethod | null;
}>();
@ -69,7 +68,18 @@ const displayPrice = (slug: string | undefined): string => {
return trans(`billing.subscribe.prices.${slug}.${key}`);
};
const { openUpgrade } = useUpgradeDialog();
const workspacesLabel = computed(() => transChoice('billing.plan.workspaces', props.workspaceCount, { count: props.workspaceCount }));
const monthlyPrice = computed(() => (props.plan ? trans(`billing.subscribe.prices.${props.plan.slug}.monthly`) : ''));
const yearlyPerMonthPrice = computed(() => (props.plan ? trans(`billing.subscribe.prices.${props.plan.slug}.yearly_per_month`) : ''));
const showAnnualBanner = computed(() => props.hasSubscription && ! isYearly.value);
const upgradeForm = useForm({});
const upgradeToAnnual = (): void => {
upgradeForm.post(swapToYearly.url(), { preserveScroll: true });
};
</script>
<template>
@ -85,6 +95,32 @@ const { openUpgrade } = useUpgradeDialog();
<SettingsTabsNav :tabs="tabs" active="billing" />
<section class="space-y-12">
<!-- ───── Annual upgrade banner ───── -->
<div
v-if="showAnnualBanner"
class="flex flex-wrap items-center gap-4 rounded-2xl border-2 border-foreground bg-emerald-100 p-5 shadow-2xs"
>
<span class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-emerald-300 shadow-2xs">
<IconRosetteDiscountCheck class="size-6 text-foreground" stroke-width="2" />
</span>
<div class="min-w-0 flex-1">
<p class="text-base font-bold text-foreground">
{{ $t('billing.annual_banner.title') }}
</p>
<p class="text-sm text-foreground/70">
{{ $t('billing.annual_banner.description') }}
</p>
<p class="mt-1 text-sm font-semibold text-foreground">
<span class="text-foreground/50 line-through tabular-nums">{{ monthlyPrice }}</span>
<span class="ml-1.5 tabular-nums">{{ yearlyPerMonthPrice }}</span>
<span class="font-medium text-foreground/60">/{{ $t('billing.plan.month') }} · {{ $t('billing.subscribe.billed_yearly') }}</span>
</p>
</div>
<Button class="shrink-0" :disabled="upgradeForm.processing" @click="upgradeToAnnual">
{{ $t('billing.annual_banner.cta') }}
</Button>
</div>
<!-- ───── Current plan hero card ───── -->
<div class="space-y-6">
<HeadingSmall
@ -103,7 +139,7 @@ const { openUpgrade } = useUpgradeDialog();
class="text-3xl font-semibold leading-tight text-foreground"
style="font-family: var(--font-display)"
>
{{ plan?.name ?? 'No plan' }}
{{ workspacesLabel }}
</h3>
<Badge v-if="onTrial" variant="secondary">{{ $t('billing.plan.trial') }}</Badge>
<Badge v-else-if="subscription?.stripe_status === 'active'" variant="success">{{ $t('billing.plan.active') }}</Badge>
@ -112,7 +148,7 @@ const { openUpgrade } = useUpgradeDialog();
</div>
<p class="text-base text-foreground/70">
<span class="text-2xl font-bold tabular-nums text-foreground">{{ displayPrice(plan?.slug) }}</span>
<span class="ml-1">/{{ $t('billing.plan.month') }}</span>
<span class="ml-1">/{{ $t('billing.plan.month') }} {{ $t('billing.plan.per_workspace') }}</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') }}
@ -124,13 +160,10 @@ const { openUpgrade } = useUpgradeDialog();
{{ $t('billing.plan.trial_ends') }}: <span class="text-foreground">{{ date.formatDate(trialEndsAt) }}</span>
</p>
</div>
<div class="flex flex-col items-end gap-4 shrink-0">
<div class="flex shrink-0 flex-col items-end gap-4">
<span class="inline-flex size-14 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-amber-200 shadow-2xs">
<IconSparkles class="size-7 text-foreground" stroke-width="2" />
</span>
<Button @click="openUpgrade()">
{{ $t('billing.plan.change') }}
</Button>
</div>
</div>
</div>

View file

@ -25,11 +25,8 @@ interface Plan {
interface UsageData {
workspaceCount: number;
workspaceLimit: number;
socialAccountCount: number;
socialAccountLimit: number;
memberCount: number;
memberLimit: number;
creditsUsed: number;
monthlyCreditsLimit: number;
}
@ -62,7 +59,7 @@ const tabs = computed(() => [
<div class="space-y-6">
<HeadingSmall
:title="$t('usage.section_account')"
:description="$t('usage.section_account_description', { plan: plan?.name ?? 'Free' })"
:description="$t('usage.section_account_description')"
/>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@ -70,7 +67,6 @@ const tabs = computed(() => [
:label="$t('usage.workspaces')"
:icon="IconBuildingCommunity"
:current="usage.workspaceCount"
:limit="usage.workspaceLimit"
tone="violet"
rotate="-rotate-2"
/>
@ -79,7 +75,6 @@ const tabs = computed(() => [
:label="$t('usage.social_accounts')"
:icon="IconAffiliate"
:current="usage.socialAccountCount"
:limit="usage.socialAccountLimit"
tone="amber"
rotate="rotate-1"
/>
@ -88,7 +83,6 @@ const tabs = computed(() => [
:label="$t('usage.members')"
:icon="IconUsers"
:current="usage.memberCount"
:limit="usage.memberLimit"
tone="emerald"
rotate="-rotate-1"
/>

View file

@ -10,6 +10,7 @@
use App\Http\Controllers\App\DiscordController as AppDiscordController;
use App\Http\Controllers\App\GiphyController;
use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\OnboardingController;
use App\Http\Controllers\App\PostAiCreateController;
use App\Http\Controllers\App\PostAiGenerateController;
use App\Http\Controllers\App\PostAiRegenerateMediaController;
@ -55,6 +56,8 @@
})->name('app.home');
Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe');
Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding');
Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store');
Route::post('billing/checkout/{plan}', [BillingController::class, 'checkout'])->name('app.billing.checkout');
Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing');
@ -254,7 +257,7 @@
// Billing
Route::get('settings/account/billing', [BillingController::class, 'index'])->name('app.billing.index');
Route::get('settings/account/billing/portal', [BillingController::class, 'portal'])->name('app.billing.portal');
Route::post('settings/account/billing/swap/{plan}', [BillingController::class, 'swap'])->name('app.billing.swap');
Route::post('settings/account/billing/swap-to-yearly', [BillingController::class, 'swapToYearly'])->name('app.billing.swap-to-yearly');
});

View file

@ -5,43 +5,59 @@
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Workspace;
use Illuminate\Support\Carbon;
test('monthly credits returns sum of credits for account this month', function () {
test('credits used between sums credits within the window', function () {
$account = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
AiUsageLog::factory()->text(credits: 5)->count(3)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
'created_at' => Carbon::parse('2026-06-10'),
]);
AiUsageLog::factory()->text(credits: 10)->count(2)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
'created_at' => Carbon::parse('2026-06-12'),
]);
expect(AiUsageLog::monthlyCredits($account->id))->toBe(35);
$used = AiUsageLog::creditsUsedBetween(
$account->id,
Carbon::parse('2026-06-01'),
Carbon::parse('2026-07-01'),
);
expect($used)->toBe(35);
});
test('monthly credits excludes logs from other months', function () {
test('credits used between excludes logs outside the window', function () {
$account = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
AiUsageLog::factory()->text(credits: 10)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
'created_at' => Carbon::parse('2026-06-10'),
]);
AiUsageLog::factory()->text(credits: 50)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
'created_at' => now()->subMonth(),
'created_at' => Carbon::parse('2026-05-10'),
]);
expect(AiUsageLog::monthlyCredits($account->id))->toBe(10);
$used = AiUsageLog::creditsUsedBetween(
$account->id,
Carbon::parse('2026-06-01'),
Carbon::parse('2026-07-01'),
);
expect($used)->toBe(10);
});
test('monthly credits excludes logs from other accounts', function () {
test('credits used between excludes logs from other accounts', function () {
$account = Account::factory()->create();
$otherAccount = Account::factory()->create();
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
@ -50,12 +66,20 @@
AiUsageLog::factory()->text(credits: 10)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
'created_at' => Carbon::parse('2026-06-10'),
]);
AiUsageLog::factory()->text(credits: 100)->create([
'account_id' => $otherAccount->id,
'workspace_id' => $otherWorkspace->id,
'created_at' => Carbon::parse('2026-06-10'),
]);
expect(AiUsageLog::monthlyCredits($account->id))->toBe(10);
$used = AiUsageLog::creditsUsedBetween(
$account->id,
Carbon::parse('2026-06-01'),
Carbon::parse('2026-07-01'),
);
expect($used)->toBe(10);
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Actions\User\CreateUser;
use App\Enums\Plan\Slug;
use Database\Seeders\PlanSeeder;
beforeEach(function () {
@ -36,6 +37,6 @@
'registration_ip' => '127.0.0.1',
]);
expect($user->account->plan_id)->not->toBeNull();
expect($user->account->plan->slug)->toBe(Slug::Workspace);
expect($user->account->trial_ends_at)->not->toBeNull();
});

View file

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\Plan;
use App\Models\Workspace;
use App\Support\BillingCycle;
use Illuminate\Support\Carbon;
/**
* @param array<string, mixed> $subscriptionAttributes
*/
function billingAccount(string $price, array $subscriptionAttributes = [], int $workspaces = 1): Account
{
$plan = Plan::query()->firstOrFail();
$plan->update([
'stripe_monthly_price_id' => 'price_month',
'stripe_yearly_price_id' => 'price_year',
]);
$account = Account::factory()->create([
'plan_id' => $plan->id,
'trial_ends_at' => null,
]);
$account->subscriptions()->create(array_merge([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => $price,
'quantity' => $workspaces,
], $subscriptionAttributes));
Workspace::factory()->count($workspaces)->create(['account_id' => $account->id]);
return $account->refresh();
}
test('monthly allotment is credits per workspace times workspace count', function () {
$account = billingAccount('price_month', workspaces: 3);
expect(BillingCycle::for($account)->creditAllotment())->toBe(7500);
});
test('yearly allotment grants twelve months upfront per workspace', function () {
$account = billingAccount('price_year', workspaces: 2);
expect(BillingCycle::for($account)->creditAllotment())->toBe(60000);
});
test('during trial the allotment is the monthly amount even on a yearly price', function () {
Carbon::setTestNow('2026-06-20 12:00:00');
$account = billingAccount('price_year', [
'created_at' => Carbon::parse('2026-06-18'),
'trial_ends_at' => Carbon::parse('2026-06-25'),
], workspaces: 1);
expect(BillingCycle::for($account)->creditAllotment())->toBe(2500);
});
test('monthly cycle window spans the anchor day to the next month', function () {
Carbon::setTestNow('2026-06-20 12:00:00');
$account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-03-05')]);
$cycle = BillingCycle::for($account);
expect($cycle->periodStart()->toDateString())->toBe('2026-06-05')
->and($cycle->periodEnd()->toDateString())->toBe('2026-07-05');
});
test('yearly cycle window spans one year from the anchor', function () {
Carbon::setTestNow('2026-06-20 12:00:00');
$account = billingAccount('price_year', ['created_at' => Carbon::parse('2025-02-10')]);
$cycle = BillingCycle::for($account);
expect($cycle->periodStart()->toDateString())->toBe('2026-02-10')
->and($cycle->periodEnd()->toDateString())->toBe('2027-02-10');
});
test('used credits counts only usage within the current cycle window', function () {
Carbon::setTestNow('2026-06-20 12:00:00');
$account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-03-05')]);
$workspace = $account->workspaces()->first();
AiUsageLog::factory()->text(credits: 10)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
'created_at' => Carbon::parse('2026-06-10'),
]);
AiUsageLog::factory()->text(credits: 99)->create([
'account_id' => $account->id,
'workspace_id' => $workspace->id,
'created_at' => Carbon::parse('2026-06-01'),
]);
expect(BillingCycle::for($account)->usedCredits())->toBe(10);
});
test('without a subscription the allotment falls back to a monthly amount', function () {
$plan = Plan::query()->firstOrFail();
$account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]);
Workspace::factory()->count(2)->create(['account_id' => $account->id]);
expect(BillingCycle::for($account)->creditAllotment())->toBe(5000);
});
test('monthly window clamps an end-of-month anchor without drift', function () {
Carbon::setTestNow('2026-06-20 12:00:00');
$account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-01-31')]);
$cycle = BillingCycle::for($account);
expect($cycle->periodStart()->toDateString())->toBe('2026-05-31')
->and($cycle->periodEnd()->toDateString())->toBe('2026-06-30');
});
test('yearly window clamps a leap-day anchor without drift', function () {
Carbon::setTestNow('2026-06-20 12:00:00');
$account = billingAccount('price_year', ['created_at' => Carbon::parse('2024-02-29')]);
$cycle = BillingCycle::for($account);
expect($cycle->periodStart()->toDateString())->toBe('2026-02-28')
->and($cycle->periodEnd()->toDateString())->toBe('2027-02-28');
});
test('allotment is zero when the account has no plan', function () {
$account = Account::factory()->create(['plan_id' => null, 'trial_ends_at' => null]);
Workspace::factory()->count(2)->create(['account_id' => $account->id]);
expect(BillingCycle::for($account)->creditAllotment())->toBe(0);
});

View file

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
test('the default trial length is 8 days', function () {
expect(config('cashier.trial_days'))->toBe(8);
});

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use Laravel\Cashier\Subscription;
test('syncWorkspaceQuantity does not touch Stripe in self-hosted mode', function () {
config()->set('trypost.self_hosted', true);
$subscription = Mockery::mock(Subscription::class);
$subscription->shouldReceive('active')->andReturnTrue();
$subscription->shouldReceive('updateQuantity')->never();
$account = Mockery::mock(Account::class)->makePartial();
$account->shouldReceive('subscription')->andReturn($subscription);
$account->shouldReceive('workspaces->count')->andReturn(3);
$account->syncWorkspaceQuantity();
});
test('syncWorkspaceQuantity does not touch Stripe without an active subscription', function () {
config()->set('trypost.self_hosted', false);
$subscription = Mockery::mock(Subscription::class);
$subscription->shouldReceive('active')->andReturnFalse();
$subscription->shouldReceive('updateQuantity')->never();
$account = Mockery::mock(Account::class)->makePartial();
$account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
$account->syncWorkspaceQuantity();
});
test('syncWorkspaceQuantity updates the subscription quantity to the workspace count', function () {
config()->set('trypost.self_hosted', false);
$subscription = Mockery::mock(Subscription::class);
$subscription->shouldReceive('active')->andReturnTrue();
$subscription->shouldReceive('updateQuantity')->once()->with(3);
$account = Mockery::mock(Account::class)->makePartial();
$account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
$account->shouldReceive('workspaces->count')->andReturn(3);
$account->syncWorkspaceQuantity();
});

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Plan;
@ -31,37 +32,37 @@
$response->assertRedirect(route('login'));
});
test('subscribe shows subscription page', function () {
test('subscribe redirects to onboarding', function () {
config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->has('plans')
);
$response->assertRedirect(route('app.onboarding'));
});
test('subscribe redirects to billing index when account has active subscription', function () {
config(['trypost.self_hosted' => false]);
test('checkout redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$plan = Plan::where('slug', 'workspace')->first();
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$response = $this->actingAs($this->user)
->post(route('app.billing.checkout', $plan), ['price_id' => 'price_x']);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response->assertRedirect(route('app.billing.index'));
$response->assertRedirect(route('app.calendar'));
});
test('subscribe redirects to calendar in self hosted mode', function () {
test('swapToYearly redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response = $this->actingAs($this->user)
->post(route('app.billing.swap-to-yearly'));
$response->assertRedirect(route('app.calendar'));
});
test('portal redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.billing.portal'));
$response->assertRedirect(route('app.calendar'));
});
@ -90,7 +91,7 @@
->component('settings/account/Billing', false)
->has('hasSubscription')
->has('plan')
->has('plans')
->has('workspaceCount')
);
});
@ -134,31 +135,6 @@
);
});
test('subscribe page exposes trialDays prop', function () {
config(['trypost.self_hosted' => false]);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->where('trialDays', config('cashier.trial_days'))
);
});
test('subscribe page exposes null trialDays when card is not required', function () {
config([
'trypost.self_hosted' => false,
'trypost.billing.require_card_for_trial' => false,
]);
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->where('trialDays', null)
);
});
test('billing index redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
@ -230,15 +206,15 @@
test('shared auth.plan exposes name slug and interval via AuthPlanResource', function () {
config(['trypost.self_hosted' => false]);
$plan = Plan::where('slug', 'pro')->firstOrFail();
$plan = Plan::where('slug', 'workspace')->firstOrFail();
$this->account->update(['plan_id' => $plan->id]);
$response = $this->actingAs($this->user->fresh())->get(route('app.billing.processing'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('auth.plan.name', 'Pro')
->where('auth.plan.slug', 'pro')
->where('auth.plan.name', 'Workspace')
->where('auth.plan.slug', 'workspace')
->where('auth.plan.interval', 'monthly')
);
});
@ -305,81 +281,62 @@
$this->actingAs($member)->get(route('app.billing.index'))->assertForbidden();
});
// Swap tests
test('swap blocks downgrade when usage exceeds target plan limits', function () {
// Swap-to-yearly tests
test('swapToYearly forbids a non-owner', function () {
config(['trypost.self_hosted' => false]);
$currentPlan = Plan::where('slug', 'plus')->first();
$currentPlan->update([
'stripe_monthly_price_id' => 'price_current_monthly',
'stripe_yearly_price_id' => 'price_current_yearly',
]);
$this->account->update(['plan_id' => $currentPlan->id]);
$member = User::factory()->create(['account_id' => $this->account->id]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_current_monthly',
'stripe_price' => 'price_monthly',
]);
Workspace::factory()->count(3)->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
// Starter: workspace_limit=1
$targetPlan = Plan::where('slug', 'starter')->first();
$targetPlan->update([
'stripe_monthly_price_id' => 'price_target_monthly',
'stripe_yearly_price_id' => 'price_target_yearly',
]);
$this->user->unsetRelation('account');
$response = $this->actingAs($this->user)
->from(route('app.billing.index'))
->post(route('app.billing.swap', $targetPlan), [
'price_id' => 'price_target_monthly',
]);
$response->assertRedirect(route('app.billing.index'));
$response->assertSessionHas('flash.error', __('billing.flash.cannot_downgrade.workspaces', [
'plan' => $targetPlan->name,
'count' => '4', // 3 created + 1 from beforeEach
'limit' => '1',
]));
$this->actingAs($member)
->post(route('app.billing.swap-to-yearly'))
->assertForbidden();
});
test('swap rejects invalid price_id for plan', function () {
test('swapToYearly is a no-op when already on annual billing', function () {
config(['trypost.self_hosted' => false]);
$plan = Plan::where('slug', 'pro')->first();
$plan = Plan::where('slug', 'workspace')->first();
$plan->update([
'stripe_monthly_price_id' => 'price_monthly',
'stripe_yearly_price_id' => 'price_yearly',
]);
$this->account->update(['plan_id' => $plan->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_other',
'stripe_price' => 'price_yearly',
]);
$this->user->unsetRelation('account');
$response = $this->actingAs($this->user)
->post(route('app.billing.swap', $plan), [
'price_id' => 'price_unrelated',
]);
$response->assertStatus(422);
$this->actingAs($this->user)
->post(route('app.billing.swap-to-yearly'))
->assertRedirect(route('app.billing.index'));
});
test('swap requires authentication', function () {
$plan = Plan::first();
$response = $this->post(route('app.billing.swap', $plan));
test('checkout rejects an archived plan', function () {
config(['trypost.self_hosted' => false]);
$archived = Plan::factory()->archived()->create(['slug' => Slug::Starter, 'name' => 'Legacy']);
$response = $this->actingAs($this->user)
->post(route('app.billing.checkout', $archived), ['price_id' => 'price_x']);
$response->assertNotFound();
});
test('swapToYearly requires authentication', function () {
$response = $this->post(route('app.billing.swap-to-yearly'));
$response->assertRedirect(route('login'));
});

View file

@ -4,20 +4,13 @@
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
config(['trypost.self_hosted' => false]);
$this->plan = Plan::first();
$this->plan->update([
'workspace_limit' => 5,
'member_limit' => 5,
]);
$this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
$this->account = Account::factory()->create();
$this->user = User::factory()->create([
'account_id' => $this->account->id,
]);
@ -30,37 +23,7 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('can invite member within limit', function () {
expect($this->user->can('inviteMember', $this->workspace))->toBeTrue();
});
test('cannot invite member beyond limit', function () {
$members = User::factory()->count(4)->create([
'account_id' => $this->account->id,
]);
foreach ($members as $member) {
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
}
// 1 owner + 4 members = 5, which is the limit
expect($this->user->can('inviteMember', $this->workspace))->toBeFalse();
});
test('self hosted mode bypasses workspace limit', function () {
config(['trypost.self_hosted' => true]);
Workspace::factory()->count(10)->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
expect($this->user->can('create', Workspace::class))->toBeTrue();
});
test('self hosted mode bypasses member limit', function () {
config(['trypost.self_hosted' => true]);
test('owner can invite members with no count limit', function () {
$members = User::factory()->count(10)->create([
'account_id' => $this->account->id,
]);
@ -71,3 +34,10 @@
expect($this->user->can('inviteMember', $this->workspace))->toBeTrue();
});
test('a non-admin member cannot invite members', function () {
$member = User::factory()->create(['account_id' => $this->account->id]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
expect($member->can('inviteMember', $this->workspace))->toBeFalse();
});

View file

@ -2,8 +2,9 @@
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Enums\PostHog\BillingEvent;
use App\Features\WorkspaceLimit;
use App\Features\MonthlyCreditsLimit;
use App\Jobs\PostHog\TrackBilling;
use App\Listeners\StripeEventListener;
use App\Models\Account;
@ -17,6 +18,19 @@
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
Plan::factory()->create([
'slug' => Slug::Starter,
'name' => 'Starter',
'stripe_monthly_price_id' => 'price_starter_monthly',
'stripe_yearly_price_id' => 'price_starter_yearly',
]);
Plan::factory()->create([
'slug' => Slug::Pro,
'name' => 'Pro',
'stripe_monthly_price_id' => 'price_pro_monthly',
'stripe_yearly_price_id' => 'price_pro_yearly',
]);
$this->account = Account::factory()->create(['stripe_id' => 'cus_test123']);
$this->user = User::factory()->create([
'account_id' => $this->account->id,
@ -431,9 +445,8 @@
$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);
// Prime the Pennant cache.
Feature::for($this->account)->value(MonthlyCreditsLimit::class);
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBeGreaterThan(0);
@ -449,15 +462,29 @@
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);
expect($this->account->fresh()->plan_id)->toBe($pro->id);
});
test('subscription updated maps an archived legacy plan by price id', function () {
$pro = Plan::query()->where('slug', 'pro')->firstOrFail();
$pro->update(['is_archived' => true]);
$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 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);
Feature::for($this->account)->value(MonthlyCreditsLimit::class);
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBeGreaterThan(0);

View file

@ -11,13 +11,13 @@
config()->set('trypost.self_hosted', false);
});
test('redirects to subscribe when account has no active subscription', function () {
test('redirects to onboarding when account has no active subscription', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]);
$this->actingAs($user)
->get(route('app.calendar'))
->assertRedirect(route('app.subscribe'));
->assertRedirect(route('app.onboarding'));
});
test('redirects to workspace create when subscribed but no workspace', function () {

View file

@ -33,7 +33,7 @@
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
$response->assertRedirect(route('app.subscribe'));
$response->assertRedirect(route('app.onboarding'));
});
test('user with active subscription can access the app', function () {

View file

@ -2,40 +2,29 @@
declare(strict_types=1);
use App\Features\MemberLimit;
use App\Enums\Plan\Slug;
use App\Features\MonthlyCreditsLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Account;
use App\Models\Plan;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Laravel\Pennant\Feature;
test('forgetPlanFeatureCache drops the cached plan-scoped features', function () {
$starter = Plan::where('slug', 'starter')->first();
$plus = Plan::where('slug', 'plus')->first();
test('forgetPlanFeatureCache drops the cached monthly credits feature', function () {
$plan = Plan::where('slug', Slug::Workspace)->first();
$account = Account::factory()->create(['plan_id' => $starter->id]);
$account = Account::factory()->create(['plan_id' => $plan->id]);
Workspace::factory()->create(['account_id' => $account->id]);
// 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);
->toBe(1);
// 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);
expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe(2500);
});

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Models\Account;
use App\Models\Invite;
use App\Models\Plan;
@ -43,17 +44,19 @@
]);
});
test('featureLimits returns plan-resolved limits', function () {
$plan = Plan::where('slug', 'plus')->first();
test('featureLimits returns the monthly credits allotment', function () {
$plan = Plan::where('slug', Slug::Workspace)->first();
$this->account->update(['plan_id' => $plan->id]);
Workspace::factory()->count(2)->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
$limits = $this->account->featureLimits();
expect($limits)->toBe([
'workspaceLimit' => $plan->workspace_limit,
'socialAccountLimit' => $plan->social_account_limit,
'memberLimit' => $plan->member_limit,
'monthlyCreditsLimit' => $plan->monthly_credits_limit,
'monthlyCreditsLimit' => 5000,
]);
});

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
use App\Enums\User\Persona;
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Support\Facades\Bus;
beforeEach(function () {
config(['trypost.self_hosted' => false]);
$this->user = User::factory()->create();
});
test('onboarding renders the persona selection for an unsubscribed account', function () {
$response = $this->actingAs($this->user)->get(route('app.onboarding'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Index')
->has('personas', count(Persona::cases()))
);
});
test('onboarding redirects to calendar in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.onboarding'));
$response->assertRedirect(route('app.calendar'));
});
test('onboarding redirects to calendar when already subscribed', function () {
$this->user->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding'));
$response->assertRedirect(route('app.calendar'));
});
test('onboarding store rejects an invalid persona', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
'persona' => 'not-a-persona',
]);
$response->assertSessionHasErrors('persona');
expect($this->user->fresh()->persona)->toBeNull();
});
test('onboarding store requires a persona', function () {
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), []);
$response->assertSessionHasErrors('persona');
});
test('onboarding store does nothing in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->persona)->toBeNull();
});
test('onboarding store saves the persona, mirrors to PostHog and starts monthly checkout', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
'stripe_yearly_price_id' => 'price_yearly_test',
]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->once()
->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $priceId === 'price_monthly_test')
->andReturn(redirect()->route('app.calendar'));
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->persona)->toBe(Persona::Agency);
Bus::assertDispatched(SendEvent::class);
});

View file

@ -6,40 +6,23 @@
use App\Models\Plan;
use Database\Seeders\PlanSeeder;
test('seeder creates 4 plans', function () {
expect(Plan::count())->toBe(4);
});
test('seeder creates only the per-workspace plan', function () {
expect(Plan::count())->toBe(1);
test('seeder creates plans with correct limits', function () {
$starter = Plan::where('slug', Slug::Starter)->first();
$workspace = Plan::where('slug', Slug::Workspace)->first();
expect($starter->name)->toBe('Starter')
->and($starter->social_account_limit)->toBe(5)
->and($starter->member_limit)->toBe(1)
->and($starter->workspace_limit)->toBe(1)
->and($starter->monthly_credits_limit)->toBe(1000)
->and($starter->sort)->toBe(1);
$max = Plan::where('slug', Slug::Max)->first();
expect($max->name)->toBe('Max')
->and($max->social_account_limit)->toBe(100)
->and($max->member_limit)->toBe(20)
->and($max->workspace_limit)->toBe(50)
->and($max->monthly_credits_limit)->toBe(15000)
->and($max->sort)->toBe(4);
expect($workspace->name)->toBe('Workspace')
->and($workspace->monthly_credits_limit)->toBe(2500)
->and($workspace->is_archived)->toBeFalse();
});
test('seeder is idempotent', function () {
$this->seed(PlanSeeder::class);
expect(Plan::count())->toBe(4);
expect(Plan::count())->toBe(1);
});
test('active scope excludes archived plans', function () {
$plan = Plan::where('slug', Slug::Starter)->first();
$plan->update(['is_archived' => true]);
expect(Plan::active()->count())->toBe(3)
->and(Plan::count())->toBe(4);
test('the per-workspace plan is active', function () {
expect(Plan::active()->count())->toBe(1)
->and(Plan::active()->first()->slug)->toBe(Slug::Workspace);
});

View file

@ -20,28 +20,25 @@
});
test('plan slug is cast to enum', function () {
$plan = Plan::where('slug', Slug::Starter)->first();
$plan = Plan::where('slug', Slug::Workspace)->first();
expect($plan->slug)->toBeInstanceOf(Slug::class)
->and($plan->slug)->toBe(Slug::Starter)
->and($plan->slug->label())->toBe('Starter');
->and($plan->slug)->toBe(Slug::Workspace)
->and($plan->slug->label())->toBe('Workspace');
});
test('active scope excludes archived plans', function () {
$activeBefore = Plan::active()->count();
$plan = Plan::where('slug', Slug::Pro)->first();
$plan = Plan::where('slug', Slug::Workspace)->first();
$plan->update(['is_archived' => true]);
expect(Plan::active()->count())->toBe($activeBefore - 1);
});
test('integer fields are cast correctly', function () {
$plan = Plan::where('slug', Slug::Starter)->first();
$plan = Plan::where('slug', Slug::Workspace)->first();
expect($plan->social_account_limit)->toBeInt()
->and($plan->member_limit)->toBeInt()
->and($plan->workspace_limit)->toBeInt()
->and($plan->monthly_credits_limit)->toBeInt()
expect($plan->monthly_credits_limit)->toBeInt()
->and($plan->sort)->toBeInt();
});

View file

@ -159,7 +159,9 @@
$response->assertViewHas('message', 'Session expired. Please try again.');
});
test('user can connect multiple facebook accounts', function () {
test('user can connect multiple facebook accounts in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
SocialAccount::factory()->facebook()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'page_existing',

View file

@ -117,10 +117,13 @@ function telegramUpdate(string $code, array $chat = []): array
expect(data_get($account->meta, 'username'))->toBeNull();
});
it('does not create a new telegram account when the workspace is at its limit', function () {
it('does not connect a second telegram channel when one is already connected', function () {
config(['trypost.self_hosted' => false]);
SocialAccount::factory()->count(5)->create(['workspace_id' => $this->workspace->id]);
SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '-1009999999999',
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
@ -128,17 +131,37 @@ function telegramUpdate(string $code, array $chat = []): array
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
expect($this->workspace->socialAccounts()->count())->toBe(5);
expect($this->workspace->socialAccounts()->count())->toBe(1);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists()
)->toBeFalse();
});
it('still reconnects an existing telegram channel even at the account limit', function () {
it('connects a second telegram channel in self-hosted mode', function () {
Http::fake();
config(['trypost.self_hosted' => true]);
SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '-1009999999999',
]);
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
expect($this->workspace->socialAccounts()->count())->toBe(2);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists()
)->toBeTrue();
});
it('reconnects an existing telegram channel', function () {
Http::fake();
config(['trypost.self_hosted' => false]);
SocialAccount::factory()->count(4)->create(['workspace_id' => $this->workspace->id]);
SocialAccount::factory()->telegram()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '-1001234567890',
@ -150,7 +173,7 @@ function telegramUpdate(string $code, array $chat = []): array
->postJson(route('telegram.webhook'), telegramUpdate($code))
->assertNoContent();
expect($this->workspace->socialAccounts()->count())->toBe(5);
expect($this->workspace->socialAccounts()->count())->toBe(1);
expect(
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->count()
)->toBe(1);

View file

@ -0,0 +1,111 @@
<?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();
});

View file

@ -38,7 +38,7 @@
});
test('account belongs to plan', function () {
$plan = Plan::where('slug', PlanSlug::Starter)->first();
$plan = Plan::where('slug', PlanSlug::Workspace)->first();
$this->account->update(['plan_id' => $plan->id]);
expect($this->account->fresh()->plan->id)->toBe($plan->id);
@ -57,7 +57,7 @@
$response = $this->actingAs($this->user)->get(route('app.calendar'));
$response->assertRedirect(route('app.subscribe'));
$response->assertRedirect(route('app.onboarding'));
});
test('billing page is accessible by account owner', function () {
@ -96,18 +96,12 @@
$response->assertForbidden();
});
test('subscribe page shows plans', function () {
test('subscribe redirects to onboarding', function () {
config(['trypost.self_hosted' => false]);
$activePlanCount = Plan::active()->count();
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->has('plans', $activePlanCount)
);
$response->assertRedirect(route('app.onboarding'));
});
test('stripe email returns account owner email', function () {
@ -118,32 +112,18 @@
expect($this->account->stripeName())->toBe($this->account->name);
});
test('create workspace is blocked when account hits plan limit in saas mode', function () {
test('creating an additional workspace redirects to billing without an active subscription', function () {
config(['trypost.self_hosted' => false]);
$starter = Plan::where('slug', PlanSlug::Starter)->first();
$this->account->update(['plan_id' => $starter->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
// The account already owns one workspace (from beforeEach) and has no subscription.
$response = $this->actingAs($this->user)->get(route('app.workspaces.create'));
// Starter plan: workspace_limit = 1; the user already owns 1 workspace (from beforeEach).
$response = $this->actingAs($this->user)
->from(route('app.calendar'))
->get(route('app.workspaces.create'));
$response->assertRedirect(route('app.calendar'))
->assertSessionHas('flash.error', __('workspaces.limit_reached'));
$response->assertRedirect(route('app.billing.index'));
});
test('create workspace is allowed when below plan limit in saas mode', function () {
test('creating an additional workspace is allowed with an active subscription', function () {
config(['trypost.self_hosted' => false]);
$pro = Plan::where('slug', PlanSlug::Pro)->first();
$this->account->update(['plan_id' => $pro->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
@ -151,40 +131,18 @@
'stripe_price' => 'price_123',
]);
// Pro plan: workspace_limit = 15; account has 1 workspace.
$response = $this->actingAs($this->user->fresh())->get(route('app.workspaces.create'));
$response->assertOk();
});
test('store workspace is blocked when account hits plan limit in saas mode', function () {
config(['trypost.self_hosted' => false]);
test('store creates an additional workspace with no count limit', function () {
config(['trypost.self_hosted' => true]);
$starter = Plan::where('slug', PlanSlug::Starter)->first();
$this->account->update(['plan_id' => $starter->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
// Starter plan: workspace_limit = 1; the user already owns 1 workspace.
$response = $this->actingAs($this->user)->post(route('app.workspaces.store'), [
'name' => 'Second workspace',
]);
$response->assertForbidden();
});
test('workspace limit is bypassed in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$starter = Plan::where('slug', PlanSlug::Starter)->first();
$this->account->update(['plan_id' => $starter->id]);
// Even on Starter (limit 1) with an existing workspace, self-hosted lets through.
$response = $this->actingAs($this->user)->get(route('app.workspaces.create'));
$response->assertOk();
$response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
expect($this->account->workspaces()->count())->toBe(2);
});

View file

@ -2,8 +2,10 @@
declare(strict_types=1);
use App\Ai\Agents\BrandAnalyzer;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Brand\LogoAttacher;
@ -405,6 +407,10 @@
});
test('destroy workspace deletes the workspace', function () {
Workspace::factory()->create([
'account_id' => $this->user->account_id,
'user_id' => $this->user->id,
]);
$workspaceId = $this->workspace->id;
$response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
@ -414,12 +420,43 @@
});
test('destroy workspace clears current workspace if deleting current', function () {
Workspace::factory()->create([
'account_id' => $this->user->account_id,
'user_id' => $this->user->id,
]);
$this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
$this->user->refresh();
expect($this->user->current_workspace_id)->toBeNull();
});
test('destroy workspace is blocked when it is the only workspace', function () {
config(['trypost.self_hosted' => false]);
$this->user->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$workspaceId = $this->workspace->id;
$response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
$response->assertSessionHas('flash.error', __('workspaces.cannot_delete_last'));
expect(Workspace::find($workspaceId))->not->toBeNull();
});
test('destroy workspace allows deleting the only workspace in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$workspaceId = $this->workspace->id;
$response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
$response->assertRedirect(route('app.workspaces.index'));
expect(Workspace::find($workspaceId))->toBeNull();
});
test('destroy workspace returns 403 for non-owner', function () {
$otherUser = User::factory()->create([]);
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
@ -467,6 +504,57 @@
expect(Workspace::count())->toBe($initialWorkspaceCount);
});
test('autofillBrand is always allowed even without a subscription', function () {
config(['trypost.self_hosted' => false]);
$account = Account::factory()->create(['trial_ends_at' => null]);
$user = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $user->id]);
expect($account->subscribed(Account::SUBSCRIPTION_NAME))->toBeFalse();
Http::fake([
'example.com/*' => Http::response(
'<html><head><title>Acme</title><meta name="description" content="We sell rockets." /></head><body></body></html>',
200,
['Content-Type' => 'text/html'],
),
'example.com' => Http::response(
'<html><head><title>Acme</title><meta name="description" content="We sell rockets." /></head><body></body></html>',
200,
['Content-Type' => 'text/html'],
),
]);
$this->actingAs($user)
->postJson(route('app.workspaces.autofill'), ['url' => 'https://example.com'])
->assertOk();
});
test('autofillBrand never records AI usage even when the LLM runs', function () {
config(['trypost.self_hosted' => false]);
config()->set('services.gemini.api_key', 'fake-key');
config()->set('ai.default', 'gemini');
Http::fake([
'example.com' => Http::response(
'<html lang="en"><head><title>Acme</title><meta name="description" content="Terse." /></head><body><main><p>Acme ships rockets fast.</p></main></body></html>',
200,
['Content-Type' => 'text/html'],
),
]);
BrandAnalyzer::fake([
['description' => 'Acme ships rockets fast.', 'language' => 'en'],
]);
$this->actingAs($this->user)
->postJson(route('app.workspaces.autofill'), ['url' => 'https://example.com'])
->assertOk();
expect(AiUsageLog::count())->toBe(0);
});
test('autofillBrand validates url is required', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]);

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use App\Enums\User\Persona;
test('every persona has a non-empty label', function () {
foreach (Persona::cases() as $persona) {
expect($persona->label())->toBeString()->not->toBe('');
}
});
test('options returns a value and label for every persona', function () {
$options = Persona::options();
expect($options)->toHaveCount(count(Persona::cases()))
->and($options[0])->toHaveKeys(['value', 'label'])
->and($options[0]['value'])->toBe(Persona::Creator->value);
});

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
test('network groups the two linkedin variants together', function () {
expect(Platform::LinkedIn->network())->toBe('linkedin')
->and(Platform::LinkedInPage->network())->toBe('linkedin');
});
test('network groups the two instagram variants together', function () {
expect(Platform::Instagram->network())->toBe('instagram')
->and(Platform::InstagramFacebook->network())->toBe('instagram');
});
test('network returns the platform value for single-variant networks', function () {
expect(Platform::X->network())->toBe('x')
->and(Platform::Facebook->network())->toBe('facebook')
->and(Platform::TikTok->network())->toBe('tiktok')
->and(Platform::YouTube->network())->toBe('youtube')
->and(Platform::Bluesky->network())->toBe('bluesky');
});
test('every platform resolves to a non-empty network', function () {
foreach (Platform::cases() as $platform) {
expect($platform->network())->toBeString()->not->toBe('');
}
});

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
use App\Features\WorkspaceLimit;
use App\Models\Account;
use App\Models\Plan;
test('returns plan workspace limit', function () {
$plan = new Plan(['workspace_limit' => 5]);
$account = new Account;
$account->setRelation('plan', $plan);
expect((new WorkspaceLimit)->resolve($account))->toBe(5);
});
test('falls back to 1 when no plan', function () {
$account = new Account;
$account->setRelation('plan', null);
expect((new WorkspaceLimit)->resolve($account))->toBe(1);
});

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
use App\Features\MemberLimit;
use App\Models\Account;
use App\Models\Plan;
test('returns plan member limit', function () {
$plan = new Plan(['member_limit' => 10]);
$account = new Account;
$account->setRelation('plan', $plan);
expect((new MemberLimit)->resolve($account))->toBe(10);
});
test('falls back to 1 when no plan', function () {
$account = new Account;
$account->setRelation('plan', null);
expect((new MemberLimit)->resolve($account))->toBe(1);
});

View file

@ -2,21 +2,23 @@
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Features\MonthlyCreditsLimit;
use App\Models\Account;
use App\Models\Plan;
use App\Models\Workspace;
test('returns plan monthly credits limit', function () {
$plan = new Plan(['monthly_credits_limit' => 5000]);
$account = new Account;
$account->setRelation('plan', $plan);
test('resolves to the billing cycle credit allotment', function () {
$plan = Plan::where('slug', Slug::Workspace)->first();
$account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]);
Workspace::factory()->count(2)->create(['account_id' => $account->id]);
expect((new MonthlyCreditsLimit)->resolve($account))->toBe(5000);
});
test('falls back to 1000 when no plan', function () {
$account = new Account;
$account->setRelation('plan', null);
test('resolves to zero when the account has no workspaces', function () {
$plan = Plan::where('slug', Slug::Workspace)->first();
$account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]);
expect((new MonthlyCreditsLimit)->resolve($account))->toBe(1000);
expect((new MonthlyCreditsLimit)->resolve($account))->toBe(0);
});

Some files were not shown because too many files have changed in this diff Show more