- Corrigir validação de keywords permitindo array vazio - Atualizar todas transações existentes sem centro de custo para Geral - Criar centro de custo Geral para usuários sem ele - Associar automaticamente novas transações criadas ao Geral - Associar automaticamente transferências ao Geral - Associar automaticamente transações importadas ao Geral
1393 lines
58 KiB
PHP
Executable File
1393 lines
58 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Transaction;
|
|
use App\Models\Account;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class TransactionController extends Controller
|
|
{
|
|
/**
|
|
* Listar transações com filtros
|
|
*/
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$query = Transaction::ofUser($request->user()->id)
|
|
->with(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']);
|
|
|
|
// Filtros
|
|
if ($request->has('account_id')) {
|
|
$query->ofAccount($request->account_id);
|
|
}
|
|
|
|
if ($request->has('category_id')) {
|
|
if ($request->category_id === 'uncategorized') {
|
|
$query->whereNull('category_id');
|
|
} else {
|
|
$query->ofCategory($request->category_id);
|
|
}
|
|
}
|
|
|
|
if ($request->has('cost_center_id')) {
|
|
$query->ofCostCenter($request->cost_center_id);
|
|
}
|
|
|
|
if ($request->has('type')) {
|
|
$query->where('type', $request->type);
|
|
}
|
|
|
|
if ($request->has('status')) {
|
|
$query->where('status', $request->status);
|
|
}
|
|
|
|
// Filtro por período
|
|
if ($request->has('start_date') && $request->has('end_date')) {
|
|
$dateField = $request->get('date_field', 'planned_date');
|
|
$query->inPeriod($request->start_date, $request->end_date, $dateField);
|
|
} else {
|
|
// Sem filtro de data: não mostrar transações futuras
|
|
$query->where('planned_date', '<=', now()->toDateString());
|
|
}
|
|
|
|
// Busca por descrição e valores
|
|
if ($request->has('search')) {
|
|
$search = $request->search;
|
|
// Limpar formatação de número para busca por valor
|
|
$searchNumber = preg_replace('/[^\d.,\-]/', '', $search);
|
|
$searchNumber = str_replace(',', '.', $searchNumber);
|
|
|
|
$query->where(function($q) use ($search, $searchNumber) {
|
|
$q->where('description', 'like', "%{$search}%")
|
|
->orWhere('reference', 'like', "%{$search}%")
|
|
->orWhere('notes', 'like', "%{$search}%");
|
|
|
|
// Se for um número válido, buscar também nos campos de valor
|
|
if (is_numeric($searchNumber)) {
|
|
$numericValue = (float) $searchNumber;
|
|
// Busca exata
|
|
$q->orWhere('planned_amount', '=', $numericValue)
|
|
->orWhere('amount', '=', $numericValue);
|
|
|
|
// Busca por valor sem decimais (ex: "147" encontra 147.00, 147.50, etc.)
|
|
if (strpos($searchNumber, '.') === false) {
|
|
$q->orWhereBetween('planned_amount', [$numericValue, $numericValue + 0.99])
|
|
->orWhereBetween('amount', [$numericValue, $numericValue + 0.99]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Ordenação
|
|
$sortField = $request->get('sort_by', 'planned_date');
|
|
$sortOrder = $request->get('sort_order', 'desc');
|
|
$query->orderBy($sortField, $sortOrder);
|
|
|
|
// Paginação ou todos
|
|
if ($request->has('per_page')) {
|
|
$transactions = $query->paginate($request->per_page);
|
|
} else {
|
|
$transactions = $query->get();
|
|
}
|
|
|
|
return response()->json($transactions);
|
|
}
|
|
|
|
/**
|
|
* Criar nova transação
|
|
*/
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'account_id' => 'required|exists:accounts,id',
|
|
'category_id' => 'nullable|exists:categories,id',
|
|
'cost_center_id' => 'nullable|exists:cost_centers,id',
|
|
'amount' => 'nullable|numeric|min:0',
|
|
'planned_amount' => 'required|numeric|min:0',
|
|
'type' => 'required|in:credit,debit',
|
|
'description' => 'required|string|max:255',
|
|
'notes' => 'nullable|string',
|
|
'effective_date' => 'nullable|date',
|
|
'planned_date' => 'required|date',
|
|
'status' => 'sometimes|in:pending,completed,cancelled',
|
|
'reference' => 'nullable|string|max:100',
|
|
'is_recurring' => 'sometimes|boolean',
|
|
]);
|
|
|
|
// Verificar se a conta pertence ao usuário
|
|
$account = Account::where('id', $validated['account_id'])
|
|
->where('user_id', $request->user()->id)
|
|
->firstOrFail();
|
|
|
|
$validated['user_id'] = $request->user()->id;
|
|
|
|
// Se não foi especificado um centro de custo, usar o Geral (sistema)
|
|
if (!isset($validated['cost_center_id']) || $validated['cost_center_id'] === null) {
|
|
$generalCostCenter = \App\Models\CostCenter::where('user_id', $request->user()->id)
|
|
->where('is_system', true)
|
|
->first();
|
|
|
|
if ($generalCostCenter) {
|
|
$validated['cost_center_id'] = $generalCostCenter->id;
|
|
}
|
|
}
|
|
|
|
// Se status é completed e não tem amount, usa planned_amount
|
|
if (($validated['status'] ?? 'pending') === 'completed') {
|
|
$validated['amount'] = $validated['amount'] ?? $validated['planned_amount'];
|
|
$validated['effective_date'] = $validated['effective_date'] ?? now()->toDateString();
|
|
}
|
|
|
|
$transaction = Transaction::create($validated);
|
|
|
|
return response()->json(
|
|
$transaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']),
|
|
201
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Exibir detalhes de uma transação
|
|
*/
|
|
public function show(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar propriedade
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
return response()->json(
|
|
$transaction->load([
|
|
'account:id,name,currency,type',
|
|
'category:id,name,color,icon,parent_id',
|
|
'category.parent:id,name',
|
|
'costCenter:id,name,color',
|
|
'recurringParent:id,description',
|
|
'recurringChildren:id,description,planned_date,status'
|
|
])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Atualizar transação
|
|
*/
|
|
public function update(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar propriedade
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'account_id' => 'sometimes|exists:accounts,id',
|
|
'category_id' => 'nullable|exists:categories,id',
|
|
'cost_center_id' => 'nullable|exists:cost_centers,id',
|
|
'amount' => 'nullable|numeric|min:0',
|
|
'planned_amount' => 'sometimes|numeric|min:0',
|
|
'type' => 'sometimes|in:credit,debit',
|
|
'description' => 'sometimes|string|max:255',
|
|
'notes' => 'nullable|string',
|
|
'effective_date' => 'nullable|date',
|
|
'planned_date' => 'sometimes|date',
|
|
'status' => 'sometimes|in:pending,completed,cancelled',
|
|
'reference' => 'nullable|string|max:100',
|
|
'is_recurring' => 'sometimes|boolean',
|
|
]);
|
|
|
|
// Se mudou account_id, verificar propriedade
|
|
if (isset($validated['account_id'])) {
|
|
Account::where('id', $validated['account_id'])
|
|
->where('user_id', $request->user()->id)
|
|
->firstOrFail();
|
|
}
|
|
|
|
// Se mudou para completed, garantir amount e effective_date
|
|
if (isset($validated['status']) && $validated['status'] === 'completed') {
|
|
if (!$transaction->amount && !isset($validated['amount'])) {
|
|
$validated['amount'] = $validated['planned_amount'] ?? $transaction->planned_amount;
|
|
}
|
|
if (!$transaction->effective_date && !isset($validated['effective_date'])) {
|
|
$validated['effective_date'] = now()->toDateString();
|
|
}
|
|
}
|
|
|
|
// Se voltou para pending, limpar amount e effective_date
|
|
if (isset($validated['status']) && $validated['status'] === 'pending') {
|
|
$validated['amount'] = null;
|
|
$validated['effective_date'] = null;
|
|
}
|
|
|
|
$transaction->update($validated);
|
|
|
|
return response()->json(
|
|
$transaction->fresh()->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color'])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Excluir transação
|
|
*/
|
|
public function destroy(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar propriedade
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
$transaction->delete();
|
|
|
|
return response()->json(['message' => 'Transação excluída com sucesso']);
|
|
}
|
|
|
|
/**
|
|
* Marcar transação como concluída
|
|
*/
|
|
public function complete(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'amount' => 'nullable|numeric|min:0',
|
|
'effective_date' => 'nullable|date',
|
|
]);
|
|
|
|
$transaction->markAsCompleted(
|
|
$validated['amount'] ?? null,
|
|
$validated['effective_date'] ?? null
|
|
);
|
|
|
|
return response()->json(
|
|
$transaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color'])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cancelar transação
|
|
*/
|
|
public function cancel(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
$transaction->markAsCancelled();
|
|
|
|
return response()->json(
|
|
$transaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color'])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reverter para pendente
|
|
*/
|
|
public function revert(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
$transaction->markAsPending();
|
|
|
|
return response()->json(
|
|
$transaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color'])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resumo de transações
|
|
*/
|
|
public function summary(Request $request): JsonResponse
|
|
{
|
|
$userId = $request->user()->id;
|
|
|
|
$query = Transaction::ofUser($userId);
|
|
|
|
// Filtros opcionais
|
|
if ($request->has('account_id')) {
|
|
$query->ofAccount($request->account_id);
|
|
}
|
|
|
|
if ($request->has('start_date') && $request->has('end_date')) {
|
|
$query->inPeriod($request->start_date, $request->end_date);
|
|
}
|
|
|
|
// Totais por status
|
|
$byStatus = Transaction::ofUser($userId)
|
|
->when($request->has('account_id'), fn($q) => $q->ofAccount($request->account_id))
|
|
->when($request->has('start_date'), fn($q) => $q->inPeriod($request->start_date, $request->end_date))
|
|
->select('status', DB::raw('COUNT(*) as count'), DB::raw('SUM(COALESCE(amount, planned_amount)) as total'))
|
|
->groupBy('status')
|
|
->get()
|
|
->keyBy('status');
|
|
|
|
// Totais por tipo (apenas completed)
|
|
$byType = Transaction::ofUser($userId)
|
|
->completed()
|
|
->when($request->has('account_id'), fn($q) => $q->ofAccount($request->account_id))
|
|
->when($request->has('start_date'), fn($q) => $q->inPeriod($request->start_date, $request->end_date, 'effective_date'))
|
|
->select('type', DB::raw('COUNT(*) as count'), DB::raw('SUM(amount) as total'))
|
|
->groupBy('type')
|
|
->get()
|
|
->keyBy('type');
|
|
|
|
// Transações pendentes atrasadas
|
|
$overdue = Transaction::ofUser($userId)
|
|
->pending()
|
|
->when($request->has('account_id'), fn($q) => $q->ofAccount($request->account_id))
|
|
->where('planned_date', '<', now()->startOfDay())
|
|
->count();
|
|
|
|
return response()->json([
|
|
'by_status' => [
|
|
'pending' => [
|
|
'count' => $byStatus['pending']->count ?? 0,
|
|
'total' => (float) ($byStatus['pending']->total ?? 0),
|
|
],
|
|
'completed' => [
|
|
'count' => $byStatus['completed']->count ?? 0,
|
|
'total' => (float) ($byStatus['completed']->total ?? 0),
|
|
],
|
|
'cancelled' => [
|
|
'count' => $byStatus['cancelled']->count ?? 0,
|
|
'total' => (float) ($byStatus['cancelled']->total ?? 0),
|
|
],
|
|
],
|
|
'by_type' => [
|
|
'credit' => [
|
|
'count' => $byType['credit']->count ?? 0,
|
|
'total' => (float) ($byType['credit']->total ?? 0),
|
|
],
|
|
'debit' => [
|
|
'count' => $byType['debit']->count ?? 0,
|
|
'total' => (float) ($byType['debit']->total ?? 0),
|
|
],
|
|
],
|
|
'balance' => (float) (($byType['credit']->total ?? 0) - ($byType['debit']->total ?? 0)),
|
|
'overdue_count' => $overdue,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Duplicar transação
|
|
*/
|
|
public function duplicate(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
$newTransaction = $transaction->replicate();
|
|
|
|
// Resetear campos que no deben duplicarse
|
|
$newTransaction->status = 'pending';
|
|
$newTransaction->amount = null;
|
|
$newTransaction->effective_date = null;
|
|
$newTransaction->planned_date = now()->toDateString();
|
|
$newTransaction->import_hash = null; // IMPORTANTE: debe ser null para evitar duplicidad
|
|
|
|
$newTransaction->save();
|
|
|
|
return response()->json(
|
|
$newTransaction->load(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']),
|
|
201
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Listar transações agrupadas por semana e separadas por divisa
|
|
*/
|
|
public function byWeek(Request $request): JsonResponse
|
|
{
|
|
$userId = $request->user()->id;
|
|
|
|
// Verificar se há filtros ativos (além de date_field e currency)
|
|
$hasActiveFilters = $request->hasAny(['account_id', 'category_id', 'cost_center_id', 'type', 'status', 'search', 'start_date', 'end_date']);
|
|
|
|
// Verificar modo de visualização
|
|
$viewMode = $request->get('view_mode', 'week'); // 'week', 'month', 'all'
|
|
|
|
// Determinar quantidade de semanas por página
|
|
// Se há filtros OU viewMode é 'month' ou 'all', trazer todas as semanas
|
|
$shouldFetchAll = $hasActiveFilters || in_array($viewMode, ['month', 'all']);
|
|
$perPage = $shouldFetchAll ? 1000 : $request->get('per_page', 10);
|
|
$page = $request->get('page', 1);
|
|
$currency = $request->get('currency'); // Filtro de divisa opcional
|
|
$dateField = $request->get('date_field', 'planned_date');
|
|
|
|
// Buscar IDs de transações que estão conciliadas com passivos
|
|
$reconciledTransactionIds = \App\Models\LiabilityInstallment::whereNotNull('reconciled_transaction_id')
|
|
->pluck('reconciled_transaction_id')
|
|
->toArray();
|
|
|
|
// Construir query base
|
|
$query = Transaction::ofUser($userId)
|
|
->with(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']);
|
|
|
|
// Filtros opcionais
|
|
if ($request->has('account_id')) {
|
|
$query->ofAccount($request->account_id);
|
|
}
|
|
|
|
if ($request->has('category_id')) {
|
|
if ($request->category_id === 'uncategorized') {
|
|
$query->whereNull('category_id');
|
|
} else {
|
|
$query->ofCategory($request->category_id);
|
|
}
|
|
}
|
|
|
|
if ($request->has('cost_center_id')) {
|
|
$query->ofCostCenter($request->cost_center_id);
|
|
}
|
|
|
|
if ($request->has('type')) {
|
|
$query->where('type', $request->type);
|
|
}
|
|
|
|
if ($request->has('status')) {
|
|
$query->where('status', $request->status);
|
|
}
|
|
|
|
// Filtro por período - aceita filtros parciais
|
|
$hasDateFilter = $request->has('start_date') || $request->has('end_date');
|
|
|
|
// Para effective_date, usar COALESCE com planned_date como fallback
|
|
$dateColumn = $dateField === 'effective_date'
|
|
? DB::raw('COALESCE(effective_date, planned_date)')
|
|
: $dateField;
|
|
|
|
if ($hasDateFilter) {
|
|
if ($request->has('start_date') && $request->has('end_date')) {
|
|
// Ambas as datas especificadas
|
|
if ($dateField === 'effective_date') {
|
|
$query->whereRaw('COALESCE(effective_date, planned_date) >= ?', [$request->start_date])
|
|
->whereRaw('COALESCE(effective_date, planned_date) <= ?', [$request->end_date]);
|
|
} else {
|
|
$query->inPeriod($request->start_date, $request->end_date, $dateField);
|
|
}
|
|
} elseif ($request->has('start_date')) {
|
|
// Apenas data inicial - mostrar a partir desta data
|
|
if ($dateField === 'effective_date') {
|
|
$query->whereRaw('COALESCE(effective_date, planned_date) >= ?', [$request->start_date]);
|
|
} else {
|
|
$query->where($dateField, '>=', $request->start_date);
|
|
}
|
|
} elseif ($request->has('end_date')) {
|
|
// Apenas data final - mostrar até esta data (incluindo futuras)
|
|
if ($dateField === 'effective_date') {
|
|
$query->whereRaw('COALESCE(effective_date, planned_date) <= ?', [$request->end_date]);
|
|
} else {
|
|
$query->where($dateField, '<=', $request->end_date);
|
|
}
|
|
}
|
|
} elseif (!$hasActiveFilters) {
|
|
// Sem filtro de data E sem filtros ativos: não mostrar transações futuras
|
|
// Quando há filtros ativos, mostrar todas as transações (incluindo futuras)
|
|
$query->where('planned_date', '<=', now()->toDateString());
|
|
}
|
|
|
|
// Busca por descrição e valores
|
|
if ($request->has('search')) {
|
|
$search = $request->search;
|
|
// Limpar formatação de número para busca por valor
|
|
$searchNumber = preg_replace('/[^\d.,\-]/', '', $search);
|
|
$searchNumber = str_replace(',', '.', $searchNumber);
|
|
|
|
$query->where(function($q) use ($search, $searchNumber) {
|
|
$q->where('description', 'like', "%{$search}%")
|
|
->orWhere('reference', 'like', "%{$search}%")
|
|
->orWhere('notes', 'like', "%{$search}%");
|
|
|
|
// Se for um número válido, buscar também nos campos de valor
|
|
if (is_numeric($searchNumber)) {
|
|
$numericValue = (float) $searchNumber;
|
|
// Busca exata
|
|
$q->orWhere('planned_amount', '=', $numericValue)
|
|
->orWhere('amount', '=', $numericValue);
|
|
|
|
// Busca por valor sem decimais (ex: "147" encontra 147.00, 147.50, etc.)
|
|
if (strpos($searchNumber, '.') === false) {
|
|
$q->orWhereBetween('planned_amount', [$numericValue, $numericValue + 0.99])
|
|
->orWhereBetween('amount', [$numericValue, $numericValue + 0.99]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Ordenar por data (effective_date com fallback para planned_date)
|
|
if ($dateField === 'effective_date') {
|
|
$query->orderByRaw('COALESCE(effective_date, planned_date) DESC');
|
|
} else {
|
|
$query->orderBy($dateField, 'desc');
|
|
}
|
|
|
|
// Obter todas as transações filtradas
|
|
$allTransactions = $query->get();
|
|
|
|
// Agrupar por divisa (da conta)
|
|
$byCurrency = $allTransactions->groupBy(function ($transaction) {
|
|
return $transaction->account->currency ?? 'EUR';
|
|
});
|
|
|
|
// Se tem filtro de divisa, aplicar
|
|
if ($currency && $byCurrency->has($currency)) {
|
|
$byCurrency = collect([$currency => $byCurrency->get($currency)]);
|
|
} elseif ($currency) {
|
|
$byCurrency = collect(); // Divisa não encontrada
|
|
}
|
|
|
|
// Estrutura de resultado por divisa
|
|
$result = [];
|
|
|
|
foreach ($byCurrency as $currencyCode => $currencyTransactions) {
|
|
// Agrupar transações por semana (YEARWEEK)
|
|
$byWeek = $currencyTransactions->groupBy(function ($transaction) use ($dateField) {
|
|
// Usar effective_date com fallback para planned_date
|
|
$date = $dateField === 'effective_date'
|
|
? ($transaction->effective_date ?? $transaction->planned_date)
|
|
: $transaction->$dateField;
|
|
$carbon = \Carbon\Carbon::parse($date);
|
|
// Usar ISO week (segunda a domingo)
|
|
return $carbon->format('o-W'); // ISO year-week (ex: 2025-49)
|
|
});
|
|
|
|
// Ordenar semanas (mais recentes primeiro)
|
|
$byWeek = $byWeek->sortKeysDesc();
|
|
|
|
// Calcular total de semanas para paginação
|
|
$totalWeeks = $byWeek->count();
|
|
|
|
// Aplicar paginação manual (semanas)
|
|
$paginatedWeeks = $byWeek->skip(($page - 1) * $perPage)->take($perPage);
|
|
|
|
$weeks = [];
|
|
foreach ($paginatedWeeks as $yearWeek => $weekTransactions) {
|
|
// Calcular datas de início e fim da semana
|
|
[$year, $week] = explode('-', $yearWeek);
|
|
$startOfWeek = \Carbon\Carbon::now()
|
|
->setISODate((int)$year, (int)$week, 1) // Segunda-feira
|
|
->startOfDay();
|
|
$endOfWeek = $startOfWeek->copy()->addDays(6)->endOfDay();
|
|
|
|
// Separar transferências das transações normais
|
|
$normalTransactions = $weekTransactions->filter(fn($t) => !$t->is_transfer);
|
|
$transfers = $weekTransactions->filter(fn($t) => $t->is_transfer);
|
|
|
|
// Ordenar transações dentro da semana por data (effective_date com fallback para planned_date)
|
|
$normalTransactions = $normalTransactions->sortByDesc(function ($t) use ($dateField) {
|
|
if ($dateField === 'effective_date') {
|
|
return $t->effective_date ?? $t->planned_date;
|
|
}
|
|
return $t->$dateField;
|
|
});
|
|
|
|
// Calcular resumo financeiro da semana (excluindo transferências)
|
|
$credits = $normalTransactions->where('type', 'credit');
|
|
$debits = $normalTransactions->where('type', 'debit');
|
|
$pending = $normalTransactions->where('status', 'pending');
|
|
$completed = $normalTransactions->where('status', 'completed');
|
|
$overdue = $normalTransactions->where('status', 'pending')
|
|
->filter(function ($t) {
|
|
return \Carbon\Carbon::parse($t->planned_date)->lt(now()->startOfDay());
|
|
});
|
|
|
|
$totalCredits = $credits->sum(function ($t) {
|
|
if ($t->status === 'completed') {
|
|
return $t->amount > 0 ? $t->amount : $t->planned_amount;
|
|
}
|
|
return $t->planned_amount;
|
|
});
|
|
|
|
$totalDebits = $debits->sum(function ($t) {
|
|
if ($t->status === 'completed') {
|
|
return $t->amount > 0 ? $t->amount : $t->planned_amount;
|
|
}
|
|
return $t->planned_amount;
|
|
});
|
|
|
|
// Agrupar transferências em pares (débito → crédito)
|
|
$processedTransferIds = [];
|
|
$groupedTransfers = [];
|
|
|
|
foreach ($transfers as $transfer) {
|
|
// Pular se já processamos esta transferência
|
|
if (in_array($transfer->id, $processedTransferIds)) {
|
|
continue;
|
|
}
|
|
|
|
$linkedTransfer = null;
|
|
if ($transfer->transfer_linked_id) {
|
|
$linkedTransfer = $transfers->firstWhere('id', $transfer->transfer_linked_id);
|
|
}
|
|
|
|
// Determinar qual é o débito e qual é o crédito
|
|
$debitTransaction = $transfer->type === 'debit' ? $transfer : $linkedTransfer;
|
|
$creditTransaction = $transfer->type === 'credit' ? $transfer : $linkedTransfer;
|
|
|
|
// Se não encontrou o par, usar apenas esta transação
|
|
if (!$linkedTransfer) {
|
|
$debitTransaction = $transfer->type === 'debit' ? $transfer : null;
|
|
$creditTransaction = $transfer->type === 'credit' ? $transfer : null;
|
|
}
|
|
|
|
$amount = $debitTransaction
|
|
? ($debitTransaction->status === 'completed' ? $debitTransaction->amount : $debitTransaction->planned_amount)
|
|
: ($creditTransaction->status === 'completed' ? $creditTransaction->amount : $creditTransaction->planned_amount);
|
|
|
|
$groupedTransfers[] = [
|
|
'id' => $transfer->id,
|
|
'is_transfer_pair' => true,
|
|
'description' => $transfer->description,
|
|
'original_description' => $transfer->original_description,
|
|
'amount' => (float) $amount,
|
|
'status' => $transfer->status,
|
|
'planned_date' => $transfer->planned_date?->format('Y-m-d'),
|
|
'effective_date' => $transfer->effective_date?->format('Y-m-d'),
|
|
'from_account' => $debitTransaction && $debitTransaction->account ? [
|
|
'id' => $debitTransaction->account->id,
|
|
'name' => $debitTransaction->account->name,
|
|
'currency' => $debitTransaction->account->currency,
|
|
] : null,
|
|
'to_account' => $creditTransaction && $creditTransaction->account ? [
|
|
'id' => $creditTransaction->account->id,
|
|
'name' => $creditTransaction->account->name,
|
|
'currency' => $creditTransaction->account->currency,
|
|
] : null,
|
|
'debit_transaction_id' => $debitTransaction?->id,
|
|
'credit_transaction_id' => $creditTransaction?->id,
|
|
];
|
|
|
|
// Marcar ambas as transações como processadas
|
|
$processedTransferIds[] = $transfer->id;
|
|
if ($linkedTransfer) {
|
|
$processedTransferIds[] = $linkedTransfer->id;
|
|
}
|
|
}
|
|
|
|
$weeks[] = [
|
|
'year_week' => $yearWeek,
|
|
'year' => (int)$year,
|
|
'week_number' => (int)$week,
|
|
'start_date' => $startOfWeek->format('Y-m-d'),
|
|
'end_date' => $endOfWeek->format('Y-m-d'),
|
|
'summary' => [
|
|
'total_transactions' => $normalTransactions->count(),
|
|
'transfers_count' => count($groupedTransfers),
|
|
'credits' => [
|
|
'count' => $credits->count(),
|
|
'total' => (float) $totalCredits,
|
|
],
|
|
'debits' => [
|
|
'count' => $debits->count(),
|
|
'total' => (float) $totalDebits,
|
|
],
|
|
'balance' => (float) ($totalCredits - $totalDebits),
|
|
'pending' => [
|
|
'count' => $pending->count(),
|
|
'total' => (float) $pending->sum('planned_amount'),
|
|
],
|
|
'completed' => [
|
|
'count' => $completed->count(),
|
|
'total' => (float) $completed->sum(fn($t) => $t->amount > 0 ? $t->amount : $t->planned_amount),
|
|
],
|
|
'overdue_count' => $overdue->count(),
|
|
],
|
|
'transactions' => $normalTransactions->values()->map(function ($t) use ($reconciledTransactionIds) {
|
|
return [
|
|
'id' => $t->id,
|
|
'description' => $t->description,
|
|
'original_description' => $t->original_description,
|
|
'type' => $t->type,
|
|
'status' => $t->status,
|
|
'amount' => (float) $t->amount,
|
|
'planned_amount' => (float) $t->planned_amount,
|
|
'planned_date' => $t->planned_date?->format('Y-m-d'),
|
|
'effective_date' => $t->effective_date?->format('Y-m-d'),
|
|
'reference' => $t->reference,
|
|
'notes' => $t->notes,
|
|
'is_transfer' => false,
|
|
'is_reconciled' => in_array($t->id, $reconciledTransactionIds),
|
|
'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,
|
|
'cost_center' => $t->costCenter ? [
|
|
'id' => $t->costCenter->id,
|
|
'name' => $t->costCenter->name,
|
|
'color' => $t->costCenter->color,
|
|
] : null,
|
|
'is_overdue' => $t->status === 'pending' &&
|
|
\Carbon\Carbon::parse($t->planned_date)->lt(now()->startOfDay()),
|
|
];
|
|
}),
|
|
'transfers' => $groupedTransfers,
|
|
];
|
|
}
|
|
|
|
$result[$currencyCode] = [
|
|
'currency' => $currencyCode,
|
|
'total_transactions' => $currencyTransactions->count(),
|
|
'pagination' => [
|
|
'current_page' => (int) $page,
|
|
'per_page' => (int) $perPage,
|
|
'total_weeks' => $totalWeeks,
|
|
'total_pages' => (int) ceil($totalWeeks / $perPage),
|
|
'has_more' => $page < ceil($totalWeeks / $perPage),
|
|
],
|
|
'weeks' => $weeks,
|
|
];
|
|
}
|
|
|
|
// Retornar divisas disponíveis
|
|
$availableCurrencies = array_keys($result);
|
|
|
|
return response()->json([
|
|
'currencies' => $availableCurrencies,
|
|
'selected_currency' => $currency,
|
|
'data' => $result,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Criar transferência entre contas
|
|
* Cria duas transações vinculadas: débito na origem, crédito no destino
|
|
*/
|
|
public function transfer(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'from_account_id' => 'required|exists:accounts,id',
|
|
'to_account_id' => 'required|exists:accounts,id|different:from_account_id',
|
|
'amount' => 'required|numeric|min:0.01',
|
|
'description' => 'nullable|string|max:255',
|
|
'date' => 'required|date',
|
|
'notes' => 'nullable|string',
|
|
]);
|
|
|
|
$userId = $request->user()->id;
|
|
|
|
// Verificar se ambas as contas pertencem ao usuário
|
|
$fromAccount = Account::where('id', $validated['from_account_id'])
|
|
->where('user_id', $userId)
|
|
->firstOrFail();
|
|
|
|
$toAccount = Account::where('id', $validated['to_account_id'])
|
|
->where('user_id', $userId)
|
|
->firstOrFail();
|
|
|
|
$description = $validated['description'] ?? "Transferência: {$fromAccount->name} → {$toAccount->name}";
|
|
|
|
// Obter centro de custo Geral do usuário
|
|
$generalCostCenter = \App\Models\CostCenter::where('user_id', $userId)
|
|
->where('is_system', true)
|
|
->first();
|
|
$costCenterId = $generalCostCenter ? $generalCostCenter->id : null;
|
|
|
|
DB::beginTransaction();
|
|
try {
|
|
// Criar transação de DÉBITO na conta de origem
|
|
$debitTransaction = Transaction::create([
|
|
'user_id' => $userId,
|
|
'account_id' => $fromAccount->id,
|
|
'type' => 'debit',
|
|
'amount' => $validated['amount'],
|
|
'planned_amount' => $validated['amount'],
|
|
'description' => $description,
|
|
'effective_date' => $validated['date'],
|
|
'planned_date' => $validated['date'],
|
|
'status' => 'completed',
|
|
'notes' => $validated['notes'] ?? "Transferência para {$toAccount->name}",
|
|
'reference' => 'TRANSFER',
|
|
'cost_center_id' => $costCenterId,
|
|
]);
|
|
|
|
// Criar transação de CRÉDITO na conta de destino
|
|
$creditTransaction = Transaction::create([
|
|
'user_id' => $userId,
|
|
'account_id' => $toAccount->id,
|
|
'type' => 'credit',
|
|
'amount' => $validated['amount'],
|
|
'planned_amount' => $validated['amount'],
|
|
'description' => $description,
|
|
'effective_date' => $validated['date'],
|
|
'planned_date' => $validated['date'],
|
|
'status' => 'completed',
|
|
'notes' => $validated['notes'] ?? "Transferência de {$fromAccount->name}",
|
|
'reference' => 'TRANSFER',
|
|
'cost_center_id' => $costCenterId,
|
|
]);
|
|
|
|
// Vincular as duas transações
|
|
$debitTransaction->update(['transfer_pair_id' => $creditTransaction->id]);
|
|
$creditTransaction->update(['transfer_pair_id' => $debitTransaction->id]);
|
|
|
|
DB::commit();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Transferência realizada com sucesso',
|
|
'data' => [
|
|
'debit' => $debitTransaction->load('account'),
|
|
'credit' => $creditTransaction->load('account'),
|
|
],
|
|
], 201);
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Erro ao realizar transferência: ' . $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Desvincular transferência
|
|
* Remove os flags is_transfer e transfer_linked_id de ambas as transações
|
|
*/
|
|
public function unlinkTransfer(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar se pertence ao usuário
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
// Verificar se é uma transferência
|
|
if (!$transaction->is_transfer) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Esta transação não é uma transferência',
|
|
], 422);
|
|
}
|
|
|
|
DB::beginTransaction();
|
|
try {
|
|
// Encontrar a transação vinculada (se existir)
|
|
$linkedTransaction = null;
|
|
if ($transaction->transfer_linked_id) {
|
|
$linkedTransaction = Transaction::where('id', $transaction->transfer_linked_id)
|
|
->where('user_id', $request->user()->id)
|
|
->first();
|
|
}
|
|
|
|
// Também verificar pelo transfer_pair_id
|
|
if (!$linkedTransaction && $transaction->transfer_pair_id) {
|
|
$linkedTransaction = Transaction::where('id', $transaction->transfer_pair_id)
|
|
->where('user_id', $request->user()->id)
|
|
->first();
|
|
}
|
|
|
|
// Remover flags da transação principal
|
|
$transaction->update([
|
|
'is_transfer' => false,
|
|
'transfer_linked_id' => null,
|
|
'transfer_pair_id' => null,
|
|
'reference' => $transaction->reference === 'TRANSFER' ? null : $transaction->reference,
|
|
]);
|
|
|
|
// Remover flags da transação vinculada (se existir)
|
|
if ($linkedTransaction) {
|
|
$linkedTransaction->update([
|
|
'is_transfer' => false,
|
|
'transfer_linked_id' => null,
|
|
'transfer_pair_id' => null,
|
|
'reference' => $linkedTransaction->reference === 'TRANSFER' ? null : $linkedTransaction->reference,
|
|
]);
|
|
}
|
|
|
|
DB::commit();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Transferência desvinculada com sucesso',
|
|
'data' => [
|
|
'transaction' => $transaction->fresh()->load(['account', 'category', 'costCenter']),
|
|
'linked_transaction' => $linkedTransaction?->fresh()->load(['account', 'category', 'costCenter']),
|
|
],
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Erro ao desvincular transferência: ' . $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Efetivação rápida de transação pendente
|
|
* Permite efetivar direto da listagem com dados mínimos
|
|
*/
|
|
public function quickComplete(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar se pertence ao usuário
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
if ($transaction->status !== 'pending') {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Apenas transações pendentes podem ser efetivadas',
|
|
], 422);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'amount' => 'nullable|numeric|min:0',
|
|
'effective_date' => 'nullable|date',
|
|
]);
|
|
|
|
$transaction->update([
|
|
'status' => 'completed',
|
|
'amount' => $validated['amount'] ?? $transaction->planned_amount,
|
|
'effective_date' => $validated['effective_date'] ?? now()->toDateString(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Transação efetivada com sucesso',
|
|
'data' => $transaction->load(['account', 'category', 'costCenter']),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Dividir transação em múltiplas categorias
|
|
* A transação original é marcada como "pai" e novas transações filhas são criadas
|
|
*/
|
|
public function split(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar se pertence ao usuário
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
// Não pode dividir transação já dividida ou filha
|
|
if ($transaction->is_split_parent || $transaction->is_split_child) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Esta transação já foi dividida ou é resultado de uma divisão',
|
|
], 422);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'splits' => 'required|array|min:2',
|
|
'splits.*.category_id' => 'nullable|exists:categories,id',
|
|
'splits.*.amount' => 'required|numeric|min:0.01',
|
|
'splits.*.description' => 'nullable|string|max:255',
|
|
]);
|
|
|
|
// Validar que a soma das divisões é igual ao valor original
|
|
$totalSplit = collect($validated['splits'])->sum('amount');
|
|
$originalAmount = $transaction->amount ?? $transaction->planned_amount;
|
|
|
|
if (abs($totalSplit - $originalAmount) > 0.01) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => "A soma das divisões ({$totalSplit}) deve ser igual ao valor original ({$originalAmount})",
|
|
], 422);
|
|
}
|
|
|
|
DB::beginTransaction();
|
|
try {
|
|
// Marcar transação original como pai
|
|
$transaction->update([
|
|
'is_split_parent' => true,
|
|
]);
|
|
|
|
$splitTransactions = [];
|
|
|
|
foreach ($validated['splits'] as $index => $split) {
|
|
$splitTransaction = Transaction::create([
|
|
'user_id' => $transaction->user_id,
|
|
'account_id' => $transaction->account_id,
|
|
'category_id' => $split['category_id'] ?? null,
|
|
'cost_center_id' => $transaction->cost_center_id,
|
|
'type' => $transaction->type,
|
|
'amount' => $transaction->status === 'completed' ? $split['amount'] : null,
|
|
'planned_amount' => $split['amount'],
|
|
'description' => $split['description'] ?? "{$transaction->description} (Parte " . ($index + 1) . ")",
|
|
'original_description' => $transaction->original_description,
|
|
'effective_date' => $transaction->effective_date,
|
|
'planned_date' => $transaction->planned_date,
|
|
'status' => $transaction->status,
|
|
'notes' => $transaction->notes,
|
|
'reference' => $transaction->reference,
|
|
'parent_transaction_id' => $transaction->id,
|
|
'is_split_child' => true,
|
|
]);
|
|
|
|
$splitTransactions[] = $splitTransaction;
|
|
}
|
|
|
|
DB::commit();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Transação dividida com sucesso em ' . count($splitTransactions) . ' partes',
|
|
'data' => [
|
|
'parent' => $transaction->fresh(['account', 'category', 'costCenter']),
|
|
'splits' => collect($splitTransactions)->map(fn($t) => $t->load(['account', 'category', 'costCenter'])),
|
|
],
|
|
], 201);
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Erro ao dividir transação: ' . $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Desfazer divisão de transação
|
|
* Remove as transações filhas e restaura a transação pai
|
|
*/
|
|
public function unsplit(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar se pertence ao usuário
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
if (!$transaction->is_split_parent) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Esta transação não foi dividida',
|
|
], 422);
|
|
}
|
|
|
|
DB::beginTransaction();
|
|
try {
|
|
// Deletar transações filhas
|
|
Transaction::where('parent_transaction_id', $transaction->id)->delete();
|
|
|
|
// Restaurar transação pai
|
|
$transaction->update([
|
|
'is_split_parent' => false,
|
|
]);
|
|
|
|
DB::commit();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Divisão desfeita com sucesso',
|
|
'data' => $transaction->fresh(['account', 'category', 'costCenter']),
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Erro ao desfazer divisão: ' . $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obter transações filhas de uma divisão
|
|
*/
|
|
public function getSplits(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar se pertence ao usuário
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
if (!$transaction->is_split_parent) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Esta transação não foi dividida',
|
|
], 422);
|
|
}
|
|
|
|
$splits = Transaction::where('parent_transaction_id', $transaction->id)
|
|
->with(['category', 'costCenter'])
|
|
->get();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => [
|
|
'parent' => $transaction->load(['account', 'category', 'costCenter']),
|
|
'splits' => $splits,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Buscar parcelas de passivo compatíveis para conciliação
|
|
*/
|
|
public function findLiabilityInstallments(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar se pertence ao usuário
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
// Só transações de débito podem ser conciliadas com passivos
|
|
if ($transaction->type !== 'debit') {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Apenas transações de débito podem ser conciliadas com passivos',
|
|
], 422);
|
|
}
|
|
|
|
// Já está conciliada?
|
|
$alreadyReconciled = \App\Models\LiabilityInstallment::where('reconciled_transaction_id', $transaction->id)->exists();
|
|
if ($alreadyReconciled) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Esta transação já está conciliada com um passivo',
|
|
], 422);
|
|
}
|
|
|
|
$toleranceDays = $request->input('tolerance_days', 15);
|
|
$toleranceAmount = $request->input('tolerance_amount', 0.10); // 10% de tolerância no valor
|
|
|
|
// Considera tanto o valor efetivo quanto o planejado da transação
|
|
$transactionEffective = abs($transaction->amount ?: 0);
|
|
$transactionPlanned = abs($transaction->planned_amount ?: 0);
|
|
// Ambas as datas para matching
|
|
$transactionDateEffective = $transaction->effective_date;
|
|
$transactionDatePlanned = $transaction->planned_date;
|
|
$transactionDate = $transactionDateEffective ?: $transactionDatePlanned;
|
|
|
|
// Buscar parcelas pendentes com valores e datas próximas
|
|
$installments = \App\Models\LiabilityInstallment::whereNull('reconciled_transaction_id')
|
|
->whereIn('status', ['pending', 'overdue'])
|
|
->whereHas('liabilityAccount', function ($q) use ($request) {
|
|
$q->where('user_id', $request->user()->id);
|
|
})
|
|
->with(['liabilityAccount:id,name,creditor'])
|
|
->get()
|
|
->map(function ($installment) use ($transactionEffective, $transactionPlanned, $transactionDateEffective, $transactionDatePlanned, $toleranceDays, $toleranceAmount) {
|
|
// Valores da parcela: planejado e pago
|
|
$installmentPlanned = abs($installment->installment_amount);
|
|
$installmentPaid = $installment->paid_amount ? abs($installment->paid_amount) : null;
|
|
|
|
// Calcular todas as combinações possíveis de diferença
|
|
$combinations = [];
|
|
|
|
// TX efetivo vs Parcela planejado
|
|
if ($transactionEffective > 0) {
|
|
$diff = abs($transactionEffective - $installmentPlanned);
|
|
$pct = $installmentPlanned > 0 ? ($diff / $installmentPlanned) : 1;
|
|
$combinations[] = ['diff' => $diff, 'pct' => $pct, 'type' => 'effective_vs_planned'];
|
|
}
|
|
|
|
// TX planejado vs Parcela planejado
|
|
if ($transactionPlanned > 0) {
|
|
$diff = abs($transactionPlanned - $installmentPlanned);
|
|
$pct = $installmentPlanned > 0 ? ($diff / $installmentPlanned) : 1;
|
|
$combinations[] = ['diff' => $diff, 'pct' => $pct, 'type' => 'planned_vs_planned'];
|
|
}
|
|
|
|
// TX efetivo vs Parcela pago
|
|
if ($transactionEffective > 0 && $installmentPaid !== null && $installmentPaid > 0) {
|
|
$diff = abs($transactionEffective - $installmentPaid);
|
|
$pct = $installmentPaid > 0 ? ($diff / $installmentPaid) : 1;
|
|
$combinations[] = ['diff' => $diff, 'pct' => $pct, 'type' => 'effective_vs_paid'];
|
|
}
|
|
|
|
// Escolher a melhor combinação (menor diferença percentual)
|
|
usort($combinations, fn($a, $b) => $a['pct'] <=> $b['pct']);
|
|
$best = $combinations[0] ?? null;
|
|
|
|
if (!$best) return null;
|
|
|
|
$amountDiff = $best['diff'];
|
|
$amountDiffPercent = $best['pct'];
|
|
$matchType = $best['type'];
|
|
|
|
// Calcular confiança
|
|
$confidence = 0;
|
|
$reasons = [];
|
|
|
|
// Valor exato ou muito próximo
|
|
if ($amountDiff < 0.01) {
|
|
$confidence += 50;
|
|
$reasons[] = 'exact_amount';
|
|
$reasons[] = $matchType;
|
|
} elseif ($amountDiffPercent <= $toleranceAmount) {
|
|
$confidence += 30;
|
|
$reasons[] = 'similar_amount';
|
|
$reasons[] = $matchType;
|
|
} else {
|
|
return null; // Valor muito diferente, ignorar
|
|
}
|
|
|
|
// Calcular diferença de datas considerando ambas as datas da transação
|
|
$daysDiffEffective = $transactionDateEffective ? abs($transactionDateEffective->diffInDays($installment->due_date)) : PHP_INT_MAX;
|
|
$daysDiffPlanned = $transactionDatePlanned ? abs($transactionDatePlanned->diffInDays($installment->due_date)) : PHP_INT_MAX;
|
|
$daysDiff = min($daysDiffEffective, $daysDiffPlanned);
|
|
|
|
// Data próxima
|
|
if ($daysDiff == 0) {
|
|
$confidence += 30;
|
|
$reasons[] = 'same_date';
|
|
} elseif ($daysDiff <= 3) {
|
|
$confidence += 25;
|
|
$reasons[] = 'within_3_days';
|
|
} elseif ($daysDiff <= 7) {
|
|
$confidence += 15;
|
|
$reasons[] = 'within_7_days';
|
|
} elseif ($daysDiff <= $toleranceDays) {
|
|
$confidence += 5;
|
|
$reasons[] = 'within_tolerance';
|
|
} else {
|
|
return null; // Data muito diferente, ignorar
|
|
}
|
|
|
|
// Status overdue aumenta chance
|
|
if ($installment->status === 'overdue') {
|
|
$confidence += 10;
|
|
$reasons[] = 'overdue';
|
|
}
|
|
|
|
$level = $confidence >= 70 ? 'high' : ($confidence >= 50 ? 'medium' : 'low');
|
|
|
|
// Calcular sobrepagamento (cargo/juros extra)
|
|
$transactionTotalAmount = $transactionEffective > 0 ? $transactionEffective : $transactionPlanned;
|
|
$overpayment = $transactionTotalAmount - $installmentPlanned;
|
|
$hasOverpayment = $overpayment > 0.01;
|
|
|
|
return [
|
|
'id' => $installment->id,
|
|
'liability_account_id' => $installment->liability_account_id,
|
|
'liability_name' => $installment->liabilityAccount->name ?? 'N/A',
|
|
'creditor' => $installment->liabilityAccount->creditor ?? null,
|
|
'installment_number' => $installment->installment_number,
|
|
'due_date' => $installment->due_date->format('Y-m-d'),
|
|
'installment_amount' => (float) $installment->installment_amount,
|
|
'status' => $installment->status,
|
|
'days_diff' => $daysDiff,
|
|
'amount_diff' => round($amountDiff, 2),
|
|
'overpayment' => $hasOverpayment ? round($overpayment, 2) : null,
|
|
'has_overpayment' => $hasOverpayment,
|
|
'confidence' => [
|
|
'percentage' => min(100, $confidence),
|
|
'level' => $level,
|
|
'reasons' => $reasons,
|
|
],
|
|
];
|
|
})
|
|
->filter()
|
|
->sortByDesc(fn($i) => $i['confidence']['percentage'])
|
|
->values();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'transaction' => [
|
|
'id' => $transaction->id,
|
|
'description' => $transaction->description,
|
|
'amount' => (float) abs($transaction->amount ?: $transaction->planned_amount),
|
|
'date' => $transactionDate->format('Y-m-d'),
|
|
],
|
|
'installments' => $installments,
|
|
'total' => $installments->count(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Conciliar transação com uma parcela de passivo
|
|
*/
|
|
public function reconcileWithLiability(Request $request, Transaction $transaction): JsonResponse
|
|
{
|
|
// Verificar se pertence ao usuário
|
|
if ($transaction->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'installment_id' => 'required|integer|exists:liability_installments,id',
|
|
'fee_amount' => 'nullable|numeric|min:0', // Cargo/juros extra (sobrepagamento)
|
|
]);
|
|
|
|
$installment = \App\Models\LiabilityInstallment::with('liabilityAccount')
|
|
->findOrFail($validated['installment_id']);
|
|
|
|
// Verificar se a parcela pertence ao usuário
|
|
if ($installment->liabilityAccount->user_id !== $request->user()->id) {
|
|
return response()->json(['message' => 'Parcela não encontrada'], 404);
|
|
}
|
|
|
|
// Verificar se a parcela já está conciliada
|
|
if ($installment->reconciled_transaction_id) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Esta parcela já está conciliada com outra transação',
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
DB::beginTransaction();
|
|
|
|
$paidAmount = abs($transaction->amount ?: $transaction->planned_amount);
|
|
$plannedAmount = (float) $installment->installment_amount;
|
|
$feeAmount = $validated['fee_amount'] ?? 0;
|
|
|
|
// Se há sobrepagamento e não foi especificado fee, calcular automaticamente
|
|
if ($paidAmount > $plannedAmount && $feeAmount == 0) {
|
|
$feeAmount = $paidAmount - $plannedAmount;
|
|
}
|
|
|
|
// Atualizar parcela
|
|
$installment->reconciled_transaction_id = $transaction->id;
|
|
$installment->payment_account_id = $transaction->account_id;
|
|
$installment->status = \App\Models\LiabilityInstallment::STATUS_PAID;
|
|
$installment->paid_amount = $paidAmount;
|
|
$installment->paid_date = $transaction->effective_date ?: $transaction->planned_date;
|
|
|
|
// Se há cargo/juros extra, registrar no fee_amount
|
|
if ($feeAmount > 0) {
|
|
$installment->fee_amount = ($installment->fee_amount ?? 0) + $feeAmount;
|
|
}
|
|
|
|
$installment->save();
|
|
|
|
// Recalcular totais da conta passivo
|
|
$installment->liabilityAccount->recalculateTotals();
|
|
|
|
DB::commit();
|
|
|
|
$responseData = [
|
|
'transaction_id' => $transaction->id,
|
|
'installment_id' => $installment->id,
|
|
'liability_name' => $installment->liabilityAccount->name,
|
|
'paid_amount' => $paidAmount,
|
|
'planned_amount' => $plannedAmount,
|
|
];
|
|
|
|
if ($feeAmount > 0) {
|
|
$responseData['fee_registered'] = $feeAmount;
|
|
$responseData['message_detail'] = "Sobrepagamento de {$feeAmount} registrado como cargo/juros";
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Transação conciliada com sucesso',
|
|
'data' => $responseData,
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Erro ao conciliar: ' . $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
}
|