diff --git a/app/Console/Commands/SonarDeskAccountsBackfill.php b/app/Console/Commands/SonarDeskAccountsBackfill.php index 499179d..ecd3464 100644 --- a/app/Console/Commands/SonarDeskAccountsBackfill.php +++ b/app/Console/Commands/SonarDeskAccountsBackfill.php @@ -3,50 +3,56 @@ namespace App\Console\Commands; use App\Models\License; -use App\Services\Payments\CustomerAccountProvisioner; +use App\Models\User; +use App\Services\Accounts\PurchasedAccountProvisioner; use Illuminate\Console\Command; final class SonarDeskAccountsBackfill extends Command { - protected $signature = 'sonardesk:accounts:backfill {--dry-run : Show missing customer accounts without creating them}'; + protected $signature = 'sonardesk:accounts:backfill {--dry-run : Report missing customer accounts without creating them} {--no-email : Create accounts without sending password setup links}'; - protected $description = 'Create portal accounts for existing licence customers that do not yet have a user account'; + protected $description = 'Create portal accounts for existing licence holders that do not already have one.'; - public function handle(CustomerAccountProvisioner $accounts): int + public function handle(PurchasedAccountProvisioner $accounts): int { $customers = License::query() ->whereNotNull('customer_email') - ->where('customer_email', '!=', '') - ->orderBy('id') - ->get(['customer_email', 'customer_name']) + ->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)); - $exists = \App\Models\User::whereRaw('lower(email) = ?', [$email])->exists(); - if ($exists) { + if ($email === '') { continue; } + if (User::query()->whereRaw('lower(email) = ?', [$email])->exists()) { + continue; + } + + $missing++; if ($this->option('dry-run')) { - $this->line("Missing account: {$email}"); - $created++; + $this->line("MISSING {$email}"); continue; } - $user = $accounts->ensure($email, $license->customer_name); - if ($user) { - $this->info("Created account: {$email}"); + if ($accounts->ensure($email, $license->customer_name, ! $this->option('no-email'))) { $created++; + $this->info("CREATED {$email}"); } } - $this->newLine(); - $this->info($this->option('dry-run') - ? "{$created} customer account(s) need creating." - : "{$created} customer account(s) created."); + 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/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php index e4c8036..502f650 100755 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/Http/Controllers/Api/PaymentWebhookController.php @@ -11,68 +11,181 @@ 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 { - 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) { + } catch (Throwable $e) { report($e); - return response()->json(['message' => 'Invalid webhook signature.'], 400); + 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); + $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')); + + $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' => 'Webhook event has no stable identifier.', + ], 400); } + $payloadHash = hash('sha256', $raw); + 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; - } - }); - } catch (\Throwable $e) { - report($e); + /* + * Paddle can deliver several related events concurrently. + * + * 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 ( + $provider, + $externalID, + $eventType, + $payload, + $payloadHash, + $provisioner + ): void { + /* + * Insert the event if this is its first delivery. + * + * Once present, lock the row so duplicate deliveries of + * the same event cannot provision concurrently. + */ + LicenseWebhookEvent::firstOrCreate( + [ + 'provider' => $provider, + 'external_event_id' => $externalID, + ], + [ + 'event_type' => $eventType, + 'payload_sha256' => $payloadHash, + 'payload' => $payload, + ] + ); - return response()->json(['message' => 'Webhook could not be processed.'], 500); + $event = LicenseWebhookEvent::query() + ->where('provider', $provider) + ->where('external_event_id', $externalID) + ->lockForUpdate() + ->firstOrFail(); + + if ($event->processed_at) { + return; + } + + if (! hash_equals( + (string) $event->payload_sha256, + $payloadHash + )) { + throw new RuntimeException( + 'Webhook ID was reused with a different payload.' + ); + } + + try { + $provisioner->provision( + $provider, + $payload + ); + + $event->processed_at = now(); + $event->processing_error = null; + $event->save(); + } catch (Throwable $e) { + /* + * 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->processing_error = mb_substr( + $e->getMessage(), + 0, + 4000 + ); + + $event->save(); + + throw $e; + } + }, + 5 + ); + } catch (Throwable $e) { + report($e); + return response()->json([ + 'message' => 'Webhook could not be processed.', + ], 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 deliberately happens outside the webhook transaction. + * + * 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); } - return response()->json(['ok' => true]); + return response()->json([ + 'ok' => true, + ]); } } diff --git a/app/Services/Accounts/PurchasedAccountProvisioner.php b/app/Services/Accounts/PurchasedAccountProvisioner.php new file mode 100644 index 0000000..2498f89 --- /dev/null +++ b/app/Services/Accounts/PurchasedAccountProvisioner.php @@ -0,0 +1,109 @@ +findByEmail($email); + + if ($user) { + return $user; + } + + $created = false; + + 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]); + }); + } + + 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); + } + + $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/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); } diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index 0d828ab..03b231b 100755 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -3,15 +3,22 @@ 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; +use Illuminate\Support\Facades\DB; use RuntimeException; +use Throwable; 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 { @@ -27,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; } @@ -48,220 +63,724 @@ final class PurchaseProvisioner } if ($type === 'transaction.refunded') { - $transactionID = (string) ($data['id'] ?? ''); - if ($transactionID !== '') { - License::where('payment_provider', 'paddle') - ->where('external_order_id', $transactionID) + $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']); + ->update([ + 'revoked_at' => now(), + 'revoke_reason' => 'Payment refunded', + ]); } } } - private function syncPaddleSubscription(string $type, array $data): void - { - $subscriptionID = (string) ($data['id'] ?? ''); - if ($subscriptionID === '') { - throw new RuntimeException('Paddle subscription webhook has no subscription ID.'); + 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.' + ); } - $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'] ?? ''); + /* + * 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; - $license = License::where('payment_provider', 'paddle') - ->where('external_subscription_id', $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'] : []; - $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 + ) ?? []; + [$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, ); - $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); + + /* + * 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; + $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; - $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); - $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; - if (in_array($status, ['active', 'trialing'], true)) { + $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; - // Trialing/active subscriptions remain valid through the current period. - $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null; + + $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)); - } elseif (in_array($status, ['canceled', 'paused'], true)) { + /* + * 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->revoke_reason = + $status === 'paused' + ? 'Subscription paused' + : 'Subscription canceled'; } $license->save(); } - private function syncPaddleTransaction(array $data): void - { - $subscriptionID = $this->nullableString($data['subscription_id'] ?? null); - $transactionID = (string) ($data['id'] ?? ''); + private function syncPaddleTransaction( + array $data + ): void { + $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. - if ($subscriptionID) { - License::where('payment_provider', 'paddle') - ->where('external_subscription_id', $subscriptionID) - ->whereNull('external_order_id') - ->update(['external_order_id' => $transactionID ?: 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; } - // 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'] + : []; - $this->issuer->issue( + $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 + ); } - private function provisionLemonSqueezy(array $event): void - { - $type = (string) ($event['meta']['event_name'] ?? ''); - $attributes = $event['data']['attributes'] ?? []; - $order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? ''); + private function provisionLemonSqueezy( + array $event + ): void { + $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::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']); + ->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; } $product = $this->mappedProduct( 'lemon-squeezy', - (string) ($attributes['variant_id'] ?? ''), - (string) ($attributes['product_id'] ?? ''), + (string) ( + $attributes['variant_id'] + ?? '' + ), + (string) ( + $attributes['product_id'] + ?? '' + ), ); - $this->issuer->issue( + [$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 + ); } - 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::query() + ->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 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(); } 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; } - private function firstRecurringItem(array $items): array - { + 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 - { + private function nullableString( + mixed $value + ): ?string { if (! is_scalar($value)) { return null; } + $value = trim((string) $value); - return $value === '' ? null : $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. + */ + $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); + } + } } } 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); + } }