From 2d9d07f722313d266f8f1ea07402c0e7b39579b6 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 30 Sep 2026 12:49:17 +0100 Subject: [PATCH] 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; } }