provisionPaddle($event); return; } if ($provider === 'lemon-squeezy') { $this->provisionLemonSqueezy($event); return; } throw new RuntimeException( 'Unsupported payment provider.' ); } private function provisionPaddle(array $event): void { $type = (string) ($event['event_type'] ?? ''); $data = is_array($event['data'] ?? null) ? $event['data'] : []; if (str_starts_with($type, 'subscription.')) { $this->syncPaddleSubscription( $type, $data ); return; } if ($type === 'transaction.completed') { $this->syncPaddleTransaction($data); return; } if ($type === 'transaction.refunded') { $transactionID = $this->nullableString( $data['id'] ?? null ); if ($transactionID !== null) { License::query() ->where('payment_provider', 'paddle') ->where( 'external_order_id', $transactionID ) ->whereNull('external_subscription_id') ->whereNull('revoked_at') ->update([ 'revoked_at' => now(), 'revoke_reason' => 'Payment refunded', ]); } } } private function syncPaddleSubscription( string $type, array $data ): void { $subscriptionID = $this->nullableString( $data['id'] ?? null ); if ($subscriptionID === null) { throw new RuntimeException( 'Paddle subscription webhook has no subscription ID.' ); } /* * Multiple Paddle events for the same subscription can arrive * simultaneously. * * The unique index protects database integrity, but by itself it * cannot stop two workers from both observing "no licence" before * either worker inserts the subscription ID. * * A MySQL advisory lock serialises provisioning for one Paddle * subscription while still allowing unrelated subscriptions to be * processed concurrently. */ $lockName = 'sonardesk:paddle:subscription:' .$subscriptionID; $this->withMysqlLock( $lockName, function () use ( $type, $data, $subscriptionID ): void { $this->syncLockedPaddleSubscription( $type, $data, $subscriptionID ); } ); } private function syncLockedPaddleSubscription( string $type, array $data, string $subscriptionID ): void { $transactionID = $this->nullableString( $data['transaction_id'] ?? null ); $status = (string) ($data['status'] ?? ''); $items = is_array($data['items'] ?? null) ? $data['items'] : []; $item = $this->firstRecurringItem($items); $price = is_array($item['price'] ?? null) ? $item['price'] : []; $priceID = (string) ( $price['id'] ?? $item['price_id'] ?? '' ); $productID = (string) ( $price['product_id'] ?? $item['product_id'] ?? '' ); /* * The Paddle subscription ID is the authoritative identity for a * recurring licence. * * lockForUpdate protects an existing licence while we mutate it. * Creation of a missing licence is serialised by the advisory lock * acquired by syncPaddleSubscription(). */ $license = License::query() ->where('payment_provider', 'paddle') ->where( 'external_subscription_id', $subscriptionID ) ->lockForUpdate() ->first(); if (! $license) { $product = $this->mappedProduct( 'paddle', $priceID, $productID ); $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; $customerID = $this->nullableString( $data['customer_id'] ?? null ); $customer = $this->paddle->customer( $customerID ) ?? []; [$license] = $this->issuer->issue( $product, $this->nullableString( $custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null ), $this->nullableString( $custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null ), 'paddle', $transactionID, $customerID, true, ); /* * LicenseIssuer may return an existing licence if the * transaction ID has already been seen. * * Re-read it with a row lock before attaching the Paddle * subscription. */ $license = License::query() ->whereKey($license->getKey()) ->lockForUpdate() ->firstOrFail(); /* * Do not attach this subscription to a licence already owned * by a different subscription. */ if ( $license->external_subscription_id !== null && $license->external_subscription_id !== $subscriptionID ) { throw new RuntimeException( 'Paddle transaction resolved to a licence ' .'belonging to another subscription.' ); } $license->external_subscription_id = $subscriptionID; } elseif ( $priceID !== '' && $priceID !== $license->external_price_id ) { /* * Upgrades and downgrades modify the entitlement attached to * the existing licence key rather than issuing another key. */ $product = $this->mappedProduct( 'paddle', $priceID, $productID ); $license->license_product_id = $product->id; $license->edition = $product->edition; $license->activation_limit = $product->activation_limit; $license->max_major_version = $product->max_major_version; } $periodEnd = $data['current_billing_period']['ends_at'] ?? null; $scheduled = $data['scheduled_change'] ?? null; $cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel' ? ($scheduled['effective_at'] ?? null) : null; $license->external_customer_id = $this->nullableString( $data['customer_id'] ?? null ) ?? $license->external_customer_id; $this->backfillPaddleCustomer( $license, $data ); /* * Account provisioning is expected to be idempotent. */ $this->accounts->ensure( $license->customer_email, $license->customer_name ); if ($priceID !== '') { $license->external_price_id = $priceID; } if ( $transactionID !== null && ! $license->external_order_id ) { $license->external_order_id = $transactionID; } if ($status !== '') { $license->subscription_status = $status; } if ($periodEnd) { $license->current_period_ends_at = Carbon::parse($periodEnd); } $license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null; /* * Subscription entitlement. */ if (in_array( $status, ['active', 'trialing'], true )) { $license->revoked_at = null; $license->revoke_reason = null; $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null; } elseif ($status === 'past_due') { /* * A failed renewal does not immediately destroy the * entitlement. Preserve Paddle's current billing period when * available and otherwise provide the existing grace period. */ $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ( $license->expires_at ?? now()->addDays(7) ); } elseif (in_array( $status, ['canceled', 'paused'], true )) { $license->expires_at = now(); $license->revoked_at = now(); $license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled'; } $license->save(); } private function syncPaddleTransaction( array $data ): void { $subscriptionID = $this->nullableString( $data['subscription_id'] ?? null ); $transactionID = $this->nullableString( $data['id'] ?? null ); /* * A recurring transaction belongs to its subscription. * * Serialise this operation using the same lock as subscription * provisioning. This prevents transaction.completed from updating * the licence while a subscription event is still creating or * modifying it. */ if ($subscriptionID !== null) { $lockName = 'sonardesk:paddle:subscription:' .$subscriptionID; $this->withMysqlLock( $lockName, function () use ( $subscriptionID, $transactionID ): void { if ($transactionID === null) { return; } $license = License::query() ->where( 'payment_provider', 'paddle' ) ->where( 'external_subscription_id', $subscriptionID ) ->lockForUpdate() ->first(); /* * transaction.completed may legitimately arrive before * subscription.created. In that case there is nothing * to update yet. The subscription event will create the * licence and Paddle's transaction_id can populate the * order ID. */ if (! $license) { return; } if (! $license->external_order_id) { $license->external_order_id = $transactionID; $license->save(); } } ); return; } /* * No subscription ID means this may be a one-time purchase. */ $items = is_array($data['items'] ?? null) ? $data['items'] : []; $item = $items[0] ?? []; $price = is_array($item['price'] ?? null) ? $item['price'] : []; $priceID = (string) ( $price['id'] ?? $item['price_id'] ?? '' ); $productID = (string) ( $price['product_id'] ?? $item['product_id'] ?? '' ); $product = $this->mappedProduct( 'paddle', $priceID, $productID ); $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; $customerID = $this->nullableString( $data['customer_id'] ?? null ); $customer = $this->paddle->customer( $customerID ) ?? []; [$license] = $this->issuer->issue( $product, $this->nullableString( $custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null ), $this->nullableString( $custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null ), 'paddle', $transactionID, $customerID, true, ); $this->accounts->ensure( $license->customer_email, $license->customer_name ); } private function provisionLemonSqueezy( array $event ): void { $type = (string) ( $event['meta']['event_name'] ?? '' ); $attributes = is_array( $event['data']['attributes'] ?? null ) ? $event['data']['attributes'] : []; $order = (string) ( $event['data']['id'] ?? $attributes['order_id'] ?? '' ); if (in_array( $type, [ 'order_refunded', 'subscription_expired', ], true )) { License::query() ->where( 'payment_provider', 'lemon-squeezy' ) ->where('external_order_id', $order) ->whereNull('revoked_at') ->update([ 'revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired', ]); return; } if (! in_array( $type, [ 'order_created', 'subscription_created', ], true )) { return; } $product = $this->mappedProduct( 'lemon-squeezy', (string) ( $attributes['variant_id'] ?? '' ), (string) ( $attributes['product_id'] ?? '' ), ); [$license] = $this->issuer->issue( $product, $this->nullableString( $attributes['user_name'] ?? null ), $this->nullableString( $attributes['user_email'] ?? null ), 'lemon-squeezy', $order, $this->nullableString( $attributes['customer_id'] ?? null ), true, ); $this->accounts->ensure( $license->customer_email, $license->customer_name ); } private function backfillPaddleCustomer( License $license, array $data ): void { $custom = is_array( $data['custom_data'] ?? null ) ? $data['custom_data'] : []; $customerID = $this->nullableString( $data['customer_id'] ?? $license->external_customer_id ); $customer = $this->paddle->customer( $customerID ) ?? []; $name = $this->nullableString( $custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null ); $email = $this->nullableString( $custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null ); if (! $license->customer_name && $name) { $license->customer_name = $name; } if (! $license->customer_email && $email) { $email = strtolower($email); $license->customer_email = $email; LicenseDeliveryOutbox::query() ->where( 'license_id', $license->id ) ->whereNull('delivered_at') ->update([ 'recipient_email' => $email, ]); } } private function mappedProduct( string $provider, string $priceID, string $productID ): LicenseProduct { /* * Price IDs are authoritative because multiple billing intervals * can belong to the same Paddle product. */ if ($priceID !== '') { $product = LicenseProduct::query() ->where( 'payment_provider', $provider ) ->where( 'external_price_id', $priceID ) ->where('active', true) ->first(); } else { $product = LicenseProduct::query() ->where( 'payment_provider', $provider ) ->where( 'external_product_id', $productID ) ->where('active', true) ->first(); } if (! $product) { throw new RuntimeException( "No active licence product maps to {$provider} " ."price {$priceID} / product {$productID}." ); } return $product; } private function firstRecurringItem( array $items ): array { foreach ($items as $item) { if (! is_array($item)) { continue; } if (($item['recurring'] ?? true) === true) { return $item; } } $first = $items[0] ?? []; return is_array($first) ? $first : []; } private function nullableString( mixed $value ): ?string { if (! is_scalar($value)) { return null; } $value = trim((string) $value); return $value === '' ? null : $value; } /** * Execute a callback while holding a MySQL advisory lock. * * The lock is connection-scoped, so it must always be explicitly * released in finally. A short timeout is sufficient because Paddle * will retry failed webhook deliveries and the controller also retries * database deadlocks. */ private function withMysqlLock( string $name, callable $callback ): mixed { /* * MySQL advisory lock names have a finite length. Hashing also * prevents provider supplied identifiers from creating unusual * lock names. */ /* * MySQL advisory locks are only available when using MySQL/MariaDB. * * Tests use SQLite in-memory, where GET_LOCK() and RELEASE_LOCK() * do not exist. */ if (DB::connection()->getDriverName() !== 'mysql') { return $callback(); } $lockName = 'sd:'.substr(hash('sha256', $name), 0, 61); $row = DB::selectOne( 'SELECT GET_LOCK(?, ?) AS acquired', [ $lockName, 10, ] ); $acquired = (int) ( $row->acquired ?? 0 ); if ($acquired !== 1) { throw new RuntimeException( 'Could not acquire Paddle subscription lock.' ); } try { return $callback(); } finally { try { DB::selectOne( 'SELECT RELEASE_LOCK(?) AS released', [$lockName] ); } catch (Throwable $e) { /* * Do not hide the original processing exception merely * because releasing an advisory lock failed. MySQL also * releases connection-owned locks when the connection * closes. */ report($e); } } } }