Rsync Studio {{ $license->edition }}
+Licence ending {{ $license->license_key_last4 }}
+This Personal plan is a separate Paddle subscription and could be billed separately. Cancel it if it was only created while upgrading.
+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 @@ -
Licence ending {{ $license->license_key_last4 }} · {{ $license->activations->whereNull('deactivated_at')->count() }}/{{ $license->activation_limit }} activations
{{ $license->is_usable ? 'Active':'Unavailable' }}
ManageManage subscriptions, licence keys and activated Macs.
+No active Rsync Studio licence is associated with {{ auth()->user()->email }}.
+ Choose a plan +Licence ending {{ $license->license_key_last4 }}
+This Personal plan is a separate Paddle subscription and could be billed separately. Cancel it if it was only created while upgrading.
+{{ $license->edition }} · key ending {{ $license->license_key_last4 }}
| Installation | Platform | Version | Last seen | |
|---|---|---|---|---|
| {{ $a->installation_name ?: $a->installation_id }} | {{ $a->platform }} | {{ $a->app_version }} | {{ optional($a->last_seen_at)->toDayDateTimeString() }} |
Licence ending {{ $license->license_key_last4 }}
+{{ $activeActivations->count() }} of {{ $license->activation_limit }} activation slots in use.
+