Skip to content

Commit 80383eb

Browse files
simonhampclaude
andcommitted
Grant Ultra subscribers access to claude-code repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c461578 commit 80383eb

File tree

5 files changed

+199
-4
lines changed

5 files changed

+199
-4
lines changed

app/Http/Controllers/GitHubIntegrationController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ public function requestClaudePluginsAccess(): RedirectResponse
167167
// Check if user has a Plugin Dev Kit license or is an Ultra team member
168168
$pluginDevKit = Product::where('slug', 'plugin-dev-kit')->first();
169169

170-
if (! $user->isUltraTeamMember() && (! $pluginDevKit || ! $user->hasProductLicense($pluginDevKit))) {
171-
return back()->with('error', 'You need a Plugin Dev Kit license or Ultra team membership to access the claude-code repository.');
170+
if (! $user->hasActiveUltraSubscription() && ! $user->isUltraTeamMember() && (! $pluginDevKit || ! $user->hasProductLicense($pluginDevKit))) {
171+
return back()->with('error', 'You need a Plugin Dev Kit license, Ultra subscription, or Ultra team membership to access the claude-code repository.');
172172
}
173173

174174
$github = GitHubOAuth::make();

app/Listeners/StripeWebhookReceivedListener.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Jobs\CreateUserFromStripeCustomer;
66
use App\Jobs\HandleInvoicePaidJob;
77
use App\Jobs\RemoveDiscordMaxRoleJob;
8+
use App\Jobs\RevokeTeamUserAccessJob;
89
use App\Jobs\SuspendTeamJob;
910
use App\Jobs\UnsuspendTeamJob;
1011
use App\Models\User;
@@ -77,6 +78,7 @@ private function handleSubscriptionDeleted(WebhookReceived $event): void
7778
$this->removeDiscordRoleIfNoMaxLicense($user);
7879

7980
dispatch(new SuspendTeamJob($user->id));
81+
dispatch(new RevokeTeamUserAccessJob($user->id));
8082
}
8183

8284
private function handleSubscriptionUpdated(WebhookReceived $event): void
@@ -101,6 +103,7 @@ private function handleSubscriptionUpdated(WebhookReceived $event): void
101103
if (in_array($status, ['canceled', 'unpaid', 'past_due', 'incomplete_expired'])) {
102104
$this->removeDiscordRoleIfNoMaxLicense($user);
103105
dispatch(new SuspendTeamJob($user->id));
106+
dispatch(new RevokeTeamUserAccessJob($user->id));
104107
}
105108

106109
// Detect reactivation: status changed to active from a non-active state

resources/views/livewire/claude-plugins-access-banner.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
@php
33
$user = auth()->user();
44
$pluginDevKit = \App\Models\Product::where('slug', 'plugin-dev-kit')->first();
5-
$hasLicense = $pluginDevKit && $user->hasProductLicense($pluginDevKit);
5+
$hasLicense = ($pluginDevKit && $user->hasProductLicense($pluginDevKit)) || $user->hasActiveUltraSubscription();
66
@endphp
77

88
@if($hasLicense)

resources/views/livewire/customer/integrations.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
<flux:heading>About Integrations</flux:heading>
2929
<div class="mt-4 prose dark:prose-invert prose-sm max-w-none">
3030
<ul class="list-disc list-inside space-y-2">
31-
<li><strong>GitHub:</strong> Max license holders can access the private <code>nativephp/mobile</code> repository. Plugin Dev Kit license holders can access <code>nativephp/claude-code</code>.</li>
31+
<li><strong>GitHub:</strong> Max license holders can access the private <code>nativephp/mobile</code> repository. Plugin Dev Kit license holders and Ultra subscribers can access <code>nativephp/claude-code</code>.</li>
3232
<li><strong>Discord:</strong> Max license holders receive a special "Max" role in the NativePHP Discord server.</li>
3333
</ul>
3434
<p class="mt-4">
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Enums\Subscription;
6+
use App\Jobs\RevokeTeamUserAccessJob;
7+
use App\Listeners\StripeWebhookReceivedListener;
8+
use App\Models\Product;
9+
use App\Models\ProductLicense;
10+
use App\Models\User;
11+
use Illuminate\Foundation\Testing\RefreshDatabase;
12+
use Illuminate\Support\Facades\Http;
13+
use Illuminate\Support\Facades\Queue;
14+
use Laravel\Cashier\Events\WebhookReceived;
15+
use Laravel\Cashier\Subscription as CashierSubscription;
16+
use Tests\TestCase;
17+
18+
class UltraClaudeCodeAccessTest extends TestCase
19+
{
20+
use RefreshDatabase;
21+
22+
private const MAX_PRICE_ID = 'price_test_max_yearly';
23+
24+
protected function setUp(): void
25+
{
26+
parent::setUp();
27+
28+
config(['subscriptions.plans.max.stripe_price_id' => self::MAX_PRICE_ID]);
29+
}
30+
31+
private function createUltraUser(): User
32+
{
33+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
34+
35+
CashierSubscription::factory()->for($user)->active()->create([
36+
'stripe_price' => self::MAX_PRICE_ID,
37+
]);
38+
39+
return $user;
40+
}
41+
42+
// ========================================
43+
// Banner Visibility Tests
44+
// ========================================
45+
46+
public function test_ultra_subscriber_sees_claude_plugins_banner(): void
47+
{
48+
Http::fake(['github.com/*' => Http::response([], 200)]);
49+
50+
$user = $this->createUltraUser();
51+
52+
$response = $this->actingAs($user)->get(route('customer.integrations'));
53+
54+
$response->assertStatus(200);
55+
$response->assertSee('Repo Access');
56+
}
57+
58+
public function test_plugin_dev_kit_license_holder_sees_claude_plugins_banner(): void
59+
{
60+
Http::fake(['github.com/*' => Http::response([], 200)]);
61+
62+
$user = User::factory()->create();
63+
$product = Product::factory()->create(['slug' => 'plugin-dev-kit']);
64+
ProductLicense::factory()->create([
65+
'user_id' => $user->id,
66+
'product_id' => $product->id,
67+
]);
68+
69+
$response = $this->actingAs($user)->get(route('customer.integrations'));
70+
71+
$response->assertStatus(200);
72+
$response->assertSee('Repo Access');
73+
}
74+
75+
public function test_non_ultra_non_licensed_user_does_not_see_claude_plugins_banner(): void
76+
{
77+
$user = User::factory()->create();
78+
79+
$response = $this->actingAs($user)->get(route('customer.integrations'));
80+
81+
$response->assertStatus(200);
82+
$response->assertDontSee('Repo Access');
83+
}
84+
85+
// ========================================
86+
// Request Access Tests
87+
// ========================================
88+
89+
public function test_ultra_subscriber_can_request_claude_plugins_access(): void
90+
{
91+
Http::fake(['github.com/*' => Http::response([], 201)]);
92+
93+
$user = $this->createUltraUser();
94+
$user->update(['github_username' => 'ultrauser']);
95+
96+
$response = $this->actingAs($user)
97+
->post(route('github.request-claude-plugins-access'));
98+
99+
$response->assertSessionHas('success');
100+
$this->assertNotNull($user->fresh()->claude_plugins_repo_access_granted_at);
101+
}
102+
103+
public function test_non_ultra_non_licensed_user_cannot_request_claude_plugins_access(): void
104+
{
105+
$user = User::factory()->create(['github_username' => 'someuser']);
106+
107+
$response = $this->actingAs($user)
108+
->post(route('github.request-claude-plugins-access'));
109+
110+
$response->assertSessionHas('error');
111+
$this->assertNull($user->fresh()->claude_plugins_repo_access_granted_at);
112+
}
113+
114+
// ========================================
115+
// Subscription Revocation Tests
116+
// ========================================
117+
118+
public function test_revoke_job_dispatched_when_subscription_deleted(): void
119+
{
120+
Queue::fake();
121+
122+
$user = $this->createUltraUser();
123+
124+
$event = new WebhookReceived([
125+
'type' => 'customer.subscription.deleted',
126+
'data' => [
127+
'object' => [
128+
'customer' => $user->stripe_id,
129+
],
130+
],
131+
]);
132+
133+
$listener = new StripeWebhookReceivedListener;
134+
$listener->handle($event);
135+
136+
Queue::assertPushed(RevokeTeamUserAccessJob::class, function ($job) use ($user) {
137+
return $job->userId === $user->id;
138+
});
139+
}
140+
141+
public function test_revoke_job_dispatched_when_subscription_canceled(): void
142+
{
143+
Queue::fake();
144+
145+
$user = $this->createUltraUser();
146+
147+
$event = new WebhookReceived([
148+
'type' => 'customer.subscription.updated',
149+
'data' => [
150+
'object' => [
151+
'customer' => $user->stripe_id,
152+
'status' => 'canceled',
153+
],
154+
'previous_attributes' => [
155+
'status' => 'active',
156+
],
157+
],
158+
]);
159+
160+
$listener = new StripeWebhookReceivedListener;
161+
$listener->handle($event);
162+
163+
Queue::assertPushed(RevokeTeamUserAccessJob::class, function ($job) use ($user) {
164+
return $job->userId === $user->id;
165+
});
166+
}
167+
168+
public function test_revoke_job_not_dispatched_when_subscription_reactivated(): void
169+
{
170+
Queue::fake();
171+
172+
$user = $this->createUltraUser();
173+
174+
$event = new WebhookReceived([
175+
'type' => 'customer.subscription.updated',
176+
'data' => [
177+
'object' => [
178+
'customer' => $user->stripe_id,
179+
'status' => 'active',
180+
],
181+
'previous_attributes' => [
182+
'status' => 'canceled',
183+
],
184+
],
185+
]);
186+
187+
$listener = new StripeWebhookReceivedListener;
188+
$listener->handle($event);
189+
190+
Queue::assertNotPushed(RevokeTeamUserAccessJob::class);
191+
}
192+
}

0 commit comments

Comments
 (0)