Skip to content

Commit 4c0557d

Browse files
simonhampclaude
andcommitted
Gate paid plugin type behind developer onboarding completion
Users must complete developer onboarding (Stripe Connect) before they can select the "Paid" plugin type when creating or editing a draft plugin. The option is visually disabled with a link to the onboarding flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a8aeda3 commit 4c0557d

7 files changed

Lines changed: 285 additions & 7 deletions

File tree

app/Livewire/Customer/Plugins/Create.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ class Create extends Component
3131

3232
public bool $reposLoaded = false;
3333

34+
#[Computed]
35+
public function hasCompletedDeveloperOnboarding(): bool
36+
{
37+
return auth()->user()->developerAccount?->hasCompletedOnboarding() ?? false;
38+
}
39+
3440
#[Computed]
3541
public function owners(): array
3642
{
@@ -142,6 +148,12 @@ function ($attribute, $value, $fail): void {
142148
return;
143149
}
144150

151+
if ($this->pluginType === 'paid' && ! $this->hasCompletedDeveloperOnboarding) {
152+
session()->flash('error', 'You must complete developer onboarding before creating a paid plugin.');
153+
154+
return;
155+
}
156+
145157
$repository = trim($this->repository, '/');
146158
$repositoryUrl = 'https://github.com/'.$repository;
147159
[$owner, $repo] = explode('/', $repository);

app/Livewire/Customer/Plugins/Show.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use App\Notifications\PluginSubmitted;
1010
use App\Services\GitHubUserService;
1111
use Illuminate\Support\Facades\Storage;
12+
use Livewire\Attributes\Computed;
1213
use Livewire\Attributes\Layout;
1314
use Livewire\Attributes\Title;
1415
use Livewire\Attributes\Validate;
@@ -48,6 +49,12 @@ class Show extends Component
4849

4950
public ?string $tier = null;
5051

52+
#[Computed]
53+
public function hasCompletedDeveloperOnboarding(): bool
54+
{
55+
return auth()->user()->developerAccount?->hasCompletedOnboarding() ?? false;
56+
}
57+
5158
public function mount(string $vendor, string $package): void
5259
{
5360
$this->plugin = Plugin::findByVendorPackageOrFail($vendor, $package);
@@ -181,6 +188,12 @@ function (string $attribute, mixed $value, \Closure $fail) {
181188
'tier.required' => 'Please select a pricing tier for your paid plugin.',
182189
]);
183190

191+
if ($this->plugin->isDraft() && $this->pluginType === 'paid' && ! $this->hasCompletedDeveloperOnboarding) {
192+
session()->flash('error', 'You must complete developer onboarding before setting a plugin as paid.');
193+
194+
return;
195+
}
196+
184197
$data = [
185198
'display_name' => $this->displayName ?: null,
186199
'support_channel' => $this->supportChannel,

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/views/livewire/customer/plugins/create.blade.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,19 @@
5959
</span>
6060
</label>
6161

62-
<label class="relative flex cursor-pointer rounded-lg border p-4 transition focus:outline-none"
63-
:class="$wire.pluginType === 'paid' ? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-950/30' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'">
64-
<input type="radio" wire:model="pluginType" value="paid" class="sr-only" />
62+
<label class="relative flex rounded-lg border p-4 transition focus:outline-none {{ $this->hasCompletedDeveloperOnboarding ? 'cursor-pointer' : 'cursor-not-allowed opacity-60' }}"
63+
:class="$wire.pluginType === 'paid' ? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-950/30' : 'border-gray-200 dark:border-gray-700 {{ $this->hasCompletedDeveloperOnboarding ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50' : '' }}'">
64+
<input type="radio" wire:model="pluginType" value="paid" class="sr-only" {{ $this->hasCompletedDeveloperOnboarding ? '' : 'disabled' }} />
6565
<span class="flex flex-1 flex-col">
6666
<span class="text-sm font-medium text-gray-900 dark:text-white">Paid Plugin</span>
6767
<span class="mt-1 text-sm text-gray-500 dark:text-gray-400">Commercial plugin, hosted on plugins.nativephp.com</span>
6868
</span>
6969
</label>
70+
@if (! $this->hasCompletedDeveloperOnboarding)
71+
<flux:text class="text-sm text-gray-500 dark:text-gray-400">
72+
To create paid plugins, you need to <a href="{{ route('customer.developer.onboarding') }}" class="font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400" wire:navigate>complete developer onboarding</a>.
73+
</flux:text>
74+
@endif
7075
</div>
7176

7277
@error('pluginType')

resources/views/livewire/customer/plugins/show.blade.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,14 +217,19 @@
217217
</span>
218218
</label>
219219

220-
<label class="relative flex cursor-pointer rounded-lg border p-4 transition focus:outline-none"
221-
:class="$wire.pluginType === 'paid' ? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-950/30' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'">
222-
<input type="radio" wire:model.live="pluginType" value="paid" class="sr-only" />
220+
<label class="relative flex rounded-lg border p-4 transition focus:outline-none {{ $this->hasCompletedDeveloperOnboarding ? 'cursor-pointer' : 'cursor-not-allowed opacity-60' }}"
221+
:class="$wire.pluginType === 'paid' ? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-950/30' : 'border-gray-200 dark:border-gray-700 {{ $this->hasCompletedDeveloperOnboarding ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50' : '' }}'">
222+
<input type="radio" wire:model.live="pluginType" value="paid" class="sr-only" {{ $this->hasCompletedDeveloperOnboarding ? '' : 'disabled' }} />
223223
<span class="flex flex-1 flex-col">
224224
<span class="text-sm font-medium text-gray-900 dark:text-white">Paid Plugin</span>
225225
<span class="mt-1 text-sm text-gray-500 dark:text-gray-400">Commercial plugin, hosted on plugins.nativephp.com</span>
226226
</span>
227227
</label>
228+
@if (! $this->hasCompletedDeveloperOnboarding)
229+
<flux:text class="text-sm text-gray-500 dark:text-gray-400">
230+
To create paid plugins, you need to <a href="{{ route('customer.developer.onboarding') }}" class="font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400" wire:navigate>complete developer onboarding</a>.
231+
</flux:text>
232+
@endif
228233
</div>
229234
</flux:card>
230235

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<?php
2+
3+
namespace Tests\Feature\Livewire\Customer;
4+
5+
use App\Enums\PluginType;
6+
use App\Features\AllowPaidPlugins;
7+
use App\Features\ShowAuthButtons;
8+
use App\Features\ShowPlugins;
9+
use App\Livewire\Customer\Plugins\Create;
10+
use App\Livewire\Customer\Plugins\Show;
11+
use App\Models\DeveloperAccount;
12+
use App\Models\Plugin;
13+
use App\Models\User;
14+
use Illuminate\Foundation\Testing\RefreshDatabase;
15+
use Illuminate\Support\Facades\Http;
16+
use Laravel\Pennant\Feature;
17+
use Livewire\Livewire;
18+
use Tests\TestCase;
19+
20+
class PluginPaidOnboardingTest extends TestCase
21+
{
22+
use RefreshDatabase;
23+
24+
protected function setUp(): void
25+
{
26+
parent::setUp();
27+
28+
Feature::define(ShowAuthButtons::class, true);
29+
Feature::define(ShowPlugins::class, true);
30+
Feature::define(AllowPaidPlugins::class, true);
31+
}
32+
33+
private function createGitHubUser(): User
34+
{
35+
return User::factory()->create([
36+
'github_id' => '12345',
37+
'github_username' => 'testuser',
38+
'github_token' => encrypt('fake-token'),
39+
]);
40+
}
41+
42+
private function fakeComposerJson(string $owner, string $repo, string $packageName): void
43+
{
44+
$composerJson = base64_encode(json_encode(['name' => $packageName]));
45+
46+
Http::fake([
47+
"api.github.com/repos/{$owner}/{$repo}/contents/composer.json*" => Http::response([
48+
'content' => $composerJson,
49+
]),
50+
'api.github.com/*' => Http::response([], 404),
51+
]);
52+
}
53+
54+
// ========================================
55+
// Create: Paid option disabled without onboarding
56+
// ========================================
57+
58+
public function test_create_page_shows_paid_option_disabled_without_onboarding(): void
59+
{
60+
$user = $this->createGitHubUser();
61+
62+
Livewire::actingAs($user)->test(Create::class)
63+
->assertSee('complete developer onboarding')
64+
->assertSeeHtml('disabled');
65+
}
66+
67+
public function test_create_page_shows_paid_option_enabled_with_onboarding(): void
68+
{
69+
$user = $this->createGitHubUser();
70+
DeveloperAccount::factory()->for($user)->create();
71+
72+
Livewire::actingAs($user)->test(Create::class)
73+
->assertDontSee('complete developer onboarding');
74+
}
75+
76+
public function test_create_paid_plugin_blocked_without_onboarding(): void
77+
{
78+
$user = $this->createGitHubUser();
79+
80+
$this->fakeComposerJson('testuser', 'my-plugin', 'testuser/my-plugin');
81+
82+
Livewire::actingAs($user)->test(Create::class)
83+
->set('repository', 'testuser/my-plugin')
84+
->set('pluginType', 'paid')
85+
->call('createPlugin')
86+
->assertNoRedirect();
87+
88+
$this->assertDatabaseMissing('plugins', [
89+
'repository_url' => 'https://github.com/testuser/my-plugin',
90+
]);
91+
}
92+
93+
public function test_create_paid_plugin_allowed_with_onboarding(): void
94+
{
95+
$user = $this->createGitHubUser();
96+
DeveloperAccount::factory()->for($user)->create();
97+
98+
$this->fakeComposerJson('testuser', 'paid-plugin', 'testuser/paid-plugin');
99+
100+
Livewire::actingAs($user)->test(Create::class)
101+
->set('repository', 'testuser/paid-plugin')
102+
->set('pluginType', 'paid')
103+
->call('createPlugin');
104+
105+
$this->assertDatabaseHas('plugins', [
106+
'repository_url' => 'https://github.com/testuser/paid-plugin',
107+
'type' => 'paid',
108+
'status' => 'draft',
109+
]);
110+
}
111+
112+
public function test_create_free_plugin_allowed_without_onboarding(): void
113+
{
114+
$user = $this->createGitHubUser();
115+
116+
$this->fakeComposerJson('testuser', 'free-plugin', 'testuser/free-plugin');
117+
118+
Livewire::actingAs($user)->test(Create::class)
119+
->set('repository', 'testuser/free-plugin')
120+
->set('pluginType', 'free')
121+
->call('createPlugin');
122+
123+
$this->assertDatabaseHas('plugins', [
124+
'repository_url' => 'https://github.com/testuser/free-plugin',
125+
'type' => 'free',
126+
'status' => 'draft',
127+
]);
128+
}
129+
130+
// ========================================
131+
// Edit Draft: Paid option disabled without onboarding
132+
// ========================================
133+
134+
public function test_edit_draft_shows_paid_option_disabled_without_onboarding(): void
135+
{
136+
$user = $this->createGitHubUser();
137+
$plugin = Plugin::factory()->draft()->for($user)->create([
138+
'name' => 'testuser/onboard-test',
139+
]);
140+
141+
[$vendor, $package] = explode('/', $plugin->name);
142+
143+
Livewire::actingAs($user)->test(Show::class, [
144+
'vendor' => $vendor,
145+
'package' => $package,
146+
])
147+
->assertSee('complete developer onboarding')
148+
->assertSeeHtml('disabled');
149+
}
150+
151+
public function test_edit_draft_shows_paid_option_enabled_with_onboarding(): void
152+
{
153+
$user = $this->createGitHubUser();
154+
DeveloperAccount::factory()->for($user)->create();
155+
$plugin = Plugin::factory()->draft()->for($user)->create([
156+
'name' => 'testuser/onboard-enabled',
157+
]);
158+
159+
[$vendor, $package] = explode('/', $plugin->name);
160+
161+
Livewire::actingAs($user)->test(Show::class, [
162+
'vendor' => $vendor,
163+
'package' => $package,
164+
])
165+
->assertDontSee('complete developer onboarding');
166+
}
167+
168+
public function test_save_draft_as_paid_blocked_without_onboarding(): void
169+
{
170+
$user = $this->createGitHubUser();
171+
$plugin = Plugin::factory()->draft()->for($user)->create([
172+
'name' => 'testuser/save-paid-test',
173+
'support_channel' => 'support@test.io',
174+
]);
175+
176+
[$vendor, $package] = explode('/', $plugin->name);
177+
178+
Livewire::actingAs($user)->test(Show::class, [
179+
'vendor' => $vendor,
180+
'package' => $package,
181+
])
182+
->set('description', 'A test plugin')
183+
->set('supportChannel', 'support@test.io')
184+
->set('pluginType', 'paid')
185+
->set('tier', 'gold')
186+
->call('save');
187+
188+
$plugin->refresh();
189+
$this->assertNotEquals(PluginType::Paid, $plugin->type);
190+
}
191+
192+
public function test_save_draft_as_paid_allowed_with_onboarding(): void
193+
{
194+
$user = $this->createGitHubUser();
195+
DeveloperAccount::factory()->for($user)->create();
196+
$plugin = Plugin::factory()->draft()->for($user)->create([
197+
'name' => 'testuser/save-paid-ok',
198+
'support_channel' => 'support@test.io',
199+
]);
200+
201+
[$vendor, $package] = explode('/', $plugin->name);
202+
203+
Livewire::actingAs($user)->test(Show::class, [
204+
'vendor' => $vendor,
205+
'package' => $package,
206+
])
207+
->set('description', 'A test plugin')
208+
->set('supportChannel', 'support@test.io')
209+
->set('pluginType', 'paid')
210+
->set('tier', 'gold')
211+
->call('save');
212+
213+
$plugin->refresh();
214+
$this->assertEquals(PluginType::Paid, $plugin->type);
215+
}
216+
217+
public function test_create_page_shows_onboarding_link_without_onboarding(): void
218+
{
219+
$user = $this->createGitHubUser();
220+
221+
Livewire::actingAs($user)->test(Create::class)
222+
->assertSeeHtml(route('customer.developer.onboarding'));
223+
}
224+
225+
public function test_edit_draft_shows_onboarding_link_without_onboarding(): void
226+
{
227+
$user = $this->createGitHubUser();
228+
$plugin = Plugin::factory()->draft()->for($user)->create([
229+
'name' => 'testuser/link-test',
230+
]);
231+
232+
[$vendor, $package] = explode('/', $plugin->name);
233+
234+
Livewire::actingAs($user)->test(Show::class, [
235+
'vendor' => $vendor,
236+
'package' => $package,
237+
])
238+
->assertSeeHtml(route('customer.developer.onboarding'));
239+
}
240+
}

tests/Feature/Livewire/Customer/PluginStatusTransitionsTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Enums\PluginType;
99
use App\Features\AllowPaidPlugins;
1010
use App\Livewire\Customer\Plugins\Show;
11+
use App\Models\DeveloperAccount;
1112
use App\Models\Plugin;
1213
use App\Models\User;
1314
use App\Notifications\PluginSubmitted;
@@ -374,6 +375,7 @@ public function test_save_paid_plugin_requires_tier(): void
374375
Feature::define(AllowPaidPlugins::class, true);
375376

376377
$user = $this->createGitHubUser();
378+
DeveloperAccount::factory()->for($user)->create();
377379
$plugin = $this->createDraftPlugin($user);
378380

379381
$this->mountShowComponent($user, $plugin)
@@ -393,6 +395,7 @@ public function test_submit_paid_plugin_with_tier_saves_type_and_tier(): void
393395
Feature::define(AllowPaidPlugins::class, true);
394396

395397
$user = $this->createGitHubUser();
398+
DeveloperAccount::factory()->for($user)->create();
396399
$plugin = $this->createDraftPlugin($user);
397400
$this->fakeGitHubForSubmission($plugin);
398401

0 commit comments

Comments
 (0)