## New Features - Email notifications for overdue and upcoming payments - User preferences page for notification settings - Daily scheduler to send alerts at user-configured time - Smart analysis: payable items, transfer suggestions between accounts ## Backend - Migration for user_preferences table - SendDuePaymentsAlert Artisan command - DuePaymentsAlert Mailable with HTML/text templates - UserPreferenceController with test-notification endpoint - Scheduler config for notify:due-payments command ## Frontend - Preferences.jsx page with notification toggle - API service for preferences - Route and menu link for settings - Translations (PT-BR, EN, ES) ## Server - Cron configured for Laravel scheduler Version: 1.44.5
373 lines
14 KiB
PHP
373 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Budget;
|
|
use App\Models\Category;
|
|
use App\Models\Transaction;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Carbon\Carbon;
|
|
|
|
class BudgetController extends Controller
|
|
{
|
|
/**
|
|
* Listar presupuestos de un período
|
|
*/
|
|
public function index(Request $request)
|
|
{
|
|
$year = $request->get('year', now()->year);
|
|
$month = $request->get('month', now()->month);
|
|
|
|
$budgets = Budget::forUser(Auth::id())
|
|
->forPeriod($year, $month)
|
|
->active()
|
|
->with(['category', 'subcategory', 'costCenter'])
|
|
->orderBy('amount', 'desc')
|
|
->get();
|
|
|
|
// Calcular totales
|
|
$totalBudget = $budgets->sum('amount');
|
|
$totalSpent = $budgets->sum('spent_amount');
|
|
$totalRemaining = $totalBudget - $totalSpent;
|
|
|
|
return response()->json([
|
|
'data' => $budgets,
|
|
'summary' => [
|
|
'total_budget' => $totalBudget,
|
|
'total_spent' => $totalSpent,
|
|
'total_remaining' => $totalRemaining,
|
|
'usage_percentage' => $totalBudget > 0 ? round(($totalSpent / $totalBudget) * 100, 1) : 0,
|
|
'year' => $year,
|
|
'month' => $month,
|
|
'period_label' => Carbon::createFromDate($year, $month, 1)->format('F Y'),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Crear un presupuesto (con propagación automática a meses futuros)
|
|
*/
|
|
public function store(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'category_id' => 'nullable|exists:categories,id',
|
|
'subcategory_id' => 'nullable|exists:categories,id',
|
|
'cost_center_id' => 'nullable|exists:cost_centers,id',
|
|
'name' => 'nullable|string|max:255',
|
|
'amount' => 'required|numeric|min:0.01',
|
|
'currency' => 'nullable|string|size:3',
|
|
'year' => 'required|integer|min:2020|max:2100',
|
|
'month' => 'required|integer|min:1|max:12',
|
|
'period_type' => 'nullable|in:monthly,bimestral,trimestral,semestral,yearly',
|
|
'is_cumulative' => 'nullable|boolean',
|
|
'notes' => 'nullable|string',
|
|
]);
|
|
|
|
// Validar que ao menos uma (categoria, subcategoria ou centro de custos) esteja preenchida
|
|
if (empty($validated['category_id']) && empty($validated['subcategory_id']) && empty($validated['cost_center_id'])) {
|
|
return response()->json([
|
|
'message' => 'Debe especificar al menos una categoría, subcategoría o centro de custos',
|
|
], 422);
|
|
}
|
|
|
|
// Validar que subcategoria pertence à categoria (se ambas fornecidas)
|
|
if (!empty($validated['subcategory_id']) && !empty($validated['category_id'])) {
|
|
$subcat = Category::find($validated['subcategory_id']);
|
|
if ($subcat && $subcat->parent_id != $validated['category_id']) {
|
|
return response()->json([
|
|
'message' => 'A subcategoria não pertence à categoria selecionada',
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
// Se tem subcategoria mas não tem categoria, buscar a categoria pai
|
|
if (!empty($validated['subcategory_id']) && empty($validated['category_id'])) {
|
|
$subcat = Category::find($validated['subcategory_id']);
|
|
if ($subcat && $subcat->parent_id) {
|
|
$validated['category_id'] = $subcat->parent_id;
|
|
}
|
|
}
|
|
|
|
// Verificar que no exista ya
|
|
$exists = Budget::forUser(Auth::id())
|
|
->where('category_id', $validated['category_id'])
|
|
->where('subcategory_id', $validated['subcategory_id'] ?? null)
|
|
->where('cost_center_id', $validated['cost_center_id'] ?? null)
|
|
->where('year', $validated['year'])
|
|
->where('month', $validated['month'])
|
|
->exists();
|
|
|
|
if ($exists) {
|
|
return response()->json([
|
|
'message' => 'Ya existe un presupuesto para esta categoría/subcategoría en este período',
|
|
], 422);
|
|
}
|
|
|
|
$validated['user_id'] = Auth::id();
|
|
// Adicionar moeda primária do usuário se não fornecida
|
|
if (empty($validated['currency'])) {
|
|
$validated['currency'] = Auth::user()->primary_currency ?? 'EUR';
|
|
}
|
|
|
|
// Crear el presupuesto del mes actual
|
|
$budget = Budget::create($validated);
|
|
|
|
// Determinar el salto de meses según period_type
|
|
$periodType = $validated['period_type'] ?? 'monthly';
|
|
$periodStepMap = [
|
|
'monthly' => 1, // A cada 1 mes (Jan, Feb, Mar, ..., Dec) = 12 presupuestos
|
|
'bimestral' => 2, // A cada 2 meses (Jan, Mar, May, Jul, Sep, Nov) = 6 presupuestos
|
|
'trimestral' => 3, // A cada 3 meses (Jan, Apr, Jul, Oct) = 4 presupuestos
|
|
'semestral' => 6, // A cada 6 meses (Jan, Jul) = 2 presupuestos
|
|
'yearly' => 12, // A cada 12 meses (solo Jan) = 1 presupuesto
|
|
];
|
|
|
|
$step = $periodStepMap[$periodType] ?? 1;
|
|
$currentYear = $validated['year'];
|
|
$currentMonth = $validated['month'];
|
|
|
|
// Propagar hasta completar 12 meses (1 año)
|
|
for ($monthsAhead = $step; $monthsAhead < 12; $monthsAhead += $step) {
|
|
$nextMonth = $currentMonth + $monthsAhead;
|
|
$nextYear = $currentYear;
|
|
|
|
if ($nextMonth > 12) {
|
|
$nextMonth -= 12;
|
|
$nextYear++;
|
|
}
|
|
|
|
// Solo crear si no existe
|
|
$existsNext = Budget::forUser(Auth::id())
|
|
->where('category_id', $validated['category_id'])
|
|
->where('subcategory_id', $validated['subcategory_id'] ?? null)
|
|
->where('cost_center_id', $validated['cost_center_id'] ?? null)
|
|
->where('year', $nextYear)
|
|
->where('month', $nextMonth)
|
|
->exists();
|
|
|
|
if (!$existsNext) {
|
|
Budget::create([
|
|
'user_id' => Auth::id(),
|
|
'category_id' => $validated['category_id'],
|
|
'subcategory_id' => $validated['subcategory_id'] ?? null,
|
|
'cost_center_id' => $validated['cost_center_id'] ?? null,
|
|
'name' => $validated['name'] ?? null,
|
|
'amount' => $validated['amount'],
|
|
'currency' => $validated['currency'] ?? null,
|
|
'year' => $nextYear,
|
|
'month' => $nextMonth,
|
|
'period_type' => $validated['period_type'] ?? 'monthly',
|
|
'is_cumulative' => $validated['is_cumulative'] ?? false,
|
|
'notes' => $validated['notes'] ?? null,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => 'Presupuesto creado y propagado',
|
|
'data' => $budget->load(['category', 'subcategory', 'costCenter']),
|
|
], 201);
|
|
}
|
|
|
|
/**
|
|
* Ver un presupuesto
|
|
*/
|
|
public function show($id)
|
|
{
|
|
$budget = Budget::forUser(Auth::id())
|
|
->with(['category', 'subcategory', 'costCenter'])
|
|
->findOrFail($id);
|
|
|
|
// Obtener transacciones del período
|
|
$query = Transaction::where('user_id', Auth::id())
|
|
->where('transaction_type', 'debit')
|
|
->whereYear('effective_date', $budget->year)
|
|
->whereMonth('effective_date', $budget->month);
|
|
|
|
// Se tem subcategoria específica, usa apenas ela
|
|
if ($budget->subcategory_id) {
|
|
$query->where('category_id', $budget->subcategory_id);
|
|
}
|
|
// Se tem apenas categoria, inclui subcategorias
|
|
elseif ($budget->category_id) {
|
|
$categoryIds = [$budget->category_id];
|
|
$subcategories = Category::where('parent_id', $budget->category_id)->pluck('id')->toArray();
|
|
$categoryIds = array_merge($categoryIds, $subcategories);
|
|
$query->whereIn('category_id', $categoryIds);
|
|
}
|
|
|
|
$transactions = $query->with('category')
|
|
->orderBy('effective_date', 'desc')
|
|
->limit(50)
|
|
->get();
|
|
|
|
return response()->json([
|
|
'budget' => $budget,
|
|
'transactions' => $transactions,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Actualizar un presupuesto
|
|
*/
|
|
public function update(Request $request, $id)
|
|
{
|
|
$budget = Budget::forUser(Auth::id())->findOrFail($id);
|
|
|
|
$validated = $request->validate([
|
|
'amount' => 'sometimes|numeric|min:0.01',
|
|
'notes' => 'nullable|string',
|
|
'is_active' => 'sometimes|boolean',
|
|
]);
|
|
|
|
$budget->update($validated);
|
|
|
|
return response()->json([
|
|
'message' => 'Presupuesto actualizado',
|
|
'data' => $budget->fresh('category'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Eliminar un presupuesto (y de meses futuros)
|
|
*/
|
|
public function destroy($id)
|
|
{
|
|
$budget = Budget::forUser(Auth::id())->findOrFail($id);
|
|
|
|
$categoryId = $budget->category_id;
|
|
$subcategoryId = $budget->subcategory_id;
|
|
$year = $budget->year;
|
|
$month = $budget->month;
|
|
|
|
$costCenterId = $budget->cost_center_id;
|
|
|
|
// Eliminar este e todos os futuros da mesma categoria/subcategoria/centro de custos
|
|
Budget::forUser(Auth::id())
|
|
->where('category_id', $categoryId)
|
|
->where('subcategory_id', $subcategoryId)
|
|
->where('cost_center_id', $costCenterId)
|
|
->where(function($q) use ($year, $month) {
|
|
$q->where('year', '>', $year)
|
|
->orWhere(function($q2) use ($year, $month) {
|
|
$q2->where('year', $year)
|
|
->where('month', '>=', $month);
|
|
});
|
|
})
|
|
->delete();
|
|
|
|
return response()->json([
|
|
'message' => 'Presupuesto eliminado (incluyendo meses futuros)',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Copiar presupuestos al siguiente mes
|
|
*/
|
|
public function copyToNextMonth(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'year' => 'required|integer',
|
|
'month' => 'required|integer|min:1|max:12',
|
|
]);
|
|
|
|
Budget::copyToNextMonth(Auth::id(), $validated['year'], $validated['month']);
|
|
|
|
$nextMonth = $validated['month'] === 12 ? 1 : $validated['month'] + 1;
|
|
$nextYear = $validated['month'] === 12 ? $validated['year'] + 1 : $validated['year'];
|
|
|
|
return response()->json([
|
|
'message' => 'Presupuestos copiados',
|
|
'next_period' => [
|
|
'year' => $nextYear,
|
|
'month' => $nextMonth,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Obtener categorías disponibles para presupuesto
|
|
*/
|
|
public function availableCategories(Request $request)
|
|
{
|
|
$year = $request->get('year', now()->year);
|
|
$month = $request->get('month', now()->month);
|
|
|
|
// Obtener pares (category_id, subcategory_id) já usados no período
|
|
$usedPairs = Budget::forUser(Auth::id())
|
|
->forPeriod($year, $month)
|
|
->get()
|
|
->map(fn($b) => $b->category_id . '_' . ($b->subcategory_id ?? 'null'))
|
|
->toArray();
|
|
|
|
// Categorías padre con tipo expense o both (gastos)
|
|
$categories = Category::where('user_id', Auth::id())
|
|
->whereNull('parent_id')
|
|
->whereIn('type', ['expense', 'both'])
|
|
->with(['subcategories' => function($q) {
|
|
$q->orderBy('name');
|
|
}])
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// Filtrar categorias/subcategorias já usadas
|
|
$available = $categories->map(function($category) use ($usedPairs) {
|
|
$categoryKey = $category->id . '_null';
|
|
|
|
// Filtrar subcategorias não usadas
|
|
$availableSubcategories = $category->subcategories->filter(function($sub) use ($usedPairs, $category) {
|
|
$subKey = $category->id . '_' . $sub->id;
|
|
return !in_array($subKey, $usedPairs);
|
|
})->values();
|
|
|
|
return [
|
|
'id' => $category->id,
|
|
'name' => $category->name,
|
|
'type' => $category->type,
|
|
'color' => $category->color,
|
|
'icon' => $category->icon,
|
|
'is_available' => !in_array($categoryKey, $usedPairs),
|
|
'subcategories' => $availableSubcategories,
|
|
];
|
|
})->filter(function($cat) {
|
|
// Manter categoria se ela mesma está disponível OU se tem subcategorias disponíveis
|
|
return $cat['is_available'] || $cat['subcategories']->count() > 0;
|
|
})->values();
|
|
|
|
return response()->json($available);
|
|
}
|
|
|
|
/**
|
|
* Resumen anual de presupuestos
|
|
*/
|
|
public function yearSummary(Request $request)
|
|
{
|
|
$year = $request->get('year', now()->year);
|
|
|
|
$monthlyData = [];
|
|
|
|
for ($month = 1; $month <= 12; $month++) {
|
|
$budgets = Budget::forUser(Auth::id())
|
|
->forPeriod($year, $month)
|
|
->active()
|
|
->get();
|
|
|
|
$totalBudget = $budgets->sum('amount');
|
|
$totalSpent = $budgets->sum('spent_amount');
|
|
|
|
$monthlyData[] = [
|
|
'month' => $month,
|
|
'month_name' => Carbon::createFromDate($year, $month, 1)->format('M'),
|
|
'budgeted' => round($totalBudget, 2),
|
|
'spent' => round($totalSpent, 2),
|
|
'remaining' => round($totalBudget - $totalSpent, 2),
|
|
'percentage' => $totalBudget > 0 ? round(($totalSpent / $totalBudget) * 100, 1) : 0,
|
|
];
|
|
}
|
|
|
|
return response()->json($monthlyData);
|
|
}
|
|
}
|