feat: implement workspace creation limits based on subscription plans and remove unused data retention feature

This commit is contained in:
Paulo Castellano 2026-05-03 14:38:31 -03:00
parent e1961374c0
commit d58af18e82
25 changed files with 109 additions and 122 deletions

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class DataRetentionDays
{
public function resolve(Account $scope): int
{
return $scope->plan?->data_retention_days ?? 30;
}
}

View file

@ -6,7 +6,6 @@
use App\Enums\Ai\UsageType;
use App\Features\AiImagesLimit;
use App\Features\DataRetentionDays;
use App\Features\MemberLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
@ -50,7 +49,6 @@ public function index(Request $request): Response|RedirectResponse
'aiImagesUsed' => AiUsageLog::monthlyCount($account->id, UsageType::Image),
'aiImagesLimit' => Feature::for($account)->value(AiImagesLimit::class),
'aiTextUsed' => AiUsageLog::monthlyCount($account->id, UsageType::Text),
'dataRetentionDays' => Feature::for($account)->value(DataRetentionDays::class),
],
]);
}

View file

@ -8,9 +8,11 @@
use App\Actions\Workspace\CreateWorkspace;
use App\Actions\Workspace\DeleteWorkspace;
use App\Enums\Workspace\BrandFont;
use App\Features\WorkspaceLimit;
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;
@ -20,6 +22,7 @@
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Throwable;
@ -66,13 +69,16 @@ public function create(Request $request): Response|RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
if ($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 redirect()->route('app.calendar')
->with('error', __('workspaces.limit_reached'));
}
return Inertia::render('workspaces/Create');
}
@ -95,6 +101,10 @@ 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();
$isFirstWorkspace = ! $user->workspaces()->exists();
@ -228,4 +238,23 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons
return redirect()->route('app.workspaces.index')
->with('success', 'Workspace deleted successfully!');
}
/**
* 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) Feature::for($account)->value(WorkspaceLimit::class);
return $account->workspaces()->count() >= $limit;
}
}

View file

@ -26,7 +26,6 @@ class Plan extends Model
'member_limit',
'workspace_limit',
'ai_images_limit',
'data_retention_days',
'sort',
'is_archived',
];
@ -40,7 +39,6 @@ protected function casts(): array
'member_limit' => 'integer',
'workspace_limit' => 'integer',
'ai_images_limit' => 'integer',
'data_retention_days' => 'integer',
'sort' => 'integer',
];
}

View file

@ -29,7 +29,6 @@ public function definition(): array
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
'workspace_limit' => fake()->randomElement([1, 3, 10, 50]),
'ai_images_limit' => fake()->randomElement([10, 50, 200, 1000]),
'data_retention_days' => fake()->randomElement([30, 90, 365, 730]),
'sort' => 0,
'is_archived' => false,
];

View file

@ -20,7 +20,6 @@ public function up(): void
$table->integer('member_limit');
$table->integer('workspace_limit');
$table->integer('ai_images_limit');
$table->integer('data_retention_days');
$table->integer('sort')->default(0);
$table->boolean('is_archived')->default(false);
$table->timestamps();

View file

@ -25,7 +25,6 @@ public function run(): void
'member_limit' => 1,
'workspace_limit' => 1,
'ai_images_limit' => 50,
'data_retention_days' => 30,
'sort' => 1,
],
[
@ -37,7 +36,6 @@ public function run(): void
'member_limit' => 5,
'workspace_limit' => 5,
'ai_images_limit' => 150,
'data_retention_days' => 60,
'sort' => 2,
],
[
@ -49,7 +47,6 @@ public function run(): void
'member_limit' => 15,
'workspace_limit' => 15,
'ai_images_limit' => 500,
'data_retention_days' => 90,
'sort' => 3,
],
[
@ -61,7 +58,6 @@ public function run(): void
'member_limit' => 20,
'workspace_limit' => 50,
'ai_images_limit' => 2000,
'data_retention_days' => 730,
'sort' => 4,
],
];

View file

@ -30,7 +30,6 @@
'workspaces' => ':count workspaces',
'members' => ':count team members',
'ai_images' => ':count AI images/mo',
'data_retention' => ':days data retention',
],
],

View file

@ -7,18 +7,10 @@
'section_account_description' => 'Quotas and limits for your :plan plan.',
'section_ai' => 'AI Generation',
'section_ai_description' => 'AI image and video generation usage for the current month.',
'section_data' => 'Data',
'section_data_description' => 'Data retention and storage for your plan.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Social Accounts',
'members' => 'Members',
'ai_images' => 'Images',
'ai_text' => 'Text generation',
'data_retention' => 'Data Retention',
'unlimited' => 'Unlimited',
'days' => 'days',
'year' => 'year',
'years' => 'years',
];

View file

@ -52,4 +52,6 @@
'first_workspace_success' => 'Workspace created. Connect a social account to start posting.',
'success' => 'Workspace created.',
],
'limit_reached' => 'You have reached your plan limit for workspaces.',
];

View file

@ -31,7 +31,6 @@
'workspaces' => ':count workspaces',
'members' => ':count miembros del equipo',
'ai_images' => ':count imágenes IA/mes',
'data_retention' => ':days de retención de datos',
],
],

View file

@ -7,18 +7,10 @@
'section_account_description' => 'Cuotas y límites de tu plan :plan.',
'section_ai' => 'Generación AI',
'section_ai_description' => 'Uso de generación de imágenes y videos AI del mes actual.',
'section_data' => 'Datos',
'section_data_description' => 'Retención de datos y almacenamiento de tu plan.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Cuentas Sociales',
'members' => 'Miembros',
'ai_images' => 'Imágenes',
'ai_text' => 'Generación de texto',
'data_retention' => 'Retención de Datos',
'unlimited' => 'Ilimitado',
'days' => 'días',
'year' => 'año',
'years' => 'años',
];

View file

@ -52,4 +52,6 @@
'first_workspace_success' => 'Workspace creado. Conecta una cuenta social para empezar a publicar.',
'success' => 'Workspace creado.',
],
'limit_reached' => 'Has alcanzado el límite de workspaces de tu plan.',
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -31,7 +31,6 @@
'workspaces' => ':count workspaces',
'members' => ':count membros da equipe',
'ai_images' => ':count imagens IA/mês',
'data_retention' => ':days de retenção de dados',
],
],

View file

@ -7,18 +7,10 @@
'section_account_description' => 'Cotas e limites do seu plano :plan.',
'section_ai' => 'Geração AI',
'section_ai_description' => 'Uso de geração de imagens e vídeos AI do mês atual.',
'section_data' => 'Dados',
'section_data_description' => 'Retenção de dados e armazenamento do seu plano.',
'workspaces' => 'Workspaces',
'social_accounts' => 'Contas Sociais',
'members' => 'Membros',
'ai_images' => 'Imagens',
'ai_text' => 'Geração de texto',
'data_retention' => 'Retenção de Dados',
'unlimited' => 'Ilimitado',
'days' => 'dias',
'year' => 'ano',
'years' => 'anos',
];

View file

@ -52,4 +52,6 @@
'first_workspace_success' => 'Workspace criado. Conecte uma conta social para começar a postar.',
'success' => 'Workspace criado.',
],
'limit_reached' => 'Você atingiu o limite de workspaces do seu plano.',
];

View file

@ -19,7 +19,6 @@ interface Plan {
member_limit: number;
workspace_limit: number;
ai_images_limit: number;
data_retention_days: number;
}
const props = defineProps<{
@ -35,11 +34,6 @@ const getPrice = (plan: Plan): string => {
return trans(key);
};
const formatRetention = (days: number): string => {
if (days >= 730) return '2 years';
return `${days} days`;
};
const selectPlan = (plan: Plan) => {
processing.value = plan.id;
const priceId = isYearly.value ? plan.stripe_yearly_price_id : plan.stripe_monthly_price_id;
@ -53,7 +47,6 @@ const features = (plan: Plan): string[] => [
trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }),
trans('billing.subscribe.features.members', { count: String(plan.member_limit) }),
trans('billing.subscribe.features.ai_images', { count: String(plan.ai_images_limit) }),
trans('billing.subscribe.features.data_retention', { days: formatRetention(plan.data_retention_days) }),
];
const isPopular = (plan: Plan): boolean => {

View file

@ -1,6 +1,5 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import UsageMetricRow from '@/components/settings/UsageMetricRow.vue';
import AppLayout from '@/layouts/AppLayout.vue';
interface Plan {
@ -18,22 +17,12 @@ interface Usage {
aiImagesUsed: number;
aiImagesLimit: number;
aiTextUsed: number;
dataRetentionDays: number;
}
const props = defineProps<{
plan: Plan | null;
usage: Usage;
}>();
const formatRetention = (days: number): string => {
if (days >= 730) return trans('usage.unlimited');
if (days >= 365) {
const years = Math.floor(days / 365);
return `${years} ${years === 1 ? trans('usage.year') : trans('usage.years')}`;
}
return `${days} ${trans('usage.days')}`;
};
</script>
<template>
@ -95,28 +84,6 @@ const formatRetention = (days: number): string => {
/>
</div>
</section>
<hr class="my-8 border-border" />
<!-- Data -->
<section class="grid grid-cols-1 gap-8 md:grid-cols-[280px_1fr] md:gap-16">
<div>
<h2 class="text-lg font-semibold tracking-tight">{{ $t('usage.section_data') }}</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ $t('usage.section_data_description') }}
</p>
</div>
<div class="divide-y">
<div class="flex items-center gap-3 py-3">
<div class="size-5 shrink-0" />
<span class="text-sm">{{ $t('usage.data_retention') }}</span>
<span class="ml-auto text-sm tabular-nums">
<span class="font-medium">{{ formatRetention(usage.dataRetentionDays) }}</span>
</span>
</div>
</div>
</section>
</div>
</AppLayout>
</template>

View file

@ -18,7 +18,6 @@
->and($starter->member_limit)->toBe(1)
->and($starter->workspace_limit)->toBe(1)
->and($starter->ai_images_limit)->toBe(50)
->and($starter->data_retention_days)->toBe(30)
->and($starter->sort)->toBe(1);
$max = Plan::where('slug', Slug::Max)->first();
@ -28,7 +27,6 @@
->and($max->member_limit)->toBe(20)
->and($max->workspace_limit)->toBe(50)
->and($max->ai_images_limit)->toBe(2000)
->and($max->data_retention_days)->toBe(730)
->and($max->sort)->toBe(4);
});

View file

@ -43,6 +43,5 @@
->and($plan->member_limit)->toBeInt()
->and($plan->workspace_limit)->toBeInt()
->and($plan->ai_images_limit)->toBeInt()
->and($plan->data_retention_days)->toBeInt()
->and($plan->sort)->toBeInt();
});

View file

@ -118,3 +118,72 @@
test('stripe name returns account name', function () {
expect($this->account->stripeName())->toBe($this->account->name);
});
test('create workspace is blocked when account hits plan limit in saas mode', 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',
]);
// Starter plan: workspace_limit = 1; the user already owns 1 workspace (from beforeEach).
$response = $this->actingAs($this->user)->get(route('app.workspaces.create'));
$response->assertRedirect(route('app.calendar'))
->assertSessionHas('error', __('workspaces.limit_reached'));
});
test('create workspace is allowed when below plan limit in saas mode', 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(),
'stripe_status' => 'active',
'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]);
$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();
});

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
use App\Features\DataRetentionDays;
use App\Models\Account;
use App\Models\Plan;
test('returns plan data retention days', function () {
$plan = new Plan(['data_retention_days' => 365]);
$account = new Account;
$account->setRelation('plan', $plan);
expect((new DataRetentionDays)->resolve($account))->toBe(365);
});
test('falls back to 30 when no plan', function () {
$account = new Account;
$account->setRelation('plan', null);
expect((new DataRetentionDays)->resolve($account))->toBe(30);
});