Skip to content

Commit c0cc24c

Browse files
simonhampclaude
andcommitted
Add Ultra teams, per-seat billing, and plugin access system
- Team management with Livewire (create teams, invite/remove members, add/remove seats) - Per-seat billing via Stripe with confirmation modals and loading states - Free official plugins for Ultra subscribers ($0 pricing, skip Stripe checkout) - Team member plugin access (inherit owner's licenses, revoked on removal) - Satis API support for team plugin downloads - Team plugins section on Purchased Plugins page - Dashboard Premium Plugins count includes team plugins - Ultra nav link in mobile and desktop menus - Comped subscription tracking (is_comped field + MarkCompedSubscriptions command) - Plugin marketplace shows "Free with Ultra" badge for official plugins - Comprehensive test coverage for all features Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8141111 commit c0cc24c

44 files changed

Lines changed: 3591 additions & 936 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/rules/laravel-boost.mdc

Lines changed: 120 additions & 172 deletions
Large diffs are not rendered by default.

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ STRIPE_PRO_PRICE_ID_EAP=
6969
STRIPE_MAX_PRICE_ID=
7070
STRIPE_MAX_PRICE_ID_MONTHLY=
7171
STRIPE_MAX_PRICE_ID_EAP=
72+
STRIPE_EXTRA_SEAT_PRICE_ID=
73+
STRIPE_EXTRA_SEAT_PRICE_ID_MONTHLY=
7274
STRIPE_FOREVER_PRICE_ID=
7375
STRIPE_TRIAL_PRICE_ID=
7476
STRIPE_MINI_PAYMENT_LINK=

.github/copilot-instructions.md

Lines changed: 120 additions & 172 deletions
Large diffs are not rendered by default.

.junie/guidelines.md

Lines changed: 120 additions & 172 deletions
Large diffs are not rendered by default.

AGENTS.md

Lines changed: 375 additions & 0 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 120 additions & 172 deletions
Large diffs are not rendered by default.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\User;
6+
use Illuminate\Console\Command;
7+
use Laravel\Cashier\Subscription;
8+
9+
class MarkCompedSubscriptions extends Command
10+
{
11+
protected $signature = 'subscriptions:mark-comped
12+
{file : Path to a CSV file containing email addresses (one per line or in an "email" column)}';
13+
14+
protected $description = 'Mark subscriptions as comped for email addresses in a CSV file';
15+
16+
public function handle(): int
17+
{
18+
$path = $this->argument('file');
19+
20+
if (! file_exists($path)) {
21+
$this->error("File not found: {$path}");
22+
23+
return self::FAILURE;
24+
}
25+
26+
$emails = $this->parseEmails($path);
27+
28+
if (empty($emails)) {
29+
$this->error('No valid email addresses found in the file.');
30+
31+
return self::FAILURE;
32+
}
33+
34+
$this->info('Found '.count($emails).' email(s) to process.');
35+
36+
$updated = 0;
37+
$skipped = [];
38+
39+
foreach ($emails as $email) {
40+
$user = User::where('email', $email)->first();
41+
42+
if (! $user) {
43+
$skipped[] = "{$email} — user not found";
44+
45+
continue;
46+
}
47+
48+
$subscription = Subscription::where('user_id', $user->id)
49+
->where('stripe_status', 'active')
50+
->first();
51+
52+
if (! $subscription) {
53+
$skipped[] = "{$email} — no active subscription";
54+
55+
continue;
56+
}
57+
58+
if ($subscription->is_comped) {
59+
$skipped[] = "{$email} — already marked as comped";
60+
61+
continue;
62+
}
63+
64+
$subscription->update(['is_comped' => true]);
65+
$updated++;
66+
$this->info("Marked {$email} as comped (subscription #{$subscription->id})");
67+
}
68+
69+
if (count($skipped) > 0) {
70+
$this->warn('Skipped:');
71+
foreach ($skipped as $reason) {
72+
$this->warn(" - {$reason}");
73+
}
74+
}
75+
76+
$this->info("Done. {$updated} subscription(s) marked as comped.");
77+
78+
return self::SUCCESS;
79+
}
80+
81+
/**
82+
* Parse email addresses from a CSV file.
83+
* Supports: plain list (one email per line), or CSV with an "email" column header.
84+
*
85+
* @return array<string>
86+
*/
87+
private function parseEmails(string $path): array
88+
{
89+
$handle = fopen($path, 'r');
90+
91+
if (! $handle) {
92+
return [];
93+
}
94+
95+
$emails = [];
96+
$emailColumnIndex = null;
97+
$isFirstRow = true;
98+
99+
while (($row = fgetcsv($handle)) !== false) {
100+
if ($isFirstRow) {
101+
$isFirstRow = false;
102+
$headers = array_map(fn ($h) => strtolower(trim($h)), $row);
103+
$emailColumnIndex = array_search('email', $headers);
104+
105+
// If the first row looks like an email itself (no header), treat it as data
106+
if ($emailColumnIndex === false && filter_var(trim($row[0]), FILTER_VALIDATE_EMAIL)) {
107+
$emailColumnIndex = 0;
108+
$emails[] = strtolower(trim($row[0]));
109+
}
110+
111+
continue;
112+
}
113+
114+
$value = trim($row[$emailColumnIndex] ?? '');
115+
116+
if (filter_var($value, FILTER_VALIDATE_EMAIL)) {
117+
$emails[] = strtolower($value);
118+
}
119+
}
120+
121+
fclose($handle);
122+
123+
return array_unique($emails);
124+
}
125+
}

app/Enums/Subscription.php

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,35 @@ enum Subscription: string
1414

1515
public static function fromStripeSubscription(\Stripe\Subscription $subscription): self
1616
{
17-
$priceId = $subscription->items->first()?->price->id;
17+
// Iterate items, skipping extra seat prices (multi-item subscriptions)
18+
foreach ($subscription->items as $item) {
19+
$priceId = $item->price->id;
1820

19-
if (! $priceId) {
20-
throw new RuntimeException('Could not resolve Stripe price id from subscription object.');
21+
if (self::isExtraSeatPrice($priceId)) {
22+
continue;
23+
}
24+
25+
return self::fromStripePriceId($priceId);
2126
}
2227

23-
return self::fromStripePriceId($priceId);
28+
throw new RuntimeException('Could not resolve a plan price id from subscription items.');
29+
}
30+
31+
public static function isExtraSeatPrice(string $priceId): bool
32+
{
33+
return in_array($priceId, array_filter([
34+
config('subscriptions.plans.max.stripe_extra_seat_price_id'),
35+
config('subscriptions.plans.max.stripe_extra_seat_price_id_monthly'),
36+
]));
37+
}
38+
39+
public static function extraSeatStripePriceId(string $interval): ?string
40+
{
41+
return match ($interval) {
42+
'year' => config('subscriptions.plans.max.stripe_extra_seat_price_id'),
43+
'month' => config('subscriptions.plans.max.stripe_extra_seat_price_id_monthly'),
44+
default => null,
45+
};
2446
}
2547

2648
public static function fromStripePriceId(string $priceId): self

app/Http/Controllers/Api/PluginAccessController.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,28 @@ protected function getAccessiblePlugins(User $user): array
151151
}
152152
}
153153

154+
// Plugins accessible via team membership
155+
$teamOwner = $user->getTeamOwner();
156+
157+
if ($teamOwner) {
158+
$teamPlugins = $teamOwner->pluginLicenses()
159+
->active()
160+
->with('plugin:id,name')
161+
->get()
162+
->pluck('plugin')
163+
->filter()
164+
->unique('id');
165+
166+
foreach ($teamPlugins as $plugin) {
167+
if (! collect($plugins)->contains('name', $plugin->name)) {
168+
$plugins[] = [
169+
'name' => $plugin->name,
170+
'access' => 'team',
171+
];
172+
}
173+
}
174+
}
175+
154176
return $plugins;
155177
}
156178
}

app/Http/Controllers/CartController.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Models\Cart;
56
use App\Models\Plugin;
67
use App\Models\PluginBundle;
8+
use App\Models\PluginLicense;
79
use App\Models\Product;
810
use App\Services\CartService;
911
use Illuminate\Http\JsonResponse;
@@ -264,6 +266,11 @@ public function checkout(Request $request): RedirectResponse
264266
// Refresh prices
265267
$this->cartService->refreshPrices($cart);
266268

269+
// If total is $0, skip Stripe entirely and create licenses directly
270+
if ($cart->getSubtotal() === 0) {
271+
return $this->processFreeCheckout($cart, $user);
272+
}
273+
267274
try {
268275
$session = $this->createMultiItemCheckoutSession($cart, $user);
269276

@@ -285,6 +292,22 @@ public function checkout(Request $request): RedirectResponse
285292

286293
public function success(Request $request): View|RedirectResponse
287294
{
295+
if ($request->query('free')) {
296+
$user = Auth::user();
297+
298+
$cart = Cart::where('user_id', $user->id)
299+
->whereNotNull('completed_at')
300+
->latest('completed_at')
301+
->with('items.plugin', 'items.pluginBundle.plugins', 'items.product')
302+
->first();
303+
304+
return view('cart.success', [
305+
'sessionId' => null,
306+
'isFreeCheckout' => true,
307+
'cart' => $cart,
308+
]);
309+
}
310+
288311
$sessionId = $request->query('session_id');
289312

290313
// Validate session ID exists and looks like a real Stripe session ID
@@ -297,6 +320,51 @@ public function success(Request $request): View|RedirectResponse
297320

298321
return view('cart.success', [
299322
'sessionId' => $sessionId,
323+
'isFreeCheckout' => false,
324+
'cart' => null,
325+
]);
326+
}
327+
328+
protected function processFreeCheckout(Cart $cart, $user): RedirectResponse
329+
{
330+
$cart->load('items.plugin', 'items.pluginBundle.plugins', 'items.product');
331+
332+
foreach ($cart->items as $item) {
333+
if ($item->isBundle()) {
334+
foreach ($item->pluginBundle->plugins as $plugin) {
335+
$this->createFreePluginLicense($user, $plugin);
336+
}
337+
} elseif (! $item->isProduct() && $item->plugin) {
338+
$this->createFreePluginLicense($user, $item->plugin);
339+
}
340+
}
341+
342+
$cart->markAsCompleted();
343+
344+
$user->getPluginLicenseKey();
345+
346+
Log::info('Free checkout completed', [
347+
'cart_id' => $cart->id,
348+
'user_id' => $user->id,
349+
'item_count' => $cart->items->count(),
350+
]);
351+
352+
return to_route('cart.success', ['free' => 1]);
353+
}
354+
355+
protected function createFreePluginLicense($user, Plugin $plugin): void
356+
{
357+
if ($user->pluginLicenses()->forPlugin($plugin)->active()->exists()) {
358+
return;
359+
}
360+
361+
PluginLicense::create([
362+
'user_id' => $user->id,
363+
'plugin_id' => $plugin->id,
364+
'price_paid' => 0,
365+
'currency' => 'USD',
366+
'is_grandfathered' => false,
367+
'purchased_at' => now(),
300368
]);
301369
}
302370

0 commit comments

Comments
 (0)