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' => [], + ]); + } +}