- Add support for subscription.uncanceled webhook event - Fix spelling mismatch for subscription.canceled (Polar) vs subscription.cancelled (code) - Implement proper cancel_at_period_end handling in subscription.canceled events - Add cancelled_at field updates for subscription.updated events - Handle Polar's spelling variants (canceled_at vs cancelled_at) consistently - Remove non-existent pause_reason column from subscription uncanceled handler - Enhance webhook logging with detailed field update tracking - Add comprehensive cancellation metadata storage in provider_data - Gracefully handle null provider_subscription_id in payment confirmation polling All Polar webhook events now properly sync subscription state including cancellation timing, reasons, and billing period details.
2365 lines
90 KiB
PHP
2365 lines
90 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':
|
|
case 'subscription.canceled': // Handle both spellings
|
|
$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 'subscription.uncanceled':
|
|
$result = $this->handleSubscriptionUncanceled($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(),
|
|
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
|
]),
|
|
];
|
|
|
|
// 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']);
|
|
} elseif (! empty($polarSubscription['canceled_at'])) {
|
|
// Handle Polar's spelling with 1 'L'
|
|
$updateData['cancelled_at'] = Carbon::parse($polarSubscription['canceled_at']);
|
|
}
|
|
if (! empty($polarSubscription['ends_at'])) {
|
|
$updateData['ends_at'] = Carbon::parse($polarSubscription['ends_at']);
|
|
}
|
|
|
|
// Handle cancellation details
|
|
if (isset($polarSubscription['cancel_at_period_end'])) {
|
|
$updateData['cancel_at_period_end'] = $polarSubscription['cancel_at_period_end'];
|
|
}
|
|
|
|
// Set cancellation reason if provided
|
|
if (! empty($polarSubscription['customer_cancellation_reason'])) {
|
|
$updateData['cancellation_reason'] = $polarSubscription['customer_cancellation_reason'];
|
|
}
|
|
|
|
$localSubscription->update($updateData);
|
|
|
|
Log::info('Polar subscription updated via webhook', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'status' => $polarSubscription['status'],
|
|
'updated_fields' => array_keys($updateData),
|
|
'cancelled_at_updated' => isset($updateData['cancelled_at']),
|
|
'cancellation_reason_updated' => isset($updateData['cancellation_reason']),
|
|
'cancel_at_period_end_updated' => isset($updateData['cancel_at_period_end']),
|
|
]);
|
|
} 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)';
|
|
}
|
|
|
|
// Check if subscription should remain active until period end
|
|
$shouldRemainActive = ! empty($polarSubscription['cancel_at_period_end']) && $polarSubscription['status'] === 'active';
|
|
|
|
$updateData = [
|
|
'status' => $shouldRemainActive ? $polarSubscription['status'] : 'cancelled',
|
|
'cancellation_reason' => $cancellationReason,
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'cancelled_at_webhook' => now()->toISOString(),
|
|
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
|
]),
|
|
];
|
|
|
|
// Set cancel_at_period_end flag if provided
|
|
if (isset($polarSubscription['cancel_at_period_end'])) {
|
|
$updateData['cancel_at_period_end'] = $polarSubscription['cancel_at_period_end'];
|
|
}
|
|
|
|
// Use Polar's cancellation timestamp if available, otherwise use now
|
|
if (! empty($polarSubscription['cancelled_at'])) {
|
|
$updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']);
|
|
} elseif (! empty($polarSubscription['canceled_at'])) {
|
|
// Handle Polar's spelling with 1 'L'
|
|
$updateData['cancelled_at'] = Carbon::parse($polarSubscription['canceled_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 cancellation processed via webhook', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'polar_status' => $polarSubscription['status'],
|
|
'local_status_set' => $updateData['status'],
|
|
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
|
'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 handleSubscriptionUncanceled(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data'];
|
|
|
|
Log::info('Processing Polar subscription uncanceled webhook', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'customer_id' => $polarSubscription['customer_id'],
|
|
'status' => $polarSubscription['status'],
|
|
]);
|
|
|
|
// Find local subscription by provider subscription ID
|
|
$localSubscription = \App\Models\Subscription::where('provider', 'polar')
|
|
->where('provider_subscription_id', $polarSubscription['id'])
|
|
->first();
|
|
|
|
if (! $localSubscription) {
|
|
Log::warning('Polar subscription uncanceled: local subscription not found', [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'customer_id' => $polarSubscription['customer_id'],
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.uncanceled',
|
|
'processed' => false,
|
|
'error' => 'Local subscription not found',
|
|
'data' => [
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// Parse dates from Polar response
|
|
$startsAt = null;
|
|
$endsAt = null;
|
|
$cancelledAt = 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']);
|
|
}
|
|
// Handle ends_at (cancellation/expiry date)
|
|
elseif (isset($polarSubscription['ends_at'])) {
|
|
$endsAt = \Carbon\Carbon::parse($polarSubscription['ends_at']);
|
|
}
|
|
|
|
// Handle cancelled_at (should be null for uncanceled subscriptions)
|
|
if (isset($polarSubscription['canceled_at'])) {
|
|
$cancelledAt = \Carbon\Carbon::parse($polarSubscription['canceled_at']);
|
|
}
|
|
|
|
// Update local subscription - subscription has been reactivated
|
|
$localSubscription->update([
|
|
'status' => $polarSubscription['status'] ?? 'active', // Should be active
|
|
'cancelled_at' => $cancelledAt, // Should be null for uncanceled
|
|
'cancellation_reason' => null, // Clear cancellation reason
|
|
'ends_at' => $endsAt, // Should be null or updated to new end date
|
|
'resumed_at' => now(), // Mark as resumed
|
|
'paused_at' => null, // Clear pause date if any
|
|
'starts_at' => $startsAt,
|
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
|
'polar_subscription' => $polarSubscription,
|
|
'uncanceled_at' => now()->toISOString(),
|
|
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
|
]),
|
|
]);
|
|
|
|
Log::info('Polar subscription uncanceled successfully', [
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'polar_subscription_id' => $polarSubscription['id'],
|
|
'uncanceled_at' => now()->toISOString(),
|
|
'new_status' => $polarSubscription['status'] ?? 'active',
|
|
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.uncanceled',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'local_subscription_id' => $localSubscription->id,
|
|
'uncanceled_at' => now()->toISOString(),
|
|
'new_status' => $polarSubscription['status'] ?? 'active',
|
|
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
|
],
|
|
];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|