- 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
287 lines
7.4 KiB
PHP
287 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
class Invoice extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
const STATUS_DRAFT = 'draft';
|
|
const STATUS_OPEN = 'open';
|
|
const STATUS_PAID = 'paid';
|
|
const STATUS_VOID = 'void';
|
|
const STATUS_UNCOLLECTIBLE = 'uncollectible';
|
|
|
|
const REASON_SUBSCRIPTION_CREATE = 'subscription_create';
|
|
const REASON_SUBSCRIPTION_CYCLE = 'subscription_cycle';
|
|
const REASON_SUBSCRIPTION_UPDATE = 'subscription_update';
|
|
const REASON_MANUAL = 'manual';
|
|
|
|
protected $fillable = [
|
|
'user_id',
|
|
'subscription_id',
|
|
'number',
|
|
'status',
|
|
'billing_reason',
|
|
'currency',
|
|
'subtotal',
|
|
'tax',
|
|
'tax_percent',
|
|
'total',
|
|
'description',
|
|
'due_date',
|
|
'paid_at',
|
|
'paypal_payment_id',
|
|
'paypal_capture_id',
|
|
'paypal_data',
|
|
'pdf_path',
|
|
'billing_info',
|
|
];
|
|
|
|
protected $casts = [
|
|
'due_date' => 'datetime',
|
|
'paid_at' => 'datetime',
|
|
'subtotal' => 'decimal:2',
|
|
'tax' => 'decimal:2',
|
|
'tax_percent' => 'decimal:2',
|
|
'total' => 'decimal:2',
|
|
'paypal_data' => 'array',
|
|
'billing_info' => 'array',
|
|
];
|
|
|
|
// ==================== BOOT ====================
|
|
|
|
protected static function boot()
|
|
{
|
|
parent::boot();
|
|
|
|
static::creating(function ($invoice) {
|
|
if (empty($invoice->number)) {
|
|
$invoice->number = static::generateNumber();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==================== RELATIONSHIPS ====================
|
|
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
public function subscription(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Subscription::class);
|
|
}
|
|
|
|
// ==================== SCOPES ====================
|
|
|
|
public function scopePaid($query)
|
|
{
|
|
return $query->where('status', self::STATUS_PAID);
|
|
}
|
|
|
|
public function scopeOpen($query)
|
|
{
|
|
return $query->where('status', self::STATUS_OPEN);
|
|
}
|
|
|
|
public function scopeForUser($query, int $userId)
|
|
{
|
|
return $query->where('user_id', $userId);
|
|
}
|
|
|
|
public function scopeRecent($query)
|
|
{
|
|
return $query->orderBy('created_at', 'desc');
|
|
}
|
|
|
|
// ==================== STATUS CHECKS ====================
|
|
|
|
public function isPaid(): bool
|
|
{
|
|
return $this->status === self::STATUS_PAID;
|
|
}
|
|
|
|
public function isOpen(): bool
|
|
{
|
|
return $this->status === self::STATUS_OPEN;
|
|
}
|
|
|
|
public function isDraft(): bool
|
|
{
|
|
return $this->status === self::STATUS_DRAFT;
|
|
}
|
|
|
|
public function isVoid(): bool
|
|
{
|
|
return $this->status === self::STATUS_VOID;
|
|
}
|
|
|
|
public function isOverdue(): bool
|
|
{
|
|
return $this->status === self::STATUS_OPEN
|
|
&& $this->due_date
|
|
&& $this->due_date->isPast();
|
|
}
|
|
|
|
// ==================== ACCESSORS ====================
|
|
|
|
public function getStatusLabelAttribute(): string
|
|
{
|
|
return match ($this->status) {
|
|
self::STATUS_DRAFT => 'Borrador',
|
|
self::STATUS_OPEN => 'Pendiente',
|
|
self::STATUS_PAID => 'Pagada',
|
|
self::STATUS_VOID => 'Anulada',
|
|
self::STATUS_UNCOLLECTIBLE => 'Incobrable',
|
|
default => $this->status,
|
|
};
|
|
}
|
|
|
|
public function getStatusColorAttribute(): string
|
|
{
|
|
return match ($this->status) {
|
|
self::STATUS_DRAFT => 'secondary',
|
|
self::STATUS_OPEN => 'warning',
|
|
self::STATUS_PAID => 'success',
|
|
self::STATUS_VOID => 'dark',
|
|
self::STATUS_UNCOLLECTIBLE => 'danger',
|
|
default => 'secondary',
|
|
};
|
|
}
|
|
|
|
public function getFormattedTotalAttribute(): string
|
|
{
|
|
$symbols = [
|
|
'EUR' => '€',
|
|
'USD' => '$',
|
|
'BRL' => 'R$',
|
|
];
|
|
|
|
$symbol = $symbols[$this->currency] ?? $this->currency;
|
|
return $symbol . number_format($this->total, 2, ',', '.');
|
|
}
|
|
|
|
public function getBillingReasonLabelAttribute(): string
|
|
{
|
|
return match ($this->billing_reason) {
|
|
self::REASON_SUBSCRIPTION_CREATE => 'Nueva suscripción',
|
|
self::REASON_SUBSCRIPTION_CYCLE => 'Renovación',
|
|
self::REASON_SUBSCRIPTION_UPDATE => 'Cambio de plan',
|
|
self::REASON_MANUAL => 'Manual',
|
|
default => $this->billing_reason,
|
|
};
|
|
}
|
|
|
|
// ==================== METHODS ====================
|
|
|
|
/**
|
|
* Generate a unique invoice number
|
|
* Format: WM-YYYY-NNNNNN
|
|
*/
|
|
public static function generateNumber(): string
|
|
{
|
|
$year = now()->year;
|
|
$prefix = "WM-{$year}-";
|
|
|
|
$lastInvoice = static::where('number', 'like', "{$prefix}%")
|
|
->orderBy('number', 'desc')
|
|
->first();
|
|
|
|
if ($lastInvoice) {
|
|
$lastNumber = (int) substr($lastInvoice->number, strlen($prefix));
|
|
$newNumber = $lastNumber + 1;
|
|
} else {
|
|
$newNumber = 1;
|
|
}
|
|
|
|
return $prefix . str_pad($newNumber, 6, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
/**
|
|
* Mark invoice as paid
|
|
*/
|
|
public function markAsPaid(?string $paypalPaymentId = null, ?string $paypalCaptureId = null): self
|
|
{
|
|
$this->status = self::STATUS_PAID;
|
|
$this->paid_at = now();
|
|
|
|
if ($paypalPaymentId) {
|
|
$this->paypal_payment_id = $paypalPaymentId;
|
|
}
|
|
if ($paypalCaptureId) {
|
|
$this->paypal_capture_id = $paypalCaptureId;
|
|
}
|
|
|
|
$this->save();
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Mark invoice as void
|
|
*/
|
|
public function markAsVoid(): self
|
|
{
|
|
$this->status = self::STATUS_VOID;
|
|
$this->save();
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Calculate and set totals
|
|
*/
|
|
public function calculateTotals(float $subtotal, float $taxPercent = 0): self
|
|
{
|
|
$this->subtotal = $subtotal;
|
|
$this->tax_percent = $taxPercent;
|
|
$this->tax = round($subtotal * ($taxPercent / 100), 2);
|
|
$this->total = $this->subtotal + $this->tax;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Create invoice for a subscription
|
|
*/
|
|
public static function createForSubscription(
|
|
Subscription $subscription,
|
|
string $billingReason = self::REASON_SUBSCRIPTION_CYCLE,
|
|
?string $description = null
|
|
): self {
|
|
$plan = $subscription->plan;
|
|
$user = $subscription->user;
|
|
|
|
$invoice = new self([
|
|
'user_id' => $user->id,
|
|
'subscription_id' => $subscription->id,
|
|
'status' => self::STATUS_DRAFT,
|
|
'billing_reason' => $billingReason,
|
|
'currency' => $subscription->currency,
|
|
'description' => $description ?? "{$plan->name} - " . now()->format('F Y'),
|
|
'due_date' => now()->addDays(7),
|
|
]);
|
|
|
|
// Calculate with VAT (21% for EU)
|
|
$invoice->calculateTotals($plan->price, 21);
|
|
|
|
// Store billing info snapshot
|
|
$invoice->billing_info = [
|
|
'name' => $user->full_name,
|
|
'email' => $user->email,
|
|
'phone' => $user->full_phone,
|
|
'country' => $user->country,
|
|
];
|
|
|
|
$invoice->save();
|
|
|
|
return $invoice;
|
|
}
|
|
}
|