Skip to content

Commit 2ca28ed

Browse files
simonhampclaude
andauthored
Ultra: Teams, per-seat billing, and plugin access (#274)
* Ultra * 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> * Teams * Fix duplicate ownedTeam method and restore team plugin access logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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> * Add Ultra upgrade flow with swap, upsell banner, and confirmation modal Existing subscribers can now upgrade to Ultra via swap() instead of creating a duplicate subscription. The pricing page detects active subscriptions and shows an upgrade button with a confirmation modal that includes monthly/annual interval selection. A dashboard upsell banner directs non-Ultra subscribers to the pricing page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add comped Ultra subscription command, config, and plugin access tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add Max-to-Ultra announcement email, command, and tests One-time notification for paying Max subscribers about the Ultra rename. Includes dry-run support, comped/Pro exclusion, and full test coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add Ultra upgrade promotion email, command, and tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add license holder promo, Plugin Dev Kit benefit, and monthly billing option Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Dashboard UI overhaul: Flux components, license renewal, plugin submission, teams - Convert dashboard cards, sub-license manager, purchase history, team manager, and settings to Flux components - Add masked license key component to prevent leaking via screen share - Move license renewal under /dashboard with auth, add yearly ($250) and monthly ($35) Ultra upgrade options - Split plugin repo selector into account picker + searchable repo dropdown - Remove Stripe Connect section from My Plugins (moved to Settings) - Fix Carbon 3 signed float issue with diffInDays() on license expiry - Add create/edit sub-license modals with Livewire - Add sidebar team management link for Ultra subscribers - Add comprehensive tests for all new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move namespace check before plugin creation in submission flow Pre-validate composer.json and namespace availability by fetching from GitHub before creating any plugin record. Also split repo selector into account picker + searchable dropdown, fix dropdown click-outside behavior, and remove Stripe Connect section from My Plugins index. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI failures: Pint formatting, test compatibility, and team plugins view Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Deferred repo loading, cached GitHub API, Flux Pro searchable selects Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Ultra teams: dashboard team pages, sidebar improvements, pricing & notification updates - Add per-team detail page showing membership benefits and accessible plugins - Show each team by name in sidebar and dropdown menu (supports multiple teams) - Collapse sidebar groups by default with spacing between sections - Add pro-rata pricing breakdown to add-seats modal - Show billing-interval-specific pricing (monthly or yearly, not both) - Add Ultra link with New badge to public nav, mobile menu, and footer - Replace third-party plugin discounts with 90% marketplace revenue messaging - Fix missing validation message when team seat limit is hit - Fix team members incorrectly getting subscriber-tier pricing - Add plugin access check for team owner's purchased plugins - Update cancellation FAQ with plugin retention details - Add tests for team detail page, plugin access, and pricing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Team dashboard refinements: billing summary, merged plugin list, Flux confirmation modals - Add billing summary card showing Ultra plan cost, extra seats, and estimated next bill from Stripe - Merge official and owner-purchased plugins into single de-duplicated list on team member page - Remove team plugins section from Purchased Plugins page (now on team detail page) - Replace browser confirm() with Flux modals for removing members and cancelling invitations - Link GitHub repo benefit to Integrations page - Fix DashboardLayoutTest assertions for new team name sidebar items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix re-inviting previously removed team members Reuse the existing Removed team_users record instead of inserting a duplicate, which violated the team_id+email unique constraint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace hasMaxTierAccess() with hasActiveUltraSubscription() for Ultra-aware platform fee logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix plugin icon button layout and update UltraPluginAccessTest for third-party pricing rules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add team seat validation, owner self-invite prevention, and fix seat counting to include owner Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix billing summary to use annualized seat price instead of raw Stripe unit amount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Reduce included seats to 5 and show unused seat count in remove modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove Anystack license creation from subscription handler Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update seat counts to 5, add Ultra section to sponsor page, and update discount copy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add EAP pricing on /ultra page, back arrow on license page, and Ultra upsell banner on dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add upgrade proration preview, persist interval toggle, refresh /ultra page hero and copy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI test failures from main merge - Fix webhook mock in CustomerPluginReviewChecksTest to return 422 so webhook correctly appears as a failing check - Add missing required supportChannel field in PluginCreateTest Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ec1b4d7 commit 2ca28ed

96 files changed

Lines changed: 8041 additions & 1894 deletions

File tree

Some content is hidden

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

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ STRIPE_MINI_PRICE_ID_EAP=
6767
STRIPE_PRO_PRICE_ID=
6868
STRIPE_PRO_PRICE_ID_EAP=
6969
STRIPE_MAX_PRICE_ID=
70+
STRIPE_MAX_PRICE_ID_MONTHLY=
7071
STRIPE_MAX_PRICE_ID_EAP=
72+
STRIPE_ULTRA_COMP_PRICE_ID=
73+
STRIPE_EXTRA_SEAT_PRICE_ID=
74+
STRIPE_EXTRA_SEAT_PRICE_ID_MONTHLY=
7175
STRIPE_FOREVER_PRICE_ID=
7276
STRIPE_TRIAL_PRICE_ID=
7377
STRIPE_MINI_PAYMENT_LINK=
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Enums\Subscription;
6+
use App\Models\User;
7+
use Illuminate\Console\Command;
8+
9+
class CompUltraSubscription extends Command
10+
{
11+
protected $signature = 'ultra:comp {email : The email address of the user to comp}';
12+
13+
protected $description = 'Create a comped Ultra subscription for a user using the dedicated $0 Stripe price';
14+
15+
public function handle(): int
16+
{
17+
$compedPriceId = config('subscriptions.plans.max.stripe_price_id_comped');
18+
19+
if (! $compedPriceId) {
20+
$this->error('STRIPE_ULTRA_COMP_PRICE_ID is not configured.');
21+
22+
return self::FAILURE;
23+
}
24+
25+
$email = $this->argument('email');
26+
$user = User::where('email', $email)->first();
27+
28+
if (! $user) {
29+
$this->error("User not found: {$email}");
30+
31+
return self::FAILURE;
32+
}
33+
34+
$existingSubscription = $user->subscription('default');
35+
36+
if ($existingSubscription && $existingSubscription->active()) {
37+
$currentPlan = 'unknown';
38+
39+
try {
40+
$currentPlan = Subscription::fromStripePriceId(
41+
$existingSubscription->items->first()?->stripe_price ?? $existingSubscription->stripe_price
42+
)->name();
43+
} catch (\Exception) {
44+
}
45+
46+
$this->error("User already has an active {$currentPlan} subscription. Cancel it first or use swap.");
47+
48+
return self::FAILURE;
49+
}
50+
51+
$user->createOrGetStripeCustomer();
52+
53+
$user->newSubscription('default', $compedPriceId)->create();
54+
55+
$this->info("Comped Ultra subscription created for {$email}.");
56+
57+
return self::SUCCESS;
58+
}
59+
}
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+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\User;
6+
use App\Notifications\MaxToUltraAnnouncement;
7+
use Illuminate\Console\Command;
8+
9+
class SendMaxToUltraAnnouncement extends Command
10+
{
11+
protected $signature = 'ultra:send-announcement
12+
{--dry-run : Show what would be sent without actually sending}';
13+
14+
protected $description = 'Send a one-time announcement email to paying Max subscribers about the Ultra upgrade';
15+
16+
public function handle(): int
17+
{
18+
$dryRun = $this->option('dry-run');
19+
20+
if ($dryRun) {
21+
$this->info('DRY RUN - No emails will be sent');
22+
}
23+
24+
$maxPriceIds = array_filter([
25+
config('subscriptions.plans.max.stripe_price_id'),
26+
config('subscriptions.plans.max.stripe_price_id_monthly'),
27+
config('subscriptions.plans.max.stripe_price_id_eap'),
28+
config('subscriptions.plans.max.stripe_price_id_discounted'),
29+
]);
30+
31+
$users = User::query()
32+
->whereHas('subscriptions', function ($query) use ($maxPriceIds) {
33+
$query->where('stripe_status', 'active')
34+
->where('is_comped', false)
35+
->whereIn('stripe_price', $maxPriceIds);
36+
})
37+
->get();
38+
39+
$this->info("Found {$users->count()} paying Max subscriber(s)");
40+
41+
$sent = 0;
42+
43+
foreach ($users as $user) {
44+
if ($dryRun) {
45+
$this->line("Would send to: {$user->email}");
46+
} else {
47+
$user->notify(new MaxToUltraAnnouncement);
48+
$this->line("Sent to: {$user->email}");
49+
}
50+
51+
$sent++;
52+
}
53+
54+
$this->newLine();
55+
$this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)");
56+
57+
return Command::SUCCESS;
58+
}
59+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Enums\Subscription;
6+
use App\Models\License;
7+
use App\Notifications\UltraLicenseHolderPromotion;
8+
use Illuminate\Console\Command;
9+
10+
class SendUltraLicenseHolderPromotion extends Command
11+
{
12+
protected $signature = 'ultra:send-license-holder-promo
13+
{--dry-run : Show what would be sent without actually sending}';
14+
15+
protected $description = 'Send a promotional email to license holders without an active subscription encouraging them to subscribe to Ultra';
16+
17+
public function handle(): int
18+
{
19+
$dryRun = $this->option('dry-run');
20+
21+
if ($dryRun) {
22+
$this->info('DRY RUN - No emails will be sent');
23+
}
24+
25+
$legacyLicenses = License::query()
26+
->whereNull('subscription_item_id')
27+
->whereHas('user')
28+
->with('user')
29+
->get();
30+
31+
// Group by user to avoid sending multiple emails to the same person
32+
$userLicenses = $legacyLicenses->groupBy('user_id');
33+
34+
$eligible = 0;
35+
$skipped = 0;
36+
37+
foreach ($userLicenses as $userId => $licenses) {
38+
$user = $licenses->first()->user;
39+
40+
if (! $user) {
41+
$skipped++;
42+
43+
continue;
44+
}
45+
46+
// Skip users who already have an active subscription
47+
$hasActiveSubscription = $user->subscriptions()
48+
->where('stripe_status', 'active')
49+
->exists();
50+
51+
if ($hasActiveSubscription) {
52+
$this->line("Skipping {$user->email} - already has active subscription");
53+
$skipped++;
54+
55+
continue;
56+
}
57+
58+
$license = $licenses->sortBy('created_at')->first();
59+
$planName = Subscription::from($license->policy_name)->name();
60+
61+
if ($dryRun) {
62+
$this->line("Would send to: {$user->email} ({$planName})");
63+
} else {
64+
$user->notify(new UltraLicenseHolderPromotion($planName));
65+
$this->line("Sent to: {$user->email} ({$planName})");
66+
}
67+
68+
$eligible++;
69+
}
70+
71+
$this->newLine();
72+
$this->info("Found {$eligible} eligible license holder(s)");
73+
$this->info($dryRun ? "Would send: {$eligible} email(s)" : "Sent: {$eligible} email(s)");
74+
$this->info("Skipped: {$skipped} user(s)");
75+
76+
return Command::SUCCESS;
77+
}
78+
}

0 commit comments

Comments
 (0)