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 = $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 = (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']); } } } private function syncPaddleSubscription(string $type, array $data): void { $subscriptionID = (string) ($data['id'] ?? ''); if ($subscriptionID === '') { 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'] ?? ''); $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)); [$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', $this->nullableString($data['transaction_id'] ?? null), $this->nullableString($data['customer_id'] ?? null), 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); $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; $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; 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; } 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)) { $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 = (string) ($data['id'] ?? ''); // 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]); 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)); $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, $this->nullableString($data['customer_id'] ?? null), true, ); } private function provisionLemonSqueezy(array $event): void { $type = (string) ($event['meta']['event_name'] ?? ''); $attributes = $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']); return; } if (! in_array($type, ['order_created', 'subscription_created'], true)) { return; } $product = $this->mappedProduct( 'lemon-squeezy', (string) ($attributes['variant_id'] ?? ''), (string) ($attributes['product_id'] ?? ''), ); $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, ); } 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. if ($priceID !== '') { $product = LicenseProduct::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) ->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 (($item['recurring'] ?? true) === true) { return $item; } } return $items[0] ?? []; } private function nullableString(mixed $value): ?string { if (! is_scalar($value)) { return null; } $value = trim((string) $value); return $value === '' ? null : $value; } }