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
This commit is contained in:
idevakk
2025-11-19 09:37:00 -08:00
parent 0560016f33
commit 27ac13948c
83 changed files with 15613 additions and 103 deletions

View File

@@ -0,0 +1,430 @@
<?php
namespace App\Services\Payments;
use App\Services\Payments\Providers\ActivationKeyProvider;
use App\Services\Payments\Providers\CryptoProvider;
use App\Services\Payments\Providers\LemonSqueezyProvider;
use App\Services\Payments\Providers\OxapayProvider;
use App\Services\Payments\Providers\PolarProvider;
use App\Services\Payments\Providers\StripeProvider;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class PaymentConfigurationManager
{
protected ProviderRegistry $registry;
protected array $providerConfigs;
public function __construct(ProviderRegistry $registry)
{
$this->registry = $registry;
$this->providerConfigs = $this->loadProviderConfigurations();
}
/**
* Initialize all configured payment providers
*/
public function initializeProviders(): void
{
try {
$this->registerStripeProvider();
$this->registerLemonSqueezyProvider();
$this->registerPolarProvider();
$this->registerOxapayProvider();
$this->registerCryptoProvider();
$this->registerActivationKeyProvider();
Log::info('Payment providers initialized', [
'providers' => array_keys($this->registry->getAllProviders()->toArray()),
'active_providers' => array_keys($this->registry->getActiveProviders()->toArray()),
]);
} catch (\Exception $e) {
Log::error('Failed to initialize payment providers', [
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Register Stripe provider if configured
*/
protected function registerStripeProvider(): void
{
$config = $this->providerConfigs['stripe'] ?? [];
if (! empty($config['secret_key'])) {
$provider = new StripeProvider($config);
$this->registry->register('stripe', $provider);
}
}
/**
* Register Lemon Squeezy provider if configured
*/
protected function registerLemonSqueezyProvider(): void
{
$config = $this->providerConfigs['lemon_squeezy'] ?? [];
if (! empty($config['api_key']) && ! empty($config['store_id'])) {
$provider = new LemonSqueezyProvider($config);
$this->registry->register('lemon_squeezy', $provider);
}
}
/**
* Register Polar provider if configured
*/
protected function registerPolarProvider(): void
{
$config = $this->providerConfigs['polar'] ?? [];
if (! empty($config['api_key'])) {
$provider = new PolarProvider($config);
$this->registry->register('polar', $provider);
}
}
/**
* Register OxaPay provider if configured
*/
protected function registerOxapayProvider(): void
{
$config = $this->providerConfigs['oxapay'] ?? [];
if (! empty($config['merchant_api_key'])) {
$provider = new OxapayProvider($config);
$this->registry->register('oxapay', $provider);
}
}
/**
* Register Crypto provider if enabled
*/
protected function registerCryptoProvider(): void
{
$config = $this->providerConfigs['crypto'] ?? [];
if ($config['enabled'] ?? false) {
$provider = new CryptoProvider($config);
$this->registry->register('crypto', $provider);
}
}
/**
* Register Activation Key provider (always available)
*/
protected function registerActivationKeyProvider(): void
{
$config = $this->providerConfigs['activation_key'] ?? [];
$provider = new ActivationKeyProvider($config);
$this->registry->register('activation_key', $provider);
}
/**
* Load provider configurations from config and cache
*/
protected function loadProviderConfigurations(): array
{
return Cache::remember('payment_provider_configs', now()->addHour(), function () {
return [
'stripe' => [
'secret_key' => config('services.stripe.secret_key'),
'publishable_key' => config('services.stripe.publishable_key'),
'webhook_secret' => config('services.stripe.webhook_secret'),
],
'lemon_squeezy' => [
'api_key' => config('services.lemon_squeezy.api_key'),
'store_id' => config('services.lemon_squeezy.store_id'),
'webhook_secret' => config('services.lemon_squeezy.webhook_secret'),
],
'polar' => [
'api_key' => config('services.polar.api_key'),
'webhook_secret' => config('services.polar.webhook_secret'),
],
'oxapay' => [
'merchant_api_key' => config('services.oxapay.merchant_api_key'),
'webhook_url' => config('services.oxapay.webhook_url'),
'success_url' => config('services.oxapay.success_url'),
'cancel_url' => config('services.oxapay.cancel_url'),
'sandbox' => config('services.oxapay.sandbox', false),
],
'crypto' => [
'enabled' => config('payments.crypto.enabled', false),
'webhook_secret' => config('payments.crypto.webhook_secret'),
'confirmation_timeout_minutes' => config('payments.crypto.confirmation_timeout_minutes', 30),
'exchange_rate_provider' => config('payments.crypto.exchange_rate_provider', 'coingecko'),
],
'activation_key' => [
'key_prefix' => config('payments.activation_key.prefix', 'AK-'),
'key_length' => config('payments.activation_key.length', 32),
'expiration_days' => config('payments.activation_key.expiration_days'),
],
];
});
}
/**
* Get provider configuration
*/
public function getProviderConfig(string $provider): array
{
return $this->providerConfigs[$provider] ?? [];
}
/**
* Update provider configuration
*/
public function updateProviderConfig(string $provider, array $config): void
{
$this->providerConfigs[$provider] = array_merge(
$this->providerConfigs[$provider] ?? [],
$config
);
// Clear cache to force reload
Cache::forget('payment_provider_configs');
Log::info('Payment provider configuration updated', [
'provider' => $provider,
]);
}
/**
* Validate provider configuration
*/
public function validateProviderConfig(string $provider, array $config): array
{
$errors = [];
switch ($provider) {
case 'stripe':
if (empty($config['secret_key'])) {
$errors[] = 'Stripe secret key is required';
}
if (empty($config['publishable_key'])) {
$errors[] = 'Stripe publishable key is required';
}
break;
case 'lemon_squeezy':
if (empty($config['api_key'])) {
$errors[] = 'Lemon Squeezy API key is required';
}
if (empty($config['store_id'])) {
$errors[] = 'Lemon Squeezy store ID is required';
}
break;
case 'polar':
if (empty($config['api_key'])) {
$errors[] = 'Polar API key is required';
}
break;
case 'oxapay':
if (empty($config['merchant_api_key'])) {
$errors[] = 'OxaPay merchant API key is required';
}
break;
case 'crypto':
if (empty($config['webhook_secret'])) {
$errors[] = 'Crypto webhook secret is required';
}
break;
case 'activation_key':
// Activation keys don't require specific configuration
break;
default:
$errors[] = "Unknown provider: {$provider}";
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
/**
* Get provider status and health information
*/
public function getProviderStatus(): array
{
$status = [];
$providers = $this->registry->getAllProviders();
foreach ($providers as $name => $provider) {
$status[$name] = [
'name' => $provider->getName(),
'active' => $provider->isActive(),
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'supported_currencies' => $provider->getSupportedCurrencies(),
'configured' => ! empty($this->providerConfigs[$name]),
'configuration' => $this->sanitizeConfig($this->providerConfigs[$name] ?? []),
];
}
return $status;
}
/**
* Sanitize configuration for display (remove sensitive data)
*/
protected function sanitizeConfig(array $config): array
{
$sensitiveKeys = ['secret_key', 'api_key', 'webhook_secret'];
$sanitized = $config;
foreach ($sensitiveKeys as $key) {
if (isset($sanitized[$key]) && ! empty($sanitized[$key])) {
$sanitized[$key] = '***'.substr($sanitized[$key], -4);
}
}
return $sanitized;
}
/**
* Enable/disable a provider
*/
public function toggleProvider(string $provider, bool $enabled): bool
{
try {
if ($enabled && ! $this->registry->has($provider)) {
// Register the provider if it doesn't exist
$this->registerProviderByName($provider);
}
if (! $enabled && $this->registry->has($provider)) {
// Unregister the provider
$this->registry->unregister($provider);
}
$this->updateProviderConfig($provider, ['enabled' => $enabled]);
Log::info('Payment provider toggled', [
'provider' => $provider,
'enabled' => $enabled,
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to toggle payment provider', [
'provider' => $provider,
'enabled' => $enabled,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Register a provider by name
*/
protected function registerProviderByName(string $provider): void
{
switch ($provider) {
case 'stripe':
$this->registerStripeProvider();
break;
case 'lemon_squeezy':
$this->registerLemonSqueezyProvider();
break;
case 'polar':
$this->registerPolarProvider();
break;
case 'oxapay':
$this->registerOxapayProvider();
break;
case 'crypto':
$this->registerCryptoProvider();
break;
case 'activation_key':
$this->registerActivationKeyProvider();
break;
default:
throw new \InvalidArgumentException("Unknown provider: {$provider}");
}
}
/**
* Get default provider for a given plan type
*/
public function getDefaultProvider(?string $planType = null): string
{
// Priority order for providers
$priority = [
'stripe', // Most reliable
'lemon_squeezy', // Good for international
'polar', // Developer-focused MoR
'oxapay', // Crypto payment gateway
'crypto', // For crypto payments
'activation_key', // For manual activation
];
foreach ($priority as $provider) {
if ($this->registry->has($provider) && $this->registry->get($provider)->isActive()) {
return $provider;
}
}
// Fallback to activation key (always available)
return 'activation_key';
}
/**
* Test provider connectivity
*/
public function testProviderConnectivity(string $provider): array
{
try {
if (! $this->registry->has($provider)) {
return [
'success' => false,
'error' => 'Provider not registered',
];
}
$providerInstance = $this->registry->get($provider);
// Basic connectivity test - check if provider is active
$isActive = $providerInstance->isActive();
return [
'success' => $isActive,
'message' => $isActive ? 'Provider is active and ready' : 'Provider is not active',
'details' => [
'name' => $providerInstance->getName(),
'supports_recurring' => $providerInstance->supportsRecurring(),
'supports_one_time' => $providerInstance->supportsOneTime(),
],
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Refresh provider configurations
*/
public function refreshConfigurations(): void
{
Cache::forget('payment_provider_configs');
$this->providerConfigs = $this->loadProviderConfigurations();
Log::info('Payment provider configurations refreshed');
}
}

View File

@@ -0,0 +1,431 @@
<?php
namespace App\Services\Payments;
use App\Models\PaymentEvent;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class PaymentLogger
{
protected array $context = [];
public function __construct()
{
$this->context = [
'request_id' => uniqid('pay_', true),
'timestamp' => now()->toISOString(),
'user_agent' => $this->getUserAgent(),
'ip_address' => $this->getIpAddress(),
];
}
/**
* Log a payment event
*/
public function logEvent(string $eventType, array $data = [], ?string $level = 'info'): void
{
$eventData = array_merge($this->context, [
'event_type' => $eventType,
'user_id' => $this->getUserId(),
'data' => $data,
'level' => $level,
]);
// Log to Laravel logs
$this->logToFile("Payment event: {$eventType}", $eventData, $level);
// Store in database for audit trail
$this->storeEvent($eventType, $data, $level);
}
/**
* Log an error event
*/
public function logError(string $eventType, array $data = [], ?\Exception $exception = null): void
{
$errorData = $data;
if ($exception) {
$errorData['exception'] = [
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
];
}
$this->logEvent($eventType, $errorData, 'error');
}
/**
* Log a security event
*/
public function logSecurityEvent(string $eventType, array $data = []): void
{
$securityData = array_merge($data, [
'security_level' => 'high',
'requires_review' => true,
]);
$this->logEvent("security_{$eventType}", $securityData, 'warning');
// Additional security logging
$this->logToFile("Security payment event: {$eventType}", array_merge($this->context, $securityData), 'warning');
}
/**
* Log webhook events
*/
public function logWebhook(string $provider, string $eventType, array $payload, bool $success = true): void
{
$webhookData = [
'provider' => $provider,
'webhook_event_type' => $eventType,
'payload_size' => strlen(json_encode($payload)),
'payload_hash' => hash('sha256', json_encode($payload)),
'success' => $success,
];
// Don't store full payload in logs for security/size reasons
$this->logEvent('webhook_received', $webhookData, $success ? 'info' : 'error');
// Store full payload in database for debugging (with retention policy)
$this->storeWebhookPayload($provider, $eventType, $payload, $success);
}
/**
* Log subscription lifecycle events
*/
public function logSubscriptionEvent(string $action, int $subscriptionId, array $data = []): void
{
$subscriptionData = array_merge($data, [
'subscription_id' => $subscriptionId,
'action' => $action,
]);
$this->logEvent("subscription_{$action}", $subscriptionData);
}
/**
* Log payment method events
*/
public function logPaymentMethodEvent(string $action, array $data = []): void
{
$this->logEvent("payment_method_{$action}", $data);
}
/**
* Log provider events
*/
public function logProviderEvent(string $provider, string $action, array $data = []): void
{
$providerData = array_merge($data, [
'provider' => $provider,
'provider_action' => $action,
]);
$this->logEvent("provider_{$action}", $providerData);
}
/**
* Log admin actions
*/
public function logAdminAction(string $action, array $data = []): void
{
$adminData = array_merge($data, [
'admin_user_id' => Auth::id(),
'admin_action' => $action,
'requires_review' => in_array($action, ['refund', 'subscription_override', 'provider_config_change']),
]);
$this->logEvent("admin_{$action}", $adminData);
}
/**
* Log migration events
*/
public function logMigrationEvent(string $action, array $data = []): void
{
$migrationData = array_merge($data, [
'migration_action' => $action,
'batch_id' => $data['batch_id'] ?? null,
]);
$this->logEvent("migration_{$action}", $migrationData);
}
/**
* Log compliance events
*/
public function logComplianceEvent(string $type, array $data = []): void
{
$complianceData = array_merge($data, [
'compliance_type' => $type,
'retention_required' => true,
'gdpr_relevant' => in_array($type, ['data_access', 'data_deletion', 'consent_withdrawn']),
]);
$this->logEvent("compliance_{$type}", $complianceData);
}
/**
* Get audit trail for a specific user
*/
public function getUserAuditTrail(int $userId, array $filters = []): array
{
$query = PaymentEvent::where('user_id', $userId);
if (! empty($filters['event_type'])) {
$query->where('event_type', $filters['event_type']);
}
if (! empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
if (! empty($filters['level'])) {
$query->where('level', $filters['level']);
}
return $query->orderBy('created_at', 'desc')
->limit($filters['limit'] ?? 1000)
->get()
->toArray();
}
/**
* Get audit trail for a subscription
*/
public function getSubscriptionAuditTrail(int $subscriptionId): array
{
return PaymentEvent::whereJsonContains('data->subscription_id', $subscriptionId)
->orderBy('created_at', 'desc')
->get()
->toArray();
}
/**
* Get provider audit trail
*/
public function getProviderAuditTrail(string $provider, array $filters = []): array
{
$query = PaymentEvent::whereJsonContains('data->provider', $provider);
if (! empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
return $query->orderBy('created_at', 'desc')
->limit($filters['limit'] ?? 1000)
->get()
->toArray();
}
/**
* Generate compliance report
*/
public function generateComplianceReport(array $criteria = []): array
{
$query = PaymentEvent::query();
if (! empty($criteria['date_from'])) {
$query->where('created_at', '>=', $criteria['date_from']);
}
if (! empty($criteria['date_to'])) {
$query->where('created_at', '<=', $criteria['date_to']);
}
if (! empty($criteria['event_types'])) {
$query->whereIn('event_type', $criteria['event_types']);
}
$events = $query->get();
return [
'report_generated_at' => now()->toISOString(),
'criteria' => $criteria,
'total_events' => $events->count(),
'events_by_type' => $events->groupBy('event_type')->map->count(),
'events_by_level' => $events->groupBy('level')->map->count(),
'security_events' => $events->filter(fn ($e) => str_contains($e->event_type, 'security'))->count(),
'compliance_events' => $events->filter(fn ($e) => str_contains($e->event_type, 'compliance'))->count(),
'retention_summary' => $this->getRetentionSummary($events),
];
}
/**
* Store event in database
*/
protected function storeEvent(string $eventType, array $data, string $level): void
{
try {
PaymentEvent::create([
'event_type' => $eventType,
'user_id' => Auth::id(),
'level' => $level,
'data' => array_merge($this->context, $data),
'ip_address' => $this->context['ip_address'],
'user_agent' => $this->context['user_agent'],
'request_id' => $this->context['request_id'],
]);
} catch (\Exception $e) {
// Fallback to file logging if database fails
$this->logToFile('Failed to store payment event in database', [
'event_type' => $eventType,
'error' => $e->getMessage(),
'data' => $data,
], 'error');
}
}
/**
* Store webhook payload
*/
protected function storeWebhookPayload(string $provider, string $eventType, array $payload, bool $success): void
{
try {
PaymentEvent::create([
'event_type' => 'webhook_payload',
'level' => $success ? 'info' : 'error',
'data' => [
'provider' => $provider,
'webhook_event_type' => $eventType,
'payload' => $payload,
'success' => $success,
'stored_at' => now()->toISOString(),
] + $this->context,
'ip_address' => $this->context['ip_address'],
'user_agent' => $this->context['user_agent'],
'request_id' => $this->context['request_id'],
'expires_at' => now()->addDays(30), // Webhook payloads expire after 30 days
]);
} catch (\Exception $e) {
$this->logToFile('Failed to store webhook payload', [
'provider' => $provider,
'event_type' => $eventType,
'error' => $e->getMessage(),
], 'error');
}
}
/**
* Get retention summary for compliance
*/
protected function getRetentionSummary($events): array
{
$now = now();
$retentionPeriods = [
'30_days' => $now->copy()->subDays(30),
'90_days' => $now->copy()->subDays(90),
'1_year' => $now->copy()->subYear(),
'7_years' => $now->copy()->subYears(7),
];
return array_map(static function ($date) use ($events) {
return $events->where('created_at', '>=', $date)->count();
}, $retentionPeriods);
}
/**
* Clean up old events based on retention policy
*/
public function cleanupOldEvents(): array
{
$cleanupResults = [];
// Clean up webhook payloads after 30 days
$webhookCleanup = PaymentEvent::where('event_type', 'webhook_payload')
->where('expires_at', '<', now())
->delete();
$cleanupResults['webhook_payloads'] = $webhookCleanup;
// Clean up debug events after 90 days
$debugCleanup = PaymentEvent::where('level', 'debug')
->where('created_at', '<', now()->subDays(90))
->delete();
$cleanupResults['debug_events'] = $debugCleanup;
// Keep compliance and security events for 7 years
// This is handled by database retention policies
$this->logToFile('Payment event cleanup completed', $cleanupResults, 'info');
return $cleanupResults;
}
/**
* Set additional context for logging
*/
public function setContext(array $context): void
{
$this->context = array_merge($this->context, $context);
}
/**
* Get current context
*/
public function getContext(): array
{
return $this->context;
}
/**
* Get user agent safely
*/
protected function getUserAgent(): ?string
{
try {
return request()->userAgent();
} catch (\Exception $e) {
return null;
}
}
/**
* Get IP address safely
*/
protected function getIpAddress(): ?string
{
try {
return request()->ip();
} catch (\Exception $e) {
return null;
}
}
/**
* Get user ID safely
*/
protected function getUserId(): ?int
{
try {
return Auth::id();
} catch (\Exception $e) {
return null;
}
}
/**
* Log to file safely
*/
protected function logToFile(string $message, array $context, string $level): void
{
try {
Log::{$level}($message, $context);
} catch (\Exception $e) {
// Silently fail if logging isn't available
}
}
}

View File

@@ -0,0 +1,910 @@
<?php
namespace App\Services\Payments;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Coupon;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\SubscriptionChange;
use App\Models\User;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class PaymentOrchestrator
{
protected ProviderRegistry $providerRegistry;
protected PaymentLogger $logger;
public function __construct(ProviderRegistry $providerRegistry, PaymentLogger $logger)
{
$this->providerRegistry = $providerRegistry;
$this->logger = $logger;
}
/**
* Create a new subscription using the preferred provider
*/
public function createSubscription(User $user, Plan $plan, ?string $providerName = null, array $options = []): array
{
$provider = $this->getProviderForPlan($plan, $providerName);
if (! $provider->isActive()) {
throw new Exception("Payment provider {$provider->getName()} is not active");
}
try {
$this->logger->logEvent('subscription_creation_started', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
'options' => $options,
]);
$result = $provider->createSubscription($user, $plan, $options);
// Create local subscription record
$subscription = $this->createLocalSubscription($user, $plan, $provider, $result);
$this->logger->logEvent('subscription_created', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'provider_subscription_id' => $result['provider_subscription_id'] ?? null,
]);
return [
'success' => true,
'subscription' => $subscription,
'provider_data' => $result,
];
} catch (Exception $e) {
$this->logger->logError('subscription_creation_failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Cancel a subscription
*/
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
$provider = $this->getProviderForSubscription($subscription);
try {
$this->logger->logEvent('subscription_cancellation_started', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'reason' => $reason,
]);
$result = $provider->cancelSubscription($subscription, $reason);
if ($result) {
$subscription->update([
'ends_at' => now(),
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
$this->logger->logEvent('subscription_cancelled', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
]);
}
return $result;
} catch (Exception $e) {
$this->logger->logError('subscription_cancellation_failed', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Update subscription plan
*/
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
$provider = $this->getProviderForSubscription($subscription);
try {
$this->logger->logEvent('subscription_update_started', [
'subscription_id' => $subscription->id,
'old_plan_id' => $subscription->plan_id,
'new_plan_id' => $newPlan->id,
'provider' => $provider->getName(),
]);
$result = $provider->updateSubscription($subscription, $newPlan);
$subscription->update([
'plan_id' => $newPlan->id,
'updated_at' => now(),
]);
$this->logger->logEvent('subscription_updated', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'provider' => $provider->getName(),
]);
return [
'success' => true,
'subscription' => $subscription->fresh(),
'provider_data' => $result,
];
} catch (Exception $e) {
$this->logger->logError('subscription_update_failed', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Create checkout session
*/
public function createCheckoutSession(User $user, Plan $plan, ?string $providerName = null, array $options = []): array
{
$provider = $this->getProviderForPlan($plan, $providerName);
try {
$this->logger->logEvent('checkout_session_created', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
]);
return $provider->createCheckoutSession($user, $plan, $options);
} catch (Exception $e) {
$this->logger->logError('checkout_session_failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Create subscription with coupon
*/
public function createSubscriptionWithCoupon(User $user, Plan $plan, Coupon $coupon, ?string $providerName = null, array $options = []): array
{
$provider = $this->getProviderForPlan($plan, $providerName);
if (! $coupon->isValid($user)) {
throw new Exception("Coupon {$coupon->code} is not valid for this user");
}
try {
$this->logger->logEvent('coupon_subscription_creation_started', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'coupon_id' => $coupon->id,
'provider' => $provider->getName(),
]);
// Add coupon to options
$options['coupon'] = $coupon->code;
$options['discount_amount'] = $this->calculateDiscountAmount($plan, $coupon);
$result = $provider->createSubscription($user, $plan, $options);
$subscription = $this->createLocalSubscription($user, $plan, $provider, $result);
// Apply coupon to subscription
$couponUsage = $subscription->applyCoupon($coupon, $options['discount_amount']);
$this->logger->logEvent('coupon_subscription_created', [
'subscription_id' => $subscription->id,
'coupon_id' => $coupon->id,
'coupon_usage_id' => $couponUsage->id,
'provider' => $provider->getName(),
]);
return [
'success' => true,
'subscription' => $subscription,
'coupon_usage' => $couponUsage,
'provider_data' => $result,
];
} catch (Exception $e) {
$this->logger->logError('coupon_subscription_creation_failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'coupon_id' => $coupon->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Extend subscription trial
*/
public function extendTrial(Subscription $subscription, int $days, string $reason = '', string $extensionType = 'manual', ?User $grantedBy = null): array
{
$provider = $this->getProviderForSubscription($subscription);
if (! $subscription->isOnTrial()) {
throw new Exception("Subscription {$subscription->id} is not on trial");
}
try {
$this->logger->logEvent('trial_extension_started', [
'subscription_id' => $subscription->id,
'days' => $days,
'reason' => $reason,
'extension_type' => $extensionType,
'granted_by' => $grantedBy?->id,
]);
// Create trial extension record
$trialExtension = $subscription->extendTrial($days, $reason, $extensionType, $grantedBy);
// Update provider if supported
if (method_exists($provider, 'extendTrial')) {
$provider->extendTrial($subscription, $days, $reason);
}
$this->logger->logEvent('trial_extended', [
'subscription_id' => $subscription->id,
'trial_extension_id' => $trialExtension->id,
'new_trial_ends_at' => $trialExtension->new_trial_ends_at,
]);
return [
'success' => true,
'trial_extension' => $trialExtension,
'subscription' => $subscription->fresh(),
];
} catch (Exception $e) {
$this->logger->logError('trial_extension_failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Record subscription change
*/
public function recordSubscriptionChange(Subscription $subscription, string $changeType, string $description, ?array $oldValues = null, ?array $newValues = null, ?string $reason = null): SubscriptionChange
{
try {
$this->logger->logEvent('subscription_change_recorded', [
'subscription_id' => $subscription->id,
'change_type' => $changeType,
'description' => $description,
]);
return $subscription->recordChange($changeType, $description, $oldValues, $newValues, $reason);
} catch (Exception $e) {
$this->logger->logError('subscription_change_recording_failed', [
'subscription_id' => $subscription->id,
'change_type' => $changeType,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Process pending subscription changes
*/
public function processPendingChanges(Subscription $subscription): array
{
try {
$pendingChanges = $subscription->getPendingChanges();
$processedCount = 0;
$errors = [];
foreach ($pendingChanges as $change) {
try {
$this->processSubscriptionChange($subscription, $change);
$change->markAsProcessed();
$processedCount++;
} catch (Exception $e) {
$errors[] = [
'change_id' => $change->id,
'error' => $e->getMessage(),
];
$this->logger->logError('subscription_change_processing_failed', [
'change_id' => $change->id,
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
}
}
$this->logger->logEvent('pending_changes_processed', [
'subscription_id' => $subscription->id,
'processed_count' => $processedCount,
'error_count' => count($errors),
]);
return [
'success' => true,
'processed_count' => $processedCount,
'errors' => $errors,
'subscription' => $subscription->fresh(),
];
} catch (Exception $e) {
$this->logger->logError('pending_changes_processing_failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Migrate subscription between providers
*/
public function migrateSubscription(Subscription $subscription, string $targetProvider): array
{
$sourceProvider = $this->getProviderForSubscription($subscription);
$targetProviderInstance = $this->providerRegistry->get($targetProvider);
if (! $targetProviderInstance) {
throw new Exception("Target provider {$targetProvider} not found");
}
if (! $targetProviderInstance->isActive()) {
throw new Exception("Target provider {$targetProvider} is not active");
}
try {
$this->logger->logEvent('subscription_migration_started', [
'subscription_id' => $subscription->id,
'source_provider' => $sourceProvider->getName(),
'target_provider' => $targetProvider,
]);
// Record the change
$this->recordSubscriptionChange(
$subscription,
'migration',
"Migrated from {$sourceProvider->getName()} to {$targetProvider}",
['provider' => $sourceProvider->getName()],
['provider' => $targetProvider],
'Provider migration for better service'
);
// Cancel with source provider if needed
if (method_exists($sourceProvider, 'cancelSubscription')) {
$sourceProvider->cancelSubscription($subscription, 'Migration to new provider');
}
// Create with target provider
$newSubscriptionData = $targetProviderInstance->createSubscription(
$subscription->user,
$subscription->plan,
['migration' => true]
);
// Update local subscription
$subscription->update([
'provider' => $targetProvider,
'provider_subscription_id' => $newSubscriptionData['provider_subscription_id'] ?? null,
'provider_data' => $newSubscriptionData,
'migration_batch_id' => uniqid('migration_', true),
'is_migrated' => true,
'last_provider_sync' => now(),
]);
$this->logger->logEvent('subscription_migration_completed', [
'subscription_id' => $subscription->id,
'migration_batch_id' => $subscription->migration_batch_id,
'target_provider' => $targetProvider,
]);
return [
'success' => true,
'subscription' => $subscription->fresh(),
'provider_data' => $newSubscriptionData,
];
} catch (Exception $e) {
$this->logger->logError('subscription_migration_failed', [
'subscription_id' => $subscription->id,
'source_provider' => $sourceProvider->getName(),
'target_provider' => $targetProvider,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Process webhook from any provider
*/
public function processWebhook(string $providerName, Request $request): array
{
$provider = $this->providerRegistry->get($providerName);
if (! $provider) {
$this->logger->logError('webhook_provider_not_found', [
'provider' => $providerName,
]);
throw new Exception("Payment provider {$providerName} not found");
}
if (! $provider->validateWebhook($request)) {
$this->logger->logError('webhook_validation_failed', [
'provider' => $providerName,
]);
throw new Exception("Webhook validation failed for {$providerName}");
}
try {
$result = $provider->processWebhook($request);
$this->logger->logEvent('webhook_processed', [
'provider' => $providerName,
'event_type' => $result['event_type'] ?? 'unknown',
'subscription_id' => $result['subscription_id'] ?? null,
]);
return $result;
} catch (Exception $e) {
$this->logger->logError('webhook_processing_failed', [
'provider' => $providerName,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Sync subscription status from provider
*/
public function syncSubscriptionStatus(Subscription $subscription): array
{
$provider = $this->getProviderForSubscription($subscription);
try {
$providerData = $provider->syncSubscriptionStatus($subscription);
// Update local subscription based on provider data
$this->updateLocalSubscriptionFromProvider($subscription, $providerData);
$this->logger->logEvent('subscription_synced', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'status' => $providerData['status'] ?? 'unknown',
]);
return $providerData;
} catch (Exception $e) {
$this->logger->logError('subscription_sync_failed', [
'subscription_id' => $subscription->id,
'provider' => $provider->getName(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Get active providers for a plan
*/
public function getActiveProvidersForPlan(Plan $plan): Collection
{
return $this->providerRegistry->getActiveProviders()
->filter(function ($provider) use ($plan) {
return $this->isProviderSupportedForPlan($provider, $plan);
});
}
/**
* Get subscription transaction history
*/
public function getTransactionHistoryOld(User $user, array $filters = []): array
{
$subscriptions = $user->subscriptions;
$history = [];
foreach ($subscriptions as $subscription) {
$provider = $this->getProviderForSubscription($subscription);
$providerHistory = $provider->getTransactionHistory($user, $filters);
$history = array_merge($history, $providerHistory);
}
// Sort by date descending
usort($history, function ($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
return $history;
}
public function getTransactionHistory(User $user, array $filters = []): array
{
$subscriptions = $user->subscriptions;
$history = [];
foreach ($subscriptions as $subscription) {
$provider = $this->getProviderForSubscription($subscription);
$providerHistory = $provider->getTransactionHistory($user, $filters);
// Use array_push with spread operator (PHP 7.4+) or array unpacking
array_push($history, ...$providerHistory);
// Alternative: Direct array concatenation
// foreach ($providerHistory as $transaction) {
// $history[] = $transaction;
// }
}
// Sort by date descending
usort($history, function ($a, $b) {
return strtotime($b['date']) - strtotime($a['date']);
});
return $history;
}
/**
* Get provider for a specific plan
*/
protected function getProviderForPlan(Plan $plan, ?string $providerName = null): PaymentProviderContract
{
if ($providerName) {
$provider = $this->providerRegistry->get($providerName);
if ($provider && $provider->isActive() && $this->isProviderSupportedForPlan($provider, $plan)) {
return $provider;
}
}
// Find the first active provider that supports this plan
foreach ($this->providerRegistry->getActiveProviders() as $provider) {
if ($this->isProviderSupportedForPlan($provider, $plan)) {
return $provider;
}
}
throw new Exception("No active payment provider available for plan: {$plan->name}");
}
/**
* Get provider for existing subscription
*/
protected function getProviderForSubscription(Subscription $subscription): PaymentProviderContract
{
$providerName = $subscription->provider ?? 'stripe'; // Default to stripe for existing subscriptions
$provider = $this->providerRegistry->get($providerName);
if (! $provider) {
throw new Exception("Payment provider {$providerName} not found for subscription {$subscription->id}");
}
return $provider;
}
/**
* Check if provider supports a specific plan
*/
protected function isProviderSupportedForPlan(PaymentProviderContract $provider, Plan $plan): bool
{
// Check if plan has provider-specific configuration
$providerConfig = $plan->details['providers'][$provider->getName()] ?? null;
if (! $providerConfig || ! ($providerConfig['enabled'] ?? false)) {
return false;
}
// Check if provider supports the plan type
if ($plan->monthly_billing && ! $provider->supportsRecurring()) {
return false;
}
if (! $plan->monthly_billing && ! $provider->supportsOneTime()) {
return false;
}
return true;
}
/**
* Create local subscription record
*/
protected function createLocalSubscription(User $user, Plan $plan, PaymentProviderContract $provider, array $providerData): Subscription
{
return Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'provider' => $provider->getName(),
'provider_subscription_id' => $providerData['provider_subscription_id'] ?? null,
'status' => $providerData['status'] ?? 'active',
'starts_at' => $providerData['starts_at'] ?? now(),
'ends_at' => $providerData['ends_at'] ?? null,
'trial_ends_at' => $providerData['trial_ends_at'] ?? null,
'provider_data' => $providerData,
]);
}
/**
* Update local subscription from provider data
*/
protected function updateLocalSubscriptionFromProvider(Subscription $subscription, array $providerData): void
{
$subscription->update([
'status' => $providerData['status'] ?? $subscription->status,
'ends_at' => $providerData['ends_at'] ?? $subscription->ends_at,
'trial_ends_at' => $providerData['trial_ends_at'] ?? $subscription->trial_ends_at,
'provider_data' => array_merge($subscription->provider_data ?? [], $providerData),
'synced_at' => now(),
]);
}
/**
* Get all available providers
*/
public function getAvailableProviders(): Collection
{
return $this->providerRegistry->getAllProviders();
}
/**
* Get active providers only
*/
public function getActiveProviders(): Collection
{
return $this->providerRegistry->getActiveProviders();
}
/**
* Calculate discount amount for a coupon
*/
protected function calculateDiscountAmount(Plan $plan, Coupon $coupon): float
{
$planPrice = $plan->price ?? 0;
return match ($coupon->discount_type) {
'percentage' => ($planPrice * $coupon->discount_value) / 100,
'fixed' => $coupon->discount_value,
default => 0,
};
}
/**
* Process individual subscription change
*/
protected function processSubscriptionChange(Subscription $subscription, SubscriptionChange $change): void
{
match ($change->change_type) {
'plan_upgrade', 'plan_downgrade' => $this->processPlanChange($subscription, $change),
'pause' => $this->processPauseChange($subscription, $change),
'resume' => $this->processResumeChange($subscription, $change),
'cancel' => $this->processCancelChange($subscription, $change),
default => throw new Exception("Unknown change type: {$change->change_type}"),
};
}
/**
* Process plan change
*/
protected function processPlanChange(Subscription $subscription, SubscriptionChange $change): void
{
$newPlanId = $change->new_values['plan_id'] ?? null;
if (! $newPlanId) {
throw new Exception('Plan ID not found in change values');
}
$newPlan = Plan::findOrFail($newPlanId);
$result = $this->updateSubscription($subscription, $newPlan);
if (! $result['success']) {
throw new Exception('Failed to update subscription plan');
}
}
/**
* Process pause change
*/
protected function processPauseChange(Subscription $subscription, SubscriptionChange $change): void
{
$provider = $this->getProviderForSubscription($subscription);
if (method_exists($provider, 'pauseSubscription')) {
$provider->pauseSubscription($subscription);
}
$subscription->update([
'status' => 'paused',
'paused_at' => now(),
]);
}
/**
* Process resume change
*/
protected function processResumeChange(Subscription $subscription, SubscriptionChange $change): void
{
$provider = $this->getProviderForSubscription($subscription);
if (method_exists($provider, 'resumeSubscription')) {
$provider->resumeSubscription($subscription);
}
$subscription->update([
'status' => 'active',
'resumed_at' => now(),
]);
}
/**
* Process cancel change
*/
protected function processCancelChange(Subscription $subscription, SubscriptionChange $change): void
{
$reason = $change->new_values['reason'] ?? 'Scheduled cancellation';
$this->cancelSubscription($subscription, $reason);
}
/**
* Get subscription analytics
*/
public function getSubscriptionAnalytics(array $filters = []): array
{
$query = Subscription::query();
if (isset($filters['provider'])) {
$query->where('provider', $filters['provider']);
}
if (isset($filters['status'])) {
$query->where('status', $filters['status']);
}
if (isset($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
$totalSubscriptions = $query->count();
$activeSubscriptions = $query->where('status', 'active')->count();
$trialSubscriptions = $query->where('status', 'trialing')->count();
$cancelledSubscriptions = $query->where('status', 'cancelled')->count();
$mrr = $query->where('status', 'active')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price');
$totalRevenue = $query->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price');
return [
'total_subscriptions' => $totalSubscriptions,
'active_subscriptions' => $activeSubscriptions,
'trial_subscriptions' => $trialSubscriptions,
'cancelled_subscriptions' => $cancelledSubscriptions,
'monthly_recurring_revenue' => $mrr,
'total_revenue' => $totalRevenue,
'churn_rate' => $totalSubscriptions > 0 ? ($cancelledSubscriptions / $totalSubscriptions) * 100 : 0,
'trial_conversion_rate' => $trialSubscriptions > 0 ? (($activeSubscriptions - $trialSubscriptions) / $trialSubscriptions) * 100 : 0,
];
}
/**
* Get coupon analytics
*/
public function getCouponAnalytics(array $filters = []): array
{
$query = CouponUsage::query();
if (isset($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
$totalUsages = $query->count();
$totalDiscount = $query->sum('discount_amount');
$uniqueUsers = $query->distinct('user_id')->count('user_id');
$conversionRate = $uniqueUsers > 0 ? ($totalUsages / $uniqueUsers) * 100 : 0;
$topCoupons = $query->join('coupons', 'coupon_usages.coupon_id', '=', 'coupons.id')
->select('coupons.code', 'coupons.discount_type', 'coupons.discount_value',
DB::raw('COUNT(*) as usage_count'),
DB::raw('SUM(coupon_usages.discount_amount) as total_discount'))
->groupBy('coupons.id', 'coupons.code', 'coupons.discount_type', 'coupons.discount_value')
->orderBy('usage_count', 'desc')
->limit(10)
->get();
return [
'total_usages' => $totalUsages,
'total_discount_given' => $totalDiscount,
'unique_users' => $uniqueUsers,
'conversion_rate' => $conversionRate,
'top_performing_coupons' => $topCoupons->toArray(),
];
}
/**
* Get trial analytics
*/
public function getTrialAnalytics(array $filters = []): array
{
$query = TrialExtension::query();
if (isset($filters['date_from'])) {
$query->whereDate('granted_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('granted_at', '<=', $filters['date_to']);
}
$totalExtensions = $query->count();
$totalDaysExtended = $query->sum('extension_days');
$uniqueUsers = $query->distinct('user_id')->count('user_id');
$extensionTypes = $query->select('extension_type', DB::raw('COUNT(*) as count'))
->groupBy('extension_type')
->pluck('count', 'extension_type')
->toArray();
$commonReasons = $query->select('reason', DB::raw('COUNT(*) as count'))
->whereNotNull('reason')
->groupBy('reason')
->orderBy('count', 'desc')
->limit(5)
->pluck('count', 'reason')
->toArray();
return [
'total_extensions' => $totalExtensions,
'total_days_extended' => $totalDaysExtended,
'unique_users' => $uniqueUsers,
'extension_types' => $extensionTypes,
'common_reasons' => $commonReasons,
'avg_extension_days' => $totalExtensions > 0 ? $totalDaysExtended / $totalExtensions : 0,
];
}
}

View File

@@ -0,0 +1,362 @@
<?php
namespace App\Services\Payments;
use App\Contracts\Payments\PaymentProviderContract;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ProviderRegistry
{
protected array $providers = [];
protected array $configurations = [];
public function __construct()
{
$this->loadConfigurations();
$this->registerDefaultProviders();
}
/**
* Register a payment provider
*/
public function register(string $name, PaymentProviderContract $provider): void
{
$this->providers[$name] = $provider;
Log::info('Payment provider registered', [
'provider' => $name,
'class' => get_class($provider),
]);
}
/**
* Get a specific provider
*/
public function get(string $name): ?PaymentProviderContract
{
return $this->providers[$name] ?? null;
}
/**
* Get all registered providers
*/
public function getAllProviders(): Collection
{
return collect($this->providers);
}
/**
* Get only active providers
*/
public function getActiveProviders(): Collection
{
return collect($this->providers)
->filter(fn ($provider) => $provider->isActive());
}
/**
* Check if provider exists
*/
public function has(string $name): bool
{
return isset($this->providers[$name]);
}
/**
* Unregister a provider
*/
public function unregister(string $name): bool
{
if (isset($this->providers[$name])) {
unset($this->providers[$name]);
Log::info('Payment provider unregistered', ['provider' => $name]);
return true;
}
return false;
}
/**
* Get provider configuration
*/
public function getConfiguration(string $providerName): array
{
return $this->configurations[$providerName] ?? [];
}
/**
* Update provider configuration
*/
public function updateConfiguration(string $providerName, array $config): void
{
$this->configurations[$providerName] = $config;
Cache::put("payment_config_{$providerName}", $config);
Log::info('Payment provider configuration updated', [
'provider' => $providerName,
'config_keys' => array_keys($config),
]);
}
/**
* Get providers that support recurring payments
*/
public function getRecurringProviders(): Collection
{
return $this->getActiveProviders()
->filter(fn ($provider) => $provider->supportsRecurring());
}
/**
* Get providers that support one-time payments
*/
public function getOneTimeProviders(): Collection
{
return $this->getActiveProviders()
->filter(fn ($provider) => $provider->supportsOneTime());
}
/**
* Get providers that support a specific currency
*/
public function getProvidersForCurrency(string $currency): Collection
{
return $this->getActiveProviders()
->filter(function ($provider) use ($currency) {
return in_array($currency, $provider->getSupportedCurrencies());
});
}
/**
* Get provider by webhook URL pattern
*/
public function getProviderByWebhookUrl(string $url): ?PaymentProviderContract
{
return $this->getActiveProviders()
->first(function ($provider) use ($url) {
$config = $provider->getConfiguration();
$webhookUrl = $config['webhook_url'] ?? null;
return $webhookUrl && str_contains($url, parse_url($webhookUrl, PHP_URL_PATH));
});
}
/**
* Validate provider health status
*/
public function validateProviders(): array
{
$results = [];
foreach ($this->providers as $name => $provider) {
try {
$isActive = $provider->isActive();
$config = $provider->getConfiguration();
$results[$name] = [
'active' => $isActive,
'configured' => ! empty($config),
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'supported_currencies' => $provider->getSupportedCurrencies(),
'last_checked' => now()->toISOString(),
];
if (! $isActive) {
Log::warning('Payment provider is inactive', ['provider' => $name]);
}
} catch (\Exception $e) {
$results[$name] = [
'active' => false,
'error' => $e->getMessage(),
'last_checked' => now()->toISOString(),
];
Log::error('Payment provider health check failed', [
'provider' => $name,
'error' => $e->getMessage(),
]);
}
}
return $results;
}
/**
* Get provider statistics
*/
public function getProviderStats(): array
{
$stats = [
'total_providers' => count($this->providers),
'active_providers' => 0,
'recurring_providers' => 0,
'one_time_providers' => 0,
'supported_currencies' => [],
'providers' => [],
];
foreach ($this->providers as $name => $provider) {
$isActive = $provider->isActive();
if ($isActive) {
$stats['active_providers']++;
}
if ($provider->supportsRecurring()) {
$stats['recurring_providers']++;
}
if ($provider->supportsOneTime()) {
$stats['one_time_providers']++;
}
$stats['supported_currencies'] = array_merge(
$stats['supported_currencies'],
$provider->getSupportedCurrencies()
);
$stats['providers'][$name] = [
'active' => $isActive,
'class' => get_class($provider),
'supports_recurring' => $provider->supportsRecurring(),
'supports_one_time' => $provider->supportsOneTime(),
'currencies' => $provider->getSupportedCurrencies(),
];
}
$stats['supported_currencies'] = array_unique($stats['supported_currencies']);
return $stats;
}
/**
* Load provider configurations from cache/database
*/
protected function loadConfigurations(): void
{
// Load from cache first
$cachedConfigs = Cache::get('payment_providers_config', []);
if (empty($cachedConfigs)) {
// Load from database or config files
$this->configurations = config('payment.providers', []);
// Cache for 1 hour
Cache::put('payment_providers_config', $this->configurations, 3600);
} else {
$this->configurations = $cachedConfigs;
}
}
/**
* Register default providers
*/
protected function registerDefaultProviders(): void
{
// Auto-register providers based on configuration
$enabledProviders = config('payment.enabled_providers', []);
foreach ($enabledProviders as $providerName) {
$this->registerProviderByName($providerName);
}
}
/**
* Register provider by name using configuration
*/
protected function registerProviderByName(string $providerName): void
{
$providerClass = config("payment.providers.{$providerName}.class");
if (! $providerClass || ! class_exists($providerClass)) {
Log::error('Payment provider class not found', [
'provider' => $providerName,
'class' => $providerClass,
]);
return;
}
try {
$config = $this->getConfiguration($providerName);
$provider = new $providerClass($config);
if ($provider instanceof PaymentProviderContract) {
$this->register($providerName, $provider);
} else {
Log::error('Payment provider does not implement contract', [
'provider' => $providerName,
'class' => $providerClass,
]);
}
} catch (\Exception $e) {
Log::error('Failed to register payment provider', [
'provider' => $providerName,
'error' => $e->getMessage(),
]);
}
}
/**
* Refresh provider (useful for configuration changes)
*/
public function refreshProvider(string $name): bool
{
if (! isset($this->providers[$name])) {
return false;
}
// Unregister current instance
unset($this->providers[$name]);
// Re-register with fresh configuration
$this->registerProviderByName($name);
return isset($this->providers[$name]);
}
/**
* Enable/disable a provider
*/
public function toggleProvider(string $name, bool $enabled): bool
{
$config = $this->getConfiguration($name);
if (empty($config)) {
return false;
}
$config['enabled'] = $enabled;
$this->updateConfiguration($name, $config);
// Refresh the provider to apply changes
return $this->refreshProvider($name);
}
/**
* Get provider for fallback
*/
public function getFallbackProvider(): ?PaymentProviderContract
{
$fallbackProvider = config('payment.fallback_provider');
if ($fallbackProvider && $this->has($fallbackProvider)) {
$provider = $this->get($fallbackProvider);
if ($provider && $provider->isActive()) {
return $provider;
}
}
// Return first active provider as fallback
return $this->getActiveProviders()->first();
}
}

View File

@@ -0,0 +1,474 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\ActivationKey;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ActivationKeyProvider implements PaymentProviderContract
{
protected array $config;
public function __construct(array $config = [])
{
$this->config = array_merge([
'key_prefix' => 'AK-',
'key_length' => 32,
'expiration_days' => null, // null means no expiration
'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'),
], $config);
}
public function getName(): string
{
return 'activation_key';
}
public function isActive(): bool
{
return true; // Activation keys are always available
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
DB::beginTransaction();
// Generate a unique activation key
$activationKey = $this->generateUniqueActivationKey();
// Create activation key record
$keyRecord = ActivationKey::create([
'user_id' => $user->id,
'activation_key' => $activationKey,
'price_id' => $plan->id,
'is_activated' => false,
]);
// Create subscription record
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'type' => 'activation_key',
'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid(more_entropy: true), // Use activation key ID + unique ID for compatibility
'stripe_status' => 'pending_activation',
'provider' => $this->getName(),
'provider_subscription_id' => $keyRecord->id,
'status' => 'pending_activation',
'starts_at' => null,
'ends_at' => null,
'provider_data' => [
'activation_key' => $activationKey,
'key_id' => $keyRecord->id,
'created_at' => now()->toISOString(),
],
]);
DB::commit();
return [
'provider_subscription_id' => $keyRecord->id,
'status' => 'pending_activation',
'activation_key' => $activationKey,
'plan_name' => $plan->name,
'plan_price' => $plan->price,
'type' => 'activation_key',
'message' => 'Activation key generated. User needs to redeem the key to activate the subscription.',
];
} catch (\Exception $e) {
DB::rollBack();
Log::error('Activation key 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 {
// For activation keys, we don't actually cancel since it's a one-time activation
// We can deactivate the subscription if needed
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
} catch (\Exception $e) {
Log::error('Activation key subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
// Activation keys don't support plan updates
// User would need a new activation key for a different plan
throw new \Exception('Activation keys do not support plan updates');
} catch (\Exception $e) {
Log::error('Activation key subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
// Activation keys can't be paused
return false;
}
public function resumeSubscription(Subscription $subscription): bool
{
// Activation keys can't be paused, so can't be resumed
return false;
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$activationKey = ActivationKey::findOrFail($providerSubscriptionId);
return [
'id' => $activationKey->id,
'activation_key' => $activationKey->activation_key,
'user_id' => $activationKey->user_id,
'price_id' => $activationKey->price_id,
'is_activated' => $activationKey->is_activated,
'created_at' => $activationKey->created_at->toISOString(),
'updated_at' => $activationKey->updated_at->toISOString(),
];
} catch (\Exception $e) {
Log::error('Activation key 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
{
return [
'portal_url' => route('dashboard'),
'message' => 'Activation keys are managed through your dashboard',
];
}
public function processWebhook(Request $request): array
{
// Activation keys don't have webhooks
return [
'event_type' => 'not_applicable',
'processed' => false,
'data' => [],
];
}
public function validateWebhook(Request $request): bool
{
// No webhooks to validate
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 {
$activationKey = ActivationKey::findOrFail($paymentMethodId);
return [
'id' => $activationKey->id,
'type' => 'activation_key',
'activation_key' => $activationKey->activation_key,
'is_activated' => $activationKey->is_activated,
'created_at' => $activationKey->created_at->toISOString(),
];
} catch (\Exception $e) {
Log::error('Activation key payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
{
// Activation keys are not refundable
throw new \Exception('Activation keys are not refundable');
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$query = ActivationKey::where('user_id', $user->id);
// Apply filters
if (isset($filters['status'])) {
if ($filters['status'] === 'activated') {
$query->where('is_activated', true);
} elseif ($filters['status'] === 'unactivated') {
$query->where('is_activated', false);
}
}
if (isset($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
$activationKeys = $query->orderBy('created_at', 'desc')->get();
$transactions = [];
foreach ($activationKeys as $key) {
$transactions[] = [
'id' => $key->id,
'activation_key' => $key->activation_key,
'plan_id' => $key->price_id,
'is_activated' => $key->is_activated,
'created_at' => $key->created_at->toISOString(),
'updated_at' => $key->updated_at->toISOString(),
];
}
return $transactions;
} catch (\Exception $e) {
Log::error('Activation key transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Activation keys have no fees
return [
'fixed_fee' => 0,
'percentage_fee' => 0,
'total_fee' => 0,
'net_amount' => $amount,
];
}
public function getSupportedCurrencies(): array
{
return ['USD']; // Activation keys are currency-agnostic
}
public function supportsRecurring(): bool
{
return false; // Activation keys are one-time
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
protected function generateUniqueActivationKey(): string
{
do {
$key = $this->config['key_prefix'].strtoupper(Str::random($this->config['key_length']));
} while (ActivationKey::where('activation_key', $key)->exists());
return $key;
}
// Public method for redeeming activation keys
public function redeemActivationKey(string $activationKey, User $user): array
{
try {
DB::beginTransaction();
$keyRecord = ActivationKey::where('activation_key', $activationKey)
->where('is_activated', false)
->firstOrFail();
// Mark key as activated and assign to user
$keyRecord->update([
'user_id' => $user->id,
'is_activated' => true,
]);
// Find or create subscription
$plan = Plan::findOrFail($keyRecord->price_id);
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'type' => 'activation_key',
'stripe_id' => 'ak_'.$keyRecord->id.'_'.uniqid(),
'stripe_status' => 'active',
'provider' => $this->getName(),
'provider_subscription_id' => $keyRecord->id,
'status' => 'active',
'starts_at' => now(),
'ends_at' => null, // No expiration for activation keys
'provider_data' => [
'activation_key' => $activationKey,
'key_id' => $keyRecord->id,
'redeemed_at' => now()->toISOString(),
],
]);
DB::commit();
return [
'success' => true,
'subscription_id' => $subscription->id,
'plan_name' => $plan->name,
'message' => 'Activation key redeemed successfully',
];
} catch (\Exception $e) {
DB::rollBack();
Log::error('Activation key redemption failed', [
'activation_key' => $activationKey,
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
$keyDetails = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return $keyDetails + [
'redeemed_at' => $subscription->provider_data['redeemed_at'] ?? null,
];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
try {
$subscription->update([
'provider_data' => array_merge($subscription->provider_data ?? [], $metadata),
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to update activation key subscription metadata', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
// Activation keys don't support trials
return false;
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
// Activation keys don't support coupons
throw new \Exception('Coupons not supported for activation keys');
}
public function removeCoupon(Subscription $subscription): bool
{
return false; // No coupons to remove
}
public function getUpcomingInvoice(Subscription $subscription): array
{
// Activation keys don't have invoices
return [
'amount_due' => 0,
'currency' => 'USD',
'next_payment_date' => null,
];
}
public function retryFailedPayment(Subscription $subscription): array
{
// No payments to retry for activation keys
return $this->syncSubscriptionStatus($subscription);
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return ! $details['is_activated']; // Can only modify before activation
} catch (\Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'non_refundable',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'activation_key',
'provider_subscription_id' => $subscription->provider_subscription_id,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('Import to activation keys not implemented');
}
}

View File

@@ -0,0 +1,639 @@
<?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\Cache;
use Illuminate\Support\Facades\Log;
class CryptoProvider implements PaymentProviderContract
{
protected array $config;
protected array $supportedCryptos = [
'BTC' => [
'name' => 'Bitcoin',
'network' => 'mainnet',
'confirmations_required' => 3,
'block_time_minutes' => 10,
],
'ETH' => [
'name' => 'Ethereum',
'network' => 'mainnet',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'USDT' => [
'name' => 'Tether',
'network' => 'ethereum',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'USDC' => [
'name' => 'USD Coin',
'network' => 'ethereum',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'LTC' => [
'name' => 'Litecoin',
'network' => 'mainnet',
'confirmations_required' => 6,
'block_time_minutes' => 2.5,
],
];
public function __construct(array $config = [])
{
$defaultConfig = [
'webhook_secret' => null,
'success_url' => null,
'cancel_url' => null,
'confirmation_timeout_minutes' => 30,
'exchange_rate_provider' => 'coingecko', // or 'binance'
];
// Try to get config values if Laravel is available
try {
if (function_exists('config')) {
$defaultConfig['webhook_secret'] = config('payments.crypto.webhook_secret');
}
if (function_exists('route')) {
$defaultConfig['success_url'] = route('payment.success');
$defaultConfig['cancel_url'] = route('payment.cancel');
}
} catch (\Exception $e) {
// Laravel not available, keep defaults
}
$this->config = array_merge($defaultConfig, $config);
}
public function getName(): string
{
return 'crypto';
}
public function isActive(): bool
{
return ! empty($this->config['webhook_secret']);
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
$crypto = $options['crypto'] ?? 'BTC';
$usdAmount = $plan->price;
// Get current exchange rate
$cryptoAmount = $this->convertUsdToCrypto($usdAmount, $crypto);
// Generate payment address
$paymentAddress = $this->generatePaymentAddress($crypto);
// Create payment record
$paymentId = $this->createPaymentRecord([
'user_id' => $user->id,
'plan_id' => $plan->id,
'crypto' => $crypto,
'usd_amount' => $usdAmount,
'crypto_amount' => $cryptoAmount,
'address' => $paymentAddress,
'status' => 'pending',
'expires_at' => now()->addMinutes($this->config['confirmation_timeout_minutes']),
]);
return [
'provider_subscription_id' => $paymentId,
'status' => 'pending_payment',
'payment_address' => $paymentAddress,
'crypto' => $crypto,
'crypto_amount' => $cryptoAmount,
'usd_amount' => $usdAmount,
'expires_at' => now()->addMinutes($this->config['confirmation_timeout_minutes'])->toISOString(),
'type' => 'crypto_payment',
];
} catch (\Exception $e) {
Log::error('Crypto 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 {
// For crypto, we just mark as cancelled since there's no external subscription
$paymentId = $subscription->provider_subscription_id;
$this->updatePaymentRecord($paymentId, [
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
} catch (\Exception $e) {
Log::error('Crypto subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
// Cancel old payment and create new one for upgraded plan
$this->cancelSubscription($subscription, 'Plan upgrade');
$user = $subscription->user;
return $this->createSubscription($user, $newPlan);
} catch (\Exception $e) {
Log::error('Crypto subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
// Crypto subscriptions don't support pausing in the traditional sense
// We could implement a temporary suspension logic here if needed
return false;
}
public function resumeSubscription(Subscription $subscription): bool
{
// Crypto subscriptions don't support pausing
return false;
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$payment = $this->getPaymentRecord($providerSubscriptionId);
return [
'id' => $payment['id'],
'status' => $payment['status'],
'crypto' => $payment['crypto'],
'usd_amount' => $payment['usd_amount'],
'crypto_amount' => $payment['crypto_amount'],
'address' => $payment['address'],
'confirmations' => $payment['confirmations'],
'created_at' => $payment['created_at'],
'expires_at' => $payment['expires_at'],
'confirmed_at' => $payment['confirmed_at'],
];
} catch (\Exception $e) {
Log::error('Crypto 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
{
// Crypto doesn't have customer portals
return [
'portal_url' => route('dashboard'),
'message' => 'Crypto payments are managed through the dashboard',
];
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$webhookData = json_decode($payload, true);
if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid webhook signature');
}
$eventType = $webhookData['type'] ?? 'unknown';
$result = [
'event_type' => $eventType,
'processed' => false,
'data' => [],
];
switch ($eventType) {
case 'payment_received':
$result = $this->handlePaymentReceived($webhookData);
break;
case 'payment_confirmed':
$result = $this->handlePaymentConfirmed($webhookData);
break;
case 'payment_expired':
$result = $this->handlePaymentExpired($webhookData);
break;
default:
Log::info('Unhandled crypto webhook event', ['event_type' => $eventType]);
}
return $result;
} catch (\Exception $e) {
Log::error('Crypto 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('Crypto 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 {
$payment = $this->getPaymentRecord($paymentMethodId);
return [
'id' => $payment['id'],
'type' => 'crypto_address',
'crypto' => $payment['crypto'],
'address' => $payment['address'],
'network' => $this->supportedCryptos[$payment['crypto']]['network'] ?? 'unknown',
'created_at' => $payment['created_at'],
];
} catch (\Exception $e) {
Log::error('Crypto payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
{
try {
// Crypto payments are typically not refundable
// We could implement a manual refund process if needed
throw new \Exception('Crypto payments are not refundable');
} catch (\Exception $e) {
Log::error('Crypto refund processing failed', [
'payment_id' => $paymentId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$payments = $this->getUserPayments($user->id, $filters);
$transactions = [];
foreach ($payments as $payment) {
$transactions[] = [
'id' => $payment['id'],
'crypto' => $payment['crypto'],
'amount' => $payment['usd_amount'],
'crypto_amount' => $payment['crypto_amount'],
'status' => $payment['status'],
'address' => $payment['address'],
'confirmations' => $payment['confirmations'],
'created_at' => $payment['created_at'],
'confirmed_at' => $payment['confirmed_at'],
];
}
return $transactions;
} catch (\Exception $e) {
Log::error('Crypto transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Crypto fees: 1% network fee + 0.5% service fee
$networkFee = $amount * 0.01;
$serviceFee = $amount * 0.005;
$totalFee = $networkFee + $serviceFee;
return [
'fixed_fee' => 0,
'percentage_fee' => $totalFee,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function getSupportedCurrencies(): array
{
return ['USD']; // We accept USD but process in crypto
}
public function supportsRecurring(): bool
{
return true; // Through manual renewal
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
public function convertUsdToCrypto(float $usdAmount, string $crypto): float
{
$cacheKey = "crypto_rate_{$crypto}_usd";
return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($usdAmount, $crypto) {
$rate = $this->getExchangeRate($crypto, 'USD');
return $usdAmount / $rate;
});
}
protected function getExchangeRate(string $fromCrypto, string $toCurrency): float
{
// This would integrate with CoinGecko, Binance, or other exchange rate APIs
// For now, return mock rates
$mockRates = [
'BTC' => 45000.00, // 1 BTC = $45,000
'ETH' => 3000.00, // 1 ETH = $3,000
'USDT' => 1.00, // 1 USDT = $1.00
'USDC' => 1.00, // 1 USDC = $1.00
'LTC' => 150.00, // 1 LTC = $150
];
return $mockRates[$fromCrypto] ?? 1.0;
}
protected function generatePaymentAddress(string $crypto): string
{
// In a real implementation, this would integrate with a crypto payment processor
// For now, generate a mock address
$prefix = [
'BTC' => 'bc1q',
'ETH' => '0x',
'USDT' => '0x',
'USDC' => '0x',
'LTC' => 'ltc1',
];
$randomPart = bin2hex(random_bytes(32));
return ($prefix[$crypto] ?? '0x').substr($randomPart, 0, 40);
}
protected function createPaymentRecord(array $data): string
{
// In a real implementation, this would save to a database
// For now, generate a mock ID and cache it
$paymentId = 'crypto_'.uniqid(more_entropy: true);
Cache::put("crypto_payment_{$paymentId}", array_merge($data, [
'id' => $paymentId,
'created_at' => now()->toISOString(),
'confirmations' => 0,
]), now()->addHours(24));
return $paymentId;
}
protected function getPaymentRecord(string $paymentId): array
{
return Cache::get("crypto_payment_{$paymentId}", []);
}
protected function updatePaymentRecord(string $paymentId, array $updates): void
{
$payment = Cache::get("crypto_payment_{$paymentId}", []);
if ($payment) {
$updatedPayment = array_merge($payment, $updates);
Cache::put("crypto_payment_{$paymentId}", $updatedPayment, now()->addHours(24));
}
}
protected function getUserPayments(int $userId, array $filters = []): array
{
// In a real implementation, this would query the database
// For now, return empty array
return [];
}
protected function confirmPayment(string $paymentId, int $confirmations, string $transactionHash): void
{
$this->updatePaymentRecord($paymentId, [
'status' => 'confirmed',
'confirmations' => $confirmations,
'transaction_hash' => $transactionHash,
'confirmed_at' => now()->toISOString(),
]);
}
// Webhook handlers
protected function handlePaymentReceived(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
$confirmations = $webhookData['confirmations'] ?? 0;
$transactionHash = $webhookData['transaction_hash'] ?? '';
$this->confirmPayment($paymentId, $confirmations, $transactionHash);
return [
'event_type' => 'payment_received',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
'confirmations' => $confirmations,
'transaction_hash' => $transactionHash,
],
];
}
protected function handlePaymentConfirmed(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
// Mark as fully confirmed
$this->updatePaymentRecord($paymentId, [
'status' => 'completed',
]);
return [
'event_type' => 'payment_confirmed',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
],
];
}
protected function handlePaymentExpired(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
$this->updatePaymentRecord($paymentId, [
'status' => 'expired',
]);
return [
'event_type' => 'payment_expired',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
$payment = $this->getPaymentRecord($subscription->provider_subscription_id);
return $payment['metadata'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
$paymentId = $subscription->provider_subscription_id;
$payment = $this->getPaymentRecord($paymentId);
if ($payment) {
$this->updatePaymentRecord($paymentId, ['metadata' => $metadata]);
return true;
}
return false;
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
// Crypto subscriptions don't have trials in the traditional sense
return false;
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
// Crypto doesn't support coupons natively
throw new \Exception('Coupons not supported for crypto payments');
}
public function removeCoupon(Subscription $subscription): bool
{
return false; // No coupons to remove
}
public function getUpcomingInvoice(Subscription $subscription): array
{
// Crypto subscriptions don't have invoices
return [
'amount_due' => 0,
'currency' => 'USD',
'next_payment_date' => null,
];
}
public function retryFailedPayment(Subscription $subscription): array
{
// Crypto payments can't be retried automatically
// User would need to make a new payment
return $this->syncSubscriptionStatus($subscription);
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return in_array($details['status'], ['pending_payment', 'confirmed', 'completed']);
} catch (\Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'non_refundable',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'crypto',
'provider_subscription_id' => $subscription->provider_subscription_id,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('Import to crypto payments not implemented');
}
}

View File

@@ -0,0 +1,795 @@
<?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');
}
}

View File

@@ -0,0 +1,383 @@
<?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\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class OxapayProvider implements PaymentProviderContract
{
protected array $config;
protected string $baseUrl;
public function __construct(array $config = [])
{
$this->config = $config;
$this->baseUrl = $config['sandbox'] ?? false
? 'https://api-sandbox.oxapay.com/v1'
: 'https://api.oxapay.com/v1';
}
public function getName(): string
{
return 'oxapay';
}
public function isActive(): bool
{
return ! empty($this->config['merchant_api_key']);
}
public function supportsRecurring(): bool
{
return false; // OxaPay doesn't support recurring payments
}
public function supportsOneTime(): bool
{
return true;
}
public function getSupportedCurrencies(): array
{
return Cache::remember('oxapay_currencies', now()->addHour(), function () {
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
])->get("{$this->baseUrl}/info/currencies");
if ($response->successful()) {
$data = $response->json();
return $data['data'] ?? [];
}
} catch (\Exception $e) {
Log::error('Failed to fetch OxaPay currencies', [
'error' => $e->getMessage(),
]);
}
return ['BTC', 'ETH', 'USDT', 'USDC', 'LTC', 'BCH']; // Default common cryptos
});
}
public function calculateFees(float $amount): array
{
// OxaPay fees vary by currency and network
// Using average estimates - actual fees should be fetched from API
$percentageFee = 0.5; // 0.5% average
$fixedFee = 0.0; // No fixed fee for most cryptos
$totalFee = ($amount * $percentageFee / 100) + $fixedFee;
return [
'fixed_fee' => $fixedFee,
'percentage_fee' => $percentageFee,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function pauseSubscription(Subscription $subscription): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function resumeSubscription(Subscription $subscription): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function getSubscriptionDetails(string $subscriptionId): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
try {
$amount = $options['amount'] ?? $plan->price;
$currency = $options['currency'] ?? 'USD';
$toCurrency = $options['to_currency'] ?? 'USDT';
$payload = [
'amount' => $amount,
'currency' => $currency,
'to_currency' => $toCurrency,
'lifetime' => $options['lifetime'] ?? 60, // 60 minutes default
'fee_paid_by_payer' => $options['fee_paid_by_payer'] ?? 0,
'callback_url' => $this->config['webhook_url'] ?? route('webhooks.oxapay'),
'return_url' => $this->config['success_url'] ?? route('payment.success'),
'email' => $user->email,
'order_id' => $options['order_id'] ?? null,
'description' => $options['description'] ?? "Payment for {$plan->name}",
'sandbox' => $this->config['sandbox'] ?? false,
];
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/payment/invoice", $payload);
if (! $response->successful()) {
throw new \Exception('Failed to create OxaPay invoice: '.$response->body());
}
$data = $response->json();
return [
'success' => true,
'checkout_url' => $data['data']['payment_url'] ?? null,
'payment_id' => $data['data']['track_id'] ?? null,
'expires_at' => $data['data']['expired_at'] ?? null,
'amount' => $amount,
'currency' => $currency,
'provider' => 'oxapay',
];
} catch (\Exception $e) {
Log::error('OxaPay checkout session creation failed', [
'error' => $e->getMessage(),
'user_id' => $user->id,
'plan_id' => $plan->id,
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
public function createCustomerPortalSession(User $user): array
{
throw new \Exception('OxaPay does not provide customer portal functionality');
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$signature = $request->header('HMAC');
if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid webhook signature');
}
$data = $request->json()->all();
$status = $data['status'] ?? 'unknown';
$trackId = $data['track_id'] ?? null;
$type = $data['type'] ?? 'payment';
return [
'success' => true,
'event_type' => $status,
'provider_transaction_id' => $trackId,
'processed' => true,
'data' => $data,
'type' => $type,
];
} catch (\Exception $e) {
Log::error('OxaPay webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
return [
'success' => false,
'event_type' => 'error',
'provider_transaction_id' => null,
'processed' => false,
'error' => $e->getMessage(),
];
}
}
public function validateWebhook(Request $request): bool
{
try {
$payload = $request->getContent();
$signature = $request->header('HMAC');
$apiSecret = $this->config['merchant_api_key'];
if (empty($signature) || empty($apiSecret)) {
return false;
}
$expectedSignature = hash_hmac('sha512', $payload, $apiSecret);
return hash_equals($expectedSignature, $signature);
} catch (\Exception $e) {
Log::error('OxaPay webhook validation failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function getPaymentMethodDetails(string $paymentId): array
{
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
])->get("{$this->baseUrl}/payment/info", [
'track_id' => $paymentId,
]);
if ($response->successful()) {
$data = $response->json();
return [
'success' => true,
'details' => $data['data'] ?? [],
];
}
return [
'success' => false,
'error' => 'Payment not found',
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
public function processRefund(string $paymentIntentId, float $amount, string $reason = ''): array
{
// OxaPay doesn't support traditional refunds in crypto
// Would need manual payout process
return [
'success' => false,
'error' => 'OxaPay refunds must be processed manually via payouts',
];
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$response = Http::withHeaders([
'merchant_api_key' => $this->config['merchant_api_key'],
])->get("{$this->baseUrl}/payment/history", array_merge([
'email' => $user->email,
], $filters));
if ($response->successful()) {
$data = $response->json();
return [
'success' => true,
'transactions' => $data['data'] ?? [],
];
}
return [
'success' => false,
'error' => 'Failed to fetch transaction history',
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
public function getSubscriptionMetadata(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function removeCoupon(Subscription $subscription): bool
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function getUpcomingInvoice(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function retryFailedPayment(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function canModifySubscription(Subscription $subscription): bool
{
return false; // No recurring support
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'no_refunds_crypto',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('OxaPay does not support recurring subscriptions');
}
}

View File

@@ -0,0 +1,985 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PolarProvider implements PaymentProviderContract
{
protected array $config;
protected string $apiBaseUrl = 'https://api.polar.sh';
public function __construct(array $config = [])
{
$this->config = array_merge([
'api_key' => config('services.polar.api_key'),
'webhook_secret' => config('services.polar.webhook_secret'),
'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'),
'webhook_url' => route('webhook.payment', 'polar'),
], $config);
}
public function getName(): string
{
return 'polar';
}
public function isActive(): bool
{
return ! empty($this->config['api_key']);
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
// Get or create Polar customer
$customer = $this->getOrCreateCustomer($user);
// Get or create Polar product/price
$priceId = $this->getOrCreatePrice($plan);
// Create checkout session
$checkoutData = [
'customer_id' => $customer['id'],
'price_id' => $priceId,
'success_url' => $this->config['success_url'],
'cancel_url' => $this->config['cancel_url'],
'customer_email' => $user->email,
'customer_name' => $user->name,
'metadata' => [
'user_id' => $user->id,
'plan_id' => $plan->id,
'plan_name' => $plan->name,
],
];
// Add trial information if specified
if (isset($options['trial_days']) && $options['trial_days'] > 0) {
$checkoutData['trial_period_days'] = $options['trial_days'];
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/checkouts', $checkoutData);
if (! $response->successful()) {
throw new \Exception('Polar checkout creation failed: '.$response->body());
}
$checkout = $response->json();
// Create subscription record
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'type' => 'recurring',
'stripe_id' => $checkout['id'], // Using stripe_id field for Polar checkout ID
'stripe_status' => 'pending',
'provider' => $this->getName(),
'provider_subscription_id' => $checkout['id'],
'status' => 'pending_payment',
'starts_at' => null,
'ends_at' => null,
'provider_data' => [
'checkout_id' => $checkout['id'],
'checkout_url' => $checkout['url'],
'customer_id' => $customer['id'],
'price_id' => $priceId,
'created_at' => now()->toISOString(),
],
]);
return [
'provider_subscription_id' => $checkout['id'],
'status' => 'pending_payment',
'checkout_url' => $checkout['url'],
'customer_id' => $customer['id'],
'price_id' => $priceId,
'type' => 'polar_checkout',
'expires_at' => $checkout['expires_at'] ?? now()->addHours(24)->toISOString(),
];
} catch (\Exception $e) {
Log::error('Polar subscription creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
// Local cancellation only
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->delete($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId, [
'cancellation_reason' => $reason,
]);
if (! $response->successful()) {
throw new \Exception('Polar subscription cancellation failed: '.$response->body());
}
// Update local subscription
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
} catch (\Exception $e) {
Log::error('Polar subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
throw new \Exception('No Polar subscription found to update');
}
$newPriceId = $this->getOrCreatePrice($newPlan);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->patch($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId, [
'price_id' => $newPriceId,
'proration_behavior' => 'create_prorations',
]);
if (! $response->successful()) {
throw new \Exception('Polar subscription update failed: '.$response->body());
}
$updatedSubscription = $response->json();
// Update local subscription
$subscription->update([
'plan_id' => $newPlan->id,
'provider_data' => array_merge($subscription->provider_data ?? [], [
'updated_at' => now()->toISOString(),
'polar_subscription' => $updatedSubscription,
]),
]);
return [
'provider_subscription_id' => $updatedSubscription['id'],
'status' => $updatedSubscription['status'],
'price_id' => $newPriceId,
'updated_at' => $updatedSubscription['updated_at'],
];
} catch (\Exception $e) {
Log::error('Polar subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
return false;
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/pause');
if (! $response->successful()) {
throw new \Exception('Polar subscription pause failed: '.$response->body());
}
$subscription->update([
'status' => 'paused',
'paused_at' => now(),
]);
return true;
} catch (\Exception $e) {
Log::error('Polar subscription pause failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function resumeSubscription(Subscription $subscription): bool
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
return false;
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/resume');
if (! $response->successful()) {
throw new \Exception('Polar subscription resume failed: '.$response->body());
}
$subscription->update([
'status' => 'active',
'resumed_at' => now(),
]);
return true;
} catch (\Exception $e) {
Log::error('Polar subscription resume failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions/'.$providerSubscriptionId);
if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar subscription: '.$response->body());
}
$polarSubscription = $response->json();
return [
'id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
'customer_id' => $polarSubscription['customer_id'],
'price_id' => $polarSubscription['price_id'],
'current_period_start' => $polarSubscription['current_period_start'],
'current_period_end' => $polarSubscription['current_period_end'],
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
'trial_start' => $polarSubscription['trial_start'] ?? null,
'trial_end' => $polarSubscription['trial_end'] ?? null,
'created_at' => $polarSubscription['created_at'],
'updated_at' => $polarSubscription['updated_at'],
];
} catch (\Exception $e) {
Log::error('Polar subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
return $this->createSubscription($user, $plan, $options);
}
public function createCustomerPortalSession(User $user): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/customer-portal', [
'customer_id' => $customer['id'],
'return_url' => route('dashboard'),
]);
if (! $response->successful()) {
throw new \Exception('Polar customer portal creation failed: '.$response->body());
}
$portal = $response->json();
return [
'portal_url' => $portal['url'],
'customer_id' => $customer['id'],
];
} catch (\Exception $e) {
Log::error('Polar customer portal creation failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$signature = $request->header('Polar-Signature');
if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid Polar webhook signature');
}
$webhookData = json_decode($payload, true);
$eventType = $webhookData['type'] ?? 'unknown';
$result = [
'event_type' => $eventType,
'processed' => false,
'data' => [],
];
switch ($eventType) {
case 'checkout.created':
$result = $this->handleCheckoutCreated($webhookData);
break;
case 'subscription.created':
$result = $this->handleSubscriptionCreated($webhookData);
break;
case 'subscription.updated':
$result = $this->handleSubscriptionUpdated($webhookData);
break;
case 'subscription.cancelled':
$result = $this->handleSubscriptionCancelled($webhookData);
break;
case 'subscription.paused':
$result = $this->handleSubscriptionPaused($webhookData);
break;
case 'subscription.resumed':
$result = $this->handleSubscriptionResumed($webhookData);
break;
default:
Log::info('Unhandled Polar webhook event', ['event_type' => $eventType]);
}
return $result;
} catch (\Exception $e) {
Log::error('Polar webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$signature = $request->header('Polar-Signature');
$payload = $request->getContent();
if (! $signature || ! $this->config['webhook_secret']) {
return false;
}
$expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']);
return hash_equals($signature, $expectedSignature);
} catch (\Exception $e) {
Log::warning('Polar webhook validation failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
}
public function getPaymentMethodDetails(string $paymentMethodId): array
{
try {
// Polar doesn't have separate payment method IDs like Stripe
// Return subscription details instead
return $this->getSubscriptionDetails($paymentMethodId);
} catch (\Exception $e) {
Log::error('Polar payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
{
try {
// Polar handles refunds through their dashboard or API
// For now, we'll return a NotImplementedError
throw new \Exception('Polar refunds must be processed through Polar dashboard or API directly');
} catch (\Exception $e) {
Log::error('Polar refund processing failed', [
'payment_id' => $paymentId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$params = [
'customer_id' => $customer['id'],
'limit' => $filters['limit'] ?? 50,
];
if (isset($filters['start_date'])) {
$params['start_date'] = $filters['start_date'];
}
if (isset($filters['end_date'])) {
$params['end_date'] = $filters['end_date'];
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions', $params);
if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar transaction history: '.$response->body());
}
$polarSubscriptions = $response->json();
$transactions = [];
foreach ($polarSubscriptions['data'] ?? [] as $subscription) {
$transactions[] = [
'id' => $subscription['id'],
'status' => $subscription['status'],
'amount' => $subscription['amount'] ?? 0,
'currency' => $subscription['currency'] ?? 'USD',
'created_at' => $subscription['created_at'],
'current_period_start' => $subscription['current_period_start'],
'current_period_end' => $subscription['current_period_end'],
];
}
return $transactions;
} catch (\Exception $e) {
Log::error('Polar transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Polar fees vary by plan and region (typically 5-8%)
// Using 6% as default for calculation
$percentageFee = $amount * 0.06;
$totalFee = $percentageFee; // Polar typically doesn't have fixed fees
return [
'fixed_fee' => 0,
'percentage_fee' => $percentageFee,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function getSupportedCurrencies(): array
{
return ['USD']; // Polar supports USD, EUR, and other currencies, but USD is most common
}
public function supportsRecurring(): bool
{
return true;
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
protected function getOrCreateCustomer(User $user): array
{
// First, try to find existing customer by email
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/customers', [
'email' => $user->email,
]);
if ($response->successful() && ! empty($response->json()['data'])) {
return $response->json()['data'][0];
}
// Create new customer
$customerData = [
'email' => $user->email,
'name' => $user->name,
'metadata' => [
'user_id' => $user->id,
],
];
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/customers', $customerData);
if (! $response->successful()) {
throw new \Exception('Failed to create Polar customer: '.$response->body());
}
return $response->json();
}
protected function getOrCreatePrice(Plan $plan): string
{
// Look for existing price by plan metadata
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/products', [
'metadata[plan_id]' => $plan->id,
]);
if ($response->successful() && ! empty($response->json()['data'])) {
$product = $response->json()['data'][0];
// Get the price for this product
$priceResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/prices', [
'product_id' => $product['id'],
'recurring_interval' => 'month',
]);
if ($priceResponse->successful() && ! empty($priceResponse->json()['data'])) {
return $priceResponse->json()['data'][0]['id'];
}
}
// Create new product and price
$productData = [
'name' => $plan->name,
'description' => $plan->description ?? 'Subscription plan',
'type' => 'service',
'metadata' => [
'plan_id' => $plan->id,
'plan_name' => $plan->name,
],
];
$productResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/products', $productData);
if (! $productResponse->successful()) {
throw new \Exception('Failed to create Polar product: '.$productResponse->body());
}
$product = $productResponse->json();
// Create price for the product
$priceData = [
'product_id' => $product['id'],
'amount' => (int) ($plan->price * 100), // Convert to cents
'currency' => 'usd',
'recurring' => [
'interval' => 'month',
'interval_count' => 1,
],
];
$priceResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/prices', $priceData);
if (! $priceResponse->successful()) {
throw new \Exception('Failed to create Polar price: '.$priceResponse->body());
}
$price = $priceResponse->json();
return $price['id'];
}
protected function getPolarSubscriptionId(Subscription $subscription): ?string
{
$providerData = $subscription->provider_data ?? [];
return $providerData['polar_subscription']['id'] ?? null;
}
// Webhook handlers
protected function handleCheckoutCreated(array $webhookData): array
{
$checkout = $webhookData['data']['object'];
// Update local subscription with checkout ID
Subscription::where('stripe_id', $checkout['id'])->update([
'provider_data' => [
'checkout_id' => $checkout['id'],
'customer_id' => $checkout['customer_id'],
'polar_checkout' => $checkout,
],
]);
return [
'event_type' => 'checkout.created',
'processed' => true,
'data' => [
'checkout_id' => $checkout['id'],
'customer_id' => $checkout['customer_id'],
],
];
}
protected function handleSubscriptionCreated(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
// Find and update local subscription
$localSubscription = Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['checkout_id'])
->first();
if ($localSubscription) {
$localSubscription->update([
'stripe_id' => $polarSubscription['id'],
'provider_subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
'starts_at' => Carbon::parse($polarSubscription['current_period_start']),
'ends_at' => Carbon::parse($polarSubscription['current_period_end']),
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
'polar_subscription' => $polarSubscription,
'activated_at' => now()->toISOString(),
]),
]);
}
return [
'event_type' => 'subscription.created',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
],
];
}
protected function handleSubscriptionUpdated(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => $polarSubscription['status'],
'provider_data' => [
'polar_subscription' => $polarSubscription,
'updated_at' => now()->toISOString(),
],
]);
return [
'event_type' => 'subscription.updated',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
'status' => $polarSubscription['status'],
],
];
}
protected function handleSubscriptionCancelled(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => 'Polar webhook cancellation',
]);
return [
'event_type' => 'subscription.cancelled',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
],
];
}
protected function handleSubscriptionPaused(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => 'paused',
'paused_at' => now(),
]);
return [
'event_type' => 'subscription.paused',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
],
];
}
protected function handleSubscriptionResumed(array $webhookData): array
{
$polarSubscription = $webhookData['data']['object'];
Subscription::where('provider', 'polar')
->where('provider_subscription_id', $polarSubscription['id'])
->update([
'status' => 'active',
'resumed_at' => now(),
]);
return [
'event_type' => 'subscription.resumed',
'processed' => true,
'data' => [
'subscription_id' => $polarSubscription['id'],
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
return $subscription->provider_data['polar_subscription'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
try {
$subscription->update([
'provider_data' => array_merge($subscription->provider_data ?? [], [
'metadata' => $metadata,
]),
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to update Polar subscription metadata', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
// Polar handles trials through checkout creation
// This would require creating a new checkout with trial period
return false;
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
// Polar supports discount codes
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
throw new \Exception('No Polar subscription found');
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
'Content-Type' => 'application/json',
])->post($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/discount', [
'coupon_code' => $couponCode,
]);
if (! $response->successful()) {
throw new \Exception('Failed to apply Polar coupon: '.$response->body());
}
return $response->json();
} catch (\Exception $e) {
Log::error('Polar coupon application failed', [
'subscription_id' => $subscription->id,
'coupon_code' => $couponCode,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function removeCoupon(Subscription $subscription): bool
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
return false;
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->delete($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/discount');
return $response->successful();
} catch (\Exception $e) {
Log::error('Polar coupon removal failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function getUpcomingInvoice(Subscription $subscription): array
{
try {
$polarSubscriptionId = $this->getPolarSubscriptionId($subscription);
if (! $polarSubscriptionId) {
return [
'amount_due' => 0,
'currency' => 'USD',
'next_payment_date' => null,
];
}
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->config['api_key'],
])->get($this->apiBaseUrl.'/v1/subscriptions/'.$polarSubscriptionId.'/upcoming-invoice');
if (! $response->successful()) {
throw new \Exception('Failed to retrieve Polar upcoming invoice: '.$response->body());
}
$invoice = $response->json();
return [
'amount_due' => $invoice['amount_due'] / 100, // Convert from cents
'currency' => $invoice['currency'],
'next_payment_date' => $invoice['next_payment_date'],
];
} catch (\Exception $e) {
Log::error('Polar upcoming invoice retrieval failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function retryFailedPayment(Subscription $subscription): array
{
// Polar doesn't have explicit retry logic - payments are retried automatically
return $this->syncSubscriptionStatus($subscription);
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return in_array($details['status'], ['active', 'trialing']);
} catch (\Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'no_pro_rated_refunds',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'polar',
'provider_subscription_id' => $subscription->provider_subscription_id,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('Import to Polar payments not implemented');
}
}

View File

@@ -0,0 +1,838 @@
<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription as CashierSubscription;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Price;
use Stripe\Product;
use Stripe\Stripe;
class StripeProvider implements PaymentProviderContract
{
protected array $config;
protected ?string $apiKey;
public function __construct(array $config = [])
{
$this->config = array_merge([
'api_key' => config('services.stripe.secret'),
'webhook_secret' => config('services.stripe.webhook_secret'),
'currency' => config('cashier.currency', 'USD'),
'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'),
], $config);
$this->apiKey = $this->config['api_key'] ?? null;
if ($this->apiKey) {
Stripe::setApiKey($this->apiKey);
}
}
public function getName(): string
{
return 'stripe';
}
public function isActive(): bool
{
return ! empty($this->apiKey) && $this->apiKey !== 'sk_test_placeholder';
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
// Create or retrieve Stripe customer
$customer = $this->getOrCreateCustomer($user);
// Create or retrieve Stripe product and price
$priceId = $this->getOrCreatePrice($plan);
// Create subscription
$subscriptionBuilder = $user->newSubscription('default', $priceId);
if (! empty($options['trial_days'])) {
$subscriptionBuilder->trialDays($options['trial_days']);
}
if (! empty($options['coupon'])) {
$subscriptionBuilder->withCoupon($options['coupon']);
}
if (! empty($options['payment_method'])) {
$subscriptionBuilder->create($options['payment_method']);
} else {
// Create checkout session for payment method collection
$sessionId = $this->createCheckoutSession($user, $plan, $options);
return [
'requires_action' => true,
'checkout_session_id' => $sessionId,
'type' => 'checkout_session',
];
}
$stripeSubscription = $subscriptionBuilder->create();
return [
'provider_subscription_id' => $stripeSubscription->stripe_id,
'status' => $stripeSubscription->stripe_status,
'current_period_start' => $stripeSubscription->created_at,
'current_period_end' => $stripeSubscription->ends_at,
'trial_ends_at' => $stripeSubscription->trial_ends_at,
'customer_id' => $stripeSubscription->stripe_id,
];
} catch (Exception $e) {
Log::error('Stripe 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 {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Cancel immediately or at period end
$cashierSubscription->cancel();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
$newPriceId = $this->getOrCreatePrice($newPlan);
// Swap to new plan
$cashierSubscription->swap($newPriceId);
return [
'provider_subscription_id' => $cashierSubscription->stripe_id,
'status' => $cashierSubscription->stripe_status,
'new_price_id' => $newPriceId,
];
} catch (Exception $e) {
Log::error('Stripe subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Stripe doesn't have a native pause feature, so we cancel at period end
$cashierSubscription->cancel();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription pause failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function resumeSubscription(Subscription $subscription): bool
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Resume cancelled subscription
$cashierSubscription->resume();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription resume failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($providerSubscriptionId);
return [
'id' => $stripeSubscription->id,
'status' => $stripeSubscription->status,
'current_period_start' => $stripeSubscription->current_period_start,
'current_period_end' => $stripeSubscription->current_period_end,
'trial_start' => $stripeSubscription->trial_start,
'trial_end' => $stripeSubscription->trial_end,
'customer' => $stripeSubscription->customer,
'items' => $stripeSubscription->items->data,
'created' => $stripeSubscription->created,
'ended_at' => $stripeSubscription->ended_at,
'canceled_at' => $stripeSubscription->canceled_at,
];
} catch (Exception $e) {
Log::error('Stripe subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$priceId = $this->getOrCreatePrice($plan);
$sessionData = [
'customer' => $customer->id,
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $priceId,
'quantity' => 1,
]],
'mode' => $plan->monthly_billing ? 'subscription' : 'payment',
'success_url' => $options['success_url'] ?? $this->config['success_url'],
'cancel_url' => $options['cancel_url'] ?? $this->config['cancel_url'],
'metadata' => [
'user_id' => $user->id,
'plan_id' => $plan->id,
],
];
if ($plan->monthly_billing && ! empty($options['trial_days'])) {
$sessionData['subscription_data'] = [
'trial_period_days' => $options['trial_days'],
];
}
if (! empty($options['coupon'])) {
$sessionData['discounts'] = [['coupon' => $options['coupon']]];
}
$session = Session::create($sessionData);
return [
'checkout_session_id' => $session->id,
'checkout_url' => $session->url,
];
} catch (Exception $e) {
Log::error('Stripe checkout session creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCustomerPortalSession(User $user): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$session = \Stripe\BillingPortal\Session::create([
'customer' => $customer->id,
'return_url' => route('dashboard'),
]);
return [
'portal_session_id' => $session->id,
'portal_url' => $session->url,
];
} catch (Exception $e) {
Log::error('Stripe customer portal session creation failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$event = \Stripe\Event::constructFrom(
json_decode($payload, true)
);
$result = [
'event_type' => $event->type,
'processed' => false,
'data' => [],
];
switch ($event->type) {
case 'invoice.payment_succeeded':
$result = $this->handleInvoicePaymentSucceeded($event);
break;
case 'invoice.payment_failed':
$result = $this->handleInvoicePaymentFailed($event);
break;
case 'customer.subscription.created':
$result = $this->handleSubscriptionCreated($event);
break;
case 'customer.subscription.updated':
$result = $this->handleSubscriptionUpdated($event);
break;
case 'customer.subscription.deleted':
$result = $this->handleSubscriptionDeleted($event);
break;
default:
Log::info('Unhandled Stripe webhook event', ['event_type' => $event->type]);
}
return $result;
} catch (Exception $e) {
Log::error('Stripe webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
if (! $sigHeader) {
return false;
}
\Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
$this->config['webhook_secret']
);
return true;
} catch (Exception $e) {
Log::warning('Stripe 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 {
$paymentMethod = \Stripe\PaymentMethod::retrieve($paymentMethodId);
return [
'id' => $paymentMethod->id,
'type' => $paymentMethod->type,
'card' => [
'brand' => $paymentMethod->card->brand,
'last4' => $paymentMethod->card->last4,
'exp_month' => $paymentMethod->card->exp_month,
'exp_year' => $paymentMethod->card->exp_year,
],
'created' => $paymentMethod->created,
];
} catch (Exception $e) {
Log::error('Stripe payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentIntentId, float $amount, string $reason = ''): array
{
try {
$refund = \Stripe\Refund::create([
'payment_intent' => $paymentIntentId,
'amount' => (int) ($amount * 100), // Convert to cents
'reason' => $reason ?: 'requested_by_customer',
]);
return [
'refund_id' => $refund->id,
'amount' => $refund->amount / 100,
'status' => $refund->status,
'created' => $refund->created,
];
} catch (Exception $e) {
Log::error('Stripe refund processing failed', [
'payment_intent_id' => $paymentIntentId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$charges = \Stripe\Charge::all([
'customer' => $customer->id,
'limit' => $filters['limit'] ?? 100,
'starting_after' => $filters['starting_after'] ?? null,
]);
$transactions = [];
foreach ($charges->data as $charge) {
$transactions[] = [
'id' => $charge->id,
'amount' => $charge->amount / 100,
'currency' => $charge->currency,
'status' => $charge->status,
'created' => $charge->created,
'description' => $charge->description,
'payment_method' => $charge->payment_method,
];
}
return $transactions;
} catch (Exception $e) {
Log::error('Stripe transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Stripe fees: 2.9% + $0.30 (US), varies by country
$fixedFee = 0.30; // $0.30
$percentageFee = 2.9; // 2.9%
$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',
];
}
public function supportsRecurring(): bool
{
return true;
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
protected function getOrCreateCustomer(User $user): Customer
{
if ($user->stripe_id) {
return Customer::retrieve($user->stripe_id);
}
$customer = Customer::create([
'email' => $user->email,
'name' => $user->name,
'metadata' => [
'user_id' => $user->id,
],
]);
$user->update(['stripe_id' => $customer->id]);
return $customer;
}
protected function getOrCreatePrice(Plan $plan): string
{
// Check if plan already has a Stripe price ID
if (! empty($plan->details['stripe_price_id'])) {
return $plan->details['stripe_price_id'];
}
// Create product if it doesn't exist
$productId = $this->getOrCreateProduct($plan);
// Create price
$priceData = [
'product' => $productId,
'unit_amount' => intval($plan->price * 100), // Convert to cents
'currency' => strtolower($this->config['currency']),
];
if ($plan->monthly_billing) {
$priceData['recurring'] = [
'interval' => 'month',
'interval_count' => 1,
];
}
$price = Price::create($priceData);
// Update plan with new price ID
$planDetails = $plan->details ?? [];
$planDetails['stripe_price_id'] = $price->id;
$plan->update(['details' => $planDetails]);
return $price->id;
}
protected function getOrCreateProduct(Plan $plan): string
{
// Check if plan already has a Stripe product ID
if (! empty($plan->details['stripe_product_id'])) {
return $plan->details['stripe_product_id'];
}
// Create product
$product = Product::create([
'name' => $plan->name,
'description' => $plan->description,
'metadata' => [
'plan_id' => $plan->id,
],
]);
// Update plan with new product ID
$planDetails = $plan->details ?? [];
$planDetails['stripe_product_id'] = $product->id;
$plan->update(['details' => $planDetails]);
return $product->id;
}
// Webhook handlers
protected function handleInvoicePaymentSucceeded($event): array
{
$invoice = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'amount_paid' => $invoice->amount_paid / 100,
'status' => 'paid',
],
];
}
protected function handleInvoicePaymentFailed($event): array
{
$invoice = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'attempt_count' => $invoice->attempt_count,
'status' => 'payment_failed',
],
];
}
protected function handleSubscriptionCreated($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'customer_id' => $subscription->customer,
'status' => $subscription->status,
],
];
}
protected function handleSubscriptionUpdated($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'status' => $subscription->status,
'current_period_start' => $subscription->current_period_start,
'current_period_end' => $subscription->current_period_end,
],
];
}
protected function handleSubscriptionDeleted($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'status' => 'canceled',
'ended_at' => $subscription->ended_at,
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
return $subscription->provider_data['metadata'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->metadata = $metadata;
$stripeSubscription->save();
return true;
} catch (Exception $e) {
Log::error('Failed to update subscription metadata', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->trial_end = now()->addDays($trialDays)->timestamp;
$stripeSubscription->save();
return true;
} catch (Exception $e) {
Log::error('Failed to start trial', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->discount = null; // Remove existing discount
$stripeSubscription->save();
// Apply new coupon
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$coupon = \Stripe\Coupon::retrieve($couponCode);
return [
'coupon_id' => $coupon->id,
'amount_off' => $coupon->amount_off ?? null,
'percent_off' => $coupon->percent_off ?? null,
];
} catch (Exception $e) {
Log::error('Failed to apply coupon', [
'subscription_id' => $subscription->id,
'coupon_code' => $couponCode,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function removeCoupon(Subscription $subscription): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->deleteDiscount();
return true;
} catch (Exception $e) {
Log::error('Failed to remove coupon', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function getUpcomingInvoice(Subscription $subscription): array
{
try {
$invoice = \Stripe\Invoice::upcoming(['subscription' => $subscription->provider_subscription_id]);
return [
'amount_due' => $invoice->amount_due / 100,
'currency' => $invoice->currency,
'period_start' => $invoice->period_start,
'period_end' => $invoice->period_end,
'lines' => $invoice->lines->data,
];
} catch (Exception $e) {
Log::error('Failed to get upcoming invoice', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function retryFailedPayment(Subscription $subscription): array
{
try {
$invoice = \Stripe\Invoice::retrieve(['subscription' => $subscription->provider_subscription_id]);
$invoice->pay();
return [
'invoice_id' => $invoice->id,
'status' => $invoice->status,
'amount_paid' => $invoice->amount_paid / 100,
];
} catch (Exception $e) {
Log::error('Failed to retry payment', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
return in_array($stripeSubscription->status, ['active', 'trialing']);
} catch (Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'pro_rata',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => true,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'stripe',
'provider_subscription_id' => $subscription->provider_subscription_id,
'stripe_id' => $subscription->stripe_id,
'status' => $subscription->stripe_status,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
// This would be used for migrating subscriptions to Stripe
// Implementation depends on specific requirements
throw new Exception('Import to Stripe not implemented');
}
}

View File

@@ -0,0 +1,325 @@
<?php
namespace App\Services;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\SubscriptionChange;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SubscriptionMigrationService
{
public function __construct(
private PaymentOrchestrator $orchestrator
) {}
/**
* Migrate subscription to a new plan
*/
public function migrateToPlan(Subscription $subscription, Plan $newPlan, string $reason = ''): bool
{
try {
DB::beginTransaction();
$oldPlan = $subscription->plan;
$oldValues = [
'plan_id' => $subscription->plan_id,
'plan_name' => $oldPlan?->name,
'price' => $oldPlan?->price,
];
// Update subscription with new plan
$subscription->update([
'plan_id' => $newPlan->id,
'updated_at' => now(),
]);
// Record the change
SubscriptionChange::createRecord(
$subscription,
'plan_change',
"Migrated from {$oldPlan?->name} to {$newPlan->name}",
$oldValues,
[
'plan_id' => $newPlan->id,
'plan_name' => $newPlan->name,
'price' => $newPlan->price,
],
$reason
);
DB::commit();
Log::info('Subscription plan migration completed', [
'subscription_id' => $subscription->id,
'old_plan_id' => $oldPlan?->id,
'new_plan_id' => $newPlan->id,
'reason' => $reason,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Subscription plan migration failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Migrate subscription to a new provider
*/
public function migrateToProvider(Subscription $subscription, string $newProvider, array $providerData = []): bool
{
try {
DB::beginTransaction();
$oldProvider = $subscription->provider;
$oldValues = [
'provider' => $oldProvider,
'provider_subscription_id' => $subscription->provider_subscription_id,
];
// Cancel subscription with old provider if needed
if ($oldProvider && $subscription->isActive()) {
$this->orchestrator->cancelSubscription($subscription, 'Provider migration');
}
// Update subscription with new provider
$subscription->update([
'provider' => $newProvider,
'provider_subscription_id' => $providerData['subscription_id'] ?? null,
'provider_data' => array_merge($subscription->provider_data ?? [], $providerData),
'last_provider_sync' => now(),
]);
// Record the change
SubscriptionChange::createRecord(
$subscription,
'provider_change',
"Migrated from {$oldProvider} to {$newProvider}",
$oldValues,
[
'provider' => $newProvider,
'provider_subscription_id' => $providerData['subscription_id'] ?? null,
],
'Provider migration for better service'
);
DB::commit();
Log::info('Subscription provider migration completed', [
'subscription_id' => $subscription->id,
'old_provider' => $oldProvider,
'new_provider' => $newProvider,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Subscription provider migration failed', [
'subscription_id' => $subscription->id,
'new_provider' => $newProvider,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Pause subscription
*/
public function pauseSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
DB::beginTransaction();
$oldValues = [
'status' => $subscription->status,
'paused_at' => null,
];
// Update subscription status
$subscription->update([
'status' => 'paused',
'paused_at' => now(),
]);
// Record the change
SubscriptionChange::createRecord(
$subscription,
'pause',
'Subscription paused',
$oldValues,
[
'status' => 'paused',
'paused_at' => now()->format('Y-m-d H:i:s'),
],
$reason
);
DB::commit();
Log::info('Subscription paused', [
'subscription_id' => $subscription->id,
'reason' => $reason,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to pause subscription', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Resume subscription
*/
public function resumeSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
DB::beginTransaction();
$oldValues = [
'status' => $subscription->status,
'paused_at' => $subscription->paused_at,
'resumed_at' => null,
];
// Update subscription status
$subscription->update([
'status' => 'active',
'resumed_at' => now(),
]);
// Record the change
SubscriptionChange::createRecord(
$subscription,
'resume',
'Subscription resumed',
$oldValues,
[
'status' => 'active',
'resumed_at' => now()->format('Y-m-d H:i:s'),
],
$reason
);
DB::commit();
Log::info('Subscription resumed', [
'subscription_id' => $subscription->id,
'reason' => $reason,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to resume subscription', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Bulk migrate subscriptions
*/
public function bulkMigrate(array $subscriptionIds, callable $migrationCallback): array
{
$results = [
'success' => 0,
'failed' => 0,
'errors' => [],
];
foreach ($subscriptionIds as $subscriptionId) {
try {
$subscription = Subscription::findOrFail($subscriptionId);
if ($migrationCallback($subscription)) {
$results['success']++;
} else {
$results['failed']++;
$results['errors'][] = "Migration failed for subscription {$subscriptionId}";
}
} catch (\Exception $e) {
$results['failed']++;
$results['errors'][] = "Error processing subscription {$subscriptionId}: {$e->getMessage()}";
}
}
return $results;
}
/**
* Get migration history for a subscription
*/
public function getMigrationHistory(Subscription $subscription): \Illuminate\Database\Eloquent\Collection
{
return $subscription->subscriptionChanges()
->whereIn('change_type', ['plan_change', 'provider_change', 'migration'])
->orderBy('effective_at', 'desc')
->get();
}
/**
* Get pending migrations
*/
public function getPendingMigrations(): \Illuminate\Database\Eloquent\Collection
{
return SubscriptionChange::query()
->whereIn('change_type', ['plan_change', 'provider_change', 'migration'])
->pending()
->with(['subscription', 'user'])
->orderBy('effective_at', 'asc')
->get();
}
/**
* Process pending migrations
*/
public function processPendingMigrations(): int
{
$pending = $this->getPendingMigrations();
$processedCount = 0;
foreach ($pending as $change) {
try {
// Here you would implement the actual migration logic
// based on the change type and data
$change->markAsProcessed();
$processedCount++;
} catch (\Exception $e) {
Log::error('Failed to process pending migration', [
'change_id' => $change->id,
'error' => $e->getMessage(),
]);
}
}
return $processedCount;
}
}