Add comprehensive webhook validation and processing system with Polar.sh integration: - Create built-in Standard Webhooks package following official specification - Implement HMAC-SHA256 signature validation with base64 encoding - Add webhook factory for multi-provider support (Polar, Stripe, generic) - Replace custom Polar webhook validation with Standard Webhooks implementation - Add proper exception handling with custom WebhookVerificationException - Support sandbox mode bypass for development environments - Update Polar provider to use database-driven configuration - Enhance webhook test suite with proper Standard Webhooks format - Add PaymentProvider model HasFactory trait for testing - Implement timestamp tolerance checking (±5 minutes) for replay protection - Support multiple signature versions and proper header validation This provides a secure, reusable webhook validation system that can be extended to other payment providers while maintaining full compliance with Standard Webhooks specification. BREAKING CHANGE: Polar webhook validation now uses Standard Webhooks format with headers 'webhook-id', 'webhook-timestamp', 'webhook-signature' instead of previous Polar-specific headers.
2238 lines
84 KiB
PHP
2238 lines
84 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Services\Webhooks\WebhookFactory;
|
|
use App\Services\Webhooks\WebhookVerificationException;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class PolarProvider implements PaymentProviderContract
|
|
{
|
|
protected array $config;
|
|
|
|
protected bool $sandbox;
|
|
|
|
protected string $apiBaseUrl;
|
|
|
|
protected string $apiKey;
|
|
|
|
protected string $webhookSecret;
|
|
|
|
protected string $accessToken;
|
|
|
|
// Force reload - timestamp: 2025-12-04-17-15-00
|
|
|
|
public function __construct(array $config = [])
|
|
{
|
|
// 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' => $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 loadConfigurationFromModel(): array
|
|
{
|
|
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
|
|
{
|
|
return 'polar';
|
|
}
|
|
|
|
public function isActive(): bool
|
|
{
|
|
return ! empty($this->apiKey) && ! empty($this->webhookSecret);
|
|
}
|
|
|
|
public function validateCredentials(): bool
|
|
{
|
|
try {
|
|
return $this->makeAuthenticatedRequest('GET', '/organizations/current')->successful();
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar credentials validation failed', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
|
|
{
|
|
$url = $this->apiBaseUrl.$endpoint;
|
|
|
|
$headers = [
|
|
'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->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}"),
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
Log::info('PolarProvider: Price retrieved/created', [
|
|
'price_id' => $priceId ?? 'null',
|
|
]);
|
|
|
|
// Create checkout session
|
|
$checkoutData = [
|
|
'product_price_id' => $priceId,
|
|
'customer_id' => $customer['id'],
|
|
'success_url' => $this->config['success_url'],
|
|
'cancel_url' => $this->config['cancel_url'],
|
|
'customer_email' => $user->email,
|
|
'customer_name' => $user->name,
|
|
'metadata' => [
|
|
'user_id' => (string) $user->id,
|
|
'plan_id' => (string) $plan->id,
|
|
'plan_name' => $plan->name,
|
|
'external_id' => (string) $user->id,
|
|
],
|
|
];
|
|
|
|
// Add discount codes if provided
|
|
if (isset($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);
|
|
|
|
Log::info('PolarProvider: Checkout response received', [
|
|
'status' => $response->status(),
|
|
'successful' => $response->successful(),
|
|
'response_body' => $response->body(),
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
$statusCode = $response->status();
|
|
$responseBody = $response->json();
|
|
|
|
// Log detailed error for debugging
|
|
Log::error('Polar checkout creation failed', [
|
|
'status_code' => $statusCode,
|
|
'response' => $responseBody,
|
|
'user_id' => $user->id,
|
|
'plan_id' => $plan->id,
|
|
]);
|
|
|
|
// Create user-friendly error message without exposing sensitive data
|
|
$errorMessage = $this->sanitizePolarErrorMessage($responseBody, $statusCode);
|
|
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,
|
|
'type' => 'default',
|
|
'stripe_id' => $checkout['id'], // Using stripe_id field for Polar checkout ID
|
|
'stripe_status' => 'pending',
|
|
'provider' => $this->getName(),
|
|
'provider_checkout_id' => $checkout['id'], // Store checkout ID separately
|
|
'provider_subscription_id' => null, // Will be populated via webhook or sync
|
|
'status' => 'pending_payment',
|
|
'starts_at' => null,
|
|
'ends_at' => null,
|
|
'provider_data' => [
|
|
'checkout_id' => $checkout['id'],
|
|
'checkout_url' => $checkout['url'],
|
|
'customer_id' => $customer['id'],
|
|
'price_id' => $priceId,
|
|
'created_at' => now()->toISOString(),
|
|
],
|
|
]);
|
|
|
|
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'],
|
|
'customer_id' => $customer['id'],
|
|
'price_id' => $priceId,
|
|
'type' => 'polar_checkout',
|
|
'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,
|
|
'plan_id' => $plan->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
|
|
{
|
|
try {
|
|
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
|
|
|
if (! $polarSubscriptionId) {
|
|
// Local cancellation only
|
|
$subscription->update([
|
|
'status' => 'cancelled',
|
|
'cancelled_at' => now(),
|
|
'cancellation_reason' => $reason,
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
$response = $this->makeAuthenticatedRequest('DELETE', '/subscriptions/'.$polarSubscriptionId, [
|
|
'reason' => $reason,
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Polar subscription cancellation failed: '.$response->body());
|
|
}
|
|
|
|
// Update local subscription
|
|
$subscription->update([
|
|
'status' => 'cancelled',
|
|
'cancelled_at' => now(),
|
|
'cancellation_reason' => $reason,
|
|
]);
|
|
|
|
return true;
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar subscription cancellation failed', [
|
|
'subscription_id' => $subscription->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
|
|
{
|
|
try {
|
|
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
|
|
|
if (! $polarSubscriptionId) {
|
|
throw new \Exception('No Polar subscription found to update');
|
|
}
|
|
|
|
$newPriceId = $this->getOrCreatePrice($newPlan);
|
|
|
|
$response = $this->makeAuthenticatedRequest('PATCH', '/subscriptions/'.$polarSubscriptionId, [
|
|
'product_price_id' => $newPriceId,
|
|
'proration_behavior' => 'prorate',
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Polar subscription update failed: '.$response->body());
|
|
}
|
|
|
|
$updatedSubscription = $response->json();
|
|
|
|
// Update local subscription
|
|
$subscription->update([
|
|
'plan_id' => $newPlan->id,
|
|
'provider_data' => array_merge($subscription->provider_data ?? [], [
|
|
'updated_at' => now()->toISOString(),
|
|
'polar_subscription' => $updatedSubscription,
|
|
]),
|
|
]);
|
|
|
|
return [
|
|
'provider_subscription_id' => $updatedSubscription['id'],
|
|
'status' => $updatedSubscription['status'],
|
|
'price_id' => $newPriceId,
|
|
'updated_at' => $updatedSubscription['updated_at'],
|
|
];
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar subscription update failed', [
|
|
'subscription_id' => $subscription->id,
|
|
'new_plan_id' => $newPlan->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function pauseSubscription(Subscription $subscription): bool
|
|
{
|
|
try {
|
|
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
|
|
|
if (! $polarSubscriptionId) {
|
|
return false;
|
|
}
|
|
|
|
$response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/pause');
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Polar subscription pause failed: '.$response->body());
|
|
}
|
|
|
|
$subscription->update([
|
|
'status' => 'paused',
|
|
'paused_at' => now(),
|
|
]);
|
|
|
|
return true;
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar subscription pause failed', [
|
|
'subscription_id' => $subscription->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function resumeSubscription(Subscription $subscription): bool
|
|
{
|
|
try {
|
|
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
|
|
|
if (! $polarSubscriptionId) {
|
|
return false;
|
|
}
|
|
|
|
$response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/resume');
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Polar subscription resume failed: '.$response->body());
|
|
}
|
|
|
|
$subscription->update([
|
|
'status' => 'active',
|
|
'resumed_at' => now(),
|
|
]);
|
|
|
|
return true;
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar subscription resume failed', [
|
|
'subscription_id' => $subscription->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function getSubscriptionDetails(string $providerSubscriptionId): array
|
|
{
|
|
try {
|
|
$response = $this->makeAuthenticatedRequest('GET', '/subscriptions/'.$providerSubscriptionId);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Failed to retrieve Polar subscription', [
|
|
'status_code' => $response->status(),
|
|
'response' => $response->json(),
|
|
'subscription_id' => $providerSubscriptionId,
|
|
]);
|
|
throw new \Exception('Subscription not found. Please check your subscription details.');
|
|
}
|
|
|
|
$polarSubscription = $response->json();
|
|
|
|
// Log the full Polar subscription response for debugging
|
|
Log::info('Polar subscription response received', [
|
|
'subscription_id' => $providerSubscriptionId,
|
|
'response_keys' => array_keys($polarSubscription),
|
|
'full_response' => $polarSubscription,
|
|
]);
|
|
|
|
if (! $polarSubscription || ! isset($polarSubscription['id'])) {
|
|
Log::error('Invalid Polar subscription response', [
|
|
'subscription_id' => $providerSubscriptionId,
|
|
'response' => $polarSubscription,
|
|
]);
|
|
throw new \Exception('Invalid Polar subscription response');
|
|
}
|
|
|
|
return [
|
|
'id' => $polarSubscription['id'],
|
|
'status' => $polarSubscription['status'],
|
|
'customer_id' => $polarSubscription['customer_id'],
|
|
'price_id' => $polarSubscription['price_id'],
|
|
'current_period_start' => $polarSubscription['current_period_start'] ?? null,
|
|
'current_period_end' => $polarSubscription['current_period_end'] ?? null,
|
|
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
|
'trial_start' => $polarSubscription['trial_start'] ?? null,
|
|
'trial_end' => $polarSubscription['trial_end'] ?? null,
|
|
'created_at' => $polarSubscription['created_at'] ?? null,
|
|
'updated_at' => $polarSubscription['modified_at'] ?? null,
|
|
'ends_at' => $polarSubscription['ends_at'] ?? null, // Check if Polar has ends_at
|
|
'expires_at' => $polarSubscription['expires_at'] ?? null, // Check if Polar has expires_at
|
|
'cancelled_at' => $polarSubscription['cancelled_at'] ?? null, // Check if Polar has cancelled_at
|
|
'customer_cancellation_reason' => $polarSubscription['customer_cancellation_reason'] ?? null,
|
|
'customer_cancellation_comment' => $polarSubscription['customer_cancellation_comment'] ?? null,
|
|
];
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar subscription details retrieval failed', [
|
|
'subscription_id' => $providerSubscriptionId,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
|
|
{
|
|
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);
|
|
}
|
|
|
|
public function createCustomerPortalSession(User $user): array
|
|
{
|
|
try {
|
|
$customer = $this->getOrCreateCustomer($user);
|
|
|
|
// Create customer session using correct Polar API endpoint
|
|
$response = $this->makeAuthenticatedRequest('POST', '/customer-sessions', [
|
|
'customer_id' => $customer['id'],
|
|
'return_url' => route('dashboard'),
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Polar customer session creation failed: '.$response->body());
|
|
throw new \Exception('Failed to create customer session');
|
|
}
|
|
|
|
$session = $response->json();
|
|
|
|
// Polar provides a direct customer_portal_url in the response
|
|
if (! isset($session['customer_portal_url'])) {
|
|
Log::error('Invalid Polar customer session response', [
|
|
'response' => $session,
|
|
]);
|
|
throw new \Exception('Invalid customer session response - missing portal URL');
|
|
}
|
|
|
|
Log::info('Polar customer portal session created successfully', [
|
|
'user_id' => $user->id,
|
|
'customer_id' => $customer['id'],
|
|
'portal_url' => $session['customer_portal_url'],
|
|
]);
|
|
|
|
return [
|
|
'portal_url' => $session['customer_portal_url'],
|
|
'customer_id' => $customer['id'],
|
|
'session_token' => $session['token'] ?? null,
|
|
'expires_at' => $session['expires_at'] ?? null,
|
|
];
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar customer portal creation failed', [
|
|
'user_id' => $user->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function processWebhook(Request $request): array
|
|
{
|
|
try {
|
|
$payload = $request->getContent();
|
|
$signature = $request->header('Polar-Signature');
|
|
|
|
if (! $this->validateWebhook($request)) {
|
|
Log::error('Invalid Polar webhook signature');
|
|
throw new \Exception('Invalid webhook signature');
|
|
}
|
|
|
|
$webhookData = json_decode($payload, true);
|
|
$eventType = $webhookData['type'] ?? 'unknown';
|
|
$webhookId = $webhookData['id'] ?? null;
|
|
|
|
// Check for idempotency - prevent duplicate processing
|
|
if ($webhookId && $this->isWebhookProcessed($webhookId)) {
|
|
Log::info('Polar webhook already processed, skipping', [
|
|
'webhook_id' => $webhookId,
|
|
'event_type' => $eventType,
|
|
]);
|
|
|
|
return [
|
|
'event_type' => $eventType,
|
|
'processed' => true,
|
|
'idempotent' => true,
|
|
'data' => [
|
|
'webhook_id' => $webhookId,
|
|
'message' => 'Webhook already processed',
|
|
],
|
|
];
|
|
}
|
|
|
|
$result = [
|
|
'event_type' => $eventType,
|
|
'processed' => false,
|
|
'idempotent' => false,
|
|
'webhook_id' => $webhookId,
|
|
'data' => [],
|
|
];
|
|
|
|
switch ($eventType) {
|
|
case 'checkout.created':
|
|
$result = $this->handleCheckoutCreated($webhookData);
|
|
break;
|
|
case 'order.created':
|
|
$result = $this->handleOrderCreated($webhookData);
|
|
break;
|
|
case 'order.paid':
|
|
$result = $this->handleOrderPaid($webhookData);
|
|
break;
|
|
case 'subscription.created':
|
|
$result = $this->handleSubscriptionCreated($webhookData);
|
|
break;
|
|
case 'subscription.active':
|
|
$result = $this->handleSubscriptionActive($webhookData);
|
|
break;
|
|
case 'subscription.updated':
|
|
$result = $this->handleSubscriptionUpdated($webhookData);
|
|
break;
|
|
case 'subscription.cancelled':
|
|
$result = $this->handleSubscriptionCancelled($webhookData);
|
|
break;
|
|
case 'subscription.paused':
|
|
$result = $this->handleSubscriptionPaused($webhookData);
|
|
break;
|
|
case 'subscription.resumed':
|
|
$result = $this->handleSubscriptionResumed($webhookData);
|
|
break;
|
|
case 'subscription.trial_will_end':
|
|
$result = $this->handleSubscriptionTrialWillEnd($webhookData);
|
|
break;
|
|
case 'subscription.trial_ended':
|
|
$result = $this->handleSubscriptionTrialEnded($webhookData);
|
|
break;
|
|
case 'customer.state_changed':
|
|
$result = $this->handleCustomerStateChanged($webhookData);
|
|
break;
|
|
default:
|
|
Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]);
|
|
}
|
|
|
|
// Mark webhook as processed if it has an ID and was successfully processed
|
|
if ($webhookId && ($result['processed'] ?? false)) {
|
|
$this->markWebhookAsProcessed($webhookId, $eventType, $result);
|
|
}
|
|
|
|
return $result;
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar webhook processing failed', [
|
|
'error' => $e->getMessage(),
|
|
'webhook_id' => $webhookId ?? 'none',
|
|
'event_type' => $eventType ?? 'unknown',
|
|
'payload' => $request->getContent(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function validateWebhook(Request $request): bool
|
|
{
|
|
try {
|
|
// In sandbox mode, bypass validation for development
|
|
// if ($this->sandbox) {
|
|
// Log::info('Polar webhook validation bypassed in sandbox mode', [
|
|
// 'sandbox_bypass' => true,
|
|
// ]);
|
|
//
|
|
// return true;
|
|
// }
|
|
|
|
// Check if we have a webhook secret
|
|
if (empty($this->webhookSecret)) {
|
|
Log::warning('Polar webhook validation failed: missing webhook secret');
|
|
|
|
return false;
|
|
}
|
|
|
|
// Extract headers
|
|
$headers = [
|
|
'webhook-id' => $request->header('webhook-id'),
|
|
'webhook-timestamp' => $request->header('webhook-timestamp'),
|
|
'webhook-signature' => $request->header('webhook-signature'),
|
|
];
|
|
|
|
$payload = $request->getContent();
|
|
|
|
Log::info('Polar webhook validation attempt using Standard Webhooks', [
|
|
'webhook_id' => $headers['webhook-id'],
|
|
'has_signature' => ! empty($headers['webhook-signature']),
|
|
'has_timestamp' => ! empty($headers['webhook-timestamp']),
|
|
'payload_length' => strlen($payload),
|
|
]);
|
|
|
|
// Create Standard Webhooks validator for Polar
|
|
$webhook = WebhookFactory::createPolar($this->webhookSecret);
|
|
|
|
// Verify the webhook
|
|
$result = $webhook->verify($payload, $headers);
|
|
|
|
Log::info('Polar webhook validation successful using Standard Webhooks', [
|
|
'webhook_id' => $headers['webhook-id'],
|
|
'payload_size' => strlen($payload),
|
|
]);
|
|
|
|
return true;
|
|
|
|
} catch (WebhookVerificationException $e) {
|
|
Log::warning('Polar webhook validation failed', [
|
|
'error' => $e->getMessage(),
|
|
'webhook_id' => $request->header('webhook-id'),
|
|
]);
|
|
|
|
return false;
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar webhook validation error', [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function getConfiguration(): array
|
|
{
|
|
return $this->config;
|
|
}
|
|
|
|
public function syncSubscriptionStatus(Subscription $subscription): array
|
|
{
|
|
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
|
|
}
|
|
|
|
public function getPaymentMethodDetails(string $paymentMethodId): array
|
|
{
|
|
try {
|
|
// Polar doesn't have separate payment method IDs like Stripe
|
|
// Return subscription details instead
|
|
return $this->getSubscriptionDetails($paymentMethodId);
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar payment method details retrieval failed', [
|
|
'payment_method_id' => $paymentMethodId,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
|
|
{
|
|
try {
|
|
// Polar handles refunds through their dashboard or API
|
|
Log::error('Polar refunds must be processed through Polar dashboard or API directly');
|
|
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;
|
|
}
|
|
}
|
|
|
|
public function getTransactionHistory(User $user, array $filters = []): array
|
|
{
|
|
try {
|
|
$customer = $this->getOrCreateCustomer($user);
|
|
|
|
$params = [
|
|
'customer_id' => $customer['id'],
|
|
'limit' => $filters['limit'] ?? 50,
|
|
];
|
|
|
|
if (isset($filters['start_date'])) {
|
|
$params['start_date'] = $filters['start_date'];
|
|
}
|
|
|
|
if (isset($filters['end_date'])) {
|
|
$params['end_date'] = $filters['end_date'];
|
|
}
|
|
|
|
$response = $this->makeAuthenticatedRequest('GET', '/orders', $params);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Failed to retrieve Polar transaction history: '.$response->body());
|
|
}
|
|
|
|
$polarOrders = $response->json();
|
|
$transactions = [];
|
|
|
|
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;
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar transaction history retrieval failed', [
|
|
'user_id' => $user->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function calculateFees(float $amount): array
|
|
{
|
|
// Polar fees vary by plan and region (typically 5-8%)
|
|
// Using 6% as default for calculation
|
|
$percentageFee = $amount * 0.06;
|
|
$totalFee = $percentageFee; // Polar typically doesn't have fixed fees
|
|
|
|
return [
|
|
'fixed_fee' => 0,
|
|
'percentage_fee' => $percentageFee,
|
|
'total_fee' => $totalFee,
|
|
'net_amount' => $amount - $totalFee,
|
|
];
|
|
}
|
|
|
|
public function getSupportedCurrencies(): array
|
|
{
|
|
return ['USD']; // Polar supports USD, EUR, and other currencies, but USD is most common
|
|
}
|
|
|
|
public function supportsRecurring(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function supportsOneTime(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Helper methods
|
|
protected function getOrCreateCustomer(User $user): array
|
|
{
|
|
// NEW 1:1 BINDING LOGIC: Use polar_cust_id for secure user binding
|
|
|
|
// Check if user already has a Polar customer ID stored
|
|
if ($user->polar_cust_id) {
|
|
Log::info('User has existing Polar customer ID, using it', [
|
|
'user_id' => $user->id,
|
|
'polar_cust_id' => $user->polar_cust_id,
|
|
]);
|
|
|
|
try {
|
|
$response = $this->makeAuthenticatedRequest('GET', '/customers/'.$user->polar_cust_id);
|
|
|
|
if ($response->successful()) {
|
|
$customer = $response->json();
|
|
Log::info('Successfully retrieved existing Polar customer', [
|
|
'user_id' => $user->id,
|
|
'customer_id' => $customer['id'],
|
|
]);
|
|
|
|
return $customer;
|
|
}
|
|
|
|
Log::warning('Stored Polar customer ID not found, will create new one', [
|
|
'user_id' => $user->id,
|
|
'polar_cust_id' => $user->polar_cust_id,
|
|
'status_code' => $response->status(),
|
|
]);
|
|
// Clear the invalid ID and continue to create new customer
|
|
$user->update(['polar_cust_id' => null]);
|
|
} catch (\Exception $e) {
|
|
Log::warning('Failed to retrieve stored Polar customer, will create new one', [
|
|
'user_id' => $user->id,
|
|
'polar_cust_id' => $user->polar_cust_id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
// Clear the invalid ID and continue to create new customer
|
|
$user->update(['polar_cust_id' => null]);
|
|
}
|
|
}
|
|
|
|
// No stored Polar customer ID, search by email to find existing customer
|
|
Log::info('No stored Polar customer ID, searching by email', [
|
|
'user_id' => $user->id,
|
|
'email' => $user->email,
|
|
]);
|
|
|
|
try {
|
|
$response = $this->makeAuthenticatedRequest('GET', '/customers', [
|
|
'email' => $user->email,
|
|
'limit' => 10,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
if (! empty($data['items'])) {
|
|
$customer = $data['items'][0]; // Take the first match
|
|
Log::info('Found existing Polar customer by email', [
|
|
'user_id' => $user->id,
|
|
'customer_id' => $customer['id'],
|
|
'customer_email' => $customer['email'],
|
|
]);
|
|
|
|
// Store the Polar customer ID for future use
|
|
$user->update(['polar_cust_id' => $customer['id']]);
|
|
|
|
return $customer;
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::info('No existing Polar customer found by email', [
|
|
'user_id' => $user->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
// No existing customer found, create new one
|
|
Log::info('Creating new Polar customer for user', [
|
|
'user_id' => $user->id,
|
|
'email' => $user->email,
|
|
]);
|
|
|
|
// Create new customer
|
|
$customerData = [
|
|
'email' => $user->email,
|
|
'name' => $user->name,
|
|
'external_id' => (string) $user->id,
|
|
'metadata' => [
|
|
'user_id' => (string) $user->id,
|
|
'source' => 'laravel_app',
|
|
],
|
|
];
|
|
|
|
$response = $this->makeAuthenticatedRequest('POST', '/customers', $customerData);
|
|
|
|
if (! $response->successful()) {
|
|
$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,
|
|
]);
|
|
|
|
// With the new 1:1 binding system, this shouldn't happen often
|
|
// But if it does, we'll handle it by searching by email again
|
|
Log::warning('Customer creation conflict, searching by email as fallback', [
|
|
'user_id' => $user->id,
|
|
'email' => $user->email,
|
|
]);
|
|
|
|
// Fallback: search by email one more time
|
|
try {
|
|
$response = $this->makeAuthenticatedRequest('GET', '/customers', [
|
|
'email' => $user->email,
|
|
'limit' => 10,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
if (! empty($data['items'])) {
|
|
$customer = $data['items'][0];
|
|
// Store the found customer ID
|
|
$user->update(['polar_cust_id' => $customer['id']]);
|
|
|
|
return $customer;
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
Log::error('Fallback email search also failed', [
|
|
'user_id' => $user->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
throw new \Exception('Unable to create or find Polar customer account. Please contact support.');
|
|
}
|
|
}
|
|
}
|
|
|
|
Log::error('Failed to create Polar customer', [
|
|
'status_code' => $response->status(),
|
|
'response' => $response->json(),
|
|
'user_id' => $user->id,
|
|
]);
|
|
|
|
throw new \Exception('Failed to create customer account. Please try again or contact support.');
|
|
}
|
|
|
|
$customer = $response->json();
|
|
|
|
if (! isset($customer['id'])) {
|
|
throw new \Exception('Invalid response from Polar API: missing customer ID');
|
|
}
|
|
|
|
// Store the new Polar customer ID for 1:1 binding
|
|
$user->update(['polar_cust_id' => $customer['id']]);
|
|
|
|
Log::info('Created new Polar customer and stored ID for 1:1 binding', [
|
|
'user_id' => $user->id,
|
|
'customer_id' => $customer['id'],
|
|
'external_id' => $customer['external_id'],
|
|
]);
|
|
|
|
return $customer;
|
|
}
|
|
|
|
protected function getOrCreatePrice(Plan $plan): string
|
|
{
|
|
// Look for existing product by plan metadata
|
|
try {
|
|
$response = $this->makeAuthenticatedRequest('GET', '/products', [
|
|
'metadata[plan_id]' => $plan->id,
|
|
'limit' => 1,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
if (! empty($data['items'])) {
|
|
$product = $data['items'][0];
|
|
|
|
// 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,
|
|
]);
|
|
}
|
|
|
|
// Create new product with correct structure
|
|
$productData = [
|
|
'name' => $plan->name,
|
|
'description' => $plan->description ?? 'Subscription plan',
|
|
'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,
|
|
],
|
|
];
|
|
|
|
Log::info('Creating Polar product with data', [
|
|
'product_data' => $productData,
|
|
]);
|
|
|
|
$response = $this->makeAuthenticatedRequest('POST', '/products', $productData);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Failed to create Polar product', [
|
|
'status_code' => $response->status(),
|
|
'response' => $response->json(),
|
|
'plan_id' => $plan->id,
|
|
]);
|
|
|
|
throw new \Exception('Failed to create payment plan. Please try again or contact support.');
|
|
}
|
|
|
|
$product = $response->json();
|
|
|
|
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'],
|
|
'price_id' => $product['prices'][0]['id'],
|
|
]);
|
|
|
|
return $product['prices'][0]['id'];
|
|
}
|
|
|
|
protected function getPolarSubscriptionId(Subscription $subscription): ?string
|
|
{
|
|
$providerData = $subscription->provider_data ?? [];
|
|
|
|
// Try different locations where the subscription ID might be stored
|
|
return $providerData['polar_subscription']['id'] ??
|
|
$providerData['subscription_id'] ??
|
|
$subscription->provider_subscription_id;
|
|
}
|
|
|
|
// Webhook handlers
|
|
protected function handleCheckoutCreated(array $webhookData): array
|
|
{
|
|
$checkout = $webhookData['data'];
|
|
|
|
// Update local subscription with checkout ID
|
|
Subscription::where('provider_subscription_id', $checkout['id'])->update([
|
|
'provider_data' => array_merge(
|
|
Subscription::where('provider_subscription_id', $checkout['id'])->first()?->provider_data ?? [],
|
|
[
|
|
'checkout_id' => $checkout['id'],
|
|
'customer_id' => $checkout['customer_id'],
|
|
'polar_checkout' => $checkout,
|
|
]
|
|
),
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'checkout.created',
|
|
'processed' => true,
|
|
'data' => [
|
|
'checkout_id' => $checkout['id'],
|
|
'customer_id' => $checkout['customer_id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleOrderCreated(array $webhookData): array
|
|
{
|
|
$order = $webhookData['data'];
|
|
|
|
// Find subscription by checkout ID or customer metadata
|
|
$subscription = Subscription::where('provider', 'polar')
|
|
->where(function ($query) use ($order) {
|
|
$query->where('provider_subscription_id', $order['checkout_id'] ?? null)
|
|
->orWhereHas('user', function ($q) use ($order) {
|
|
$q->where('email', $order['customer_email'] ?? null);
|
|
});
|
|
})
|
|
->first();
|
|
|
|
if ($subscription) {
|
|
$subscription->update([
|
|
'provider_data' => array_merge($subscription->provider_data ?? [], [
|
|
'order_id' => $order['id'],
|
|
'polar_order' => $order,
|
|
'order_created_at' => now()->toISOString(),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'event_type' => 'order.created',
|
|
'processed' => true,
|
|
'data' => [
|
|
'order_id' => $order['id'],
|
|
'checkout_id' => $order['checkout_id'] ?? null,
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleOrderPaid(array $webhookData): array
|
|
{
|
|
$order = $webhookData['data'];
|
|
|
|
// Find and activate subscription
|
|
$subscription = Subscription::where('provider', 'polar')
|
|
->where(function ($query) use ($order) {
|
|
$query->where('provider_subscription_id', $order['checkout_id'] ?? null)
|
|
->orWhereHas('user', function ($q) use ($order) {
|
|
$q->where('email', $order['customer_email'] ?? null);
|
|
});
|
|
})
|
|
->first();
|
|
|
|
if ($subscription && $subscription->status === 'pending_payment') {
|
|
$subscription->update([
|
|
'status' => 'active',
|
|
'starts_at' => now(),
|
|
'provider_data' => array_merge($subscription->provider_data ?? [], [
|
|
'order_paid_at' => now()->toISOString(),
|
|
'polar_order' => $order,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'event_type' => 'order.paid',
|
|
'processed' => true,
|
|
'data' => [
|
|
'order_id' => $order['id'],
|
|
'subscription_id' => $subscription?->id,
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionCreated(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
|
|
// Find subscription using both subscription ID and checkout ID fallback
|
|
$localSubscription = $this->findSubscriptionByPolarId(
|
|
$polarSubscription['id'],
|
|
$polarSubscription['checkout_id'] ?? null
|
|
);
|
|
|
|
if ($localSubscription) {
|
|
$updateData = [
|
|
'provider_subscription_id' => $polarSubscription['id'],
|
|
'status' => $polarSubscription['status'],
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'activated_at' => now()->toISOString(),
|
|
]),
|
|
];
|
|
|
|
// Parse dates if available
|
|
if (! empty($polarSubscription['current_period_start'])) {
|
|
$updateData['starts_at'] = Carbon::parse($polarSubscription['current_period_start']);
|
|
}
|
|
if (! empty($polarSubscription['current_period_end'])) {
|
|
$updateData['ends_at'] = Carbon::parse($polarSubscription['current_period_end']);
|
|
}
|
|
if (! empty($polarSubscription['trial_end'])) {
|
|
$updateData['trial_ends_at'] = Carbon::parse($polarSubscription['trial_end']);
|
|
}
|
|
if (! empty($polarSubscription['cancelled_at'])) {
|
|
$updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']);
|
|
}
|
|
|
|
$localSubscription->update($updateData);
|
|
|
|
Log::info('Polar subscription created/updated via webhook', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
'status' => $polarSubscription['status'],
|
|
]);
|
|
} else {
|
|
Log::warning('Subscription not found for Polar subscription.created webhook', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'event_type' => 'subscription.created',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'status' => $polarSubscription['status'],
|
|
'local_subscription_id' => $localSubscription?->id,
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionActive(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
|
|
// Find subscription using both subscription ID and checkout ID fallback
|
|
$localSubscription = $this->findSubscriptionByPolarId(
|
|
$polarSubscription['id'],
|
|
$polarSubscription['checkout_id'] ?? null
|
|
);
|
|
|
|
if ($localSubscription) {
|
|
$updateData = [
|
|
'status' => 'active',
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'activated_at' => now()->toISOString(),
|
|
]),
|
|
];
|
|
|
|
// Parse dates if available
|
|
if (! empty($polarSubscription['current_period_start'])) {
|
|
$updateData['starts_at'] = Carbon::parse($polarSubscription['current_period_start']);
|
|
}
|
|
if (! empty($polarSubscription['current_period_end'])) {
|
|
$updateData['ends_at'] = Carbon::parse($polarSubscription['current_period_end']);
|
|
}
|
|
if (! empty($polarSubscription['trial_end'])) {
|
|
$updateData['trial_ends_at'] = Carbon::parse($polarSubscription['trial_end']);
|
|
}
|
|
|
|
$localSubscription->update($updateData);
|
|
|
|
Log::info('Polar subscription activated via webhook', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'status' => 'active',
|
|
]);
|
|
} else {
|
|
Log::warning('Subscription not found for Polar subscription.active webhook', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'event_type' => 'subscription.active',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'status' => 'active',
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionUpdated(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
|
|
// Find subscription using both subscription ID and checkout ID fallback
|
|
$localSubscription = $this->findSubscriptionByPolarId(
|
|
$polarSubscription['id'],
|
|
$polarSubscription['checkout_id'] ?? null
|
|
);
|
|
|
|
if ($localSubscription) {
|
|
$updateData = [
|
|
'status' => $polarSubscription['status'],
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'updated_at' => now()->toISOString(),
|
|
]),
|
|
];
|
|
|
|
// Parse dates if available
|
|
if (! empty($polarSubscription['current_period_start'])) {
|
|
$updateData['starts_at'] = Carbon::parse($polarSubscription['current_period_start']);
|
|
}
|
|
if (! empty($polarSubscription['current_period_end'])) {
|
|
$updateData['ends_at'] = Carbon::parse($polarSubscription['current_period_end']);
|
|
}
|
|
if (! empty($polarSubscription['trial_end'])) {
|
|
$updateData['trial_ends_at'] = Carbon::parse($polarSubscription['trial_end']);
|
|
}
|
|
if (! empty($polarSubscription['cancelled_at'])) {
|
|
$updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']);
|
|
}
|
|
if (! empty($polarSubscription['ends_at'])) {
|
|
$updateData['ends_at'] = Carbon::parse($polarSubscription['ends_at']);
|
|
}
|
|
|
|
$localSubscription->update($updateData);
|
|
|
|
Log::info('Polar subscription updated via webhook', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'status' => $polarSubscription['status'],
|
|
]);
|
|
} else {
|
|
Log::warning('Subscription not found for Polar subscription.updated webhook', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'event_type' => 'subscription.updated',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'status' => $polarSubscription['status'],
|
|
'local_subscription_id' => $localSubscription?->id,
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionCancelled(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
|
|
// Find subscription using both subscription ID and checkout ID fallback
|
|
$localSubscription = $this->findSubscriptionByPolarId(
|
|
$polarSubscription['id'],
|
|
$polarSubscription['checkout_id'] ?? null
|
|
);
|
|
|
|
if ($localSubscription) {
|
|
// Build cancellation reason from Polar data
|
|
$cancellationReason = 'Polar webhook cancellation';
|
|
|
|
if (! empty($polarSubscription['customer_cancellation_reason'])) {
|
|
$cancellationReason = $polarSubscription['customer_cancellation_reason'];
|
|
|
|
// Add comment if available
|
|
if (! empty($polarSubscription['customer_cancellation_comment'])) {
|
|
$cancellationReason .= ' - Comment: '.$polarSubscription['customer_cancellation_comment'];
|
|
}
|
|
} elseif (! empty($polarSubscription['cancel_at_period_end']) && $polarSubscription['cancel_at_period_end']) {
|
|
$cancellationReason = 'Customer cancelled via Polar portal (cancel at period end)';
|
|
}
|
|
|
|
$updateData = [
|
|
'status' => 'cancelled',
|
|
'cancellation_reason' => $cancellationReason,
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'cancelled_at_webhook' => now()->toISOString(),
|
|
]),
|
|
];
|
|
|
|
// Use Polar's cancellation timestamp if available, otherwise use now
|
|
if (! empty($polarSubscription['cancelled_at'])) {
|
|
$updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']);
|
|
} else {
|
|
$updateData['cancelled_at'] = now();
|
|
}
|
|
|
|
// Set ends_at if Polar provides it (actual expiry date)
|
|
if (! empty($polarSubscription['ends_at'])) {
|
|
$updateData['ends_at'] = Carbon::parse($polarSubscription['ends_at']);
|
|
} elseif (! empty($polarSubscription['current_period_end'])) {
|
|
// If no explicit ends_at, use current_period_end as expiry
|
|
$updateData['ends_at'] = Carbon::parse($polarSubscription['current_period_end']);
|
|
}
|
|
|
|
$localSubscription->update($updateData);
|
|
|
|
Log::info('Polar subscription cancelled via webhook', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'cancellation_reason' => $cancellationReason,
|
|
'cancelled_at' => $updateData['cancelled_at']->toISOString(),
|
|
'ends_at' => $updateData['ends_at']?->toISOString(),
|
|
]);
|
|
} else {
|
|
Log::warning('Subscription not found for Polar subscription.cancelled webhook', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
]);
|
|
}
|
|
|
|
return [
|
|
'event_type' => 'subscription.cancelled',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'cancellation_reason' => $cancellationReason ?? 'Polar webhook cancellation',
|
|
'local_subscription_id' => $localSubscription?->id,
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionPaused(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
$localSubscription = $this->findSubscriptionByPolarId(
|
|
$polarSubscription['id'],
|
|
$polarSubscription['checkout_id'] ?? null
|
|
);
|
|
|
|
if (! $localSubscription) {
|
|
Log::warning('Polar paused webhook: subscription not found', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.paused',
|
|
'processed' => false,
|
|
'error' => 'Subscription not found',
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// Parse dates from Polar response
|
|
$pausedAt = null;
|
|
if (isset($polarSubscription['paused_at'])) {
|
|
$pausedAt = \Carbon\Carbon::parse($polarSubscription['paused_at']);
|
|
}
|
|
|
|
// Update local subscription
|
|
$localSubscription->update([
|
|
'status' => 'paused',
|
|
'paused_at' => $pausedAt,
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'paused_at' => $pausedAt?->toISOString(),
|
|
'pause_reason' => $polarSubscription['pause_reason'] ?? null,
|
|
]),
|
|
]);
|
|
|
|
Log::info('Polar subscription paused', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'paused_at' => $pausedAt?->toISOString(),
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.paused',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'paused_at' => $pausedAt?->toISOString(),
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionResumed(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
$localSubscription = $this->findSubscriptionByPolarId(
|
|
$polarSubscription['id'],
|
|
$polarSubscription['checkout_id'] ?? null
|
|
);
|
|
|
|
if (! $localSubscription) {
|
|
Log::warning('Polar resumed webhook: subscription not found', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.resumed',
|
|
'processed' => false,
|
|
'error' => 'Subscription not found',
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// Parse dates from Polar response
|
|
$resumedAt = null;
|
|
if (isset($polarSubscription['resumed_at'])) {
|
|
$resumedAt = \Carbon\Carbon::parse($polarSubscription['resumed_at']);
|
|
}
|
|
|
|
// Handle current_period_start/end for resumed subscription
|
|
$startsAt = null;
|
|
$endsAt = null;
|
|
|
|
if (isset($polarSubscription['current_period_start'])) {
|
|
$startsAt = \Carbon\Carbon::parse($polarSubscription['current_period_start']);
|
|
}
|
|
|
|
if (isset($polarSubscription['current_period_end'])) {
|
|
$endsAt = \Carbon\Carbon::parse($polarSubscription['current_period_end']);
|
|
}
|
|
|
|
// Update local subscription
|
|
$localSubscription->update([
|
|
'status' => 'active',
|
|
'resumed_at' => $resumedAt,
|
|
'starts_at' => $startsAt,
|
|
'ends_at' => $endsAt,
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'resumed_at' => $resumedAt?->toISOString(),
|
|
'resume_reason' => $polarSubscription['resume_reason'] ?? null,
|
|
]),
|
|
]);
|
|
|
|
Log::info('Polar subscription resumed', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'resumed_at' => $resumedAt?->toISOString(),
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.resumed',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'resumed_at' => $resumedAt?->toISOString(),
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionTrialWillEnd(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
$localSubscription = $this->findSubscriptionByPolarId(
|
|
$polarSubscription['id'],
|
|
$polarSubscription['checkout_id'] ?? null
|
|
);
|
|
|
|
if (! $localSubscription) {
|
|
Log::warning('Polar trial_will_end webhook: subscription not found', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.trial_will_end',
|
|
'processed' => false,
|
|
'error' => 'Subscription not found',
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// Parse trial end date from Polar response
|
|
$trialEndsAt = null;
|
|
if (isset($polarSubscription['trial_ends_at'])) {
|
|
$trialEndsAt = \Carbon\Carbon::parse($polarSubscription['trial_ends_at']);
|
|
}
|
|
|
|
// Update local subscription with trial information
|
|
$localSubscription->update([
|
|
'trial_ends_at' => $trialEndsAt,
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'trial_will_end_sent_at' => now()->toISOString(),
|
|
'trial_ends_at' => $trialEndsAt?->toISOString(),
|
|
]),
|
|
]);
|
|
|
|
Log::info('Polar subscription trial will end soon', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'trial_ends_at' => $trialEndsAt?->toISOString(),
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.trial_will_end',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'trial_ends_at' => $trialEndsAt?->toISOString(),
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionTrialEnded(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
$localSubscription = $this->findSubscriptionByPolarId(
|
|
$polarSubscription['id'],
|
|
$polarSubscription['checkout_id'] ?? null
|
|
);
|
|
|
|
if (! $localSubscription) {
|
|
Log::warning('Polar trial_ended webhook: subscription not found', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'checkout_id' => $polarSubscription['checkout_id'] ?? null,
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.trial_ended',
|
|
'processed' => false,
|
|
'error' => 'Subscription not found',
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// Parse dates from Polar response
|
|
$trialEndedAt = null;
|
|
if (isset($polarSubscription['trial_ended_at'])) {
|
|
$trialEndedAt = \Carbon\Carbon::parse($polarSubscription['trial_ended_at']);
|
|
}
|
|
|
|
// Handle current_period_start/end for converted subscription
|
|
$startsAt = null;
|
|
$endsAt = null;
|
|
|
|
if (isset($polarSubscription['current_period_start'])) {
|
|
$startsAt = \Carbon\Carbon::parse($polarSubscription['current_period_start']);
|
|
}
|
|
|
|
if (isset($polarSubscription['current_period_end'])) {
|
|
$endsAt = \Carbon\Carbon::parse($polarSubscription['current_period_end']);
|
|
}
|
|
|
|
// Update local subscription - trial has ended, convert to active or handle accordingly
|
|
$localSubscription->update([
|
|
'status' => $polarSubscription['status'] ?? 'active', // Usually becomes active
|
|
'trial_ends_at' => now(), // Mark trial as ended
|
|
'starts_at' => $startsAt,
|
|
'ends_at' => $endsAt,
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'trial_ended_at' => $trialEndedAt?->toISOString(),
|
|
'trial_converted_to' => $polarSubscription['status'] ?? 'active',
|
|
]),
|
|
]);
|
|
|
|
Log::info('Polar subscription trial ended', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'trial_ended_at' => $trialEndedAt?->toISOString(),
|
|
'new_status' => $polarSubscription['status'] ?? 'active',
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.trial_ended',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'trial_ended_at' => $trialEndedAt?->toISOString(),
|
|
'new_status' => $polarSubscription['status'] ?? 'active',
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleCustomerStateChanged(array $webhookData): array
|
|
{
|
|
$customer = $webhookData['data'];
|
|
|
|
Log::info('Processing Polar customer state changed webhook', [
|
|
'customer_id' => $customer['id'],
|
|
'customer_email' => $customer['email'],
|
|
'external_id' => $customer['external_id'] ?? null,
|
|
'active_subscriptions_count' => count($customer['active_subscriptions'] ?? []),
|
|
]);
|
|
|
|
// Find user by external_id or email
|
|
$user = null;
|
|
if (! empty($customer['external_id'])) {
|
|
$user = \App\Models\User::where('id', $customer['external_id'])->first();
|
|
}
|
|
|
|
if (! $user) {
|
|
$user = \App\Models\User::where('email', $customer['email'])->first();
|
|
}
|
|
|
|
if (! $user) {
|
|
Log::warning('Customer state changed: User not found', [
|
|
'customer_email' => $customer['email'],
|
|
'external_id' => $customer['external_id'] ?? null,
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'customer.state_changed',
|
|
'processed' => false,
|
|
'data' => [
|
|
'error' => 'User not found',
|
|
'customer_email' => $customer['email'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// Update user's Polar customer ID if needed
|
|
if (empty($user->polar_cust_id) || $user->polar_cust_id !== $customer['id']) {
|
|
$user->update(['polar_cust_id' => $customer['id']]);
|
|
Log::info('Updated user Polar customer ID', [
|
|
'user_id' => $user->id,
|
|
'polar_cust_id' => $customer['id'],
|
|
]);
|
|
}
|
|
|
|
// Process active subscriptions from the webhook
|
|
$processedSubscriptions = 0;
|
|
foreach ($customer['active_subscriptions'] ?? [] as $activeSub) {
|
|
$subscription = Subscription::where('user_id', $user->id)
|
|
->where('provider', 'polar')
|
|
->where(function ($query) use ($activeSub) {
|
|
$query->where('provider_subscription_id', $activeSub['id'])
|
|
->orWhere('provider_checkout_id', $activeSub['id']);
|
|
})
|
|
->first();
|
|
|
|
if ($subscription) {
|
|
// Update subscription with latest data from Polar
|
|
$subscription->update([
|
|
'provider_subscription_id' => $activeSub['id'],
|
|
'status' => $activeSub['status'],
|
|
'starts_at' => $activeSub['started_at'] ? \Carbon\Carbon::parse($activeSub['started_at']) : null,
|
|
'ends_at' => $activeSub['ends_at'] ? \Carbon\Carbon::parse($activeSub['ends_at']) : null,
|
|
'cancelled_at' => $activeSub['canceled_at'] ? \Carbon\Carbon::parse($activeSub['canceled_at']) : null,
|
|
'provider_data' => array_merge($subscription->provider_data ?? [], [
|
|
'customer_state_changed_at' => now()->toISOString(),
|
|
'polar_subscription_data' => $activeSub,
|
|
'customer_metadata' => $customer['metadata'] ?? [],
|
|
]),
|
|
]);
|
|
|
|
$processedSubscriptions++;
|
|
Log::info('Updated subscription from customer state changed', [
|
|
'subscription_id' => $subscription->id,
|
|
'polar_subscription_id' => $activeSub['id'],
|
|
'status' => $activeSub['status'],
|
|
]);
|
|
} else {
|
|
Log::info('Active subscription not found in local database', [
|
|
'user_id' => $user->id,
|
|
'polar_subscription_id' => $activeSub['id'],
|
|
'status' => $activeSub['status'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'event_type' => 'customer.state_changed',
|
|
'processed' => true,
|
|
'data' => [
|
|
'customer_id' => $customer['id'],
|
|
'user_id' => $user->id,
|
|
'processed_subscriptions' => $processedSubscriptions,
|
|
'active_subscriptions_count' => count($customer['active_subscriptions'] ?? []),
|
|
],
|
|
];
|
|
}
|
|
|
|
// Additional interface methods
|
|
public function getSubscriptionMetadata(Subscription $subscription): array
|
|
{
|
|
return $subscription->provider_data['polar_subscription'] ?? [];
|
|
}
|
|
|
|
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
|
|
{
|
|
try {
|
|
$subscription->update([
|
|
'provider_data' => array_merge($subscription->provider_data ?? [], [
|
|
'metadata' => $metadata,
|
|
]),
|
|
]);
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to update Polar subscription metadata', [
|
|
'subscription_id' => $subscription->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function startTrial(Subscription $subscription, int $trialDays): bool
|
|
{
|
|
// Polar handles trials through checkout creation
|
|
// This would require creating a new checkout with trial period
|
|
return false;
|
|
}
|
|
|
|
public function applyCoupon(Subscription $subscription, string $couponCode): array
|
|
{
|
|
try {
|
|
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
|
|
|
if (! $polarSubscriptionId) {
|
|
throw new \Exception('No Polar subscription found');
|
|
}
|
|
|
|
$response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/discount', [
|
|
'coupon_code' => $couponCode,
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Failed to apply Polar coupon: '.$response->body());
|
|
}
|
|
|
|
return $response->json();
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar coupon application failed', [
|
|
'subscription_id' => $subscription->id,
|
|
'coupon_code' => $couponCode,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function removeCoupon(Subscription $subscription): bool
|
|
{
|
|
try {
|
|
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
|
|
|
if (! $polarSubscriptionId) {
|
|
return false;
|
|
}
|
|
|
|
$response = $this->makeAuthenticatedRequest('DELETE', '/subscriptions/'.$polarSubscriptionId.'/discount');
|
|
|
|
return $response->successful();
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar coupon removal failed', [
|
|
'subscription_id' => $subscription->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function getUpcomingInvoice(Subscription $subscription): array
|
|
{
|
|
try {
|
|
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
|
|
|
if (! $polarSubscriptionId) {
|
|
return [
|
|
'amount_due' => 0,
|
|
'currency' => 'USD',
|
|
'next_payment_date' => null,
|
|
];
|
|
}
|
|
|
|
$response = $this->makeAuthenticatedRequest('GET', '/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice');
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Failed to retrieve Polar upcoming invoice: '.$response->body());
|
|
}
|
|
|
|
$invoice = $response->json();
|
|
|
|
return [
|
|
'amount_due' => $invoice['amount_due'] / 100, // Convert from cents
|
|
'currency' => $invoice['currency'],
|
|
'next_payment_date' => $invoice['next_payment_date'],
|
|
];
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar upcoming invoice retrieval failed', [
|
|
'subscription_id' => $subscription->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
public function retryFailedPayment(Subscription $subscription): array
|
|
{
|
|
// Polar doesn't have explicit retry logic - payments are retried automatically
|
|
return $this->syncSubscriptionStatus($subscription);
|
|
}
|
|
|
|
public function canModifySubscription(Subscription $subscription): bool
|
|
{
|
|
try {
|
|
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
|
|
|
|
return in_array($details['status'], ['active', 'trialing']);
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function getCancellationTerms(Subscription $subscription): array
|
|
{
|
|
return [
|
|
'immediate_cancellation' => true,
|
|
'refund_policy' => 'no_pro_rated_refunds',
|
|
'cancellation_effective' => 'immediately',
|
|
'billing_cycle_proration' => false,
|
|
];
|
|
}
|
|
|
|
public function exportSubscriptionData(Subscription $subscription): array
|
|
{
|
|
return [
|
|
'provider' => 'polar',
|
|
'provider_subscription_id' => $subscription->provider_subscription_id,
|
|
'data' => $subscription->provider_data,
|
|
];
|
|
}
|
|
|
|
public function importSubscriptionData(User $user, array $subscriptionData): array
|
|
{
|
|
Log::error('Import to Polar payments not implemented');
|
|
throw new \Exception('Import subscription data not implemented for Polar');
|
|
}
|
|
|
|
/**
|
|
* Sanitize Polar API error messages to prevent exposing sensitive information
|
|
*/
|
|
private function sanitizePolarErrorMessage(array $responseBody, int $statusCode): string
|
|
{
|
|
// Handle specific error types with user-friendly messages
|
|
if (isset($responseBody['error'])) {
|
|
$errorType = $responseBody['error'];
|
|
|
|
return match ($errorType) {
|
|
'RequestValidationError' => $this->handleValidationError($responseBody),
|
|
'AuthenticationError' => 'Payment service authentication failed. Please try again.',
|
|
'InsufficientPermissions' => 'Insufficient permissions to process payment. Please contact support.',
|
|
'ResourceNotFound' => 'Payment resource not found. Please try again.',
|
|
'RateLimitExceeded' => 'Too many payment requests. Please wait and try again.',
|
|
'PaymentRequired' => 'Payment required to complete this action.',
|
|
default => 'Payment processing failed. Please try again or contact support.',
|
|
};
|
|
}
|
|
|
|
// Handle validation errors in detail array
|
|
if (isset($responseBody['detail']) && is_array($responseBody['detail'])) {
|
|
return $this->handleValidationError($responseBody);
|
|
}
|
|
|
|
// Generic error based on status code
|
|
return match ($statusCode) {
|
|
400 => 'Invalid payment request. Please check your information and try again.',
|
|
401 => 'Payment authentication failed. Please try again.',
|
|
403 => 'Payment authorization failed. Please contact support.',
|
|
404 => 'Payment service not available. Please try again.',
|
|
429 => 'Too many payment requests. Please wait and try again.',
|
|
500 => 'Payment service error. Please try again later.',
|
|
502, 503, 504 => 'Payment service temporarily unavailable. Please try again later.',
|
|
default => 'Payment processing failed. Please try again or contact support.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle validation errors and extract user-friendly messages
|
|
*/
|
|
private function handleValidationError(array $responseBody): string
|
|
{
|
|
if (! isset($responseBody['detail']) || ! is_array($responseBody['detail'])) {
|
|
return 'Invalid payment information provided. Please check your details and try again.';
|
|
}
|
|
|
|
$errors = [];
|
|
foreach ($responseBody['detail'] as $error) {
|
|
if (isset($error['msg']) && isset($error['loc'])) {
|
|
$field = $this->extractFieldName($error['loc']);
|
|
$message = $this->sanitizeValidationMessage($error['msg'], $field);
|
|
$errors[] = $message;
|
|
}
|
|
}
|
|
|
|
if (empty($errors)) {
|
|
return 'Invalid payment information provided. Please check your details and try again.';
|
|
}
|
|
|
|
return implode(' ', array_unique($errors));
|
|
}
|
|
|
|
/**
|
|
* Extract field name from error location path
|
|
*/
|
|
private function extractFieldName(array $loc): string
|
|
{
|
|
if (empty($loc)) {
|
|
return 'field';
|
|
}
|
|
|
|
// Get the last element of the location array
|
|
$field = end($loc);
|
|
|
|
// Convert to user-friendly field names
|
|
return match ($field) {
|
|
'customer_email' => 'email address',
|
|
'customer_name' => 'name',
|
|
'products' => 'product selection',
|
|
'product_id' => 'product selection',
|
|
'product_price_id' => 'product selection',
|
|
'success_url' => 'redirect settings',
|
|
'cancel_url' => 'redirect settings',
|
|
default => strtolower(str_replace('_', ' ', $field)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sanitize validation message to remove sensitive information
|
|
*/
|
|
private function sanitizeValidationMessage(string $message, string $field): string
|
|
{
|
|
// Remove email addresses and other sensitive data from error messages
|
|
$sanitized = preg_replace('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', '[email]', $message);
|
|
|
|
// Remove domain names and URLs
|
|
$sanitized = preg_replace('/\b[a-z0-9.-]+\.[a-z]{2,}\b/i', '[domain]', $sanitized);
|
|
|
|
// Remove UUIDs and other identifiers
|
|
$sanitized = preg_replace('/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i', '[ID]', $sanitized);
|
|
|
|
// Convert technical terms to user-friendly language
|
|
$sanitized = str_replace([
|
|
'is not a valid email address',
|
|
'does not exist',
|
|
'Field required',
|
|
'value_error',
|
|
'missing',
|
|
'type_error',
|
|
], [
|
|
'is not valid',
|
|
'is not available',
|
|
'is required',
|
|
'is invalid',
|
|
'is missing',
|
|
'is not correct',
|
|
], $sanitized);
|
|
|
|
// Add field context
|
|
return "The {$field} {$sanitized}";
|
|
}
|
|
|
|
/**
|
|
* Check if webhook has already been processed
|
|
*/
|
|
protected function isWebhookProcessed(string $webhookId): bool
|
|
{
|
|
return cache()->has("polar_webhook_processed_{$webhookId}");
|
|
}
|
|
|
|
/**
|
|
* Mark webhook as processed to prevent duplicate processing
|
|
*/
|
|
protected function markWebhookAsProcessed(string $webhookId, string $eventType, array $result): void
|
|
{
|
|
// Store webhook processing record for 24 hours
|
|
cache()->put("polar_webhook_processed_{$webhookId}", [
|
|
'webhook_id' => $webhookId,
|
|
'event_type' => $eventType,
|
|
'processed_at' => now()->toISOString(),
|
|
'result' => $result,
|
|
], now()->addHours(24));
|
|
|
|
Log::info('Polar webhook marked as processed', [
|
|
'webhook_id' => $webhookId,
|
|
'event_type' => $eventType,
|
|
'processed_at' => now()->toISOString(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Find subscription by Polar subscription ID or checkout ID
|
|
*/
|
|
protected function findSubscriptionByPolarId(string $polarSubscriptionId, ?string $checkoutId = null): ?Subscription
|
|
{
|
|
$query = Subscription::where('provider', 'polar');
|
|
|
|
// First try by subscription ID
|
|
$subscription = $query->where('provider_subscription_id', $polarSubscriptionId)->first();
|
|
|
|
// If not found and checkout ID is provided, try by checkout ID
|
|
if (! $subscription && $checkoutId) {
|
|
$subscription = Subscription::where('provider', 'polar')
|
|
->where('provider_checkout_id', $checkoutId)
|
|
->first();
|
|
|
|
// If found by checkout ID, update the subscription with the actual subscription ID
|
|
if ($subscription) {
|
|
$subscription->update(['provider_subscription_id' => $polarSubscriptionId]);
|
|
Log::info('Updated subscription with Polar subscription ID', [
|
|
'subscription_id' => $subscription->id,
|
|
'checkout_id' => $checkoutId,
|
|
'provider_subscription_id' => $polarSubscriptionId,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $subscription;
|
|
}
|
|
|
|
/**
|
|
* Get webhook processing statistics
|
|
*/
|
|
public function getWebhookStats(): array
|
|
{
|
|
// This could be enhanced to use a database table for more permanent stats
|
|
$stats = [
|
|
'total_processed' => 0,
|
|
'recent_processed' => 0,
|
|
'error_rate' => 0,
|
|
];
|
|
|
|
// For now, return basic stats - could be expanded with database tracking
|
|
return $stats;
|
|
}
|
|
}
|