config = array_merge([ 'api_key' => config('services.polar.api_key'), 'webhook_secret' => config('services.polar.webhook_secret'), 'success_url' => route('payment.success'), 'cancel_url' => route('payment.cancel'), 'webhook_url' => route('webhook.payment', 'polar'), ], $config); } public function getName(): string { return 'polar'; } public function isActive(): bool { return ! empty($this->config['api_key']); } public function createSubscription(User $user, Plan $plan, array $options = []): array { try { // Get or create Polar customer $customer = $this->getOrCreateCustomer($user); // Get or create Polar product/price $priceId = $this->getOrCreatePrice($plan); // Create checkout session $checkoutData = [ 'customer_id' => $customer['id'], 'price_id' => $priceId, 'success_url' => $this->config['success_url'], 'cancel_url' => $this->config['cancel_url'], 'customer_email' => $user->email, 'customer_name' => $user->name, 'metadata' => [ 'user_id' => $user->id, 'plan_id' => $plan->id, 'plan_name' => $plan->name, ], ]; // Add trial information if specified if (isset($options['trial_days']) && $options['trial_days'] > 0) { $checkoutData['trial_period_days'] = $options['trial_days']; } $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->post($this->apiBaseUrl.'/v1/checkouts', $checkoutData); if (! $response->successful()) { throw new \Exception('Polar checkout creation failed: '.$response->body()); } $checkout = $response->json(); // Create subscription record $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(), ], ]); return [ '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(), ]; } 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 = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->delete($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId, [ 'cancellation_reason' => $reason, ]); if (! $response->successful()) { throw new \Exception('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 = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->patch($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId, [ 'price_id' => $newPriceId, 'proration_behavior' => 'create_prorations', ]); if (! $response->successful()) { throw new \Exception('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 = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/pause'); if (! $response->successful()) { throw new \Exception('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 = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/resume'); if (! $response->successful()) { throw new \Exception('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 = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], ])->get($this->apiBaseUrl.'/v1/subscriptions/'.$providerSubscriptionId); if (! $response->successful()) { throw new \Exception('Failed to retrieve Polar subscription: '.$response->body()); } $polarSubscription = $response->json(); 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['updated_at'], ]; } 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 { return $this->createSubscription($user, $plan, $options); } public function createCustomerPortalSession(User $user): array { try { $customer = $this->getOrCreateCustomer($user); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->post($this->apiBaseUrl.'/v1/customer-portal', [ 'customer_id' => $customer['id'], 'return_url' => route('dashboard'), ]); if (! $response->successful()) { throw new \Exception('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)) { throw new \Exception('Invalid Polar 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 'subscription.created': $result = $this->handleSubscriptionCreated($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; 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->config['webhook_secret']) { return false; } $expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']); 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 // For now, we'll return a NotImplementedError throw new \Exception('Polar refunds must be processed through Polar dashboard or API directly'); } 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 = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], ])->get($this->apiBaseUrl.'/v1/subscriptions', $params); if (! $response->successful()) { throw new \Exception('Failed to retrieve Polar transaction history: '.$response->body()); } $polarSubscriptions = $response->json(); $transactions = []; foreach ($polarSubscriptions['data'] ?? [] as $subscription) { $transactions[] = [ 'id' => $subscription['id'], 'status' => $subscription['status'], 'amount' => $subscription['amount'] ?? 0, 'currency' => $subscription['currency'] ?? 'USD', 'created_at' => $subscription['created_at'], 'current_period_start' => $subscription['current_period_start'], 'current_period_end' => $subscription['current_period_end'], ]; } 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 { // First, try to find existing customer by email $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], ])->get($this->apiBaseUrl.'/v1/customers', [ 'email' => $user->email, ]); if ($response->successful() && ! empty($response->json()['data'])) { return $response->json()['data'][0]; } // Create new customer $customerData = [ 'email' => $user->email, 'name' => $user->name, 'metadata' => [ 'user_id' => $user->id, ], ]; $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->post($this->apiBaseUrl.'/v1/customers', $customerData); if (! $response->successful()) { throw new \Exception('Failed to create Polar customer: '.$response->body()); } return $response->json(); } protected function getOrCreatePrice(Plan $plan): string { // Look for existing price by plan metadata $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], ])->get($this->apiBaseUrl.'/v1/products', [ 'metadata[plan_id]' => $plan->id, ]); if ($response->successful() && ! empty($response->json()['data'])) { $product = $response->json()['data'][0]; // Get the price for this product $priceResponse = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], ])->get($this->apiBaseUrl.'/v1/prices', [ 'product_id' => $product['id'], 'recurring_interval' => 'month', ]); if ($priceResponse->successful() && ! empty($priceResponse->json()['data'])) { return $priceResponse->json()['data'][0]['id']; } } // Create new product and price $productData = [ 'name' => $plan->name, 'description' => $plan->description ?? 'Subscription plan', 'type' => 'service', 'metadata' => [ 'plan_id' => $plan->id, 'plan_name' => $plan->name, ], ]; $productResponse = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->post($this->apiBaseUrl.'/v1/products', $productData); if (! $productResponse->successful()) { throw new \Exception('Failed to create Polar product: '.$productResponse->body()); } $product = $productResponse->json(); // Create price for the product $priceData = [ 'product_id' => $product['id'], 'amount' => (int) ($plan->price * 100), // Convert to cents 'currency' => 'usd', 'recurring' => [ 'interval' => 'month', 'interval_count' => 1, ], ]; $priceResponse = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->post($this->apiBaseUrl.'/v1/prices', $priceData); if (! $priceResponse->successful()) { throw new \Exception('Failed to create Polar price: '.$priceResponse->body()); } $price = $priceResponse->json(); return $price['id']; } protected function getPolarSubscriptionId(Subscription $subscription): ?string { $providerData = $subscription->provider_data ?? []; return $providerData['polar_subscription']['id'] ?? null; } // Webhook handlers protected function handleCheckoutCreated(array $webhookData): array { $checkout = $webhookData['data']['object']; // Update local subscription with checkout ID Subscription::where('stripe_id', $checkout['id'])->update([ '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 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 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 handleSubscriptionPaused(array $webhookData): array { $polarSubscription = $webhookData['data']['object']; Subscription::where('provider', 'polar') ->where('provider_subscription_id', $polarSubscription['id']) ->update([ 'status' => 'paused', 'paused_at' => now(), ]); return [ 'event_type' => 'subscription.paused', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], ], ]; } protected function handleSubscriptionResumed(array $webhookData): array { $polarSubscription = $webhookData['data']['object']; Subscription::where('provider', 'polar') ->where('provider_subscription_id', $polarSubscription['id']) ->update([ 'status' => 'active', 'resumed_at' => now(), ]); return [ 'event_type' => 'subscription.resumed', 'processed' => true, 'data' => [ 'subscription_id' => $polarSubscription['id'], ], ]; } // 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 { // Polar supports discount codes try { $polarSubscriptionId = $this->getPolarSubscriptionId($subscription); if (! $polarSubscriptionId) { throw new \Exception('No Polar subscription found'); } $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], 'Content-Type' => 'application/json', ])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/discount', [ 'coupon_code' => $couponCode, ]); if (! $response->successful()) { throw new \Exception('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 = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], ])->delete($this->apiBaseUrl.'/v1/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 = Http::withHeaders([ 'Authorization' => 'Bearer '.$this->config['api_key'], ])->get($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice'); if (! $response->successful()) { throw new \Exception('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 { throw new \Exception('Import to Polar payments not implemented'); } }