Files
zemailnator/app/Services/Payments/Providers/LemonSqueezyProvider.php
idevakk 27ac13948c feat: implement comprehensive multi-provider payment processing system
- Add unified payment provider architecture with contract-based design
  - Implement 6 payment providers: Stripe, Lemon Squeezy, Polar, Oxapay, Crypto, Activation Keys
  - Create subscription management with lifecycle handling (create, cancel, pause, resume, update)
  - Add coupon system with usage tracking and trial extensions
  - Build Filament admin resources for payment providers, subscriptions, coupons, and trials
  - Implement payment orchestration service with provider registry and configuration management
  - Add comprehensive payment logging and webhook handling for all providers
  - Create customer analytics dashboard with revenue, churn, and lifetime value metrics
  - Add subscription migration service for provider switching
  - Include extensive test coverage for all payment functionality
2025-11-19 09:37:00 -08:00

796 lines
26 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 Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LemonSqueezyProvider implements PaymentProviderContract
{
protected array $config;
protected ?string $apiKey;
public function __construct(array $config = [])
{
$this->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');
}
}