refactor: address code review for per-workspace pricing

Bug fix
- Surface the localized "network already connected" message on the
  Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
  callbacks: catch NetworkAlreadyConnectedException before the generic catch
  so the conflict no longer falls through to a generic error + Log::error.

Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
  (onboarding starts checkout directly); delete the now-dead DiscordWidget and
  useFeatureAccess composable; prune orphaned i18n keys left by removing the
  plan picker / upgrade dialog / count limits (billing.subscribe.*,
  accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
  the onboarding persona payload (labels come from i18n); remove
  Persona::options()/label().

Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
  WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
This commit is contained in:
Paulo Castellano 2026-06-21 21:28:47 -03:00
parent cbf8fb283c
commit 0619f2a0f4
42 changed files with 157 additions and 350 deletions

View file

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

View file

@ -12,27 +12,4 @@ enum Persona: string
case Agency = 'agency';
case SmallBusiness = 'small_business';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Creator => 'Creator',
self::Freelancer => 'Freelancer',
self::Startup => 'Startup',
self::Agency => 'Agency',
self::SmallBusiness => 'Small business',
self::Other => 'Other',
};
}
/**
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
fn (self $persona): array => ['value' => $persona->value, 'label' => $persona->label()],
self::cases(),
);
}
}

View file

@ -4,9 +4,7 @@
namespace App\Http\Controllers\App;
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Models\Account;
use App\Models\Plan;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@ -23,33 +21,6 @@ public function subscribe(): RedirectResponse
return redirect()->route('app.onboarding');
}
public function checkout(Request $request, Plan $plan, StartSubscriptionCheckout $checkout): SymfonyResponse|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
$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'],
]);
$priceId = $request->input('price_id');
abort_unless(
$priceId === $plan->stripe_monthly_price_id || $priceId === $plan->stripe_yearly_price_id,
422,
'Invalid price for this plan',
);
return $checkout->redirect($account, $priceId, route('app.onboarding'));
}
public function processing(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
@ -163,7 +134,7 @@ public function swapToYearly(Request $request): RedirectResponse
return redirect()->route('app.billing.index');
}
$authorization = Gate::inspect('swapPlan', [$account, $plan]);
$authorization = Gate::inspect('swapPlan', [$account]);
if ($authorization->denied()) {
return back()->with('flash.error', $authorization->message());

View file

@ -32,7 +32,7 @@ public function index(Request $request): Response|RedirectResponse
}
return Inertia::render('onboarding/Index', [
'personas' => Persona::options(),
'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()),
'selected' => $user->persona?->value,
]);
}

View file

@ -34,7 +34,6 @@ public function index(Request $request): Response|RedirectResponse
}
return Inertia::render('settings/account/Usage', [
'plan' => $account->plan,
'usage' => [
'workspaceCount' => $account->workspaces()->count(),
'socialAccountCount' => $totalSocialAccounts,

View file

@ -10,6 +10,7 @@
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ImageStyle;
use App\Http\Requests\App\Workspace\AutofillBrandRequest;
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
use App\Http\Resources\App\WorkspaceMemberResource;
@ -82,14 +83,10 @@ public function create(Request $request): Response|RedirectResponse
]);
}
public function autofillBrand(Request $request, AutofillBrand $autofill): JsonResponse
public function autofillBrand(AutofillBrandRequest $request, AutofillBrand $autofill): JsonResponse
{
$validated = $request->validate([
'url' => ['required', 'string', 'max:255'],
]);
try {
$metadata = $autofill(data_get($validated, 'url'));
$metadata = $autofill(data_get($request->validated(), 'url'));
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
}

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -131,6 +132,8 @@ public function callback(Request $request): View|RedirectResponse
]);
return redirect()->route('app.social.facebook.select-page');
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Facebook OAuth Error', [
'error' => $e->getMessage(),
@ -256,6 +259,8 @@ public function select(Request $request): View
session()->forget(['facebook_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Facebook page selection error', [
'error' => $e->getMessage(),

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -100,6 +101,8 @@ public function callback(Request $request): View
);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Instagram OAuth Error', [
'error' => $e->getMessage(),

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -110,6 +111,8 @@ public function callback(Request $request): View|RedirectResponse
]);
return redirect()->route('app.social.instagram-facebook.select-page');
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Instagram via Facebook OAuth Error', [
'error' => $e->getMessage(),
@ -182,6 +185,8 @@ public function select(Request $request): View
session()->forget(['instagram_facebook_oauth', 'social_reconnect_id']);
return $result;
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Instagram via Facebook page selection error', ['error' => $e->getMessage()]);

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use App\Services\Social\LinkedInTokenSynchronizer;
use Illuminate\Http\RedirectResponse;
@ -222,6 +223,8 @@ public function select(Request $request): View
session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn Page selection error', [
'error' => $e->getMessage(),

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -157,6 +158,8 @@ public function callback(Request $request): View
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('Threads OAuth Error', [
'error' => $e->getMessage(),

View file

@ -6,6 +6,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -116,6 +117,8 @@ public function callback(Request $request): View|RedirectResponse
]);
return redirect()->route('app.social.youtube.select-channel');
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('YouTube OAuth Error', [
'error' => $e->getMessage(),
@ -250,6 +253,8 @@ public function select(Request $request): View
session()->forget(['youtube_oauth', 'social_reconnect_id']);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('YouTube channel selection error', [
'error' => $e->getMessage(),

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Workspace;
use Illuminate\Foundation\Http\FormRequest;
class AutofillBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'url' => ['required', 'string', 'max:255'],
];
}
}

View file

@ -5,7 +5,6 @@
namespace App\Policies;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Support\BillingCycle;
use Illuminate\Auth\Access\Response;
@ -59,7 +58,7 @@ public function useAi(User $user, Account $account): Response
* 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
public function swapPlan(User $user, Account $account): Response
{
if ($user->id !== $account->owner_id) {
return Response::deny(__('billing.flash.cannot_manage'));

View file

@ -18,6 +18,13 @@
*/
class BillingCycle
{
/** @var array{0: CarbonImmutable, 1: CarbonImmutable}|null */
private ?array $window = null;
private ?Subscription $subscription = null;
private bool $subscriptionResolved = false;
private function __construct(private readonly Account $account) {}
public static function for(Account $account): self
@ -71,6 +78,14 @@ public function periodEnd(): CarbonImmutable
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
*/
private function window(): array
{
return $this->window ??= $this->computeWindow();
}
/**
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
*/
private function computeWindow(): array
{
$subscription = $this->subscription();
@ -116,6 +131,11 @@ private function onTrial(): bool
private function subscription(): ?Subscription
{
return $this->account->subscription(Account::SUBSCRIPTION_NAME);
if (! $this->subscriptionResolved) {
$this->subscription = $this->account->subscription(Account::SUBSCRIPTION_NAME);
$this->subscriptionResolved = true;
}
return $this->subscription;
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

View file

@ -15,8 +15,6 @@
'search' => 'Search accounts...',
'added' => 'Added :date',
'limit_reached' => 'You have reached your plan limit for social accounts.',
'not_connected' => 'Not connected',
'connect' => 'Connect',
'connection_lost' => 'Connection lost',

View file

@ -16,35 +16,11 @@
],
'subscribe' => [
'page_title' => 'Choose your plan',
'eyebrow' => 'Pricing',
'title' => 'Choose the right plan for you',
'description' => 'Pick the plan that fits you. Billed monthly or annually.',
'trial_info' => ':days-day free trial, then billed automatically',
'monthly' => 'Monthly',
'yearly' => 'Yearly',
'per_month' => 'monthly',
'per_year' => 'yearly',
'billed_monthly' => 'Billed monthly',
'billed_yearly' => 'Billed annually',
'features_included' => "What's included:",
'everything_in' => 'Everything in :plan, plus:',
'save_months' => '2 months free',
'popular' => 'Most popular',
'start_trial' => 'Start :days-day free trial',
'subscribe_cta' => 'Subscribe',
'prices' => [
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
'features' => [
'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' => [
'workspace' => 'Each workspace includes 2,500 AI credits per month — about 160 AI images or 280 AI text generations.',
],
],
'plan' => [

View file

@ -4,11 +4,6 @@
return [
'discord' => [
'label' => 'Discord',
'join' => 'Join our Discord community',
],
'back' => 'Back',
'confirm_modal' => [

View file

@ -42,7 +42,6 @@
'success' => 'Workspace created. Connect a social account to start posting.',
],
'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' => [

View file

@ -15,8 +15,6 @@
'search' => 'Buscar cuentas...',
'added' => 'Agregada :date',
'limit_reached' => 'Has alcanzado el límite de cuentas sociales de tu plan.',
'not_connected' => 'No conectado',
'connect' => 'Conectar',
'connection_lost' => 'Conexión perdida',

View file

@ -16,35 +16,11 @@
],
'subscribe' => [
'page_title' => 'Elige tu plan',
'eyebrow' => 'Precios',
'title' => 'Elige el plan ideal para ti',
'description' => 'Elige el plan que te queda. Facturación mensual o anual.',
'trial_info' => 'Prueba gratuita de :days días, luego se cobra automáticamente',
'monthly' => 'Mensual',
'yearly' => 'Anual',
'per_month' => 'mensual',
'per_year' => 'anual',
'billed_monthly' => 'Facturado mensualmente',
'billed_yearly' => 'Facturado anualmente',
'features_included' => 'Qué incluye:',
'everything_in' => 'Todo lo de :plan, más:',
'save_months' => '2 meses gratis',
'popular' => 'Más popular',
'start_trial' => 'Comenzar prueba de :days días',
'subscribe_cta' => 'Suscribirse',
'prices' => [
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
],
'features' => [
'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' => [
'workspace' => 'Cada workspace incluye 2.500 créditos de IA al mes — alrededor de 160 imágenes o 280 textos generados con IA.',
],
],
'plan' => [

View file

@ -4,11 +4,6 @@
return [
'discord' => [
'label' => 'Discord',
'join' => 'Unite a nuestra comunidad en Discord',
],
'back' => 'Volver',
'confirm_modal' => [

View file

@ -42,7 +42,6 @@
'success' => 'Workspace creado. Conecta una cuenta social para empezar a publicar.',
],
'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' => [

View file

@ -15,8 +15,6 @@
'search' => 'Buscar contas...',
'added' => 'Adicionada :date',
'limit_reached' => 'Você atingiu o limite de contas sociais do seu plano.',
'not_connected' => 'Não conectado',
'connect' => 'Conectar',
'connection_lost' => 'Conexão perdida',

View file

@ -16,35 +16,11 @@
],
'subscribe' => [
'page_title' => 'Escolha seu plano',
'eyebrow' => 'Preços',
'title' => 'Escolha o plano ideal pra você',
'description' => 'Escolha o plano que combina com você. Cobrança mensal ou anual.',
'trial_info' => ':days dias grátis, depois cobrança automática',
'monthly' => 'Mensal',
'yearly' => 'Anual',
'per_month' => 'mensal',
'per_year' => 'anual',
'billed_monthly' => 'Cobrança mensal',
'billed_yearly' => 'Cobrança anual',
'features_included' => 'O que está incluído:',
'everything_in' => 'Tudo do :plan, mais:',
'save_months' => '2 meses grátis',
'popular' => 'Mais popular',
'start_trial' => 'Iniciar teste de :days dias',
'subscribe_cta' => 'Assinar',
'prices' => [
'workspace' => ['monthly' => 'R$ 60', 'yearly_per_month' => 'R$ 50', 'yearly' => 'R$ 600'],
],
'features' => [
'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' => [
'workspace' => 'Cada workspace inclui 2.500 créditos de IA por mês — cerca de 160 imagens ou 280 textos gerados por IA.',
],
],
'plan' => [

View file

@ -4,11 +4,6 @@
return [
'discord' => [
'label' => 'Discord',
'join' => 'Entre na nossa comunidade no Discord',
],
'back' => 'Voltar',
'confirm_modal' => [

View file

@ -42,7 +42,6 @@
'success' => 'Workspace criado. Conecte uma conta social para começar a postar.',
],
'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' => [

View file

@ -1,27 +0,0 @@
<script setup lang="ts">
import { IconBrandDiscord } from '@tabler/icons-vue';
import { captureEvent } from '@/posthog';
const DISCORD_URL = 'https://trypost.it/discord';
// Distinct from the marketing site's site_discord_clicked, following the
// app's noun.verb event convention.
const trackClick = () => {
captureEvent('discord.clicked', { placement: 'floating_widget' });
};
</script>
<template>
<a
:href="DISCORD_URL"
target="_blank"
rel="noopener noreferrer"
:aria-label="$t('common.discord.join')"
class="group fixed bottom-5 right-5 z-50 inline-flex items-center gap-2 rounded-full border-2 border-foreground bg-[#5865F2] py-2.5 pl-3 pr-4 text-sm font-bold text-white shadow-md transition-all hover:-translate-y-0.5 hover:shadow-lg"
@click="trackClick"
>
<IconBrandDiscord class="size-5 shrink-0" stroke-width="2" />
<span>{{ $t('common.discord.label') }}</span>
</a>
</template>

View file

@ -1,47 +0,0 @@
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import type { Usage } from '@/types';
interface Plan {
id: string;
slug: string;
name: string;
}
interface Features {
monthlyCreditsLimit: number;
}
export const useFeatureAccess = () => {
const page = usePage();
const isSelfHosted = computed(() => page.props.selfHosted as boolean);
const plan = computed<Plan | null>(() => (page.props.auth as { plan: Plan | null }).plan ?? null);
const usage = computed<Usage | null>(() => (page.props.usage as Usage | null) ?? null);
const features = computed<Features | null>(() => (page.props.features as Features | null) ?? null);
const monthlyCreditsLimit = computed(() => features.value?.monthlyCreditsLimit ?? 0);
const canCreateWorkspace = computed(() => true);
const canConnectSocialAccount = computed(() => true);
const canInviteMember = computed(() => true);
const hasCreditsLeft = computed(() => {
if (isSelfHosted.value) return true;
if (!usage.value) return true;
return usage.value.creditsUsed < monthlyCreditsLimit.value;
});
return {
plan,
usage,
features,
isSelfHosted,
monthlyCreditsLimit,
canCreateWorkspace,
canConnectSocialAccount,
canInviteMember,
hasCreditsLeft,
};
};

View file

@ -15,17 +15,12 @@ import type { FunctionalComponent } from 'vue';
import { store } from '@/routes/app/onboarding';
interface Persona {
value: string;
label: string;
}
const { selected } = defineProps<{
personas: Persona[];
const props = defineProps<{
personas: string[];
selected?: string | null;
}>();
const form = useForm({ persona: selected ?? '' });
const form = useForm({ persona: props.selected ?? '' });
const icons: Record<string, FunctionalComponent> = {
creator: IconUser,
@ -77,22 +72,22 @@ const submit = (): void => {
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="persona in personas"
:key="persona.value"
:key="persona"
type="button"
:class="[
'relative flex cursor-pointer flex-col items-start gap-3 rounded-2xl border-2 border-foreground p-5 text-left shadow-2xs transition-shadow hover:shadow-md',
form.persona === persona.value ? 'bg-violet-100' : 'bg-card',
form.persona === persona ? 'bg-violet-100' : 'bg-card',
]"
@click="select(persona.value)"
@click="select(persona)"
>
<span class="inline-flex size-10 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
<component :is="icons[persona.value] ?? IconDots" class="size-5 text-foreground" stroke-width="2" />
<component :is="icons[persona] ?? IconDots" class="size-5 text-foreground" stroke-width="2" />
</span>
<span class="text-base font-bold tracking-tight text-foreground">
{{ personaLabel(persona.value) }}
{{ personaLabel(persona) }}
</span>
<span
v-if="form.persona === persona.value"
v-if="form.persona === persona"
class="absolute right-4 top-4 inline-flex size-5 items-center justify-center rounded-full border-2 border-foreground bg-foreground"
>
<IconCheck class="size-3 text-background" stroke-width="3" />

View file

@ -17,7 +17,6 @@ import { index as usageIndex } from '@/routes/app/usage';
import type { AuthPlan } from '@/types';
interface Plan {
name: string;
slug: string;
}

View file

@ -18,11 +18,6 @@ import { edit as accountEdit } from '@/routes/app/account';
import { index as billingIndex } from '@/routes/app/billing';
import { index as usageIndex } from '@/routes/app/usage';
interface Plan {
name: string;
slug: string;
}
interface UsageData {
workspaceCount: number;
socialAccountCount: number;
@ -32,7 +27,6 @@ interface UsageData {
}
defineProps<{
plan: Plan | null;
usage: UsageData;
}>();

View file

@ -58,7 +58,6 @@
Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe');
Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding');
Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store');
Route::post('billing/checkout/{plan}', [BillingController::class, 'checkout'])->name('app.billing.checkout');
Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing');
Route::get('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create');

View file

@ -9,35 +9,6 @@
use App\Support\BillingCycle;
use Illuminate\Support\Carbon;
/**
* @param array<string, mixed> $subscriptionAttributes
*/
function billingAccount(string $price, array $subscriptionAttributes = [], int $workspaces = 1): Account
{
$plan = Plan::query()->firstOrFail();
$plan->update([
'stripe_monthly_price_id' => 'price_month',
'stripe_yearly_price_id' => 'price_year',
]);
$account = Account::factory()->create([
'plan_id' => $plan->id,
'trial_ends_at' => null,
]);
$account->subscriptions()->create(array_merge([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => $price,
'quantity' => $workspaces,
], $subscriptionAttributes));
Workspace::factory()->count($workspaces)->create(['account_id' => $account->id]);
return $account->refresh();
}
test('monthly allotment is credits per workspace times workspace count', function () {
$account = billingAccount('price_month', workspaces: 3);
@ -103,7 +74,6 @@ function billingAccount(string $price, array $subscriptionAttributes = [], int $
});
test('without a subscription the allotment falls back to a monthly amount', function () {
$plan = Plan::query()->firstOrFail();
$account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]);
Workspace::factory()->count(2)->create(['account_id' => $account->id]);

View file

@ -8,12 +8,12 @@
test('syncWorkspaceQuantity does not touch Stripe in self-hosted mode', function () {
config()->set('trypost.self_hosted', true);
$subscription = Mockery::mock(Subscription::class);
$subscription = mock(Subscription::class);
$subscription->shouldReceive('active')->andReturnTrue();
$subscription->shouldReceive('updateQuantity')->never();
$account = Mockery::mock(Account::class)->makePartial();
$account->shouldReceive('subscription')->andReturn($subscription);
$account = mock(Account::class)->makePartial();
$account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
$account->shouldReceive('workspaces->count')->andReturn(3);
$account->syncWorkspaceQuantity();
@ -22,11 +22,11 @@
test('syncWorkspaceQuantity does not touch Stripe without an active subscription', function () {
config()->set('trypost.self_hosted', false);
$subscription = Mockery::mock(Subscription::class);
$subscription = mock(Subscription::class);
$subscription->shouldReceive('active')->andReturnFalse();
$subscription->shouldReceive('updateQuantity')->never();
$account = Mockery::mock(Account::class)->makePartial();
$account = mock(Account::class)->makePartial();
$account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
$account->syncWorkspaceQuantity();
@ -35,11 +35,11 @@
test('syncWorkspaceQuantity updates the subscription quantity to the workspace count', function () {
config()->set('trypost.self_hosted', false);
$subscription = Mockery::mock(Subscription::class);
$subscription = mock(Subscription::class);
$subscription->shouldReceive('active')->andReturnTrue();
$subscription->shouldReceive('updateQuantity')->once()->with(3);
$account = Mockery::mock(Account::class)->makePartial();
$account = mock(Account::class)->makePartial();
$account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
$account->shouldReceive('workspaces->count')->andReturn(3);

View file

@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Enums\Plan\Slug;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Plan;
@ -40,16 +39,6 @@
$response->assertRedirect(route('app.onboarding'));
});
test('checkout redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
$plan = Plan::where('slug', 'workspace')->first();
$response = $this->actingAs($this->user)
->post(route('app.billing.checkout', $plan), ['price_id' => 'price_x']);
$response->assertRedirect(route('app.calendar'));
});
test('swapToYearly redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);
@ -228,13 +217,6 @@
});
// Checkout tests
test('checkout requires authentication', function () {
$plan = Plan::first();
$response = $this->post(route('app.billing.checkout', $plan));
$response->assertRedirect(route('login'));
});
// Portal tests
test('portal requires authentication', function () {
$response = $this->get(route('app.billing.portal'));
@ -324,17 +306,6 @@
->assertRedirect(route('app.billing.index'));
});
test('checkout rejects an archived plan', function () {
config(['trypost.self_hosted' => false]);
$archived = Plan::factory()->archived()->create(['slug' => Slug::Starter, 'name' => 'Legacy']);
$response = $this->actingAs($this->user)
->post(route('app.billing.checkout', $archived), ['price_id' => 'price_x']);
$response->assertNotFound();
});
test('swapToYearly requires authentication', function () {
$response = $this->post(route('app.billing.swap-to-yearly'));

View file

@ -23,7 +23,7 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('owner can invite members with no count limit', function () {
test('owner can invite members regardless of count', function () {
$members = User::factory()->count(10)->create([
'account_id' => $this->account->id,
]);

View file

@ -4,6 +4,8 @@
use App\Enums\UserWorkspace\Role;
use App\Models\AccessToken;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -108,3 +110,48 @@ function feedFixture(string $name): string
{
return file_get_contents(base_path("tests/fixtures/feeds/{$name}.xml"));
}
/**
* Create an account on the Workspace plan with an active subscription on the
* given Stripe price, plus N workspaces. Used by the billing-cycle tests.
*
* @param array<string, mixed> $subscriptionAttributes
*/
function billingAccount(string $price, array $subscriptionAttributes = [], int $workspaces = 1): Account
{
$plan = Plan::query()->firstOrFail();
$plan->update([
'stripe_monthly_price_id' => 'price_month',
'stripe_yearly_price_id' => 'price_year',
]);
$account = Account::factory()->create([
'plan_id' => $plan->id,
'trial_ends_at' => null,
]);
$account->subscriptions()->create(array_merge([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => $price,
'quantity' => $workspaces,
], $subscriptionAttributes));
Workspace::factory()->count($workspaces)->create(['account_id' => $account->id]);
return $account->refresh();
}
/**
* Attach an active default subscription to the given account.
*/
function subscribeAccount(Account $account): void
{
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
}

View file

@ -4,16 +4,15 @@
use App\Enums\User\Persona;
test('every persona has a non-empty label', function () {
test('persona values are stable', function () {
expect(array_map(fn (Persona $persona): string => $persona->value, Persona::cases()))
->toBe(['creator', 'freelancer', 'startup', 'agency', 'small_business', 'other']);
});
test('every persona has an onboarding label in every locale', function (string $locale) {
foreach (Persona::cases() as $persona) {
expect($persona->label())->toBeString()->not->toBe('');
$key = "onboarding.personas.{$persona->value}";
expect(__($key, [], $locale))->not->toBe($key);
}
});
test('options returns a value and label for every persona', function () {
$options = Persona::options();
expect($options)->toHaveCount(count(Persona::cases()))
->and($options[0])->toHaveKeys(['value', 'label'])
->and($options[0]['value'])->toBe(Persona::Creator->value);
});
})->with(['en', 'es', 'pt-BR']);

View file

@ -20,33 +20,20 @@
});
test('swapPlan allows the account owner', function () {
$plan = Plan::where('slug', 'workspace')->first();
$response = $this->policy->swapPlan($this->owner, $this->account, $plan);
$response = $this->policy->swapPlan($this->owner, $this->account);
expect($response->allowed())->toBeTrue();
});
test('swapPlan denies a non-owner', function () {
$member = User::factory()->create(['account_id' => $this->account->id]);
$plan = Plan::where('slug', 'workspace')->first();
$response = $this->policy->swapPlan($member, $this->account, $plan);
$response = $this->policy->swapPlan($member, $this->account);
expect($response->denied())->toBeTrue();
expect($response->message())->toBe(__('billing.flash.cannot_manage'));
});
function subscribeAccount(Account $account): void
{
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
}
test('useAi allows when subscribed and credits remain', function () {
config()->set('trypost.self_hosted', false);
Workspace::factory()->create([