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 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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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