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:
marcoitaloesp-ai 2025-12-17 10:46:34 +00:00 committed by GitHub
parent abaf0097c5
commit 0adb5c889f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1297 additions and 1 deletions

View File

@ -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/).
## [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
### Added

View File

@ -1 +1 @@
1.48.0
1.49.0

View 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,
],
],
]);
}
}

View 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
View 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();
}
}

View 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;
}
}

View File

@ -90,4 +90,101 @@ public function isAdmin(): bool
{
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();
}
}

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View 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()
);
}
}

View File

@ -22,11 +22,16 @@
use App\Http\Controllers\Api\ReportController;
use App\Http\Controllers\Api\FinancialHealthController;
use App\Http\Controllers\Api\UserPreferenceController;
use App\Http\Controllers\Api\PlanController;
// Public routes with rate limiting
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
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)
Route::post('/email/send-test', [EmailTestController::class, 'sendTest']);
Route::get('/email/anti-spam-info', [EmailTestController::class, 'getAntiSpamInfo']);