refactor: reorganize billing language keys and implement AccountPolicy for subscription management
This commit is contained in:
parent
d529a21617
commit
0f385e1b59
19 changed files with 552 additions and 120 deletions
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
65
app/Policies/AccountPolicy.php
Normal file
65
app/Policies/AccountPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
53
tests/Feature/Models/AccountModelTest.php
Normal file
53
tests/Feature/Models/AccountModelTest.php
Normal 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);
|
||||
});
|
||||
68
tests/Feature/Models/HasUsageTraitTest.php
Normal file
68
tests/Feature/Models/HasUsageTraitTest.php
Normal 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);
|
||||
});
|
||||
112
tests/Unit/Policies/AccountPolicyTest.php
Normal file
112
tests/Unit/Policies/AccountPolicyTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue