Skip to content

Commit 10e1aa7

Browse files
simonhampclaude
andauthored
Gate paid plugin type behind developer onboarding completion (#345)
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 2ed0198 commit 10e1aa7

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)