Skip to content

Commit 06c56d0

Browse files
simonhampclaude
andcommitted
Per-seat billing UI, comped sub exclusion, and plugin submission flow improvements
- Add seat management UI with Add/Remove Seats modals and Stripe billing - Pending invitations now count against seat capacity - Comped Max subscriptions excluded from Ultra features - Move Stripe Connect and author display name into plugin submission flow - Dashboard card badges wrap on smaller layouts - Fix PHP 8.5 PDO deprecation in database config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ae9f58e commit 06c56d0

16 files changed

Lines changed: 466 additions & 214 deletions

app/Http/Controllers/CustomerLicenseController.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public function index(): View
2828
$activeSubscription = $user->subscription();
2929
$ownPluginIds = $user->pluginLicenses()->pluck('plugin_id');
3030
$teamPluginCount = 0;
31-
$teamMembership = $user->teamMembership;
31+
$teamMembership = $user->activeTeamMembership();
3232

3333
if ($teamMembership) {
3434
$teamPluginCount = $teamMembership->team->owner
@@ -97,6 +97,7 @@ public function index(): View
9797
$hasTeam = $ownedTeam !== null;
9898
$teamName = $ownedTeam?->name;
9999
$teamMemberCount = $ownedTeam?->activeUserCount() ?? 0;
100+
$teamPendingCount = $ownedTeam?->pendingInvitations()->count() ?? 0;
100101
$hasMaxAccess = $user->hasActiveUltraSubscription();
101102

102103
return view('customer.dashboard', compact(
@@ -112,6 +113,7 @@ public function index(): View
112113
'hasTeam',
113114
'teamName',
114115
'teamMemberCount',
116+
'teamPendingCount',
115117
'hasMaxAccess'
116118
));
117119
}

app/Http/Controllers/CustomerPluginController.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,21 @@ public function index(): View
3737

3838
public function create(): View
3939
{
40-
return view('customer.plugins.create');
40+
$user = Auth::user();
41+
$developerAccount = $user->developerAccount;
42+
43+
return view('customer.plugins.create', compact('developerAccount'));
4144
}
4245

4346
public function store(SubmitPluginRequest $request, PluginSyncService $syncService): RedirectResponse
4447
{
4548
$user = Auth::user();
4649

50+
// Save display name if provided during submission
51+
if ($request->filled('display_name') && ! $user->display_name) {
52+
$user->update(['display_name' => $request->input('display_name')]);
53+
}
54+
4755
// Reject paid plugin submissions if the feature is disabled
4856
if ($request->type === 'paid' && ! Feature::active(AllowPaidPlugins::class)) {
4957
return to_route('customer.plugins.create')

app/Http/Controllers/CustomerPurchasedPluginsController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public function index(): View
2626
// Team plugins for team members
2727
$teamPlugins = collect();
2828
$teamOwnerName = null;
29-
$teamMembership = $user->teamMembership;
29+
$teamMembership = $user->activeTeamMembership();
3030

3131
if ($teamMembership) {
3232
$teamOwner = $teamMembership->team->owner;

app/Http/Controllers/TeamUserController.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,8 @@ public function invite(InviteTeamUserRequest $request): RedirectResponse
5252
return back()->with('error', 'This email has already been invited or is an active member.');
5353
}
5454

55-
// Hard cap at 10 for initial release
5655
if ($team->isOverIncludedLimit()) {
57-
return back()->with('error', 'Your team has reached the maximum of 10 members. Need more seats? Contact us.');
56+
return back()->with('show_add_seats', true);
5857
}
5958

6059
$member = $team->users()->create([

app/Livewire/TeamManager.php

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace App\Livewire;
44

5+
use App\Enums\Subscription;
6+
use App\Enums\TeamUserStatus;
57
use App\Models\Team;
68
use Livewire\Component;
79

@@ -14,17 +16,126 @@ public function mount(Team $team): void
1416
$this->team = $team;
1517
}
1618

19+
public function addSeats(int $count = 1): void
20+
{
21+
$owner = $this->team->owner;
22+
$subscription = $owner->subscription();
23+
24+
if (! $subscription) {
25+
return;
26+
}
27+
28+
// Determine the correct extra seat price based on subscription interval
29+
$planPriceId = $subscription->stripe_price;
30+
31+
if (! $planPriceId) {
32+
foreach ($subscription->items as $item) {
33+
if (! Subscription::isExtraSeatPrice($item->stripe_price)) {
34+
$planPriceId = $item->stripe_price;
35+
break;
36+
}
37+
}
38+
}
39+
40+
$isMonthly = $planPriceId === config('subscriptions.plans.max.stripe_price_id_monthly');
41+
$interval = $isMonthly ? 'month' : 'year';
42+
$priceId = Subscription::extraSeatStripePriceId($interval);
43+
44+
if (! $priceId) {
45+
return;
46+
}
47+
48+
// Check if subscription already has this price item
49+
$existingItem = $subscription->items->firstWhere('stripe_price', $priceId);
50+
51+
if ($existingItem) {
52+
$subscription->incrementAndInvoice($count, $priceId);
53+
} else {
54+
$subscription->addPriceAndInvoice($priceId, $count);
55+
}
56+
57+
$this->team->increment('extra_seats', $count);
58+
$this->team->refresh();
59+
60+
$this->dispatch('seats-updated');
61+
}
62+
63+
public function removeSeats(int $count = 1): void
64+
{
65+
if ($this->team->extra_seats < $count) {
66+
return;
67+
}
68+
69+
// Don't allow removing seats if it would go below occupied count
70+
$newCapacity = $this->team->totalSeatCapacity() - $count;
71+
if ($newCapacity < $this->team->occupiedSeatCount()) {
72+
return;
73+
}
74+
75+
$owner = $this->team->owner;
76+
$subscription = $owner->subscription();
77+
78+
if (! $subscription) {
79+
return;
80+
}
81+
82+
$planPriceId = $subscription->stripe_price;
83+
84+
if (! $planPriceId) {
85+
foreach ($subscription->items as $item) {
86+
if (! Subscription::isExtraSeatPrice($item->stripe_price)) {
87+
$planPriceId = $item->stripe_price;
88+
break;
89+
}
90+
}
91+
}
92+
93+
$isMonthly = $planPriceId === config('subscriptions.plans.max.stripe_price_id_monthly');
94+
$interval = $isMonthly ? 'month' : 'year';
95+
$priceId = Subscription::extraSeatStripePriceId($interval);
96+
97+
if (! $priceId) {
98+
return;
99+
}
100+
101+
$existingItem = $subscription->items->firstWhere('stripe_price', $priceId);
102+
103+
if ($existingItem) {
104+
if ($existingItem->quantity <= $count) {
105+
$subscription->removePrice($priceId);
106+
} else {
107+
$subscription->decrementQuantity($count, $priceId);
108+
}
109+
}
110+
111+
$this->team->decrement('extra_seats', $count);
112+
$this->team->refresh();
113+
114+
$this->dispatch('seats-updated');
115+
}
116+
17117
public function render()
18118
{
19119
$this->team->refresh();
20120
$this->team->load('users');
21121

22-
$activeMembers = $this->team->users->where('status', \App\Enums\TeamUserStatus::Active);
23-
$pendingInvitations = $this->team->users->where('status', \App\Enums\TeamUserStatus::Pending);
122+
$activeMembers = $this->team->users->where('status', TeamUserStatus::Active);
123+
$pendingInvitations = $this->team->users->where('status', TeamUserStatus::Pending);
124+
125+
$extraSeatPriceYearly = config('subscriptions.plans.max.extra_seat_price_yearly', 4);
126+
$extraSeatPriceMonthly = config('subscriptions.plans.max.extra_seat_price_monthly', 5);
127+
128+
$removableSeats = min(
129+
$this->team->extra_seats,
130+
$this->team->totalSeatCapacity() - $this->team->occupiedSeatCount()
131+
);
24132

25133
return view('livewire.team-manager', [
26134
'activeMembers' => $activeMembers,
27135
'pendingInvitations' => $pendingInvitations,
136+
'extraSeatPriceYearly' => $extraSeatPriceYearly,
137+
'extraSeatPriceMonthly' => $extraSeatPriceMonthly,
138+
'removableSeats' => max(0, $removableSeats),
28139
]);
29140
}
30141
}

app/Models/Plugin.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ public function isFeatured(): bool
280280

281281
public function isOfficial(): bool
282282
{
283-
return ($this->is_official ?? false) || str_starts_with($this->name, 'nativephp/');
283+
return $this->is_official ?? false;
284284
}
285285

286286
public function isSatisSynced(): bool

app/Models/Team.php

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class Team extends Model
1515
'user_id',
1616
'name',
1717
'is_suspended',
18+
'extra_seats',
1819
];
1920

2021
/**
@@ -56,14 +57,34 @@ public function activeUserCount(): int
5657
return $this->activeUsers()->count();
5758
}
5859

60+
public function includedSeats(): int
61+
{
62+
return config('subscriptions.plans.max.included_seats', 10);
63+
}
64+
65+
public function totalSeatCapacity(): int
66+
{
67+
return $this->includedSeats() + ($this->extra_seats ?? 0);
68+
}
69+
70+
public function occupiedSeatCount(): int
71+
{
72+
return $this->activeUserCount() + $this->pendingInvitations()->count();
73+
}
74+
75+
public function availableSeats(): int
76+
{
77+
return max(0, $this->totalSeatCapacity() - $this->occupiedSeatCount());
78+
}
79+
5980
public function isOverIncludedLimit(): bool
6081
{
61-
return $this->activeUserCount() >= 10;
82+
return $this->occupiedSeatCount() >= $this->totalSeatCapacity();
6283
}
6384

6485
public function extraSeatsCount(): int
6586
{
66-
return max(0, $this->activeUserCount() - 10);
87+
return max(0, $this->activeUserCount() - $this->includedSeats());
6788
}
6889

6990
public function suspend(): bool

app/Models/User.php

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,12 @@ public function ownedTeam(): HasOne
4545
return $this->hasOne(Team::class);
4646
}
4747

48-
/**
49-
* @return HasOne<TeamUser>
50-
*/
51-
public function teamMembership(): HasOne
52-
{
53-
return $this->hasOne(TeamUser::class)->where('status', 'active');
54-
}
55-
5648
/**
5749
* Get the team owner if this user is an active team member.
5850
*/
5951
public function getTeamOwner(): ?self
6052
{
61-
$membership = $this->teamMembership;
53+
$membership = $this->activeTeamMembership();
6254

6355
if (! $membership) {
6456
return null;
@@ -203,6 +195,12 @@ public function hasMaxAccess(): bool
203195

204196
public function hasActiveUltraSubscription(): bool
205197
{
198+
$subscription = $this->subscription();
199+
200+
if (! $subscription || $subscription->is_comped) {
201+
return false;
202+
}
203+
206204
return $this->subscribedToPrice(array_filter([
207205
config('subscriptions.plans.max.stripe_price_id'),
208206
config('subscriptions.plans.max.stripe_price_id_monthly'),

config/database.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
'strict' => true,
6060
'engine' => null,
6161
'options' => extension_loaded('pdo_mysql') ? array_filter([
62-
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
62+
Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
6363
]) : [],
6464
],
6565

resources/views/components/dashboard-card.blade.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
'description' => null,
1010
'badge' => null,
1111
'badgeColor' => 'green',
12+
'secondBadge' => null,
13+
'secondBadgeColor' => 'yellow',
1214
])
1315

1416
@php
@@ -45,7 +47,7 @@
4547
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
4648
{{ $title }}
4749
</dt>
48-
<dd class="flex items-baseline">
50+
<dd class="flex flex-wrap items-baseline gap-y-1">
4951
@if($count !== null)
5052
<span class="text-2xl font-semibold text-gray-900 dark:text-white">
5153
{{ $count }}
@@ -55,9 +57,18 @@
5557
{{ $value }}
5658
</span>
5759
@endif
58-
@if($badge)
59-
<span class="{{ $badgeClasses[$badgeColor] ?? $badgeClasses['green'] }} ml-2 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
60-
{{ $badge }}
60+
@if($badge || $secondBadge)
61+
<span class="flex w-full items-center gap-1.5 lg:ml-2 lg:w-auto">
62+
@if($badge)
63+
<span class="{{ $badgeClasses[$badgeColor] ?? $badgeClasses['green'] }} inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
64+
{{ $badge }}
65+
</span>
66+
@endif
67+
@if($secondBadge)
68+
<span class="{{ $badgeClasses[$secondBadgeColor] ?? $badgeClasses['yellow'] }} inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium">
69+
{{ $secondBadge }}
70+
</span>
71+
@endif
6172
</span>
6273
@endif
6374
</dd>

0 commit comments

Comments
 (0)