feat: introduce Account entity as billing owner and refactor architecture

- Create Account model as Cashier Billable entity (stripe, plan, subscription)
- Account owns workspaces and has an owner_id (User)
- User belongs to one Account via account_id
- Workspace belongs to Account via account_id, no longer has billing fields
- Remove Brand model entirely (workspaces serve as grouping)
- Rename brand_limit to workspace_limit in plans
- Workspace roles simplified: admin/member/viewer (owner via Account)
- Invites now belong to Account with workspaces JSON array
- Pennant features scope changed from Workspace to Account
- EnsureSubscribed middleware checks Account subscription
- All controllers updated: BillingController, OnboardingController,
  WorkspaceInviteController, SocialController, StripeEventListener
- Frontend: extract GoogleAuthButton component, create WorkspaceRole
  enum for type-safe role checks, fix all views for new architecture
- All 1101 tests passing
This commit is contained in:
Paulo Castellano 2026-04-14 22:22:04 -03:00
parent ba27688c59
commit 2da15df96c
138 changed files with 1468 additions and 1839 deletions

View file

@ -4,19 +4,20 @@
namespace App\Actions\Invite;
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Mail\WorkspaceInvite as WorkspaceInviteMail;
use App\Models\Invite;
use App\Models\Workspace;
use App\Models\WorkspaceInvite;
use Illuminate\Support\Facades\Mail;
class CreateInvite
{
public static function execute(Workspace $workspace, array $data): WorkspaceInvite
public static function execute(Workspace $workspace, array $data): Invite
{
$invite = $workspace->invites()->create([
$invite = Invite::create([
'account_id' => $workspace->account_id,
'invited_by' => auth()->id(),
'email' => data_get($data, 'email'),
'role' => data_get($data, 'role', WorkspaceRole::Member),
'workspaces' => [$workspace->id],
]);
Mail::to($invite->email)->send(new WorkspaceInviteMail($invite));

View file

@ -4,11 +4,11 @@
namespace App\Actions\Invite;
use App\Models\WorkspaceInvite;
use App\Models\Invite;
class DeleteInvite
{
public static function execute(WorkspaceInvite $invite): void
public static function execute(Invite $invite): void
{
$invite->delete();
}

View file

@ -6,6 +6,7 @@
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
@ -20,21 +21,29 @@ public static function execute(array $data): User
return DB::transaction(function () use ($data): User {
$isInviteRegistration = data_get($data, 'is_invite', false);
$account = Account::create([
'name' => data_get($data, 'name')."'s Account",
]);
$user = User::create([
'name' => data_get($data, 'name'),
'email' => data_get($data, 'email'),
'password' => data_get($data, 'password'),
'setup' => data_get($data, 'setup', $isInviteRegistration ? Setup::Completed : Setup::Role),
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
'account_id' => $account->id,
]);
$account->update(['owner_id' => $user->id]);
$workspace = Workspace::create([
'account_id' => $account->id,
'user_id' => $user->id,
'name' => $user->name."'s Workspace",
'timezone' => data_get($data, 'timezone', 'UTC'),
]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);

View file

@ -13,12 +13,13 @@ class CreateWorkspace
public static function execute(User $user, array $data): Workspace
{
$workspace = Workspace::create([
'account_id' => $user->account_id,
'user_id' => $user->id,
...$data,
'timezone' => config('app.timezone', 'UTC'),
]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->switchWorkspace($workspace);
return $workspace;

View file

@ -13,10 +13,6 @@ public static function execute(User $user, Workspace $workspace): void
{
User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]);
if (! config('trypost.self_hosted') && $workspace->subscribed(Workspace::SUBSCRIPTION_NAME)) {
$workspace->subscription(Workspace::SUBSCRIPTION_NAME)->cancel();
}
$workspace->delete();
}
}

View file

@ -6,32 +6,16 @@
enum Role: string
{
case Owner = 'owner';
case Admin = 'admin';
case Member = 'member';
case Viewer = 'viewer';
public function label(): string
{
return match ($this) {
self::Owner => 'Owner',
self::Admin => 'Admin',
self::Member => 'Member',
};
}
public function canManageTeam(): bool
{
return match ($this) {
self::Owner, self::Admin => true,
self::Member => false,
};
}
public function canManageAccounts(): bool
{
return match ($this) {
self::Owner, self::Admin => true,
self::Member => false,
self::Viewer => 'Viewer',
};
}
}

View file

@ -4,7 +4,7 @@
namespace App\Events;
use App\Models\Workspace;
use App\Models\Account;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
@ -15,12 +15,12 @@ class SubscriptionCreated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Workspace $workspace) {}
public function __construct(public Account $account) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('users.'.$this->workspace->user_id),
new PrivateChannel('users.'.$this->account->owner_id),
];
}

View file

@ -4,11 +4,11 @@
namespace App\Features;
use App\Models\Workspace;
use App\Models\Account;
class AiImagesLimit
{
public function resolve(Workspace $scope): int
public function resolve(Account $scope): int
{
return $scope->plan?->ai_images_limit ?? 50;
}

View file

@ -4,11 +4,11 @@
namespace App\Features;
use App\Models\Workspace;
use App\Models\Account;
class AiVideosLimit
{
public function resolve(Workspace $scope): int
public function resolve(Account $scope): int
{
return $scope->plan?->ai_videos_limit ?? 10;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Workspace;
class BrandLimit
{
public function resolve(Workspace $scope): int
{
return $scope->plan?->brand_limit ?? 0;
}
}

View file

@ -4,11 +4,11 @@
namespace App\Features;
use App\Models\Workspace;
use App\Models\Account;
class DataRetentionDays
{
public function resolve(Workspace $scope): int
public function resolve(Account $scope): int
{
return $scope->plan?->data_retention_days ?? 30;
}

View file

@ -4,11 +4,11 @@
namespace App\Features;
use App\Models\Workspace;
use App\Models\Account;
class MemberLimit
{
public function resolve(Workspace $scope): int
public function resolve(Account $scope): int
{
return $scope->plan?->member_limit ?? 1;
}

View file

@ -4,11 +4,11 @@
namespace App\Features;
use App\Models\Workspace;
use App\Models\Account;
class SocialAccountLimit
{
public function resolve(Workspace $scope): int
public function resolve(Account $scope): int
{
return $scope->plan?->social_account_limit ?? 5;
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Features;
use App\Models\Account;
class WorkspaceLimit
{
public function resolve(Account $scope): int
{
return $scope->plan?->workspace_limit ?? 1;
}
}

View file

@ -4,8 +4,8 @@
namespace App\Http\Controllers\App;
use App\Models\Account;
use App\Models\Plan;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -16,9 +16,9 @@ class BillingController extends Controller
{
public function subscribe(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
$account = $request->user()->account;
if ($workspace && $workspace->hasActiveSubscription()) {
if ($account && $account->hasActiveSubscription()) {
return redirect()->route('app.billing.index');
}
@ -30,9 +30,9 @@ public function subscribe(Request $request): Response|RedirectResponse
public function checkout(Request $request, Plan $plan): SymfonyResponse
{
$workspace = $request->user()->currentWorkspace;
$account = $request->user()->account;
$this->authorize('manageBilling', $workspace);
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$priceId = $request->input('interval', 'monthly') === 'yearly'
? $plan->stripe_yearly_price_id
@ -40,12 +40,12 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse
abort_if(! $priceId, 422, 'Plan price not configured');
$workspace->createOrGetStripeCustomer([
'email' => $workspace->stripeEmail(),
'name' => $workspace->stripeName(),
$account->createOrGetStripeCustomer([
'email' => $account->stripeEmail(),
'name' => $account->stripeName(),
]);
$subscription = $workspace->newSubscription(Workspace::SUBSCRIPTION_NAME, $priceId)
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId)
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'));
@ -54,17 +54,17 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse
'cancel_url' => route('app.billing.processing').'?status=cancelled',
]);
$workspace->update(['plan_id' => $plan->id]);
$account->update(['plan_id' => $plan->id]);
return Inertia::location($checkoutSession->url);
}
public function processing(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
$account = $request->user()->account;
$status = $request->query('status', 'processing');
if ($workspace && $workspace->subscribed(Workspace::SUBSCRIPTION_NAME)) {
if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
@ -73,37 +73,37 @@ public function processing(Request $request): Response|RedirectResponse
}
return Inertia::render('billing/Processing', [
'workspaceId' => $workspace?->id,
'accountId' => $account?->id,
'status' => $status,
]);
}
public function index(Request $request): Response
{
$workspace = $request->user()->currentWorkspace;
$account = $request->user()->account;
$this->authorize('manageBilling', $workspace);
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
$subscription = $workspace->subscription(Workspace::SUBSCRIPTION_NAME);
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);
return Inertia::render('billing/Index', [
'hasSubscription' => $workspace->subscribed(Workspace::SUBSCRIPTION_NAME),
'hasSubscription' => $account->subscribed(Account::SUBSCRIPTION_NAME),
'onTrial' => $subscription?->onTrial() ?? false,
'trialEndsAt' => $subscription?->trial_ends_at?->toFormattedDateString(),
'subscription' => $subscription?->only([
'stripe_status',
'ends_at',
]),
'plan' => $workspace->plan,
'plan' => $account->plan,
'plans' => Plan::active()->orderBy('sort')->get(),
'invoices' => $workspace->invoices()->map(fn ($invoice) => [
'invoices' => $account->invoices()->map(fn ($invoice) => [
'id' => $invoice->id,
'date' => $invoice->date()->toFormattedDateString(),
'total' => $invoice->total(),
'status' => $invoice->status,
'invoice_pdf' => $invoice->invoice_pdf,
]),
'defaultPaymentMethod' => $workspace->defaultPaymentMethod()?->card?->only([
'defaultPaymentMethod' => $account->defaultPaymentMethod()?->card?->only([
'brand',
'last4',
'exp_month',
@ -114,11 +114,10 @@ public function index(Request $request): Response
public function swap(Request $request, Plan $plan): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
$account = $request->user()->account;
$this->authorize('manageBilling', $workspace);
abort_unless($workspace->subscribed(Workspace::SUBSCRIPTION_NAME), 422, 'No active subscription');
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), 422, 'No active subscription');
$priceId = $request->input('interval', 'monthly') === 'yearly'
? $plan->stripe_yearly_price_id
@ -126,19 +125,19 @@ public function swap(Request $request, Plan $plan): RedirectResponse
abort_if(! $priceId, 422, 'Plan price not configured');
$workspace->subscription(Workspace::SUBSCRIPTION_NAME)->swap($priceId);
$workspace->update(['plan_id' => $plan->id]);
$account->subscription(Account::SUBSCRIPTION_NAME)->swap($priceId);
$account->update(['plan_id' => $plan->id]);
return redirect()->route('app.billing.index');
}
public function portal(Request $request): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
$account = $request->user()->account;
$this->authorize('manageBilling', $workspace);
abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN);
return $workspace->redirectToBillingPortal(
return $account->redirectToBillingPortal(
route('app.billing.index')
);
}

View file

@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Http\Requests\App\Brand\StoreBrandRequest;
use App\Http\Requests\App\Brand\UpdateBrandRequest;
use App\Models\Brand;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class BrandController extends Controller
{
public function index(Request $request): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('viewAny', [Brand::class, $workspace]);
$brands = $workspace->brands()
->withCount('socialAccounts')
->latest()
->paginate(config('app.pagination.default'));
return Inertia::render('brands/Index', [
'brands' => Inertia::scroll(fn () => $brands),
'canCreate' => $request->user()->can('create', [Brand::class, $workspace]),
]);
}
public function store(StoreBrandRequest $request): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('create', [Brand::class, $workspace]);
$workspace->brands()->create([
'name' => data_get($request->validated(), 'name'),
]);
session()->flash('flash.banner', __('brands.flash.created'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.brands.index');
}
public function update(UpdateBrandRequest $request, Brand $brand): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('update', $brand);
$brand->update([
'name' => data_get($request->validated(), 'name'),
]);
session()->flash('flash.banner', __('brands.flash.updated'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.brands.index');
}
public function destroy(Request $request, Brand $brand): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('delete', $brand);
$brand->delete();
session()->flash('flash.banner', __('brands.flash.deleted'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.brands.index');
}
}

View file

@ -8,9 +8,9 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@ -92,14 +92,15 @@ public function storeConnect(Request $request): SymfonyResponse|RedirectResponse
$user->update(['setup' => Setup::Subscription]);
$account = $user->account;
$defaultPlan = Plan::where('slug', PlanSlug::Starter)->firstOrFail();
$workspace->createOrGetStripeCustomer([
'email' => $workspace->stripeEmail(),
'name' => $workspace->stripeName(),
$account->createOrGetStripeCustomer([
'email' => $account->stripeEmail(),
'name' => $account->stripeName(),
]);
$subscription = $workspace->newSubscription(Workspace::SUBSCRIPTION_NAME, $defaultPlan->stripe_monthly_price_id)
$subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $defaultPlan->stripe_monthly_price_id)
->allowPromotionCodes()
->trialDays(config('cashier.trial_days'));
@ -108,7 +109,7 @@ public function storeConnect(Request $request): SymfonyResponse|RedirectResponse
'cancel_url' => route('app.onboarding.connect'),
]);
$workspace->update(['plan_id' => $defaultPlan->id]);
$account->update(['plan_id' => $defaultPlan->id]);
return Inertia::location($checkoutSession->url);
}

View file

@ -4,10 +4,10 @@
namespace App\Http\Controllers\App\Settings;
use App\Enums\UserWorkspace\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Settings\ProfileDeleteRequest;
use App\Http\Requests\App\Settings\ProfileUpdateRequest;
use App\Models\Account;
use App\Models\Workspace;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
@ -93,14 +93,20 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
DB::transaction(function () use ($user) {
$user->update(['current_workspace_id' => null]);
$ownedWorkspaces = $user->workspaces()->wherePivot('role', Role::Owner->value)->get();
$account = $user->account;
// Cancel account subscription if exists
if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) {
$account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow();
}
if ($account) {
$account->subscriptions()->delete();
}
$ownedWorkspaces = Workspace::where('user_id', $user->id)->get();
foreach ($ownedWorkspaces as $workspace) {
if ($workspace->subscribed(Workspace::SUBSCRIPTION_NAME)) {
$workspace->subscription(Workspace::SUBSCRIPTION_NAME)->cancelNow();
}
$workspace->subscriptions()->delete();
foreach ($workspace->members as $member) {
if ($member->id !== $user->id && $member->current_workspace_id === $workspace->id) {
$otherWorkspace = $member->workspaces()
@ -114,12 +120,15 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
$workspace->socialAccounts()->delete();
$workspace->hashtags()->delete();
$workspace->labels()->delete();
$workspace->invites()->delete();
$workspace->members()->detach();
$workspace->delete();
}
$user->workspaces()->detach();
if ($account) {
$account->delete();
}
});
Auth::logout();

View file

@ -6,7 +6,6 @@
use App\Actions\Workspace\CreateWorkspace;
use App\Actions\Workspace\DeleteWorkspace;
use App\Enums\UserWorkspace\Role;
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
use App\Models\Workspace;
@ -39,7 +38,7 @@ public function create(Request $request): Response|RedirectResponse
$workspace = $user->currentWorkspace;
if ($user->ownedWorkspacesCount() > 0 && (! $workspace || ! $workspace->hasActiveSubscription())) {
if ($user->ownedWorkspacesCount() > 0 && ! $user->account?->hasActiveSubscription()) {
return redirect()->route('app.billing.index')
->with('message', 'Subscribe to create more workspaces.');
}
@ -51,9 +50,7 @@ public function store(StoreWorkspaceRequest $request): RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
if ($user->ownedWorkspacesCount() > 0 && (! $workspace || ! $workspace->hasActiveSubscription())) {
if ($user->ownedWorkspacesCount() > 0 && ! $user->account?->hasActiveSubscription()) {
return redirect()->route('app.billing.index')
->with('message', 'Subscribe to create more workspaces.');
}
@ -99,11 +96,11 @@ public function settings(Request $request): Response|RedirectResponse
'name' => $member->name,
'email' => $member->email,
'role' => $member->pivot->role,
'is_owner' => $member->pivot->role === Role::Owner->value,
'is_owner' => $member->id === $workspace->account?->owner_id,
]);
$invitations = $workspace->invites()
->select('id', 'email', 'role')
->select('id', 'email')
->latest()
->get();

View file

@ -9,7 +9,7 @@
use App\Actions\Invite\RemoveMember;
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Http\Requests\App\Invite\StoreWorkspaceInviteRequest;
use App\Models\WorkspaceInvite;
use App\Models\Invite;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@ -34,7 +34,6 @@ public function index(Request $request): Response|RedirectResponse
->latest()
->get(),
'members' => $workspace->members()
->wherePivot('role', '!=', WorkspaceRole::Owner->value)
->get()
->map(fn ($member) => [
'id' => $member->id,
@ -43,13 +42,11 @@ public function index(Request $request): Response|RedirectResponse
'role' => $member->pivot->role,
]),
'owner' => [
'id' => $workspace->owner->id,
'name' => $workspace->owner->name,
'email' => $workspace->owner->email,
'role' => WorkspaceRole::Owner->value,
'id' => $workspace->account?->owner?->id,
'name' => $workspace->account?->owner?->name,
'email' => $workspace->account?->owner?->email,
],
'roles' => collect(WorkspaceRole::cases())
->filter(fn ($role) => $role !== WorkspaceRole::Owner)
->map(fn ($role) => [
'value' => $role->value,
'label' => $role->label(),
@ -91,7 +88,7 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse
return back();
}
public function destroy(Request $request, WorkspaceInvite $invite): RedirectResponse
public function destroy(Request $request, Invite $invite): RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
@ -101,7 +98,7 @@ public function destroy(Request $request, WorkspaceInvite $invite): RedirectResp
$this->authorize('manageTeam', $workspace);
if ($invite->workspace_id !== $workspace->id) {
if ($invite->account_id !== $workspace->account_id) {
abort(404);
}
@ -123,10 +120,9 @@ public function removeMember(Request $request, string $userId): RedirectResponse
$this->authorize('manageTeam', $workspace);
$memberPivot = $workspace->members()->where('user_id', $userId)->first()?->pivot;
if ($memberPivot && $memberPivot->role === WorkspaceRole::Owner->value) {
return back()->withErrors(['member' => 'Cannot remove the workspace owner.']);
// Account owner cannot be removed
if ($userId === $workspace->account?->owner_id) {
return back()->withErrors(['member' => 'Cannot remove the account owner.']);
}
RemoveMember::execute($workspace, $userId);
@ -147,14 +143,13 @@ public function updateRole(Request $request, string $userId): RedirectResponse
$this->authorize('manageTeam', $workspace);
$memberPivot = $workspace->members()->where('user_id', $userId)->first()?->pivot;
if ($memberPivot && $memberPivot->role === WorkspaceRole::Owner->value) {
return back()->withErrors(['role' => 'Cannot change the workspace owner role.']);
// Account owner's role cannot be changed
if ($userId === $workspace->account?->owner_id) {
return back()->withErrors(['role' => 'Cannot change the account owner role.']);
}
$validated = $request->validate([
'role' => ['required', Rule::in([WorkspaceRole::Admin->value, WorkspaceRole::Member->value])],
'role' => ['required', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
]);
$workspace->members()->updateExistingPivot($userId, [

View file

@ -4,8 +4,10 @@
namespace App\Http\Controllers\Auth;
use App\Enums\UserWorkspace\Role;
use App\Http\Controllers\Controller;
use App\Models\WorkspaceInvite;
use App\Models\Invite;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -16,22 +18,19 @@ class AcceptInviteController extends Controller
/**
* Display the invite view.
*/
public function show(WorkspaceInvite $invite): Response
public function show(Invite $invite): Response
{
$invite->load('workspace');
$invite->load('account');
return Inertia::render('auth/AcceptInvite', [
'invite' => [
'id' => $invite->id,
'email' => $invite->email,
'role' => [
'value' => $invite->role->value,
'label' => $invite->role->label(),
],
'workspace' => [
'id' => $invite->workspace->id,
'name' => $invite->workspace->name,
'account' => [
'id' => $invite->account->id,
'name' => $invite->account->name,
],
'workspaces' => $invite->workspaces,
],
]);
}
@ -39,7 +38,7 @@ public function show(WorkspaceInvite $invite): Response
/**
* Accept the invite.
*/
public function accept(Request $request, WorkspaceInvite $invite): RedirectResponse
public function accept(Request $request, Invite $invite): RedirectResponse
{
$user = $request->user();
@ -51,9 +50,9 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo
return redirect()->route('app.calendar');
}
// Check if already a member
if ($invite->workspace->hasMember($user)) {
$invite->delete();
// Check if already a member of the account
if ($user->account_id === $invite->account_id) {
$invite->update(['accepted_at' => now()]);
session()->flash('flash.banner', __('settings.members.flash.already_member'));
session()->flash('flash.bannerStyle', 'info');
@ -61,10 +60,28 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo
return redirect()->route('app.calendar');
}
// Accept the invite
$workspaceId = $invite->workspace_id;
$invite->accept($user);
$user->update(['current_workspace_id' => $workspaceId]);
// Add user to the account
$user->update(['account_id' => $invite->account_id]);
// Attach user to the invited workspaces
if ($invite->workspaces) {
foreach ($invite->workspaces as $workspaceId) {
$workspace = Workspace::find($workspaceId);
if ($workspace && $workspace->account_id === $invite->account_id) {
$workspace->members()->syncWithoutDetaching([
$user->id => ['role' => Role::Member->value],
]);
// Set first workspace as current
if (! $user->current_workspace_id) {
$user->update(['current_workspace_id' => $workspace->id]);
}
}
}
}
$invite->update(['accepted_at' => now()]);
session()->flash('flash.banner', __('settings.members.flash.invite_accepted'));
session()->flash('flash.bannerStyle', 'success');
@ -75,7 +92,7 @@ public function accept(Request $request, WorkspaceInvite $invite): RedirectRespo
/**
* Decline the invite.
*/
public function decline(Request $request, WorkspaceInvite $invite): RedirectResponse
public function decline(Request $request, Invite $invite): RedirectResponse
{
$user = $request->user();

View file

@ -38,7 +38,7 @@ protected function ensureSocialAccountLimit(Workspace $workspace): void
return;
}
$limit = Feature::for($workspace)->value(SocialAccountLimit::class);
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
if ($workspace->socialAccounts()->count() >= $limit) {
abort(SymfonyResponse::HTTP_FORBIDDEN, __('accounts.limit_reached'));
@ -56,7 +56,6 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('view', $workspace);
$connectedAccounts = $workspace->socialAccounts()
->with('brand')
->get();
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [

View file

@ -54,6 +54,6 @@ public function handle(Request $request, Closure $next): Response
private function hasActiveSubscription(Workspace $workspace): bool
{
return $workspace->hasActiveSubscription();
return $workspace->account?->hasActiveSubscription() ?? false;
}
}

View file

@ -22,9 +22,9 @@ public function handle(Request $request, Closure $next): Response
return redirect()->route('login');
}
$workspace = $user->currentWorkspace;
$account = $user->account;
if ($workspace && $workspace->hasActiveSubscription()) {
if ($account && $account->hasActiveSubscription()) {
return $next($request);
}

View file

@ -58,6 +58,6 @@ public function handle(Request $request, Closure $next): Response
private function hasActiveSubscription(Workspace $workspace): bool
{
return $workspace->hasActiveSubscription();
return $workspace->account?->hasActiveSubscription() ?? false;
}
}

View file

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

View file

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

View file

@ -28,7 +28,7 @@ public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role' => ['nullable', Rule::in([WorkspaceRole::Admin->value, WorkspaceRole::Member->value])],
'role' => ['nullable', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
];
}

View file

@ -38,6 +38,10 @@ public static function summary(Workspace $workspace): array
private static function resolveRole(Workspace $workspace, User $user): ?string
{
if ($user->isAccountOwner() && $workspace->account_id === $user->account_id) {
return 'owner';
}
return $workspace->members()
->where('users.id', $user->id)
->first()

View file

@ -6,7 +6,7 @@
use App\Enums\User\Setup;
use App\Events\SubscriptionCreated;
use App\Models\Workspace;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
@ -22,7 +22,7 @@ public function handle(WebhookReceived $event): void
return;
}
$workspace = Workspace::where('stripe_id', $stripeCustomerId)->first();
$workspace = Account::where('stripe_id', $stripeCustomerId)->first();
if (! $workspace) {
return;
@ -42,7 +42,7 @@ public function handle(WebhookReceived $event): void
}
}
protected function handleSubscriptionCreated(Workspace $workspace, array $payload): void
protected function handleSubscriptionCreated(Account $workspace, array $payload): void
{
$owner = $workspace->owner;
@ -53,12 +53,12 @@ protected function handleSubscriptionCreated(Workspace $workspace, array $payloa
SubscriptionCreated::dispatch($workspace);
}
protected function handleSubscriptionUpdated(Workspace $workspace, array $payload): void
protected function handleSubscriptionUpdated(Account $workspace, array $payload): void
{
//
}
protected function handleSubscriptionDeleted(Workspace $workspace, array $payload): void
protected function handleSubscriptionDeleted(Account $workspace, array $payload): void
{
//
}

View file

@ -4,7 +4,7 @@
namespace App\Mail;
use App\Models\WorkspaceInvite as WorkspaceInviteModel;
use App\Models\Invite;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
@ -17,13 +17,13 @@ class WorkspaceInvite extends Mailable implements ShouldQueue
use Queueable, SerializesModels;
public function __construct(
public WorkspaceInviteModel $invite
public Invite $invite
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "You've been invited to join {$this->invite->workspace->name}",
subject: "You've been invited to join {$this->invite->account->name}",
);
}
@ -32,8 +32,8 @@ public function content(): Content
return new Content(
view: 'mail.workspace-invite',
with: [
'title' => "You've been invited to join {$this->invite->workspace->name}",
'previewText' => "You've been invited to join {$this->invite->workspace->name}",
'title' => "You've been invited to join {$this->invite->account->name}",
'previewText' => "You've been invited to join {$this->invite->account->name}",
'invite' => $this->invite,
'url' => route('app.invites.show', $this->invite),
],

76
app/Models/Account.php Normal file
View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\AccountFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Cashier\Billable;
class Account extends Model
{
/** @use HasFactory<AccountFactory> */
use Billable, HasFactory, HasUuids;
public const SUBSCRIPTION_NAME = 'default';
protected $fillable = [
'owner_id',
'name',
'plan_id',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function workspaces(): HasMany
{
return $this->hasMany(Workspace::class);
}
public function invites(): HasMany
{
return $this->hasMany(Invite::class);
}
public function hasActiveSubscription(): bool
{
if (config('trypost.self_hosted')) {
return true;
}
return $this->subscribed(self::SUBSCRIPTION_NAME);
}
public function isOnTrial(): bool
{
return $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial() ?? false;
}
public function stripeEmail(): string
{
return $this->owner?->email ?? '';
}
public function stripeName(): string
{
return $this->name;
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\BrandFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Brand extends Model
{
/** @use HasFactory<BrandFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'workspace_id',
'name',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function socialAccounts(): HasMany
{
return $this->hasMany(SocialAccount::class);
}
}

43
app/Models/Invite.php Normal file
View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\InviteFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Invite extends Model
{
/** @use HasFactory<InviteFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'account_id',
'invited_by',
'email',
'workspaces',
'accepted_at',
];
protected function casts(): array
{
return [
'workspaces' => 'array',
'accepted_at' => 'datetime',
];
}
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by');
}
}

View file

@ -26,7 +26,7 @@ class Plan extends Model
'yearly_price',
'social_account_limit',
'member_limit',
'brand_limit',
'workspace_limit',
'ai_images_limit',
'ai_videos_limit',
'data_retention_days',
@ -43,7 +43,7 @@ protected function casts(): array
'yearly_price' => 'integer',
'social_account_limit' => 'integer',
'member_limit' => 'integer',
'brand_limit' => 'integer',
'workspace_limit' => 'integer',
'ai_images_limit' => 'integer',
'ai_videos_limit' => 'integer',
'data_retention_days' => 'integer',
@ -51,9 +51,9 @@ protected function casts(): array
];
}
public function workspaces(): HasMany
public function accounts(): HasMany
{
return $this->hasMany(Workspace::class);
return $this->hasMany(Account::class);
}
public function scopeActive(Builder $query): Builder

View file

@ -28,7 +28,6 @@ class SocialAccount extends Model
protected $fillable = [
'workspace_id',
'brand_id',
'platform',
'platform_user_id',
'username',
@ -70,11 +69,6 @@ public function workspace(): BelongsTo
return $this->belongsTo(Workspace::class);
}
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function postPlatforms(): HasMany
{
return $this->hasMany(PostPlatform::class);

View file

@ -4,7 +4,6 @@
namespace App\Models\Traits;
use App\Enums\UserWorkspace\Role;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -50,6 +49,6 @@ public function belongsToWorkspace(Workspace $workspace): bool
*/
public function ownedWorkspacesCount(): int
{
return $this->workspaces()->wherePivot('role', Role::Owner->value)->count();
return Workspace::where('user_id', $this->id)->count();
}
}

View file

@ -13,6 +13,7 @@
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -24,8 +25,6 @@ class User extends Authenticatable implements MustVerifyEmail
use HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
@ -34,13 +33,12 @@ class User extends Authenticatable implements MustVerifyEmail
'password',
'setup',
'persona',
'account_id',
'current_workspace_id',
'email_verified_at',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
@ -65,11 +63,6 @@ public function getPhotoUrlAttribute(): ?string
return $this->getFirstMediaUrl('avatar');
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
@ -81,9 +74,6 @@ protected function casts(): array
];
}
/**
* @return HasMany<Notification, $this>
*/
public function notifications(): HasMany
{
return $this->hasMany(Notification::class);
@ -94,12 +84,22 @@ public function notificationPreference(): HasOne
return $this->hasOne(NotificationPreference::class);
}
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function isAccountOwner(): bool
{
return $this->id === $this->account?->owner_id;
}
public function wantsEmailFor(NotificationType $type): bool
{
$preference = $this->notificationPreference;
if (! $preference) {
return true; // Default: all enabled
return true;
}
return match ($type) {

View file

@ -6,28 +6,22 @@
use App\Models\Traits\HasMedia;
use Database\Factories\WorkspaceFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Cashier\Billable;
class Workspace extends Model
{
/** @use HasFactory<WorkspaceFactory> */
use Billable, HasFactory, HasMedia, HasUuids;
public const SUBSCRIPTION_NAME = 'default';
use HasFactory, HasMedia, HasUuids;
protected $fillable = [
'account_id',
'user_id',
'plan_id',
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
'name',
'timezone',
];
@ -44,16 +38,16 @@ public function getLogoUrlAttribute(): ?string
return $this->getFirstMediaUrl('logo') ?: null;
}
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class)
@ -71,11 +65,6 @@ public function posts(): HasMany
return $this->hasMany(Post::class);
}
public function invites(): HasMany
{
return $this->hasMany(WorkspaceInvite::class);
}
public function hashtags(): HasMany
{
return $this->hasMany(WorkspaceHashtag::class);
@ -86,9 +75,16 @@ public function labels(): HasMany
return $this->hasMany(WorkspaceLabel::class);
}
public function brands(): HasMany
/**
* Get invites for this workspace (invites from the same account that include this workspace).
*
* @return Collection<int, Invite>
*/
public function invites()
{
return $this->hasMany(Brand::class);
return Invite::where('account_id', $this->account_id)
->whereJsonContains('workspaces', $this->id)
->whereNull('accepted_at');
}
public function apiTokens(): HasMany
@ -98,7 +94,7 @@ public function apiTokens(): HasMany
public function hasMember(User $user): bool
{
return $this->user_id === $user->id || $this->members()->where('user_id', $user->id)->exists();
return $this->account?->owner_id === $user->id || $this->members()->where('user_id', $user->id)->exists();
}
public function hasConnectedPlatform(string $platform): bool
@ -110,41 +106,4 @@ public function getSocialAccount(string $platform): ?SocialAccount
{
return $this->socialAccounts()->where('platform', $platform)->first();
}
/**
* Check if the workspace has an active subscription.
* In self-hosted mode, always returns true.
*/
public function hasActiveSubscription(): bool
{
if (config('trypost.self_hosted')) {
return true;
}
return $this->subscribed(self::SUBSCRIPTION_NAME);
}
/**
* Check if the workspace is on a trial period.
*/
public function isOnTrial(): bool
{
return $this->subscription(self::SUBSCRIPTION_NAME)?->onTrial() ?? false;
}
/**
* Get the email address for Stripe.
*/
public function stripeEmail(): string
{
return $this->owner?->email ?? '';
}
/**
* Get the name for Stripe.
*/
public function stripeName(): string
{
return $this->name;
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Enums\UserWorkspace\Role;
use App\Features\BrandLimit;
use App\Models\Brand;
use App\Models\User;
use App\Models\Workspace;
use Laravel\Pennant\Feature;
class BrandPolicy
{
public function viewAny(User $user, Workspace $workspace): bool
{
return $workspace->members()->where('user_id', $user->id)->exists();
}
public function create(User $user, Workspace $workspace): bool
{
if (! $this->canManage($user, $workspace)) {
return false;
}
if (config('trypost.self_hosted')) {
return true;
}
$limit = Feature::for($workspace)->value(BrandLimit::class);
return $workspace->brands()->count() < $limit;
}
public function update(User $user, Brand $brand): bool
{
return $this->canManage($user, $brand->workspace);
}
public function delete(User $user, Brand $brand): bool
{
return $this->canManage($user, $brand->workspace);
}
private function canManage(User $user, Workspace $workspace): bool
{
$member = $workspace->members()->where('user_id', $user->id)->first();
if (! $member) {
return false;
}
return in_array(Role::tryFrom($member->pivot->role), [Role::Owner, Role::Admin]);
}
}

View file

@ -19,57 +19,56 @@ public function viewAny(User $user): bool
public function view(User $user, Workspace $workspace): bool
{
return $this->isMember($user, $workspace);
return $this->canAccess($user, $workspace);
}
public function create(User $user): bool
{
return true;
return $user->isAccountOwner();
}
public function update(User $user, Workspace $workspace): bool
{
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
}
public function delete(User $user, Workspace $workspace): bool
{
return $this->hasRole($user, $workspace, [Role::Owner]);
return $this->isOwner($user, $workspace);
}
public function restore(User $user, Workspace $workspace): bool
{
return $this->hasRole($user, $workspace, [Role::Owner]);
return $this->isOwner($user, $workspace);
}
public function forceDelete(User $user, Workspace $workspace): bool
{
return $this->hasRole($user, $workspace, [Role::Owner]);
return $this->isOwner($user, $workspace);
}
public function manageTeam(User $user, Workspace $workspace): bool
{
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
}
public function manageAccounts(User $user, Workspace $workspace): bool
{
return $this->hasRole($user, $workspace, [Role::Owner, Role::Admin]);
return $this->isOwnerOrWorkspaceAdmin($user, $workspace);
}
public function createPost(User $user, Workspace $workspace): bool
{
return $this->isMember($user, $workspace);
}
if ($this->isOwner($user, $workspace)) {
return true;
}
public function manageBilling(User $user, Workspace $workspace): bool
{
return $this->hasRole($user, $workspace, [Role::Owner]);
return $this->hasRole($user, $workspace, [Role::Admin, Role::Member]);
}
public function inviteMember(User $user, Workspace $workspace): bool
{
if (! $this->hasRole($user, $workspace, [Role::Owner, Role::Admin])) {
if (! $this->isOwnerOrWorkspaceAdmin($user, $workspace)) {
return false;
}
@ -77,16 +76,49 @@ public function inviteMember(User $user, Workspace $workspace): bool
return true;
}
$limit = Feature::for($workspace)->value(MemberLimit::class);
$limit = Feature::for($user->account)->value(MemberLimit::class);
return $workspace->members()->count() < $limit;
}
/**
* @param Role[] $roles
*/
public function manageBilling(User $user, Workspace $workspace): bool
{
return $this->isOwner($user, $workspace);
}
private function isOwner(User $user, Workspace $workspace): bool
{
return $workspace->account_id === $user->account_id && $user->isAccountOwner();
}
private function isOwnerOrWorkspaceAdmin(User $user, Workspace $workspace): bool
{
if ($this->isOwner($user, $workspace)) {
return true;
}
return $this->hasRole($user, $workspace, [Role::Admin]);
}
private function canAccess(User $user, Workspace $workspace): bool
{
if ($workspace->account_id !== $user->account_id) {
return false;
}
if ($user->isAccountOwner()) {
return true;
}
return $workspace->members()->where('user_id', $user->id)->exists();
}
private function hasRole(User $user, Workspace $workspace, array $roles): bool
{
if ($workspace->account_id !== $user->account_id) {
return false;
}
$member = $workspace->members()->where('user_id', $user->id)->first();
if (! $member) {
@ -95,9 +127,4 @@ private function hasRole(User $user, Workspace $workspace, array $roles): bool
return in_array(Role::tryFrom($member->pivot->role), $roles);
}
private function isMember(User $user, Workspace $workspace): bool
{
return $workspace->members()->where('user_id', $user->id)->exists();
}
}

View file

@ -5,7 +5,8 @@
namespace App\Providers;
use App\Listeners\StripeEventListener;
use App\Models\Brand;
use App\Models\Account;
use App\Models\Invite;
use App\Models\Media;
use App\Models\Notification;
use App\Models\NotificationPreference;
@ -18,7 +19,6 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceHashtag;
use App\Models\WorkspaceInvite;
use App\Models\WorkspaceLabel;
use App\Socialite\InstagramProvider;
use App\Socialite\LinkedInPageExtendSocialite;
@ -76,11 +76,11 @@ public function boot(): void
$this->configureSocialite();
$this->configureStripeWebhooks();
Cashier::useCustomerModel(Workspace::class);
Cashier::useCustomerModel(Account::class);
Cashier::useSubscriptionModel(Subscription::class);
Cashier::useSubscriptionItemModel(SubscriptionItem::class);
Feature::resolveScopeUsing(fn () => auth()->user()?->currentWorkspace);
Feature::resolveScopeUsing(fn () => auth()->user()?->account);
Feature::useMorphMap();
Feature::discover();
}
@ -88,7 +88,8 @@ public function boot(): void
protected function configureMorphMap(): void
{
Relation::enforceMorphMap([
'brand' => Brand::class,
'account' => Account::class,
'invite' => Invite::class,
'media' => Media::class,
'notification' => Notification::class,
'plan' => Plan::class,
@ -101,7 +102,6 @@ protected function configureMorphMap(): void
'user' => User::class,
'workspace' => Workspace::class,
'workspaceHashtag' => WorkspaceHashtag::class,
'workspaceInvite' => WorkspaceInvite::class,
'workspaceLabel' => WorkspaceLabel::class,
]);
}

View file

@ -4,14 +4,13 @@
namespace Database\Factories;
use App\Models\Brand;
use App\Models\Workspace;
use App\Models\Account;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Brand>
* @extends Factory<Account>
*/
class BrandFactory extends Factory
class AccountFactory extends Factory
{
/**
* Define the model's default state.
@ -21,7 +20,6 @@ class BrandFactory extends Factory
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'name' => fake()->company(),
];
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Invite>
*/
class InviteFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'account_id' => Account::factory(),
'invited_by' => User::factory(),
'email' => fake()->unique()->safeEmail(),
'workspaces' => [],
];
}
}

View file

@ -29,7 +29,7 @@ public function definition(): array
'yearly_price' => fake()->randomElement([9000, 19000, 39000, 79000]),
'social_account_limit' => fake()->randomElement([3, 10, 25, 100]),
'member_limit' => fake()->randomElement([1, 3, 10, 50]),
'brand_limit' => fake()->randomElement([1, 3, 10, 50]),
'workspace_limit' => fake()->randomElement([1, 3, 10, 50]),
'ai_images_limit' => fake()->randomElement([10, 50, 200, 1000]),
'ai_videos_limit' => fake()->randomElement([5, 25, 100, 500]),
'data_retention_days' => fake()->randomElement([30, 90, 365, 730]),

View file

@ -4,6 +4,7 @@
namespace Database\Factories;
use App\Models\Account;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
@ -32,6 +33,7 @@ public function definition(): array
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'account_id' => Account::factory(),
'current_workspace_id' => null,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
@ -39,6 +41,15 @@ public function definition(): array
];
}
public function configure(): static
{
return $this->afterCreating(function (User $user) {
if ($user->account_id && ! $user->account?->owner_id) {
$user->account->update(['owner_id' => $user->id]);
}
});
}
/**
* Indicate that the model's email address should be unverified.
*/

View file

@ -4,6 +4,7 @@
namespace Database\Factories;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -22,9 +23,27 @@ public function definition(): array
{
return [
'user_id' => User::factory(),
'plan_id' => null,
'name' => fake()->company(),
'timezone' => fake()->timezone(),
];
}
public function configure(): static
{
return $this->afterMaking(function (Workspace $workspace) {
if (! $workspace->account_id) {
if ($workspace->user_id) {
$user = User::find($workspace->user_id);
if ($user?->account_id) {
$workspace->account_id = $user->account_id;
return;
}
}
$workspace->account_id = Account::factory()->create()->id;
}
});
}
}

View file

@ -32,6 +32,7 @@ public function up(): void
$table->rememberToken();
$table->string('setup')->nullable();
$table->string('persona')->nullable();
$table->uuid('account_id')->nullable();
$table->uuid('current_workspace_id')->nullable();
$table->uuid('language_id')->nullable();
$table->timestamps();

View file

@ -20,7 +20,7 @@ public function up(): void
$table->integer('yearly_price');
$table->integer('social_account_limit');
$table->integer('member_limit');
$table->integer('brand_limit');
$table->integer('workspace_limit');
$table->integer('ai_images_limit');
$table->integer('ai_videos_limit');
$table->integer('data_retention_days');

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('accounts', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('owner_id')->nullable();
$table->string('name');
$table->foreignUuid('plan_id')->nullable()->constrained()->nullOnDelete();
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamps();
$table->foreign('owner_id')->references('id')->on('users')->nullOnDelete();
});
// Add FK constraint for users.account_id -> accounts.id
Schema::table('users', function (Blueprint $table) {
$table->foreign('account_id')->references('id')->on('accounts')->nullOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('accounts');
}
};

View file

@ -15,14 +15,10 @@ public function up(): void
{
Schema::create('workspaces', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('account_id')->constrained()->cascadeOnDelete();
$table->uuid('user_id');
$table->foreignUuid('plan_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->string('timezone');
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('brands', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('brands');
}
};

View file

@ -15,7 +15,7 @@ public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('account_id')->constrained()->cascadeOnDelete();
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
@ -25,7 +25,7 @@ public function up(): void
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['workspace_id', 'stripe_status']);
$table->index(['account_id', 'stripe_status']);
});
}

View file

@ -16,7 +16,6 @@ public function up(): void
Schema::create('social_accounts', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('workspace_id');
$table->foreignUuid('brand_id')->nullable()->constrained()->nullOnDelete();
$table->string('platform');
$table->string('platform_user_id');
$table->string('username')->nullable();

View file

@ -8,27 +8,23 @@
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('workspace_invites', function (Blueprint $table) {
Schema::create('invites', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('account_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('invited_by')->nullable()->constrained('users')->nullOnDelete();
$table->string('email');
$table->string('role')->default('member');
$table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete();
$table->json('workspaces');
$table->timestamp('accepted_at')->nullable();
$table->timestamps();
$table->unique(['email', 'workspace_id']);
$table->unique(['email', 'account_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('workspace_invites');
Schema::dropIfExists('invites');
}
};

View file

@ -25,7 +25,7 @@ public function run(): void
'yearly_price' => 19000,
'social_account_limit' => 5,
'member_limit' => 1,
'brand_limit' => 0,
'workspace_limit' => 1,
'ai_images_limit' => 50,
'ai_videos_limit' => 10,
'data_retention_days' => 30,
@ -40,7 +40,7 @@ public function run(): void
'yearly_price' => 29000,
'social_account_limit' => 10,
'member_limit' => 5,
'brand_limit' => 5,
'workspace_limit' => 5,
'ai_images_limit' => 150,
'ai_videos_limit' => 30,
'data_retention_days' => 60,
@ -55,7 +55,7 @@ public function run(): void
'yearly_price' => 49000,
'social_account_limit' => 30,
'member_limit' => 15,
'brand_limit' => 15,
'workspace_limit' => 15,
'ai_images_limit' => 500,
'ai_videos_limit' => 100,
'data_retention_days' => 90,
@ -70,7 +70,7 @@ public function run(): void
'yearly_price' => 99000,
'social_account_limit' => 100,
'member_limit' => 20,
'brand_limit' => 50,
'workspace_limit' => 50,
'ai_images_limit' => 2000,
'ai_videos_limit' => 500,
'data_retention_days' => 730,

View file

@ -1,8 +1,35 @@
# Workspace Billing, Plans & Brands
# Account Billing, Plans & Workspaces
## Overview
Move billing from User to Workspace. Each workspace has its own subscription tied to a plan. Introduce a `plans` table with 4 tiers (Starter, Plus, Pro, Max) that enforce limits on social accounts, members, brands, AI generation, and data retention. Introduce `brands` as a way to group social accounts within a workspace.
Introduce an Account entity as the central billing and organizational unit. Account holds the Stripe subscription, plan, and limits. Workspaces group social accounts within an Account. Users belong to one Account with an account-level role (admin/user) and can be assigned to specific workspaces with workspace-level roles (member/viewer). The Account owner has full access to everything.
## Data Model
```
Account (Billable, plan_id, owner_id)
├── Users (account_id, account_role: admin/user)
└── Workspaces
├── Members (user_id, workspace_role: member/viewer)
├── Invites
└── Social Accounts
```
## Roles
### Account-level
Owner is determined by `accounts.owner_id`. No account-level role column on users. Only the owner can manage billing, create workspaces, and delete the account. The owner has full access to all workspaces automatically.
### Workspace-level roles (`user_workspace.role`)
| Role | Permissions |
|---|---|
| Admin | Everything in the workspace: manage members, connect accounts, settings |
| Member | Create posts, schedule |
| Viewer | View only (future: comments) |
The Account owner has full access to all workspaces without needing a workspace pivot record.
## Plans
@ -10,116 +37,151 @@ ## Plans
|---|---|---|---|---|
| Social accounts | 5 | 10 | 30 | 100 |
| Members | 1 | 5 | 15 | 20 |
| Brands | 0 | 5 | 15 | 50 |
| Workspaces | 1 | 5 | 15 | 50 |
| AI Images/month | 50 | 150 | 500 | 2000 |
| AI Videos/month | 10 | 30 | 100 | 500 |
| Data retention (days) | 30 | 60 | 90 | 730 |
| Monthly price | $19 | $29 | $49 | $99 |
| Yearly price | $190 | $290 | $490 | $990 |
All limits are at the **Account level** (totals across all workspaces).
Starter plan: 1 member (owner only), 1 workspace (default, UI hides workspace management).
Trial period: 8 days (configurable via `CASHIER_TRIAL_DAYS`).
---
## Database Changes
### New table: `plans`
### New table: `accounts`
| Column | Type | Notes |
|---|---|---|
| id | uuid, PK | |
| slug | string, unique | "starter", "plus", "pro", "max" |
| name | string | "Starter", "Plus", "Pro", "Max" |
| stripe_monthly_price_id | string, nullable | Stripe price ID for monthly billing |
| stripe_yearly_price_id | string, nullable | Stripe price ID for yearly billing |
| monthly_price | integer | In cents (1900, 2900, 4900, 9900) |
| yearly_price | integer | In cents (19000, 29000, 49000, 99000) |
| social_account_limit | integer | 5, 10, 30, 100 |
| member_limit | integer | 1, 5, 15, 20 |
| brand_limit | integer | 0, 5, 15, 50 |
| ai_images_limit | integer | 50, 150, 500, 2000 |
| ai_videos_limit | integer | 10, 30, 100, 500 |
| data_retention_days | integer | 30, 60, 90, 730 |
| sort | integer | Display order |
| is_archived | boolean, default false | Hide from selection without deleting |
| timestamps | | |
### New table: `brands`
| Column | Type | Notes |
|---|---|---|
| id | uuid, PK | |
| workspace_id | FK -> workspaces, cascade delete | |
| name | string | |
| timestamps | | |
### Modify table: `social_accounts`
Add `brand_id` (FK -> brands, nullable, set null on delete). Social accounts can optionally belong to a brand for grouping.
### Modify table: `workspaces`
Add columns (Cashier Billable fields + plan reference):
| Column | Type | Notes |
|---|---|---|
| plan_id | FK -> plans, nullable, constrained | Current plan |
| owner_id | FK -> users, nullable initially | Set after user creation (chicken-egg) |
| plan_id | FK -> plans, nullable | Current plan |
| name | string | Account/company name |
| stripe_id | string, nullable, indexed | Stripe customer ID |
| pm_type | string, nullable | Payment method type |
| pm_last_four | string, nullable | Last 4 digits |
| trial_ends_at | timestamp, nullable | |
| timestamps | | |
### Modify table: `subscriptions`
Note: `owner_id` is nullable because during signup the user and account are created in the same transaction. Set `owner_id` after user creation.
Change FK from `user_id` to `workspace_id` (uuid, FK -> workspaces, cascade delete). Drop the old `user_id` column.
### New table: `plans` (unchanged from current)
### Modify table: `subscription_items`
No changes needed (references `subscription_id` which remains the same).
Already implemented. slug, name, stripe price IDs, all limits, sort, is_archived.
### Modify table: `users`
Remove billing columns: `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`.
Add columns:
- `account_id` (FK -> accounts, nullable, constrained)
- `account_role` (string, default 'user') — enum: admin, user
Remove columns:
- `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at` (already removed)
### Modify table: `workspaces`
Remove columns:
- `plan_id` (moves to Account)
- `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at` (moves to Account)
Add columns:
- `account_id` (FK -> accounts, constrained, cascade delete)
Keep: `user_id` (original creator, not necessarily the owner), `name`, `timezone`
### Modify table: `user_workspace`
Change `role` values from `owner/admin/member` to `member/viewer`. Owner is determined by Account, not workspace pivot.
### Modify table: `subscriptions`
Change FK from `workspace_id` to `account_id`.
### Modify table: `invites` (rename from `workspace_invites`)
| Column | Type | Notes |
|---|---|---|
| id | uuid, PK | |
| account_id | FK -> accounts, cascade delete | |
| email | string | Invited email |
| workspaces | json | Array of {workspace_id, role} |
| invited_by | FK -> users, nullable | Who sent the invite |
| accepted_at | timestamp, nullable | |
| timestamps | | |
### Remove table: `brands`
Brands concept is replaced by Workspaces. Remove brands table, remove `brand_id` from social_accounts.
---
## Models
### Plan (new)
### Account (new)
- UUID primary key
- Fillable: slug, name, prices, all limits, sort, is_archived
- Casts: is_archived (boolean), monthly_price (integer), yearly_price (integer)
- Scopes: `active()` (where is_archived = false)
- Relationship: `workspaces()` hasMany
- Method: `formattedMonthlyPrice()`, `formattedYearlyPrice()`
### Brand (new)
- UUID primary key
- Fillable: workspace_id, name
- Relationship: `workspace()` belongsTo, `socialAccounts()` hasMany
### Workspace (modified)
- Add `Billable` trait from Laravel Cashier
- Add to fillable: `plan_id`, `stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`
- New relationships: `plan()` belongsTo, `brands()` hasMany
- New constant: `SUBSCRIPTION_NAME = 'default'`
- Methods: `hasActiveSubscription()`, `isOnTrial()`, `stripeEmail()` (returns owner's email), `stripeName()` (returns workspace name)
- `Billable` trait from Cashier
- Constant: `SUBSCRIPTION_NAME = 'default'`
- Fillable: name, owner_id, plan_id
- Relationships: `owner()` belongsTo User, `plan()` belongsTo Plan, `users()` hasMany User, `workspaces()` hasMany Workspace, `invites()` hasMany Invite
- Methods: `hasActiveSubscription()`, `isOnTrial()`, `stripeEmail()` (owner email), `stripeName()` (account name)
### User (modified)
- Remove `Billable` trait (already removed)
- Add `account_id` and `account_role` to fillable
- Add cast: `account_role` to `AccountRole` enum
- Add relationship: `account()` belongsTo Account
- Remove: `HasWorkspace` trait methods related to billing
- Keep: `currentWorkspace()`, `switchWorkspace()`, workspace navigation methods
- Add: `isAccountOwner()``$this->id === $this->account?->owner_id`
- Add: `isAccountAdmin()``$this->account_role === AccountRole::Admin || $this->isAccountOwner()`
### Workspace (modified)
- Remove `Billable` trait
- Remove `SUBSCRIPTION_NAME` constant
- Remove `hasActiveSubscription()`, `hasEverSubscribed()` methods
- Remove billing fields from fillable
- Simplify `HasWorkspace` trait: remove `canCreateWorkspace()`, `incrementWorkspaceQuantity()`, `decrementWorkspaceQuantity()`, `syncWorkspaceQuantity()`
- Remove billing fields from fillable (stripe_id, pm_type, pm_last_four, trial_ends_at, plan_id)
- Add `account_id` to fillable
- Add relationship: `account()` belongsTo Account
- Keep: members(), socialAccounts(), posts(), invites is now on Account
### SocialAccount (modified)
### Invite (modified from WorkspaceInvite)
- Add `brand_id` to fillable
- New relationship: `brand()` belongsTo (nullable)
- Rename model from WorkspaceInvite to Invite
- Add: `account_id`, `workspaces` (json), `invited_by`
- Add cast: `workspaces` to array
- Relationship: `account()` belongsTo Account, `invitedBy()` belongsTo User
---
## Enums
### AccountRole (new)
```php
enum AccountRole: string
{
case Admin = 'admin';
case User = 'user';
}
```
### WorkspaceRole (modify existing)
```php
enum WorkspaceRole: string
{
case Member = 'member';
case Viewer = 'viewer';
}
```
Remove `Owner` and `Admin` cases.
---
@ -128,303 +190,133 @@ ## Cashier Configuration
### AppServiceProvider
```php
Cashier::useCustomerModel(Workspace::class);
Cashier::useCustomerModel(Account::class);
```
### config/cashier.php
Remove the `plans` section (plans come from the database now). Keep Stripe keys, webhook, currency, trial_days, invoice settings.
---
## Middleware: EnsureSubscribed
Simplified logic:
1. If `config('trypost.self_hosted')` is true -> pass through
2. Get `$user->currentWorkspace`
3. If workspace has active subscription or is on trial -> pass through
1. If `config('trypost.self_hosted')` → pass
2. Get `$user->account`
3. If account has active subscription or trial → pass
4. Redirect to `/subscribe`
No more checking the workspace owner's subscription — the workspace IS the subscriber.
---
## Pennant Features
Scope changes from Workspace to Account:
```php
Feature::resolveScopeUsing(fn () => auth()->user()?->account);
```
All 6 feature classes receive `Account $scope` instead of `Workspace $scope`:
- `SocialAccountLimit``$scope->plan?->social_account_limit ?? 5`
- `MemberLimit``$scope->plan?->member_limit ?? 1`
- `WorkspaceLimit``$scope->plan?->workspace_limit ?? 1` (replaces BrandLimit)
- `AiImagesLimit``$scope->plan?->ai_images_limit ?? 50`
- `AiVideosLimit``$scope->plan?->ai_videos_limit ?? 10`
- `DataRetentionDays``$scope->plan?->data_retention_days ?? 30`
---
## Limit Enforcement
All limits checked at Account level:
- **Social accounts**: total across all workspaces in the account
- **Members**: unique users in the account (excluding owner) — `$account->users()->where('id', '!=', $account->owner_id)->count()`
- **Workspaces**: `$account->workspaces()->count()`
- **AI images/videos**: monthly usage tracked per account
- **Data retention**: applied per account
---
## Controllers
### BillingController (modified)
### BillingController
All billing operations change from `$user` to `$workspace`:
All operations on `$user->account`:
- `$account->subscribed()`, `$account->subscription()`, `$account->invoices()`
- `subscribe()` — show plan selection page with all active plans
- `checkout(Plan $plan)` — create Stripe Checkout for workspace with selected plan's price ID
- `processing()` — check `$workspace->subscribed()` instead of `$user->subscribed()`
- `index()` — show workspace subscription, invoices, payment method, current plan details with limits and usage
- `portal()``$workspace->redirectToBillingPortal()`
- `swap(Plan $plan)` — swap workspace subscription to a different plan
### OnboardingController
### OnboardingController (modified)
Signup flow:
1. Create Account
2. Create User with `account_id` and `account_role = admin` (owner)
3. Set `account.owner_id`
4. Create default Workspace with `account_id`
5. Stripe checkout on Account
`storeConnect()` changes from `$user->newSubscription(...)` to `$workspace->newSubscription(...)`. The plan selection needs to happen during onboarding — default to Starter plan or let user choose.
### StripeEventListener
### StripeEventListener (modified)
Find `Account::where('stripe_id', $stripeCustomerId)` instead of Workspace.
Change `User::where('stripe_id', ...)` to `Workspace::where('stripe_id', ...)`. Update `handleSubscriptionCreated` to work with workspace context (still updates user setup to Completed).
### InviteController (replaces WorkspaceInviteController)
### BrandController (new)
Full CRUD:
- `index()` — list brands for current workspace with social account counts
- `store(StoreBrandRequest)` — create brand (enforce plan limit via policy)
- `update(UpdateBrandRequest, Brand)` — rename brand
- `destroy(Brand)` — delete brand (social accounts get `brand_id = null`, not deleted)
### SocialAccountController (modified if exists)
Add ability to assign/unassign a social account to a brand.
- `store()`: create invite on Account with workspaces + roles array
- On accept: set user's `account_id`, create workspace pivot records
---
## Policies
## Signup Flow
### BrandPolicy
1. User registers (name, email, password)
2. `CreateUser` action:
- Creates Account (name = user's name)
- Creates User with `account_id`, `account_role = admin`
- Sets `account.owner_id = user.id`
- Creates default Workspace within Account
- Sets `user.current_workspace_id`
3. Onboarding: role → connect accounts → Stripe checkout (on Account)
- `viewAny(User, Workspace)` — user belongs to workspace
- `create(User, Workspace)` — user can manage accounts AND `$workspace->brands()->count() < $workspace->plan->brand_limit`
- `update(User, Workspace, Brand)` — user can manage accounts AND brand belongs to workspace
- `delete(User, Workspace, Brand)` — same as update
## Invite Flow
### WorkspacePolicy (modified)
- `manageBilling(User, Workspace)` — only owner (unchanged)
- `inviteMember(User, Workspace)` — enforce member limit: `$workspace->members()->count() < $workspace->plan->member_limit`
### SocialAccountPolicy (new or modified)
- Enforce social account limit on connect: `$workspace->socialAccounts()->count() < $workspace->plan->social_account_limit`
1. Owner/Admin creates invite: email + [{workspace_id, role}]
2. Email sent with invite link
3. Person clicks link:
- If no TryPost account: register with `account_id` pre-set, `account_role = user`
- If has TryPost account with same Account: add workspace assignments
- If has TryPost account with different Account: error — must use different email
4. Workspace pivot records created per the invite's workspaces array
---
## Pennant Features (app/Features/)
## UI Behavior by Plan
Use Laravel Pennant to resolve plan limits per workspace. Each feature class resolves the limit value from the workspace's plan, with a sensible fallback for self-hosted mode (unlimited).
### Starter (1 workspace, 1 member)
- Workspace switcher hidden
- "Create workspace" hidden
- Invite members hidden
- Simple single-workspace experience
### SocialAccountLimit
Returns `$workspace->plan->social_account_limit` (fallback: `PHP_INT_MAX` for self-hosted).
### MemberLimit
Returns `$workspace->plan->member_limit` (fallback: `PHP_INT_MAX`).
### BrandLimit
Returns `$workspace->plan->brand_limit` (fallback: `PHP_INT_MAX`).
### AiImagesLimit
Returns `$workspace->plan->ai_images_limit` (fallback: `PHP_INT_MAX`).
### AiVideosLimit
Returns `$workspace->plan->ai_videos_limit` (fallback: `PHP_INT_MAX`).
### DataRetentionDays
Returns `$workspace->plan->data_retention_days` (fallback: `PHP_INT_MAX` for unlimited).
### Pennant Configuration
In `AppServiceProvider`:
```php
Feature::resolveScopeUsing(fn () => auth()->user()?->currentWorkspace);
Feature::discover();
```
### Usage in Policies
Policies use Pennant to resolve limits:
```php
use Laravel\Pennant\Feature;
use App\Features\BrandLimit;
// In BrandPolicy::create()
$limit = Feature::for($workspace)->value(BrandLimit::class);
return $workspace->brands()->count() < $limit;
```
This decouples limit enforcement from direct plan access, making it testable and overridable per workspace if needed.
### Plus and above
- Workspace switcher visible
- Create workspace button visible
- Invite members visible
- Full multi-workspace experience
---
## Routes
## Plans table: workspace_limit replaces brand_limit
### Billing routes (modified)
```
GET /subscribe BillingController@subscribe
POST /billing/checkout/{plan} BillingController@checkout
GET /billing/processing BillingController@processing
GET /settings/billing BillingController@index
GET /settings/billing/portal BillingController@portal
POST /settings/billing/swap/{plan} BillingController@swap
```
### Brand routes (new)
```
GET /brands BrandController@index
POST /brands BrandController@store
PUT /brands/{brand} BrandController@update
DELETE /brands/{brand} BrandController@destroy
```
---
## Frontend (Vue)
### Subscribe page (modified)
Show plan selection cards (4 plans) with monthly/yearly toggle instead of a single checkout button. Each card shows limits and price. User selects plan -> goes to Stripe Checkout.
### Billing settings page (modified)
Show current plan name, limits with usage bars (e.g., "3/5 social accounts"), subscription status, invoices, payment method. Add "Change Plan" button that shows plan comparison.
### Brands pages (new)
- **Brands list** — cards/list showing brands with social account count per brand. Create button (disabled if at limit with tooltip explaining).
- **Brand create/edit** — simple form with name field.
- **Social account assignment** — in the accounts page, ability to assign accounts to brands (dropdown or drag).
### Sidebar (modified)
Add "Brands" link in the sidebar navigation (only visible if plan allows brands, i.e., brand_limit > 0).
---
## Seeder: PlanSeeder
Creates the 4 plans:
```php
[
[
'slug' => 'starter',
'name' => 'Starter',
'stripe_monthly_price_id' => env('STRIPE_STARTER_MONTHLY'),
'stripe_yearly_price_id' => env('STRIPE_STARTER_YEARLY'),
'monthly_price' => 1900,
'yearly_price' => 19000,
'social_account_limit' => 5,
'member_limit' => 1,
'brand_limit' => 0,
'ai_images_limit' => 50,
'ai_videos_limit' => 10,
'data_retention_days' => 30,
'sort' => 1,
],
[
'slug' => 'plus',
'name' => 'Plus',
'monthly_price' => 2900,
'yearly_price' => 29000,
'social_account_limit' => 10,
'member_limit' => 5,
'brand_limit' => 5,
'ai_images_limit' => 150,
'ai_videos_limit' => 30,
'data_retention_days' => 60,
'sort' => 2,
],
[
'slug' => 'pro',
'name' => 'Pro',
'monthly_price' => 4900,
'yearly_price' => 49000,
'social_account_limit' => 30,
'member_limit' => 15,
'brand_limit' => 15,
'ai_images_limit' => 500,
'ai_videos_limit' => 100,
'data_retention_days' => 90,
'sort' => 3,
],
[
'slug' => 'max',
'name' => 'Max',
'monthly_price' => 9900,
'yearly_price' => 99000,
'social_account_limit' => 100,
'member_limit' => 20,
'brand_limit' => 50,
'ai_images_limit' => 2000,
'ai_videos_limit' => 500,
'data_retention_days' => 730,
'sort' => 4,
],
]
```
---
## Tests
### Plan tests
- Plan seeder creates 4 plans with correct limits
- Plan `active()` scope excludes archived plans
- Plan formatted prices return correct values
### Brand CRUD tests
- Create brand (success + limit enforcement)
- List brands for workspace
- Update brand name
- Delete brand (social accounts get null brand_id)
- Cannot create brand on Starter plan (limit = 0)
- Cannot create brand beyond plan limit
- Cannot access brands from another workspace
### Billing tests
- Workspace checkout creates Stripe session
- Workspace subscription grants access via middleware
- No subscription redirects to /subscribe
- Self-hosted mode bypasses subscription check
- Plan swap updates workspace plan_id
- Invoices returned for workspace
- Only owner can manage billing
### Limit enforcement tests
- Cannot connect social account beyond plan limit
- Cannot invite member beyond plan limit
- Cannot create brand beyond plan limit
### Migration tests
- Workspace has billing columns after migration
- User no longer has billing columns
- Subscriptions reference workspace_id
| Column change | Old | New |
|---|---|---|
| brand_limit | 0, 5, 15, 50 | renamed to workspace_limit: 1, 5, 15, 50 |
---
## Self-hosted mode
All plan limits and subscription checks are bypassed when `config('trypost.self_hosted')` is true. In self-hosted mode:
- No plans table interaction needed
- No subscription checks
- Unlimited social accounts, members, brands
- Unlimited data retention
- Brand feature is still available (just no limit)
All limits bypassed. Account still exists but no billing. Unlimited workspaces, members, social accounts.
---
## What does NOT change
- Onboarding flow steps (Role -> Connect -> Payment -> Complete)
- Social account OAuth flows
- Social account OAuth flows (connect/disconnect)
- Post creation and publishing
- Analytics
- Workspace creation flow
- User authentication
- Multi-account social connections (multiple accounts per platform)

View file

@ -6,7 +6,7 @@
'description' => 'Overview of all your connected social accounts',
'add_social' => 'Add Social',
'add_social_title' => 'Connect a Social Account',
'add_social_description' => 'Choose a platform to connect',
'add_social_description' => 'Connect a social account to TryPost to start posting',
'no_accounts' => 'No accounts connected yet',
'no_accounts_description' => 'Connect your social networks to start scheduling and publishing posts',
'added' => 'Added :date',
@ -20,10 +20,19 @@
'view_profile' => 'View profile',
'disconnect' => 'Disconnect',
'tooltips' => [
'instagram_facebook' => 'Connects via your Facebook Page. Recommended for business accounts linked to a Facebook Page.',
'instagram_direct' => 'Connects directly through Instagram. For professional/creator accounts without a Facebook Page.',
'bluesky' => "We don't currently support two-factor authentication. If it's enabled on Bluesky, you'll need to disable it.",
'descriptions' => [
'linkedin' => 'Connect your LinkedIn personal profile',
'linkedin-page' => 'Connect a LinkedIn company page',
'x' => 'Connect your X (Twitter) account',
'tiktok' => 'Connect your TikTok account',
'youtube' => 'Connect a YouTube channel',
'facebook' => 'Connect a Facebook page',
'instagram' => 'Connect an Instagram professional account',
'instagram-facebook' => 'Connect Instagram via Facebook page',
'threads' => 'Connect your Threads account',
'pinterest' => 'Connect your Pinterest account',
'bluesky' => 'Connect your Bluesky account',
'mastodon' => 'Connect your Mastodon account',
],
'disconnect_modal' => [

View file

@ -6,7 +6,7 @@
'description' => 'Resumen de todas tus cuentas sociales conectadas',
'add_social' => 'Agregar Red Social',
'add_social_title' => 'Conectar una Cuenta Social',
'add_social_description' => 'Elige una plataforma para conectar',
'add_social_description' => 'Conecta una cuenta social a TryPost para empezar a publicar',
'no_accounts' => 'No hay cuentas conectadas todavía',
'no_accounts_description' => 'Conecta tus redes sociales para empezar a programar y publicar posts',
'added' => 'Agregada :date',
@ -20,10 +20,19 @@
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'tooltips' => [
'instagram_facebook' => 'Conecta a través de tu Página de Facebook. Recomendado para cuentas business vinculadas a una Página de Facebook.',
'instagram_direct' => 'Conecta directamente por Instagram. Para cuentas profesionales/creadores sin Página de Facebook.',
'bluesky' => 'No soportamos autenticación de dos factores. Si está activada en Bluesky, necesitarás desactivarla.',
'descriptions' => [
'linkedin' => 'Conecta tu perfil personal de LinkedIn',
'linkedin-page' => 'Conecta una página de empresa de LinkedIn',
'x' => 'Conecta tu cuenta de X (Twitter)',
'tiktok' => 'Conecta tu cuenta de TikTok',
'youtube' => 'Conecta un canal de YouTube',
'facebook' => 'Conecta una página de Facebook',
'instagram' => 'Conecta una cuenta profesional de Instagram',
'instagram-facebook' => 'Conecta Instagram vía página de Facebook',
'threads' => 'Conecta tu cuenta de Threads',
'pinterest' => 'Conecta tu cuenta de Pinterest',
'bluesky' => 'Conecta tu cuenta de Bluesky',
'mastodon' => 'Conecta tu cuenta de Mastodon',
],
'disconnect_modal' => [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,7 +6,7 @@
'description' => 'Visão geral de todas as suas contas sociais conectadas',
'add_social' => 'Adicionar Rede Social',
'add_social_title' => 'Conectar uma Conta Social',
'add_social_description' => 'Escolha uma plataforma para conectar',
'add_social_description' => 'Conecte uma conta social ao TryPost para começar a publicar',
'no_accounts' => 'Nenhuma conta conectada ainda',
'no_accounts_description' => 'Conecte suas redes sociais para começar a agendar e publicar posts',
'added' => 'Adicionada :date',
@ -20,10 +20,19 @@
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'tooltips' => [
'instagram_facebook' => 'Conecta via sua Página do Facebook. Recomendado para contas business vinculadas a uma Página do Facebook.',
'instagram_direct' => 'Conecta direto pelo Instagram. Para contas profissionais/criadores sem Página do Facebook.',
'bluesky' => 'Não suportamos autenticação de dois fatores. Se estiver ativada no Bluesky, será necessário desativá-la.',
'descriptions' => [
'linkedin' => 'Conecte seu perfil pessoal do LinkedIn',
'linkedin-page' => 'Conecte uma página de empresa do LinkedIn',
'x' => 'Conecte sua conta do X (Twitter)',
'tiktok' => 'Conecte sua conta do TikTok',
'youtube' => 'Conecte um canal do YouTube',
'facebook' => 'Conecte uma página do Facebook',
'instagram' => 'Conecte uma conta profissional do Instagram',
'instagram-facebook' => 'Conecte Instagram via página do Facebook',
'threads' => 'Conecte sua conta do Threads',
'pinterest' => 'Conecte sua conta do Pinterest',
'bluesky' => 'Conecte sua conta do Bluesky',
'mastodon' => 'Conecte sua conta do Mastodon',
],
'disconnect_modal' => [

View file

@ -8,7 +8,6 @@ import {
IconClock,
IconFileCheck,
IconFileText,
IconBuildingStore,
IconHash,
IconLifebuoy,
IconMessageCircle,
@ -22,6 +21,7 @@ import { computed } from 'vue';
import { store as storePost } from '@/actions/App/Http/Controllers/App/PostController';
import { index as postsIndex } from '@/actions/App/Http/Controllers/App/PostController';
import { WorkspaceRole } from '@/enums/workspace-role';
import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue';
import { Avatar } from '@/components/ui/avatar';
@ -45,7 +45,6 @@ import {
useSidebar,
} from '@/components/ui/sidebar';
import { accounts, analytics, calendar } from '@/routes/app';
import { index as brands } from '@/routes/app/brands';
import { index as hashtags } from '@/routes/app/hashtags';
import { index as labels } from '@/routes/app/labels';
import { edit as editProfile } from '@/routes/app/profile';
@ -102,7 +101,10 @@ const postsNavItems = computed<NavItem[]>(() => [
},
]);
const canManageWorkspace = computed(() => auth.value.currentWorkspace?.role !== 'member');
const canManageWorkspace = computed(() => {
const role = auth.value.currentWorkspace?.role;
return role === WorkspaceRole.Owner || role === WorkspaceRole.Admin;
});
const configNavItems = computed(() => {
const items: NavItem[] = [
@ -111,11 +113,6 @@ const configNavItems = computed(() => {
href: accounts.url(),
icon: IconAffiliate,
},
{
title: trans('sidebar.config.brands'),
href: brands.url(),
icon: IconBuildingStore,
},
{
title: trans('sidebar.config.hashtags'),
href: hashtags.url(),

View file

@ -1,6 +1,5 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconInfoCircle } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onMounted, onUnmounted } from 'vue';
@ -12,7 +11,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export interface AvailablePlatform {
value: string;
@ -44,13 +42,8 @@ const getPlatformLogo = (platform: string): string => {
return logos[platform] || '/images/accounts/linkedin.png';
};
const getPlatformTooltip = (platform: string): string | null => {
const tooltips: Record<string, string> = {
'instagram-facebook': trans('accounts.tooltips.instagram_facebook'),
'instagram': trans('accounts.tooltips.instagram_direct'),
'bluesky': trans('accounts.tooltips.bluesky'),
};
return tooltips[platform] || null;
const getPlatformDescription = (platform: string): string => {
return trans(`accounts.descriptions.${platform}`);
};
const openOAuthPopup = (platformValue: string) => {
@ -60,6 +53,8 @@ const openOAuthPopup = (platformValue: string) => {
const left = window.screenX + (window.outerWidth - width) / 2;
const top = window.screenY + (window.outerHeight - height) / 2;
open.value = false;
window.open(
url,
'oauth-popup',
@ -86,43 +81,31 @@ onUnmounted(() => {
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogContent class="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{{ $t('accounts.add_social_title') }}</DialogTitle>
<DialogDescription>
{{ $t('accounts.add_social_description') }}
</DialogDescription>
</DialogHeader>
<div class="grid grid-cols-2 gap-3">
<Button
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button
v-for="platform in platforms"
:key="platform.value"
variant="outline"
class="h-auto flex-col gap-2 py-4"
class="flex flex-col items-center gap-2 rounded-lg border border-border p-4 text-center transition-colors hover:bg-accent"
@click="openOAuthPopup(platform.value)"
>
<div class="relative">
<img :src="getPlatformLogo(platform.value)" :alt="platform.label" class="h-8 w-8 rounded-full object-contain" />
</div>
<div class="flex items-center gap-1">
<span class="text-xs">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<TooltipProvider v-if="getPlatformTooltip(platform.value)">
<Tooltip>
<TooltipTrigger as-child>
<IconInfoCircle class="h-3 w-3 shrink-0 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" class="max-w-[250px]">
<p>{{ getPlatformTooltip(platform.value) }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</Button>
<img :src="getPlatformLogo(platform.value)" :alt="platform.label" class="h-10 w-10 rounded-full object-contain" />
<span class="text-sm font-medium">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<p class="line-clamp-2 text-xs leading-tight text-muted-foreground">
{{ getPlatformDescription(platform.value) }}
</p>
</button>
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import { redirect as googleRedirect } from '@/routes/auth/google';
const props = defineProps<{
label: string;
}>();
const page = usePage();
const isEnabled = computed(() => page.props.googleAuthEnabled);
</script>
<template>
<template v-if="isEnabled">
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
<img src="/images/social/google.svg" alt="Google" class="size-4" />
{{ label }}
</Button>
<div
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
</div>
</template>
</template>

View file

@ -1,76 +0,0 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { store as brandsStore } from '@/routes/app/brands';
const open = defineModel<boolean>('open', { default: false });
const form = useForm({
name: '',
});
const submit = () => {
form.post(brandsStore.url(), {
onSuccess: () => {
open.value = false;
form.reset();
},
});
};
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
form.clearErrors();
}
open.value = value;
};
</script>
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('brands.create.title') }}</DialogTitle>
<DialogDescription>
{{ $t('brands.create.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="create-brand-name">{{ $t('brands.create.name') }}</Label>
<Input
id="create-brand-name"
v-model="form.name"
:placeholder="trans('brands.create.name_placeholder')"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? $t('brands.create.submitting') : $t('brands.create.submit') }}
</Button>
<Button type="button" variant="secondary" @click="open = false">
{{ $t('common.cancel') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -1,85 +0,0 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { watch } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { update as brandsUpdate } from '@/routes/app/brands';
interface Brand {
id: string;
name: string;
}
const props = defineProps<{
brand: Brand | null;
}>();
const open = defineModel<boolean>('open', { default: false });
const form = useForm({
name: '',
});
watch(() => props.brand, (brand) => {
if (brand) {
form.name = brand.name;
form.clearErrors();
}
}, { immediate: true });
const submit = () => {
if (!props.brand) return;
form.put(brandsUpdate.url(props.brand.id), {
onSuccess: () => {
open.value = false;
},
});
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('brands.edit.title') }}</DialogTitle>
<DialogDescription>
{{ $t('brands.edit.description') }}
</DialogDescription>
</DialogHeader>
<form @submit.prevent="submit" class="space-y-4">
<div class="space-y-2">
<Label for="edit-brand-name">{{ $t('brands.edit.name') }}</Label>
<Input
id="edit-brand-name"
v-model="form.name"
:placeholder="trans('brands.edit.name_placeholder')"
:class="{ 'border-destructive': form.errors.name }"
/>
<p v-if="form.errors.name" class="text-sm text-destructive">
{{ form.errors.name }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
{{ form.processing ? $t('brands.edit.submitting') : $t('brands.edit.submit') }}
</Button>
<Button type="button" variant="secondary" @click="open = false">
{{ $t('common.cancel') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -3,6 +3,7 @@ import { Form } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import { WorkspaceRole } from '@/enums/workspace-role';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
@ -26,10 +27,10 @@ import { store as storeInvite } from '@/routes/app/invites';
const open = defineModel<boolean>('open', { default: false });
const inviteRole = ref('member');
const inviteRole = ref(WorkspaceRole.Member);
const onSuccess = () => {
inviteRole.value = 'member';
inviteRole.value = WorkspaceRole.Member;
open.value = false;
};
</script>
@ -67,8 +68,9 @@ const onSuccess = () => {
<SelectValue :placeholder="trans('settings.members.invite.role_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">{{ $t('settings.members.roles.member') }}</SelectItem>
<SelectItem value="admin">{{ $t('settings.members.roles.admin') }}</SelectItem>
<SelectItem :value="WorkspaceRole.Member">{{ $t('settings.members.roles.member') }}</SelectItem>
<SelectItem :value="WorkspaceRole.Admin">{{ $t('settings.members.roles.admin') }}</SelectItem>
<SelectItem :value="WorkspaceRole.Viewer">{{ $t('settings.members.roles.viewer') }}</SelectItem>
</SelectContent>
</Select>
<input type="hidden" name="role" :value="inviteRole" />

View file

@ -0,0 +1,8 @@
export const WorkspaceRole = {
Owner: 'owner',
Admin: 'admin',
Member: 'member',
Viewer: 'viewer',
} as const;
export type WorkspaceRoleValue = (typeof WorkspaceRole)[keyof typeof WorkspaceRole];

View file

@ -4,6 +4,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { WorkspaceRole } from '@/enums/workspace-role';
import { toUrl } from '@/lib/utils';
import { index as apiKeys } from '@/routes/app/api-keys';
import { index as billing } from '@/routes/app/billing';
@ -15,7 +16,10 @@ import { type NavItem, type SharedData } from '@/types';
const page = usePage<SharedData>();
const auth = computed(() => page.props.auth);
const canManageWorkspace = computed(() => auth.value.currentWorkspace?.role !== 'member');
const canManageWorkspace = computed(() => {
const role = auth.value.currentWorkspace?.role;
return role === WorkspaceRole.Owner || role === WorkspaceRole.Admin;
});
const navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [

View file

@ -29,7 +29,6 @@ interface SocialAccount {
is_active: boolean;
error_message: string | null;
created_at: string;
brand: { id: string; name: string } | null;
}
interface Props {
@ -180,11 +179,6 @@ const handleDisconnect = (accountId: string) => {
@{{ account.username || account.display_name }}
</p>
<!-- Brand badge -->
<p v-if="account.brand" class="mt-1 text-xs text-muted-foreground">
{{ account.brand.name }}
</p>
<!-- Added date -->
<p class="mt-2 text-xs text-muted-foreground">
{{ $t('accounts.added', { date: date.diffForHumans(account.created_at) }) }}

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import GoogleAuthButton from '@/components/auth/GoogleAuthButton.vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
@ -10,7 +11,6 @@ import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue';
import { register } from '@/routes';
import { redirect as googleRedirect } from '@/routes/auth/google';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
@ -31,18 +31,7 @@ defineProps<{
</div>
<div class="flex flex-col gap-6">
<template v-if="$page.props.googleAuthEnabled">
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
<img src="/images/social/google.svg" alt="Google" class="size-4" />
{{ $t('auth.google_login') }}
</Button>
<div
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
</div>
</template>
<GoogleAuthButton :label="$t('auth.google_login')" />
<Form v-bind="store.form()" :reset-on-success="['password']" v-slot="{ errors, processing }"
class="flex flex-col gap-6">
@ -87,4 +76,4 @@ defineProps<{
</Form>
</div>
</AuthBase>
</template>
</template>

View file

@ -3,6 +3,7 @@ import { Form, Head } from '@inertiajs/vue3';
import { IconEye, IconEyeOff } from '@tabler/icons-vue';
import { ref } from 'vue';
import GoogleAuthButton from '@/components/auth/GoogleAuthButton.vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
@ -17,7 +18,6 @@ import {
} from '@/components/ui/tooltip';
import AuthBase from '@/layouts/AuthLayout.vue';
import { login } from '@/routes';
import { redirect as googleRedirect } from '@/routes/auth/google';
import { store } from '@/routes/register';
defineProps<{
@ -27,7 +27,6 @@ defineProps<{
const showPassword = ref(false);
// Get user's timezone from browser
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
</script>
@ -40,18 +39,7 @@ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
<Head :title="$t('auth.register.page_title')" />
<div class="flex flex-col gap-6">
<template v-if="$page.props.googleAuthEnabled">
<Button variant="outline" class="w-full" as="a" :href="googleRedirect.url()">
<img src="/images/social/google.svg" alt="Google" class="size-4" />
{{ $t('auth.google_signup') }}
</Button>
<div
class="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"
>
<span class="relative z-10 bg-background px-2 text-muted-foreground">{{ $t('auth.or_continue_with') }}</span>
</div>
</template>
<GoogleAuthButton :label="$t('auth.google_signup')" />
<Form
v-bind="store.form()"

View file

@ -1,131 +0,0 @@
<script setup lang="ts">
import { Head, InfiniteScroll } from '@inertiajs/vue3';
import { IconBuildingStore, IconPencil, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import CreateDialog from '@/components/brands/CreateDialog.vue';
import EditDialog from '@/components/brands/EditDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as brandsIndex, destroy as brandsDestroy } from '@/routes/app/brands';
import { type BreadcrumbItemType } from '@/types';
interface Brand {
id: string;
name: string;
social_accounts_count: number;
}
interface ScrollBrands {
data: Brand[];
meta: {
hasNextPage: boolean;
};
}
interface Props {
brands: ScrollBrands;
canCreate: boolean;
}
const props = defineProps<Props>();
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false);
const editingBrand = ref<Brand | null>(null);
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('sidebar.config.brands'), href: brandsIndex.url() },
]);
const openEditDialog = (brand: Brand) => {
editingBrand.value = brand;
isEditDialogOpen.value = true;
};
const handleDelete = (brandId: string) => {
deleteModal.value?.open({
url: brandsDestroy.url(brandId),
});
};
</script>
<template>
<Head :title="$t('sidebar.config.brands')" />
<AppLayout :breadcrumbs="breadcrumbs">
<template #header-right>
<Button :disabled="!canCreate" @click="isCreateDialogOpen = true">
{{ $t('brands.new_brand') }}
</Button>
</template>
<div class="flex flex-col gap-6 p-6">
<EmptyState
v-if="brands.data.length === 0"
:icon="IconBuildingStore"
:title="$t('brands.no_brands_yet')"
:description="$t('brands.no_brands_description')"
/>
<div v-else>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="brand in brands.data" :key="brand.id">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-lg">{{ brand.name }}</CardTitle>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="openEditDialog(brand)">
<IconPencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
@click="handleDelete(brand.id)">
<IconTrash class="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p class="text-sm text-muted-foreground">
{{ $t('brands.accounts_count', { count: String(brand.social_accounts_count) }) }}
</p>
</CardContent>
</Card>
</div>
<InfiniteScroll data="brands" #default="{ loading }">
<div v-if="loading" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
<Card v-for="i in 3" :key="i">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<Skeleton class="h-6 w-32" />
<div class="flex gap-1">
<Skeleton class="h-8 w-8" />
<Skeleton class="h-8 w-8" />
</div>
</div>
</CardHeader>
<CardContent>
<Skeleton class="h-4 w-24" />
</CardContent>
</Card>
</div>
</InfiniteScroll>
</div>
</div>
</AppLayout>
<CreateDialog v-model:open="isCreateDialogOpen" />
<EditDialog v-model:open="isEditDialogOpen" :brand="editingBrand" />
<ConfirmDeleteModal ref="deleteModal" :title="$t('brands.delete.title')"
:description="$t('brands.delete.description')" :action="$t('brands.delete.confirm')"
:cancel="$t('brands.delete.cancel')" />
</template>

View file

@ -4,6 +4,7 @@ import { IconUserPlus, IconUsers, IconMail, IconTrash, IconCrown, IconUser, Icon
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { WorkspaceRole } from '@/enums/workspace-role';
import HeadingSmall from '@/components/HeadingSmall.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -58,7 +59,7 @@ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
const form = useForm({
email: '',
role: 'member',
role: WorkspaceRole.Member,
});
const submitInvite = () => {
@ -91,7 +92,7 @@ const getRoleLabel = (role: string): string => {
};
const getRoleIcon = (role: string) => {
if (role === 'admin') return IconShield;
if (role === WorkspaceRole.Admin) return IconShield;
return IconUser;
};
</script>

View file

@ -5,6 +5,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
import { WorkspaceRole } from '@/enums/workspace-role';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue';
@ -50,7 +51,6 @@ interface Member {
name: string;
email: string;
role: string;
is_owner: boolean;
}
interface Invitation {
@ -175,12 +175,12 @@ const changeRole = (member: Member, role: string) => {
{{ member.email }}
</TableCell>
<TableCell>
<Badge :variant="member.is_owner ? 'default' : 'secondary'">
<Badge :variant="member.role === WorkspaceRole.Admin ? 'default' : 'secondary'">
{{ member.role }}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu v-if="!member.is_owner">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8">
<IconDots class="size-3.5" />
@ -188,15 +188,15 @@ const changeRole = (member: Member, role: string) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="member.role === 'member'"
@click="changeRole(member, 'admin')"
v-if="member.role === WorkspaceRole.Member"
@click="changeRole(member, WorkspaceRole.Admin)"
>
<IconShield class="size-3.5" />
{{ $t('settings.members.make_admin') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="member.role === 'admin'"
@click="changeRole(member, 'member')"
v-if="member.role === WorkspaceRole.Admin"
@click="changeRole(member, WorkspaceRole.Member)"
>
<IconUser class="size-3.5" />
{{ $t('settings.members.make_member') }}

View file

@ -1,18 +1,20 @@
import { InertiaLinkProps } from '@inertiajs/vue3';
import type { Component } from 'vue';
export type WorkspaceRole = 'owner' | 'admin' | 'member' | 'viewer';
export interface Workspace {
id: string;
name: string;
logo_url: string | null;
timezone: string;
role?: 'owner' | 'admin' | 'member' | null;
role?: WorkspaceRole | null;
[key: string]: unknown;
}
export interface Auth {
user: User;
role: 'owner' | 'admin' | 'member' | null;
role: WorkspaceRole | null;
currentWorkspace: Workspace | null;
workspaces: Workspace[];
}

View file

@ -5,7 +5,6 @@
use App\Http\Controllers\App\AnalyticsController;
use App\Http\Controllers\App\ApiKeyController;
use App\Http\Controllers\App\BillingController;
use App\Http\Controllers\App\BrandController;
use App\Http\Controllers\App\MediaController;
use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\OnboardingController;
@ -164,12 +163,6 @@
Route::put('labels/{label}', [WorkspaceLabelController::class, 'update'])->name('app.labels.update');
Route::delete('labels/{label}', [WorkspaceLabelController::class, 'destroy'])->name('app.labels.destroy');
// Brands
Route::get('brands', [BrandController::class, 'index'])->name('app.brands.index');
Route::post('brands', [BrandController::class, 'store'])->name('app.brands.store');
Route::put('brands/{brand}', [BrandController::class, 'update'])->name('app.brands.update');
Route::delete('brands/{brand}', [BrandController::class, 'destroy'])->name('app.brands.destroy');
// API Keys
Route::get('api-keys', [ApiKeyController::class, 'index'])->name('app.api-keys.index');
Route::post('api-keys', [ApiKeyController::class, 'store'])->name('app.api-keys.store');

View file

@ -3,21 +3,30 @@
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceInvite;
beforeEach(function () {
$this->owner = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
$this->account = Account::factory()->create();
$this->owner = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->account->update(['owner_id' => $this->owner->id]);
$this->workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
});
test('show invite displays invite details for guest', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'newuser@example.com',
'role' => WorkspaceRole::Member,
'workspaces' => [$this->workspace->id],
]);
$response = $this->get(route('app.invites.show', $invite));
@ -28,8 +37,7 @@
->has('invite')
->where('invite.id', $invite->id)
->where('invite.email', 'newuser@example.com')
->where('invite.role.value', WorkspaceRole::Member->value)
->where('invite.workspace.name', $this->workspace->name)
->where('invite.account.name', $this->account->name)
);
});
@ -39,10 +47,11 @@
'setup' => Setup::Completed,
]);
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'invitee@example.com',
'role' => WorkspaceRole::Member,
'workspaces' => [$this->workspace->id],
]);
$response = $this->actingAs($user)->get(route('app.invites.show', $invite));
@ -61,8 +70,9 @@
});
test('accept invite requires authentication', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
]);
$response = $this->post(route('app.invites.accept', $invite));
@ -70,31 +80,36 @@
$response->assertRedirect(route('login'));
});
test('accept invite adds user to workspace', function () {
test('accept invite adds user to account and workspaces', function () {
$user = User::factory()->create([
'email' => 'invitee@example.com',
'setup' => Setup::Completed,
]);
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'invitee@example.com',
'role' => WorkspaceRole::Admin,
'workspaces' => [$this->workspace->id],
]);
$response = $this->actingAs($user)->post(route('app.invites.accept', $invite));
$response->assertRedirect(route('app.calendar'));
// User should be member of workspace
expect($this->workspace->hasMember($user))->toBeTrue();
// User should be added to the account
$user->refresh();
expect($user->account_id)->toBe($this->account->id);
// Invite should be deleted
expect(WorkspaceInvite::find($invite->id))->toBeNull();
// User should be member of workspace
expect($this->workspace->members()->where('user_id', $user->id)->exists())->toBeTrue();
// User's current workspace should be updated
$user->refresh();
expect($user->current_workspace_id)->toBe($this->workspace->id);
// Invite should be marked as accepted
$invite->refresh();
expect($invite->accepted_at)->not->toBeNull();
});
test('accept invite fails for wrong email', function () {
@ -103,10 +118,11 @@
'setup' => Setup::Completed,
]);
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'invitee@example.com',
'role' => WorkspaceRole::Member,
'workspaces' => [$this->workspace->id],
]);
$response = $this->actingAs($user)->post(route('app.invites.accept', $invite));
@ -114,22 +130,23 @@
$response->assertRedirect(route('app.calendar'));
$response->assertSessionHas('flash.bannerStyle', 'danger');
// Invite should NOT be deleted
expect(WorkspaceInvite::find($invite->id))->not->toBeNull();
// Invite should NOT be accepted
$invite->refresh();
expect($invite->accepted_at)->toBeNull();
});
test('accept invite handles already member', function () {
test('accept invite handles already member of account', function () {
$user = User::factory()->create([
'email' => 'invitee@example.com',
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($user->id, ['role' => WorkspaceRole::Member->value]);
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'invitee@example.com',
'role' => WorkspaceRole::Admin,
'workspaces' => [$this->workspace->id],
]);
$response = $this->actingAs($user)->post(route('app.invites.accept', $invite));
@ -137,13 +154,15 @@
$response->assertRedirect(route('app.calendar'));
$response->assertSessionHas('flash.bannerStyle', 'info');
// Invite should be deleted
expect(WorkspaceInvite::find($invite->id))->toBeNull();
// Invite should be marked as accepted
$invite->refresh();
expect($invite->accepted_at)->not->toBeNull();
});
test('decline invite requires authentication', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
]);
$response = $this->post(route('app.invites.decline', $invite));
@ -157,10 +176,11 @@
'setup' => Setup::Completed,
]);
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'invitee@example.com',
'role' => WorkspaceRole::Member,
'workspaces' => [$this->workspace->id],
]);
$response = $this->actingAs($user)->post(route('app.invites.decline', $invite));
@ -169,10 +189,7 @@
$response->assertSessionHas('flash.bannerStyle', 'info');
// Invite should be deleted
expect(WorkspaceInvite::find($invite->id))->toBeNull();
// User should NOT be member of workspace
expect($this->workspace->hasMember($user))->toBeFalse();
expect(Invite::find($invite->id))->toBeNull();
});
test('decline invite fails for wrong email', function () {
@ -181,10 +198,11 @@
'setup' => Setup::Completed,
]);
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'invitee@example.com',
'role' => WorkspaceRole::Member,
'workspaces' => [$this->workspace->id],
]);
$response = $this->actingAs($user)->post(route('app.invites.decline', $invite));
@ -193,5 +211,5 @@
$response->assertSessionHas('flash.bannerStyle', 'danger');
// Invite should NOT be deleted
expect(WorkspaceInvite::find($invite->id))->not->toBeNull();
expect(Invite::find($invite->id))->not->toBeNull();
});

View file

@ -139,7 +139,7 @@ function createApiToken(array $overrides = []): array
$result = createApiToken();
$result['workspace']->subscriptions()->create([
$result['workspace']->account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test_123',
'stripe_status' => 'active',
@ -161,7 +161,7 @@ function createApiToken(array $overrides = []): array
$result = createApiToken();
$result['workspace']->subscriptions()->create([
$result['workspace']->account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_trial_123',
'stripe_status' => 'trialing',

View file

@ -18,7 +18,7 @@
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$plainToken = 'tp_'.Str::random(48);
$this->plainToken = $plainToken;

View file

@ -14,7 +14,7 @@
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$plainToken = 'tp_'.Str::random(48);
$this->plainToken = $plainToken;

View file

@ -11,7 +11,7 @@
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->user->refresh();
});

View file

@ -4,14 +4,23 @@
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->account = Account::factory()->create();
$this->user = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
@ -35,9 +44,9 @@
);
});
test('subscribe redirects to billing index when workspace has active subscription', function () {
$this->workspace->subscriptions()->create([
'type' => Workspace::SUBSCRIPTION_NAME,
test('subscribe redirects to billing index when account has active subscription', function () {
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
@ -56,8 +65,8 @@
});
test('billing index shows billing dashboard', function () {
$this->workspace->subscriptions()->create([
'type' => Workspace::SUBSCRIPTION_NAME,
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
@ -87,7 +96,7 @@
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('billing/Processing', false)
->has('workspaceId')
->has('accountId')
->has('status')
);
});
@ -126,13 +135,16 @@
});
// Authorization tests
test('admin cannot access billing index', function () {
$admin = User::factory()->create(['setup' => Setup::Completed]);
test('non-owner admin cannot access billing index', function () {
$admin = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($admin->id, ['role' => Role::Admin->value]);
$admin->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->subscriptions()->create([
'type' => Workspace::SUBSCRIPTION_NAME,
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
@ -142,12 +154,15 @@
});
test('member cannot access billing index', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$member = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->subscriptions()->create([
'type' => Workspace::SUBSCRIPTION_NAME,
$this->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',

View file

@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Http\Middleware\App\EnsureSubscribed;
use App\Models\Brand;
use App\Models\Plan;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
config(['trypost.self_hosted' => true]);
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('can list brands', function () {
Brand::factory()->count(2)->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->get(route('app.brands.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('brands/Index', false)
->has('brands.data', 2)
);
});
test('can create brand', function () {
$response = $this->actingAs($this->user)->post(route('app.brands.store'), [
'name' => 'My Brand',
]);
$response->assertRedirect(route('app.brands.index'));
$this->assertDatabaseHas('brands', [
'workspace_id' => $this->workspace->id,
'name' => 'My Brand',
]);
});
test('can update brand name', function () {
$brand = Brand::factory()->create(['workspace_id' => $this->workspace->id, 'name' => 'Old Name']);
$response = $this->actingAs($this->user)->put(route('app.brands.update', $brand), [
'name' => 'New Name',
]);
$response->assertRedirect(route('app.brands.index'));
$brand->refresh();
expect($brand->name)->toBe('New Name');
});
test('can delete brand', function () {
$brand = Brand::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->delete(route('app.brands.destroy', $brand));
$response->assertRedirect(route('app.brands.index'));
expect(Brand::find($brand->id))->toBeNull();
});
test('deleting brand nullifies social account brand_id', function () {
$brand = Brand::factory()->create(['workspace_id' => $this->workspace->id]);
$socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'brand_id' => $brand->id,
]);
$this->actingAs($this->user)->delete(route('app.brands.destroy', $brand));
$socialAccount->refresh();
expect($socialAccount->brand_id)->toBeNull();
});
test('cannot create brand beyond plan limit', function () {
config(['trypost.self_hosted' => false]);
$plan = Plan::query()->first() ?? Plan::factory()->create();
$plan->update(['brand_limit' => 0]);
$this->workspace->update(['plan_id' => $plan->id]);
$response = $this->withoutMiddleware(EnsureSubscribed::class)
->actingAs($this->user)
->post(route('app.brands.store'), [
'name' => 'Should Fail',
]);
$response->assertForbidden();
});
test('cannot access brands from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$brand = Brand::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->put(route('app.brands.update', $brand), [
'name' => 'Hacked',
]);
$response->assertForbidden();
});

View file

@ -14,7 +14,7 @@
'setup' => Setup::Completed,
]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->subscriptions()->create([
$this->user->account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_123',
'stripe_status' => 'active',

View file

@ -38,7 +38,7 @@
]);
$pivot = $user->workspaces->first()->pivot;
expect($pivot->role)->toBe(Role::Owner->value);
expect($pivot->role)->toBe(Role::Member->value);
});
test('sets current workspace on user', function () {

View file

@ -237,7 +237,7 @@
$this->app->instance(LinkedInPublisher::class, $publisher);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
(new PublishToSocialPlatform($this->postPlatform))->handle();
@ -255,7 +255,7 @@
$this->app->instance(LinkedInPublisher::class, $publisher);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
(new PublishToSocialPlatform($this->postPlatform))->handle();

View file

@ -4,7 +4,7 @@
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Brand;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use App\Models\Workspace;
@ -14,37 +14,32 @@
$this->plan = Plan::first();
$this->plan->update([
'brand_limit' => 5,
'workspace_limit' => 5,
'member_limit' => 5,
]);
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create([
'user_id' => $this->user->id,
'plan_id' => $this->plan->id,
$this->account = Account::factory()->create(['plan_id' => $this->plan->id]);
$this->user = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $this->account->id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('can create brand within limit', function () {
Brand::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeTrue();
});
test('cannot create brand beyond limit', function () {
Brand::factory()->count(5)->create(['workspace_id' => $this->workspace->id]);
expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeFalse();
});
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();
$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]);
@ -54,18 +49,23 @@
expect($this->user->can('inviteMember', $this->workspace))->toBeFalse();
});
test('self hosted mode bypasses brand limit', function () {
test('self hosted mode bypasses workspace limit', function () {
config(['trypost.self_hosted' => true]);
Brand::factory()->count(10)->create(['workspace_id' => $this->workspace->id]);
Workspace::factory()->count(10)->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
expect($this->user->can('create', [Brand::class, $this->workspace]))->toBeTrue();
expect($this->user->can('create', Workspace::class))->toBeTrue();
});
test('self hosted mode bypasses member limit', function () {
config(['trypost.self_hosted' => true]);
$members = User::factory()->count(10)->create();
$members = User::factory()->count(10)->create([
'account_id' => $this->account->id,
]);
foreach ($members as $member) {
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);

View file

@ -5,18 +5,18 @@
use App\Enums\User\Setup;
use App\Events\SubscriptionCreated;
use App\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create([
'user_id' => $this->user->id,
'stripe_id' => 'cus_test123',
$this->account = Account::factory()->create(['stripe_id' => 'cus_test123']);
$this->user = User::factory()->create([
'account_id' => $this->account->id,
]);
$this->account->update(['owner_id' => $this->user->id]);
$this->listener = new StripeEventListener;
});
@ -33,7 +33,7 @@
'data' => ['object' => ['customer' => 'cus_test123', 'id' => 'sub_123']],
]));
Event::assertDispatched(SubscriptionCreated::class, fn ($e) => $e->workspace->id === $this->workspace->id);
Event::assertDispatched(SubscriptionCreated::class, fn ($e) => $e->account->id === $this->account->id);
});
test('subscription created marks setup as completed when on subscription step', function () {

View file

@ -14,7 +14,7 @@
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});

View file

@ -15,7 +15,7 @@
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});

View file

@ -15,7 +15,7 @@
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});

View file

@ -17,7 +17,7 @@
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->socialAccount = SocialAccount::factory()->create([

View file

@ -14,7 +14,7 @@
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});

View file

@ -11,7 +11,7 @@
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});

View file

@ -4,6 +4,7 @@
use App\Enums\UserWorkspace\Role;
use App\Http\Middleware\Mcp\AuthenticateMcpToken;
use App\Models\Account;
use App\Models\ApiToken;
use App\Models\User;
use App\Models\Workspace;
@ -23,7 +24,7 @@ function createMcpToken(array $overrides = []): array
$user = data_get($overrides, 'user') ?? User::factory()->create();
$workspace = data_get($overrides, 'workspace') ?? Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->syncWithoutDetaching([$user->id => ['role' => Role::Owner->value]]);
$workspace->members()->syncWithoutDetaching([$user->id => ['role' => Role::Member->value]]);
$apiToken = ApiToken::factory()->create([
'workspace_id' => $workspace->id,
@ -139,8 +140,8 @@ function callMiddleware(string $bearerToken = ''): JsonResponse|Response
config(['trypost.self_hosted' => false]);
$result = createMcpToken();
$result['workspace']->subscriptions()->create([
'type' => Workspace::SUBSCRIPTION_NAME,
$result['workspace']->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_test',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
@ -155,8 +156,8 @@ function callMiddleware(string $bearerToken = ''): JsonResponse|Response
config(['trypost.self_hosted' => false]);
$result = createMcpToken();
$result['workspace']->subscriptions()->create([
'type' => Workspace::SUBSCRIPTION_NAME,
$result['workspace']->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_trial',
'stripe_status' => 'trialing',
'stripe_price' => 'price_123',

View file

@ -4,6 +4,7 @@
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
@ -12,7 +13,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -32,10 +33,10 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$workspace->subscriptions()->create([
$user->account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_123',
'stripe_status' => 'active',
@ -52,11 +53,10 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
// Create a subscription with trial
$workspace->subscriptions()->create([
$user->account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_trial_123',
'stripe_status' => 'trialing',
@ -74,7 +74,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -87,11 +87,10 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
// Create an expired trial subscription
$workspace->subscriptions()->create([
$user->account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_expired_trial',
'stripe_status' => 'canceled',
@ -110,10 +109,10 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$workspace->subscriptions()->create([
$user->account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_123',
'stripe_status' => 'canceled',
@ -126,21 +125,32 @@
->assertRedirect(route('app.subscribe'));
});
test('invited member can access workspace when owner has active subscription', function () {
test('member can access workspace when account has active subscription', function () {
config(['trypost.self_hosted' => false]);
$owner = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$account = Account::factory()->create();
$owner = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $account->id,
]);
$account->update(['owner_id' => $owner->id]);
$workspace = Workspace::factory()->create([
'account_id' => $account->id,
'user_id' => $owner->id,
]);
$workspace->members()->attach($owner->id, ['role' => Role::Member->value]);
$workspace->subscriptions()->create([
$account->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_owner_123',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$member = User::factory()->create(['setup' => Setup::Completed]);
$member = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $account->id,
]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $workspace->id]);
@ -149,14 +159,24 @@
->assertOk();
});
test('invited member is redirected to subscribe when owner has no subscription', function () {
test('member is redirected to subscribe when account has no subscription', function () {
config(['trypost.self_hosted' => false]);
$owner = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $owner->id]);
$workspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$account = Account::factory()->create();
$owner = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $account->id,
]);
$account->update(['owner_id' => $owner->id]);
$workspace = Workspace::factory()->create([
'account_id' => $account->id,
'user_id' => $owner->id,
]);
$member = User::factory()->create(['setup' => Setup::Completed]);
$member = User::factory()->create([
'setup' => Setup::Completed,
'account_id' => $account->id,
]);
$workspace->members()->attach($member->id, ['role' => Role::Member->value]);
$member->update(['current_workspace_id' => $workspace->id]);
@ -164,29 +184,3 @@
->get(route('app.calendar'))
->assertRedirect(route('app.subscribe'));
});
test('invited member on own workspace without subscription is redirected to subscribe', function () {
config(['trypost.self_hosted' => false]);
$owner = User::factory()->create(['setup' => Setup::Completed]);
$ownerWorkspace = Workspace::factory()->create(['user_id' => $owner->id]);
$ownerWorkspace->members()->attach($owner->id, ['role' => Role::Owner->value]);
$ownerWorkspace->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_owner_123',
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
$member = User::factory()->create(['setup' => Setup::Completed]);
$ownerWorkspace->members()->attach($member->id, ['role' => Role::Member->value]);
$memberWorkspace = Workspace::factory()->create(['user_id' => $member->id]);
$memberWorkspace->members()->attach($member->id, ['role' => Role::Owner->value]);
$member->update(['current_workspace_id' => $memberWorkspace->id]);
$this->actingAs($member)
->get(route('app.calendar'))
->assertRedirect(route('app.subscribe'));
});

View file

@ -12,7 +12,7 @@
$user = User::factory()->create(['setup' => Setup::Completed]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -25,7 +25,7 @@
$user = User::factory()->create(['setup' => Setup::Role]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -38,7 +38,7 @@
$user = User::factory()->create(['setup' => Setup::Connections]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)
@ -51,7 +51,7 @@
$user = User::factory()->create(['setup' => Setup::Subscription]);
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Owner->value]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$this->actingAs($user)

Some files were not shown because too many files have changed in this diff Show more