diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b21d1..9de4f7d 100644 --- a/CHANGELOG.md +++ b/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/). +## [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 diff --git a/VERSION b/VERSION index 9db5ea1..7f3a46a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.48.0 +1.49.0 diff --git a/backend/app/Http/Controllers/Api/PlanController.php b/backend/app/Http/Controllers/Api/PlanController.php new file mode 100644 index 0000000..5a1276f --- /dev/null +++ b/backend/app/Http/Controllers/Api/PlanController.php @@ -0,0 +1,84 @@ +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, + ], + ], + ]); + } +} diff --git a/backend/app/Models/Invoice.php b/backend/app/Models/Invoice.php new file mode 100644 index 0000000..93458b2 --- /dev/null +++ b/backend/app/Models/Invoice.php @@ -0,0 +1,286 @@ + '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; + } +} diff --git a/backend/app/Models/Plan.php b/backend/app/Models/Plan.php new file mode 100644 index 0000000..84d0e9a --- /dev/null +++ b/backend/app/Models/Plan.php @@ -0,0 +1,160 @@ + '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(); + } +} diff --git a/backend/app/Models/Subscription.php b/backend/app/Models/Subscription.php new file mode 100644 index 0000000..6e1c512 --- /dev/null +++ b/backend/app/Models/Subscription.php @@ -0,0 +1,321 @@ + '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; + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 48459cc..161f2fd 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -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(); + } } diff --git a/backend/database/migrations/2025_12_17_114500_create_plans_table.php b/backend/database/migrations/2025_12_17_114500_create_plans_table.php new file mode 100644 index 0000000..55b2efa --- /dev/null +++ b/backend/database/migrations/2025_12_17_114500_create_plans_table.php @@ -0,0 +1,43 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_12_17_114501_create_subscriptions_table.php b/backend/database/migrations/2025_12_17_114501_create_subscriptions_table.php new file mode 100644 index 0000000..60a5e94 --- /dev/null +++ b/backend/database/migrations/2025_12_17_114501_create_subscriptions_table.php @@ -0,0 +1,59 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_12_17_114502_create_invoices_table.php b/backend/database/migrations/2025_12_17_114502_create_invoices_table.php new file mode 100644 index 0000000..4ec4755 --- /dev/null +++ b/backend/database/migrations/2025_12_17_114502_create_invoices_table.php @@ -0,0 +1,74 @@ +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'); + } +}; diff --git a/backend/database/seeders/PlansSeeder.php b/backend/database/seeders/PlansSeeder.php new file mode 100644 index 0000000..0e538cc --- /dev/null +++ b/backend/database/seeders/PlansSeeder.php @@ -0,0 +1,136 @@ + '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() + ); + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index eebc20f..55d0486 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -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']);