From 8c7b703146b35f9649228ae6cfb0a446df171e36 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 1 Oct 2026 17:57:59 +0100 Subject: [PATCH] Give Ultra subscribers a discount on third-party plugins Third-party plugins get an Ultra price (Gold $70, Silver $35, Bronze $20) for buyers with an active Ultra subscription. The seller keeps all of what the buyer pays on those sales. Everyone else sees the Ultra price advertised on third-party plugin pages. Payouts on every plugin and bundle sale are now worked out from what the buyer actually paid after promotion codes and coupons, instead of the listed price. Co-Authored-By: Claude Opus 5.5 --- .../Commands/BackfillMissingPayouts.php | 2 +- app/Enums/PluginTier.php | 3 + app/Enums/PriceTier.php | 8 +- .../PricesRelationManager.php | 3 +- .../PricesRelationManager.php | 3 +- .../PricesRelationManager.php | 3 +- .../Resources/ThirdPartySaleResource.php | 1 + app/Http/Controllers/CartController.php | 6 +- .../Controllers/PluginDirectoryController.php | 1 + app/Jobs/HandleInvoicePaidJob.php | 58 ++- app/Models/CartItem.php | 4 + app/Models/DeveloperAccount.php | 13 + app/Models/Plugin.php | 63 ++- app/Models/PluginLicense.php | 10 + app/Models/PluginPrice.php | 5 + app/Models/User.php | 4 + database/factories/PluginPriceFactory.php | 7 + ...dd_price_tier_to_plugin_licenses_table.php | 28 ++ ...dd_ultra_prices_to_third_party_plugins.php | 51 ++ resources/views/cart/show.blade.php | 2 + .../views/customer/ultra/index.blade.php | 10 + .../customer/developer/onboarding.blade.php | 2 +- .../livewire/customer/plugins/show.blade.php | 11 +- .../views/livewire/mobile-pricing.blade.php | 9 + resources/views/plugin-show.blade.php | 23 + resources/views/pricing.blade.php | 3 +- ...PricesToThirdPartyPluginsMigrationTest.php | 78 +++ tests/Feature/CheckoutCouponTest.php | 55 +++ .../Commands/BackfillMissingPayoutsTest.php | 23 + .../PluginPricesRelationManagerTest.php | 67 +++ .../Filament/ThirdPartySaleResourceTest.php | 31 ++ .../Customer/PluginPaidOnboardingTest.php | 21 + tests/Feature/MaxSubscriberPayoutTest.php | 339 +++++++++++++- tests/Feature/MobileRouteTest.php | 11 + tests/Feature/PluginShowUltraCardTest.php | 75 +++ tests/Feature/TierBasedPricingTest.php | 24 +- tests/Feature/UltraBenefitsPageTest.php | 1 + tests/Feature/UltraPluginAccessTest.php | 4 +- tests/Feature/UltraPluginPricingTest.php | 443 ++++++++++++++++++ 39 files changed, 1473 insertions(+), 32 deletions(-) create mode 100644 database/migrations/2026_10_01_124505_add_price_tier_to_plugin_licenses_table.php create mode 100644 database/migrations/2026_10_01_124505_add_ultra_prices_to_third_party_plugins.php create mode 100644 tests/Feature/AddUltraPricesToThirdPartyPluginsMigrationTest.php create mode 100644 tests/Feature/Filament/PluginPricesRelationManagerTest.php create mode 100644 tests/Feature/UltraPluginPricingTest.php 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'); + } +}