- 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
477 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|