Files
zemailnator/app/Models/Subscription.php
idevakk 5f5da23a40 feat: migrate legacy subscription checks to unified payment system
- Replace Laravel Cashier methods with new subscription system
  - Remove session-based subscription checking in bulk components
  - Update Dashboard.php to use PaymentOrchestrator for provider-agnostic sync
  - Maintain backward compatibility with existing Stripe subscriptions
  - Improve performance by eliminating session overhead
  - Add automatic migration of legacy subscriptions to new system

BREAKING CHANGE: Subscription checking now uses unified payment system instead of Laravel Cashier methods
2025-11-20 11:05:51 -08:00

477 lines
12 KiB
PHP

<?php
namespace App\Models;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
class Subscription extends Model
{
protected $fillable = [
'user_id',
'type',
'stripe_id',
'stripe_status',
'stripe_price',
'quantity',
'trial_ends_at',
'ends_at',
'provider',
'provider_subscription_id',
'unified_status',
'cancelled_at',
'cancellation_reason',
'paused_at',
'resumed_at',
'migration_batch_id',
'is_migrated',
'legacy_data',
'synced_at',
'provider_data',
'last_provider_sync',
'plan_id',
'starts_at',
'status',
'updated_at',
'polar_subscription',
'metadata',
'migration_source',
'migration_date',
'migration_reason',
'created_at',
];
protected $casts = [
'is_migrated' => '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 {
$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;
}
}
/**
* 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;
}
}