Files
zemailnator/app/Services/Payments/PaymentOrchestrator.php
idevakk 15e018eb88 feat(payment): implement comprehensive Polar subscription sync with proper date and cancellation handling
- Add Polar-specific date field mapping in PaymentOrchestrator (current_period_start, current_period_end, cancelled_at, trial_end)
  - Handle both cancellation scenarios: cancel_at_period_end=true and existing cancelled_at timestamp
  - Map customer_cancellation_reason and customer_cancellation_comment from Polar to database
  - Update billing page to show correct renewal vs expiry dates based on cancellation status
  - Restrict cancel button to activation_key provider only (Polar uses customer portal)
  - Fix button spacing between "Manage in Polar" and "Sync" buttons
  - Ensure both "Sync" and "Recheck Status" buttons use identical sync functionality
2025-12-06 10:42:25 -08:00

1082 lines
37 KiB
PHP

<?php
namespace App\Services\Payments;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Coupon;
use App\Models\CouponUsage;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\SubscriptionChange;
use App\Models\TrialExtension;
use App\Models\User;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
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
{
Log::info('PaymentOrchestrator: Getting provider for plan', [
'plan_id' => $plan->id,
'requested_provider' => $providerName,
]);
if ($providerName) {
$provider = $this->providerRegistry->get($providerName);
Log::info('PaymentOrchestrator: Checking specific provider', [
'provider_name' => $providerName,
'provider_exists' => $provider ? true : false,
'provider_active' => $provider?->isActive(),
'provider_supported' => $provider ? $this->isProviderSupportedForPlan($provider, $plan) : false,
]);
if ($provider && $provider->isActive() && $this->isProviderSupportedForPlan($provider, $plan)) {
Log::info('PaymentOrchestrator: Using requested provider', [
'provider' => $providerName,
]);
return $provider;
}
}
// Find the first active provider that supports this plan
foreach ($this->providerRegistry->getActiveProviders() as $provider) {
Log::info('PaymentOrchestrator: Checking fallback provider', [
'provider' => $provider->getName(),
'supported' => $this->isProviderSupportedForPlan($provider, $plan),
]);
if ($this->isProviderSupportedForPlan($provider, $plan)) {
Log::info('PaymentOrchestrator: Using fallback provider', [
'provider' => $provider->getName(),
]);
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
{
// Use the same approach as Plan::supportsProvider() - check database relationship
$isSupported = $plan->planProviders()
->where('provider', $provider->getName())
->where('is_enabled', true)
->exists();
if (! $isSupported) {
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
{
$updateData = [
'status' => $providerData['status'] ?? $subscription->status,
'provider_data' => array_merge($subscription->provider_data ?? [], $providerData),
'synced_at' => now(),
];
// Handle Polar-specific date mapping
if ($subscription->provider === 'polar') {
// Map Polar's date fields to our database columns
$updateData['starts_at'] = $this->parseDateTime($providerData['current_period_start'] ?? null);
// Check if subscription is scheduled for cancellation
$isScheduledForCancellation = $providerData['cancel_at_period_end'] ?? false;
// For ends_at and cancelled_at logic:
if (! empty($providerData['cancelled_at'])) {
// Already cancelled subscription - use Polar's actual cancellation data
$updateData['ends_at'] = $this->parseDateTime($providerData['ends_at'] ?? $providerData['current_period_end'] ?? null);
$updateData['cancelled_at'] = $this->parseDateTime($providerData['cancelled_at']);
$updateData['status'] = 'cancelled';
} elseif ($isScheduledForCancellation) {
// Scheduled for cancellation - treat as cancelled with expiry at period end
$updateData['ends_at'] = $this->parseDateTime($providerData['current_period_end'] ?? null);
$updateData['cancelled_at'] = now(); // Set cancellation time to now when detected
$updateData['status'] = 'cancelled';
$updateData['cancellation_reason'] = $updateData['cancellation_reason'] ?? 'Customer cancelled via Polar portal (cancel at period end)';
} else {
// Active subscription
$updateData['ends_at'] = $this->parseDateTime($providerData['current_period_end'] ?? null);
// Don't overwrite existing cancelled_at for active subscriptions
}
$updateData['trial_ends_at'] = $this->parseDateTime($providerData['trial_end'] ?? null);
// Map cancellation reason if available
if (! empty($providerData['customer_cancellation_reason'])) {
$updateData['cancellation_reason'] = $providerData['customer_cancellation_reason'];
// Also store the comment if available
if (! empty($providerData['customer_cancellation_comment'])) {
$updateData['cancellation_reason'] .= ' - Comment: '.$providerData['customer_cancellation_comment'];
}
}
} else {
// Generic date mapping for other providers
$updateData['ends_at'] = $this->parseDateTime($providerData['ends_at'] ?? $subscription->ends_at);
$updateData['trial_ends_at'] = $this->parseDateTime($providerData['trial_ends_at'] ?? $subscription->trial_ends_at);
}
// Only update fields that are actually provided (not null)
$updateData = array_filter($updateData, function ($value, $key) use ($providerData) {
// Keep null values from API if they're explicitly provided
if (array_key_exists($key, $providerData)) {
return true;
}
// Otherwise don't overwrite existing values with null
return $value !== null;
}, ARRAY_FILTER_USE_BOTH);
$subscription->update($updateData);
Log::info('Subscription updated from provider data', [
'subscription_id' => $subscription->id,
'provider' => $subscription->provider,
'update_data' => $updateData,
]);
}
/**
* Parse datetime from various formats
*/
protected function parseDateTime($dateTime): ?\Carbon\Carbon
{
if (empty($dateTime)) {
return null;
}
try {
return \Carbon\Carbon::parse($dateTime);
} catch (\Exception $e) {
Log::warning('Failed to parse datetime', [
'datetime' => $dateTime,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* 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,
];
}
/**
* Check subscription status via provider
*/
public function checkSubscriptionStatus(User $user, string $providerName, string $providerSubscriptionId): array
{
try {
$provider = $this->providerRegistry->get($providerName);
if (! $provider) {
return [
'success' => false,
'error' => "Provider {$providerName} not found",
];
}
if (! $provider->isActive()) {
return [
'success' => false,
'error' => "Provider {$providerName} is not active",
];
}
Log::info('PaymentOrchestrator: Checking subscription status', [
'user_id' => $user->id,
'provider' => $providerName,
'provider_subscription_id' => $providerSubscriptionId,
]);
// Get subscription details from provider
$details = $provider->getSubscriptionDetails($providerSubscriptionId);
if (empty($details)) {
return [
'success' => false,
'error' => 'Unable to fetch subscription details from provider',
];
}
return [
'success' => true,
'status' => $details['status'] ?? 'unknown',
'details' => $details,
];
} catch (Exception $e) {
Log::error('PaymentOrchestrator: Error checking subscription status', [
'user_id' => $user->id,
'provider' => $providerName,
'provider_subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
}