config = array_merge([ 'api_key' => config('services.lemon_squeezy.api_key'), 'store_id' => config('services.lemon_squeezy.store_id'), 'webhook_secret' => config('services.lemon_squeezy.webhook_secret'), 'success_url' => route('payment.success'), 'cancel_url' => route('payment.cancel'), 'api_version' => 'v1', ], $config); $this->apiKey = $this->config['api_key'] ?? null; } public function getName(): string { return 'lemon_squeezy'; } public function isActive(): bool { return ! empty($this->apiKey) && ! empty($this->config['store_id']); } public function createSubscription(User $user, Plan $plan, array $options = []): array { try { $variantId = $this->getOrCreateVariant($plan); $checkoutData = [ 'store_id' => $this->config['store_id'], 'variant_id' => $variantId, 'customer_email' => $user->email, 'success_url' => $options['success_url'] ?? $this->config['success_url'], 'cancel_url' => $options['cancel_url'] ?? $this->config['cancel_url'], 'embed' => false, 'invoice_grace_period' => 0, ]; if (! empty($options['trial_days'])) { $checkoutData['trial_period'] = $options['trial_days']; } if (! empty($options['coupon_code'])) { $checkoutData['discount_code'] = $options['coupon_code']; } // Add custom data for tracking $checkoutData['custom_data'] = [ 'user_id' => $user->id, 'plan_id' => $plan->id, 'provider' => 'lemon_squeezy', ]; $response = $this->makeRequest('POST', '/checkouts', $checkoutData); return [ 'provider_subscription_id' => $response['data']['id'], 'status' => 'pending', 'checkout_url' => $response['data']['attributes']['url'], 'type' => 'checkout_session', ]; } catch (\Exception $e) { Log::error('Lemon Squeezy 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 { $subscriptionId = $subscription->provider_subscription_id; if (! $subscriptionId) { throw new \Exception('No Lemon Squeezy subscription ID found'); } // Cancel at period end (graceful cancellation) $response = $this->makeRequest('DELETE', "/subscriptions/{$subscriptionId}", [ 'cancel_at_period_end' => true, ]); return true; } catch (\Exception $e) { Log::error('Lemon Squeezy subscription cancellation failed', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); throw $e; } } public function updateSubscription(Subscription $subscription, Plan $newPlan): array { try { $subscriptionId = $subscription->provider_subscription_id; $newVariantId = $this->getOrCreateVariant($newPlan); // Update subscription variant $response = $this->makeRequest('PATCH', "/subscriptions/{$subscriptionId}", [ 'variant_id' => $newVariantId, ]); return [ 'provider_subscription_id' => $subscriptionId, 'status' => $response['data']['attributes']['status'], 'new_variant_id' => $newVariantId, ]; } catch (\Exception $e) { Log::error('Lemon Squeezy subscription update failed', [ 'subscription_id' => $subscription->id, 'new_plan_id' => $newPlan->id, 'error' => $e->getMessage(), ]); throw $e; } } public function pauseSubscription(Subscription $subscription): bool { try { $subscriptionId = $subscription->provider_subscription_id; // Pause subscription $response = $this->makeRequest('PATCH', "/subscriptions/{$subscriptionId}", [ 'pause' => [ 'mode' => 'void', ], ]); return true; } catch (\Exception $e) { Log::error('Lemon Squeezy subscription pause failed', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); throw $e; } } public function resumeSubscription(Subscription $subscription): bool { try { $subscriptionId = $subscription->provider_subscription_id; // Unpause subscription $response = $this->makeRequest('PATCH', "/subscriptions/{$subscriptionId}", [ 'pause' => null, 'cancel_at_period_end' => false, ]); return true; } catch (\Exception $e) { Log::error('Lemon Squeezy subscription resume failed', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); throw $e; } } public function getSubscriptionDetails(string $providerSubscriptionId): array { try { $response = $this->makeRequest('GET', "/subscriptions/{$providerSubscriptionId}"); $data = $response['data']['attributes']; return [ 'id' => $data['id'], 'status' => $data['status'], 'customer_id' => $data['customer_id'], 'order_id' => $data['order_id'], 'product_id' => $data['product_id'], 'variant_id' => $data['variant_id'], 'created_at' => $data['created_at'], 'updated_at' => $data['updated_at'], 'trial_ends_at' => $data['trial_ends_at'] ?? null, 'renews_at' => $data['renews_at'] ?? null, 'ends_at' => $data['ends_at'] ?? null, 'cancelled_at' => $data['cancelled_at'] ?? null, ]; } catch (\Exception $e) { Log::error('Lemon Squeezy 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 { // Lemon Squeezy doesn't have a customer portal like Stripe // Instead, we can redirect to the customer's orders page return [ 'portal_url' => 'https://app.lemonsqueezy.com/my-orders', 'message' => 'Lemon Squeezy customer portal', ]; } catch (\Exception $e) { Log::error('Lemon Squeezy customer portal creation failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); throw $e; } } public function processWebhook(Request $request): array { try { $payload = $request->getContent(); $eventData = json_decode($payload, true); $eventType = $eventData['meta']['event_name'] ?? 'unknown'; $result = [ 'event_type' => $eventType, 'processed' => false, 'data' => [], ]; switch ($eventType) { case 'subscription_created': $result = $this->handleSubscriptionCreated($eventData); break; case 'subscription_updated': $result = $this->handleSubscriptionUpdated($eventData); break; case 'subscription_cancelled': $result = $this->handleSubscriptionCancelled($eventData); break; case 'subscription_resumed': $result = $this->handleSubscriptionResumed($eventData); break; case 'order_created': $result = $this->handleOrderCreated($eventData); break; case 'order_payment_succeeded': $result = $this->handleOrderPaymentSucceeded($eventData); break; default: Log::info('Unhandled Lemon Squeezy webhook event', ['event_type' => $eventType]); } return $result; } catch (\Exception $e) { Log::error('Lemon Squeezy webhook processing failed', [ 'error' => $e->getMessage(), 'payload' => $request->getContent(), ]); throw $e; } } public function validateWebhook(Request $request): bool { try { $signature = $request->header('X-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('Lemon Squeezy 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 { $response = $this->makeRequest('GET', "/payment-methods/{$paymentMethodId}"); $data = $response['data']['attributes']; return [ 'id' => $data['id'], 'type' => $data['type'], 'card' => [ 'last4' => $data['last4'] ?? null, 'brand' => $data['brand'] ?? null, 'exp_month' => $data['exp_month'] ?? null, 'exp_year' => $data['exp_year'] ?? null, ], 'created_at' => $data['created_at'], ]; } catch (\Exception $e) { Log::error('Lemon Squeezy payment method details retrieval failed', [ 'payment_method_id' => $paymentMethodId, 'error' => $e->getMessage(), ]); throw $e; } } public function processRefund(string $orderId, float $amount, string $reason = ''): array { try { $response = $this->makeRequest('POST', "/orders/{$orderId}/refunds", [ 'amount' => (int) ($amount * 100), // Lemon Squeezy uses cents 'reason' => $reason ?: 'requested_by_customer', 'note' => 'Refund processed via unified payment system', ]); return [ 'refund_id' => $response['data']['id'], 'amount' => $response['data']['attributes']['amount'] / 100, 'status' => $response['data']['attributes']['status'], 'created_at' => $response['data']['attributes']['created_at'], ]; } catch (\Exception $e) { Log::error('Lemon Squeezy refund processing failed', [ 'order_id' => $orderId, 'amount' => $amount, 'error' => $e->getMessage(), ]); throw $e; } } public function getTransactionHistory(User $user, array $filters = []): array { try { // Get all orders for the customer $response = $this->makeRequest('GET', '/orders', [ 'filter' => [ 'customer_email' => $user->email, ], 'page' => [ 'limit' => $filters['limit'] ?? 100, ], ]); $transactions = []; foreach ($response['data'] as $order) { $attributes = $order['attributes']; $transactions[] = [ 'id' => $attributes['id'], 'order_number' => $attributes['order_number'], 'amount' => $attributes['total'] / 100, 'currency' => $attributes['currency'], 'status' => $attributes['status'], 'created_at' => $attributes['created_at'], 'refunded' => $attributes['refunded'] ?? false, 'customer_email' => $attributes['customer_email'], ]; } return $transactions; } catch (\Exception $e) { Log::error('Lemon Squeezy transaction history retrieval failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); throw $e; } } public function calculateFees(float $amount): array { // Lemon Squeezy fees: 5% + $0.50 flat fee $fixedFee = 0.50; $percentageFee = 5.0; $percentageAmount = ($amount * $percentageFee) / 100; $totalFee = $fixedFee + $percentageAmount; return [ 'fixed_fee' => $fixedFee, 'percentage_fee' => $percentageAmount, 'total_fee' => $totalFee, 'net_amount' => $amount - $totalFee, ]; } public function getSupportedCurrencies(): array { return [ 'USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'SEK', 'NOK', 'DKK', 'PLN', 'CZK', 'HUF', 'RON', 'BGN', 'HRK', 'RUB', 'TRY', 'MXN', 'BRL', 'ARS', 'CLP', 'COP', 'PEN', 'UYU', 'JPY', 'SGD', 'HKD', 'INR', 'MYR', 'THB', 'PHP', 'TWD', 'KRW', 'CNY', 'NZD', 'ZAR', 'NGN', 'KES', 'GHS', 'EGP', 'MAD', 'TND', 'DZD', ]; } public function supportsRecurring(): bool { return true; } public function supportsOneTime(): bool { return true; } // Helper methods protected function makeRequest(string $method, string $endpoint, array $data = []): array { $url = "https://api.lemonsqueezy.com/{$this->config['api_version']}{$endpoint}"; $headers = [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'Authorization' => 'Bearer '.$this->apiKey, ]; $response = Http::withHeaders($headers) ->asJson() ->send($method, $url, $data); if (! $response->successful()) { throw new \Exception("Lemon Squeezy API request failed: {$response->status()} - {$response->body()}"); } return $response->json(); } protected function getOrCreateVariant(Plan $plan): string { // Check if plan already has a Lemon Squeezy variant ID if (! empty($plan->details['lemon_squeezy_variant_id'])) { return $plan->details['lemon_squeezy_variant_id']; } // Create product if it doesn't exist $productId = $this->getOrCreateProduct($plan); // Create variant $variantData = [ 'product_id' => $productId, 'name' => $plan->name, 'description' => $plan->description ?? '', 'price' => $plan->price * 100, // Convert to cents 'price_formatted' => $this->formatPrice($plan->price), ]; if ($plan->monthly_billing) { $variantData['interval'] = 'month'; $variantData['interval_count'] = 1; } else { $variantData['interval'] = 'one_time'; } $response = $this->makeRequest('POST', '/variants', $variantData); $variantId = $response['data']['id']; // Update plan with new variant ID $planDetails = $plan->details ?? []; $planDetails['lemon_squeezy_variant_id'] = $variantId; $plan->update(['details' => $planDetails]); return $variantId; } protected function getOrCreateProduct(Plan $plan): string { // Check if plan already has a Lemon Squeezy product ID if (! empty($plan->details['lemon_squeezy_product_id'])) { return $plan->details['lemon_squeezy_product_id']; } // Create product $productData = [ 'store_id' => $this->config['store_id'], 'name' => $plan->name, 'description' => $plan->description ?? '', 'slug' => strtolower(str_replace(' ', '-', $plan->name)), ]; $response = $this->makeRequest('POST', '/products', $productData); $productId = $response['data']['id']; // Update plan with new product ID $planDetails = $plan->details ?? []; $planDetails['lemon_squeezy_product_id'] = $productId; $plan->update(['details' => $planDetails]); return $productId; } protected function formatPrice(float $price): string { // Format price based on currency $currency = $this->config['currency'] ?? 'USD'; switch ($currency) { case 'USD': case 'CAD': case 'AUD': return '$'.number_format($price, 2); case 'EUR': return '€'.number_format($price, 2); case 'GBP': return '£'.number_format($price, 2); default: return number_format($price, 2).' '.$currency; } } // Webhook handlers protected function handleSubscriptionCreated(array $eventData): array { $attributes = $eventData['data']['attributes']; return [ 'event_type' => $eventData['meta']['event_name'], 'processed' => true, 'data' => [ 'subscription_id' => $attributes['id'], 'customer_id' => $attributes['customer_id'], 'product_id' => $attributes['product_id'], 'variant_id' => $attributes['variant_id'], 'status' => $attributes['status'], ], ]; } protected function handleSubscriptionUpdated(array $eventData): array { $attributes = $eventData['data']['attributes']; return [ 'event_type' => $eventData['meta']['event_name'], 'processed' => true, 'data' => [ 'subscription_id' => $attributes['id'], 'status' => $attributes['status'], 'renews_at' => $attributes['renews_at'] ?? null, ], ]; } protected function handleSubscriptionCancelled(array $eventData): array { $attributes = $eventData['data']['attributes']; return [ 'event_type' => $eventData['meta']['event_name'], 'processed' => true, 'data' => [ 'subscription_id' => $attributes['id'], 'status' => 'cancelled', 'cancelled_at' => $attributes['cancelled_at'], ], ]; } protected function handleSubscriptionResumed(array $eventData): array { $attributes = $eventData['data']['attributes']; return [ 'event_type' => $eventData['meta']['event_name'], 'processed' => true, 'data' => [ 'subscription_id' => $attributes['id'], 'status' => $attributes['status'], ], ]; } protected function handleOrderCreated(array $eventData): array { $attributes = $eventData['data']['attributes']; return [ 'event_type' => $eventData['meta']['event_name'], 'processed' => true, 'data' => [ 'order_id' => $attributes['id'], 'order_number' => $attributes['order_number'], 'customer_email' => $attributes['customer_email'], 'total' => $attributes['total'], 'currency' => $attributes['currency'], ], ]; } protected function handleOrderPaymentSucceeded(array $eventData): array { $attributes = $eventData['data']['attributes']; return [ 'event_type' => $eventData['meta']['event_name'], 'processed' => true, 'data' => [ 'order_id' => $attributes['id'], 'order_number' => $attributes['order_number'], 'total' => $attributes['total'], 'status' => 'paid', ], ]; } // Additional interface methods public function getSubscriptionMetadata(Subscription $subscription): array { return $subscription->provider_data['metadata'] ?? []; } public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool { // Lemon Squeezy doesn't support metadata on subscriptions directly // Store in our local provider_data instead $providerData = $subscription->provider_data ?? []; $providerData['metadata'] = $metadata; $subscription->update(['provider_data' => $providerData]); return true; } public function startTrial(Subscription $subscription, int $trialDays): bool { // Lemon Squeezy handles trials via variant configuration // This would require creating a trial variant and switching return true; } public function applyCoupon(Subscription $subscription, string $couponCode): array { try { // Apply discount code to subscription $subscriptionId = $subscription->provider_subscription_id; $response = $this->makeRequest('POST', "/subscriptions/{$subscriptionId}/discounts", [ 'discount_code' => $couponCode, ]); return [ 'discount_id' => $response['data']['id'], 'amount' => $response['data']['attributes']['amount'] / 100, 'type' => $response['data']['attributes']['type'], ]; } catch (\Exception $e) { Log::error('Failed to apply Lemon Squeezy coupon', [ 'subscription_id' => $subscription->id, 'coupon_code' => $couponCode, 'error' => $e->getMessage(), ]); throw $e; } } public function removeCoupon(Subscription $subscription): bool { try { $subscriptionId = $subscription->provider_subscription_id; // Get and delete all discounts $discounts = $this->makeRequest('GET', "/subscriptions/{$subscriptionId}/discounts"); foreach ($discounts['data'] as $discount) { $this->makeRequest('DELETE', "/discounts/{$discount['id']}"); } return true; } catch (\Exception $e) { Log::error('Failed to remove Lemon Squeezy coupon', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); return false; } } public function getUpcomingInvoice(Subscription $subscription): array { try { $subscriptionId = $subscription->provider_subscription_id; $response = $this->makeRequest('GET', "/subscriptions/{$subscriptionId}"); $attributes = $response['data']['attributes']; return [ 'amount_due' => $attributes['renews_at'] ? 0 : $attributes['subtotal'] / 100, 'currency' => $attributes['currency'], 'next_payment_date' => $attributes['renews_at'], ]; } catch (\Exception $e) { Log::error('Failed to get Lemon Squeezy upcoming invoice', [ 'subscription_id' => $subscription->id, 'error' => $e->getMessage(), ]); throw $e; } } public function retryFailedPayment(Subscription $subscription): array { // Lemon Squeezy handles failed payments automatically // We can trigger a subscription sync instead 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', 'paused']); } catch (\Exception $e) { return false; } } public function getCancellationTerms(Subscription $subscription): array { return [ 'immediate_cancellation' => false, // Lemon Squeezy cancels at period end 'refund_policy' => 'as_per_terms', 'cancellation_effective' => 'period_end', 'billing_cycle_proration' => true, ]; } public function exportSubscriptionData(Subscription $subscription): array { return [ 'provider' => 'lemon_squeezy', 'provider_subscription_id' => $subscription->provider_subscription_id, 'data' => $subscription->provider_data, ]; } public function importSubscriptionData(User $user, array $subscriptionData): array { // Import to Lemon Squeezy - would require creating matching products/variants throw new \Exception('Import to Lemon Squeezy not implemented'); } }