diff --git a/app/Http/Controllers/Portal/PortalController.php b/app/Http/Controllers/Portal/PortalController.php index 429263d..7f59908 100644 --- a/app/Http/Controllers/Portal/PortalController.php +++ b/app/Http/Controllers/Portal/PortalController.php @@ -1,15 +1,142 @@ whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); } - public function index(Request $request){ return view('portal.index',['licenses'=>$this->owned($request)->latest()->get()]); } - public function show(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $license->load(['product','activations']); return view('portal.show',compact('license')); } - public function deactivate(Request $request,License $license,int $activation){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $item=$license->activations()->whereKey($activation)->firstOrFail(); if(!$item->deactivated_at){$item->update(['deactivated_at'=>now()]);} return back()->with('status','Activation released.'); } - public function resend(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $out=LicenseDeliveryOutbox::where('license_id',$license->id)->first(); if(!$out) return back()->withErrors(['licence'=>'This licence was issued manually and its full key is not recoverable. Contact support.']); $plain=Crypt::decryptString($out->encrypted_license_key); Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.",fn($m)=>$m->to($request->user()->email)->subject('Your Rsync Studio licence key')); $out->update(['delivered_at'=>now()]); return back()->with('status','Licence key sent to your verified email address.'); } +use RuntimeException; + +final class PortalController extends Controller +{ + public function __construct(private PaddleClient $paddle) {} + + private function owned(Request $request) + { + return License::with(['product', 'activations']) + ->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); + } + + public function index(Request $request) + { + $licenses = $this->owned($request)->latest()->get(); + $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional'); + + return view('portal.index', compact('licenses', 'hasProfessional')); + } + + public function show(Request $request, License $license) + { + $this->authorise($request, $license); + $license->load(['product', 'activations']); + + return view('portal.show', compact('license')); + } + + public function upgrade(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless($license->edition === 'Personal' && $license->is_usable, 422); + + $targetPriceID = $this->professionalPriceFor($license); + $targetProduct = LicenseProduct::query() + ->where('payment_provider', 'paddle') + ->where('external_price_id', $targetPriceID) + ->where('active', true) + ->firstOrFail(); + + try { + $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + // Apply the entitlement immediately. Paddle webhooks remain authoritative and + // will reconcile subscription status/current-period details afterwards. + $license->update([ + 'license_product_id' => $targetProduct->id, + 'edition' => $targetProduct->edition, + 'activation_limit' => $targetProduct->activation_limit, + 'max_major_version' => $targetProduct->max_major_version, + 'external_price_id' => $targetPriceID, + 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status), + 'revoked_at' => null, + 'revoke_reason' => null, + ]); + + return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.'); + } + + public function cancel(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless(!in_array($license->subscription_status, ['canceled'], true), 422); + + try { + $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + $license->update([ + 'subscription_status' => 'canceled', + 'expires_at' => now(), + 'revoked_at' => now(), + 'revoke_reason' => 'Subscription canceled from licence portal', + 'cancel_scheduled_at' => null, + ]); + + return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.'); + } + + public function deactivate(Request $request, License $license, int $activation) + { + $this->authorise($request, $license); + $item = $license->activations()->whereKey($activation)->firstOrFail(); + if (!$item->deactivated_at) { + $item->update(['deactivated_at' => now()]); + } + + return back()->with('status', 'Mac deactivated. Rsync Studio will detect this through the licensing API.'); + } + + public function resend(Request $request, License $license) + { + $this->authorise($request, $license); + $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first(); + if (!$out) { + return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']); + } + + $plain = Crypt::decryptString($out->encrypted_license_key); + Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your Rsync Studio licence key')); + $out->update(['delivered_at' => now()]); + + return back()->with('status', 'Licence key sent to your verified email address.'); + } + + private function authorise(Request $request, License $license): void + { + abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404); + } + + private function professionalPriceFor(License $license): string + { + $paddle = config('rsync-studio.paddle'); + $current = (string) $license->external_price_id; + + if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) { + return (string) ($paddle['professional_annual_price_id'] ?? ''); + } + + return (string) ($paddle['professional_monthly_price_id'] ?? ''); + } } diff --git a/app/Services/Payments/PaddleClient.php b/app/Services/Payments/PaddleClient.php index a84edff..7463176 100644 --- a/app/Services/Payments/PaddleClient.php +++ b/app/Services/Payments/PaddleClient.php @@ -2,7 +2,9 @@ namespace App\Services\Payments; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; +use RuntimeException; final class PaddleClient { @@ -10,14 +12,66 @@ final class PaddleClient { if (!$customerID || !config('rsync-studio.paddle.api_key')) return null; - $response = Http::baseUrl(config('rsync-studio.paddle.api_url')) - ->withToken(config('rsync-studio.paddle.api_key')) - ->acceptJson() - ->timeout(10) - ->get('/customers/'.$customerID); + $response = $this->request()->get('/customers/'.$customerID); if (!$response->successful()) return null; $data = $response->json('data'); return is_array($data) ? $data : null; } + + public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array + { + $this->requireWritableConfiguration(); + + $response = $this->request()->patch('/subscriptions/'.$subscriptionID, [ + 'proration_billing_mode' => 'prorated_immediately', + 'items' => [[ + 'price_id' => $priceID, + 'quantity' => 1, + ]], + ]); + + return $this->subscriptionData($response, 'Paddle could not update the subscription.'); + } + + public function cancelSubscriptionImmediately(string $subscriptionID): array + { + $this->requireWritableConfiguration(); + + $response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [ + 'effective_from' => 'immediately', + ]); + + return $this->subscriptionData($response, 'Paddle could not cancel the subscription.'); + } + + private function request() + { + return Http::baseUrl((string) config('rsync-studio.paddle.api_url')) + ->withToken((string) config('rsync-studio.paddle.api_key')) + ->acceptJson() + ->timeout(15); + } + + private function requireWritableConfiguration(): void + { + if (trim((string) config('rsync-studio.paddle.api_key')) === '') { + throw new RuntimeException('Paddle subscription management is not configured.'); + } + } + + private function subscriptionData(Response $response, string $fallback): array + { + if (!$response->successful()) { + $detail = trim((string) ($response->json('error.detail') ?? '')); + throw new RuntimeException($detail !== '' ? $detail : $fallback); + } + + $data = $response->json('data'); + if (!is_array($data)) { + throw new RuntimeException($fallback); + } + + return $data; + } } diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index f8b4c2a..35734f5 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1 +1,6 @@ -{{ $title ?? 'Rsync Studio' }}
@if(session('status'))
{{ session('status') }}
@endif @if($errors->any())
{{ $errors->first() }}
@endif @yield('content')
+{{ $title ?? 'Rsync Studio' }}
@if(session('status'))
{{ session('status') }}
@endif @if($errors->any())
{{ $errors->first() }}
@endif @yield('content')
diff --git a/resources/views/portal/index.blade.php b/resources/views/portal/index.blade.php index f43bef1..c6f1ea5 100644 --- a/resources/views/portal/index.blade.php +++ b/resources/views/portal/index.blade.php @@ -1 +1,113 @@ -@extends('layouts.app') @section('content')

My licences

@forelse($licenses as $license)

{{ $license->product->name }} · {{ $license->edition }}

Licence ending {{ $license->license_key_last4 }} · {{ $license->activations->whereNull('deactivated_at')->count() }}/{{ $license->activation_limit }} activations

{{ $license->is_usable ? 'Active':'Unavailable' }}

Manage
@empty
No licences are associated with {{ auth()->user()->email }}.
@endforelse@endsection +@extends('layouts.app') + +@section('content') +
+
+ Account +

My licences

+

Manage subscriptions, licence keys and activated Macs.

+
+ Buy another licence +
+ +@php + $activeLicences = $licenses->filter(fn ($license) => $license->is_usable); + $historyLicences = $licenses->reject(fn ($license) => $license->is_usable); +@endphp + +@if($activeLicences->isEmpty()) +
+

No active licences

+

No active Rsync Studio licence is associated with {{ auth()->user()->email }}.

+ Choose a plan +
+@else +
+ @foreach($activeLicences as $license) + @php + $activeCount = $license->activations->whereNull('deactivated_at')->count(); + $isDuplicatePersonal = $hasProfessional && $license->edition === 'Personal'; + $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; + @endphp +
+
+
+
+
+ Active + {{ $license->edition }} + {{ $billing }} +
+

Rsync Studio {{ $license->edition }}

+

Licence ending {{ $license->license_key_last4 }}

+
+ Manage licence +
+ +
+
+ Macs activated + {{ $activeCount }} of {{ $license->activation_limit }} +
+
+ Subscription + {{ ucfirst($license->subscription_status ?: 'active') }} +
+
+ {{ $license->subscription_status === 'trialing' ? 'Trial ends' : 'Renews' }} + {{ optional($license->current_period_ends_at)->format('j M Y') ?: '—' }} +
+
+ +
+ +
+ + @if($isDuplicatePersonal) +
+
+ You already have an active Professional subscription. +

This Personal plan is a separate Paddle subscription and could be billed separately. Cancel it if it was only created while upgrading.

+
+
+ @csrf + @method('DELETE') + +
+
+ @elseif($license->edition === 'Personal') +
+
+ Need more Macs? + Upgrade this subscription to Professional for up to 3 Macs. +
+
+ @csrf + +
+
+ @endif +
+
+ @endforeach +
+@endif + +@if($historyLicences->isNotEmpty()) +
+ Licence history {{ $historyLicences->count() }} +
+ @foreach($historyLicences as $license) +
+
+ Rsync Studio {{ $license->edition }} + Licence ending {{ $license->license_key_last4 }} +
+
{{ ucfirst($license->subscription_status ?: ($license->revoked_at ? 'revoked' : 'unavailable')) }}
+ Details +
+ @endforeach +
+
+@endif +@endsection diff --git a/resources/views/portal/show.blade.php b/resources/views/portal/show.blade.php index ff70c71..1832157 100644 --- a/resources/views/portal/show.blade.php +++ b/resources/views/portal/show.blade.php @@ -1 +1,80 @@ -@extends('layouts.app') @section('content')

{{ $license->product->name }}

{{ $license->edition }} · key ending {{ $license->license_key_last4 }}

@csrf

Installations

@foreach($license->activations->whereNull('deactivated_at') as $a)@endforeach
InstallationPlatformVersionLast seen
{{ $a->installation_name ?: $a->installation_id }}{{ $a->platform }}{{ $a->app_version }}{{ optional($a->last_seen_at)->toDayDateTimeString() }}
@csrf @method('DELETE')
@endsection +@extends('layouts.app') + +@section('content') +@php + $activeActivations = $license->activations->whereNull('deactivated_at'); + $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; +@endphp + +← My licences + +
+
+
+ {{ $license->is_usable ? 'Active' : 'Unavailable' }} + {{ $license->edition }} + {{ $billing }} +
+

Rsync Studio {{ $license->edition }}

+

Licence ending {{ $license->license_key_last4 }}

+
+
+ @csrf + +
+
+ +
+
+
+
+

Activated Macs

+

{{ $activeActivations->count() }} of {{ $license->activation_limit }} activation slots in use.

+
+ {{ $activeActivations->count() }}/{{ $license->activation_limit }} +
+ + @forelse($activeActivations as $activation) +
+
⌘
+
+ {{ $activation->installation_name ?: 'Mac' }} + {{ $activation->platform ?: 'macOS' }}@if($activation->app_version) · Rsync Studio {{ $activation->app_version }}@endif + Last seen {{ optional($activation->last_seen_at)->diffForHumans() ?: 'not yet recorded' }} +
+
+ @csrf + @method('DELETE') + +
+
+ @empty +
No Macs are currently activated with this licence.
+ @endforelse +
+ + +
+@endsection diff --git a/routes/web.stage5.php b/routes/web.stage5.php index 44e7b51..779d0fe 100644 --- a/routes/web.stage5.php +++ b/routes/web.stage5.php @@ -13,5 +13,5 @@ Route::get('/buy', CheckoutController::class)->name('buy'); Route::get('/support', fn()=>view('support'))->name('support'); Route::middleware('guest')->group(function(){Route::get('/login',[AuthenticatedSessionController::class,'create'])->name('login');Route::post('/login',[AuthenticatedSessionController::class,'store'])->middleware('throttle:login');Route::get('/register',[RegisteredUserController::class,'create'])->name('register');Route::post('/register',[RegisteredUserController::class,'store'])->middleware('throttle:registration');Route::get('/forgot-password',[PasswordController::class,'requestForm'])->name('password.request');Route::post('/forgot-password',[PasswordController::class,'email'])->name('password.email')->middleware('throttle:5,1');Route::get('/reset-password/{token}',[PasswordController::class,'resetForm'])->name('password.reset');Route::post('/reset-password',[PasswordController::class,'reset'])->name('password.update');}); Route::middleware('auth')->group(function(){Route::post('/logout',[AuthenticatedSessionController::class,'destroy'])->name('logout');Route::get('/verify-email',[EmailVerificationController::class,'notice'])->name('verification.notice');Route::get('/verify-email/{id}/{hash}',[EmailVerificationController::class,'verify'])->middleware(['signed','throttle:6,1'])->name('verification.verify');Route::post('/email/verification-notification',[EmailVerificationController::class,'resend'])->middleware('throttle:6,1')->name('verification.send');}); -Route::middleware(['auth','verified'])->prefix('portal')->name('portal.')->group(function(){Route::get('/',[PortalController::class,'index'])->name('index');Route::get('/licences/{license}',[PortalController::class,'show'])->name('show');Route::post('/licences/{license}/resend',[PortalController::class,'resend'])->middleware('throttle:3,10')->name('resend');Route::delete('/licences/{license}/activations/{activation}',[PortalController::class,'deactivate'])->name('deactivate');}); +Route::middleware(['auth','verified'])->prefix('portal')->name('portal.')->group(function(){Route::get('/',[PortalController::class,'index'])->name('index');Route::get('/licences/{license}',[PortalController::class,'show'])->name('show');Route::post('/licences/{license}/upgrade',[PortalController::class,'upgrade'])->middleware('throttle:5,1')->name('upgrade');Route::delete('/licences/{license}/subscription',[PortalController::class,'cancel'])->middleware('throttle:5,1')->name('cancel');Route::post('/licences/{license}/resend',[PortalController::class,'resend'])->middleware('throttle:3,10')->name('resend');Route::delete('/licences/{license}/activations/{activation}',[PortalController::class,'deactivate'])->name('deactivate');}); Route::middleware(['auth','verified',AdminOnly::class])->prefix('admin')->name('admin.')->group(function(){Route::get('/',[AdminController::class,'index'])->name('index');Route::get('/licences',[AdminController::class,'licenses'])->name('licenses');Route::get('/licences/{license}',[AdminController::class,'show'])->name('license');Route::post('/licences/{license}/revoke',[AdminController::class,'revoke'])->name('revoke');Route::post('/licences/{license}/restore',[AdminController::class,'restore'])->name('restore');}); diff --git a/tests/Feature/PortalSubscriptionManagementTest.php b/tests/Feature/PortalSubscriptionManagementTest.php new file mode 100644 index 0000000..5ebb77f --- /dev/null +++ b/tests/Feature/PortalSubscriptionManagementTest.php @@ -0,0 +1,66 @@ + $code, + 'name' => 'Rsync Studio '.$edition.' '.(str_contains($code, 'annual') ? 'Annual' : 'Monthly'), + 'edition' => $edition, + 'activation_limit' => $limit, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_product_id' => 'pro_'.strtolower($edition), + 'external_price_id' => $price, + 'active' => true, + ]); + } + + public function test_personal_subscription_is_upgraded_in_place(): void + { + config()->set('rsync-studio.paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('rsync-studio.paddle.professional_monthly_price_id', 'pri_professional_monthly'); + + $personal = $this->product('personal-monthly', 'Personal', 'pri_personal_monthly', 1); + $professional = $this->product('professional-monthly', 'Professional', 'pri_professional_monthly', 3); + $user = User::factory()->create(['email' => 'mark@example.com', 'email_verified_at' => now()]); + $license = License::create([ + 'license_product_id' => $personal->id, + 'license_key_hash' => str_repeat('a', 64), + 'license_key_last4' => 'TEST', + 'customer_email' => $user->email, + 'edition' => 'Personal', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_subscription_id' => 'sub_test', + 'external_price_id' => 'pri_personal_monthly', + 'subscription_status' => 'active', + ]); + + $this->mock(PaddleClient::class, function (MockInterface $mock) { + $mock->shouldReceive('updateSubscriptionPrice')->once()->with('sub_test', 'pri_professional_monthly')->andReturn(['status' => 'active']); + }); + + $this->actingAs($user)->post(route('portal.upgrade', $license))->assertRedirect(route('portal.show', $license)); + + $license->refresh(); + $this->assertSame($professional->id, $license->license_product_id); + $this->assertSame('Professional', $license->edition); + $this->assertSame(3, $license->activation_limit); + $this->assertSame('sub_test', $license->external_subscription_id); + } +}