'boolean', 'legacy_data' => 'array', 'provider_data' => 'array', 'metadata' => 'array', 'trial_ends_at' => 'datetime', 'ends_at' => 'datetime', 'cancelled_at' => 'datetime', 'paused_at' => 'datetime', 'resumed_at' => 'datetime', 'synced_at' => 'datetime', 'last_provider_sync' => 'datetime', 'starts_at' => 'datetime', ]; /** * Accessor for total coupon discount */ protected function getTotalCouponDiscountAttribute(): float { // Use preloaded sum if available, otherwise calculate it if (array_key_exists('total_coupon_discount', $this->attributes)) { return (float) $this->attributes['total_coupon_discount']; } return $this->couponUsages()->sum('discount_amount'); } protected $dates = [ 'trial_ends_at', 'ends_at', 'cancelled_at', 'paused_at', 'resumed_at', 'synced_at', 'last_provider_sync', 'starts_at', ]; public function user() { return $this->belongsTo(User::class); } public function plan() { return $this->belongsTo(Plan::class); } public function trialExtensions() { return $this->hasMany(TrialExtension::class); } public function subscriptionChanges() { return $this->hasMany(SubscriptionChange::class); } public function couponUsages() { return $this->hasMany(CouponUsage::class); } /** * Check if subscription is active */ public function isActive(): bool { return in_array($this->status, ['active', 'trialing']) && (! $this->ends_at || $this->ends_at->isFuture()); } /** * Check if subscription is on trial */ public function isOnTrial(): bool { return $this->status === 'trialing' && $this->trial_ends_at && $this->trial_ends_at->isFuture(); } /** * Check if subscription is cancelled */ public function isCancelled(): bool { return $this->status === 'cancelled' || ($this->ends_at && $this->ends_at->isPast()); } /** * Check if subscription supports recurring payments */ public function isRecurring(): bool { return $this->plan && $this->plan->monthly_billing; } /** * Get the display name for the provider */ public function getProviderDisplayName(): string { $displayNames = [ 'stripe' => 'Stripe', 'lemon_squeezy' => 'Lemon Squeezy', 'polar' => 'Polar.sh', 'oxapay' => 'OxaPay', 'crypto' => 'Crypto', 'activation_key' => 'Activation Key', ]; return $displayNames[$this->provider] ?? ucfirst($this->provider); } /** * Get provider-specific data */ public function getProviderData(?string $key = null, $default = null) { if ($key) { return data_get($this->provider_data, $key, $default); } return $this->provider_data; } /** * Set provider-specific data */ public function setProviderData(string $key, $value): void { $data = $this->provider_data ?? []; data_set($data, $key, $value); $this->provider_data = $data; } /** * Sync subscription status with provider */ public function syncWithProvider(): bool { try { // For Polar provider, check if we need to fetch subscription ID first if ($this->provider === 'polar' && empty($this->provider_subscription_id) && ! empty($this->user->polar_cust_id)) { $this->fetchPolarSubscriptionId(); } $orchestrator = app(PaymentOrchestrator::class); $result = $orchestrator->syncSubscriptionStatus($this); $this->update([ 'status' => $result['status'] ?? $this->status, 'provider_data' => array_merge($this->provider_data ?? [], $result), 'last_provider_sync' => now(), ]); Log::info('Subscription synced with provider', [ 'subscription_id' => $this->id, 'provider' => $this->provider, 'status' => $result['status'] ?? 'unknown', ]); return true; } catch (\Exception $e) { Log::error('Failed to sync subscription with provider', [ 'subscription_id' => $this->id, 'provider' => $this->provider, 'error' => $e->getMessage(), ]); return false; } } /** * Fetch Polar subscription ID using customer ID if missing */ protected function fetchPolarSubscriptionId(): void { if ($this->provider !== 'polar' || empty($this->user->polar_cust_id)) { return; } try { $polarProvider = app(\App\Services\Payments\Providers\PolarProvider::class); // Get active subscriptions for this customer $response = $polarProvider->makeAuthenticatedRequest('GET', '/subscriptions', [ 'customer_id' => $this->user->polar_cust_id, 'status' => 'active', 'limit' => 10, ]); if ($response->successful()) { $data = $response->json(); $subscriptions = $data['items'] ?? []; if (! empty($subscriptions)) { // Find the subscription that matches our plan or take the most recent active one $matchingSubscription = null; foreach ($subscriptions as $sub) { // Check if this subscription matches our plan (via metadata or other criteria) if (isset($sub['metadata']['plan_id']) && $sub['metadata']['plan_id'] == $this->plan_id) { $matchingSubscription = $sub; break; } } // If no exact match, take the most recent active subscription if (! $matchingSubscription && ! empty($subscriptions)) { $matchingSubscription = $subscriptions[0]; } if ($matchingSubscription) { // Parse dates from Polar response $startsAt = null; $endsAt = null; $cancelledAt = null; // Handle current_period_start if (isset($matchingSubscription['current_period_start'])) { $startsAt = \Carbon\Carbon::parse($matchingSubscription['current_period_start']); } // Handle current_period_end (renewal date) if (isset($matchingSubscription['current_period_end'])) { $endsAt = \Carbon\Carbon::parse($matchingSubscription['current_period_end']); } // Handle ends_at (cancellation/expiry date) elseif (isset($matchingSubscription['ends_at'])) { $endsAt = \Carbon\Carbon::parse($matchingSubscription['ends_at']); } // Handle expires_at (expiry date) elseif (isset($matchingSubscription['expires_at'])) { $endsAt = \Carbon\Carbon::parse($matchingSubscription['expires_at']); } // Handle cancelled_at if (isset($matchingSubscription['cancelled_at'])) { $cancelledAt = \Carbon\Carbon::parse($matchingSubscription['cancelled_at']); } $this->update([ 'provider_subscription_id' => $matchingSubscription['id'], 'status' => $matchingSubscription['status'], 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'cancelled_at' => $cancelledAt, 'provider_data' => array_merge($this->provider_data ?? [], [ 'polar_subscription' => $matchingSubscription, 'subscription_id_fetched_at' => now()->toISOString(), 'polar_dates' => [ 'current_period_start' => $matchingSubscription['current_period_start'] ?? null, 'current_period_end' => $matchingSubscription['current_period_end'] ?? null, 'ends_at' => $matchingSubscription['ends_at'] ?? null, 'expires_at' => $matchingSubscription['expires_at'] ?? null, 'cancelled_at' => $matchingSubscription['cancelled_at'] ?? null, ], ]), ]); Log::info('Polar subscription ID fetched and updated', [ 'subscription_id' => $this->id, 'polar_subscription_id' => $matchingSubscription['id'], 'customer_id' => $this->user->polar_cust_id, 'starts_at' => $startsAt?->toISOString(), 'ends_at' => $endsAt?->toISOString(), 'cancelled_at' => $cancelledAt?->toISOString(), ]); } } } else { Log::warning('Failed to fetch Polar subscriptions for sync', [ 'customer_id' => $this->user->polar_cust_id, 'status_code' => $response->status(), 'response' => $response->json(), ]); } } catch (\Exception $e) { Log::error('Error fetching Polar subscription ID', [ 'subscription_id' => $this->id, 'customer_id' => $this->user->polar_cust_id ?? 'none', 'error' => $e->getMessage(), ]); } } /** * Cancel the subscription */ public function cancel(string $reason = ''): bool { try { $orchestrator = app(PaymentOrchestrator::class); $result = $orchestrator->cancelSubscription($this, $reason); if ($result) { $this->update([ 'status' => 'cancelled', 'cancelled_at' => now(), 'cancellation_reason' => $reason, ]); } return $result; } catch (\Exception $e) { Log::error('Failed to cancel subscription', [ 'subscription_id' => $this->id, 'provider' => $this->provider, 'error' => $e->getMessage(), ]); return false; } } /** * Update subscription plan */ public function updatePlan(Plan $newPlan): bool { try { $orchestrator = app(PaymentOrchestrator::class); $result = $orchestrator->updateSubscription($this, $newPlan); if ($result['success']) { $this->update([ 'plan_id' => $newPlan->id, 'updated_at' => now(), ]); } return $result['success']; } catch (\Exception $e) { Log::error('Failed to update subscription plan', [ 'subscription_id' => $this->id, 'provider' => $this->provider, 'error' => $e->getMessage(), ]); return false; } } /** * Get subscription metadata */ public function getMetadata(?string $key = null, $default = null) { if ($key) { return data_get($this->metadata, $key, $default); } return $this->metadata; } /** * Set subscription metadata */ public function setMetadata(string $key, $value): void { $data = $this->metadata ?? []; data_set($data, $key, $value); $this->metadata = $data; } /** * Scope: Active subscriptions */ public function scopeActive($query) { return $query->where('status', 'active') ->where(function ($q) { $q->whereNull('ends_at')->orWhere('ends_at', '>', now()); }); } /** * Scope: Cancelled subscriptions */ public function scopeCancelled($query) { return $query->where('status', 'cancelled') ->where(function ($q) { $q->whereNull('ends_at')->orWhere('ends_at', '<=', now()); }); } /** * Scope: On trial subscriptions */ public function scopeOnTrial($query) { return $query->where('status', 'trialing') ->where('trial_ends_at', '>', now()); } /** * Scope: By provider */ public function scopeByProvider($query, string $provider) { return $query->where('provider', $provider); } /** * Scope: By user */ public function scopeByUser($query, $userId) { return $query->where('user_id', $userId); } /** * Scope: With total coupon discount */ public function scopeWithTotalCouponDiscount($query) { return $query->withSum('couponUsages as total_coupon_discount', 'discount_amount'); } /** * Extend trial period */ public function extendTrial(int $days, string $reason = '', string $extensionType = 'manual', ?User $grantedBy = null): TrialExtension { $originalEnd = $this->trial_ends_at; $newEnd = $originalEnd ? $originalEnd->copy()->addDays($days) : now()->addDays($days); $extension = $this->trialExtensions()->create([ 'user_id' => $this->user_id, 'extension_days' => $days, 'reason' => $reason, 'extension_type' => $extensionType, 'original_trial_ends_at' => $originalEnd, 'new_trial_ends_at' => $newEnd, 'granted_at' => now(), 'granted_by_admin_id' => $grantedBy?->id, ]); // Update the subscription's trial end date $this->update(['trial_ends_at' => $newEnd]); // Record the change SubscriptionChange::createRecord( $this, 'pause', "Trial extended by {$days} days", ['trial_ends_at' => $originalEnd?->format('Y-m-d H:i:s')], ['trial_ends_at' => $newEnd->format('Y-m-d H:i:s')], $reason ); return $extension; } /** * Get total trial extensions granted */ public function getTotalTrialExtensionsDays(): int { return $this->trialExtensions()->sum('extension_days'); } /** * Get latest trial extension */ public function getLatestTrialExtension(): ?TrialExtension { return $this->trialExtensions()->latest()->first(); } /** * Check if trial was extended */ public function hasExtendedTrial(): bool { return $this->trialExtensions()->exists(); } /** * Apply coupon to subscription */ public function applyCoupon(Coupon $coupon, float $amount): CouponUsage { if (! $coupon->isValid($this->user)) { throw new \Exception('Coupon is not valid for this user'); } return $coupon->applyToSubscription($this, $amount); } /** * Get total discount from coupons */ public function getTotalCouponDiscount(): float { return $this->couponUsages()->sum('discount_amount'); } /** * Record subscription change */ public function recordChange( string $changeType, string $description, ?array $oldValues = null, ?array $newValues = null, ?string $reason = null ): SubscriptionChange { return SubscriptionChange::createRecord( $this, $changeType, $description, $oldValues, $newValues, $reason ); } /** * Get pending changes */ public function getPendingChanges(): \Illuminate\Database\Eloquent\Collection { return $this->subscriptionChanges()->pending()->get(); } /** * Process pending changes */ public function processPendingChanges(): int { $pending = $this->getPendingChanges(); $processedCount = 0; foreach ($pending as $change) { $change->markAsProcessed(); $processedCount++; } return $processedCount; } /** * Calculate Monthly Recurring Revenue (MRR) for this subscription */ public function calculateMRR(): float { // Only active and trialing subscriptions contribute to MRR if (! in_array($this->status, ['active', 'trialing'])) { return 0; } // Check if subscription has ended if ($this->ends_at && $this->ends_at->isPast()) { return 0; } // Get the plan's MRR calculation if ($this->plan) { return $this->plan->calculateMRR(); } // Fallback: try to calculate from legacy data or provider data $price = $this->getLegacyPrice(); if ($price > 0) { $cycleDays = $this->getLegacyBillingCycleDays(); return ($price / $cycleDays) * 30; } return 0; } /** * Get price from legacy data or provider data */ private function getLegacyPrice(): float { // Try to get price from plan first if ($this->plan && $this->plan->price) { return (float) $this->plan->price; } // Try provider data if ($this->provider_data && isset($this->provider_data['plan_details']['price'])) { return (float) $this->provider_data['plan_details']['price']; } // Try legacy stripe_price field if ($this->stripe_price) { // This would need additional logic to get price from Stripe // For now, return 0 as we can't easily get the amount return 0; } return 0; } /** * Get billing cycle days from legacy data */ private function getLegacyBillingCycleDays(): int { // Try to get from plan first if ($this->plan && $this->plan->billing_cycle_days) { return (int) $this->plan->billing_cycle_days; } // Try provider data if ($this->provider_data && isset($this->provider_data['plan_details']['billing_cycle_days'])) { return (int) $this->provider_data['plan_details']['billing_cycle_days']; } // Fallback to legacy monthly_billing return $this->plan && $this->plan->monthly_billing ? 30 : 365; } }