- 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
796 lines
26 KiB
PHP
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');
|
|
}
|
|
}
|