Files
zemailnator/app/Services/Payments/Providers/PolarProvider.php
idevakk 0d33c57b32 feat(notifications): implement comprehensive telegram notifications for payment providers
- Add NotifyMe trait with centralized Telegram bot integration
  - Implement webhook notifications for Polar, OxaPay, and ActivationKey providers
  - Add subscription lifecycle notifications (create, activate, cancel, pause, resume)
  - Enhance Polar webhook processing with user context and error handling
  - Fix subscription.updated and subscription.canceled webhook column errors
  - Add idempotent webhook processing to prevent duplicate handling
2025-12-08 09:25:19 -08:00

2768 lines
108 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\NotifyMe;
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
{
use NotifyMe;
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
{
try {
$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,
],
]);
$response = 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}"),
};
// Log API errors
if (! $response->successful()) {
$this->notifyProviderError("{$method} {$endpoint}", "HTTP {$response->status()}", [
'status_code' => $response->status(),
'response' => $response->json(),
]);
}
return $response;
} catch (\Exception $e) {
$this->notifyProviderError("{$method} {$endpoint}", $e->getMessage());
throw $e;
}
}
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(),
];
// Notify checkout created successfully
$this->notifyCheckoutCreated($user, $plan, $checkout);
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,
]);
// Notify subscription cancelled
$this->notifySubscriptionCancelled($subscription, $reason ?: 'Local cancellation');
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,
]);
// Notify subscription cancelled
$this->notifySubscriptionCancelled($subscription, $reason ?: 'Manual cancellation via API');
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)) {
$webhookId = $request->header('webhook-id'); // Extract from header for error reporting too
Log::error('Invalid Polar webhook signature');
$this->notifyWebhookError($webhookData['type'] ?? 'unknown', $webhookId, 'Invalid webhook signature');
throw new \Exception('Invalid webhook signature');
}
$webhookData = json_decode($payload, true);
$eventType = $webhookData['type'] ?? 'unknown';
$webhookId = $request->header('webhook-id'); // Extract from header as per Polar documentation
Log::info('Polar webhook received', [
'event_type' => $eventType,
'webhook_id' => $webhookId,
'webhook_id_header' => $request->header('webhook-id'),
'available_headers' => $request->headers->all(),
]);
// Notify webhook received
$this->notifyWebhookReceived($eventType, $webhookId, $webhookData);
// 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);
// Notify webhook processed successfully
$subscriptionId = $result['data']['subscription_id'] ?? null;
$this->notifyWebhookProcessed($eventType, $webhookId, $subscriptionId);
}
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(),
]);
// Notify webhook processing error
$this->notifyWebhookError($eventType ?? 'unknown', $webhookId ?? 'unknown', $e->getMessage());
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;
}
// Notification methods
protected function notifyPaymentSuccess(User $user, Plan $plan, array $paymentDetails): void
{
$message = "💰 PAYMENT SUCCESS\n".
"📧 Customer: {$user->name} ({$user->email})\n".
"📦 Plan: {$plan->name}\n".
'💵 Amount: $'.number_format($paymentDetails['amount'] ?? 0, 2).' '.($paymentDetails['currency'] ?? 'USD')."\n".
"🏪 Provider: Polar\n".
'🆔 Transaction: '.($paymentDetails['transaction_id'] ?? 'N/A')."\n".
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionCreated(User $user, Plan $plan, Subscription $subscription): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$message = "🆕 SUBSCRIPTION CREATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🔄 Subscription ID: {$subscription->provider_subscription_id}\n".
'📅 Created: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyWebhookReceived(string $eventType, ?string $webhookId, ?array $webhookData = null): void
{
$message = "🔔 WEBHOOK RECEIVED\n".
"🏪 Provider: Polar\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n";
// Try to extract user information safely
try {
$userInfo = $this->extractUserInfoFromWebhook($webhookData);
if ($userInfo['name'] || $userInfo['email']) {
$message .= '👤 User: ';
if ($userInfo['name']) {
$message .= $userInfo['name'];
if ($userInfo['email']) {
$message .= " ({$userInfo['email']})";
}
} elseif ($userInfo['email']) {
$message .= $userInfo['email'];
}
$message .= "\n";
}
} catch (\Exception $e) {
// Silently ignore user info extraction errors to avoid webhook processing failures
Log::debug('Failed to extract user info for webhook notification', [
'event_type' => $eventType,
'webhook_id' => $webhookId,
'error' => $e->getMessage(),
]);
}
$message .= "📊 Status: Processing...\n".
'⏰ Received: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function extractUserInfoFromWebhook(?array $webhookData): array
{
$userInfo = [
'name' => null,
'email' => null,
];
if (! $webhookData) {
return $userInfo;
}
// Try different paths where user info might be stored in webhook payloads
$data = $webhookData['data'] ?? [];
// 1. Check customer object (most common)
if (isset($data['customer'])) {
$customer = $data['customer'];
if (is_array($customer)) {
$userInfo['name'] = $customer['name'] ?? $customer['display_name'] ?? null;
$userInfo['email'] = $customer['email'] ?? null;
}
}
// 2. Check user object (some webhooks use this)
if (! $userInfo['name'] && ! $userInfo['email'] && isset($data['user'])) {
$user = $data['user'];
if (is_array($user)) {
$userInfo['name'] = $user['name'] ?? $user['display_name'] ?? null;
$userInfo['email'] = $user['email'] ?? null;
}
}
// 3. Check direct customer fields
if (! $userInfo['name'] && ! $userInfo['email']) {
$userInfo['name'] = $data['customer_name'] ?? $data['customer_display_name'] ?? null;
$userInfo['email'] = $data['customer_email'] ?? $data['email'] ?? null;
}
// 4. Check metadata for user info
if (! $userInfo['name'] && ! $userInfo['email'] && isset($data['metadata'])) {
$metadata = $data['metadata'];
if (is_array($metadata)) {
$userInfo['name'] = $metadata['user_name'] ?? $metadata['customer_name'] ?? null;
$userInfo['email'] = $metadata['user_email'] ?? $metadata['customer_email'] ?? null;
}
}
// 5. For order-related webhooks, check order data
if (! $userInfo['name'] && ! $userInfo['email'] && isset($data['order'])) {
$order = $data['order'];
if (is_array($order)) {
$userInfo['name'] = $order['customer_name'] ?? $order['user_name'] ?? null;
$userInfo['email'] = $order['customer_email'] ?? $order['user_email'] ?? null;
// Check nested customer in order
if (! $userInfo['name'] && ! $userInfo['email'] && isset($order['customer'])) {
$orderCustomer = $order['customer'];
if (is_array($orderCustomer)) {
$userInfo['name'] = $orderCustomer['name'] ?? $orderCustomer['display_name'] ?? null;
$userInfo['email'] = $orderCustomer['email'] ?? null;
}
}
}
}
// 6. Check subscription data for user info
if (! $userInfo['name'] && ! $userInfo['email'] && isset($data['subscription'])) {
$subscription = $data['subscription'];
if (is_array($subscription)) {
$userInfo['name'] = $subscription['customer_name'] ?? null;
$userInfo['email'] = $subscription['customer_email'] ?? null;
// Check nested customer in subscription
if (! $userInfo['name'] && ! $userInfo['email'] && isset($subscription['customer'])) {
$subCustomer = $subscription['customer'];
if (is_array($subCustomer)) {
$userInfo['name'] = $subCustomer['name'] ?? $subCustomer['display_name'] ?? null;
$userInfo['email'] = $subCustomer['email'] ?? null;
}
}
}
}
return $userInfo;
}
protected function notifyWebhookProcessed(string $eventType, ?string $webhookId, ?string $subscriptionId = null): void
{
$message = "✅ WEBHOOK PROCESSED\n".
"🏪 Provider: Polar\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n".
($subscriptionId ? "🔄 Subscription ID: {$subscriptionId}\n" : '').
"📊 Status: Completed\n".
'⏰ Processed: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyWebhookError(string $eventType, ?string $webhookId, string $error): void
{
$message = "❌ WEBHOOK ERROR\n".
"🏪 Provider: Polar\n".
"📋 Event: {$eventType}\n".
'🆔 Webhook ID: '.($webhookId ?? 'N/A')."\n".
"💥 Error: {$error}\n".
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionUpdated(Subscription $subscription, string $changeType, $details = null): void
{
$user = $subscription->user;
$plan = $subscription->plan;
$message = "🔄 SUBSCRIPTION UPDATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"🏪 Provider: Polar\n".
"🔄 Change: {$changeType}\n".
($details ? "📝 Details: {$details}\n" : '').
"🆔 Subscription ID: {$subscription->provider_subscription_id}\n".
'⏰ Updated: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionCancelled(Subscription $subscription, string $reason): void
{
$user = $subscription->user;
$plan = $subscription->plan;
$message = "❌ SUBSCRIPTION CANCELLED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"🏪 Provider: Polar\n".
"💭 Reason: {$reason}\n".
"🆔 Subscription ID: {$subscription->provider_subscription_id}\n".
($subscription->ends_at ? '📅 Effective: '.$subscription->ends_at->format('Y-m-d')."\n" : '').
'⏰ Cancelled: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyProviderError(string $operation, string $error, array $context = []): void
{
$contextStr = '';
if (! empty($context)) {
$contextStr = '📝 Details: '.json_encode(array_slice($context, 0, 3, true), JSON_UNESCAPED_SLASHES)."\n";
if (count($context) > 3) {
$contextStr .= '📝 Additional: '.(count($context) - 3).' more items'."\n";
}
}
$message = "🚨 PROVIDER ERROR\n".
"🏪 Provider: Polar\n".
"📡 Operation: {$operation}\n".
"💥 Error: {$error}\n".
$contextStr.
'⏰ Time: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionActivated(User $user, Plan $plan, Subscription $subscription): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$message = "✅ SUBSCRIPTION ACTIVATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🔄 Subscription ID: {$subscription->provider_subscription_id}\n".
'📅 Activated: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionPaused(User $user, Plan $plan, Subscription $subscription, string $reason): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$message = "⏸️ SUBSCRIPTION PAUSED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🔄 Subscription ID: {$subscription->provider_subscription_id}\n".
"💭 Reason: {$reason}\n".
'📅 Paused: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifySubscriptionResumed(User $user, Plan $plan, Subscription $subscription, string $reason): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$message = "▶️ SUBSCRIPTION RESUMED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🔄 Subscription ID: {$subscription->provider_subscription_id}\n".
"💭 Reason: {$reason}\n".
'📅 Resumed: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyCheckoutCreated(User $user, Plan $plan, array $checkout): void
{
$amount = $plan->price ? '$'.number_format($plan->price, 2).'/month' : 'Custom pricing';
$customerId = $checkout['customer_id'] ?? 'N/A';
$message = "🛒 CHECKOUT CREATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Billing: {$amount}\n".
"🏪 Provider: Polar\n".
"🆔 Checkout ID: {$checkout['id']}\n".
"🔗 Checkout URL: {$checkout['url']}\n".
"👤 Customer ID: {$customerId}\n".
'⏰ Created: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
protected function notifyOrderCreated(User $user, Plan $plan, array $order): void
{
$amount = isset($order['amount']) ? '$'.number_format($order['amount'] / 100, 2) : 'N/A';
$currency = $order['currency'] ?? 'USD';
$checkoutId = $order['checkout_id'] ?? 'N/A';
$customerId = $order['customer_id'] ?? 'N/A';
$status = $order['status'] ?? 'unknown';
$message = "📦 ORDER CREATED\n".
"👤 User: {$user->name} ({$user->email})\n".
"📋 Plan: {$plan->name}\n".
"💰 Amount: {$amount} {$currency}\n".
"🏪 Provider: Polar\n".
"🆔 Order ID: {$order['id']}\n".
"🛒 Checkout ID: {$checkoutId}\n".
"👤 Customer ID: {$customerId}\n".
"📊 Status: {$status}\n".
'⏰ Created: '.now()->format('Y-m-d H:i:s');
$this->sendTelegramNotification($message);
}
// 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,
]);
}
// Convert billing cycle to Polar's format
$billingInterval = $this->convertBillingCycleToPolarInterval($plan->billing_cycle_days ?? 30);
// Create new product with correct structure
$productData = [
'name' => $plan->name,
'description' => $plan->description ?? 'Subscription plan',
'recurring_interval' => $billingInterval['interval'],
'recurring_interval_count' => $billingInterval['interval_count'],
'prices' => [
[
'amount_type' => 'fixed',
'price_amount' => (int) ($plan->price * 100), // Convert to cents
'price_currency' => 'usd',
'recurring_interval' => $billingInterval['interval'],
'recurring_interval_count' => $billingInterval['interval_count'],
],
],
'metadata' => [
'plan_id' => $plan->id,
'plan_name' => $plan->name,
'billing_cycle_days' => $plan->billing_cycle_days ?? 30,
],
];
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(),
]),
]);
// Notify order created
$this->notifyOrderCreated($subscription->user, $subscription->plan, $order);
}
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,
]),
]);
// Notify payment success
$this->notifyPaymentSuccess($subscription->user, $subscription->plan, [
'amount' => $order['amount'] / 100, // Convert from cents
'currency' => $order['currency'] ?? 'USD',
'transaction_id' => $order['id'],
]);
}
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);
// Notify subscription created
$this->notifySubscriptionCreated($localSubscription->user, $localSubscription->plan, $localSubscription);
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);
// Notify subscription activated
$this->notifySubscriptionActivated($localSubscription->user, $localSubscription->plan, $localSubscription);
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']);
}
// 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_stored' => ($updateData['provider_data']['cancel_at_period_end'] ?? false),
]);
} 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,
]),
];
// 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);
// Notify subscription cancelled
$this->notifySubscriptionCancelled($localSubscription, $cancellationReason);
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,
]),
]);
// Notify subscription paused
$this->notifySubscriptionPaused($localSubscription->user, $localSubscription->plan, $localSubscription, $polarSubscription['pause_reason'] ?? 'Manual pause');
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,
]),
]);
// Notify subscription resumed
$this->notifySubscriptionResumed($localSubscription->user, $localSubscription->plan, $localSubscription, $polarSubscription['resume_reason'] ?? 'Manual resume');
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;
}
/**
* Convert billing cycle days to Polar's recurring interval format
*/
protected function convertBillingCycleToPolarInterval(int $billingCycleDays): array
{
return match ($billingCycleDays) {
1 => ['interval' => 'day', 'interval_count' => 1], // Daily
7 => ['interval' => 'week', 'interval_count' => 1], // Weekly
30 => ['interval' => 'month', 'interval_count' => 1], // Monthly
60 => ['interval' => 'month', 'interval_count' => 2], // Bi-monthly
90 => ['interval' => 'month', 'interval_count' => 3], // Quarterly
180 => ['interval' => 'month', 'interval_count' => 6], // Semi-annual
365 => ['interval' => 'year', 'interval_count' => 1], // Yearly
default => ['interval' => 'month', 'interval_count' => 1], // Default to monthly
};
}
}