feat(payments): enhance Polar.sh provider with official API compliance

- 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.
This commit is contained in:
idevakk
2025-11-22 06:19:27 -08:00
parent 38ae2770ea
commit ad89b84471
4 changed files with 330 additions and 82 deletions

View File

@@ -100,6 +100,9 @@ LEMON_SQUEEZY_CANCEL_URL=/payment/cancel
# Polar.sh Payment Provider # Polar.sh Payment Provider
POLAR_API_KEY= POLAR_API_KEY=
POLAR_WEBHOOK_SECRET= POLAR_WEBHOOK_SECRET=
POLAR_SANDBOX=false
POLAR_SANDBOX_API_KEY=
POLAR_SANDBOX_WEBHOOK_SECRET=
POLAR_ACCESS_TOKEN= POLAR_ACCESS_TOKEN=
POLAR_SUCCESS_URL=/payment/success POLAR_SUCCESS_URL=/payment/success
POLAR_CANCEL_URL=/payment/cancel POLAR_CANCEL_URL=/payment/cancel

View File

@@ -40,6 +40,14 @@ class Subscription extends Model
'migration_date', 'migration_date',
'migration_reason', 'migration_reason',
'created_at', 'created_at',
'activated_at',
'checkout_id',
'customer_id',
'polar_checkout',
'order_id',
'polar_order',
'order_created_at',
'order_paid_at',
]; ];
protected $casts = [ protected $casts = [

View File

@@ -15,19 +15,40 @@ class PolarProvider implements PaymentProviderContract
{ {
protected array $config; protected array $config;
protected string $apiBaseUrl = 'https://api.polar.sh'; /**
* 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 = []) public function __construct(array $config = [])
{ {
$isSandbox = $config['sandbox'] ?? config('services.polar.sandbox', false);
$this->config = array_merge([ $this->config = array_merge([
'api_key' => config('services.polar.api_key'), 'sandbox' => $isSandbox,
'webhook_secret' => config('services.polar.webhook_secret'), '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'), 'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'), 'cancel_url' => route('payment.cancel'),
'webhook_url' => route('webhook.payment', 'polar'), 'webhook_url' => route('webhook.payment', 'polar'),
], $config); ], $config);
} }
protected function getApiBaseUrl(): string
{
return $this->config['sandbox']
? 'https://sandbox-api.polar.sh/v1'
: 'https://api.polar.sh/v1';
}
public function getName(): string public function getName(): string
{ {
return 'polar'; return 'polar';
@@ -35,7 +56,71 @@ class PolarProvider implements PaymentProviderContract
public function isActive(): bool public function isActive(): bool
{ {
return ! empty($this->config['api_key']); 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 public function createSubscription(User $user, Plan $plan, array $options = []): array
@@ -47,33 +132,31 @@ class PolarProvider implements PaymentProviderContract
// Get or create Polar product/price // Get or create Polar product/price
$priceId = $this->getOrCreatePrice($plan); $priceId = $this->getOrCreatePrice($plan);
// Create checkout session // Create checkout session with Polar's correct structure
$checkoutData = [ $checkoutData = [
'product_price_id' => $priceId,
'customer_id' => $customer['id'], 'customer_id' => $customer['id'],
'price_id' => $priceId,
'success_url' => $this->config['success_url'], 'success_url' => $this->config['success_url'],
'cancel_url' => $this->config['cancel_url'], 'cancel_url' => $this->config['cancel_url'],
'customer_email' => $user->email, 'customer_email' => $user->email,
'customer_name' => $user->name, 'customer_name' => $user->name,
'metadata' => [ 'metadata' => [
'user_id' => $user->id, 'user_id' => (string) $user->id,
'plan_id' => $plan->id, 'plan_id' => (string) $plan->id,
'plan_name' => $plan->name, 'plan_name' => $plan->name,
'external_id' => $user->id, // Polar supports external_id for user mapping
], ],
]; ];
// Add trial information if specified // Add discount codes if provided
if (isset($options['trial_days']) && $options['trial_days'] > 0) { if (isset($options['discount_code'])) {
$checkoutData['trial_period_days'] = $options['trial_days']; $checkoutData['discount_code'] = $options['discount_code'];
} }
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('POST', '/checkouts', $checkoutData);
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/checkouts', $checkoutData);
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Polar checkout creation failed: '.$response->body()); Log::error('Polar checkout creation failed: '.$response->body());
} }
$checkout = $response->json(); $checkout = $response->json();
@@ -135,15 +218,12 @@ class PolarProvider implements PaymentProviderContract
return true; return true;
} }
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('DELETE', '/subscriptions/'.$polarSubscriptionId, [
'Authorization' => 'Bearer '.$this->config['api_key'], 'reason' => $reason,
'Content-Type' => 'application/json',
])->delete($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId, [
'cancellation_reason' => $reason,
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Polar subscription cancellation failed: '.$response->body()); Log::error('Polar subscription cancellation failed: '.$response->body());
} }
// Update local subscription // Update local subscription
@@ -170,21 +250,18 @@ class PolarProvider implements PaymentProviderContract
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription); $polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) { if (! $polarSubscriptionId) {
throw new \Exception('No Polar subscription found to update'); Log::error('No Polar subscription found to update');
} }
$newPriceId = $this->getOrCreatePrice($newPlan); $newPriceId = $this->getOrCreatePrice($newPlan);
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('PATCH', '/subscriptions/'.$polarSubscriptionId, [
'Authorization' => 'Bearer '.$this->config['api_key'], 'product_price_id' => $newPriceId,
'Content-Type' => 'application/json', 'preserve_period' => true, // Polar equivalent of proration behavior
])->patch($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId, [
'price_id' => $newPriceId,
'proration_behavior' => 'create_prorations',
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Polar subscription update failed: '.$response->body()); Log::error('Polar subscription update failed: '.$response->body());
} }
$updatedSubscription = $response->json(); $updatedSubscription = $response->json();
@@ -224,13 +301,10 @@ class PolarProvider implements PaymentProviderContract
return false; return false;
} }
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/pause');
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/pause');
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Polar subscription pause failed: '.$response->body()); Log::error('Polar subscription pause failed: '.$response->body());
} }
$subscription->update([ $subscription->update([
@@ -258,13 +332,10 @@ class PolarProvider implements PaymentProviderContract
return false; return false;
} }
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('POST', '/subscriptions/'.$polarSubscriptionId.'/resume');
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/resume');
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Polar subscription resume failed: '.$response->body()); Log::error('Polar subscription resume failed: '.$response->body());
} }
$subscription->update([ $subscription->update([
@@ -286,12 +357,10 @@ class PolarProvider implements PaymentProviderContract
public function getSubscriptionDetails(string $providerSubscriptionId): array public function getSubscriptionDetails(string $providerSubscriptionId): array
{ {
try { try {
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('GET', '/subscriptions/'.$providerSubscriptionId);
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions/'.$providerSubscriptionId);
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar subscription: '.$response->body()); Log::error('Failed to retrieve Polar subscription: '.$response->body());
} }
$polarSubscription = $response->json(); $polarSubscription = $response->json();
@@ -329,16 +398,13 @@ class PolarProvider implements PaymentProviderContract
try { try {
$customer = $this->getOrCreateCustomer($user); $customer = $this->getOrCreateCustomer($user);
$response = Http::withHeaders([ $response = $this->makeAuthenticatedRequest('POST', '/customer-portal', [
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/customer-portal', [
'customer_id' => $customer['id'], 'customer_id' => $customer['id'],
'return_url' => route('dashboard'), 'return_url' => route('dashboard'),
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Polar customer portal creation failed: '.$response->body()); Log::error('Polar customer portal creation failed: '.$response->body());
} }
$portal = $response->json(); $portal = $response->json();
@@ -364,7 +430,7 @@ class PolarProvider implements PaymentProviderContract
$signature = $request->header('Polar-Signature'); $signature = $request->header('Polar-Signature');
if (! $this->validateWebhook($request)) { if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid Polar webhook signature'); Log::error('Invalid Polar webhook signature');
} }
$webhookData = json_decode($payload, true); $webhookData = json_decode($payload, true);
@@ -380,20 +446,29 @@ class PolarProvider implements PaymentProviderContract
case 'checkout.created': case 'checkout.created':
$result = $this->handleCheckoutCreated($webhookData); $result = $this->handleCheckoutCreated($webhookData);
break; break;
case 'order.created':
$result = $this->handleOrderCreated($webhookData);
break;
case 'order.paid':
$result = $this->handleOrderPaid($webhookData);
break;
case 'subscription.created': case 'subscription.created':
$result = $this->handleSubscriptionCreated($webhookData); $result = $this->handleSubscriptionCreated($webhookData);
break; break;
case 'subscription.active':
$result = $this->handleSubscriptionActive($webhookData);
break;
case 'subscription.updated': case 'subscription.updated':
$result = $this->handleSubscriptionUpdated($webhookData); $result = $this->handleSubscriptionUpdated($webhookData);
break; break;
case 'subscription.cancelled': case 'subscription.cancelled':
$result = $this->handleSubscriptionCancelled($webhookData); $result = $this->handleSubscriptionCancelled($webhookData);
break; break;
case 'subscription.paused': case 'customer.state_changed':
$result = $this->handleSubscriptionPaused($webhookData); $result = $this->handleCustomerStateChanged($webhookData);
break; break;
case 'subscription.resumed': case 'benefit_grant.created':
$result = $this->handleSubscriptionResumed($webhookData); $result = $this->handleBenefitGrantCreated($webhookData);
break; break;
default: default:
Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]); Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]);
@@ -464,15 +539,17 @@ class PolarProvider implements PaymentProviderContract
try { try {
// Polar handles refunds through their dashboard or API // Polar handles refunds through their dashboard or API
// For now, we'll return a NotImplementedError // For now, we'll return a NotImplementedError
throw new \Exception('Polar refunds must be processed through Polar dashboard or API directly'); Log::error('Polar refunds must be processed through Polar dashboard or API directly');
todo('Write process refund process');
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Polar refund processing failed', [ Log::error('Polar refund processing failed', [
'payment_id' => $paymentId, 'payment_id' => $paymentId,
'amount' => $amount, 'amount' => $amount,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
throw $e;
} }
return [];
} }
public function getTransactionHistory(User $user, array $filters = []): array public function getTransactionHistory(User $user, array $filters = []): array
@@ -495,10 +572,10 @@ class PolarProvider implements PaymentProviderContract
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions', $params); ])->get($this->getApiBaseUrl().'/v1/subscriptions', $params);
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar transaction history: '.$response->body()); Log::error('Failed to retrieve Polar transaction history: '.$response->body());
} }
$polarSubscriptions = $response->json(); $polarSubscriptions = $response->json();
@@ -560,11 +637,12 @@ class PolarProvider implements PaymentProviderContract
// Helper methods // Helper methods
protected function getOrCreateCustomer(User $user): array protected function getOrCreateCustomer(User $user): array
{ {
// First, try to find existing customer by email // First, try to find existing customer by email and external_id
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/customers', [ ])->get($this->getApiBaseUrl().'/customers', [
'email' => $user->email, 'email' => $user->email,
'external_id' => $user->id, // Use external_id for better customer matching
]); ]);
if ($response->successful() && ! empty($response->json()['data'])) { if ($response->successful() && ! empty($response->json()['data'])) {
@@ -575,18 +653,20 @@ class PolarProvider implements PaymentProviderContract
$customerData = [ $customerData = [
'email' => $user->email, 'email' => $user->email,
'name' => $user->name, 'name' => $user->name,
'external_id' => $user->id, // Polar supports external_id for user mapping
'metadata' => [ 'metadata' => [
'user_id' => $user->id, 'user_id' => (string) $user->id,
'source' => 'laravel_app',
], ],
]; ];
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/customers', $customerData); ])->post($this->getApiBaseUrl().'/customers', $customerData);
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Failed to create Polar customer: '.$response->body()); Log::error('Failed to create Polar customer: '.$response->body());
} }
return $response->json(); return $response->json();
@@ -597,7 +677,7 @@ class PolarProvider implements PaymentProviderContract
// Look for existing price by plan metadata // Look for existing price by plan metadata
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/products', [ ])->get($this->getApiBaseUrl().'/v1/products', [
'metadata[plan_id]' => $plan->id, 'metadata[plan_id]' => $plan->id,
]); ]);
@@ -607,7 +687,7 @@ class PolarProvider implements PaymentProviderContract
// Get the price for this product // Get the price for this product
$priceResponse = Http::withHeaders([ $priceResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/prices', [ ])->get($this->getApiBaseUrl().'/v1/prices', [
'product_id' => $product['id'], 'product_id' => $product['id'],
'recurring_interval' => 'month', 'recurring_interval' => 'month',
]); ]);
@@ -631,10 +711,10 @@ class PolarProvider implements PaymentProviderContract
$productResponse = Http::withHeaders([ $productResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/products', $productData); ])->post($this->getApiBaseUrl().'/v1/products', $productData);
if (! $productResponse->successful()) { if (! $productResponse->successful()) {
throw new \Exception('Failed to create Polar product: '.$productResponse->body()); Log::error('Failed to create Polar product: '.$productResponse->body());
} }
$product = $productResponse->json(); $product = $productResponse->json();
@@ -653,10 +733,10 @@ class PolarProvider implements PaymentProviderContract
$priceResponse = Http::withHeaders([ $priceResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/prices', $priceData); ])->post($this->getApiBaseUrl().'/v1/prices', $priceData);
if (! $priceResponse->successful()) { if (! $priceResponse->successful()) {
throw new \Exception('Failed to create Polar price: '.$priceResponse->body()); Log::error('Failed to create Polar price: '.$priceResponse->body());
} }
$price = $priceResponse->json(); $price = $priceResponse->json();
@@ -677,12 +757,15 @@ class PolarProvider implements PaymentProviderContract
$checkout = $webhookData['data']['object']; $checkout = $webhookData['data']['object'];
// Update local subscription with checkout ID // Update local subscription with checkout ID
Subscription::where('stripe_id', $checkout['id'])->update([ Subscription::where('provider_subscription_id', $checkout['id'])->update([
'provider_data' => [ 'provider_data' => array_merge(
Subscription::where('provider_subscription_id', $checkout['id'])->first()?->provider_data ?? [],
[
'checkout_id' => $checkout['id'], 'checkout_id' => $checkout['id'],
'customer_id' => $checkout['customer_id'], 'customer_id' => $checkout['customer_id'],
'polar_checkout' => $checkout, 'polar_checkout' => $checkout,
], ]
),
]); ]);
return [ return [
@@ -695,6 +778,154 @@ class PolarProvider implements PaymentProviderContract
]; ];
} }
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 protected function handleSubscriptionCreated(array $webhookData): array
{ {
$polarSubscription = $webhookData['data']['object']; $polarSubscription = $webhookData['data']['object'];
@@ -853,18 +1084,18 @@ class PolarProvider implements PaymentProviderContract
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription); $polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) { if (! $polarSubscriptionId) {
throw new \Exception('No Polar subscription found'); Log::error('No Polar subscription found');
} }
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/discount', [ ])->post($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/discount', [
'coupon_code' => $couponCode, 'coupon_code' => $couponCode,
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Failed to apply Polar coupon: '.$response->body()); Log::error('Failed to apply Polar coupon: '.$response->body());
} }
return $response->json(); return $response->json();
@@ -890,7 +1121,7 @@ class PolarProvider implements PaymentProviderContract
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
])->delete($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/discount'); ])->delete($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/discount');
return $response->successful(); return $response->successful();
@@ -919,10 +1150,10 @@ class PolarProvider implements PaymentProviderContract
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'], 'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice'); ])->get($this->getApiBaseUrl().'/v1/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice');
if (! $response->successful()) { if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar upcoming invoice: '.$response->body()); Log::error('Failed to retrieve Polar upcoming invoice: '.$response->body());
} }
$invoice = $response->json(); $invoice = $response->json();
@@ -980,6 +1211,9 @@ class PolarProvider implements PaymentProviderContract
public function importSubscriptionData(User $user, array $subscriptionData): array public function importSubscriptionData(User $user, array $subscriptionData): array
{ {
throw new \Exception('Import to Polar payments not implemented'); Log::error('Import to Polar payments not implemented');
todo('Write import subscription data');
return [];
} }
} }

View File

@@ -63,6 +63,9 @@ return [
'polar' => [ 'polar' => [
'api_key' => env('POLAR_API_KEY'), 'api_key' => env('POLAR_API_KEY'),
'webhook_secret' => env('POLAR_WEBHOOK_SECRET'), 'webhook_secret' => env('POLAR_WEBHOOK_SECRET'),
'sandbox' => env('POLAR_SANDBOX', false),
'sandbox_api_key' => env('POLAR_SANDBOX_API_KEY'),
'sandbox_webhook_secret' => env('POLAR_SANDBOX_WEBHOOK_SECRET'),
'success_url' => env('POLAR_SUCCESS_URL', '/payment/success'), 'success_url' => env('POLAR_SUCCESS_URL', '/payment/success'),
'cancel_url' => env('POLAR_CANCEL_URL', '/payment/cancel'), 'cancel_url' => env('POLAR_CANCEL_URL', '/payment/cancel'),
'access_token' => env('POLAR_ACCESS_TOKEN'), 'access_token' => env('POLAR_ACCESS_TOKEN'),