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:
255
app/Models/Coupon.php
Normal file
255
app/Models/Coupon.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
78
app/Models/CouponUsage.php
Normal file
78
app/Models/CouponUsage.php
Normal 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
304
app/Models/PaymentEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
301
app/Models/PaymentProvider.php
Normal file
301
app/Models/PaymentProvider.php
Normal 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
475
app/Models/Subscription.php
Normal 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;
|
||||
}
|
||||
}
|
||||
138
app/Models/SubscriptionChange.php
Normal file
138
app/Models/SubscriptionChange.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
96
app/Models/TrialExtension.php
Normal file
96
app/Models/TrialExtension.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user