feat: implement workspace creation limits based on subscription plans and remove unused data retention feature
This commit is contained in:
parent
e1961374c0
commit
d58af18e82
25 changed files with 109 additions and 122 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
'workspaces' => ':count workspaces',
|
||||
'members' => ':count team members',
|
||||
'ai_images' => ':count AI images/mo',
|
||||
'data_retention' => ':days data retention',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in a new issue