loadConfigurationFromModel(); // Merge with any passed config (passed config takes precedence) $config = array_merge($dbConfig, $config); Log::info('PolarProvider configuration loaded', [ 'config_keys' => array_keys($config), 'has_api_key' => isset($config['api_key']) && ! empty($config['api_key']), 'has_webhook_secret' => isset($config['webhook_secret']) && ! empty($config['webhook_secret']), 'has_sandbox_api_key' => isset($config['sandbox_api_key']) && ! empty($config['sandbox_api_key']), 'has_sandbox_webhook_secret' => isset($config['sandbox_webhook_secret']) && ! empty($config['sandbox_webhook_secret']), 'has_access_token' => isset($config['access_token']) && ! empty($config['access_token']), 'sandbox' => $config['sandbox'] ?? false, ]); $this->sandbox = $config['sandbox'] ?? false; $this->apiBaseUrl = $this->sandbox ? 'https://sandbox-api.polar.sh/v1' : 'https://api.polar.sh/v1'; // Use sandbox credentials when sandbox mode is enabled if ($this->sandbox) { $this->apiKey = $config['sandbox_api_key'] ?? ''; $this->webhookSecret = $config['sandbox_webhook_secret'] ?? ''; } else { $this->apiKey = $config['api_key'] ?? ''; $this->webhookSecret = $config['webhook_secret'] ?? ''; } // Access token is common for both environments $this->accessToken = $config['access_token'] ?? ''; Log::info('PolarProvider properties set', [ 'sandbox' => $this->sandbox, 'api_key_empty' => empty($this->apiKey), 'webhook_secret_empty' => empty($this->webhookSecret), 'access_token_empty' => empty($this->accessToken), 'using_sandbox_creds' => $this->sandbox, 'is_active_will_be' => ! empty($this->apiKey) && ! empty($this->webhookSecret), ]); $this->config = array_merge([ 'sandbox' => $this->sandbox, 'api_key' => $this->apiKey, 'webhook_secret' => $this->webhookSecret, 'success_url' => route('payment.success'), 'cancel_url' => route('payment.cancel'), 'webhook_url' => route('webhook.payment', 'polar'), ], $config); } protected function loadConfigurationFromModel(): array { try { $providerModel = PaymentProviderModel::where('name', 'polar') ->where('is_active', true) ->first(); if (! $providerModel) { Log::error('Polar provider not found in database or not active'); return []; } // The configuration is automatically decrypted by the encrypted:array cast return $providerModel->configuration ?? []; } catch (\Exception $e) { Log::error('Failed to load Polar configuration from model', [ 'error' => $e->getMessage(), ]); return []; } } public function getName(): string { return 'polar'; } public function isActive(): bool { return ! empty($this->apiKey) && ! empty($this->webhookSecret); } public function validateCredentials(): bool { try { return $this->makeAuthenticatedRequest('GET', '/organizations/current')->successful(); } catch (\Exception $e) { Log::error('Polar credentials validation failed', [ 'error' => $e->getMessage(), ]); return false; } } public function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response { $url = $this->apiBaseUrl.$endpoint; $headers = [ 'Authorization' => 'Bearer '.$this->apiKey, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ]; $http = Http::withHeaders($headers) ->timeout(30) ->withOptions([ 'verify' => true, 'curl' => [ CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2, ], ]); return match ($method) { 'GET' => $http->get($url, $data), 'POST' => $http->post($url, $data), 'PATCH' => $http->patch($url, $data), 'DELETE' => $http->delete($url, $data), default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"), }; } public function createSubscription(User $user, Plan $plan, array $options = []): array { try { Log::info('PolarProvider: createSubscription started', [ 'user_id' => $user->id, 'plan_id' => $plan->id, 'plan_name' => $plan->name, ]); // Get or create Polar customer $customer = $this->getOrCreateCustomer($user); Log::info('PolarProvider: Customer retrieved/created', [ 'customer_id' => $customer['id'] ?? 'null', ]); // Get or create Polar product/price $priceId = $this->getOrCreatePrice($plan); Log::info('PolarProvider: Price retrieved/created', [ 'price_id' => $priceId ?? 'null', ]); // Create checkout session $checkoutData = [ 'product_price_id' => $priceId, 'customer_id' => $customer['id'], 'success_url' => $this->config['success_url'], 'cancel_url' => $this->config['cancel_url'], 'customer_email' => $user->email, 'customer_name' => $user->name, 'metadata' => [ 'user_id' => (string) $user->id, 'plan_id' => (string) $plan->id, 'plan_name' => $plan->name, 'external_id' => (string) $user->id, ], ]; // Add discount codes if provided if (isset($options['discount_code'])) { $checkoutData['discount_code'] = $options['discount_code']; } Log::info('PolarProvider: Creating checkout session', [ 'checkout_data' => $checkoutData, 'api_url' => $this->apiBaseUrl.'/checkouts', ]); $response = $this->makeAuthenticatedRequest('POST', '/checkouts', $checkoutData); Log::info('PolarProvider: Checkout response received', [ 'status' => $response->status(), 'successful' => $response->successful(), 'response_body' => $response->body(), ]); if (! $response->successful()) { $statusCode = $response->status(); $responseBody = $response->json(); // Log detailed error for debugging Log::error('Polar checkout creation failed', [ 'status_code' => $statusCode, 'response' => $responseBody, 'user_id' => $user->id, 'plan_id' => $plan->id, ]); // Create user-friendly error message without exposing sensitive data $errorMessage = $this->sanitizePolarErrorMessage($responseBody, $statusCode); throw new \Exception($errorMessage); } $checkout = $response->json(); if (! isset($checkout['id'])) { throw new \Exception('Invalid response from Polar API: missing checkout ID'); } if (! isset($checkout['url'])) { throw new \Exception('Invalid response from Polar API: missing checkout URL'); } // Create subscription record Log::info('PolarProvider: Creating subscription record', [ 'checkout_id' => $checkout['id'], 'user_id' => $user->id, 'plan_id' => $plan->id, ]); try { $subscription = Subscription::create([ 'user_id' => $user->id, 'plan_id' => $plan->id, 'type' => 'default', 'stripe_id' => $checkout['id'], // Using stripe_id field for Polar checkout ID 'stripe_status' => 'pending', 'provider' => $this->getName(), '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, 'provider_data' => [ 'checkout_id' => $checkout['id'], 'checkout_url' => $checkout['url'], 'customer_id' => $customer['id'], 'price_id' => $priceId, 'created_at' => now()->toISOString(), ], ]); Log::info('PolarProvider: Subscription record created successfully', [ 'subscription_id' => $subscription->id, ]); } catch (\Exception $e) { Log::error('PolarProvider: Failed to create subscription record', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); throw $e; } $result = [ 'provider_subscription_id' => $checkout['id'], 'status' => 'pending_payment', 'checkout_url' => $checkout['url'], 'customer_id' => $customer['id'], 'price_id' => $priceId, 'type' => 'polar_checkout', 'expires_at' => $checkout['expires_at'] ?? now()->addHours(24)->toISOString(), ]; Log::info('PolarProvider: Returning successful result', [ 'result_keys' => array_keys($result), 'checkout_url' => $result['checkout_url'], 'provider_subscription_id' => $result['provider_subscription_id'], ]); return $result; } catch (\Exception $e) { Log::error('Polar subscription creation failed', [ 'user_id' => $user->id, 'plan_id' => $plan->id, 'error' => $e->getMessage(), ]); throw $e; } } public function cancelSubscription(Subscription $subscription, string $reason = ''): bool { try { $polarSubscriptionId = $this->getPolarSubscriptionId($subscription); if (! $polarSubscriptionId) { // Local cancellation only $subscription->update([ 'status' => 'cancelled', 'cancelled_at' => now(), 'cancellation_reason' => $reason, ]); return true; } $response = $this->makeAuthenticatedRequest('DELETE', '/subscriptions/'.$polarSubscriptionId, [ 'reason' => $reason, ]); if (! $response->successful()) { Log::error('Polar subscription cancellation failed: '.$response->body()); } // Update local subscription $subscription->update([ 'status' => 'cancelled', 'cancelled_at' => now(), 'cancellation_reason' => $reason, ]); return true; } catch (\Exception $e) { Log::error('Polar subscription cancellation failed', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); throw $e; } } public function updateSubscription(Subscription $subscription, Plan $newPlan): array { try { $polarSubscriptionId = $this->getPolarSubscriptionId($subscription); if (! $polarSubscriptionId) { throw new \Exception('No Polar subscription found to update'); } $newPriceId = $this->getOrCreatePrice($newPlan); $response = $this->makeAuthenticatedRequest('PATCH', '/subscriptions/'.$polarSubscriptionId, [ 'product_price_id' => $newPriceId, 'proration_behavior' => 'prorate', ]); if (! $response->successful()) { Log::error('Polar subscription update failed: '.$response->body()); } $updatedSubscription = $response->json(); // Update local subscription $subscription->update([ 'plan_id' => $newPlan->id, 'provider_data' => array_merge($subscription->provider_data ?? [], [ 'updated_at' => now()->toISOString(), 'polar_subscription' => $updatedSubscription, ]), ]); return [ 'provider_subscription_id' => $updatedSubscription['id'], 'status' => $updatedSubscription['status'], 'price_id' => $newPriceId, 'updated_at' => $updatedSubscription['updated_at'], ]; } catch (\Exception $e) { Log::error('Polar subscription update failed', [ 'subscription_id' => $subscription->id, 'new_plan_id' => $newPlan->id, 'error' => $e->getMessage(), ]); throw $e; } } public function pauseSubscription(Subscription $subscription): bool { try { $polarSubscriptionId = $this->getPolarSubscriptionId($subscription); if (! $polarSubscriptionId) { return false; } $response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/pause'); if (! $response->successful()) { Log::error('Polar subscription pause failed: '.$response->body()); } $subscription->update([ 'status' => 'paused', 'paused_at' => now(), ]); return true; } catch (\Exception $e) { Log::error('Polar subscription pause failed', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); throw $e; } } public function resumeSubscription(Subscription $subscription): bool { try { $polarSubscriptionId = $this->getPolarSubscriptionId($subscription); if (! $polarSubscriptionId) { return false; } $response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/resume'); if (! $response->successful()) { Log::error('Polar subscription resume failed: '.$response->body()); } $subscription->update([ 'status' => 'active', 'resumed_at' => now(), ]); return true; } catch (\Exception $e) { Log::error('Polar subscription resume failed', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); throw $e; } } public function getSubscriptionDetails(string $providerSubscriptionId): array { try { $response = $this->makeAuthenticatedRequest('GET', '/subscriptions/'.$providerSubscriptionId); if (! $response->successful()) { Log::error('Failed to retrieve Polar subscription', [ 'status_code' => $response->status(), 'response' => $response->json(), 'subscription_id' => $providerSubscriptionId, ]); throw new \Exception('Subscription not found. Please check your subscription details.'); } $polarSubscription = $response->json(); // Log the full Polar subscription response for debugging Log::info('Polar subscription response received', [ 'subscription_id' => $providerSubscriptionId, 'response_keys' => array_keys($polarSubscription), 'full_response' => $polarSubscription, ]); if (! $polarSubscription || ! isset($polarSubscription['id'])) { Log::error('Invalid Polar subscription response', [ 'subscription_id' => $providerSubscriptionId, 'response' => $polarSubscription, ]); throw new \Exception('Invalid Polar subscription response'); } return [ 'id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], 'customer_id' => $polarSubscription['customer_id'], 'price_id' => $polarSubscription['price_id'], 'current_period_start' => $polarSubscription['current_period_start'] ?? null, 'current_period_end' => $polarSubscription['current_period_end'] ?? null, 'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false, 'trial_start' => $polarSubscription['trial_start'] ?? null, 'trial_end' => $polarSubscription['trial_end'] ?? null, 'created_at' => $polarSubscription['created_at'] ?? null, 'updated_at' => $polarSubscription['modified_at'] ?? null, 'ends_at' => $polarSubscription['ends_at'] ?? null, // Check if Polar has ends_at 'expires_at' => $polarSubscription['expires_at'] ?? null, // Check if Polar has expires_at 'cancelled_at' => $polarSubscription['cancelled_at'] ?? null, // Check if Polar has cancelled_at 'customer_cancellation_reason' => $polarSubscription['customer_cancellation_reason'] ?? null, 'customer_cancellation_comment' => $polarSubscription['customer_cancellation_comment'] ?? null, ]; } catch (\Exception $e) { Log::error('Polar subscription details retrieval failed', [ 'subscription_id' => $providerSubscriptionId, 'error' => $e->getMessage(), ]); throw $e; } } public function createCheckoutSession(User $user, Plan $plan, array $options = []): array { Log::info('PolarProvider: createCheckoutSession called', [ 'user_id' => $user->id, 'plan_id' => $plan->id, 'plan_name' => $plan->name, 'options_count' => count($options), ]); return $this->createSubscription($user, $plan, $options); } public function createCustomerPortalSession(User $user): array { try { $customer = $this->getOrCreateCustomer($user); // Create customer session using correct Polar API endpoint $response = $this->makeAuthenticatedRequest('POST', '/customer-sessions', [ 'customer_id' => $customer['id'], 'return_url' => route('dashboard'), ]); if (! $response->successful()) { Log::error('Polar customer session creation failed: '.$response->body()); throw new \Exception('Failed to create customer session'); } $session = $response->json(); // Polar provides a direct customer_portal_url in the response if (! isset($session['customer_portal_url'])) { Log::error('Invalid Polar customer session response', [ 'response' => $session, ]); throw new \Exception('Invalid customer session response - missing portal URL'); } Log::info('Polar customer portal session created successfully', [ 'user_id' => $user->id, 'customer_id' => $customer['id'], 'portal_url' => $session['customer_portal_url'], ]); return [ 'portal_url' => $session['customer_portal_url'], 'customer_id' => $customer['id'], 'session_token' => $session['token'] ?? null, 'expires_at' => $session['expires_at'] ?? null, ]; } catch (\Exception $e) { Log::error('Polar customer portal creation failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); throw $e; } } public function processWebhook(Request $request): array { try { $payload = $request->getContent(); $signature = $request->header('Polar-Signature'); if (! $this->validateWebhook($request)) { Log::error('Invalid Polar webhook signature'); throw new \Exception('Invalid webhook signature'); } $webhookData = json_decode($payload, true); $eventType = $webhookData['type'] ?? 'unknown'; $webhookId = $webhookData['id'] ?? null; // Check for idempotency - prevent duplicate processing if ($webhookId && $this->isWebhookProcessed($webhookId)) { Log::info('Polar webhook already processed, skipping', [ 'webhook_id' => $webhookId, 'event_type' => $eventType, ]); return [ 'event_type' => $eventType, 'processed' => true, 'idempotent' => true, 'data' => [ 'webhook_id' => $webhookId, 'message' => 'Webhook already processed', ], ]; } $result = [ 'event_type' => $eventType, 'processed' => false, 'idempotent' => false, 'webhook_id' => $webhookId, 'data' => [], ]; switch ($eventType) { case 'checkout.created': $result = $this->handleCheckoutCreated($webhookData); break; case 'order.created': $result = $this->handleOrderCreated($webhookData); break; case 'order.paid': $result = $this->handleOrderPaid($webhookData); break; case 'subscription.created': $result = $this->handleSubscriptionCreated($webhookData); break; case 'subscription.active': $result = $this->handleSubscriptionActive($webhookData); break; case 'subscription.updated': $result = $this->handleSubscriptionUpdated($webhookData); break; case 'subscription.cancelled': $result = $this->handleSubscriptionCancelled($webhookData); break; case 'subscription.paused': $result = $this->handleSubscriptionPaused($webhookData); break; case 'subscription.resumed': $result = $this->handleSubscriptionResumed($webhookData); break; case 'subscription.trial_will_end': $result = $this->handleSubscriptionTrialWillEnd($webhookData); break; case 'subscription.trial_ended': $result = $this->handleSubscriptionTrialEnded($webhookData); break; case 'customer.state_changed': $result = $this->handleCustomerStateChanged($webhookData); break; default: Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]); } // Mark webhook as processed if it has an ID and was successfully processed if ($webhookId && ($result['processed'] ?? false)) { $this->markWebhookAsProcessed($webhookId, $eventType, $result); } return $result; } catch (\Exception $e) { Log::error('Polar webhook processing failed', [ 'error' => $e->getMessage(), 'webhook_id' => $webhookId ?? 'none', 'event_type' => $eventType ?? 'unknown', 'payload' => $request->getContent(), ]); throw $e; } } public function validateWebhook(Request $request): bool { try { // In sandbox mode, bypass validation for development // if ($this->sandbox) { // Log::info('Polar webhook validation bypassed in sandbox mode', [ // 'sandbox_bypass' => true, // ]); // // return true; // } // Check if we have a webhook secret if (empty($this->webhookSecret)) { Log::warning('Polar webhook validation failed: missing webhook secret'); return false; } // Extract headers $headers = [ 'webhook-id' => $request->header('webhook-id'), 'webhook-timestamp' => $request->header('webhook-timestamp'), 'webhook-signature' => $request->header('webhook-signature'), ]; $payload = $request->getContent(); Log::info('Polar webhook validation attempt using Standard Webhooks', [ 'webhook_id' => $headers['webhook-id'], 'has_signature' => ! empty($headers['webhook-signature']), 'has_timestamp' => ! empty($headers['webhook-timestamp']), 'payload_length' => strlen($payload), ]); // Create Standard Webhooks validator for Polar $webhook = WebhookFactory::createPolar($this->webhookSecret); // Verify the webhook $result = $webhook->verify($payload, $headers); Log::info('Polar webhook validation successful using Standard Webhooks', [ 'webhook_id' => $headers['webhook-id'], 'payload_size' => strlen($payload), ]); return true; } catch (WebhookVerificationException $e) { Log::warning('Polar webhook validation failed', [ 'error' => $e->getMessage(), 'webhook_id' => $request->header('webhook-id'), ]); return false; } catch (\Exception $e) { Log::error('Polar webhook validation error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return false; } } public function getConfiguration(): array { return $this->config; } public function syncSubscriptionStatus(Subscription $subscription): array { return $this->getSubscriptionDetails($subscription->provider_subscription_id); } public function getPaymentMethodDetails(string $paymentMethodId): array { try { // Polar doesn't have separate payment method IDs like Stripe // Return subscription details instead return $this->getSubscriptionDetails($paymentMethodId); } catch (\Exception $e) { Log::error('Polar payment method details retrieval failed', [ 'payment_method_id' => $paymentMethodId, 'error' => $e->getMessage(), ]); throw $e; } } public function processRefund(string $paymentId, float $amount, string $reason = ''): array { try { // Polar handles refunds through their dashboard or API Log::error('Polar refunds must be processed through Polar dashboard or API directly'); throw new \Exception('Refund processing not implemented for Polar'); } catch (\Exception $e) { Log::error('Polar refund processing failed', [ 'payment_id' => $paymentId, 'amount' => $amount, 'error' => $e->getMessage(), ]); throw $e; } } public function getTransactionHistory(User $user, array $filters = []): array { try { $customer = $this->getOrCreateCustomer($user); $params = [ 'customer_id' => $customer['id'], 'limit' => $filters['limit'] ?? 50, ]; if (isset($filters['start_date'])) { $params['start_date'] = $filters['start_date']; } if (isset($filters['end_date'])) { $params['end_date'] = $filters['end_date']; } $response = $this->makeAuthenticatedRequest('GET', '/orders', $params); if (! $response->successful()) { Log::error('Failed to retrieve Polar transaction history: '.$response->body()); } $polarOrders = $response->json(); $transactions = []; foreach ($polarOrders['items'] ?? [] as $order) { $transactions[] = [ 'id' => $order['id'], 'status' => $order['status'], 'amount' => $order['amount'] ?? 0, 'currency' => $order['currency'] ?? 'USD', 'created_at' => $order['created_at'], 'type' => 'order', ]; } // Also get subscriptions $subscriptionResponse = $this->makeAuthenticatedRequest('GET', '/subscriptions', $params); if ($subscriptionResponse->successful()) { $polarSubscriptions = $subscriptionResponse->json(); foreach ($polarSubscriptions['items'] ?? [] as $subscription) { $transactions[] = [ 'id' => $subscription['id'], 'status' => $subscription['status'], 'amount' => $subscription['amount'] ?? 0, 'currency' => $subscription['currency'] ?? 'USD', 'created_at' => $subscription['created_at'], 'type' => 'subscription', 'current_period_start' => $subscription['current_period_start'], 'current_period_end' => $subscription['current_period_end'], ]; } } // Sort by date descending usort($transactions, function ($a, $b) { return strtotime($b['created_at']) - strtotime($a['created_at']); }); return $transactions; } catch (\Exception $e) { Log::error('Polar transaction history retrieval failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); throw $e; } } public function calculateFees(float $amount): array { // Polar fees vary by plan and region (typically 5-8%) // Using 6% as default for calculation $percentageFee = $amount * 0.06; $totalFee = $percentageFee; // Polar typically doesn't have fixed fees return [ 'fixed_fee' => 0, 'percentage_fee' => $percentageFee, 'total_fee' => $totalFee, 'net_amount' => $amount - $totalFee, ]; } public function getSupportedCurrencies(): array { return ['USD']; // Polar supports USD, EUR, and other currencies, but USD is most common } public function supportsRecurring(): bool { return true; } public function supportsOneTime(): bool { return true; } // Helper methods protected function getOrCreateCustomer(User $user): array { // NEW 1:1 BINDING LOGIC: Use polar_cust_id for secure user binding // Check if user already has a Polar customer ID stored if ($user->polar_cust_id) { Log::info('User has existing Polar customer ID, using it', [ 'user_id' => $user->id, 'polar_cust_id' => $user->polar_cust_id, ]); try { $response = $this->makeAuthenticatedRequest('GET', '/customers/'.$user->polar_cust_id); if ($response->successful()) { $customer = $response->json(); Log::info('Successfully retrieved existing Polar customer', [ 'user_id' => $user->id, 'customer_id' => $customer['id'], ]); return $customer; } Log::warning('Stored Polar customer ID not found, will create new one', [ 'user_id' => $user->id, 'polar_cust_id' => $user->polar_cust_id, 'status_code' => $response->status(), ]); // Clear the invalid ID and continue to create new customer $user->update(['polar_cust_id' => null]); } catch (\Exception $e) { Log::warning('Failed to retrieve stored Polar customer, will create new one', [ 'user_id' => $user->id, 'polar_cust_id' => $user->polar_cust_id, 'error' => $e->getMessage(), ]); // Clear the invalid ID and continue to create new customer $user->update(['polar_cust_id' => null]); } } // No stored Polar customer ID, search by email to find existing customer Log::info('No stored Polar customer ID, searching by email', [ 'user_id' => $user->id, 'email' => $user->email, ]); try { $response = $this->makeAuthenticatedRequest('GET', '/customers', [ 'email' => $user->email, 'limit' => 10, ]); if ($response->successful()) { $data = $response->json(); if (! empty($data['items'])) { $customer = $data['items'][0]; // Take the first match Log::info('Found existing Polar customer by email', [ 'user_id' => $user->id, 'customer_id' => $customer['id'], 'customer_email' => $customer['email'], ]); // Store the Polar customer ID for future use $user->update(['polar_cust_id' => $customer['id']]); return $customer; } } } catch (\Exception $e) { Log::info('No existing Polar customer found by email', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); } // No existing customer found, create new one Log::info('Creating new Polar customer for user', [ 'user_id' => $user->id, 'email' => $user->email, ]); // Create new customer $customerData = [ 'email' => $user->email, 'name' => $user->name, 'external_id' => (string) $user->id, 'metadata' => [ 'user_id' => (string) $user->id, 'source' => 'laravel_app', ], ]; $response = $this->makeAuthenticatedRequest('POST', '/customers', $customerData); if (! $response->successful()) { $errorBody = $response->json(); // Check if customer already exists if (isset($errorBody['detail']) && is_array($errorBody['detail'])) { foreach ($errorBody['detail'] as $error) { if (isset($error['msg']) && ( str_contains($error['msg'], 'already exists') || str_contains($error['msg'], 'email address already exists') || str_contains($error['msg'], 'external ID already exists') )) { // Customer already exists, try to find again Log::warning('Polar customer already exists, attempting to find again', [ 'user_id' => $user->id, 'email' => $user->email, ]); // With the new 1:1 binding system, this shouldn't happen often // But if it does, we'll handle it by searching by email again Log::warning('Customer creation conflict, searching by email as fallback', [ 'user_id' => $user->id, 'email' => $user->email, ]); // Fallback: search by email one more time try { $response = $this->makeAuthenticatedRequest('GET', '/customers', [ 'email' => $user->email, 'limit' => 10, ]); if ($response->successful()) { $data = $response->json(); if (! empty($data['items'])) { $customer = $data['items'][0]; // Store the found customer ID $user->update(['polar_cust_id' => $customer['id']]); return $customer; } } } catch (\Exception $e) { Log::error('Fallback email search also failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); } throw new \Exception('Unable to create or find Polar customer account. Please contact support.'); } } } Log::error('Failed to create Polar customer', [ 'status_code' => $response->status(), 'response' => $response->json(), 'user_id' => $user->id, ]); throw new \Exception('Failed to create customer account. Please try again or contact support.'); } $customer = $response->json(); if (! isset($customer['id'])) { throw new \Exception('Invalid response from Polar API: missing customer ID'); } // Store the new Polar customer ID for 1:1 binding $user->update(['polar_cust_id' => $customer['id']]); Log::info('Created new Polar customer and stored ID for 1:1 binding', [ 'user_id' => $user->id, 'customer_id' => $customer['id'], 'external_id' => $customer['external_id'], ]); return $customer; } protected function getOrCreatePrice(Plan $plan): string { // Look for existing product by plan metadata try { $response = $this->makeAuthenticatedRequest('GET', '/products', [ 'metadata[plan_id]' => $plan->id, 'limit' => 1, ]); if ($response->successful()) { $data = $response->json(); if (! empty($data['items'])) { $product = $data['items'][0]; // Return the first price ID from the product if (! empty($product['prices'])) { return $product['prices'][0]['id']; } } } } catch (\Exception $e) { Log::info('No existing product found, will create new one', [ 'plan_id' => $plan->id, ]); } // Create new product with correct structure $productData = [ 'name' => $plan->name, 'description' => $plan->description ?? 'Subscription plan', 'recurring_interval' => 'month', 'recurring_interval_count' => 1, 'prices' => [ [ 'amount_type' => 'fixed', 'price_amount' => (int) ($plan->price * 100), // Convert to cents 'price_currency' => 'usd', 'recurring_interval' => 'month', 'recurring_interval_count' => 1, ], ], 'metadata' => [ 'plan_id' => $plan->id, 'plan_name' => $plan->name, ], ]; Log::info('Creating Polar product with data', [ 'product_data' => $productData, ]); $response = $this->makeAuthenticatedRequest('POST', '/products', $productData); if (! $response->successful()) { Log::error('Failed to create Polar product', [ 'status_code' => $response->status(), 'response' => $response->json(), 'plan_id' => $plan->id, ]); throw new \Exception('Failed to create payment plan. Please try again or contact support.'); } $product = $response->json(); if (! isset($product['id'])) { throw new \Exception('Invalid response from Polar API: missing product ID'); } // Polar returns the price ID in the prices array of the product if (! isset($product['prices'][0]['id'])) { throw new \Exception('Invalid response from Polar API: missing price ID in product'); } Log::info('Successfully created Polar product', [ 'product_id' => $product['id'], 'price_id' => $product['prices'][0]['id'], ]); return $product['prices'][0]['id']; } protected function getPolarSubscriptionId(Subscription $subscription): ?string { $providerData = $subscription->provider_data ?? []; // Try different locations where the subscription ID might be stored return $providerData['polar_subscription']['id'] ?? $providerData['subscription_id'] ?? $subscription->provider_subscription_id; } // Webhook handlers protected function handleCheckoutCreated(array $webhookData): array { $checkout = $webhookData['data']; // Update local subscription with checkout ID Subscription::where('provider_subscription_id', $checkout['id'])->update([ 'provider_data' => array_merge( Subscription::where('provider_subscription_id', $checkout['id'])->first()?->provider_data ?? [], [ 'checkout_id' => $checkout['id'], 'customer_id' => $checkout['customer_id'], 'polar_checkout' => $checkout, ] ), ]); return [ 'event_type' => 'checkout.created', 'processed' => true, 'data' => [ 'checkout_id' => $checkout['id'], 'customer_id' => $checkout['customer_id'], ], ]; } protected function handleOrderCreated(array $webhookData): array { $order = $webhookData['data']; // Find subscription by checkout ID or customer metadata $subscription = Subscription::where('provider', 'polar') ->where(function ($query) use ($order) { $query->where('provider_subscription_id', $order['checkout_id'] ?? null) ->orWhereHas('user', function ($q) use ($order) { $q->where('email', $order['customer_email'] ?? null); }); }) ->first(); if ($subscription) { $subscription->update([ 'provider_data' => array_merge($subscription->provider_data ?? [], [ 'order_id' => $order['id'], 'polar_order' => $order, 'order_created_at' => now()->toISOString(), ]), ]); } return [ 'event_type' => 'order.created', 'processed' => true, 'data' => [ 'order_id' => $order['id'], 'checkout_id' => $order['checkout_id'] ?? null, ], ]; } protected function handleOrderPaid(array $webhookData): array { $order = $webhookData['data']; // Find and activate subscription $subscription = Subscription::where('provider', 'polar') ->where(function ($query) use ($order) { $query->where('provider_subscription_id', $order['checkout_id'] ?? null) ->orWhereHas('user', function ($q) use ($order) { $q->where('email', $order['customer_email'] ?? null); }); }) ->first(); if ($subscription && $subscription->status === 'pending_payment') { $subscription->update([ 'status' => 'active', 'starts_at' => now(), 'provider_data' => array_merge($subscription->provider_data ?? [], [ 'order_paid_at' => now()->toISOString(), 'polar_order' => $order, ]), ]); } return [ 'event_type' => 'order.paid', 'processed' => true, 'data' => [ 'order_id' => $order['id'], 'subscription_id' => $subscription?->id, ], ]; } protected function handleSubscriptionCreated(array $webhookData): array { $polarSubscription = $webhookData['data']; // Find subscription using both subscription ID and checkout ID fallback $localSubscription = $this->findSubscriptionByPolarId( $polarSubscription['id'], $polarSubscription['checkout_id'] ?? null ); if ($localSubscription) { $updateData = [ 'provider_subscription_id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'activated_at' => now()->toISOString(), ]), ]; // Parse dates if available if (! empty($polarSubscription['current_period_start'])) { $updateData['starts_at'] = Carbon::parse($polarSubscription['current_period_start']); } if (! empty($polarSubscription['current_period_end'])) { $updateData['ends_at'] = Carbon::parse($polarSubscription['current_period_end']); } if (! empty($polarSubscription['trial_end'])) { $updateData['trial_ends_at'] = Carbon::parse($polarSubscription['trial_end']); } if (! empty($polarSubscription['cancelled_at'])) { $updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']); } $localSubscription->update($updateData); Log::info('Polar subscription created/updated via webhook', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, 'status' => $polarSubscription['status'], ]); } else { Log::warning('Subscription not found for Polar subscription.created webhook', [ 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, ]); } return [ 'event_type' => 'subscription.created', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], 'local_subscription_id' => $localSubscription?->id, ], ]; } protected function handleSubscriptionActive(array $webhookData): array { $polarSubscription = $webhookData['data']; // Find subscription using both subscription ID and checkout ID fallback $localSubscription = $this->findSubscriptionByPolarId( $polarSubscription['id'], $polarSubscription['checkout_id'] ?? null ); if ($localSubscription) { $updateData = [ 'status' => 'active', 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'activated_at' => now()->toISOString(), ]), ]; // Parse dates if available if (! empty($polarSubscription['current_period_start'])) { $updateData['starts_at'] = Carbon::parse($polarSubscription['current_period_start']); } if (! empty($polarSubscription['current_period_end'])) { $updateData['ends_at'] = Carbon::parse($polarSubscription['current_period_end']); } if (! empty($polarSubscription['trial_end'])) { $updateData['trial_ends_at'] = Carbon::parse($polarSubscription['trial_end']); } $localSubscription->update($updateData); Log::info('Polar subscription activated via webhook', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'status' => 'active', ]); } else { Log::warning('Subscription not found for Polar subscription.active webhook', [ 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, ]); } return [ 'event_type' => 'subscription.active', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'status' => 'active', ], ]; } protected function handleSubscriptionUpdated(array $webhookData): array { $polarSubscription = $webhookData['data']; // Find subscription using both subscription ID and checkout ID fallback $localSubscription = $this->findSubscriptionByPolarId( $polarSubscription['id'], $polarSubscription['checkout_id'] ?? null ); if ($localSubscription) { $updateData = [ 'status' => $polarSubscription['status'], 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'updated_at' => now()->toISOString(), ]), ]; // Parse dates if available if (! empty($polarSubscription['current_period_start'])) { $updateData['starts_at'] = Carbon::parse($polarSubscription['current_period_start']); } if (! empty($polarSubscription['current_period_end'])) { $updateData['ends_at'] = Carbon::parse($polarSubscription['current_period_end']); } if (! empty($polarSubscription['trial_end'])) { $updateData['trial_ends_at'] = Carbon::parse($polarSubscription['trial_end']); } if (! empty($polarSubscription['cancelled_at'])) { $updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']); } if (! empty($polarSubscription['ends_at'])) { $updateData['ends_at'] = Carbon::parse($polarSubscription['ends_at']); } $localSubscription->update($updateData); Log::info('Polar subscription updated via webhook', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], ]); } else { Log::warning('Subscription not found for Polar subscription.updated webhook', [ 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, ]); } return [ 'event_type' => 'subscription.updated', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], 'local_subscription_id' => $localSubscription?->id, ], ]; } protected function handleSubscriptionCancelled(array $webhookData): array { $polarSubscription = $webhookData['data']; // Find subscription using both subscription ID and checkout ID fallback $localSubscription = $this->findSubscriptionByPolarId( $polarSubscription['id'], $polarSubscription['checkout_id'] ?? null ); if ($localSubscription) { // Build cancellation reason from Polar data $cancellationReason = 'Polar webhook cancellation'; if (! empty($polarSubscription['customer_cancellation_reason'])) { $cancellationReason = $polarSubscription['customer_cancellation_reason']; // Add comment if available if (! empty($polarSubscription['customer_cancellation_comment'])) { $cancellationReason .= ' - Comment: '.$polarSubscription['customer_cancellation_comment']; } } elseif (! empty($polarSubscription['cancel_at_period_end']) && $polarSubscription['cancel_at_period_end']) { $cancellationReason = 'Customer cancelled via Polar portal (cancel at period end)'; } $updateData = [ 'status' => 'cancelled', 'cancellation_reason' => $cancellationReason, 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'cancelled_at_webhook' => now()->toISOString(), ]), ]; // Use Polar's cancellation timestamp if available, otherwise use now if (! empty($polarSubscription['cancelled_at'])) { $updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']); } else { $updateData['cancelled_at'] = now(); } // Set ends_at if Polar provides it (actual expiry date) if (! empty($polarSubscription['ends_at'])) { $updateData['ends_at'] = Carbon::parse($polarSubscription['ends_at']); } elseif (! empty($polarSubscription['current_period_end'])) { // If no explicit ends_at, use current_period_end as expiry $updateData['ends_at'] = Carbon::parse($polarSubscription['current_period_end']); } $localSubscription->update($updateData); Log::info('Polar subscription cancelled via webhook', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'cancellation_reason' => $cancellationReason, 'cancelled_at' => $updateData['cancelled_at']->toISOString(), 'ends_at' => $updateData['ends_at']?->toISOString(), ]); } else { Log::warning('Subscription not found for Polar subscription.cancelled webhook', [ 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, ]); } return [ 'event_type' => 'subscription.cancelled', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'cancellation_reason' => $cancellationReason ?? 'Polar webhook cancellation', 'local_subscription_id' => $localSubscription?->id, ], ]; } protected function handleSubscriptionPaused(array $webhookData): array { $polarSubscription = $webhookData['data']; $localSubscription = $this->findSubscriptionByPolarId( $polarSubscription['id'], $polarSubscription['checkout_id'] ?? null ); if (! $localSubscription) { Log::warning('Polar paused webhook: subscription not found', [ 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, ]); return [ 'event_type' => 'subscription.paused', 'processed' => false, 'error' => 'Subscription not found', 'data' => [ 'subscription_id' => $polarSubscription['id'], ], ]; } // Parse dates from Polar response $pausedAt = null; if (isset($polarSubscription['paused_at'])) { $pausedAt = \Carbon\Carbon::parse($polarSubscription['paused_at']); } // Update local subscription $localSubscription->update([ 'status' => 'paused', 'paused_at' => $pausedAt, 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'paused_at' => $pausedAt?->toISOString(), 'pause_reason' => $polarSubscription['pause_reason'] ?? null, ]), ]); Log::info('Polar subscription paused', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'paused_at' => $pausedAt?->toISOString(), ]); return [ 'event_type' => 'subscription.paused', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'local_subscription_id' => $localSubscription->id, 'paused_at' => $pausedAt?->toISOString(), ], ]; } protected function handleSubscriptionResumed(array $webhookData): array { $polarSubscription = $webhookData['data']; $localSubscription = $this->findSubscriptionByPolarId( $polarSubscription['id'], $polarSubscription['checkout_id'] ?? null ); if (! $localSubscription) { Log::warning('Polar resumed webhook: subscription not found', [ 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, ]); return [ 'event_type' => 'subscription.resumed', 'processed' => false, 'error' => 'Subscription not found', 'data' => [ 'subscription_id' => $polarSubscription['id'], ], ]; } // Parse dates from Polar response $resumedAt = null; if (isset($polarSubscription['resumed_at'])) { $resumedAt = \Carbon\Carbon::parse($polarSubscription['resumed_at']); } // Handle current_period_start/end for resumed subscription $startsAt = null; $endsAt = null; if (isset($polarSubscription['current_period_start'])) { $startsAt = \Carbon\Carbon::parse($polarSubscription['current_period_start']); } if (isset($polarSubscription['current_period_end'])) { $endsAt = \Carbon\Carbon::parse($polarSubscription['current_period_end']); } // Update local subscription $localSubscription->update([ 'status' => 'active', 'resumed_at' => $resumedAt, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'resumed_at' => $resumedAt?->toISOString(), 'resume_reason' => $polarSubscription['resume_reason'] ?? null, ]), ]); Log::info('Polar subscription resumed', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'resumed_at' => $resumedAt?->toISOString(), ]); return [ 'event_type' => 'subscription.resumed', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'local_subscription_id' => $localSubscription->id, 'resumed_at' => $resumedAt?->toISOString(), ], ]; } protected function handleSubscriptionTrialWillEnd(array $webhookData): array { $polarSubscription = $webhookData['data']; $localSubscription = $this->findSubscriptionByPolarId( $polarSubscription['id'], $polarSubscription['checkout_id'] ?? null ); if (! $localSubscription) { Log::warning('Polar trial_will_end webhook: subscription not found', [ 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, ]); return [ 'event_type' => 'subscription.trial_will_end', 'processed' => false, 'error' => 'Subscription not found', 'data' => [ 'subscription_id' => $polarSubscription['id'], ], ]; } // Parse trial end date from Polar response $trialEndsAt = null; if (isset($polarSubscription['trial_ends_at'])) { $trialEndsAt = \Carbon\Carbon::parse($polarSubscription['trial_ends_at']); } // Update local subscription with trial information $localSubscription->update([ 'trial_ends_at' => $trialEndsAt, 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'trial_will_end_sent_at' => now()->toISOString(), 'trial_ends_at' => $trialEndsAt?->toISOString(), ]), ]); Log::info('Polar subscription trial will end soon', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'trial_ends_at' => $trialEndsAt?->toISOString(), ]); return [ 'event_type' => 'subscription.trial_will_end', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'local_subscription_id' => $localSubscription->id, 'trial_ends_at' => $trialEndsAt?->toISOString(), ], ]; } protected function handleSubscriptionTrialEnded(array $webhookData): array { $polarSubscription = $webhookData['data']; $localSubscription = $this->findSubscriptionByPolarId( $polarSubscription['id'], $polarSubscription['checkout_id'] ?? null ); if (! $localSubscription) { Log::warning('Polar trial_ended webhook: subscription not found', [ 'polar_subscription_id' => $polarSubscription['id'], 'checkout_id' => $polarSubscription['checkout_id'] ?? null, ]); return [ 'event_type' => 'subscription.trial_ended', 'processed' => false, 'error' => 'Subscription not found', 'data' => [ 'subscription_id' => $polarSubscription['id'], ], ]; } // Parse dates from Polar response $trialEndedAt = null; if (isset($polarSubscription['trial_ended_at'])) { $trialEndedAt = \Carbon\Carbon::parse($polarSubscription['trial_ended_at']); } // Handle current_period_start/end for converted subscription $startsAt = null; $endsAt = null; if (isset($polarSubscription['current_period_start'])) { $startsAt = \Carbon\Carbon::parse($polarSubscription['current_period_start']); } if (isset($polarSubscription['current_period_end'])) { $endsAt = \Carbon\Carbon::parse($polarSubscription['current_period_end']); } // Update local subscription - trial has ended, convert to active or handle accordingly $localSubscription->update([ 'status' => $polarSubscription['status'] ?? 'active', // Usually becomes active 'trial_ends_at' => now(), // Mark trial as ended 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'trial_ended_at' => $trialEndedAt?->toISOString(), 'trial_converted_to' => $polarSubscription['status'] ?? 'active', ]), ]); Log::info('Polar subscription trial ended', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'trial_ended_at' => $trialEndedAt?->toISOString(), 'new_status' => $polarSubscription['status'] ?? 'active', ]); return [ 'event_type' => 'subscription.trial_ended', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'local_subscription_id' => $localSubscription->id, 'trial_ended_at' => $trialEndedAt?->toISOString(), 'new_status' => $polarSubscription['status'] ?? 'active', ], ]; } protected function handleCustomerStateChanged(array $webhookData): array { $customer = $webhookData['data']; Log::info('Processing Polar customer state changed webhook', [ 'customer_id' => $customer['id'], 'customer_email' => $customer['email'], 'external_id' => $customer['external_id'] ?? null, 'active_subscriptions_count' => count($customer['active_subscriptions'] ?? []), ]); // Find user by external_id or email $user = null; if (! empty($customer['external_id'])) { $user = \App\Models\User::where('id', $customer['external_id'])->first(); } if (! $user) { $user = \App\Models\User::where('email', $customer['email'])->first(); } if (! $user) { Log::warning('Customer state changed: User not found', [ 'customer_email' => $customer['email'], 'external_id' => $customer['external_id'] ?? null, ]); return [ 'event_type' => 'customer.state_changed', 'processed' => false, 'data' => [ 'error' => 'User not found', 'customer_email' => $customer['email'], ], ]; } // Update user's Polar customer ID if needed if (empty($user->polar_cust_id) || $user->polar_cust_id !== $customer['id']) { $user->update(['polar_cust_id' => $customer['id']]); Log::info('Updated user Polar customer ID', [ 'user_id' => $user->id, 'polar_cust_id' => $customer['id'], ]); } // Process active subscriptions from the webhook $processedSubscriptions = 0; foreach ($customer['active_subscriptions'] ?? [] as $activeSub) { $subscription = Subscription::where('user_id', $user->id) ->where('provider', 'polar') ->where(function ($query) use ($activeSub) { $query->where('provider_subscription_id', $activeSub['id']) ->orWhere('provider_checkout_id', $activeSub['id']); }) ->first(); if ($subscription) { // Update subscription with latest data from Polar $subscription->update([ 'provider_subscription_id' => $activeSub['id'], 'status' => $activeSub['status'], 'starts_at' => $activeSub['started_at'] ? \Carbon\Carbon::parse($activeSub['started_at']) : null, 'ends_at' => $activeSub['ends_at'] ? \Carbon\Carbon::parse($activeSub['ends_at']) : null, 'cancelled_at' => $activeSub['canceled_at'] ? \Carbon\Carbon::parse($activeSub['canceled_at']) : null, 'provider_data' => array_merge($subscription->provider_data ?? [], [ 'customer_state_changed_at' => now()->toISOString(), 'polar_subscription_data' => $activeSub, 'customer_metadata' => $customer['metadata'] ?? [], ]), ]); $processedSubscriptions++; Log::info('Updated subscription from customer state changed', [ 'subscription_id' => $subscription->id, 'polar_subscription_id' => $activeSub['id'], 'status' => $activeSub['status'], ]); } else { Log::info('Active subscription not found in local database', [ 'user_id' => $user->id, 'polar_subscription_id' => $activeSub['id'], 'status' => $activeSub['status'], ]); } } return [ 'event_type' => 'customer.state_changed', 'processed' => true, 'data' => [ 'customer_id' => $customer['id'], 'user_id' => $user->id, 'processed_subscriptions' => $processedSubscriptions, 'active_subscriptions_count' => count($customer['active_subscriptions'] ?? []), ], ]; } // Additional interface methods public function getSubscriptionMetadata(Subscription $subscription): array { return $subscription->provider_data['polar_subscription'] ?? []; } public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool { try { $subscription->update([ 'provider_data' => array_merge($subscription->provider_data ?? [], [ 'metadata' => $metadata, ]), ]); return true; } catch (\Exception $e) { Log::error('Failed to update Polar subscription metadata', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); return false; } } public function startTrial(Subscription $subscription, int $trialDays): bool { // Polar handles trials through checkout creation // This would require creating a new checkout with trial period return false; } public function applyCoupon(Subscription $subscription, string $couponCode): array { try { $polarSubscriptionId = $this->getPolarSubscriptionId($subscription); if (! $polarSubscriptionId) { throw new \Exception('No Polar subscription found'); } $response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/discount', [ 'coupon_code' => $couponCode, ]); if (! $response->successful()) { Log::error('Failed to apply Polar coupon: '.$response->body()); } return $response->json(); } catch (\Exception $e) { Log::error('Polar coupon application failed', [ 'subscription_id' => $subscription->id, 'coupon_code' => $couponCode, 'error' => $e->getMessage(), ]); throw $e; } } public function removeCoupon(Subscription $subscription): bool { try { $polarSubscriptionId = $this->getPolarSubscriptionId($subscription); if (! $polarSubscriptionId) { return false; } $response = $this->makeAuthenticatedRequest('DELETE', '/subscriptions/'.$polarSubscriptionId.'/discount'); return $response->successful(); } catch (\Exception $e) { Log::error('Polar coupon removal failed', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); return false; } } public function getUpcomingInvoice(Subscription $subscription): array { try { $polarSubscriptionId = $this->getPolarSubscriptionId($subscription); if (! $polarSubscriptionId) { return [ 'amount_due' => 0, 'currency' => 'USD', 'next_payment_date' => null, ]; } $response = $this->makeAuthenticatedRequest('GET', '/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice'); if (! $response->successful()) { Log::error('Failed to retrieve Polar upcoming invoice: '.$response->body()); } $invoice = $response->json(); return [ 'amount_due' => $invoice['amount_due'] / 100, // Convert from cents 'currency' => $invoice['currency'], 'next_payment_date' => $invoice['next_payment_date'], ]; } catch (\Exception $e) { Log::error('Polar upcoming invoice retrieval failed', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); throw $e; } } public function retryFailedPayment(Subscription $subscription): array { // Polar doesn't have explicit retry logic - payments are retried automatically return $this->syncSubscriptionStatus($subscription); } public function canModifySubscription(Subscription $subscription): bool { try { $details = $this->getSubscriptionDetails($subscription->provider_subscription_id); return in_array($details['status'], ['active', 'trialing']); } catch (\Exception $e) { return false; } } public function getCancellationTerms(Subscription $subscription): array { return [ 'immediate_cancellation' => true, 'refund_policy' => 'no_pro_rated_refunds', 'cancellation_effective' => 'immediately', 'billing_cycle_proration' => false, ]; } public function exportSubscriptionData(Subscription $subscription): array { return [ 'provider' => 'polar', 'provider_subscription_id' => $subscription->provider_subscription_id, 'data' => $subscription->provider_data, ]; } public function importSubscriptionData(User $user, array $subscriptionData): array { Log::error('Import to Polar payments not implemented'); throw new \Exception('Import subscription data not implemented for Polar'); } /** * Sanitize Polar API error messages to prevent exposing sensitive information */ private function sanitizePolarErrorMessage(array $responseBody, int $statusCode): string { // Handle specific error types with user-friendly messages if (isset($responseBody['error'])) { $errorType = $responseBody['error']; return match ($errorType) { 'RequestValidationError' => $this->handleValidationError($responseBody), 'AuthenticationError' => 'Payment service authentication failed. Please try again.', 'InsufficientPermissions' => 'Insufficient permissions to process payment. Please contact support.', 'ResourceNotFound' => 'Payment resource not found. Please try again.', 'RateLimitExceeded' => 'Too many payment requests. Please wait and try again.', 'PaymentRequired' => 'Payment required to complete this action.', default => 'Payment processing failed. Please try again or contact support.', }; } // Handle validation errors in detail array if (isset($responseBody['detail']) && is_array($responseBody['detail'])) { return $this->handleValidationError($responseBody); } // Generic error based on status code return match ($statusCode) { 400 => 'Invalid payment request. Please check your information and try again.', 401 => 'Payment authentication failed. Please try again.', 403 => 'Payment authorization failed. Please contact support.', 404 => 'Payment service not available. Please try again.', 429 => 'Too many payment requests. Please wait and try again.', 500 => 'Payment service error. Please try again later.', 502, 503, 504 => 'Payment service temporarily unavailable. Please try again later.', default => 'Payment processing failed. Please try again or contact support.', }; } /** * Handle validation errors and extract user-friendly messages */ private function handleValidationError(array $responseBody): string { if (! isset($responseBody['detail']) || ! is_array($responseBody['detail'])) { return 'Invalid payment information provided. Please check your details and try again.'; } $errors = []; foreach ($responseBody['detail'] as $error) { if (isset($error['msg']) && isset($error['loc'])) { $field = $this->extractFieldName($error['loc']); $message = $this->sanitizeValidationMessage($error['msg'], $field); $errors[] = $message; } } if (empty($errors)) { return 'Invalid payment information provided. Please check your details and try again.'; } return implode(' ', array_unique($errors)); } /** * Extract field name from error location path */ private function extractFieldName(array $loc): string { if (empty($loc)) { return 'field'; } // Get the last element of the location array $field = end($loc); // Convert to user-friendly field names return match ($field) { 'customer_email' => 'email address', 'customer_name' => 'name', 'products' => 'product selection', 'product_id' => 'product selection', 'product_price_id' => 'product selection', 'success_url' => 'redirect settings', 'cancel_url' => 'redirect settings', default => strtolower(str_replace('_', ' ', $field)), }; } /** * Sanitize validation message to remove sensitive information */ private function sanitizeValidationMessage(string $message, string $field): string { // Remove email addresses and other sensitive data from error messages $sanitized = preg_replace('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', '[email]', $message); // Remove domain names and URLs $sanitized = preg_replace('/\b[a-z0-9.-]+\.[a-z]{2,}\b/i', '[domain]', $sanitized); // Remove UUIDs and other identifiers $sanitized = preg_replace('/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i', '[ID]', $sanitized); // Convert technical terms to user-friendly language $sanitized = str_replace([ 'is not a valid email address', 'does not exist', 'Field required', 'value_error', 'missing', 'type_error', ], [ 'is not valid', 'is not available', 'is required', 'is invalid', 'is missing', 'is not correct', ], $sanitized); // Add field context return "The {$field} {$sanitized}"; } /** * Check if webhook has already been processed */ protected function isWebhookProcessed(string $webhookId): bool { return cache()->has("polar_webhook_processed_{$webhookId}"); } /** * Mark webhook as processed to prevent duplicate processing */ protected function markWebhookAsProcessed(string $webhookId, string $eventType, array $result): void { // Store webhook processing record for 24 hours cache()->put("polar_webhook_processed_{$webhookId}", [ 'webhook_id' => $webhookId, 'event_type' => $eventType, 'processed_at' => now()->toISOString(), 'result' => $result, ], now()->addHours(24)); Log::info('Polar webhook marked as processed', [ 'webhook_id' => $webhookId, 'event_type' => $eventType, 'processed_at' => now()->toISOString(), ]); } /** * Find subscription by Polar subscription ID or checkout ID */ protected function findSubscriptionByPolarId(string $polarSubscriptionId, ?string $checkoutId = null): ?Subscription { $query = Subscription::where('provider', 'polar'); // First try by subscription ID $subscription = $query->where('provider_subscription_id', $polarSubscriptionId)->first(); // If not found and checkout ID is provided, try by checkout ID if (! $subscription && $checkoutId) { $subscription = Subscription::where('provider', 'polar') ->where('provider_checkout_id', $checkoutId) ->first(); // If found by checkout ID, update the subscription with the actual subscription ID if ($subscription) { $subscription->update(['provider_subscription_id' => $polarSubscriptionId]); Log::info('Updated subscription with Polar subscription ID', [ 'subscription_id' => $subscription->id, 'checkout_id' => $checkoutId, 'provider_subscription_id' => $polarSubscriptionId, ]); } } return $subscription; } /** * Get webhook processing statistics */ public function getWebhookStats(): array { // This could be enhanced to use a database table for more permanent stats $stats = [ 'total_processed' => 0, 'recent_processed' => 0, 'error_rate' => 0, ]; // For now, return basic stats - could be expanded with database tracking return $stats; } }