Skip to content

Commit 0c629ca

Browse files
simonhampclaude
andcommitted
Add per-account payout percentage to developer accounts
Store a configurable payout_percentage (default 70) on each developer account so platform fees can be adjusted per developer. The invoice paid job now reads this value instead of the hardcoded constant, while Ultra subscriber purchases still override to 100% developer payout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6d6c738 commit 0c629ca

File tree

6 files changed

+208
-2
lines changed

6 files changed

+208
-2
lines changed

app/Filament/Resources/UserResource.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ public static function form(Schema $schema): Schema
110110
Forms\Components\Placeholder::make('developerAccount.accepted_plugin_terms_at')
111111
->label('Plugin Terms Accepted')
112112
->content(fn (User $record) => $record->developerAccount->accepted_plugin_terms_at?->format('M j, Y g:i A') ?? ''),
113+
Forms\Components\Placeholder::make('developerAccount.payout_percentage')
114+
->label('Payout Percentage')
115+
->content(fn (User $record) => $record->developerAccount->payout_percentage.'%'),
113116
Forms\Components\Placeholder::make('developerAccount.plugin_terms_version')
114117
->label('Terms Version')
115118
->content(fn (User $record) => $record->developerAccount->plugin_terms_version ?? ''),

app/Jobs/HandleInvoicePaidJob.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ private function createPluginLicense(User $user, Plugin $plugin, int $amount): P
523523
if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $amount > 0) {
524524
$platformFeePercent = ($user->hasActiveUltraSubscription() && ! $plugin->isOfficial())
525525
? 0
526-
: PluginPayout::PLATFORM_FEE_PERCENT;
526+
: $plugin->developerAccount->platformFeePercent();
527527

528528
$split = PluginPayout::calculateSplit($amount, $platformFeePercent);
529529

@@ -565,7 +565,7 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun
565565
if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) {
566566
$platformFeePercent = ($user->hasActiveUltraSubscription() && ! $plugin->isOfficial())
567567
? 0
568-
: PluginPayout::PLATFORM_FEE_PERCENT;
568+
: $plugin->developerAccount->platformFeePercent();
569569

570570
$split = PluginPayout::calculateSplit($allocatedAmount, $platformFeePercent);
571571

app/Models/DeveloperAccount.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ public function hasAcceptedCurrentTerms(): bool
6666
&& $this->plugin_terms_version === self::CURRENT_PLUGIN_TERMS_VERSION;
6767
}
6868

69+
public function platformFeePercent(): int
70+
{
71+
return 100 - $this->payout_percentage;
72+
}
73+
6974
protected function casts(): array
7075
{
7176
return [
@@ -74,6 +79,7 @@ protected function casts(): array
7479
'charges_enabled' => 'boolean',
7580
'onboarding_completed_at' => 'datetime',
7681
'accepted_plugin_terms_at' => 'datetime',
82+
'payout_percentage' => 'integer',
7783
];
7884
}
7985
}

database/factories/DeveloperAccountFactory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public function definition(): array
2828
'onboarding_completed_at' => now(),
2929
'country' => 'US',
3030
'payout_currency' => 'USD',
31+
'payout_percentage' => 70,
3132
];
3233
}
3334

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('developer_accounts', function (Blueprint $table) {
15+
$table->unsignedTinyInteger('payout_percentage')->default(70)->after('payout_currency');
16+
});
17+
}
18+
19+
/**
20+
* Reverse the migrations.
21+
*/
22+
public function down(): void
23+
{
24+
Schema::table('developer_accounts', function (Blueprint $table) {
25+
$table->dropColumn('payout_percentage');
26+
});
27+
}
28+
};
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Jobs\HandleInvoicePaidJob;
6+
use App\Models\Cart;
7+
use App\Models\CartItem;
8+
use App\Models\DeveloperAccount;
9+
use App\Models\Plugin;
10+
use App\Models\PluginPayout;
11+
use App\Models\PluginPrice;
12+
use App\Models\User;
13+
use Illuminate\Foundation\Testing\RefreshDatabase;
14+
use Laravel\Cashier\Subscription;
15+
use PHPUnit\Framework\Attributes\Test;
16+
use Stripe\Invoice;
17+
use Tests\TestCase;
18+
19+
class DeveloperAccountPayoutTest extends TestCase
20+
{
21+
use RefreshDatabase;
22+
23+
private const MAX_PRICE_ID = 'price_1RoZk0AyFo6rlwXqjkLj4hZ0';
24+
25+
protected function setUp(): void
26+
{
27+
parent::setUp();
28+
29+
config(['subscriptions.plans.max.stripe_price_id' => self::MAX_PRICE_ID]);
30+
}
31+
32+
#[Test]
33+
public function payout_percentage_defaults_to_seventy(): void
34+
{
35+
$account = DeveloperAccount::factory()->create();
36+
37+
$this->assertEquals(70, $account->payout_percentage);
38+
}
39+
40+
#[Test]
41+
public function platform_fee_percent_is_inverse_of_payout_percentage(): void
42+
{
43+
$account = DeveloperAccount::factory()->create(['payout_percentage' => 80]);
44+
45+
$this->assertEquals(20, $account->platformFeePercent());
46+
}
47+
48+
#[Test]
49+
public function platform_fee_percent_with_default_payout_percentage(): void
50+
{
51+
$account = DeveloperAccount::factory()->create();
52+
53+
$this->assertEquals(30, $account->platformFeePercent());
54+
}
55+
56+
#[Test]
57+
public function custom_payout_percentage_is_used_for_plugin_purchase(): void
58+
{
59+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
60+
61+
$developerAccount = DeveloperAccount::factory()->create(['payout_percentage' => 80]);
62+
$plugin = Plugin::factory()->approved()->paid()->create([
63+
'is_active' => true,
64+
'is_official' => false,
65+
'user_id' => $developerAccount->user_id,
66+
'developer_account_id' => $developerAccount->id,
67+
]);
68+
PluginPrice::factory()->regular()->amount(10000)->create(['plugin_id' => $plugin->id]);
69+
70+
$cart = Cart::factory()->for($buyer)->create();
71+
CartItem::create([
72+
'cart_id' => $cart->id,
73+
'plugin_id' => $plugin->id,
74+
'plugin_price_id' => $plugin->prices->first()->id,
75+
'price_at_addition' => 10000,
76+
]);
77+
78+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
79+
$job = new HandleInvoicePaidJob($invoice);
80+
$job->handle();
81+
82+
$payout = PluginPayout::first();
83+
$this->assertNotNull($payout);
84+
$this->assertEquals(10000, $payout->gross_amount);
85+
$this->assertEquals(2000, $payout->platform_fee);
86+
$this->assertEquals(8000, $payout->developer_amount);
87+
}
88+
89+
#[Test]
90+
public function ultra_subscriber_overrides_custom_payout_percentage_to_full(): void
91+
{
92+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
93+
Subscription::factory()->for($buyer)->active()->create(['stripe_price' => self::MAX_PRICE_ID]);
94+
95+
$developerAccount = DeveloperAccount::factory()->create(['payout_percentage' => 80]);
96+
$plugin = Plugin::factory()->approved()->paid()->create([
97+
'is_active' => true,
98+
'is_official' => false,
99+
'user_id' => $developerAccount->user_id,
100+
'developer_account_id' => $developerAccount->id,
101+
]);
102+
PluginPrice::factory()->regular()->amount(10000)->create(['plugin_id' => $plugin->id]);
103+
104+
$cart = Cart::factory()->for($buyer)->create();
105+
CartItem::create([
106+
'cart_id' => $cart->id,
107+
'plugin_id' => $plugin->id,
108+
'plugin_price_id' => $plugin->prices->first()->id,
109+
'price_at_addition' => 10000,
110+
]);
111+
112+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
113+
$job = new HandleInvoicePaidJob($invoice);
114+
$job->handle();
115+
116+
$payout = PluginPayout::first();
117+
$this->assertNotNull($payout);
118+
$this->assertEquals(10000, $payout->gross_amount);
119+
$this->assertEquals(0, $payout->platform_fee);
120+
$this->assertEquals(10000, $payout->developer_amount);
121+
}
122+
123+
#[Test]
124+
public function developer_with_hundred_percent_payout_gets_full_amount(): void
125+
{
126+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
127+
128+
$developerAccount = DeveloperAccount::factory()->create(['payout_percentage' => 100]);
129+
$plugin = Plugin::factory()->approved()->paid()->create([
130+
'is_active' => true,
131+
'is_official' => false,
132+
'user_id' => $developerAccount->user_id,
133+
'developer_account_id' => $developerAccount->id,
134+
]);
135+
PluginPrice::factory()->regular()->amount(5000)->create(['plugin_id' => $plugin->id]);
136+
137+
$cart = Cart::factory()->for($buyer)->create();
138+
CartItem::create([
139+
'cart_id' => $cart->id,
140+
'plugin_id' => $plugin->id,
141+
'plugin_price_id' => $plugin->prices->first()->id,
142+
'price_at_addition' => 5000,
143+
]);
144+
145+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
146+
$job = new HandleInvoicePaidJob($invoice);
147+
$job->handle();
148+
149+
$payout = PluginPayout::first();
150+
$this->assertNotNull($payout);
151+
$this->assertEquals(5000, $payout->gross_amount);
152+
$this->assertEquals(0, $payout->platform_fee);
153+
$this->assertEquals(5000, $payout->developer_amount);
154+
}
155+
156+
private function createStripeInvoice(string $cartId, string $customerId): Invoice
157+
{
158+
return Invoice::constructFrom([
159+
'id' => 'in_test_'.uniqid(),
160+
'billing_reason' => Invoice::BILLING_REASON_MANUAL,
161+
'customer' => $customerId,
162+
'payment_intent' => 'pi_test_'.uniqid(),
163+
'currency' => 'usd',
164+
'metadata' => ['cart_id' => $cartId],
165+
'lines' => [],
166+
]);
167+
}
168+
}

0 commit comments

Comments
 (0)