From 0c629cac9967265997fa648650303738a83b8cf6 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 15 Apr 2026 08:13:19 +0100 Subject: [PATCH] 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 --- app/Filament/Resources/UserResource.php | 3 + app/Jobs/HandleInvoicePaidJob.php | 4 +- app/Models/DeveloperAccount.php | 6 + .../factories/DeveloperAccountFactory.php | 1 + ...percentage_to_developer_accounts_table.php | 28 +++ tests/Feature/DeveloperAccountPayoutTest.php | 168 ++++++++++++++++++ 6 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_04_13_172153_add_payout_percentage_to_developer_accounts_table.php create mode 100644 tests/Feature/DeveloperAccountPayoutTest.php diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 8320ba24..a03b689e 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -110,6 +110,9 @@ public static function form(Schema $schema): Schema Forms\Components\Placeholder::make('developerAccount.accepted_plugin_terms_at') ->label('Plugin Terms Accepted') ->content(fn (User $record) => $record->developerAccount->accepted_plugin_terms_at?->format('M j, Y g:i A') ?? '—'), + Forms\Components\Placeholder::make('developerAccount.payout_percentage') + ->label('Payout Percentage') + ->content(fn (User $record) => $record->developerAccount->payout_percentage.'%'), Forms\Components\Placeholder::make('developerAccount.plugin_terms_version') ->label('Terms Version') ->content(fn (User $record) => $record->developerAccount->plugin_terms_version ?? '—'), diff --git a/app/Jobs/HandleInvoicePaidJob.php b/app/Jobs/HandleInvoicePaidJob.php index 9fccbade..a7d64dad 100644 --- a/app/Jobs/HandleInvoicePaidJob.php +++ b/app/Jobs/HandleInvoicePaidJob.php @@ -523,7 +523,7 @@ private function createPluginLicense(User $user, Plugin $plugin, int $amount): P if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $amount > 0) { $platformFeePercent = ($user->hasActiveUltraSubscription() && ! $plugin->isOfficial()) ? 0 - : PluginPayout::PLATFORM_FEE_PERCENT; + : $plugin->developerAccount->platformFeePercent(); $split = PluginPayout::calculateSplit($amount, $platformFeePercent); @@ -565,7 +565,7 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun if ($plugin->developerAccount && $plugin->developerAccount->canReceivePayouts() && $allocatedAmount > 0) { $platformFeePercent = ($user->hasActiveUltraSubscription() && ! $plugin->isOfficial()) ? 0 - : PluginPayout::PLATFORM_FEE_PERCENT; + : $plugin->developerAccount->platformFeePercent(); $split = PluginPayout::calculateSplit($allocatedAmount, $platformFeePercent); diff --git a/app/Models/DeveloperAccount.php b/app/Models/DeveloperAccount.php index f0994f27..3c8157a7 100644 --- a/app/Models/DeveloperAccount.php +++ b/app/Models/DeveloperAccount.php @@ -66,6 +66,11 @@ public function hasAcceptedCurrentTerms(): bool && $this->plugin_terms_version === self::CURRENT_PLUGIN_TERMS_VERSION; } + public function platformFeePercent(): int + { + return 100 - $this->payout_percentage; + } + protected function casts(): array { return [ @@ -74,6 +79,7 @@ protected function casts(): array 'charges_enabled' => 'boolean', 'onboarding_completed_at' => 'datetime', 'accepted_plugin_terms_at' => 'datetime', + 'payout_percentage' => 'integer', ]; } } diff --git a/database/factories/DeveloperAccountFactory.php b/database/factories/DeveloperAccountFactory.php index 16fec8b5..cc6e4d74 100644 --- a/database/factories/DeveloperAccountFactory.php +++ b/database/factories/DeveloperAccountFactory.php @@ -28,6 +28,7 @@ public function definition(): array 'onboarding_completed_at' => now(), 'country' => 'US', 'payout_currency' => 'USD', + 'payout_percentage' => 70, ]; } diff --git a/database/migrations/2026_04_13_172153_add_payout_percentage_to_developer_accounts_table.php b/database/migrations/2026_04_13_172153_add_payout_percentage_to_developer_accounts_table.php new file mode 100644 index 00000000..47ce6d5a --- /dev/null +++ b/database/migrations/2026_04_13_172153_add_payout_percentage_to_developer_accounts_table.php @@ -0,0 +1,28 @@ +unsignedTinyInteger('payout_percentage')->default(70)->after('payout_currency'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('developer_accounts', function (Blueprint $table) { + $table->dropColumn('payout_percentage'); + }); + } +}; diff --git a/tests/Feature/DeveloperAccountPayoutTest.php b/tests/Feature/DeveloperAccountPayoutTest.php new file mode 100644 index 00000000..1ff07324 --- /dev/null +++ b/tests/Feature/DeveloperAccountPayoutTest.php @@ -0,0 +1,168 @@ + self::MAX_PRICE_ID]); + } + + #[Test] + public function payout_percentage_defaults_to_seventy(): void + { + $account = DeveloperAccount::factory()->create(); + + $this->assertEquals(70, $account->payout_percentage); + } + + #[Test] + public function platform_fee_percent_is_inverse_of_payout_percentage(): void + { + $account = DeveloperAccount::factory()->create(['payout_percentage' => 80]); + + $this->assertEquals(20, $account->platformFeePercent()); + } + + #[Test] + public function platform_fee_percent_with_default_payout_percentage(): void + { + $account = DeveloperAccount::factory()->create(); + + $this->assertEquals(30, $account->platformFeePercent()); + } + + #[Test] + public function custom_payout_percentage_is_used_for_plugin_purchase(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + + $developerAccount = DeveloperAccount::factory()->create(['payout_percentage' => 80]); + $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(10000)->create(['plugin_id' => $plugin->id]); + + $cart = Cart::factory()->for($buyer)->create(); + CartItem::create([ + 'cart_id' => $cart->id, + 'plugin_id' => $plugin->id, + 'plugin_price_id' => $plugin->prices->first()->id, + 'price_at_addition' => 10000, + ]); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(10000, $payout->gross_amount); + $this->assertEquals(2000, $payout->platform_fee); + $this->assertEquals(8000, $payout->developer_amount); + } + + #[Test] + public function ultra_subscriber_overrides_custom_payout_percentage_to_full(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + Subscription::factory()->for($buyer)->active()->create(['stripe_price' => self::MAX_PRICE_ID]); + + $developerAccount = DeveloperAccount::factory()->create(['payout_percentage' => 80]); + $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(10000)->create(['plugin_id' => $plugin->id]); + + $cart = Cart::factory()->for($buyer)->create(); + CartItem::create([ + 'cart_id' => $cart->id, + 'plugin_id' => $plugin->id, + 'plugin_price_id' => $plugin->prices->first()->id, + 'price_at_addition' => 10000, + ]); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(10000, $payout->gross_amount); + $this->assertEquals(0, $payout->platform_fee); + $this->assertEquals(10000, $payout->developer_amount); + } + + #[Test] + public function developer_with_hundred_percent_payout_gets_full_amount(): void + { + $buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]); + + $developerAccount = DeveloperAccount::factory()->create(['payout_percentage' => 100]); + $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(5000)->create(['plugin_id' => $plugin->id]); + + $cart = Cart::factory()->for($buyer)->create(); + CartItem::create([ + 'cart_id' => $cart->id, + 'plugin_id' => $plugin->id, + 'plugin_price_id' => $plugin->prices->first()->id, + 'price_at_addition' => 5000, + ]); + + $invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id); + $job = new HandleInvoicePaidJob($invoice); + $job->handle(); + + $payout = PluginPayout::first(); + $this->assertNotNull($payout); + $this->assertEquals(5000, $payout->gross_amount); + $this->assertEquals(0, $payout->platform_fee); + $this->assertEquals(5000, $payout->developer_amount); + } + + private function createStripeInvoice(string $cartId, string $customerId): Invoice + { + return Invoice::constructFrom([ + 'id' => 'in_test_'.uniqid(), + 'billing_reason' => Invoice::BILLING_REASON_MANUAL, + 'customer' => $customerId, + 'payment_intent' => 'pi_test_'.uniqid(), + 'currency' => 'usd', + 'metadata' => ['cart_id' => $cartId], + 'lines' => [], + ]); + } +}