From a32238e632d649abb8a97c23659f44e5f8637556 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 11:47:04 +0100 Subject: [PATCH 01/10] new stage code --- .../Commands/SonarDeskAccountsBackfill.php | 59 +++++++++++++++++++ .../Accounts/PurchasedAccountProvisioner.php | 53 +++++++++++++++++ app/Services/Payments/PurchaseProvisioner.php | 15 ++++- .../PaddleSubscriptionProvisioningTest.php | 27 +++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/SonarDeskAccountsBackfill.php create mode 100644 app/Services/Accounts/PurchasedAccountProvisioner.php diff --git a/app/Console/Commands/SonarDeskAccountsBackfill.php b/app/Console/Commands/SonarDeskAccountsBackfill.php new file mode 100644 index 0000000..ecd3464 --- /dev/null +++ b/app/Console/Commands/SonarDeskAccountsBackfill.php @@ -0,0 +1,59 @@ +whereNotNull('customer_email') + ->where('customer_email', '<>', '') + ->select(['customer_email', 'customer_name']) + ->orderBy('customer_email') + ->get() + ->unique(fn (License $license) => strtolower(trim((string) $license->customer_email))); + + $missing = 0; + $created = 0; + + foreach ($customers as $license) { + $email = strtolower(trim((string) $license->customer_email)); + if ($email === '') { + continue; + } + + if (User::query()->whereRaw('lower(email) = ?', [$email])->exists()) { + continue; + } + + $missing++; + if ($this->option('dry-run')) { + $this->line("MISSING {$email}"); + continue; + } + + if ($accounts->ensure($email, $license->customer_name, ! $this->option('no-email'))) { + $created++; + $this->info("CREATED {$email}"); + } + } + + if ($this->option('dry-run')) { + $this->info("{$missing} account(s) would be created."); + } else { + $this->info("{$created} account(s) created."); + } + + return self::SUCCESS; + } +} diff --git a/app/Services/Accounts/PurchasedAccountProvisioner.php b/app/Services/Accounts/PurchasedAccountProvisioner.php new file mode 100644 index 0000000..ba24577 --- /dev/null +++ b/app/Services/Accounts/PurchasedAccountProvisioner.php @@ -0,0 +1,53 @@ +whereRaw('lower(email) = ?', [$email])->first(); + if ($user) { + return $user; + } + + $user = User::create([ + 'name' => $this->displayName($name, $email), + 'email' => $email, + 'email_verified_at' => now(), + 'password' => Str::random(64), + 'is_admin' => false, + ]); + + if ($sendSetupLink) { + DB::afterCommit(static function () use ($email): void { + Password::sendResetLink(['email' => $email]); + }); + } + + return $user; + } + + private function displayName(?string $name, string $email): string + { + $name = trim((string) $name); + if ($name !== '') { + return mb_substr($name, 0, 120); + } + + $localPart = strstr($email, '@', true) ?: 'SonarDesk Customer'; + $localPart = trim(str_replace(['.', '_', '-'], ' ', $localPart)); + + return mb_substr($localPart !== '' ? ucwords($localPart) : 'SonarDesk Customer', 0, 120); + } +} diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index 0d828ab..d99f400 100755 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -5,13 +5,18 @@ namespace App\Services\Payments; use App\Models\License; use App\Models\LicenseProduct; use App\Models\LicenseDeliveryOutbox; +use App\Services\Accounts\PurchasedAccountProvisioner; use App\Services\Licensing\LicenseIssuer; use Illuminate\Support\Carbon; use RuntimeException; final class PurchaseProvisioner { - public function __construct(private LicenseIssuer $issuer, private PaddleClient $paddle) {} + public function __construct( + private LicenseIssuer $issuer, + private PaddleClient $paddle, + private PurchasedAccountProvisioner $accounts, + ) {} public function provision(string $provider, array $event): void { @@ -89,6 +94,7 @@ final class PurchaseProvisioner $this->nullableString($data['customer_id'] ?? null), true, ); + $this->accounts->ensure($license->customer_email, $license->customer_name); $license->external_subscription_id = $subscriptionID; } elseif ($priceID !== '' && $priceID !== $license->external_price_id) { // Upgrades/downgrades change the entitlement attached to the existing key. @@ -107,6 +113,7 @@ final class PurchaseProvisioner $license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id; $this->backfillPaddleCustomer($license, $data); + $this->accounts->ensure($license->customer_email, $license->customer_name); $license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id; $license->subscription_status = $status !== '' ? $status : $license->subscription_status; $license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at; @@ -155,7 +162,7 @@ final class PurchaseProvisioner $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)) ?? []; - $this->issuer->issue( + [$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), @@ -164,6 +171,7 @@ final class PurchaseProvisioner $this->nullableString($data['customer_id'] ?? null), true, ); + $this->accounts->ensure($license->customer_email, $license->customer_name); } private function provisionLemonSqueezy(array $event): void @@ -191,7 +199,7 @@ final class PurchaseProvisioner (string) ($attributes['product_id'] ?? ''), ); - $this->issuer->issue( + [$license] = $this->issuer->issue( $product, $this->nullableString($attributes['user_name'] ?? null), $this->nullableString($attributes['user_email'] ?? null), @@ -200,6 +208,7 @@ final class PurchaseProvisioner $this->nullableString($attributes['customer_id'] ?? null), true, ); + $this->accounts->ensure($license->customer_email, $license->customer_name); } private function backfillPaddleCustomer(License $license, array $data): void diff --git a/tests/Feature/PaddleSubscriptionProvisioningTest.php b/tests/Feature/PaddleSubscriptionProvisioningTest.php index 527216e..a2c1f07 100755 --- a/tests/Feature/PaddleSubscriptionProvisioningTest.php +++ b/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; use App\Models\License; use App\Models\LicenseProduct; +use App\Models\User; use App\Services\Payments\PurchaseProvisioner; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -102,4 +103,30 @@ class PaddleSubscriptionProvisioningTest extends TestCase $this->assertNotNull($license->revoked_at); $this->assertFalse($license->is_usable); } + + public function test_new_purchase_creates_a_portal_user_for_the_customer(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal' + )); + + $user = User::where('email', 'mark@example.com')->firstOrFail(); + $this->assertSame('Mark', $user->name); + $this->assertNotNull($user->email_verified_at); + } + + public function test_existing_customer_account_is_reused(): void + { + User::factory()->create(['name' => 'Existing Mark', 'email' => 'mark@example.com']); + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal' + )); + + $this->assertSame(1, User::where('email', 'mark@example.com')->count()); + $this->assertSame('Existing Mark', User::where('email', 'mark@example.com')->firstOrFail()->name); + } } From 15b8d9b75b6b181230359d62cbfd85befacbe9ef Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:19:35 +0100 Subject: [PATCH 02/10] new stage code --- app/Http/Controllers/Api/PaymentWebhookController.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php index e4c8036..faf0ce8 100755 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/Http/Controllers/Api/PaymentWebhookController.php @@ -60,8 +60,13 @@ final class PaymentWebhookController extends Controller }); } catch (\Throwable $e) { report($e); - - return response()->json(['message' => 'Webhook could not be processed.'], 500); + return response()->json([ + 'message' => 'Webhook could not be processed.', + 'exception' => get_class($e), + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ], 500); } // Delivery is intentionally outside the webhook transaction. A temporary mail From 5b3413549d1a237a2e7d4d7dfa6f751495e99822 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:38:04 +0100 Subject: [PATCH 03/10] update to provisioner code --- .../Accounts/PurchasedAccountProvisioner.php | 79 ++++++++++++++++--- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/app/Services/Accounts/PurchasedAccountProvisioner.php b/app/Services/Accounts/PurchasedAccountProvisioner.php index ba24577..96ec7d4 100644 --- a/app/Services/Accounts/PurchasedAccountProvisioner.php +++ b/app/Services/Accounts/PurchasedAccountProvisioner.php @@ -3,33 +3,74 @@ namespace App\Services\Accounts; use App\Models\User; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; +use RuntimeException; final class PurchasedAccountProvisioner { - public function ensure(?string $email, ?string $name = null, bool $sendSetupLink = true): ?User - { + public function ensure( + ?string $email, + ?string $name = null, + bool $sendSetupLink = true + ): ?User { $email = strtolower(trim((string) $email)); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { return null; } - $user = User::query()->whereRaw('lower(email) = ?', [$email])->first(); + /* + * Fast path for the normal case. + * + * Paddle can deliver several related webhook events concurrently, so + * this lookup alone cannot guarantee that another request will not + * create the user between this SELECT and our INSERT. + */ + $user = $this->findByEmail($email); + if ($user) { return $user; } - $user = User::create([ - 'name' => $this->displayName($name, $email), - 'email' => $email, - 'email_verified_at' => now(), - 'password' => Str::random(64), - 'is_admin' => false, - ]); + $created = false; - if ($sendSetupLink) { + try { + $user = User::create([ + 'name' => $this->displayName($name, $email), + 'email' => $email, + 'email_verified_at' => now(), + 'password' => Str::random(64), + 'is_admin' => false, + ]); + + $created = true; + } catch (UniqueConstraintViolationException $exception) { + /* + * Another webhook may have created this account after our SELECT + * but before our INSERT. In that case the unique email constraint + * correctly rejects our INSERT; simply reuse the account that won + * the race. + */ + $user = $this->findByEmail($email); + + if (! $user) { + /* + * The unique violation was not caused by the email account + * appearing concurrently, so preserve the original failure. + */ + throw $exception; + } + } + + /* + * Only the request that actually created the account should send the + * account setup/reset link. Concurrent Paddle events must not result + * in several setup emails being sent to the customer. + */ + if ($created && $sendSetupLink) { DB::afterCommit(static function () use ($email): void { Password::sendResetLink(['email' => $email]); }); @@ -38,9 +79,17 @@ final class PurchasedAccountProvisioner return $user; } + private function findByEmail(string $email): ?User + { + return User::query() + ->whereRaw('LOWER(email) = ?', [$email]) + ->first(); + } + private function displayName(?string $name, string $email): string { $name = trim((string) $name); + if ($name !== '') { return mb_substr($name, 0, 120); } @@ -48,6 +97,12 @@ final class PurchasedAccountProvisioner $localPart = strstr($email, '@', true) ?: 'SonarDesk Customer'; $localPart = trim(str_replace(['.', '_', '-'], ' ', $localPart)); - return mb_substr($localPart !== '' ? ucwords($localPart) : 'SonarDesk Customer', 0, 120); + return mb_substr( + $localPart !== '' + ? ucwords($localPart) + : 'SonarDesk Customer', + 0, + 120 + ); } } From 20dbb094330db8d58650ed09bcace0e5916a15d6 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:40:29 +0100 Subject: [PATCH 04/10] update to provisioner code --- app/Services/Accounts/PurchasedAccountProvisioner.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Services/Accounts/PurchasedAccountProvisioner.php b/app/Services/Accounts/PurchasedAccountProvisioner.php index 96ec7d4..2498f89 100644 --- a/app/Services/Accounts/PurchasedAccountProvisioner.php +++ b/app/Services/Accounts/PurchasedAccountProvisioner.php @@ -28,6 +28,7 @@ final class PurchasedAccountProvisioner * Paddle can deliver several related webhook events concurrently, so * this lookup alone cannot guarantee that another request will not * create the user between this SELECT and our INSERT. + * enure this is pushed */ $user = $this->findByEmail($email); From 2d9d07f722313d266f8f1ea07402c0e7b39579b6 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:49:17 +0100 Subject: [PATCH 05/10] update to provisioner code --- app/Services/Payments/PurchaseProvisioner.php | 456 ++++++++++++++---- 1 file changed, 374 insertions(+), 82 deletions(-) diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index d99f400..0eeca47 100755 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -3,8 +3,8 @@ namespace App\Services\Payments; use App\Models\License; -use App\Models\LicenseProduct; use App\Models\LicenseDeliveryOutbox; +use App\Models\LicenseProduct; use App\Services\Accounts\PurchasedAccountProvisioner; use App\Services\Licensing\LicenseIssuer; use Illuminate\Support\Carbon; @@ -54,12 +54,16 @@ final class PurchaseProvisioner if ($type === 'transaction.refunded') { $transactionID = (string) ($data['id'] ?? ''); + if ($transactionID !== '') { License::where('payment_provider', 'paddle') ->where('external_order_id', $transactionID) ->whereNull('external_subscription_id') ->whereNull('revoked_at') - ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']); + ->update([ + 'revoked_at' => now(), + 'revoke_reason' => 'Payment refunded', + ]); } } } @@ -67,38 +71,119 @@ final class PurchaseProvisioner private function syncPaddleSubscription(string $type, array $data): void { $subscriptionID = (string) ($data['id'] ?? ''); + if ($subscriptionID === '') { - throw new RuntimeException('Paddle subscription webhook has no subscription ID.'); + throw new RuntimeException( + 'Paddle subscription webhook has no subscription ID.' + ); } - $status = (string) ($data['status'] ?? ''); - $item = $this->firstRecurringItem($data['items'] ?? []); - $price = $item['price'] ?? []; - $priceID = (string) ($price['id'] ?? $item['price_id'] ?? ''); - $productID = (string) ($price['product_id'] ?? $item['product_id'] ?? ''); + /* + * Paddle webhook delivery order is not guaranteed. + * + * A transaction.completed event can arrive before the corresponding + * subscription event has created our local licence. Capture the + * transaction ID from the subscription payload as well whenever + * Paddle provides one. + */ + $transactionID = $this->nullableString( + $data['transaction_id'] ?? null + ); + $status = (string) ($data['status'] ?? ''); + + $item = $this->firstRecurringItem( + is_array($data['items'] ?? null) + ? $data['items'] + : [] + ); + + $price = is_array($item['price'] ?? null) + ? $item['price'] + : []; + + $priceID = (string) ( + $price['id'] + ?? $item['price_id'] + ?? '' + ); + + $productID = (string) ( + $price['product_id'] + ?? $item['product_id'] + ?? '' + ); + + /* + * Subscription ID is the authoritative identity for recurring + * Paddle licences. + */ $license = License::where('payment_provider', 'paddle') ->where('external_subscription_id', $subscriptionID) ->first(); if (! $license) { - $product = $this->mappedProduct('paddle', $priceID, $productID); - $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; - $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)) ?? []; + $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) ?? []; + + /* + * Use the transaction ID as the initial external order ID where + * available. If it is absent, transaction.completed can backfill + * it later. + */ [$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), + $this->nullableString( + $custom['customer_name'] + ?? $custom['name'] + ?? $customer['name'] + ?? null + ), + $this->nullableString( + $custom['customer_email'] + ?? $custom['email'] + ?? $customer['email'] + ?? null + ), 'paddle', - $this->nullableString($data['transaction_id'] ?? null), - $this->nullableString($data['customer_id'] ?? null), + $transactionID, + $customerID, true, ); - $this->accounts->ensure($license->customer_email, $license->customer_name); + + $this->accounts->ensure( + $license->customer_email, + $license->customer_name + ); + $license->external_subscription_id = $subscriptionID; - } elseif ($priceID !== '' && $priceID !== $license->external_price_id) { - // Upgrades/downgrades change the entitlement attached to the existing key. - $product = $this->mappedProduct('paddle', $priceID, $productID); + } elseif ( + $priceID !== '' + && $priceID !== $license->external_price_id + ) { + /* + * Upgrades/downgrades change the entitlement attached to the + * existing licence key. + */ + $product = $this->mappedProduct( + 'paddle', + $priceID, + $productID + ); + $license->license_product_id = $product->id; $license->edition = $product->edition; $license->activation_limit = $product->activation_limit; @@ -106,32 +191,78 @@ final class PurchaseProvisioner } $periodEnd = $data['current_billing_period']['ends_at'] ?? null; + $scheduled = $data['scheduled_change'] ?? null; - $cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel' + + $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); - $this->accounts->ensure($license->customer_email, $license->customer_name); - $license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id; - $license->subscription_status = $status !== '' ? $status : $license->subscription_status; - $license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at; - $license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null; + $license->external_customer_id = + $this->nullableString($data['customer_id'] ?? null) + ?? $license->external_customer_id; + $this->backfillPaddleCustomer($license, $data); + + /* + * ensure() must be idempotent. Existing users must simply be + * returned rather than created again. + */ + $this->accounts->ensure( + $license->customer_email, + $license->customer_name + ); + + if ($priceID !== '') { + $license->external_price_id = $priceID; + } + + if ($transactionID && ! $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; + + /* + * Entitlement handling. + */ if (in_array($status, ['active', 'trialing'], true)) { $license->revoked_at = null; $license->revoke_reason = null; - // Trialing/active subscriptions remain valid through the current period. - $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null; + + /* + * Active/trialing subscriptions remain valid through the + * current Paddle billing period. + */ + $license->expires_at = $periodEnd + ? Carbon::parse($periodEnd) + : null; } elseif ($status === 'past_due') { - // Do not instantly kill a licence on the first failed renewal. Preserve the - // period Paddle says is current; a later canceled event ends entitlement. - $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ($license->expires_at ?? now()->addDays(7)); + /* + * Do not revoke immediately after the first failed renewal. + * Preserve Paddle's current billing period where possible. + */ + $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->revoke_reason = $status === 'paused' + ? 'Subscription paused' + : 'Subscription canceled'; } $license->save(); @@ -139,57 +270,152 @@ final class PurchaseProvisioner private function syncPaddleTransaction(array $data): void { - $subscriptionID = $this->nullableString($data['subscription_id'] ?? null); - $transactionID = (string) ($data['id'] ?? ''); + $subscriptionID = $this->nullableString( + $data['subscription_id'] ?? null + ); - // Recurring purchases are provisioned from subscription events because those - // events contain the authoritative trial/status/current-period state. + $transactionID = $this->nullableString( + $data['id'] ?? null + ); + + /* + * Recurring transactions belong to an existing subscription. + * + * The subscription event normally creates the licence. If the + * transaction event arrives afterwards we simply backfill the + * transaction/order ID. + * + * If transaction.completed arrives first, it does nothing here. + * The subsequent subscription event will obtain transaction_id + * directly from its payload where Paddle supplies it. + */ if ($subscriptionID) { - License::where('payment_provider', 'paddle') - ->where('external_subscription_id', $subscriptionID) - ->whereNull('external_order_id') - ->update(['external_order_id' => $transactionID ?: null]); + if ($transactionID) { + License::where('payment_provider', 'paddle') + ->where( + 'external_subscription_id', + $subscriptionID + ) + ->whereNull('external_order_id') + ->update([ + 'external_order_id' => $transactionID, + ]); + } return; } - // Keep support for any future one-time Paddle licence product. - $item = $data['items'][0] ?? []; - $price = $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'] : []; - $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)) ?? []; + /* + * 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), + $this->nullableString( + $custom['customer_name'] + ?? $custom['name'] + ?? $customer['name'] + ?? null + ), + $this->nullableString( + $custom['customer_email'] + ?? $custom['email'] + ?? $customer['email'] + ?? null + ), 'paddle', $transactionID, - $this->nullableString($data['customer_id'] ?? null), + $customerID, true, ); - $this->accounts->ensure($license->customer_email, $license->customer_name); + + $this->accounts->ensure( + $license->customer_email, + $license->customer_name + ); } private function provisionLemonSqueezy(array $event): void { - $type = (string) ($event['meta']['event_name'] ?? ''); - $attributes = $event['data']['attributes'] ?? []; - $order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? ''); + $type = (string) ( + $event['meta']['event_name'] + ?? '' + ); - if (in_array($type, ['order_refunded', 'subscription_expired'], true)) { - License::where('payment_provider', 'lemon-squeezy') + $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::where( + 'payment_provider', + 'lemon-squeezy' + ) ->where('external_order_id', $order) ->whereNull('revoked_at') - ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']); + ->update([ + 'revoked_at' => now(), + 'revoke_reason' => + 'Payment refunded or subscription expired', + ]); return; } - if (! in_array($type, ['order_created', 'subscription_created'], true)) { + if (! in_array( + $type, + ['order_created', 'subscription_created'], + true + )) { return; } @@ -201,53 +427,110 @@ final class PurchaseProvisioner [$license] = $this->issuer->issue( $product, - $this->nullableString($attributes['user_name'] ?? null), - $this->nullableString($attributes['user_email'] ?? null), + $this->nullableString( + $attributes['user_name'] ?? null + ), + $this->nullableString( + $attributes['user_email'] ?? null + ), 'lemon-squeezy', $order, - $this->nullableString($attributes['customer_id'] ?? null), + $this->nullableString( + $attributes['customer_id'] ?? null + ), true, ); - $this->accounts->ensure($license->customer_email, $license->customer_name); + + $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'] : []; - $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? $license->external_customer_id)) ?? []; + private function backfillPaddleCustomer( + License $license, + array $data + ): void { + $custom = is_array($data['custom_data'] ?? null) + ? $data['custom_data'] + : []; - $name = $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null); - $email = $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null); + $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) { - $license->customer_email = strtolower($email); - LicenseDeliveryOutbox::where('license_id', $license->id) + $email = strtolower($email); + + $license->customer_email = $email; + + LicenseDeliveryOutbox::where( + 'license_id', + $license->id + ) ->whereNull('delivered_at') - ->update(['recipient_email' => strtolower($email)]); + ->update([ + 'recipient_email' => $email, + ]); } } - private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct - { - // Price IDs are the authoritative mapping because monthly and annual prices - // intentionally share the same Paddle product ID. + private function mappedProduct( + string $provider, + string $priceID, + string $productID + ): LicenseProduct { + /* + * Price IDs are authoritative. + * + * Monthly and annual Paddle prices can intentionally belong to the + * same Paddle product, therefore product ID alone cannot reliably + * identify the licence plan. + */ if ($priceID !== '') { - $product = LicenseProduct::where('payment_provider', $provider) + $product = LicenseProduct::where( + 'payment_provider', + $provider + ) ->where('external_price_id', $priceID) ->where('active', true) ->first(); } else { - $product = LicenseProduct::where('payment_provider', $provider) + $product = LicenseProduct::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}."); + throw new RuntimeException( + "No active licence product maps to {$provider} " + ."price {$priceID} / product {$productID}." + ); } return $product; @@ -256,12 +539,18 @@ final class PurchaseProvisioner private function firstRecurringItem(array $items): array { foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + if (($item['recurring'] ?? true) === true) { return $item; } } - return $items[0] ?? []; + $first = $items[0] ?? []; + + return is_array($first) ? $first : []; } private function nullableString(mixed $value): ?string @@ -269,8 +558,11 @@ final class PurchaseProvisioner if (! is_scalar($value)) { return null; } + $value = trim((string) $value); - return $value === '' ? null : $value; + return $value === '' + ? null + : $value; } } From 6850a00e767f90916f91dc0ca50564a481c7c73c Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:52:29 +0100 Subject: [PATCH 06/10] update to provisioner code --- .../Api/PaymentWebhookController.php | 195 ++++++++++++++---- 1 file changed, 160 insertions(+), 35 deletions(-) diff --git a/app/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php index faf0ce8..0e59c89 100755 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/Http/Controllers/Api/PaymentWebhookController.php @@ -14,52 +14,171 @@ use Illuminate\Support\Facades\DB; final class PaymentWebhookController extends Controller { - public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse - { + public function __invoke( + string $provider, + Request $request, + PurchaseProvisioner $provisioner, + LicenseDeliveryService $delivery + ): JsonResponse { $raw = (string) $request->getContent(); + $verifier = match ($provider) { - 'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null + 'paddle' => app(PaddleWebhookVerifier::class), + 'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class), + default => null, }; + if (! $verifier) { - return response()->json(['message' => 'Unknown webhook provider.'], 404); + return response()->json([ + 'message' => 'Unknown webhook provider.', + ], 404); } + try { $verifier->verify($request, $raw); } catch (\Throwable $e) { report($e); - return response()->json(['message' => 'Invalid webhook signature.'], 400); - } - try { - $payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); - } catch (\Throwable) { - return response()->json(['message' => 'Invalid JSON.'], 400); - } - $externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? '')); - $eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown')); - if ($externalID === '') { - return response()->json(['message' => 'Webhook event has no stable identifier.'], 400); + return response()->json([ + 'message' => 'Invalid webhook signature.', + ], 400); } try { - DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) { - $event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]); - if ($event->processed_at) { - return; - } - if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) { - throw new \RuntimeException('Webhook ID was reused with a different payload.'); - } - try { - $provisioner->provision($provider, $payload); - $event->update(['processed_at' => now(), 'processing_error' => null]); - } catch (\Throwable $e) { - $event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]); - throw $e; - } - }); + $payload = json_decode( + $raw, + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Invalid JSON.', + ], 400); + } + + $externalID = (string) ( + $provider === 'paddle' + ? ($payload['event_id'] ?? '') + : ($payload['meta']['event_name'] ?? '') + .':' + .($payload['data']['id'] ?? '') + ); + + $eventType = (string) ( + $provider === 'paddle' + ? ($payload['event_type'] ?? 'unknown') + : ($payload['meta']['event_name'] ?? 'unknown') + ); + + if ($externalID === '') { + return response()->json([ + 'message' => 'Webhook event has no stable identifier.', + ], 400); + } + + try { + /* + * Paddle can deliver several related webhook events at almost + * exactly the same time. + * + * Laravel will automatically retry the entire transaction when + * MySQL reports a deadlock / serialization failure. + * + * Five attempts gives short-lived webhook contention time to + * resolve without weakening any database constraints. + */ + DB::transaction( + function () use ( + $provider, + $externalID, + $eventType, + $payload, + $raw, + $provisioner + ): void { + /* + * firstOrCreate protects against Paddle retrying the exact + * same event ID. + * + * lockForUpdate prevents two workers handling the same + * webhook event concurrently after the row exists. + */ + LicenseWebhookEvent::firstOrCreate( + [ + 'provider' => $provider, + 'external_event_id' => $externalID, + ], + [ + 'event_type' => $eventType, + 'payload_sha256' => hash('sha256', $raw), + 'payload' => $payload, + ] + ); + + $event = LicenseWebhookEvent::query() + ->where('provider', $provider) + ->where('external_event_id', $externalID) + ->lockForUpdate() + ->firstOrFail(); + + /* + * Already successfully processed. + * + * Returning here makes duplicate delivery of the exact + * same Paddle event idempotent. + */ + if ($event->processed_at !== null) { + return; + } + + /* + * An external event ID must never be reused with different + * content. + */ + $incomingHash = hash('sha256', $raw); + + if (! hash_equals( + (string) $event->payload_sha256, + $incomingHash + )) { + throw new \RuntimeException( + 'Webhook ID was reused with a different payload.' + ); + } + + try { + $provisioner->provision( + $provider, + $payload + ); + + $event->update([ + 'processed_at' => now(), + 'processing_error' => null, + ]); + } catch (\Throwable $e) { + /* + * This update is part of the same transaction. + * + * If processing fails and the transaction rolls back, + * Paddle can retry the webhook normally. + */ + $event->update([ + 'processing_error' => mb_substr( + $e->getMessage(), + 0, + 4000 + ), + ]); + + throw $e; + } + }, + 5 + ); } catch (\Throwable $e) { report($e); + return response()->json([ 'message' => 'Webhook could not be processed.', 'exception' => get_class($e), @@ -69,15 +188,21 @@ final class PaymentWebhookController extends Controller ], 500); } - // Delivery is intentionally outside the webhook transaction. A temporary mail - // outage must not roll back an otherwise valid Paddle event. Pending outbox - // rows remain available for portal resend or a later webhook delivery attempt. + /* + * Delivery is intentionally outside the webhook transaction. + * + * A temporary mail outage must not roll back an otherwise valid + * payment event. Pending outbox rows remain available for portal + * resend or a later webhook delivery attempt. + */ try { $delivery->deliverPending(10); } catch (\Throwable $e) { report($e); } - return response()->json(['ok' => true]); + return response()->json([ + 'ok' => true, + ]); } } From 58eec7da9a172b8b4bed9a1cdbeedde45da3f76e Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:55:32 +0100 Subject: [PATCH 07/10] update to provisioner code --- .../Api/PaymentWebhookController.php | 92 ++-- app/Services/Payments/PurchaseProvisioner.php | 458 +++++++++++++----- 2 files changed, 378 insertions(+), 172 deletions(-) diff --git a/app/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php index 0e59c89..6a5146f 100755 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/Http/Controllers/Api/PaymentWebhookController.php @@ -11,6 +11,8 @@ use App\Services\Payments\PurchaseProvisioner; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use RuntimeException; +use Throwable; final class PaymentWebhookController extends Controller { @@ -36,7 +38,7 @@ final class PaymentWebhookController extends Controller try { $verifier->verify($request, $raw); - } catch (\Throwable $e) { + } catch (Throwable $e) { report($e); return response()->json([ @@ -50,7 +52,7 @@ final class PaymentWebhookController extends Controller true, flags: JSON_THROW_ON_ERROR ); - } catch (\Throwable) { + } catch (Throwable) { return response()->json([ 'message' => 'Invalid JSON.', ], 400); @@ -76,16 +78,16 @@ final class PaymentWebhookController extends Controller ], 400); } + $payloadHash = hash('sha256', $raw); + try { /* - * Paddle can deliver several related webhook events at almost - * exactly the same time. + * Paddle can deliver several related events concurrently. * - * Laravel will automatically retry the entire transaction when - * MySQL reports a deadlock / serialization failure. - * - * Five attempts gives short-lived webhook contention time to - * resolve without weakening any database constraints. + * Laravel retries this complete transaction when MySQL reports + * a deadlock. Five attempts gives short-lived lock contention + * enough opportunity to resolve without acknowledging an event + * that was not actually processed. */ DB::transaction( function () use ( @@ -93,15 +95,14 @@ final class PaymentWebhookController extends Controller $externalID, $eventType, $payload, - $raw, + $payloadHash, $provisioner ): void { /* - * firstOrCreate protects against Paddle retrying the exact - * same event ID. + * Insert the event if this is its first delivery. * - * lockForUpdate prevents two workers handling the same - * webhook event concurrently after the row exists. + * Once present, lock the row so duplicate deliveries of + * the same event cannot provision concurrently. */ LicenseWebhookEvent::firstOrCreate( [ @@ -110,7 +111,7 @@ final class PaymentWebhookController extends Controller ], [ 'event_type' => $eventType, - 'payload_sha256' => hash('sha256', $raw), + 'payload_sha256' => $payloadHash, 'payload' => $payload, ] ); @@ -121,27 +122,15 @@ final class PaymentWebhookController extends Controller ->lockForUpdate() ->firstOrFail(); - /* - * Already successfully processed. - * - * Returning here makes duplicate delivery of the exact - * same Paddle event idempotent. - */ - if ($event->processed_at !== null) { + if ($event->processed_at) { return; } - /* - * An external event ID must never be reused with different - * content. - */ - $incomingHash = hash('sha256', $raw); - if (! hash_equals( (string) $event->payload_sha256, - $incomingHash + $payloadHash )) { - throw new \RuntimeException( + throw new RuntimeException( 'Webhook ID was reused with a different payload.' ); } @@ -152,31 +141,30 @@ final class PaymentWebhookController extends Controller $payload ); - $event->update([ - 'processed_at' => now(), - 'processing_error' => null, - ]); - } catch (\Throwable $e) { + $event->processed_at = now(); + $event->processing_error = null; + $event->save(); + } catch (Throwable $e) { /* - * This update is part of the same transaction. - * - * If processing fails and the transaction rolls back, - * Paddle can retry the webhook normally. + * This assignment is useful when the surrounding + * transaction eventually succeeds. If processing + * throws, the transaction itself is rolled back and + * Paddle will retry the event. */ - $event->update([ - 'processing_error' => mb_substr( - $e->getMessage(), - 0, - 4000 - ), - ]); + $event->processing_error = mb_substr( + $e->getMessage(), + 0, + 4000 + ); + + $event->save(); throw $e; } }, 5 ); - } catch (\Throwable $e) { + } catch (Throwable $e) { report($e); return response()->json([ @@ -189,15 +177,15 @@ final class PaymentWebhookController extends Controller } /* - * Delivery is intentionally outside the webhook transaction. + * Delivery deliberately happens outside the webhook transaction. * - * A temporary mail outage must not roll back an otherwise valid - * payment event. Pending outbox rows remain available for portal - * resend or a later webhook delivery attempt. + * A temporary SMTP failure must not undo a successfully processed + * payment event. The outbox retains anything that still needs + * delivery. */ try { $delivery->deliverPending(10); - } catch (\Throwable $e) { + } catch (Throwable $e) { report($e); } diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index 0eeca47..9217590 100755 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -8,7 +8,9 @@ use App\Models\LicenseProduct; use App\Services\Accounts\PurchasedAccountProvisioner; use App\Services\Licensing\LicenseIssuer; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use RuntimeException; +use Throwable; final class PurchaseProvisioner { @@ -32,16 +34,24 @@ final class PurchaseProvisioner return; } - throw new RuntimeException('Unsupported payment provider.'); + throw new RuntimeException( + 'Unsupported payment provider.' + ); } private function provisionPaddle(array $event): void { $type = (string) ($event['event_type'] ?? ''); - $data = $event['data'] ?? []; + + $data = is_array($event['data'] ?? null) + ? $event['data'] + : []; if (str_starts_with($type, 'subscription.')) { - $this->syncPaddleSubscription($type, $data); + $this->syncPaddleSubscription( + $type, + $data + ); return; } @@ -53,11 +63,17 @@ final class PurchaseProvisioner } if ($type === 'transaction.refunded') { - $transactionID = (string) ($data['id'] ?? ''); + $transactionID = $this->nullableString( + $data['id'] ?? null + ); - if ($transactionID !== '') { - License::where('payment_provider', 'paddle') - ->where('external_order_id', $transactionID) + if ($transactionID !== null) { + License::query() + ->where('payment_provider', 'paddle') + ->where( + 'external_order_id', + $transactionID + ) ->whereNull('external_subscription_id') ->whereNull('revoked_at') ->update([ @@ -68,35 +84,67 @@ final class PurchaseProvisioner } } - private function syncPaddleSubscription(string $type, array $data): void - { - $subscriptionID = (string) ($data['id'] ?? ''); + private function syncPaddleSubscription( + string $type, + array $data + ): void { + $subscriptionID = $this->nullableString( + $data['id'] ?? null + ); - if ($subscriptionID === '') { + if ($subscriptionID === null) { throw new RuntimeException( 'Paddle subscription webhook has no subscription ID.' ); } /* - * Paddle webhook delivery order is not guaranteed. + * Multiple Paddle events for the same subscription can arrive + * simultaneously. * - * A transaction.completed event can arrive before the corresponding - * subscription event has created our local licence. Capture the - * transaction ID from the subscription payload as well whenever - * Paddle provides one. + * 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'] ?? ''); - $item = $this->firstRecurringItem( - is_array($data['items'] ?? null) - ? $data['items'] - : [] - ); + $items = is_array($data['items'] ?? null) + ? $data['items'] + : []; + + $item = $this->firstRecurringItem($items); $price = is_array($item['price'] ?? null) ? $item['price'] @@ -115,11 +163,20 @@ final class PurchaseProvisioner ); /* - * Subscription ID is the authoritative identity for recurring - * Paddle licences. + * 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::where('payment_provider', 'paddle') - ->where('external_subscription_id', $subscriptionID) + $license = License::query() + ->where('payment_provider', 'paddle') + ->where( + 'external_subscription_id', + $subscriptionID + ) + ->lockForUpdate() ->first(); if (! $license) { @@ -137,13 +194,10 @@ final class PurchaseProvisioner $data['customer_id'] ?? null ); - $customer = $this->paddle->customer($customerID) ?? []; + $customer = $this->paddle->customer( + $customerID + ) ?? []; - /* - * Use the transaction ID as the initial external order ID where - * available. If it is absent, transaction.completed can backfill - * it later. - */ [$license] = $this->issuer->issue( $product, $this->nullableString( @@ -164,19 +218,42 @@ final class PurchaseProvisioner true, ); - $this->accounts->ensure( - $license->customer_email, - $license->customer_name - ); + /* + * 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(); - $license->external_subscription_id = $subscriptionID; + /* + * 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/downgrades change the entitlement attached to the - * existing licence key. + * Upgrades and downgrades modify the entitlement attached to + * the existing licence key rather than issuing another key. */ $product = $this->mappedProduct( 'paddle', @@ -186,11 +263,15 @@ final class PurchaseProvisioner $license->license_product_id = $product->id; $license->edition = $product->edition; - $license->activation_limit = $product->activation_limit; - $license->max_major_version = $product->max_major_version; + $license->activation_limit = + $product->activation_limit; + $license->max_major_version = + $product->max_major_version; } - $periodEnd = $data['current_billing_period']['ends_at'] ?? null; + $periodEnd = + $data['current_billing_period']['ends_at'] + ?? null; $scheduled = $data['scheduled_change'] ?? null; @@ -200,14 +281,18 @@ final class PurchaseProvisioner : null; $license->external_customer_id = - $this->nullableString($data['customer_id'] ?? null) + $this->nullableString( + $data['customer_id'] ?? null + ) ?? $license->external_customer_id; - $this->backfillPaddleCustomer($license, $data); + $this->backfillPaddleCustomer( + $license, + $data + ); /* - * ensure() must be idempotent. Existing users must simply be - * returned rather than created again. + * Account provisioning is expected to be idempotent. */ $this->accounts->ensure( $license->customer_email, @@ -218,8 +303,12 @@ final class PurchaseProvisioner $license->external_price_id = $priceID; } - if ($transactionID && ! $license->external_order_id) { - $license->external_order_id = $transactionID; + if ( + $transactionID !== null + && ! $license->external_order_id + ) { + $license->external_order_id = + $transactionID; } if ($status !== '') { @@ -227,7 +316,8 @@ final class PurchaseProvisioner } if ($periodEnd) { - $license->current_period_ends_at = Carbon::parse($periodEnd); + $license->current_period_ends_at = + Carbon::parse($periodEnd); } $license->cancel_scheduled_at = $cancelAt @@ -235,41 +325,51 @@ final class PurchaseProvisioner : null; /* - * Entitlement handling. + * Subscription entitlement. */ - if (in_array($status, ['active', 'trialing'], true)) { + if (in_array( + $status, + ['active', 'trialing'], + true + )) { $license->revoked_at = null; $license->revoke_reason = null; - /* - * Active/trialing subscriptions remain valid through the - * current Paddle billing period. - */ $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null; } elseif ($status === 'past_due') { /* - * Do not revoke immediately after the first failed renewal. - * Preserve Paddle's current billing period where possible. + * 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()->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->revoke_reason = + $status === 'paused' + ? 'Subscription paused' + : 'Subscription canceled'; } $license->save(); } - private function syncPaddleTransaction(array $data): void - { + private function syncPaddleTransaction( + array $data + ): void { $subscriptionID = $this->nullableString( $data['subscription_id'] ?? null ); @@ -279,28 +379,58 @@ final class PurchaseProvisioner ); /* - * Recurring transactions belong to an existing subscription. + * A recurring transaction belongs to its subscription. * - * The subscription event normally creates the licence. If the - * transaction event arrives afterwards we simply backfill the - * transaction/order ID. - * - * If transaction.completed arrives first, it does nothing here. - * The subsequent subscription event will obtain transaction_id - * directly from its payload where Paddle supplies it. + * 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) { - if ($transactionID) { - License::where('payment_provider', 'paddle') - ->where( - 'external_subscription_id', - $subscriptionID - ) - ->whereNull('external_order_id') - ->update([ - 'external_order_id' => $transactionID, - ]); - } + 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; } @@ -344,7 +474,9 @@ final class PurchaseProvisioner $data['customer_id'] ?? null ); - $customer = $this->paddle->customer($customerID) ?? []; + $customer = $this->paddle->customer( + $customerID + ) ?? []; [$license] = $this->issuer->issue( $product, @@ -372,8 +504,9 @@ final class PurchaseProvisioner ); } - private function provisionLemonSqueezy(array $event): void - { + private function provisionLemonSqueezy( + array $event + ): void { $type = (string) ( $event['meta']['event_name'] ?? '' @@ -393,13 +526,17 @@ final class PurchaseProvisioner if (in_array( $type, - ['order_refunded', 'subscription_expired'], + [ + 'order_refunded', + 'subscription_expired', + ], true )) { - License::where( - 'payment_provider', - 'lemon-squeezy' - ) + License::query() + ->where( + 'payment_provider', + 'lemon-squeezy' + ) ->where('external_order_id', $order) ->whereNull('revoked_at') ->update([ @@ -413,7 +550,10 @@ final class PurchaseProvisioner if (! in_array( $type, - ['order_created', 'subscription_created'], + [ + 'order_created', + 'subscription_created', + ], true )) { return; @@ -421,8 +561,14 @@ final class PurchaseProvisioner $product = $this->mappedProduct( 'lemon-squeezy', - (string) ($attributes['variant_id'] ?? ''), - (string) ($attributes['product_id'] ?? ''), + (string) ( + $attributes['variant_id'] + ?? '' + ), + (string) ( + $attributes['product_id'] + ?? '' + ), ); [$license] = $this->issuer->issue( @@ -451,7 +597,9 @@ final class PurchaseProvisioner License $license, array $data ): void { - $custom = is_array($data['custom_data'] ?? null) + $custom = is_array( + $data['custom_data'] ?? null + ) ? $data['custom_data'] : []; @@ -460,7 +608,9 @@ final class PurchaseProvisioner ?? $license->external_customer_id ); - $customer = $this->paddle->customer($customerID) ?? []; + $customer = $this->paddle->customer( + $customerID + ) ?? []; $name = $this->nullableString( $custom['customer_name'] @@ -485,10 +635,11 @@ final class PurchaseProvisioner $license->customer_email = $email; - LicenseDeliveryOutbox::where( - 'license_id', - $license->id - ) + LicenseDeliveryOutbox::query() + ->where( + 'license_id', + $license->id + ) ->whereNull('delivered_at') ->update([ 'recipient_email' => $email, @@ -502,26 +653,31 @@ final class PurchaseProvisioner string $productID ): LicenseProduct { /* - * Price IDs are authoritative. - * - * Monthly and annual Paddle prices can intentionally belong to the - * same Paddle product, therefore product ID alone cannot reliably - * identify the licence plan. + * Price IDs are authoritative because multiple billing intervals + * can belong to the same Paddle product. */ if ($priceID !== '') { - $product = LicenseProduct::where( - 'payment_provider', - $provider - ) - ->where('external_price_id', $priceID) + $product = LicenseProduct::query() + ->where( + 'payment_provider', + $provider + ) + ->where( + 'external_price_id', + $priceID + ) ->where('active', true) ->first(); } else { - $product = LicenseProduct::where( - 'payment_provider', - $provider - ) - ->where('external_product_id', $productID) + $product = LicenseProduct::query() + ->where( + 'payment_provider', + $provider + ) + ->where( + 'external_product_id', + $productID + ) ->where('active', true) ->first(); } @@ -536,8 +692,9 @@ final class PurchaseProvisioner return $product; } - private function firstRecurringItem(array $items): array - { + private function firstRecurringItem( + array $items + ): array { foreach ($items as $item) { if (! is_array($item)) { continue; @@ -550,11 +707,14 @@ final class PurchaseProvisioner $first = $items[0] ?? []; - return is_array($first) ? $first : []; + return is_array($first) + ? $first + : []; } - private function nullableString(mixed $value): ?string - { + private function nullableString( + mixed $value + ): ?string { if (! is_scalar($value)) { return null; } @@ -565,4 +725,62 @@ final class PurchaseProvisioner ? 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. + */ + $lockName = 'sd:'.hash('sha256', $name); + + $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); + } + } + } } From 2dde56a2f675ea7116f570c533b194b8d0c34261 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:59:42 +0100 Subject: [PATCH 08/10] update to provisioner code --- app/Services/Payments/PurchaseProvisioner.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index 9217590..03b231b 100755 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -743,7 +743,7 @@ final class PurchaseProvisioner * prevents provider supplied identifiers from creating unusual * lock names. */ - $lockName = 'sd:'.hash('sha256', $name); + $lockName = 'sd:'.substr(hash('sha256', $name), 0, 61); $row = DB::selectOne( 'SELECT GET_LOCK(?, ?) AS acquired', From 0f051bb956943567b048a8acfb5a4c5326d495ab Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 13:05:20 +0100 Subject: [PATCH 09/10] update to provisioner code --- app/Http/Controllers/Api/PaymentWebhookController.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php index 6a5146f..502f650 100755 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/Http/Controllers/Api/PaymentWebhookController.php @@ -166,13 +166,8 @@ final class PaymentWebhookController extends Controller ); } catch (Throwable $e) { report($e); - return response()->json([ 'message' => 'Webhook could not be processed.', - 'exception' => get_class($e), - 'error' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), ], 500); } From 527bb1b0176136d827254704ba49c543f80bb953 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 13:15:21 +0100 Subject: [PATCH 10/10] update to provisioner code nwew paddle code --- app/Services/Payments/PaddleClient.php | 136 ++++++++++++++++++++----- 1 file changed, 112 insertions(+), 24 deletions(-) diff --git a/app/Services/Payments/PaddleClient.php b/app/Services/Payments/PaddleClient.php index 4dc7eb8..f2f23aa 100755 --- a/app/Services/Payments/PaddleClient.php +++ b/app/Services/Payments/PaddleClient.php @@ -2,6 +2,7 @@ namespace App\Services\Payments; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use RuntimeException; @@ -19,60 +20,147 @@ final class PaddleClient if (! $response->successful()) { return null; } + $data = $response->json('data'); return is_array($data) ? $data : null; } - public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array + public function subscription(string $subscriptionID): array { $this->requireWritableConfiguration(); - $response = $this->request()->patch('/subscriptions/'.$subscriptionID, [ - 'proration_billing_mode' => 'prorated_immediately', - 'items' => [[ - 'price_id' => $priceID, - 'quantity' => 1, - ]], - ]); + $response = $this->request() + ->get('/subscriptions/'.$subscriptionID); - return $this->subscriptionData($response, 'Paddle could not update the subscription.'); + return $this->subscriptionData( + $response, + 'Paddle could not retrieve the subscription.' + ); } - public function cancelSubscriptionImmediately(string $subscriptionID): array - { + public function updateSubscriptionPrice( + string $subscriptionID, + string $priceID + ): array { $this->requireWritableConfiguration(); - $response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [ - 'effective_from' => 'immediately', - ]); + /* + * 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); - return $this->subscriptionData($response, 'Paddle could not cancel the subscription.'); + $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.' + ); } - private function request() + 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('sonardesk.paddle.api_url')) - ->withToken((string) config('sonardesk.paddle.api_key')) + return Http::baseUrl( + (string) config('sonardesk.paddle.api_url') + ) + ->withToken( + (string) config('sonardesk.paddle.api_key') + ) ->acceptJson() ->timeout(15); } private function requireWritableConfiguration(): void { - if (trim((string) config('sonardesk.paddle.api_key')) === '') { - throw new RuntimeException('Paddle subscription management is not configured.'); + if ( + trim( + (string) config('sonardesk.paddle.api_key') + ) === '' + ) { + throw new RuntimeException( + 'Paddle subscription management is not configured.' + ); } } - private function subscriptionData(Response $response, string $fallback): array - { + private function subscriptionData( + Response $response, + string $fallback + ): array { if (! $response->successful()) { - $detail = trim((string) ($response->json('error.detail') ?? '')); - throw new RuntimeException($detail !== '' ? $detail : $fallback); + $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); }