SEO additions

This commit is contained in:
Mark Gregory
2026-10-02 10:21:54 +01:00
parent cbb1f0c366
commit eaedf1c559
54 changed files with 397 additions and 253 deletions
@@ -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;
@@ -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;
@@ -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;
+8 -1
View File
@@ -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'];
@@ -1,26 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
final class SonarDeskLicensingServiceProvider extends ServiceProvider
{
public function boot(): void
{
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'))),
];
});
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()));
}
}
-3
View File
@@ -1,7 +1,4 @@
<?php
use App\Providers\SonarDeskLicensingServiceProvider;
return [
SonarDeskLicensingServiceProvider::class,
];
+5 -2
View File
@@ -3,7 +3,10 @@
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"repositories": [
{
@@ -41,7 +44,7 @@
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/",
"Modules\\": "Modules/"
"Modules\\": "modules/"
}
},
"autoload-dev": {
Generated
+11 -11
View File
@@ -1542,16 +1542,16 @@
},
{
"name": "league/commonmark",
"version": "2.10.1",
"version": "2.10.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e"
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": ""
},
"require": {
@@ -1645,7 +1645,7 @@
"type": "tidelift"
}
],
"time": "2026-09-07T13:44:26+00:00"
"time": "2026-09-21T13:07:34+00:00"
},
{
"name": "league/config",
@@ -4970,16 +4970,16 @@
},
{
"name": "symfony/polyfill-php80",
"version": "v1.37.0",
"version": "v1.43.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"shasum": ""
},
"require": {
@@ -5030,7 +5030,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
"source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
},
"funding": [
{
@@ -5050,7 +5050,7 @@
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
"time": "2026-09-25T15:50:05+00:00"
},
{
"name": "symfony/polyfill-php82",
-15
View File
@@ -28,21 +28,6 @@ return [
'notify_admin_users' => 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'),
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<\App\Models\User>
*/
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,
]);
}
}
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Services\Licensing\LicenseDeliveryService;
use Modules\Licensing\Services\LicenseDeliveryService;
use Illuminate\Console\Command;
final class SonarDeskLicenseDeliver extends Command
@@ -1,9 +1,9 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Console\Command;
class SonarDeskLicenseIssue extends Command
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\License;
use Modules\Licensing\Models\License;
use Illuminate\Console\Command;
class SonarDeskLicenseRevoke extends Command
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\LicenseProduct;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Console\Command;
class SonarDeskProductCreate extends Command
@@ -0,0 +1,92 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $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'));
}
}
};
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Api;
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Services\Licensing\LicenseKey;
use App\Services\Licensing\LicenseSigner;
use App\Services\Licensing\LicenseTokenVerifier;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseActivation;
use Modules\Licensing\Services\LicenseKey;
use Modules\Licensing\Services\LicenseSigner;
use Modules\Licensing\Services\LicenseTokenVerifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Api;
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use Modules\Licensing\Models\LicenseWebhookEvent;
use Modules\Licensing\Services\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use Modules\Paddle\Services\PaddleWebhookVerifier;
use Modules\Paddle\Services\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Portal;
namespace Modules\Licensing\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Payments\PaddleClient;
use App\Services\Licensing\LicenseDeliveryService;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Paddle\Services\PaddleClient;
use Modules\Licensing\Services\LicenseDeliveryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use RuntimeException;
@@ -131,7 +131,7 @@ final class PortalController extends Controller
private function professionalPriceFor(License $license): string
{
$paddle = config('sonardesk.paddle');
$paddle = config('paddle');
$current = (string) $license->external_price_id;
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -3,6 +3,9 @@
namespace Modules\Licensing\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Database\Eloquent\Factory;
use Illuminate\Support\Facades\File;
use Modules\Licensing\Support\ModuleInfo;
@@ -38,6 +41,19 @@ class LicensingServiceProvider extends ServiceProvider
*/
public function boot() : void
{
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())
);
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();
+9 -15
View File
@@ -1,18 +1,12 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Api\LicenseActivationController;
use Modules\Licensing\Http\Controllers\Api\PaymentWebhookController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::middleware('auth:api')->get('/licensing', function (Request $request) {
return $request->user();
});
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']);
});
+9 -12
View File
@@ -1,16 +1,13 @@
<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Portal\PortalController;
Route::prefix('licensing')->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');
});
@@ -1,8 +1,8 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
@@ -1,10 +1,10 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
@@ -1,6 +1,6 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
final class LicenseKey
{
@@ -1,8 +1,8 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\License;
use Modules\Licensing\Models\License;
use Carbon\CarbonInterface;
use RuntimeException;
@@ -1,6 +1,6 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use RuntimeException;
+5 -19
View File
@@ -1,29 +1,15 @@
{
"name": "mpba/licensing",
"description": "",
"authors": [
{
"name": "Mark Gregory",
"email": "mark.gregory@mpba.co.uk"
}
],
"extra": {
"laravel": {
"providers": [],
"aliases": {
}
}
},
"description": "SonarDesk Licensing module",
"type": "library",
"autoload": {
"psr-4": {
"Modules\\Licensing\\": ""
},
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/",
"Modules\\Licensing\\Tests\\": "modules/Licensing/Tests/"
}
"Modules\\Licensing\\Tests\\": "Tests/"
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "Licensing",
"alias": "licensing",
"version": "1.0.0",
"description": "",
"description": "SonarDesk Licensing module",
"keywords": [],
"priority": 0,
"providers": [
+11 -3
View File
@@ -2,7 +2,15 @@
return [
'name' => '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'),
];
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('licenses', function (Blueprint $table) {
$table->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',
]);
});
}
};
@@ -1,8 +1,8 @@
<?php
namespace Database\Seeders;
namespace Modules\Paddle\Database\Seeders;
use App\Models\LicenseProduct;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Database\Seeder;
class PaddleCatalogSeeder extends Seeder
@@ -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.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) {
@@ -1,6 +1,8 @@
<?php
namespace App\Http\Controllers;
namespace Modules\Paddle\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use RuntimeException;
@@ -9,7 +11,7 @@ final class CheckoutController extends Controller
{
public function __invoke(): View
{
$paddle = config('sonardesk.paddle');
$paddle = config('paddle');
$token = trim((string) ($paddle['client_side_token'] ?? ''));
if ($token === '') {
+3 -13
View File
@@ -1,16 +1,6 @@
<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
use Illuminate\Support\Facades\Route;
use Modules\Paddle\Http\Controllers\CheckoutController;
Route::prefix('paddle')->group(function() {
Route::get('/', 'PaddleController@index');
});
Route::get('/buy', CheckoutController::class)->name('buy');
@@ -1,17 +1,17 @@
<?php
namespace App\Services\Payments;
namespace Modules\Paddle\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class PaddleClient
class PaddleClient
{
public function customer(?string $customerID): ?array
{
if (! $customerID || ! config('sonardesk.paddle.api_key')) {
if (! $customerID || ! config('paddle.api_key')) {
return null;
}
@@ -118,10 +118,10 @@ final class PaddleClient
private function request(): PendingRequest
{
return Http::baseUrl(
(string) config('sonardesk.paddle.api_url')
(string) config('paddle.api_url')
)
->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(
@@ -1,6 +1,8 @@
<?php
namespace App\Services\Payments;
namespace Modules\Paddle\Services;
use App\Services\Payments\WebhookVerifier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -10,7 +12,7 @@ final class PaddleWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = trim((string) config('sonardesk.paddle.webhook_secret'));
$secret = trim((string) config('paddle.webhook_secret'));
$header = trim((string) $request->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);
@@ -1,12 +1,12 @@
<?php
namespace App\Services\Payments;
namespace Modules\Paddle\Services;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use App\Services\Accounts\PurchasedAccountProvisioner;
use App\Services\Licensing\LicenseIssuer;
use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
@@ -743,6 +743,16 @@ final class PurchaseProvisioner
* prevents provider supplied identifiers from creating unusual
* lock names.
*/
/*
* MySQL advisory locks are only available when using MySQL/MariaDB.
*
* Tests use SQLite in-memory, where GET_LOCK() and RELEASE_LOCK()
* do not exist.
*/
if (DB::connection()->getDriverName() !== 'mysql') {
return $callback();
}
$lockName = 'sd:'.substr(hash('sha256', $name), 0, 61);
$row = DB::selectOne(
+5 -19
View File
@@ -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/"
}
}
}
+4 -2
View File
@@ -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"
]
}
+1 -1
View File
@@ -1,2 +1,2 @@
{!! '<?xml version="1.0" encoding="utf-8"?>' !!}
@php echo '<?xml version="1.0" encoding="utf-8"?>'; @endphp
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><title>SonarDesk Updates</title><link>{{ route('updates.appcast') }}</link><description>SonarDesk stable release feed</description>@foreach($releases as $release)<item><title>SonarDesk {{ $release->version }}</title><pubDate>{{ $release->published_at?->toRfc2822String() }}</pubDate><sparkle:version>{{ $release->build_number }}</sparkle:version><sparkle:shortVersionString>{{ $release->version }}</sparkle:shortVersionString>@if($release->minimum_macos)<sparkle:minimumSystemVersion>{{ $release->minimum_macos }}</sparkle:minimumSystemVersion>@endif<description><![CDATA[{!! nl2br(e($release->release_notes ?: 'SonarDesk update')) !!}]]></description><enclosure url="{{ route('download.release',$release) }}" length="{{ $release->file_size }}" type="application/octet-stream"@if($release->sparkle_signature) sparkle:edSignature="{{ $release->sparkle_signature }}"@endif /></item>@endforeach</channel></rss>
+1 -10
View File
@@ -1,12 +1,3 @@
<?php
use App\Http\Controllers\Api\LicenseActivationController;
use App\Http\Controllers\Api\PaymentWebhookController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->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.
+1 -1
View File
@@ -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();
-11
View File
@@ -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');
+8 -6
View File
@@ -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()
+3 -3
View File
@@ -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;
@@ -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);
@@ -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);
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}
View File