From 0724e6da4334ffa3a92365b0c22cd0b635b02e34 Mon Sep 17 00:00:00 2001 From: idevakk <219866223+idevakk@users.noreply.github.com> Date: Sat, 6 Dec 2025 02:28:40 -0800 Subject: [PATCH] feat(payments): implement smart Polar subscription sync with checkout tracking - Add provider_checkout_id column to separate checkout ID from subscription ID - Update Polar provider to store checkout ID separately and set subscription ID to null initially - Implement smart sync logic that queries Polar API when subscription ID is missing - Add fetchPolarSubscriptionId method to find active subscriptions via customer ID - Update webhook handlers to use provider_checkout_id for subscription lookup - Make makeAuthenticatedRequest public to enable Subscription model API access - Support plan metadata matching for accurate subscription identification - Add fallback to most recent active subscription when no exact match found This resolves sync button issues by properly tracking checkout vs subscription IDs and enables automatic subscription ID recovery when webhooks fail. --- app/Models/Subscription.php | 86 +++++++++++++++++++ .../Payments/Providers/PolarProvider.php | 9 +- ...der_checkout_id_to_subscriptions_table.php | 29 +++++++ 3 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2025_12_06_101452_add_provider_checkout_id_to_subscriptions_table.php diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index be2d069..086529f 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -48,6 +48,7 @@ class Subscription extends Model 'polar_order', 'order_created_at', 'order_paid_at', + 'provider_checkout_id', ]; protected $casts = [ @@ -195,6 +196,11 @@ class Subscription extends Model public function syncWithProvider(): bool { try { + // For Polar provider, check if we need to fetch subscription ID first + if ($this->provider === 'polar' && empty($this->provider_subscription_id) && !empty($this->user->polar_cust_id)) { + $this->fetchPolarSubscriptionId(); + } + $orchestrator = app(PaymentOrchestrator::class); $result = $orchestrator->syncSubscriptionStatus($this); @@ -223,6 +229,86 @@ class Subscription extends Model } } + /** + * Fetch Polar subscription ID using customer ID if missing + */ + protected function fetchPolarSubscriptionId(): void + { + if ($this->provider !== 'polar' || empty($this->user->polar_cust_id)) { + return; + } + + try { + $polarProvider = app(\App\Services\Payments\Providers\PolarProvider::class); + + // Get active subscriptions for this customer + $response = $polarProvider->makeAuthenticatedRequest('GET', '/subscriptions', [ + 'customer_id' => $this->user->polar_cust_id, + 'status' => 'active', + 'limit' => 10, + ]); + + if ($response->successful()) { + $data = $response->json(); + $subscriptions = $data['items'] ?? []; + + if (!empty($subscriptions)) { + // Find the subscription that matches our plan or take the most recent active one + $matchingSubscription = null; + + foreach ($subscriptions as $sub) { + // Check if this subscription matches our plan (via metadata or other criteria) + if (isset($sub['metadata']['plan_id']) && $sub['metadata']['plan_id'] == $this->plan_id) { + $matchingSubscription = $sub; + break; + } + } + + // If no exact match, take the most recent active subscription + if (!$matchingSubscription && !empty($subscriptions)) { + $matchingSubscription = $subscriptions[0]; + } + + if ($matchingSubscription) { + $this->update([ + 'provider_subscription_id' => $matchingSubscription['id'], + 'status' => $matchingSubscription['status'], + 'starts_at' => isset($matchingSubscription['current_period_start']) + ? \Carbon\Carbon::parse($matchingSubscription['current_period_start']) + : null, + 'ends_at' => isset($matchingSubscription['current_period_end']) + ? \Carbon\Carbon::parse($matchingSubscription['current_period_end']) + : null, + 'provider_data' => array_merge($this->provider_data ?? [], [ + 'polar_subscription' => $matchingSubscription, + 'subscription_id_fetched_at' => now()->toISOString(), + ]), + ]); + + Log::info('Polar subscription ID fetched and updated', [ + 'subscription_id' => $this->id, + 'polar_subscription_id' => $matchingSubscription['id'], + 'customer_id' => $this->user->polar_cust_id, + ]); + } + } + } else { + Log::warning('Failed to fetch Polar subscriptions for sync', [ + 'customer_id' => $this->user->polar_cust_id, + 'status_code' => $response->status(), + 'response' => $response->json(), + ]); + } + + } catch (\Exception $e) { + Log::error('Error fetching Polar subscription ID', [ + 'subscription_id' => $this->id, + 'customer_id' => $this->user->polar_cust_id ?? 'none', + 'error' => $e->getMessage(), + ]); + } + } + /** * Cancel the subscription */ diff --git a/app/Services/Payments/Providers/PolarProvider.php b/app/Services/Payments/Providers/PolarProvider.php index 116fd92..5bac3bf 100644 --- a/app/Services/Payments/Providers/PolarProvider.php +++ b/app/Services/Payments/Providers/PolarProvider.php @@ -132,7 +132,7 @@ class PolarProvider implements PaymentProviderContract } } - protected function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response + public function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response { $url = $this->apiBaseUrl.$endpoint; @@ -261,7 +261,8 @@ class PolarProvider implements PaymentProviderContract 'stripe_id' => $checkout['id'], // Using stripe_id field for Polar checkout ID 'stripe_status' => 'pending', 'provider' => $this->getName(), - 'provider_subscription_id' => $checkout['id'], + 'provider_checkout_id' => $checkout['id'], // Store checkout ID separately + 'provider_subscription_id' => null, // Will be populated via webhook or sync 'status' => 'pending_payment', 'starts_at' => null, 'ends_at' => null, @@ -1170,9 +1171,9 @@ class PolarProvider implements PaymentProviderContract { $polarSubscription = $webhookData['data']['object']; - // Find and update local subscription + // Find and update local subscription using checkout_id $localSubscription = Subscription::where('provider', 'polar') - ->where('provider_subscription_id', $polarSubscription['checkout_id']) + ->where('provider_checkout_id', $polarSubscription['checkout_id']) ->first(); if ($localSubscription) { diff --git a/database/migrations/2025_12_06_101452_add_provider_checkout_id_to_subscriptions_table.php b/database/migrations/2025_12_06_101452_add_provider_checkout_id_to_subscriptions_table.php new file mode 100644 index 0000000..39cdf3b --- /dev/null +++ b/database/migrations/2025_12_06_101452_add_provider_checkout_id_to_subscriptions_table.php @@ -0,0 +1,29 @@ +string('provider_checkout_id')->nullable()->after('provider_subscription_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('provider_checkout_id'); + }); + } +};