Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
292 changes: 120 additions & 172 deletions .cursor/rules/laravel-boost.mdc

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ 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_EXTRA_SEAT_PRICE_ID=
STRIPE_EXTRA_SEAT_PRICE_ID_MONTHLY=
STRIPE_FOREVER_PRICE_ID=
STRIPE_TRIAL_PRICE_ID=
STRIPE_MINI_PAYMENT_LINK=
Expand Down
292 changes: 120 additions & 172 deletions .github/copilot-instructions.md

Large diffs are not rendered by default.

292 changes: 120 additions & 172 deletions .junie/guidelines.md

Large diffs are not rendered by default.

375 changes: 375 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

292 changes: 120 additions & 172 deletions CLAUDE.md

Large diffs are not rendered by default.

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);
}
}
37 changes: 32 additions & 5 deletions app/Enums/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +56,7 @@ 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,
default => throw new RuntimeException("Unknown Stripe price id: {$priceId}"),
Expand All @@ -57,7 +80,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) {
Expand All @@ -68,6 +91,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");
}

Expand Down
9 changes: 9 additions & 0 deletions app/Enums/TeamUserRole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace App\Enums;

enum TeamUserRole: string
{
case Owner = 'owner';
case Member = 'member';
}
10 changes: 10 additions & 0 deletions app/Enums/TeamUserStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace App\Enums;

enum TeamUserStatus: string
{
case Pending = 'pending';
case Active = 'active';
case Removed = 'removed';
}
39 changes: 39 additions & 0 deletions app/Http/Controllers/Api/PluginAccessController.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,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', \App\Enums\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;
}
}
27 changes: 27 additions & 0 deletions app/Http/Controllers/Auth/CustomerAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\Http\RedirectResponse;
Expand Down Expand Up @@ -46,6 +48,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) {
Expand Down Expand Up @@ -77,6 +82,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) {
Expand Down Expand Up @@ -155,4 +163,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}!");
}
}
}
Loading
Loading