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
This commit is contained in:
parent
abaf0097c5
commit
0adb5c889f
31
CHANGELOG.md
31
CHANGELOG.md
@ -5,6 +5,37 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
|
|||||||
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
|
||||||
|
|
||||||
|
|
||||||
|
## [1.49.0] - 2025-12-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 💳 **Sistema de Assinaturas (Fase 2)** - Infraestrutura completa para modelo SaaS
|
||||||
|
- **Tabela `plans`**: Planos de assinatura com preços, features e limites
|
||||||
|
- Free: Grátis, até 3 contas, 10 categorias, 3 orçamentos, 1 meta
|
||||||
|
- Pro Mensual: €9.99/mês, 7 dias trial, tudo ilimitado
|
||||||
|
- Pro Anual: €99.99/ano, 17% desconto, 7 dias trial
|
||||||
|
- **Tabela `subscriptions`**: Assinaturas dos usuários
|
||||||
|
- Status: trialing, active, past_due, canceled, expired
|
||||||
|
- Suporte a trial, grace period, cancelamento
|
||||||
|
- Campos para integração PayPal
|
||||||
|
- **Tabela `invoices`**: Faturas com numeração sequencial (WM-2025-NNNNNN)
|
||||||
|
- Suporte a IVA/VAT (21% para EU)
|
||||||
|
- Snapshot de dados de billing
|
||||||
|
- **API pública**: `GET /api/plans` e `GET /api/plans/{slug}`
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- **Migrations**: 3 novas tabelas (plans, subscriptions, invoices)
|
||||||
|
- **Models**: Plan, Subscription, Invoice com relacionamentos e helpers
|
||||||
|
- **User Model**: Novos métodos hasActiveSubscription(), onTrial(), subscribedTo(), hasFeature(), getLimit(), currentPlan()
|
||||||
|
- **PlanController**: Endpoint público para listar planos
|
||||||
|
- **PlansSeeder**: Criação dos 3 planos base
|
||||||
|
|
||||||
|
### Roadmap SaaS
|
||||||
|
1. ✅ **Fase 1**: Perfil completo do usuário
|
||||||
|
2. ✅ **Fase 2**: Tabelas de assinaturas (plans, subscriptions, invoices)
|
||||||
|
3. ⏳ **Fase 3**: Integração PayPal Subscriptions
|
||||||
|
4. ⏳ **Fase 4**: Página de Billing e gestão de assinatura
|
||||||
|
|
||||||
|
|
||||||
## [1.48.0] - 2025-12-17
|
## [1.48.0] - 2025-12-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
84
backend/app/Http/Controllers/Api/PlanController.php
Normal file
84
backend/app/Http/Controllers/Api/PlanController.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PlanController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List all active plans for pricing page
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$plans = Plan::active()->ordered()->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'plans' => $plans->map(function ($plan) {
|
||||||
|
return [
|
||||||
|
'id' => $plan->id,
|
||||||
|
'slug' => $plan->slug,
|
||||||
|
'name' => $plan->name,
|
||||||
|
'description' => $plan->description,
|
||||||
|
'price' => $plan->price,
|
||||||
|
'formatted_price' => $plan->formatted_price,
|
||||||
|
'monthly_price' => $plan->monthly_price,
|
||||||
|
'currency' => $plan->currency,
|
||||||
|
'billing_period' => $plan->billing_period,
|
||||||
|
'trial_days' => $plan->trial_days,
|
||||||
|
'features' => $plan->features,
|
||||||
|
'limits' => $plan->limits,
|
||||||
|
'is_free' => $plan->is_free,
|
||||||
|
'is_featured' => $plan->is_featured,
|
||||||
|
'has_trial' => $plan->has_trial,
|
||||||
|
'savings_percent' => $plan->savings_percent,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single plan by slug
|
||||||
|
*/
|
||||||
|
public function show(string $slug): JsonResponse
|
||||||
|
{
|
||||||
|
$plan = Plan::where('slug', $slug)->where('is_active', true)->first();
|
||||||
|
|
||||||
|
if (!$plan) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Plan not found',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'plan' => [
|
||||||
|
'id' => $plan->id,
|
||||||
|
'slug' => $plan->slug,
|
||||||
|
'name' => $plan->name,
|
||||||
|
'description' => $plan->description,
|
||||||
|
'price' => $plan->price,
|
||||||
|
'formatted_price' => $plan->formatted_price,
|
||||||
|
'monthly_price' => $plan->monthly_price,
|
||||||
|
'currency' => $plan->currency,
|
||||||
|
'billing_period' => $plan->billing_period,
|
||||||
|
'trial_days' => $plan->trial_days,
|
||||||
|
'features' => $plan->features,
|
||||||
|
'limits' => $plan->limits,
|
||||||
|
'is_free' => $plan->is_free,
|
||||||
|
'is_featured' => $plan->is_featured,
|
||||||
|
'has_trial' => $plan->has_trial,
|
||||||
|
'savings_percent' => $plan->savings_percent,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
286
backend/app/Models/Invoice.php
Normal file
286
backend/app/Models/Invoice.php
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
backend/app/Models/Plan.php
Normal file
160
backend/app/Models/Plan.php
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Plan extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'description',
|
||||||
|
'price',
|
||||||
|
'currency',
|
||||||
|
'billing_period',
|
||||||
|
'trial_days',
|
||||||
|
'features',
|
||||||
|
'limits',
|
||||||
|
'is_active',
|
||||||
|
'is_featured',
|
||||||
|
'sort_order',
|
||||||
|
'paypal_plan_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'price' => 'decimal:2',
|
||||||
|
'features' => 'array',
|
||||||
|
'limits' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'trial_days' => 'integer',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ==================== RELATIONSHIPS ====================
|
||||||
|
|
||||||
|
public function subscriptions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Subscription::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SCOPES ====================
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrdered($query)
|
||||||
|
{
|
||||||
|
return $query->orderBy('sort_order')->orderBy('price');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeFree($query)
|
||||||
|
{
|
||||||
|
return $query->where('billing_period', 'free');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePaid($query)
|
||||||
|
{
|
||||||
|
return $query->where('billing_period', '!=', 'free');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ACCESSORS ====================
|
||||||
|
|
||||||
|
public function getIsFreeAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->billing_period === 'free' || $this->price == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHasTrialAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->trial_days > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormattedPriceAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->is_free) {
|
||||||
|
return 'Gratis';
|
||||||
|
}
|
||||||
|
|
||||||
|
$symbols = [
|
||||||
|
'EUR' => '€',
|
||||||
|
'USD' => '$',
|
||||||
|
'BRL' => 'R$',
|
||||||
|
];
|
||||||
|
|
||||||
|
$symbol = $symbols[$this->currency] ?? $this->currency;
|
||||||
|
$period = match ($this->billing_period) {
|
||||||
|
'monthly' => '/mes',
|
||||||
|
'annual' => '/año',
|
||||||
|
'lifetime' => ' (único)',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return $symbol . number_format($this->price, 2, ',', '.') . $period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonthlyPriceAttribute(): float
|
||||||
|
{
|
||||||
|
if ($this->billing_period === 'annual') {
|
||||||
|
return round($this->price / 12, 2);
|
||||||
|
}
|
||||||
|
return $this->price;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSavingsPercentAttribute(): ?int
|
||||||
|
{
|
||||||
|
if ($this->billing_period !== 'annual') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find monthly equivalent
|
||||||
|
$monthlyPlan = static::where('billing_period', 'monthly')
|
||||||
|
->where('is_active', true)
|
||||||
|
->where('price', '>', 0)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$monthlyPlan) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$annualEquivalent = $monthlyPlan->price * 12;
|
||||||
|
$savings = (($annualEquivalent - $this->price) / $annualEquivalent) * 100;
|
||||||
|
|
||||||
|
return (int) round($savings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== METHODS ====================
|
||||||
|
|
||||||
|
public function hasFeature(string $feature): bool
|
||||||
|
{
|
||||||
|
return in_array($feature, $this->features ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLimit(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->limits[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default free plan
|
||||||
|
*/
|
||||||
|
public static function getFreePlan(): ?self
|
||||||
|
{
|
||||||
|
return static::where('slug', 'free')->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plans for pricing page
|
||||||
|
*/
|
||||||
|
public static function getForPricing(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return static::active()->ordered()->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
321
backend/app/Models/Subscription.php
Normal file
321
backend/app/Models/Subscription.php
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -90,4 +90,101 @@ public function isAdmin(): bool
|
|||||||
{
|
{
|
||||||
return $this->is_admin === true;
|
return $this->is_admin === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SUBSCRIPTION RELATIONSHIPS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all subscriptions for the user
|
||||||
|
*/
|
||||||
|
public function subscriptions()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Subscription::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the active subscription
|
||||||
|
*/
|
||||||
|
public function subscription()
|
||||||
|
{
|
||||||
|
return $this->hasOne(Subscription::class)->active()->latest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all invoices for the user
|
||||||
|
*/
|
||||||
|
public function invoices()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Invoice::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SUBSCRIPTION HELPERS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has an active subscription
|
||||||
|
*/
|
||||||
|
public function hasActiveSubscription(): bool
|
||||||
|
{
|
||||||
|
return $this->subscriptions()->active()->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is on trial
|
||||||
|
*/
|
||||||
|
public function onTrial(): bool
|
||||||
|
{
|
||||||
|
$subscription = $this->subscriptions()->active()->first();
|
||||||
|
return $subscription && $subscription->isOnTrial();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is subscribed to a specific plan
|
||||||
|
*/
|
||||||
|
public function subscribedTo(string $planSlug): bool
|
||||||
|
{
|
||||||
|
return $this->subscriptions()
|
||||||
|
->active()
|
||||||
|
->whereHas('plan', fn($q) => $q->where('slug', $planSlug))
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has access to a feature
|
||||||
|
*/
|
||||||
|
public function hasFeature(string $feature): bool
|
||||||
|
{
|
||||||
|
$subscription = $this->subscriptions()->active()->with('plan')->first();
|
||||||
|
|
||||||
|
if (!$subscription) {
|
||||||
|
// Check free plan limits
|
||||||
|
$freePlan = Plan::getFreePlan();
|
||||||
|
return $freePlan && $freePlan->hasFeature($feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subscription->plan->hasFeature($feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's limit for a specific resource
|
||||||
|
*/
|
||||||
|
public function getLimit(string $resource, $default = null)
|
||||||
|
{
|
||||||
|
$subscription = $this->subscriptions()->active()->with('plan')->first();
|
||||||
|
|
||||||
|
if (!$subscription) {
|
||||||
|
// Check free plan limits
|
||||||
|
$freePlan = Plan::getFreePlan();
|
||||||
|
return $freePlan ? $freePlan->getLimit($resource, $default) : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subscription->plan->getLimit($resource, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current plan
|
||||||
|
*/
|
||||||
|
public function currentPlan(): ?Plan
|
||||||
|
{
|
||||||
|
$subscription = $this->subscriptions()->active()->with('plan')->first();
|
||||||
|
return $subscription ? $subscription->plan : Plan::getFreePlan();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plans', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name'); // "Free", "Pro Monthly", "Pro Annual"
|
||||||
|
$table->string('slug')->unique(); // "free", "pro-monthly", "pro-annual"
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->decimal('price', 10, 2)->default(0); // 0 for Free, 9.99 for Pro Monthly, 99.99 for Pro Annual
|
||||||
|
$table->string('currency', 3)->default('EUR'); // EUR, USD, BRL
|
||||||
|
$table->enum('billing_period', ['monthly', 'annual', 'lifetime', 'free'])->default('monthly');
|
||||||
|
$table->integer('trial_days')->default(0); // 7 days for Pro plans
|
||||||
|
$table->json('features')->nullable(); // JSON array of features
|
||||||
|
$table->json('limits')->nullable(); // JSON with limits (accounts, categories, etc.)
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->boolean('is_featured')->default(false); // Highlight this plan
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->string('paypal_plan_id')->nullable(); // PayPal Plan ID for subscriptions
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('is_active');
|
||||||
|
$table->index('sort_order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('plans');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('subscriptions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->foreignId('plan_id')->constrained()->onDelete('restrict');
|
||||||
|
|
||||||
|
// Status: trialing, active, past_due, canceled, expired
|
||||||
|
$table->enum('status', ['trialing', 'active', 'past_due', 'canceled', 'expired'])->default('trialing');
|
||||||
|
|
||||||
|
// Trial period
|
||||||
|
$table->timestamp('trial_ends_at')->nullable();
|
||||||
|
|
||||||
|
// Current billing period
|
||||||
|
$table->timestamp('current_period_start')->nullable();
|
||||||
|
$table->timestamp('current_period_end')->nullable();
|
||||||
|
|
||||||
|
// Cancellation
|
||||||
|
$table->timestamp('canceled_at')->nullable();
|
||||||
|
$table->timestamp('ends_at')->nullable(); // When subscription actually ends after cancellation
|
||||||
|
$table->string('cancel_reason')->nullable();
|
||||||
|
|
||||||
|
// PayPal integration
|
||||||
|
$table->string('paypal_subscription_id')->nullable()->unique();
|
||||||
|
$table->string('paypal_status')->nullable(); // PayPal subscription status
|
||||||
|
$table->json('paypal_data')->nullable(); // Full PayPal response for debugging
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
$table->decimal('price_paid', 10, 2)->nullable(); // Price when subscribed (may differ from plan price)
|
||||||
|
$table->string('currency', 3)->default('EUR');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'status']);
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('current_period_end');
|
||||||
|
$table->index('trial_ends_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('subscriptions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('invoices', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->foreignId('subscription_id')->nullable()->constrained()->onDelete('set null');
|
||||||
|
|
||||||
|
// Invoice number: WM-2025-0001
|
||||||
|
$table->string('number')->unique();
|
||||||
|
|
||||||
|
// Status: draft, open, paid, void, uncollectible
|
||||||
|
$table->enum('status', ['draft', 'open', 'paid', 'void', 'uncollectible'])->default('draft');
|
||||||
|
|
||||||
|
// Billing reason
|
||||||
|
$table->enum('billing_reason', [
|
||||||
|
'subscription_create', // First payment
|
||||||
|
'subscription_cycle', // Recurring payment
|
||||||
|
'subscription_update', // Plan change
|
||||||
|
'manual' // Manual invoice
|
||||||
|
])->default('subscription_cycle');
|
||||||
|
|
||||||
|
// Amounts
|
||||||
|
$table->string('currency', 3)->default('EUR');
|
||||||
|
$table->decimal('subtotal', 10, 2)->default(0);
|
||||||
|
$table->decimal('tax', 10, 2)->default(0);
|
||||||
|
$table->decimal('tax_percent', 5, 2)->default(0); // e.g., 21% VAT
|
||||||
|
$table->decimal('total', 10, 2)->default(0);
|
||||||
|
|
||||||
|
// Line items description
|
||||||
|
$table->string('description')->nullable(); // "Pro Monthly - December 2025"
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
$table->timestamp('due_date')->nullable();
|
||||||
|
$table->timestamp('paid_at')->nullable();
|
||||||
|
|
||||||
|
// PayPal
|
||||||
|
$table->string('paypal_payment_id')->nullable();
|
||||||
|
$table->string('paypal_capture_id')->nullable();
|
||||||
|
$table->json('paypal_data')->nullable();
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
$table->string('pdf_path')->nullable(); // Path to stored PDF
|
||||||
|
|
||||||
|
// Billing info snapshot (in case user changes later)
|
||||||
|
$table->json('billing_info')->nullable(); // Name, address, tax_id at time of invoice
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'status']);
|
||||||
|
$table->index('status');
|
||||||
|
$table->index('paid_at');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('invoices');
|
||||||
|
}
|
||||||
|
};
|
||||||
136
backend/database/seeders/PlansSeeder.php
Normal file
136
backend/database/seeders/PlansSeeder.php
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Plan;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class PlansSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Free Plan
|
||||||
|
Plan::updateOrCreate(
|
||||||
|
['slug' => 'free'],
|
||||||
|
[
|
||||||
|
'name' => 'Free',
|
||||||
|
'description' => 'Perfecto para empezar a controlar tus finanzas personales',
|
||||||
|
'price' => 0,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'billing_period' => 'free',
|
||||||
|
'trial_days' => 0,
|
||||||
|
'features' => [
|
||||||
|
'Hasta 3 cuentas bancarias',
|
||||||
|
'Hasta 10 categorías',
|
||||||
|
'Dashboard básico',
|
||||||
|
'Importar transacciones (CSV)',
|
||||||
|
'Detección de duplicados',
|
||||||
|
'Soporte por email',
|
||||||
|
],
|
||||||
|
'limits' => [
|
||||||
|
'accounts' => 3,
|
||||||
|
'categories' => 10,
|
||||||
|
'budgets' => 3,
|
||||||
|
'goals' => 1,
|
||||||
|
'reports' => false,
|
||||||
|
'recurring' => false,
|
||||||
|
'api_access' => false,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
'is_featured' => false,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pro Monthly
|
||||||
|
Plan::updateOrCreate(
|
||||||
|
['slug' => 'pro-monthly'],
|
||||||
|
[
|
||||||
|
'name' => 'Pro Mensual',
|
||||||
|
'description' => 'Todas las funcionalidades para control financiero completo',
|
||||||
|
'price' => 9.99,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'billing_period' => 'monthly',
|
||||||
|
'trial_days' => 7,
|
||||||
|
'features' => [
|
||||||
|
'Cuentas bancarias ilimitadas',
|
||||||
|
'Categorías ilimitadas',
|
||||||
|
'Dashboard avanzado',
|
||||||
|
'Presupuestos ilimitados',
|
||||||
|
'Metas financieras ilimitadas',
|
||||||
|
'Transacciones recurrentes',
|
||||||
|
'Informes detallados',
|
||||||
|
'Exportar a Excel/PDF',
|
||||||
|
'Notificaciones por email',
|
||||||
|
'Notificaciones WhatsApp',
|
||||||
|
'Módulo de negocios',
|
||||||
|
'Soporte prioritario',
|
||||||
|
],
|
||||||
|
'limits' => [
|
||||||
|
'accounts' => null, // unlimited
|
||||||
|
'categories' => null,
|
||||||
|
'budgets' => null,
|
||||||
|
'goals' => null,
|
||||||
|
'reports' => true,
|
||||||
|
'recurring' => true,
|
||||||
|
'api_access' => true,
|
||||||
|
'business_module' => true,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
'is_featured' => true,
|
||||||
|
'sort_order' => 2,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pro Annual
|
||||||
|
Plan::updateOrCreate(
|
||||||
|
['slug' => 'pro-annual'],
|
||||||
|
[
|
||||||
|
'name' => 'Pro Anual',
|
||||||
|
'description' => 'Ahorra 2 meses con el plan anual - ¡Mejor valor!',
|
||||||
|
'price' => 99.99,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'billing_period' => 'annual',
|
||||||
|
'trial_days' => 7,
|
||||||
|
'features' => [
|
||||||
|
'Todo lo de Pro Mensual',
|
||||||
|
'¡Ahorra 17% vs mensual!',
|
||||||
|
'Cuentas bancarias ilimitadas',
|
||||||
|
'Categorías ilimitadas',
|
||||||
|
'Dashboard avanzado',
|
||||||
|
'Presupuestos ilimitados',
|
||||||
|
'Metas financieras ilimitadas',
|
||||||
|
'Transacciones recurrentes',
|
||||||
|
'Informes detallados',
|
||||||
|
'Exportar a Excel/PDF',
|
||||||
|
'Notificaciones por email',
|
||||||
|
'Notificaciones WhatsApp',
|
||||||
|
'Módulo de negocios',
|
||||||
|
'Soporte prioritario',
|
||||||
|
],
|
||||||
|
'limits' => [
|
||||||
|
'accounts' => null,
|
||||||
|
'categories' => null,
|
||||||
|
'budgets' => null,
|
||||||
|
'goals' => null,
|
||||||
|
'reports' => true,
|
||||||
|
'recurring' => true,
|
||||||
|
'api_access' => true,
|
||||||
|
'business_module' => true,
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
'is_featured' => false,
|
||||||
|
'sort_order' => 3,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->command->info('Plans seeded successfully!');
|
||||||
|
$this->command->table(
|
||||||
|
['Slug', 'Name', 'Price', 'Billing'],
|
||||||
|
Plan::orderBy('sort_order')->get(['slug', 'name', 'price', 'billing_period'])->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,11 +22,16 @@
|
|||||||
use App\Http\Controllers\Api\ReportController;
|
use App\Http\Controllers\Api\ReportController;
|
||||||
use App\Http\Controllers\Api\FinancialHealthController;
|
use App\Http\Controllers\Api\FinancialHealthController;
|
||||||
use App\Http\Controllers\Api\UserPreferenceController;
|
use App\Http\Controllers\Api\UserPreferenceController;
|
||||||
|
use App\Http\Controllers\Api\PlanController;
|
||||||
|
|
||||||
// Public routes with rate limiting
|
// Public routes with rate limiting
|
||||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
||||||
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
|
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
|
||||||
|
|
||||||
|
// Plans (public - for pricing page)
|
||||||
|
Route::get('/plans', [PlanController::class, 'index']);
|
||||||
|
Route::get('/plans/{slug}', [PlanController::class, 'show']);
|
||||||
|
|
||||||
// Email testing routes (should be protected in production)
|
// Email testing routes (should be protected in production)
|
||||||
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
|
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
|
||||||
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);
|
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user