From cbf8fb283c1d86d1a2d3c43293c126308d7fb6f7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 21 Jun 2026 20:40:03 -0300 Subject: [PATCH] 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. --- .env.example | 4 + CLAUDE.md | 7 + app/Actions/Ai/AutofillBrand.php | 5 +- .../Billing/StartSubscriptionCheckout.php | 40 +++ .../SocialAccount/ConnectTelegramChannel.php | 14 +- app/Actions/User/CreateUser.php | 2 +- app/Actions/Workspace/CreateWorkspace.php | 3 + app/Actions/Workspace/DeleteWorkspace.php | 4 + app/Enums/Plan/Slug.php | 2 + app/Enums/SocialAccount/Platform.php | 29 ++ app/Enums/User/Persona.php | 38 +++ .../NetworkAlreadyConnectedException.php | 16 + app/Features/MemberLimit.php | 17 - app/Features/MonthlyCreditsLimit.php | 3 +- app/Features/SocialAccountLimit.php | 17 - app/Features/WorkspaceLimit.php | 17 - .../Controllers/App/BillingController.php | 69 +--- .../Controllers/App/OnboardingController.php | 67 ++++ .../App/Settings/UsageController.php | 10 +- .../Controllers/App/WorkspaceController.php | 46 +-- .../Controllers/Auth/BlueskyController.php | 1 - .../Controllers/Auth/FacebookController.php | 1 - .../Controllers/Auth/InstagramController.php | 1 - .../Auth/InstagramFacebookController.php | 1 - .../Auth/LinkedInPageController.php | 1 - .../Controllers/Auth/MastodonController.php | 1 - .../Controllers/Auth/SocialController.php | 20 +- .../Controllers/Auth/TelegramController.php | 1 - .../Controllers/Auth/ThreadsController.php | 1 - .../Controllers/Auth/YouTubeController.php | 1 - .../Middleware/App/EnsureAccountReady.php | 2 +- .../Middleware/App/HandleInertiaRequests.php | 2 - .../App/Onboarding/StoreOnboardingRequest.php | 27 ++ app/Models/Account.php | 35 +- app/Models/AiUsageLog.php | 6 +- app/Models/Plan.php | 6 - app/Models/Traits/HasUsage.php | 18 +- app/Models/User.php | 3 + app/Observers/SocialAccountObserver.php | 32 ++ app/Policies/AccountPolicy.php | 59 ++-- app/Policies/WorkspacePolicy.php | 14 +- app/Services/Brand/BrandAnalyzerRunner.php | 19 +- app/Support/BillingCycle.php | 121 +++++++ database/factories/PlanFactory.php | 5 +- database/factories/UserFactory.php | 1 + ...42_drop_limit_columns_from_plans_table.php | 30 ++ ...6_21_194741_add_persona_to_users_table.php | 28 ++ database/seeders/PlanSeeder.php | 58 +--- lang/en/accounts.php | 1 + lang/en/billing.php | 49 +-- lang/en/onboarding.php | 18 + lang/en/usage.php | 2 +- lang/en/workspaces.php | 1 + lang/es/accounts.php | 1 + lang/es/billing.php | 49 +-- lang/es/onboarding.php | 18 + lang/es/usage.php | 2 +- lang/es/workspaces.php | 1 + lang/pt-BR/accounts.php | 1 + lang/pt-BR/billing.php | 49 +-- lang/pt-BR/onboarding.php | 18 + lang/pt-BR/usage.php | 2 +- lang/pt-BR/workspaces.php | 1 + resources/js/components/AppSidebar.vue | 10 +- resources/js/components/UpgradeDialog.vue | 319 ------------------ resources/js/components/settings/UsersTab.vue | 12 +- resources/js/composables/useFeatureAccess.ts | 29 +- resources/js/composables/useUpgradeDialog.ts | 17 - resources/js/layouts/app/AppSidebarLayout.vue | 4 - resources/js/pages/accounts/Index.vue | 10 - resources/js/pages/billing/Subscribe.vue | 287 ---------------- resources/js/pages/onboarding/Index.vue | 119 +++++++ .../js/pages/settings/account/Billing.vue | 61 +++- resources/js/pages/settings/account/Usage.vue | 8 +- routes/app.php | 5 +- tests/Feature/AiUsageLogTest.php | 38 ++- .../Auth/SignupRequiresCheckoutTest.php | 3 +- tests/Feature/Billing/BillingCycleTest.php | 139 ++++++++ tests/Feature/Billing/TrialLengthTest.php | 7 + .../Billing/WorkspaceQuantitySyncTest.php | 47 +++ tests/Feature/BillingControllerTest.php | 149 +++----- tests/Feature/LimitEnforcementTest.php | 48 +-- .../Listeners/StripeEventListenerTest.php | 41 ++- .../Middleware/EnsureAccountReadyTest.php | 4 +- .../Middleware/TrialMiddlewareAccessTest.php | 2 +- tests/Feature/Models/AccountModelTest.php | 27 +- tests/Feature/Models/HasUsageTraitTest.php | 15 +- .../Onboarding/OnboardingControllerTest.php | 99 ++++++ tests/Feature/PlanSeederTest.php | 37 +- tests/Feature/PlanTest.php | 15 +- .../Feature/Social/FacebookControllerTest.php | 4 +- .../Feature/Social/TelegramConnectionTest.php | 35 +- .../SocialAccount/NetworkUniquenessTest.php | 111 ++++++ tests/Feature/WorkspaceBillingTest.php | 68 +--- tests/Feature/WorkspaceControllerTest.php | 88 +++++ tests/Unit/Enums/PersonaTest.php | 19 ++ tests/Unit/Enums/PlatformNetworkTest.php | 29 ++ tests/Unit/Features/BrandLimitTest.php | 22 -- tests/Unit/Features/MemberLimitTest.php | 22 -- .../Unit/Features/MonthlyCreditsLimitTest.php | 18 +- .../Unit/Features/SocialAccountLimitTest.php | 22 -- tests/Unit/Policies/AccountPolicyTest.php | 132 +++----- 102 files changed, 1644 insertions(+), 1596 deletions(-) create mode 100644 app/Actions/Billing/StartSubscriptionCheckout.php create mode 100644 app/Enums/User/Persona.php create mode 100644 app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php delete mode 100644 app/Features/MemberLimit.php delete mode 100644 app/Features/SocialAccountLimit.php delete mode 100644 app/Features/WorkspaceLimit.php create mode 100644 app/Http/Controllers/App/OnboardingController.php create mode 100644 app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php create mode 100644 app/Support/BillingCycle.php create mode 100644 database/migrations/2026_06_21_184042_drop_limit_columns_from_plans_table.php create mode 100644 database/migrations/2026_06_21_194741_add_persona_to_users_table.php create mode 100644 lang/en/onboarding.php create mode 100644 lang/es/onboarding.php create mode 100644 lang/pt-BR/onboarding.php delete mode 100644 resources/js/components/UpgradeDialog.vue delete mode 100644 resources/js/composables/useUpgradeDialog.ts delete mode 100644 resources/js/pages/billing/Subscribe.vue create mode 100644 resources/js/pages/onboarding/Index.vue create mode 100644 tests/Feature/Billing/BillingCycleTest.php create mode 100644 tests/Feature/Billing/TrialLengthTest.php create mode 100644 tests/Feature/Billing/WorkspaceQuantitySyncTest.php create mode 100644 tests/Feature/Onboarding/OnboardingControllerTest.php create mode 100644 tests/Feature/SocialAccount/NetworkUniquenessTest.php create mode 100644 tests/Unit/Enums/PersonaTest.php create mode 100644 tests/Unit/Enums/PlatformNetworkTest.php delete mode 100644 tests/Unit/Features/BrandLimitTest.php delete mode 100644 tests/Unit/Features/MemberLimitTest.php delete mode 100644 tests/Unit/Features/SocialAccountLimitTest.php diff --git a/.env.example b/.env.example index 235552aa..2f34dfeb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index ce363040..fbf0ae44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/app/Actions/Ai/AutofillBrand.php b/app/Actions/Ai/AutofillBrand.php index 6fcf98c5..8654f346 100644 --- a/app/Actions/Ai/AutofillBrand.php +++ b/app/Actions/Ai/AutofillBrand.php @@ -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 diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php new file mode 100644 index 00000000..1515ce11 --- /dev/null +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/app/Actions/SocialAccount/ConnectTelegramChannel.php b/app/Actions/SocialAccount/ConnectTelegramChannel.php index 3743a113..c4d5e4b3 100644 --- a/app/Actions/SocialAccount/ConnectTelegramChannel.php +++ b/app/Actions/SocialAccount/ConnectTelegramChannel.php @@ -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(); } } diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index 5f01e207..c5af71fe 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -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)); } diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php index e6dd18a9..a2313ef3 100644 --- a/app/Actions/Workspace/CreateWorkspace.php +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -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; } } diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php index 84cdacdc..aa889ecd 100644 --- a/app/Actions/Workspace/DeleteWorkspace.php +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -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); } diff --git a/app/Enums/Plan/Slug.php b/app/Enums/Plan/Slug.php index 6a9ed87e..7b056c48 100644 --- a/app/Enums/Plan/Slug.php +++ b/app/Enums/Plan/Slug.php @@ -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', }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 0d645c97..d2e18c43 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -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 + */ + 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) { diff --git a/app/Enums/User/Persona.php b/app/Enums/User/Persona.php new file mode 100644 index 00000000..3a8b6c2b --- /dev/null +++ b/app/Enums/User/Persona.php @@ -0,0 +1,38 @@ + 'Creator', + self::Freelancer => 'Freelancer', + self::Startup => 'Startup', + self::Agency => 'Agency', + self::SmallBusiness => 'Small business', + self::Other => 'Other', + }; + } + + /** + * @return array + */ + public static function options(): array + { + return array_map( + fn (self $persona): array => ['value' => $persona->value, 'label' => $persona->label()], + self::cases(), + ); + } +} diff --git a/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php b/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php new file mode 100644 index 00000000..af66de5a --- /dev/null +++ b/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php @@ -0,0 +1,16 @@ +network()} account connected."); + } +} diff --git a/app/Features/MemberLimit.php b/app/Features/MemberLimit.php deleted file mode 100644 index a1cedb15..00000000 --- a/app/Features/MemberLimit.php +++ /dev/null @@ -1,17 +0,0 @@ -plan?->member_limit ?? 1; - } -} diff --git a/app/Features/MonthlyCreditsLimit.php b/app/Features/MonthlyCreditsLimit.php index 9b565d68..b4dc719c 100644 --- a/app/Features/MonthlyCreditsLimit.php +++ b/app/Features/MonthlyCreditsLimit.php @@ -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(); } } diff --git a/app/Features/SocialAccountLimit.php b/app/Features/SocialAccountLimit.php deleted file mode 100644 index 0fa1d460..00000000 --- a/app/Features/SocialAccountLimit.php +++ /dev/null @@ -1,17 +0,0 @@ -plan?->social_account_limit ?? 5; - } -} diff --git a/app/Features/WorkspaceLimit.php b/app/Features/WorkspaceLimit.php deleted file mode 100644 index 64328819..00000000 --- a/app/Features/WorkspaceLimit.php +++ /dev/null @@ -1,17 +0,0 @@ -plan?->workspace_limit ?? 1; - } -} diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 3ea14e0d..a6e9b73b 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -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 diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php new file mode 100644 index 00000000..f82e1b0e --- /dev/null +++ b/app/Http/Controllers/App/OnboardingController.php @@ -0,0 +1,67 @@ +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'), + ); + } +} diff --git a/app/Http/Controllers/App/Settings/UsageController.php b/app/Http/Controllers/App/Settings/UsageController.php index 0aedcab2..3c27b608 100644 --- a/app/Http/Controllers/App/Settings/UsageController.php +++ b/app/Http/Controllers/App/Settings/UsageController.php @@ -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), ], ]); diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index d670c8e0..4b4d3dd1 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -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; - } } diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index e262daaa..6b3630b9 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -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() ?? [], diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index d91eb252..4c554aff 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -42,7 +42,6 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); - $this->ensureSocialAccountLimit($workspace); session([ 'social_connect_workspace' => $workspace->id, diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index bc667e19..6cd595c3 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -38,7 +38,6 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); - $this->ensureSocialAccountLimit($workspace); session([ 'social_connect_workspace' => $workspace->id, diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 625ec5dc..8cdef9d4 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -44,7 +44,6 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); - $this->ensureSocialAccountLimit($workspace); session([ 'social_connect_workspace' => $workspace->id, diff --git a/app/Http/Controllers/Auth/LinkedInPageController.php b/app/Http/Controllers/Auth/LinkedInPageController.php index c60c6815..ca5a9f89 100644 --- a/app/Http/Controllers/Auth/LinkedInPageController.php +++ b/app/Http/Controllers/Auth/LinkedInPageController.php @@ -35,7 +35,6 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse } $this->authorize('manageAccounts', $workspace); - $this->ensureSocialAccountLimit($workspace); session([ 'social_connect_workspace' => $workspace->id, diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index c834e690..d42367b6 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -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() ?? [], diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 6d67ff69..36a8b859 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -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, diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php index e24fa9cf..309a4606 100644 --- a/app/Http/Controllers/Auth/TelegramController.php +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -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); diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index f3c4f418..76d9414e 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -36,7 +36,6 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); - $this->ensureSocialAccountLimit($workspace); session([ 'social_connect_workspace' => $workspace->id, diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index 59be9062..f4686c00 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -40,7 +40,6 @@ public function connect(Request $request): Response|RedirectResponse } $this->authorize('manageAccounts', $workspace); - $this->ensureSocialAccountLimit($workspace); session([ 'social_connect_workspace' => $workspace->id, diff --git a/app/Http/Middleware/App/EnsureAccountReady.php b/app/Http/Middleware/App/EnsureAccountReady.php index 20a0900f..775bc3fa 100644 --- a/app/Http/Middleware/App/EnsureAccountReady.php +++ b/app/Http/Middleware/App/EnsureAccountReady.php @@ -32,7 +32,7 @@ public function handle(Request $request, Closure $next): Response ); if (! $hasAccess) { - return redirect()->route('app.subscribe'); + return redirect()->route('app.onboarding'); } } diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 5d46fa0c..5e51d6d8 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -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'), diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php new file mode 100644 index 00000000..ee0d7428 --- /dev/null +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'persona' => ['required', Rule::enum(Persona::class)], + ]; + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index eafefef1..ce5454ba 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -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')) { diff --git a/app/Models/AiUsageLog.php b/app/Models/AiUsageLog.php index 00207334..b7b3016b 100644 --- a/app/Models/AiUsageLog.php +++ b/app/Models/AiUsageLog.php @@ -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'); } } diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 49088c62..74cc1226 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -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', ]; diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index 099a68c5..0ae06502 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -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), ]; } diff --git a/app/Models/User.php b/app/Models/User.php index dafee99d..da109ae4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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, ]; } diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index 5ca57281..2e4fbfa9 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -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); diff --git a/app/Policies/AccountPolicy.php b/app/Policies/AccountPolicy.php index a45916c9..a11a9eca 100644 --- a/app/Policies/AccountPolicy.php +++ b/app/Policies/AccountPolicy.php @@ -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(); } } diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 865b87ab..ad4f40b3 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -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 diff --git a/app/Services/Brand/BrandAnalyzerRunner.php b/app/Services/Brand/BrandAnalyzerRunner.php index 31b2b8a5..2eae3e1c 100644 --- a/app/Services/Brand/BrandAnalyzerRunner.php +++ b/app/Services/Brand/BrandAnalyzerRunner.php @@ -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); } diff --git a/app/Support/BillingCycle.php b/app/Support/BillingCycle.php new file mode 100644 index 00000000..9c3b88f6 --- /dev/null +++ b/app/Support/BillingCycle.php @@ -0,0 +1,121 @@ +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); + } +} diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index c722c81b..522130c1 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -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, ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 61edb164..8caa1162 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -46,6 +46,7 @@ public function definition(): array 'utm_term' => null, 'utm_content' => null, 'registration_ip' => null, + 'persona' => null, ]; } diff --git a/database/migrations/2026_06_21_184042_drop_limit_columns_from_plans_table.php b/database/migrations/2026_06_21_184042_drop_limit_columns_from_plans_table.php new file mode 100644 index 00000000..ef729301 --- /dev/null +++ b/database/migrations/2026_06_21_184042_drop_limit_columns_from_plans_table.php @@ -0,0 +1,30 @@ +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); + }); + } +}; diff --git a/database/migrations/2026_06_21_194741_add_persona_to_users_table.php b/database/migrations/2026_06_21_194741_add_persona_to_users_table.php new file mode 100644 index 00000000..fffa77cf --- /dev/null +++ b/database/migrations/2026_06_21_194741_add_persona_to_users_table.php @@ -0,0 +1,28 @@ +string('persona')->nullable()->after('registration_ip'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('persona'); + }); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index fed08472..65b73134 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -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, - ); - } + ); } } diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 52871353..db61e02c 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -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.', diff --git a/lang/en/billing.php b/lang/en/billing.php index 915b0eca..ef9df49e 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -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' => [ diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php new file mode 100644 index 00000000..c6434d08 --- /dev/null +++ b/lang/en/onboarding.php @@ -0,0 +1,18 @@ + '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', + ], +]; diff --git a/lang/en/usage.php b/lang/en/usage.php index 17c37d9b..3d348a0e 100644 --- a/lang/en/usage.php +++ b/lang/en/usage.php @@ -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.', diff --git a/lang/en/workspaces.php b/lang/en/workspaces.php index 4b023271..5f57dbf9 100644 --- a/lang/en/workspaces.php +++ b/lang/en/workspaces.php @@ -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.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 3a8ed353..9918a354 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -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.', diff --git a/lang/es/billing.php b/lang/es/billing.php index 28f6b422..221048be 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -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' => [ diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php new file mode 100644 index 00000000..96d2d269 --- /dev/null +++ b/lang/es/onboarding.php @@ -0,0 +1,18 @@ + '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', + ], +]; diff --git a/lang/es/usage.php b/lang/es/usage.php index 6468f119..f71b9264 100644 --- a/lang/es/usage.php +++ b/lang/es/usage.php @@ -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.', diff --git a/lang/es/workspaces.php b/lang/es/workspaces.php index 41b1d087..466ef073 100644 --- a/lang/es/workspaces.php +++ b/lang/es/workspaces.php @@ -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.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index a4382bb9..60def2aa 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -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.', diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 618a9672..ca33b3d3 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -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' => [ diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php new file mode 100644 index 00000000..ee3b4e62 --- /dev/null +++ b/lang/pt-BR/onboarding.php @@ -0,0 +1,18 @@ + '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', + ], +]; diff --git a/lang/pt-BR/usage.php b/lang/pt-BR/usage.php index bd21cee1..cd49ec21 100644 --- a/lang/pt-BR/usage.php +++ b/lang/pt-BR/usage.php @@ -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.', diff --git a/lang/pt-BR/workspaces.php b/lang/pt-BR/workspaces.php index caeabd6f..040945f3 100644 --- a/lang/pt-BR/workspaces.php +++ b/lang/pt-BR/workspaces.php @@ -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.', diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index e48ce749..f472b314 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -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()); }; diff --git a/resources/js/components/UpgradeDialog.vue b/resources/js/components/UpgradeDialog.vue deleted file mode 100644 index b23dc1a8..00000000 --- a/resources/js/components/UpgradeDialog.vue +++ /dev/null @@ -1,319 +0,0 @@ - - - diff --git a/resources/js/components/settings/UsersTab.vue b/resources/js/components/settings/UsersTab.vue index d9cfd119..240ad5f0 100644 --- a/resources/js/components/settings/UsersTab.vue +++ b/resources/js/components/settings/UsersTab.vue @@ -1,7 +1,6 @@ - - diff --git a/resources/js/pages/onboarding/Index.vue b/resources/js/pages/onboarding/Index.vue new file mode 100644 index 00000000..2dfeb5d4 --- /dev/null +++ b/resources/js/pages/onboarding/Index.vue @@ -0,0 +1,119 @@ + + + diff --git a/resources/js/pages/settings/account/Billing.vue b/resources/js/pages/settings/account/Billing.vue index 0098384d..e92aa816 100644 --- a/resources/js/pages/settings/account/Billing.vue +++ b/resources/js/pages/settings/account/Billing.vue @@ -1,7 +1,7 @@