From c85986d010d30727bf32502d5998a685cf994084 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 11:40:35 +0100 Subject: [PATCH] updated deployer code --- .../Commands/SonarDeskAccountsBackfill.php | 53 ++++++ app/Console/Commands/SonarDeskAdminCreate.php | 36 ---- .../Commands/SonarDeskLicenseDeliver.php | 20 --- .../Commands/SonarDeskLicenseIssue.php | 29 ---- .../Commands/SonarDeskLicenseRevoke.php | 22 --- .../Commands/SonarDeskProductCreate.php | 21 --- .../Controllers/Admin/AdminController.php | 49 ------ .../Controllers/Admin/CmsPageController.php | 35 ---- .../Admin/SupportTicketController.php | 59 ------- .../Api/LicenseActivationController.php | 164 ------------------ .../Api/PaymentWebhookController.php | 78 --------- .../Auth/AuthenticatedSessionController.php | 35 ---- .../Auth/EmailVerificationController.php | 31 ---- .../Controllers/Auth/PasswordController.php | 43 ----- .../Auth/RegisteredUserController.php | 29 ---- app/Http/Controllers/CheckoutController.php | 38 ---- app/Http/Controllers/CmsPageController.php | 14 -- app/Http/Controllers/Controller.php | 8 - .../Controllers/Portal/PortalController.php | 143 --------------- .../Controllers/Support/SupportController.php | 92 ---------- app/Http/Middleware/AdminOnly.php | 17 -- app/Models/CmsPage.php | 15 -- app/Models/License.php | 36 ---- app/Models/LicenseActivation.php | 14 -- app/Models/LicenseDeliveryOutbox.php | 20 --- app/Models/LicenseProduct.php | 12 -- app/Models/LicenseWebhookEvent.php | 12 -- app/Models/SupportTicket.php | 46 ----- app/Models/User.php | 22 --- app/Notifications/NewSupportTicket.php | 43 ----- .../SonarDeskLicensingServiceProvider.php | 26 --- .../Licensing/LicenseDeliveryService.php | 53 ------ app/Services/Licensing/LicenseIssuer.php | 46 ----- app/Services/Licensing/LicenseKey.php | 27 --- app/Services/Licensing/LicenseSigner.php | 59 ------- .../Licensing/LicenseTokenVerifier.php | 40 ----- .../Payments/CustomerAccountProvisioner.php | 63 +++++++ .../Payments/LemonSqueezyWebhookVerifier.php | 22 --- app/Services/Payments/PaddleClient.php | 82 --------- .../Payments/PaddleWebhookVerifier.php | 106 ----------- app/Services/Payments/PurchaseProvisioner.php | 13 +- app/Services/Payments/WebhookVerifier.php | 10 -- tests/Feature/CheckoutPageTest.php | 27 --- tests/Feature/LicenseActivationTest.php | 85 --------- .../PaddleSubscriptionProvisioningTest.php | 35 ++++ .../PortalSubscriptionManagementTest.php | 66 ------- 46 files changed, 161 insertions(+), 1835 deletions(-) create mode 100644 app/Console/Commands/SonarDeskAccountsBackfill.php delete mode 100755 app/Console/Commands/SonarDeskAdminCreate.php delete mode 100644 app/Console/Commands/SonarDeskLicenseDeliver.php delete mode 100755 app/Console/Commands/SonarDeskLicenseIssue.php delete mode 100755 app/Console/Commands/SonarDeskLicenseRevoke.php delete mode 100755 app/Console/Commands/SonarDeskProductCreate.php delete mode 100755 app/Http/Controllers/Admin/AdminController.php delete mode 100644 app/Http/Controllers/Admin/CmsPageController.php delete mode 100755 app/Http/Controllers/Admin/SupportTicketController.php delete mode 100755 app/Http/Controllers/Api/LicenseActivationController.php delete mode 100755 app/Http/Controllers/Api/PaymentWebhookController.php delete mode 100755 app/Http/Controllers/Auth/AuthenticatedSessionController.php delete mode 100755 app/Http/Controllers/Auth/EmailVerificationController.php delete mode 100755 app/Http/Controllers/Auth/PasswordController.php delete mode 100755 app/Http/Controllers/Auth/RegisteredUserController.php delete mode 100755 app/Http/Controllers/CheckoutController.php delete mode 100644 app/Http/Controllers/CmsPageController.php delete mode 100755 app/Http/Controllers/Controller.php delete mode 100755 app/Http/Controllers/Portal/PortalController.php delete mode 100755 app/Http/Controllers/Support/SupportController.php delete mode 100755 app/Http/Middleware/AdminOnly.php delete mode 100644 app/Models/CmsPage.php delete mode 100755 app/Models/License.php delete mode 100755 app/Models/LicenseActivation.php delete mode 100755 app/Models/LicenseDeliveryOutbox.php delete mode 100755 app/Models/LicenseProduct.php delete mode 100755 app/Models/LicenseWebhookEvent.php delete mode 100755 app/Models/SupportTicket.php delete mode 100755 app/Models/User.php delete mode 100755 app/Notifications/NewSupportTicket.php delete mode 100755 app/Providers/SonarDeskLicensingServiceProvider.php delete mode 100755 app/Services/Licensing/LicenseDeliveryService.php delete mode 100755 app/Services/Licensing/LicenseIssuer.php delete mode 100755 app/Services/Licensing/LicenseKey.php delete mode 100755 app/Services/Licensing/LicenseSigner.php delete mode 100755 app/Services/Licensing/LicenseTokenVerifier.php create mode 100644 app/Services/Payments/CustomerAccountProvisioner.php delete mode 100755 app/Services/Payments/LemonSqueezyWebhookVerifier.php delete mode 100755 app/Services/Payments/PaddleClient.php delete mode 100755 app/Services/Payments/PaddleWebhookVerifier.php delete mode 100755 app/Services/Payments/WebhookVerifier.php delete mode 100755 tests/Feature/CheckoutPageTest.php delete mode 100755 tests/Feature/LicenseActivationTest.php delete mode 100755 tests/Feature/PortalSubscriptionManagementTest.php diff --git a/app/Console/Commands/SonarDeskAccountsBackfill.php b/app/Console/Commands/SonarDeskAccountsBackfill.php new file mode 100644 index 0000000..499179d --- /dev/null +++ b/app/Console/Commands/SonarDeskAccountsBackfill.php @@ -0,0 +1,53 @@ +whereNotNull('customer_email') + ->where('customer_email', '!=', '') + ->orderBy('id') + ->get(['customer_email', 'customer_name']) + ->unique(fn (License $license) => strtolower(trim((string) $license->customer_email))); + + $created = 0; + + foreach ($customers as $license) { + $email = strtolower(trim((string) $license->customer_email)); + $exists = \App\Models\User::whereRaw('lower(email) = ?', [$email])->exists(); + if ($exists) { + continue; + } + + if ($this->option('dry-run')) { + $this->line("Missing account: {$email}"); + $created++; + continue; + } + + $user = $accounts->ensure($email, $license->customer_name); + if ($user) { + $this->info("Created account: {$email}"); + $created++; + } + } + + $this->newLine(); + $this->info($this->option('dry-run') + ? "{$created} customer account(s) need creating." + : "{$created} customer account(s) created."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SonarDeskAdminCreate.php b/app/Console/Commands/SonarDeskAdminCreate.php deleted file mode 100755 index 2ffb683..0000000 --- a/app/Console/Commands/SonarDeskAdminCreate.php +++ /dev/null @@ -1,36 +0,0 @@ -argument('email'))); - $password = Str::password(24, true, true, false, false); - $u = User::firstOrNew(['email' => $email]); - $u->name = $this->option('name'); - $u->is_admin = true; - $u->email_verified_at = now(); - if (! $u->exists) { - $u->password = Hash::make($password); - } $u->save(); - $this->info("Administrator ready: {$email}"); - if (! $u->wasRecentlyCreated) { - $this->line('Existing password retained.'); - } else { - $this->warn("Temporary password (shown once): {$password}"); - } - -return self::SUCCESS; - } -} diff --git a/app/Console/Commands/SonarDeskLicenseDeliver.php b/app/Console/Commands/SonarDeskLicenseDeliver.php deleted file mode 100644 index 68d5efe..0000000 --- a/app/Console/Commands/SonarDeskLicenseDeliver.php +++ /dev/null @@ -1,20 +0,0 @@ -option('limit'), 100)); - $count = $delivery->deliverPending($limit); - $this->info("Delivered {$count} licence email(s)."); - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/SonarDeskLicenseIssue.php b/app/Console/Commands/SonarDeskLicenseIssue.php deleted file mode 100755 index 962b540..0000000 --- a/app/Console/Commands/SonarDeskLicenseIssue.php +++ /dev/null @@ -1,29 +0,0 @@ -argument('product'))->firstOrFail(); - [$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null); - if (! $key) { - $this->warn('Licence already existed; plaintext key is intentionally not recoverable.'); - - return self::FAILURE; - } $this->line('Licence ID: '.$l->id); - $this->line('Licence key: '.$key); - $this->warn('This is the only time the plaintext key is shown.'); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/SonarDeskLicenseRevoke.php b/app/Console/Commands/SonarDeskLicenseRevoke.php deleted file mode 100755 index e6fdecd..0000000 --- a/app/Console/Commands/SonarDeskLicenseRevoke.php +++ /dev/null @@ -1,22 +0,0 @@ -argument('id')); - $l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]); - $this->info('Licence revoked.'); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/SonarDeskProductCreate.php b/app/Console/Commands/SonarDeskProductCreate.php deleted file mode 100755 index 9edce35..0000000 --- a/app/Console/Commands/SonarDeskProductCreate.php +++ /dev/null @@ -1,21 +0,0 @@ - $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]); - $this->info("Product {$p->code} ready."); - - return self::SUCCESS; - } -} diff --git a/app/Http/Controllers/Admin/AdminController.php b/app/Http/Controllers/Admin/AdminController.php deleted file mode 100755 index 6c2bb25..0000000 --- a/app/Http/Controllers/Admin/AdminController.php +++ /dev/null @@ -1,49 +0,0 @@ - License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]); - } - - public function licenses(Request $r) - { - $q = License::with('product')->latest(); - if ($s = trim((string) $r->query('q'))) { - $q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%")); - } - -return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]); - } - - public function show(License $license) - { - $license->load(['product', 'activations']); - - return view('admin.license', compact('license')); - } - - public function revoke(Request $request, License $license) - { - $data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]); - $license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']); - - return back()->with('status', 'Licence revoked.'); - } - - public function restore(License $license) - { - $license->update(['revoked_at' => null, 'revoke_reason' => null]); - - return back()->with('status','Licence restored.'); - } -} diff --git a/app/Http/Controllers/Admin/CmsPageController.php b/app/Http/Controllers/Admin/CmsPageController.php deleted file mode 100644 index b0d53ab..0000000 --- a/app/Http/Controllers/Admin/CmsPageController.php +++ /dev/null @@ -1,35 +0,0 @@ - CmsPage::latest()->paginate(25)]); } - public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); } - public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); } - public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); } - public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); } - public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); } - - private function validated(Request $request, ?CmsPage $page = null): array - { - $request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]); - $data = $request->validate([ - 'title' => ['required','string','max:160'], - 'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)], - 'excerpt' => ['nullable','string','max:1000'], - 'body' => ['required','string'], - 'meta_title' => ['nullable','string','max:160'], - 'meta_description' => ['nullable','string','max:320'], - ]); - $data['is_published'] = $request->boolean('is_published'); - $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; - return $data; - } -} diff --git a/app/Http/Controllers/Admin/SupportTicketController.php b/app/Http/Controllers/Admin/SupportTicketController.php deleted file mode 100755 index 99484af..0000000 --- a/app/Http/Controllers/Admin/SupportTicketController.php +++ /dev/null @@ -1,59 +0,0 @@ -with('assignee') - ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) - ->latest() - ->paginate(50) - ->withQueryString(); - - return view('admin.support.index', compact('tickets')); - } - - public function show(SupportTicket $ticket) - { - $ticket->load(['user', 'assignee']); - if (auth()->user()) { - auth()->user()->unreadNotifications()->get() - ->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id) - ->each->markAsRead(); - } - - return view('admin.support.show', [ - 'ticket' => $ticket, - 'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']), - ]); - } - - public function update(Request $request, SupportTicket $ticket): RedirectResponse - { - $data = $request->validate([ - 'status' => ['required', 'in:new,open,waiting,resolved,closed'], - 'priority' => ['required', 'in:low,normal,high,urgent'], - 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], - ]); - - if ($data['assigned_to'] ?? null) { - abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422); - } - - $data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true) - ? ($ticket->resolved_at ?? now()) - : null; - $ticket->update($data); - - return back()->with('status', 'Support ticket updated.'); - } -} diff --git a/app/Http/Controllers/Api/LicenseActivationController.php b/app/Http/Controllers/Api/LicenseActivationController.php deleted file mode 100755 index d4e1aba..0000000 --- a/app/Http/Controllers/Api/LicenseActivationController.php +++ /dev/null @@ -1,164 +0,0 @@ -validate([ - 'licenseKey' => ['required', 'string', 'max:128'], - 'installationID' => ['required', 'uuid'], - 'appVersion' => ['required', 'string', 'max:32'], - 'platform' => ['required', 'string', 'max:32'], - ]); - - return DB::transaction(function () use ($data, $signer) { - $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey'])) - ->lockForUpdate() - ->first(); - - if (! $license) { - throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']); - } - - if (! $license->is_usable) { - return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); - } - - $installationID = strtolower($data['installationID']); - $activation = LicenseActivation::where('license_id', $license->id) - ->where('installation_id', $installationID) - ->first(); - - if ($activation && $activation->deactivated_at === null) { - $activation->update([ - 'last_seen_at' => now(), - 'platform' => $data['platform'], - 'app_version' => $data['appVersion'], - ]); - } else { - $activeCount = LicenseActivation::where('license_id', $license->id) - ->whereNull('deactivated_at') - ->count(); - - if ($activeCount >= $license->activation_limit) { - return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409); - } - - if ($activation) { - $activation->update([ - 'deactivated_at' => null, - 'activated_at' => now(), - 'last_seen_at' => now(), - 'platform' => $data['platform'], - 'app_version' => $data['appVersion'], - ]); - } else { - LicenseActivation::create([ - 'license_id' => $license->id, - 'installation_id' => $installationID, - 'platform' => $data['platform'], - 'app_version' => $data['appVersion'], - 'activated_at' => now(), - 'last_seen_at' => now(), - ]); - } - } - - return response()->json([ - 'licenseToken' => $signer->sign($license, $data['installationID']), - ]); - }); - } - - public function validateInstallation( - Request $request, - LicenseTokenVerifier $verifier, - LicenseSigner $signer, - ): JsonResponse { - $data = $request->validate([ - 'licenseToken' => ['required', 'string', 'max:8192'], - 'installationID' => ['required', 'uuid'], - 'appVersion' => ['required', 'string', 'max:32'], - 'platform' => ['required', 'string', 'max:32'], - ]); - - try { - $payload = $verifier->verify($data['licenseToken']); - } catch (\Throwable) { - return response()->json(['message' => 'The licence token is invalid.'], 400); - } - - if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { - return response()->json(['message' => 'The installation does not match this licence token.'], 400); - } - - return DB::transaction(function () use ($data, $payload, $signer) { - $license = License::whereKey((string) ($payload['licenseID'] ?? '')) - ->lockForUpdate() - ->first(); - - if (! $license || ! $license->is_usable) { - return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); - } - - $activation = LicenseActivation::where('license_id', $license->id) - ->where('installation_id', strtolower($data['installationID'])) - ->first(); - - if (! $activation || $activation->deactivated_at !== null) { - return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403); - } - - $activation->update([ - 'last_seen_at' => now(), - 'platform' => $data['platform'], - 'app_version' => $data['appVersion'], - ]); - - return response()->json([ - 'active' => true, - // Refreshing the signed token also applies subscription edition/expiry changes - // without requiring the user to reactivate manually. - 'licenseToken' => $signer->sign($license, $data['installationID']), - ]); - }); - } - - public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse - { - $data = $request->validate([ - 'licenseToken' => ['required', 'string', 'max:8192'], - 'installationID' => ['required', 'uuid'], - ]); - - try { - $payload = $verifier->verify($data['licenseToken']); - } catch (\Throwable) { - return response()->json(['message' => 'The licence token is invalid.'], 400); - } - - if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { - return response()->json(['message' => 'The installation does not match this licence token.'], 400); - } - - LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? '')) - ->where('installation_id', strtolower($data['installationID'])) - ->whereNull('deactivated_at') - ->update(['deactivated_at' => now(), 'last_seen_at' => now()]); - - return response()->json((object) []); - } -} diff --git a/app/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php deleted file mode 100755 index e4c8036..0000000 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ /dev/null @@ -1,78 +0,0 @@ -getContent(); - $verifier = match ($provider) { - 'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null - }; - if (! $verifier) { - return response()->json(['message' => 'Unknown webhook provider.'], 404); - } - try { - $verifier->verify($request, $raw); - } catch (\Throwable $e) { - report($e); - - return response()->json(['message' => 'Invalid webhook signature.'], 400); - } - try { - $payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); - } catch (\Throwable) { - return response()->json(['message' => 'Invalid JSON.'], 400); - } - $externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? '')); - $eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown')); - if ($externalID === '') { - return response()->json(['message' => 'Webhook event has no stable identifier.'], 400); - } - - try { - DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) { - $event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]); - if ($event->processed_at) { - return; - } - if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) { - throw new \RuntimeException('Webhook ID was reused with a different payload.'); - } - try { - $provisioner->provision($provider, $payload); - $event->update(['processed_at' => now(), 'processing_error' => null]); - } catch (\Throwable $e) { - $event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]); - throw $e; - } - }); - } catch (\Throwable $e) { - report($e); - - return response()->json(['message' => 'Webhook could not be processed.'], 500); - } - - // Delivery is intentionally outside the webhook transaction. A temporary mail - // outage must not roll back an otherwise valid Paddle event. Pending outbox - // rows remain available for portal resend or a later webhook delivery attempt. - try { - $delivery->deliverPending(10); - } catch (\Throwable $e) { - report($e); - } - - return response()->json(['ok' => true]); - } -} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php deleted file mode 100755 index d63878e..0000000 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ /dev/null @@ -1,35 +0,0 @@ -validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); - if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) { - return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email'); - } - $request->session()->regenerate(); - - return redirect()->intended(route('portal.index')); - } - - public function destroy(Request $request) - { - Auth::logout(); - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return redirect()->route('login'); - } -} diff --git a/app/Http/Controllers/Auth/EmailVerificationController.php b/app/Http/Controllers/Auth/EmailVerificationController.php deleted file mode 100755 index 2f38de1..0000000 --- a/app/Http/Controllers/Auth/EmailVerificationController.php +++ /dev/null @@ -1,31 +0,0 @@ -fulfill(); - - return redirect()->route('portal.index')->with('status', 'Email verified.'); - } - - public function resend(Request $request) - { - if (! $request->user()->hasVerifiedEmail()) { - $request->user()->sendEmailVerificationNotification(); - } - -return back()->with('status', 'Verification link sent.'); - } -} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php deleted file mode 100755 index 4eb6694..0000000 --- a/app/Http/Controllers/Auth/PasswordController.php +++ /dev/null @@ -1,43 +0,0 @@ -validate(['email' => ['required', 'email']]); - $status = Password::sendResetLink(['email' => strtolower($request->email)]); - - return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]); - } - - public function resetForm(Request $request, string $token) - { - return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]); - } - - public function reset(Request $request) - { - $data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]); - $status = Password::reset($data, function (User $user, string $password) { - $user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save(); - event(new PasswordReset($user)); - }); - - return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]); - } -} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php deleted file mode 100755 index 3b5c073..0000000 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ /dev/null @@ -1,29 +0,0 @@ -validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]); - $user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]); - event(new Registered($user)); - Auth::login($user); - $request->session()->regenerate(); - - return redirect()->route('verification.notice'); - } -} diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php deleted file mode 100755 index 47a5f82..0000000 --- a/app/Http/Controllers/CheckoutController.php +++ /dev/null @@ -1,38 +0,0 @@ - ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£2.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1], - 'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£24.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1], - 'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£4.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3], - 'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£44.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3], - ]; - - foreach ($plans as $plan) { - if (trim((string) $plan['price_id']) === '') { - throw new RuntimeException('One or more Paddle price IDs are not configured.'); - } - } - - return view('checkout.buy', [ - 'plans' => $plans, - 'paddleToken' => $token, - 'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'), - ]); - } -} diff --git a/app/Http/Controllers/CmsPageController.php b/app/Http/Controllers/CmsPageController.php deleted file mode 100644 index cb86e52..0000000 --- a/app/Http/Controllers/CmsPageController.php +++ /dev/null @@ -1,14 +0,0 @@ -where('is_published', true)->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail(); - return view('cms.show', compact('page')); - } -} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php deleted file mode 100755 index ae7ff85..0000000 --- a/app/Http/Controllers/Controller.php +++ /dev/null @@ -1,8 +0,0 @@ -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. SonarDesk 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.']); - } - - $out->update([ - 'recipient_email' => strtolower($request->user()->email), - 'delivered_at' => null, - ]); - $this->delivery->deliverPending(1); - - 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('sonardesk.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/Http/Controllers/Support/SupportController.php b/app/Http/Controllers/Support/SupportController.php deleted file mode 100755 index 55d6a68..0000000 --- a/app/Http/Controllers/Support/SupportController.php +++ /dev/null @@ -1,92 +0,0 @@ - [ - 'technical' => 'Technical support', - 'licensing' => 'Licence & activation', - 'billing' => 'Billing & subscription', - 'feature' => 'Feature request', - 'privacy' => 'Privacy / data request', - 'general' => 'General enquiry', - ], - ]); - } - - public function store(Request $request): RedirectResponse - { - // Honeypot: bots tend to fill hidden fields. Return success without retaining spam. - if (filled($request->input('company_website'))) { - return back()->with('status', 'Your support request has been received.'); - } - - $data = $request->validate([ - 'name' => ['required', 'string', 'max:120'], - 'email' => ['required', 'email:rfc', 'max:254'], - 'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'], - 'subject' => ['required', 'string', 'max:180'], - 'message' => ['required', 'string', 'min:10', 'max:10000'], - 'privacy_notice' => ['accepted'], - ]); - - $ticket = SupportTicket::create([ - 'user_id' => $request->user()?->id, - 'reference' => $this->reference(), - 'name' => $data['name'], - 'email' => strtolower($data['email']), - 'category' => $data['category'], - 'subject' => $data['subject'], - 'message' => $data['message'], - 'privacy_notice_accepted_at' => now(), - ]); - - $this->notifyTeam($ticket); - - Mail::raw( - "We received your SonarDesk support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.", - fn ($mail) => $mail->to($ticket->email)->subject("SonarDesk support request {$ticket->reference}") - ); - - return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you."); - } - - private function notifyTeam(SupportTicket $ticket): void - { - if (config('sonardesk.support.notify_admin_users', true)) { - User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket)); - } - - $emails = collect(config('sonardesk.support.alert_emails', [])) - ->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)) - ->unique() - ->values(); - - if ($emails->isNotEmpty()) { - Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket)); - } - } - - private function reference(): string - { - do { - $reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6)); - } while (SupportTicket::where('reference', $reference)->exists()); - - return $reference; - } -} diff --git a/app/Http/Middleware/AdminOnly.php b/app/Http/Middleware/AdminOnly.php deleted file mode 100755 index e7cd3a6..0000000 --- a/app/Http/Middleware/AdminOnly.php +++ /dev/null @@ -1,17 +0,0 @@ -user()?->is_admin === true, 403); - - return $next($request); - } -} diff --git a/app/Models/CmsPage.php b/app/Models/CmsPage.php deleted file mode 100644 index 180a18e..0000000 --- a/app/Models/CmsPage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'boolean', 'published_at' => 'datetime']; - } -} diff --git a/app/Models/License.php b/app/Models/License.php deleted file mode 100755 index c3dcc56..0000000 --- a/app/Models/License.php +++ /dev/null @@ -1,36 +0,0 @@ - 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime']; - - public function product(): BelongsTo - { - return $this->belongsTo(LicenseProduct::class, 'license_product_id'); - } - - public function activations(): HasMany - { - return $this->hasMany(LicenseActivation::class); - } - - public function getIsUsableAttribute(): bool - { - return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); - } -} diff --git a/app/Models/LicenseActivation.php b/app/Models/LicenseActivation.php deleted file mode 100755 index 8562d4b..0000000 --- a/app/Models/LicenseActivation.php +++ /dev/null @@ -1,14 +0,0 @@ - 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime']; -} diff --git a/app/Models/LicenseDeliveryOutbox.php b/app/Models/LicenseDeliveryOutbox.php deleted file mode 100755 index ac7fb94..0000000 --- a/app/Models/LicenseDeliveryOutbox.php +++ /dev/null @@ -1,20 +0,0 @@ - 'datetime']; - - public function license(): BelongsTo - { - return $this->belongsTo(License::class); - } -} diff --git a/app/Models/LicenseProduct.php b/app/Models/LicenseProduct.php deleted file mode 100755 index 85ac9f5..0000000 --- a/app/Models/LicenseProduct.php +++ /dev/null @@ -1,12 +0,0 @@ - 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer']; -} diff --git a/app/Models/LicenseWebhookEvent.php b/app/Models/LicenseWebhookEvent.php deleted file mode 100755 index 38fea30..0000000 --- a/app/Models/LicenseWebhookEvent.php +++ /dev/null @@ -1,12 +0,0 @@ - 'array', 'processed_at' => 'datetime']; -} diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php deleted file mode 100755 index ccbd655..0000000 --- a/app/Models/SupportTicket.php +++ /dev/null @@ -1,46 +0,0 @@ - 'datetime', - 'resolved_at' => 'datetime', - ]; - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function assignee(): BelongsTo - { - return $this->belongsTo(User::class, 'assigned_to'); - } - - public function prunable(): Builder - { - $days = max(30, (int) config('sonardesk.privacy.support_retention_days', 730)); - - return static::query() - ->whereNotNull('resolved_at') - ->where('resolved_at', '<=', now()->subDays($days)); - } -} diff --git a/app/Models/User.php b/app/Models/User.php deleted file mode 100755 index ff6872c..0000000 --- a/app/Models/User.php +++ /dev/null @@ -1,22 +0,0 @@ - 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean']; - } -} diff --git a/app/Notifications/NewSupportTicket.php b/app/Notifications/NewSupportTicket.php deleted file mode 100755 index 704c05a..0000000 --- a/app/Notifications/NewSupportTicket.php +++ /dev/null @@ -1,43 +0,0 @@ -subject("New SonarDesk support request {$this->ticket->reference}") - ->greeting('New support request') - ->line("{$this->ticket->name} <{$this->ticket->email}>") - ->line("Category: {$this->ticket->category}") - ->line("Subject: {$this->ticket->subject}") - ->line($this->ticket->message) - ->action('Open support ticket', route('admin.support.show', $this->ticket)); - } - - public function toArray(object $notifiable): array - { - return [ - 'support_ticket_id' => $this->ticket->id, - 'reference' => $this->ticket->reference, - 'subject' => $this->ticket->subject, - 'email' => $this->ticket->email, - ]; - } -} diff --git a/app/Providers/SonarDeskLicensingServiceProvider.php b/app/Providers/SonarDeskLicensingServiceProvider.php deleted file mode 100755 index 3674573..0000000 --- a/app/Providers/SonarDeskLicensingServiceProvider.php +++ /dev/null @@ -1,26 +0,0 @@ -by($request->ip()), - Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), - ]; - }); - - RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5) - ->by(strtolower((string) $request->input('email')).'|'.$request->ip())); - - RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip())); - } -} diff --git a/app/Services/Licensing/LicenseDeliveryService.php b/app/Services/Licensing/LicenseDeliveryService.php deleted file mode 100755 index 38a2c05..0000000 --- a/app/Services/Licensing/LicenseDeliveryService.php +++ /dev/null @@ -1,53 +0,0 @@ -with('license') - ->whereNull('delivered_at') - ->whereNotNull('recipient_email') - ->where('recipient_email', '!=', '') - ->oldest() - ->limit(max(1, min($limit, 100))) - ->get(); - - foreach ($pending as $outbox) { - $license = $outbox->license; - if (! $license) { - continue; - } - - $plain = Crypt::decryptString($outbox->encrypted_license_key); - $recipient = strtolower(trim((string) $outbox->recipient_email)); - if ($recipient === '') { - continue; - } - - $data = [ - 'customerName' => $license->customer_name, - 'edition' => $license->edition, - 'licenseKey' => $plain, - 'expiresAt' => $license->expires_at, - 'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'), - ]; - - Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void { - $message->to($recipient)->subject('Your SonarDesk licence key'); - }); - - $outbox->update(['delivered_at' => now()]); - $delivered++; - } - - return $delivered; - } -} diff --git a/app/Services/Licensing/LicenseIssuer.php b/app/Services/Licensing/LicenseIssuer.php deleted file mode 100755 index ec51713..0000000 --- a/app/Services/Licensing/LicenseIssuer.php +++ /dev/null @@ -1,46 +0,0 @@ -where('external_order_id', $orderID)->first(); - if ($existing) { - return [$existing, null]; - } - } - $plain = LicenseKey::generate(); - $license = License::create([ - 'license_product_id' => $product->id, - 'license_key_hash' => LicenseKey::hash($plain), - 'license_key_last4' => substr(LicenseKey::normalize($plain), -4), - 'customer_name' => $name, - 'customer_email' => $email ? strtolower(trim($email)) : null, - 'edition' => $product->edition, - 'activation_limit' => $product->activation_limit, - 'max_major_version' => $product->max_major_version, - 'payment_provider' => $provider, - 'external_order_id' => $orderID, - 'external_customer_id' => $customerID, - ]); - if ($queueDelivery) { - LicenseDeliveryOutbox::updateOrCreate( - ['license_id' => $license->id], - ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null] - ); - } - - return [$license, $plain]; - }); - } -} diff --git a/app/Services/Licensing/LicenseKey.php b/app/Services/Licensing/LicenseKey.php deleted file mode 100755 index 3e42361..0000000 --- a/app/Services/Licensing/LicenseKey.php +++ /dev/null @@ -1,27 +0,0 @@ -privateSeed(); - $pair = sodium_crypto_sign_seed_keypair($seed); - $secret = sodium_crypto_sign_secretkey($pair); - - $payload = [ - 'customerEmail' => $license->customer_email, - 'customerName' => $license->customer_name, - 'edition' => $license->edition, - 'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'), - 'installationID' => strtolower($installationID), - 'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'), - 'licenseID' => strtolower((string) $license->id), - 'maxMajorVersion' => $license->max_major_version, - ]; - ksort($payload, SORT_STRING); - $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); - $sig = sodium_crypto_sign_detached($json, $secret); - - return 'RS3.'.self::b64url($json).'.'.self::b64url($sig); - } - - public function publicKeyBase64(): string - { - $pair = sodium_crypto_sign_seed_keypair($this->privateSeed()); - - return base64_encode(sodium_crypto_sign_publickey($pair)); - } - - private function privateSeed(): string - { - $path = config('sonardesk.private_key_path'); - if (! $path || ! is_file($path)) { - throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); - } - $decoded = base64_decode(trim((string) file_get_contents($path)), true); - if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { - throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); - } - - return $decoded; - } - - private static function b64url(string $value): string - { - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } -} diff --git a/app/Services/Licensing/LicenseTokenVerifier.php b/app/Services/Licensing/LicenseTokenVerifier.php deleted file mode 100755 index fa497fc..0000000 --- a/app/Services/Licensing/LicenseTokenVerifier.php +++ /dev/null @@ -1,40 +0,0 @@ -decode($parts[1]); - $signature = $this->decode($parts[2]); - $public = base64_decode((string) config('sonardesk.public_key'), true); - if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { - throw new RuntimeException('Server licence public key is invalid.'); - } - if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) { - throw new RuntimeException('Licence token signature is invalid.'); - } - $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); - - return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.'); - } - - private function decode(string $value): string - { - $value = strtr($value, '-_', '+/'); - $value .= str_repeat('=', (4 - strlen($value) % 4) % 4); - $out = base64_decode($value, true); - if ($out === false) { - throw new RuntimeException('Invalid Base64URL value.'); - } - - return $out; - } -} diff --git a/app/Services/Payments/CustomerAccountProvisioner.php b/app/Services/Payments/CustomerAccountProvisioner.php new file mode 100644 index 0000000..f970ea9 --- /dev/null +++ b/app/Services/Payments/CustomerAccountProvisioner.php @@ -0,0 +1,63 @@ +first(); + if ($user) { + return $user; + } + + $displayName = trim((string) $name); + if ($displayName === '') { + $displayName = Str::headline(Str::before($email, '@')) ?: 'SonarDesk Customer'; + } + + $user = User::firstOrCreate( + ['email' => $email], + [ + 'name' => $displayName, + // The customer never receives or knows this password. They choose their + // own password through Laravel's signed, single-use reset flow. + 'password' => Str::random(64), + // Access still requires possession of the mailbox because account setup + // is completed using the reset link sent to this address. + 'email_verified_at' => now(), + ], + ); + + if ($user->wasRecentlyCreated) { + DB::afterCommit(function () use ($email): void { + try { + $status = Password::sendResetLink(['email' => $email]); + if ($status !== Password::RESET_LINK_SENT) { + Log::warning('SonarDesk customer account created but setup email was not sent.', [ + 'email' => $email, + 'status' => $status, + ]); + } + } catch (\Throwable $exception) { + // A mail outage must never undo a paid licence. The customer can + // also use Forgot Password once mail is restored. + report($exception); + } + }); + } + + return $user; + } +} diff --git a/app/Services/Payments/LemonSqueezyWebhookVerifier.php b/app/Services/Payments/LemonSqueezyWebhookVerifier.php deleted file mode 100755 index 356a3e5..0000000 --- a/app/Services/Payments/LemonSqueezyWebhookVerifier.php +++ /dev/null @@ -1,22 +0,0 @@ -header('X-Signature'); - if ($secret === '' || $signature === '') { - throw new RuntimeException('Lemon Squeezy webhook verification is not configured.'); - } - $expected = hash_hmac('sha256', $rawBody, $secret); - if (! hash_equals($expected, $signature)) { - throw new RuntimeException('Lemon Squeezy webhook signature is invalid.'); - } - } -} diff --git a/app/Services/Payments/PaddleClient.php b/app/Services/Payments/PaddleClient.php deleted file mode 100755 index 4dc7eb8..0000000 --- a/app/Services/Payments/PaddleClient.php +++ /dev/null @@ -1,82 +0,0 @@ -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('sonardesk.paddle.api_url')) - ->withToken((string) config('sonardesk.paddle.api_key')) - ->acceptJson() - ->timeout(15); - } - - private function requireWritableConfiguration(): void - { - if (trim((string) config('sonardesk.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/app/Services/Payments/PaddleWebhookVerifier.php b/app/Services/Payments/PaddleWebhookVerifier.php deleted file mode 100755 index 477bcb7..0000000 --- a/app/Services/Payments/PaddleWebhookVerifier.php +++ /dev/null @@ -1,106 +0,0 @@ -header('Paddle-Signature')); - - if ($secret === '') { - throw new RuntimeException('Paddle webhook secret is not configured.'); - } - - if ($header === '') { - throw new RuntimeException('Paddle-Signature header is missing.'); - } - - $parts = []; - - foreach (explode(';', $header) as $part) { - [$key, $value] = array_pad( - explode('=', trim($part), 2), - 2, - null - ); - - if ($key !== null && $value !== null) { - $parts[$key][] = $value; - } - } - - $timestamp = isset($parts['ts'][0]) - ? (int) $parts['ts'][0] - : 0; - - $signatures = $parts['h1'] ?? []; - - if ($timestamp === 0) { - throw new RuntimeException( - 'Paddle webhook signature has no timestamp.' - ); - } - - if ($signatures === []) { - throw new RuntimeException( - 'Paddle webhook signature has no h1 signature.' - ); - } - - $tolerance = max( - 1, - (int) config('sonardesk.paddle.timestamp_tolerance', 300) - ); - - $age = abs(time() - $timestamp); - - if ($age > $tolerance) { - throw new RuntimeException( - "Paddle webhook timestamp is outside the allowed tolerance ({$age}s)." - ); - } - - $signedPayload = $timestamp . ':' . $rawBody; - - $expected = hash_hmac( - 'sha256', - $signedPayload, - $secret - ); - - Log::debug('Paddle webhook signature diagnostics', [ - 'timestamp' => $timestamp, - 'age_seconds' => $age, - 'body_length' => strlen($rawBody), - 'body_sha256' => hash('sha256', $rawBody), - - // Safe diagnostics — do NOT log the secret itself. - 'secret_prefix' => substr($secret, 0, 11), - 'secret_length' => strlen($secret), - - 'received_signature_prefixes' => array_map( - static fn (string $signature): string => - substr($signature, 0, 12), - $signatures - ), - - 'expected_signature_prefix' => substr($expected, 0, 12), - ]); - - foreach ($signatures as $signature) { - if (hash_equals($expected, $signature)) { - return; - } - } - - throw new RuntimeException( - 'Paddle webhook signature is invalid.' - ); - } -} diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index 0d828ab..7deded7 100755 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -11,7 +11,11 @@ use RuntimeException; final class PurchaseProvisioner { - public function __construct(private LicenseIssuer $issuer, private PaddleClient $paddle) {} + public function __construct( + private LicenseIssuer $issuer, + private PaddleClient $paddle, + private CustomerAccountProvisioner $accounts, + ) {} public function provision(string $provider, array $event): void { @@ -128,6 +132,7 @@ final class PurchaseProvisioner } $license->save(); + $this->accounts->ensure($license->customer_email, $license->customer_name); } private function syncPaddleTransaction(array $data): void @@ -155,7 +160,7 @@ final class PurchaseProvisioner $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)) ?? []; - $this->issuer->issue( + [$license] = $this->issuer->issue( $product, $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null), $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null), @@ -164,6 +169,7 @@ final class PurchaseProvisioner $this->nullableString($data['customer_id'] ?? null), true, ); + $this->accounts->ensure($license->customer_email, $license->customer_name); } private function provisionLemonSqueezy(array $event): void @@ -191,7 +197,7 @@ final class PurchaseProvisioner (string) ($attributes['product_id'] ?? ''), ); - $this->issuer->issue( + [$license] = $this->issuer->issue( $product, $this->nullableString($attributes['user_name'] ?? null), $this->nullableString($attributes['user_email'] ?? null), @@ -200,6 +206,7 @@ final class PurchaseProvisioner $this->nullableString($attributes['customer_id'] ?? null), true, ); + $this->accounts->ensure($license->customer_email, $license->customer_name); } private function backfillPaddleCustomer(License $license, array $data): void diff --git a/app/Services/Payments/WebhookVerifier.php b/app/Services/Payments/WebhookVerifier.php deleted file mode 100755 index 984c28e..0000000 --- a/app/Services/Payments/WebhookVerifier.php +++ /dev/null @@ -1,10 +0,0 @@ -set('sonardesk.paddle.client_side_token', 'test_example'); - config()->set('sonardesk.paddle.environment', 'sandbox'); - config()->set('sonardesk.paddle.personal_monthly_price_id', 'pri_personal_monthly'); - config()->set('sonardesk.paddle.personal_annual_price_id', 'pri_personal_annual'); - config()->set('sonardesk.paddle.professional_monthly_price_id', 'pri_professional_monthly'); - config()->set('sonardesk.paddle.professional_annual_price_id', 'pri_professional_annual'); - - $this->get('/buy') - ->assertOk() - ->assertSee('7-day free trial') - ->assertSee('pri_personal_monthly') - ->assertSee('pri_personal_annual') - ->assertSee('pri_professional_monthly') - ->assertSee('pri_professional_annual') - ->assertSee('test_example'); - } -} diff --git a/tests/Feature/LicenseActivationTest.php b/tests/Feature/LicenseActivationTest.php deleted file mode 100755 index 7d81a87..0000000 --- a/tests/Feature/LicenseActivationTest.php +++ /dev/null @@ -1,85 +0,0 @@ - 'test', 'name' => 'Test', 'edition' => 'Professional', 'activation_limit' => 1, 'max_major_version' => 1, 'active' => true]); - [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); - $one = ['licenseKey' => $key, 'installationID' => '11111111-1111-4111-8111-111111111111', 'appVersion' => '1.0.0', 'platform' => 'macOS']; - $this->postJson('/api/v1/activate', $one)->assertOk()->assertJsonStructure(['licenseToken']); - $this->postJson('/api/v1/activate', $one)->assertOk(); - $this->postJson('/api/v1/activate', array_merge($one, ['installationID' => '22222222-2222-4222-8222-222222222222']))->assertStatus(409); - } - - public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void - { - $product = LicenseProduct::create([ - 'code' => 'validate-active', - 'name' => 'Validate Active', - 'edition' => 'Professional', - 'activation_limit' => 1, - 'max_major_version' => 1, - 'active' => true, - ]); - [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); - $request = [ - 'licenseKey' => $key, - 'installationID' => '11111111-1111-4111-8111-111111111111', - 'appVersion' => '1.0.0', - 'platform' => 'macOS', - ]; - - $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); - - $this->postJson('/api/v1/validate', [ - 'licenseToken' => $token, - 'installationID' => $request['installationID'], - 'appVersion' => '1.0.1', - 'platform' => 'macOS', - ])->assertOk()->assertJson([ - 'active' => true, - ])->assertJsonStructure(['licenseToken']); - } - - public function test_validation_rejects_an_installation_deactivated_from_the_portal(): void - { - $product = LicenseProduct::create([ - 'code' => 'validate-deactivated', - 'name' => 'Validate Deactivated', - 'edition' => 'Personal', - 'activation_limit' => 1, - 'max_major_version' => 1, - 'active' => true, - ]); - [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); - $request = [ - 'licenseKey' => $key, - 'installationID' => '22222222-2222-4222-8222-222222222222', - 'appVersion' => '1.0.0', - 'platform' => 'macOS', - ]; - - $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); - LicenseActivation::query()->where('installation_id', strtolower($request['installationID']))->update(['deactivated_at' => now()]); - - $this->postJson('/api/v1/validate', [ - 'licenseToken' => $token, - 'installationID' => $request['installationID'], - 'appVersion' => '1.0.0', - 'platform' => 'macOS', - ])->assertStatus(403)->assertJson([ - 'message' => 'This Mac has been deactivated from the licence portal.', - ]); - } -} diff --git a/tests/Feature/PaddleSubscriptionProvisioningTest.php b/tests/Feature/PaddleSubscriptionProvisioningTest.php index 527216e..9598046 100755 --- a/tests/Feature/PaddleSubscriptionProvisioningTest.php +++ b/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature; use App\Models\License; use App\Models\LicenseProduct; +use App\Models\User; +use Illuminate\Support\Facades\Notification; use App\Services\Payments\PurchaseProvisioner; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -47,6 +49,39 @@ class PaddleSubscriptionProvisioningTest extends TestCase ]; } + public function test_new_paddle_customer_gets_a_portal_account(): void + { + Notification::fake(); + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal' + )); + + $user = User::where('email', 'mark@example.com')->firstOrFail(); + $this->assertSame('Mark', $user->name); + $this->assertNotNull($user->email_verified_at); + } + + public function test_existing_customer_account_is_reused(): void + { + Notification::fake(); + $existing = User::create([ + 'email' => 'mark@example.com', + 'name' => 'Existing Mark', + 'password' => 'ExistingPassword123!', + 'email_verified_at' => now(), + ]); + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal' + )); + + $this->assertSame(1, User::where('email', 'mark@example.com')->count()); + $this->assertSame($existing->id, User::where('email', 'mark@example.com')->firstOrFail()->id); + } + public function test_trialing_subscription_issues_a_usable_license(): void { $this->product('pri_personal_monthly', 'Personal', 1); diff --git a/tests/Feature/PortalSubscriptionManagementTest.php b/tests/Feature/PortalSubscriptionManagementTest.php deleted file mode 100755 index 5fd08de..0000000 --- a/tests/Feature/PortalSubscriptionManagementTest.php +++ /dev/null @@ -1,66 +0,0 @@ - $code, - 'name' => 'SonarDesk '.$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('sonardesk.paddle.personal_monthly_price_id', 'pri_personal_monthly'); - config()->set('sonardesk.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); - } -}