Skip to content

Commit e1d26f5

Browse files
simonhampclaude
andcommitted
Max subscribers get full payout for third-party plugins, no subscriber discounts on third-party plugins
- Add User::hasMaxTierAccess() to check Max subscription via Stripe or Anystack - Waive platform fee (0% instead of 30%) when Max subscriber buys third-party plugin - Third-party plugins always show regular price (no subscriber/EAP discounts) - Official plugins retain existing tier-based discount behavior - Add PluginPayout::calculateSplit() optional platformFeePercent parameter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 664ca1a commit e1d26f5

File tree

7 files changed

+435
-27
lines changed

7 files changed

+435
-27
lines changed

app/Jobs/HandleInvoicePaidJob.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,11 @@ private function createPluginLicense(User $user, Plugin $plugin, int $amount): P
548548

549549
// Create payout record for developer if applicable
550550
if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $amount > 0) {
551-
$split = PluginPayout::calculateSplit($amount);
551+
$platformFeePercent = ($user->hasMaxTierAccess() && ! $plugin->isOfficial())
552+
? 0
553+
: PluginPayout::PLATFORM_FEE_PERCENT;
554+
555+
$split = PluginPayout::calculateSplit($amount, $platformFeePercent);
552556

553557
PluginPayout::create([
554558
'plugin_license_id' => $license->id,
@@ -586,7 +590,11 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun
586590

587591
// Create proportional payout for developer
588592
if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) {
589-
$split = PluginPayout::calculateSplit($allocatedAmount);
593+
$platformFeePercent = ($user->hasMaxTierAccess() && ! $plugin->isOfficial())
594+
? 0
595+
: PluginPayout::PLATFORM_FEE_PERCENT;
596+
597+
$split = PluginPayout::calculateSplit($allocatedAmount, $platformFeePercent);
590598

591599
PluginPayout::create([
592600
'plugin_license_id' => $license->id,

app/Models/Plugin.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,15 @@ public function activePrice(): HasOne
166166
/**
167167
* Get the best (lowest) active price for a user based on their eligible tiers.
168168
* Returns null if no price exists for the user's eligible tiers.
169+
* Third-party plugins never offer subscriber discounts — always regular price.
169170
*/
170171
public function getBestPriceForUser(?User $user): ?PluginPrice
171172
{
172-
$eligibleTiers = $user ? $user->getEligiblePriceTiers() : [PriceTier::Regular];
173+
if (! $this->isOfficial()) {
174+
$eligibleTiers = [PriceTier::Regular];
175+
} else {
176+
$eligibleTiers = $user ? $user->getEligiblePriceTiers() : [PriceTier::Regular];
177+
}
173178

174179
// Get the lowest active price for the user's eligible tiers
175180
return $this->prices()

app/Models/PluginPayout.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ protected function failed(Builder $query): Builder
6666
/**
6767
* @return array{platform_fee: int, developer_amount: int}
6868
*/
69-
public static function calculateSplit(int $grossAmount): array
69+
public static function calculateSplit(int $grossAmount, int $platformFeePercent = self::PLATFORM_FEE_PERCENT): array
7070
{
71-
$platformFee = (int) round($grossAmount * self::PLATFORM_FEE_PERCENT / 100);
71+
$platformFee = (int) round($grossAmount * $platformFeePercent / 100);
7272
$developerAmount = $grossAmount - $platformFee;
7373

7474
return [

app/Models/User.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
// use Illuminate\Contracts\Auth\MustVerifyEmail;
66
use App\Enums\PriceTier;
7+
use App\Enums\Subscription;
78
use Filament\Models\Contracts\FilamentUser;
89
use Filament\Panel;
910
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -124,6 +125,25 @@ public function hasMaxAccess(): bool
124125
return $this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense();
125126
}
126127

128+
public function hasMaxTierAccess(): bool
129+
{
130+
if ($this->hasMaxAccess()) {
131+
return true;
132+
}
133+
134+
$subscription = $this->subscription();
135+
136+
if ($subscription?->active()) {
137+
try {
138+
return Subscription::fromStripePriceId($subscription->stripe_price) === Subscription::Max;
139+
} catch (\RuntimeException) {
140+
return false;
141+
}
142+
}
143+
144+
return false;
145+
}
146+
127147
/**
128148
* Check if user was an Early Access Program customer.
129149
* EAP customers purchased before June 1, 2025.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Enums\PayoutStatus;
6+
use App\Jobs\HandleInvoicePaidJob;
7+
use App\Models\Cart;
8+
use App\Models\CartItem;
9+
use App\Models\DeveloperAccount;
10+
use App\Models\Plugin;
11+
use App\Models\PluginPayout;
12+
use App\Models\PluginPrice;
13+
use App\Models\User;
14+
use Illuminate\Foundation\Testing\RefreshDatabase;
15+
use Laravel\Cashier\Subscription;
16+
use PHPUnit\Framework\Attributes\Test;
17+
use Stripe\Invoice;
18+
use Tests\TestCase;
19+
20+
class MaxSubscriberPayoutTest extends TestCase
21+
{
22+
use RefreshDatabase;
23+
24+
private const MAX_PRICE_ID = 'price_1RoZk0AyFo6rlwXqjkLj4hZ0';
25+
26+
private const PRO_PRICE_ID = 'price_1RoZeVAyFo6rlwXqtnOViUCf';
27+
28+
private function createStripeInvoice(string $cartId, string $customerId): Invoice
29+
{
30+
$invoice = Invoice::constructFrom([
31+
'id' => 'in_test_'.uniqid(),
32+
'billing_reason' => Invoice::BILLING_REASON_MANUAL,
33+
'customer' => $customerId,
34+
'payment_intent' => 'pi_test_'.uniqid(),
35+
'currency' => 'usd',
36+
'metadata' => ['cart_id' => $cartId],
37+
'lines' => [],
38+
]);
39+
40+
return $invoice;
41+
}
42+
43+
private function createSubscription(User $user, string $priceId): Subscription
44+
{
45+
return Subscription::factory()
46+
->for($user)
47+
->active()
48+
->create(['stripe_price' => $priceId]);
49+
}
50+
51+
#[Test]
52+
public function max_subscriber_gets_zero_platform_fee_for_third_party_plugin(): void
53+
{
54+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
55+
$this->createSubscription($buyer, self::MAX_PRICE_ID);
56+
57+
$developerAccount = DeveloperAccount::factory()->create();
58+
$plugin = Plugin::factory()->approved()->paid()->create([
59+
'is_active' => true,
60+
'is_official' => false,
61+
'user_id' => $developerAccount->user_id,
62+
'developer_account_id' => $developerAccount->id,
63+
]);
64+
PluginPrice::factory()->regular()->amount(2999)->create(['plugin_id' => $plugin->id]);
65+
66+
$cart = Cart::factory()->for($buyer)->create();
67+
CartItem::create([
68+
'cart_id' => $cart->id,
69+
'plugin_id' => $plugin->id,
70+
'plugin_price_id' => $plugin->prices->first()->id,
71+
'price_at_addition' => 2999,
72+
]);
73+
74+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
75+
$job = new HandleInvoicePaidJob($invoice);
76+
$job->handle();
77+
78+
$payout = PluginPayout::first();
79+
$this->assertNotNull($payout);
80+
$this->assertEquals(2999, $payout->gross_amount);
81+
$this->assertEquals(0, $payout->platform_fee);
82+
$this->assertEquals(2999, $payout->developer_amount);
83+
$this->assertEquals(PayoutStatus::Pending, $payout->status);
84+
}
85+
86+
#[Test]
87+
public function non_max_subscriber_gets_normal_platform_fee_for_third_party_plugin(): void
88+
{
89+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
90+
$this->createSubscription($buyer, self::PRO_PRICE_ID);
91+
92+
$developerAccount = DeveloperAccount::factory()->create();
93+
$plugin = Plugin::factory()->approved()->paid()->create([
94+
'is_active' => true,
95+
'is_official' => false,
96+
'user_id' => $developerAccount->user_id,
97+
'developer_account_id' => $developerAccount->id,
98+
]);
99+
PluginPrice::factory()->regular()->amount(2999)->create(['plugin_id' => $plugin->id]);
100+
101+
$cart = Cart::factory()->for($buyer)->create();
102+
CartItem::create([
103+
'cart_id' => $cart->id,
104+
'plugin_id' => $plugin->id,
105+
'plugin_price_id' => $plugin->prices->first()->id,
106+
'price_at_addition' => 2999,
107+
]);
108+
109+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
110+
$job = new HandleInvoicePaidJob($invoice);
111+
$job->handle();
112+
113+
$payout = PluginPayout::first();
114+
$this->assertNotNull($payout);
115+
$this->assertEquals(2999, $payout->gross_amount);
116+
$this->assertEquals(900, $payout->platform_fee);
117+
$this->assertEquals(2099, $payout->developer_amount);
118+
}
119+
120+
#[Test]
121+
public function non_subscriber_gets_normal_platform_fee_for_third_party_plugin(): void
122+
{
123+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
124+
125+
$developerAccount = DeveloperAccount::factory()->create();
126+
$plugin = Plugin::factory()->approved()->paid()->create([
127+
'is_active' => true,
128+
'is_official' => false,
129+
'user_id' => $developerAccount->user_id,
130+
'developer_account_id' => $developerAccount->id,
131+
]);
132+
PluginPrice::factory()->regular()->amount(2999)->create(['plugin_id' => $plugin->id]);
133+
134+
$cart = Cart::factory()->for($buyer)->create();
135+
CartItem::create([
136+
'cart_id' => $cart->id,
137+
'plugin_id' => $plugin->id,
138+
'plugin_price_id' => $plugin->prices->first()->id,
139+
'price_at_addition' => 2999,
140+
]);
141+
142+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
143+
$job = new HandleInvoicePaidJob($invoice);
144+
$job->handle();
145+
146+
$payout = PluginPayout::first();
147+
$this->assertNotNull($payout);
148+
$this->assertEquals(2999, $payout->gross_amount);
149+
$this->assertEquals(900, $payout->platform_fee);
150+
$this->assertEquals(2099, $payout->developer_amount);
151+
}
152+
153+
#[Test]
154+
public function max_subscriber_gets_normal_platform_fee_for_official_plugin(): void
155+
{
156+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
157+
$this->createSubscription($buyer, self::MAX_PRICE_ID);
158+
159+
$developerAccount = DeveloperAccount::factory()->create();
160+
$plugin = Plugin::factory()->approved()->paid()->create([
161+
'is_active' => true,
162+
'is_official' => true,
163+
'user_id' => $developerAccount->user_id,
164+
'developer_account_id' => $developerAccount->id,
165+
]);
166+
PluginPrice::factory()->regular()->amount(2999)->create(['plugin_id' => $plugin->id]);
167+
168+
$cart = Cart::factory()->for($buyer)->create();
169+
CartItem::create([
170+
'cart_id' => $cart->id,
171+
'plugin_id' => $plugin->id,
172+
'plugin_price_id' => $plugin->prices->first()->id,
173+
'price_at_addition' => 2999,
174+
]);
175+
176+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
177+
$job = new HandleInvoicePaidJob($invoice);
178+
$job->handle();
179+
180+
$payout = PluginPayout::first();
181+
$this->assertNotNull($payout);
182+
$this->assertEquals(2999, $payout->gross_amount);
183+
$this->assertEquals(900, $payout->platform_fee);
184+
$this->assertEquals(2099, $payout->developer_amount);
185+
}
186+
}

0 commit comments

Comments
 (0)