Server-side pagination
Issue browsing is designed to remain practical on projects with larger result sets by paging data from the server.
diff --git a/app/Http/Controllers/Portal/PortalController.php b/app/Http/Controllers/Portal/PortalController.php index 3610f54..0f7c8dd 100755 --- a/app/Http/Controllers/Portal/PortalController.php +++ b/app/Http/Controllers/Portal/PortalController.php @@ -7,15 +7,14 @@ use App\Models\License; use App\Models\LicenseDeliveryOutbox; use App\Models\LicenseProduct; use App\Services\Payments\PaddleClient; +use App\Services\Licensing\LicenseDeliveryService; 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) {} + public function __construct(private PaddleClient $paddle, private LicenseDeliveryService $delivery) {} private function owned(Request $request) { @@ -116,9 +115,11 @@ final class PortalController extends Controller 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 SonarDesk licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your SonarDesk licence key')); - $out->update(['delivered_at' => now()]); + $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.'); } diff --git a/app/Models/LicenseDeliveryOutbox.php b/app/Models/LicenseDeliveryOutbox.php index e0af182..ac7fb94 100755 --- a/app/Models/LicenseDeliveryOutbox.php +++ b/app/Models/LicenseDeliveryOutbox.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class LicenseDeliveryOutbox extends Model { @@ -11,4 +12,9 @@ class LicenseDeliveryOutbox extends Model protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at']; protected $casts = ['delivered_at' => 'datetime']; + + public function license(): BelongsTo + { + return $this->belongsTo(License::class); + } } diff --git a/app/Services/Licensing/LicenseDeliveryService.php b/app/Services/Licensing/LicenseDeliveryService.php index ecfd43a..38a2c05 100755 --- a/app/Services/Licensing/LicenseDeliveryService.php +++ b/app/Services/Licensing/LicenseDeliveryService.php @@ -12,6 +12,7 @@ final class LicenseDeliveryService { $delivered = 0; $pending = LicenseDeliveryOutbox::query() + ->with('license') ->whereNull('delivered_at') ->whereNotNull('recipient_email') ->where('recipient_email', '!=', '') @@ -20,16 +21,28 @@ final class LicenseDeliveryService ->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; } - Mail::raw( - "Thank you for choosing SonarDesk.\n\nYour licence key is:\n\n{$plain}\n\nOpen SonarDesk → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the SonarDesk licence portal.", - fn ($message) => $message->to($recipient)->subject('Your SonarDesk licence key') - ); + $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++; diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index 2cc9680..0d828ab 100755 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -4,6 +4,7 @@ namespace App\Services\Payments; use App\Models\License; use App\Models\LicenseProduct; +use App\Models\LicenseDeliveryOutbox; use App\Services\Licensing\LicenseIssuer; use Illuminate\Support\Carbon; use RuntimeException; @@ -78,7 +79,7 @@ final class PurchaseProvisioner if (! $license) { $product = $this->mappedProduct('paddle', $priceID, $productID); $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; - $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)); + $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)) ?? []; [$license] = $this->issuer->issue( $product, $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null), @@ -105,6 +106,7 @@ final class PurchaseProvisioner : null; $license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id; + $this->backfillPaddleCustomer($license, $data); $license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id; $license->subscription_status = $status !== '' ? $status : $license->subscription_status; $license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at; @@ -151,7 +153,7 @@ final class PurchaseProvisioner $productID = (string) ($price['product_id'] ?? $item['product_id'] ?? ''); $product = $this->mappedProduct('paddle', $priceID, $productID); $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; - $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)); + $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)) ?? []; $this->issuer->issue( $product, @@ -200,6 +202,25 @@ final class PurchaseProvisioner ); } + private function backfillPaddleCustomer(License $license, array $data): void + { + $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; + $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? $license->external_customer_id)) ?? []; + + $name = $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null); + $email = $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null); + + if (! $license->customer_name && $name) { + $license->customer_name = $name; + } + if (! $license->customer_email && $email) { + $license->customer_email = strtolower($email); + LicenseDeliveryOutbox::where('license_id', $license->id) + ->whereNull('delivered_at') + ->update(['recipient_email' => strtolower($email)]); + } + } + private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct { // Price IDs are the authoritative mapping because monthly and annual prices diff --git a/config/sonardesk.php b/config/sonardesk.php index e32c7b6..d56d04b 100644 --- a/config/sonardesk.php +++ b/config/sonardesk.php @@ -1,6 +1,7 @@ env('SONARDESK_ENVIRONMENT', 'develop'), 'product_code' => env('SONARDESK_PRODUCT_CODE', 'sonardesk'), 'private_key_path' => env('SONARDESK_LICENSE_PRIVATE_KEY_PATH'), 'public_key' => env('SONARDESK_LICENSE_PUBLIC_KEY'), diff --git a/database/seeders/PaddleCatalogSeeder.php b/database/seeders/PaddleCatalogSeeder.php index 29a2390..1c23d60 100755 --- a/database/seeders/PaddleCatalogSeeder.php +++ b/database/seeders/PaddleCatalogSeeder.php @@ -10,10 +10,10 @@ class PaddleCatalogSeeder extends Seeder public function run(): void { $products = [ - ['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.personal_product_id'), 'external_price_id' => config('sonardesk.paddle.personal_monthly_price_id')], - ['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.personal_product_id'), 'external_price_id' => config('sonardesk.paddle.personal_annual_price_id')], - ['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.professional_product_id'), 'external_price_id' => config('sonardesk.paddle.professional_monthly_price_id')], - ['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.professional_product_id'), 'external_price_id' => config('sonardesk.paddle.professional_annual_price_id')], + ['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.personal_monthly_price_id')], + ['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.personal_annual_price_id')], + ['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.professional_monthly_price_id')], + ['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.professional_annual_price_id')], ]; foreach ($products as $data) { diff --git a/deploy.php b/deploy.php index c80c1ac..628690d 100755 --- a/deploy.php +++ b/deploy.php @@ -18,14 +18,14 @@ add('shared_dirs', ['storage']); add('writable_dirs', ['storage', 'bootstrap/cache']); // Host Definitions -host('main') - ->set('hostname', 'sonardesk.absdev.net') +host('production') + ->set('hostname', 'sonardsk.absdev.net') ->set('remote_user', 'mag') ->set('branch', 'main') ->set('deploy_path', '/var/www/sonardesk/main'); host('stage') - ->set('hostname', 'sonardesk.absdev.net') + ->set('hostname', 'sonardesk.stage.absdev.net') ->set('remote_user', 'mag') ->set('branch', 'stage') ->set('deploy_path', '/var/www/sonardesk/stage'); diff --git a/resources/views/checkout/buy.blade.php b/resources/views/checkout/buy.blade.php index 294121f..76ff5be 100755 --- a/resources/views/checkout/buy.blade.php +++ b/resources/views/checkout/buy.blade.php @@ -33,8 +33,8 @@
Paddle is confirming your subscription. Your SonarDesk licence key will be sent to the email used at checkout. You can then activate it in SonarDesk → Settings → Licence.
-If the email takes a moment, wait for the Paddle confirmation and check your junk folder. You can also create a portal account with the same email address.
+Paddle is confirming your subscription. Your SonarDesk licence key is generated by our licensing server and emailed to the address used at checkout. You can then activate it in SonarDesk → Settings → Licence.
+Delivery normally follows the subscription webhook within moments. Check your junk folder if needed. You can also create a portal account with the same email address and resend the key from My licences.
Create portal accountIssue browsing is designed to remain practical on projects with larger result sets by paging data from the server.
Configure the environments you work with and keep them available from the same SonarDesk workspace.
SonarQube tokens are stored using macOS Keychain rather than being treated as ordinary application preferences.
Customers can manage licence information and activations from the SonarDesk portal, with secure online activation.
After checkout, the licence key is emailed to the customer. The SonarDesk portal can resend the key and manage licence information and Mac activations.
SonarDesk is distributed directly for macOS, giving the product a focused release and support channel outside the Mac App Store.
Choose Personal for one active Mac or Professional for up to three.
Every plan includes the full SonarDesk application. Professional increases the activation allowance for people working across multiple Macs.
or £2.99 monthly
or £4.99 monthly
or £2.99 monthly
or £4.99 monthly