diff --git a/app/Console/Commands/BackfillMissingPayouts.php b/app/Console/Commands/BackfillMissingPayouts.php index 3001937f5..98d1bc355 100644 --- a/app/Console/Commands/BackfillMissingPayouts.php +++ b/app/Console/Commands/BackfillMissingPayouts.php @@ -42,7 +42,7 @@ public function handle(): int foreach ($licenses as $license) { $developerAccount = $license->plugin->developerAccount; - $split = PluginPayout::calculateSplit($license->price_paid, $developerAccount->platformFeePercent()); + $split = PluginPayout::calculateSplit($license->price_paid, $developerAccount->platformFeePercentFor($license)); $status = $developerAccount->canReceivePayouts() ? PayoutStatus::Pending diff --git a/app/Enums/PluginTier.php b/app/Enums/PluginTier.php index 30b952ad9..72f119d64 100644 --- a/app/Enums/PluginTier.php +++ b/app/Enums/PluginTier.php @@ -46,14 +46,17 @@ public function getPrices(): array self::Bronze => [ PriceTier::Regular->value => 2900, PriceTier::Subscriber->value => 1100, + PriceTier::Ultra->value => 2000, ], self::Silver => [ PriceTier::Regular->value => 4900, PriceTier::Subscriber->value => 1700, + PriceTier::Ultra->value => 3500, ], self::Gold => [ PriceTier::Regular->value => 9900, PriceTier::Subscriber->value => 3100, + PriceTier::Ultra->value => 7000, ], }; } diff --git a/app/Enums/PriceTier.php b/app/Enums/PriceTier.php index b982fb429..0f9f48980 100644 --- a/app/Enums/PriceTier.php +++ b/app/Enums/PriceTier.php @@ -7,6 +7,7 @@ enum PriceTier: string case Regular = 'regular'; case Subscriber = 'subscriber'; case Eap = 'eap'; + case Ultra = 'ultra'; public function label(): string { @@ -14,6 +15,7 @@ public function label(): string self::Regular => 'Regular', self::Subscriber => 'Subscriber', self::Eap => 'Early Access', + self::Ultra => 'Ultra', }; } @@ -23,6 +25,7 @@ public function description(): string self::Regular => 'Standard pricing for all customers', self::Subscriber => 'Discounted pricing for subscribers', self::Eap => 'Special pricing for Early Access Program customers', + self::Ultra => 'Discounted pricing for Ultra subscribers', }; } @@ -35,8 +38,9 @@ public function priority(): int { return match ($this) { self::Eap => 1, - self::Subscriber => 2, - self::Regular => 3, + self::Ultra => 2, + self::Subscriber => 3, + self::Regular => 4, }; } } diff --git a/app/Filament/Resources/PluginBundleResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/PluginBundleResource/RelationManagers/PricesRelationManager.php index a248c95ba..1dcdfe32b 100644 --- a/app/Filament/Resources/PluginBundleResource/RelationManagers/PricesRelationManager.php +++ b/app/Filament/Resources/PluginBundleResource/RelationManagers/PricesRelationManager.php @@ -24,7 +24,7 @@ public function form(Schema $schema): Schema ->options(PriceTier::class) ->required() ->default(PriceTier::Regular) - ->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access'), + ->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access, Ultra = Ultra subscribers'), Forms\Components\TextInput::make('amount') ->label('Price (cents)') @@ -59,6 +59,7 @@ public function table(Table $table): Table PriceTier::Regular => 'gray', PriceTier::Subscriber => 'info', PriceTier::Eap => 'success', + PriceTier::Ultra => 'warning', }) ->sortable(), diff --git a/app/Filament/Resources/PluginResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/PluginResource/RelationManagers/PricesRelationManager.php index eefd3b27e..ae82786f3 100644 --- a/app/Filament/Resources/PluginResource/RelationManagers/PricesRelationManager.php +++ b/app/Filament/Resources/PluginResource/RelationManagers/PricesRelationManager.php @@ -24,7 +24,7 @@ public function form(Schema $schema): Schema ->options(PriceTier::class) ->required() ->default(PriceTier::Regular) - ->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access'), + ->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access, Ultra = Ultra subscribers (the developer keeps 100% of these sales)'), Forms\Components\TextInput::make('amount') ->label('Price (cents)') @@ -59,6 +59,7 @@ public function table(Table $table): Table PriceTier::Regular => 'gray', PriceTier::Subscriber => 'info', PriceTier::Eap => 'success', + PriceTier::Ultra => 'warning', }) ->sortable(), diff --git a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php index 1a5228190..85590f824 100644 --- a/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php +++ b/app/Filament/Resources/ProductResource/RelationManagers/PricesRelationManager.php @@ -28,7 +28,7 @@ public function form(Schema $schema): Schema ->options(PriceTier::class) ->required() ->default(PriceTier::Regular) - ->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access'), + ->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access, Ultra = Ultra subscribers'), Forms\Components\TextInput::make('stripe_price_id') ->label('Stripe price ID') @@ -108,6 +108,7 @@ public function table(Table $table): Table PriceTier::Regular => 'gray', PriceTier::Subscriber => 'info', PriceTier::Eap => 'success', + PriceTier::Ultra => 'warning', }) ->sortable(), diff --git a/app/Filament/Resources/ThirdPartySaleResource.php b/app/Filament/Resources/ThirdPartySaleResource.php index f73303fbd..c45af73f1 100644 --- a/app/Filament/Resources/ThirdPartySaleResource.php +++ b/app/Filament/Resources/ThirdPartySaleResource.php @@ -80,6 +80,7 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('price_paid') ->label('Paid') ->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2)) + ->description(fn (PluginLicense $record): ?string => $record->wasSoldAtUltraPrice() ? 'Ultra price' : null) ->summarize(Tables\Columns\Summarizers\Sum::make('sum')->money('USD', divideBy: 100)) ->sortable(), diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php index 06b3b240c..d91b53ab9 100644 --- a/app/Http/Controllers/CartController.php +++ b/app/Http/Controllers/CartController.php @@ -34,7 +34,7 @@ public function show(Request $request): View // Refresh prices and notify of changes $priceChanges = $this->cartService->refreshPrices($cart); - $cart = $cart->fresh(['items.plugin.activePrice', 'items.plugin.user', 'items.pluginBundle.plugins', 'items.product.activePrice']); + $cart = $cart->fresh(['items.plugin.activePrice', 'items.plugin.user', 'items.pluginPrice', 'items.pluginBundle.plugins', 'items.product.activePrice']); // Get bundle IDs already in the cart $cartBundleIds = $cart->items() @@ -480,7 +480,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session 'currency' => strtolower($item->currency), 'unit_amount' => $item->bundle_price_at_addition, 'product_data' => [ - 'name' => $bundle->name.' (Bundle)', + 'name' => $item->getItemName(), 'description' => 'Includes: '.$pluginNames, ], ], @@ -523,7 +523,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session 'currency' => strtolower($item->currency), 'unit_amount' => $item->price_at_addition, 'product_data' => [ - 'name' => $plugin->name, + 'name' => $item->getItemName(), 'description' => $plugin->description ?? 'NativePHP Plugin', ], ], diff --git a/app/Http/Controllers/PluginDirectoryController.php b/app/Http/Controllers/PluginDirectoryController.php index 8dc00da4a..6ffc3d3df 100644 --- a/app/Http/Controllers/PluginDirectoryController.php +++ b/app/Http/Controllers/PluginDirectoryController.php @@ -79,6 +79,7 @@ public function show(string $vendor, string $package): View 'bundles' => $bundles, 'bestPrice' => $bestPrice, 'regularPrice' => $regularPrice, + 'ultraOfferPrice' => $user?->hasActiveUltraSubscription() ? null : $plugin->getUltraPrice(), 'hasDiscount' => $bestPrice && $regularPrice && $bestPrice->id !== $regularPrice->id, 'isAdminPreview' => (! $plugin->isApproved() || ! $plugin->is_active) && ($isAdmin || $isOwner), ]); diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index 896fc2c43..5c04c82dd 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Enums\PayoutStatus; +use App\Enums\PriceTier; use App\Enums\Subscription; use App\Models\Cart; use App\Models\CartItem; @@ -226,6 +227,7 @@ private function processCartPurchase(string $cartId): void { $cart = Cart::with([ 'items.plugin.developerAccount', + 'items.pluginPrice', 'items.pluginBundle.plugins.developerAccount', 'items.product', ])->find($cartId); @@ -350,7 +352,51 @@ private function processCartPluginItem(User $user, CartItem $item): void return; } - $this->createPluginLicense($user, $plugin, $item->price_at_addition); + $this->createPluginLicense($user, $plugin, $this->amountPaidFor($item), $item->pluginPrice?->tier); + } + + /** + * What the buyer paid for a plugin or bundle once any promotion code or coupon has come off. + * Developers are paid their share of this rather than of the listed price, so it has to match the charge. + * + * Stripe reports the discount against each invoice line, which checkout names after the cart + * item. If the item's line can't be found, the invoice's discount is shared out in proportion + * to the listed price instead, rounded down so the payouts for one invoice can never add up + * to more than was charged. + */ + private function amountPaidFor(CartItem $item): int + { + $listedAmount = $item->getItemPrice(); + + $invoiceDiscount = (int) collect($this->invoice->total_discount_amounts ?? [])->sum('amount'); + + if ($invoiceDiscount <= 0) { + return $listedAmount; + } + + $line = collect($this->invoice->lines->data ?? [])->first( + fn (StripeObject $line): bool => ($line->description ?? null) === $item->getItemName() + && ($line->amount ?? null) === $listedAmount + ); + + if ($line) { + $lineDiscount = (int) collect($line->discount_amounts ?? [])->sum('amount'); + + return max(0, $listedAmount - $lineDiscount); + } + + $subtotal = (int) ($this->invoice->subtotal ?? 0); + + if ($subtotal <= 0) { + return $listedAmount; + } + + Log::warning('Invoice line not found for discounted cart item; sharing the discount proportionally', [ + 'invoice_id' => $this->invoice->id, + 'cart_item_id' => $item->id, + ]); + + return intdiv($listedAmount * max(0, $subtotal - $invoiceDiscount), $subtotal); } private function processCartBundleItem(User $user, CartItem $item): void @@ -366,8 +412,8 @@ private function processCartBundleItem(User $user, CartItem $item): void // Load plugins with developer accounts if not already loaded $bundle->loadMissing('plugins.developerAccount'); - // Calculate proportional allocation based on price at time of addition - $allocations = $bundle->calculateProportionalAllocation($item->bundle_price_at_addition); + // Calculate proportional allocation based on what was paid for the bundle + $allocations = $bundle->calculateProportionalAllocation($this->amountPaidFor($item)); foreach ($bundle->plugins as $plugin) { // Check if license already exists for this invoice + plugin + bundle @@ -549,7 +595,7 @@ private function processPluginBundles(User $user, array $metadata): void } } - private function createPluginLicense(User $user, Plugin $plugin, int $amount): PluginLicense + private function createPluginLicense(User $user, Plugin $plugin, int $amount, ?PriceTier $priceTier = null): PluginLicense { $license = PluginLicense::create([ 'user_id' => $user->id, @@ -557,6 +603,7 @@ private function createPluginLicense(User $user, Plugin $plugin, int $amount): P 'stripe_invoice_id' => $this->invoice->id, 'stripe_payment_intent_id' => $this->invoice->payment_intent, 'price_paid' => $amount, + 'price_tier' => $priceTier, 'currency' => strtoupper($this->invoice->currency), 'is_grandfathered' => false, 'purchased_at' => now(), @@ -603,6 +650,7 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun * Payouts are 1:1 with paid sales. If the developer cannot yet receive payouts, * the payout is created as Held and promoted to Pending by the daily * payouts:process-eligible run once their Stripe Connect account is ready. + * Sales at the Ultra price carry no platform fee. */ private function createPayoutForLicense(PluginLicense $license, Plugin $plugin, int $amount): void { @@ -622,7 +670,7 @@ private function createPayoutForLicense(PluginLicense $license, Plugin $plugin, return; } - $split = PluginPayout::calculateSplit($amount, $plugin->developerAccount->platformFeePercent()); + $split = PluginPayout::calculateSplit($amount, $plugin->developerAccount->platformFeePercentFor($license)); PluginPayout::create([ 'plugin_license_id' => $license->id, diff --git a/app/Models/CartItem.php b/app/Models/CartItem.php index 3e770422b..275e5ae98 100644 --- a/app/Models/CartItem.php +++ b/app/Models/CartItem.php @@ -130,6 +130,10 @@ public function hasPriceChanged(): bool return $currentPrice->amount !== $this->price_at_addition; } + /** + * The name the item is sold under at checkout. Plugins and bundles are matched + * back to their Stripe invoice line by it, so it has to stay the same in both places. + */ public function getItemName(): string { if ($this->isProduct()) { diff --git a/app/Models/DeveloperAccount.php b/app/Models/DeveloperAccount.php index 3c8157a78..3932697b9 100644 --- a/app/Models/DeveloperAccount.php +++ b/app/Models/DeveloperAccount.php @@ -71,6 +71,19 @@ public function platformFeePercent(): int return 100 - $this->payout_percentage; } + /** + * The platform fee for one sale. A sale at the Ultra price carries no fee, + * so the developer is paid everything the buyer paid. + */ + public function platformFeePercentFor(PluginLicense $license): int + { + if ($license->wasSoldAtUltraPrice()) { + return 0; + } + + return $this->platformFeePercent(); + } + protected function casts(): array { return [ diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 16d9671d5..3f4cd0db9 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -113,6 +113,7 @@ protected static function booted(): void /** * Create or update prices based on the plugin's tier. + * Official plugins are included with Ultra, so they don't get an Ultra price. */ public function syncPricesFromTier(): void { @@ -122,6 +123,10 @@ public function syncPricesFromTier(): void $tierPrices = $this->tier->getPrices(); + if ($this->isOfficial()) { + unset($tierPrices[PriceTier::Ultra->value]); + } + foreach ($tierPrices as $priceTier => $amount) { $this->prices()->updateOrCreate( ['tier' => $priceTier], @@ -202,26 +207,44 @@ public function activePrice(): HasOne /** * Get the best (lowest) active price for a user based on their eligible tiers. * Returns null if no price exists for the user's eligible tiers. - * Third-party plugins never offer subscriber discounts — always regular price. + * The regular price wins a tie, so a discounted tier only applies when it is actually cheaper. */ public function getBestPriceForUser(?User $user): ?PluginPrice { - if (! $this->isOfficial()) { - $eligibleTiers = [PriceTier::Regular]; - } else { - $eligibleTiers = $user ? $user->getEligiblePriceTiers() : [PriceTier::Regular]; - } - // Get the lowest active price for the user's eligible tiers $bestPrice = $this->prices() ->active() - ->forTiers($eligibleTiers) + ->forTiers($this->getEligiblePriceTiersFor($user)) ->orderBy('amount', 'asc') + ->orderByRaw("CASE WHEN tier = 'regular' THEN 0 ELSE 1 END") ->first(); return $bestPrice; } + /** + * Get the price tiers a user can buy this plugin at. + * Third-party plugins never offer subscriber or EAP discounts — only Ultra subscribers pay less. + * Official plugins are included with Ultra, so they never use an Ultra price. + * + * @return array + */ + protected function getEligiblePriceTiersFor(?User $user): array + { + if (! $this->isOfficial()) { + return $user?->hasActiveUltraSubscription() + ? [PriceTier::Regular, PriceTier::Ultra] + : [PriceTier::Regular]; + } + + $eligibleTiers = $user ? $user->getEligiblePriceTiers() : [PriceTier::Regular]; + + return array_values(array_filter( + $eligibleTiers, + fn (PriceTier $tier): bool => $tier !== PriceTier::Ultra + )); + } + /** * Check if a user has access to at least one price tier for this plugin. */ @@ -241,6 +264,30 @@ public function getRegularPrice(): ?PluginPrice ->first() ?? $this->activePrice; } + /** + * Get the discounted price Ultra subscribers pay for a paid third-party plugin. + * Returns null if the plugin has no Ultra price, or it isn't lower than the regular price. + */ + public function getUltraPrice(): ?PluginPrice + { + if (! $this->isPaid() || $this->isOfficial()) { + return null; + } + + $ultraPrice = $this->prices() + ->active() + ->forTier(PriceTier::Ultra) + ->first(); + + $regularPrice = $this->getRegularPrice(); + + if (! $ultraPrice || ! $regularPrice || $ultraPrice->amount >= $regularPrice->amount) { + return null; + } + + return $ultraPrice; + } + /** * @return HasMany */ diff --git a/app/Models/PluginLicense.php b/app/Models/PluginLicense.php index 9f2c61660..4867c56f3 100644 --- a/app/Models/PluginLicense.php +++ b/app/Models/PluginLicense.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\PriceTier; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -61,6 +62,14 @@ public function wasPurchasedAsBundle(): bool return $this->plugin_bundle_id !== null; } + /** + * Ultra subscribers buy third-party plugins at a lower price, and the developer keeps all of it. + */ + public function wasSoldAtUltraPrice(): bool + { + return $this->price_tier === PriceTier::Ultra; + } + /** * The Stripe dashboard page for this purchase's payment, which also lists any refund. */ @@ -165,6 +174,7 @@ protected function casts(): array { return [ 'price_paid' => 'integer', + 'price_tier' => PriceTier::class, 'is_grandfathered' => 'boolean', 'purchased_at' => 'datetime', 'expires_at' => 'datetime', diff --git a/app/Models/PluginPrice.php b/app/Models/PluginPrice.php index 9b2c318c6..6e00986da 100644 --- a/app/Models/PluginPrice.php +++ b/app/Models/PluginPrice.php @@ -81,6 +81,11 @@ public function isEapTier(): bool return $this->tier === PriceTier::Eap; } + public function isUltraTier(): bool + { + return $this->tier === PriceTier::Ultra; + } + protected function casts(): array { return [ diff --git a/app/Models/User.php b/app/Models/User.php index ba4679feb..d90b78ee6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -395,6 +395,10 @@ public function getEligiblePriceTiers(): array $tiers[] = PriceTier::Subscriber; } + if ($this->hasActiveUltraSubscription()) { + $tiers[] = PriceTier::Ultra; + } + if ($this->isEapCustomer()) { $tiers[] = PriceTier::Eap; } diff --git a/database/factories/PluginPriceFactory.php b/database/factories/PluginPriceFactory.php index 640563e9b..2a7623b07 100644 --- a/database/factories/PluginPriceFactory.php +++ b/database/factories/PluginPriceFactory.php @@ -46,6 +46,13 @@ public function eap(): static ]); } + public function ultra(): static + { + return $this->state(fn (array $attributes) => [ + 'tier' => PriceTier::Ultra, + ]); + } + public function inactive(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_10_01_124505_add_price_tier_to_plugin_licenses_table.php b/database/migrations/2026_10_01_124505_add_price_tier_to_plugin_licenses_table.php new file mode 100644 index 000000000..974502be2 --- /dev/null +++ b/database/migrations/2026_10_01_124505_add_price_tier_to_plugin_licenses_table.php @@ -0,0 +1,28 @@ +string('price_tier')->nullable()->after('price_paid'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugin_licenses', function (Blueprint $table) { + $table->dropColumn('price_tier'); + }); + } +}; diff --git a/database/migrations/2026_10_01_124505_add_ultra_prices_to_third_party_plugins.php b/database/migrations/2026_10_01_124505_add_ultra_prices_to_third_party_plugins.php new file mode 100644 index 000000000..3bc55abd8 --- /dev/null +++ b/database/migrations/2026_10_01_124505_add_ultra_prices_to_third_party_plugins.php @@ -0,0 +1,51 @@ + 2000, + 'silver' => 3500, + 'gold' => 7000, + ]; + + foreach ($ultraPrices as $tier => $amount) { + DB::table('plugins') + ->where('is_official', false) + ->where('tier', $tier) + ->whereNotExists(fn (Builder $query) => $query + ->from('plugin_prices') + ->whereColumn('plugin_prices.plugin_id', 'plugins.id') + ->where('plugin_prices.tier', 'ultra')) + ->pluck('id') + ->each(fn (int $pluginId) => DB::table('plugin_prices')->insert([ + 'plugin_id' => $pluginId, + 'tier' => 'ultra', + 'amount' => $amount, + 'currency' => 'USD', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ])); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('plugin_prices')->where('tier', 'ultra')->delete(); + } +}; diff --git a/resources/views/cart/show.blade.php b/resources/views/cart/show.blade.php index 604d26de6..4ea9054dc 100644 --- a/resources/views/cart/show.blade.php +++ b/resources/views/cart/show.blade.php @@ -241,6 +241,8 @@ class="flex gap-4 p-6"

@if ($item->plugin->isOfficial() && auth()->user()?->hasUltraAccess()) Included with Ultra + @elseif ($item->pluginPrice?->isUltraTier()) + Ultra price @endif diff --git a/resources/views/customer/ultra/index.blade.php b/resources/views/customer/ultra/index.blade.php index b83b5c48a..7b06f454f 100644 --- a/resources/views/customer/ultra/index.blade.php +++ b/resources/views/customer/ultra/index.blade.php @@ -48,6 +48,16 @@ +
+
+ +
+
+
Discounted third-party plugins
+ Pay around 30% less for third-party plugins in the Marketplace. +
+
+
diff --git a/resources/views/livewire/customer/developer/onboarding.blade.php b/resources/views/livewire/customer/developer/onboarding.blade.php index ac86e285b..5e63067a9 100644 --- a/resources/views/livewire/customer/developer/onboarding.blade.php +++ b/resources/views/livewire/customer/developer/onboarding.blade.php @@ -197,7 +197,7 @@ class="mt-0.5 size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-5
How does the revenue share work? - You receive 70% of each sale. NativePHP retains 30% to cover payment processing, hosting, and platform maintenance. + You receive 70% of each sale. NativePHP retains 30% to cover payment processing, hosting, and platform maintenance. NativePHP Ultra subscribers pay a lower Ultra price for your plugin, and you receive 100% of what they pay.
diff --git a/resources/views/livewire/customer/plugins/show.blade.php b/resources/views/livewire/customer/plugins/show.blade.php index d2fe45488..abaa2ff99 100644 --- a/resources/views/livewire/customer/plugins/show.blade.php +++ b/resources/views/livewire/customer/plugins/show.blade.php @@ -320,13 +320,18 @@
@foreach (\App\Enums\PluginTier::cases() as $pluginTier) @php - $regularPrice = $pluginTier->getPrices()[\App\Enums\PriceTier::Regular->value] / 100; + $tierPrices = $pluginTier->getPrices(); + $regularPrice = $tierPrices[\App\Enums\PriceTier::Regular->value] / 100; + $ultraPrice = $tierPrices[\App\Enums\PriceTier::Ultra->value] / 100; @endphp @@ -338,7 +343,7 @@ @enderror - Actual sale price may vary due to discounts and offers. You keep 70% of the sale price. If a NativePHP Ultra subscriber purchases your plugin, you receive 100% of the sale price. Additional payment processing fees may apply. + Actual sale price may vary due to discounts and offers. You keep 70% of the sale price. NativePHP Ultra subscribers pay the lower Ultra price, and you keep 100% of what they pay. Additional payment processing fees may apply. @endif diff --git a/resources/views/livewire/mobile-pricing.blade.php b/resources/views/livewire/mobile-pricing.blade.php index f8fbc2f1a..5963b66fc 100644 --- a/resources/views/livewire/mobile-pricing.blade.php +++ b/resources/views/livewire/mobile-pricing.blade.php @@ -230,6 +230,15 @@ class="grid size-7 shrink-0 place-items-center rounded-xl bg-[#D4FD7D] dark:bg-[
All first-party plugins at no extra cost
+
+ +
Around 30% off third-party plugins
+
+
+
+ +
+
+

Get this plugin for ${{ number_format($ultraOfferPrice->amount / 100) }} with Ultra

+

+ Ultra subscribers save ${{ number_format(($regularPrice->amount - $ultraOfferPrice->amount) / 100) }} on this plugin, and get every first-party NativePHP plugin included, from just ${{ config('subscriptions.plans.max.price_monthly') }}/month. +

+ + Learn more + +
+
+
+ @endif +

NativePHP Ultra is a premium subscription that gives you - access to all first-party plugins, the Claude Code + access to all first-party plugins, around 30% off + third-party plugins, the Claude Code Plugin Dev Kit, discounts on NativePHP courses and apps, Teams support, premium support through private channels with expedited turnaround times, and up to 90% revenue share on diff --git a/tests/Feature/AddUltraPricesToThirdPartyPluginsMigrationTest.php b/tests/Feature/AddUltraPricesToThirdPartyPluginsMigrationTest.php new file mode 100644 index 000000000..b8b64b000 --- /dev/null +++ b/tests/Feature/AddUltraPricesToThirdPartyPluginsMigrationTest.php @@ -0,0 +1,78 @@ +paid()->create(['is_official' => false, 'tier' => PluginTier::Bronze]); + $silver = Plugin::factory()->paid()->create(['is_official' => false, 'tier' => PluginTier::Silver]); + $gold = Plugin::factory()->paid()->create(['is_official' => false, 'tier' => PluginTier::Gold]); + + $this->migration()->up(); + + $this->assertSame(2000, $this->ultraPriceOf($bronze)->amount); + $this->assertSame(3500, $this->ultraPriceOf($silver)->amount); + $this->assertSame(7000, $this->ultraPriceOf($gold)->amount); + + $this->assertTrue($this->ultraPriceOf($gold)->is_active); + $this->assertSame('USD', $this->ultraPriceOf($gold)->currency); + } + + public function test_official_plugins_and_plugins_without_a_tier_are_left_alone(): void + { + $official = Plugin::factory()->paid()->create(['is_official' => true, 'tier' => PluginTier::Gold]); + $withoutTier = Plugin::factory()->paid()->create(['is_official' => false, 'tier' => null]); + PluginPrice::factory()->regular()->amount(2200)->create(['plugin_id' => $withoutTier->id]); + + $this->migration()->up(); + + $this->assertNull($this->ultraPriceOf($official)); + $this->assertNull($this->ultraPriceOf($withoutTier)); + } + + public function test_an_existing_ultra_price_is_not_duplicated_or_changed(): void + { + $plugin = Plugin::factory()->paid()->create(['is_official' => false, 'tier' => PluginTier::Gold]); + PluginPrice::factory()->ultra()->amount(6000)->create(['plugin_id' => $plugin->id]); + + $this->migration()->up(); + + $this->assertSame(1, $plugin->prices()->forTier(PriceTier::Ultra)->count()); + $this->assertSame(6000, $this->ultraPriceOf($plugin)->amount); + } + + public function test_rolling_back_removes_the_ultra_prices(): void + { + $plugin = Plugin::factory()->paid()->create(['is_official' => false, 'tier' => PluginTier::Gold]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + + $migration = $this->migration(); + $migration->up(); + $migration->down(); + + $this->assertNull($this->ultraPriceOf($plugin)); + $this->assertSame(1, $plugin->prices()->count()); + } + + private function ultraPriceOf(Plugin $plugin): ?PluginPrice + { + return $plugin->prices()->forTier(PriceTier::Ultra)->first(); + } + + private function migration(): Migration + { + return require database_path('migrations/2026_10_01_124505_add_ultra_prices_to_third_party_plugins.php'); + } +} diff --git a/tests/Feature/CheckoutCouponTest.php b/tests/Feature/CheckoutCouponTest.php index a55e25027..6bb55076a 100644 --- a/tests/Feature/CheckoutCouponTest.php +++ b/tests/Feature/CheckoutCouponTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature; +use App\Models\Plugin; +use App\Models\PluginPrice; use App\Models\Product; use App\Models\ProductPrice; use App\Models\User; @@ -285,6 +287,59 @@ public function cart_checkout_keeps_manual_promotion_codes_when_none_pre_applied $this->assertArrayNotHasKey('discounts', $captured->params); } + #[Test] + public function cart_checkout_charges_the_ultra_price_and_still_takes_promotion_codes(): void + { + $captured = $this->captureStripeCheckoutParams(); + + $ultraSubscriber = User::factory()->create(['stripe_id' => 'cus_test123']); + Subscription::factory() + ->for($ultraSubscriber) + ->active() + ->create(['stripe_price' => config('subscriptions.plans.max.stripe_price_id')]); + + $plugin = Plugin::factory()->approved()->paid()->create(['is_active' => true, 'is_official' => false]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->ultra()->amount(7000)->create(['plugin_id' => $plugin->id]); + + $cartService = resolve(CartService::class); + $cart = $cartService->getCart($ultraSubscriber); + $cartService->addPlugin($cart, $plugin); + + $this->actingAs($ultraSubscriber) + ->post(route('cart.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + $this->assertSame(7000, $captured->params['line_items'][0]['price_data']['unit_amount']); + $this->assertSame($plugin->name, $captured->params['line_items'][0]['price_data']['product_data']['name']); + $this->assertTrue($captured->params['allow_promotion_codes']); + $this->assertArrayNotHasKey('discounts', $captured->params); + } + + #[Test] + public function cart_checkout_charges_the_regular_price_to_a_buyer_without_ultra(): void + { + $captured = $this->captureStripeCheckoutParams(); + $user = User::factory()->create(['stripe_id' => 'cus_test123']); + + $plugin = Plugin::factory()->approved()->paid()->create(['is_active' => true, 'is_official' => false]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->ultra()->amount(7000)->create(['plugin_id' => $plugin->id]); + + $cartService = resolve(CartService::class); + $cart = $cartService->getCart($user); + $cartService->addPlugin($cart, $plugin); + + $this->actingAs($user) + ->post(route('cart.checkout')) + ->assertRedirect('https://checkout.stripe.com/test-session'); + + $this->assertNotNull($captured->params, 'Stripe checkout session should have been created'); + $this->assertSame(9900, $captured->params['line_items'][0]['price_data']['unit_amount']); + $this->assertTrue($captured->params['allow_promotion_codes']); + } + #[Test] public function course_checkout_uses_stripe_price_id_from_the_database_over_config(): void { diff --git a/tests/Feature/Commands/BackfillMissingPayoutsTest.php b/tests/Feature/Commands/BackfillMissingPayoutsTest.php index 5a4cf6c2e..c7f6b43fc 100644 --- a/tests/Feature/Commands/BackfillMissingPayoutsTest.php +++ b/tests/Feature/Commands/BackfillMissingPayoutsTest.php @@ -4,6 +4,7 @@ use App\Enums\PayoutStatus; use App\Enums\PluginType; +use App\Enums\PriceTier; use App\Models\DeveloperAccount; use App\Models\Plugin; use App\Models\PluginLicense; @@ -52,6 +53,28 @@ public function test_creates_pending_payout_using_developer_percentage(): void $this->assertTrue($payout->eligible_for_payout_at->isPast()); } + public function test_creates_payout_without_platform_fee_for_sale_at_ultra_price(): void + { + $developerAccount = DeveloperAccount::factory()->create(['payout_percentage' => 70]); + $plugin = $this->createThirdPartyPlugin($developerAccount); + + PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 7000, + 'price_tier' => PriceTier::Ultra, + ]); + + $this->artisan('payouts:backfill') + ->expectsOutputToContain('Created 1 payout record(s).') + ->assertExitCode(0); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(7000, $payout->gross_amount); + $this->assertEquals(0, $payout->platform_fee); + $this->assertEquals(7000, $payout->developer_amount); + } + public function test_creates_held_payout_when_developer_cannot_receive_payouts(): void { $developerAccount = DeveloperAccount::factory()->pending()->create(); diff --git a/tests/Feature/Filament/PluginPricesRelationManagerTest.php b/tests/Feature/Filament/PluginPricesRelationManagerTest.php new file mode 100644 index 000000000..2bb2e602f --- /dev/null +++ b/tests/Feature/Filament/PluginPricesRelationManagerTest.php @@ -0,0 +1,67 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_pricing_table_lists_an_ultra_price(): void + { + $plugin = Plugin::factory()->paid()->approved()->create(['is_official' => false]); + $regularPrice = PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + $ultraPrice = PluginPrice::factory()->ultra()->amount(7000)->create(['plugin_id' => $plugin->id]); + + Livewire::actingAs($this->admin) + ->test(PricesRelationManager::class, [ + 'ownerRecord' => $plugin, + 'pageClass' => EditPlugin::class, + ]) + ->assertSuccessful() + ->assertCanSeeTableRecords([$regularPrice, $ultraPrice]) + ->assertSee('$70.00'); + } + + public function test_admin_can_add_an_ultra_price(): void + { + $plugin = Plugin::factory()->paid()->approved()->create(['is_official' => false]); + + Livewire::actingAs($this->admin) + ->test(PricesRelationManager::class, [ + 'ownerRecord' => $plugin, + 'pageClass' => EditPlugin::class, + ]) + ->callTableAction('create', data: [ + 'tier' => 'ultra', + 'amount' => 7000, + 'currency' => 'USD', + 'is_active' => true, + ]) + ->assertHasNoTableActionErrors(); + + $this->assertDatabaseHas('plugin_prices', [ + 'plugin_id' => $plugin->id, + 'tier' => 'ultra', + 'amount' => 7000, + ]); + } +} diff --git a/tests/Feature/Filament/ThirdPartySaleResourceTest.php b/tests/Feature/Filament/ThirdPartySaleResourceTest.php index f15169998..9a6cd492b 100644 --- a/tests/Feature/Filament/ThirdPartySaleResourceTest.php +++ b/tests/Feature/Filament/ThirdPartySaleResourceTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Filament; use App\Enums\PluginType; +use App\Enums\PriceTier; use App\Filament\Resources\ThirdPartySaleResource\Pages\ListThirdPartySales; use App\Models\DeveloperAccount; use App\Models\Plugin; @@ -159,6 +160,36 @@ public function test_totals_paid_and_due_to_seller(): void ->assertSee('$49.00'); } + public function test_marks_sales_made_at_the_ultra_price(): void + { + $plugin = $this->createThirdPartyPlugin(); + $ultraSale = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 7000, + 'price_tier' => PriceTier::Ultra, + ]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertCanSeeTableRecords([$ultraSale]) + ->assertSee('Ultra price'); + } + + public function test_does_not_mark_sales_made_at_the_regular_price(): void + { + $plugin = $this->createThirdPartyPlugin(); + $regularSale = PluginLicense::factory()->create([ + 'plugin_id' => $plugin->id, + 'price_paid' => 9900, + 'price_tier' => PriceTier::Regular, + ]); + + Livewire::actingAs($this->admin) + ->test(ListThirdPartySales::class) + ->assertCanSeeTableRecords([$regularSale]) + ->assertDontSee('Ultra price'); + } + public function test_filters_sales_missing_payouts(): void { $plugin = $this->createThirdPartyPlugin(); diff --git a/tests/Feature/Livewire/Customer/PluginPaidOnboardingTest.php b/tests/Feature/Livewire/Customer/PluginPaidOnboardingTest.php index faa9288e6..4985b6254 100644 --- a/tests/Feature/Livewire/Customer/PluginPaidOnboardingTest.php +++ b/tests/Feature/Livewire/Customer/PluginPaidOnboardingTest.php @@ -165,6 +165,27 @@ public function test_edit_draft_shows_paid_option_enabled_with_onboarding(): voi ->assertDontSee('complete developer onboarding'); } + public function test_edit_draft_shows_what_ultra_subscribers_pay_for_each_tier(): void + { + $user = $this->createGitHubUser(); + DeveloperAccount::factory()->for($user)->create(); + $plugin = Plugin::factory()->draft()->for($user)->create([ + 'name' => 'testuser/tier-prices', + ]); + + [$vendor, $package] = explode('/', $plugin->name); + + Livewire::actingAs($user)->test(Show::class, [ + 'vendor' => $vendor, + 'package' => $package, + ]) + ->set('pluginType', 'paid') + ->assertSee('$20 for Ultra subscribers') + ->assertSee('$35 for Ultra subscribers') + ->assertSee('$70 for Ultra subscribers') + ->assertSee('you keep 100% of what they pay'); + } + public function test_save_draft_as_paid_blocked_without_onboarding(): void { $user = $this->createGitHubUser(); diff --git a/tests/Feature/MaxSubscriberPayoutTest.php b/tests/Feature/MaxSubscriberPayoutTest.php index 0f9ea93df..380bfb908 100644 --- a/tests/Feature/MaxSubscriberPayoutTest.php +++ b/tests/Feature/MaxSubscriberPayoutTest.php @@ -3,14 +3,21 @@ namespace Tests\Feature; use App\Enums\PayoutStatus; +use App\Enums\PriceTier; use App\Jobs\HandleInvoicePaidJob; +use App\Models\BundlePrice; use App\Models\Cart; use App\Models\CartItem; use App\Models\DeveloperAccount; use App\Models\Plugin; +use App\Models\PluginBundle; +use App\Models\PluginLicense; use App\Models\PluginPayout; use App\Models\PluginPrice; +use App\Models\Product; +use App\Models\ProductPrice; use App\Models\User; +use App\Services\CartService; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Cashier\Subscription; use PHPUnit\Framework\Attributes\Test; @@ -32,7 +39,11 @@ protected function setUp(): void config(['subscriptions.plans.max.stripe_price_id' => self::MAX_PRICE_ID]); } - private function createStripeInvoice(string $cartId, string $customerId): Invoice + /** + * @param array $cartItemIds + * @param array $attributes + */ + private function createStripeInvoice(string $cartId, string $customerId, array $cartItemIds = [], array $attributes = []): Invoice { $invoice = Invoice::constructFrom([ 'id' => 'in_test_'.uniqid(), @@ -40,13 +51,32 @@ private function createStripeInvoice(string $cartId, string $customerId): Invoic 'customer' => $customerId, 'payment_intent' => 'pi_test_'.uniqid(), 'currency' => 'usd', - 'metadata' => ['cart_id' => $cartId], + 'metadata' => array_filter([ + 'cart_id' => $cartId, + 'cart_item_ids' => implode(',', $cartItemIds), + ]), 'lines' => [], + ...$attributes, ]); return $invoice; } + /** + * An invoice line as Stripe reports it, with the discount taken off that line. + * + * @return array + */ + private function invoiceLine(string $description, int $amount, int $discount = 0): array + { + return [ + 'object' => 'line_item', + 'description' => $description, + 'amount' => $amount, + 'discount_amounts' => $discount > 0 ? [['amount' => $discount, 'discount' => 'di_test']] : [], + ]; + } + private function createSubscription(User $user, string $priceId): Subscription { return Subscription::factory() @@ -55,6 +85,21 @@ private function createSubscription(User $user, string $priceId): Subscription ->create(['stripe_price' => $priceId]); } + private function createThirdPartyPluginWithUltraPrice(DeveloperAccount $developerAccount): Plugin + { + $plugin = Plugin::factory()->approved()->paid()->create([ + 'is_active' => true, + 'is_official' => false, + 'user_id' => $developerAccount->user_id, + 'developer_account_id' => $developerAccount->id, + ]); + + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->ultra()->amount(7000)->create(['plugin_id' => $plugin->id]); + + return $plugin; + } + #[Test] public function max_subscriber_pays_normal_platform_fee_for_third_party_plugin(): void { @@ -90,6 +135,296 @@ public function max_subscriber_pays_normal_platform_fee_for_third_party_plugin() $this->assertEquals(PayoutStatus::Pending, $payout->status); } + #[Test] + public function max_subscriber_buying_at_the_ultra_price_pays_no_platform_fee(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + $this->createSubscription($buyer, self::MAX_PRICE_ID); + + $plugin = $this->createThirdPartyPluginWithUltraPrice(DeveloperAccount::factory()->create()); + + $cart = Cart::factory()->for($buyer)->create(); + $item = resolve(CartService::class)->addPlugin($cart, $plugin); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$item->id]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $license = PluginLicense::first(); + $this->assertNotNull($license); + $this->assertEquals(7000, $license->price_paid); + $this->assertEquals(PriceTier::Ultra, $license->price_tier); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(7000, $payout->gross_amount); + $this->assertEquals(0, $payout->platform_fee); + $this->assertEquals(7000, $payout->developer_amount); + $this->assertEquals(PayoutStatus::Pending, $payout->status); + } + + #[Test] + public function ultra_priced_sale_pays_the_developer_in_full_whatever_their_payout_percentage(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + $this->createSubscription($buyer, self::MAX_PRICE_ID); + + $plugin = $this->createThirdPartyPluginWithUltraPrice( + DeveloperAccount::factory()->create(['payout_percentage' => 80]) + ); + + $cart = Cart::factory()->for($buyer)->create(); + $item = resolve(CartService::class)->addPlugin($cart, $plugin); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$item->id]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(7000, $payout->gross_amount); + $this->assertEquals(0, $payout->platform_fee); + $this->assertEquals(7000, $payout->developer_amount); + } + + #[Test] + public function buyer_without_ultra_pays_the_regular_price_and_the_normal_platform_fee(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + $this->createSubscription($buyer, self::PRO_PRICE_ID); + + $plugin = $this->createThirdPartyPluginWithUltraPrice(DeveloperAccount::factory()->create()); + + $cart = Cart::factory()->for($buyer)->create(); + $item = resolve(CartService::class)->addPlugin($cart, $plugin); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$item->id]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $license = PluginLicense::first(); + $this->assertNotNull($license); + $this->assertEquals(9900, $license->price_paid); + $this->assertEquals(PriceTier::Regular, $license->price_tier); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(9900, $payout->gross_amount); + $this->assertEquals(2970, $payout->platform_fee); + $this->assertEquals(6930, $payout->developer_amount); + } + + #[Test] + public function promotion_code_on_an_ultra_priced_sale_pays_the_developer_what_the_buyer_paid(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + $this->createSubscription($buyer, self::MAX_PRICE_ID); + + $plugin = $this->createThirdPartyPluginWithUltraPrice(DeveloperAccount::factory()->create()); + + $cart = Cart::factory()->for($buyer)->create(); + $item = resolve(CartService::class)->addPlugin($cart, $plugin); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$item->id], [ + 'subtotal' => 7000, + 'total' => 6300, + 'total_discount_amounts' => [['amount' => 700, 'discount' => 'di_test']], + 'lines' => [ + 'object' => 'list', + 'data' => [$this->invoiceLine($plugin->name, 7000, discount: 700)], + ], + ]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $license = PluginLicense::first(); + $this->assertNotNull($license); + $this->assertEquals(6300, $license->price_paid); + $this->assertEquals(PriceTier::Ultra, $license->price_tier); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(6300, $payout->gross_amount); + $this->assertEquals(0, $payout->platform_fee); + $this->assertEquals(6300, $payout->developer_amount); + } + + #[Test] + public function coupon_on_another_item_does_not_reduce_an_ultra_priced_payout(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + $this->createSubscription($buyer, self::MAX_PRICE_ID); + + $plugin = $this->createThirdPartyPluginWithUltraPrice(DeveloperAccount::factory()->create()); + + $product = Product::factory()->active()->create(); + ProductPrice::factory()->for($product)->regular()->amount(29900)->create(); + + $cartService = resolve(CartService::class); + $cart = Cart::factory()->for($buyer)->create(); + $pluginItem = $cartService->addPlugin($cart, $plugin); + $productItem = $cartService->addProduct($cart, $product); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$pluginItem->id, $productItem->id], [ + 'subtotal' => 36900, + 'total' => 26900, + 'total_discount_amounts' => [['amount' => 10000, 'discount' => 'di_test']], + 'lines' => [ + 'object' => 'list', + 'data' => [ + $this->invoiceLine($plugin->name, 7000), + $this->invoiceLine($product->name, 29900, discount: 10000), + ], + ], + ]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $this->assertEquals(7000, PluginLicense::first()->price_paid); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(7000, $payout->gross_amount); + $this->assertEquals(0, $payout->platform_fee); + $this->assertEquals(7000, $payout->developer_amount); + } + + #[Test] + public function discount_is_shared_out_when_the_ultra_priced_plugin_has_no_invoice_line(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + $this->createSubscription($buyer, self::MAX_PRICE_ID); + + $plugin = $this->createThirdPartyPluginWithUltraPrice(DeveloperAccount::factory()->create()); + + $cart = Cart::factory()->for($buyer)->create(); + $item = resolve(CartService::class)->addPlugin($cart, $plugin); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$item->id], [ + 'subtotal' => 10500, + 'total' => 9400, + 'total_discount_amounts' => [['amount' => 1100, 'discount' => 'di_test']], + ]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + // 7000 of a 10500 order that had 1100 taken off is 6266.67, rounded down + $this->assertEquals(6266, PluginLicense::first()->price_paid); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(6266, $payout->gross_amount); + $this->assertEquals(0, $payout->platform_fee); + $this->assertEquals(6266, $payout->developer_amount); + } + + #[Test] + public function ultra_priced_sale_fully_covered_by_a_promotion_code_creates_no_payout(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + $this->createSubscription($buyer, self::MAX_PRICE_ID); + + $plugin = $this->createThirdPartyPluginWithUltraPrice(DeveloperAccount::factory()->create()); + + $cart = Cart::factory()->for($buyer)->create(); + $item = resolve(CartService::class)->addPlugin($cart, $plugin); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$item->id], [ + 'subtotal' => 7000, + 'total' => 0, + 'total_discount_amounts' => [['amount' => 7000, 'discount' => 'di_test']], + 'lines' => [ + 'object' => 'list', + 'data' => [$this->invoiceLine($plugin->name, 7000, discount: 7000)], + ], + ]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $this->assertEquals(0, PluginLicense::first()->price_paid); + $this->assertEquals(0, PluginPayout::count()); + } + + #[Test] + public function promotion_code_on_a_regular_priced_sale_pays_the_developer_their_share_of_what_the_buyer_paid(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + + $plugin = $this->createThirdPartyPluginWithUltraPrice(DeveloperAccount::factory()->create()); + + $cart = Cart::factory()->for($buyer)->create(); + $item = resolve(CartService::class)->addPlugin($cart, $plugin); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$item->id], [ + 'subtotal' => 9900, + 'total' => 8910, + 'total_discount_amounts' => [['amount' => 990, 'discount' => 'di_test']], + 'lines' => [ + 'object' => 'list', + 'data' => [$this->invoiceLine($plugin->name, 9900, discount: 990)], + ], + ]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $license = PluginLicense::first(); + $this->assertEquals(8910, $license->price_paid); + $this->assertEquals(PriceTier::Regular, $license->price_tier); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(8910, $payout->gross_amount); + $this->assertEquals(2673, $payout->platform_fee); + $this->assertEquals(6237, $payout->developer_amount); + } + + #[Test] + public function promotion_code_on_a_bundle_is_shared_between_its_plugins(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + + $firstPlugin = $this->createThirdPartyPluginWithUltraPrice(DeveloperAccount::factory()->create()); + $secondPlugin = Plugin::factory()->approved()->paid()->create([ + 'is_active' => true, + 'is_official' => false, + 'developer_account_id' => DeveloperAccount::factory()->create()->id, + ]); + PluginPrice::factory()->regular()->amount(4900)->create(['plugin_id' => $secondPlugin->id]); + + $bundle = PluginBundle::factory()->active()->create(); + $bundle->plugins()->attach([ + $firstPlugin->id => ['sort_order' => 1], + $secondPlugin->id => ['sort_order' => 2], + ]); + BundlePrice::factory()->regular()->amount(10000)->create(['plugin_bundle_id' => $bundle->id]); + + $cart = Cart::factory()->for($buyer)->create(); + $item = resolve(CartService::class)->addBundle($cart, $bundle); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id, [$item->id], [ + 'subtotal' => 10000, + 'total' => 9000, + 'total_discount_amounts' => [['amount' => 1000, 'discount' => 'di_test']], + 'lines' => [ + 'object' => 'list', + 'data' => [$this->invoiceLine($bundle->name.' (Bundle)', 10000, discount: 1000)], + ], + ]); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + // The 9000 paid is split by regular price: 9900 and 4900 of 14800 + $firstLicense = PluginLicense::where('plugin_id', $firstPlugin->id)->sole(); + $secondLicense = PluginLicense::where('plugin_id', $secondPlugin->id)->sole(); + $this->assertEquals(6020, $firstLicense->price_paid); + $this->assertEquals(2980, $secondLicense->price_paid); + + $this->assertEquals(6020, $firstLicense->payout->gross_amount); + $this->assertEquals(4214, $firstLicense->payout->developer_amount); + $this->assertEquals(2980, $secondLicense->payout->gross_amount); + $this->assertEquals(2086, $secondLicense->payout->developer_amount); + } + #[Test] public function non_max_subscriber_gets_normal_platform_fee_for_third_party_plugin(): void { diff --git a/tests/Feature/MobileRouteTest.php b/tests/Feature/MobileRouteTest.php index d02166a77..d2ebd697d 100644 --- a/tests/Feature/MobileRouteTest.php +++ b/tests/Feature/MobileRouteTest.php @@ -28,4 +28,15 @@ public function pricing_route_returns_pricing_page() ->assertOk() ->assertSeeLivewire('mobile-pricing'); } + + #[Test] + public function pricing_page_lists_the_third_party_plugin_discount() + { + $this + ->withoutVite() + ->get(route('pricing')) + ->assertOk() + ->assertSee('Around 30% off third-party plugins') + ->assertSee('around 30% off'); + } } diff --git a/tests/Feature/PluginShowUltraCardTest.php b/tests/Feature/PluginShowUltraCardTest.php index 79dd0b2f2..ed550ea8b 100644 --- a/tests/Feature/PluginShowUltraCardTest.php +++ b/tests/Feature/PluginShowUltraCardTest.php @@ -26,6 +26,16 @@ protected function setUp(): void config(['subscriptions.plans.max.stripe_price_id' => self::MAX_PRICE_ID]); } + private function createThirdPartyPluginWithUltraPrice(): Plugin + { + $plugin = Plugin::factory()->approved()->paid()->create(['is_official' => false]); + + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->ultra()->amount(7000)->create(['plugin_id' => $plugin->id]); + + return $plugin; + } + public function test_ultra_card_shows_on_paid_first_party_plugin_for_guest(): void { $plugin = Plugin::factory()->approved()->paid()->create(['is_official' => true]); @@ -57,6 +67,71 @@ public function test_ultra_card_is_hidden_on_third_party_plugin(): void ->assertDontSee('Included with Ultra'); } + public function test_ultra_offer_shows_on_third_party_plugin_for_guest(): void + { + $plugin = $this->createThirdPartyPluginWithUltraPrice(); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('Get this plugin for $70 with Ultra') + ->assertSee('save $29 on this plugin') + ->assertSee('Learn more') + ->assertSee(route('pricing')) + ->assertDontSee('Included with Ultra') + ->assertDontSee('pricing applied'); + } + + public function test_ultra_offer_shows_on_third_party_plugin_for_user_without_ultra(): void + { + $plugin = $this->createThirdPartyPluginWithUltraPrice(); + + $this->actingAs(User::factory()->create()) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('Get this plugin for $70 with Ultra'); + } + + public function test_ultra_subscriber_sees_the_ultra_price_instead_of_the_offer(): void + { + $user = User::factory()->create(); + Subscription::factory() + ->for($user) + ->active() + ->create(['stripe_price' => self::MAX_PRICE_ID]); + + $plugin = $this->createThirdPartyPluginWithUltraPrice(); + + $this->actingAs($user) + ->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertDontSee('Get this plugin for') + ->assertSee('Ultra pricing applied') + ->assertSeeInOrder(['line-through', '$99', '$70'], false); + } + + public function test_ultra_offer_is_hidden_when_third_party_plugin_has_no_ultra_price(): void + { + $plugin = Plugin::factory()->approved()->paid()->create(['is_official' => false]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('$99') + ->assertDontSee('Get this plugin for'); + } + + public function test_ultra_offer_is_hidden_on_first_party_plugin(): void + { + $plugin = Plugin::factory()->approved()->paid()->create(['is_official' => true]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->ultra()->amount(7000)->create(['plugin_id' => $plugin->id]); + + $this->get(route('plugins.show', $plugin->routeParams())) + ->assertStatus(200) + ->assertSee('Included with Ultra') + ->assertDontSee('Get this plugin for'); + } + public function test_ultra_subscriber_sees_dashboard_link(): void { $user = User::factory()->create(); diff --git a/tests/Feature/TierBasedPricingTest.php b/tests/Feature/TierBasedPricingTest.php index 95145bdb9..1e52ce97f 100644 --- a/tests/Feature/TierBasedPricingTest.php +++ b/tests/Feature/TierBasedPricingTest.php @@ -348,6 +348,28 @@ public function subscriber_sees_subscriber_price_when_it_is_lower_than_eap_for_o $this->assertEquals(PriceTier::Subscriber, $bestPrice->tier); } + #[Test] + public function regular_plugin_price_wins_a_tie_with_a_discounted_tier(): void + { + $user = User::factory()->create(); + License::factory() + ->for($user) + ->mini() + ->active() + ->eapEligible() + ->withoutSubscriptionItem() + ->create(); + + $plugin = Plugin::factory()->approved()->paid()->create(['is_active' => true, 'is_official' => true]); + PluginPrice::factory()->eap()->amount(2999)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->regular()->amount(2999)->create(['plugin_id' => $plugin->id]); + + $bestPrice = $plugin->getBestPriceForUser($user); + + $this->assertEquals(2999, $bestPrice->amount); + $this->assertEquals(PriceTier::Regular, $bestPrice->tier); + } + #[Test] public function plugin_falls_back_to_regular_price_when_user_tier_not_available(): void { @@ -940,7 +962,7 @@ public function third_party_plugin_always_returns_regular_price_for_eap_customer } #[Test] - public function third_party_plugin_always_returns_regular_price_for_max_subscriber(): void + public function third_party_plugin_ignores_subscriber_and_eap_prices_for_max_subscriber(): void { $user = User::factory()->create(); $this->createSubscription($user, self::MAX_PRICE_ID); diff --git a/tests/Feature/UltraBenefitsPageTest.php b/tests/Feature/UltraBenefitsPageTest.php index 950b1792b..d4db4ffd6 100644 --- a/tests/Feature/UltraBenefitsPageTest.php +++ b/tests/Feature/UltraBenefitsPageTest.php @@ -70,6 +70,7 @@ public function test_benefits_page_displays_expected_content(): void $response->assertSee('Ultra'); $response->assertSee('Your premium subscription benefits'); $response->assertSee('All first-party plugins'); + $response->assertSee('Discounted third-party plugins'); $response->assertSee('Claude Code Plugin Dev Kit'); $response->assertSee('Teams'); $response->assertSee('Premium support'); diff --git a/tests/Feature/UltraPluginAccessTest.php b/tests/Feature/UltraPluginAccessTest.php index cd1304d19..c215d0f91 100644 --- a/tests/Feature/UltraPluginAccessTest.php +++ b/tests/Feature/UltraPluginAccessTest.php @@ -136,7 +136,7 @@ public function test_ultra_user_gets_subscriber_price_for_official_plugin(): voi $this->assertEquals(1999, $bestPrice->amount); } - public function test_ultra_user_gets_regular_price_for_third_party_plugin(): void + public function test_ultra_user_does_not_get_subscriber_price_for_third_party_plugin(): void { $user = User::factory()->create(); $this->createPaidMaxSubscription($user); @@ -640,7 +640,7 @@ public function test_team_member_without_own_subscription_sees_regular_price(): $this->assertEquals(4900, $bestPrice->amount); } - public function test_team_member_with_own_subscription_sees_regular_price_for_third_party_plugin(): void + public function test_team_member_with_own_subscription_does_not_get_subscriber_price_for_third_party_plugin(): void { $owner = User::factory()->create(); $this->createPaidMaxSubscription($owner); diff --git a/tests/Feature/UltraPluginPricingTest.php b/tests/Feature/UltraPluginPricingTest.php new file mode 100644 index 000000000..82370ccdb --- /dev/null +++ b/tests/Feature/UltraPluginPricingTest.php @@ -0,0 +1,443 @@ + self::ULTRA_PRICE_ID, + 'subscriptions.plans.max.stripe_price_id_comped' => self::COMPED_ULTRA_PRICE_ID, + 'subscriptions.plans.pro.stripe_price_id' => self::PRO_PRICE_ID, + ]); + } + + /** + * @param array $attributes + */ + private function createSubscriber(string $priceId, array $attributes = []): User + { + $user = User::factory()->create(); + + Subscription::factory() + ->for($user) + ->active() + ->create(['stripe_price' => $priceId, ...$attributes]); + + return $user; + } + + private function createThirdPartyPlugin(?int $ultraAmount = 7000): Plugin + { + $plugin = Plugin::factory()->approved()->paid()->create([ + 'is_active' => true, + 'is_official' => false, + ]); + + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + + if ($ultraAmount !== null) { + PluginPrice::factory()->ultra()->amount($ultraAmount)->create(['plugin_id' => $plugin->id]); + } + + return $plugin; + } + + // ---- Who is eligible for the Ultra price tier ---- + + public function test_ultra_subscriber_is_eligible_for_the_ultra_price_tier(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + + $this->assertContains(PriceTier::Ultra, $user->getEligiblePriceTiers()); + } + + public function test_pro_subscriber_is_not_eligible_for_the_ultra_price_tier(): void + { + $user = $this->createSubscriber(self::PRO_PRICE_ID); + + $this->assertNotContains(PriceTier::Ultra, $user->getEligiblePriceTiers()); + } + + public function test_user_without_a_subscription_is_not_eligible_for_the_ultra_price_tier(): void + { + $user = User::factory()->create(); + + $this->assertNotContains(PriceTier::Ultra, $user->getEligiblePriceTiers()); + } + + // ---- What each buyer pays for a third-party plugin ---- + + public function test_ultra_subscriber_pays_the_ultra_price_for_a_third_party_plugin(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + $plugin = $this->createThirdPartyPlugin(); + + $bestPrice = $plugin->getBestPriceForUser($user); + + $this->assertEquals(7000, $bestPrice->amount); + $this->assertEquals(PriceTier::Ultra, $bestPrice->tier); + } + + public function test_comped_ultra_subscriber_pays_the_ultra_price_for_a_third_party_plugin(): void + { + $user = $this->createSubscriber(self::COMPED_ULTRA_PRICE_ID); + $plugin = $this->createThirdPartyPlugin(); + + $this->assertEquals(7000, $plugin->getBestPriceForUser($user)->amount); + } + + public function test_ultra_subscriber_with_extra_seats_pays_the_ultra_price_for_a_third_party_plugin(): void + { + config(['subscriptions.plans.max.stripe_extra_seat_price_id' => 'price_test_extra_seat']); + + $user = User::factory()->create(); + $subscription = Subscription::factory() + ->for($user) + ->active() + ->create(['stripe_price' => null]); + SubscriptionItem::factory() + ->for($subscription, 'subscription') + ->create(['stripe_price' => self::ULTRA_PRICE_ID, 'quantity' => 1]); + SubscriptionItem::factory() + ->for($subscription, 'subscription') + ->create(['stripe_price' => 'price_test_extra_seat', 'quantity' => 2]); + + $plugin = $this->createThirdPartyPlugin(); + + $this->assertEquals(7000, $plugin->getBestPriceForUser($user)->amount); + } + + public function test_guest_pays_the_regular_price_for_a_third_party_plugin(): void + { + $plugin = $this->createThirdPartyPlugin(); + + $bestPrice = $plugin->getBestPriceForUser(null); + + $this->assertEquals(9900, $bestPrice->amount); + $this->assertEquals(PriceTier::Regular, $bestPrice->tier); + } + + public function test_user_without_a_subscription_pays_the_regular_price_for_a_third_party_plugin(): void + { + $user = User::factory()->create(); + $plugin = $this->createThirdPartyPlugin(); + + $this->assertEquals(9900, $plugin->getBestPriceForUser($user)->amount); + } + + public function test_pro_subscriber_pays_the_regular_price_for_a_third_party_plugin(): void + { + $user = $this->createSubscriber(self::PRO_PRICE_ID); + $plugin = $this->createThirdPartyPlugin(); + + $this->assertEquals(9900, $plugin->getBestPriceForUser($user)->amount); + } + + public function test_legacy_comped_max_subscriber_pays_the_regular_price_for_a_third_party_plugin(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID, ['is_comped' => true]); + $plugin = $this->createThirdPartyPlugin(); + + $this->assertEquals(9900, $plugin->getBestPriceForUser($user)->amount); + } + + public function test_user_whose_ultra_subscription_has_ended_pays_the_regular_price_for_a_third_party_plugin(): void + { + $user = User::factory()->create(); + Subscription::factory() + ->for($user) + ->canceled() + ->create(['stripe_price' => self::ULTRA_PRICE_ID]); + $plugin = $this->createThirdPartyPlugin(); + + $this->assertEquals(9900, $plugin->getBestPriceForUser($user)->amount); + } + + public function test_team_member_without_their_own_ultra_subscription_pays_the_regular_price_for_a_third_party_plugin(): void + { + $owner = $this->createSubscriber(self::ULTRA_PRICE_ID); + $team = Team::factory()->create(['user_id' => $owner->id]); + $member = User::factory()->create(); + TeamUser::factory()->active()->create([ + 'team_id' => $team->id, + 'user_id' => $member->id, + 'email' => $member->email, + ]); + $plugin = $this->createThirdPartyPlugin(); + + $this->assertEquals(9900, $plugin->getBestPriceForUser($member)->amount); + } + + public function test_ultra_subscriber_pays_the_regular_price_when_the_plugin_has_no_ultra_price(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + $plugin = $this->createThirdPartyPlugin(ultraAmount: null); + + $bestPrice = $plugin->getBestPriceForUser($user); + + $this->assertEquals(9900, $bestPrice->amount); + $this->assertEquals(PriceTier::Regular, $bestPrice->tier); + } + + public function test_ultra_subscriber_pays_the_regular_price_when_the_ultra_price_is_inactive(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + $plugin = $this->createThirdPartyPlugin(ultraAmount: null); + PluginPrice::factory()->ultra()->inactive()->amount(7000)->create(['plugin_id' => $plugin->id]); + + $this->assertEquals(9900, $plugin->getBestPriceForUser($user)->amount); + } + + public function test_regular_price_wins_when_the_ultra_price_is_not_cheaper(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + + $plugin = Plugin::factory()->approved()->paid()->create([ + 'is_active' => true, + 'is_official' => false, + ]); + PluginPrice::factory()->ultra()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + + $bestPrice = $plugin->getBestPriceForUser($user); + + $this->assertEquals(9900, $bestPrice->amount); + $this->assertEquals(PriceTier::Regular, $bestPrice->tier); + } + + public function test_official_plugin_never_uses_an_ultra_price(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + + $plugin = Plugin::factory()->approved()->paid()->create([ + 'is_active' => true, + 'is_official' => true, + ]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->subscriber()->amount(3100)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->ultra()->amount(2000)->create(['plugin_id' => $plugin->id]); + + $bestPrice = $plugin->getBestPriceForUser($user); + + $this->assertEquals(3100, $bestPrice->amount); + $this->assertEquals(PriceTier::Subscriber, $bestPrice->tier); + } + + // ---- The Ultra price shown to everyone else ---- + + public function test_ultra_price_is_available_for_a_paid_third_party_plugin(): void + { + $plugin = $this->createThirdPartyPlugin(); + + $this->assertEquals(7000, $plugin->getUltraPrice()->amount); + } + + public function test_ultra_price_is_null_when_the_plugin_has_none(): void + { + $plugin = $this->createThirdPartyPlugin(ultraAmount: null); + + $this->assertNull($plugin->getUltraPrice()); + } + + public function test_ultra_price_is_null_when_it_is_not_lower_than_the_regular_price(): void + { + $plugin = $this->createThirdPartyPlugin(ultraAmount: 9900); + + $this->assertNull($plugin->getUltraPrice()); + } + + public function test_ultra_price_is_null_for_an_official_plugin(): void + { + $plugin = Plugin::factory()->approved()->paid()->create(['is_official' => true]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->ultra()->amount(7000)->create(['plugin_id' => $plugin->id]); + + $this->assertNull($plugin->getUltraPrice()); + } + + public function test_ultra_price_is_null_for_a_free_plugin(): void + { + $plugin = Plugin::factory()->approved()->free()->create(['is_official' => false]); + PluginPrice::factory()->regular()->amount(9900)->create(['plugin_id' => $plugin->id]); + PluginPrice::factory()->ultra()->amount(7000)->create(['plugin_id' => $plugin->id]); + + $this->assertNull($plugin->getUltraPrice()); + } + + // ---- Ultra prices come from the plugin's pricing tier ---- + + #[DataProvider('pricesByPluginTier')] + public function test_setting_a_tier_on_a_third_party_plugin_creates_its_ultra_price(PluginTier $tier, int $regularAmount, int $ultraAmount): void + { + $plugin = Plugin::factory()->paid()->create(['is_official' => false]); + + $plugin->update(['tier' => $tier]); + + $this->assertSame($regularAmount, $plugin->prices()->forTier(PriceTier::Regular)->sole()->amount); + $this->assertSame($ultraAmount, $plugin->prices()->forTier(PriceTier::Ultra)->sole()->amount); + } + + /** + * @return array + */ + public static function pricesByPluginTier(): array + { + return [ + 'bronze' => [PluginTier::Bronze, 2900, 2000], + 'silver' => [PluginTier::Silver, 4900, 3500], + 'gold' => [PluginTier::Gold, 9900, 7000], + ]; + } + + public function test_changing_the_tier_updates_the_ultra_price(): void + { + $plugin = Plugin::factory()->paid()->create(['is_official' => false]); + $plugin->update(['tier' => PluginTier::Gold]); + + $plugin->update(['tier' => PluginTier::Silver]); + + $this->assertSame(3500, $plugin->prices()->forTier(PriceTier::Ultra)->sole()->amount); + } + + public function test_setting_a_tier_on_an_official_plugin_does_not_create_an_ultra_price(): void + { + $plugin = Plugin::factory()->paid()->create(['is_official' => true]); + + $plugin->update(['tier' => PluginTier::Gold]); + + $this->assertTrue($plugin->prices()->forTier(PriceTier::Regular)->exists()); + $this->assertFalse($plugin->prices()->forTier(PriceTier::Ultra)->exists()); + } + + // ---- The cart charges the price the buyer is entitled to ---- + + public function test_cart_adds_a_third_party_plugin_at_the_ultra_price_for_an_ultra_subscriber(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + $cart = Cart::factory()->for($user)->create(); + $plugin = $this->createThirdPartyPlugin(); + + $item = (new CartService)->addPlugin($cart, $plugin); + + $this->assertEquals(7000, $item->price_at_addition); + $this->assertEquals(PriceTier::Ultra, $item->pluginPrice->tier); + } + + public function test_cart_adds_a_third_party_plugin_at_the_regular_price_for_everyone_else(): void + { + $user = $this->createSubscriber(self::PRO_PRICE_ID); + $cart = Cart::factory()->for($user)->create(); + $plugin = $this->createThirdPartyPlugin(); + + $item = (new CartService)->addPlugin($cart, $plugin); + + $this->assertEquals(9900, $item->price_at_addition); + $this->assertEquals(PriceTier::Regular, $item->pluginPrice->tier); + } + + public function test_cart_drops_to_the_ultra_price_once_the_buyer_subscribes_to_ultra(): void + { + $user = User::factory()->create(); + $cart = Cart::factory()->for($user)->create(); + $plugin = $this->createThirdPartyPlugin(); + + $cartService = new CartService; + $item = $cartService->addPlugin($cart, $plugin); + $this->assertEquals(9900, $item->price_at_addition); + + Subscription::factory() + ->for($user) + ->active() + ->create(['stripe_price' => self::ULTRA_PRICE_ID]); + + $changes = $cartService->refreshPrices($cart->fresh()); + + $this->assertCount(1, $changes); + $this->assertEquals(9900, $changes[0]['old_price']); + $this->assertEquals(7000, $changes[0]['new_price']); + + $item->refresh(); + $this->assertEquals(7000, $item->price_at_addition); + $this->assertEquals(PriceTier::Ultra, $item->pluginPrice->tier); + } + + public function test_cart_returns_to_the_regular_price_once_the_ultra_subscription_ends(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + $cart = Cart::factory()->for($user)->create(); + $plugin = $this->createThirdPartyPlugin(); + + $cartService = new CartService; + $item = $cartService->addPlugin($cart, $plugin); + $this->assertEquals(7000, $item->price_at_addition); + + $user->subscription()->update(['stripe_status' => 'canceled', 'ends_at' => now()]); + + $cartService->refreshPrices($cart->fresh()); + + $item->refresh(); + $this->assertEquals(9900, $item->price_at_addition); + $this->assertEquals(PriceTier::Regular, $item->pluginPrice->tier); + } + + public function test_cart_page_labels_a_plugin_sold_at_the_ultra_price(): void + { + $user = $this->createSubscriber(self::ULTRA_PRICE_ID); + $plugin = $this->createThirdPartyPlugin(); + + $cartService = resolve(CartService::class); + $cartService->addPlugin($cartService->getCart($user), $plugin); + + $this->actingAs($user) + ->get(route('cart.show')) + ->assertOk() + ->assertSee('$70') + ->assertSee('Ultra price'); + } + + public function test_cart_page_does_not_label_a_plugin_sold_at_the_regular_price(): void + { + $user = User::factory()->create(); + $plugin = $this->createThirdPartyPlugin(); + + $cartService = resolve(CartService::class); + $cartService->addPlugin($cartService->getCart($user), $plugin); + + $this->actingAs($user) + ->get(route('cart.show')) + ->assertOk() + ->assertSee('$99') + ->assertDontSee('Ultra price'); + } +}