- 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
1082 lines
37 KiB
PHP
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(),
|
|
];
|
|
}
|
|
}
|
|
}
|