feat(payment): integrate Polar.sh payment provider with checkout flow

- Build PolarProvider from scratch with proper HTTP API integration
  - Add encrypted configuration loading from payment_providers table via model
  - Implement sandbox/live environment switching with proper credential handling
  - Fix product creation API structure for Polar.sh requirements
  - Add comprehensive error handling and logging throughout checkout flow
  - Fix PaymentController checkout URL handling to support Polar's checkout_url response
  - Add debug logging for troubleshooting checkout session creation
  - Support both regular and trial checkout flows for Polar payments
This commit is contained in:
idevakk
2025-12-04 10:29:25 -08:00
parent c2c18f2406
commit 75086ad83b
6 changed files with 770 additions and 352 deletions

View File

@@ -7,6 +7,7 @@ use App\Models\User;
use App\Services\Payments\PaymentOrchestrator; use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class PaymentController extends Controller class PaymentController extends Controller
@@ -75,8 +76,23 @@ class PaymentController extends Controller
], 404); ], 404);
} }
Log::info('PaymentController: Creating checkout session', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'requested_provider' => $provider,
'is_trial' => $isTrial,
'options_count' => count($options),
]);
$result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $options); $result = $this->orchestrator->createCheckoutSession($user, $plan, $provider, $options);
Log::info('PaymentController: Returning successful checkout response', [
'result_keys' => array_keys($result),
'has_checkout_url' => isset($result['checkout_url']),
'checkout_url' => $result['checkout_url'] ?? 'missing',
'provider_subscription_id' => $result['provider_subscription_id'] ?? 'missing',
]);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => $result, 'data' => $result,
@@ -277,6 +293,14 @@ class PaymentController extends Controller
'is_trial' => false, 'is_trial' => false,
]); ]);
Log::info('PaymentController: enhancedCheckout result', [
'result_keys' => array_keys($result),
'has_redirect_url' => isset($result['redirect_url']),
'has_session_url' => isset($result['session_url']),
'has_checkout_url' => isset($result['checkout_url']),
'checkout_url' => $result['checkout_url'] ?? 'missing',
]);
// Redirect to provider's checkout page // Redirect to provider's checkout page
if (isset($result['redirect_url'])) { if (isset($result['redirect_url'])) {
return redirect($result['redirect_url']); return redirect($result['redirect_url']);
@@ -287,6 +311,11 @@ class PaymentController extends Controller
return redirect($result['session_url']); return redirect($result['session_url']);
} }
// Polar checkout URL handling
if (isset($result['checkout_url'])) {
return redirect($result['checkout_url']);
}
session()->flash('error', 'Unable to create checkout session. Please try again.'); session()->flash('error', 'Unable to create checkout session. Please try again.');
return redirect()->route('dashboard'); return redirect()->route('dashboard');
@@ -332,6 +361,14 @@ class PaymentController extends Controller
'trial_requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? true, 'trial_requires_payment_method' => $trialConfig?->trial_requires_payment_method ?? true,
]); ]);
Log::info('PaymentController: trialCheckout result', [
'result_keys' => array_keys($result),
'has_redirect_url' => isset($result['redirect_url']),
'has_session_url' => isset($result['session_url']),
'has_checkout_url' => isset($result['checkout_url']),
'checkout_url' => $result['checkout_url'] ?? 'missing',
]);
// Redirect to provider's checkout page // Redirect to provider's checkout page
if (isset($result['redirect_url'])) { if (isset($result['redirect_url'])) {
return redirect($result['redirect_url']); return redirect($result['redirect_url']);
@@ -342,6 +379,11 @@ class PaymentController extends Controller
return redirect($result['session_url']); return redirect($result['session_url']);
} }
// Polar checkout URL handling
if (isset($result['checkout_url'])) {
return redirect($result['checkout_url']);
}
session()->flash('error', 'Unable to create trial checkout session. Please try again.'); session()->flash('error', 'Unable to create trial checkout session. Please try again.');
return redirect()->route('dashboard'); return redirect()->route('dashboard');

View File

@@ -4,14 +4,17 @@ namespace App\Services\Payments;
use App\Contracts\Payments\PaymentProviderContract; use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Coupon; use App\Models\Coupon;
use App\Models\CouponUsage;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\SubscriptionChange; use App\Models\SubscriptionChange;
use App\Models\TrialExtension;
use App\Models\User; use App\Models\User;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PaymentOrchestrator class PaymentOrchestrator
{ {
@@ -588,16 +591,41 @@ class PaymentOrchestrator
*/ */
protected function getProviderForPlan(Plan $plan, ?string $providerName = null): PaymentProviderContract protected function getProviderForPlan(Plan $plan, ?string $providerName = null): PaymentProviderContract
{ {
Log::info('PaymentOrchestrator: Getting provider for plan', [
'plan_id' => $plan->id,
'requested_provider' => $providerName,
]);
if ($providerName) { if ($providerName) {
$provider = $this->providerRegistry->get($providerName); $provider = $this->providerRegistry->get($providerName);
Log::info('PaymentOrchestrator: Checking specific provider', [
'provider_name' => $providerName,
'provider_exists' => $provider ? true : false,
'provider_active' => $provider?->isActive(),
'provider_supported' => $provider ? $this->isProviderSupportedForPlan($provider, $plan) : false,
]);
if ($provider && $provider->isActive() && $this->isProviderSupportedForPlan($provider, $plan)) { if ($provider && $provider->isActive() && $this->isProviderSupportedForPlan($provider, $plan)) {
Log::info('PaymentOrchestrator: Using requested provider', [
'provider' => $providerName,
]);
return $provider; return $provider;
} }
} }
// Find the first active provider that supports this plan // Find the first active provider that supports this plan
foreach ($this->providerRegistry->getActiveProviders() as $provider) { foreach ($this->providerRegistry->getActiveProviders() as $provider) {
Log::info('PaymentOrchestrator: Checking fallback provider', [
'provider' => $provider->getName(),
'supported' => $this->isProviderSupportedForPlan($provider, $plan),
]);
if ($this->isProviderSupportedForPlan($provider, $plan)) { if ($this->isProviderSupportedForPlan($provider, $plan)) {
Log::info('PaymentOrchestrator: Using fallback provider', [
'provider' => $provider->getName(),
]);
return $provider; return $provider;
} }
} }
@@ -625,10 +653,13 @@ class PaymentOrchestrator
*/ */
protected function isProviderSupportedForPlan(PaymentProviderContract $provider, Plan $plan): bool protected function isProviderSupportedForPlan(PaymentProviderContract $provider, Plan $plan): bool
{ {
// Check if plan has provider-specific configuration // Use the same approach as Plan::supportsProvider() - check database relationship
$providerConfig = $plan->details['providers'][$provider->getName()] ?? null; $isSupported = $plan->planProviders()
->where('provider', $provider->getName())
->where('is_enabled', true)
->exists();
if (! $providerConfig || ! ($providerConfig['enabled'] ?? false)) { if (! $isSupported) {
return false; return false;
} }

View File

@@ -3,6 +3,7 @@
namespace App\Services\Payments; namespace App\Services\Payments;
use App\Contracts\Payments\PaymentProviderContract; use App\Contracts\Payments\PaymentProviderContract;
use App\Models\PaymentProvider as PaymentProviderModel;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -15,8 +16,8 @@ class ProviderRegistry
public function __construct() public function __construct()
{ {
$this->loadConfigurations(); $this->loadConfigurationsFromDatabase();
$this->registerDefaultProviders(); $this->registerProvidersFromDatabase();
} }
/** /**
@@ -93,14 +94,30 @@ class ProviderRegistry
*/ */
public function updateConfiguration(string $providerName, array $config): void public function updateConfiguration(string $providerName, array $config): void
{ {
$this->configurations[$providerName] = $config; try {
// Update database - encode as JSON string since the model expects to cast it
PaymentProviderModel::where('name', $providerName)
->update(['configuration' => $config]); // Laravel will automatically cast array to JSON
Cache::put("payment_config_{$providerName}", $config); // Update local cache
$this->configurations[$providerName] = array_merge(
$this->configurations[$providerName] ?? [],
['config' => $config]
);
// Clear main cache
Cache::forget('payment_providers_config');
Log::info('Payment provider configuration updated', [ Log::info('Payment provider configuration updated', [
'provider' => $providerName, 'provider' => $providerName,
'config_keys' => array_keys($config), 'config_keys' => array_keys($config),
]); ]);
} catch (\Exception $e) {
Log::error('Failed to update payment provider configuration', [
'provider' => $providerName,
'error' => $e->getMessage(),
]);
}
} }
/** /**
@@ -237,47 +254,80 @@ class ProviderRegistry
} }
/** /**
* Load provider configurations from cache/database * Load provider configurations from database
*/ */
protected function loadConfigurations(): void protected function loadConfigurationsFromDatabase(): void
{ {
try {
// Load from cache first // Load from cache first
$cachedConfigs = Cache::get('payment_providers_config', []); $cachedConfigs = Cache::get('payment_providers_config', []);
if (empty($cachedConfigs)) { if (empty($cachedConfigs)) {
// Load from database or config files // Load from database
$this->configurations = config('payment.providers', []); $providers = PaymentProviderModel::where('is_active', true)->get();
$this->configurations = [];
foreach ($providers as $provider) {
// Configuration is already cast to array by the model
$configData = $provider->configuration ?? [];
$this->configurations[$provider->name] = [
'enabled' => $provider->is_active,
'display_name' => $provider->display_name,
'class' => $configData['class'] ?? null,
'config' => $configData,
'supports_recurring' => $provider->supports_recurring,
'supports_one_time' => $provider->supports_one_time,
'supported_currencies' => $provider->supported_currencies ?? [],
'fee_structure' => $provider->fee_structure ?? [],
'priority' => $provider->priority,
'is_fallback' => $provider->is_fallback,
];
}
// Cache for 1 hour // Cache for 1 hour
Cache::put('payment_providers_config', $this->configurations, 3600); Cache::put('payment_providers_config', $this->configurations, 3600);
} else { } else {
$this->configurations = $cachedConfigs; $this->configurations = $cachedConfigs;
} }
} } catch (\Exception $e) {
Log::error('Failed to load payment provider configurations from database', [
/** 'error' => $e->getMessage(),
* Register default providers ]);
*/ $this->configurations = [];
protected function registerDefaultProviders(): void
{
// Auto-register providers based on configuration
$enabledProviders = config('payment.enabled_providers', []);
foreach ($enabledProviders as $providerName) {
$this->registerProviderByName($providerName);
} }
} }
/** /**
* Register provider by name using configuration * Register providers from database
*/ */
protected function registerProviderByName(string $providerName): void protected function registerProvidersFromDatabase(): void
{ {
$providerClass = config("payment.providers.{$providerName}.class"); try {
$activeProviders = PaymentProviderModel::where('is_active', true)->get();
foreach ($activeProviders as $providerModel) {
$this->registerProviderFromModel($providerModel);
}
} catch (\Exception $e) {
Log::error('Failed to register payment providers from database', [
'error' => $e->getMessage(),
]);
}
}
/**
* Register provider from database model
*/
protected function registerProviderFromModel(PaymentProviderModel $providerModel): void
{
// Configuration is already cast to array by the model
$configData = $providerModel->configuration ?? [];
$providerClass = $configData['class'] ?? null;
if (! $providerClass || ! class_exists($providerClass)) { if (! $providerClass || ! class_exists($providerClass)) {
Log::error('Payment provider class not found', [ Log::error('Payment provider class not found', [
'provider' => $providerName, 'provider' => $providerModel->name,
'class' => $providerClass, 'class' => $providerClass,
]); ]);
@@ -285,42 +335,120 @@ class ProviderRegistry
} }
try { try {
$config = $this->getConfiguration($providerName); // Use the full configuration data for the provider
$config = $this->getConfiguration($providerModel->name);
$provider = new $providerClass($config); $provider = new $providerClass($config);
if ($provider instanceof PaymentProviderContract) { if ($provider instanceof PaymentProviderContract) {
$this->register($providerName, $provider); $this->register($providerModel->name, $provider);
} else { } else {
Log::error('Payment provider does not implement contract', [ Log::error('Payment provider does not implement contract', [
'provider' => $providerName, 'provider' => $providerModel->name,
'class' => $providerClass, 'class' => $providerClass,
]); ]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Failed to register payment provider', [ Log::error('Failed to register payment provider', [
'provider' => $providerName, 'provider' => $providerModel->name,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
/**
* Get provider class name based on provider name from database
*/
protected function getProviderClass(string $providerName): ?string
{
$config = $this->getConfiguration($providerName);
return $config['class'] ?? null;
}
/**
* Get API key from database configuration
*/
protected function getApiKeyFromConfig(string $providerName): ?string
{
$config = $this->getConfiguration($providerName);
$configData = $config['config'] ?? [];
// Try different possible API key field names
$apiKeyFields = [
'secret_key',
'api_key',
'merchant_api_key',
'private_key',
'key',
];
foreach ($apiKeyFields as $field) {
if (isset($configData[$field]) && ! empty($configData[$field])) {
return $configData[$field];
}
}
return null;
}
/**
* Get webhook secret from database configuration
*/
protected function getWebhookSecretFromConfig(string $providerName): ?string
{
$config = $this->getConfiguration($providerName);
$configData = $config['config'] ?? [];
// Try different possible webhook secret field names
$webhookFields = [
'webhook_secret',
'secret',
'signing_secret',
];
foreach ($webhookFields as $field) {
if (isset($configData[$field]) && ! empty($configData[$field])) {
return $configData[$field];
}
}
return null;
}
/** /**
* Refresh provider (useful for configuration changes) * Refresh provider (useful for configuration changes)
*/ */
public function refreshProvider(string $name): bool public function refreshProvider(string $name): bool
{ {
if (! isset($this->providers[$name])) { try {
return false; // Clear cache to force reload from database
} Cache::forget('payment_providers_config');
// Unregister current instance // Unregister current instance
unset($this->providers[$name]); unset($this->providers[$name]);
// Re-register with fresh configuration // Reload configurations from database
$this->registerProviderByName($name); $this->loadConfigurationsFromDatabase();
// Re-register from database
$providerModel = PaymentProviderModel::where('name', $name)
->where('is_active', true)
->first();
if ($providerModel) {
$this->registerProviderFromModel($providerModel);
}
return isset($this->providers[$name]); return isset($this->providers[$name]);
} catch (\Exception $e) {
Log::error('Failed to refresh payment provider', [
'provider' => $name,
'error' => $e->getMessage(),
]);
return false;
}
} }
/** /**
@@ -328,17 +456,29 @@ class ProviderRegistry
*/ */
public function toggleProvider(string $name, bool $enabled): bool public function toggleProvider(string $name, bool $enabled): bool
{ {
$config = $this->getConfiguration($name); try {
// Update database
$updated = PaymentProviderModel::where('name', $name)
->update(['is_active' => $enabled]);
if (empty($config)) { if (! $updated) {
return false; return false;
} }
$config['enabled'] = $enabled; // Clear cache to force reload
$this->updateConfiguration($name, $config); Cache::forget('payment_providers_config');
// Refresh the provider to apply changes // Refresh the provider to apply changes
return $this->refreshProvider($name); return $this->refreshProvider($name);
} catch (\Exception $e) {
Log::error('Failed to toggle payment provider', [
'provider' => $name,
'enabled' => $enabled,
'error' => $e->getMessage(),
]);
return false;
}
} }
/** /**
@@ -346,17 +486,36 @@ class ProviderRegistry
*/ */
public function getFallbackProvider(): ?PaymentProviderContract public function getFallbackProvider(): ?PaymentProviderContract
{ {
$fallbackProvider = config('payment.fallback_provider'); try {
// First try to get the provider marked as fallback in database
if ($fallbackProvider && $this->has($fallbackProvider)) { $fallbackProvider = PaymentProviderModel::where('is_fallback', true)
$provider = $this->get($fallbackProvider); ->where('is_active', true)
->first();
if ($fallbackProvider && $this->has($fallbackProvider->name)) {
$provider = $this->get($fallbackProvider->name);
if ($provider && $provider->isActive()) { if ($provider && $provider->isActive()) {
return $provider; return $provider;
} }
} }
// Return first active provider as fallback // Fallback to environment variable
$fallbackProviderName = env('PAYMENT_FALLBACK_PROVIDER', 'stripe');
if ($this->has($fallbackProviderName)) {
$provider = $this->get($fallbackProviderName);
if ($provider && $provider->isActive()) {
return $provider;
}
}
// Return first active provider as final fallback
return $this->getActiveProviders()->first();
} catch (\Exception $e) {
Log::error('Failed to get fallback provider', [
'error' => $e->getMessage(),
]);
return $this->getActiveProviders()->first(); return $this->getActiveProviders()->first();
} }
} }
}

View File

@@ -3,6 +3,7 @@
namespace App\Services\Payments\Providers; namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract; use App\Contracts\Payments\PaymentProviderContract;
use App\Models\PaymentProvider as PaymentProviderModel;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Subscription; use App\Models\Subscription;
use App\Models\User; use App\Models\User;
@@ -15,38 +16,97 @@ class PolarProvider implements PaymentProviderContract
{ {
protected array $config; protected array $config;
/** protected bool $sandbox;
* Rate limiting: 300 requests per minute for Polar API
*/
private const RATE_LIMIT_REQUESTS = 300;
private const RATE_LIMIT_WINDOW = 60; // seconds protected string $apiBaseUrl;
private static array $requestTimes = []; protected string $apiKey;
protected string $webhookSecret;
protected string $accessToken;
// Force reload - timestamp: 2025-12-04-17-15-00
public function __construct(array $config = []) public function __construct(array $config = [])
{ {
$isSandbox = $config['sandbox'] ?? config('services.polar.sandbox', false); // DEBUG: Log that our new PolarProvider is being loaded
Log::info('NEW PolarProvider constructor called - timestamp: 2025-12-04-17-15-00');
// ALWAYS load configuration from PaymentProvider model to get the latest data
$dbConfig = $this->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([ $this->config = array_merge([
'sandbox' => $isSandbox, 'sandbox' => $this->sandbox,
'api_key' => $isSandbox 'api_key' => $this->apiKey,
? config('services.polar.sandbox_api_key') 'webhook_secret' => $this->webhookSecret,
: config('services.polar.api_key'),
'webhook_secret' => $isSandbox
? config('services.polar.sandbox_webhook_secret')
: config('services.polar.webhook_secret'),
'success_url' => route('payment.success'), 'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'), 'cancel_url' => route('payment.cancel'),
'webhook_url' => route('webhook.payment', 'polar'), 'webhook_url' => route('webhook.payment', 'polar'),
], $config); ], $config);
} }
protected function getApiBaseUrl(): string protected function loadConfigurationFromModel(): array
{ {
return $this->config['sandbox'] try {
? 'https://sandbox-api.polar.sh/v1' $providerModel = PaymentProviderModel::where('name', 'polar')
: 'https://api.polar.sh/v1'; ->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 public function getName(): string
@@ -56,12 +116,9 @@ class PolarProvider implements PaymentProviderContract
public function isActive(): bool public function isActive(): bool
{ {
return ! empty($this->config['api_key']) && ! empty($this->config['webhook_secret']); return ! empty($this->apiKey) && ! empty($this->webhookSecret);
} }
/**
* Check if the provided API key is valid by making a test API call
*/
public function validateCredentials(): bool public function validateCredentials(): bool
{ {
try { try {
@@ -75,64 +132,60 @@ class PolarProvider implements PaymentProviderContract
} }
} }
/**
* Make authenticated API request with rate limiting
*/
protected function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response protected function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
{ {
$this->checkRateLimit(); $url = $this->apiBaseUrl.$endpoint;
$url = $this->getApiBaseUrl().$endpoint;
$headers = [ $headers = [
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->apiKey,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'Accept' => '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) { return match ($method) {
'GET' => Http::withHeaders($headers)->get($url, $data), 'GET' => $http->get($url, $data),
'POST' => Http::withHeaders($headers)->post($url, $data), 'POST' => $http->post($url, $data),
'PATCH' => Http::withHeaders($headers)->patch($url, $data), 'PATCH' => $http->patch($url, $data),
'DELETE' => Http::withHeaders($headers)->delete($url, $data), 'DELETE' => $http->delete($url, $data),
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"), default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"),
}; };
} }
/**
* Simple rate limiting implementation
*/
private function checkRateLimit(): void
{
$now = time();
$windowStart = $now - self::RATE_LIMIT_WINDOW;
// Clean old requests outside the current window
self::$requestTimes = array_filter(self::$requestTimes, fn ($time) => $time > $windowStart);
// Check if we're at the rate limit
if (count(self::$requestTimes) >= self::RATE_LIMIT_REQUESTS) {
$sleepTime = self::RATE_LIMIT_WINDOW - ($now - (self::$requestTimes[0] ?? $now));
if ($sleepTime > 0) {
Log::warning('Polar API rate limit reached, sleeping for '.$sleepTime.' seconds');
sleep($sleepTime);
}
}
// Record this request
self::$requestTimes[] = $now;
}
public function createSubscription(User $user, Plan $plan, array $options = []): array public function createSubscription(User $user, Plan $plan, array $options = []): array
{ {
try { try {
Log::info('PolarProvider: createSubscription started', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'plan_name' => $plan->name,
]);
// Get or create Polar customer // Get or create Polar customer
$customer = $this->getOrCreateCustomer($user); $customer = $this->getOrCreateCustomer($user);
Log::info('PolarProvider: Customer retrieved/created', [
'customer_id' => $customer['id'] ?? 'null',
]);
// Get or create Polar product/price // Get or create Polar product/price
$priceId = $this->getOrCreatePrice($plan); $priceId = $this->getOrCreatePrice($plan);
// Create checkout session with Polar's correct structure Log::info('PolarProvider: Price retrieved/created', [
'price_id' => $priceId ?? 'null',
]);
// Create checkout session
$checkoutData = [ $checkoutData = [
'product_price_id' => $priceId, 'product_price_id' => $priceId,
'customer_id' => $customer['id'], 'customer_id' => $customer['id'],
@@ -144,7 +197,7 @@ class PolarProvider implements PaymentProviderContract
'user_id' => (string) $user->id, 'user_id' => (string) $user->id,
'plan_id' => (string) $plan->id, 'plan_id' => (string) $plan->id,
'plan_name' => $plan->name, 'plan_name' => $plan->name,
'external_id' => $user->id, // Polar supports external_id for user mapping 'external_id' => (string) $user->id,
], ],
]; ];
@@ -153,15 +206,43 @@ class PolarProvider implements PaymentProviderContract
$checkoutData['discount_code'] = $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); $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()) { if (! $response->successful()) {
Log::error('Polar checkout creation failed: '.$response->body()); $errorMessage = 'Polar checkout creation failed: '.$response->body();
Log::error($errorMessage);
throw new \Exception($errorMessage);
} }
$checkout = $response->json(); $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 // 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([ $subscription = Subscription::create([
'user_id' => $user->id, 'user_id' => $user->id,
'plan_id' => $plan->id, 'plan_id' => $plan->id,
@@ -182,7 +263,18 @@ class PolarProvider implements PaymentProviderContract
], ],
]); ]);
return [ 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'], 'provider_subscription_id' => $checkout['id'],
'status' => 'pending_payment', 'status' => 'pending_payment',
'checkout_url' => $checkout['url'], 'checkout_url' => $checkout['url'],
@@ -192,6 +284,14 @@ class PolarProvider implements PaymentProviderContract
'expires_at' => $checkout['expires_at'] ?? now()->addHours(24)->toISOString(), '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) { } catch (\Exception $e) {
Log::error('Polar subscription creation failed', [ Log::error('Polar subscription creation failed', [
'user_id' => $user->id, 'user_id' => $user->id,
@@ -250,14 +350,14 @@ class PolarProvider implements PaymentProviderContract
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription); $polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) { if (! $polarSubscriptionId) {
Log::error('No Polar subscription found to update'); throw new \Exception('No Polar subscription found to update');
} }
$newPriceId = $this->getOrCreatePrice($newPlan); $newPriceId = $this->getOrCreatePrice($newPlan);
$response = $this->makeAuthenticatedRequest('PATCH', '/subscriptions/'.$polarSubscriptionId, [ $response = $this->makeAuthenticatedRequest('PATCH', '/subscriptions/'.$polarSubscriptionId, [
'product_price_id' => $newPriceId, 'product_price_id' => $newPriceId,
'preserve_period' => true, // Polar equivalent of proration behavior 'proration_behavior' => 'prorate',
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
@@ -390,6 +490,13 @@ class PolarProvider implements PaymentProviderContract
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array 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); return $this->createSubscription($user, $plan, $options);
} }
@@ -431,6 +538,7 @@ class PolarProvider implements PaymentProviderContract
if (! $this->validateWebhook($request)) { if (! $this->validateWebhook($request)) {
Log::error('Invalid Polar webhook signature'); Log::error('Invalid Polar webhook signature');
throw new \Exception('Invalid webhook signature');
} }
$webhookData = json_decode($payload, true); $webhookData = json_decode($payload, true);
@@ -467,9 +575,6 @@ class PolarProvider implements PaymentProviderContract
case 'customer.state_changed': case 'customer.state_changed':
$result = $this->handleCustomerStateChanged($webhookData); $result = $this->handleCustomerStateChanged($webhookData);
break; break;
case 'benefit_grant.created':
$result = $this->handleBenefitGrantCreated($webhookData);
break;
default: default:
Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]); Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]);
} }
@@ -491,11 +596,11 @@ class PolarProvider implements PaymentProviderContract
$signature = $request->header('Polar-Signature'); $signature = $request->header('Polar-Signature');
$payload = $request->getContent(); $payload = $request->getContent();
if (! $signature || ! $this->config['webhook_secret']) { if (! $signature || ! $this->webhookSecret) {
return false; return false;
} }
$expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']); $expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret);
return hash_equals($signature, $expectedSignature); return hash_equals($signature, $expectedSignature);
@@ -538,18 +643,16 @@ class PolarProvider implements PaymentProviderContract
{ {
try { try {
// Polar handles refunds through their dashboard or API // Polar handles refunds through their dashboard or API
// For now, we'll return a NotImplementedError
Log::error('Polar refunds must be processed through Polar dashboard or API directly'); Log::error('Polar refunds must be processed through Polar dashboard or API directly');
todo('Write process refund process'); throw new \Exception('Refund processing not implemented for Polar');
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Polar refund processing failed', [ Log::error('Polar refund processing failed', [
'payment_id' => $paymentId, 'payment_id' => $paymentId,
'amount' => $amount, 'amount' => $amount,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
throw $e;
} }
return [];
} }
public function getTransactionHistory(User $user, array $filters = []): array public function getTransactionHistory(User $user, array $filters = []): array
@@ -570,28 +673,50 @@ class PolarProvider implements PaymentProviderContract
$params['end_date'] = $filters['end_date']; $params['end_date'] = $filters['end_date'];
} }
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('GET', '/orders', $params);
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->getApiBaseUrl().'/v1/subscriptions', $params);
if (! $response->successful()) { if (! $response->successful()) {
Log::error('Failed to retrieve Polar transaction history: '.$response->body()); Log::error('Failed to retrieve Polar transaction history: '.$response->body());
} }
$polarSubscriptions = $response->json(); $polarOrders = $response->json();
$transactions = []; $transactions = [];
foreach ($polarSubscriptions['data'] ?? [] as $subscription) { 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[] = [ $transactions[] = [
'id' => $subscription['id'], 'id' => $subscription['id'],
'status' => $subscription['status'], 'status' => $subscription['status'],
'amount' => $subscription['amount'] ?? 0, 'amount' => $subscription['amount'] ?? 0,
'currency' => $subscription['currency'] ?? 'USD', 'currency' => $subscription['currency'] ?? 'USD',
'created_at' => $subscription['created_at'], 'created_at' => $subscription['created_at'],
'type' => 'subscription',
'current_period_start' => $subscription['current_period_start'], 'current_period_start' => $subscription['current_period_start'],
'current_period_end' => $subscription['current_period_end'], '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; return $transactions;
@@ -637,118 +762,231 @@ class PolarProvider implements PaymentProviderContract
// Helper methods // Helper methods
protected function getOrCreateCustomer(User $user): array protected function getOrCreateCustomer(User $user): array
{ {
// First, try to find existing customer by email and external_id // First, try to find existing customer by external_id
$response = Http::withHeaders([ try {
'Authorization' => 'Bearer '.$this->config['api_key'], $response = $this->makeAuthenticatedRequest('GET', '/customers', [
])->get($this->getApiBaseUrl().'/customers', [ 'external_id' => (string) $user->id,
'email' => $user->email, 'limit' => 1,
'external_id' => $user->id, // Use external_id for better customer matching
]); ]);
if ($response->successful() && ! empty($response->json()['data'])) { if ($response->successful()) {
return $response->json()['data'][0]; $data = $response->json();
if (! empty($data['items'])) {
Log::info('Found existing Polar customer by external_id', [
'user_id' => $user->id,
'customer_id' => $data['items'][0]['id'],
]);
return $data['items'][0];
}
}
} catch (\Exception $e) {
Log::info('Customer not found by external_id, will create new one', [
'user_id' => $user->id,
]);
}
// Try to find by email as fallback
try {
$response = $this->makeAuthenticatedRequest('GET', '/customers', [
'email' => $user->email,
'limit' => 1,
]);
if ($response->successful()) {
$data = $response->json();
if (! empty($data['items'])) {
Log::info('Found existing Polar customer by email', [
'user_id' => $user->id,
'customer_id' => $data['items'][0]['id'],
]);
return $data['items'][0];
}
}
} catch (\Exception $e) {
Log::info('Customer not found by email, will create new one', [
'user_id' => $user->id,
]);
} }
// Create new customer // Create new customer
$customerData = [ $customerData = [
'email' => $user->email, 'email' => $user->email,
'name' => $user->name, 'name' => $user->name,
'external_id' => $user->id, // Polar supports external_id for user mapping 'external_id' => (string) $user->id,
'metadata' => [ 'metadata' => [
'user_id' => (string) $user->id, 'user_id' => (string) $user->id,
'source' => 'laravel_app', 'source' => 'laravel_app',
], ],
]; ];
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('POST', '/customers', $customerData);
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->getApiBaseUrl().'/customers', $customerData);
if (! $response->successful()) { if (! $response->successful()) {
Log::error('Failed to create Polar customer: '.$response->body()); $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,
]);
return $this->findExistingCustomer($user);
}
}
} }
return $response->json(); $errorMessage = 'Failed to create Polar customer: '.$response->body();
Log::error($errorMessage);
throw new \Exception($errorMessage);
}
$customer = $response->json();
if (! isset($customer['id'])) {
throw new \Exception('Invalid response from Polar API: missing customer ID');
}
Log::info('Created new Polar customer', [
'user_id' => $user->id,
'customer_id' => $customer['id'],
]);
return $customer;
}
protected function findExistingCustomer(User $user): array
{
// Try multiple approaches to find the customer
$attempts = [
fn () => $this->makeAuthenticatedRequest('GET', '/customers', ['external_id' => (string) $user->id, 'limit' => 100]),
fn () => $this->makeAuthenticatedRequest('GET', '/customers', ['email' => $user->email, 'limit' => 100]),
fn () => $this->makeAuthenticatedRequest('GET', '/customers', ['limit' => 1000]),
];
foreach ($attempts as $attempt) {
try {
$response = $attempt();
if ($response->successful()) {
$data = $response->json();
foreach ($data['items'] ?? [] as $customer) {
if (($customer['email'] && strtolower($customer['email']) === strtolower($user->email)) ||
($customer['external_id'] && (string) $customer['external_id'] === (string) $user->id)) {
Log::info('Found existing Polar customer', [
'user_id' => $user->id,
'customer_id' => $customer['id'],
]);
return $customer;
}
}
}
} catch (\Exception $e) {
Log::warning('Customer lookup attempt failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
}
}
throw new \Exception('Customer exists in Polar but could not be retrieved after multiple lookup attempts');
} }
protected function getOrCreatePrice(Plan $plan): string protected function getOrCreatePrice(Plan $plan): string
{ {
// Look for existing price by plan metadata // Look for existing product by plan metadata
$response = Http::withHeaders([ try {
'Authorization' => 'Bearer '.$this->config['api_key'], $response = $this->makeAuthenticatedRequest('GET', '/products', [
])->get($this->getApiBaseUrl().'/v1/products', [
'metadata[plan_id]' => $plan->id, 'metadata[plan_id]' => $plan->id,
'limit' => 1,
]); ]);
if ($response->successful() && ! empty($response->json()['data'])) { if ($response->successful()) {
$product = $response->json()['data'][0]; $data = $response->json();
if (! empty($data['items'])) {
$product = $data['items'][0];
// Get the price for this product // Return the first price ID from the product
$priceResponse = Http::withHeaders([ if (! empty($product['prices'])) {
'Authorization' => 'Bearer '.$this->config['api_key'], return $product['prices'][0]['id'];
])->get($this->getApiBaseUrl().'/v1/prices', [ }
'product_id' => $product['id'], }
'recurring_interval' => 'month', }
} catch (\Exception $e) {
Log::info('No existing product found, will create new one', [
'plan_id' => $plan->id,
]); ]);
if ($priceResponse->successful() && ! empty($priceResponse->json()['data'])) {
return $priceResponse->json()['data'][0]['id'];
}
} }
// Create new product and price // Create new product with correct structure
$productData = [ $productData = [
'name' => $plan->name, 'name' => $plan->name,
'description' => $plan->description ?? 'Subscription plan', 'description' => $plan->description ?? 'Subscription plan',
'type' => 'service', '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' => [ 'metadata' => [
'plan_id' => $plan->id, 'plan_id' => $plan->id,
'plan_name' => $plan->name, 'plan_name' => $plan->name,
], ],
]; ];
$productResponse = Http::withHeaders([ Log::info('Creating Polar product with data', [
'Authorization' => 'Bearer '.$this->config['api_key'], 'product_data' => $productData,
'Content-Type' => 'application/json', ]);
])->post($this->getApiBaseUrl().'/v1/products', $productData);
if (! $productResponse->successful()) { $response = $this->makeAuthenticatedRequest('POST', '/products', $productData);
Log::error('Failed to create Polar product: '.$productResponse->body());
if (! $response->successful()) {
$errorMessage = 'Failed to create Polar product: '.$response->body();
Log::error($errorMessage);
throw new \Exception($errorMessage);
} }
$product = $productResponse->json(); $product = $response->json();
// Create price for the product if (! isset($product['id'])) {
$priceData = [ 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'], 'product_id' => $product['id'],
'amount' => (int) ($plan->price * 100), // Convert to cents 'price_id' => $product['prices'][0]['id'],
'currency' => 'usd', ]);
'recurring' => [
'interval' => 'month',
'interval_count' => 1,
],
];
$priceResponse = Http::withHeaders([ return $product['prices'][0]['id'];
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->getApiBaseUrl().'/v1/prices', $priceData);
if (! $priceResponse->successful()) {
Log::error('Failed to create Polar price: '.$priceResponse->body());
}
$price = $priceResponse->json();
return $price['id'];
} }
protected function getPolarSubscriptionId(Subscription $subscription): ?string protected function getPolarSubscriptionId(Subscription $subscription): ?string
{ {
$providerData = $subscription->provider_data ?? []; $providerData = $subscription->provider_data ?? [];
return $providerData['polar_subscription']['id'] ?? null; // Try different locations where the subscription ID might be stored
return $providerData['polar_subscription']['id'] ??
$providerData['subscription_id'] ??
$subscription->provider_subscription_id;
} }
// Webhook handlers // Webhook handlers
@@ -847,85 +1085,6 @@ class PolarProvider implements PaymentProviderContract
]; ];
} }
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 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'],
],
];
}
protected function handleBenefitGrantCreated(array $webhookData): array
{
$benefitGrant = $webhookData['data']['object'];
// Log benefit grants for analytics or feature access
Log::info('Polar benefit grant created', [
'grant_id' => $benefitGrant['id'],
'customer_id' => $benefitGrant['customer_id'],
'benefit_id' => $benefitGrant['benefit_id'],
]);
return [
'event_type' => 'benefit_grant.created',
'processed' => true,
'data' => [
'grant_id' => $benefitGrant['id'],
'customer_id' => $benefitGrant['customer_id'],
'benefit_id' => $benefitGrant['benefit_id'],
],
];
}
protected function handleSubscriptionCreated(array $webhookData): array protected function handleSubscriptionCreated(array $webhookData): array
{ {
$polarSubscription = $webhookData['data']['object']; $polarSubscription = $webhookData['data']['object'];
@@ -959,6 +1118,37 @@ class PolarProvider implements PaymentProviderContract
]; ];
} }
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 protected function handleSubscriptionUpdated(array $webhookData): array
{ {
$polarSubscription = $webhookData['data']['object']; $polarSubscription = $webhookData['data']['object'];
@@ -1004,42 +1194,28 @@ class PolarProvider implements PaymentProviderContract
]; ];
} }
protected function handleSubscriptionPaused(array $webhookData): array protected function handleCustomerStateChanged(array $webhookData): array
{ {
$polarSubscription = $webhookData['data']['object']; $customer = $webhookData['data']['object'];
Subscription::where('provider', 'polar') // Update all subscriptions for this customer
->where('provider_subscription_id', $polarSubscription['id']) Subscription::whereHas('user', function ($query) use ($customer) {
->update([ $query->where('email', $customer['email']);
'status' => 'paused', })->where('provider', 'polar')->get()->each(function ($subscription) use ($customer) {
'paused_at' => now(), $subscription->update([
'provider_data' => array_merge($subscription->provider_data ?? [], [
'customer_state' => $customer['state'],
'customer_updated_at' => now()->toISOString(),
]),
]); ]);
});
return [ return [
'event_type' => 'subscription.paused', 'event_type' => 'customer.state_changed',
'processed' => true, 'processed' => true,
'data' => [ 'data' => [
'subscription_id' => $polarSubscription['id'], 'customer_id' => $customer['id'],
], 'state' => $customer['state'],
];
}
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'],
], ],
]; ];
} }
@@ -1079,18 +1255,14 @@ class PolarProvider implements PaymentProviderContract
public function applyCoupon(Subscription $subscription, string $couponCode): array public function applyCoupon(Subscription $subscription, string $couponCode): array
{ {
// Polar supports discount codes
try { try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription); $polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) { if (! $polarSubscriptionId) {
Log::error('No Polar subscription found'); throw new \Exception('No Polar subscription found');
} }
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/discount', [
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/discount', [
'coupon_code' => $couponCode, 'coupon_code' => $couponCode,
]); ]);
@@ -1119,9 +1291,7 @@ class PolarProvider implements PaymentProviderContract
return false; return false;
} }
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('DELETE', '/subscriptions/'.$polarSubscriptionId.'/discount');
'Authorization' => 'Bearer '.$this->config['api_key'],
])->delete($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/discount');
return $response->successful(); return $response->successful();
@@ -1148,9 +1318,7 @@ class PolarProvider implements PaymentProviderContract
]; ];
} }
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('GET', '/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice');
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice');
if (! $response->successful()) { if (! $response->successful()) {
Log::error('Failed to retrieve Polar upcoming invoice: '.$response->body()); Log::error('Failed to retrieve Polar upcoming invoice: '.$response->body());
@@ -1212,8 +1380,6 @@ class PolarProvider implements PaymentProviderContract
public function importSubscriptionData(User $user, array $subscriptionData): array public function importSubscriptionData(User $user, array $subscriptionData): array
{ {
Log::error('Import to Polar payments not implemented'); Log::error('Import to Polar payments not implemented');
todo('Write import subscription data'); throw new \Exception('Import subscription data not implemented for Polar');
return [];
} }
} }

View File

@@ -1,6 +1,28 @@
<?php <?php
use App\Http\Controllers\AppController; use App\Http\Controllers\AppController;
// DEBUG: Test route to check PolarProvider
Route::get('/debug-polar', function () {
try {
$provider = new \App\Services\Payments\Providers\PolarProvider;
return response()->json([
'status' => 'success',
'provider_class' => get_class($provider),
'is_active' => $provider->isActive(),
'config' => $provider->getConfiguration(),
'sandbox' => $provider->config['sandbox'] ?? 'unknown',
'timestamp' => '2025-12-04-17-15-00',
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
});
use App\Http\Controllers\ImpersonationController; use App\Http\Controllers\ImpersonationController;
use App\Http\Controllers\WebhookController; use App\Http\Controllers\WebhookController;
use App\Http\Middleware\CheckPageSlug; use App\Http\Middleware\CheckPageSlug;

View File

@@ -1,2 +0,0 @@
*
!.gitignore