Skip to content

Commit b8907a1

Browse files
simonhampclaude
andcommitted
Hide mobile repo access for Ultra subscribers after Feb 1 2026
Ultra subscribers whose subscriptions started on or after February 1, 2026 no longer qualify for nativephp/mobile repository access. Only Max license holders and pre-cutoff Ultra subscribers retain access. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b2987da commit b8907a1

File tree

6 files changed

+266
-8
lines changed

6 files changed

+266
-8
lines changed

app/Console/Commands/RemoveExpiredGitHubAccess.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ public function handle(): int
2424
->get();
2525

2626
foreach ($users as $user) {
27-
// Check if user still has Max access (direct or sub-license)
28-
if (! $user->hasMaxAccess()) {
27+
// Check if user still qualifies for mobile repo access
28+
if (! $user->hasMobileRepoAccess()) {
2929
// Remove from repository
3030
$success = $github->removeFromMobileRepo($user->github_username);
3131

app/Http/Controllers/GitHubIntegrationController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public function requestRepoAccess(): RedirectResponse
138138
return back()->with('error', 'Please connect your GitHub account first.');
139139
}
140140

141-
if (! $user->hasMaxAccess()) {
141+
if (! $user->hasMobileRepoAccess()) {
142142
return back()->with('error', 'You need an active Max license to access the mobile repository.');
143143
}
144144

app/Models/User.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,26 @@ public function hasMaxAccess(): bool
215215
return $this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense();
216216
}
217217

218+
/**
219+
* Check if the user should have access to the nativephp/mobile repository.
220+
* Max license holders always have access. Ultra subscribers only qualify
221+
* if their subscription was created before February 1, 2026.
222+
*/
223+
public function hasMobileRepoAccess(): bool
224+
{
225+
if ($this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense()) {
226+
return true;
227+
}
228+
229+
$subscription = $this->subscription();
230+
231+
if (! $subscription || ! $subscription->active()) {
232+
return false;
233+
}
234+
235+
return $subscription->created_at->lt('2026-02-01 00:00:00');
236+
}
237+
218238
/**
219239
* Check if the user's subscription is a comped (free) subscription.
220240
* Covers both legacy comped (is_comped flag) and comped Ultra price.

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,13 @@
4242
<livewire:claude-plugins-access-banner :inline="true" />
4343
</div>
4444

45-
@if(auth()->user()->hasMaxAccess())
46-
<div class="space-y-6">
45+
<div class="space-y-6">
46+
@if(auth()->user()->hasMobileRepoAccess())
4747
<livewire:git-hub-access-banner :inline="true" />
48+
@endif
49+
50+
@if(auth()->user()->hasMaxAccess())
4851
<livewire:discord-access-banner :inline="true" />
49-
</div>
50-
@endif
52+
@endif
53+
</div>
5154
</div>

resources/views/livewire/git-hub-access-banner.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<div>
2-
@if(auth()->user()->hasMaxAccess())
2+
@if(auth()->user()->hasMobileRepoAccess())
33
<div @class(['max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' => !$inline])>
44
<div class="bg-gradient-to-r from-gray-50 to-slate-100 dark:from-gray-800 dark:to-slate-900 border border-gray-300 dark:border-gray-600 rounded-lg p-6 h-full">
55
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Models\License;
6+
use App\Models\User;
7+
use Illuminate\Foundation\Testing\RefreshDatabase;
8+
use Illuminate\Support\Facades\Cache;
9+
use Illuminate\Support\Facades\Http;
10+
use Laravel\Cashier\Subscription as CashierSubscription;
11+
use Tests\TestCase;
12+
13+
class MobileRepoAccessTest extends TestCase
14+
{
15+
use RefreshDatabase;
16+
17+
private const MAX_PRICE_ID = 'price_test_max_yearly';
18+
19+
protected function setUp(): void
20+
{
21+
parent::setUp();
22+
23+
config(['subscriptions.plans.max.stripe_price_id' => self::MAX_PRICE_ID]);
24+
25+
Cache::flush();
26+
}
27+
28+
// ========================================
29+
// hasMobileRepoAccess() Unit Tests
30+
// ========================================
31+
32+
public function test_user_with_active_max_license_has_mobile_repo_access(): void
33+
{
34+
$user = User::factory()->create();
35+
License::factory()->create([
36+
'user_id' => $user->id,
37+
'policy_name' => 'max',
38+
'expires_at' => now()->addDays(30),
39+
'is_suspended' => false,
40+
]);
41+
42+
$this->assertTrue($user->hasMobileRepoAccess());
43+
}
44+
45+
public function test_ultra_subscriber_before_cutoff_has_mobile_repo_access(): void
46+
{
47+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
48+
49+
CashierSubscription::factory()->for($user)->active()->create([
50+
'stripe_price' => self::MAX_PRICE_ID,
51+
'created_at' => '2026-01-15 00:00:00',
52+
]);
53+
54+
$this->assertTrue($user->hasMobileRepoAccess());
55+
}
56+
57+
public function test_ultra_subscriber_after_cutoff_does_not_have_mobile_repo_access(): void
58+
{
59+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
60+
61+
CashierSubscription::factory()->for($user)->active()->create([
62+
'stripe_price' => self::MAX_PRICE_ID,
63+
'created_at' => '2026-02-01 00:00:00',
64+
]);
65+
66+
$this->assertFalse($user->hasMobileRepoAccess());
67+
}
68+
69+
public function test_ultra_subscriber_on_cutoff_date_does_not_have_mobile_repo_access(): void
70+
{
71+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
72+
73+
CashierSubscription::factory()->for($user)->active()->create([
74+
'stripe_price' => self::MAX_PRICE_ID,
75+
'created_at' => '2026-02-01 12:00:00',
76+
]);
77+
78+
$this->assertFalse($user->hasMobileRepoAccess());
79+
}
80+
81+
public function test_user_without_license_or_subscription_does_not_have_mobile_repo_access(): void
82+
{
83+
$user = User::factory()->create();
84+
85+
$this->assertFalse($user->hasMobileRepoAccess());
86+
}
87+
88+
public function test_user_with_inactive_subscription_does_not_have_mobile_repo_access(): void
89+
{
90+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
91+
92+
CashierSubscription::factory()->for($user)->canceled()->create([
93+
'stripe_price' => self::MAX_PRICE_ID,
94+
'created_at' => '2025-12-01 00:00:00',
95+
]);
96+
97+
$this->assertFalse($user->hasMobileRepoAccess());
98+
}
99+
100+
// ========================================
101+
// Integrations Page Visibility Tests
102+
// ========================================
103+
104+
public function test_ultra_subscriber_before_cutoff_sees_mobile_repo_banner(): void
105+
{
106+
Http::fake(['api.github.com/*' => Http::response([], 404)]);
107+
108+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
109+
110+
CashierSubscription::factory()->for($user)->active()->create([
111+
'stripe_price' => self::MAX_PRICE_ID,
112+
'created_at' => '2026-01-15 00:00:00',
113+
]);
114+
115+
$response = $this->actingAs($user)->get('/dashboard/integrations');
116+
117+
$response->assertStatus(200);
118+
$response->assertSee('nativephp/mobile');
119+
$response->assertSee('Repo Access');
120+
}
121+
122+
public function test_ultra_subscriber_after_cutoff_does_not_see_mobile_repo_banner(): void
123+
{
124+
Http::fake(['api.github.com/*' => Http::response([], 404)]);
125+
126+
$user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]);
127+
128+
CashierSubscription::factory()->for($user)->active()->create([
129+
'stripe_price' => self::MAX_PRICE_ID,
130+
'created_at' => '2026-02-15 00:00:00',
131+
]);
132+
133+
$response = $this->actingAs($user)->get('/dashboard/integrations');
134+
135+
$response->assertStatus(200);
136+
$response->assertDontSee('nativephp/mobile</a> Repo Access', false);
137+
}
138+
139+
// ========================================
140+
// Request Access Controller Tests
141+
// ========================================
142+
143+
public function test_ultra_subscriber_after_cutoff_cannot_request_repo_access(): void
144+
{
145+
$user = User::factory()->create([
146+
'stripe_id' => 'cus_'.uniqid(),
147+
'github_username' => 'testuser',
148+
'github_id' => '123456',
149+
]);
150+
151+
CashierSubscription::factory()->for($user)->active()->create([
152+
'stripe_price' => self::MAX_PRICE_ID,
153+
'created_at' => '2026-02-15 00:00:00',
154+
]);
155+
156+
$response = $this->actingAs($user)
157+
->post('/dashboard/github/request-access');
158+
159+
$response->assertRedirect();
160+
$response->assertSessionHas('error');
161+
}
162+
163+
public function test_ultra_subscriber_before_cutoff_can_request_repo_access(): void
164+
{
165+
Http::fake([
166+
'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 201),
167+
]);
168+
169+
$user = User::factory()->create([
170+
'stripe_id' => 'cus_'.uniqid(),
171+
'github_username' => 'testuser',
172+
'github_id' => '123456',
173+
]);
174+
175+
CashierSubscription::factory()->for($user)->active()->create([
176+
'stripe_price' => self::MAX_PRICE_ID,
177+
'created_at' => '2026-01-15 00:00:00',
178+
]);
179+
180+
$response = $this->actingAs($user)
181+
->post('/dashboard/github/request-access');
182+
183+
$response->assertRedirect();
184+
$response->assertSessionHas('success');
185+
$this->assertNotNull($user->fresh()->mobile_repo_access_granted_at);
186+
}
187+
188+
// ========================================
189+
// Cleanup Command Tests
190+
// ========================================
191+
192+
public function test_cleanup_command_removes_access_for_post_cutoff_ultra_subscriber(): void
193+
{
194+
Http::fake([
195+
'api.github.com/repos/nativephp/mobile/collaborators/testuser' => Http::response([], 204),
196+
]);
197+
198+
$user = User::factory()->create([
199+
'stripe_id' => 'cus_'.uniqid(),
200+
'github_username' => 'testuser',
201+
'github_id' => '123456',
202+
'mobile_repo_access_granted_at' => now()->subDays(10),
203+
]);
204+
205+
CashierSubscription::factory()->for($user)->active()->create([
206+
'stripe_price' => self::MAX_PRICE_ID,
207+
'created_at' => '2026-02-15 00:00:00',
208+
]);
209+
210+
$this->artisan('github:remove-expired-access')
211+
->assertExitCode(0);
212+
213+
$this->assertNull($user->fresh()->mobile_repo_access_granted_at);
214+
}
215+
216+
public function test_cleanup_command_retains_access_for_pre_cutoff_ultra_subscriber(): void
217+
{
218+
$user = User::factory()->create([
219+
'stripe_id' => 'cus_'.uniqid(),
220+
'github_username' => 'testuser',
221+
'github_id' => '123456',
222+
'mobile_repo_access_granted_at' => now()->subDays(10),
223+
]);
224+
225+
CashierSubscription::factory()->for($user)->active()->create([
226+
'stripe_price' => self::MAX_PRICE_ID,
227+
'created_at' => '2026-01-15 00:00:00',
228+
]);
229+
230+
$this->artisan('github:remove-expired-access')
231+
->assertExitCode(0);
232+
233+
$this->assertNotNull($user->fresh()->mobile_repo_access_granted_at);
234+
}
235+
}

0 commit comments

Comments
 (0)