diff --git a/Modules/Licensing/Http/Controllers/CheckoutHandoffController.php b/Modules/Licensing/Http/Controllers/CheckoutHandoffController.php new file mode 100644 index 0000000..b24983d --- /dev/null +++ b/Modules/Licensing/Http/Controllers/CheckoutHandoffController.php @@ -0,0 +1,42 @@ + $this->destination() !== null]); + } + + public function redirect(): RedirectResponse + { + $destination = $this->destination(); + abort_unless($destination !== null, 503, 'Checkout is not configured.'); + return redirect()->away($destination, 302); + } + + private function destination(): ?string + { + if (! config('sonardesk-integration.checkout_enabled')) { + return null; + } + $url = trim((string) config('sonardesk-integration.checkout_url')); + $parts = parse_url($url); + if (! is_array($parts) || strtolower($parts['scheme'] ?? '') !== 'https' + || ! isset($parts['host']) || isset($parts['user']) || isset($parts['pass']) + || isset($parts['fragment']) || isset($parts['port'])) { + return null; + } + $host = strtolower(rtrim($parts['host'], '.')); + $allowed = array_map(static fn ($value) => strtolower(rtrim((string) $value, '.')), config('sonardesk-integration.allowed_checkout_hosts', [])); + if (! in_array($host, $allowed, true)) { + return null; + } + return $url; + } +} diff --git a/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php index 3c8c37d..8e00edb 100644 --- a/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php +++ b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php @@ -21,6 +21,7 @@ final class IntegratedLicensingServiceProvider extends ServiceProvider ]); $this->mergeConfigFrom(base_path('config/sonardesk-paddle.php'), 'sonardesk-paddle'); + $this->mergeConfigFrom(base_path('config/sonardesk-integration.php'), 'sonardesk-integration'); $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations'); $this->loadViewsFrom(__DIR__.'/../Resources/views', 'licensing'); $this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'licensing'); @@ -34,5 +35,6 @@ final class IntegratedLicensingServiceProvider extends ServiceProvider Route::group([], __DIR__.'/../Routes/admin.php'); Route::group([], __DIR__.'/../Routes/customer.php'); Route::group([], __DIR__.'/../Routes/distribution.php'); + Route::group([], __DIR__.'/../Routes/checkout.php'); } } diff --git a/Modules/Licensing/Resources/views/checkout/index.blade.php b/Modules/Licensing/Resources/views/checkout/index.blade.php new file mode 100644 index 0000000..93c7353 --- /dev/null +++ b/Modules/Licensing/Resources/views/checkout/index.blade.php @@ -0,0 +1,15 @@ +@extends('licensing::customer.layout') +@section('title', 'SonarDesk · Purchase') +@section('content') +
+

SonarDesk

+

Native SonarQube project monitoring for macOS.

+

Choose your licence on our secure checkout service. Your subscription and licence are processed by the existing SonarDesk licensing platform.

+ @if($enabled) +

View plans & buy

+ @else +

Online checkout is not yet enabled on this site. No payment will be taken here.

+ @endif +

Downloads & release notes · My licences

+
+@endsection diff --git a/Modules/Licensing/Routes/checkout.php b/Modules/Licensing/Routes/checkout.php new file mode 100644 index 0000000..7da3f56 --- /dev/null +++ b/Modules/Licensing/Routes/checkout.php @@ -0,0 +1,8 @@ +prefix('sonardesk')->name('sonardesk.')->group(function (): void { + Route::get('/buy', [CheckoutHandoffController::class, 'index'])->name('buy'); + Route::get('/checkout', [CheckoutHandoffController::class, 'redirect'])->name('checkout'); +}); diff --git a/config/sonardesk-integration.php b/config/sonardesk-integration.php new file mode 100644 index 0000000..72af937 --- /dev/null +++ b/config/sonardesk-integration.php @@ -0,0 +1,7 @@ + (bool) env('SONARDESK_CHECKOUT_ENABLED', false), + 'checkout_url' => env('SONARDESK_CHECKOUT_URL', ''), + 'allowed_checkout_hosts' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_CHECKOUT_ALLOWED_HOSTS', ''))))), +]; diff --git a/integration/PHASE12.md b/integration/PHASE12.md new file mode 100644 index 0000000..affbade --- /dev/null +++ b/integration/PHASE12.md @@ -0,0 +1,37 @@ +# Phase 12 — Controlled checkout handoff and production-readiness gate + +This is a **safe first cutover increment**, not a completed production migration. It introduces a unified SonarDesk purchase landing page in ABSDev and an explicit, allowlisted HTTPS handoff to the *existing* licensing checkout. It deliberately does NOT duplicate payment processing, webhooks, licence issuance, subscription mutations or customer ownership rules. + +## Install +Extract this archive **into the existing ABSDEV project root**, preserving `Modules/Licensing`. No root-level module copies are included. + +```sh +composer dump-autoload +php artisan optimize:clear +php artisan route:list --path=sonardesk/buy +php artisan route:list --path=sonardesk/checkout +php artisan test --filter=SonarDeskCheckoutHandoffTest +``` + +## Staging-only opt-in +Add to ABSDev `.env` (use the real existing hosted checkout URL): + +```dotenv +SONARDESK_CHECKOUT_ENABLED=false +SONARDESK_CHECKOUT_URL=https://sonardesk.stage.absdev.net/buy +SONARDESK_CHECKOUT_ALLOWED_HOSTS=sonardesk.stage.absdev.net +``` + +After verifying the hosted path exists and the correct Paddle **sandbox** account is used, set `SONARDESK_CHECKOUT_ENABLED=true` in staging and clear configuration cache. Hostnames are exact-match allowlisted. Do not use production until separately approved. + +## Acceptance and cutover gates — NOT automatically satisfied +1. Verify the checkout path and Paddle sandbox checkout end to end. +2. Verify webhook signatures, idempotency, retry handling, licence issuance and delivery in the **existing** service. +3. Verify customer portal email ownership, subscription visibility and activation limits. +4. Verify signed Sparkle appcast and actual updater in a signed test build; merely serving RSS is insufficient. +5. Test product downloads, checksums, platform restrictions, admin permissions and backup/restore. +6. Address `npm audit` findings; perform Laravel tests, integration tests and manual UI checks. +7. Plan DB and file migration, dual-write/rollback, DNS and webhook destination cutover **before** switching billing authority. +8. Keep legacy production licensing service and webhook endpoint unchanged until a separate validated cutover. + +No production deployment or billing authority transfer is performed by this patch. diff --git a/tests/Feature/SonarDeskCheckoutHandoffTest.php b/tests/Feature/SonarDeskCheckoutHandoffTest.php new file mode 100644 index 0000000..9a0fcd5 --- /dev/null +++ b/tests/Feature/SonarDeskCheckoutHandoffTest.php @@ -0,0 +1,30 @@ +set('sonardesk-integration.checkout_enabled', false); + $this->get('/sonardesk/buy')->assertOk()->assertSee('not yet enabled'); + $this->get('/sonardesk/checkout')->assertStatus(503); + } + + public function test_checkout_rejects_unlisted_host(): void + { + config()->set('sonardesk-integration.checkout_enabled', true); + config()->set('sonardesk-integration.checkout_url', 'https://evil.example/buy'); + config()->set('sonardesk-integration.allowed_checkout_hosts', ['sonardesk.example']); + $this->get('/sonardesk/checkout')->assertStatus(503); + } + + public function test_checkout_redirects_only_to_approved_https_host(): void + { + config()->set('sonardesk-integration.checkout_enabled', true); + config()->set('sonardesk-integration.checkout_url', 'https://sonardesk.example/buy'); + config()->set('sonardesk-integration.allowed_checkout_hosts', ['sonardesk.example']); + $this->get('/sonardesk/checkout')->assertRedirect('https://sonardesk.example/buy'); + } +}