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]
);
Log::info('Payment provider configuration updated', [ // Clear main cache
'provider' => $providerName, Cache::forget('payment_providers_config');
'config_keys' => array_keys($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
{ {
// Load from cache first try {
$cachedConfigs = Cache::get('payment_providers_config', []); // Load from cache first
$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();
// Cache for 1 hour $this->configurations = [];
Cache::put('payment_providers_config', $this->configurations, 3600); foreach ($providers as $provider) {
} else { // Configuration is already cast to array by the model
$this->configurations = $cachedConfigs; $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;
}
} catch (\Exception $e) {
Log::error('Failed to load payment provider configurations from database', [
'error' => $e->getMessage(),
]);
$this->configurations = [];
} }
} }
/** /**
* Register default providers * Register providers from database
*/ */
protected function registerDefaultProviders(): void protected function registerProvidersFromDatabase(): void
{ {
// Auto-register providers based on configuration try {
$enabledProviders = config('payment.enabled_providers', []); $activeProviders = PaymentProviderModel::where('is_active', true)->get();
foreach ($enabledProviders as $providerName) { foreach ($activeProviders as $providerModel) {
$this->registerProviderByName($providerName); $this->registerProviderFromModel($providerModel);
}
} catch (\Exception $e) {
Log::error('Failed to register payment providers from database', [
'error' => $e->getMessage(),
]);
} }
} }
/** /**
* Register provider by name using configuration * Register provider from database model
*/ */
protected function registerProviderByName(string $providerName): void protected function registerProviderFromModel(PaymentProviderModel $providerModel): void
{ {
$providerClass = config("payment.providers.{$providerName}.class"); // 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 {
// Clear cache to force reload from database
Cache::forget('payment_providers_config');
// Unregister current instance
unset($this->providers[$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; return false;
} }
// Unregister current instance
unset($this->providers[$name]);
// Re-register with fresh configuration
$this->registerProviderByName($name);
return isset($this->providers[$name]);
} }
/** /**
@@ -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 (! $updated) {
return false;
}
// 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(),
]);
if (empty($config)) {
return false; return false;
} }
$config['enabled'] = $enabled;
$this->updateConfiguration($name, $config);
// Refresh the provider to apply changes
return $this->refreshProvider($name);
} }
/** /**
@@ -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
$fallbackProvider = PaymentProviderModel::where('is_fallback', true)
->where('is_active', true)
->first();
if ($fallbackProvider && $this->has($fallbackProvider)) { if ($fallbackProvider && $this->has($fallbackProvider->name)) {
$provider = $this->get($fallbackProvider); $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
return $this->getActiveProviders()->first(); $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();
}
} }
} }

File diff suppressed because it is too large Load Diff

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