refactor: reorganize billing language keys and implement AccountPolicy for subscription management

This commit is contained in:
Paulo Castellano 2026-05-03 17:26:55 -03:00
parent d529a21617
commit 0f385e1b59
19 changed files with 552 additions and 120 deletions

View file

@ -8,6 +8,7 @@
use App\Models\Plan;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
@ -102,7 +103,7 @@ public function index(Request $request): Response|RedirectResponse
return Inertia::render('settings/account/Billing', [
'hasSubscription' => $account->subscribed(Account::SUBSCRIPTION_NAME),
'onTrial' => $subscription?->onTrial() ?? false,
'trialEndsAt' => $subscription?->trial_ends_at?->toFormattedDateString(),
'trialEndsAt' => $subscription?->trial_ends_at,
'subscription' => $subscription?->only([
'stripe_status',
'ends_at',
@ -111,7 +112,7 @@ public function index(Request $request): Response|RedirectResponse
'plans' => Plan::active()->orderBy('sort')->get(),
'invoices' => $account->invoices()->map(fn ($invoice) => [
'id' => $invoice->id,
'date' => $invoice->date()->toFormattedDateString(),
'date' => $invoice->date(),
'total' => $invoice->total(),
'status' => $invoice->status,
'invoice_pdf' => $invoice->invoice_pdf,
@ -159,6 +160,12 @@ public function swap(Request $request, Plan $plan): RedirectResponse
'Cannot downgrade from yearly to monthly billing.',
);
$authorization = Gate::inspect('swapPlan', [$account, $plan]);
if ($authorization->denied()) {
return back()->with('flash.error', $authorization->message());
}
$subscription->swap($priceId);
$account->update(['plan_id' => $plan->id]);

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class AccountPolicy
{
public function update(User $user, Account $account): bool
{
return $user->id === $account->owner_id;
}
public function manageBilling(User $user, Account $account): bool
{
return $user->id === $account->owner_id;
}
/**
* 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).
*/
public function swapPlan(User $user, Account $account, Plan $plan): Response
{
if ($user->id !== $account->owner_id) {
return Response::deny(__('billing.flash.cannot_manage'));
}
$usage = $account->usage();
$checks = [
'workspaces' => [
'count' => $usage['workspaceCount'],
'limit' => (int) $plan->workspace_limit,
],
'social_accounts' => [
'count' => $usage['socialAccountCount'],
'limit' => (int) $plan->social_account_limit,
],
'members' => [
'count' => $usage['memberCount'] + $usage['pendingInviteCount'],
'limit' => (int) $plan->member_limit,
],
];
foreach ($checks as $type => $data) {
if ($data['count'] > $data['limit']) {
return Response::deny(__('billing.flash.cannot_downgrade.'.$type, [
'plan' => $plan->name,
'count' => (string) $data['count'],
'limit' => (string) $data['limit'],
]));
}
}
return Response::allow();
}
}

View file

@ -14,6 +14,7 @@
'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.',
@ -82,6 +83,12 @@
'flash' => [
'plan_changed' => 'You are now on the :plan plan.',
'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.',
],
],
'processing' => [

View file

@ -1,8 +1,7 @@
<?php
return [
'title' => 'Suscripción',
'description' => 'Administra tu suscripción y método de pago',
'title' => 'Facturación',
'upgrade_dialog' => [
'title' => 'Actualiza tu plan',
@ -15,6 +14,7 @@
'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.',
@ -52,30 +52,43 @@
],
],
'trial' => [
'title' => 'Periodo de prueba activo',
'description' => 'Tu prueba termina el :date. Después, tu suscripción se cobrará automáticamente.',
'plan' => [
'title' => 'Plan',
'description' => 'Gestiona tu plan de suscripción.',
'change' => 'Cambiar plan',
'label' => 'Plan',
'price' => 'Precio',
'month' => 'mes',
'trial' => 'Prueba',
'active' => 'Activo',
'past_due' => 'Vencido',
'cancelling' => 'Cancelando',
'trial_ends' => 'La prueba termina en',
],
'subscription' => [
'title' => 'Tu suscripción',
'status' => 'Estado',
'workspaces' => 'Workspaces',
'quantity' => 'Cantidad de suscripción',
'expires' => 'Expira :date',
'canceled_on' => 'Tu suscripción se cancelará el :date',
'manage' => 'Administrar en Stripe',
'title' => 'Suscripción',
'description' => 'Gestiona tu método de pago, datos de facturación y suscripción.',
'payment_method' => 'Método de pago',
'manage_label' => 'Suscripción',
'manage_stripe' => 'Gestionar en Stripe',
],
'invoices' => [
'title' => 'Facturas',
'description' => 'Historial de pagos',
'description' => 'Descarga tus facturas anteriores.',
'empty' => 'No se encontraron facturas',
'paid' => 'Pagado',
],
'flash' => [
'plan_changed' => 'Ahora estás en el plan :plan.',
'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.',
],
],
'processing' => [
@ -88,14 +101,4 @@
'cancelled_description' => 'Tu pago fue cancelado. No se realizaron cargos.',
'retry' => 'Intentar de nuevo',
],
'status' => [
'active' => 'Activa',
'canceled' => 'Cancelada',
'incomplete' => 'Incompleta',
'incomplete_expired' => 'Expirada',
'past_due' => 'Vencida',
'trialing' => 'Prueba',
'unpaid' => 'Sin pagar',
],
];

View file

@ -218,21 +218,15 @@
'edit' => [
'title' => 'Editar post',
'view_title' => 'Ver post',
'manage_platforms' => 'Administrar plataformas',
'sync' => 'Sincronizar',
'labels' => 'Etiquetas',
'signatures' => 'Firmas',
'schedule' => 'Programar',
'publish' => 'Publicar',
'delete' => 'Eliminar',
'settings' => 'Configuración',
'schedule_for' => 'Programar para',
'scheduled_for' => 'Programado para',
'saving' => 'Guardando...',
'saved' => 'Guardado',
'draft' => 'Borrador',
'scheduled_at' => 'Programado:',
'published_at' => 'Publicado:',
'media' => 'Multimedia',
'add_media' => 'Añadir media',
'caption' => 'Descripción',
@ -250,8 +244,6 @@
'add' => 'Añadir',
'publish_to' => 'Publicar en',
'organize' => 'Organizar',
'no_caption' => 'Sin descripción',
'no_content' => 'Sin contenido',
'no_labels' => 'Todavía no hay etiquetas creadas',
'pick_time' => 'Elegir hora',
'post_now' => 'Publicar ahora',
@ -315,11 +307,6 @@
'failed' => 'Fallido',
],
'empty_state' => [
'title' => 'No hay plataformas seleccionadas',
'description' => 'Selecciona al menos una plataforma para crear tu post',
],
'delete_modal' => [
'title' => 'Eliminar post',
'description' => '¿Estás seguro de que deseas eliminar este post? Esta acción no se puede deshacer.',

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

@ -1,8 +1,7 @@
<?php
return [
'title' => 'Assinatura',
'description' => 'Gerencie sua assinatura e método de pagamento',
'title' => 'Faturamento',
'upgrade_dialog' => [
'title' => 'Faça upgrade do seu plano',
@ -15,6 +14,7 @@
'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.',
@ -52,30 +52,43 @@
],
],
'trial' => [
'title' => 'Período de teste ativo',
'description' => 'Seu período de teste termina em :date. Após isso, sua assinatura será cobrada automaticamente.',
'plan' => [
'title' => 'Plano',
'description' => 'Gerencie seu plano de assinatura.',
'change' => 'Mudar plano',
'label' => 'Plano',
'price' => 'Preço',
'month' => 'mês',
'trial' => 'Trial',
'active' => 'Ativo',
'past_due' => 'Vencido',
'cancelling' => 'Cancelando',
'trial_ends' => 'Teste termina em',
],
'subscription' => [
'title' => 'Sua Assinatura',
'status' => 'Status',
'workspaces' => 'Workspaces',
'quantity' => 'Quantidade da assinatura',
'expires' => 'Expira em :date',
'canceled_on' => 'Sua assinatura será cancelada em :date',
'manage' => 'Gerenciar no Stripe',
'title' => 'Assinatura',
'description' => 'Gerencie seu método de pagamento, dados de cobrança e assinatura.',
'payment_method' => 'Método de pagamento',
'manage_label' => 'Assinatura',
'manage_stripe' => 'Gerenciar no Stripe',
],
'invoices' => [
'title' => 'Faturas',
'description' => 'Histórico de pagamentos',
'description' => 'Baixe suas faturas anteriores.',
'empty' => 'Nenhuma fatura encontrada',
'paid' => 'Pago',
],
'flash' => [
'plan_changed' => 'Você está agora no plano :plan.',
'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.',
],
],
'processing' => [
@ -88,14 +101,4 @@
'cancelled_description' => 'Seu pagamento foi cancelado. Nenhuma cobrança foi realizada.',
'retry' => 'Tentar novamente',
],
'status' => [
'active' => 'Ativo',
'canceled' => 'Cancelado',
'incomplete' => 'Incompleto',
'incomplete_expired' => 'Expirado',
'past_due' => 'Vencido',
'trialing' => 'Teste',
'unpaid' => 'Não pago',
],
];

View file

@ -218,21 +218,15 @@
'edit' => [
'title' => 'Editar Post',
'view_title' => 'Visualizar Post',
'manage_platforms' => 'Gerenciar plataformas',
'sync' => 'Sincronizar',
'labels' => 'Etiqueta',
'signatures' => 'Assinaturas',
'schedule' => 'Agendar',
'publish' => 'Publicar',
'delete' => 'Excluir',
'settings' => 'Configurações',
'schedule_for' => 'Agendar para',
'scheduled_for' => 'Agendado para',
'saving' => 'Salvando...',
'saved' => 'Salvo',
'draft' => 'Rascunho',
'scheduled_at' => 'Agendado:',
'published_at' => 'Publicado:',
'media' => 'Mídia',
'add_media' => 'Adicionar mídia',
'caption' => 'Legenda',
@ -250,8 +244,6 @@
'add' => 'Adicionar',
'publish_to' => 'Publicar em',
'organize' => 'Organizar',
'no_caption' => 'Sem legenda',
'no_content' => 'Sem conteúdo',
'no_labels' => 'Nenhuma etiqueta criada ainda',
'pick_time' => 'Escolher horário',
'post_now' => 'Publicar agora',
@ -315,11 +307,6 @@
'failed' => 'Falhou',
],
'empty_state' => [
'title' => 'Nenhuma plataforma selecionada',
'description' => 'Selecione pelo menos uma plataforma para criar seu post',
],
'delete_modal' => [
'title' => 'Excluir Post',
'description' => 'Tem certeza que deseja excluir este post? Esta ação não pode ser desfeita.',

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconAlertCircle, IconCheck, IconExternalLink, IconInfoCircle, IconRefresh, IconTrash } from '@tabler/icons-vue';
import { IconAlertCircle, IconCheck, IconExternalLink, IconRefresh, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onMounted, onUnmounted } from 'vue';
@ -125,15 +125,6 @@ const getProfileUrl = (platform: string, username: string | null, platformUserId
return urls[platform] || null;
};
const getPlatformTooltip = (platform: string): string | null => {
const tooltips: Record<string, string> = {
'instagram-facebook': trans('accounts.tooltips.instagram_facebook'),
'instagram': trans('accounts.tooltips.instagram_direct'),
'bluesky': trans('accounts.tooltips.bluesky'),
};
return tooltips[platform] || null;
};
const isDisconnected = (account: SocialAccount | null): boolean => {
if (!account) return false;
return account.status === 'disconnected' || account.status === 'token_expired';
@ -173,16 +164,6 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
</template>
<template v-else>{{ platform.label }}</template>
</h3>
<TooltipProvider v-if="getPlatformTooltip(platform.value)">
<Tooltip>
<TooltipTrigger as-child>
<IconInfoCircle class="h-4 w-4 shrink-0 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" class="max-w-[250px]">
<p>{{ getPlatformTooltip(platform.value) }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch v-if="platform.connected && platform.account" :model-value="platform.account.is_active"
@update:model-value="handleToggle(platform.account.id)" />
@ -279,4 +260,4 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
</div>
</div>
</div>
</template>
</template>

View file

@ -7,7 +7,7 @@ import { computed, ref, watch } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { Switch } from '@/components/ui/switch';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { checkout, swap } from '@/routes/app/billing';
@ -24,6 +24,7 @@ interface Plan {
}
const { open, reason, closeUpgrade } = useUpgradeDialog();
const { usage } = useFeatureAccess();
const page = usePage();
const plans = computed<Plan[]>(() => (page.props.plans as Plan[]) ?? []);
@ -66,8 +67,39 @@ const features = (plan: Plan): string[] => [
trans('billing.subscribe.features.ai_images', { count: String(plan.ai_images_limit) }),
];
const downgradeBlocker = (plan: Plan): string | null => {
if (!usage.value) return null;
if (usage.value.workspaceCount > plan.workspace_limit) {
return trans('billing.flash.cannot_downgrade.workspaces', {
plan: plan.name,
count: String(usage.value.workspaceCount),
limit: String(plan.workspace_limit),
});
}
if (usage.value.socialAccountCount > plan.social_account_limit) {
return trans('billing.flash.cannot_downgrade.social_accounts', {
plan: plan.name,
count: String(usage.value.socialAccountCount),
limit: String(plan.social_account_limit),
});
}
const totalMembers = usage.value.memberCount + usage.value.pendingInviteCount;
if (totalMembers > plan.member_limit) {
return trans('billing.flash.cannot_downgrade.members', {
plan: plan.name,
count: String(totalMembers),
limit: String(plan.member_limit),
});
}
return null;
};
const isBlocked = (plan: Plan): boolean => !isCurrent(plan) && downgradeBlocker(plan) !== null;
const ctaLabel = (plan: Plan): string => {
if (isCurrent(plan)) return trans('billing.upgrade_dialog.current_plan');
if (isBlocked(plan)) return trans('billing.upgrade_dialog.unavailable');
if (isSamePlan(plan)) {
return isYearly.value
? trans('billing.upgrade_dialog.switch_to_yearly')
@ -102,31 +134,43 @@ const onOpenChange = (value: boolean) => {
<template>
<Dialog :open="open" @update:open="onOpenChange">
<DialogContent class="w-[95vw] gap-0 p-0 sm:max-w-4xl">
<div class="px-6 pt-6 pb-4">
<DialogTitle class="text-2xl font-semibold">
{{ $t('billing.upgrade_dialog.title') }}
</DialogTitle>
<DialogDescription class="mt-1">
{{ reason ?? $t('billing.upgrade_dialog.description') }}
</DialogDescription>
</div>
<div class="flex flex-col gap-4 px-6 pt-6 pb-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div class="min-w-0">
<DialogTitle class="text-2xl font-semibold">
{{ $t('billing.upgrade_dialog.title') }}
</DialogTitle>
<DialogDescription class="mt-1">
{{ reason ?? $t('billing.upgrade_dialog.description') }}
</DialogDescription>
</div>
<div v-if="!isOnYearly" class="border-t px-6 py-4">
<div class="flex items-center justify-center gap-3">
<span class="text-sm" :class="!isYearly ? 'font-medium' : 'text-muted-foreground'">
<div
v-if="!isOnYearly"
class="inline-flex h-9 shrink-0 items-center rounded-lg bg-muted p-[3px] text-sm"
>
<button
type="button"
class="inline-flex h-full items-center rounded-md px-3 font-medium transition-colors"
:class="!isYearly ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="isYearly = false"
>
{{ $t('billing.subscribe.monthly') }}
</span>
<Switch v-model="isYearly" />
<span class="flex items-center gap-2 text-sm" :class="isYearly ? 'font-medium' : 'text-muted-foreground'">
</button>
<button
type="button"
class="inline-flex h-full items-center gap-2 rounded-md px-3 font-medium transition-colors"
:class="isYearly ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="isYearly = true"
>
{{ $t('billing.subscribe.yearly') }}
<Badge variant="secondary" class="whitespace-nowrap">
<Badge variant="secondary" class="whitespace-nowrap text-[10px]">
{{ $t('billing.subscribe.save_months') }}
</Badge>
</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-4 border-t px-6 pt-8 pb-6 sm:grid-cols-2 lg:grid-cols-4">
<div class="grid grid-cols-1 gap-4 px-6 pt-8 pb-6 sm:grid-cols-2 lg:grid-cols-4">
<div
v-for="plan in plans"
:key="plan.id"
@ -164,9 +208,10 @@ const onOpenChange = (value: boolean) => {
<Button
class="w-full"
:variant="isCurrent(plan) ? 'secondary' : isPopular(plan) ? 'default' : 'outline'"
:variant="isCurrent(plan) || isBlocked(plan) ? 'secondary' : isPopular(plan) ? 'default' : 'outline'"
:loading="processing === plan.id"
:disabled="(processing !== null && processing !== plan.id) || isCurrent(plan)"
:disabled="(processing !== null && processing !== plan.id) || isCurrent(plan) || isBlocked(plan)"
:title="downgradeBlocker(plan) ?? undefined"
@click="handleSelect(plan)"
>
{{ ctaLabel(plan) }}

View file

@ -9,6 +9,11 @@ function getUserTimezone(): string {
}
export default {
formatDate(date: string | null | undefined) {
if (!date) return '-';
return dayjs.utc(date).tz(getUserTimezone()).format('LL');
},
formatDateTime(date: string) {
return dayjs
.utc(date)

View file

@ -4,6 +4,7 @@ import calendar from 'dayjs/plugin/calendar';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import duration from 'dayjs/plugin/duration';
import isBetween from 'dayjs/plugin/isBetween';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import updateLocale from 'dayjs/plugin/updateLocale';
@ -24,6 +25,7 @@ dayjs.extend(relativeTime);
dayjs.extend(duration);
dayjs.extend(updateLocale);
dayjs.extend(advancedFormat);
dayjs.extend(localizedFormat);
dayjs.extend(weekday);
dayjs.extend(isBetween);

View file

@ -8,6 +8,7 @@ import SettingsTabsNav from '@/components/settings/SettingsTabsNav.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { settings as settingsHub } from '@/routes/app';
import { edit as accountEdit } from '@/routes/app/account';
@ -116,7 +117,7 @@ const { openUpgrade } = useUpgradeDialog();
<div v-if="onTrial && trialEndsAt" class="flex items-center gap-3 py-3">
<span class="text-sm">{{ $t('billing.plan.trial_ends') }}</span>
<span class="ml-auto text-sm font-medium">{{ trialEndsAt }}</span>
<span class="ml-auto text-sm font-medium">{{ date.formatDate(trialEndsAt) }}</span>
</div>
</div>
</div>
@ -167,7 +168,7 @@ const { openUpgrade } = useUpgradeDialog();
>
<IconFileText class="size-4 shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<span class="text-sm">{{ invoice.date }}</span>
<span class="text-sm">{{ date.formatDate(invoice.date) }}</span>
<span class="ml-2 text-sm text-muted-foreground">{{ invoice.total }}</span>
</div>
<Badge variant="outline">

View file

@ -181,3 +181,109 @@
$this->actingAs($member)->get(route('app.billing.index'))->assertForbidden();
});
// Swap tests
test('swap blocks downgrade when usage exceeds target plan limits', function () {
config(['trypost.self_hosted' => false]);
$currentPlan = Plan::where('slug', 'plus')->first();
$currentPlan->update([
'stripe_monthly_price_id' => 'price_current_monthly',
'stripe_yearly_price_id' => 'price_current_yearly',
]);
$this->account->update(['plan_id' => $currentPlan->id]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_current_monthly',
]);
Workspace::factory()->count(3)->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
// Starter: workspace_limit=1
$targetPlan = Plan::where('slug', 'starter')->first();
$targetPlan->update([
'stripe_monthly_price_id' => 'price_target_monthly',
'stripe_yearly_price_id' => 'price_target_yearly',
]);
$this->user->unsetRelation('account');
$response = $this->actingAs($this->user)
->from(route('app.billing.index'))
->post(route('app.billing.swap', $targetPlan), [
'price_id' => 'price_target_monthly',
]);
$response->assertRedirect(route('app.billing.index'));
$response->assertSessionHas('flash.error', __('billing.flash.cannot_downgrade.workspaces', [
'plan' => $targetPlan->name,
'count' => '4', // 3 created + 1 from beforeEach
'limit' => '1',
]));
});
test('swap blocks yearly to monthly downgrade', function () {
config(['trypost.self_hosted' => false]);
$plan = Plan::where('slug', 'max')->first();
$plan->update([
'stripe_monthly_price_id' => 'price_monthly',
'stripe_yearly_price_id' => 'price_yearly',
]);
$this->account->update(['plan_id' => $plan->id]);
$this->user->unsetRelation('account');
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_yearly',
]);
$response = $this->actingAs($this->user)
->post(route('app.billing.swap', $plan), [
'price_id' => 'price_monthly',
]);
$response->assertStatus(422);
});
test('swap rejects invalid price_id for plan', function () {
config(['trypost.self_hosted' => false]);
$plan = Plan::where('slug', 'pro')->first();
$plan->update([
'stripe_monthly_price_id' => 'price_monthly',
'stripe_yearly_price_id' => 'price_yearly',
]);
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_other',
]);
$this->user->unsetRelation('account');
$response = $this->actingAs($this->user)
->post(route('app.billing.swap', $plan), [
'price_id' => 'price_unrelated',
]);
$response->assertStatus(422);
});
test('swap requires authentication', function () {
$plan = Plan::first();
$response = $this->post(route('app.billing.swap', $plan));
$response->assertRedirect(route('login'));
});

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Features\AiImagesLimit;
use App\Features\MemberLimit;
use App\Features\SocialAccountLimit;
use App\Features\WorkspaceLimit;
use App\Models\Account;
use App\Models\Plan;
use Illuminate\Support\Facades\DB;
use Laravel\Pennant\Feature;
test('booted hook flushes pennant cache when plan_id changes', function () {
$starter = Plan::where('slug', 'starter')->first();
$plus = Plan::where('slug', 'plus')->first();
$account = Account::factory()->create(['plan_id' => $starter->id]);
expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($starter->workspace_limit);
expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($starter->social_account_limit);
expect(Feature::for($account)->value(MemberLimit::class))->toBe($starter->member_limit);
expect(Feature::for($account)->value(AiImagesLimit::class))->toBe($starter->ai_images_limit);
$account->update(['plan_id' => $plus->id]);
$account->load('plan');
expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($plus->workspace_limit);
expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($plus->social_account_limit);
expect(Feature::for($account)->value(MemberLimit::class))->toBe($plus->member_limit);
expect(Feature::for($account)->value(AiImagesLimit::class))->toBe($plus->ai_images_limit);
});
test('booted hook does not flush pennant when other fields change', function () {
$plan = Plan::where('slug', 'plus')->first();
$account = Account::factory()->create(['plan_id' => $plan->id]);
Feature::for($account)->value(WorkspaceLimit::class);
$cachedRow = DB::table('features')
->where('scope', 'account|'.$account->id)
->first();
expect($cachedRow)->not->toBeNull();
$account->update(['name' => 'Updated Name']);
$stillCachedRow = DB::table('features')
->where('scope', 'account|'.$account->id)
->first();
expect($stillCachedRow->id)->toBe($cachedRow->id);
});

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use App\Models\Account;
use App\Models\Invite;
use App\Models\Plan;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->account = Account::factory()->create();
$this->owner = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->owner->id]);
});
test('usage returns correct counts across the account', function () {
Workspace::factory()->count(2)->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
$workspace = $this->account->workspaces()->first();
SocialAccount::factory()->count(3)->create(['workspace_id' => $workspace->id]);
User::factory()->count(2)->create(['account_id' => $this->account->id]);
Invite::factory()->count(2)->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
]);
$usage = $this->account->usage();
expect($usage)->toBe([
'workspaceCount' => 2,
'socialAccountCount' => 3,
'memberCount' => 3,
'pendingInviteCount' => 2,
]);
});
test('featureLimits returns plan-resolved limits', function () {
$plan = Plan::where('slug', 'plus')->first();
$this->account->update(['plan_id' => $plan->id]);
$limits = $this->account->featureLimits();
expect($limits)->toBe([
'workspaceLimit' => $plan->workspace_limit,
'socialAccountLimit' => $plan->social_account_limit,
'memberLimit' => $plan->member_limit,
'aiImagesLimit' => $plan->ai_images_limit,
]);
});
test('pendingInviteCount excludes accepted invites', function () {
Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
]);
Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'accepted_at' => now(),
]);
expect($this->account->usage()['pendingInviteCount'])->toBe(1);
});

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Invite;
use App\Models\Plan;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Policies\AccountPolicy;
beforeEach(function () {
$this->policy = new AccountPolicy;
$this->account = Account::factory()->create();
$this->owner = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->owner->id]);
});
test('swapPlan allows account owner with usage fitting the target plan', function () {
// Plus: workspace_limit=5, social_account_limit=10, member_limit=5
$plan = Plan::where('slug', 'plus')->first();
$response = $this->policy->swapPlan($this->owner, $this->account, $plan);
expect($response->allowed())->toBeTrue();
});
test('swapPlan denies non-owner', function () {
$member = User::factory()->create(['account_id' => $this->account->id]);
$plan = Plan::where('slug', 'plus')->first();
$response = $this->policy->swapPlan($member, $this->account, $plan);
expect($response->denied())->toBeTrue();
expect($response->message())->toBe(__('billing.flash.cannot_manage'));
});
test('swapPlan denies when workspace count exceeds target plan limit', function () {
Workspace::factory()->count(3)->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
// Starter: workspace_limit=1
$plan = Plan::where('slug', 'starter')->first();
$response = $this->policy->swapPlan($this->owner, $this->account, $plan);
expect($response->denied())->toBeTrue();
expect($response->message())->toBe(__('billing.flash.cannot_downgrade.workspaces', [
'plan' => $plan->name,
'count' => '3',
'limit' => '1',
]));
});
test('swapPlan denies when social account count exceeds target plan limit', function () {
$workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
SocialAccount::factory()->count(6)->create(['workspace_id' => $workspace->id]);
// Starter: social_account_limit=5
$plan = Plan::where('slug', 'starter')->first();
$response = $this->policy->swapPlan($this->owner, $this->account, $plan);
expect($response->denied())->toBeTrue();
expect($response->message())->toBe(__('billing.flash.cannot_downgrade.social_accounts', [
'plan' => $plan->name,
'count' => '6',
'limit' => '5',
]));
});
test('swapPlan denies when members + pending invites exceed target plan limit', function () {
User::factory()->count(2)->create(['account_id' => $this->account->id]);
Invite::factory()->count(2)->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'role' => Role::Member,
]);
// Starter: member_limit=1
$plan = Plan::where('slug', 'starter')->first();
$response = $this->policy->swapPlan($this->owner, $this->account, $plan);
expect($response->denied())->toBeTrue();
expect($response->message())->toBe(__('billing.flash.cannot_downgrade.members', [
'plan' => $plan->name,
'count' => '5',
'limit' => '1',
]));
});
test('swapPlan allows when usage equals target plan limit (boundary)', function () {
Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
// Starter: workspace_limit=1, owner has exactly 1 workspace
$plan = Plan::where('slug', 'starter')->first();
$response = $this->policy->swapPlan($this->owner, $this->account, $plan);
expect($response->allowed())->toBeTrue();
});