Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8141111
Ultra
simonhamp Feb 23, 2026
c0cc24c
Add Ultra teams, per-seat billing, and plugin access system
simonhamp Mar 5, 2026
0880f8f
Teams
simonhamp Mar 5, 2026
ae9f58e
Fix duplicate ownedTeam method and restore team plugin access logic
simonhamp Mar 5, 2026
06c56d0
Per-seat billing UI, comped sub exclusion, and plugin submission flow…
simonhamp Mar 12, 2026
e5d7a8b
Add Ultra upgrade flow with swap, upsell banner, and confirmation modal
simonhamp Mar 19, 2026
18deb57
Add comped Ultra subscription command, config, and plugin access tests
simonhamp Mar 22, 2026
5a82526
Add Max-to-Ultra announcement email, command, and tests
simonhamp Mar 22, 2026
176b0f6
Add Ultra upgrade promotion email, command, and tests
simonhamp Mar 22, 2026
b116520
Add license holder promo, Plugin Dev Kit benefit, and monthly billing…
simonhamp Mar 23, 2026
d086097
Merge remote-tracking branch 'origin/main' into ultra
simonhamp Mar 24, 2026
c467039
Dashboard UI overhaul: Flux components, license renewal, plugin submi…
simonhamp Mar 24, 2026
49d7855
Move namespace check before plugin creation in submission flow
simonhamp Mar 24, 2026
7d8910e
Fix CI failures: Pint formatting, test compatibility, and team plugin…
simonhamp Mar 24, 2026
e1fc7e4
Merge remote-tracking branch 'origin/main' into ultra
simonhamp Mar 24, 2026
29b9c37
Deferred repo loading, cached GitHub API, Flux Pro searchable selects
simonhamp Mar 24, 2026
2003c69
Merge remote-tracking branch 'origin/main' into ultra
simonhamp Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
59 changes: 59 additions & 0 deletions app/Console/Commands/CompUltraSubscription.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace App\Console\Commands;

use App\Enums\Subscription;
use App\Models\User;
use Illuminate\Console\Command;

class CompUltraSubscription extends Command
{
protected $signature = 'ultra:comp {email : The email address of the user to comp}';

protected $description = 'Create a comped Ultra subscription for a user using the dedicated $0 Stripe price';

public function handle(): int
{
$compedPriceId = config('subscriptions.plans.max.stripe_price_id_comped');

if (! $compedPriceId) {
$this->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;
}
}
125 changes: 125 additions & 0 deletions app/Console/Commands/MarkCompedSubscriptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;
use Laravel\Cashier\Subscription;

class MarkCompedSubscriptions extends Command
{
protected $signature = 'subscriptions:mark-comped
{file : Path to a CSV file containing email addresses (one per line or in an "email" column)}';

protected $description = 'Mark subscriptions as comped for email addresses in a CSV file';

public function handle(): int
{
$path = $this->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<string>
*/
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);
}
}
59 changes: 59 additions & 0 deletions app/Console/Commands/SendMaxToUltraAnnouncement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace App\Console\Commands;

use App\Models\User;
use App\Notifications\MaxToUltraAnnouncement;
use Illuminate\Console\Command;

class SendMaxToUltraAnnouncement extends Command
{
protected $signature = 'ultra:send-announcement
{--dry-run : Show what would be sent without actually sending}';

protected $description = 'Send a one-time announcement email to paying Max subscribers about the Ultra upgrade';

public function handle(): int
{
$dryRun = $this->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;
}
}
78 changes: 78 additions & 0 deletions app/Console/Commands/SendUltraLicenseHolderPromotion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace App\Console\Commands;

use App\Enums\Subscription;
use App\Models\License;
use App\Notifications\UltraLicenseHolderPromotion;
use Illuminate\Console\Command;

class SendUltraLicenseHolderPromotion extends Command
{
protected $signature = 'ultra:send-license-holder-promo
{--dry-run : Show what would be sent without actually sending}';

protected $description = 'Send a promotional email to license holders without an active subscription encouraging them to subscribe to Ultra';

public function handle(): int
{
$dryRun = $this->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;
}
}
Loading
Loading