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
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 + +
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