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:
@@ -7,6 +7,7 @@ use App\Models\User;
|
||||
use App\Services\Payments\PaymentOrchestrator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PaymentController extends Controller
|
||||
@@ -75,8 +76,23 @@ class PaymentController extends Controller
|
||||
], 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);
|
||||
|
||||
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([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
@@ -277,6 +293,14 @@ class PaymentController extends Controller
|
||||
'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
|
||||
if (isset($result['redirect_url'])) {
|
||||
return redirect($result['redirect_url']);
|
||||
@@ -287,6 +311,11 @@ class PaymentController extends Controller
|
||||
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.');
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
@@ -332,6 +361,14 @@ class PaymentController extends Controller
|
||||
'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
|
||||
if (isset($result['redirect_url'])) {
|
||||
return redirect($result['redirect_url']);
|
||||
@@ -342,6 +379,11 @@ class PaymentController extends Controller
|
||||
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.');
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
|
||||
@@ -4,14 +4,17 @@ namespace App\Services\Payments;
|
||||
|
||||
use App\Contracts\Payments\PaymentProviderContract;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\CouponUsage;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionChange;
|
||||
use App\Models\TrialExtension;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaymentOrchestrator
|
||||
{
|
||||
@@ -588,16 +591,41 @@ class PaymentOrchestrator
|
||||
*/
|
||||
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) {
|
||||
$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)) {
|
||||
Log::info('PaymentOrchestrator: Using requested provider', [
|
||||
'provider' => $providerName,
|
||||
]);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the first active provider that supports this plan
|
||||
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)) {
|
||||
Log::info('PaymentOrchestrator: Using fallback provider', [
|
||||
'provider' => $provider->getName(),
|
||||
]);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
}
|
||||
@@ -625,10 +653,13 @@ class PaymentOrchestrator
|
||||
*/
|
||||
protected function isProviderSupportedForPlan(PaymentProviderContract $provider, Plan $plan): bool
|
||||
{
|
||||
// Check if plan has provider-specific configuration
|
||||
$providerConfig = $plan->details['providers'][$provider->getName()] ?? null;
|
||||
// Use the same approach as Plan::supportsProvider() - check database relationship
|
||||
$isSupported = $plan->planProviders()
|
||||
->where('provider', $provider->getName())
|
||||
->where('is_enabled', true)
|
||||
->exists();
|
||||
|
||||
if (! $providerConfig || ! ($providerConfig['enabled'] ?? false)) {
|
||||
if (! $isSupported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Contracts\Payments\PaymentProviderContract;
|
||||
use App\Models\PaymentProvider as PaymentProviderModel;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -15,8 +16,8 @@ class ProviderRegistry
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->loadConfigurations();
|
||||
$this->registerDefaultProviders();
|
||||
$this->loadConfigurationsFromDatabase();
|
||||
$this->registerProvidersFromDatabase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,14 +94,30 @@ class ProviderRegistry
|
||||
*/
|
||||
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', [
|
||||
'provider' => $providerName,
|
||||
'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
|
||||
$cachedConfigs = Cache::get('payment_providers_config', []);
|
||||
|
||||
if (empty($cachedConfigs)) {
|
||||
// Load from database or config files
|
||||
$this->configurations = config('payment.providers', []);
|
||||
// Load from database
|
||||
$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::put('payment_providers_config', $this->configurations, 3600);
|
||||
} else {
|
||||
$this->configurations = $cachedConfigs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register default providers
|
||||
*/
|
||||
protected function registerDefaultProviders(): void
|
||||
{
|
||||
// Auto-register providers based on configuration
|
||||
$enabledProviders = config('payment.enabled_providers', []);
|
||||
|
||||
foreach ($enabledProviders as $providerName) {
|
||||
$this->registerProviderByName($providerName);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to load payment provider configurations from database', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->configurations = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) {
|
||||
Log::error('Payment provider class not found', [
|
||||
'provider' => $providerName,
|
||||
'provider' => $providerModel->name,
|
||||
'class' => $providerClass,
|
||||
]);
|
||||
|
||||
@@ -285,42 +335,120 @@ class ProviderRegistry
|
||||
}
|
||||
|
||||
try {
|
||||
$config = $this->getConfiguration($providerName);
|
||||
// Use the full configuration data for the provider
|
||||
$config = $this->getConfiguration($providerModel->name);
|
||||
$provider = new $providerClass($config);
|
||||
|
||||
if ($provider instanceof PaymentProviderContract) {
|
||||
$this->register($providerName, $provider);
|
||||
$this->register($providerModel->name, $provider);
|
||||
} else {
|
||||
Log::error('Payment provider does not implement contract', [
|
||||
'provider' => $providerName,
|
||||
'provider' => $providerModel->name,
|
||||
'class' => $providerClass,
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to register payment provider', [
|
||||
'provider' => $providerName,
|
||||
'provider' => $providerModel->name,
|
||||
'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)
|
||||
*/
|
||||
public function refreshProvider(string $name): bool
|
||||
{
|
||||
if (! isset($this->providers[$name])) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// Clear cache to force reload from database
|
||||
Cache::forget('payment_providers_config');
|
||||
|
||||
// Unregister current instance
|
||||
unset($this->providers[$name]);
|
||||
|
||||
// Re-register with fresh configuration
|
||||
$this->registerProviderByName($name);
|
||||
// Reload configurations from database
|
||||
$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]);
|
||||
} 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
|
||||
{
|
||||
$config = $this->getConfiguration($name);
|
||||
try {
|
||||
// Update database
|
||||
$updated = PaymentProviderModel::where('name', $name)
|
||||
->update(['is_active' => $enabled]);
|
||||
|
||||
if (empty($config)) {
|
||||
if (! $updated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$config['enabled'] = $enabled;
|
||||
$this->updateConfiguration($name, $config);
|
||||
// Clear cache to force reload
|
||||
Cache::forget('payment_providers_config');
|
||||
|
||||
// Refresh the provider to apply changes
|
||||
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
|
||||
{
|
||||
$fallbackProvider = config('payment.fallback_provider');
|
||||
|
||||
if ($fallbackProvider && $this->has($fallbackProvider)) {
|
||||
$provider = $this->get($fallbackProvider);
|
||||
try {
|
||||
// First try to get the provider marked as fallback in database
|
||||
$fallbackProvider = PaymentProviderModel::where('is_fallback', true)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if ($fallbackProvider && $this->has($fallbackProvider->name)) {
|
||||
$provider = $this->get($fallbackProvider->name);
|
||||
if ($provider && $provider->isActive()) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Payments\Providers;
|
||||
|
||||
use App\Contracts\Payments\PaymentProviderContract;
|
||||
use App\Models\PaymentProvider as PaymentProviderModel;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
@@ -15,38 +16,97 @@ class PolarProvider implements PaymentProviderContract
|
||||
{
|
||||
protected array $config;
|
||||
|
||||
/**
|
||||
* Rate limiting: 300 requests per minute for Polar API
|
||||
*/
|
||||
private const RATE_LIMIT_REQUESTS = 300;
|
||||
protected bool $sandbox;
|
||||
|
||||
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 = [])
|
||||
{
|
||||
$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([
|
||||
'sandbox' => $isSandbox,
|
||||
'api_key' => $isSandbox
|
||||
? config('services.polar.sandbox_api_key')
|
||||
: config('services.polar.api_key'),
|
||||
'webhook_secret' => $isSandbox
|
||||
? config('services.polar.sandbox_webhook_secret')
|
||||
: config('services.polar.webhook_secret'),
|
||||
'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 getApiBaseUrl(): string
|
||||
protected function loadConfigurationFromModel(): array
|
||||
{
|
||||
return $this->config['sandbox']
|
||||
? 'https://sandbox-api.polar.sh/v1'
|
||||
: 'https://api.polar.sh/v1';
|
||||
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
|
||||
@@ -56,12 +116,9 @@ class PolarProvider implements PaymentProviderContract
|
||||
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
$this->checkRateLimit();
|
||||
|
||||
$url = $this->getApiBaseUrl().$endpoint;
|
||||
$url = $this->apiBaseUrl.$endpoint;
|
||||
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
'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::withHeaders($headers)->get($url, $data),
|
||||
'POST' => Http::withHeaders($headers)->post($url, $data),
|
||||
'PATCH' => Http::withHeaders($headers)->patch($url, $data),
|
||||
'DELETE' => Http::withHeaders($headers)->delete($url, $data),
|
||||
'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}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
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);
|
||||
|
||||
// Create checkout session with Polar's correct structure
|
||||
Log::info('PolarProvider: Price retrieved/created', [
|
||||
'price_id' => $priceId ?? 'null',
|
||||
]);
|
||||
|
||||
// Create checkout session
|
||||
$checkoutData = [
|
||||
'product_price_id' => $priceId,
|
||||
'customer_id' => $customer['id'],
|
||||
@@ -144,7 +197,7 @@ class PolarProvider implements PaymentProviderContract
|
||||
'user_id' => (string) $user->id,
|
||||
'plan_id' => (string) $plan->id,
|
||||
'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'];
|
||||
}
|
||||
|
||||
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()) {
|
||||
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();
|
||||
|
||||
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,
|
||||
@@ -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'],
|
||||
'status' => 'pending_payment',
|
||||
'checkout_url' => $checkout['url'],
|
||||
@@ -192,6 +284,14 @@ class PolarProvider implements PaymentProviderContract
|
||||
'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,
|
||||
@@ -250,14 +350,14 @@ class PolarProvider implements PaymentProviderContract
|
||||
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
||||
|
||||
if (! $polarSubscriptionId) {
|
||||
Log::error('No Polar subscription found to update');
|
||||
throw new \Exception('No Polar subscription found to update');
|
||||
}
|
||||
|
||||
$newPriceId = $this->getOrCreatePrice($newPlan);
|
||||
|
||||
$response = $this->makeAuthenticatedRequest('PATCH', '/subscriptions/'.$polarSubscriptionId, [
|
||||
'product_price_id' => $newPriceId,
|
||||
'preserve_period' => true, // Polar equivalent of proration behavior
|
||||
'proration_behavior' => 'prorate',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
@@ -390,6 +490,13 @@ class PolarProvider implements PaymentProviderContract
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -431,6 +538,7 @@ class PolarProvider implements PaymentProviderContract
|
||||
|
||||
if (! $this->validateWebhook($request)) {
|
||||
Log::error('Invalid Polar webhook signature');
|
||||
throw new \Exception('Invalid webhook signature');
|
||||
}
|
||||
|
||||
$webhookData = json_decode($payload, true);
|
||||
@@ -467,9 +575,6 @@ class PolarProvider implements PaymentProviderContract
|
||||
case 'customer.state_changed':
|
||||
$result = $this->handleCustomerStateChanged($webhookData);
|
||||
break;
|
||||
case 'benefit_grant.created':
|
||||
$result = $this->handleBenefitGrantCreated($webhookData);
|
||||
break;
|
||||
default:
|
||||
Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]);
|
||||
}
|
||||
@@ -491,11 +596,11 @@ class PolarProvider implements PaymentProviderContract
|
||||
$signature = $request->header('Polar-Signature');
|
||||
$payload = $request->getContent();
|
||||
|
||||
if (! $signature || ! $this->config['webhook_secret']) {
|
||||
if (! $signature || ! $this->webhookSecret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']);
|
||||
$expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret);
|
||||
|
||||
return hash_equals($signature, $expectedSignature);
|
||||
|
||||
@@ -538,18 +643,16 @@ class PolarProvider implements PaymentProviderContract
|
||||
{
|
||||
try {
|
||||
// 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');
|
||||
todo('Write process refund process');
|
||||
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;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getTransactionHistory(User $user, array $filters = []): array
|
||||
@@ -570,28 +673,50 @@ class PolarProvider implements PaymentProviderContract
|
||||
$params['end_date'] = $filters['end_date'];
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
])->get($this->getApiBaseUrl().'/v1/subscriptions', $params);
|
||||
$response = $this->makeAuthenticatedRequest('GET', '/orders', $params);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Failed to retrieve Polar transaction history: '.$response->body());
|
||||
}
|
||||
|
||||
$polarSubscriptions = $response->json();
|
||||
$polarOrders = $response->json();
|
||||
$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[] = [
|
||||
'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;
|
||||
|
||||
@@ -637,118 +762,231 @@ class PolarProvider implements PaymentProviderContract
|
||||
// Helper methods
|
||||
protected function getOrCreateCustomer(User $user): array
|
||||
{
|
||||
// First, try to find existing customer by email and external_id
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
])->get($this->getApiBaseUrl().'/customers', [
|
||||
'email' => $user->email,
|
||||
'external_id' => $user->id, // Use external_id for better customer matching
|
||||
// First, try to find existing customer by external_id
|
||||
try {
|
||||
$response = $this->makeAuthenticatedRequest('GET', '/customers', [
|
||||
'external_id' => (string) $user->id,
|
||||
'limit' => 1,
|
||||
]);
|
||||
|
||||
if ($response->successful() && ! empty($response->json()['data'])) {
|
||||
return $response->json()['data'][0];
|
||||
if ($response->successful()) {
|
||||
$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
|
||||
$customerData = [
|
||||
'email' => $user->email,
|
||||
'name' => $user->name,
|
||||
'external_id' => $user->id, // Polar supports external_id for user mapping
|
||||
'external_id' => (string) $user->id,
|
||||
'metadata' => [
|
||||
'user_id' => (string) $user->id,
|
||||
'source' => 'laravel_app',
|
||||
],
|
||||
];
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
'Content-Type' => 'application/json',
|
||||
])->post($this->getApiBaseUrl().'/customers', $customerData);
|
||||
$response = $this->makeAuthenticatedRequest('POST', '/customers', $customerData);
|
||||
|
||||
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
|
||||
{
|
||||
// Look for existing price by plan metadata
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
])->get($this->getApiBaseUrl().'/v1/products', [
|
||||
// Look for existing product by plan metadata
|
||||
try {
|
||||
$response = $this->makeAuthenticatedRequest('GET', '/products', [
|
||||
'metadata[plan_id]' => $plan->id,
|
||||
'limit' => 1,
|
||||
]);
|
||||
|
||||
if ($response->successful() && ! empty($response->json()['data'])) {
|
||||
$product = $response->json()['data'][0];
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
if (! empty($data['items'])) {
|
||||
$product = $data['items'][0];
|
||||
|
||||
// Get the price for this product
|
||||
$priceResponse = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
])->get($this->getApiBaseUrl().'/v1/prices', [
|
||||
'product_id' => $product['id'],
|
||||
'recurring_interval' => 'month',
|
||||
// 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,
|
||||
]);
|
||||
|
||||
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 = [
|
||||
'name' => $plan->name,
|
||||
'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' => [
|
||||
'plan_id' => $plan->id,
|
||||
'plan_name' => $plan->name,
|
||||
],
|
||||
];
|
||||
|
||||
$productResponse = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
'Content-Type' => 'application/json',
|
||||
])->post($this->getApiBaseUrl().'/v1/products', $productData);
|
||||
Log::info('Creating Polar product with data', [
|
||||
'product_data' => $productData,
|
||||
]);
|
||||
|
||||
if (! $productResponse->successful()) {
|
||||
Log::error('Failed to create Polar product: '.$productResponse->body());
|
||||
$response = $this->makeAuthenticatedRequest('POST', '/products', $productData);
|
||||
|
||||
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
|
||||
$priceData = [
|
||||
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'],
|
||||
'amount' => (int) ($plan->price * 100), // Convert to cents
|
||||
'currency' => 'usd',
|
||||
'recurring' => [
|
||||
'interval' => 'month',
|
||||
'interval_count' => 1,
|
||||
],
|
||||
];
|
||||
'price_id' => $product['prices'][0]['id'],
|
||||
]);
|
||||
|
||||
$priceResponse = Http::withHeaders([
|
||||
'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'];
|
||||
return $product['prices'][0]['id'];
|
||||
}
|
||||
|
||||
protected function getPolarSubscriptionId(Subscription $subscription): ?string
|
||||
{
|
||||
$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
|
||||
@@ -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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$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')
|
||||
->where('provider_subscription_id', $polarSubscription['id'])
|
||||
->update([
|
||||
'status' => 'paused',
|
||||
'paused_at' => now(),
|
||||
// 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' => 'subscription.paused',
|
||||
'event_type' => 'customer.state_changed',
|
||||
'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'],
|
||||
'customer_id' => $customer['id'],
|
||||
'state' => $customer['state'],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -1079,18 +1255,14 @@ class PolarProvider implements PaymentProviderContract
|
||||
|
||||
public function applyCoupon(Subscription $subscription, string $couponCode): array
|
||||
{
|
||||
// Polar supports discount codes
|
||||
try {
|
||||
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
||||
|
||||
if (! $polarSubscriptionId) {
|
||||
Log::error('No Polar subscription found');
|
||||
throw new \Exception('No Polar subscription found');
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
'Content-Type' => 'application/json',
|
||||
])->post($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/discount', [
|
||||
$response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/discount', [
|
||||
'coupon_code' => $couponCode,
|
||||
]);
|
||||
|
||||
@@ -1119,9 +1291,7 @@ class PolarProvider implements PaymentProviderContract
|
||||
return false;
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
])->delete($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/discount');
|
||||
$response = $this->makeAuthenticatedRequest('DELETE', '/subscriptions/'.$polarSubscriptionId.'/discount');
|
||||
|
||||
return $response->successful();
|
||||
|
||||
@@ -1148,9 +1318,7 @@ class PolarProvider implements PaymentProviderContract
|
||||
];
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->config['api_key'],
|
||||
])->get($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice');
|
||||
$response = $this->makeAuthenticatedRequest('GET', '/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice');
|
||||
|
||||
if (! $response->successful()) {
|
||||
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
|
||||
{
|
||||
Log::error('Import to Polar payments not implemented');
|
||||
todo('Write import subscription data');
|
||||
|
||||
return [];
|
||||
throw new \Exception('Import subscription data not implemented for Polar');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<?php
|
||||
|
||||
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\WebhookController;
|
||||
use App\Http\Middleware\CheckPageSlug;
|
||||
|
||||
2
storage/framework/cache/data/.gitignore
vendored
2
storage/framework/cache/data/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
Reference in New Issue
Block a user