- Add comprehensive rate limiting (300 req/min) with automatic throttling - Implement centralized API request method for consistent authentication - Add support for Polar-specific webhook events (order.created, order.paid, subscription.active, customer.state_changed, benefit_grant.created) - Update API endpoints to match Polar's official structure (remove /v1 prefix) - Add external_id support for reliable customer-user mapping - Implement sandbox mode with separate credentials configuration - Add discount code support in checkout flow - Add credential validation method for API connectivity testing - Update webhook signature validation and event handling - Enhance error handling and logging throughout provider - Add proper metadata structure with user and plan information - Update services configuration and environment variables for sandbox support BREAKING CHANGE: Updated API endpoint structure and webhook event handling to comply with Polar.sh official API specification.
1220 lines
41 KiB
PHP
1220 lines
41 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payments\Providers;
|
|
|
|
use App\Contracts\Payments\PaymentProviderContract;
|
|
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;
|
|
|
|
/**
|
|
* Rate limiting: 300 requests per minute for Polar API
|
|
*/
|
|
private const RATE_LIMIT_REQUESTS = 300;
|
|
|
|
private const RATE_LIMIT_WINDOW = 60; // seconds
|
|
|
|
private static array $requestTimes = [];
|
|
|
|
public function __construct(array $config = [])
|
|
{
|
|
$isSandbox = $config['sandbox'] ?? config('services.polar.sandbox', false);
|
|
|
|
$this->config = array_merge([
|
|
'sandbox' => $isSandbox,
|
|
'api_key' => $isSandbox
|
|
? config('services.polar.sandbox_api_key')
|
|
: config('services.polar.api_key'),
|
|
'webhook_secret' => $isSandbox
|
|
? config('services.polar.sandbox_webhook_secret')
|
|
: config('services.polar.webhook_secret'),
|
|
'success_url' => route('payment.success'),
|
|
'cancel_url' => route('payment.cancel'),
|
|
'webhook_url' => route('webhook.payment', 'polar'),
|
|
], $config);
|
|
}
|
|
|
|
protected function getApiBaseUrl(): string
|
|
{
|
|
return $this->config['sandbox']
|
|
? 'https://sandbox-api.polar.sh/v1'
|
|
: 'https://api.polar.sh/v1';
|
|
}
|
|
|
|
public function getName(): string
|
|
{
|
|
return 'polar';
|
|
}
|
|
|
|
public function isActive(): bool
|
|
{
|
|
return ! empty($this->config['api_key']) && ! empty($this->config['webhook_secret']);
|
|
}
|
|
|
|
/**
|
|
* Check if the provided API key is valid by making a test API call
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make authenticated API request with rate limiting
|
|
*/
|
|
protected function makeAuthenticatedRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
|
|
{
|
|
$this->checkRateLimit();
|
|
|
|
$url = $this->getApiBaseUrl().$endpoint;
|
|
|
|
$headers = [
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
'Content-Type' => 'application/json',
|
|
'Accept' => 'application/json',
|
|
];
|
|
|
|
return match ($method) {
|
|
'GET' => Http::withHeaders($headers)->get($url, $data),
|
|
'POST' => Http::withHeaders($headers)->post($url, $data),
|
|
'PATCH' => Http::withHeaders($headers)->patch($url, $data),
|
|
'DELETE' => Http::withHeaders($headers)->delete($url, $data),
|
|
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Simple rate limiting implementation
|
|
*/
|
|
private function checkRateLimit(): void
|
|
{
|
|
$now = time();
|
|
$windowStart = $now - self::RATE_LIMIT_WINDOW;
|
|
|
|
// Clean old requests outside the current window
|
|
self::$requestTimes = array_filter(self::$requestTimes, fn ($time) => $time > $windowStart);
|
|
|
|
// Check if we're at the rate limit
|
|
if (count(self::$requestTimes) >= self::RATE_LIMIT_REQUESTS) {
|
|
$sleepTime = self::RATE_LIMIT_WINDOW - ($now - (self::$requestTimes[0] ?? $now));
|
|
if ($sleepTime > 0) {
|
|
Log::warning('Polar API rate limit reached, sleeping for '.$sleepTime.' seconds');
|
|
sleep($sleepTime);
|
|
}
|
|
}
|
|
|
|
// Record this request
|
|
self::$requestTimes[] = $now;
|
|
}
|
|
|
|
public function createSubscription(User $user, Plan $plan, array $options = []): array
|
|
{
|
|
try {
|
|
// Get or create Polar customer
|
|
$customer = $this->getOrCreateCustomer($user);
|
|
|
|
// Get or create Polar product/price
|
|
$priceId = $this->getOrCreatePrice($plan);
|
|
|
|
// Create checkout session with Polar's correct structure
|
|
$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' => $user->id, // Polar supports external_id for user mapping
|
|
],
|
|
];
|
|
|
|
// Add discount codes if provided
|
|
if (isset($options['discount_code'])) {
|
|
$checkoutData['discount_code'] = $options['discount_code'];
|
|
}
|
|
|
|
$response = $this->makeAuthenticatedRequest('POST', '/checkouts', $checkoutData);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Polar checkout creation failed: '.$response->body());
|
|
}
|
|
|
|
$checkout = $response->json();
|
|
|
|
// Create subscription record
|
|
$subscription = Subscription::create([
|
|
'user_id' => $user->id,
|
|
'plan_id' => $plan->id,
|
|
'type' => 'recurring',
|
|
'stripe_id' => $checkout['id'], // Using stripe_id field for Polar checkout ID
|
|
'stripe_status' => 'pending',
|
|
'provider' => $this->getName(),
|
|
'provider_subscription_id' => $checkout['id'],
|
|
'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(),
|
|
],
|
|
]);
|
|
|
|
return [
|
|
'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(),
|
|
];
|
|
|
|
} 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) {
|
|
Log::error('No Polar subscription found to update');
|
|
}
|
|
|
|
$newPriceId = $this->getOrCreatePrice($newPlan);
|
|
|
|
$response = $this->makeAuthenticatedRequest('PATCH', '/subscriptions/'.$polarSubscriptionId, [
|
|
'product_price_id' => $newPriceId,
|
|
'preserve_period' => true, // Polar equivalent of proration behavior
|
|
]);
|
|
|
|
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: '.$response->body());
|
|
}
|
|
|
|
$polarSubscription = $response->json();
|
|
|
|
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['updated_at'],
|
|
];
|
|
|
|
} 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
|
|
{
|
|
return $this->createSubscription($user, $plan, $options);
|
|
}
|
|
|
|
public function createCustomerPortalSession(User $user): array
|
|
{
|
|
try {
|
|
$customer = $this->getOrCreateCustomer($user);
|
|
|
|
$response = $this->makeAuthenticatedRequest('POST', '/customer-portal', [
|
|
'customer_id' => $customer['id'],
|
|
'return_url' => route('dashboard'),
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Polar customer portal creation failed: '.$response->body());
|
|
}
|
|
|
|
$portal = $response->json();
|
|
|
|
return [
|
|
'portal_url' => $portal['url'],
|
|
'customer_id' => $customer['id'],
|
|
];
|
|
|
|
} 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');
|
|
}
|
|
|
|
$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;
|
|
case 'benefit_grant.created':
|
|
$result = $this->handleBenefitGrantCreated($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->config['webhook_secret']) {
|
|
return false;
|
|
}
|
|
|
|
$expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']);
|
|
|
|
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
|
|
// For now, we'll return a NotImplementedError
|
|
Log::error('Polar refunds must be processed through Polar dashboard or API directly');
|
|
todo('Write process refund process');
|
|
} catch (\Exception $e) {
|
|
Log::error('Polar refund processing failed', [
|
|
'payment_id' => $paymentId,
|
|
'amount' => $amount,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
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 = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
])->get($this->getApiBaseUrl().'/v1/subscriptions', $params);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Failed to retrieve Polar transaction history: '.$response->body());
|
|
}
|
|
|
|
$polarSubscriptions = $response->json();
|
|
$transactions = [];
|
|
|
|
foreach ($polarSubscriptions['data'] ?? [] as $subscription) {
|
|
$transactions[] = [
|
|
'id' => $subscription['id'],
|
|
'status' => $subscription['status'],
|
|
'amount' => $subscription['amount'] ?? 0,
|
|
'currency' => $subscription['currency'] ?? 'USD',
|
|
'created_at' => $subscription['created_at'],
|
|
'current_period_start' => $subscription['current_period_start'],
|
|
'current_period_end' => $subscription['current_period_end'],
|
|
];
|
|
}
|
|
|
|
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
|
|
{
|
|
// First, try to find existing customer by email and external_id
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
])->get($this->getApiBaseUrl().'/customers', [
|
|
'email' => $user->email,
|
|
'external_id' => $user->id, // Use external_id for better customer matching
|
|
]);
|
|
|
|
if ($response->successful() && ! empty($response->json()['data'])) {
|
|
return $response->json()['data'][0];
|
|
}
|
|
|
|
// Create new customer
|
|
$customerData = [
|
|
'email' => $user->email,
|
|
'name' => $user->name,
|
|
'external_id' => $user->id, // Polar supports external_id for user mapping
|
|
'metadata' => [
|
|
'user_id' => (string) $user->id,
|
|
'source' => 'laravel_app',
|
|
],
|
|
];
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
'Content-Type' => 'application/json',
|
|
])->post($this->getApiBaseUrl().'/customers', $customerData);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('Failed to create Polar customer: '.$response->body());
|
|
}
|
|
|
|
return $response->json();
|
|
}
|
|
|
|
protected function getOrCreatePrice(Plan $plan): string
|
|
{
|
|
// Look for existing price by plan metadata
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
])->get($this->getApiBaseUrl().'/v1/products', [
|
|
'metadata[plan_id]' => $plan->id,
|
|
]);
|
|
|
|
if ($response->successful() && ! empty($response->json()['data'])) {
|
|
$product = $response->json()['data'][0];
|
|
|
|
// Get the price for this product
|
|
$priceResponse = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
])->get($this->getApiBaseUrl().'/v1/prices', [
|
|
'product_id' => $product['id'],
|
|
'recurring_interval' => 'month',
|
|
]);
|
|
|
|
if ($priceResponse->successful() && ! empty($priceResponse->json()['data'])) {
|
|
return $priceResponse->json()['data'][0]['id'];
|
|
}
|
|
}
|
|
|
|
// Create new product and price
|
|
$productData = [
|
|
'name' => $plan->name,
|
|
'description' => $plan->description ?? 'Subscription plan',
|
|
'type' => 'service',
|
|
'metadata' => [
|
|
'plan_id' => $plan->id,
|
|
'plan_name' => $plan->name,
|
|
],
|
|
];
|
|
|
|
$productResponse = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
'Content-Type' => 'application/json',
|
|
])->post($this->getApiBaseUrl().'/v1/products', $productData);
|
|
|
|
if (! $productResponse->successful()) {
|
|
Log::error('Failed to create Polar product: '.$productResponse->body());
|
|
}
|
|
|
|
$product = $productResponse->json();
|
|
|
|
// Create price for the product
|
|
$priceData = [
|
|
'product_id' => $product['id'],
|
|
'amount' => (int) ($plan->price * 100), // Convert to cents
|
|
'currency' => 'usd',
|
|
'recurring' => [
|
|
'interval' => 'month',
|
|
'interval_count' => 1,
|
|
],
|
|
];
|
|
|
|
$priceResponse = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
'Content-Type' => 'application/json',
|
|
])->post($this->getApiBaseUrl().'/v1/prices', $priceData);
|
|
|
|
if (! $priceResponse->successful()) {
|
|
Log::error('Failed to create Polar price: '.$priceResponse->body());
|
|
}
|
|
|
|
$price = $priceResponse->json();
|
|
|
|
return $price['id'];
|
|
}
|
|
|
|
protected function getPolarSubscriptionId(Subscription $subscription): ?string
|
|
{
|
|
$providerData = $subscription->provider_data ?? [];
|
|
|
|
return $providerData['polar_subscription']['id'] ?? null;
|
|
}
|
|
|
|
// 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 handleSubscriptionActive(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data']['object'];
|
|
|
|
Subscription::where('provider', 'polar')
|
|
->where('provider_subscription_id', $polarSubscription['id'])
|
|
->update([
|
|
'status' => 'active',
|
|
'starts_at' => Carbon::parse($polarSubscription['current_period_start']),
|
|
'ends_at' => Carbon::parse($polarSubscription['current_period_end']),
|
|
'provider_data' => array_merge(
|
|
Subscription::where('provider', 'polar')
|
|
->where('provider_subscription_id', $polarSubscription['id'])
|
|
->first()?->provider_data ?? [],
|
|
[
|
|
'polar_subscription' => $polarSubscription,
|
|
'activated_at' => now()->toISOString(),
|
|
]
|
|
),
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.active',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
'status' => 'active',
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleCustomerStateChanged(array $webhookData): array
|
|
{
|
|
$customer = $webhookData['data']['object'];
|
|
|
|
// Update all subscriptions for this customer
|
|
Subscription::whereHas('user', function ($query) use ($customer) {
|
|
$query->where('email', $customer['email']);
|
|
})->where('provider', 'polar')->get()->each(function ($subscription) use ($customer) {
|
|
$subscription->update([
|
|
'provider_data' => array_merge($subscription->provider_data ?? [], [
|
|
'customer_state' => $customer['state'],
|
|
'customer_updated_at' => now()->toISOString(),
|
|
]),
|
|
]);
|
|
});
|
|
|
|
return [
|
|
'event_type' => 'customer.state_changed',
|
|
'processed' => true,
|
|
'data' => [
|
|
'customer_id' => $customer['id'],
|
|
'state' => $customer['state'],
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleBenefitGrantCreated(array $webhookData): array
|
|
{
|
|
$benefitGrant = $webhookData['data']['object'];
|
|
|
|
// Log benefit grants for analytics or feature access
|
|
Log::info('Polar benefit grant created', [
|
|
'grant_id' => $benefitGrant['id'],
|
|
'customer_id' => $benefitGrant['customer_id'],
|
|
'benefit_id' => $benefitGrant['benefit_id'],
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'benefit_grant.created',
|
|
'processed' => true,
|
|
'data' => [
|
|
'grant_id' => $benefitGrant['id'],
|
|
'customer_id' => $benefitGrant['customer_id'],
|
|
'benefit_id' => $benefitGrant['benefit_id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionCreated(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data']['object'];
|
|
|
|
// Find and update local subscription
|
|
$localSubscription = Subscription::where('provider', 'polar')
|
|
->where('provider_subscription_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 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 handleSubscriptionPaused(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data']['object'];
|
|
|
|
Subscription::where('provider', 'polar')
|
|
->where('provider_subscription_id', $polarSubscription['id'])
|
|
->update([
|
|
'status' => 'paused',
|
|
'paused_at' => now(),
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.paused',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function handleSubscriptionResumed(array $webhookData): array
|
|
{
|
|
$polarSubscription = $webhookData['data']['object'];
|
|
|
|
Subscription::where('provider', 'polar')
|
|
->where('provider_subscription_id', $polarSubscription['id'])
|
|
->update([
|
|
'status' => 'active',
|
|
'resumed_at' => now(),
|
|
]);
|
|
|
|
return [
|
|
'event_type' => 'subscription.resumed',
|
|
'processed' => true,
|
|
'data' => [
|
|
'subscription_id' => $polarSubscription['id'],
|
|
],
|
|
];
|
|
}
|
|
|
|
// 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
|
|
{
|
|
// Polar supports discount codes
|
|
try {
|
|
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
|
|
|
|
if (! $polarSubscriptionId) {
|
|
Log::error('No Polar subscription found');
|
|
}
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
'Content-Type' => 'application/json',
|
|
])->post($this->getApiBaseUrl().'/v1/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 = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
])->delete($this->getApiBaseUrl().'/v1/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 = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->config['api_key'],
|
|
])->get($this->getApiBaseUrl().'/v1/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');
|
|
todo('Write import subscription data');
|
|
|
|
return [];
|
|
}
|
|
}
|