Files
zemailnator/app/Services/Payments/Providers/PolarProvider.php
idevakk 0724e6da43 feat(payments): implement smart Polar subscription sync with checkout tracking
- Add provider_checkout_id column to separate checkout ID from subscription ID
   - Update Polar provider to store checkout ID separately and set subscription ID to null initially
   - Implement smart sync logic that queries Polar API when subscription ID is missing
   - Add fetchPolarSubscriptionId method to find active subscriptions via customer ID
   - Update webhook handlers to use provider_checkout_id for subscription lookup
   - Make makeAuthenticatedRequest public to enable Subscription model API access
   - Support plan metadata matching for accurate subscription identification
   - Add fallback to most recent active subscription when no exact match found

   This resolves sync button issues by properly tracking checkout vs subscription IDs
   and enables automatic subscription ID recovery when webhooks fail.
2025-12-06 02:37:52 -08:00

1591 lines
57 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 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();
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'],
'current_period_end' => $polarSubscription['current_period_end'],
'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'],
'updated_at' => $polarSubscription['modified_at'] ?? 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';
$result = [
'event_type' => $eventType,
'processed' => false,
'data' => [],
];
switch ($eventType) {
case 'checkout.created':
$result = $this->handleCheckoutCreated($webhookData);
break;
case 'order.created':
$result = $this->handleOrderCreated($webhookData);
break;
case 'order.paid':
$result = $this->handleOrderPaid($webhookData);
break;
case 'subscription.created':
$result = $this->handleSubscriptionCreated($webhookData);
break;
case 'subscription.active':
$result = $this->handleSubscriptionActive($webhookData);
break;
case 'subscription.updated':
$result = $this->handleSubscriptionUpdated($webhookData);
break;
case 'subscription.cancelled':
$result = $this->handleSubscriptionCancelled($webhookData);
break;
case 'customer.state_changed':
$result = $this->handleCustomerStateChanged($webhookData);
break;
default:
Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]);
}
return $result;
} catch (\Exception $e) {
Log::error('Polar webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$signature = $request->header('Polar-Signature');
$payload = $request->getContent();
if (! $signature || ! $this->webhookSecret) {
return false;
}
$expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret);
return hash_equals($signature, $expectedSignature);
} catch (\Exception $e) {
Log::warning('Polar webhook validation failed', [
'error' => $e->getMessage(),
]);
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;
} else {
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']['object'];
// 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']['object'];
// 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']['object'];
// 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']['object'];
// Find and update local subscription using checkout_id
$localSubscription = Subscription::where('provider', 'polar')
->where('provider_checkout_id', $polarSubscription['checkout_id'])
->first();
if ($localSubscription) {
$localSubscription->update([
'stripe_id' => $polarSubscription['id'],
'provider_subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
'starts_at' => Carbon::parse($polarSubscription['current_period_start']),
'ends_at' => Carbon::parse($polarSubscription['current_period_end']),
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
'polar_subscription' => $polarSubscription,
'activated_at' => now()->toISOString(),
]),
]);
}
return [
'event_type' => 'subscription.created',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
],
];
}
protected function handleSubscriptionActive(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => 'active',
'starts_at' => Carbon::parse($polarSubscription['current_period_start']),
'ends_at' => Carbon::parse($polarSubscription['current_period_end']),
'provider_data' => array_merge(
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->first()?->provider_data ?? [],
[
'polar_subscription' => $polarSubscription,
'activated_at' => now()->toISOString(),
]
),
]);
return [
'event_type' => 'subscription.active',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
'status' => 'active',
],
];
}
protected function handleSubscriptionUpdated(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => $polarSubscription['status'],
'provider_data' => [
'polar_subscription' => $polarSubscription,
'updated_at' => now()->toISOString(),
],
]);
return [
'event_type' => 'subscription.updated',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
],
];
}
protected function handleSubscriptionCancelled(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => 'Polar webhook cancellation',
]);
return [
'event_type' => 'subscription.cancelled',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
],
];
}
protected function handleCustomerStateChanged(array $webhookData): array
{
$customer = $webhookData['data']['object'];
// Update all subscriptions for this customer
Subscription::whereHas('user', function ($query) use ($customer) {
$query->where('email', $customer['email']);
})->where('provider', 'polar')->get()->each(function ($subscription) use ($customer) {
$subscription->update([
'provider_data' => array_merge($subscription->provider_data ?? [], [
'customer_state' => $customer['state'],
'customer_updated_at' => now()->toISOString(),
]),
]);
});
return [
'event_type' => 'customer.state_changed',
'processed' => true,
'data' => [
'customer_id' => $customer['id'],
'state' => $customer['state'],
],
];
}
// 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}";
}
}