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 @@ diff --git a/resources/views/features.blade.php b/resources/views/features.blade.php index ef2097b..5602e8a 100644 --- a/resources/views/features.blade.php +++ b/resources/views/features.blade.php @@ -8,7 +8,7 @@
Scale

Server-side pagination

Issue browsing is designed to remain practical on projects with larger result sets by paging data from the server.

Connections

Multiple SonarQube servers

Configure the environments you work with and keep them available from the same SonarDesk workspace.

Security

macOS Keychain

SonarQube tokens are stored using macOS Keychain rather than being treated as ordinary application preferences.

-
Licensing

Self-service customer portal

Customers can manage licence information and activations from the SonarDesk portal, with secure online activation.

+
Licensing

Email delivery and self-service portal

After checkout, the licence key is emailed to the customer. The SonarDesk portal can resend the key and manage licence information and Mac activations.

Distribution

Built for direct Mac distribution

SonarDesk is distributed directly for macOS, giving the product a focused release and support channel outside the Mac App Store.

7-day free trial

Try the complete app.

Choose Personal for one active Mac or Professional for up to three.

View pricing
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 375e035..8f376d5 100755 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -147,6 +147,9 @@ .launch-hero{padding:58px 18px 88px;background:linear-gradient(180deg,#0b1220 0,#101b31 84%,#f7f9fc 84%)}.launch-hero h1{font-size:48px}.hero-copy{font-size:17px}.app-body{grid-template-columns:120px 1fr;min-height:330px}.app-sidebar{padding:12px 8px}.side-item{padding:7px 8px}.app-content{padding:14px}.launch-section{padding:68px 18px}.feature-grid,.workflow-grid{grid-template-columns:1fr}.security-panel{padding:30px 24px;border-radius:20px}.security-panel h2{font-size:32px}.cta-box{padding:36px 26px;align-items:flex-start;flex-direction:column}.cta-box h2{font-size:32px}.footer-shell{align-items:flex-start;flex-direction:column}.launch-cta{padding:16px 18px 72px} } @media(max-width:480px){.launch-hero h1{font-size:42px}.hero-actions{flex-direction:column}.hero-actions .btn{width:100%}.app-body{grid-template-columns:1fr}.app-sidebar{display:none}.window-title{display:none}.launch-kicker{font-size:11px}.feature-card{padding:22px}} + @media(max-width:760px){ + .site-header{position:static}.nav-shell{min-height:auto;padding:12px 16px;flex-wrap:wrap;gap:10px}.brand{flex:1}.nav-links{order:3;width:100%;overflow-x:auto;display:flex;gap:6px;padding:4px 0 2px;scrollbar-width:none}.nav-links::-webkit-scrollbar{display:none}.nav-links a{white-space:nowrap;padding:8px 10px;border-radius:8px;background:var(--panel)}.nav-account{margin-left:auto}.nav-email{display:none}.wrap{padding-left:16px;padding-right:16px}.hero-card{padding:30px 22px}.marketing-hero h1{font-size:clamp(38px,11vw,52px);line-height:1.02}.marketing-actions{align-items:stretch}.marketing-actions .btn{width:100%;justify-content:center}.pricing-grid,.pricing-preview-grid,.feature-detail-grid,.feature-grid{grid-template-columns:1fr!important}.price-card{min-width:0}.checkout-button{width:100%}.cta-panel{display:grid;grid-template-columns:1fr;gap:20px}.cta-panel .btn{width:100%;justify-content:center}.footer-shell{grid-template-columns:1fr;gap:20px}.footer-links{justify-content:flex-start}.cookie-banner{left:12px;right:12px;bottom:12px}.cookie-actions{display:grid;grid-template-columns:1fr;width:100%}.cookie-actions .btn{width:100%}} + @media(max-width:420px){.nav-account .nav-auth-link{display:none}.nav-cta{padding:9px 11px}.hero-card{padding:26px 18px}.wrap{padding-left:12px;padding-right:12px}.price{font-size:34px}} @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}} diff --git a/resources/views/portal/welcome.blade.php b/resources/views/portal/welcome.blade.php index 5523b8f..55091b4 100755 --- a/resources/views/portal/welcome.blade.php +++ b/resources/views/portal/welcome.blade.php @@ -38,8 +38,8 @@
Straightforward pricing

Start small. Upgrade when you need more Macs.

Every plan includes the full SonarDesk application. Professional increases the activation allowance for people working across multiple Macs.

-
Personal

For one Mac

£24.99 / year

or £2.99 monthly

  • Full SonarDesk feature set
  • 1 active Mac
  • 7-day free trial
  • Customer licence portal
- +
Personal

For one Mac

£24.99 / year

or £2.99 monthly

  • Full SonarDesk feature set
  • 1 active Mac
  • 7-day free trial
  • Licence key delivered by email
  • Customer licence portal
+
See pricing and start trial
diff --git a/routes/console.php b/routes/console.php index b98b725..6fff514 100755 --- a/routes/console.php +++ b/routes/console.php @@ -4,3 +4,7 @@ use Illuminate\Support\Facades\Schedule; // Removes resolved support requests after the configured retention period. Schedule::command('model:prune')->dailyAt('03:20')->withoutOverlapping(); + +Schedule::call(function (): void { + app(\App\Services\Licensing\LicenseDeliveryService::class)->deliverPending(50); +})->name('license-delivery')->everyMinute()->withoutOverlapping();