webmoney/backend/app/Http/Controllers/Api/DashboardController.php

954 lines
42 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Models\Account;
use App\Models\RecurringInstance;
use App\Models\LiabilityInstallment;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DashboardController extends Controller
{
/**
* Fluxo de caixa mensal
* Retorna receitas, despesas e saldo por mês
* Ignora transações marcadas como transferências entre contas
*/
public function cashflow(Request $request): JsonResponse
{
$userId = $request->user()->id;
// Parâmetros de período (padrão: últimos 12 meses)
$months = min((int) $request->get('months', 12), 24); // máximo 24 meses
$endDate = Carbon::parse($request->get('end_date', now()->endOfMonth()));
$startDate = $endDate->copy()->subMonths($months - 1)->startOfMonth();
// Buscar dados mensais de transações completed (excluindo transferências)
// Usar strftime para SQLite ou DATE_FORMAT para MySQL
$driver = DB::connection()->getDriverName();
$monthExpression = $driver === 'sqlite'
? "strftime('%Y-%m', effective_date)"
: "DATE_FORMAT(effective_date, '%Y-%m')";
$monthlyData = Transaction::ofUser($userId)
->completed()
->where('is_transfer', false) // Ignorar transferências entre contas
->whereBetween('effective_date', [$startDate, $endDate])
->select(
DB::raw("$monthExpression as month"),
DB::raw("SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) as income"),
DB::raw("SUM(CASE WHEN type = 'debit' THEN amount ELSE 0 END) as expense")
)
->groupBy('month')
->orderBy('month')
->get()
->keyBy('month');
// Criar array com todos os meses (mesmo sem dados)
$result = [];
$current = $startDate->copy();
$cumulativeBalance = 0;
// Calcular saldo inicial (antes do período) - excluindo transferências
$initialBalance = Transaction::ofUser($userId)
->completed()
->where('is_transfer', false)
->where('effective_date', '<', $startDate)
->selectRaw("SUM(CASE WHEN type = 'credit' THEN amount ELSE -amount END) as balance")
->value('balance') ?? 0;
$cumulativeBalance = (float) $initialBalance;
while ($current <= $endDate) {
$monthKey = $current->format('Y-m');
$data = $monthlyData->get($monthKey);
$income = (float) ($data->income ?? 0);
$expense = (float) ($data->expense ?? 0);
$balance = $income - $expense;
$cumulativeBalance += $balance;
$result[] = [
'month' => $monthKey,
'month_label' => $current->translatedFormat('M/Y'),
'income' => $income,
'expense' => $expense,
'balance' => $balance,
'cumulative_balance' => $cumulativeBalance,
];
$current->addMonth();
}
// Totais do período
$totals = [
'income' => array_sum(array_column($result, 'income')),
'expense' => array_sum(array_column($result, 'expense')),
'balance' => array_sum(array_column($result, 'balance')),
'average_income' => count($result) > 0 ? array_sum(array_column($result, 'income')) / count($result) : 0,
'average_expense' => count($result) > 0 ? array_sum(array_column($result, 'expense')) / count($result) : 0,
];
return response()->json([
'period' => [
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
'months' => $months,
],
'data' => $result,
'totals' => $totals,
]);
}
/**
* Resumo geral do dashboard
* Ignora transações marcadas como transferências entre contas
* Agrupa valores por divisa para sistema multi-divisa
*/
public function summary(Request $request): JsonResponse
{
$userId = $request->user()->id;
// Saldo total das contas agrupado por divisa
$balancesByCurrency = Account::where('user_id', $userId)
->where('is_active', true)
->where('include_in_total', true)
->select('currency', DB::raw('SUM(current_balance) as total'))
->groupBy('currency')
->get()
->mapWithKeys(function ($item) {
return [$item->currency => (float) $item->total];
})
->toArray();
// Transações do mês atual agrupadas por divisa da conta (excluindo transferências)
$currentMonth = [
now()->startOfMonth()->format('Y-m-d'),
now()->endOfMonth()->format('Y-m-d')
];
$monthlyStatsByCurrency = Transaction::where('transactions.user_id', $userId)
->where('transactions.status', 'completed')
->where('transactions.is_transfer', false)
->whereBetween('transactions.effective_date', $currentMonth)
->whereNull('transactions.deleted_at')
->join('accounts', 'transactions.account_id', '=', 'accounts.id')
->select(
'accounts.currency',
DB::raw("SUM(CASE WHEN transactions.type = 'credit' THEN transactions.amount ELSE 0 END) as income"),
DB::raw("SUM(CASE WHEN transactions.type = 'debit' THEN transactions.amount ELSE 0 END) as expense"),
DB::raw("COUNT(*) as transactions_count")
)
->groupBy('accounts.currency')
->get();
$incomeByCurrency = [];
$expenseByCurrency = [];
$transactionsCount = 0;
foreach ($monthlyStatsByCurrency as $stats) {
$incomeByCurrency[$stats->currency] = (float) $stats->income;
$expenseByCurrency[$stats->currency] = (float) $stats->expense;
$transactionsCount += (int) $stats->transactions_count;
}
// Pendentes agrupadas por divisa (excluindo transferências)
$pendingByCurrency = Transaction::where('transactions.user_id', $userId)
->where('transactions.status', 'pending')
->where('transactions.is_transfer', false)
->whereNull('transactions.deleted_at')
->join('accounts', 'transactions.account_id', '=', 'accounts.id')
->select(
'accounts.currency',
DB::raw("SUM(CASE WHEN transactions.type = 'credit' THEN transactions.planned_amount ELSE 0 END) as income"),
DB::raw("SUM(CASE WHEN transactions.type = 'debit' THEN transactions.planned_amount ELSE 0 END) as expense"),
DB::raw("COUNT(*) as count")
)
->groupBy('accounts.currency')
->get();
$pendingIncomeByCurrency = [];
$pendingExpenseByCurrency = [];
$pendingCount = 0;
foreach ($pendingByCurrency as $pending) {
$pendingIncomeByCurrency[$pending->currency] = (float) $pending->income;
$pendingExpenseByCurrency[$pending->currency] = (float) $pending->expense;
$pendingCount += (int) $pending->count;
}
// Atrasadas (vencidas) agrupadas por divisa - excluindo transferências
$overdueByCurrency = Transaction::where('transactions.user_id', $userId)
->where('transactions.status', 'pending')
->where('transactions.is_transfer', false)
->where('transactions.planned_date', '<', now()->startOfDay())
->whereNull('transactions.deleted_at')
->join('accounts', 'transactions.account_id', '=', 'accounts.id')
->select(
'accounts.currency',
DB::raw("SUM(transactions.planned_amount) as total"),
DB::raw("COUNT(*) as count")
)
->groupBy('accounts.currency')
->get();
$overdueTotalByCurrency = [];
$overdueCount = 0;
foreach ($overdueByCurrency as $overdue) {
$overdueTotalByCurrency[$overdue->currency] = (float) $overdue->total;
$overdueCount += (int) $overdue->count;
}
// Determinar divisa principal (a com maior saldo ou primeira encontrada)
$primaryCurrency = !empty($balancesByCurrency) ? array_key_first($balancesByCurrency) : 'BRL';
return response()->json([
// Compatibilidade com versão anterior (usando primeira divisa)
'total_balance' => (float) ($balancesByCurrency[$primaryCurrency] ?? 0),
'current_month' => [
'income' => (float) ($incomeByCurrency[$primaryCurrency] ?? 0),
'expense' => (float) ($expenseByCurrency[$primaryCurrency] ?? 0),
'balance' => (float) (($incomeByCurrency[$primaryCurrency] ?? 0) - ($expenseByCurrency[$primaryCurrency] ?? 0)),
'transactions_count' => $transactionsCount,
],
'pending' => [
'income' => (float) ($pendingIncomeByCurrency[$primaryCurrency] ?? 0),
'expense' => (float) ($pendingExpenseByCurrency[$primaryCurrency] ?? 0),
'count' => $pendingCount,
],
'overdue' => [
'total' => (float) ($overdueTotalByCurrency[$primaryCurrency] ?? 0),
'count' => $overdueCount,
],
// Novos campos multi-divisa
'primary_currency' => $primaryCurrency,
'balances_by_currency' => $balancesByCurrency,
'current_month_by_currency' => [
'income' => $incomeByCurrency,
'expense' => $expenseByCurrency,
],
'pending_by_currency' => [
'income' => $pendingIncomeByCurrency,
'expense' => $pendingExpenseByCurrency,
],
'overdue_by_currency' => $overdueTotalByCurrency,
]);
}
/**
* Despesas por categoria (últimos N meses)
* Ignora transações marcadas como transferências entre contas
*/
public function expensesByCategory(Request $request): JsonResponse
{
$userId = $request->user()->id;
$months = min((int) $request->get('months', 3), 12);
$startDate = now()->subMonths($months - 1)->startOfMonth();
$endDate = now()->endOfMonth();
$data = Transaction::ofUser($userId)
->completed()
->where('is_transfer', false) // Ignorar transferências entre contas
->where('type', 'debit')
->whereBetween('effective_date', [$startDate, $endDate])
->whereNotNull('category_id')
->select(
'category_id',
DB::raw('SUM(amount) as total'),
DB::raw('COUNT(*) as count')
)
->groupBy('category_id')
->with('category:id,name,color,icon')
->orderByDesc('total')
->limit(10)
->get();
$total = $data->sum('total');
$result = $data->map(function ($item) use ($total) {
return [
'category_id' => $item->category_id,
'category_name' => $item->category->name ?? 'Sem categoria',
'category_color' => $item->category->color ?? '#6b7280',
'category_icon' => $item->category->icon ?? 'bi-tag',
'total' => (float) $item->total,
'count' => (int) $item->count,
'percentage' => $total > 0 ? round(($item->total / $total) * 100, 1) : 0,
];
});
return response()->json([
'period' => [
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
'months' => $months,
],
'data' => $result,
'total' => (float) $total,
]);
}
/**
* Receitas por categoria (últimos N meses)
* Ignora transações marcadas como transferências entre contas
*/
public function incomeByCategory(Request $request): JsonResponse
{
$userId = $request->user()->id;
$months = min((int) $request->get('months', 3), 12);
$startDate = now()->subMonths($months - 1)->startOfMonth();
$endDate = now()->endOfMonth();
$data = Transaction::ofUser($userId)
->completed()
->where('is_transfer', false) // Ignorar transferências entre contas
->where('type', 'credit')
->whereBetween('effective_date', [$startDate, $endDate])
->whereNotNull('category_id')
->select(
'category_id',
DB::raw('SUM(amount) as total'),
DB::raw('COUNT(*) as count')
)
->groupBy('category_id')
->with('category:id,name,color,icon')
->orderByDesc('total')
->limit(10)
->get();
$total = $data->sum('total');
$result = $data->map(function ($item) use ($total) {
return [
'category_id' => $item->category_id,
'category_name' => $item->category->name ?? 'Sem categoria',
'category_color' => $item->category->color ?? '#6b7280',
'category_icon' => $item->category->icon ?? 'bi-tag',
'total' => (float) $item->total,
'count' => (int) $item->count,
'percentage' => $total > 0 ? round(($item->total / $total) * 100, 1) : 0,
];
});
return response()->json([
'period' => [
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
'months' => $months,
],
'data' => $result,
'total' => (float) $total,
]);
}
/**
* Análise de diferenças entre valores planejados e efetivos
* Mostra sobrepagamentos e subpagamentos
*/
public function paymentVariances(Request $request): JsonResponse
{
$userId = $request->user()->id;
$months = min((int) $request->get('months', 3), 12);
$startDate = now()->subMonths($months - 1)->startOfMonth();
$endDate = now()->endOfMonth();
// Buscar transações completed com diferença entre planned_amount e amount
$transactions = Transaction::ofUser($userId)
->completed()
->where('is_transfer', false)
->whereBetween('effective_date', [$startDate, $endDate])
->whereRaw('ABS(amount - planned_amount) > 0.01') // Diferença maior que 1 centavo
->with(['category:id,name,color,icon', 'account:id,name'])
->orderByRaw('ABS(amount - planned_amount) DESC')
->limit(50)
->get();
$result = $transactions->map(function ($t) {
$variance = $t->amount - $t->planned_amount;
$variancePercent = $t->planned_amount > 0
? round(($variance / $t->planned_amount) * 100, 2)
: 0;
// Calcular dias de atraso (diferença entre effective_date e planned_date)
$delayDays = null;
if ($t->planned_date && $t->effective_date) {
$delayDays = $t->planned_date->diffInDays($t->effective_date, false);
// Positivo = pago depois do planejado (atrasado)
// Negativo = pago antes do planejado (adiantado)
}
return [
'id' => $t->id,
'description' => $t->description,
'type' => $t->type,
'planned_amount' => (float) $t->planned_amount,
'actual_amount' => (float) $t->amount,
'variance' => (float) $variance,
'variance_percent' => $variancePercent,
'effective_date' => $t->effective_date->format('Y-m-d'),
'planned_date' => $t->planned_date ? $t->planned_date->format('Y-m-d') : null,
'delay_days' => $delayDays,
'category' => $t->category ? [
'id' => $t->category->id,
'name' => $t->category->name,
'color' => $t->category->color,
] : null,
'account' => $t->account ? [
'id' => $t->account->id,
'name' => $t->account->name,
] : null,
];
});
// Calcular totais
$overpayments = $result->filter(fn($t) => $t['variance'] > 0);
$underpayments = $result->filter(fn($t) => $t['variance'] < 0);
// Agrupar por mês para o gráfico
$byMonth = $transactions->groupBy(function ($t) {
return $t->effective_date->format('Y-m');
})->map(function ($items, $month) {
$over = $items->filter(fn($t) => $t->amount > $t->planned_amount)
->sum(fn($t) => $t->amount - $t->planned_amount);
$under = $items->filter(fn($t) => $t->amount < $t->planned_amount)
->sum(fn($t) => $t->planned_amount - $t->amount);
return [
'month' => $month,
'overpayment' => round($over, 2),
'underpayment' => round($under, 2),
'net' => round($over - $under, 2),
'count' => $items->count(),
];
})->sortKeys()->values();
return response()->json([
'period' => [
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
'months' => $months,
],
'summary' => [
'total_overpayment' => round($overpayments->sum('variance'), 2),
'total_underpayment' => round(abs($underpayments->sum('variance')), 2),
'net_variance' => round($result->sum('variance'), 2),
'overpayment_count' => $overpayments->count(),
'underpayment_count' => $underpayments->count(),
],
'by_month' => $byMonth,
'transactions' => $result,
]);
}
/**
* Dados do calendário para o dashboard
* Retorna transações e instâncias recorrentes pendentes por data
*/
public function calendar(Request $request): JsonResponse
{
$userId = $request->user()->id;
// Período: mês atual por padrão, ou o mês especificado
$year = (int) $request->get('year', now()->year);
$month = (int) $request->get('month', now()->month);
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
// Buscar transações do período
$transactions = Transaction::ofUser($userId)
->whereBetween('effective_date', [$startDate, $endDate])
->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date')
->get()
->map(function ($t) {
return [
'id' => $t->id,
'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'),
'description' => $t->description,
'amount' => (float) $t->amount,
'transaction_type' => $t->type,
'status' => $t->status,
'is_transfer' => $t->is_transfer,
'account' => $t->account ? [
'id' => $t->account->id,
'name' => $t->account->name,
'currency' => $t->account->currency,
] : null,
'category' => $t->category ? [
'id' => $t->category->id,
'name' => $t->category->name,
'color' => $t->category->color,
'icon' => $t->category->icon,
] : null,
];
});
// Buscar instâncias recorrentes pendentes do período
$recurringInstances = RecurringInstance::where('user_id', $userId)
->whereBetween('due_date', [$startDate, $endDate])
->where('status', 'pending')
->whereNull('transaction_id') // Não reconciliadas
->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon'])
->orderBy('due_date')
->get()
->map(function ($ri) {
return [
'id' => $ri->id,
'type' => 'recurring',
'date' => $ri->due_date->format('Y-m-d'),
'description' => $ri->template->name ?? 'Recorrência',
'amount' => (float) $ri->planned_amount,
'transaction_type' => $ri->template->type ?? 'debit',
'status' => $ri->status,
'occurrence_number' => $ri->occurrence_number,
'template_id' => $ri->recurring_template_id,
'account' => $ri->template && $ri->template->account ? [
'id' => $ri->template->account->id,
'name' => $ri->template->account->name,
'currency' => $ri->template->account->currency,
] : null,
'category' => $ri->template && $ri->template->category ? [
'id' => $ri->template->category->id,
'name' => $ri->template->category->name,
'color' => $ri->template->category->color,
'icon' => $ri->template->category->icon,
] : null,
];
});
// Combinar e agrupar por data
$allItems = $transactions->concat($recurringInstances);
$byDate = $allItems->groupBy('date')->map(function ($items, $date) {
return [
'date' => $date,
'items' => $items->values(),
'total_credit' => $items->where('transaction_type', 'credit')->sum('amount'),
'total_debit' => $items->where('transaction_type', 'debit')->sum('amount'),
'has_transactions' => $items->where('type', 'transaction')->count() > 0,
'has_recurring' => $items->where('type', 'recurring')->count() > 0,
'pending_count' => $items->whereIn('status', ['pending', 'scheduled'])->count(),
];
})->values();
// Resumo do mês (excluindo transferências entre contas)
$nonTransferTransactions = $transactions->where('is_transfer', false);
$summary = [
'transactions_count' => $nonTransferTransactions->count(),
'recurring_count' => $recurringInstances->count(),
'total_income' => $nonTransferTransactions->where('transaction_type', 'credit')->sum('amount'),
'total_expense' => $nonTransferTransactions->where('transaction_type', 'debit')->sum('amount'),
'pending_recurring' => $recurringInstances->count(),
'pending_recurring_amount' => $recurringInstances->sum('amount'),
];
return response()->json([
'period' => [
'year' => $year,
'month' => $month,
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
],
'by_date' => $byDate,
'summary' => $summary,
]);
}
/**
* Transações e recorrências de um dia específico
*/
public function calendarDay(Request $request): JsonResponse
{
$userId = $request->user()->id;
$date = Carbon::parse($request->get('date', now()->format('Y-m-d')));
// Buscar transações do dia
$transactions = Transaction::ofUser($userId)
->whereDate('effective_date', $date)
->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date')
->orderBy('created_at')
->get()
->map(function ($t) {
return [
'id' => $t->id,
'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'),
'description' => $t->description,
'amount' => (float) $t->amount,
'transaction_type' => $t->type,
'status' => $t->status,
'is_transfer' => $t->is_transfer,
'notes' => $t->notes,
'account' => $t->account ? [
'id' => $t->account->id,
'name' => $t->account->name,
'currency' => $t->account->currency,
] : null,
'category' => $t->category ? [
'id' => $t->category->id,
'name' => $t->category->name,
'color' => $t->category->color,
'icon' => $t->category->icon,
] : null,
];
});
// Buscar instâncias recorrentes pendentes do dia
$recurringInstances = RecurringInstance::where('user_id', $userId)
->whereDate('due_date', $date)
->where('status', 'pending')
->whereNull('transaction_id')
->with(['template:id,name,type,planned_amount,account_id,category_id,description,transaction_description', 'template.account:id,name,currency', 'template.category:id,name,color,icon'])
->orderBy('due_date')
->get()
->map(function ($ri) {
return [
'id' => $ri->id,
'type' => 'recurring',
'date' => $ri->due_date->format('Y-m-d'),
'description' => $ri->template->name ?? 'Recorrência',
'amount' => (float) $ri->planned_amount,
'transaction_type' => $ri->template->type ?? 'debit',
'status' => $ri->status,
'occurrence_number' => $ri->occurrence_number,
'template_id' => $ri->recurring_template_id,
'notes' => $ri->template->description ?? null,
'account' => $ri->template && $ri->template->account ? [
'id' => $ri->template->account->id,
'name' => $ri->template->account->name,
'currency' => $ri->template->account->currency,
] : null,
'category' => $ri->template && $ri->template->category ? [
'id' => $ri->template->category->id,
'name' => $ri->template->category->name,
'color' => $ri->template->category->color,
'icon' => $ri->template->category->icon,
] : null,
];
});
// Combinar
$allItems = $transactions->concat($recurringInstances);
// Para o resumo, excluir transferências entre contas
$nonTransferItems = $allItems->filter(fn($item) => !($item['is_transfer'] ?? false));
return response()->json([
'date' => $date->format('Y-m-d'),
'date_formatted' => $date->translatedFormat('l, d F Y'),
'items' => $allItems->values(),
'summary' => [
'transactions_count' => $transactions->count(),
'recurring_count' => $recurringInstances->count(),
'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'),
'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'),
],
]);
}
/**
* Transações pendentes dos próximos dias (incluindo hoje)
*/
public function upcomingTransactions(Request $request): JsonResponse
{
$userId = $request->user()->id;
$days = min((int) $request->get('days', 7), 30); // máximo 30 dias
$startDate = now()->startOfDay();
$endDate = now()->addDays($days - 1)->endOfDay();
// Buscar transações pendentes do período
$transactions = Transaction::ofUser($userId)
->whereIn('status', ['pending', 'scheduled'])
->whereBetween('effective_date', [$startDate, $endDate])
->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('effective_date')
->orderBy('created_at')
->get()
->map(function ($t) {
return [
'id' => $t->id,
'type' => 'transaction',
'date' => $t->effective_date->format('Y-m-d'),
'date_formatted' => $t->effective_date->translatedFormat('D, d M'),
'description' => $t->description,
'amount' => (float) $t->amount,
'currency' => $t->account->currency ?? 'EUR',
'transaction_type' => $t->type,
'status' => $t->status,
'is_transfer' => $t->is_transfer,
'days_until' => (int) now()->startOfDay()->diffInDays($t->effective_date, false),
'account' => $t->account ? [
'id' => $t->account->id,
'name' => $t->account->name,
'currency' => $t->account->currency,
] : null,
'category' => $t->category ? [
'id' => $t->category->id,
'name' => $t->category->name,
'color' => $t->category->color,
'icon' => $t->category->icon,
] : null,
];
});
// Buscar instâncias recorrentes pendentes do período
$recurringInstances = RecurringInstance::where('user_id', $userId)
->where('status', 'pending')
->whereNull('transaction_id')
->whereBetween('due_date', [$startDate, $endDate])
->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon'])
->orderBy('due_date')
->get()
->map(function ($ri) {
return [
'id' => $ri->id,
'type' => 'recurring',
'date' => $ri->due_date->format('Y-m-d'),
'date_formatted' => $ri->due_date->translatedFormat('D, d M'),
'description' => $ri->template->name ?? 'Recorrência',
'amount' => (float) $ri->planned_amount,
'currency' => $ri->template->account->currency ?? 'EUR',
'transaction_type' => $ri->template->type ?? 'debit',
'status' => $ri->status,
'occurrence_number' => $ri->occurrence_number,
'template_id' => $ri->recurring_template_id,
'days_until' => (int) now()->startOfDay()->diffInDays($ri->due_date, false),
'account' => $ri->template && $ri->template->account ? [
'id' => $ri->template->account->id,
'name' => $ri->template->account->name,
'currency' => $ri->template->account->currency,
] : null,
'category' => $ri->template && $ri->template->category ? [
'id' => $ri->template->category->id,
'name' => $ri->template->category->name,
'color' => $ri->template->category->color,
'icon' => $ri->template->category->icon,
] : null,
];
});
// Combinar e ordenar por data
$allItems = $transactions->concat($recurringInstances)
->sortBy('date')
->values();
// Agrupar por data
$byDate = $allItems->groupBy('date')->map(function ($items, $date) {
$carbonDate = Carbon::parse($date);
$daysUntil = (int) now()->startOfDay()->diffInDays($carbonDate, false);
return [
'date' => $date,
'date_formatted' => $carbonDate->translatedFormat('l, d M'),
'days_until' => $daysUntil,
'is_today' => $daysUntil === 0,
'items' => $items->values(),
'total_credit' => $items->where('transaction_type', 'credit')->where('is_transfer', '!==', true)->sum('amount'),
'total_debit' => $items->where('transaction_type', 'debit')->where('is_transfer', '!==', true)->sum('amount'),
];
})->values();
// Totais gerais (excluindo transferências)
$nonTransferItems = $allItems->filter(fn($item) => !($item['is_transfer'] ?? false));
$summary = [
'total_items' => $allItems->count(),
'transactions_count' => $transactions->count(),
'recurring_count' => $recurringInstances->count(),
'total_credit' => $nonTransferItems->where('transaction_type', 'credit')->sum('amount'),
'total_debit' => $nonTransferItems->where('transaction_type', 'debit')->sum('amount'),
];
return response()->json([
'period' => [
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
'days' => $days,
],
'by_date' => $byDate,
'items' => $allItems,
'summary' => $summary,
]);
}
/**
* Transações em atraso (vencidas e não pagas)
*/
public function overdueTransactions(Request $request): JsonResponse
{
$userId = $request->user()->id;
$limit = min((int) $request->get('limit', 50), 100); // máximo 100
$today = now()->startOfDay();
// Buscar transações pendentes com data planejada no passado
$transactions = Transaction::ofUser($userId)
->whereIn('status', ['pending', 'scheduled'])
->where('is_transfer', false)
->where('planned_date', '<', $today)
->with(['account:id,name,currency', 'category:id,name,color,icon'])
->orderBy('planned_date')
->limit($limit)
->get()
->map(function ($t) use ($today) {
$plannedDate = Carbon::parse($t->planned_date);
$daysOverdue = (int) $plannedDate->diffInDays($today);
return [
'id' => $t->id,
'type' => 'transaction',
'planned_date' => $t->planned_date->format('Y-m-d'),
'planned_date_formatted' => $t->planned_date->translatedFormat('D, d M Y'),
'description' => $t->description,
'amount' => (float) ($t->planned_amount ?? $t->amount),
'currency' => $t->account->currency ?? 'EUR',
'transaction_type' => $t->type,
'status' => $t->status,
'days_overdue' => $daysOverdue,
'account' => $t->account ? [
'id' => $t->account->id,
'name' => $t->account->name,
'currency' => $t->account->currency,
] : null,
'category' => $t->category ? [
'id' => $t->category->id,
'name' => $t->category->name,
'color' => $t->category->color,
'icon' => $t->category->icon,
] : null,
];
});
// Buscar instâncias recorrentes em atraso
$recurringInstances = RecurringInstance::where('user_id', $userId)
->where('status', 'pending')
->whereNull('transaction_id')
->where('due_date', '<', $today)
->with(['template:id,name,type,planned_amount,account_id,category_id', 'template.account:id,name,currency', 'template.category:id,name,color,icon'])
->orderBy('due_date')
->limit($limit)
->get()
->map(function ($ri) use ($today) {
$dueDate = Carbon::parse($ri->due_date);
$daysOverdue = (int) $dueDate->diffInDays($today);
return [
'id' => $ri->id,
'type' => 'recurring',
'planned_date' => $ri->due_date->format('Y-m-d'),
'planned_date_formatted' => $ri->due_date->translatedFormat('D, d M Y'),
'description' => $ri->template->name ?? 'Recorrência',
'amount' => (float) $ri->planned_amount,
'currency' => $ri->template->account->currency ?? 'EUR',
'transaction_type' => $ri->template->type ?? 'debit',
'status' => $ri->status,
'occurrence_number' => $ri->occurrence_number,
'template_id' => $ri->recurring_template_id,
'days_overdue' => $daysOverdue,
'account' => $ri->template && $ri->template->account ? [
'id' => $ri->template->account->id,
'name' => $ri->template->account->name,
'currency' => $ri->template->account->currency,
] : null,
'category' => $ri->template && $ri->template->category ? [
'id' => $ri->template->category->id,
'name' => $ri->template->category->name,
'color' => $ri->template->category->color,
'icon' => $ri->template->category->icon,
] : null,
];
});
// Buscar parcelas de passivo em atraso
$liabilityInstallments = LiabilityInstallment::whereHas('liabilityAccount', function ($query) use ($userId) {
$query->where('user_id', $userId);
})
->where('status', 'pending')
->where('due_date', '<', $today)
->with(['liabilityAccount:id,name,creditor,currency'])
->orderBy('due_date')
->limit($limit)
->get()
->map(function ($li) use ($today) {
$dueDate = Carbon::parse($li->due_date);
$daysOverdue = (int) $dueDate->diffInDays($today);
return [
'id' => $li->id,
'type' => 'liability',
'planned_date' => $li->due_date->format('Y-m-d'),
'planned_date_formatted' => $li->due_date->translatedFormat('D, d M Y'),
'description' => $li->liabilityAccount->name . ' - Parcela ' . $li->installment_number,
'amount' => (float) $li->amount,
'currency' => $li->liabilityAccount->currency ?? 'EUR',
'transaction_type' => 'debit',
'status' => $li->status,
'installment_number' => $li->installment_number,
'liability_account_id' => $li->liability_account_id,
'creditor' => $li->liabilityAccount->creditor,
'days_overdue' => $daysOverdue,
'account' => null,
'category' => null,
];
});
// Combinar e ordenar por dias em atraso (mais antigo primeiro)
$allItems = $transactions->concat($recurringInstances)->concat($liabilityInstallments)
->sortByDesc('days_overdue')
->values();
// Agrupar por faixa de atraso
$byRange = collect([
['key' => 'critical', 'min' => 30, 'max' => PHP_INT_MAX, 'label' => '> 30 dias'],
['key' => 'high', 'min' => 15, 'max' => 29, 'label' => '15-30 dias'],
['key' => 'medium', 'min' => 7, 'max' => 14, 'label' => '7-14 dias'],
['key' => 'low', 'min' => 1, 'max' => 6, 'label' => '1-6 dias'],
])->map(function ($range) use ($allItems) {
$items = $allItems->filter(function ($item) use ($range) {
return $item['days_overdue'] >= $range['min'] && $item['days_overdue'] <= $range['max'];
})->values();
return [
'key' => $range['key'],
'label' => $range['label'],
'min_days' => $range['min'],
'max_days' => $range['max'] === PHP_INT_MAX ? null : $range['max'],
'items' => $items,
'count' => $items->count(),
'total' => $items->sum('amount'),
];
})->filter(fn($range) => $range['count'] > 0)->values();
// Totais gerais
$summary = [
'total_items' => $allItems->count(),
'transactions_count' => $transactions->count(),
'recurring_count' => $recurringInstances->count(),
'liability_count' => $liabilityInstallments->count(),
'total_amount' => $allItems->sum('amount'),
'total_credit' => $allItems->where('transaction_type', 'credit')->sum('amount'),
'total_debit' => $allItems->where('transaction_type', 'debit')->sum('amount'),
'oldest_date' => $allItems->isNotEmpty() ? $allItems->first()['planned_date'] : null,
'max_days_overdue' => $allItems->isNotEmpty() ? $allItems->first()['days_overdue'] : 0,
];
return response()->json([
'by_range' => $byRange,
'items' => $allItems->take($limit),
'summary' => $summary,
]);
}
}