From 5b3413549d1a237a2e7d4d7dfa6f751495e99822 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:38:04 +0100 Subject: [PATCH] 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 + ); } }