feat: implement comprehensive multi-provider payment processing system

- Add unified payment provider architecture with contract-based design
  - Implement 6 payment providers: Stripe, Lemon Squeezy, Polar, Oxapay, Crypto, Activation Keys
  - Create subscription management with lifecycle handling (create, cancel, pause, resume, update)
  - Add coupon system with usage tracking and trial extensions
  - Build Filament admin resources for payment providers, subscriptions, coupons, and trials
  - Implement payment orchestration service with provider registry and configuration management
  - Add comprehensive payment logging and webhook handling for all providers
  - Create customer analytics dashboard with revenue, churn, and lifetime value metrics
  - Add subscription migration service for provider switching
  - Include extensive test coverage for all payment functionality
This commit is contained in:
idevakk
2025-11-19 09:37:00 -08:00
parent 0560016f33
commit 27ac13948c
83 changed files with 15613 additions and 103 deletions

255
app/Models/Coupon.php Normal file
View File

@@ -0,0 +1,255 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Coupon extends Model
{
protected $fillable = [
'code',
'name',
'description',
'type',
'value',
'minimum_amount',
'max_uses',
'uses_count',
'max_uses_per_user',
'starts_at',
'expires_at',
'is_active',
'metadata',
];
protected $casts = [
'minimum_amount' => 'decimal:2',
'value' => 'decimal:2',
'starts_at' => 'datetime',
'expires_at' => 'datetime',
'is_active' => 'boolean',
'metadata' => 'array',
];
protected $dates = [
'starts_at',
'expires_at',
];
/**
* Relationships
*/
public function usages()
{
return $this->hasMany(CouponUsage::class);
}
/**
* Check if coupon is currently valid
*/
public function isValid(?User $user = null): bool
{
// Check if coupon is active
if (! $this->is_active) {
return false;
}
// Check start date
if ($this->starts_at && $this->starts_at->isFuture()) {
return false;
}
// Check expiration date
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
// Check usage limits
if ($this->max_uses && $this->uses_count >= $this->max_uses) {
return false;
}
// Check per-user usage limits
if ($user && $this->max_uses_per_user) {
$userUsageCount = $this->usages()->where('user_id', $user->id)->count();
if ($userUsageCount >= $this->max_uses_per_user) {
return false;
}
}
return true;
}
/**
* Calculate discount amount for a given subtotal
*/
public function calculateDiscount(float $subtotal): float
{
// Check minimum amount requirement
if ($this->minimum_amount && $subtotal < $this->minimum_amount) {
return 0;
}
if ($this->type === 'percentage') {
return $subtotal * ($this->value / 100);
}
return min($this->value, $subtotal);
}
/**
* Apply coupon to a subscription
*/
public function applyToSubscription(Subscription $subscription, float $amount, string $currency = 'USD'): CouponUsage
{
$discountAmount = $this->calculateDiscount($amount);
$usage = $this->usages()->create([
'user_id' => $subscription->user_id,
'subscription_id' => $subscription->id,
'discount_amount' => $discountAmount,
'currency' => $currency,
'used_at' => now(),
]);
// Increment usage count
$this->increment('uses_count');
return $usage;
}
/**
* Get formatted discount value
*/
public function getFormattedDiscountAttribute(): string
{
if ($this->type === 'percentage') {
return $this->value.'%';
}
return '$'.number_format($this->value, 2);
}
/**
* Get remaining uses
*/
public function getRemainingUsesAttribute(): ?int
{
if (! $this->max_uses) {
return null;
}
return max(0, $this->max_uses - $this->uses_count);
}
/**
* Get remaining uses for specific user
*/
public function getRemainingUsesForUser(User $user): ?int
{
if (! $this->max_uses_per_user) {
return null;
}
$userUsageCount = $this->usages()->where('user_id', $user->id)->count();
return max(0, $this->max_uses_per_user - $userUsageCount);
}
/**
* Check if coupon is expiring soon (within 7 days)
*/
public function isExpiringSoon(): bool
{
return $this->expires_at &&
$this->expires_at->copy()->subDays(7)->isPast() &&
$this->expires_at->isFuture();
}
/**
* Scope: Active coupons
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* Scope: Valid for use right now
*/
public function scopeValid(Builder $query, ?User $user = null): Builder
{
$query->where('is_active', true)
->where(function ($q) {
$q->whereNull('starts_at')
->orWhere('starts_at', '<=', now());
})
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
// Check global usage limits
$query->where(function ($q) {
$q->whereNull('max_uses')
->orWhereRaw('uses_count < max_uses');
});
return $query;
}
/**
* Scope: By type
*/
public function scopeByType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
/**
* Scope: Expiring soon
*/
public function scopeExpiringSoon(Builder $query, int $days = 7): Builder
{
return $query->where('expires_at', '<=', now()->addDays($days))
->where('expires_at', '>', now());
}
/**
* Scope: Search by code or name
*/
public function scopeSearch(Builder $query, string $term): Builder
{
return $query->where(function ($q) use ($term) {
$q->where('code', 'like', "%{$term}%")
->orWhere('name', 'like', "%{$term}%");
});
}
/**
* Find coupon by code
*/
public static function findByCode(string $code): ?self
{
return static::where('code', strtoupper($code))->first();
}
/**
* Boot: Automatically uppercase coupon codes
*/
protected static function boot()
{
parent::boot();
static::creating(function ($coupon) {
$coupon->code = strtoupper($coupon->code);
});
static::updating(function ($coupon) {
if ($coupon->isDirty('code')) {
$coupon->code = strtoupper($coupon->code);
}
});
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CouponUsage extends Model
{
protected $fillable = [
'coupon_id',
'user_id',
'subscription_id',
'discount_amount',
'currency',
'used_at',
'metadata',
];
protected $casts = [
'discount_amount' => 'decimal:2',
'used_at' => 'datetime',
'metadata' => 'array',
];
protected $dates = [
'used_at',
];
/**
* Relationships
*/
public function coupon()
{
return $this->belongsTo(Coupon::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function subscription()
{
return $this->belongsTo(Subscription::class);
}
/**
* Get formatted discount amount
*/
public function getFormattedDiscountAttribute(): string
{
return $this->currency.' '.number_format($this->discount_amount, 2);
}
/**
* Scope: By user
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: By coupon
*/
public function scopeByCoupon($query, $couponId)
{
return $query->where('coupon_id', $couponId);
}
/**
* Scope: Within date range
*/
public function scopeBetweenDates($query, $startDate, $endDate)
{
return $query->whereBetween('used_at', [$startDate, $endDate]);
}
}

304
app/Models/PaymentEvent.php Normal file
View File

@@ -0,0 +1,304 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class PaymentEvent extends Model
{
protected $fillable = [
'event_type',
'level',
'data',
'user_type',
'user_id',
'request_id',
'ip_address',
'user_agent',
'expires_at',
'provider',
'webhook_event_type',
'payload',
'success',
'stored_at',
];
protected $casts = [
'data' => 'array',
'expires_at' => 'datetime',
];
/**
* Scope to get only security events
*/
public function scopeSecurity(Builder $query): Builder
{
return $query->where('event_type', 'like', 'security_%');
}
/**
* Scope to get only compliance events
*/
public function scopeCompliance(Builder $query): Builder
{
return $query->where('event_type', 'like', 'compliance_%');
}
/**
* Scope to get only webhook events
*/
public function scopeWebhooks(Builder $query): Builder
{
return $query->where('event_type', 'like', 'webhook_%');
}
/**
* Scope to get only error events
*/
public function scopeErrors(Builder $query): Builder
{
return $query->where('level', 'error');
}
/**
* Scope to get events for a specific provider
*/
public function scopeForProvider(Builder $query, string $provider): Builder
{
return $query->whereJsonContains('data->provider', $provider);
}
/**
* Scope to get events for a specific subscription
*/
public function scopeForSubscription(Builder $query, int $subscriptionId): Builder
{
return $query->whereJsonContains('data->subscription_id', $subscriptionId);
}
/**
* Scope to get events that require review
*/
public function scopeRequiresReview(Builder $query): Builder
{
return $query->whereJsonContains('data->requires_review', true);
}
/**
* Scope to get events that haven't expired
*/
public function scopeNotExpired(Builder $query): Builder
{
return $query->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/**
* Scope to get expired events (for cleanup)
*/
public function scopeExpired(Builder $query): Builder
{
return $query->where('expires_at', '<', now());
}
/**
* Get the user relationship
*/
public function user()
{
return $this->morphTo();
}
/**
* Check if event is security-related
*/
public function isSecurityEvent(): bool
{
return str_starts_with($this->event_type, 'security_');
}
/**
* Check if event is compliance-related
*/
public function isComplianceEvent(): bool
{
return str_starts_with($this->event_type, 'compliance_');
}
/**
* Check if event is webhook-related
*/
public function isWebhookEvent(): bool
{
return str_starts_with($this->event_type, 'webhook_');
}
/**
* Check if event requires review
*/
public function requiresReview(): bool
{
return ($this->data['requires_review'] ?? false) ||
$this->isSecurityEvent() ||
$this->level === 'error';
}
/**
* Get the provider from event data
*/
public function getProvider(): ?string
{
return $this->data['provider'] ?? null;
}
/**
* Get the subscription ID from event data
*/
public function getSubscriptionId(): ?int
{
return $this->data['subscription_id'] ?? null;
}
/**
* Get the action from event data
*/
public function getAction(): ?string
{
return $this->data['action'] ?? null;
}
/**
* Check if event contains sensitive data
*/
public function containsSensitiveData(): bool
{
$sensitiveKeys = ['payment_method', 'card_number', 'bank_account', 'ssn', 'full_credit_card'];
foreach ($sensitiveKeys as $key) {
if (isset($this->data[$key])) {
return true;
}
}
return false;
}
/**
* Get sanitized data for display (removes sensitive information)
*/
public function getSanitizedData(): array
{
$data = $this->data;
// Remove or mask sensitive fields
$sensitivePatterns = [
'/payment_method.*?number/i',
'/card_?number/i',
'/cvv/i',
'/cvc/i',
'/ssn/i',
'/bank_?account/i',
'/routing_?number/i',
];
foreach ($sensitivePatterns as $pattern) {
$data = array_map(function ($value) use ($pattern) {
if (is_string($value) && preg_match($pattern, $value)) {
return str_repeat('*', strlen($value) - 4).substr($value, -4);
}
return $value;
}, $data);
}
return $data;
}
/**
* Export event for compliance reporting
*/
public function toComplianceArray(): array
{
return [
'id' => $this->id,
'event_type' => $this->event_type,
'level' => $this->level,
'created_at' => $this->created_at->toISOString(),
'user_id' => $this->user_id,
'user_type' => $this->user_type,
'request_id' => $this->request_id,
'ip_address' => $this->ip_address,
'provider' => $this->getProvider(),
'subscription_id' => $this->getSubscriptionId(),
'action' => $this->getAction(),
'requires_review' => $this->requiresReview(),
'is_security_event' => $this->isSecurityEvent(),
'is_compliance_event' => $this->isComplianceEvent(),
'contains_sensitive_data' => $this->containsSensitiveData(),
];
}
/**
* Clean up old events based on retention policy
*/
public static function cleanup(): array
{
$results = [];
// Clean up expired webhook payloads
$webhookCleanup = static::webhooks()
->expired()
->delete();
$results['webhook_payloads'] = $webhookCleanup;
// Clean up old debug events
$debugCleanup = static::where('level', 'debug')
->where('created_at', '<', now()->subDays(90))
->delete();
$results['debug_events'] = $debugCleanup;
// Clean up old info events (keep for 1 year)
$infoCleanup = static::where('level', 'info')
->where('created_at', '<', now()->subYear())
->whereNot(function ($query) {
$query->security()
->compliance();
})
->delete();
$results['info_events'] = $infoCleanup;
return $results;
}
/**
* Get events by date range for reporting
*/
public static function getByDateRange(\DateTime $start, \DateTime $end, array $filters = []): Builder
{
$query = static::whereBetween('created_at', [$start, $end]);
if (! empty($filters['event_types'])) {
$query->whereIn('event_type', $filters['event_types']);
}
if (! empty($filters['levels'])) {
$query->whereIn('level', $filters['levels']);
}
if (! empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
if (! empty($filters['provider'])) {
$query->forProvider($filters['provider']);
}
return $query;
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentProvider extends Model
{
protected $fillable = [
'name',
'display_name',
'description',
'is_active',
'configuration',
'supports_recurring',
'supports_one_time',
'supported_currencies',
'webhook_url',
'webhook_secret',
'fee_structure',
'priority',
'is_fallback',
];
protected $casts = [
'is_active' => 'boolean',
'configuration' => 'array',
'supports_recurring' => 'boolean',
'supports_one_time' => 'boolean',
'supported_currencies' => 'array',
'fee_structure' => 'array',
'is_fallback' => 'boolean',
];
/**
* Scope to get only active providers
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope to get providers that support recurring payments
*/
public function scopeRecurring($query)
{
return $query->where('supports_recurring', true);
}
/**
* Scope to get providers that support one-time payments
*/
public function scopeOneTime($query)
{
return $query->where('supports_one_time', true);
}
/**
* Scope to get providers ordered by priority
*/
public function scopeByPriority($query)
{
return $query->orderBy('priority', 'desc');
}
/**
* Get the fallback provider
*/
public static function getFallback()
{
return static::where('is_fallback', true)->active()->first();
}
/**
* Check if provider supports a specific currency
*/
public function supportsCurrency(string $currency): bool
{
return in_array(strtoupper($currency), $this->supported_currencies);
}
/**
* Get fee for a specific amount
*/
public function calculateFee(float $amount): array
{
if (! $this->fee_structure) {
return [
'fixed_fee' => 0,
'percentage_fee' => 0,
'total_fee' => 0,
'net_amount' => $amount,
];
}
$fixedFee = $this->fee_structure['fixed_fee'] ?? 0;
$percentageFee = $this->fee_structure['percentage_fee'] ?? 0;
$percentageAmount = ($amount * $percentageFee) / 100;
$totalFee = $fixedFee + $percentageAmount;
return [
'fixed_fee' => $fixedFee,
'percentage_fee' => $percentageAmount,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
/**
* Get webhook endpoint URL
*/
public function getWebhookUrl(): string
{
return $this->webhook_url ?? route('webhook.payment', $this->name);
}
/**
* Update configuration
*/
public function updateConfiguration(array $config): void
{
$this->configuration = array_merge($this->configuration ?? [], $config);
$this->save();
}
/**
* Get specific configuration value
*/
public function getConfigValue(string $key, $default = null)
{
return data_get($this->configuration, $key, $default);
}
/**
* Set specific configuration value
*/
public function setConfigValue(string $key, $value): void
{
$config = $this->configuration ?? [];
data_set($config, $key, $value);
$this->configuration = $config;
$this->save();
}
/**
* Check if provider is properly configured
*/
public function isConfigured(): bool
{
$requiredFields = $this->getConfigValue('required_fields', []);
foreach ($requiredFields as $field) {
if (empty($this->getConfigValue($field))) {
return false;
}
}
return true;
}
/**
* Get provider statistics
*/
public function getStats(): array
{
$subscriptionCount = Subscription::where('provider', $this->name)->count();
$activeSubscriptionCount = Subscription::where('provider', $this->name)
->where('unified_status', 'active')
->count();
return [
'name' => $this->name,
'display_name' => $this->display_name,
'is_active' => $this->is_active,
'is_configured' => $this->isConfigured(),
'total_subscriptions' => $subscriptionCount,
'active_subscriptions' => $activeSubscriptionCount,
'supports_recurring' => $this->supports_recurring,
'supports_one_time' => $this->supports_one_time,
'supported_currencies' => $this->supported_currencies,
'is_fallback' => $this->is_fallback,
'priority' => $this->priority,
];
}
/**
* Activate provider
*/
public function activate(): void
{
$this->is_active = true;
$this->save();
}
/**
* Deactivate provider
*/
public function deactivate(): void
{
if ($this->is_fallback) {
throw new \Exception('Cannot deactivate fallback provider');
}
$this->is_active = false;
$this->save();
}
/**
* Set as fallback provider
*/
public function setAsFallback(): void
{
// Remove fallback status from other providers
static::where('is_fallback', true)->update(['is_fallback' => false]);
$this->is_fallback = true;
$this->save();
}
/**
* Remove fallback status
*/
public function removeFallback(): void
{
$this->is_fallback = false;
$this->save();
}
/**
* Get provider class name
*/
public function getProviderClass(): string
{
return $this->getConfigValue('class', '');
}
/**
* Test provider connection
*/
public function testConnection(): array
{
try {
$class = $this->getProviderClass();
if (! class_exists($class)) {
return [
'success' => false,
'error' => "Provider class {$class} not found",
];
}
$provider = new $class($this->configuration);
if (! $provider instanceof \App\Contracts\Payments\PaymentProviderContract) {
return [
'success' => false,
'error' => 'Provider class does not implement PaymentProviderContract',
];
}
$isActive = $provider->isActive();
return [
'success' => true,
'is_active' => $isActive,
'configuration_valid' => $this->isConfigured(),
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Get all active providers ordered by priority
*/
public static function getActiveOrdered()
{
return static::active()->byPriority()->get();
}
/**
* Get providers that support a specific plan type
*/
public static function getForPlanType(bool $recurring = false)
{
$query = static::active();
if ($recurring) {
$query->recurring();
} else {
$query->oneTime();
}
return $query->byPriority()->get();
}
}

475
app/Models/Subscription.php Normal file
View File

@@ -0,0 +1,475 @@
<?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',
];
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;
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SubscriptionChange extends Model
{
protected $fillable = [
'subscription_id',
'user_id',
'change_type',
'change_description',
'old_values',
'new_values',
'reason',
'effective_at',
'processed_at',
'is_processed',
'metadata',
];
protected $casts = [
'old_values' => 'array',
'new_values' => 'array',
'effective_at' => 'datetime',
'processed_at' => 'datetime',
'is_processed' => 'boolean',
'metadata' => 'array',
];
protected $dates = [
'effective_at',
'processed_at',
];
/**
* Relationships
*/
public function subscription()
{
return $this->belongsTo(Subscription::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Get human-readable change type
*/
public function getChangeTypeLabelAttribute(): string
{
return [
'plan_change' => 'Plan Change',
'cancellation' => 'Cancellation',
'pause' => 'Pause',
'resume' => 'Resume',
'migration' => 'Migration',
'provider_change' => 'Provider Change',
][$this->change_type] ?? ucfirst($this->change_type);
}
/**
* Mark as processed
*/
public function markAsProcessed(): void
{
$this->update([
'is_processed' => true,
'processed_at' => now(),
]);
}
/**
* Scope: By change type
*/
public function scopeByType($query, string $type)
{
return $query->where('change_type', $type);
}
/**
* Scope: Processed
*/
public function scopeProcessed($query)
{
return $query->where('is_processed', true);
}
/**
* Scope: Pending processing
*/
public function scopePending($query)
{
return $query->where('is_processed', false);
}
/**
* Scope: By user
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: Within date range
*/
public function scopeBetweenDates($query, $startDate, $endDate)
{
return $query->whereBetween('effective_at', [$startDate, $endDate]);
}
/**
* Create a subscription change record
*/
public static function createRecord(
Subscription $subscription,
string $changeType,
string $description,
?array $oldValues = null,
?array $newValues = null,
?string $reason = null
): self {
return static::create([
'subscription_id' => $subscription->id,
'user_id' => $subscription->user_id,
'change_type' => $changeType,
'change_description' => $description,
'old_values' => $oldValues,
'new_values' => $newValues,
'reason' => $reason,
'effective_at' => now(),
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TrialExtension extends Model
{
protected $fillable = [
'subscription_id',
'user_id',
'extension_days',
'reason',
'extension_type',
'original_trial_ends_at',
'new_trial_ends_at',
'granted_at',
'granted_by_admin_id',
'metadata',
];
protected $casts = [
'original_trial_ends_at' => 'datetime',
'new_trial_ends_at' => 'datetime',
'granted_at' => 'datetime',
'metadata' => 'array',
];
protected $dates = [
'original_trial_ends_at',
'new_trial_ends_at',
'granted_at',
];
/**
* Relationships
*/
public function subscription()
{
return $this->belongsTo(Subscription::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function grantedByAdmin()
{
return $this->belongsTo(User::class, 'granted_by_admin_id');
}
/**
* Get human-readable extension type
*/
public function getExtensionTypeLabelAttribute(): string
{
return [
'manual' => 'Manual Grant',
'automatic' => 'Automatic Extension',
'compensation' => 'Compensation',
][$this->extension_type] ?? ucfirst($this->extension_type);
}
/**
* Scope: By extension type
*/
public function scopeByType($query, string $type)
{
return $query->where('extension_type', $type);
}
/**
* Scope: By user
*/
public function scopeByUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope: Granted by admin
*/
public function scopeGrantedBy($query, $adminId)
{
return $query->where('granted_by_admin_id', $adminId);
}
/**
* Scope: Within date range
*/
public function scopeBetweenDates($query, $startDate, $endDate)
{
return $query->whereBetween('granted_at', [$startDate, $endDate]);
}
}

View File

@@ -149,4 +149,287 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
{
return $this->hasMany(ImpersonationLog::class, 'target_user_id');
}
/**
* Get all subscriptions for the user
*/
public function subscriptions()
{
return $this->hasMany(Subscription::class);
}
/**
* Get the current active subscription for the user
*/
public function currentSubscription()
{
return $this->hasOne(Subscription::class)
->where(function ($query) {
$query->where('status', 'active')
->orWhere('status', 'trialing');
})
->where(function ($query) {
$query->whereNull('ends_at')
->orWhere('ends_at', '>', now());
})
->latest();
}
/**
* Get the latest subscription (regardless of status)
*/
public function latestSubscription()
{
return $this->hasOne(Subscription::class)->latestOfMany();
}
/**
* Scope: Users with active subscriptions
*/
public function scopeWithActiveSubscription($query)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery->where(function ($q) {
$q->where('status', 'active')
->orWhere('status', 'trialing');
})->where(function ($q) {
$q->whereNull('ends_at')
->orWhere('ends_at', '>', now());
});
});
}
/**
* Scope: Users with trial subscriptions
*/
public function scopeWithTrialSubscription($query)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery->where('status', 'trialing')
->where('trial_ends_at', '>', now());
});
}
/**
* Scope: Users with cancelled subscriptions
*/
public function scopeWithCancelledSubscription($query)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery->where('status', 'cancelled')
->orWhere(function ($q) {
$q->where('ends_at', '<=', now());
});
});
}
/**
* Scope: Users without any active subscriptions
*/
public function scopeWithoutActiveSubscription($query)
{
return $query->whereDoesntHave('subscriptions', function ($subscriptionQuery) {
$subscriptionQuery->where(function ($q) {
$q->where('status', 'active')
->orWhere('status', 'trialing');
})->where(function ($q) {
$q->whereNull('ends_at')
->orWhere('ends_at', '>', now());
});
});
}
/**
* Scope: Users by subscription provider
*/
public function scopeBySubscriptionProvider($query, string $provider)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) use ($provider) {
$subscriptionQuery->where('provider', $provider)
->where(function ($q) {
$q->whereNull('ends_at')
->orWhere('ends_at', '>', now());
});
});
}
/**
* Scope: Users with subscriptions expiring soon (within given days)
*/
public function scopeWithSubscriptionExpiringSoon($query, int $days = 7)
{
return $query->whereHas('subscriptions', function ($subscriptionQuery) use ($days) {
$subscriptionQuery->where('status', 'active')
->whereNotNull('ends_at')
->where('ends_at', '<=', now()->addDays($days))
->where('ends_at', '>', now());
});
}
/**
* Check if user has an active subscription
*/
public function hasActiveSubscription(): bool
{
return $this->subscriptions()
->where(function ($query) {
$query->where('status', 'active')
->orWhere('status', 'trialing');
})
->where(function ($query) {
$query->whereNull('ends_at')
->orWhere('ends_at', '>', now());
})
->exists();
}
/**
* Check if user is currently on trial
*/
public function isOnTrial(): bool
{
return $this->subscriptions()
->where('status', 'trialing')
->where('trial_ends_at', '>', now())
->exists();
}
/**
* Check if user has cancelled subscription
*/
public function hasCancelledSubscription(): bool
{
return $this->subscriptions()
->where(function ($query) {
$query->where('status', 'cancelled')
->orWhere(function ($q) {
$q->whereNotNull('ends_at')
->where('ends_at', '<=', now());
});
})
->exists();
}
/**
* Check if user has ever had a subscription
*/
public function hasHadSubscription(): bool
{
return $this->subscriptions()->exists();
}
/**
* Get user's subscription status as string
*/
public function getSubscriptionStatus(): string
{
if ($this->isOnTrial()) {
return 'trialing';
}
if ($this->hasActiveSubscription()) {
return 'active';
}
if ($this->hasCancelledSubscription()) {
return 'cancelled';
}
return 'none';
}
/**
* Get user's current subscription plan
*/
public function getCurrentPlan(): ?Plan
{
return $this->currentSubscription?->plan;
}
/**
* Get user's subscription expiry date
*/
public function getSubscriptionExpiryDate(): ?\Carbon\Carbon
{
return $this->currentSubscription?->ends_at;
}
/**
* Get user's trial end date
*/
public function getTrialEndDate(): ?\Carbon\Carbon
{
$trialSubscription = $this->subscriptions()
->where('status', 'trialing')
->where('trial_ends_at', '>', now())
->first();
return $trialSubscription?->trial_ends_at;
}
/**
* Check if user's subscription is expiring soon (within given days)
*/
public function isSubscriptionExpiringSoon(int $days = 7): bool
{
$currentSubscription = $this->currentSubscription;
return $currentSubscription &&
$currentSubscription->ends_at &&
$currentSubscription->ends_at->lte(now()->addDays($days)) &&
$currentSubscription->ends_at->gt(now());
}
/**
* Get total amount spent by user across all subscriptions
*/
public function getTotalSpent(): float
{
return $this->subscriptions()
->with('plan')
->get()
->sum(function ($subscription) {
return $subscription->plan ? $subscription->plan->price : 0;
});
}
/**
* Get user's subscription provider
*/
public function getSubscriptionProvider(): ?string
{
return $this->currentSubscription?->provider;
}
/**
* Check if user can upgrade/downgrade their plan
*/
public function canChangePlan(): bool
{
return $this->hasActiveSubscription() &&
$this->currentSubscription?->isRecurring();
}
/**
* Get subscription metrics for analytics
*/
public function getSubscriptionMetrics(): array
{
$subscriptions = $this->subscriptions()->with('plan')->get();
return [
'total_subscriptions' => $subscriptions->count(),
'active_subscriptions' => $subscriptions->where(function ($sub) {
return in_array($sub->status, ['active', 'trialing']) &&
(!$sub->ends_at || $sub->ends_at->isFuture());
})->count(),
'total_spent' => $this->getTotalSpent(),
'current_plan' => $this->getCurrentPlan()?->name,
'provider' => $this->getSubscriptionProvider(),
'status' => $this->getSubscriptionStatus(),
'trial_ends_at' => $this->getTrialEndDate(),
'subscription_ends_at' => $this->getSubscriptionExpiryDate(),
'is_expiring_soon' => $this->isSubscriptionExpiringSoon(),
];
}
}