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 --}}
-
-
- {{-- 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 --}}
-
-
-
-
Processing Your Purchase...
-
- Please wait while we confirm your payment and set up your licenses.
-
-
- This usually takes just a few seconds.
-
+ @if ($isFreeCheckout ?? false)
+ {{-- Free Checkout: Immediate success, no polling --}}
+
+
-
- {{-- Success State --}}
-
-
- {{-- Success Icon --}}
-
-
-
Thank You for Your Purchase!
-
- Your payment was successful.
-
+
Plugins Added!
+
+ Your plugins are ready to use.
+
- {{-- Purchased Products --}}
-
-
-
Products
-
-
+ @if ($cart && $cart->items->isNotEmpty())
+
+
Plugins
+
+ @foreach ($cart->items as $item)
+ @if ($item->plugin)
-
-
+
+
-
-
-
- Includes access to
-
-
+
{{ $item->plugin->name }}
+ @if ($item->price_at_addition === 0 && $item->plugin->isOfficial())
+
Included with Ultra
+ @endif
+
+ View Plugin
+
-
-
+ @endif
+
+ @if ($item->isBundle() && $item->pluginBundle)
+ @foreach ($item->pluginBundle->plugins as $plugin)
+
+
+
+
+
+
{{ $plugin->name }}
+
+
+ @endforeach
+ @endif
+ @endforeach
-
+
+ @endif
- {{-- Purchased Plugins --}}
-
-
-
Plugins
-
-
-
-
-
-
+ {{-- Plugin Installation --}}
+
+
Plugin Installation
+
+
+
+
+
+ Configure your Composer credentials in your project
+
+
+
+
+
+ Run composer require [package-name]
+
+
+
+
+
+ Follow the plugin's installation instructions
+
+
+
+
+ {{-- Actions --}}
+
+
+ @else
+ {{-- Paid Checkout: Alpine.js polling for Stripe webhook --}}
+
+ {{-- Loading State --}}
+
+
+
+
Processing Your Purchase...
+
+ Please wait while we confirm your payment and set up your licenses.
+
+
+ This usually takes just a few seconds.
+
+
+
+
+ {{-- Success State --}}
+
+
+ {{-- Success Icon --}}
+
+
+
Thank You for Your Purchase!
+
+ Your payment was successful.
+
+
+ {{-- Purchased Products --}}
+
+
-
-
+
- {{-- GitHub Connection CTA --}}
-
-
-
-
-
-
-
+ {{-- Purchased Plugins --}}
+
+
+
Plugins
+
-
-
Connect GitHub to Access Your Repositories
-
- Your purchase includes access to private GitHub repositories. Connect your GitHub account to get access.
-
-
- Go to Integrations
-
-
+
+
+
+ {{-- GitHub Connection CTA --}}
+
+
+
+
+
+
Connect GitHub to Access Your Repositories
+
+ Your purchase includes access to private GitHub repositories. Connect your GitHub account to get access.
+
+
+ Go to Integrations
+
+
+
+
+
-
-
+
- {{-- GitHub Access Granted --}}
-
-
-
-
-
-
Repository Access Granted
-
- Check your GitHub notifications for repository invitations. Accept the invitations to access your new repositories.
-
+ {{-- GitHub Access Granted --}}
+
+
+
+
+
+
Repository Access Granted
+
+ Check your GitHub notifications for repository invitations. Accept the invitations to access your new repositories.
+
+
-
-
+
- {{-- Next Steps for Plugins --}}
-
-
-
Plugin Installation
-
-
-
-
-
- Configure your Composer credentials in your project
-
-
-
-
-
- Run composer require [package-name]
-
-
-
-
-
- Follow the plugin's installation instructions
-
-
-
-
+ {{-- Next Steps for Plugins --}}
+
+
+
Plugin Installation
+
+
+
+
+
+ Configure your Composer credentials in your project
+
+
+
+
+
+ Run composer require [package-name]
+
+
+
+
+
+ Follow the plugin's installation instructions
+
+
+
+
- {{-- Actions --}}
-
-
-
+
- {{-- Timeout/Error State --}}
-
-
-
-
Taking Longer Than Expected
-
- Your payment is being processed, but it's taking longer than usual. Don't worry - your purchase is confirmed.
-
-
- Please check your dashboard in a few minutes, or contact support if you don't see your items.
-
-
-
- Go to Dashboard
-
+ {{-- Timeout/Error State --}}
+
+
+
+
Taking Longer Than Expected
+
+ Your payment is being processed, but it's taking longer than usual. Don't worry - your purchase is confirmed.
+
+
+ Please check your dashboard in a few minutes, or contact support if you don't see your items.
+
+
-
-
-
+
+
+ @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)
+
+
+
+ Teams support —
+ 10 seats
+ included
+ (extra: $5/seat/mo or $4/seat/yr)
+
+
+ @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)
+
+
+
+
+
+
Upgrade to Ultra
+
+ Get access to all official plugins, team sharing, and more with an Ultra subscription.
+
+
+
+ Learn more
+
+
+
+
+
+
+
+ @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 }}
-
- Copy
-
+
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.
-
-
-
- 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 --}}
+
+ @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.
+
+
+
+ @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
-
+
-
- You'll be redirected to Stripe to complete your subscription setup.
-
+ {{-- Monthly Option --}}
+
+
+
+
Monthly
+
+ $35
+ /month
+
Billed 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 @@
-
+
@@ -56,7 +56,7 @@
@else
-