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; } } protected 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' => 'recurring', 'stripe_id' => $checkout['id'], // Using stripe_id field for Polar checkout ID 'stripe_status' => 'pending', 'provider' => $this->getName(), 'provider_subscription_id' => $checkout['id'], '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(); 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'], 'current_period_end' => $polarSubscription['current_period_end'], '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'], 'updated_at' => $polarSubscription['modified_at'] ?? 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); $response = $this->makeAuthenticatedRequest('POST', '/customer-portal', [ 'customer_id' => $customer['id'], 'return_url' => route('dashboard'), ]); if (! $response->successful()) { Log::error('Polar customer portal creation failed: '.$response->body()); } $portal = $response->json(); return [ 'portal_url' => $portal['url'], 'customer_id' => $customer['id'], ]; } 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'; $result = [ 'event_type' => $eventType, 'processed' => false, '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 'customer.state_changed': $result = $this->handleCustomerStateChanged($webhookData); break; default: Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]); } return $result; } catch (\Exception $e) { Log::error('Polar webhook processing failed', [ 'error' => $e->getMessage(), 'payload' => $request->getContent(), ]); throw $e; } } public function validateWebhook(Request $request): bool { try { $signature = $request->header('Polar-Signature'); $payload = $request->getContent(); if (! $signature || ! $this->webhookSecret) { return false; } $expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret); return hash_equals($signature, $expectedSignature); } catch (\Exception $e) { Log::warning('Polar webhook validation failed', [ 'error' => $e->getMessage(), ]); 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; } else { 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']['object']; // 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']['object']; // 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']['object']; // 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']['object']; // Find and update local subscription $localSubscription = Subscription::where('provider', 'polar') ->where('provider_subscription_id', $polarSubscription['checkout_id']) ->first(); if ($localSubscription) { $localSubscription->update([ 'stripe_id' => $polarSubscription['id'], 'provider_subscription_id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], 'starts_at' => Carbon::parse($polarSubscription['current_period_start']), 'ends_at' => Carbon::parse($polarSubscription['current_period_end']), 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'activated_at' => now()->toISOString(), ]), ]); } return [ 'event_type' => 'subscription.created', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], ], ]; } protected function handleSubscriptionActive(array $webhookData): array { $polarSubscription = $webhookData['data']['object']; Subscription::where('provider', 'polar') ->where('provider_subscription_id', $polarSubscription['id']) ->update([ 'status' => 'active', 'starts_at' => Carbon::parse($polarSubscription['current_period_start']), 'ends_at' => Carbon::parse($polarSubscription['current_period_end']), 'provider_data' => array_merge( Subscription::where('provider', 'polar') ->where('provider_subscription_id', $polarSubscription['id']) ->first()?->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'activated_at' => now()->toISOString(), ] ), ]); return [ 'event_type' => 'subscription.active', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'status' => 'active', ], ]; } protected function handleSubscriptionUpdated(array $webhookData): array { $polarSubscription = $webhookData['data']['object']; Subscription::where('provider', 'polar') ->where('provider_subscription_id', $polarSubscription['id']) ->update([ 'status' => $polarSubscription['status'], 'provider_data' => [ 'polar_subscription' => $polarSubscription, 'updated_at' => now()->toISOString(), ], ]); return [ 'event_type' => 'subscription.updated', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], ], ]; } protected function handleSubscriptionCancelled(array $webhookData): array { $polarSubscription = $webhookData['data']['object']; Subscription::where('provider', 'polar') ->where('provider_subscription_id', $polarSubscription['id']) ->update([ 'status' => 'cancelled', 'cancelled_at' => now(), 'cancellation_reason' => 'Polar webhook cancellation', ]); return [ 'event_type' => 'subscription.cancelled', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], ], ]; } protected function handleCustomerStateChanged(array $webhookData): array { $customer = $webhookData['data']['object']; // Update all subscriptions for this customer Subscription::whereHas('user', function ($query) use ($customer) { $query->where('email', $customer['email']); })->where('provider', 'polar')->get()->each(function ($subscription) use ($customer) { $subscription->update([ 'provider_data' => array_merge($subscription->provider_data ?? [], [ 'customer_state' => $customer['state'], 'customer_updated_at' => now()->toISOString(), ]), ]); }); return [ 'event_type' => 'customer.state_changed', 'processed' => true, 'data' => [ 'customer_id' => $customer['id'], 'state' => $customer['state'], ], ]; } // 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}"; } }