webmoney/backend/app/Http/Controllers/Api/ReportController.php
marco 54cccdd095 refactor: migração para desenvolvimento direto no servidor
- Removido README.md padrão do Laravel (backend)
- Removidos scripts de deploy (não mais necessários)
- Atualizado copilot-instructions.md para novo fluxo
- Adicionada documentação de auditoria do servidor
- Sincronizado código de produção com repositório

Novo workflow:
- Trabalhamos diretamente em /root/webmoney (symlink para /var/www/webmoney)
- Mudanças PHP são instantâneas
- Mudanças React requerem 'npm run build'
- Commit após validação funcional
2025-12-19 11:45:32 +01:00

2184 lines
90 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Models\Category;
use App\Models\Account;
use App\Models\CostCenter;
use App\Models\LiabilityAccount;
use App\Models\LiabilityInstallment;
use App\Models\RecurringTemplate;
use App\Models\RecurringInstance;
use App\Models\Budget;
use App\Models\FinancialGoal;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ReportController extends Controller
{
private $userId;
private $primaryCurrency;
private $exchangeRates;
private function init()
{
$this->userId = Auth::id();
$this->setPrimaryCurrency();
$this->loadExchangeRates();
}
private function setPrimaryCurrency()
{
$account = Account::where('user_id', $this->userId)
->where('include_in_total', true)
->orderByDesc('current_balance')
->first();
$this->primaryCurrency = $account->currency ?? 'EUR';
}
private function loadExchangeRates()
{
$this->exchangeRates = [
'EUR' => 1.0, 'USD' => 0.92, 'GBP' => 1.17, 'BRL' => 0.18,
'MXN' => 0.054, 'COP' => 0.00023, 'ARS' => 0.0011, 'CLP' => 0.0010, 'PEN' => 0.25,
];
}
private function convertToPrimaryCurrency($amount, $fromCurrency)
{
if ($fromCurrency === $this->primaryCurrency) return $amount;
$rate = $this->exchangeRates[$fromCurrency] ?? 1;
$primaryRate = $this->exchangeRates[$this->primaryCurrency] ?? 1;
return ($amount * $rate) / $primaryRate;
}
// Condición base para excluir transferencias
private function excludeTransfers()
{
return "(is_transfer IS NULL OR is_transfer = 0)";
}
/**
* Resumen general de reportes con multi-divisa
*/
public function summary(Request $request)
{
$this->init();
$year = $request->get('year', now()->year);
// Datos por moneda
$yearData = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND YEAR(t.effective_date) = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, $year]);
$lastYearData = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND YEAR(t.effective_date) = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, $year - 1]);
// Convertir y sumar
$currentIncome = 0; $currentExpense = 0;
$byCurrency = [];
foreach ($yearData as $row) {
$currentIncome += $this->convertToPrimaryCurrency($row->income, $row->currency);
$currentExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
$byCurrency[$row->currency] = [
'income' => round($row->income, 2),
'expense' => round($row->expense, 2),
'balance' => round($row->income - $row->expense, 2),
];
}
$previousIncome = 0; $previousExpense = 0;
foreach ($lastYearData as $row) {
$previousIncome += $this->convertToPrimaryCurrency($row->income, $row->currency);
$previousExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
return response()->json([
'year' => $year,
'currency' => $this->primaryCurrency,
'current' => [
'income' => round($currentIncome, 2),
'expense' => round($currentExpense, 2),
'balance' => round($currentIncome - $currentExpense, 2),
],
'previous' => [
'income' => round($previousIncome, 2),
'expense' => round($previousExpense, 2),
'balance' => round($previousIncome - $previousExpense, 2),
],
'variation' => [
'income' => $previousIncome > 0 ? round((($currentIncome - $previousIncome) / $previousIncome) * 100, 1) : 0,
'expense' => $previousExpense > 0 ? round((($currentExpense - $previousExpense) / $previousExpense) * 100, 1) : 0,
],
'by_currency' => $byCurrency,
]);
}
/**
* Reporte por categorías con multi-divisa
*/
public function byCategory(Request $request)
{
$this->init();
$startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
$type = $request->get('type', 'debit');
$groupByParent = $request->get('group_by_parent', false);
$parentId = $request->get('parent_id');
// Se filtrar por parent_id, mostra subcategorias dessa categoria pai
if ($parentId) {
$data = DB::select("
SELECT
c.id as category_id,
c.name as category_name,
c.icon,
c.color,
COALESCE(a.currency, 'EUR') as currency,
SUM(ABS(t.amount)) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.type = ?
AND (c.id = ? OR c.parent_id = ?)
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR')
ORDER BY total DESC
", [$this->userId, $startDate, $endDate, $type, $parentId, $parentId]);
}
// Si se quiere agrupar por categoría padre, obtenemos el nombre del padre
else if ($groupByParent) {
$data = DB::select("
SELECT
COALESCE(c.parent_id, c.id) as category_id,
COALESCE(cp.name, c.name) as category_name,
COALESCE(cp.icon, c.icon) as icon,
COALESCE(cp.color, c.color) as color,
COALESCE(a.currency, 'EUR') as currency,
SUM(ABS(t.amount)) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN categories cp ON c.parent_id = cp.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.type = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(c.parent_id, c.id), COALESCE(cp.name, c.name), COALESCE(cp.icon, c.icon), COALESCE(cp.color, c.color), COALESCE(a.currency, 'EUR')
ORDER BY total DESC
", [$this->userId, $startDate, $endDate, $type]);
} else {
// Vista padrão: agrupar por categoria pai (soma transações de subcategorias)
$data = DB::select("
SELECT
COALESCE(c.parent_id, c.id) as category_id,
COALESCE(cp.name, c.name) as category_name,
COALESCE(cp.icon, c.icon) as icon,
COALESCE(cp.color, c.color) as color,
COALESCE(a.currency, 'EUR') as currency,
SUM(ABS(t.amount)) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN categories cp ON c.parent_id = cp.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.type = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(c.parent_id, c.id), COALESCE(cp.name, c.name), COALESCE(cp.icon, c.icon), COALESCE(cp.color, c.color), COALESCE(a.currency, 'EUR')
ORDER BY total DESC
", [$this->userId, $startDate, $endDate, $type]);
}
// Agrupar por categoría y convertir monedas
$byCategory = [];
foreach ($data as $row) {
$catId = $row->category_id ?? 0;
if (!isset($byCategory[$catId])) {
$byCategory[$catId] = [
'category_id' => $catId,
'category_name' => $row->category_name ?? 'Sin categoría',
'icon' => $row->icon ?? 'bi-tag',
'color' => $row->color ?? '#6b7280',
'total_converted' => 0,
'by_currency' => [],
];
}
$byCategory[$catId]['by_currency'][$row->currency] = round($row->total, 2);
$byCategory[$catId]['total_converted'] += $this->convertToPrimaryCurrency($row->total, $row->currency);
}
// Ordenar y calcular porcentajes
usort($byCategory, fn($a, $b) => $b['total_converted'] <=> $a['total_converted']);
$grandTotal = array_sum(array_column($byCategory, 'total_converted'));
$result = array_map(function($cat) use ($grandTotal) {
$cat['total'] = round($cat['total_converted'], 2);
$cat['percentage'] = $grandTotal > 0 ? round(($cat['total_converted'] / $grandTotal) * 100, 1) : 0;
unset($cat['total_converted']);
return $cat;
}, $byCategory);
return response()->json([
'data' => array_values($result),
'total' => round($grandTotal, 2),
'currency' => $this->primaryCurrency,
'period' => ['start' => $startDate, 'end' => $endDate],
]);
}
/**
* Reporte por centro de costos (3 niveles: centro -> categoría -> subcategoría)
*/
public function byCostCenter(Request $request)
{
$this->init();
$startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
$costCenterId = $request->get('cost_center_id');
$categoryId = $request->get('category_id');
// Nível 3: Subcategorias de uma categoria específica de um centro de custo
if ($costCenterId !== null && $categoryId !== null) {
$data = DB::select("
SELECT
c.id as category_id,
c.name as category_name,
c.icon as category_icon,
c.color as category_color,
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND " . ($costCenterId == 0 ? "t.cost_center_id IS NULL" : "t.cost_center_id = ?") . "
AND (c.id = ? OR c.parent_id = ?)
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR')
ORDER BY expense DESC
", $costCenterId == 0
? [$this->userId, $startDate, $endDate, $categoryId, $categoryId]
: [$this->userId, $startDate, $endDate, $costCenterId, $categoryId, $categoryId]
);
}
// Nível 2: Categorias pai de um centro de custo específico
else if ($costCenterId !== null) {
$data = DB::select("
SELECT
COALESCE(c.parent_id, c.id) as category_id,
COALESCE(cp.name, c.name) as category_name,
COALESCE(cp.icon, c.icon) as category_icon,
COALESCE(cp.color, c.color) as category_color,
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN categories cp ON c.parent_id = cp.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND " . ($costCenterId == 0 ? "t.cost_center_id IS NULL" : "t.cost_center_id = ?") . "
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(c.parent_id, c.id), COALESCE(cp.name, c.name), COALESCE(cp.icon, c.icon), COALESCE(cp.color, c.color), COALESCE(a.currency, 'EUR')
ORDER BY expense DESC
", $costCenterId == 0
? [$this->userId, $startDate, $endDate]
: [$this->userId, $startDate, $endDate, $costCenterId]
);
}
// Nível 1: Centros de custo
else {
$data = DB::select("
SELECT
COALESCE(cc.id, 0) as cost_center_id,
COALESCE(cc.name, 'Sem classificar') as cost_center_name,
COALESCE(cc.color, '#6b7280') as color,
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(cc.id, 0), COALESCE(cc.name, 'Sem classificar'), COALESCE(cc.color, '#6b7280'), COALESCE(a.currency, 'EUR')
ORDER BY expense DESC
", [$this->userId, $startDate, $endDate]);
}
// Agrupar y procesar resultados
$results = [];
// Nível 3: Subcategorias
if ($costCenterId !== null && $categoryId !== null) {
foreach ($data as $row) {
$id = $row->category_id ?? 0;
if (!isset($results[$id])) {
$results[$id] = [
'id' => $id == 0 ? null : $id,
'name' => $row->category_name ?? 'Sin categoría',
'icon' => $row->category_icon ?? 'bi-tag',
'color' => $row->category_color ?? '#6b7280',
'income_converted' => 0,
'expense_converted' => 0,
];
}
$results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency);
$results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
}
// Nível 2: Categorias
else if ($costCenterId !== null) {
foreach ($data as $row) {
$id = $row->category_id ?? 0;
if (!isset($results[$id])) {
$results[$id] = [
'id' => $id == 0 ? null : $id,
'name' => $row->category_name ?? 'Sin categoría',
'icon' => $row->category_icon ?? 'bi-tag',
'color' => $row->category_color ?? '#6b7280',
'income_converted' => 0,
'expense_converted' => 0,
];
}
$results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency);
$results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
}
// Nível 1: Centros de custo
else {
foreach ($data as $row) {
$id = $row->cost_center_id;
if (!isset($results[$id])) {
$results[$id] = [
'id' => $id == 0 ? null : $id,
'name' => $row->cost_center_name,
'color' => $row->color,
'income_converted' => 0,
'expense_converted' => 0,
];
}
$results[$id]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency);
$results[$id]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
}
$result = array_map(function($item) {
return [
'id' => $item['id'],
'name' => $item['name'],
'icon' => $item['icon'] ?? null,
'color' => $item['color'],
'income' => round($item['income_converted'], 2),
'expense' => round($item['expense_converted'], 2),
'balance' => round($item['income_converted'] - $item['expense_converted'], 2),
];
}, $results);
usort($result, fn($a, $b) => $b['expense'] <=> $a['expense']);
return response()->json([
'data' => array_values($result),
'currency' => $this->primaryCurrency,
'total_income' => round(array_sum(array_column($result, 'income')), 2),
'total_expense' => round(array_sum(array_column($result, 'expense')), 2),
]);
}
/**
* Reporte de evolución mensual con multi-divisa
*/
public function monthlyEvolution(Request $request)
{
$this->init();
$months = $request->get('months', 12);
$startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d');
$data = DB::select("
SELECT
DATE_FORMAT(t.effective_date, '%Y-%m') as month,
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date >= ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY DATE_FORMAT(t.effective_date, '%Y-%m'), COALESCE(a.currency, 'EUR')
ORDER BY month
", [$this->userId, $startDate]);
// Agrupar por mes y convertir
$byMonth = [];
foreach ($data as $row) {
if (!isset($byMonth[$row->month])) {
$byMonth[$row->month] = [
'month' => $row->month,
'income_converted' => 0,
'expense_converted' => 0,
'by_currency' => [],
];
}
$byMonth[$row->month]['by_currency'][$row->currency] = [
'income' => round($row->income, 2),
'expense' => round($row->expense, 2),
];
$byMonth[$row->month]['income_converted'] += $this->convertToPrimaryCurrency($row->income, $row->currency);
$byMonth[$row->month]['expense_converted'] += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
$result = array_map(function($item) {
$balance = $item['income_converted'] - $item['expense_converted'];
$savingsRate = $item['income_converted'] > 0 ? round(($balance / $item['income_converted']) * 100, 1) : 0;
return [
'month' => $item['month'],
'month_label' => Carbon::parse($item['month'] . '-01')->isoFormat('MMM YYYY'),
'income' => round($item['income_converted'], 2),
'expense' => round($item['expense_converted'], 2),
'balance' => round($balance, 2),
'savings_rate' => $savingsRate,
'by_currency' => $item['by_currency'],
];
}, $byMonth);
$resultArray = array_values($result);
$avgIncome = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'income')) / count($resultArray) : 0;
$avgExpense = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'expense')) / count($resultArray) : 0;
$avgBalance = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'balance')) / count($resultArray) : 0;
$avgSavings = count($resultArray) > 0 ? array_sum(array_column($resultArray, 'savings_rate')) / count($resultArray) : 0;
return response()->json([
'data' => $resultArray,
'currency' => $this->primaryCurrency,
'averages' => [
'income' => round($avgIncome, 2),
'expense' => round($avgExpense, 2),
'balance' => round($avgBalance, 2),
'savings_rate' => round($avgSavings, 1),
],
]);
}
/**
* Reporte de gastos por día de la semana con multi-divisa
*/
public function byDayOfWeek(Request $request)
{
$this->init();
$months = $request->get('months', 6);
$startDate = now()->subMonths($months)->format('Y-m-d');
$data = DB::select("
SELECT
DAYOFWEEK(t.effective_date) as day_num,
COALESCE(a.currency, 'EUR') as currency,
COUNT(*) as count,
SUM(ABS(t.amount)) as total
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.type = 'debit'
AND t.effective_date >= ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY DAYOFWEEK(t.effective_date), COALESCE(a.currency, 'EUR')
", [$this->userId, $startDate]);
// Agrupar por día
$byDay = [];
for ($i = 1; $i <= 7; $i++) {
$byDay[$i] = ['count' => 0, 'total_converted' => 0, 'by_currency' => []];
}
foreach ($data as $row) {
$byDay[$row->day_num]['count'] += $row->count;
$byDay[$row->day_num]['total_converted'] += $this->convertToPrimaryCurrency($row->total, $row->currency);
if (!isset($byDay[$row->day_num]['by_currency'][$row->currency])) {
$byDay[$row->day_num]['by_currency'][$row->currency] = 0;
}
$byDay[$row->day_num]['by_currency'][$row->currency] += round($row->total, 2);
}
// Mapeo de días que el frontend traducirá
$dayKeys = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
$result = [];
foreach ($byDay as $dayNum => $info) {
$result[] = [
'day_key' => $dayKeys[$dayNum - 1],
'day_num' => $dayNum,
'count' => $info['count'],
'total' => round($info['total_converted'], 2),
'average' => $info['count'] > 0 ? round($info['total_converted'] / $info['count'], 2) : 0,
'by_currency' => $info['by_currency'],
];
}
return response()->json([
'data' => $result,
'currency' => $this->primaryCurrency,
]);
}
/**
* Top gastos con multi-divisa
*/
public function topExpenses(Request $request)
{
$this->init();
$startDate = $request->get('start_date', now()->startOfMonth()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
$limit = $request->get('limit', 20);
$data = DB::select("
SELECT
t.id,
t.description,
ABS(t.amount) as amount,
t.effective_date,
COALESCE(a.currency, 'EUR') as currency,
c.name as category_name,
c.icon as category_icon,
c.color as category_color,
a.name as account_name,
cc.name as cost_center_name
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id
WHERE t.user_id = ?
AND t.type = 'debit'
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
ORDER BY ABS(t.amount) DESC
LIMIT ?
", [$this->userId, $startDate, $endDate, $limit]);
$totalConverted = 0;
$result = array_map(function($row) use (&$totalConverted) {
$converted = $this->convertToPrimaryCurrency($row->amount, $row->currency);
$totalConverted += $converted;
return [
'id' => $row->id,
'description' => $row->description,
'amount' => round($row->amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'date' => $row->effective_date,
'category' => $row->category_name,
'category_icon' => $row->category_icon,
'category_color' => $row->category_color,
'account' => $row->account_name,
'cost_center' => $row->cost_center_name,
];
}, $data);
return response()->json([
'data' => $result,
'total' => round($totalConverted, 2),
'currency' => $this->primaryCurrency,
'period' => ['start' => $startDate, 'end' => $endDate],
]);
}
/**
* Comparativa de períodos con multi-divisa
*/
public function comparePeriods(Request $request)
{
$this->init();
$period1Start = $request->get('period1_start');
$period1End = $request->get('period1_end');
$period2Start = $request->get('period2_start');
$period2End = $request->get('period2_end');
if (!$period1Start) {
$period1Start = now()->startOfMonth()->format('Y-m-d');
$period1End = now()->endOfMonth()->format('Y-m-d');
$period2Start = now()->subMonth()->startOfMonth()->format('Y-m-d');
$period2End = now()->subMonth()->endOfMonth()->format('Y-m-d');
}
$getPeriodData = function($start, $end) {
$data = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense,
COUNT(*) as transactions
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, $start, $end]);
$income = 0;
$expense = 0;
$transactions = 0;
$byCurrency = [];
foreach ($data as $row) {
$income += $this->convertToPrimaryCurrency($row->income, $row->currency);
$expense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
$transactions += $row->transactions;
$byCurrency[$row->currency] = [
'income' => round($row->income, 2),
'expense' => round($row->expense, 2),
];
}
return [
'income' => round($income, 2),
'expense' => round($expense, 2),
'balance' => round($income - $expense, 2),
'transactions' => $transactions,
'by_currency' => $byCurrency,
];
};
$period1Data = $getPeriodData($period1Start, $period1End);
$period2Data = $getPeriodData($period2Start, $period2End);
$calcVariation = function($current, $previous) {
return $previous > 0 ? round((($current - $previous) / $previous) * 100, 1) : 0;
};
return response()->json([
'period1' => array_merge([
'label' => Carbon::parse($period1Start)->isoFormat('MMM YYYY'),
'start' => $period1Start,
'end' => $period1End,
], $period1Data),
'period2' => array_merge([
'label' => Carbon::parse($period2Start)->isoFormat('MMM YYYY'),
'start' => $period2Start,
'end' => $period2End,
], $period2Data),
'variation' => [
'income' => $calcVariation($period1Data['income'], $period2Data['income']),
'expense' => $calcVariation($period1Data['expense'], $period2Data['expense']),
'balance' => $calcVariation($period1Data['balance'], $period2Data['balance']),
],
'currency' => $this->primaryCurrency,
]);
}
/**
* Reporte de cuentas con multi-divisa
*/
public function accountsReport(Request $request)
{
$this->init();
$accounts = Account::where('user_id', $this->userId)->get();
$result = [];
$totalBalanceConverted = 0;
foreach ($accounts as $account) {
// Movimientos del mes (excluyendo transferencias)
$monthStats = DB::select("
SELECT
SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
FROM transactions
WHERE account_id = ?
AND MONTH(effective_date) = ?
AND YEAR(effective_date) = ?
AND deleted_at IS NULL
AND {$this->excludeTransfers()}
", [$account->id, now()->month, now()->year]);
$stats = $monthStats[0] ?? (object)['income' => 0, 'expense' => 0];
// Últimas transacciones
$recent = DB::select("
SELECT id, description, amount, type, effective_date
FROM transactions
WHERE account_id = ?
AND deleted_at IS NULL
ORDER BY effective_date DESC
LIMIT 5
", [$account->id]);
$balanceConverted = $this->convertToPrimaryCurrency($account->current_balance, $account->currency);
if ($account->include_in_total) {
$totalBalanceConverted += $balanceConverted;
}
$result[] = [
'id' => $account->id,
'name' => $account->name,
'type' => $account->type,
'currency' => $account->currency,
'balance' => round($account->current_balance, 2),
'balance_converted' => round($balanceConverted, 2),
'include_in_total' => $account->include_in_total,
'month_income' => round($stats->income ?? 0, 2),
'month_expense' => round($stats->expense ?? 0, 2),
'recent_activity' => array_map(function($t) {
return [
'id' => $t->id,
'description' => $t->description,
'amount' => $t->amount,
'type' => $t->type,
'date' => $t->effective_date,
];
}, $recent),
];
}
return response()->json([
'accounts' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_accounts' => count($accounts),
'total_balance_converted' => round($totalBalanceConverted, 2),
],
]);
}
/**
* Proyección de gastos con multi-divisa
*/
public function projection(Request $request)
{
$this->init();
$months = $request->get('months', 3);
$startDate = now()->subMonths($months)->startOfMonth()->format('Y-m-d');
$endMonthStart = now()->startOfMonth()->format('Y-m-d');
$today = now()->format('Y-m-d');
$endOfMonth = now()->endOfMonth()->format('Y-m-d');
// =========================================================================
// 1. HISTÓRICO: Média mensal dos últimos N meses
// =========================================================================
$historical = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) / ? as monthly_income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) / ? as monthly_expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date >= ?
AND t.effective_date < ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$months, $months, $this->userId, $startDate, $endMonthStart]);
$histIncome = 0;
$histExpense = 0;
foreach ($historical as $row) {
$histIncome += $this->convertToPrimaryCurrency($row->monthly_income, $row->currency);
$histExpense += $this->convertToPrimaryCurrency($row->monthly_expense, $row->currency);
}
// =========================================================================
// 2. MÊS ATUAL: Transações já realizadas (effective_date)
// =========================================================================
$current = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND MONTH(t.effective_date) = ?
AND YEAR(t.effective_date) = ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, now()->month, now()->year]);
$currIncome = 0;
$currExpense = 0;
foreach ($current as $row) {
$currIncome += $this->convertToPrimaryCurrency($row->income, $row->currency);
$currExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
}
$daysElapsed = max(1, now()->day);
$daysInMonth = now()->daysInMonth;
$daysRemaining = $daysInMonth - $daysElapsed;
// =========================================================================
// 3. RECORRÊNCIAS PENDENTES: Até o fim do mês
// =========================================================================
$recurringIncome = 0;
$recurringExpense = 0;
$recurrences = DB::select("
SELECT
rt.id,
rt.name,
rt.planned_amount,
rt.type,
rt.frequency,
rt.day_of_month,
rt.start_date,
rt.end_date,
rt.last_generated_date,
COALESCE(a.currency, 'EUR') as currency
FROM recurring_templates rt
LEFT JOIN accounts a ON rt.account_id = a.id
WHERE rt.user_id = ?
AND rt.is_active = 1
AND rt.deleted_at IS NULL
AND (rt.end_date IS NULL OR rt.end_date >= ?)
", [$this->userId, $today]);
foreach ($recurrences as $rec) {
// Verificar se ainda vai executar este mês
$nextDates = $this->getNextRecurrenceDates($rec, $today, $endOfMonth);
foreach ($nextDates as $nextDate) {
$amount = abs($rec->planned_amount);
$converted = $this->convertToPrimaryCurrency($amount, $rec->currency);
if ($rec->type === 'credit') {
$recurringIncome += $converted;
} else {
$recurringExpense += $converted;
}
}
}
// =========================================================================
// 4. PASSIVOS PENDENTES: Parcelas até o fim do mês
// =========================================================================
$liabilityExpense = 0;
$pendingInstallments = DB::select("
SELECT
li.installment_amount as amount,
la.currency
FROM liability_installments li
JOIN liability_accounts la ON li.liability_account_id = la.id
WHERE la.user_id = ?
AND li.status = 'pending'
AND li.due_date >= ?
AND li.due_date <= ?
AND li.deleted_at IS NULL
", [$this->userId, $today, $endOfMonth]);
foreach ($pendingInstallments as $row) {
$liabilityExpense += $this->convertToPrimaryCurrency(abs($row->amount), $row->currency);
}
// =========================================================================
// 5. TRANSAÇÕES EM ATRASO (overdue)
// =========================================================================
$overdueIncome = 0;
$overdueExpense = 0;
// Transações pendentes vencidas (status='pending' e planned_date < hoje)
$overduePendingTransactions = DB::select("
SELECT
t.amount,
t.type,
COALESCE(a.currency, 'EUR') as currency
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.status = 'pending'
AND t.planned_date < ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
", [$this->userId, $today]);
foreach ($overduePendingTransactions as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
if ($row->type === 'credit') {
$overdueIncome += $converted;
} else {
$overdueExpense += $converted;
}
}
// Parcelas de passivos vencidas
$overdueInstallments = DB::select("
SELECT li.installment_amount as amount, la.currency
FROM liability_installments li
JOIN liability_accounts la ON li.liability_account_id = la.id
WHERE la.user_id = ?
AND li.status != 'paid'
AND li.due_date < ?
AND li.deleted_at IS NULL
", [$this->userId, $today]);
foreach ($overdueInstallments as $row) {
$overdueExpense += $this->convertToPrimaryCurrency(abs($row->amount), $row->currency);
}
// Recorrências que deveriam ter executado mas não executaram
$overdueRecurrences = $this->getOverdueRecurrences($today);
foreach ($overdueRecurrences as $rec) {
$overdueExpense += $this->convertToPrimaryCurrency($rec['amount'], $rec['currency']);
}
// Instâncias de recorrências pendentes vencidas
$overdueRecurringInstances = DB::select("
SELECT
ri.planned_amount as amount,
rt.type,
COALESCE(a.currency, 'EUR') as currency
FROM recurring_instances ri
JOIN recurring_templates rt ON ri.recurring_template_id = rt.id
LEFT JOIN accounts a ON rt.account_id = a.id
WHERE ri.user_id = ?
AND ri.status = 'pending'
AND ri.due_date < ?
AND ri.deleted_at IS NULL
", [$this->userId, $today]);
foreach ($overdueRecurringInstances as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
if ($row->type === 'credit') {
$overdueIncome += $converted;
} else {
$overdueExpense += $converted;
}
}
// =========================================================================
// 6. TRANSAÇÕES PENDENTES ATÉ O FIM DO MÊS (planned_date entre hoje e fim do mês)
// =========================================================================
$pendingIncome = 0;
$pendingExpense = 0;
$pendingTransactions = DB::select("
SELECT
t.amount,
t.type,
COALESCE(a.currency, 'EUR') as currency
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.status = 'pending'
AND t.planned_date >= ?
AND t.planned_date <= ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
", [$this->userId, $today, $endOfMonth]);
foreach ($pendingTransactions as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
if ($row->type === 'credit') {
$pendingIncome += $converted;
} else {
$pendingExpense += $converted;
}
}
// =========================================================================
// 7. CÁLCULOS FINAIS DA PROJEÇÃO
// =========================================================================
// Projeção simples (extrapolação linear)
$simpleProjectedExpense = ($currExpense / $daysElapsed) * $daysInMonth;
$simpleProjectedIncome = ($currIncome / $daysElapsed) * $daysInMonth;
// Projeção inteligente: realizado + pendente (todos os tipos)
$smartProjectedIncome = $currIncome + $recurringIncome + $overdueIncome + $pendingIncome;
$smartProjectedExpense = $currExpense + $recurringExpense + $liabilityExpense + $overdueExpense + $pendingExpense;
return response()->json([
'historical_average' => [
'income' => round($histIncome, 2),
'expense' => round($histExpense, 2),
'balance' => round($histIncome - $histExpense, 2),
],
'current_month' => [
'income' => round($currIncome, 2),
'expense' => round($currExpense, 2),
'balance' => round($currIncome - $currExpense, 2),
'days_elapsed' => $daysElapsed,
'days_remaining' => $daysRemaining,
],
'pending_this_month' => [
'recurring_income' => round($recurringIncome, 2),
'recurring_expense' => round($recurringExpense, 2),
'liability_installments' => round($liabilityExpense, 2),
'pending_income' => round($pendingIncome, 2),
'pending_expense' => round($pendingExpense, 2),
'total_pending_income' => round($recurringIncome + $pendingIncome, 2),
'total_pending_expense' => round($recurringExpense + $liabilityExpense + $pendingExpense, 2),
],
'overdue' => [
'income' => round($overdueIncome, 2),
'expense' => round($overdueExpense, 2),
'total' => round($overdueExpense - $overdueIncome, 2),
],
'projection' => [
// Valores principais (usa projeção inteligente)
'income' => round($smartProjectedIncome, 2),
'expense' => round($smartProjectedExpense, 2),
'balance' => round($smartProjectedIncome - $smartProjectedExpense, 2),
// Projeção simples (extrapolação linear)
'simple' => [
'income' => round($simpleProjectedIncome, 2),
'expense' => round($simpleProjectedExpense, 2),
'balance' => round($simpleProjectedIncome - $simpleProjectedExpense, 2),
],
// Projeção inteligente (realizado + recorrências + passivos)
'smart' => [
'income' => round($smartProjectedIncome, 2),
'expense' => round($smartProjectedExpense, 2),
'balance' => round($smartProjectedIncome - $smartProjectedExpense, 2),
],
],
'vs_average' => [
'income' => $histIncome > 0 ? round((($smartProjectedIncome - $histIncome) / $histIncome) * 100, 1) : 0,
'expense' => $histExpense > 0 ? round((($smartProjectedExpense - $histExpense) / $histExpense) * 100, 1) : 0,
],
'currency' => $this->primaryCurrency,
]);
}
/**
* Projeção de saldo para gráfico com dados diários/semanais
*/
public function projectionChart(Request $request)
{
$this->init();
$months = (int) min(max($request->input('months', 3), 1), 12);
$today = Carbon::today();
$endDate = $today->copy()->addMonths($months);
// Obter saldo atual total das contas (valor REAL de hoje)
$currentBalance = DB::selectOne("
SELECT COALESCE(SUM(current_balance), 0) as total
FROM accounts
WHERE user_id = ?
AND include_in_total = 1
AND deleted_at IS NULL
", [$this->userId])->total ?? 0;
// Gerar pontos de dados (diário para até 3 meses, semanal para mais)
$dataPoints = [];
$runningBalance = (float) $currentBalance;
$interval = $months <= 3 ? 'day' : 'week';
$current = $today->copy();
// =========================================================================
// BUSCAR INSTÂNCIAS DE RECORRÊNCIAS PENDENTES (incluindo VENCIDAS)
// =========================================================================
$recurringInstances = DB::select("
SELECT
ri.due_date as date,
ri.planned_amount as amount,
rt.type,
COALESCE(a.currency, 'EUR') as currency,
CASE WHEN ri.due_date < ? THEN 1 ELSE 0 END as is_overdue
FROM recurring_instances ri
JOIN recurring_templates rt ON ri.recurring_template_id = rt.id
LEFT JOIN accounts a ON rt.account_id = a.id
WHERE ri.user_id = ?
AND ri.status = 'pending'
AND ri.due_date <= ?
AND ri.deleted_at IS NULL
ORDER BY ri.due_date
", [$today->toDateString(), $this->userId, $endDate->toDateString()]);
// =========================================================================
// BUSCAR TRANSAÇÕES PENDENTES/AGENDADAS (incluindo VENCIDAS)
// =========================================================================
$scheduledTransactions = DB::select("
SELECT
COALESCE(t.planned_date, t.effective_date) as date,
t.amount,
t.type,
COALESCE(a.currency, 'EUR') as currency,
CASE WHEN COALESCE(t.planned_date, t.effective_date) < ? THEN 1 ELSE 0 END as is_overdue
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.status IN ('pending', 'scheduled')
AND COALESCE(t.planned_date, t.effective_date) <= ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
ORDER BY COALESCE(t.planned_date, t.effective_date)
", [$today->toDateString(), $this->userId, $endDate->toDateString()]);
// =========================================================================
// BUSCAR PARCELAS DE PASSIVOS PENDENTES (incluindo VENCIDAS)
// =========================================================================
$liabilityInstallments = DB::select("
SELECT
li.due_date as date,
li.installment_amount as amount,
la.currency,
CASE WHEN li.due_date < ? THEN 1 ELSE 0 END as is_overdue
FROM liability_installments li
JOIN liability_accounts la ON li.liability_account_id = la.id
WHERE la.user_id = ?
AND li.status = 'pending'
AND li.due_date <= ?
AND li.deleted_at IS NULL
ORDER BY li.due_date
", [$today->toDateString(), $this->userId, $endDate->toDateString()]);
// =========================================================================
// PROCESSAR VENCIDOS: aplicar no saldo inicial
// =========================================================================
$overdueImpact = 0;
foreach ($recurringInstances as $ri) {
if ($ri->is_overdue) {
$amount = $this->convertToPrimaryCurrency(abs($ri->amount), $ri->currency);
if ($ri->type === 'credit') {
$overdueImpact += $amount;
} else {
$overdueImpact -= $amount;
}
}
}
foreach ($scheduledTransactions as $tx) {
if ($tx->is_overdue) {
$amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency);
if ($tx->type === 'credit') {
$overdueImpact += $amount;
} else {
$overdueImpact -= $amount;
}
}
}
foreach ($liabilityInstallments as $inst) {
if ($inst->is_overdue) {
$amount = $this->convertToPrimaryCurrency(abs($inst->amount), $inst->currency);
$overdueImpact -= $amount;
}
}
// Aplicar impacto dos vencidos ao saldo inicial
$runningBalance += $overdueImpact;
// =========================================================================
// PONTO INICIAL = SALDO ATUAL (sem modificações)
// =========================================================================
$dataPoints[] = [
'date' => $today->toDateString(),
'balance' => round($runningBalance, 2),
'label' => $today->format('d/m'),
'isToday' => true,
];
// =========================================================================
// GERAR PROJEÇÃO FUTURA
// =========================================================================
while ($current->lt($endDate)) {
if ($interval === 'day') {
$current->addDay();
} else {
$current->addWeek();
}
if ($current->gt($endDate)) break;
$periodStart = $dataPoints[count($dataPoints) - 1]['date'];
$periodEnd = $current->toDateString();
// Processar instâncias de recorrências neste período (SOMENTE futuras, não vencidas)
foreach ($recurringInstances as $ri) {
if (!$ri->is_overdue && $ri->date > $periodStart && $ri->date <= $periodEnd) {
$amount = $this->convertToPrimaryCurrency(abs($ri->amount), $ri->currency);
if ($ri->type === 'credit') {
$runningBalance += $amount;
} else {
$runningBalance -= $amount;
}
}
}
// Processar transações agendadas neste período (SOMENTE futuras, não vencidas)
foreach ($scheduledTransactions as $tx) {
if (!$tx->is_overdue && $tx->date > $periodStart && $tx->date <= $periodEnd) {
$amount = $this->convertToPrimaryCurrency(abs($tx->amount), $tx->currency);
if ($tx->type === 'credit') {
$runningBalance += $amount;
} else {
$runningBalance -= $amount;
}
}
}
// Processar parcelas de passivos neste período (SOMENTE futuras, não vencidas)
foreach ($liabilityInstallments as $inst) {
if (!$inst->is_overdue && $inst->date > $periodStart && $inst->date <= $periodEnd) {
$amount = $this->convertToPrimaryCurrency(abs($inst->amount), $inst->currency);
$runningBalance -= $amount;
}
}
$dataPoints[] = [
'date' => $current->toDateString(),
'balance' => round($runningBalance, 2),
'label' => $current->format('d/m'),
'isToday' => false,
];
}
// Calcular estatísticas
$balances = array_column($dataPoints, 'balance');
$minBalance = min($balances);
$maxBalance = max($balances);
$avgBalance = array_sum($balances) / count($balances);
$finalBalance = end($balances);
// Detectar mês de saldo negativo (se houver)
$negativeMonth = null;
foreach ($dataPoints as $point) {
if ($point['balance'] < 0) {
$negativeMonth = Carbon::parse($point['date'])->format('M Y');
break;
}
}
return response()->json([
'data' => $dataPoints,
'summary' => [
'current_balance' => round($currentBalance, 2),
'final_balance' => round($finalBalance, 2),
'min_balance' => round($minBalance, 2),
'max_balance' => round($maxBalance, 2),
'avg_balance' => round($avgBalance, 2),
'change' => round($finalBalance - $currentBalance, 2),
'change_percent' => $currentBalance != 0 ? round((($finalBalance - $currentBalance) / abs($currentBalance)) * 100, 1) : 0,
'negative_month' => $negativeMonth,
'overdue_impact' => round($overdueImpact, 2),
],
'period' => [
'start' => $today->toDateString(),
'end' => $endDate->toDateString(),
'months' => $months,
'interval' => $interval,
'total_points' => count($dataPoints),
],
'currency' => $this->primaryCurrency,
]);
}
/**
* Reporte de recurrencias con multi-divisa
*/
public function recurringReport(Request $request)
{
$this->init();
$templates = RecurringTemplate::where('user_id', $this->userId)
->where('is_active', true)
->with(['category', 'account'])
->get();
$monthlyIncomeConverted = 0;
$monthlyExpenseConverted = 0;
$byCurrency = [];
$result = $templates->map(function($t) use (&$monthlyIncomeConverted, &$monthlyExpenseConverted, &$byCurrency) {
$currency = $t->account ? $t->account->currency : 'EUR';
// CORRIGIDO: usar planned_amount em vez de amount
$amount = abs($t->planned_amount ?? 0);
// Converter para valor mensal baseado na frequência
$monthlyAmount = $this->convertToMonthlyAmount($amount, $t->frequency);
$converted = $this->convertToPrimaryCurrency($monthlyAmount, $currency);
if (!isset($byCurrency[$currency])) {
$byCurrency[$currency] = ['income' => 0, 'expense' => 0];
}
if ($t->type === 'credit') {
$monthlyIncomeConverted += $converted;
$byCurrency[$currency]['income'] += $monthlyAmount;
} else {
$monthlyExpenseConverted += $converted;
$byCurrency[$currency]['expense'] += $monthlyAmount;
}
return [
'id' => $t->id,
// CORRIGIDO: usar name em vez de description
'description' => $t->name ?? $t->transaction_description,
'amount' => round($amount, 2),
'monthly_amount' => round($monthlyAmount, 2),
'amount_converted' => round($converted, 2),
'currency' => $currency,
'type' => $t->type,
'frequency' => $t->frequency,
'category' => $t->category ? $t->category->name : null,
'category_icon' => $t->category ? $t->category->icon : null,
'category_color' => $t->category ? $t->category->color : null,
'next_date' => $t->next_occurrence_date,
'account' => $t->account ? $t->account->name : null,
];
});
return response()->json([
'templates' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_recurring' => $templates->count(),
'monthly_income' => round($monthlyIncomeConverted, 2),
'monthly_expense' => round($monthlyExpenseConverted, 2),
'net_recurring' => round($monthlyIncomeConverted - $monthlyExpenseConverted, 2),
'by_currency' => $byCurrency,
],
]);
}
/**
* Converte um valor para equivalente mensal baseado na frequência
*/
private function convertToMonthlyAmount(float $amount, string $frequency): float
{
return match($frequency) {
'daily' => $amount * 30,
'weekly' => $amount * 4.33,
'biweekly' => $amount * 2.17,
'monthly' => $amount,
'bimonthly' => $amount / 2,
'quarterly' => $amount / 3,
'semiannual' => $amount / 6,
'annual' => $amount / 12,
default => $amount,
};
}
/**
* Reporte de pasivos/deudas
*/
public function liabilities(Request $request)
{
$this->init();
$liabilities = LiabilityAccount::where('user_id', $this->userId)
->with(['installments' => function($q) {
$q->orderBy('due_date');
}])
->get();
$totalDebtConverted = 0;
$totalPaidConverted = 0;
$totalPendingConverted = 0;
$result = $liabilities->map(function($l) use (&$totalDebtConverted, &$totalPaidConverted, &$totalPendingConverted) {
$currency = $l->currency ?? 'EUR';
$totalAmount = $l->total_contract_value ?? 0;
// Usar installment_amount en lugar de amount
$paidAmount = $l->installments->where('status', 'paid')->sum('installment_amount');
$pendingAmount = $l->installments->where('status', '!=', 'paid')->sum('installment_amount');
$totalDebtConverted += $this->convertToPrimaryCurrency($totalAmount, $currency);
$totalPaidConverted += $this->convertToPrimaryCurrency($paidAmount, $currency);
$totalPendingConverted += $this->convertToPrimaryCurrency($pendingAmount, $currency);
// Próxima cuota pendiente
$nextInstallment = $l->installments->where('status', '!=', 'paid')->sortBy('due_date')->first();
// Cuotas vencidas
$overdueInstallments = $l->installments
->where('status', '!=', 'paid')
->where('due_date', '<', now()->format('Y-m-d'))
->count();
return [
'id' => $l->id,
'name' => $l->name,
'type' => $l->type,
'currency' => $currency,
'total_amount' => round($totalAmount, 2),
'paid_amount' => round($paidAmount, 2),
'pending_amount' => round($pendingAmount, 2),
'progress' => $totalAmount > 0 ? round(($paidAmount / $totalAmount) * 100, 1) : 0,
'total_installments' => $l->installments->count(),
'paid_installments' => $l->installments->where('status', 'paid')->count(),
'overdue_installments' => $overdueInstallments,
'next_installment' => $nextInstallment ? [
'amount' => round($nextInstallment->installment_amount, 2),
'due_date' => $nextInstallment->due_date,
'is_overdue' => $nextInstallment->due_date < now()->format('Y-m-d'),
] : null,
'start_date' => $l->start_date,
'end_date' => $l->end_date,
];
});
return response()->json([
'data' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_liabilities' => $liabilities->count(),
'total_debt' => round($totalDebtConverted, 2),
'total_paid' => round($totalPaidConverted, 2),
'total_pending' => round($totalPendingConverted, 2),
'progress' => $totalDebtConverted > 0 ? round(($totalPaidConverted / $totalDebtConverted) * 100, 1) : 0,
],
]);
}
/**
* Transacciones futuras programadas
* Incluye: transacciones pending/scheduled, cuotas de pasivos, y proyecciones de recurrencias
*/
public function futureTransactions(Request $request)
{
$this->init();
$days = (int) $request->get('days', 30);
$endDate = now()->addDays($days)->format('Y-m-d');
$today = now()->format('Y-m-d');
$result = [];
$totalIncomeConverted = 0;
$totalExpenseConverted = 0;
// 1. Transacciones pendientes/scheduled (usando planned_date)
$pendingTransactions = DB::select("
SELECT
t.id,
t.description,
COALESCE(t.planned_amount, t.amount) as amount,
t.type,
t.planned_date as date,
t.status,
COALESCE(a.currency, 'EUR') as currency,
a.name as account_name,
c.name as category_name,
c.icon as category_icon,
cc.name as cost_center_name
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN cost_centers cc ON t.cost_center_id = cc.id
WHERE t.user_id = ?
AND t.status IN ('pending', 'scheduled')
AND t.planned_date >= ?
AND t.planned_date <= ?
AND t.deleted_at IS NULL
ORDER BY t.planned_date ASC
", [$this->userId, $today, $endDate]);
foreach ($pendingTransactions as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
if ($row->type === 'credit') {
$totalIncomeConverted += $converted;
} else {
$totalExpenseConverted += $converted;
}
$result[] = [
'id' => $row->id,
'description' => $row->description,
'amount' => round($amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'type' => $row->type,
'source_type' => 'transaction',
'status' => $row->status,
'date' => $row->date,
'days_until' => (int) max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1),
'account' => $row->account_name,
'category' => $row->category_name,
'category_icon' => $row->category_icon,
'cost_center' => $row->cost_center_name,
];
}
// 2. Cuotas de pasivos pendientes
$pendingInstallments = DB::select("
SELECT
li.id,
la.name as description,
li.installment_amount as amount,
'debit' as type,
li.due_date as date,
li.status,
la.currency,
a.name as account_name
FROM liability_installments li
JOIN liability_accounts la ON li.liability_account_id = la.id
LEFT JOIN accounts a ON la.account_id = a.id
WHERE la.user_id = ?
AND li.status = 'pending'
AND li.due_date >= ?
AND li.due_date <= ?
AND li.deleted_at IS NULL
ORDER BY li.due_date ASC
", [$this->userId, $today, $endDate]);
foreach ($pendingInstallments as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
$totalExpenseConverted += $converted;
$result[] = [
'id' => $row->id,
'description' => $row->description . ' (Cuota)',
'amount' => round($amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'type' => 'debit',
'source_type' => 'liability_installment',
'status' => $row->status,
'date' => $row->date,
'days_until' => (int) max(0, Carbon::parse($row->date)->diffInDays(now(), false) * -1),
'account' => $row->account_name,
'category' => null,
'category_icon' => null,
'cost_center' => null,
];
}
// 3. Proyecciones de recurrencias activas
$recurrences = DB::select("
SELECT
rt.id,
rt.name,
rt.transaction_description as description,
rt.planned_amount as amount,
rt.type,
rt.frequency,
rt.day_of_month,
rt.start_date,
rt.end_date,
COALESCE(a.currency, 'EUR') as currency,
a.name as account_name,
c.name as category_name,
c.icon as category_icon,
cc.name as cost_center_name
FROM recurring_templates rt
LEFT JOIN accounts a ON rt.account_id = a.id
LEFT JOIN categories c ON rt.category_id = c.id
LEFT JOIN cost_centers cc ON rt.cost_center_id = cc.id
WHERE rt.user_id = ?
AND rt.is_active = 1
AND rt.deleted_at IS NULL
AND (rt.end_date IS NULL OR rt.end_date >= ?)
", [$this->userId, $today]);
foreach ($recurrences as $rec) {
// Calcular próximas ejecuciones dentro del período
$nextDates = $this->getNextRecurrenceDates($rec, $today, $endDate);
foreach ($nextDates as $nextDate) {
$amount = abs($rec->amount);
$converted = $this->convertToPrimaryCurrency($amount, $rec->currency);
if ($rec->type === 'credit') {
$totalIncomeConverted += $converted;
} else {
$totalExpenseConverted += $converted;
}
$result[] = [
'id' => $rec->id,
'description' => $rec->name . ' (Recurrente)',
'amount' => round($amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $rec->currency,
'type' => $rec->type,
'source_type' => 'recurring',
'status' => 'projected',
'date' => $nextDate,
'days_until' => (int) max(0, Carbon::parse($nextDate)->diffInDays(now(), false) * -1),
'account' => $rec->account_name,
'category' => $rec->category_name,
'category_icon' => $rec->category_icon,
'cost_center' => $rec->cost_center_name,
];
}
}
// Ordenar por fecha
usort($result, fn($a, $b) => strcmp($a['date'], $b['date']));
return response()->json([
'data' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_transactions' => count($result),
'total_income' => round($totalIncomeConverted, 2),
'total_expense' => round($totalExpenseConverted, 2),
'net_impact' => round($totalIncomeConverted - $totalExpenseConverted, 2),
],
]);
}
/**
* Transacciones vencidas (pendientes de pago)
* Incluye: cuotas de pasivos vencidas, transacciones pendientes/scheduled pasadas,
* y recurrencias que deberían haber ejecutado pero no lo hicieron
*/
public function overdueTransactions(Request $request)
{
\Log::info('overdueTransactions called');
try {
$this->init();
$today = now()->format('Y-m-d');
$result = [];
$totalOverdueConverted = 0;
// 1. Cuotas de pasivos vencidas
$overdueInstallments = DB::select("
SELECT
li.id,
la.name as description,
li.installment_amount as amount,
li.due_date,
la.currency,
DATEDIFF(?, li.due_date) as days_overdue,
a.name as account_name
FROM liability_installments li
JOIN liability_accounts la ON li.liability_account_id = la.id
LEFT JOIN accounts a ON la.account_id = a.id
WHERE la.user_id = ?
AND li.status != 'paid'
AND li.due_date < ?
AND li.deleted_at IS NULL
ORDER BY li.due_date ASC
", [$today, $this->userId, $today]);
foreach ($overdueInstallments as $row) {
$converted = $this->convertToPrimaryCurrency($row->amount, $row->currency);
$totalOverdueConverted += $converted;
$result[] = [
'id' => $row->id,
'description' => $row->description . ' (Cuota)',
'amount' => round($row->amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'due_date' => $row->due_date,
'days_overdue' => (int) $row->days_overdue,
'source_type' => 'liability_installment',
'account' => $row->account_name,
'category' => null,
];
}
// 2. Transacciones pendientes/scheduled con fecha pasada
$overdueTransactions = DB::select("
SELECT
t.id,
t.description,
COALESCE(t.planned_amount, t.amount) as amount,
t.planned_date as due_date,
t.type,
t.status,
COALESCE(a.currency, 'EUR') as currency,
DATEDIFF(?, t.planned_date) as days_overdue,
a.name as account_name,
c.name as category_name
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ?
AND t.status IN ('pending', 'scheduled')
AND t.planned_date < ?
AND t.deleted_at IS NULL
ORDER BY t.planned_date ASC
", [$today, $this->userId, $today]);
foreach ($overdueTransactions as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
$totalOverdueConverted += $converted;
$result[] = [
'id' => $row->id,
'description' => $row->description,
'amount' => round($amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'due_date' => $row->due_date,
'days_overdue' => (int) $row->days_overdue,
'source_type' => 'transaction',
'type' => $row->type,
'status' => $row->status,
'account' => $row->account_name,
'category' => $row->category_name,
];
}
// 3. Instancias de recorrências pendentes e vencidas
$overdueRecurringInstances = DB::select("
SELECT
ri.id,
rt.name as description,
ri.planned_amount as amount,
ri.due_date,
COALESCE(a.currency, 'EUR') as currency,
DATEDIFF(?, ri.due_date) as days_overdue,
a.name as account_name,
c.name as category_name,
rt.type
FROM recurring_instances ri
JOIN recurring_templates rt ON ri.recurring_template_id = rt.id
LEFT JOIN accounts a ON rt.account_id = a.id
LEFT JOIN categories c ON rt.category_id = c.id
WHERE ri.user_id = ?
AND ri.status = 'pending'
AND ri.due_date < ?
AND ri.deleted_at IS NULL
ORDER BY ri.due_date ASC
", [$today, $this->userId, $today]);
foreach ($overdueRecurringInstances as $row) {
$amount = abs($row->amount);
$converted = $this->convertToPrimaryCurrency($amount, $row->currency);
$totalOverdueConverted += $converted;
$result[] = [
'id' => $row->id,
'description' => $row->description . ' (Recorrente)',
'amount' => round($amount, 2),
'amount_converted' => round($converted, 2),
'currency' => $row->currency,
'due_date' => $row->due_date,
'days_overdue' => (int) $row->days_overdue,
'source_type' => 'recurring_instance',
'type' => $row->type,
'status' => 'pending',
'account' => $row->account_name,
'category' => $row->category_name,
];
}
// Ordenar por días de atraso (más atrasado primero)
usort($result, fn($a, $b) => $b['days_overdue'] <=> $a['days_overdue']);
return response()->json([
'data' => $result,
'currency' => $this->primaryCurrency,
'summary' => [
'total_overdue' => count($result),
'total_amount' => round($totalOverdueConverted, 2),
],
]);
} catch (\Throwable $e) {
\Log::error('overdueTransactions error: ' . $e->getMessage() . ' at line ' . $e->getLine());
return response()->json([
'error' => $e->getMessage(),
'line' => $e->getLine(),
'file' => $e->getFile()
], 500);
}
}
/**
* Resumen ejecutivo completo
*/
public function executiveSummary(Request $request)
{
$this->init();
$startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
// Totales del período
$totals = DB::select("
SELECT
COALESCE(a.currency, 'EUR') as currency,
SUM(CASE WHEN t.type = 'credit' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type = 'debit' THEN ABS(t.amount) ELSE 0 END) as expense,
COUNT(*) as transactions
FROM transactions t
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY COALESCE(a.currency, 'EUR')
", [$this->userId, $startDate, $endDate]);
$totalIncome = 0;
$totalExpense = 0;
$totalTransactions = 0;
$byCurrency = [];
foreach ($totals as $row) {
$totalIncome += $this->convertToPrimaryCurrency($row->income, $row->currency);
$totalExpense += $this->convertToPrimaryCurrency($row->expense, $row->currency);
$totalTransactions += $row->transactions;
$byCurrency[$row->currency] = [
'income' => round($row->income, 2),
'expense' => round($row->expense, 2),
];
}
// Top 5 categorías de gasto
$topCategories = DB::select("
SELECT
c.name,
c.icon,
c.color,
COALESCE(a.currency, 'EUR') as currency,
SUM(ABS(t.amount)) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN accounts a ON t.account_id = a.id
WHERE t.user_id = ?
AND t.type = 'debit'
AND t.effective_date BETWEEN ? AND ?
AND t.deleted_at IS NULL
AND {$this->excludeTransfers()}
GROUP BY c.id, c.name, c.icon, c.color, COALESCE(a.currency, 'EUR')
", [$this->userId, $startDate, $endDate]);
$categoryTotals = [];
foreach ($topCategories as $row) {
$name = $row->name ?? 'Sin categoría';
if (!isset($categoryTotals[$name])) {
$categoryTotals[$name] = [
'name' => $name,
'icon' => $row->icon,
'color' => $row->color,
'total' => 0,
];
}
$categoryTotals[$name]['total'] += $this->convertToPrimaryCurrency($row->total, $row->currency);
}
usort($categoryTotals, fn($a, $b) => $b['total'] <=> $a['total']);
$top5Categories = array_slice($categoryTotals, 0, 5);
// Cuentas
$accounts = Account::where('user_id', $this->userId)
->where('include_in_total', true)
->get();
$totalBalance = 0;
foreach ($accounts as $acc) {
$totalBalance += $this->convertToPrimaryCurrency($acc->current_balance, $acc->currency);
}
return response()->json([
'period' => ['start' => $startDate, 'end' => $endDate],
'currency' => $this->primaryCurrency,
'totals' => [
'income' => round($totalIncome, 2),
'expense' => round($totalExpense, 2),
'balance' => round($totalIncome - $totalExpense, 2),
'savings_rate' => $totalIncome > 0 ? round((($totalIncome - $totalExpense) / $totalIncome) * 100, 1) : 0,
'transactions' => $totalTransactions,
'by_currency' => $byCurrency,
],
'accounts' => [
'count' => $accounts->count(),
'total_balance' => round($totalBalance, 2),
],
'top_expense_categories' => array_map(function($c) {
return [
'name' => $c['name'],
'icon' => $c['icon'],
'color' => $c['color'],
'total' => round($c['total'], 2),
];
}, $top5Categories),
]);
}
/**
* Calcula las próximas fechas de ejecución de una recurrencia dentro de un período
*/
private function getNextRecurrenceDates($recurrence, $startDate, $endDate)
{
$dates = [];
$start = Carbon::parse($startDate);
$end = Carbon::parse($endDate);
$recStart = Carbon::parse($recurrence->start_date);
$recEnd = $recurrence->end_date ? Carbon::parse($recurrence->end_date) : null;
// Si la recurrencia termina antes del período, no hay fechas
if ($recEnd && $recEnd->lt($start)) {
return $dates;
}
// Calcular la primera fecha dentro del período
$current = $recStart->copy();
// Avanzar hasta estar dentro del período (EXCLUYE startDate para evitar duplicados)
while ($current->lte($start)) {
$current = $this->advanceToNextOccurrence($current, $recurrence);
if ($current->gt($end)) {
return $dates;
}
}
// Generar fechas hasta el fin del período
$maxIterations = 100; // Prevenir bucles infinitos
$iterations = 0;
while ($current->lte($end) && $iterations < $maxIterations) {
// Verificar que no pase de la fecha de fin de la recurrencia
if ($recEnd && $current->gt($recEnd)) {
break;
}
$dates[] = $current->format('Y-m-d');
$current = $this->advanceToNextOccurrence($current, $recurrence);
$iterations++;
}
return $dates;
}
/**
* Avanza a la próxima ocurrencia según la frecuencia
*/
private function advanceToNextOccurrence($date, $recurrence)
{
$next = $date->copy();
switch ($recurrence->frequency) {
case 'daily':
$next->addDays($recurrence->frequency_interval ?? 1);
break;
case 'weekly':
$next->addWeeks($recurrence->frequency_interval ?? 1);
break;
case 'biweekly':
$next->addWeeks(2);
break;
case 'monthly':
$next->addMonths($recurrence->frequency_interval ?? 1);
if ($recurrence->day_of_month) {
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
}
break;
case 'bimonthly':
$next->addMonths(2);
if ($recurrence->day_of_month) {
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
}
break;
case 'quarterly':
$next->addMonths(3);
if ($recurrence->day_of_month) {
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
}
break;
case 'semiannual':
$next->addMonths(6);
if ($recurrence->day_of_month) {
$next->day = min($recurrence->day_of_month, $next->daysInMonth);
}
break;
case 'annual':
$next->addYears($recurrence->frequency_interval ?? 1);
break;
default:
$next->addMonths(1);
}
return $next;
}
/**
* Obtiene las recurrencias que deberían haber ejecutado pero no lo hicieron
* Busca la última ejecución esperada y verifica si existe una transacción para esa fecha
*/
private function getOverdueRecurrences($today)
{
$result = [];
$todayCarbon = Carbon::parse($today);
// Obtener todas las recurrencias activas
$recurrences = DB::select("
SELECT
rt.id,
rt.name,
rt.transaction_description as description,
rt.planned_amount as amount,
rt.type,
rt.frequency,
rt.day_of_month,
rt.start_date,
rt.end_date,
rt.last_generated_date,
COALESCE(a.currency, 'EUR') as currency,
a.name as account_name,
c.name as category_name
FROM recurring_templates rt
LEFT JOIN accounts a ON rt.account_id = a.id
LEFT JOIN categories c ON rt.category_id = c.id
WHERE rt.user_id = ?
AND rt.is_active = 1
AND rt.deleted_at IS NULL
", [$this->userId]);
foreach ($recurrences as $rec) {
// Calcular la fecha de la última ejecución esperada
$expectedDate = $this->getLastExpectedExecutionDate($rec, $todayCarbon);
if (!$expectedDate) {
continue;
}
// Verificar si ya existe una transacción para esta recurrencia en esa fecha
// Buscamos por descripción similar y fecha cercana (±2 días)
$existingTransaction = DB::selectOne("
SELECT id FROM transactions
WHERE user_id = ?
AND (description LIKE ? OR description LIKE ?)
AND effective_date BETWEEN DATE_SUB(?, INTERVAL 2 DAY) AND DATE_ADD(?, INTERVAL 2 DAY)
AND deleted_at IS NULL
LIMIT 1
", [
$this->userId,
'%' . $rec->name . '%',
'%' . ($rec->description ?? '') . '%',
$expectedDate->format('Y-m-d'),
$expectedDate->format('Y-m-d')
]);
// Si no existe transacción y la fecha esperada es anterior a hoy, está vencida
if (!$existingTransaction && $expectedDate->lt($todayCarbon)) {
$daysOverdue = abs($expectedDate->diffInDays($todayCarbon));
$result[] = [
'id' => $rec->id,
'description' => $rec->name,
'amount' => round(abs($rec->amount), 2),
'currency' => $rec->currency,
'due_date' => $expectedDate->format('Y-m-d'),
'days_overdue' => (int) $daysOverdue,
'source_type' => 'recurring_overdue',
'type' => $rec->type,
'status' => 'not_executed',
'account' => $rec->account_name,
'category' => $rec->category_name,
];
}
}
return $result;
}
/**
* Calcula la fecha de la última ejecución esperada para una recurrencia
*/
private function getLastExpectedExecutionDate($recurrence, $today)
{
$startDate = Carbon::parse($recurrence->start_date);
// Si aún no ha llegado la fecha de inicio, no hay ejecución esperada
if ($startDate->gt($today)) {
return null;
}
// Si tiene fecha de fin y ya pasó, usar la fecha de fin
$endDate = $recurrence->end_date ? Carbon::parse($recurrence->end_date) : null;
$referenceDate = ($endDate && $endDate->lt($today)) ? $endDate : $today;
// Calcular la fecha esperada según la frecuencia
switch ($recurrence->frequency) {
case 'monthly':
$dayOfMonth = $recurrence->day_of_month ?? $startDate->day;
$expectedDate = $referenceDate->copy()->day(min($dayOfMonth, $referenceDate->daysInMonth));
// Si la fecha calculada es posterior a hoy, retroceder un mes
if ($expectedDate->gt($today)) {
$expectedDate->subMonth();
$expectedDate->day = min($dayOfMonth, $expectedDate->daysInMonth);
}
return $expectedDate;
case 'weekly':
$dayOfWeek = $startDate->dayOfWeek;
$expectedDate = $referenceDate->copy()->startOfWeek()->addDays($dayOfWeek);
if ($expectedDate->gt($today)) {
$expectedDate->subWeek();
}
return $expectedDate;
case 'biweekly':
$dayOfWeek = $startDate->dayOfWeek;
$weeksSinceStart = $startDate->diffInWeeks($referenceDate);
$biweeklyPeriods = floor($weeksSinceStart / 2);
$expectedDate = $startDate->copy()->addWeeks($biweeklyPeriods * 2);
if ($expectedDate->gt($today)) {
$expectedDate->subWeeks(2);
}
return $expectedDate;
case 'quarterly':
$dayOfMonth = $recurrence->day_of_month ?? $startDate->day;
$quarterMonth = floor(($referenceDate->month - 1) / 3) * 3 + 1;
$expectedDate = $referenceDate->copy()->month($quarterMonth)->day(min($dayOfMonth, Carbon::create($referenceDate->year, $quarterMonth, 1)->daysInMonth));
if ($expectedDate->gt($today)) {
$expectedDate->subMonths(3);
}
return $expectedDate;
case 'annual':
$expectedDate = Carbon::create($referenceDate->year, $startDate->month, min($startDate->day, Carbon::create($referenceDate->year, $startDate->month, 1)->daysInMonth));
if ($expectedDate->gt($today)) {
$expectedDate->subYear();
}
return $expectedDate;
default:
return null;
}
}
}