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