- Add support for subscription.uncanceled webhook event - Fix spelling mismatch for subscription.canceled (Polar) vs subscription.cancelled (code) - Implement proper cancel_at_period_end handling in subscription.canceled events - Add cancelled_at field updates for subscription.updated events - Handle Polar's spelling variants (canceled_at vs cancelled_at) consistently - Remove non-existent pause_reason column from subscription uncanceled handler - Enhance webhook logging with detailed field update tracking - Add comprehensive cancellation metadata storage in provider_data - Gracefully handle null provider_subscription_id in payment confirmation polling All Polar webhook events now properly sync subscription state including cancellation timing, reasons, and billing period details.
1095 lines
38 KiB
PHP
1095 lines
38 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",
|
|
];
|
|
}
|
|
|
|
if (empty($providerSubscriptionId)) {
|
|
Log::info('PaymentOrchestrator: Cannot check status - no provider subscription ID', [
|
|
'user_id' => $user->id,
|
|
'provider' => $providerName,
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Provider subscription ID not available - webhook may not have processed yet',
|
|
'retry_suggested' => true,
|
|
];
|
|
}
|
|
|
|
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(),
|
|
];
|
|
}
|
|
}
|
|
}
|