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:
parent
cbf8fb283c
commit
0619f2a0f4
42 changed files with 157 additions and 350 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
25
app/Http/Requests/App/Workspace/AutofillBrandRequest.php
Normal file
25
app/Http/Requests/App/Workspace/AutofillBrandRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@
|
|||
|
||||
return [
|
||||
|
||||
'discord' => [
|
||||
'label' => 'Discord',
|
||||
'join' => 'Join our Discord community',
|
||||
],
|
||||
|
||||
'back' => 'Back',
|
||||
|
||||
'confirm_modal' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@
|
|||
|
||||
return [
|
||||
|
||||
'discord' => [
|
||||
'label' => 'Discord',
|
||||
'join' => 'Unite a nuestra comunidad en Discord',
|
||||
],
|
||||
|
||||
'back' => 'Volver',
|
||||
|
||||
'confirm_modal' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@
|
|||
|
||||
return [
|
||||
|
||||
'discord' => [
|
||||
'label' => 'Discord',
|
||||
'join' => 'Entre na nossa comunidade no Discord',
|
||||
],
|
||||
|
||||
'back' => 'Voltar',
|
||||
|
||||
'confirm_modal' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { index as usageIndex } from '@/routes/app/usage';
|
|||
import type { AuthPlan } from '@/types';
|
||||
|
||||
interface Plan {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue