From eaedf1c5590b00abfe2a2b0b88b5720c1d11de8e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 2 Oct 2026 10:21:54 +0100 Subject: [PATCH] SEO additions --- .../Commands/SonarDeskAccountsBackfill.php | 2 +- .../Controllers/Admin/AdminController.php | 4 +- app/Http/Controllers/Admin/UserController.php | 4 +- app/Models/User.php | 9 +- .../SonarDeskLicensingServiceProvider.php | 26 ------ bootstrap/providers.php | 3 - composer.json | 7 +- composer.lock | 22 ++--- config/sonardesk.php | 15 --- database/factories/UserFactory.php | 34 +++++++ .../Console}/SonarDeskLicenseDeliver.php | 4 +- .../Console}/SonarDeskLicenseIssue.php | 6 +- .../Console}/SonarDeskLicenseRevoke.php | 4 +- .../Console}/SonarDeskProductCreate.php | 4 +- ...0000_create_sonardesk_licensing_tables.php | 92 +++++++++++++++++++ ..._08_210000_add_sonardesk_portal_fields.php | 30 ++++++ .../Api/LicenseActivationController.php | 12 +-- .../Api/PaymentWebhookController.php | 10 +- .../Controllers/Portal/PortalController.php | 14 +-- {app => modules/Licensing}/Models/License.php | 2 +- .../Licensing}/Models/LicenseActivation.php | 2 +- .../Models/LicenseDeliveryOutbox.php | 2 +- .../Licensing}/Models/LicenseProduct.php | 2 +- .../Licensing}/Models/LicenseWebhookEvent.php | 2 +- .../Providers/LicensingServiceProvider.php | 16 ++++ modules/Licensing/Routes/api.php | 24 ++--- modules/Licensing/Routes/web.php | 21 ++--- .../Services}/LicenseDeliveryService.php | 4 +- .../Licensing/Services}/LicenseIssuer.php | 8 +- .../Licensing/Services}/LicenseKey.php | 2 +- .../Licensing/Services}/LicenseSigner.php | 4 +- .../Services}/LicenseTokenVerifier.php | 2 +- modules/Licensing/composer.json | 24 +---- modules/Licensing/module.json | 2 +- modules/Paddle/Config/config.php | 14 ++- ..._100000_add_paddle_subscription_fields.php | 34 +++++++ .../Database/Seeders}/PaddleCatalogSeeder.php | 12 +-- .../Http/Controllers/CheckoutController.php | 6 +- modules/Paddle/Routes/web.php | 16 +--- .../Paddle/Services}/PaddleClient.php | 12 +-- .../Services}/PaddleWebhookVerifier.php | 8 +- .../Paddle/Services}/PurchaseProvisioner.php | 20 +++- modules/Paddle/composer.json | 24 +---- modules/Paddle/module.json | 6 +- resources/views/downloads/appcast.blade.php | 2 +- routes/api.php | 11 +-- routes/console.php | 2 +- routes/web.php | 11 --- tests/Feature/CheckoutPageTest.php | 14 +-- tests/Feature/LicenseActivationTest.php | 6 +- .../PaddleSubscriptionProvisioningTest.php | 10 +- .../PortalSubscriptionManagementTest.php | 13 ++- tests/TestCase.php | 10 ++ tests/Unit/.gitkeep | 0 54 files changed, 397 insertions(+), 253 deletions(-) delete mode 100755 app/Providers/SonarDeskLicensingServiceProvider.php create mode 100644 database/factories/UserFactory.php rename {app/Console/Commands => modules/Licensing/Console}/SonarDeskLicenseDeliver.php (86%) rename {app/Console/Commands => modules/Licensing/Console}/SonarDeskLicenseIssue.php (86%) rename {app/Console/Commands => modules/Licensing/Console}/SonarDeskLicenseRevoke.php (87%) rename {app/Console/Commands => modules/Licensing/Console}/SonarDeskProductCreate.php (92%) create mode 100755 modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php create mode 100755 modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php rename {app => modules/Licensing}/Http/Controllers/Api/LicenseActivationController.php (95%) rename {app => modules/Licensing}/Http/Controllers/Api/PaymentWebhookController.php (95%) rename {app => modules/Licensing}/Http/Controllers/Portal/PortalController.php (94%) rename {app => modules/Licensing}/Models/License.php (97%) rename {app => modules/Licensing}/Models/LicenseActivation.php (91%) rename {app => modules/Licensing}/Models/LicenseDeliveryOutbox.php (92%) rename {app => modules/Licensing}/Models/LicenseProduct.php (91%) rename {app => modules/Licensing}/Models/LicenseWebhookEvent.php (89%) rename {app/Services/Licensing => modules/Licensing/Services}/LicenseDeliveryService.php (94%) rename {app/Services/Licensing => modules/Licensing/Services}/LicenseIssuer.php (91%) rename {app/Services/Licensing => modules/Licensing/Services}/LicenseKey.php (94%) rename {app/Services/Licensing => modules/Licensing/Services}/LicenseSigner.php (96%) rename {app/Services/Licensing => modules/Licensing/Services}/LicenseTokenVerifier.php (98%) create mode 100755 modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php rename {database/seeders => modules/Paddle/Database/Seeders}/PaddleCatalogSeeder.php (61%) rename {app => modules/Paddle}/Http/Controllers/CheckoutController.php (93%) rename {app/Services/Payments => modules/Paddle/Services}/PaddleClient.php (93%) rename {app/Services/Payments => modules/Paddle/Services}/PaddleWebhookVerifier.php (93%) rename {app/Services/Payments => modules/Paddle/Services}/PurchaseProvisioner.php (97%) create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/.gitkeep diff --git a/app/Console/Commands/SonarDeskAccountsBackfill.php b/app/Console/Commands/SonarDeskAccountsBackfill.php index ecd3464..62798b2 100644 --- a/app/Console/Commands/SonarDeskAccountsBackfill.php +++ b/app/Console/Commands/SonarDeskAccountsBackfill.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Models\License; +use Modules\Licensing\Models\License; use App\Models\User; use App\Services\Accounts\PurchasedAccountProvisioner; use Illuminate\Console\Command; diff --git a/app/Http/Controllers/Admin/AdminController.php b/app/Http/Controllers/Admin/AdminController.php index 6c2bb25..2609d01 100755 --- a/app/Http/Controllers/Admin/AdminController.php +++ b/app/Http/Controllers/Admin/AdminController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use App\Models\License; -use App\Models\LicenseActivation; +use Modules\Licensing\Models\License; +use Modules\Licensing\Models\LicenseActivation; use App\Models\User; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 7f4d45c..f37a764 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use App\Models\License; -use App\Models\LicenseActivation; +use Modules\Licensing\Models\License; +use Modules\Licensing\Models\LicenseActivation; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; diff --git a/app/Models/User.php b/app/Models/User.php index c529da4..f593607 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,7 +11,14 @@ class User extends Authenticatable implements MustVerifyEmail { use HasFactory, Notifiable; - protected $fillable = ['name', 'email', 'password', 'is_admin', 'disabled_at']; + protected $fillable = [ + 'name', + 'email', + 'email_verified_at', + 'password', + 'is_admin', + 'disabled_at', + ]; protected $hidden = ['password', 'remember_token']; 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/bootstrap/providers.php b/bootstrap/providers.php index cfe2c04..b625128 100755 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,4 @@ filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL), ], - 'paddle' => [ - 'api_key' => env('PADDLE_API_KEY'), - 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), - 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'), - 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), - 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), - 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), - - // SonarDesk is one Paddle product with four recurring prices. - 'product_id' => env('PADDLE_PRODUCT_ID'), - 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'), - 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'), - 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'), - 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'), - ], 'lemon_squeezy' => [ 'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'), diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c411c07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,34 @@ + + */ +class UserFactory extends Factory +{ + protected static ?string $password; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + 'is_admin' => false, + ]; + } + + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/app/Console/Commands/SonarDeskLicenseDeliver.php b/modules/Licensing/Console/SonarDeskLicenseDeliver.php similarity index 86% rename from app/Console/Commands/SonarDeskLicenseDeliver.php rename to modules/Licensing/Console/SonarDeskLicenseDeliver.php index 68d5efe..67e5050 100644 --- a/app/Console/Commands/SonarDeskLicenseDeliver.php +++ b/modules/Licensing/Console/SonarDeskLicenseDeliver.php @@ -1,8 +1,8 @@ id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('edition')->default('Professional'); + $table->unsignedInteger('activation_limit')->default(1); + $table->unsignedInteger('max_major_version')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_product_id')->nullable(); + $table->string('external_price_id')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->unique(['payment_provider', 'external_price_id']); + }); + + Schema::create('licenses', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('license_product_id')->constrained('license_products'); + $table->string('license_key_hash', 64)->unique(); + $table->string('license_key_last4', 4); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable()->index(); + $table->string('edition'); + $table->unsignedInteger('activation_limit'); + $table->unsignedInteger('max_major_version')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->string('revoke_reason')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_order_id')->nullable(); + $table->string('external_customer_id')->nullable(); + $table->timestamps(); + $table->unique(['payment_provider', 'external_order_id']); + }); + + Schema::create('license_delivery_outbox', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id')->unique(); + $table->text('encrypted_license_key'); + $table->string('recipient_email')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + }); + + Schema::create('license_activations', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id'); + $table->uuid('installation_id'); + $table->string('platform', 32)->nullable(); + $table->string('app_version', 32)->nullable(); + $table->timestamp('activated_at'); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + $table->unique(['license_id', 'installation_id']); + $table->index(['license_id', 'deactivated_at']); + }); + + Schema::create('license_webhook_events', function (Blueprint $table) { + $table->id(); + $table->string('provider'); + $table->string('external_event_id'); + $table->string('event_type'); + $table->string('payload_sha256', 64); + $table->json('payload'); + $table->timestamp('processed_at')->nullable(); + $table->text('processing_error')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'external_event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_webhook_events'); + Schema::dropIfExists('license_activations'); + Schema::dropIfExists('license_delivery_outbox'); + Schema::dropIfExists('licenses'); + Schema::dropIfExists('license_products'); + } +}; diff --git a/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php new file mode 100755 index 0000000..b302e12 --- /dev/null +++ b/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php @@ -0,0 +1,30 @@ + $table->boolean('is_admin')->default(false)->index()); + } + + if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id')); + } + } + + public function down(): void + { + if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name')); + } + + if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin')); + } + } +}; diff --git a/app/Http/Controllers/Api/LicenseActivationController.php b/modules/Licensing/Http/Controllers/Api/LicenseActivationController.php similarity index 95% rename from app/Http/Controllers/Api/LicenseActivationController.php rename to modules/Licensing/Http/Controllers/Api/LicenseActivationController.php index d4e1aba..7580871 100755 --- a/app/Http/Controllers/Api/LicenseActivationController.php +++ b/modules/Licensing/Http/Controllers/Api/LicenseActivationController.php @@ -1,13 +1,13 @@ external_price_id; if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) { diff --git a/app/Models/License.php b/modules/Licensing/Models/License.php similarity index 97% rename from app/Models/License.php rename to modules/Licensing/Models/License.php index c3dcc56..86e5c6a 100755 --- a/app/Models/License.php +++ b/modules/Licensing/Models/License.php @@ -1,6 +1,6 @@ Limit::perMinute(5) + ->by(strtolower((string) $request->input('email')).'|'.$request->ip())); + + RateLimiter::for('registration', fn (Request $request) => + Limit::perHour(5)->by($request->ip()) + ); + + RateLimiter::for('license-activation', function (Request $request): array { + return [ + Limit::perMinute(12)->by($request->ip()), + Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), + ]; + }); $this->registerTranslations(); $this->registerConfig(); $this->registerViews(); diff --git a/modules/Licensing/Routes/api.php b/modules/Licensing/Routes/api.php index bc590fb..a923714 100644 --- a/modules/Licensing/Routes/api.php +++ b/modules/Licensing/Routes/api.php @@ -1,18 +1,12 @@ get('/licensing', function (Request $request) { - return $request->user(); -}); \ No newline at end of file +Route::prefix('v1')->group(function (): void { + Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); + Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); + Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); + Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']); +}); diff --git a/modules/Licensing/Routes/web.php b/modules/Licensing/Routes/web.php index 710fff8..cfe4d88 100644 --- a/modules/Licensing/Routes/web.php +++ b/modules/Licensing/Routes/web.php @@ -1,16 +1,13 @@ group(function() { - Route::get('/', 'LicensingController@index'); +Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function (): void { + Route::get('/', [PortalController::class, 'index'])->name('index'); + Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show'); + Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade'); + Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel'); + Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend'); + Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate'); }); diff --git a/app/Services/Licensing/LicenseDeliveryService.php b/modules/Licensing/Services/LicenseDeliveryService.php similarity index 94% rename from app/Services/Licensing/LicenseDeliveryService.php rename to modules/Licensing/Services/LicenseDeliveryService.php index 0c1e584..053600d 100755 --- a/app/Services/Licensing/LicenseDeliveryService.php +++ b/modules/Licensing/Services/LicenseDeliveryService.php @@ -1,8 +1,8 @@ 'Paddle', - 'features' => - [ - ], + 'api_key' => env('PADDLE_API_KEY'), + 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), + 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'), + 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), + 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), + 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), + 'product_id' => env('PADDLE_PRODUCT_ID'), + 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'), + 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'), + 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'), + 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'), ]; diff --git a/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php b/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php new file mode 100755 index 0000000..ef60b76 --- /dev/null +++ b/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php @@ -0,0 +1,34 @@ +string('external_subscription_id')->nullable()->after('external_customer_id'); + $table->string('external_price_id')->nullable()->after('external_subscription_id'); + $table->string('subscription_status')->nullable()->after('external_price_id'); + $table->timestamp('current_period_ends_at')->nullable()->after('subscription_status'); + $table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at'); + $table->unique(['payment_provider', 'external_subscription_id']); + }); + } + + public function down(): void + { + Schema::table('licenses', function (Blueprint $table) { + $table->dropUnique(['payment_provider', 'external_subscription_id']); + $table->dropColumn([ + 'external_subscription_id', + 'external_price_id', + 'subscription_status', + 'current_period_ends_at', + 'cancel_scheduled_at', + ]); + }); + } +}; diff --git a/database/seeders/PaddleCatalogSeeder.php b/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php similarity index 61% rename from database/seeders/PaddleCatalogSeeder.php rename to modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php index 1c23d60..203ffe5 100755 --- a/database/seeders/PaddleCatalogSeeder.php +++ b/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php @@ -1,8 +1,8 @@ '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')], + ['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.personal_monthly_price_id')], + ['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.personal_annual_price_id')], + ['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.professional_monthly_price_id')], + ['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.professional_annual_price_id')], ]; foreach ($products as $data) { diff --git a/app/Http/Controllers/CheckoutController.php b/modules/Paddle/Http/Controllers/CheckoutController.php similarity index 93% rename from app/Http/Controllers/CheckoutController.php rename to modules/Paddle/Http/Controllers/CheckoutController.php index 9c8d5c5..008c07d 100755 --- a/app/Http/Controllers/CheckoutController.php +++ b/modules/Paddle/Http/Controllers/CheckoutController.php @@ -1,6 +1,8 @@ group(function() { - Route::get('/', 'PaddleController@index'); -}); +Route::get('/buy', CheckoutController::class)->name('buy'); diff --git a/app/Services/Payments/PaddleClient.php b/modules/Paddle/Services/PaddleClient.php similarity index 93% rename from app/Services/Payments/PaddleClient.php rename to modules/Paddle/Services/PaddleClient.php index f2f23aa..3585df4 100755 --- a/app/Services/Payments/PaddleClient.php +++ b/modules/Paddle/Services/PaddleClient.php @@ -1,17 +1,17 @@ withToken( - (string) config('sonardesk.paddle.api_key') + (string) config('paddle.api_key') ) ->acceptJson() ->timeout(15); @@ -131,7 +131,7 @@ final class PaddleClient { if ( trim( - (string) config('sonardesk.paddle.api_key') + (string) config('paddle.api_key') ) === '' ) { throw new RuntimeException( diff --git a/app/Services/Payments/PaddleWebhookVerifier.php b/modules/Paddle/Services/PaddleWebhookVerifier.php similarity index 93% rename from app/Services/Payments/PaddleWebhookVerifier.php rename to modules/Paddle/Services/PaddleWebhookVerifier.php index 477bcb7..5ba1ba2 100755 --- a/app/Services/Payments/PaddleWebhookVerifier.php +++ b/modules/Paddle/Services/PaddleWebhookVerifier.php @@ -1,6 +1,8 @@ header('Paddle-Signature')); if ($secret === '') { @@ -55,7 +57,7 @@ final class PaddleWebhookVerifier implements WebhookVerifier $tolerance = max( 1, - (int) config('sonardesk.paddle.timestamp_tolerance', 300) + (int) config('paddle.timestamp_tolerance', 300) ); $age = abs(time() - $timestamp); diff --git a/app/Services/Payments/PurchaseProvisioner.php b/modules/Paddle/Services/PurchaseProvisioner.php similarity index 97% rename from app/Services/Payments/PurchaseProvisioner.php rename to modules/Paddle/Services/PurchaseProvisioner.php index 03b231b..2209f9c 100755 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/modules/Paddle/Services/PurchaseProvisioner.php @@ -1,12 +1,12 @@ getDriverName() !== 'mysql') { + return $callback(); + } + $lockName = 'sd:'.substr(hash('sha256', $name), 0, 61); $row = DB::selectOne( diff --git a/modules/Paddle/composer.json b/modules/Paddle/composer.json index 96582bf..eee3cac 100644 --- a/modules/Paddle/composer.json +++ b/modules/Paddle/composer.json @@ -1,29 +1,15 @@ { "name": "mpba/paddle", - "description": "", - "authors": [ - { - "name": "Mark Gregory", - "email": "mark.gregory@mpba.co.uk" - } - ], - "extra": { - "laravel": { - "providers": [], - "aliases": { - - } - } - }, + "description": "SonarDesk Paddle module", + "type": "library", "autoload": { "psr-4": { "Modules\\Paddle\\": "" - }, + } + }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/", - "Modules\\Paddle\\Tests\\": "modules/Paddle/Tests/" - } + "Modules\\Paddle\\Tests\\": "Tests/" } } } diff --git a/modules/Paddle/module.json b/modules/Paddle/module.json index 8917b3b..0430866 100644 --- a/modules/Paddle/module.json +++ b/modules/Paddle/module.json @@ -2,7 +2,7 @@ "name": "Paddle", "alias": "paddle", "version": "1.0.0", - "description": "", + "description": "SonarDesk Paddle module", "keywords": [], "priority": 0, "providers": [ @@ -10,5 +10,7 @@ ], "aliases": {}, "files": [], - "requires": [] + "requires": [ + "Licensing" + ] } diff --git a/resources/views/downloads/appcast.blade.php b/resources/views/downloads/appcast.blade.php index ca21258..a7060d9 100644 --- a/resources/views/downloads/appcast.blade.php +++ b/resources/views/downloads/appcast.blade.php @@ -1,2 +1,2 @@ -{!! '' !!} +@php echo ''; @endphp SonarDesk Updates{{ route('updates.appcast') }}SonarDesk stable release feed@foreach($releases as $release)SonarDesk {{ $release->version }}{{ $release->published_at?->toRfc2822String() }}{{ $release->build_number }}{{ $release->version }}@if($release->minimum_macos){{ $release->minimum_macos }}@endifrelease_notes ?: 'SonarDesk update')) !!}]]>sparkle_signature) sparkle:edSignature="{{ $release->sparkle_signature }}"@endif />@endforeach diff --git a/routes/api.php b/routes/api.php index 9548279..deb587a 100755 --- a/routes/api.php +++ b/routes/api.php @@ -1,12 +1,3 @@ group(function () { - Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); - Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); - Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); - Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']); -}); +// Module-owned API routes are loaded by their RouteServiceProviders. diff --git a/routes/console.php b/routes/console.php index 6fff514..d790415 100755 --- a/routes/console.php +++ b/routes/console.php @@ -6,5 +6,5 @@ use Illuminate\Support\Facades\Schedule; Schedule::command('model:prune')->dailyAt('03:20')->withoutOverlapping(); Schedule::call(function (): void { - app(\App\Services\Licensing\LicenseDeliveryService::class)->deliverPending(50); + app(\Modules\Licensing\Services\LicenseDeliveryService::class)->deliverPending(50); })->name('license-delivery')->everyMinute()->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index 7ef77d8..a3e6d6f 100755 --- a/routes/web.php +++ b/routes/web.php @@ -12,9 +12,7 @@ use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\Auth\EmailVerificationController; use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\RegisteredUserController; -use App\Http\Controllers\CheckoutController; use App\Http\Controllers\ReleaseController; -use App\Http\Controllers\Portal\PortalController; use App\Http\Controllers\Support\SupportController; use App\Http\Middleware\AdminOnly; use Illuminate\Support\Facades\Route; @@ -23,7 +21,6 @@ Route::get('/sitemap.xml', [SeoController::class, 'sitemap'])->name('sitemap'); Route::get('/robots.txt', [SeoController::class, 'robots'])->name('robots'); Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\SonarDeskRelease::published()->where('channel', 'stable')->latest('published_at')->first()]))->name('home'); -Route::get('/buy', CheckoutController::class)->name('buy'); Route::view('/features', 'features')->name('features'); Route::get('/release', [ReleaseController::class, 'index'])->name('release'); Route::get('/download', [ReleaseController::class, 'latest'])->name('download.latest'); @@ -51,14 +48,6 @@ Route::middleware('auth')->group(function () { Route::get('/verify-email/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); Route::post('/email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); }); -Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function () { - Route::get('/', [PortalController::class, 'index'])->name('index'); - Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show'); - Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade'); - Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel'); - Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend'); - Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate'); -}); Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name('admin.')->group(function () { Route::get('/', [AdminController::class, 'index'])->name('index'); Route::get('/licences', [AdminController::class, 'licenses'])->name('licenses'); diff --git a/tests/Feature/CheckoutPageTest.php b/tests/Feature/CheckoutPageTest.php index 1fd31b8..b8dff77 100755 --- a/tests/Feature/CheckoutPageTest.php +++ b/tests/Feature/CheckoutPageTest.php @@ -2,18 +2,20 @@ namespace Tests\Feature; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; final class CheckoutPageTest extends TestCase { + use RefreshDatabase; public function test_buy_page_contains_all_four_configured_paddle_prices(): void { - config()->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'); + config()->set('paddle.client_side_token', 'test_example'); + config()->set('paddle.environment', 'sandbox'); + config()->set('paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('paddle.personal_annual_price_id', 'pri_personal_annual'); + config()->set('paddle.professional_monthly_price_id', 'pri_professional_monthly'); + config()->set('paddle.professional_annual_price_id', 'pri_professional_annual'); $this->get('/buy') ->assertOk() diff --git a/tests/Feature/LicenseActivationTest.php b/tests/Feature/LicenseActivationTest.php index 7d81a87..ebb0b4e 100755 --- a/tests/Feature/LicenseActivationTest.php +++ b/tests/Feature/LicenseActivationTest.php @@ -2,9 +2,9 @@ namespace Tests\Feature; -use App\Models\LicenseActivation; -use App\Models\LicenseProduct; -use App\Services\Licensing\LicenseIssuer; +use Modules\Licensing\Models\LicenseActivation; +use Modules\Licensing\Models\LicenseProduct; +use Modules\Licensing\Services\LicenseIssuer; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; diff --git a/tests/Feature/PaddleSubscriptionProvisioningTest.php b/tests/Feature/PaddleSubscriptionProvisioningTest.php index a2c1f07..1c062e1 100755 --- a/tests/Feature/PaddleSubscriptionProvisioningTest.php +++ b/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -2,10 +2,10 @@ namespace Tests\Feature; -use App\Models\License; -use App\Models\LicenseProduct; +use Modules\Licensing\Models\License; +use Modules\Licensing\Models\LicenseProduct; use App\Models\User; -use App\Services\Payments\PurchaseProvisioner; +use Modules\Paddle\Services\PurchaseProvisioner; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -84,7 +84,7 @@ class PaddleSubscriptionProvisioningTest extends TestCase $this->product('pri_personal_monthly', 'Personal', 1); app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( 'subscription.updated', 'active', 'pri_personal_monthly', 'pro_personal', - ['data' => ['scheduled_change' => ['action' => 'cancel', 'effective_at' => '2026-10-09T00:00:00Z']]] + ['scheduled_change' => ['action' => 'cancel', 'effective_at' => '2026-10-09T00:00:00Z']] )); $license = License::firstOrFail(); @@ -97,7 +97,7 @@ class PaddleSubscriptionProvisioningTest extends TestCase $this->product('pri_personal_monthly', 'Personal', 1); $service = app(PurchaseProvisioner::class); $service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); - $service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['data' => ['current_billing_period' => null]])); + $service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['current_billing_period' => null])); $license = License::firstOrFail(); $this->assertNotNull($license->revoked_at); diff --git a/tests/Feature/PortalSubscriptionManagementTest.php b/tests/Feature/PortalSubscriptionManagementTest.php index 5fd08de..8e23027 100755 --- a/tests/Feature/PortalSubscriptionManagementTest.php +++ b/tests/Feature/PortalSubscriptionManagementTest.php @@ -2,11 +2,12 @@ namespace Tests\Feature; -use App\Models\License; -use App\Models\LicenseProduct; +use Modules\Licensing\Models\License; +use Modules\Licensing\Models\LicenseProduct; use App\Models\User; -use App\Services\Payments\PaddleClient; +use Modules\Paddle\Services\PaddleClient; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; use Mockery\MockInterface; use Tests\TestCase; @@ -31,8 +32,10 @@ class PortalSubscriptionManagementTest extends TestCase 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'); + $this->withoutMiddleware(PreventRequestForgery::class); + + config()->set('paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('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); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +