move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 13:25:16 +01:00
parent 563f0979b3
commit b4e040999d
212 changed files with 7976 additions and 0 deletions
@@ -0,0 +1,16 @@
<?php
return [
'name' => '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),
'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',
]);
});
}
};
@@ -0,0 +1,29 @@
<?php
namespace Modules\Paddle\Database\Seeders;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Database\Seeder;
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('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) {
if (! $data['external_product_id'] || ! $data['external_price_id']) {
continue;
}
LicenseProduct::updateOrCreate(
['code' => $data['code']],
$data + ['payment_provider' => 'paddle', 'max_major_version' => config('sonardesk.default_max_major_version'), 'active' => true]
);
}
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Paddle\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class PaddleDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}
@@ -0,0 +1,12 @@
<?php
use Modules\Paddle\Support\ModuleInfo;
if (! function_exists('paddle_feature_enabled')) {
/**
* Check if a feature is enabled in the config.
*/
function paddle_feature_enabled(string $key): bool
{
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Paddle\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use RuntimeException;
final class CheckoutController extends Controller
{
public function __invoke(): View
{
$paddle = config('paddle');
$token = trim((string) ($paddle['client_side_token'] ?? ''));
if ($token === '') {
throw new RuntimeException('PADDLE_CLIENT_SIDE_TOKEN is not configured.');
}
$plans = [
'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£4.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£39.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£8.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£79.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
];
foreach ($plans as $plan) {
if (trim((string) $plan['price_id']) === '') {
throw new RuntimeException('One or more Paddle price IDs are not configured.');
}
}
return view('checkout.buy', [
'plans' => $plans,
'paddleToken' => $token,
'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'),
]);
}
}
@@ -0,0 +1,79 @@
<?php
namespace Modules\Paddle\Http\Controllers;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class PaddleController extends Controller
{
/**
* Display a listing of the resource.
* @return Renderable
*/
public function index()
{
return view('paddle::index');
}
/**
* Show the form for creating a new resource.
* @return Renderable
*/
public function create()
{
return view('paddle::create');
}
/**
* Store a newly created resource in storage.
* @param Request $request
* @return Renderable
*/
public function store(Request $request)
{
//
}
/**
* Show the specified resource.
* @param int $id
* @return Renderable
*/
public function show($id)
{
return view('paddle::show');
}
/**
* Show the form for editing the specified resource.
* @param int $id
* @return Renderable
*/
public function edit($id)
{
return view('paddle::edit');
}
/**
* Update the specified resource in storage.
* @param Request $request
* @param int $id
* @return Renderable
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
* @param int $id
* @return Renderable
*/
public function destroy($id)
{
//
}
}
@@ -0,0 +1,155 @@
<?php
namespace Modules\Paddle\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Eloquent\Factory;
use Illuminate\Support\Facades\File;
use Modules\Paddle\Support\ModuleInfo;
use Config;
class PaddleServiceProvider extends ServiceProvider
{
/**
* @var string $moduleName
*/
protected string $moduleName = 'Paddle';
/**
* @var string $moduleNameLower
*/
protected string $moduleNameLower = 'paddle';
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides():array
{
return [];
}
/**
* Boot the application events.
*
* @return void
*/
public function boot() : void
{
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->registerHelpers();
$this->registerCommands();
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
}
/**
* Register the service provider.
*
* @return void
*/
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
}
/**
* Register the helpers.
*
* @return void
*/
protected function registerHelpers(): void
{
foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) {
require_once $filename;
}
}
/**
* Dynamically Register Commands.
*
* @return void
*/
public function registerCommands(): void
{
if (!$this->app->runningInConsole()) {
return;
}
$commandPath = __DIR__ . '/../Console';
$commands = collect(File::allFiles($commandPath))
->map(function ($file) {
$class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension();
return $class;
})
->filter(function ($class) {
return class_exists($class);
})
->values()
->all();
$this->commands($commands);
}
/**
* Register config.
*
* @return void
*/
protected function registerConfig(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'),
], 'config');
$this->mergeConfigFrom(
module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()
);
}
/**
* Register views.
*
* @return void
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/' . ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
$this->publishes([
$sourcePath => $viewPath
], ['views', ModuleInfo::nameLower() . '-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
}
/**
* Register translations.
*
* @return void
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/' . ModuleInfo::nameLower());
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
} else {
$this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
}
}
/**
* Regsiter View Paths
* @return array
*/
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (Config::get('view.paths') as $path) {
if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
$paths[] = $path . '/modules/' . ModuleInfo::nameLower();
}
}
return $paths;
}
}
@@ -0,0 +1,69 @@
<?php
namespace Modules\Paddle\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
/**
* The module namespace to assume when generating URLs to actions.
*
* @var string
*/
protected $moduleNamespace = 'Modules\Paddle\Http\Controllers';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*
* @return void
*/
public function boot()
{
parent::boot();
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::middleware('web')
->namespace($this->moduleNamespace)
->group(module_path('Paddle', '/Routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->moduleNamespace)
->group(module_path('Paddle', '/Routes/api.php'));
}
}
@@ -0,0 +1,9 @@
@extends('paddle::layouts.master')
@section('content')
<h1>Hello World</h1>
<p>
This view is loaded from module: {!! config('paddle.name') !!}
</p>
@endsection
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Module Paddle</title>
{{-- Laravel Mix - CSS File --}}
{{-- <link rel="stylesheet" href="{{ mix('css/paddle.css') }}"> --}}
</head>
<body>
@yield('content')
{{-- Laravel Mix - JS File --}}
{{-- <script src="{{ mix('js/paddle.js') }}"></script> --}}
</body>
</html>
@@ -0,0 +1,18 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| 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('/paddle', function (Request $request) {
return $request->user();
});
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Paddle\Http\Controllers\CheckoutController;
Route::get('/buy', CheckoutController::class)->name('buy');
@@ -0,0 +1,170 @@
<?php
namespace Modules\Paddle\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class PaddleClient
{
public function customer(?string $customerID): ?array
{
if (! $customerID || ! config('paddle.api_key')) {
return null;
}
$response = $this->request()->get('/customers/'.$customerID);
if (! $response->successful()) {
return null;
}
$data = $response->json('data');
return is_array($data) ? $data : null;
}
public function subscription(string $subscriptionID): array
{
$this->requireWritableConfiguration();
$response = $this->request()
->get('/subscriptions/'.$subscriptionID);
return $this->subscriptionData(
$response,
'Paddle could not retrieve the subscription.'
);
}
public function updateSubscriptionPrice(
string $subscriptionID,
string $priceID
): array {
$this->requireWritableConfiguration();
/*
* Paddle restricts subscription item changes while a subscription
* is trialing. Existing item quantities may only be updated using
* do_not_bill during the trial.
*
* Once the subscription is outside its trial we can use normal
* immediate proration.
*/
$subscription = $this->subscription($subscriptionID);
$status = (string) ($subscription['status'] ?? '');
$prorationBillingMode = $status === 'trialing'
? 'do_not_bill'
: 'prorated_immediately';
/*
* Preserve the existing subscription item and replace its price.
*
* Paddle treats the submitted items array as the desired
* subscription items, so quantity is retained where possible.
*/
$existingItems = is_array($subscription['items'] ?? null)
? $subscription['items']
: [];
$existingItem = $existingItems[0] ?? [];
$quantity = max(
1,
(int) ($existingItem['quantity'] ?? 1)
);
$response = $this->request()->patch(
'/subscriptions/'.$subscriptionID,
[
'proration_billing_mode' => $prorationBillingMode,
'items' => [
[
'price_id' => $priceID,
'quantity' => $quantity,
],
],
]
);
return $this->subscriptionData(
$response,
'Paddle could not update the subscription.'
);
}
public function cancelSubscriptionImmediately(
string $subscriptionID
): array {
$this->requireWritableConfiguration();
$response = $this->request()->post(
'/subscriptions/'.$subscriptionID.'/cancel',
[
'effective_from' => 'immediately',
]
);
return $this->subscriptionData(
$response,
'Paddle could not cancel the subscription.'
);
}
private function request(): PendingRequest
{
return Http::baseUrl(
(string) config('paddle.api_url')
)
->withToken(
(string) config('paddle.api_key')
)
->acceptJson()
->timeout(15);
}
private function requireWritableConfiguration(): void
{
if (
trim(
(string) config('paddle.api_key')
) === ''
) {
throw new RuntimeException(
'Paddle subscription management is not configured.'
);
}
}
private function subscriptionData(
Response $response,
string $fallback
): array {
if (! $response->successful()) {
$detail = trim(
(string) (
$response->json('error.detail')
?? ''
)
);
throw new RuntimeException(
$detail !== ''
? $detail
: $fallback
);
}
$data = $response->json('data');
if (! is_array($data)) {
throw new RuntimeException($fallback);
}
return $data;
}
}
@@ -0,0 +1,108 @@
<?php
namespace Modules\Paddle\Services;
use App\Services\Payments\WebhookVerifier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use RuntimeException;
final class PaddleWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = trim((string) config('paddle.webhook_secret'));
$header = trim((string) $request->header('Paddle-Signature'));
if ($secret === '') {
throw new RuntimeException('Paddle webhook secret is not configured.');
}
if ($header === '') {
throw new RuntimeException('Paddle-Signature header is missing.');
}
$parts = [];
foreach (explode(';', $header) as $part) {
[$key, $value] = array_pad(
explode('=', trim($part), 2),
2,
null
);
if ($key !== null && $value !== null) {
$parts[$key][] = $value;
}
}
$timestamp = isset($parts['ts'][0])
? (int) $parts['ts'][0]
: 0;
$signatures = $parts['h1'] ?? [];
if ($timestamp === 0) {
throw new RuntimeException(
'Paddle webhook signature has no timestamp.'
);
}
if ($signatures === []) {
throw new RuntimeException(
'Paddle webhook signature has no h1 signature.'
);
}
$tolerance = max(
1,
(int) config('paddle.timestamp_tolerance', 300)
);
$age = abs(time() - $timestamp);
if ($age > $tolerance) {
throw new RuntimeException(
"Paddle webhook timestamp is outside the allowed tolerance ({$age}s)."
);
}
$signedPayload = $timestamp . ':' . $rawBody;
$expected = hash_hmac(
'sha256',
$signedPayload,
$secret
);
Log::debug('Paddle webhook signature diagnostics', [
'timestamp' => $timestamp,
'age_seconds' => $age,
'body_length' => strlen($rawBody),
'body_sha256' => hash('sha256', $rawBody),
// Safe diagnostics — do NOT log the secret itself.
'secret_prefix' => substr($secret, 0, 11),
'secret_length' => strlen($secret),
'received_signature_prefixes' => array_map(
static fn (string $signature): string =>
substr($signature, 0, 12),
$signatures
),
'expected_signature_prefix' => substr($expected, 0, 12),
]);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return;
}
}
throw new RuntimeException(
'Paddle webhook signature is invalid.'
);
}
}
@@ -0,0 +1,796 @@
<?php
namespace Modules\Paddle\Services;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use App\Services\Accounts\PurchasedAccountProvisioner;
use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
final class PurchaseProvisioner
{
public function __construct(
private LicenseIssuer $issuer,
private PaddleClient $paddle,
private PurchasedAccountProvisioner $accounts,
) {}
public function provision(string $provider, array $event): void
{
if ($provider === 'paddle') {
$this->provisionPaddle($event);
return;
}
if ($provider === 'lemon-squeezy') {
$this->provisionLemonSqueezy($event);
return;
}
throw new RuntimeException(
'Unsupported payment provider.'
);
}
private function provisionPaddle(array $event): void
{
$type = (string) ($event['event_type'] ?? '');
$data = is_array($event['data'] ?? null)
? $event['data']
: [];
if (str_starts_with($type, 'subscription.')) {
$this->syncPaddleSubscription(
$type,
$data
);
return;
}
if ($type === 'transaction.completed') {
$this->syncPaddleTransaction($data);
return;
}
if ($type === 'transaction.refunded') {
$transactionID = $this->nullableString(
$data['id'] ?? null
);
if ($transactionID !== null) {
License::query()
->where('payment_provider', 'paddle')
->where(
'external_order_id',
$transactionID
)
->whereNull('external_subscription_id')
->whereNull('revoked_at')
->update([
'revoked_at' => now(),
'revoke_reason' => 'Payment refunded',
]);
}
}
}
private function syncPaddleSubscription(
string $type,
array $data
): void {
$subscriptionID = $this->nullableString(
$data['id'] ?? null
);
if ($subscriptionID === null) {
throw new RuntimeException(
'Paddle subscription webhook has no subscription ID.'
);
}
/*
* Multiple Paddle events for the same subscription can arrive
* simultaneously.
*
* The unique index protects database integrity, but by itself it
* cannot stop two workers from both observing "no licence" before
* either worker inserts the subscription ID.
*
* A MySQL advisory lock serialises provisioning for one Paddle
* subscription while still allowing unrelated subscriptions to be
* processed concurrently.
*/
$lockName = 'sonardesk:paddle:subscription:'
.$subscriptionID;
$this->withMysqlLock(
$lockName,
function () use (
$type,
$data,
$subscriptionID
): void {
$this->syncLockedPaddleSubscription(
$type,
$data,
$subscriptionID
);
}
);
}
private function syncLockedPaddleSubscription(
string $type,
array $data,
string $subscriptionID
): void {
$transactionID = $this->nullableString(
$data['transaction_id'] ?? null
);
$status = (string) ($data['status'] ?? '');
$items = is_array($data['items'] ?? null)
? $data['items']
: [];
$item = $this->firstRecurringItem($items);
$price = is_array($item['price'] ?? null)
? $item['price']
: [];
$priceID = (string) (
$price['id']
?? $item['price_id']
?? ''
);
$productID = (string) (
$price['product_id']
?? $item['product_id']
?? ''
);
/*
* The Paddle subscription ID is the authoritative identity for a
* recurring licence.
*
* lockForUpdate protects an existing licence while we mutate it.
* Creation of a missing licence is serialised by the advisory lock
* acquired by syncPaddleSubscription().
*/
$license = License::query()
->where('payment_provider', 'paddle')
->where(
'external_subscription_id',
$subscriptionID
)
->lockForUpdate()
->first();
if (! $license) {
$product = $this->mappedProduct(
'paddle',
$priceID,
$productID
);
$custom = is_array($data['custom_data'] ?? null)
? $data['custom_data']
: [];
$customerID = $this->nullableString(
$data['customer_id'] ?? null
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
[$license] = $this->issuer->issue(
$product,
$this->nullableString(
$custom['customer_name']
?? $custom['name']
?? $customer['name']
?? null
),
$this->nullableString(
$custom['customer_email']
?? $custom['email']
?? $customer['email']
?? null
),
'paddle',
$transactionID,
$customerID,
true,
);
/*
* LicenseIssuer may return an existing licence if the
* transaction ID has already been seen.
*
* Re-read it with a row lock before attaching the Paddle
* subscription.
*/
$license = License::query()
->whereKey($license->getKey())
->lockForUpdate()
->firstOrFail();
/*
* Do not attach this subscription to a licence already owned
* by a different subscription.
*/
if (
$license->external_subscription_id !== null
&& $license->external_subscription_id
!== $subscriptionID
) {
throw new RuntimeException(
'Paddle transaction resolved to a licence '
.'belonging to another subscription.'
);
}
$license->external_subscription_id =
$subscriptionID;
} elseif (
$priceID !== ''
&& $priceID !== $license->external_price_id
) {
/*
* Upgrades and downgrades modify the entitlement attached to
* the existing licence key rather than issuing another key.
*/
$product = $this->mappedProduct(
'paddle',
$priceID,
$productID
);
$license->license_product_id = $product->id;
$license->edition = $product->edition;
$license->activation_limit =
$product->activation_limit;
$license->max_major_version =
$product->max_major_version;
}
$periodEnd =
$data['current_billing_period']['ends_at']
?? null;
$scheduled = $data['scheduled_change'] ?? null;
$cancelAt = is_array($scheduled)
&& ($scheduled['action'] ?? null) === 'cancel'
? ($scheduled['effective_at'] ?? null)
: null;
$license->external_customer_id =
$this->nullableString(
$data['customer_id'] ?? null
)
?? $license->external_customer_id;
$this->backfillPaddleCustomer(
$license,
$data
);
/*
* Account provisioning is expected to be idempotent.
*/
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
if ($priceID !== '') {
$license->external_price_id = $priceID;
}
if (
$transactionID !== null
&& ! $license->external_order_id
) {
$license->external_order_id =
$transactionID;
}
if ($status !== '') {
$license->subscription_status = $status;
}
if ($periodEnd) {
$license->current_period_ends_at =
Carbon::parse($periodEnd);
}
$license->cancel_scheduled_at = $cancelAt
? Carbon::parse($cancelAt)
: null;
/*
* Subscription entitlement.
*/
if (in_array(
$status,
['active', 'trialing'],
true
)) {
$license->revoked_at = null;
$license->revoke_reason = null;
$license->expires_at = $periodEnd
? Carbon::parse($periodEnd)
: null;
} elseif ($status === 'past_due') {
/*
* A failed renewal does not immediately destroy the
* entitlement. Preserve Paddle's current billing period when
* available and otherwise provide the existing grace period.
*/
$license->expires_at = $periodEnd
? Carbon::parse($periodEnd)
: (
$license->expires_at
?? now()->addDays(7)
);
} elseif (in_array(
$status,
['canceled', 'paused'],
true
)) {
$license->expires_at = now();
$license->revoked_at = now();
$license->revoke_reason =
$status === 'paused'
? 'Subscription paused'
: 'Subscription canceled';
}
$license->save();
}
private function syncPaddleTransaction(
array $data
): void {
$subscriptionID = $this->nullableString(
$data['subscription_id'] ?? null
);
$transactionID = $this->nullableString(
$data['id'] ?? null
);
/*
* A recurring transaction belongs to its subscription.
*
* Serialise this operation using the same lock as subscription
* provisioning. This prevents transaction.completed from updating
* the licence while a subscription event is still creating or
* modifying it.
*/
if ($subscriptionID !== null) {
$lockName = 'sonardesk:paddle:subscription:'
.$subscriptionID;
$this->withMysqlLock(
$lockName,
function () use (
$subscriptionID,
$transactionID
): void {
if ($transactionID === null) {
return;
}
$license = License::query()
->where(
'payment_provider',
'paddle'
)
->where(
'external_subscription_id',
$subscriptionID
)
->lockForUpdate()
->first();
/*
* transaction.completed may legitimately arrive before
* subscription.created. In that case there is nothing
* to update yet. The subscription event will create the
* licence and Paddle's transaction_id can populate the
* order ID.
*/
if (! $license) {
return;
}
if (! $license->external_order_id) {
$license->external_order_id =
$transactionID;
$license->save();
}
}
);
return;
}
/*
* No subscription ID means this may be a one-time purchase.
*/
$items = is_array($data['items'] ?? null)
? $data['items']
: [];
$item = $items[0] ?? [];
$price = is_array($item['price'] ?? null)
? $item['price']
: [];
$priceID = (string) (
$price['id']
?? $item['price_id']
?? ''
);
$productID = (string) (
$price['product_id']
?? $item['product_id']
?? ''
);
$product = $this->mappedProduct(
'paddle',
$priceID,
$productID
);
$custom = is_array($data['custom_data'] ?? null)
? $data['custom_data']
: [];
$customerID = $this->nullableString(
$data['customer_id'] ?? null
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
[$license] = $this->issuer->issue(
$product,
$this->nullableString(
$custom['customer_name']
?? $custom['name']
?? $customer['name']
?? null
),
$this->nullableString(
$custom['customer_email']
?? $custom['email']
?? $customer['email']
?? null
),
'paddle',
$transactionID,
$customerID,
true,
);
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
}
private function provisionLemonSqueezy(
array $event
): void {
$type = (string) (
$event['meta']['event_name']
?? ''
);
$attributes = is_array(
$event['data']['attributes'] ?? null
)
? $event['data']['attributes']
: [];
$order = (string) (
$event['data']['id']
?? $attributes['order_id']
?? ''
);
if (in_array(
$type,
[
'order_refunded',
'subscription_expired',
],
true
)) {
License::query()
->where(
'payment_provider',
'lemon-squeezy'
)
->where('external_order_id', $order)
->whereNull('revoked_at')
->update([
'revoked_at' => now(),
'revoke_reason' =>
'Payment refunded or subscription expired',
]);
return;
}
if (! in_array(
$type,
[
'order_created',
'subscription_created',
],
true
)) {
return;
}
$product = $this->mappedProduct(
'lemon-squeezy',
(string) (
$attributes['variant_id']
?? ''
),
(string) (
$attributes['product_id']
?? ''
),
);
[$license] = $this->issuer->issue(
$product,
$this->nullableString(
$attributes['user_name'] ?? null
),
$this->nullableString(
$attributes['user_email'] ?? null
),
'lemon-squeezy',
$order,
$this->nullableString(
$attributes['customer_id'] ?? null
),
true,
);
$this->accounts->ensure(
$license->customer_email,
$license->customer_name
);
}
private function backfillPaddleCustomer(
License $license,
array $data
): void {
$custom = is_array(
$data['custom_data'] ?? null
)
? $data['custom_data']
: [];
$customerID = $this->nullableString(
$data['customer_id']
?? $license->external_customer_id
);
$customer = $this->paddle->customer(
$customerID
) ?? [];
$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) {
$email = strtolower($email);
$license->customer_email = $email;
LicenseDeliveryOutbox::query()
->where(
'license_id',
$license->id
)
->whereNull('delivered_at')
->update([
'recipient_email' => $email,
]);
}
}
private function mappedProduct(
string $provider,
string $priceID,
string $productID
): LicenseProduct {
/*
* Price IDs are authoritative because multiple billing intervals
* can belong to the same Paddle product.
*/
if ($priceID !== '') {
$product = LicenseProduct::query()
->where(
'payment_provider',
$provider
)
->where(
'external_price_id',
$priceID
)
->where('active', true)
->first();
} else {
$product = LicenseProduct::query()
->where(
'payment_provider',
$provider
)
->where(
'external_product_id',
$productID
)
->where('active', true)
->first();
}
if (! $product) {
throw new RuntimeException(
"No active licence product maps to {$provider} "
."price {$priceID} / product {$productID}."
);
}
return $product;
}
private function firstRecurringItem(
array $items
): array {
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
if (($item['recurring'] ?? true) === true) {
return $item;
}
}
$first = $items[0] ?? [];
return is_array($first)
? $first
: [];
}
private function nullableString(
mixed $value
): ?string {
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === ''
? null
: $value;
}
/**
* Execute a callback while holding a MySQL advisory lock.
*
* The lock is connection-scoped, so it must always be explicitly
* released in finally. A short timeout is sufficient because Paddle
* will retry failed webhook deliveries and the controller also retries
* database deadlocks.
*/
private function withMysqlLock(
string $name,
callable $callback
): mixed {
/*
* MySQL advisory lock names have a finite length. Hashing also
* 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(
'SELECT GET_LOCK(?, ?) AS acquired',
[
$lockName,
10,
]
);
$acquired = (int) (
$row->acquired
?? 0
);
if ($acquired !== 1) {
throw new RuntimeException(
'Could not acquire Paddle subscription lock.'
);
}
try {
return $callback();
} finally {
try {
DB::selectOne(
'SELECT RELEASE_LOCK(?) AS released',
[$lockName]
);
} catch (Throwable $e) {
/*
* Do not hide the original processing exception merely
* because releasing an advisory lock failed. MySQL also
* releases connection-owned locks when the connection
* closes.
*/
report($e);
}
}
}
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Paddle\Support;
class ModuleInfo
{
protected static string $moduleName = 'Paddle';
/**
* defines the Module Name
* @return string
*/
public static function name(): string
{
return static::$moduleName;
}
/**
* define the lowercase version
* @return string
*/
public static function nameLower(): string
{
return strtolower(static::$moduleName);
}
}
@@ -0,0 +1,15 @@
{
"name": "mpba/paddle",
"description": "SonarDesk Paddle module",
"type": "library",
"autoload": {
"psr-4": {
"Modules\\Paddle\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Paddle\\Tests\\": "Tests/"
}
}
}
@@ -0,0 +1,16 @@
{
"name": "Paddle",
"alias": "paddle",
"version": "1.0.0",
"description": "SonarDesk Paddle module",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Paddle\\Providers\\PaddleServiceProvider"
],
"aliases": {},
"files": [],
"requires": [
"Licensing"
]
}
@@ -0,0 +1,17 @@
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"cross-env": "^7.0",
"laravel-mix": "^5.0.1",
"laravel-mix-merge-manifest": "^0.1.2"
}
}
@@ -0,0 +1,11 @@
const dotenvExpand = require('dotenv-expand');
dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/}));
const mix = require('laravel-mix');
mix.js(__dirname + '/Resources/assets/js/app.js', 'js/paddle.js')
.sass( __dirname + '/Resources/assets/sass/app.scss', 'css/paddle.css');
if (mix.inProduction()) {
mix.version();
}