143 lines
5.7 KiB
PHP
Executable File
143 lines
5.7 KiB
PHP
Executable File
<?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;
|
|
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'] ?? '');
|
|
}
|
|
}
|