webmoney/backend/app/Models/Subscription.php
marcoitaloesp-ai 0adb5c889f
feat(subscriptions): sistema de assinaturas SaaS v1.49.0
- Criar tabela plans com Free, Pro Monthly, Pro Annual
- Criar tabela subscriptions com status e integração PayPal
- Criar tabela invoices com numeração sequencial WM-YYYY-NNNNNN
- Models: Plan, Subscription, Invoice com helpers
- User: hasActiveSubscription(), onTrial(), currentPlan(), etc.
- API: GET /api/plans (público)
- Seeder: PlansSeeder com 3 planos base
- Fase 2 do roadmap SaaS concluída
2025-12-17 10:46:34 +00:00

322 lines
8.3 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Carbon\Carbon;
class Subscription extends Model
{
use HasFactory;
const STATUS_TRIALING = 'trialing';
const STATUS_ACTIVE = 'active';
const STATUS_PAST_DUE = 'past_due';
const STATUS_CANCELED = 'canceled';
const STATUS_EXPIRED = 'expired';
protected $fillable = [
'user_id',
'plan_id',
'status',
'trial_ends_at',
'current_period_start',
'current_period_end',
'canceled_at',
'ends_at',
'cancel_reason',
'paypal_subscription_id',
'paypal_status',
'paypal_data',
'price_paid',
'currency',
];
protected $casts = [
'trial_ends_at' => 'datetime',
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
'canceled_at' => 'datetime',
'ends_at' => 'datetime',
'paypal_data' => 'array',
'price_paid' => 'decimal:2',
];
// ==================== RELATIONSHIPS ====================
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
// ==================== SCOPES ====================
public function scopeActive($query)
{
return $query->whereIn('status', [self::STATUS_TRIALING, self::STATUS_ACTIVE]);
}
public function scopeTrialing($query)
{
return $query->where('status', self::STATUS_TRIALING);
}
public function scopeCanceled($query)
{
return $query->where('status', self::STATUS_CANCELED);
}
public function scopeExpired($query)
{
return $query->where('status', self::STATUS_EXPIRED);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
// ==================== STATUS CHECKS ====================
public function isActive(): bool
{
return in_array($this->status, [self::STATUS_TRIALING, self::STATUS_ACTIVE]);
}
public function isTrialing(): bool
{
return $this->status === self::STATUS_TRIALING;
}
public function isCanceled(): bool
{
return $this->status === self::STATUS_CANCELED;
}
public function isExpired(): bool
{
return $this->status === self::STATUS_EXPIRED;
}
public function isPastDue(): bool
{
return $this->status === self::STATUS_PAST_DUE;
}
public function onGracePeriod(): bool
{
return $this->canceled_at !== null
&& $this->ends_at !== null
&& $this->ends_at->isFuture();
}
public function hasTrialEnded(): bool
{
return $this->trial_ends_at !== null
&& $this->trial_ends_at->isPast();
}
public function isOnTrial(): bool
{
return $this->status === self::STATUS_TRIALING
&& $this->trial_ends_at !== null
&& $this->trial_ends_at->isFuture();
}
// ==================== ACCESSORS ====================
public function getDaysUntilTrialEndsAttribute(): ?int
{
if (!$this->trial_ends_at) {
return null;
}
if ($this->trial_ends_at->isPast()) {
return 0;
}
return (int) now()->diffInDays($this->trial_ends_at);
}
public function getDaysUntilRenewalAttribute(): ?int
{
if (!$this->current_period_end) {
return null;
}
if ($this->current_period_end->isPast()) {
return 0;
}
return (int) now()->diffInDays($this->current_period_end);
}
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_TRIALING => 'En prueba',
self::STATUS_ACTIVE => 'Activa',
self::STATUS_PAST_DUE => 'Pago pendiente',
self::STATUS_CANCELED => 'Cancelada',
self::STATUS_EXPIRED => 'Expirada',
default => $this->status,
};
}
public function getStatusColorAttribute(): string
{
return match ($this->status) {
self::STATUS_TRIALING => 'info',
self::STATUS_ACTIVE => 'success',
self::STATUS_PAST_DUE => 'warning',
self::STATUS_CANCELED => 'secondary',
self::STATUS_EXPIRED => 'danger',
default => 'secondary',
};
}
// ==================== METHODS ====================
/**
* Start a trial for this subscription
*/
public function startTrial(int $days): self
{
$this->status = self::STATUS_TRIALING;
$this->trial_ends_at = now()->addDays($days);
$this->current_period_start = now();
$this->current_period_end = now()->addDays($days);
$this->save();
return $this;
}
/**
* Activate the subscription (after trial or payment)
*/
public function activate(?Carbon $periodEnd = null): self
{
$this->status = self::STATUS_ACTIVE;
$this->trial_ends_at = null;
$this->current_period_start = now();
if ($periodEnd) {
$this->current_period_end = $periodEnd;
} elseif ($this->plan->billing_period === 'monthly') {
$this->current_period_end = now()->addMonth();
} elseif ($this->plan->billing_period === 'annual') {
$this->current_period_end = now()->addYear();
}
$this->save();
return $this;
}
/**
* Cancel the subscription
*/
public function cancel(?string $reason = null, bool $immediately = false): self
{
$this->canceled_at = now();
$this->cancel_reason = $reason;
if ($immediately) {
$this->status = self::STATUS_CANCELED;
$this->ends_at = now();
} else {
// Keep active until period ends (grace period)
$this->ends_at = $this->current_period_end;
}
$this->save();
return $this;
}
/**
* Mark as expired
*/
public function markAsExpired(): self
{
$this->status = self::STATUS_EXPIRED;
$this->save();
return $this;
}
/**
* Renew the subscription for another period
*/
public function renew(): self
{
$this->current_period_start = $this->current_period_end ?? now();
if ($this->plan->billing_period === 'monthly') {
$this->current_period_end = $this->current_period_start->copy()->addMonth();
} elseif ($this->plan->billing_period === 'annual') {
$this->current_period_end = $this->current_period_start->copy()->addYear();
}
$this->status = self::STATUS_ACTIVE;
$this->canceled_at = null;
$this->ends_at = null;
$this->save();
return $this;
}
/**
* Swap to a different plan
*/
public function swap(Plan $newPlan): self
{
$this->plan_id = $newPlan->id;
$this->price_paid = $newPlan->price;
$this->save();
return $this;
}
/**
* Create a subscription for a user
*/
public static function createForUser(User $user, Plan $plan): self
{
$subscription = new self([
'user_id' => $user->id,
'plan_id' => $plan->id,
'status' => self::STATUS_TRIALING,
'price_paid' => $plan->price,
'currency' => $plan->currency,
]);
if ($plan->is_free) {
$subscription->status = self::STATUS_ACTIVE;
$subscription->current_period_start = now();
$subscription->current_period_end = null; // Never expires
} elseif ($plan->has_trial) {
$subscription->trial_ends_at = now()->addDays($plan->trial_days);
$subscription->current_period_start = now();
$subscription->current_period_end = now()->addDays($plan->trial_days);
}
$subscription->save();
return $subscription;
}
}