feat: per-workspace pricing, onboarding, and billing overhaul
Pricing - Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the workspace count and syncs on workspace create/delete. - 2,500 AI credits per workspace, pooled at the account level; monthly reset on the billing anniversary, annual granted upfront (no rollover). - One social account per network per workspace; remove all count-based limits (workspace/social/member) and the legacy plan tiers (single Workspace plan). Onboarding (cloud only: SELF_HOSTED=false + PostHog) - Replace the /subscribe plan picker with /onboarding persona selection (Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user (users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly price. 8-day trial so Stripe displays 7. Billing screen - Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade banner for monthly subscribers (swapToYearly). - Current-plan card shows the workspace count instead of the plan name. System AI - Brand analyzer / workspace autofill is always allowed and never debits credits (system feature, not the user's usage). Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network, and onboarding logic.
This commit is contained in:
parent
0a407c4d3b
commit
cbf8fb283c
102 changed files with 1644 additions and 1596 deletions
|
|
@ -193,6 +193,8 @@ STRIPE_SECRET=
|
|||
STRIPE_WEBHOOK_SECRET=
|
||||
# Set to false to allow generic signup trial without requiring a card.
|
||||
REQUIRE_CARD_FOR_TRIAL=true
|
||||
# Free trial length in days (Stripe Checkout shows one day fewer).
|
||||
CASHIER_TRIAL_DAYS=8
|
||||
|
||||
# Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder.
|
||||
STRIPE_STARTER_MONTHLY=
|
||||
|
|
@ -203,6 +205,8 @@ STRIPE_PRO_MONTHLY=
|
|||
STRIPE_PRO_YEARLY=
|
||||
STRIPE_MAX_MONTHLY=
|
||||
STRIPE_MAX_YEARLY=
|
||||
STRIPE_WORKSPACE_MONTHLY=
|
||||
STRIPE_WORKSPACE_YEARLY=
|
||||
|
||||
# Laravel Nightwatch (production telemetry — disabled by default in dev)
|
||||
NIGHTWATCH_ENABLED=false
|
||||
|
|
|
|||
|
|
@ -241,6 +241,13 @@ ## AI agents (`app/Ai/Agents`)
|
|||
- Put system/instruction text in Blade under `resources/views/prompts/` (e.g. `prompts.post_content.generator`, `prompts.post_image.regenerator`).
|
||||
- In `instructions()`, return `view('prompts....', [...])->render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`.
|
||||
|
||||
## System AI (always allowed, never metered)
|
||||
|
||||
- The brand analyzer / workspace autofill (`App\Services\Brand\BrandAnalyzerRunner`, `App\Actions\Ai\AutofillBrand`, `WorkspaceController::autofillBrand`) is a **system** feature, not the user's AI usage. It runs during workspace creation, before the user has AI access.
|
||||
- It MUST always be allowed: NEVER gate it behind the `useAi` policy, an active subscription, or a credit check.
|
||||
- It MUST NOT deduct anything: NEVER call `RecordAiUsage` (or otherwise consume the account's credits) for brand analysis. Cost is the platform's, not the user's.
|
||||
- Any future "system" AI helper (runs as part of the platform, not on behalf of a workspace's metered quota) follows the same rule: ungated and unmetered.
|
||||
|
||||
## Icons (@tabler/icons-vue)
|
||||
|
||||
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
namespace App\Actions\Ai;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Brand\BrandAnalyzerRunner;
|
||||
use App\Services\Brand\BrandMetadata;
|
||||
use App\Services\Brand\HomepageMetaExtractor;
|
||||
|
|
@ -20,7 +19,7 @@ public function __construct(
|
|||
private readonly LogoColorExtractor $logoColors,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $url, ?Workspace $workspace = null, ?string $userId = null): BrandMetadata
|
||||
public function __invoke(string $url): BrandMetadata
|
||||
{
|
||||
$url = $this->fetcher->normalizeUrl($url);
|
||||
|
||||
|
|
@ -54,7 +53,7 @@ public function __invoke(string $url, ?Workspace $workspace = null, ?string $use
|
|||
return $metadata;
|
||||
}
|
||||
|
||||
$analysis = $this->analyzer->analyze($this->extractor->extractBodyHtml($html), $workspace, $userId);
|
||||
$analysis = $this->analyzer->analyze($this->extractor->extractBodyHtml($html));
|
||||
|
||||
return $analysis === null
|
||||
? $metadata
|
||||
|
|
|
|||
40
app/Actions/Billing/StartSubscriptionCheckout.php
Normal file
40
app/Actions/Billing/StartSubscriptionCheckout.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Billing;
|
||||
|
||||
use App\Models\Account;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StartSubscriptionCheckout
|
||||
{
|
||||
/**
|
||||
* Create a Stripe Checkout session for the given price and return an Inertia
|
||||
* redirect to it. Quantity tracks the account's workspace count; a trial is
|
||||
* attached when the instance requires a card up front.
|
||||
*/
|
||||
public function redirect(Account $account, string $priceId, string $cancelUrl): Response
|
||||
{
|
||||
$account->createOrGetStripeCustomer([
|
||||
'email' => $account->stripeEmail(),
|
||||
'name' => $account->stripeName(),
|
||||
]);
|
||||
|
||||
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
|
||||
->quantity(max(1, $account->workspaces()->count()))
|
||||
->allowPromotionCodes();
|
||||
|
||||
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
|
||||
$subscription->trialDays(config('cashier.trial_days'));
|
||||
}
|
||||
|
||||
$session = $subscription->checkout([
|
||||
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
|
||||
'cancel_url' => $cancelUrl,
|
||||
]);
|
||||
|
||||
return Inertia::location($session->url);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,13 +7,11 @@
|
|||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Events\TelegramChannelConnected;
|
||||
use App\Features\SocialAccountLimit;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\Telegram\TelegramApi;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Throwable;
|
||||
|
||||
class ConnectTelegramChannel
|
||||
|
|
@ -30,13 +28,12 @@ public static function execute(Workspace $workspace, array $chat, string $nonce)
|
|||
$chatId = (string) data_get($chat, 'id');
|
||||
$username = data_get($chat, 'username');
|
||||
|
||||
// Block only brand-new accounts against the plan limit, never reconnects.
|
||||
$isNewAccount = ! $workspace->socialAccounts()
|
||||
->where('platform', Platform::Telegram->value)
|
||||
->where('platform_user_id', $chatId)
|
||||
->exists();
|
||||
|
||||
if ($isNewAccount && self::workspaceAtAccountLimit($workspace)) {
|
||||
if ($isNewAccount && self::networkAlreadyConnected($workspace, $chatId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -103,14 +100,15 @@ private static function fetchChannelAvatar(string $chatId): ?string
|
|||
}
|
||||
}
|
||||
|
||||
private static function workspaceAtAccountLimit(Workspace $workspace): bool
|
||||
private static function networkAlreadyConnected(Workspace $workspace, string $chatId): bool
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
|
||||
|
||||
return $workspace->socialAccounts()->count() >= $limit;
|
||||
return $workspace->socialAccounts()
|
||||
->whereIn('platform', Platform::Telegram->networkPlatformValues())
|
||||
->where('platform_user_id', '!=', $chatId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public static function execute(array $data, array $utmParameters = []): User
|
|||
];
|
||||
|
||||
if (! $requiresCardForTrial) {
|
||||
$accountAttributes['plan_id'] = Plan::where('slug', Slug::Starter)->value('id');
|
||||
$accountAttributes['plan_id'] = Plan::where('slug', Slug::Workspace)->value('id');
|
||||
$accountAttributes['trial_ends_at'] = now()->addDays(config('cashier.trial_days', 7));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ public static function execute(User $user, array $data): Workspace
|
|||
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
|
||||
$user->switchWorkspace($workspace);
|
||||
|
||||
$user->account?->forgetPlanFeatureCache();
|
||||
$user->account?->syncWorkspaceQuantity();
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,14 @@ public static function execute(User $user, Workspace $workspace): void
|
|||
{
|
||||
User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]);
|
||||
|
||||
$account = $workspace->account;
|
||||
$accountId = (string) $workspace->account_id;
|
||||
|
||||
$workspace->delete();
|
||||
|
||||
$account?->forgetPlanFeatureCache();
|
||||
$account?->syncWorkspaceQuantity();
|
||||
|
||||
if (PostHogService::isEnabled()) {
|
||||
SyncAccountUsage::dispatch($accountId, null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ enum Slug: string
|
|||
case Plus = 'plus';
|
||||
case Pro = 'pro';
|
||||
case Max = 'max';
|
||||
case Workspace = 'workspace';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -18,6 +19,7 @@ public function label(): string
|
|||
self::Plus => 'Plus',
|
||||
self::Pro => 'Pro',
|
||||
self::Max => 'Max',
|
||||
self::Workspace => 'Workspace',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,35 @@ enum Platform: string
|
|||
case Telegram = 'telegram';
|
||||
case Discord = 'discord';
|
||||
|
||||
/**
|
||||
* The social network this platform belongs to. Variants that represent the
|
||||
* same network (LinkedIn profile vs. company page, Instagram standalone vs.
|
||||
* Facebook-linked) collapse to one key so a workspace may connect only one
|
||||
* account per network.
|
||||
*/
|
||||
public function network(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::LinkedIn, self::LinkedInPage => 'linkedin',
|
||||
self::Instagram, self::InstagramFacebook => 'instagram',
|
||||
default => $this->value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All platform values that share this platform's network, used to enforce
|
||||
* the one-account-per-network rule across variants.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function networkPlatformValues(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (self $platform): string => $platform->value,
|
||||
array_filter(self::cases(), fn (self $platform): bool => $platform->network() === $this->network()),
|
||||
));
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
|
|||
38
app/Enums/User/Persona.php
Normal file
38
app/Enums/User/Persona.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\User;
|
||||
|
||||
enum Persona: string
|
||||
{
|
||||
case Creator = 'creator';
|
||||
case Freelancer = 'freelancer';
|
||||
case Startup = 'startup';
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\SocialAccount;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use RuntimeException;
|
||||
|
||||
class NetworkAlreadyConnectedException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly Platform $platform)
|
||||
{
|
||||
parent::__construct("This workspace already has a {$platform->network()} account connected.");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Account;
|
||||
|
||||
class MemberLimit
|
||||
{
|
||||
public string $name = 'member-limit';
|
||||
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->member_limit ?? 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Features;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Support\BillingCycle;
|
||||
|
||||
class MonthlyCreditsLimit
|
||||
{
|
||||
|
|
@ -12,6 +13,6 @@ class MonthlyCreditsLimit
|
|||
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->monthly_credits_limit ?? 1000;
|
||||
return BillingCycle::for($scope)->creditAllotment();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Account;
|
||||
|
||||
class SocialAccountLimit
|
||||
{
|
||||
public string $name = 'social-account-limit';
|
||||
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->social_account_limit ?? 5;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\Account;
|
||||
|
||||
class WorkspaceLimit
|
||||
{
|
||||
public string $name = 'workspace-limit';
|
||||
|
||||
public function resolve(Account $scope): int
|
||||
{
|
||||
return $scope->plan?->workspace_limit ?? 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Actions\Billing\StartSubscriptionCheckout;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -17,27 +18,12 @@
|
|||
|
||||
class BillingController extends Controller
|
||||
{
|
||||
public function subscribe(Request $request): Response|RedirectResponse
|
||||
public function subscribe(): RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$account = $request->user()->account;
|
||||
|
||||
if ($account && $account->hasActiveSubscription()) {
|
||||
return redirect()->route('app.billing.index');
|
||||
}
|
||||
|
||||
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
||||
|
||||
return Inertia::render('billing/Subscribe', [
|
||||
'plans' => Plan::active()->orderBy('sort')->get(),
|
||||
'trialDays' => $requiresCardForTrial ? config('cashier.trial_days') : null,
|
||||
]);
|
||||
return redirect()->route('app.onboarding');
|
||||
}
|
||||
|
||||
public function checkout(Request $request, Plan $plan): SymfonyResponse|RedirectResponse
|
||||
public function checkout(Request $request, Plan $plan, StartSubscriptionCheckout $checkout): SymfonyResponse|RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
|
|
@ -47,6 +33,7 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
|
|||
$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'],
|
||||
|
|
@ -60,24 +47,7 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
|
|||
'Invalid price for this plan',
|
||||
);
|
||||
|
||||
$account->createOrGetStripeCustomer([
|
||||
'email' => $account->stripeEmail(),
|
||||
'name' => $account->stripeName(),
|
||||
]);
|
||||
|
||||
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
|
||||
->allowPromotionCodes();
|
||||
|
||||
if ((bool) config('trypost.billing.require_card_for_trial', true)) {
|
||||
$subscription->trialDays(config('cashier.trial_days'));
|
||||
}
|
||||
|
||||
$checkoutSession = $subscription->checkout([
|
||||
'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}',
|
||||
'cancel_url' => route('app.subscribe'),
|
||||
]);
|
||||
|
||||
return Inertia::location($checkoutSession->url);
|
||||
return $checkout->redirect($account, $priceId, route('app.onboarding'));
|
||||
}
|
||||
|
||||
public function processing(Request $request): Response|RedirectResponse
|
||||
|
|
@ -159,7 +129,7 @@ public function index(Request $request): Response|RedirectResponse
|
|||
'ends_at',
|
||||
]),
|
||||
'plan' => $account->plan,
|
||||
'plans' => Plan::active()->orderBy('sort')->get(),
|
||||
'workspaceCount' => $account->workspaces()->count(),
|
||||
'invoices' => $account->invoices()->map(fn ($invoice) => [
|
||||
'id' => $invoice->id,
|
||||
'date' => $invoice->date(),
|
||||
|
|
@ -171,7 +141,7 @@ public function index(Request $request): Response|RedirectResponse
|
|||
]);
|
||||
}
|
||||
|
||||
public function swap(Request $request, Plan $plan): RedirectResponse
|
||||
public function swapToYearly(Request $request): RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
|
|
@ -180,20 +150,18 @@ public function swap(Request $request, Plan $plan): RedirectResponse
|
|||
$account = $request->user()->account;
|
||||
|
||||
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
|
||||
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), 422, 'No active subscription');
|
||||
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription');
|
||||
|
||||
$request->validate([
|
||||
'price_id' => ['required', 'string'],
|
||||
]);
|
||||
$plan = $account->plan;
|
||||
$yearlyPriceId = $plan?->stripe_yearly_price_id;
|
||||
|
||||
abort_if($yearlyPriceId === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No annual price configured');
|
||||
|
||||
$priceId = $request->input('price_id');
|
||||
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);
|
||||
|
||||
abort_unless(
|
||||
$priceId === $plan->stripe_monthly_price_id || $priceId === $plan->stripe_yearly_price_id,
|
||||
422,
|
||||
'Invalid price for this plan',
|
||||
);
|
||||
if ($subscription->stripe_price === $yearlyPriceId) {
|
||||
return redirect()->route('app.billing.index');
|
||||
}
|
||||
|
||||
$authorization = Gate::inspect('swapPlan', [$account, $plan]);
|
||||
|
||||
|
|
@ -201,12 +169,11 @@ public function swap(Request $request, Plan $plan): RedirectResponse
|
|||
return back()->with('flash.error', $authorization->message());
|
||||
}
|
||||
|
||||
$subscription->swap($priceId);
|
||||
$account->update(['plan_id' => $plan->id]);
|
||||
$subscription->swap($yearlyPriceId);
|
||||
$account->forgetPlanFeatureCache();
|
||||
|
||||
return redirect()->route('app.billing.index')
|
||||
->with('flash.success', __('billing.flash.plan_changed', ['plan' => $plan->name]));
|
||||
->with('flash.success', __('billing.flash.switched_to_yearly'));
|
||||
}
|
||||
|
||||
public function portal(Request $request): RedirectResponse
|
||||
|
|
|
|||
67
app/Http/Controllers/App/OnboardingController.php
Normal file
67
app/Http/Controllers/App/OnboardingController.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Actions\Billing\StartSubscriptionCheckout;
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Enums\User\Persona;
|
||||
use App\Http\Requests\App\Onboarding\StoreOnboardingRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Services\PostHogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class OnboardingController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
return Inertia::render('onboarding/Index', [
|
||||
'personas' => Persona::options(),
|
||||
'selected' => $user->persona?->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreOnboardingRequest $request,
|
||||
PostHogService $postHog,
|
||||
StartSubscriptionCheckout $checkout,
|
||||
): SymfonyResponse|RedirectResponse {
|
||||
if (config('trypost.self_hosted')) {
|
||||
return redirect()->route('app.calendar');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$account = $user->account;
|
||||
$persona = (string) $request->validated('persona');
|
||||
|
||||
$user->update(['persona' => $persona]);
|
||||
|
||||
$postHog->identify($user->id, [
|
||||
'persona' => $persona,
|
||||
]);
|
||||
|
||||
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
|
||||
|
||||
return $checkout->redirect(
|
||||
$account,
|
||||
(string) $plan->stripe_monthly_price_id,
|
||||
route('app.onboarding'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,9 @@
|
|||
|
||||
namespace App\Http\Controllers\App\Settings;
|
||||
|
||||
use App\Features\MemberLimit;
|
||||
use App\Features\MonthlyCreditsLimit;
|
||||
use App\Features\SocialAccountLimit;
|
||||
use App\Features\WorkspaceLimit;
|
||||
use App\Http\Controllers\App\Controller;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Support\BillingCycle;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -40,12 +37,9 @@ public function index(Request $request): Response|RedirectResponse
|
|||
'plan' => $account->plan,
|
||||
'usage' => [
|
||||
'workspaceCount' => $account->workspaces()->count(),
|
||||
'workspaceLimit' => Feature::for($account)->value(WorkspaceLimit::class),
|
||||
'socialAccountCount' => $totalSocialAccounts,
|
||||
'socialAccountLimit' => Feature::for($account)->value(SocialAccountLimit::class),
|
||||
'memberCount' => $totalMembers,
|
||||
'memberLimit' => Feature::for($account)->value(MemberLimit::class),
|
||||
'creditsUsed' => AiUsageLog::monthlyCredits($account->id),
|
||||
'creditsUsed' => BillingCycle::for($account)->usedCredits(),
|
||||
'monthlyCreditsLimit' => Feature::for($account)->value(MonthlyCreditsLimit::class),
|
||||
],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -13,14 +13,12 @@
|
|||
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
|
||||
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
|
||||
use App\Http\Resources\App\WorkspaceMemberResource;
|
||||
use App\Models\Account;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Brand\LogoAttacher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
|
@ -70,15 +68,13 @@ public function create(Request $request): Response|RedirectResponse
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->ownedWorkspacesCount() > 0 && ! $user->account?->hasActiveSubscription()) {
|
||||
if (! config('trypost.self_hosted')
|
||||
&& $user->ownedWorkspacesCount() > 0
|
||||
&& ! $user->account?->hasActiveSubscription()) {
|
||||
return redirect()->route('app.billing.index')
|
||||
->with('message', 'Subscribe to create more workspaces.');
|
||||
}
|
||||
|
||||
if ($this->hasReachedWorkspaceLimit($user->account)) {
|
||||
return back()->with('flash.error', __('workspaces.limit_reached'));
|
||||
}
|
||||
|
||||
return Inertia::render('workspaces/Create', [
|
||||
'availableFonts' => BrandFont::values(),
|
||||
'availableImageStyles' => ImageStyle::values(),
|
||||
|
|
@ -92,15 +88,8 @@ public function autofillBrand(Request $request, AutofillBrand $autofill): JsonRe
|
|||
'url' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$gate = Gate::inspect('useAi', $user->account);
|
||||
if ($gate->denied()) {
|
||||
return response()->json(['message' => $gate->message()], SymfonyResponse::HTTP_PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
$metadata = $autofill(data_get($validated, 'url'), $user->currentWorkspace, $user->id);
|
||||
$metadata = $autofill(data_get($validated, 'url'));
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
|
@ -112,10 +101,6 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($this->hasReachedWorkspaceLimit($user->account)) {
|
||||
abort(SymfonyResponse::HTTP_FORBIDDEN, __('workspaces.limit_reached'));
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$workspace = CreateWorkspace::execute($user, $validated);
|
||||
|
|
@ -235,28 +220,13 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons
|
|||
|
||||
$user = $request->user();
|
||||
|
||||
if (! config('trypost.self_hosted') && $workspace->account->workspaces()->count() <= 1) {
|
||||
return back()->with('flash.error', __('workspaces.cannot_delete_last'));
|
||||
}
|
||||
|
||||
DeleteWorkspace::execute($user, $workspace);
|
||||
|
||||
return redirect()->route('app.workspaces.index')
|
||||
->with('flash.success', __('workspaces.flash.deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the account has hit its plan's workspace limit.
|
||||
* Returns false in self-hosted mode (no plan limits apply).
|
||||
*/
|
||||
private function hasReachedWorkspaceLimit(?Account $account): bool
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $account) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$limit = (int) ($account->plan?->workspace_limit ?? 1);
|
||||
|
||||
return $account->workspaces()->count() >= $limit;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
return Inertia::render('accounts/BlueskyConnect', [
|
||||
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse
|
|||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
return Inertia::render('accounts/MastodonConnect', [
|
||||
'errors' => session('errors')?->getBag('default')?->toArray() ?? [],
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
use App\Enums\PostPlatform\Status as PostPlatformStatus;
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Features\SocialAccountLimit;
|
||||
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\App\SocialAccountResource;
|
||||
use App\Models\SocialAccount;
|
||||
|
|
@ -19,7 +19,6 @@
|
|||
use Illuminate\View\View;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
|
|
@ -34,19 +33,6 @@ protected function ensurePlatformEnabled(): void
|
|||
}
|
||||
}
|
||||
|
||||
protected function ensureSocialAccountLimit(Workspace $workspace): void
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
|
||||
|
||||
if ($workspace->socialAccounts()->count() >= $limit) {
|
||||
abort(SymfonyResponse::HTTP_FORBIDDEN, __('accounts.limit_reached'));
|
||||
}
|
||||
}
|
||||
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
|
@ -146,8 +132,6 @@ protected function redirectToProvider(Request $request, string $driver, array $s
|
|||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
session(['social_connect_workspace' => $workspace->id]);
|
||||
session(['social_connect_onboarding' => $request->boolean('onboarding')]);
|
||||
|
||||
|
|
@ -201,6 +185,8 @@ protected function handleCallback(
|
|||
);
|
||||
|
||||
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $platform->value);
|
||||
} catch (NetworkAlreadyConnectedException) {
|
||||
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $platform->value);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Social OAuth Error', [
|
||||
'platform' => $platform->value,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ public function connect(Request $request): JsonResponse
|
|||
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
$expiresAt = now()->addMinutes(15);
|
||||
$code = TelegramConnectCode::issue($workspace->id, $expiresAt);
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ public function handle(Request $request, Closure $next): Response
|
|||
);
|
||||
|
||||
if (! $hasAccess) {
|
||||
return redirect()->route('app.subscribe');
|
||||
return redirect()->route('app.onboarding');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
|
||||
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
|
||||
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
|
|
@ -48,7 +47,6 @@ public function share(Request $request): array
|
|||
],
|
||||
'usage' => $account && ! $isSelfHosted ? $account->usage() : null,
|
||||
'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null,
|
||||
'plans' => $isSelfHosted ? [] : Plan::active()->orderBy('sort')->get(),
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'flash' => $request->session()->get('flash', []),
|
||||
'applicationUrl' => config('app.url'),
|
||||
|
|
|
|||
27
app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php
Normal file
27
app/Http/Requests/App/Onboarding/StoreOnboardingRequest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Onboarding;
|
||||
|
||||
use App\Enums\User\Persona;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreOnboardingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'persona' => ['required', Rule::enum(Persona::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Features\MemberLimit;
|
||||
use App\Features\MonthlyCreditsLimit;
|
||||
use App\Features\SocialAccountLimit;
|
||||
use App\Features\WorkspaceLimit;
|
||||
use App\Models\Traits\HasUsage;
|
||||
use Carbon\CarbonInterface;
|
||||
use Database\Factories\AccountFactory;
|
||||
|
|
@ -16,8 +13,10 @@
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Throwable;
|
||||
|
||||
class Account extends Model
|
||||
{
|
||||
|
|
@ -50,9 +49,6 @@ public static function postsCountCacheKey(string $accountId): string
|
|||
public function forgetPlanFeatureCache(): void
|
||||
{
|
||||
Feature::for($this)->forget([
|
||||
WorkspaceLimit::class,
|
||||
SocialAccountLimit::class,
|
||||
MemberLimit::class,
|
||||
MonthlyCreditsLimit::class,
|
||||
]);
|
||||
}
|
||||
|
|
@ -91,6 +87,33 @@ public function hasActiveSubscription(): bool
|
|||
return $this->subscribed(self::SUBSCRIPTION_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the Stripe subscription quantity with the number of workspaces the
|
||||
* account owns. Each workspace is a billed unit. No-op in self-hosted mode
|
||||
* or when there is no active subscription (e.g. during onboarding).
|
||||
*/
|
||||
public function syncWorkspaceQuantity(): void
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscription = $this->subscription(self::SUBSCRIPTION_NAME);
|
||||
|
||||
if (! $subscription || ! $subscription->active()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$subscription->updateQuantity($this->workspaces()->count());
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to sync workspace quantity to Stripe', [
|
||||
'account_id' => $this->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function isPastDue(): bool
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\Ai\UsageType;
|
||||
use Carbon\CarbonInterface;
|
||||
use Database\Factories\AiUsageLogFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -50,10 +51,11 @@ public function account(): BelongsTo
|
|||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public static function monthlyCredits(string $accountId): int
|
||||
public static function creditsUsedBetween(string $accountId, CarbonInterface $start, CarbonInterface $end): int
|
||||
{
|
||||
return (int) static::where('account_id', $accountId)
|
||||
->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])
|
||||
->where('created_at', '>=', $start)
|
||||
->where('created_at', '<', $end)
|
||||
->sum('credits');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ class Plan extends Model
|
|||
'name',
|
||||
'stripe_monthly_price_id',
|
||||
'stripe_yearly_price_id',
|
||||
'social_account_limit',
|
||||
'member_limit',
|
||||
'workspace_limit',
|
||||
'monthly_credits_limit',
|
||||
'sort',
|
||||
'is_archived',
|
||||
|
|
@ -35,9 +32,6 @@ protected function casts(): array
|
|||
return [
|
||||
'slug' => Slug::class,
|
||||
'is_archived' => 'boolean',
|
||||
'social_account_limit' => 'integer',
|
||||
'member_limit' => 'integer',
|
||||
'workspace_limit' => 'integer',
|
||||
'monthly_credits_limit' => 'integer',
|
||||
'sort' => 'integer',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,23 +4,20 @@
|
|||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use App\Features\MemberLimit;
|
||||
use App\Features\MonthlyCreditsLimit;
|
||||
use App\Features\SocialAccountLimit;
|
||||
use App\Features\WorkspaceLimit;
|
||||
use App\Models\Account;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Invite;
|
||||
use App\Models\Post;
|
||||
use App\Support\BillingCycle;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
/**
|
||||
* Provides account-level usage counts and plan-resolved feature limits.
|
||||
*
|
||||
* The `featureLimits()` call resolves Pennant features for the account,
|
||||
* which writes to the features cache table on first access. Pennant cache
|
||||
* is invalidated automatically when `plan_id` changes (see Account::booted).
|
||||
* The `featureLimits()` call resolves the Pennant credit feature for the account,
|
||||
* which writes to the features cache table on first access. The cache is invalidated
|
||||
* explicitly via `Account::forgetPlanFeatureCache()` on plan and workspace changes.
|
||||
*/
|
||||
trait HasUsage
|
||||
{
|
||||
|
|
@ -49,19 +46,16 @@ public function usage(): array
|
|||
->whereNull('accepted_at')
|
||||
->count(),
|
||||
'postCount' => $this->cachedPostCount($workspaces->pluck('id')->all()),
|
||||
'creditsUsed' => AiUsageLog::monthlyCredits($this->id),
|
||||
'creditsUsed' => BillingCycle::for($this)->usedCredits(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{workspaceLimit: int, socialAccountLimit: int, memberLimit: int, monthlyCreditsLimit: int}
|
||||
* @return array{monthlyCreditsLimit: int}
|
||||
*/
|
||||
public function featureLimits(): array
|
||||
{
|
||||
return [
|
||||
'workspaceLimit' => Feature::for($this)->value(WorkspaceLimit::class),
|
||||
'socialAccountLimit' => Feature::for($this)->value(SocialAccountLimit::class),
|
||||
'memberLimit' => Feature::for($this)->value(MemberLimit::class),
|
||||
'monthlyCreditsLimit' => Feature::for($this)->value(MonthlyCreditsLimit::class),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\Notification\Type as NotificationType;
|
||||
use App\Enums\User\Persona;
|
||||
use App\Models\Traits\HasMedia;
|
||||
use App\Models\Traits\HasWorkspace;
|
||||
use Database\Factories\UserFactory;
|
||||
|
|
@ -42,6 +43,7 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable
|
|||
'utm_term',
|
||||
'utm_content',
|
||||
'registration_ip',
|
||||
'persona',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -75,6 +77,7 @@ protected function casts(): array
|
|||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
'persona' => Persona::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,44 @@
|
|||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
||||
use App\Jobs\PostHog\SyncAccountUsage;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\PostHogService;
|
||||
|
||||
class SocialAccountObserver
|
||||
{
|
||||
/**
|
||||
* Enforce one connected account per social network per workspace. Variants
|
||||
* of the same network (LinkedIn profile/page, Instagram standalone/Facebook)
|
||||
* collapse via Platform::network(). Reconnecting an existing account goes
|
||||
* through updateOrCreate's update path and never reaches this hook. Bypassed
|
||||
* in self-hosted mode, which has no per-workspace limits.
|
||||
*/
|
||||
public function creating(SocialAccount $socialAccount): void
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$platform = $socialAccount->platform;
|
||||
|
||||
if (! $platform instanceof Platform) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conflict = SocialAccount::query()
|
||||
->where('workspace_id', $socialAccount->workspace_id)
|
||||
->whereIn('platform', $platform->networkPlatformValues())
|
||||
->where('platform_user_id', '!=', $socialAccount->platform_user_id)
|
||||
->exists();
|
||||
|
||||
if ($conflict) {
|
||||
throw new NetworkAlreadyConnectedException($platform);
|
||||
}
|
||||
}
|
||||
|
||||
public function created(SocialAccount $socialAccount): void
|
||||
{
|
||||
$this->syncUsage($socialAccount);
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@
|
|||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Features\MonthlyCreditsLimit;
|
||||
use App\Models\Account;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Support\BillingCycle;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class AccountPolicy
|
||||
{
|
||||
|
|
@ -25,9 +23,9 @@ public function manageBilling(User $user, Account $account): bool
|
|||
}
|
||||
|
||||
/**
|
||||
* Authorize using AI features. Allows when the account still has monthly
|
||||
* credits remaining. Manual post creation is unaffected — only AI calls
|
||||
* are gated by this check.
|
||||
* Authorize using AI features. Requires an active subscription (or trial)
|
||||
* and remaining monthly credits. Manual post creation is unaffected — only
|
||||
* AI calls are gated by this check.
|
||||
*/
|
||||
public function useAi(User $user, Account $account): Response
|
||||
{
|
||||
|
|
@ -35,10 +33,19 @@ public function useAi(User $user, Account $account): Response
|
|||
return Response::allow();
|
||||
}
|
||||
|
||||
$limit = (int) Feature::for($account)->value(MonthlyCreditsLimit::class);
|
||||
$used = AiUsageLog::monthlyCredits($account->id);
|
||||
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
||||
|
||||
if ($used >= $limit) {
|
||||
$hasAccess = $account->subscribed(Account::SUBSCRIPTION_NAME)
|
||||
|| (! $requiresCardForTrial && $account->isOnTrial());
|
||||
|
||||
if (! $hasAccess) {
|
||||
return Response::deny(__('billing.flash.subscription_required'));
|
||||
}
|
||||
|
||||
$cycle = BillingCycle::for($account);
|
||||
$limit = $cycle->creditAllotment();
|
||||
|
||||
if ($cycle->usedCredits() >= $limit) {
|
||||
return Response::deny(__('billing.flash.credits_exhausted', [
|
||||
'limit' => (string) $limit,
|
||||
]));
|
||||
|
|
@ -48,10 +55,9 @@ public function useAi(User $user, Account $account): Response
|
|||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Authorize swapping the account's subscription billing interval. Only the
|
||||
* 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
|
||||
{
|
||||
|
|
@ -59,33 +65,6 @@ public function swapPlan(User $user, Account $account, Plan $plan): Response
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@
|
|||
namespace App\Policies;
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Features\MemberLimit;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class WorkspacePolicy
|
||||
{
|
||||
|
|
@ -68,17 +66,7 @@ public function createPost(User $user, Workspace $workspace): bool
|
|||
|
||||
public function inviteMember(User $user, Workspace $workspace): bool
|
||||
{
|
||||
if (! $this->isOwnerOrWorkspaceAdmin($user, $workspace)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config('trypost.self_hosted')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$limit = Feature::for($user->account)->value(MemberLimit::class);
|
||||
|
||||
return $workspace->members()->count() < $limit;
|
||||
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
|
||||
}
|
||||
|
||||
public function manageBilling(User $user, Workspace $workspace): bool
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
namespace App\Services\Brand;
|
||||
|
||||
use App\Ai\Agents\BrandAnalyzer;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\RecordAiUsage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
|
|
@ -25,7 +23,7 @@ public function isAvailable(): bool
|
|||
};
|
||||
}
|
||||
|
||||
public function analyze(string $bodyHtml, ?Workspace $workspace = null, ?string $userId = null): ?LlmBrandAnalysis
|
||||
public function analyze(string $bodyHtml): ?LlmBrandAnalysis
|
||||
{
|
||||
$markdown = $this->htmlToMarkdown($bodyHtml);
|
||||
|
||||
|
|
@ -41,21 +39,6 @@ public function analyze(string $bodyHtml, ?Workspace $workspace = null, ?string
|
|||
return null;
|
||||
}
|
||||
|
||||
// Brand analysis can run during onboarding before the user has a
|
||||
// workspace; skip usage tracking in that case (the cost is small and
|
||||
// one-shot). Other AI flows always carry a workspace.
|
||||
if ($workspace !== null) {
|
||||
RecordAiUsage::recordText(
|
||||
workspace: $workspace,
|
||||
promptTokens: $response->usage->promptTokens,
|
||||
completionTokens: $response->usage->completionTokens,
|
||||
provider: (string) config('ai.default'),
|
||||
model: (string) config('ai.default_text_model'),
|
||||
userId: $userId,
|
||||
metadata: ['agent' => 'brand_analyzer'],
|
||||
);
|
||||
}
|
||||
|
||||
return LlmBrandAnalysis::fromResponse($response);
|
||||
}
|
||||
|
||||
|
|
|
|||
121
app/Support/BillingCycle.php
Normal file
121
app/Support/BillingCycle.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AiUsageLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
/**
|
||||
* Resolves an account's current AI credit cycle: the allotment it is entitled to
|
||||
* and the time window usage is measured against. The window follows the Stripe
|
||||
* billing cycle (monthly or yearly), anchored on the subscription date — so an
|
||||
* annual subscriber receives twelve months of credits upfront and resets on
|
||||
* their renewal date, while a monthly subscriber resets each anniversary day.
|
||||
*/
|
||||
class BillingCycle
|
||||
{
|
||||
private function __construct(private readonly Account $account) {}
|
||||
|
||||
public static function for(Account $account): self
|
||||
{
|
||||
return new self($account);
|
||||
}
|
||||
|
||||
public function intervalMonths(): int
|
||||
{
|
||||
$subscription = $this->subscription();
|
||||
$plan = $this->account->plan;
|
||||
|
||||
if ($subscription !== null
|
||||
&& $plan?->stripe_yearly_price_id !== null
|
||||
&& $subscription->stripe_price === $plan->stripe_yearly_price_id
|
||||
) {
|
||||
return 12;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function creditAllotment(): int
|
||||
{
|
||||
$base = (int) ($this->account->plan?->monthly_credits_limit ?? 0);
|
||||
$months = $this->onTrial() ? 1 : $this->intervalMonths();
|
||||
|
||||
return $base * $this->account->workspaces()->count() * $months;
|
||||
}
|
||||
|
||||
public function usedCredits(): int
|
||||
{
|
||||
return AiUsageLog::creditsUsedBetween(
|
||||
(string) $this->account->id,
|
||||
$this->periodStart(),
|
||||
$this->periodEnd(),
|
||||
);
|
||||
}
|
||||
|
||||
public function periodStart(): CarbonImmutable
|
||||
{
|
||||
return $this->window()[0];
|
||||
}
|
||||
|
||||
public function periodEnd(): CarbonImmutable
|
||||
{
|
||||
return $this->window()[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: CarbonImmutable, 1: CarbonImmutable}
|
||||
*/
|
||||
private function window(): array
|
||||
{
|
||||
$subscription = $this->subscription();
|
||||
|
||||
if ($subscription !== null && $subscription->onTrial()) {
|
||||
return [
|
||||
CarbonImmutable::parse($subscription->created_at),
|
||||
CarbonImmutable::parse($subscription->trial_ends_at),
|
||||
];
|
||||
}
|
||||
|
||||
$anchor = $this->anchor();
|
||||
$now = CarbonImmutable::now();
|
||||
$step = $this->intervalMonths();
|
||||
|
||||
$periods = 0;
|
||||
|
||||
while ($anchor->addMonthsNoOverflow(($periods + 1) * $step)->lessThanOrEqualTo($now)) {
|
||||
$periods++;
|
||||
}
|
||||
|
||||
return [
|
||||
$anchor->addMonthsNoOverflow($periods * $step),
|
||||
$anchor->addMonthsNoOverflow(($periods + 1) * $step),
|
||||
];
|
||||
}
|
||||
|
||||
private function anchor(): CarbonImmutable
|
||||
{
|
||||
$subscription = $this->subscription();
|
||||
|
||||
$anchor = $subscription?->trial_ends_at
|
||||
?? $subscription?->created_at
|
||||
?? $this->account->created_at
|
||||
?? CarbonImmutable::now();
|
||||
|
||||
return CarbonImmutable::parse($anchor);
|
||||
}
|
||||
|
||||
private function onTrial(): bool
|
||||
{
|
||||
return (bool) $this->subscription()?->onTrial();
|
||||
}
|
||||
|
||||
private function subscription(): ?Subscription
|
||||
{
|
||||
return $this->account->subscription(Account::SUBSCRIPTION_NAME);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,10 +25,7 @@ public function definition(): array
|
|||
'name' => fake()->word(),
|
||||
'stripe_monthly_price_id' => null,
|
||||
'stripe_yearly_price_id' => null,
|
||||
'social_account_limit' => fake()->randomElement([3, 10, 25, 100]),
|
||||
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
'workspace_limit' => fake()->randomElement([1, 3, 10, 50]),
|
||||
'monthly_credits_limit' => fake()->randomElement([1000, 2000, 5000, 15000]),
|
||||
'monthly_credits_limit' => 2500,
|
||||
'sort' => 0,
|
||||
'is_archived' => false,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ public function definition(): array
|
|||
'utm_term' => null,
|
||||
'utm_content' => null,
|
||||
'registration_ip' => null,
|
||||
'persona' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plans', function (Blueprint $table) {
|
||||
$table->dropColumn(['social_account_limit', 'member_limit', 'workspace_limit']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plans', function (Blueprint $table) {
|
||||
$table->integer('social_account_limit')->default(0);
|
||||
$table->integer('member_limit')->default(0);
|
||||
$table->integer('workspace_limit')->default(0);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('persona')->nullable()->after('registration_ip');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('persona');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -15,58 +15,16 @@ class PlanSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$plans = [
|
||||
Plan::updateOrCreate(
|
||||
['slug' => Slug::Workspace],
|
||||
[
|
||||
'slug' => Slug::Starter,
|
||||
'name' => 'Starter',
|
||||
'stripe_monthly_price_id' => env('STRIPE_STARTER_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_STARTER_YEARLY'),
|
||||
'social_account_limit' => 5,
|
||||
'member_limit' => 1,
|
||||
'workspace_limit' => 1,
|
||||
'monthly_credits_limit' => 1000,
|
||||
'name' => 'Workspace',
|
||||
'stripe_monthly_price_id' => env('STRIPE_WORKSPACE_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_WORKSPACE_YEARLY'),
|
||||
'monthly_credits_limit' => 2500,
|
||||
'sort' => 1,
|
||||
'is_archived' => false,
|
||||
],
|
||||
[
|
||||
'slug' => Slug::Plus,
|
||||
'name' => 'Plus',
|
||||
'stripe_monthly_price_id' => env('STRIPE_PLUS_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_PLUS_YEARLY'),
|
||||
'social_account_limit' => 10,
|
||||
'member_limit' => 5,
|
||||
'workspace_limit' => 5,
|
||||
'monthly_credits_limit' => 2000,
|
||||
'sort' => 2,
|
||||
],
|
||||
[
|
||||
'slug' => Slug::Pro,
|
||||
'name' => 'Pro',
|
||||
'stripe_monthly_price_id' => env('STRIPE_PRO_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_PRO_YEARLY'),
|
||||
'social_account_limit' => 30,
|
||||
'member_limit' => 15,
|
||||
'workspace_limit' => 15,
|
||||
'monthly_credits_limit' => 5000,
|
||||
'sort' => 3,
|
||||
],
|
||||
[
|
||||
'slug' => Slug::Max,
|
||||
'name' => 'Max',
|
||||
'stripe_monthly_price_id' => env('STRIPE_MAX_MONTHLY'),
|
||||
'stripe_yearly_price_id' => env('STRIPE_MAX_YEARLY'),
|
||||
'social_account_limit' => 100,
|
||||
'member_limit' => 20,
|
||||
'workspace_limit' => 50,
|
||||
'monthly_credits_limit' => 15000,
|
||||
'sort' => 4,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($plans as $plan) {
|
||||
Plan::updateOrCreate(
|
||||
['slug' => $plan['slug']],
|
||||
$plan,
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@
|
|||
'connected' => 'Account connected!',
|
||||
'reconnected' => 'Account reconnected!',
|
||||
'error_connecting' => 'Error connecting account. Please try again.',
|
||||
'network_taken' => 'This workspace already has an account for this network. Disconnect it first.',
|
||||
'error_connecting_page' => 'Error connecting page. Please try again.',
|
||||
'error_connecting_channel' => 'Error connecting channel. Please try again.',
|
||||
'session_expired' => 'Session expired. Please try again.',
|
||||
|
|
|
|||
|
|
@ -9,23 +9,10 @@
|
|||
'cta' => 'Update payment',
|
||||
],
|
||||
|
||||
'upgrade_dialog' => [
|
||||
'title' => 'Upgrade your plan',
|
||||
'description' => 'Pick a plan that fits your needs.',
|
||||
'current_plan' => 'Current plan',
|
||||
'current_short' => 'Current',
|
||||
'current_badge' => 'Current',
|
||||
'subscribe' => 'Subscribe',
|
||||
'switch' => 'Switch to this plan',
|
||||
'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.',
|
||||
'member_limit' => 'You\'ve reached the team member limit on your current plan. Upgrade to invite more people.',
|
||||
],
|
||||
'annual_banner' => [
|
||||
'title' => 'Save 2 months with annual billing',
|
||||
'description' => 'Switch to annual and get 2 months free — pay less every month.',
|
||||
'cta' => 'Upgrade to annual',
|
||||
],
|
||||
|
||||
'subscribe' => [
|
||||
|
|
@ -47,30 +34,25 @@
|
|||
'start_trial' => 'Start :days-day free trial',
|
||||
'subscribe_cta' => 'Subscribe',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
|
||||
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
|
||||
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
|
||||
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
|
||||
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
|
||||
],
|
||||
'features' => [
|
||||
'social_accounts' => ':count social accounts',
|
||||
'workspaces' => ':count workspaces',
|
||||
'members' => ':count team members',
|
||||
'credits' => ':count AI credits/mo',
|
||||
'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' => [
|
||||
'starter' => 'Roughly 150 medium-length posts plus 5 AI images per month.',
|
||||
'plus' => 'Roughly 300 medium-length posts plus 10 AI images per month.',
|
||||
'pro' => 'Roughly 700 medium-length posts plus 30 AI images per month.',
|
||||
'max' => 'Roughly 2,000 medium-length posts plus 100 AI images per month.',
|
||||
'workspace' => 'Each workspace includes 2,500 AI credits per month — about 160 AI images or 280 AI text generations.',
|
||||
],
|
||||
],
|
||||
|
||||
'plan' => [
|
||||
'title' => 'Plan',
|
||||
'description' => 'Manage your subscription plan.',
|
||||
'change' => 'Change plan',
|
||||
'label' => 'Plan',
|
||||
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
|
||||
'per_workspace' => 'per workspace',
|
||||
'price' => 'Price',
|
||||
'month' => 'month',
|
||||
'trial' => 'Trial',
|
||||
|
|
@ -99,13 +81,10 @@
|
|||
|
||||
'flash' => [
|
||||
'plan_changed' => 'You are now on the :plan plan.',
|
||||
'switched_to_yearly' => 'You\'re now on annual billing.',
|
||||
'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.',
|
||||
],
|
||||
'credits_exhausted' => 'Out of AI credits — your monthly :limit allowance has been used. Upgrade your plan or wait until next month.',
|
||||
'subscription_required' => 'An active subscription is required to use AI features.',
|
||||
],
|
||||
|
||||
'processing' => [
|
||||
|
|
|
|||
18
lang/en/onboarding.php
Normal file
18
lang/en/onboarding.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'title' => 'Welcome to TryPost',
|
||||
'description' => 'Tell us what best describes you so we can tailor your experience.',
|
||||
'continue' => 'Continue',
|
||||
'trial_note' => 'Try it free for 7 days, no commitment. You won\'t be charged during your trial and can cancel anytime.',
|
||||
'personas' => [
|
||||
'creator' => 'Creator',
|
||||
'freelancer' => 'Freelancer',
|
||||
'startup' => 'Startup',
|
||||
'agency' => 'Agency',
|
||||
'small_business' => 'Small business',
|
||||
'other' => 'Other',
|
||||
],
|
||||
];
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
'title' => 'Usage',
|
||||
|
||||
'section_account' => 'Account',
|
||||
'section_account_description' => 'Quotas and limits for your :plan plan.',
|
||||
'section_account_description' => 'Quotas and limits for your plan.',
|
||||
'section_ai' => 'AI Credits',
|
||||
'section_ai_description' => 'Credits are debited as AI features are used. They reset on the first of every month.',
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
],
|
||||
|
||||
'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' => [
|
||||
'deleted' => 'Workspace deleted successfully.',
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@
|
|||
'connected' => '¡Cuenta conectada!',
|
||||
'reconnected' => '¡Cuenta reconectada!',
|
||||
'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.',
|
||||
'network_taken' => 'Este workspace ya tiene una cuenta para esta red. Desconéctala primero.',
|
||||
'error_connecting_page' => 'Error al conectar la página. Inténtalo de nuevo.',
|
||||
'error_connecting_channel' => 'Error al conectar el canal. Inténtalo de nuevo.',
|
||||
'session_expired' => 'Sesión expirada. Inténtalo de nuevo.',
|
||||
|
|
|
|||
|
|
@ -9,23 +9,10 @@
|
|||
'cta' => 'Actualizar pago',
|
||||
],
|
||||
|
||||
'upgrade_dialog' => [
|
||||
'title' => 'Actualiza tu plan',
|
||||
'description' => 'Elige un plan que se adapte a tus necesidades.',
|
||||
'current_plan' => 'Plan actual',
|
||||
'current_short' => 'Actual',
|
||||
'current_badge' => 'Actual',
|
||||
'subscribe' => 'Suscribirse',
|
||||
'switch' => 'Cambiar a este plan',
|
||||
'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.',
|
||||
'member_limit' => 'Has alcanzado el límite de miembros de tu plan. Actualiza para invitar a más personas.',
|
||||
],
|
||||
'annual_banner' => [
|
||||
'title' => 'Ahorra 2 meses con la facturación anual',
|
||||
'description' => 'Cambia al anual y obtén 2 meses gratis — paga menos cada mes.',
|
||||
'cta' => 'Cambiar a anual',
|
||||
],
|
||||
|
||||
'subscribe' => [
|
||||
|
|
@ -47,30 +34,25 @@
|
|||
'start_trial' => 'Comenzar prueba de :days días',
|
||||
'subscribe_cta' => 'Suscribirse',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
|
||||
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
|
||||
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
|
||||
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
|
||||
'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'],
|
||||
],
|
||||
'features' => [
|
||||
'social_accounts' => ':count cuentas sociales',
|
||||
'workspaces' => ':count workspaces',
|
||||
'members' => ':count miembros del equipo',
|
||||
'credits' => ':count créditos IA/mes',
|
||||
'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' => [
|
||||
'starter' => 'En promedio 150 posts de largo medio + 5 imágenes IA por mes.',
|
||||
'plus' => 'En promedio 300 posts de largo medio + 10 imágenes IA por mes.',
|
||||
'pro' => 'En promedio 700 posts de largo medio + 30 imágenes IA por mes.',
|
||||
'max' => 'En promedio 2.000 posts de largo medio + 100 imágenes IA por mes.',
|
||||
'workspace' => 'Cada workspace incluye 2.500 créditos de IA al mes — alrededor de 160 imágenes o 280 textos generados con IA.',
|
||||
],
|
||||
],
|
||||
|
||||
'plan' => [
|
||||
'title' => 'Plan',
|
||||
'description' => 'Gestiona tu plan de suscripción.',
|
||||
'change' => 'Cambiar plan',
|
||||
'label' => 'Plan',
|
||||
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
|
||||
'per_workspace' => 'por workspace',
|
||||
'price' => 'Precio',
|
||||
'month' => 'mes',
|
||||
'trial' => 'Prueba',
|
||||
|
|
@ -99,13 +81,10 @@
|
|||
|
||||
'flash' => [
|
||||
'plan_changed' => 'Ahora estás en el plan :plan.',
|
||||
'switched_to_yearly' => 'Ahora tienes facturación anual.',
|
||||
'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.',
|
||||
],
|
||||
'credits_exhausted' => 'Sin créditos de IA — has usado tus :limit créditos mensuales. Mejora tu plan o espera hasta el próximo mes.',
|
||||
'subscription_required' => 'Se requiere una suscripción activa para usar las funciones de IA.',
|
||||
],
|
||||
|
||||
'processing' => [
|
||||
|
|
|
|||
18
lang/es/onboarding.php
Normal file
18
lang/es/onboarding.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'title' => 'Bienvenido a TryPost',
|
||||
'description' => 'Cuéntanos qué te describe mejor para personalizar tu experiencia.',
|
||||
'continue' => 'Continuar',
|
||||
'trial_note' => 'Pruébalo gratis por 7 días, sin compromiso. No se te cobrará durante la prueba y puedes cancelar cuando quieras.',
|
||||
'personas' => [
|
||||
'creator' => 'Creador',
|
||||
'freelancer' => 'Freelancer',
|
||||
'startup' => 'Startup',
|
||||
'agency' => 'Agencia',
|
||||
'small_business' => 'Pequeña empresa',
|
||||
'other' => 'Otro',
|
||||
],
|
||||
];
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
'title' => 'Uso',
|
||||
|
||||
'section_account' => 'Cuenta',
|
||||
'section_account_description' => 'Cuotas y límites de tu plan :plan.',
|
||||
'section_account_description' => 'Cuotas y límites de tu plan.',
|
||||
'section_ai' => 'Créditos AI',
|
||||
'section_ai_description' => 'Los créditos se debitan a medida que usas las funciones de AI. Se renuevan el día 1 de cada mes.',
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
],
|
||||
|
||||
'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' => [
|
||||
'deleted' => 'Workspace eliminado correctamente.',
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@
|
|||
'connected' => 'Conta conectada!',
|
||||
'reconnected' => 'Conta reconectada!',
|
||||
'error_connecting' => 'Erro ao conectar conta. Por favor, tente novamente.',
|
||||
'network_taken' => 'Este workspace já tem uma conta para esta rede. Desconecte-a primeiro.',
|
||||
'error_connecting_page' => 'Erro ao conectar página. Por favor, tente novamente.',
|
||||
'error_connecting_channel' => 'Erro ao conectar canal. Por favor, tente novamente.',
|
||||
'session_expired' => 'Sessão expirada. Por favor, tente novamente.',
|
||||
|
|
|
|||
|
|
@ -9,23 +9,10 @@
|
|||
'cta' => 'Atualizar pagamento',
|
||||
],
|
||||
|
||||
'upgrade_dialog' => [
|
||||
'title' => 'Faça upgrade do seu plano',
|
||||
'description' => 'Escolha um plano que se encaixe nas suas necessidades.',
|
||||
'current_plan' => 'Plano atual',
|
||||
'current_short' => 'Atual',
|
||||
'current_badge' => 'Atual',
|
||||
'subscribe' => 'Assinar',
|
||||
'switch' => 'Mudar para este plano',
|
||||
'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.',
|
||||
'member_limit' => 'Você atingiu o limite de membros do seu plano. Faça upgrade pra convidar mais pessoas.',
|
||||
],
|
||||
'annual_banner' => [
|
||||
'title' => 'Economize 2 meses com a cobrança anual',
|
||||
'description' => 'Mude para o anual e ganhe 2 meses grátis — pague menos por mês.',
|
||||
'cta' => 'Mudar para anual',
|
||||
],
|
||||
|
||||
'subscribe' => [
|
||||
|
|
@ -47,30 +34,25 @@
|
|||
'start_trial' => 'Iniciar teste de :days dias',
|
||||
'subscribe_cta' => 'Assinar',
|
||||
'prices' => [
|
||||
'starter' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79', 'yearly' => 'R$ 950'],
|
||||
'plus' => ['monthly' => 'R$ 145', 'yearly_per_month' => 'R$ 121', 'yearly' => 'R$ 1450'],
|
||||
'pro' => ['monthly' => 'R$ 245', 'yearly_per_month' => 'R$ 204', 'yearly' => 'R$ 2450'],
|
||||
'max' => ['monthly' => 'R$ 495', 'yearly_per_month' => 'R$ 413', 'yearly' => 'R$ 4950'],
|
||||
'workspace' => ['monthly' => 'R$ 60', 'yearly_per_month' => 'R$ 50', 'yearly' => 'R$ 600'],
|
||||
],
|
||||
'features' => [
|
||||
'social_accounts' => ':count contas sociais',
|
||||
'workspaces' => ':count workspaces',
|
||||
'members' => ':count membros da equipe',
|
||||
'credits' => ':count créditos IA/mês',
|
||||
'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' => [
|
||||
'starter' => 'Em média 150 posts de tamanho médio + 5 imagens de IA por mês.',
|
||||
'plus' => 'Em média 300 posts de tamanho médio + 10 imagens de IA por mês.',
|
||||
'pro' => 'Em média 700 posts de tamanho médio + 30 imagens de IA por mês.',
|
||||
'max' => 'Em média 2.000 posts de tamanho médio + 100 imagens de IA por mês.',
|
||||
'workspace' => 'Cada workspace inclui 2.500 créditos de IA por mês — cerca de 160 imagens ou 280 textos gerados por IA.',
|
||||
],
|
||||
],
|
||||
|
||||
'plan' => [
|
||||
'title' => 'Plano',
|
||||
'description' => 'Gerencie seu plano de assinatura.',
|
||||
'change' => 'Mudar plano',
|
||||
'label' => 'Plano',
|
||||
'workspaces' => '{1}:count workspace|[2,*]:count workspaces',
|
||||
'per_workspace' => 'por workspace',
|
||||
'price' => 'Preço',
|
||||
'month' => 'mês',
|
||||
'trial' => 'Trial',
|
||||
|
|
@ -99,13 +81,10 @@
|
|||
|
||||
'flash' => [
|
||||
'plan_changed' => 'Você está agora no plano :plan.',
|
||||
'switched_to_yearly' => 'Você está agora na cobrança anual.',
|
||||
'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.',
|
||||
],
|
||||
'credits_exhausted' => 'Sem créditos de IA — você usou seus :limit créditos mensais. Faça upgrade do plano ou aguarde até o próximo mês.',
|
||||
'subscription_required' => 'É necessária uma assinatura ativa para usar os recursos de IA.',
|
||||
],
|
||||
|
||||
'processing' => [
|
||||
|
|
|
|||
18
lang/pt-BR/onboarding.php
Normal file
18
lang/pt-BR/onboarding.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'title' => 'Bem-vindo ao TryPost',
|
||||
'description' => 'Conte o que melhor te descreve para personalizarmos sua experiência.',
|
||||
'continue' => 'Continuar',
|
||||
'trial_note' => 'Experimente grátis por 7 dias, sem compromisso. Você não será cobrado durante o teste e pode cancelar quando quiser.',
|
||||
'personas' => [
|
||||
'creator' => 'Criador',
|
||||
'freelancer' => 'Freelancer',
|
||||
'startup' => 'Startup',
|
||||
'agency' => 'Agência',
|
||||
'small_business' => 'Pequena empresa',
|
||||
'other' => 'Outro',
|
||||
],
|
||||
];
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
'title' => 'Uso',
|
||||
|
||||
'section_account' => 'Conta',
|
||||
'section_account_description' => 'Cotas e limites do seu plano :plan.',
|
||||
'section_account_description' => 'Cotas e limites do seu plano.',
|
||||
'section_ai' => 'Créditos AI',
|
||||
'section_ai_description' => 'Os créditos são debitados conforme você usa os recursos de AI. Eles são renovados no dia 1 de cada mês.',
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
],
|
||||
|
||||
'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' => [
|
||||
'deleted' => 'Workspace excluído com sucesso.',
|
||||
|
|
|
|||
|
|
@ -43,12 +43,10 @@ import {
|
|||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useActiveUrl } from '@/composables/useActiveUrl';
|
||||
import { useFeatureAccess } from '@/composables/useFeatureAccess';
|
||||
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
|
||||
import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app';
|
||||
import { portal } from '@/routes/app/billing';
|
||||
import { index as assets } from '@/routes/app/assets';
|
||||
import { index as automations } from '@/routes/app/automations';
|
||||
import { portal } from '@/routes/app/billing';
|
||||
import { index as labels } from '@/routes/app/labels';
|
||||
import { index as signatures } from '@/routes/app/signatures';
|
||||
import { create as createWorkspaceRoute, switchMethod } from '@/routes/app/workspaces';
|
||||
|
|
@ -137,15 +135,9 @@ const switchWorkspace = (workspaceId: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
const { canCreateWorkspace } = useFeatureAccess();
|
||||
const { openUpgrade } = useUpgradeDialog();
|
||||
const { urlIsActive } = useActiveUrl();
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
if (!canCreateWorkspace.value) {
|
||||
openUpgrade(trans('billing.upgrade_dialog.reasons.workspace_limit'));
|
||||
return;
|
||||
}
|
||||
router.visit(createWorkspaceRoute.url());
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,319 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { IconCheck, IconInfoCircle, IconLoader2 } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useFeatureAccess } from '@/composables/useFeatureAccess';
|
||||
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
|
||||
import { checkout, swap } from '@/routes/app/billing';
|
||||
import type { AuthPlan } from '@/types';
|
||||
|
||||
type PlanSlug = 'starter' | 'plus' | 'pro' | 'max';
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
slug: PlanSlug;
|
||||
name: string;
|
||||
stripe_monthly_price_id: string | null;
|
||||
stripe_yearly_price_id: string | null;
|
||||
social_account_limit: number;
|
||||
member_limit: number;
|
||||
workspace_limit: number;
|
||||
monthly_credits_limit: number;
|
||||
}
|
||||
|
||||
interface Highlight {
|
||||
text: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const { open, reason, closeUpgrade } = useUpgradeDialog();
|
||||
const { usage } = useFeatureAccess();
|
||||
const page = usePage();
|
||||
|
||||
const plans = computed<Plan[]>(() => (page.props.plans as Plan[]) ?? []);
|
||||
const currentPlan = computed<AuthPlan | null>(() => (page.props.auth as { plan: AuthPlan | null }).plan ?? null);
|
||||
const hasActiveSubscription = computed<boolean>(() => Boolean((page.props.auth as { hasActiveSubscription?: boolean }).hasActiveSubscription));
|
||||
|
||||
const isOnYearly = computed(() => currentPlan.value?.interval === 'yearly');
|
||||
|
||||
const isYearly = ref(true);
|
||||
const processing = ref<string | null>(null);
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
isYearly.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Mirrors Subscribe.vue: headline price = the per-month figure regardless
|
||||
// of toggle. When yearly is selected, we read the pre-computed per-month
|
||||
// rate so the price stays comparable visually.
|
||||
const getDisplayPrice = (plan: Plan): string => {
|
||||
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly_per_month' : 'monthly'}`;
|
||||
return trans(key);
|
||||
};
|
||||
|
||||
const isSamePlan = (plan: Plan): boolean => currentPlan.value?.id === plan.id;
|
||||
|
||||
const isCurrent = (plan: Plan): boolean => {
|
||||
if (!isSamePlan(plan)) return false;
|
||||
return isYearly.value === isOnYearly.value;
|
||||
};
|
||||
|
||||
const isPopular = (plan: Plan): boolean => plan.slug === 'pro';
|
||||
|
||||
const inheritsFrom: Partial<Record<PlanSlug, PlanSlug>> = {
|
||||
plus: 'starter',
|
||||
pro: 'plus',
|
||||
max: 'pro',
|
||||
};
|
||||
|
||||
const planNameBySlug = (list: Plan[], slug: PlanSlug): string =>
|
||||
list.find((plan) => plan.slug === slug)?.name ?? slug;
|
||||
|
||||
const planTones: Record<PlanSlug, string> = {
|
||||
starter: 'bg-amber-50',
|
||||
plus: 'bg-rose-50',
|
||||
pro: 'bg-violet-100',
|
||||
max: 'bg-emerald-50',
|
||||
};
|
||||
|
||||
const highlights = (plan: Plan): Highlight[] => [
|
||||
{ text: trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }) },
|
||||
{
|
||||
text: trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
|
||||
tooltip: trans(`billing.subscribe.credit_tooltips.${plan.slug}`),
|
||||
},
|
||||
{ text: trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }) },
|
||||
{ text: trans('billing.subscribe.features.members', { count: String(plan.member_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')
|
||||
: trans('billing.upgrade_dialog.switch_to_monthly');
|
||||
}
|
||||
if (hasActiveSubscription.value) return trans('billing.upgrade_dialog.switch');
|
||||
return trans('billing.upgrade_dialog.subscribe');
|
||||
};
|
||||
|
||||
const handleSelect = (plan: Plan) => {
|
||||
if (isCurrent(plan)) return;
|
||||
|
||||
processing.value = plan.id;
|
||||
const priceId = isYearly.value ? plan.stripe_yearly_price_id : plan.stripe_monthly_price_id;
|
||||
const url = hasActiveSubscription.value ? swap.url(plan.id) : checkout.url(plan.id);
|
||||
|
||||
router.post(url, { price_id: priceId }, {
|
||||
onSuccess: () => {
|
||||
closeUpgrade();
|
||||
},
|
||||
onFinish: () => {
|
||||
processing.value = null;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) closeUpgrade();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="onOpenChange">
|
||||
<DialogContent class="w-[95vw] gap-0 p-0 sm:max-w-6xl">
|
||||
<TooltipProvider :delay-duration="150">
|
||||
<div class="px-6 pt-6 text-center">
|
||||
<DialogTitle
|
||||
class="text-3xl font-semibold leading-tight"
|
||||
style="font-family: var(--font-display)"
|
||||
>
|
||||
{{ $t('billing.upgrade_dialog.title') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="mx-auto mt-2 max-w-2xl text-balance text-base text-foreground/70">
|
||||
{{ reason ?? $t('billing.upgrade_dialog.description') }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<!-- Billing toggle pill -->
|
||||
<div class="flex justify-center pt-6 pb-2">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground bg-card p-1 shadow-xs"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="!isYearly"
|
||||
:class="[
|
||||
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
|
||||
!isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
|
||||
]"
|
||||
@click="isYearly = false"
|
||||
>
|
||||
{{ $t('billing.subscribe.monthly') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="isYearly"
|
||||
:class="[
|
||||
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
|
||||
isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
|
||||
]"
|
||||
@click="isYearly = true"
|
||||
>
|
||||
{{ $t('billing.subscribe.yearly') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none absolute top-1/2 left-full ml-3 -translate-y-1/2 -rotate-3 inline-flex items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-amber-200 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest text-foreground shadow-xs transition-all',
|
||||
isYearly ? 'opacity-100' : 'opacity-30 grayscale',
|
||||
]"
|
||||
>
|
||||
<span>←</span>
|
||||
{{ $t('billing.subscribe.save_months') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan cards -->
|
||||
<div class="grid grid-cols-1 gap-5 px-6 pt-8 pb-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
:class="[
|
||||
planTones[plan.slug],
|
||||
'relative flex flex-col rounded-2xl border-2 border-foreground p-6 transition-shadow',
|
||||
isPopular(plan) ? 'shadow-xl' : 'shadow-sm hover:shadow-md',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="isPopular(plan)"
|
||||
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-foreground px-3 py-1 text-[11px] font-black uppercase tracking-widest text-background shadow-2xs"
|
||||
>
|
||||
⭐ {{ $t('billing.subscribe.popular') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isCurrent(plan)"
|
||||
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center whitespace-nowrap rounded-md border-2 border-foreground bg-card px-3 py-1 text-[11px] font-black uppercase tracking-widest text-foreground shadow-2xs"
|
||||
>
|
||||
{{ $t('billing.upgrade_dialog.current_badge') }}
|
||||
</span>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-bold tracking-tight text-foreground">
|
||||
{{ plan.name }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black tabular-nums text-foreground">
|
||||
{{ getDisplayPrice(plan) }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-foreground/60">
|
||||
{{ $t('billing.subscribe.per_month') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-tight text-foreground/60">
|
||||
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="processing !== null || isCurrent(plan) || isBlocked(plan)"
|
||||
:title="downgradeBlocker(plan) ?? undefined"
|
||||
:class="[
|
||||
'my-5 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border-2 border-foreground px-4 py-2.5 text-sm font-semibold shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isPopular(plan) ? 'bg-foreground text-background' : 'bg-card text-foreground',
|
||||
]"
|
||||
@click="handleSelect(plan)"
|
||||
>
|
||||
<IconLoader2 v-if="processing === plan.id" class="size-4 animate-spin" />
|
||||
{{ ctaLabel(plan) }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm font-bold leading-snug text-foreground">
|
||||
<template v-if="inheritsFrom[plan.slug]">
|
||||
{{ trans('billing.subscribe.everything_in', { plan: planNameBySlug(plans, inheritsFrom[plan.slug]!) }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('billing.subscribe.features_included') }}
|
||||
</template>
|
||||
</p>
|
||||
<div
|
||||
v-for="highlight in highlights(plan)"
|
||||
:key="highlight.text"
|
||||
class="flex items-start gap-2.5"
|
||||
>
|
||||
<div class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-foreground bg-card shadow-2xs">
|
||||
<IconCheck class="size-3 text-foreground" stroke-width="3" />
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 text-sm leading-snug text-foreground/80">
|
||||
{{ highlight.text }}
|
||||
<Tooltip v-if="highlight.tooltip">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center text-foreground/40 transition-colors hover:text-foreground"
|
||||
:aria-label="highlight.tooltip"
|
||||
>
|
||||
<IconInfoCircle class="size-3.5" stroke-width="2" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-xs text-sm leading-snug">
|
||||
{{ highlight.tooltip }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { IconClock, IconDots, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
|
|
@ -23,11 +22,9 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useFeatureAccess } from '@/composables/useFeatureAccess';
|
||||
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
|
||||
import { WorkspaceRole } from '@/types/workspace-role';
|
||||
import { destroy as destroyInvite } from '@/routes/app/invites';
|
||||
import { remove as removeMemberRoute, updateRole } from '@/routes/app/members';
|
||||
import { WorkspaceRole } from '@/types/workspace-role';
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
|
|
@ -51,14 +48,7 @@ const inviteDialogOpen = ref(false);
|
|||
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const { canInviteMember } = useFeatureAccess();
|
||||
const { openUpgrade } = useUpgradeDialog();
|
||||
|
||||
const handleInviteClick = () => {
|
||||
if (!canInviteMember.value) {
|
||||
openUpgrade(trans('billing.upgrade_dialog.reasons.member_limit'));
|
||||
return;
|
||||
}
|
||||
inviteDialogOpen.value = true;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ interface Plan {
|
|||
}
|
||||
|
||||
interface Features {
|
||||
workspaceLimit: number;
|
||||
socialAccountLimit: number;
|
||||
memberLimit: number;
|
||||
monthlyCreditsLimit: number;
|
||||
}
|
||||
|
||||
|
|
@ -24,28 +21,11 @@ export const useFeatureAccess = () => {
|
|||
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 workspaceLimit = computed(() => features.value?.workspaceLimit ?? 1);
|
||||
const socialAccountLimit = computed(() => features.value?.socialAccountLimit ?? 1);
|
||||
const memberLimit = computed(() => features.value?.memberLimit ?? 1);
|
||||
const monthlyCreditsLimit = computed(() => features.value?.monthlyCreditsLimit ?? 0);
|
||||
|
||||
const canCreateWorkspace = computed(() => {
|
||||
if (isSelfHosted.value) return true;
|
||||
if (!usage.value) return true;
|
||||
return usage.value.workspaceCount < workspaceLimit.value;
|
||||
});
|
||||
|
||||
const canConnectSocialAccount = computed(() => {
|
||||
if (isSelfHosted.value) return true;
|
||||
if (!usage.value) return true;
|
||||
return usage.value.socialAccountCount < socialAccountLimit.value;
|
||||
});
|
||||
|
||||
const canInviteMember = computed(() => {
|
||||
if (isSelfHosted.value) return true;
|
||||
if (!usage.value) return true;
|
||||
return usage.value.memberCount + usage.value.pendingInviteCount < memberLimit.value;
|
||||
});
|
||||
const canCreateWorkspace = computed(() => true);
|
||||
const canConnectSocialAccount = computed(() => true);
|
||||
const canInviteMember = computed(() => true);
|
||||
|
||||
const hasCreditsLeft = computed(() => {
|
||||
if (isSelfHosted.value) return true;
|
||||
|
|
@ -58,9 +38,6 @@ export const useFeatureAccess = () => {
|
|||
usage,
|
||||
features,
|
||||
isSelfHosted,
|
||||
workspaceLimit,
|
||||
socialAccountLimit,
|
||||
memberLimit,
|
||||
monthlyCreditsLimit,
|
||||
canCreateWorkspace,
|
||||
canConnectSocialAccount,
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { ref } from 'vue';
|
||||
|
||||
const open = ref(false);
|
||||
const reason = ref<string | null>(null);
|
||||
|
||||
export const useUpgradeDialog = () => ({
|
||||
open,
|
||||
reason,
|
||||
openUpgrade: (cause?: string) => {
|
||||
reason.value = cause ?? null;
|
||||
open.value = true;
|
||||
},
|
||||
closeUpgrade: () => {
|
||||
open.value = false;
|
||||
reason.value = null;
|
||||
},
|
||||
});
|
||||
|
|
@ -4,10 +4,8 @@ import { onBeforeUnmount, onMounted } from 'vue';
|
|||
|
||||
import AppHeader from '@/components/AppHeader.vue';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import DiscordWidget from '@/components/DiscordWidget.vue';
|
||||
import Toast from '@/components/Toast.vue';
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import UpgradeDialog from '@/components/UpgradeDialog.vue';
|
||||
import { heartbeat as heartbeatRoute } from '@/routes/app/presence';
|
||||
|
||||
const page = usePage();
|
||||
|
|
@ -75,7 +73,5 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<UpgradeDialog />
|
||||
<Toast />
|
||||
<DiscordWidget />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
IconSearch,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Auth/SocialController';
|
||||
|
|
@ -38,9 +37,7 @@ import {
|
|||
TableLoadMore,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useFeatureAccess } from '@/composables/useFeatureAccess';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
|
||||
import date from '@/date';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
|
|
@ -79,14 +76,7 @@ const isAddDialogOpen = ref(props.openDialog);
|
|||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const searchQuery = ref(props.filters.search);
|
||||
|
||||
const { canConnectSocialAccount } = useFeatureAccess();
|
||||
const { openUpgrade } = useUpgradeDialog();
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (!canConnectSocialAccount.value) {
|
||||
openUpgrade(trans('billing.upgrade_dialog.reasons.social_account_limit'));
|
||||
return;
|
||||
}
|
||||
isAddDialogOpen.value = true;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,287 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { IconArrowRight, IconCheck, IconInfoCircle } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useTracking } from '@/composables/useTracking';
|
||||
import { checkout } from '@/routes/app/billing';
|
||||
|
||||
type PlanSlug = 'starter' | 'plus' | 'pro' | 'max';
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
slug: PlanSlug;
|
||||
name: string;
|
||||
stripe_monthly_price_id: string | null;
|
||||
stripe_yearly_price_id: string | null;
|
||||
social_account_limit: number;
|
||||
member_limit: number;
|
||||
workspace_limit: number;
|
||||
monthly_credits_limit: number;
|
||||
}
|
||||
|
||||
interface Highlight {
|
||||
text: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const { plans, trialDays } = defineProps<{
|
||||
plans: Plan[];
|
||||
trialDays: number | null;
|
||||
}>();
|
||||
|
||||
const isYearly = ref(true);
|
||||
const processing = ref<string | null>(null);
|
||||
|
||||
const { trackBeginCheckout } = useTracking();
|
||||
|
||||
// Headline price = the per-month figure, regardless of toggle. When
|
||||
// yearly is selected we read the pre-computed per-month rate from
|
||||
// `lang/*/billing.php` (already with the right currency) so the price
|
||||
// stays comparable visually across toggles.
|
||||
const getDisplayPrice = (plan: Plan): string =>
|
||||
trans(`billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly_per_month' : 'monthly'}`);
|
||||
|
||||
const selectPlan = (plan: Plan) => {
|
||||
processing.value = plan.id;
|
||||
|
||||
const interval = isYearly.value ? 'yearly' : 'monthly';
|
||||
const priceId = isYearly.value ? plan.stripe_yearly_price_id : plan.stripe_monthly_price_id;
|
||||
|
||||
trackBeginCheckout({
|
||||
name: plan.name,
|
||||
interval,
|
||||
});
|
||||
|
||||
router.post(checkout.url(plan.id), {
|
||||
price_id: priceId,
|
||||
});
|
||||
};
|
||||
|
||||
// Mirrors the marketing site: each tier shows only the deltas vs the
|
||||
// previous one, plus a header telling the reader the lower tier is
|
||||
// already included. Starter shows the full "What's included" list.
|
||||
const inheritsFrom: Partial<Record<PlanSlug, PlanSlug>> = {
|
||||
plus: 'starter',
|
||||
pro: 'plus',
|
||||
max: 'pro',
|
||||
};
|
||||
|
||||
const planNameBySlug = (plans: Plan[], slug: PlanSlug): string =>
|
||||
plans.find((plan) => plan.slug === slug)?.name ?? slug;
|
||||
|
||||
const highlights = (plan: Plan): Highlight[] => [
|
||||
{ text: trans('billing.subscribe.features.social_accounts', { count: String(plan.social_account_limit) }) },
|
||||
{
|
||||
text: trans('billing.subscribe.features.credits', { count: plan.monthly_credits_limit.toLocaleString() }),
|
||||
tooltip: trans(`billing.subscribe.credit_tooltips.${plan.slug}`),
|
||||
},
|
||||
{ text: trans('billing.subscribe.features.workspaces', { count: String(plan.workspace_limit) }) },
|
||||
{ text: trans('billing.subscribe.features.members', { count: String(plan.member_limit) }) },
|
||||
];
|
||||
|
||||
const isPopular = (plan: Plan): boolean => plan.slug === 'pro';
|
||||
|
||||
const planTones: Record<PlanSlug, string> = {
|
||||
starter: 'bg-amber-50',
|
||||
plus: 'bg-rose-50',
|
||||
pro: 'bg-violet-100',
|
||||
max: 'bg-emerald-50',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('billing.subscribe.page_title')" />
|
||||
|
||||
<TooltipProvider :delay-duration="150">
|
||||
<section class="relative min-h-screen overflow-hidden bg-background">
|
||||
<!-- Dot pattern overlay (subtle ink dots) -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-[0.06]"
|
||||
style="background-image: radial-gradient(circle, #0a0a0a 1px, transparent 1px); background-size: 28px 28px;"
|
||||
/>
|
||||
|
||||
<!-- Soft violet glow blobs -->
|
||||
<div class="pointer-events-none absolute -top-20 right-0 size-[560px] rounded-full bg-violet-200/50 blur-3xl" />
|
||||
<div class="pointer-events-none absolute top-1/3 -left-32 size-[440px] rounded-full bg-fuchsia-200/30 blur-3xl" />
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="relative mx-auto max-w-7xl px-6 pt-14 pb-4 lg:pt-20">
|
||||
<div class="mx-auto max-w-3xl space-y-4 text-center">
|
||||
<span class="-rotate-1 inline-block rounded-md border-2 border-foreground bg-violet-200 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-foreground shadow-2xs">
|
||||
{{ $t('billing.subscribe.eyebrow') }}
|
||||
</span>
|
||||
<h1
|
||||
class="text-balance text-3xl font-normal leading-[1.1] tracking-tight text-foreground sm:text-5xl lg:text-6xl"
|
||||
style="font-family: var(--font-display);"
|
||||
>
|
||||
{{ $t('billing.subscribe.title') }}
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-balance text-base text-muted-foreground sm:text-lg">
|
||||
{{ $t('billing.subscribe.description') }}
|
||||
</p>
|
||||
<p v-if="trialDays !== null" class="text-sm font-semibold text-foreground/70">
|
||||
{{ trans('billing.subscribe.trial_info', { days: String(trialDays) }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plans -->
|
||||
<div class="relative mx-auto max-w-7xl px-4 pb-20">
|
||||
<!-- Billing toggle pill -->
|
||||
<div class="flex justify-center pt-8 pb-10">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground bg-card p-1 shadow-xs"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="!isYearly"
|
||||
:class="[
|
||||
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
|
||||
!isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
|
||||
]"
|
||||
@click="isYearly = false"
|
||||
>
|
||||
{{ $t('billing.subscribe.monthly') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="isYearly"
|
||||
:class="[
|
||||
'cursor-pointer whitespace-nowrap rounded-full px-5 py-1.5 text-sm font-semibold transition-colors',
|
||||
isYearly ? 'bg-foreground text-background' : 'text-foreground hover:bg-foreground/5',
|
||||
]"
|
||||
@click="isYearly = true"
|
||||
>
|
||||
{{ $t('billing.subscribe.yearly') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none absolute -top-6 -right-12 inline-flex items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-amber-200 px-2.5 py-1 text-[10px] font-black uppercase tracking-widest text-foreground shadow-xs transition-all sm:top-1/2 sm:right-auto sm:left-full sm:ml-3 sm:-translate-y-1/2 rotate-6 sm:-rotate-3',
|
||||
isYearly ? 'opacity-100' : 'opacity-30 grayscale',
|
||||
]"
|
||||
>
|
||||
<span class="hidden sm:inline">←</span>
|
||||
{{ $t('billing.subscribe.save_months') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan cards -->
|
||||
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
:class="[
|
||||
planTones[plan.slug],
|
||||
'relative flex flex-col rounded-2xl border-2 border-foreground p-7 transition-shadow sm:p-8',
|
||||
isPopular(plan) ? 'shadow-xl' : 'shadow-sm hover:shadow-md',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="isPopular(plan)"
|
||||
class="absolute -top-3.5 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-md border-2 border-foreground bg-foreground px-3 py-1 text-[11px] font-black uppercase tracking-widest text-background shadow-2xs"
|
||||
>
|
||||
⭐ {{ $t('billing.subscribe.popular') }}
|
||||
</span>
|
||||
|
||||
<div class="mb-5">
|
||||
<h3 class="text-2xl font-bold tracking-tight text-foreground">
|
||||
{{ plan.name }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="relative inline-block overflow-hidden leading-none">
|
||||
<Transition
|
||||
enter-active-class="motion-safe:transition-transform motion-safe:duration-[350ms] motion-safe:ease-out"
|
||||
leave-active-class="motion-safe:transition-transform motion-safe:duration-[350ms] motion-safe:ease-out absolute top-0 left-0"
|
||||
enter-from-class="motion-safe:translate-y-full"
|
||||
leave-to-class="motion-safe:-translate-y-full"
|
||||
>
|
||||
<span :key="getDisplayPrice(plan)" class="block text-5xl font-black tabular-nums text-foreground">
|
||||
{{ getDisplayPrice(plan) }}
|
||||
</span>
|
||||
</Transition>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-foreground/60">
|
||||
{{ $t('billing.subscribe.per_month') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-tight text-foreground/60">
|
||||
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="processing !== null"
|
||||
:class="[
|
||||
'my-5 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full border-2 border-foreground px-4 py-2.5 text-sm font-semibold shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isPopular(plan) ? 'bg-foreground text-background' : 'bg-card text-foreground',
|
||||
]"
|
||||
@click="selectPlan(plan)"
|
||||
>
|
||||
<template v-if="trialDays !== null">
|
||||
{{ trans('billing.subscribe.start_trial', { days: String(trialDays) }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('billing.subscribe.subscribe_cta') }}
|
||||
</template>
|
||||
<IconArrowRight class="size-4" />
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm font-bold leading-snug text-foreground">
|
||||
<template v-if="inheritsFrom[plan.slug]">
|
||||
{{ trans('billing.subscribe.everything_in', { plan: planNameBySlug(plans, inheritsFrom[plan.slug]!) }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('billing.subscribe.features_included') }}
|
||||
</template>
|
||||
</p>
|
||||
<div
|
||||
v-for="highlight in highlights(plan)"
|
||||
:key="highlight.text"
|
||||
class="flex items-start gap-2.5"
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 border-foreground bg-card shadow-2xs"
|
||||
>
|
||||
<IconCheck class="size-3 text-foreground" stroke-width="3" />
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 text-sm leading-snug text-foreground/80">
|
||||
{{ highlight.text }}
|
||||
<Tooltip v-if="highlight.tooltip">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center text-foreground/40 transition-colors hover:text-foreground"
|
||||
:aria-label="highlight.tooltip"
|
||||
>
|
||||
<IconInfoCircle class="size-3.5" stroke-width="2" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-xs text-sm leading-snug">
|
||||
{{ highlight.tooltip }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
119
resources/js/pages/onboarding/Index.vue
Normal file
119
resources/js/pages/onboarding/Index.vue
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconBriefcase,
|
||||
IconBuildingSkyscraper,
|
||||
IconBuildingStore,
|
||||
IconCheck,
|
||||
IconDots,
|
||||
IconRocket,
|
||||
IconUser,
|
||||
} from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import type { FunctionalComponent } from 'vue';
|
||||
|
||||
import { store } from '@/routes/app/onboarding';
|
||||
|
||||
interface Persona {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const { selected } = defineProps<{
|
||||
personas: Persona[];
|
||||
selected?: string | null;
|
||||
}>();
|
||||
|
||||
const form = useForm({ persona: selected ?? '' });
|
||||
|
||||
const icons: Record<string, FunctionalComponent> = {
|
||||
creator: IconUser,
|
||||
freelancer: IconBriefcase,
|
||||
startup: IconRocket,
|
||||
agency: IconBuildingSkyscraper,
|
||||
small_business: IconBuildingStore,
|
||||
other: IconDots,
|
||||
};
|
||||
|
||||
const personaLabel = (value: string): string => trans(`onboarding.personas.${value}`);
|
||||
|
||||
const select = (value: string): void => {
|
||||
form.persona = value;
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (!form.persona || form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.post(store.url());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="$t('onboarding.title')" />
|
||||
|
||||
<section class="relative min-h-screen overflow-hidden bg-background">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 opacity-[0.06]"
|
||||
style="background-image: radial-gradient(circle, #0a0a0a 1px, transparent 1px); background-size: 28px 28px;"
|
||||
/>
|
||||
<div class="pointer-events-none absolute -top-20 right-0 size-[560px] rounded-full bg-violet-200/50 blur-3xl" />
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col justify-center px-6 py-12">
|
||||
<div class="mx-auto mb-10 max-w-xl space-y-3 text-center">
|
||||
<h1
|
||||
class="text-balance text-3xl font-normal leading-[1.1] tracking-tight text-foreground sm:text-4xl"
|
||||
style="font-family: var(--font-display);"
|
||||
>
|
||||
{{ $t('onboarding.title') }}
|
||||
</h1>
|
||||
<p class="text-balance text-base text-muted-foreground">
|
||||
{{ $t('onboarding.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
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',
|
||||
]"
|
||||
@click="select(persona.value)"
|
||||
>
|
||||
<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" />
|
||||
</span>
|
||||
<span class="text-base font-bold tracking-tight text-foreground">
|
||||
{{ personaLabel(persona.value) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="form.persona === persona.value"
|
||||
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" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-10 flex w-full max-w-sm flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!form.persona || form.processing"
|
||||
class="inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-full border-2 border-foreground bg-foreground px-4 py-3 text-sm font-semibold text-background shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('onboarding.continue') }}
|
||||
<IconArrowRight class="size-4" />
|
||||
</button>
|
||||
<p class="text-center text-xs text-foreground/60">
|
||||
{{ $t('onboarding.trial_note') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, usePage } from '@inertiajs/vue3';
|
||||
import { IconCreditCard, IconDownload, IconFileText, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { Head, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { IconCreditCard, IconDownload, IconFileText, IconRosetteDiscountCheck, IconSparkles } from '@tabler/icons-vue';
|
||||
import { trans, transChoice } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import HeadingSmall from '@/components/HeadingSmall.vue';
|
||||
|
|
@ -9,11 +9,10 @@ import PageHeader from '@/components/PageHeader.vue';
|
|||
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 { edit as accountEdit } from '@/routes/app/account';
|
||||
import { index as billingIndex, portal } from '@/routes/app/billing';
|
||||
import { index as billingIndex, portal, swapToYearly } from '@/routes/app/billing';
|
||||
import { index as usageIndex } from '@/routes/app/usage';
|
||||
import type { AuthPlan } from '@/types';
|
||||
|
||||
|
|
@ -42,13 +41,13 @@ interface Invoice {
|
|||
invoice_pdf: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
hasSubscription: boolean;
|
||||
onTrial: boolean;
|
||||
trialEndsAt: string | null;
|
||||
subscription: Subscription | null;
|
||||
plan: Plan | null;
|
||||
plans: Plan[];
|
||||
workspaceCount: number;
|
||||
invoices: Invoice[];
|
||||
defaultPaymentMethod: PaymentMethod | null;
|
||||
}>();
|
||||
|
|
@ -69,7 +68,18 @@ const displayPrice = (slug: string | undefined): string => {
|
|||
return trans(`billing.subscribe.prices.${slug}.${key}`);
|
||||
};
|
||||
|
||||
const { openUpgrade } = useUpgradeDialog();
|
||||
const workspacesLabel = computed(() => transChoice('billing.plan.workspaces', props.workspaceCount, { count: props.workspaceCount }));
|
||||
|
||||
const monthlyPrice = computed(() => (props.plan ? trans(`billing.subscribe.prices.${props.plan.slug}.monthly`) : ''));
|
||||
const yearlyPerMonthPrice = computed(() => (props.plan ? trans(`billing.subscribe.prices.${props.plan.slug}.yearly_per_month`) : ''));
|
||||
|
||||
const showAnnualBanner = computed(() => props.hasSubscription && ! isYearly.value);
|
||||
|
||||
const upgradeForm = useForm({});
|
||||
|
||||
const upgradeToAnnual = (): void => {
|
||||
upgradeForm.post(swapToYearly.url(), { preserveScroll: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -85,6 +95,32 @@ const { openUpgrade } = useUpgradeDialog();
|
|||
<SettingsTabsNav :tabs="tabs" active="billing" />
|
||||
|
||||
<section class="space-y-12">
|
||||
<!-- ───── Annual upgrade banner ───── -->
|
||||
<div
|
||||
v-if="showAnnualBanner"
|
||||
class="flex flex-wrap items-center gap-4 rounded-2xl border-2 border-foreground bg-emerald-100 p-5 shadow-2xs"
|
||||
>
|
||||
<span class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-emerald-300 shadow-2xs">
|
||||
<IconRosetteDiscountCheck class="size-6 text-foreground" stroke-width="2" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base font-bold text-foreground">
|
||||
{{ $t('billing.annual_banner.title') }}
|
||||
</p>
|
||||
<p class="text-sm text-foreground/70">
|
||||
{{ $t('billing.annual_banner.description') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-semibold text-foreground">
|
||||
<span class="text-foreground/50 line-through tabular-nums">{{ monthlyPrice }}</span>
|
||||
<span class="ml-1.5 tabular-nums">{{ yearlyPerMonthPrice }}</span>
|
||||
<span class="font-medium text-foreground/60">/{{ $t('billing.plan.month') }} · {{ $t('billing.subscribe.billed_yearly') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button class="shrink-0" :disabled="upgradeForm.processing" @click="upgradeToAnnual">
|
||||
{{ $t('billing.annual_banner.cta') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- ───── Current plan hero card ───── -->
|
||||
<div class="space-y-6">
|
||||
<HeadingSmall
|
||||
|
|
@ -103,7 +139,7 @@ const { openUpgrade } = useUpgradeDialog();
|
|||
class="text-3xl font-semibold leading-tight text-foreground"
|
||||
style="font-family: var(--font-display)"
|
||||
>
|
||||
{{ plan?.name ?? 'No plan' }}
|
||||
{{ workspacesLabel }}
|
||||
</h3>
|
||||
<Badge v-if="onTrial" variant="secondary">{{ $t('billing.plan.trial') }}</Badge>
|
||||
<Badge v-else-if="subscription?.stripe_status === 'active'" variant="success">{{ $t('billing.plan.active') }}</Badge>
|
||||
|
|
@ -112,7 +148,7 @@ const { openUpgrade } = useUpgradeDialog();
|
|||
</div>
|
||||
<p class="text-base text-foreground/70">
|
||||
<span class="text-2xl font-bold tabular-nums text-foreground">{{ displayPrice(plan?.slug) }}</span>
|
||||
<span class="ml-1">/{{ $t('billing.plan.month') }}</span>
|
||||
<span class="ml-1">/{{ $t('billing.plan.month') }} {{ $t('billing.plan.per_workspace') }}</span>
|
||||
</p>
|
||||
<p v-if="plan" class="text-xs font-medium text-foreground/60">
|
||||
{{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }}
|
||||
|
|
@ -124,13 +160,10 @@ const { openUpgrade } = useUpgradeDialog();
|
|||
{{ $t('billing.plan.trial_ends') }}: <span class="text-foreground">{{ date.formatDate(trialEndsAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-4 shrink-0">
|
||||
<div class="flex shrink-0 flex-col items-end gap-4">
|
||||
<span class="inline-flex size-14 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-amber-200 shadow-2xs">
|
||||
<IconSparkles class="size-7 text-foreground" stroke-width="2" />
|
||||
</span>
|
||||
<Button @click="openUpgrade()">
|
||||
{{ $t('billing.plan.change') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,11 +25,8 @@ interface Plan {
|
|||
|
||||
interface UsageData {
|
||||
workspaceCount: number;
|
||||
workspaceLimit: number;
|
||||
socialAccountCount: number;
|
||||
socialAccountLimit: number;
|
||||
memberCount: number;
|
||||
memberLimit: number;
|
||||
creditsUsed: number;
|
||||
monthlyCreditsLimit: number;
|
||||
}
|
||||
|
|
@ -62,7 +59,7 @@ const tabs = computed(() => [
|
|||
<div class="space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('usage.section_account')"
|
||||
:description="$t('usage.section_account_description', { plan: plan?.name ?? 'Free' })"
|
||||
:description="$t('usage.section_account_description')"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -70,7 +67,6 @@ const tabs = computed(() => [
|
|||
:label="$t('usage.workspaces')"
|
||||
:icon="IconBuildingCommunity"
|
||||
:current="usage.workspaceCount"
|
||||
:limit="usage.workspaceLimit"
|
||||
tone="violet"
|
||||
rotate="-rotate-2"
|
||||
/>
|
||||
|
|
@ -79,7 +75,6 @@ const tabs = computed(() => [
|
|||
:label="$t('usage.social_accounts')"
|
||||
:icon="IconAffiliate"
|
||||
:current="usage.socialAccountCount"
|
||||
:limit="usage.socialAccountLimit"
|
||||
tone="amber"
|
||||
rotate="rotate-1"
|
||||
/>
|
||||
|
|
@ -88,7 +83,6 @@ const tabs = computed(() => [
|
|||
:label="$t('usage.members')"
|
||||
:icon="IconUsers"
|
||||
:current="usage.memberCount"
|
||||
:limit="usage.memberLimit"
|
||||
tone="emerald"
|
||||
rotate="-rotate-1"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Http\Controllers\App\DiscordController as AppDiscordController;
|
||||
use App\Http\Controllers\App\GiphyController;
|
||||
use App\Http\Controllers\App\NotificationController;
|
||||
use App\Http\Controllers\App\OnboardingController;
|
||||
use App\Http\Controllers\App\PostAiCreateController;
|
||||
use App\Http\Controllers\App\PostAiGenerateController;
|
||||
use App\Http\Controllers\App\PostAiRegenerateMediaController;
|
||||
|
|
@ -55,6 +56,8 @@
|
|||
})->name('app.home');
|
||||
|
||||
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');
|
||||
|
||||
|
|
@ -254,7 +257,7 @@
|
|||
// Billing
|
||||
Route::get('settings/account/billing', [BillingController::class, 'index'])->name('app.billing.index');
|
||||
Route::get('settings/account/billing/portal', [BillingController::class, 'portal'])->name('app.billing.portal');
|
||||
Route::post('settings/account/billing/swap/{plan}', [BillingController::class, 'swap'])->name('app.billing.swap');
|
||||
Route::post('settings/account/billing/swap-to-yearly', [BillingController::class, 'swapToYearly'])->name('app.billing.swap-to-yearly');
|
||||
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,43 +5,59 @@
|
|||
use App\Models\Account;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
test('monthly credits returns sum of credits for account this month', function () {
|
||||
test('credits used between sums credits within the window', function () {
|
||||
$account = Account::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
|
||||
|
||||
AiUsageLog::factory()->text(credits: 5)->count(3)->create([
|
||||
'account_id' => $account->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'created_at' => Carbon::parse('2026-06-10'),
|
||||
]);
|
||||
|
||||
AiUsageLog::factory()->text(credits: 10)->count(2)->create([
|
||||
'account_id' => $account->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'created_at' => Carbon::parse('2026-06-12'),
|
||||
]);
|
||||
|
||||
expect(AiUsageLog::monthlyCredits($account->id))->toBe(35);
|
||||
$used = AiUsageLog::creditsUsedBetween(
|
||||
$account->id,
|
||||
Carbon::parse('2026-06-01'),
|
||||
Carbon::parse('2026-07-01'),
|
||||
);
|
||||
|
||||
expect($used)->toBe(35);
|
||||
});
|
||||
|
||||
test('monthly credits excludes logs from other months', function () {
|
||||
test('credits used between excludes logs outside the window', function () {
|
||||
$account = Account::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
|
||||
|
||||
AiUsageLog::factory()->text(credits: 10)->create([
|
||||
'account_id' => $account->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'created_at' => Carbon::parse('2026-06-10'),
|
||||
]);
|
||||
|
||||
AiUsageLog::factory()->text(credits: 50)->create([
|
||||
'account_id' => $account->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'created_at' => now()->subMonth(),
|
||||
'created_at' => Carbon::parse('2026-05-10'),
|
||||
]);
|
||||
|
||||
expect(AiUsageLog::monthlyCredits($account->id))->toBe(10);
|
||||
$used = AiUsageLog::creditsUsedBetween(
|
||||
$account->id,
|
||||
Carbon::parse('2026-06-01'),
|
||||
Carbon::parse('2026-07-01'),
|
||||
);
|
||||
|
||||
expect($used)->toBe(10);
|
||||
});
|
||||
|
||||
test('monthly credits excludes logs from other accounts', function () {
|
||||
test('credits used between excludes logs from other accounts', function () {
|
||||
$account = Account::factory()->create();
|
||||
$otherAccount = Account::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['account_id' => $account->id]);
|
||||
|
|
@ -50,12 +66,20 @@
|
|||
AiUsageLog::factory()->text(credits: 10)->create([
|
||||
'account_id' => $account->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'created_at' => Carbon::parse('2026-06-10'),
|
||||
]);
|
||||
|
||||
AiUsageLog::factory()->text(credits: 100)->create([
|
||||
'account_id' => $otherAccount->id,
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
'created_at' => Carbon::parse('2026-06-10'),
|
||||
]);
|
||||
|
||||
expect(AiUsageLog::monthlyCredits($account->id))->toBe(10);
|
||||
$used = AiUsageLog::creditsUsedBetween(
|
||||
$account->id,
|
||||
Carbon::parse('2026-06-01'),
|
||||
Carbon::parse('2026-07-01'),
|
||||
);
|
||||
|
||||
expect($used)->toBe(10);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\User\CreateUser;
|
||||
use App\Enums\Plan\Slug;
|
||||
use Database\Seeders\PlanSeeder;
|
||||
|
||||
beforeEach(function () {
|
||||
|
|
@ -36,6 +37,6 @@
|
|||
'registration_ip' => '127.0.0.1',
|
||||
]);
|
||||
|
||||
expect($user->account->plan_id)->not->toBeNull();
|
||||
expect($user->account->plan->slug)->toBe(Slug::Workspace);
|
||||
expect($user->account->trial_ends_at)->not->toBeNull();
|
||||
});
|
||||
|
|
|
|||
139
tests/Feature/Billing/BillingCycleTest.php
Normal file
139
tests/Feature/Billing/BillingCycleTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Workspace;
|
||||
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);
|
||||
|
||||
expect(BillingCycle::for($account)->creditAllotment())->toBe(7500);
|
||||
});
|
||||
|
||||
test('yearly allotment grants twelve months upfront per workspace', function () {
|
||||
$account = billingAccount('price_year', workspaces: 2);
|
||||
|
||||
expect(BillingCycle::for($account)->creditAllotment())->toBe(60000);
|
||||
});
|
||||
|
||||
test('during trial the allotment is the monthly amount even on a yearly price', function () {
|
||||
Carbon::setTestNow('2026-06-20 12:00:00');
|
||||
|
||||
$account = billingAccount('price_year', [
|
||||
'created_at' => Carbon::parse('2026-06-18'),
|
||||
'trial_ends_at' => Carbon::parse('2026-06-25'),
|
||||
], workspaces: 1);
|
||||
|
||||
expect(BillingCycle::for($account)->creditAllotment())->toBe(2500);
|
||||
});
|
||||
|
||||
test('monthly cycle window spans the anchor day to the next month', function () {
|
||||
Carbon::setTestNow('2026-06-20 12:00:00');
|
||||
$account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-03-05')]);
|
||||
|
||||
$cycle = BillingCycle::for($account);
|
||||
|
||||
expect($cycle->periodStart()->toDateString())->toBe('2026-06-05')
|
||||
->and($cycle->periodEnd()->toDateString())->toBe('2026-07-05');
|
||||
});
|
||||
|
||||
test('yearly cycle window spans one year from the anchor', function () {
|
||||
Carbon::setTestNow('2026-06-20 12:00:00');
|
||||
$account = billingAccount('price_year', ['created_at' => Carbon::parse('2025-02-10')]);
|
||||
|
||||
$cycle = BillingCycle::for($account);
|
||||
|
||||
expect($cycle->periodStart()->toDateString())->toBe('2026-02-10')
|
||||
->and($cycle->periodEnd()->toDateString())->toBe('2027-02-10');
|
||||
});
|
||||
|
||||
test('used credits counts only usage within the current cycle window', function () {
|
||||
Carbon::setTestNow('2026-06-20 12:00:00');
|
||||
|
||||
$account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-03-05')]);
|
||||
$workspace = $account->workspaces()->first();
|
||||
|
||||
AiUsageLog::factory()->text(credits: 10)->create([
|
||||
'account_id' => $account->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'created_at' => Carbon::parse('2026-06-10'),
|
||||
]);
|
||||
|
||||
AiUsageLog::factory()->text(credits: 99)->create([
|
||||
'account_id' => $account->id,
|
||||
'workspace_id' => $workspace->id,
|
||||
'created_at' => Carbon::parse('2026-06-01'),
|
||||
]);
|
||||
|
||||
expect(BillingCycle::for($account)->usedCredits())->toBe(10);
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
expect(BillingCycle::for($account)->creditAllotment())->toBe(5000);
|
||||
});
|
||||
|
||||
test('monthly window clamps an end-of-month anchor without drift', function () {
|
||||
Carbon::setTestNow('2026-06-20 12:00:00');
|
||||
$account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-01-31')]);
|
||||
|
||||
$cycle = BillingCycle::for($account);
|
||||
|
||||
expect($cycle->periodStart()->toDateString())->toBe('2026-05-31')
|
||||
->and($cycle->periodEnd()->toDateString())->toBe('2026-06-30');
|
||||
});
|
||||
|
||||
test('yearly window clamps a leap-day anchor without drift', function () {
|
||||
Carbon::setTestNow('2026-06-20 12:00:00');
|
||||
$account = billingAccount('price_year', ['created_at' => Carbon::parse('2024-02-29')]);
|
||||
|
||||
$cycle = BillingCycle::for($account);
|
||||
|
||||
expect($cycle->periodStart()->toDateString())->toBe('2026-02-28')
|
||||
->and($cycle->periodEnd()->toDateString())->toBe('2027-02-28');
|
||||
});
|
||||
|
||||
test('allotment is zero when the account has no plan', function () {
|
||||
$account = Account::factory()->create(['plan_id' => null, 'trial_ends_at' => null]);
|
||||
Workspace::factory()->count(2)->create(['account_id' => $account->id]);
|
||||
|
||||
expect(BillingCycle::for($account)->creditAllotment())->toBe(0);
|
||||
});
|
||||
7
tests/Feature/Billing/TrialLengthTest.php
Normal file
7
tests/Feature/Billing/TrialLengthTest.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
test('the default trial length is 8 days', function () {
|
||||
expect(config('cashier.trial_days'))->toBe(8);
|
||||
});
|
||||
47
tests/Feature/Billing/WorkspaceQuantitySyncTest.php
Normal file
47
tests/Feature/Billing/WorkspaceQuantitySyncTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Account;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
test('syncWorkspaceQuantity does not touch Stripe in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
|
||||
$subscription = Mockery::mock(Subscription::class);
|
||||
$subscription->shouldReceive('active')->andReturnTrue();
|
||||
$subscription->shouldReceive('updateQuantity')->never();
|
||||
|
||||
$account = Mockery::mock(Account::class)->makePartial();
|
||||
$account->shouldReceive('subscription')->andReturn($subscription);
|
||||
$account->shouldReceive('workspaces->count')->andReturn(3);
|
||||
|
||||
$account->syncWorkspaceQuantity();
|
||||
});
|
||||
|
||||
test('syncWorkspaceQuantity does not touch Stripe without an active subscription', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
|
||||
$subscription = Mockery::mock(Subscription::class);
|
||||
$subscription->shouldReceive('active')->andReturnFalse();
|
||||
$subscription->shouldReceive('updateQuantity')->never();
|
||||
|
||||
$account = Mockery::mock(Account::class)->makePartial();
|
||||
$account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
|
||||
|
||||
$account->syncWorkspaceQuantity();
|
||||
});
|
||||
|
||||
test('syncWorkspaceQuantity updates the subscription quantity to the workspace count', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
|
||||
$subscription = Mockery::mock(Subscription::class);
|
||||
$subscription->shouldReceive('active')->andReturnTrue();
|
||||
$subscription->shouldReceive('updateQuantity')->once()->with(3);
|
||||
|
||||
$account = Mockery::mock(Account::class)->makePartial();
|
||||
$account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription);
|
||||
$account->shouldReceive('workspaces->count')->andReturn(3);
|
||||
|
||||
$account->syncWorkspaceQuantity();
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
|
|
@ -31,37 +32,37 @@
|
|||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('subscribe shows subscription page', function () {
|
||||
test('subscribe redirects to onboarding', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Subscribe', false)
|
||||
->has('plans')
|
||||
);
|
||||
$response->assertRedirect(route('app.onboarding'));
|
||||
});
|
||||
|
||||
test('subscribe redirects to billing index when account has active subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
test('checkout redirects to calendar in self hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
$plan = Plan::where('slug', 'workspace')->first();
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
$response = $this->actingAs($this->user)
|
||||
->post(route('app.billing.checkout', $plan), ['price_id' => 'price_x']);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
|
||||
$response->assertRedirect(route('app.billing.index'));
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
||||
test('subscribe redirects to calendar in self hosted mode', function () {
|
||||
test('swapToYearly redirects to calendar in self hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
$response = $this->actingAs($this->user)
|
||||
->post(route('app.billing.swap-to-yearly'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
||||
test('portal redirects to calendar in self hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.billing.portal'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
|
@ -90,7 +91,7 @@
|
|||
->component('settings/account/Billing', false)
|
||||
->has('hasSubscription')
|
||||
->has('plan')
|
||||
->has('plans')
|
||||
->has('workspaceCount')
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -134,31 +135,6 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('subscribe page exposes trialDays prop', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Subscribe', false)
|
||||
->where('trialDays', config('cashier.trial_days'))
|
||||
);
|
||||
});
|
||||
|
||||
test('subscribe page exposes null trialDays when card is not required', function () {
|
||||
config([
|
||||
'trypost.self_hosted' => false,
|
||||
'trypost.billing.require_card_for_trial' => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Subscribe', false)
|
||||
->where('trialDays', null)
|
||||
);
|
||||
});
|
||||
|
||||
test('billing index redirects to calendar in self hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
|
|
@ -230,15 +206,15 @@
|
|||
test('shared auth.plan exposes name slug and interval via AuthPlanResource', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$plan = Plan::where('slug', 'pro')->firstOrFail();
|
||||
$plan = Plan::where('slug', 'workspace')->firstOrFail();
|
||||
$this->account->update(['plan_id' => $plan->id]);
|
||||
|
||||
$response = $this->actingAs($this->user->fresh())->get(route('app.billing.processing'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('auth.plan.name', 'Pro')
|
||||
->where('auth.plan.slug', 'pro')
|
||||
->where('auth.plan.name', 'Workspace')
|
||||
->where('auth.plan.slug', 'workspace')
|
||||
->where('auth.plan.interval', 'monthly')
|
||||
);
|
||||
});
|
||||
|
|
@ -305,81 +281,62 @@
|
|||
$this->actingAs($member)->get(route('app.billing.index'))->assertForbidden();
|
||||
});
|
||||
|
||||
// Swap tests
|
||||
test('swap blocks downgrade when usage exceeds target plan limits', function () {
|
||||
// Swap-to-yearly tests
|
||||
test('swapToYearly forbids a non-owner', 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]);
|
||||
$member = User::factory()->create(['account_id' => $this->account->id]);
|
||||
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
$member->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_current_monthly',
|
||||
'stripe_price' => 'price_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',
|
||||
]));
|
||||
$this->actingAs($member)
|
||||
->post(route('app.billing.swap-to-yearly'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('swap rejects invalid price_id for plan', function () {
|
||||
test('swapToYearly is a no-op when already on annual billing', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$plan = Plan::where('slug', 'pro')->first();
|
||||
$plan = Plan::where('slug', 'workspace')->first();
|
||||
$plan->update([
|
||||
'stripe_monthly_price_id' => 'price_monthly',
|
||||
'stripe_yearly_price_id' => 'price_yearly',
|
||||
]);
|
||||
$this->account->update(['plan_id' => $plan->id]);
|
||||
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_other',
|
||||
'stripe_price' => 'price_yearly',
|
||||
]);
|
||||
|
||||
$this->user->unsetRelation('account');
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->post(route('app.billing.swap', $plan), [
|
||||
'price_id' => 'price_unrelated',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.billing.swap-to-yearly'))
|
||||
->assertRedirect(route('app.billing.index'));
|
||||
});
|
||||
|
||||
test('swap requires authentication', function () {
|
||||
$plan = Plan::first();
|
||||
$response = $this->post(route('app.billing.swap', $plan));
|
||||
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'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,20 +4,13 @@
|
|||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$this->plan = Plan::first();
|
||||
$this->plan->update([
|
||||
'workspace_limit' => 5,
|
||||
'member_limit' => 5,
|
||||
]);
|
||||
|
||||
$this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
|
||||
$this->account = Account::factory()->create();
|
||||
$this->user = User::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
|
@ -30,37 +23,7 @@
|
|||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('can invite member within limit', function () {
|
||||
expect($this->user->can('inviteMember', $this->workspace))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cannot invite member beyond limit', function () {
|
||||
$members = User::factory()->count(4)->create([
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
foreach ($members as $member) {
|
||||
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
}
|
||||
|
||||
// 1 owner + 4 members = 5, which is the limit
|
||||
expect($this->user->can('inviteMember', $this->workspace))->toBeFalse();
|
||||
});
|
||||
|
||||
test('self hosted mode bypasses workspace limit', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
Workspace::factory()->count(10)->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
expect($this->user->can('create', Workspace::class))->toBeTrue();
|
||||
});
|
||||
|
||||
test('self hosted mode bypasses member limit', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
test('owner can invite members with no count limit', function () {
|
||||
$members = User::factory()->count(10)->create([
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
|
@ -71,3 +34,10 @@
|
|||
|
||||
expect($this->user->can('inviteMember', $this->workspace))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a non-admin member cannot invite members', function () {
|
||||
$member = User::factory()->create(['account_id' => $this->account->id]);
|
||||
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
||||
|
||||
expect($member->can('inviteMember', $this->workspace))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Enums\PostHog\BillingEvent;
|
||||
use App\Features\WorkspaceLimit;
|
||||
use App\Features\MonthlyCreditsLimit;
|
||||
use App\Jobs\PostHog\TrackBilling;
|
||||
use App\Listeners\StripeEventListener;
|
||||
use App\Models\Account;
|
||||
|
|
@ -17,6 +18,19 @@
|
|||
beforeEach(function () {
|
||||
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
|
||||
|
||||
Plan::factory()->create([
|
||||
'slug' => Slug::Starter,
|
||||
'name' => 'Starter',
|
||||
'stripe_monthly_price_id' => 'price_starter_monthly',
|
||||
'stripe_yearly_price_id' => 'price_starter_yearly',
|
||||
]);
|
||||
Plan::factory()->create([
|
||||
'slug' => Slug::Pro,
|
||||
'name' => 'Pro',
|
||||
'stripe_monthly_price_id' => 'price_pro_monthly',
|
||||
'stripe_yearly_price_id' => 'price_pro_yearly',
|
||||
]);
|
||||
|
||||
$this->account = Account::factory()->create(['stripe_id' => 'cus_test123']);
|
||||
$this->user = User::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
|
|
@ -431,9 +445,8 @@
|
|||
|
||||
$this->account->update(['plan_id' => $starter->id]);
|
||||
|
||||
// Prime the Pennant cache against the starter limit.
|
||||
expect(Feature::for($this->account)->value(WorkspaceLimit::class))
|
||||
->toBe($starter->workspace_limit);
|
||||
// Prime the Pennant cache.
|
||||
Feature::for($this->account)->value(MonthlyCreditsLimit::class);
|
||||
|
||||
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
|
||||
->toBeGreaterThan(0);
|
||||
|
|
@ -449,15 +462,29 @@
|
|||
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
|
||||
->toBe(0);
|
||||
|
||||
expect(Feature::for($this->account->fresh())->value(WorkspaceLimit::class))
|
||||
->toBe($pro->workspace_limit);
|
||||
expect($this->account->fresh()->plan_id)->toBe($pro->id);
|
||||
});
|
||||
|
||||
test('subscription updated maps an archived legacy plan by price id', function () {
|
||||
$pro = Plan::query()->where('slug', 'pro')->firstOrFail();
|
||||
$pro->update(['is_archived' => true]);
|
||||
|
||||
$this->listener->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => ['object' => [
|
||||
'customer' => 'cus_test123',
|
||||
'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]],
|
||||
]],
|
||||
]));
|
||||
|
||||
expect($this->account->fresh()->plan_id)->toBe($pro->id);
|
||||
});
|
||||
|
||||
test('subscription deleted flushes the pennant cache', function () {
|
||||
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
|
||||
$this->account->update(['plan_id' => $starter->id]);
|
||||
|
||||
Feature::for($this->account)->value(WorkspaceLimit::class);
|
||||
Feature::for($this->account)->value(MonthlyCreditsLimit::class);
|
||||
|
||||
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
|
||||
->toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@
|
|||
config()->set('trypost.self_hosted', false);
|
||||
});
|
||||
|
||||
test('redirects to subscribe when account has no active subscription', function () {
|
||||
test('redirects to onboarding when account has no active subscription', function () {
|
||||
$account = Account::factory()->create();
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('app.calendar'))
|
||||
->assertRedirect(route('app.subscribe'));
|
||||
->assertRedirect(route('app.onboarding'));
|
||||
});
|
||||
|
||||
test('redirects to workspace create when subscribed but no workspace', function () {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
$response = $this->actingAs($user->fresh())->get(route('app.accounts'));
|
||||
|
||||
$response->assertRedirect(route('app.subscribe'));
|
||||
$response->assertRedirect(route('app.onboarding'));
|
||||
});
|
||||
|
||||
test('user with active subscription can access the app', function () {
|
||||
|
|
|
|||
|
|
@ -2,40 +2,29 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Features\MemberLimit;
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Features\MonthlyCreditsLimit;
|
||||
use App\Features\SocialAccountLimit;
|
||||
use App\Features\WorkspaceLimit;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
test('forgetPlanFeatureCache drops the cached plan-scoped features', function () {
|
||||
$starter = Plan::where('slug', 'starter')->first();
|
||||
$plus = Plan::where('slug', 'plus')->first();
|
||||
test('forgetPlanFeatureCache drops the cached monthly credits feature', function () {
|
||||
$plan = Plan::where('slug', Slug::Workspace)->first();
|
||||
|
||||
$account = Account::factory()->create(['plan_id' => $starter->id]);
|
||||
$account = Account::factory()->create(['plan_id' => $plan->id]);
|
||||
Workspace::factory()->create(['account_id' => $account->id]);
|
||||
|
||||
// Prime the Pennant cache against the starter plan.
|
||||
Feature::for($account)->value(WorkspaceLimit::class);
|
||||
Feature::for($account)->value(SocialAccountLimit::class);
|
||||
Feature::for($account)->value(MemberLimit::class);
|
||||
Feature::for($account)->value(MonthlyCreditsLimit::class);
|
||||
|
||||
expect(DB::table('features')->where('scope', 'account|'.$account->id)->count())
|
||||
->toBe(4);
|
||||
->toBe(1);
|
||||
|
||||
// Move the account to a plan with different limits and forget the cache.
|
||||
$account->update(['plan_id' => $plus->id]);
|
||||
$account->forgetPlanFeatureCache();
|
||||
$account->load('plan');
|
||||
|
||||
expect(DB::table('features')->where('scope', 'account|'.$account->id)->count())
|
||||
->toBe(0);
|
||||
|
||||
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(MonthlyCreditsLimit::class))->toBe($plus->monthly_credits_limit);
|
||||
expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe(2500);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Models\Account;
|
||||
use App\Models\Invite;
|
||||
use App\Models\Plan;
|
||||
|
|
@ -43,17 +44,19 @@
|
|||
]);
|
||||
});
|
||||
|
||||
test('featureLimits returns plan-resolved limits', function () {
|
||||
$plan = Plan::where('slug', 'plus')->first();
|
||||
test('featureLimits returns the monthly credits allotment', function () {
|
||||
$plan = Plan::where('slug', Slug::Workspace)->first();
|
||||
$this->account->update(['plan_id' => $plan->id]);
|
||||
|
||||
Workspace::factory()->count(2)->create([
|
||||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->owner->id,
|
||||
]);
|
||||
|
||||
$limits = $this->account->featureLimits();
|
||||
|
||||
expect($limits)->toBe([
|
||||
'workspaceLimit' => $plan->workspace_limit,
|
||||
'socialAccountLimit' => $plan->social_account_limit,
|
||||
'memberLimit' => $plan->member_limit,
|
||||
'monthlyCreditsLimit' => $plan->monthly_credits_limit,
|
||||
'monthlyCreditsLimit' => 5000,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
99
tests/Feature/Onboarding/OnboardingControllerTest.php
Normal file
99
tests/Feature/Onboarding/OnboardingControllerTest.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Billing\StartSubscriptionCheckout;
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Enums\User\Persona;
|
||||
use App\Jobs\PostHog\SendEvent;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
$this->user = User::factory()->create();
|
||||
});
|
||||
|
||||
test('onboarding renders the persona selection for an unsubscribed account', function () {
|
||||
$response = $this->actingAs($this->user)->get(route('app.onboarding'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('onboarding/Index')
|
||||
->has('personas', count(Persona::cases()))
|
||||
);
|
||||
});
|
||||
|
||||
test('onboarding redirects to calendar in self-hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.onboarding'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
||||
test('onboarding redirects to calendar when already subscribed', function () {
|
||||
$this->user->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
});
|
||||
|
||||
test('onboarding store rejects an invalid persona', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
|
||||
'persona' => 'not-a-persona',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('persona');
|
||||
expect($this->user->fresh()->persona)->toBeNull();
|
||||
});
|
||||
|
||||
test('onboarding store requires a persona', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), []);
|
||||
|
||||
$response->assertSessionHasErrors('persona');
|
||||
});
|
||||
|
||||
test('onboarding store does nothing in self-hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
|
||||
'persona' => Persona::Agency->value,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
expect($this->user->fresh()->persona)->toBeNull();
|
||||
});
|
||||
|
||||
test('onboarding store saves the persona, mirrors to PostHog and starts monthly checkout', function () {
|
||||
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
|
||||
Bus::fake();
|
||||
|
||||
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
|
||||
'stripe_monthly_price_id' => 'price_monthly_test',
|
||||
'stripe_yearly_price_id' => 'price_yearly_test',
|
||||
]);
|
||||
|
||||
$this->mock(StartSubscriptionCheckout::class)
|
||||
->shouldReceive('redirect')
|
||||
->once()
|
||||
->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $priceId === 'price_monthly_test')
|
||||
->andReturn(redirect()->route('app.calendar'));
|
||||
|
||||
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
|
||||
'persona' => Persona::Agency->value,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('app.calendar'));
|
||||
expect($this->user->fresh()->persona)->toBe(Persona::Agency);
|
||||
|
||||
Bus::assertDispatched(SendEvent::class);
|
||||
});
|
||||
|
|
@ -6,40 +6,23 @@
|
|||
use App\Models\Plan;
|
||||
use Database\Seeders\PlanSeeder;
|
||||
|
||||
test('seeder creates 4 plans', function () {
|
||||
expect(Plan::count())->toBe(4);
|
||||
});
|
||||
test('seeder creates only the per-workspace plan', function () {
|
||||
expect(Plan::count())->toBe(1);
|
||||
|
||||
test('seeder creates plans with correct limits', function () {
|
||||
$starter = Plan::where('slug', Slug::Starter)->first();
|
||||
$workspace = Plan::where('slug', Slug::Workspace)->first();
|
||||
|
||||
expect($starter->name)->toBe('Starter')
|
||||
->and($starter->social_account_limit)->toBe(5)
|
||||
->and($starter->member_limit)->toBe(1)
|
||||
->and($starter->workspace_limit)->toBe(1)
|
||||
->and($starter->monthly_credits_limit)->toBe(1000)
|
||||
->and($starter->sort)->toBe(1);
|
||||
|
||||
$max = Plan::where('slug', Slug::Max)->first();
|
||||
|
||||
expect($max->name)->toBe('Max')
|
||||
->and($max->social_account_limit)->toBe(100)
|
||||
->and($max->member_limit)->toBe(20)
|
||||
->and($max->workspace_limit)->toBe(50)
|
||||
->and($max->monthly_credits_limit)->toBe(15000)
|
||||
->and($max->sort)->toBe(4);
|
||||
expect($workspace->name)->toBe('Workspace')
|
||||
->and($workspace->monthly_credits_limit)->toBe(2500)
|
||||
->and($workspace->is_archived)->toBeFalse();
|
||||
});
|
||||
|
||||
test('seeder is idempotent', function () {
|
||||
$this->seed(PlanSeeder::class);
|
||||
|
||||
expect(Plan::count())->toBe(4);
|
||||
expect(Plan::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('active scope excludes archived plans', function () {
|
||||
$plan = Plan::where('slug', Slug::Starter)->first();
|
||||
$plan->update(['is_archived' => true]);
|
||||
|
||||
expect(Plan::active()->count())->toBe(3)
|
||||
->and(Plan::count())->toBe(4);
|
||||
test('the per-workspace plan is active', function () {
|
||||
expect(Plan::active()->count())->toBe(1)
|
||||
->and(Plan::active()->first()->slug)->toBe(Slug::Workspace);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,28 +20,25 @@
|
|||
});
|
||||
|
||||
test('plan slug is cast to enum', function () {
|
||||
$plan = Plan::where('slug', Slug::Starter)->first();
|
||||
$plan = Plan::where('slug', Slug::Workspace)->first();
|
||||
|
||||
expect($plan->slug)->toBeInstanceOf(Slug::class)
|
||||
->and($plan->slug)->toBe(Slug::Starter)
|
||||
->and($plan->slug->label())->toBe('Starter');
|
||||
->and($plan->slug)->toBe(Slug::Workspace)
|
||||
->and($plan->slug->label())->toBe('Workspace');
|
||||
});
|
||||
|
||||
test('active scope excludes archived plans', function () {
|
||||
$activeBefore = Plan::active()->count();
|
||||
|
||||
$plan = Plan::where('slug', Slug::Pro)->first();
|
||||
$plan = Plan::where('slug', Slug::Workspace)->first();
|
||||
$plan->update(['is_archived' => true]);
|
||||
|
||||
expect(Plan::active()->count())->toBe($activeBefore - 1);
|
||||
});
|
||||
|
||||
test('integer fields are cast correctly', function () {
|
||||
$plan = Plan::where('slug', Slug::Starter)->first();
|
||||
$plan = Plan::where('slug', Slug::Workspace)->first();
|
||||
|
||||
expect($plan->social_account_limit)->toBeInt()
|
||||
->and($plan->member_limit)->toBeInt()
|
||||
->and($plan->workspace_limit)->toBeInt()
|
||||
->and($plan->monthly_credits_limit)->toBeInt()
|
||||
expect($plan->monthly_credits_limit)->toBeInt()
|
||||
->and($plan->sort)->toBeInt();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -159,7 +159,9 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user can connect multiple facebook accounts', function () {
|
||||
test('user can connect multiple facebook accounts in self-hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
SocialAccount::factory()->facebook()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'page_existing',
|
||||
|
|
|
|||
|
|
@ -117,10 +117,13 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
expect(data_get($account->meta, 'username'))->toBeNull();
|
||||
});
|
||||
|
||||
it('does not create a new telegram account when the workspace is at its limit', function () {
|
||||
it('does not connect a second telegram channel when one is already connected', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
SocialAccount::factory()->count(5)->create(['workspace_id' => $this->workspace->id]);
|
||||
SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '-1009999999999',
|
||||
]);
|
||||
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
|
||||
|
||||
|
|
@ -128,17 +131,37 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
->postJson(route('telegram.webhook'), telegramUpdate($code))
|
||||
->assertNoContent();
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(5);
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1);
|
||||
expect(
|
||||
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists()
|
||||
)->toBeFalse();
|
||||
});
|
||||
|
||||
it('still reconnects an existing telegram channel even at the account limit', function () {
|
||||
it('connects a second telegram channel in self-hosted mode', function () {
|
||||
Http::fake();
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '-1009999999999',
|
||||
]);
|
||||
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate($code))
|
||||
->assertNoContent();
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(2);
|
||||
expect(
|
||||
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
it('reconnects an existing telegram channel', function () {
|
||||
Http::fake();
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
SocialAccount::factory()->count(4)->create(['workspace_id' => $this->workspace->id]);
|
||||
SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '-1001234567890',
|
||||
|
|
@ -150,7 +173,7 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
->postJson(route('telegram.webhook'), telegramUpdate($code))
|
||||
->assertNoContent();
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(5);
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1);
|
||||
expect(
|
||||
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->count()
|
||||
)->toBe(1);
|
||||
|
|
|
|||
111
tests/Feature/SocialAccount/NetworkUniquenessTest.php
Normal file
111
tests/Feature/SocialAccount/NetworkUniquenessTest.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
});
|
||||
|
||||
test('blocks a second account of the same network', function () {
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'ig-a',
|
||||
]);
|
||||
|
||||
expect(fn () => SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'ig-b',
|
||||
]))->toThrow(NetworkAlreadyConnectedException::class);
|
||||
});
|
||||
|
||||
test('collapses platform variants into one network', function () {
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
'platform_user_id' => 'li-profile',
|
||||
]);
|
||||
|
||||
expect(fn () => SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedInPage,
|
||||
'platform_user_id' => 'li-page',
|
||||
]))->toThrow(NetworkAlreadyConnectedException::class);
|
||||
});
|
||||
|
||||
test('allows different networks in the same workspace', function () {
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'ig-a',
|
||||
]);
|
||||
|
||||
$x = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::X,
|
||||
'platform_user_id' => 'x-a',
|
||||
]);
|
||||
|
||||
expect($x->exists)->toBeTrue();
|
||||
});
|
||||
|
||||
test('allows the same network in different workspaces', function () {
|
||||
$other = Workspace::factory()->create();
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'ig-a',
|
||||
]);
|
||||
|
||||
$second = SocialAccount::factory()->create([
|
||||
'workspace_id' => $other->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'ig-a',
|
||||
]);
|
||||
|
||||
expect($second->exists)->toBeTrue();
|
||||
});
|
||||
|
||||
test('reconnecting the same account via updateOrCreate is allowed', function () {
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'ig-a',
|
||||
'username' => 'old',
|
||||
]);
|
||||
|
||||
$this->workspace->socialAccounts()->updateOrCreate(
|
||||
['platform' => Platform::Instagram->value, 'platform_user_id' => 'ig-a'],
|
||||
['username' => 'new', 'status' => Status::Connected],
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($this->workspace->socialAccounts()->first()->username)->toBe('new');
|
||||
});
|
||||
|
||||
test('self-hosted mode bypasses the one-per-network rule', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'ig-a',
|
||||
]);
|
||||
|
||||
$second = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'ig-b',
|
||||
]);
|
||||
|
||||
expect($second->exists)->toBeTrue();
|
||||
});
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
});
|
||||
|
||||
test('account belongs to plan', function () {
|
||||
$plan = Plan::where('slug', PlanSlug::Starter)->first();
|
||||
$plan = Plan::where('slug', PlanSlug::Workspace)->first();
|
||||
$this->account->update(['plan_id' => $plan->id]);
|
||||
|
||||
expect($this->account->fresh()->plan->id)->toBe($plan->id);
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->get(route('app.calendar'));
|
||||
|
||||
$response->assertRedirect(route('app.subscribe'));
|
||||
$response->assertRedirect(route('app.onboarding'));
|
||||
});
|
||||
|
||||
test('billing page is accessible by account owner', function () {
|
||||
|
|
@ -96,18 +96,12 @@
|
|||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('subscribe page shows plans', function () {
|
||||
test('subscribe redirects to onboarding', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$activePlanCount = Plan::active()->count();
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.subscribe'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('billing/Subscribe', false)
|
||||
->has('plans', $activePlanCount)
|
||||
);
|
||||
$response->assertRedirect(route('app.onboarding'));
|
||||
});
|
||||
|
||||
test('stripe email returns account owner email', function () {
|
||||
|
|
@ -118,32 +112,18 @@
|
|||
expect($this->account->stripeName())->toBe($this->account->name);
|
||||
});
|
||||
|
||||
test('create workspace is blocked when account hits plan limit in saas mode', function () {
|
||||
test('creating an additional workspace redirects to billing without an active subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$starter = Plan::where('slug', PlanSlug::Starter)->first();
|
||||
$this->account->update(['plan_id' => $starter->id]);
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
// The account already owns one workspace (from beforeEach) and has no subscription.
|
||||
$response = $this->actingAs($this->user)->get(route('app.workspaces.create'));
|
||||
|
||||
// Starter plan: workspace_limit = 1; the user already owns 1 workspace (from beforeEach).
|
||||
$response = $this->actingAs($this->user)
|
||||
->from(route('app.calendar'))
|
||||
->get(route('app.workspaces.create'));
|
||||
|
||||
$response->assertRedirect(route('app.calendar'))
|
||||
->assertSessionHas('flash.error', __('workspaces.limit_reached'));
|
||||
$response->assertRedirect(route('app.billing.index'));
|
||||
});
|
||||
|
||||
test('create workspace is allowed when below plan limit in saas mode', function () {
|
||||
test('creating an additional workspace is allowed with an active subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$pro = Plan::where('slug', PlanSlug::Pro)->first();
|
||||
$this->account->update(['plan_id' => $pro->id]);
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
|
|
@ -151,40 +131,18 @@
|
|||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
// Pro plan: workspace_limit = 15; account has 1 workspace.
|
||||
$response = $this->actingAs($this->user->fresh())->get(route('app.workspaces.create'));
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('store workspace is blocked when account hits plan limit in saas mode', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
test('store creates an additional workspace with no count limit', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$starter = Plan::where('slug', PlanSlug::Starter)->first();
|
||||
$this->account->update(['plan_id' => $starter->id]);
|
||||
$this->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
// Starter plan: workspace_limit = 1; the user already owns 1 workspace.
|
||||
$response = $this->actingAs($this->user)->post(route('app.workspaces.store'), [
|
||||
'name' => 'Second workspace',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('workspace limit is bypassed in self-hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$starter = Plan::where('slug', PlanSlug::Starter)->first();
|
||||
$this->account->update(['plan_id' => $starter->id]);
|
||||
|
||||
// Even on Starter (limit 1) with an existing workspace, self-hosted lets through.
|
||||
$response = $this->actingAs($this->user)->get(route('app.workspaces.create'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
|
||||
expect($this->account->workspaces()->count())->toBe(2);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Ai\Agents\BrandAnalyzer;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Account;
|
||||
use App\Models\AiUsageLog;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Brand\LogoAttacher;
|
||||
|
|
@ -405,6 +407,10 @@
|
|||
});
|
||||
|
||||
test('destroy workspace deletes the workspace', function () {
|
||||
Workspace::factory()->create([
|
||||
'account_id' => $this->user->account_id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$workspaceId = $this->workspace->id;
|
||||
|
||||
$response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
|
||||
|
|
@ -414,12 +420,43 @@
|
|||
});
|
||||
|
||||
test('destroy workspace clears current workspace if deleting current', function () {
|
||||
Workspace::factory()->create([
|
||||
'account_id' => $this->user->account_id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
|
||||
|
||||
$this->user->refresh();
|
||||
expect($this->user->current_workspace_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('destroy workspace is blocked when it is the only workspace', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
$this->user->account->subscriptions()->create([
|
||||
'type' => Account::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test_'.fake()->uuid(),
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
$workspaceId = $this->workspace->id;
|
||||
|
||||
$response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
|
||||
|
||||
$response->assertSessionHas('flash.error', __('workspaces.cannot_delete_last'));
|
||||
expect(Workspace::find($workspaceId))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('destroy workspace allows deleting the only workspace in self-hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
$workspaceId = $this->workspace->id;
|
||||
|
||||
$response = $this->actingAs($this->user)->delete(route('app.workspaces.destroy', $this->workspace));
|
||||
|
||||
$response->assertRedirect(route('app.workspaces.index'));
|
||||
expect(Workspace::find($workspaceId))->toBeNull();
|
||||
});
|
||||
|
||||
test('destroy workspace returns 403 for non-owner', function () {
|
||||
$otherUser = User::factory()->create([]);
|
||||
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
|
@ -467,6 +504,57 @@
|
|||
expect(Workspace::count())->toBe($initialWorkspaceCount);
|
||||
});
|
||||
|
||||
test('autofillBrand is always allowed even without a subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
$account = Account::factory()->create(['trial_ends_at' => null]);
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
$account->update(['owner_id' => $user->id]);
|
||||
|
||||
expect($account->subscribed(Account::SUBSCRIPTION_NAME))->toBeFalse();
|
||||
|
||||
Http::fake([
|
||||
'example.com/*' => Http::response(
|
||||
'<html><head><title>Acme</title><meta name="description" content="We sell rockets." /></head><body></body></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html'],
|
||||
),
|
||||
'example.com' => Http::response(
|
||||
'<html><head><title>Acme</title><meta name="description" content="We sell rockets." /></head><body></body></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html'],
|
||||
),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('app.workspaces.autofill'), ['url' => 'https://example.com'])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('autofillBrand never records AI usage even when the LLM runs', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
config()->set('services.gemini.api_key', 'fake-key');
|
||||
config()->set('ai.default', 'gemini');
|
||||
|
||||
Http::fake([
|
||||
'example.com' => Http::response(
|
||||
'<html lang="en"><head><title>Acme</title><meta name="description" content="Terse." /></head><body><main><p>Acme ships rockets fast.</p></main></body></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html'],
|
||||
),
|
||||
]);
|
||||
|
||||
BrandAnalyzer::fake([
|
||||
['description' => 'Acme ships rockets fast.', 'language' => 'en'],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.workspaces.autofill'), ['url' => 'https://example.com'])
|
||||
->assertOk();
|
||||
|
||||
expect(AiUsageLog::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('autofillBrand validates url is required', function () {
|
||||
$account = Account::factory()->create();
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
|
|
|
|||
19
tests/Unit/Enums/PersonaTest.php
Normal file
19
tests/Unit/Enums/PersonaTest.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\User\Persona;
|
||||
|
||||
test('every persona has a non-empty label', function () {
|
||||
foreach (Persona::cases() as $persona) {
|
||||
expect($persona->label())->toBeString()->not->toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
29
tests/Unit/Enums/PlatformNetworkTest.php
Normal file
29
tests/Unit/Enums/PlatformNetworkTest.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
test('network groups the two linkedin variants together', function () {
|
||||
expect(Platform::LinkedIn->network())->toBe('linkedin')
|
||||
->and(Platform::LinkedInPage->network())->toBe('linkedin');
|
||||
});
|
||||
|
||||
test('network groups the two instagram variants together', function () {
|
||||
expect(Platform::Instagram->network())->toBe('instagram')
|
||||
->and(Platform::InstagramFacebook->network())->toBe('instagram');
|
||||
});
|
||||
|
||||
test('network returns the platform value for single-variant networks', function () {
|
||||
expect(Platform::X->network())->toBe('x')
|
||||
->and(Platform::Facebook->network())->toBe('facebook')
|
||||
->and(Platform::TikTok->network())->toBe('tiktok')
|
||||
->and(Platform::YouTube->network())->toBe('youtube')
|
||||
->and(Platform::Bluesky->network())->toBe('bluesky');
|
||||
});
|
||||
|
||||
test('every platform resolves to a non-empty network', function () {
|
||||
foreach (Platform::cases() as $platform) {
|
||||
expect($platform->network())->toBeString()->not->toBe('');
|
||||
}
|
||||
});
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Features\WorkspaceLimit;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
|
||||
test('returns plan workspace limit', function () {
|
||||
$plan = new Plan(['workspace_limit' => 5]);
|
||||
$account = new Account;
|
||||
$account->setRelation('plan', $plan);
|
||||
|
||||
expect((new WorkspaceLimit)->resolve($account))->toBe(5);
|
||||
});
|
||||
|
||||
test('falls back to 1 when no plan', function () {
|
||||
$account = new Account;
|
||||
$account->setRelation('plan', null);
|
||||
|
||||
expect((new WorkspaceLimit)->resolve($account))->toBe(1);
|
||||
});
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Features\MemberLimit;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
|
||||
test('returns plan member limit', function () {
|
||||
$plan = new Plan(['member_limit' => 10]);
|
||||
$account = new Account;
|
||||
$account->setRelation('plan', $plan);
|
||||
|
||||
expect((new MemberLimit)->resolve($account))->toBe(10);
|
||||
});
|
||||
|
||||
test('falls back to 1 when no plan', function () {
|
||||
$account = new Account;
|
||||
$account->setRelation('plan', null);
|
||||
|
||||
expect((new MemberLimit)->resolve($account))->toBe(1);
|
||||
});
|
||||
|
|
@ -2,21 +2,23 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Plan\Slug;
|
||||
use App\Features\MonthlyCreditsLimit;
|
||||
use App\Models\Account;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Workspace;
|
||||
|
||||
test('returns plan monthly credits limit', function () {
|
||||
$plan = new Plan(['monthly_credits_limit' => 5000]);
|
||||
$account = new Account;
|
||||
$account->setRelation('plan', $plan);
|
||||
test('resolves to the billing cycle credit allotment', function () {
|
||||
$plan = Plan::where('slug', Slug::Workspace)->first();
|
||||
$account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]);
|
||||
Workspace::factory()->count(2)->create(['account_id' => $account->id]);
|
||||
|
||||
expect((new MonthlyCreditsLimit)->resolve($account))->toBe(5000);
|
||||
});
|
||||
|
||||
test('falls back to 1000 when no plan', function () {
|
||||
$account = new Account;
|
||||
$account->setRelation('plan', null);
|
||||
test('resolves to zero when the account has no workspaces', function () {
|
||||
$plan = Plan::where('slug', Slug::Workspace)->first();
|
||||
$account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]);
|
||||
|
||||
expect((new MonthlyCreditsLimit)->resolve($account))->toBe(1000);
|
||||
expect((new MonthlyCreditsLimit)->resolve($account))->toBe(0);
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue