SEO additions
This commit is contained in:
@@ -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'),
|
||||
];
|
||||
|
||||
+34
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+29
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Executable
+170
@@ -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;
|
||||
}
|
||||
}
|
||||
+108
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
+796
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user