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 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']); // Se há filtros, trazer mais semanas para mostrar todos os resultados $perPage = $hasActiveFilters ? 100 : $request->get('per_page', 10); // Mais semanas quando filtrado $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}"; 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', ]); // 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', ]); // 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); } } }