new updates for paddel

This commit is contained in:
Mark Gregory
2026-09-09 16:10:28 +01:00
parent 75439328c3
commit 12ab54469a
7 changed files with 458 additions and 15 deletions
@@ -1,15 +1,142 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Payments\PaddleClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
final class PortalController extends Controller {
private function owned(Request $request){ return License::with(['product','activations'])->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'] ?? '');
}
}
+59 -5
View File
@@ -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;
}
}
+6 -1
View File
@@ -1 +1,6 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><title>{{ $title ?? 'Rsync Studio' }}</title><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;background:#f5f6f8;color:#18202a}nav{background:#111827;color:#fff;padding:14px 24px;display:flex;gap:18px;align-items:center}nav a{color:#fff;text-decoration:none}.hero-card{padding:28px 0 18px}.hero-card h1{font-size:42px;line-height:1.05;margin:8px 0 12px}.eyebrow{text-transform:uppercase;letter-spacing:.12em;font-size:12px;font-weight:800;color:#2563eb}.lead{font-size:18px;line-height:1.55;max-width:720px;color:#475467}.pricing-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:20px 0 28px}.price-card{display:flex;flex-direction:column;justify-content:space-between;gap:24px;background:#fff;border:1px solid #d9dee7;border-radius:16px;padding:24px;box-shadow:0 4px 18px #0000000d}.price-card.featured{border-color:#9dbcf7}.plan-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.plan-heading h2{margin:0 0 5px}.price{font-size:28px;font-weight:800;margin:20px 0}.features{padding-left:20px;line-height:1.9}.pill,.sandbox-badge{display:inline-block;border-radius:999px;padding:5px 9px;font-size:12px;font-weight:700;background:#eff6ff;color:#1d4ed8}.sandbox-badge{margin-top:8px;background:#fff7ed;color:#9a3412}.checkout-button{width:100%;font-size:15px;font-weight:700;padding:12px 16px}.checkout-status{border-color:#86efac}@media(max-width:720px){.pricing-grid{grid-template-columns:1fr}.hero-card h1{font-size:34px}}.wrap{max-width:980px;margin:32px auto;padding:0 20px}.card{background:#fff;border:1px solid #d9dee7;border-radius:12px;padding:22px;margin:16px 0;box-shadow:0 2px 8px #0000000d}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px}.muted{color:#667085}.ok{color:#067647}.bad{color:#b42318}button,.btn{display:inline-block;border:0;border-radius:8px;padding:10px 14px;background:#2563eb;color:#fff;text-decoration:none;cursor:pointer}button.danger{background:#b42318}input{width:100%;box-sizing:border-box;padding:10px;border:1px solid #cbd5e1;border-radius:8px;margin:6px 0 14px}label{font-weight:600}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid #e5e7eb}.alert{padding:12px;border-radius:8px;background:#ecfdf3;margin-bottom:14px}.error{padding:12px;border-radius:8px;background:#fef3f2;margin-bottom:14px}</style></head><body><nav><strong>Rsync Studio</strong><a href="{{ route('home') }}">Home</a><a href="{{ route('buy') }}">Buy</a>@auth @if(auth()->user()->hasVerifiedEmail())<a href="{{ route('portal.index') }}">My licences</a>@endif @if(auth()->user()->is_admin)<a href="{{ route('admin.index') }}">Admin</a>@endif <span style="margin-left:auto">{{ auth()->user()->email }}</span><form method="post" action="{{ route('logout') }}">@csrf<button>Sign out</button></form>@else<span style="margin-left:auto"></span><a href="{{ route('login') }}">Sign in</a><a href="{{ route('register') }}">Create account</a>@endauth</nav><main class="wrap">@if(session('status'))<div class="alert">{{ session('status') }}</div>@endif @if($errors->any())<div class="error">{{ $errors->first() }}</div>@endif @yield('content')</main></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><title>{{ $title ?? 'Rsync Studio' }}</title><style>
:root{color-scheme:light;--bg:#f6f7fb;--surface:#fff;--text:#18202a;--muted:#667085;--line:#e4e7ec;--blue:#2563eb;--blue-dark:#1d4ed8;--blue-soft:#eff6ff;--green:#067647;--green-soft:#ecfdf3;--red:#b42318;--red-soft:#fef3f2;--nav:#101828}
*{box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;background:var(--bg);color:var(--text);font-size:15px;line-height:1.45}nav{background:var(--nav);color:#fff;padding:0 28px;height:64px;display:flex;gap:22px;align-items:center}nav strong{font-size:16px;margin-right:4px}nav a{color:#e5e7eb;text-decoration:none;font-weight:600;font-size:14px}nav a:hover{color:#fff}.wrap{max-width:1120px;margin:0 auto;padding:42px 24px 64px}.hero-card{padding:28px 0 18px}.hero-card h1,.portal-header h1,.licence-detail-head h1{font-size:38px;line-height:1.08;margin:7px 0 10px;letter-spacing:-.025em}.eyebrow{text-transform:uppercase;letter-spacing:.12em;font-size:11px;font-weight:800;color:var(--blue)}.lead{font-size:17px;line-height:1.55;max-width:720px;color:#475467}.lead.compact{margin:0}.pricing-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:20px 0 28px}.price-card,.card,.panel{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:24px;box-shadow:0 1px 3px rgba(16,24,40,.05)}.price-card{display:flex;flex-direction:column;justify-content:space-between;gap:24px}.price-card.featured{border-color:#9dbcf7}.plan-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.plan-heading h2{margin:0 0 5px}.price{font-size:28px;font-weight:800;margin:20px 0}.features{padding-left:20px;line-height:1.9}.pill,.sandbox-badge{display:inline-block;border-radius:999px;padding:5px 9px;font-size:12px;font-weight:700;background:var(--blue-soft);color:var(--blue-dark)}.sandbox-badge{margin-top:8px;background:#fff7ed;color:#9a3412}.checkout-button{width:100%;font-size:15px;font-weight:700;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px}.muted{color:var(--muted)}.ok{color:var(--green)}.bad{color:var(--red)}button,.btn{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:9px;padding:10px 15px;background:var(--blue);color:#fff;text-decoration:none;cursor:pointer;font:inherit;font-weight:700;line-height:1.2}button:hover,.btn:hover{background:var(--blue-dark)}.btn-secondary,button.btn-secondary{background:#fff;color:#344054;border:1px solid #d0d5dd}.btn-secondary:hover,button.btn-secondary:hover{background:#f9fafb}.danger{background:var(--red)}.danger:hover{background:#912018}.danger-outline{background:#fff;color:var(--red);border:1px solid #f3b7b2}.danger-outline:hover{background:var(--red-soft)}.full-width{width:100%}input{width:100%;padding:10px;border:1px solid #cbd5e1;border-radius:8px;margin:6px 0 14px}label{font-weight:600}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid #e5e7eb}.alert,.error{padding:13px 15px;border-radius:10px;margin:0 0 20px;font-weight:600}.alert{background:var(--green-soft);color:var(--green);border:1px solid #abefc6}.error{background:var(--red-soft);color:var(--red);border:1px solid #fecdca}
.portal-header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:26px}.licence-list{display:grid;gap:16px}.licence-card{background:#fff;border:1px solid var(--line);border-radius:18px;box-shadow:0 1px 3px rgba(16,24,40,.05);overflow:hidden}.licence-card-featured{border-color:#c6d7fb}.licence-main{padding:24px 26px}.licence-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:20px}.licence-title-row h2{font-size:22px;margin:10px 0 2px;letter-spacing:-.015em}.licence-key{margin:0}.badge-row{display:flex;gap:7px;flex-wrap:wrap}.status-badge,.plan-badge,.billing-badge{display:inline-flex;align-items:center;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:800}.status-active{background:var(--green-soft);color:var(--green)}.status-inactive{background:var(--red-soft);color:var(--red)}.plan-badge{background:var(--blue-soft);color:var(--blue-dark)}.billing-badge{background:#f2f4f7;color:#475467}.licence-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:24px 0 12px;padding-top:20px;border-top:1px solid var(--line)}.licence-stats>div{display:flex;flex-direction:column;gap:3px}.stat-label{font-size:12px;color:var(--muted);font-weight:700;text-transform:uppercase;letter-spacing:.04em}.licence-stats strong{font-size:15px}.activation-meter{height:7px;background:#eef2f6;border-radius:99px;overflow:hidden}.activation-meter span{display:block;height:100%;background:var(--blue);border-radius:99px}.upgrade-strip,.subscription-warning{margin-top:20px;border-radius:12px;padding:15px 16px;display:flex;align-items:center;justify-content:space-between;gap:16px}.upgrade-strip{background:#f5f8ff;border:1px solid #dbe7ff}.upgrade-strip div{display:flex;flex-direction:column;gap:2px}.upgrade-strip span{color:#475467;font-size:14px}.subscription-warning{background:#fff7ed;border:1px solid #fed7aa}.subscription-warning p{margin:3px 0 0;color:#9a3412;font-size:13px;max-width:720px}.empty-state{background:#fff;border:1px dashed #cbd5e1;border-radius:16px;padding:44px;text-align:center}.empty-state h2{margin-top:0}.history-panel{margin-top:26px;background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden}.history-panel summary{cursor:pointer;padding:16px 20px;font-weight:800}.history-panel summary span{display:inline-flex;margin-left:6px;min-width:22px;height:22px;align-items:center;justify-content:center;border-radius:99px;background:#f2f4f7;color:#475467;font-size:12px}.history-list{border-top:1px solid var(--line)}.history-row{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:18px;padding:14px 20px;border-bottom:1px solid var(--line)}.history-row:last-child{border-bottom:0}.history-row>div:first-child{display:flex;flex-direction:column}.history-row span{font-size:13px;color:var(--muted)}.history-status{font-size:13px;color:var(--muted)}.history-row a{font-weight:700;color:var(--blue);text-decoration:none}.back-link{display:inline-block;margin-bottom:20px;color:#475467;text-decoration:none;font-weight:700}.licence-detail-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:24px}.licence-detail-head h1{font-size:32px}.detail-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(280px,.7fr);gap:18px}.panel{margin:0}.panel h2{margin:0;font-size:20px}.panel-heading{display:flex;justify-content:space-between;gap:18px;align-items:flex-start;padding-bottom:18px;border-bottom:1px solid var(--line)}.panel-heading p{margin:4px 0 0;color:var(--muted)}.activation-count{font-weight:800;background:var(--blue-soft);color:var(--blue-dark);padding:6px 10px;border-radius:999px}.device-row{display:grid;grid-template-columns:42px 1fr auto;gap:14px;align-items:center;padding:17px 0;border-bottom:1px solid var(--line)}.device-row:last-child{border-bottom:0}.device-icon{width:42px;height:42px;display:flex;align-items:center;justify-content:center;background:#f2f4f7;border-radius:10px;font-size:20px}.device-copy{display:flex;flex-direction:column;min-width:0}.device-copy span,.device-copy small{color:var(--muted)}.device-copy small{margin-top:2px}.empty-inline{padding:26px 0 4px;color:var(--muted)}.subscription-panel form+form{margin-top:10px}.subscription-facts{margin:18px 0 22px}.subscription-facts>div{display:flex;justify-content:space-between;gap:16px;padding:11px 0;border-bottom:1px solid var(--line)}.subscription-facts dt{color:var(--muted)}.subscription-facts dd{margin:0;font-weight:700;text-align:right}
@media(max-width:760px){nav{padding:0 16px;gap:12px;overflow-x:auto}.wrap{padding:28px 16px 48px}.pricing-grid{grid-template-columns:1fr}.hero-card h1,.portal-header h1{font-size:32px}.portal-header,.licence-detail-head,.licence-title-row,.upgrade-strip,.subscription-warning{align-items:stretch;flex-direction:column}.portal-header .btn,.licence-title-row .btn{width:100%}.licence-stats{grid-template-columns:1fr}.detail-grid{grid-template-columns:1fr}.history-row{grid-template-columns:1fr auto}.history-row a{grid-column:1/-1}.device-row{grid-template-columns:38px 1fr}.device-row form{grid-column:1/-1}.device-row form button{width:100%}}
</style></head><body><nav><strong>Rsync Studio</strong><a href="{{ route('home') }}">Home</a><a href="{{ route('buy') }}">Buy</a>@auth @if(auth()->user()->hasVerifiedEmail())<a href="{{ route('portal.index') }}">My licences</a>@endif @if(auth()->user()->is_admin)<a href="{{ route('admin.index') }}">Admin</a>@endif <span style="margin-left:auto">{{ auth()->user()->email }}</span><form method="post" action="{{ route('logout') }}">@csrf<button>Sign out</button></form>@else<span style="margin-left:auto"></span><a href="{{ route('login') }}">Sign in</a><a href="{{ route('register') }}">Create account</a>@endauth</nav><main class="wrap">@if(session('status'))<div class="alert">{{ session('status') }}</div>@endif @if($errors->any())<div class="error">{{ $errors->first() }}</div>@endif @yield('content')</main></body></html>
+113 -1
View File
@@ -1 +1,113 @@
@extends('layouts.app') @section('content')<h1>My licences</h1>@forelse($licenses as $license)<div class="card"><h2>{{ $license->product->name }} · {{ $license->edition }}</h2><p>Licence ending <strong>{{ $license->license_key_last4 }}</strong> · {{ $license->activations->whereNull('deactivated_at')->count() }}/{{ $license->activation_limit }} activations</p><p class="{{ $license->is_usable ? 'ok':'bad' }}">{{ $license->is_usable ? 'Active':'Unavailable' }}</p><a class="btn" href="{{ route('portal.show',$license) }}">Manage</a></div>@empty<div class="card">No licences are associated with {{ auth()->user()->email }}.</div>@endforelse@endsection
@extends('layouts.app')
@section('content')
<section class="portal-header">
<div>
<span class="eyebrow">Account</span>
<h1>My licences</h1>
<p class="lead compact">Manage subscriptions, licence keys and activated Macs.</p>
</div>
<a class="btn btn-secondary" href="{{ route('buy') }}">Buy another licence</a>
</section>
@php
$activeLicences = $licenses->filter(fn ($license) => $license->is_usable);
$historyLicences = $licenses->reject(fn ($license) => $license->is_usable);
@endphp
@if($activeLicences->isEmpty())
<div class="empty-state">
<h2>No active licences</h2>
<p>No active Rsync Studio licence is associated with {{ auth()->user()->email }}.</p>
<a class="btn" href="{{ route('buy') }}">Choose a plan</a>
</div>
@else
<div class="licence-list">
@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
<article class="licence-card {{ $license->edition === 'Professional' ? 'licence-card-featured' : '' }}">
<div class="licence-main">
<div class="licence-title-row">
<div>
<div class="badge-row">
<span class="status-badge status-active">Active</span>
<span class="plan-badge">{{ $license->edition }}</span>
<span class="billing-badge">{{ $billing }}</span>
</div>
<h2>Rsync Studio {{ $license->edition }}</h2>
<p class="muted licence-key">Licence ending <strong>{{ $license->license_key_last4 }}</strong></p>
</div>
<a class="btn btn-secondary" href="{{ route('portal.show', $license) }}">Manage licence</a>
</div>
<div class="licence-stats">
<div>
<span class="stat-label">Macs activated</span>
<strong>{{ $activeCount }} of {{ $license->activation_limit }}</strong>
</div>
<div>
<span class="stat-label">Subscription</span>
<strong>{{ ucfirst($license->subscription_status ?: 'active') }}</strong>
</div>
<div>
<span class="stat-label">{{ $license->subscription_status === 'trialing' ? 'Trial ends' : 'Renews' }}</span>
<strong>{{ optional($license->current_period_ends_at)->format('j M Y') ?: '—' }}</strong>
</div>
</div>
<div class="activation-meter" aria-label="{{ $activeCount }} of {{ $license->activation_limit }} Macs activated">
<span style="width: {{ min(100, ($activeCount / max(1, $license->activation_limit)) * 100) }}%"></span>
</div>
@if($isDuplicatePersonal)
<div class="subscription-warning">
<div>
<strong>You already have an active Professional subscription.</strong>
<p>This Personal plan is a separate Paddle subscription and could be billed separately. Cancel it if it was only created while upgrading.</p>
</div>
<form method="post" action="{{ route('portal.cancel', $license) }}" onsubmit="return confirm('Cancel this Personal subscription immediately in Paddle? This cannot be undone.');">
@csrf
@method('DELETE')
<button class="danger">Cancel Personal</button>
</form>
</div>
@elseif($license->edition === 'Personal')
<div class="upgrade-strip">
<div>
<strong>Need more Macs?</strong>
<span>Upgrade this subscription to Professional for up to 3 Macs.</span>
</div>
<form method="post" action="{{ route('portal.upgrade', $license) }}" onsubmit="return confirm('Upgrade this existing Paddle subscription to Professional now? Paddle will prorate the change.');">
@csrf
<button>Upgrade to Professional</button>
</form>
</div>
@endif
</div>
</article>
@endforeach
</div>
@endif
@if($historyLicences->isNotEmpty())
<details class="history-panel">
<summary>Licence history <span>{{ $historyLicences->count() }}</span></summary>
<div class="history-list">
@foreach($historyLicences as $license)
<div class="history-row">
<div>
<strong>Rsync Studio {{ $license->edition }}</strong>
<span>Licence ending {{ $license->license_key_last4 }}</span>
</div>
<div class="history-status">{{ ucfirst($license->subscription_status ?: ($license->revoked_at ? 'revoked' : 'unavailable')) }}</div>
<a href="{{ route('portal.show', $license) }}">Details</a>
</div>
@endforeach
</div>
</details>
@endif
@endsection
+80 -1
View File
@@ -1 +1,80 @@
@extends('layouts.app') @section('content')<div class="card"><h1>{{ $license->product->name }}</h1><p>{{ $license->edition }} · key ending {{ $license->license_key_last4 }}</p><form method="post" action="{{ route('portal.resend',$license) }}">@csrf<button>Email my licence key</button></form></div><div class="card"><h2>Installations</h2><table><tr><th>Installation</th><th>Platform</th><th>Version</th><th>Last seen</th><th></th></tr>@foreach($license->activations->whereNull('deactivated_at') as $a)<tr><td>{{ $a->installation_name ?: $a->installation_id }}</td><td>{{ $a->platform }}</td><td>{{ $a->app_version }}</td><td>{{ optional($a->last_seen_at)->toDayDateTimeString() }}</td><td><form method="post" action="{{ route('portal.deactivate',[$license,$a->id]) }}">@csrf @method('DELETE')<button class="danger">Deactivate</button></form></td></tr>@endforeach</table></div>@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
<a class="back-link" href="{{ route('portal.index') }}">← My licences</a>
<section class="licence-detail-head">
<div>
<div class="badge-row">
<span class="status-badge {{ $license->is_usable ? 'status-active' : 'status-inactive' }}">{{ $license->is_usable ? 'Active' : 'Unavailable' }}</span>
<span class="plan-badge">{{ $license->edition }}</span>
<span class="billing-badge">{{ $billing }}</span>
</div>
<h1>Rsync Studio {{ $license->edition }}</h1>
<p class="muted">Licence ending <strong>{{ $license->license_key_last4 }}</strong></p>
</div>
<form method="post" action="{{ route('portal.resend', $license) }}">
@csrf
<button class="btn-secondary">Email licence key</button>
</form>
</section>
<div class="detail-grid">
<section class="panel">
<div class="panel-heading">
<div>
<h2>Activated Macs</h2>
<p>{{ $activeActivations->count() }} of {{ $license->activation_limit }} activation slots in use.</p>
</div>
<span class="activation-count">{{ $activeActivations->count() }}/{{ $license->activation_limit }}</span>
</div>
@forelse($activeActivations as $activation)
<div class="device-row">
<div class="device-icon">⌘</div>
<div class="device-copy">
<strong>{{ $activation->installation_name ?: 'Mac' }}</strong>
<span>{{ $activation->platform ?: 'macOS' }}@if($activation->app_version) · Rsync Studio {{ $activation->app_version }}@endif</span>
<small>Last seen {{ optional($activation->last_seen_at)->diffForHumans() ?: 'not yet recorded' }}</small>
</div>
<form method="post" action="{{ route('portal.deactivate', [$license, $activation->id]) }}" onsubmit="return confirm('Deactivate this Mac? Rsync Studio on that Mac will lose access when it next validates.');">
@csrf
@method('DELETE')
<button class="danger-outline">Deactivate</button>
</form>
</div>
@empty
<div class="empty-inline">No Macs are currently activated with this licence.</div>
@endforelse
</section>
<aside class="panel subscription-panel">
<h2>Subscription</h2>
<dl class="subscription-facts">
<div><dt>Plan</dt><dd>{{ $license->edition }} {{ $billing }}</dd></div>
<div><dt>Status</dt><dd>{{ ucfirst($license->subscription_status ?: 'active') }}</dd></div>
<div><dt>{{ $license->subscription_status === 'trialing' ? 'Trial ends' : 'Current period ends' }}</dt><dd>{{ optional($license->current_period_ends_at)->format('j M Y') ?: '—' }}</dd></div>
</dl>
@if($license->edition === 'Personal' && $license->is_usable)
<form method="post" action="{{ route('portal.upgrade', $license) }}" onsubmit="return confirm('Upgrade this existing Paddle subscription to Professional now? Paddle will prorate the change.');">
@csrf
<button class="full-width">Upgrade to Professional</button>
</form>
@endif
@if($license->payment_provider === 'paddle' && $license->external_subscription_id && $license->subscription_status !== 'canceled')
<form method="post" action="{{ route('portal.cancel', $license) }}" onsubmit="return confirm('Cancel this Paddle subscription immediately? This cannot be undone.');">
@csrf
@method('DELETE')
<button class="danger-outline full-width">Cancel subscription</button>
</form>
@endif
</aside>
</div>
@endsection
+1 -1
View File
@@ -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');});
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\User;
use App\Services\Payments\PaddleClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
use Tests\TestCase;
class PortalSubscriptionManagementTest extends TestCase
{
use RefreshDatabase;
private function product(string $code, string $edition, string $price, int $limit): LicenseProduct
{
return LicenseProduct::create([
'code' => $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);
}
}