diff --git a/.env.example b/.env.example index a82d36de..6f3fc12a 100644 --- a/.env.example +++ b/.env.example @@ -67,7 +67,11 @@ STRIPE_MINI_PRICE_ID_EAP= STRIPE_PRO_PRICE_ID= STRIPE_PRO_PRICE_ID_EAP= STRIPE_MAX_PRICE_ID= +STRIPE_MAX_PRICE_ID_MONTHLY= STRIPE_MAX_PRICE_ID_EAP= +STRIPE_ULTRA_COMP_PRICE_ID= +STRIPE_EXTRA_SEAT_PRICE_ID= +STRIPE_EXTRA_SEAT_PRICE_ID_MONTHLY= STRIPE_FOREVER_PRICE_ID= STRIPE_TRIAL_PRICE_ID= STRIPE_MINI_PAYMENT_LINK= diff --git a/app/Console/Commands/CompUltraSubscription.php b/app/Console/Commands/CompUltraSubscription.php new file mode 100644 index 00000000..170f339f --- /dev/null +++ b/app/Console/Commands/CompUltraSubscription.php @@ -0,0 +1,59 @@ +error('STRIPE_ULTRA_COMP_PRICE_ID is not configured.'); + + return self::FAILURE; + } + + $email = $this->argument('email'); + $user = User::where('email', $email)->first(); + + if (! $user) { + $this->error("User not found: {$email}"); + + return self::FAILURE; + } + + $existingSubscription = $user->subscription('default'); + + if ($existingSubscription && $existingSubscription->active()) { + $currentPlan = 'unknown'; + + try { + $currentPlan = Subscription::fromStripePriceId( + $existingSubscription->items->first()?->stripe_price ?? $existingSubscription->stripe_price + )->name(); + } catch (\Exception) { + } + + $this->error("User already has an active {$currentPlan} subscription. Cancel it first or use swap."); + + return self::FAILURE; + } + + $user->createOrGetStripeCustomer(); + + $user->newSubscription('default', $compedPriceId)->create(); + + $this->info("Comped Ultra subscription created for {$email}."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MarkCompedSubscriptions.php b/app/Console/Commands/MarkCompedSubscriptions.php new file mode 100644 index 00000000..11a67e7e --- /dev/null +++ b/app/Console/Commands/MarkCompedSubscriptions.php @@ -0,0 +1,125 @@ +argument('file'); + + if (! file_exists($path)) { + $this->error("File not found: {$path}"); + + return self::FAILURE; + } + + $emails = $this->parseEmails($path); + + if (empty($emails)) { + $this->error('No valid email addresses found in the file.'); + + return self::FAILURE; + } + + $this->info('Found '.count($emails).' email(s) to process.'); + + $updated = 0; + $skipped = []; + + foreach ($emails as $email) { + $user = User::where('email', $email)->first(); + + if (! $user) { + $skipped[] = "{$email} — user not found"; + + continue; + } + + $subscription = Subscription::where('user_id', $user->id) + ->where('stripe_status', 'active') + ->first(); + + if (! $subscription) { + $skipped[] = "{$email} — no active subscription"; + + continue; + } + + if ($subscription->is_comped) { + $skipped[] = "{$email} — already marked as comped"; + + continue; + } + + $subscription->update(['is_comped' => true]); + $updated++; + $this->info("Marked {$email} as comped (subscription #{$subscription->id})"); + } + + if (count($skipped) > 0) { + $this->warn('Skipped:'); + foreach ($skipped as $reason) { + $this->warn(" - {$reason}"); + } + } + + $this->info("Done. {$updated} subscription(s) marked as comped."); + + return self::SUCCESS; + } + + /** + * Parse email addresses from a CSV file. + * Supports: plain list (one email per line), or CSV with an "email" column header. + * + * @return array + */ + private function parseEmails(string $path): array + { + $handle = fopen($path, 'r'); + + if (! $handle) { + return []; + } + + $emails = []; + $emailColumnIndex = null; + $isFirstRow = true; + + while (($row = fgetcsv($handle)) !== false) { + if ($isFirstRow) { + $isFirstRow = false; + $headers = array_map(fn ($h) => strtolower(trim($h)), $row); + $emailColumnIndex = array_search('email', $headers); + + // If the first row looks like an email itself (no header), treat it as data + if ($emailColumnIndex === false && filter_var(trim($row[0]), FILTER_VALIDATE_EMAIL)) { + $emailColumnIndex = 0; + $emails[] = strtolower(trim($row[0])); + } + + continue; + } + + $value = trim($row[$emailColumnIndex] ?? ''); + + if (filter_var($value, FILTER_VALIDATE_EMAIL)) { + $emails[] = strtolower($value); + } + } + + fclose($handle); + + return array_unique($emails); + } +} diff --git a/app/Console/Commands/SendMaxToUltraAnnouncement.php b/app/Console/Commands/SendMaxToUltraAnnouncement.php new file mode 100644 index 00000000..1515b5a9 --- /dev/null +++ b/app/Console/Commands/SendMaxToUltraAnnouncement.php @@ -0,0 +1,59 @@ +option('dry-run'); + + if ($dryRun) { + $this->info('DRY RUN - No emails will be sent'); + } + + $maxPriceIds = array_filter([ + config('subscriptions.plans.max.stripe_price_id'), + config('subscriptions.plans.max.stripe_price_id_monthly'), + config('subscriptions.plans.max.stripe_price_id_eap'), + config('subscriptions.plans.max.stripe_price_id_discounted'), + ]); + + $users = User::query() + ->whereHas('subscriptions', function ($query) use ($maxPriceIds) { + $query->where('stripe_status', 'active') + ->where('is_comped', false) + ->whereIn('stripe_price', $maxPriceIds); + }) + ->get(); + + $this->info("Found {$users->count()} paying Max subscriber(s)"); + + $sent = 0; + + foreach ($users as $user) { + if ($dryRun) { + $this->line("Would send to: {$user->email}"); + } else { + $user->notify(new MaxToUltraAnnouncement); + $this->line("Sent to: {$user->email}"); + } + + $sent++; + } + + $this->newLine(); + $this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)"); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/SendUltraLicenseHolderPromotion.php b/app/Console/Commands/SendUltraLicenseHolderPromotion.php new file mode 100644 index 00000000..29a03243 --- /dev/null +++ b/app/Console/Commands/SendUltraLicenseHolderPromotion.php @@ -0,0 +1,78 @@ +option('dry-run'); + + if ($dryRun) { + $this->info('DRY RUN - No emails will be sent'); + } + + $legacyLicenses = License::query() + ->whereNull('subscription_item_id') + ->whereHas('user') + ->with('user') + ->get(); + + // Group by user to avoid sending multiple emails to the same person + $userLicenses = $legacyLicenses->groupBy('user_id'); + + $eligible = 0; + $skipped = 0; + + foreach ($userLicenses as $userId => $licenses) { + $user = $licenses->first()->user; + + if (! $user) { + $skipped++; + + continue; + } + + // Skip users who already have an active subscription + $hasActiveSubscription = $user->subscriptions() + ->where('stripe_status', 'active') + ->exists(); + + if ($hasActiveSubscription) { + $this->line("Skipping {$user->email} - already has active subscription"); + $skipped++; + + continue; + } + + $license = $licenses->sortBy('created_at')->first(); + $planName = Subscription::from($license->policy_name)->name(); + + if ($dryRun) { + $this->line("Would send to: {$user->email} ({$planName})"); + } else { + $user->notify(new UltraLicenseHolderPromotion($planName)); + $this->line("Sent to: {$user->email} ({$planName})"); + } + + $eligible++; + } + + $this->newLine(); + $this->info("Found {$eligible} eligible license holder(s)"); + $this->info($dryRun ? "Would send: {$eligible} email(s)" : "Sent: {$eligible} email(s)"); + $this->info("Skipped: {$skipped} user(s)"); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/SendUltraUpgradePromotion.php b/app/Console/Commands/SendUltraUpgradePromotion.php new file mode 100644 index 00000000..4b1fcbfb --- /dev/null +++ b/app/Console/Commands/SendUltraUpgradePromotion.php @@ -0,0 +1,74 @@ +option('dry-run'); + + if ($dryRun) { + $this->info('DRY RUN - No emails will be sent'); + } + + $miniPriceIds = array_filter([ + config('subscriptions.plans.mini.stripe_price_id'), + config('subscriptions.plans.mini.stripe_price_id_eap'), + ]); + + $proPriceIds = array_filter([ + config('subscriptions.plans.pro.stripe_price_id'), + config('subscriptions.plans.pro.stripe_price_id_eap'), + config('subscriptions.plans.pro.stripe_price_id_discounted'), + ]); + + $eligiblePriceIds = array_merge($miniPriceIds, $proPriceIds); + + $users = User::query() + ->whereHas('subscriptions', function ($query) use ($eligiblePriceIds) { + $query->where('stripe_status', 'active') + ->where('is_comped', false) + ->whereIn('stripe_price', $eligiblePriceIds); + }) + ->get(); + + $this->info("Found {$users->count()} eligible subscriber(s)"); + + $sent = 0; + + foreach ($users as $user) { + $priceId = $user->subscriptions() + ->where('stripe_status', 'active') + ->where('is_comped', false) + ->whereIn('stripe_price', $eligiblePriceIds) + ->value('stripe_price'); + + $planName = Subscription::fromStripePriceId($priceId)->name(); + + if ($dryRun) { + $this->line("Would send to: {$user->email} ({$planName})"); + } else { + $user->notify(new UltraUpgradePromotion($planName)); + $this->line("Sent to: {$user->email} ({$planName})"); + } + + $sent++; + } + + $this->newLine(); + $this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)"); + + return Command::SUCCESS; + } +} diff --git a/app/Enums/Subscription.php b/app/Enums/Subscription.php index 29d2a3c8..0b06551b 100644 --- a/app/Enums/Subscription.php +++ b/app/Enums/Subscription.php @@ -14,13 +14,35 @@ enum Subscription: string public static function fromStripeSubscription(\Stripe\Subscription $subscription): self { - $priceId = $subscription->items->first()?->price->id; + // Iterate items, skipping extra seat prices (multi-item subscriptions) + foreach ($subscription->items as $item) { + $priceId = $item->price->id; - if (! $priceId) { - throw new RuntimeException('Could not resolve Stripe price id from subscription object.'); + if (self::isExtraSeatPrice($priceId)) { + continue; + } + + return self::fromStripePriceId($priceId); } - return self::fromStripePriceId($priceId); + throw new RuntimeException('Could not resolve a plan price id from subscription items.'); + } + + public static function isExtraSeatPrice(string $priceId): bool + { + return in_array($priceId, array_filter([ + config('subscriptions.plans.max.stripe_extra_seat_price_id'), + config('subscriptions.plans.max.stripe_extra_seat_price_id_monthly'), + ])); + } + + public static function extraSeatStripePriceId(string $interval): ?string + { + return match ($interval) { + 'year' => config('subscriptions.plans.max.stripe_extra_seat_price_id'), + 'month' => config('subscriptions.plans.max.stripe_extra_seat_price_id_monthly'), + default => null, + }; } public static function fromStripePriceId(string $priceId): self @@ -34,8 +56,10 @@ public static function fromStripePriceId(string $priceId): self config('subscriptions.plans.pro.stripe_price_id_eap') => self::Pro, 'price_1RoZk0AyFo6rlwXqjkLj4hZ0', config('subscriptions.plans.max.stripe_price_id'), + config('subscriptions.plans.max.stripe_price_id_monthly'), config('subscriptions.plans.max.stripe_price_id_discounted'), - config('subscriptions.plans.max.stripe_price_id_eap') => self::Max, + config('subscriptions.plans.max.stripe_price_id_eap'), + config('subscriptions.plans.max.stripe_price_id_comped') => self::Max, default => throw new RuntimeException("Unknown Stripe price id: {$priceId}"), }; } @@ -57,7 +81,7 @@ public function name(): string return config("subscriptions.plans.{$this->value}.name"); } - public function stripePriceId(bool $forceEap = false, bool $discounted = false): string + public function stripePriceId(bool $forceEap = false, bool $discounted = false, string $interval = 'year'): string { // EAP ends June 1st at midnight UTC if (now()->isBefore('2025-06-01 00:00:00') || $forceEap) { @@ -68,6 +92,10 @@ public function stripePriceId(bool $forceEap = false, bool $discounted = false): return config("subscriptions.plans.{$this->value}.stripe_price_id_discounted"); } + if ($interval === 'month') { + return config("subscriptions.plans.{$this->value}.stripe_price_id_monthly"); + } + return config("subscriptions.plans.{$this->value}.stripe_price_id"); } diff --git a/app/Enums/TeamUserRole.php b/app/Enums/TeamUserRole.php new file mode 100644 index 00000000..95fa8f29 --- /dev/null +++ b/app/Enums/TeamUserRole.php @@ -0,0 +1,9 @@ +visible(fn (User $record) => empty($record->stripe_id)), + Actions\Action::make('compUltraSubscription') + ->label('Comp Ultra Subscription') + ->color('warning') + ->icon('heroicon-o-sparkles') + ->modalHeading('Comp Ultra Subscription') + ->modalSubmitActionLabel('Comp Ultra') + ->form(function (User $record): array { + $existingSubscription = $record->subscription('default'); + $hasActiveSubscription = $existingSubscription && $existingSubscription->active(); + + $fields = []; + + if ($hasActiveSubscription) { + $currentPlan = 'their current plan'; + + try { + $currentPlan = Subscription::fromStripePriceId( + $existingSubscription->items->first()?->stripe_price ?? $existingSubscription->stripe_price + )->name(); + } catch (\Exception) { + } + + $fields[] = Placeholder::make('info') + ->label('') + ->content("This user has an active {$currentPlan} subscription. Choose when to switch them to the comped Ultra plan."); + + $fields[] = Radio::make('timing') + ->label('When to switch') + ->options([ + 'now' => 'Immediately — swap now and credit remaining value (swapAndInvoice)', + 'renewal' => 'At renewal — keep current plan until period ends, then switch (swap)', + ]) + ->default('now') + ->required(); + } else { + $fields[] = Placeholder::make('info') + ->label('') + ->content("This will create a free Ultra subscription for {$record->email}. A Stripe customer will be created if one doesn't exist."); + } + + return $fields; + }) + ->action(function (array $data, User $record): void { + $compedPriceId = config('subscriptions.plans.max.stripe_price_id_comped'); + + if (! $compedPriceId) { + Notification::make() + ->danger() + ->title('STRIPE_ULTRA_COMP_PRICE_ID is not configured.') + ->send(); + + return; + } + + $record->createOrGetStripeCustomer(); + + $existingSubscription = $record->subscription('default'); + + if ($existingSubscription && $existingSubscription->active()) { + $timing = $data['timing'] ?? 'now'; + + if ($timing === 'now') { + $existingSubscription->skipTrial()->swapAndInvoice($compedPriceId); + $message = 'Subscription swapped to comped Ultra immediately. Remaining value has been credited.'; + } else { + $existingSubscription->skipTrial()->swap($compedPriceId); + $message = 'Subscription will switch to comped Ultra at the end of the current billing period.'; + } + + Notification::make() + ->success() + ->title('Comped Ultra subscription applied.') + ->body($message) + ->send(); + } else { + $record->newSubscription('default', $compedPriceId)->create(); + + Notification::make() + ->success() + ->title('Comped Ultra subscription created.') + ->body("Ultra subscription created for {$record->email}.") + ->send(); + } + }) + ->visible(function (User $record): bool { + if (! config('subscriptions.plans.max.stripe_price_id_comped')) { + return false; + } + + return ! $record->hasActiveUltraSubscription(); + }), + Actions\Action::make('createAnystackLicense') ->label('Create Anystack License') ->color('gray') diff --git a/app/Http/Controllers/Api/PluginAccessController.php b/app/Http/Controllers/Api/PluginAccessController.php index 9e2a332b..e56d63ad 100644 --- a/app/Http/Controllers/Api/PluginAccessController.php +++ b/app/Http/Controllers/Api/PluginAccessController.php @@ -152,6 +152,45 @@ protected function getAccessiblePlugins(User $user): array } } + // Team members get access to official plugins and owner's purchased plugins + if ($user->isUltraTeamMember()) { + $officialPlugins = Plugin::query() + ->where('type', PluginType::Paid) + ->where('is_official', true) + ->whereNotNull('name') + ->get(['name']); + + foreach ($officialPlugins as $plugin) { + if (! collect($plugins)->contains('name', $plugin->name)) { + $plugins[] = [ + 'name' => $plugin->name, + 'access' => 'team', + ]; + } + } + } + + $teamOwner = $user->getTeamOwner(); + + if ($teamOwner) { + $teamPlugins = $teamOwner->pluginLicenses() + ->active() + ->with('plugin:id,name') + ->get() + ->pluck('plugin') + ->filter() + ->unique('id'); + + foreach ($teamPlugins as $plugin) { + if (! collect($plugins)->contains('name', $plugin->name)) { + $plugins[] = [ + 'name' => $plugin->name, + 'access' => 'team', + ]; + } + } + } + return $plugins; } } diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php index 9e13cd89..67a5e7e8 100644 --- a/app/Http/Controllers/Auth/CustomerAuthController.php +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -2,9 +2,11 @@ namespace App\Http\Controllers\Auth; +use App\Enums\TeamUserStatus; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; use App\Models\Plugin; +use App\Models\TeamUser; use App\Models\User; use App\Services\CartService; use Illuminate\Auth\Passwords\PasswordBroker; @@ -48,6 +50,9 @@ public function register(Request $request): RedirectResponse // Transfer guest cart to user $this->cartService->transferGuestCartToUser($user); + // Check for pending team invitation + $this->acceptPendingTeamInvitation($user); + // Check for pending add-to-cart action $pendingPluginId = session()->pull('pending_add_to_cart'); if ($pendingPluginId) { @@ -79,6 +84,9 @@ public function login(LoginRequest $request): RedirectResponse // Transfer guest cart to user $this->cartService->transferGuestCartToUser($user); + // Check for pending team invitation + $this->acceptPendingTeamInvitation($user); + // Check for pending add-to-cart action $pendingPluginId = session()->pull('pending_add_to_cart'); if ($pendingPluginId) { @@ -157,4 +165,23 @@ function ($user, $password): void { ? to_route('customer.login')->with('status', __($status)) : back()->withErrors(['email' => [__($status)]]); } + + private function acceptPendingTeamInvitation(User $user): void + { + $token = session()->pull('pending_team_invitation_token'); + + if (! $token) { + return; + } + + $teamUser = TeamUser::where('invitation_token', $token) + ->where('email', $user->email) + ->where('status', TeamUserStatus::Pending) + ->first(); + + if ($teamUser) { + $teamUser->accept($user); + session()->flash('success', "You've joined {$teamUser->team->name}!"); + } + } } diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php index 82fd3743..1f891091 100644 --- a/app/Http/Controllers/CartController.php +++ b/app/Http/Controllers/CartController.php @@ -2,8 +2,10 @@ namespace App\Http\Controllers; +use App\Models\Cart; use App\Models\Plugin; use App\Models\PluginBundle; +use App\Models\PluginLicense; use App\Models\Product; use App\Services\CartService; use Illuminate\Http\JsonResponse; @@ -266,6 +268,11 @@ public function checkout(Request $request): RedirectResponse // Refresh prices $this->cartService->refreshPrices($cart); + // If total is $0, skip Stripe entirely and create licenses directly + if ($cart->getSubtotal() === 0) { + return $this->processFreeCheckout($cart, $user); + } + try { $session = $this->createMultiItemCheckoutSession($cart, $user); @@ -287,6 +294,22 @@ public function checkout(Request $request): RedirectResponse public function success(Request $request): View|RedirectResponse { + if ($request->query('free')) { + $user = Auth::user(); + + $cart = Cart::where('user_id', $user->id) + ->whereNotNull('completed_at') + ->latest('completed_at') + ->with('items.plugin', 'items.pluginBundle.plugins', 'items.product') + ->first(); + + return view('cart.success', [ + 'sessionId' => null, + 'isFreeCheckout' => true, + 'cart' => $cart, + ]); + } + $sessionId = $request->query('session_id'); // Validate session ID exists and looks like a real Stripe session ID @@ -299,6 +322,51 @@ public function success(Request $request): View|RedirectResponse return view('cart.success', [ 'sessionId' => $sessionId, + 'isFreeCheckout' => false, + 'cart' => null, + ]); + } + + protected function processFreeCheckout(Cart $cart, $user): RedirectResponse + { + $cart->load('items.plugin', 'items.pluginBundle.plugins', 'items.product'); + + foreach ($cart->items as $item) { + if ($item->isBundle()) { + foreach ($item->pluginBundle->plugins as $plugin) { + $this->createFreePluginLicense($user, $plugin); + } + } elseif (! $item->isProduct() && $item->plugin) { + $this->createFreePluginLicense($user, $item->plugin); + } + } + + $cart->markAsCompleted(); + + $user->getPluginLicenseKey(); + + Log::info('Free checkout completed', [ + 'cart_id' => $cart->id, + 'user_id' => $user->id, + 'item_count' => $cart->items->count(), + ]); + + return to_route('cart.success', ['free' => 1]); + } + + protected function createFreePluginLicense($user, Plugin $plugin): void + { + if ($user->pluginLicenses()->forPlugin($plugin)->active()->exists()) { + return; + } + + PluginLicense::create([ + 'user_id' => $user->id, + 'plugin_id' => $plugin->id, + 'price_paid' => 0, + 'currency' => 'USD', + 'is_grandfathered' => false, + 'purchased_at' => now(), ]); } diff --git a/app/Http/Controllers/CustomerLicenseController.php b/app/Http/Controllers/CustomerLicenseController.php index fd365ac9..4a8f0de4 100644 --- a/app/Http/Controllers/CustomerLicenseController.php +++ b/app/Http/Controllers/CustomerLicenseController.php @@ -27,18 +27,44 @@ public function index(): View $licenseCount = $user->licenses()->count(); $isEapCustomer = $user->isEapCustomer(); $activeSubscription = $user->subscription(); - $pluginLicenseCount = $user->pluginLicenses()->count(); + $ownPluginIds = $user->pluginLicenses()->pluck('plugin_id'); + $teamPluginCount = 0; + $teamMembership = $user->activeTeamMembership(); + + if ($teamMembership) { + $teamPluginCount = $teamMembership->team->owner + ->pluginLicenses() + ->active() + ->whereNotIn('plugin_id', $ownPluginIds) + ->distinct('plugin_id') + ->count('plugin_id'); + } + + $pluginLicenseCount = $ownPluginIds->count() + $teamPluginCount; // Get subscription plan name $subscriptionName = null; if ($activeSubscription) { - if ($activeSubscription->stripe_price) { - try { - $subscriptionName = Subscription::fromStripePriceId($activeSubscription->stripe_price)->name(); - } catch (\RuntimeException) { + try { + // On multi-item subscriptions, stripe_price may be null. + // Find the plan price from subscription items, skipping extra seat prices. + $planPriceId = $activeSubscription->stripe_price; + + if (! $planPriceId) { + foreach ($activeSubscription->items as $item) { + if (! Subscription::isExtraSeatPrice($item->stripe_price)) { + $planPriceId = $item->stripe_price; + break; + } + } + } + + if ($planPriceId) { + $subscriptionName = Subscription::fromStripePriceId($planPriceId)->name(); + } else { $subscriptionName = ucfirst($activeSubscription->type); } - } else { + } catch (\RuntimeException) { $subscriptionName = ucfirst($activeSubscription->type); } } @@ -70,6 +96,15 @@ public function index(): View $developerAccount = $user->developerAccount; + // Team info + $ownedTeam = $user->ownedTeam; + $hasTeam = $ownedTeam !== null; + $teamName = $ownedTeam?->name; + $teamMemberCount = $ownedTeam?->activeUserCount() ?? 0; + $teamPendingCount = $ownedTeam?->pendingInvitations()->count() ?? 0; + $hasMaxAccess = $user->hasActiveUltraSubscription(); + $showUltraUpsell = ! $hasMaxAccess && ($licenseCount > 0 || $activeSubscription); + return view('customer.dashboard', compact( 'licenseCount', 'isEapCustomer', @@ -80,7 +115,13 @@ public function index(): View 'connectedAccountsCount', 'connectedAccountsDescription', 'totalPurchases', - 'developerAccount' + 'developerAccount', + 'hasTeam', + 'teamName', + 'teamMemberCount', + 'teamPendingCount', + 'hasMaxAccess', + 'showUltraUpsell' )); } diff --git a/app/Http/Controllers/CustomerPurchasedPluginsController.php b/app/Http/Controllers/CustomerPurchasedPluginsController.php index 35a53669..53d16bee 100644 --- a/app/Http/Controllers/CustomerPurchasedPluginsController.php +++ b/app/Http/Controllers/CustomerPurchasedPluginsController.php @@ -23,6 +23,28 @@ public function index(): View ->orderBy('purchased_at', 'desc') ->get(); - return view('customer.purchased-plugins.index', compact('pluginLicenses', 'pluginLicenseKey')); + // Team plugins for team members + $teamPlugins = collect(); + $teamOwnerName = null; + $teamMembership = $user->activeTeamMembership(); + + if ($teamMembership) { + $teamOwner = $teamMembership->team->owner; + $teamOwnerName = $teamOwner->display_name; + $teamPlugins = $teamOwner->pluginLicenses() + ->active() + ->with('plugin') + ->get() + ->pluck('plugin') + ->filter() + ->unique('id'); + } + + return view('customer.purchased-plugins.index', compact( + 'pluginLicenses', + 'pluginLicenseKey', + 'teamPlugins', + 'teamOwnerName', + )); } } diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index de99b114..8298de3b 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -164,11 +164,11 @@ public function requestClaudePluginsAccess(): RedirectResponse return back()->with('error', 'Please connect your GitHub account first.'); } - // Check if user has a Plugin Dev Kit license + // Check if user has a Plugin Dev Kit license or is an Ultra team member $pluginDevKit = Product::where('slug', 'plugin-dev-kit')->first(); - if (! $pluginDevKit || ! $user->hasProductLicense($pluginDevKit)) { - return back()->with('error', 'You need a Plugin Dev Kit license to access the claude-code repository.'); + if (! $user->isUltraTeamMember() && (! $pluginDevKit || ! $user->hasProductLicense($pluginDevKit))) { + return back()->with('error', 'You need a Plugin Dev Kit license or Ultra team membership to access the claude-code repository.'); } $github = GitHubOAuth::make(); diff --git a/app/Http/Controllers/LicenseRenewalController.php b/app/Http/Controllers/LicenseRenewalController.php index 88f77b41..4076bdf2 100644 --- a/app/Http/Controllers/LicenseRenewalController.php +++ b/app/Http/Controllers/LicenseRenewalController.php @@ -18,37 +18,31 @@ public function show(Request $request, string $licenseKey): View ->with('user') ->firstOrFail(); - // Ensure the user owns this license (if they're logged in) - if (auth()->check() && $license->user_id !== auth()->id()) { + if ($license->user_id !== auth()->id()) { abort(403, 'You can only renew your own licenses.'); } - $subscriptionType = Subscription::from($license->policy_name); - $isNearExpiry = $license->expires_at->isPast() || $license->expires_at->diffInDays(now()) <= 30; - return view('license.renewal', [ 'license' => $license, - 'subscriptionType' => $subscriptionType, - 'isNearExpiry' => $isNearExpiry, - 'stripePriceId' => $subscriptionType->stripePriceId(forceEap: true), // Will use EAP pricing - 'stripePublishableKey' => config('cashier.key'), ]); } public function createCheckoutSession(Request $request, string $licenseKey) { + $request->validate([ + 'billing_period' => ['required', 'in:yearly,monthly'], + ]); + $license = License::where('key', $licenseKey) ->whereNull('subscription_item_id') // Only legacy licenses ->whereNotNull('expires_at') // Must have an expiry date ->with('user') ->firstOrFail(); - // Ensure the user owns this license (if they're logged in) - if (auth()->check() && $license->user_id !== auth()->id()) { + if ($license->user_id !== auth()->id()) { abort(403, 'You can only renew your own licenses.'); } - $subscriptionType = Subscription::from($license->policy_name); $user = $license->user; // Ensure the user has a Stripe customer ID @@ -56,27 +50,33 @@ public function createCheckoutSession(Request $request, string $licenseKey) $user->createAsStripeCustomer(); } + // Always upgrade to Ultra (Max) - EAP yearly or standard monthly + $ultra = Subscription::Max; + $priceId = $request->billing_period === 'monthly' + ? $ultra->stripePriceId(interval: 'month') + : $ultra->stripePriceId(forceEap: true); + // Create Stripe checkout session $stripe = new StripeClient(config('cashier.secret')); $checkoutSession = $stripe->checkout->sessions->create([ 'payment_method_types' => ['card'], 'line_items' => [[ - 'price' => $subscriptionType->stripePriceId(forceEap: true), // Uses EAP pricing + 'price' => $priceId, 'quantity' => 1, ]], 'mode' => 'subscription', 'success_url' => route('license.renewal.success', ['license' => $licenseKey]).'?session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('license.renewal', ['license' => $licenseKey]), - 'customer' => $user->stripe_id, // Use existing customer ID + 'customer' => $user->stripe_id, 'customer_update' => [ - 'name' => 'auto', // Allow Stripe to update customer name for tax ID collection - 'address' => 'auto', // Allow Stripe to update customer address for tax ID collection + 'name' => 'auto', + 'address' => 'auto', ], 'metadata' => [ 'license_key' => $licenseKey, 'license_id' => $license->id, - 'renewal' => 'true', // Flag this as a renewal, not a new purchase + 'renewal' => 'true', ], 'consent_collection' => [ 'terms_of_service' => 'required', diff --git a/app/Http/Controllers/TeamController.php b/app/Http/Controllers/TeamController.php new file mode 100644 index 00000000..fac09023 --- /dev/null +++ b/app/Http/Controllers/TeamController.php @@ -0,0 +1,67 @@ +middleware('auth'); + } + + public function index(): View + { + $user = Auth::user(); + $team = $user->ownedTeam; + $membership = $user->activeTeamMembership(); + + return view('customer.team.index', compact('team', 'membership')); + } + + public function store(Request $request): RedirectResponse + { + $user = Auth::user(); + + if (! $user->hasActiveUltraSubscription()) { + return back()->with('error', 'You need an active Ultra subscription to create a team.'); + } + + if ($user->ownedTeam) { + return back()->with('error', 'You already have a team.'); + } + + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $user->ownedTeam()->create([ + 'name' => $request->name, + ]); + + return to_route('customer.team.index') + ->with('success', 'Team created successfully!'); + } + + public function update(Request $request): RedirectResponse + { + $user = Auth::user(); + $team = $user->ownedTeam; + + if (! $team) { + return back()->with('error', 'You do not own a team.'); + } + + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $team->update(['name' => $request->name]); + + return back()->with('success', 'Team name updated.'); + } +} diff --git a/app/Http/Controllers/TeamUserController.php b/app/Http/Controllers/TeamUserController.php new file mode 100644 index 00000000..40ae2ba4 --- /dev/null +++ b/app/Http/Controllers/TeamUserController.php @@ -0,0 +1,148 @@ +middleware('auth')->except('accept'); + } + + public function invite(InviteTeamUserRequest $request): RedirectResponse + { + $user = Auth::user(); + $team = $user->ownedTeam; + + if (! $team) { + return back()->with('error', 'You do not have a team.'); + } + + if ($team->is_suspended) { + return back()->with('error', 'Your team is currently suspended.'); + } + + // Rate limit: 5 invites per minute per team + $rateLimitKey = "team-invite:{$team->id}"; + if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) { + return back()->with('error', 'Too many invitations sent. Please wait a moment.'); + } + RateLimiter::hit($rateLimitKey, 60); + + $email = $request->validated()['email']; + + // Check for duplicate (active or pending) + $existingMember = $team->users() + ->where('email', $email) + ->whereIn('status', [TeamUserStatus::Pending, TeamUserStatus::Active]) + ->first(); + + if ($existingMember) { + return back()->with('error', 'This email has already been invited or is an active member.'); + } + + if ($team->isOverIncludedLimit()) { + return back()->with('show_add_seats', true); + } + + $member = $team->users()->create([ + 'email' => $email, + 'invitation_token' => bin2hex(random_bytes(32)), + 'invited_at' => now(), + ]); + + Notification::route('mail', $email) + ->notify(new TeamInvitation($member)); + + return back()->with('success', "Invitation sent to {$email}."); + } + + public function remove(TeamUser $teamUser): RedirectResponse + { + $user = Auth::user(); + + if (! $user->ownedTeam || $teamUser->team_id !== $user->ownedTeam->id) { + return back()->with('error', 'You are not authorized to remove this member.'); + } + + $teamUser->remove(); + + Notification::route('mail', $teamUser->email) + ->notify(new TeamUserRemoved($teamUser)); + + if ($teamUser->user_id) { + dispatch(new RevokeTeamUserAccessJob($teamUser->user_id)); + } + + return back()->with('success', "{$teamUser->email} has been removed from the team."); + } + + public function resend(TeamUser $teamUser): RedirectResponse + { + $user = Auth::user(); + + if (! $user->ownedTeam || $teamUser->team_id !== $user->ownedTeam->id) { + return back()->with('error', 'You are not authorized to resend this invitation.'); + } + + if (! $teamUser->isPending()) { + return back()->with('error', 'This invitation cannot be resent.'); + } + + // Rate limit: 1 resend per minute per member + $rateLimitKey = "team-resend:{$teamUser->id}"; + if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) { + return back()->with('error', 'Please wait before resending this invitation.'); + } + RateLimiter::hit($rateLimitKey, 60); + + Notification::route('mail', $teamUser->email) + ->notify(new TeamInvitation($teamUser)); + + return back()->with('success', "Invitation resent to {$teamUser->email}."); + } + + public function accept(string $token): RedirectResponse + { + $teamUser = TeamUser::where('invitation_token', $token) + ->where('status', TeamUserStatus::Pending) + ->first(); + + if (! $teamUser) { + return to_route('dashboard') + ->with('error', 'This invitation is invalid or has already been used.'); + } + + $user = Auth::user(); + + if ($user) { + // Authenticated user + if (strtolower($user->email) !== strtolower($teamUser->email)) { + return to_route('dashboard') + ->with('error', 'This invitation was sent to a different email address.'); + } + + $teamUser->accept($user); + + return to_route('dashboard') + ->with('success', "You've joined {$teamUser->team->name}!"); + } + + // Not authenticated — store token in session and redirect to login + session(['pending_team_invitation_token' => $token]); + + return to_route('customer.login') + ->with('message', 'Please log in or register to accept your team invitation.'); + } +} diff --git a/app/Http/Requests/InviteTeamUserRequest.php b/app/Http/Requests/InviteTeamUserRequest.php new file mode 100644 index 00000000..52e2f064 --- /dev/null +++ b/app/Http/Requests/InviteTeamUserRequest.php @@ -0,0 +1,23 @@ +> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + ]; + } +} diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index 12b5dcf6..3f5316f2 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -29,6 +29,7 @@ use Laravel\Cashier\Cashier; use Laravel\Cashier\SubscriptionItem; use Stripe\Invoice; +use Stripe\StripeObject; use UnexpectedValueException; class HandleInvoicePaidJob implements ShouldQueue @@ -49,13 +50,22 @@ public function handle(): void match ($this->invoice->billing_reason) { Invoice::BILLING_REASON_SUBSCRIPTION_CREATE => $this->handleSubscriptionCreated(), - Invoice::BILLING_REASON_SUBSCRIPTION_UPDATE => null, // TODO: Handle subscription update + Invoice::BILLING_REASON_SUBSCRIPTION_UPDATE => $this->handleSubscriptionUpdate(), Invoice::BILLING_REASON_SUBSCRIPTION_CYCLE => $this->handleSubscriptionRenewal(), Invoice::BILLING_REASON_MANUAL => $this->handleManualInvoice(), default => null, }; } + private function handleSubscriptionUpdate(): void + { + Log::info('HandleInvoicePaidJob: subscription update invoice received, no license action needed.', [ + 'invoice_id' => $this->invoice->id, + ]); + + $this->updateSubscriptionCompedStatus(); + } + private function handleSubscriptionCreated(): void { // Get the subscription to check for renewal metadata @@ -68,12 +78,14 @@ private function handleSubscriptionCreated(): void if ($isRenewal && $licenseKey && $licenseId) { $this->handleLegacyLicenseRenewal($subscription, $licenseKey, $licenseId); + $this->updateSubscriptionCompedStatus(); return; } // Normal flow - create a new license $this->createLicense(); + $this->updateSubscriptionCompedStatus(); } private function handleLegacyLicenseRenewal($subscription, string $licenseKey, string $licenseId): void @@ -100,7 +112,7 @@ private function handleLegacyLicenseRenewal($subscription, string $licenseKey, s } // Get the subscription item - if (blank($subscriptionItemId = $this->invoice->lines->first()->subscription_item)) { + if (blank($subscriptionItemId = $this->findPlanLineItem()->subscription_item)) { throw new UnexpectedValueException('Failed to retrieve the Stripe subscription item id from invoice lines.'); } @@ -134,10 +146,10 @@ private function createLicense(): void Sleep::sleep(10); // Assert the invoice line item is for a price_id that relates to a license plan. - $plan = Subscription::fromStripePriceId($this->invoice->lines->first()->price->id); + $plan = Subscription::fromStripePriceId($this->findPlanLineItem()->price->id); // Assert the invoice line item relates to a subscription and has a subscription item id. - if (blank($subscriptionItemId = $this->invoice->lines->first()->subscription_item)) { + if (blank($subscriptionItemId = $this->findPlanLineItem()->subscription_item)) { throw new UnexpectedValueException('Failed to retrieve the Stripe subscription item id from invoice lines.'); } @@ -163,7 +175,7 @@ private function createLicense(): void private function handleSubscriptionRenewal(): void { // Get the subscription item ID from the invoice line - if (blank($subscriptionItemId = $this->invoice->lines->first()->subscription_item)) { + if (blank($subscriptionItemId = $this->findPlanLineItem()->subscription_item)) { throw new UnexpectedValueException('Failed to retrieve the Stripe subscription item id from invoice lines.'); } @@ -197,6 +209,8 @@ private function handleSubscriptionRenewal(): void 'subscription_id' => $this->invoice->subscription, 'invoice_id' => $this->invoice->id, ]); + + $this->updateSubscriptionCompedStatus(); } private function handleManualInvoice(): void @@ -609,6 +623,42 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun return $license; } + /** + * Mark the local Cashier subscription as comped if the invoice total is zero. + */ + private function updateSubscriptionCompedStatus(): void + { + if (! $this->invoice->subscription) { + return; + } + + $subscription = \Laravel\Cashier\Subscription::where('stripe_id', $this->invoice->subscription)->first(); + + if ($subscription) { + $invoiceTotal = $this->invoice->total ?? 0; + + $subscription->update([ + 'is_comped' => $invoiceTotal <= 0, + ]); + } + } + + /** + * Find the plan line item from invoice lines, filtering out extra seat price items. + */ + private function findPlanLineItem(): ?StripeObject + { + foreach ($this->invoice->lines->data as $line) { + if ($line->price && Subscription::isExtraSeatPrice($line->price->id)) { + continue; + } + + return $line; + } + + return null; + } + private function sendDeveloperSaleNotifications(string $invoiceId): void { $payouts = PluginPayout::query() diff --git a/app/Jobs/RevokeTeamUserAccessJob.php b/app/Jobs/RevokeTeamUserAccessJob.php new file mode 100644 index 00000000..702f1bf0 --- /dev/null +++ b/app/Jobs/RevokeTeamUserAccessJob.php @@ -0,0 +1,61 @@ +userId); + + if (! $user) { + return; + } + + $pluginDevKit = Product::where('slug', 'plugin-dev-kit')->first(); + + if (! $pluginDevKit) { + return; + } + + // If user still has access via direct license or another team, skip + if ($user->productLicenses()->forProduct($pluginDevKit)->exists()) { + return; + } + + if ($user->isUltraTeamMember()) { + return; + } + + if (! $user->github_username || ! $user->claude_plugins_repo_access_granted_at) { + return; + } + + $github = GitHubOAuth::make(); + $github->removeFromClaudePluginsRepo($user->github_username); + + $user->update(['claude_plugins_repo_access_granted_at' => null]); + + Log::info('Revoked claude-plugins repo access for removed team member', [ + 'user_id' => $user->id, + ]); + } +} diff --git a/app/Jobs/SuspendTeamJob.php b/app/Jobs/SuspendTeamJob.php new file mode 100644 index 00000000..7b59451e --- /dev/null +++ b/app/Jobs/SuspendTeamJob.php @@ -0,0 +1,51 @@ +userId); + + if (! $user) { + return; + } + + $team = $user->ownedTeam; + + if (! $team) { + return; + } + + $team->suspend(); + + Log::info('Team suspended due to subscription change', [ + 'team_id' => $team->id, + 'user_id' => $user->id, + ]); + + // Revoke access for all active members + $team->activeUsers() + ->whereNotNull('user_id') + ->each(function ($member): void { + dispatch(new RevokeTeamUserAccessJob($member->user_id)); + }); + } +} diff --git a/app/Jobs/UnsuspendTeamJob.php b/app/Jobs/UnsuspendTeamJob.php new file mode 100644 index 00000000..fe5a2158 --- /dev/null +++ b/app/Jobs/UnsuspendTeamJob.php @@ -0,0 +1,48 @@ +userId); + + if (! $user) { + return; + } + + $team = $user->ownedTeam; + + if (! $team || ! $team->is_suspended) { + return; + } + + if (! $user->hasMaxAccess()) { + return; + } + + $team->unsuspend(); + + Log::info('Team unsuspended due to subscription reactivation', [ + 'team_id' => $team->id, + 'user_id' => $user->id, + ]); + } +} diff --git a/app/Listeners/StripeWebhookReceivedListener.php b/app/Listeners/StripeWebhookReceivedListener.php index b8274812..7544b010 100644 --- a/app/Listeners/StripeWebhookReceivedListener.php +++ b/app/Listeners/StripeWebhookReceivedListener.php @@ -5,6 +5,8 @@ use App\Jobs\CreateUserFromStripeCustomer; use App\Jobs\HandleInvoicePaidJob; use App\Jobs\RemoveDiscordMaxRoleJob; +use App\Jobs\SuspendTeamJob; +use App\Jobs\UnsuspendTeamJob; use App\Models\User; use App\Notifications\SubscriptionCancelled; use Exception; @@ -73,6 +75,8 @@ private function handleSubscriptionDeleted(WebhookReceived $event): void } $this->removeDiscordRoleIfNoMaxLicense($user); + + dispatch(new SuspendTeamJob($user->id)); } private function handleSubscriptionUpdated(WebhookReceived $event): void @@ -96,6 +100,12 @@ private function handleSubscriptionUpdated(WebhookReceived $event): void if (in_array($status, ['canceled', 'unpaid', 'past_due', 'incomplete_expired'])) { $this->removeDiscordRoleIfNoMaxLicense($user); + dispatch(new SuspendTeamJob($user->id)); + } + + // Detect reactivation: status changed to active from a non-active state + if ($status === 'active' && isset($previousAttributes['status'])) { + dispatch(new UnsuspendTeamJob($user->id)); } } diff --git a/app/Livewire/Customer/Dashboard.php b/app/Livewire/Customer/Dashboard.php index 2063320e..9818a50f 100644 --- a/app/Livewire/Customer/Dashboard.php +++ b/app/Livewire/Customer/Dashboard.php @@ -3,6 +3,7 @@ namespace App\Livewire\Customer; use App\Enums\Subscription; +use App\Models\Team; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -50,6 +51,24 @@ public function subscriptionName(): ?string return ucfirst($subscription->type); } + #[Computed] + public function hasUltraSubscription(): bool + { + return auth()->user()->hasActiveUltraSubscription(); + } + + #[Computed] + public function ownedTeam(): ?Team + { + return auth()->user()->ownedTeam; + } + + #[Computed] + public function teamMemberCount(): int + { + return $this->ownedTeam?->activeUserCount() ?? 0; + } + #[Computed] public function pluginLicenseCount(): int { diff --git a/app/Livewire/Customer/Plugins/Create.php b/app/Livewire/Customer/Plugins/Create.php index f4612445..deddedb0 100644 --- a/app/Livewire/Customer/Plugins/Create.php +++ b/app/Livewire/Customer/Plugins/Create.php @@ -9,7 +9,9 @@ use App\Notifications\PluginSubmitted; use App\Services\GitHubUserService; use App\Services\PluginSyncService; +use Illuminate\Support\Facades\Cache; use Laravel\Pennant\Feature; +use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; @@ -20,25 +22,57 @@ class Create extends Component { public string $pluginType = 'free'; + public string $selectedOwner = ''; + public string $repository = ''; - /** @var array */ + /** @var array */ public array $repositories = []; public bool $loadingRepos = false; public bool $reposLoaded = false; + #[Computed] + public function owners(): array + { + return collect($this->repositories) + ->pluck('owner') + ->unique() + ->sort(SORT_NATURAL | SORT_FLAG_CASE) + ->values() + ->toArray(); + } + + #[Computed] + public function ownerRepositories(): array + { + if ($this->selectedOwner === '') { + return []; + } + + return collect($this->repositories) + ->where('owner', $this->selectedOwner) + ->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE) + ->values() + ->toArray(); + } + + public function updatedSelectedOwner(): void + { + $this->repository = ''; + } + public function mount(): void { if (auth()->user()->github_id) { - $this->loadRepositories(); + $this->loadingRepos = true; } } public function loadRepositories(): void { - if ($this->loadingRepos || $this->reposLoaded) { + if ($this->reposLoaded) { return; } @@ -48,14 +82,23 @@ public function loadRepositories(): void $user = auth()->user(); if ($user->hasGitHubToken()) { - $githubService = GitHubUserService::for($user); - $this->repositories = $githubService->getRepositories() - ->map(fn ($repo) => [ - 'id' => $repo['id'], - 'full_name' => $repo['full_name'], - 'private' => $repo['private'] ?? false, - ]) - ->toArray(); + $cacheKey = "github_repos_{$user->id}"; + + $repos = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($user) { + $githubService = GitHubUserService::for($user); + + return $githubService->getRepositories() + ->map(fn ($repo) => [ + 'id' => $repo['id'], + 'full_name' => $repo['full_name'], + 'name' => $repo['name'], + 'owner' => explode('/', $repo['full_name'])[0], + 'private' => $repo['private'] ?? false, + ]) + ->all(); + }); + + $this->repositories = collect($repos)->values()->all(); } $this->reposLoaded = true; @@ -101,15 +144,38 @@ function ($attribute, $value, $fail): void { return; } + $repository = trim($this->repository, '/'); + $repositoryUrl = 'https://github.com/'.$repository; + [$owner, $repo] = explode('/', $repository); + + // Check composer.json and namespace availability before creating the plugin + $githubService = GitHubUserService::for($user); + $composerJson = $githubService->getComposerJson($owner, $repo); + + if (! $composerJson || empty($composerJson['name'])) { + session()->flash('error', 'Could not find a valid composer.json in the repository. Please ensure your repository contains a composer.json with a valid package name.'); + + return; + } + + $packageName = $composerJson['name']; + $namespace = explode('/', $packageName)[0] ?? null; + + if ($namespace && ! Plugin::isNamespaceAvailableForUser($namespace, $user->id)) { + $errorMessage = Plugin::isReservedNamespace($namespace) + ? "The namespace '{$namespace}' is reserved and cannot be used for plugin submissions." + : "The namespace '{$namespace}' is already claimed by another user. You cannot submit plugins under this namespace."; + + session()->flash('error', $errorMessage); + + return; + } + $developerAccountId = null; if ($this->pluginType === 'paid' && $user->developerAccount) { $developerAccountId = $user->developerAccount->id; } - $repository = trim($this->repository, '/'); - $repositoryUrl = 'https://github.com/'.$repository; - [$owner, $repo] = explode('/', $repository); - $plugin = $user->plugins()->create([ 'repository_url' => $repositoryUrl, 'type' => $this->pluginType, @@ -121,7 +187,6 @@ function ($attribute, $value, $fail): void { $webhookInstalled = false; if ($user->hasGitHubToken()) { - $githubService = GitHubUserService::for($user); $webhookResult = $githubService->createWebhook( $owner, $repo, @@ -145,19 +210,6 @@ function ($attribute, $value, $fail): void { return; } - $namespace = $plugin->getVendorNamespace(); - if ($namespace && ! Plugin::isNamespaceAvailableForUser($namespace, $user->id)) { - $plugin->delete(); - - $errorMessage = Plugin::isReservedNamespace($namespace) - ? "The namespace '{$namespace}' is reserved and cannot be used for plugin submissions." - : "The namespace '{$namespace}' is already claimed by another user. You cannot submit plugins under this namespace."; - - session()->flash('error', $errorMessage); - - return; - } - $user->notify(new PluginSubmitted($plugin)); $successMessage = 'Your plugin has been submitted for review!'; diff --git a/app/Livewire/Customer/PurchasedPlugins/Index.php b/app/Livewire/Customer/PurchasedPlugins/Index.php index fbec8e25..984d81e3 100644 --- a/app/Livewire/Customer/PurchasedPlugins/Index.php +++ b/app/Livewire/Customer/PurchasedPlugins/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\Customer\PurchasedPlugins; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as SupportCollection; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -27,6 +28,32 @@ public function pluginLicenseKey(): string return auth()->user()->getPluginLicenseKey(); } + #[Computed] + public function teamPlugins(): SupportCollection + { + $membership = auth()->user()->activeTeamMembership(); + + if (! $membership) { + return collect(); + } + + return $membership->team->owner->pluginLicenses() + ->active() + ->with('plugin') + ->get() + ->pluck('plugin') + ->filter() + ->unique('id'); + } + + #[Computed] + public function teamOwnerName(): ?string + { + $membership = auth()->user()->activeTeamMembership(); + + return $membership?->team->owner->display_name; + } + public function rotateKey(): void { auth()->user()->regeneratePluginLicenseKey(); diff --git a/app/Livewire/MobilePricing.php b/app/Livewire/MobilePricing.php index dbf437e6..4d1b73cf 100644 --- a/app/Livewire/MobilePricing.php +++ b/app/Livewire/MobilePricing.php @@ -15,13 +15,12 @@ class MobilePricing extends Component { - #[Locked] - public bool $discounted = false; + public string $interval = 'month'; #[Locked] public $user; - public function mount() + public function mount(): void { if (request()->has('email')) { $this->user = $this->findOrCreateUser(request()->query('email')); @@ -53,16 +52,12 @@ public function createCheckoutSession(?string $plan, ?User $user = null) $user = $user?->exists ? $user : Auth::user(); if (! $user) { - // TODO: return a flash message or notification to the user that there - // was an error. Log::error('Failed to create checkout session. User does not exist and user is not authenticated.'); return; } if (! ($subscription = Subscription::tryFrom($plan))) { - // TODO: return a flash message or notification to the user that there - // was an error. Log::error('Failed to create checkout session. Invalid subscription plan name provided.'); return; @@ -71,7 +66,7 @@ public function createCheckoutSession(?string $plan, ?User $user = null) $user->createOrGetStripeCustomer(); $checkout = $user - ->newSubscription('default', $subscription->stripePriceId(discounted: $this->discounted)) + ->newSubscription('default', $subscription->stripePriceId(interval: $this->interval)) ->allowPromotionCodes() ->checkout([ 'success_url' => $this->successUrl(), @@ -91,6 +86,31 @@ public function createCheckoutSession(?string $plan, ?User $user = null) return redirect($checkout->url); } + public function upgradeSubscription(): mixed + { + $user = Auth::user(); + + if (! $user) { + Log::error('Failed to upgrade subscription. User is not authenticated.'); + + return null; + } + + $subscription = $user->subscription('default'); + + if (! $subscription || ! $subscription->active()) { + Log::error('Failed to upgrade subscription. No active subscription found.'); + + return null; + } + + $newPriceId = Subscription::Max->stripePriceId(interval: $this->interval); + + $subscription->skipTrial()->swapAndInvoice($newPriceId); + + return redirect(route('customer.dashboard'))->with('success', 'Your subscription has been upgraded to Ultra!'); + } + private function findOrCreateUser(string $email): User { Validator::validate(['email' => $email], [ @@ -118,6 +138,31 @@ private function successUrl(): string public function render() { - return view('livewire.mobile-pricing'); + $hasExistingSubscription = false; + $currentPlanName = null; + $isAlreadyUltra = false; + + if ($user = Auth::user()) { + $subscription = $user->subscription('default'); + + if ($subscription && $subscription->active()) { + $hasExistingSubscription = true; + $isAlreadyUltra = $user->hasActiveUltraSubscription(); + + try { + $currentPlanName = Subscription::fromStripePriceId( + $subscription->items->first()?->stripe_price ?? $subscription->stripe_price + )->name(); + } catch (\Exception $e) { + $currentPlanName = 'your current plan'; + } + } + } + + return view('livewire.mobile-pricing', [ + 'hasExistingSubscription' => $hasExistingSubscription, + 'currentPlanName' => $currentPlanName, + 'isAlreadyUltra' => $isAlreadyUltra, + ]); } } diff --git a/app/Livewire/SubLicenseManager.php b/app/Livewire/SubLicenseManager.php index fc5aa507..934c21ef 100644 --- a/app/Livewire/SubLicenseManager.php +++ b/app/Livewire/SubLicenseManager.php @@ -2,7 +2,12 @@ namespace App\Livewire; +use App\Jobs\CreateAnystackSubLicenseJob; +use App\Jobs\RevokeMaxAccessJob; +use App\Jobs\UpdateAnystackContactAssociationJob; use App\Models\License; +use App\Models\SubLicense; +use Flux; use Livewire\Component; class SubLicenseManager extends Component @@ -13,15 +18,93 @@ class SubLicenseManager extends Component public int $initialSubLicenseCount; + public string $createName = ''; + + public string $createAssignedEmail = ''; + + public ?int $editingSubLicenseId = null; + + public string $editName = ''; + + public string $editAssignedEmail = ''; + public function mount(License $license): void { $this->license = $license; $this->initialSubLicenseCount = $license->subLicenses->count(); } - public function startPolling(): void + public function openCreateModal(): void { + $this->reset(['createName', 'createAssignedEmail']); + Flux::modal('create-sub-license')->show(); + } + + public function createSubLicense(): void + { + $this->validate([ + 'createName' => ['nullable', 'string', 'max:255'], + 'createAssignedEmail' => ['nullable', 'email', 'max:255'], + ]); + + if (! $this->license->canCreateSubLicense()) { + return; + } + + dispatch(new CreateAnystackSubLicenseJob( + $this->license, + $this->createName ?: null, + $this->createAssignedEmail ?: null, + )); + $this->isPolling = true; + $this->reset(['createName', 'createAssignedEmail']); + Flux::modal('create-sub-license')->close(); + } + + public function editSubLicense(int $subLicenseId): void + { + $subLicense = $this->license->subLicenses->firstWhere('id', $subLicenseId); + + if (! $subLicense) { + return; + } + + $this->editingSubLicenseId = $subLicenseId; + $this->editName = $subLicense->name ?? ''; + $this->editAssignedEmail = $subLicense->assigned_email ?? ''; + + Flux::modal('edit-sub-license')->show(); + } + + public function updateSubLicense(): void + { + $this->validate([ + 'editName' => ['nullable', 'string', 'max:255'], + 'editAssignedEmail' => ['nullable', 'email', 'max:255'], + ]); + + $subLicense = SubLicense::where('id', $this->editingSubLicenseId) + ->where('parent_license_id', $this->license->id) + ->firstOrFail(); + + $oldEmail = $subLicense->assigned_email; + + $subLicense->update([ + 'name' => $this->editName ?: null, + 'assigned_email' => $this->editAssignedEmail ?: null, + ]); + + if ($oldEmail !== ($this->editAssignedEmail ?: null) && $this->editAssignedEmail) { + dispatch(new UpdateAnystackContactAssociationJob($subLicense, $this->editAssignedEmail)); + } + + if ($oldEmail && $oldEmail !== ($this->editAssignedEmail ?: null) && $this->license->policy_name === 'max') { + dispatch(new RevokeMaxAccessJob($oldEmail)); + } + + $this->reset(['editingSubLicenseId', 'editName', 'editAssignedEmail']); + Flux::modal('edit-sub-license')->close(); } public function render() diff --git a/app/Livewire/TeamManager.php b/app/Livewire/TeamManager.php new file mode 100644 index 00000000..512bb3a0 --- /dev/null +++ b/app/Livewire/TeamManager.php @@ -0,0 +1,142 @@ +team = $team; + } + + public function addSeats(int $count = 1): void + { + $owner = $this->team->owner; + $subscription = $owner->subscription(); + + if (! $subscription) { + return; + } + + // Determine the correct extra seat price based on subscription interval + $planPriceId = $subscription->stripe_price; + + if (! $planPriceId) { + foreach ($subscription->items as $item) { + if (! Subscription::isExtraSeatPrice($item->stripe_price)) { + $planPriceId = $item->stripe_price; + break; + } + } + } + + $isMonthly = $planPriceId === config('subscriptions.plans.max.stripe_price_id_monthly'); + $interval = $isMonthly ? 'month' : 'year'; + $priceId = Subscription::extraSeatStripePriceId($interval); + + if (! $priceId) { + return; + } + + // Check if subscription already has this price item + $existingItem = $subscription->items->firstWhere('stripe_price', $priceId); + + if ($existingItem) { + $subscription->incrementAndInvoice($count, $priceId); + } else { + $subscription->addPriceAndInvoice($priceId, $count); + } + + $this->team->increment('extra_seats', $count); + $this->team->refresh(); + + Flux::modal('add-seats')->close(); + } + + public function removeSeats(int $count = 1): void + { + if ($this->team->extra_seats < $count) { + return; + } + + // Don't allow removing seats if it would go below occupied count + $newCapacity = $this->team->totalSeatCapacity() - $count; + if ($newCapacity < $this->team->occupiedSeatCount()) { + return; + } + + $owner = $this->team->owner; + $subscription = $owner->subscription(); + + if (! $subscription) { + return; + } + + $planPriceId = $subscription->stripe_price; + + if (! $planPriceId) { + foreach ($subscription->items as $item) { + if (! Subscription::isExtraSeatPrice($item->stripe_price)) { + $planPriceId = $item->stripe_price; + break; + } + } + } + + $isMonthly = $planPriceId === config('subscriptions.plans.max.stripe_price_id_monthly'); + $interval = $isMonthly ? 'month' : 'year'; + $priceId = Subscription::extraSeatStripePriceId($interval); + + if (! $priceId) { + return; + } + + $existingItem = $subscription->items->firstWhere('stripe_price', $priceId); + + if ($existingItem) { + if ($existingItem->quantity <= $count) { + $subscription->removePrice($priceId); + } else { + $subscription->decrementQuantity($count, $priceId); + } + } + + $this->team->decrement('extra_seats', $count); + $this->team->refresh(); + + Flux::modal('remove-seats')->close(); + } + + public function render() + { + $this->team->refresh(); + $this->team->load('users'); + + $activeMembers = $this->team->users->where('status', TeamUserStatus::Active); + $pendingInvitations = $this->team->users->where('status', TeamUserStatus::Pending); + + $extraSeatPriceYearly = config('subscriptions.plans.max.extra_seat_price_yearly', 4); + $extraSeatPriceMonthly = config('subscriptions.plans.max.extra_seat_price_monthly', 5); + + $removableSeats = min( + $this->team->extra_seats, + $this->team->totalSeatCapacity() - $this->team->occupiedSeatCount() + ); + + return view('livewire.team-manager', [ + 'activeMembers' => $activeMembers, + 'pendingInvitations' => $pendingInvitations, + 'extraSeatPriceYearly' => $extraSeatPriceYearly, + 'extraSeatPriceMonthly' => $extraSeatPriceMonthly, + 'removableSeats' => max(0, $removableSeats), + ]); + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 44a41b84..192fd4f3 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -172,11 +172,23 @@ public function getBestPriceForUser(?User $user): ?PluginPrice $eligibleTiers = $user ? $user->getEligiblePriceTiers() : [PriceTier::Regular]; // Get the lowest active price for the user's eligible tiers - return $this->prices() + $bestPrice = $this->prices() ->active() ->forTiers($eligibleTiers) ->orderBy('amount', 'asc') ->first(); + + // Ultra subscribers get official plugins for free + if ($bestPrice && $user && $this->isOfficial() && $user->hasUltraAccess()) { + $freePrice = $bestPrice->replicate(); + $freePrice->amount = 0; + $freePrice->id = $bestPrice->id; + $freePrice->exists = true; + + return $freePrice; + } + + return $bestPrice; } /** diff --git a/app/Models/Team.php b/app/Models/Team.php new file mode 100644 index 00000000..e4dec82c --- /dev/null +++ b/app/Models/Team.php @@ -0,0 +1,107 @@ + + */ + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * @return HasMany + */ + public function users(): HasMany + { + return $this->hasMany(TeamUser::class); + } + + /** + * @return HasMany + */ + public function activeUsers(): HasMany + { + return $this->hasMany(TeamUser::class) + ->where('status', TeamUserStatus::Active); + } + + /** + * @return HasMany + */ + public function pendingInvitations(): HasMany + { + return $this->hasMany(TeamUser::class) + ->where('status', TeamUserStatus::Pending); + } + + public function activeUserCount(): int + { + return $this->activeUsers()->count(); + } + + public function includedSeats(): int + { + return config('subscriptions.plans.max.included_seats', 10); + } + + public function totalSeatCapacity(): int + { + return $this->includedSeats() + ($this->extra_seats ?? 0); + } + + public function occupiedSeatCount(): int + { + return $this->activeUserCount() + $this->pendingInvitations()->count(); + } + + public function availableSeats(): int + { + return max(0, $this->totalSeatCapacity() - $this->occupiedSeatCount()); + } + + public function isOverIncludedLimit(): bool + { + return $this->occupiedSeatCount() >= $this->totalSeatCapacity(); + } + + public function extraSeatsCount(): int + { + return max(0, $this->activeUserCount() - $this->includedSeats()); + } + + public function suspend(): bool + { + return $this->update(['is_suspended' => true]); + } + + public function unsuspend(): bool + { + return $this->update(['is_suspended' => false]); + } + + protected function casts(): array + { + return [ + 'is_suspended' => 'boolean', + ]; + } +} diff --git a/app/Models/TeamUser.php b/app/Models/TeamUser.php new file mode 100644 index 00000000..9711e7e8 --- /dev/null +++ b/app/Models/TeamUser.php @@ -0,0 +1,84 @@ + + */ + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isActive(): bool + { + return $this->status === TeamUserStatus::Active; + } + + public function isPending(): bool + { + return $this->status === TeamUserStatus::Pending; + } + + public function isRemoved(): bool + { + return $this->status === TeamUserStatus::Removed; + } + + public function accept(User $user): void + { + $this->update([ + 'user_id' => $user->id, + 'status' => TeamUserStatus::Active, + 'invitation_token' => null, + 'accepted_at' => now(), + ]); + } + + public function remove(): void + { + $this->update([ + 'status' => TeamUserStatus::Removed, + 'invitation_token' => null, + ]); + } + + protected function casts(): array + { + return [ + 'status' => TeamUserStatus::class, + 'role' => TeamUserRole::class, + 'invited_at' => 'datetime', + 'accepted_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 39b7c08b..b5bf66fa 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,8 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\PriceTier; +use App\Enums\Subscription; +use App\Enums\TeamUserStatus; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -38,6 +40,28 @@ public function isAdmin(): bool return in_array($this->email, config('filament.users'), true); } + /** + * @return HasOne + */ + public function ownedTeam(): HasOne + { + return $this->hasOne(Team::class); + } + + /** + * Get the team owner if this user is an active team member. + */ + public function getTeamOwner(): ?self + { + $membership = $this->activeTeamMembership(); + + if (! $membership) { + return null; + } + + return $membership->team->owner; + } + /** * @return HasMany */ @@ -83,9 +107,11 @@ public function productLicenses(): HasMany */ public function hasProductLicense(Product $product): bool { - return $this->productLicenses() - ->forProduct($product) - ->exists(); + if ($this->productLicenses()->forProduct($product)->exists()) { + return true; + } + + return $this->hasProductAccessViaTeam($product); } /** @@ -96,6 +122,52 @@ public function developerAccount(): HasOne return $this->hasOne(DeveloperAccount::class); } + /** + * @return HasMany + */ + public function teamMemberships(): HasMany + { + return $this->hasMany(TeamUser::class); + } + + public function isUltraTeamMember(): bool + { + // Team owners count as members + if ($this->ownedTeam && ! $this->ownedTeam->is_suspended) { + return true; + } + + return TeamUser::query() + ->where('user_id', $this->id) + ->where('status', TeamUserStatus::Active) + ->whereHas('team', fn ($query) => $query->where('is_suspended', false)) + ->exists(); + } + + public function activeTeamMembership(): ?TeamUser + { + return TeamUser::query() + ->where('user_id', $this->id) + ->where('status', TeamUserStatus::Active) + ->whereHas('team', fn ($query) => $query->where('is_suspended', false)) + ->with('team') + ->first(); + } + + public function hasProductAccessViaTeam(Product $product): bool + { + $membership = $this->activeTeamMembership(); + + if (! $membership) { + return false; + } + + // Check the owner's direct product licenses only (not via team) to avoid recursion + return $membership->team->owner->productLicenses() + ->forProduct($product) + ->exists(); + } + public function hasActiveMaxLicense(): bool { return $this->licenses() @@ -124,6 +196,80 @@ public function hasMaxAccess(): bool return $this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense(); } + public function hasActiveUltraSubscription(): bool + { + $subscription = $this->subscription(); + + if (! $subscription) { + return false; + } + + // Comped Ultra subs use a dedicated price — always grant Ultra access + $compedUltraPriceId = config('subscriptions.plans.max.stripe_price_id_comped'); + + if ($compedUltraPriceId && $this->subscribedToPrice($compedUltraPriceId)) { + return true; + } + + // Legacy comped Max subs should not get Ultra access + if ($subscription->is_comped) { + return false; + } + + return $this->subscribedToPrice(array_filter([ + config('subscriptions.plans.max.stripe_price_id'), + config('subscriptions.plans.max.stripe_price_id_monthly'), + config('subscriptions.plans.max.stripe_price_id_eap'), + config('subscriptions.plans.max.stripe_price_id_discounted'), + ])); + } + + /** + * Check if the user has Ultra access (paying or comped Ultra), + * qualifying them for Ultra benefits like Teams and free plugins. + */ + public function hasUltraAccess(): bool + { + $subscription = $this->subscription(); + + if (! $subscription || ! $subscription->active()) { + return false; + } + + // Comped Ultra subs always get full access + $compedUltraPriceId = config('subscriptions.plans.max.stripe_price_id_comped'); + + if ($compedUltraPriceId && $this->subscribedToPrice($compedUltraPriceId)) { + return true; + } + + $planPriceId = $subscription->stripe_price; + + if (! $planPriceId) { + foreach ($subscription->items as $item) { + if (! Subscription::isExtraSeatPrice($item->stripe_price)) { + $planPriceId = $item->stripe_price; + break; + } + } + } + + if (! $planPriceId) { + return false; + } + + try { + if (Subscription::fromStripePriceId($planPriceId) !== Subscription::Max) { + return false; + } + } catch (\RuntimeException) { + return false; + } + + // Legacy comped Max subs don't get Ultra access + return ! $subscription->is_comped; + } + /** * Check if user was an Early Access Program customer. * EAP customers purchased before June 1, 2025. @@ -145,7 +291,7 @@ public function getEligiblePriceTiers(): array { $tiers = [PriceTier::Regular]; - if ($this->subscribed()) { + if ($this->subscribed() || $this->isUltraTeamMember()) { $tiers[] = PriceTier::Subscriber; } @@ -235,10 +381,16 @@ public function hasPluginAccess(Plugin $plugin): bool return true; } - return $this->pluginLicenses() - ->forPlugin($plugin) - ->active() - ->exists(); + if ($this->pluginLicenses()->forPlugin($plugin)->active()->exists()) { + return true; + } + + // Ultra team members get access to all official (first-party) plugins + if ($plugin->isOfficial() && $this->isUltraTeamMember()) { + return true; + } + + return false; } public function getGitHubToken(): ?string diff --git a/app/Notifications/MaxToUltraAnnouncement.php b/app/Notifications/MaxToUltraAnnouncement.php new file mode 100644 index 00000000..6e756d9c --- /dev/null +++ b/app/Notifications/MaxToUltraAnnouncement.php @@ -0,0 +1,41 @@ +name ? explode(' ', $notifiable->name)[0] : null; + $greeting = $firstName ? "Hi {$firstName}," : 'Hi there,'; + + return (new MailMessage) + ->subject('Your Max Plan is Now NativePHP Ultra') + ->greeting($greeting) + ->line('We have some exciting news: **your Max plan has been upgraded to NativePHP Ultra** - at no extra cost.') + ->line('Here\'s what you now get as an Ultra subscriber:') + ->line('- **Teams** - invite up to 10 collaborators to share your plugin access') + ->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription') + ->line('- **Plugin Dev Kit** - tools and resources to build and publish your own plugins') + ->line('- **Priority support** - get help faster when you need it') + ->line('- **Early access** - be first to try new features and plugins') + ->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members') + ->line('- **Shape the roadmap** - your feedback directly influences what we build next') + ->line('---') + ->line('**Nothing changes on your end.** Your billing stays exactly the same - you just get more.') + ->action('See All Ultra Benefits', route('pricing')) + ->salutation("Cheers,\n\nThe NativePHP Team"); + } +} diff --git a/app/Notifications/TeamInvitation.php b/app/Notifications/TeamInvitation.php new file mode 100644 index 00000000..0b5deca6 --- /dev/null +++ b/app/Notifications/TeamInvitation.php @@ -0,0 +1,55 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $team = $this->teamUser->team; + $ownerName = $team->owner->display_name; + + return (new MailMessage) + ->subject("You've been invited to join {$team->name} on NativePHP") + ->greeting('Hello!') + ->line("**{$ownerName}** ({$team->owner->email}) has invited you to join **{$team->name}** on NativePHP.") + ->line('As a team member, you will receive:') + ->line('- Free access to all first-party NativePHP plugins') + ->line('- Subscriber-tier pricing on third-party plugins') + ->line('- Access to the Plugin Dev Kit GitHub repository') + ->action('Accept Invitation', route('team.invitation.accept', $this->teamUser->invitation_token)) + ->line('If you did not expect this invitation, you can safely ignore this email.'); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'team_user_id' => $this->teamUser->id, + 'team_name' => $this->teamUser->team->name, + 'email' => $this->teamUser->email, + ]; + } +} diff --git a/app/Notifications/TeamUserRemoved.php b/app/Notifications/TeamUserRemoved.php new file mode 100644 index 00000000..8e81d065 --- /dev/null +++ b/app/Notifications/TeamUserRemoved.php @@ -0,0 +1,50 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $teamName = $this->teamUser->team->name; + + return (new MailMessage) + ->subject("You have been removed from {$teamName}") + ->greeting('Hello!') + ->line("You have been removed from **{$teamName}** on NativePHP.") + ->line('Your team benefits, including free plugin access and subscriber-tier pricing, have been revoked.') + ->action('View Plans', route('pricing')) + ->line('If you believe this was a mistake, please contact the team owner.'); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'team_user_id' => $this->teamUser->id, + 'team_name' => $this->teamUser->team->name, + ]; + } +} diff --git a/app/Notifications/UltraLicenseHolderPromotion.php b/app/Notifications/UltraLicenseHolderPromotion.php new file mode 100644 index 00000000..b2242db3 --- /dev/null +++ b/app/Notifications/UltraLicenseHolderPromotion.php @@ -0,0 +1,43 @@ +name ? explode(' ', $notifiable->name)[0] : null; + $greeting = $firstName ? "Hi {$firstName}," : 'Hi there,'; + + return (new MailMessage) + ->subject('Unlock More with NativePHP Ultra') + ->greeting($greeting) + ->line("You previously purchased a **{$this->planName}** license - thank you for supporting NativePHP early on!") + ->line('Although NativePHP for Mobile is now free and open source and doesn\'t require licenses any more, we\'ve created a subscription plan that gives you some incredible benefits:') + ->line('- **Teams** - invite up to 10 collaborators to share your plugin access') + ->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription') + ->line('- **Plugin Dev Kit** - tools and resources to build and publish your own plugins') + ->line('- **Priority support** - get help faster when you need it') + ->line('- **Early access** - be first to try new features and plugins') + ->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members') + ->line('- **Shape the roadmap** - your feedback directly influences what we build next') + ->line('---') + ->line('Ultra is available with **annual or monthly billing** - choose what works best for you.') + ->action('See Ultra Plans', route('pricing')) + ->salutation("Cheers,\n\nThe NativePHP Team"); + } +} diff --git a/app/Notifications/UltraUpgradePromotion.php b/app/Notifications/UltraUpgradePromotion.php new file mode 100644 index 00000000..e779a715 --- /dev/null +++ b/app/Notifications/UltraUpgradePromotion.php @@ -0,0 +1,43 @@ +name ? explode(' ', $notifiable->name)[0] : null; + $greeting = $firstName ? "Hi {$firstName}," : 'Hi there,'; + + return (new MailMessage) + ->subject('Unlock More with NativePHP Ultra') + ->greeting($greeting) + ->line("You're currently on the **{$this->currentPlanName}** plan - and we'd love to show you what you're missing.") + ->line('**NativePHP Ultra** gives you everything you need to build and ship faster:') + ->line('- **Teams** - invite up to 10 collaborators to share your plugin access') + ->line('- **Free official plugins** - every NativePHP-published plugin, included with your subscription') + ->line('- **Plugin Dev Kit** - tools and resources to build and publish your own plugins') + ->line('- **Priority support** - get help faster when you need it') + ->line('- **Early access** - be first to try new features and plugins') + ->line('- **Exclusive content** - tutorials, guides, and deep dives just for Ultra members') + ->line('- **Shape the roadmap** - your feedback directly influences what we build next') + ->line('---') + ->line('**Upgrading is seamless.** You\'ll only pay the prorated difference for the rest of your billing cycle - no double charges. Ultra is available with **annual or monthly billing**.') + ->action('Upgrade to Ultra', route('pricing')) + ->salutation("Cheers,\n\nThe NativePHP Team"); + } +} diff --git a/config/database.php b/config/database.php index 137ad18c..4621f85a 100644 --- a/config/database.php +++ b/config/database.php @@ -1,6 +1,7 @@ true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/subscriptions.php b/config/subscriptions.php index 2f4727b7..ac98da5d 100644 --- a/config/subscriptions.php +++ b/config/subscriptions.php @@ -20,13 +20,20 @@ 'anystack_policy_id' => env('ANYSTACK_PRO_POLICY_ID'), ], 'max' => [ - 'name' => 'Max', + 'name' => 'Ultra', 'stripe_price_id' => env('STRIPE_MAX_PRICE_ID'), + 'stripe_price_id_monthly' => env('STRIPE_MAX_PRICE_ID_MONTHLY'), 'stripe_price_id_eap' => env('STRIPE_MAX_PRICE_ID_EAP'), 'stripe_price_id_discounted' => env('STRIPE_MAX_PRICE_ID_DISCOUNTED'), + 'stripe_price_id_comped' => env('STRIPE_ULTRA_COMP_PRICE_ID'), 'stripe_payment_link' => env('STRIPE_MAX_PAYMENT_LINK'), 'anystack_product_id' => env('ANYSTACK_PRODUCT_ID'), 'anystack_policy_id' => env('ANYSTACK_MAX_POLICY_ID'), + 'stripe_extra_seat_price_id' => env('STRIPE_EXTRA_SEAT_PRICE_ID'), + 'stripe_extra_seat_price_id_monthly' => env('STRIPE_EXTRA_SEAT_PRICE_ID_MONTHLY'), + 'included_seats' => 10, + 'extra_seat_price_yearly' => 4, + 'extra_seat_price_monthly' => 5, ], 'forever' => [ 'name' => 'Forever', diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php new file mode 100644 index 00000000..1c2fe493 --- /dev/null +++ b/database/factories/TeamFactory.php @@ -0,0 +1,37 @@ + + */ +class TeamFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'name' => fake()->company(), + 'is_suspended' => false, + ]; + } + + /** + * Indicate that the team is suspended. + */ + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'is_suspended' => true, + ]); + } +} diff --git a/database/factories/TeamUserFactory.php b/database/factories/TeamUserFactory.php new file mode 100644 index 00000000..7ff3a46e --- /dev/null +++ b/database/factories/TeamUserFactory.php @@ -0,0 +1,59 @@ + + */ +class TeamUserFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'user_id' => null, + 'email' => fake()->unique()->safeEmail(), + 'role' => TeamUserRole::Member, + 'status' => TeamUserStatus::Pending, + 'invitation_token' => bin2hex(random_bytes(32)), + 'invited_at' => now(), + 'accepted_at' => null, + ]; + } + + /** + * Indicate the team user is active (accepted invitation). + */ + public function active(): static + { + return $this->state(fn (array $attributes) => [ + 'user_id' => User::factory(), + 'status' => TeamUserStatus::Active, + 'invitation_token' => null, + 'accepted_at' => now(), + ]); + } + + /** + * Indicate the team user has been removed. + */ + public function removed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => TeamUserStatus::Removed, + 'invitation_token' => null, + ]); + } +} diff --git a/database/migrations/2026_02_23_230918_create_teams_table.php b/database/migrations/2026_02_23_230918_create_teams_table.php new file mode 100644 index 00000000..e94da65d --- /dev/null +++ b/database/migrations/2026_02_23_230918_create_teams_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->boolean('is_suspended')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('teams'); + } +}; diff --git a/database/migrations/2026_02_23_230919_create_team_users_table.php b/database/migrations/2026_02_23_230919_create_team_users_table.php new file mode 100644 index 00000000..b0715b91 --- /dev/null +++ b/database/migrations/2026_02_23_230919_create_team_users_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('email'); + $table->string('role')->default('member'); + $table->string('status')->default('pending'); + $table->string('invitation_token', 64)->unique()->nullable(); + $table->timestamp('invited_at')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + $table->unique(['team_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('team_users'); + } +}; diff --git a/database/migrations/2026_03_05_114700_create_teams_tables.php b/database/migrations/2026_03_05_114700_create_teams_tables.php new file mode 100644 index 00000000..2d2ed3d9 --- /dev/null +++ b/database/migrations/2026_03_05_114700_create_teams_tables.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->boolean('is_suspended')->default(false); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('team_users')) { + Schema::create('team_users', function (Blueprint $table) { + $table->id(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('email'); + $table->string('role')->default('member'); + $table->string('status')->default('pending'); + $table->string('invitation_token')->unique()->nullable(); + $table->timestamp('invited_at')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + $table->unique(['team_id', 'email']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('team_users'); + Schema::dropIfExists('teams'); + } +}; diff --git a/database/migrations/2026_03_05_114737_add_extra_seats_to_teams_table.php b/database/migrations/2026_03_05_114737_add_extra_seats_to_teams_table.php new file mode 100644 index 00000000..0b13e5d0 --- /dev/null +++ b/database/migrations/2026_03_05_114737_add_extra_seats_to_teams_table.php @@ -0,0 +1,32 @@ +unsignedInteger('extra_seats')->default(0)->after('is_suspended'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropColumn('extra_seats'); + }); + } +}; diff --git a/database/migrations/2026_03_05_130556_add_is_comped_to_subscriptions_table.php b/database/migrations/2026_03_05_130556_add_is_comped_to_subscriptions_table.php new file mode 100644 index 00000000..03d52a5d --- /dev/null +++ b/database/migrations/2026_03_05_130556_add_is_comped_to_subscriptions_table.php @@ -0,0 +1,25 @@ +boolean('is_comped')->default(false)->after('quantity'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('is_comped'); + }); + } +}; diff --git a/resources/views/alt-pricing.blade.php b/resources/views/alt-pricing.blade.php deleted file mode 100644 index caaae847..00000000 --- a/resources/views/alt-pricing.blade.php +++ /dev/null @@ -1,191 +0,0 @@ - - {{-- Hero Section --}} -
-
- {{-- Primary Heading --}} -

- Discounted Licenses -

- - {{-- Introduction Description --}} -

- Thanks for supporting NativePHP and Bifrost.
- Now go get your discounted license! -

-
-
- - {{-- Pricing Section --}} - - - {{-- Ultra Section --}} - - - {{-- Testimonials Section --}} - {{-- --}} - - {{-- FAQ Section --}} -
- {{-- Section Heading --}} -

- Frequently Asked Questions -

- - {{-- FAQ List --}} -
- -

- No catch! They're the same licenses. -

-
- - -

- It'll renew at the discounted price. As long as you keep up your subscription, you'll - benefit from that discounted price. -

-
- - -

- Yes. Renewing your license entitles you to receive the - latest package updates but isn't required to build and - release apps. -

-
- - -

That's not currently possible.

-
- - -

- Absolutely! You can use NativePHP for any kind of project, - including commercial ones. We can't wait to see what you - build! -

-
- - -

- You'll get an invoice with your receipt via email and you can always retrieve past invoices - in the - - Stripe billing portal. - -

-
- - -

- You can manage your subscription via the - - Stripe billing portal. - -

-
-
-
-
diff --git a/resources/views/cart/show.blade.php b/resources/views/cart/show.blade.php index 382265bd..d014e8eb 100644 --- a/resources/views/cart/show.blade.php +++ b/resources/views/cart/show.blade.php @@ -232,9 +232,14 @@ class="flex gap-4 p-6" by {{ $item->plugin->user->display_name }}

-

- {{ $item->getFormattedPrice() }} -

+
+

+ {{ $item->getFormattedPrice() }} +

+ @if ($item->price_at_addition === 0 && $item->plugin->isOfficial()) + Included with Ultra + @endif +
diff --git a/resources/views/cart/success.blade.php b/resources/views/cart/success.blade.php index fd3e5111..972975de 100644 --- a/resources/views/cart/success.blade.php +++ b/resources/views/cart/success.blade.php @@ -1,238 +1,331 @@
-
- {{-- Loading State --}} - - {{-- Success State --}} - - {{-- Timeout/Error State --}} - +
+ @endif
diff --git a/resources/views/components/customer/masked-key.blade.php b/resources/views/components/customer/masked-key.blade.php new file mode 100644 index 00000000..72c35a75 --- /dev/null +++ b/resources/views/components/customer/masked-key.blade.php @@ -0,0 +1,16 @@ +@props(['key-value']) + + + {{ Str::substr($keyValue, 0, 4) }}****{{ Str::substr($keyValue, -4) }} + + Copy + Copied! + + diff --git a/resources/views/components/dashboard-card.blade.php b/resources/views/components/dashboard-card.blade.php index 7c8126a5..b6319092 100644 --- a/resources/views/components/dashboard-card.blade.php +++ b/resources/views/components/dashboard-card.blade.php @@ -9,6 +9,8 @@ 'description' => null, 'badge' => null, 'badgeColor' => 'green', + 'secondBadge' => null, + 'secondBadgeColor' => 'yellow', ]) @php @@ -20,17 +22,9 @@ 'indigo' => 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400', 'gray' => 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400', ]; - - $badgeClasses = [ - 'green' => 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', - 'blue' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', - 'yellow' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', - 'red' => 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', - 'gray' => 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400', - ]; @endphp -
+
@if($icon) @@ -41,41 +35,36 @@
@endif
-
-
- {{ $title }} -
-
- @if($count !== null) - - {{ $count }} - - @elseif($value !== null) - - {{ $value }} - - @endif - @if($badge) - - {{ $badge }} - - @endif -
- @if($description) -
- {{ $description }} -
+ {{ $title }} +
+ @if($count !== null) + {{ $count }} + @elseif($value !== null) + {{ $value }} @endif -
+ @if($badge || $secondBadge) + + @if($badge) + {{ $badge }} + @endif + @if($secondBadge) + {{ $secondBadge }} + @endif + + @endif +
+ @if($description) + {{ $description }} + @endif
@if($href) - + diff --git a/resources/views/components/dashboard-menu.blade.php b/resources/views/components/dashboard-menu.blade.php index cedcb3c4..09d8ea41 100644 --- a/resources/views/components/dashboard-menu.blade.php +++ b/resources/views/components/dashboard-menu.blade.php @@ -48,6 +48,11 @@ class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 Integrations + @if(auth()->user()->hasActiveUltraSubscription() || auth()->user()->ownedTeam) + + Team + + @endif Manage Subscription diff --git a/resources/views/components/layouts/dashboard.blade.php b/resources/views/components/layouts/dashboard.blade.php index 0fda420c..fbba3982 100644 --- a/resources/views/components/layouts/dashboard.blade.php +++ b/resources/views/components/layouts/dashboard.blade.php @@ -120,6 +120,14 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text + @if(auth()->user()->hasActiveUltraSubscription() || auth()->user()->isUltraTeamMember()) + + + {{ auth()->user()->ownedTeam ? 'Manage' : 'Create Team' }} + + + @endif + @feature(App\Features\ShowPlugins::class) diff --git a/resources/views/components/navbar/mobile-menu.blade.php b/resources/views/components/navbar/mobile-menu.blade.php index b174d2ef..7d45170f 100644 --- a/resources/views/components/navbar/mobile-menu.blade.php +++ b/resources/views/components/navbar/mobile-menu.blade.php @@ -88,7 +88,7 @@ class="@md:grid-cols-3 grid grid-cols-2 text-xl" > @php $isHomeActive = request()->routeIs('welcome*'); - $isDocsActive = request()->is('docs*'); + $isUltraActive = request()->routeIs('pricing'); $isBlogActive = request()->routeIs('blog*'); $isPartnersActive = request()->routeIs('partners*'); $isServicesActive = request()->routeIs('build-my-app'); @@ -120,18 +120,18 @@ class="size-4 shrink-0" - {{-- Docs Link --}} + {{-- Ultra Link --}} @@ -406,7 +406,7 @@ class="mx-auto mt-4 flex" aria-label="Social media" >
diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php index 65f18de9..0ff7a278 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -168,6 +168,25 @@ class="size-[3px] rotate-45 rounded-xs bg-gray-400 transition duration-200 dark: aria-hidden="true" > + {{-- Link --}} + request()->routeIs('pricing'), + 'opacity-60 hover:opacity-100' => ! request()->routeIs('pricing'), + ]) + aria-current="{{ request()->routeIs('pricing') ? 'page' : 'false' }}" + > + Ultra + + + {{-- Decorative circle --}} + + {{-- Link --}} @endif - @if ($plugin->isPaid()) + @if ($plugin->isPaid() && $plugin->isOfficial() && auth()->user()?->hasUltraAccess()) + + Free with Ultra + + @elseif ($plugin->isPaid()) Paid diff --git a/resources/views/components/pricing-plan-features.blade.php b/resources/views/components/pricing-plan-features.blade.php index 4018ccbf..fca19834 100644 --- a/resources/views/components/pricing-plan-features.blade.php +++ b/resources/views/components/pricing-plan-features.blade.php @@ -48,6 +48,20 @@ class="size-5 shrink-0" developer seats (keys) + @if($features['teams'] ?? false) +
+
+ @endif {{-- Divider - Decorative --}} diff --git a/resources/views/customer/dashboard.blade.php b/resources/views/customer/dashboard.blade.php index 78608b03..3f434af2 100644 --- a/resources/views/customer/dashboard.blade.php +++ b/resources/views/customer/dashboard.blade.php @@ -15,6 +15,50 @@ + {{-- Session Messages --}} +
+ @if (session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if (session('message')) +
+

{{ session('message') }}

+
+ @endif + + @if (session('error')) +
+

{{ session('error') }}

+
+ @endif +
+ + {{-- Ultra Upsell --}} + @if($showUltraUpsell) +
+ @endif + {{-- Banners --}}
@@ -117,8 +161,35 @@ :href="route('customer.plugins.create')" link-text="Submit a plugin" /> + @endfeature + {{-- Team Card --}} + @if($hasTeam) + + @elseif($hasMaxAccess) + + @endif + {{-- Connected Accounts Card --}}
- {{-- Session Messages --}} -
- @if (session('success')) -
-

{{ session('success') }}

-
- @endif - - @if (session('message')) -
-

{{ session('message') }}

-
- @endif - - @if (session('error')) -
-

{{ session('error') }}

-
- @endif -
diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index c319521c..6655b490 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -296,9 +296,16 @@ class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text @endif -

- {{ $license->key }} -

+
+

+ {{ Str::mask($license->key, '*', 8, -4) }} + {{ $license->key }} +

+ +
@@ -378,9 +385,16 @@ class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text @endif
-

- {{ $subLicense->key }} -

+
+

+ {{ Str::mask($subLicense->key, '*', 8, -4) }} + {{ $subLicense->key }} +

+ +
diff --git a/resources/views/customer/licenses/list.blade.php b/resources/views/customer/licenses/list.blade.php index 70c05a92..ef147e1f 100644 --- a/resources/views/customer/licenses/list.blade.php +++ b/resources/views/customer/licenses/list.blade.php @@ -82,9 +82,16 @@ @endif
-

- {{ $license->key }} -

+
+

+ {{ Str::mask($license->key, '*', 8, -4) }} + {{ $license->key }} +

+ +
@@ -164,9 +171,16 @@ @endif
-

- {{ $subLicense->key }} -

+
+

+ {{ Str::mask($subLicense->key, '*', 8, -4) }} + {{ $subLicense->key }} +

+ +
diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php index dc88a35a..b813da62 100644 --- a/resources/views/customer/licenses/show.blade.php +++ b/resources/views/customer/licenses/show.blade.php @@ -5,11 +5,11 @@
- + - Dashboard + Licenses

{{ $license->name ?: $license->policy_name }} @@ -68,18 +68,29 @@
License Key
-
+
- {{ $license->key }} + {{ Str::mask($license->key, '*', 8, -4) }} + {{ $license->key }} - +
+ + +

diff --git a/resources/views/customer/plugins/create.blade.php b/resources/views/customer/plugins/create.blade.php index c0fece78..a6cad783 100644 --- a/resources/views/customer/plugins/create.blade.php +++ b/resources/views/customer/plugins/create.blade.php @@ -242,6 +242,100 @@ class="sr-only" @endfeature + {{-- Author Display Name (only show if not set) --}} + @unless(auth()->user()->display_name) +
+

Author Display Name

+

+ This is how your name will appear on your plugins in the directory. You can change this later. +

+
+ +

+ Leave blank to use your account name: {{ auth()->user()->name }} +

+
+
+ @endunless + + {{-- Stripe Connect (only show when paid selected and not connected) --}} + @feature(App\Features\AllowPaidPlugins::class) +
+ @if ($developerAccount && $developerAccount->hasCompletedOnboarding()) +
+
+
+ + + +
+
+

Stripe Connect Active

+

+ Your account is ready to receive payouts for paid plugin sales. +

+
+
+
+ @elseif ($developerAccount) +
+
+
+ + + +
+
+

Stripe Setup Incomplete

+

+ You need to complete your Stripe Connect setup before you can submit a paid plugin. +

+ + Continue Setup + + + + +
+
+
+ @else +
+
+
+ + + +
+
+

Connect Stripe to Sell Plugins

+

+ To submit a paid plugin, you need to connect your Stripe account. You'll earn 70% of each sale. +

+ + Connect Stripe + + + + +
+
+
+ @endif +
+ @endfeature + {{-- Repository Selection (for all plugins) --}}
diff --git a/resources/views/customer/plugins/index.blade.php b/resources/views/customer/plugins/index.blade.php index d6dfc827..7211faec 100644 --- a/resources/views/customer/plugins/index.blade.php +++ b/resources/views/customer/plugins/index.blade.php @@ -84,151 +84,75 @@
- {{-- Author Display Name Section --}} -
-
-
-
-
- - - -
-
-

Author Display Name

-

- This is how your name will appear on your plugins in the directory. -

-
- @csrf - @method('PATCH') -
- - @error('display_name') -

{{ $message }}

- @enderror -
- -
-

- Leave blank to use your account name: {{ auth()->user()->name }} -

-
-
-
-
-
- - {{-- Stripe Connect Section (only show when paid plugins are enabled) --}} + {{-- Stripe Connect Status (only show when connected or in progress) --}} @feature(App\Features\AllowPaidPlugins::class) -
@if ($developerAccount && $developerAccount->hasCompletedOnboarding()) - {{-- Connected Account --}} -
-
-
-
- - - -
-
-

Stripe Connect Active

-

- Your developer account is set up and ready to receive payouts for paid plugin sales. -

-
- - - - - Payouts {{ $developerAccount->payouts_enabled ? 'Enabled' : 'Pending' }} - - - - - - {{ $developerAccount->stripe_connect_status->label() }} - +
+
+
+
+
+ + + +
+
+

Stripe Connect Active

+

+ Your developer account is set up and ready to receive payouts for paid plugin sales. +

+
+ + + + + Payouts {{ $developerAccount->payouts_enabled ? 'Enabled' : 'Pending' }} + + + + + + {{ $developerAccount->stripe_connect_status->label() }} + +
+ + View Dashboard + + + +
- - View Dashboard - - - -
@elseif ($developerAccount) - {{-- Onboarding In Progress --}} -
-
-
-
- - - -
-
-

Complete Your Stripe Setup

-

- You've started the Stripe Connect setup but there are still some steps remaining. Complete the onboarding to start receiving payouts. -

+
+
+
+
+
+ + + +
+
+

Complete Your Stripe Setup

+

+ You've started the Stripe Connect setup but there are still some steps remaining. Complete the onboarding to start receiving payouts. +

+
-
- - Continue Setup - - - - -
-
- @else - {{-- No Developer Account --}} -
-
-
- -
-

Sell Paid Plugins

-

- Want to sell premium plugins? Connect your Stripe account to receive payouts when customers purchase your paid plugins. You'll earn 70% of each sale. -

-
+
- - Connect Stripe - - - -
@endif -
@endfeature {{-- Success Message --}} diff --git a/resources/views/customer/purchased-plugins/index.blade.php b/resources/views/customer/purchased-plugins/index.blade.php index 53b96f0c..82862b75 100644 --- a/resources/views/customer/purchased-plugins/index.blade.php +++ b/resources/views/customer/purchased-plugins/index.blade.php @@ -247,6 +247,53 @@ class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text
@endif + + {{-- Team Plugins --}} + @if ($teamPlugins->isNotEmpty()) +

+ Team Plugins + — shared by {{ $teamOwnerName }} +

+ + @endif
diff --git a/resources/views/customer/team/index.blade.php b/resources/views/customer/team/index.blade.php new file mode 100644 index 00000000..42670687 --- /dev/null +++ b/resources/views/customer/team/index.blade.php @@ -0,0 +1,95 @@ + +
+
+ @if($team) +
+ {{ $team->name }} + +
+ Manage your team and share your Ultra benefits + + {{-- Inline Team Name Edit --}} +
+
+ @csrf + @method('PATCH') +
+ + @error('name') + {{ $message }} + @enderror +
+ Save + Cancel +
+
+ @else + Team + Manage your team and share your Ultra benefits + @endif +
+ + {{-- Flash Messages --}} + @if(session('success')) + + {{ session('success') }} + + @endif + + @if(session('error')) + + {{ session('error') }} + + @endif + +
+ @if($team) + {{-- User owns a team --}} + + @elseif($membership) + {{-- User is a member of another team --}} + + Team Membership + + You are a member of {{ $membership->team->name }}. + +
+ Your benefits include: +
    +
  • Free access to all first-party NativePHP plugins
  • +
  • Subscriber-tier pricing on third-party plugins
  • +
  • Access to the Plugin Dev Kit GitHub repository
  • +
+
+
+ @elseif(auth()->user()->hasActiveUltraSubscription()) + {{-- User has Ultra but no team --}} + + Create a Team + As an Ultra subscriber, you can create a team and invite up to 10 members who will share your benefits. + +
+ @csrf +
+
+ +
+ Create Team +
+ @error('name') + {{ $message }} + @enderror +
+
+ @else + {{-- User doesn't have Ultra --}} + + Teams + Teams are available to Ultra subscribers. Upgrade to Ultra to create a team and share benefits with up to 10 members. + + View Plans + + @endif +
+
+
diff --git a/resources/views/license/renewal.blade.php b/resources/views/license/renewal.blade.php index d6eb2039..338e3f98 100644 --- a/resources/views/license/renewal.blade.php +++ b/resources/views/license/renewal.blade.php @@ -1,106 +1,64 @@ - -
-
-
- {{-- Header --}} -
-

- Renew Your NativePHP License -

-

- Set up automatic renewal to keep your license active beyond its expiry date. -

-
- - {{-- License Information --}} -
-
-
-
License
-
- {{ $license->name ?: $subscriptionType->name() }} -
-
-
-
License Key
-
- - {{ $license->key }} - -
-
-
-
Current Expiry
-
- {{ $license->expires_at->format('F j, Y \a\t g:i A T') }} - - ({{ $license->expires_at->diffForHumans() }}) - -
-
-
-
+ +
+
+ Upgrade to Ultra + Your Early Access license qualifies you for special upgrade pricing. +
- {{-- Renewal Information --}} -
-
-

- 🎉 Early Access Pricing Available! -

-

- As an early adopter, you're eligible for our special Early Access Pricing - the same great - rates you enjoyed when you first purchased your license. This pricing is only available - until your license expires. After that you will have to renew at full price. -

-
+ + Early Access Pricing + + As an early adopter, you can upgrade to Ultra at a special discounted rate. + This pricing is only available until your license expires. After that you will have to renew at full price. + + -
-

What happens when you renew:

-
    -
  • - - - - Your existing license will continue to work without interruption -
  • -
  • - - - - Automatic renewal will be set up to prevent future expiry -
  • -
  • - - - - You'll receive Early Access Pricing for your renewal -
  • -
  • - - - - No new license key - your existing key continues to work -
  • -
+
+ {{-- Yearly Option --}} + +
+ Recommended + Yearly +
+ $250 + /year
+ Early Access Price -
-
- @csrf - -
+
+ @csrf + + + Upgrade Yearly + +
+
+ -

- You'll be redirected to Stripe to complete your subscription setup. -

+ {{-- Monthly Option --}} + +
+
+ Monthly +
+ $35 + /month
+ Billed monthly + +
+ @csrf + + + Upgrade Monthly + +
-
+
+ + + You'll be redirected to Stripe to complete your subscription setup. +
- + diff --git a/resources/views/livewire/customer/dashboard.blade.php b/resources/views/livewire/customer/dashboard.blade.php index 9272a682..a2aa7343 100644 --- a/resources/views/livewire/customer/dashboard.blade.php +++ b/resources/views/livewire/customer/dashboard.blade.php @@ -81,6 +81,31 @@ /> @endif + {{-- Team Card --}} + @if($this->hasUltraSubscription) + @if($this->ownedTeam) + + @else + + @endif + @endif + {{-- Premium Plugins Card --}} @feature(App\Features\ShowPlugins::class) licenses as $license) @php $isLegacyLicense = $license->isLegacy(); - $daysUntilExpiry = $license->expires_at ? $license->expires_at->diffInDays(now()) : null; + $daysUntilExpiry = $license->expires_at ? (int) round(abs(now()->diffInDays($license->expires_at))) : null; $needsRenewal = $isLegacyLicense && $daysUntilExpiry !== null && !$license->expires_at->isPast(); $status = match(true) { @@ -40,7 +40,7 @@ - {{ $license->key }} + @@ -58,7 +58,12 @@ @endif
@elseif($license->expires_at) - {{ $license->expires_at->format('M j, Y') }} +
+ {{ $license->expires_at->format('M j, Y') }} + @if($license->expires_at->isPast()) + Expired {{ $license->expires_at->diffForHumans() }} + @endif +
@else No expiration @endif @@ -99,7 +104,7 @@ - {{ $subLicense->key }} + @@ -108,7 +113,12 @@ @if($subLicense->expires_at) - {{ $subLicense->expires_at->format('M j, Y') }} +
+ {{ $subLicense->expires_at->format('M j, Y') }} + @if($subLicense->expires_at->isPast()) + Expired {{ $subLicense->expires_at->diffForHumans() }} + @endif +
@else No expiration @endif diff --git a/resources/views/livewire/customer/licenses/show.blade.php b/resources/views/livewire/customer/licenses/show.blade.php index 6a0ba815..98fbb9a1 100644 --- a/resources/views/livewire/customer/licenses/show.blade.php +++ b/resources/views/livewire/customer/licenses/show.blade.php @@ -24,71 +24,64 @@ -
- {{-- License Key --}} -
-
License Key
-
-
- {{ $license->key }} - - Copy - -
-
-
+ + + {{-- License Key --}} + + License Key + + + + - {{-- License Name --}} -
-
License Name
-
-
- - {{ $license->name ?: 'No name set' }} - - - Edit - -
-
-
+ {{-- License Name --}} + + License Name + +
+ + {{ $license->name ?: 'No name set' }} + + + Edit + +
+
+
- {{-- License Type --}} -
-
License Type
-
{{ $license->policy_name }}
-
+ {{-- License Type --}} + + License Type + {{ $license->policy_name }} + - {{-- Created --}} -
-
Created
-
- {{ $license->created_at->format('F j, Y \a\t g:i A') }} - - ({{ $license->created_at->diffForHumans() }}) - -
-
+ {{-- Created --}} + + Created + + {{ $license->created_at->format('F j, Y \a\t g:i A') }} + ({{ $license->created_at->diffForHumans() }}) + + - {{-- Expires --}} -
-
Expires
-
- @if($license->expires_at) - {{ $license->expires_at->format('F j, Y \a\t g:i A') }} - + {{-- Expires --}} + + Expires + + @if($license->expires_at) + {{ $license->expires_at->format('F j, Y \a\t g:i A') }} @if($license->expires_at->isPast()) - (Expired {{ $license->expires_at->diffForHumans() }}) + (Expired {{ $license->expires_at->diffForHumans() }}) @else - ({{ $license->expires_at->diffForHumans() }}) + ({{ $license->expires_at->diffForHumans() }}) @endif - - @else - Never - @endif -
-
-
+ @else + Never + @endif +
+ + + {{-- Sub-license Manager --}} @@ -99,7 +92,7 @@ {{-- Renewal CTA --}} @php $isLegacyLicense = $license->isLegacy(); - $daysUntilExpiry = $license->expires_at ? $license->expires_at->diffInDays(now()) : null; + $daysUntilExpiry = $license->expires_at ? (int) round(abs(now()->diffInDays($license->expires_at))) : null; $needsRenewal = $isLegacyLicense && $daysUntilExpiry !== null; @endphp @@ -108,7 +101,7 @@ Renewal Available with Early Access Pricing Your license expires in {{ $daysUntilExpiry }} day{{ $daysUntilExpiry === 1 ? '' : 's' }}. - Set up automatic renewal now to avoid interruption and lock in your Early Access Pricing! + Set up automatic renewal now to upgrade to Ultra with your Early Access Pricing! Set Up Renewal @@ -148,8 +141,7 @@ Give your license a descriptive name to help organize your licenses.
-
- Cancel +
Update Name
diff --git a/resources/views/livewire/customer/plugins/create.blade.php b/resources/views/livewire/customer/plugins/create.blade.php index cd93a94b..36cd6efc 100644 --- a/resources/views/livewire/customer/plugins/create.blade.php +++ b/resources/views/livewire/customer/plugins/create.blade.php @@ -1,4 +1,4 @@ -
+