user()->id; $toleranceDays = min(max((int) $request->input('tolerance_days', 3), 1), 30); // 1-30 dias // Buscar todas as transações não deletadas do usuário $debits = DB::select(" SELECT t.id, t.description, t.planned_amount, t.planned_date, t.status, t.account_id, a.name as account_name FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.deleted_at IS NULL AND t.is_split_child = 0 AND t.type = 'debit' AND t.planned_amount > 0 AND (t.is_transfer = 0 OR t.is_transfer IS NULL) ORDER BY t.planned_date DESC, t.planned_amount DESC ", [$userId]); $credits = DB::select(" SELECT t.id, t.description, t.planned_amount, t.planned_date, t.status, t.account_id, a.name as account_name FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.deleted_at IS NULL AND t.is_split_child = 0 AND t.type = 'credit' AND t.planned_amount > 0 AND (t.is_transfer = 0 OR t.is_transfer IS NULL) ORDER BY t.planned_date DESC, t.planned_amount DESC ", [$userId]); $potentialTransfers = []; $usedDebitIds = []; $usedCreditIds = []; foreach ($debits as $debit) { if (in_array($debit->id, $usedDebitIds)) continue; foreach ($credits as $credit) { if (in_array($credit->id, $usedCreditIds)) continue; // Deve ser em contas DIFERENTES if ($debit->account_id === $credit->account_id) continue; // Mesmo valor if (abs((float)$debit->planned_amount - (float)$credit->planned_amount) > 0.01) continue; // Data dentro da tolerância $debitDate = strtotime($debit->planned_date); $creditDate = strtotime($credit->planned_date); $daysDiff = abs(($creditDate - $debitDate) / 86400); if ($daysDiff > $toleranceDays) continue; // Calcular confiança $confidence = $this->calculateConfidence($debit, $credit, $daysDiff); $potentialTransfers[] = [ 'debit' => [ 'id' => $debit->id, 'description' => $debit->description, 'amount' => $debit->planned_amount, 'date' => $debit->planned_date, 'status' => $debit->status, 'account_id' => $debit->account_id, 'account_name' => $debit->account_name, ], 'credit' => [ 'id' => $credit->id, 'description' => $credit->description, 'amount' => $credit->planned_amount, 'date' => $credit->planned_date, 'status' => $credit->status, 'account_id' => $credit->account_id, 'account_name' => $credit->account_name, ], 'amount' => $debit->planned_amount, 'days_diff' => (int)$daysDiff, 'confidence' => $confidence, ]; $usedDebitIds[] = $debit->id; $usedCreditIds[] = $credit->id; break; // Encontrou match, passar para próximo débito } } // Ordenar por confiança (maior primeiro) usort($potentialTransfers, function($a, $b) { return $b['confidence']['percentage'] <=> $a['confidence']['percentage']; }); return response()->json([ 'data' => $potentialTransfers, 'total' => count($potentialTransfers), 'tolerance_days' => $toleranceDays, ]); } /** * Calcula nível de confiança de que é uma transferência */ private function calculateConfidence($debit, $credit, $daysDiff): array { $confidence = 50; // Base: mesmo valor em contas diferentes $reasons = ['same_amount_different_accounts']; // Mesma data = +30% if ($daysDiff == 0) { $confidence += 30; $reasons[] = 'same_date'; } elseif ($daysDiff == 1) { $confidence += 20; $reasons[] = 'next_day'; } elseif ($daysDiff <= 3) { $confidence += 10; $reasons[] = 'within_3_days'; } // Descrição contém palavras-chave de transferência $transferKeywords = ['transfer', 'transferencia', 'transferência', 'traspaso', 'envio', 'recebido', 'recibido', 'deposito', 'depósito']; $debitDesc = strtolower($debit->description ?? ''); $creditDesc = strtolower($credit->description ?? ''); foreach ($transferKeywords as $keyword) { if (strpos($debitDesc, $keyword) !== false || strpos($creditDesc, $keyword) !== false) { $confidence += 15; $reasons[] = 'transfer_keyword'; break; } } // Mesmo status = +5% if ($debit->status === $credit->status) { $confidence += 5; $reasons[] = 'same_status'; } $confidence = min(100, $confidence); if ($confidence >= 90) { $level = 'high'; } elseif ($confidence >= 70) { $level = 'medium'; } else { $level = 'low'; } return [ 'percentage' => $confidence, 'level' => $level, 'reasons' => $reasons, ]; } /** * Busca possíveis pares para uma transação específica * Para converter uma transação em transferência */ public function findPairs(Request $request, int $transactionId): JsonResponse { $userId = $request->user()->id; $toleranceDays = $request->input('tolerance_days', 7); // Tolerância maior para busca manual // Buscar a transação origem $transaction = Transaction::where('id', $transactionId) ->where('user_id', $userId) ->whereNull('deleted_at') ->first(); if (!$transaction) { return response()->json(['error' => 'Transaction not found'], 404); } // Não pode ser uma transferência já vinculada if ($transaction->is_transfer) { return response()->json(['error' => 'Transaction is already a transfer'], 400); } // Determinar o tipo oposto $oppositeType = $transaction->type === 'debit' ? 'credit' : 'debit'; // Buscar transações candidatas (tipo oposto, mesmo valor, contas diferentes, datas próximas) $candidates = DB::select(" SELECT t.id, t.description, t.planned_amount, t.amount, t.planned_date, t.effective_date, t.status, t.type, t.account_id, a.name as account_name FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.deleted_at IS NULL AND t.is_split_child = 0 AND t.type = ? AND t.account_id != ? AND (t.is_transfer = 0 OR t.is_transfer IS NULL) AND ABS(t.planned_amount - ?) < 0.01 AND ABS(DATEDIFF(t.planned_date, ?)) <= ? ORDER BY ABS(DATEDIFF(t.planned_date, ?)) ASC, t.planned_date DESC ", [ $userId, $oppositeType, $transaction->account_id, $transaction->planned_amount, $transaction->planned_date, $toleranceDays, $transaction->planned_date ]); $potentialPairs = []; foreach ($candidates as $candidate) { $candidateDate = strtotime($candidate->planned_date); $transactionDate = strtotime($transaction->planned_date); $daysDiff = abs(($candidateDate - $transactionDate) / 86400); // Usar método de confiança existente (adaptar objetos) $sourceObj = (object)[ 'description' => $transaction->description, 'status' => $transaction->status, ]; $candidateObj = (object)[ 'description' => $candidate->description, 'status' => $candidate->status, ]; $confidence = $this->calculateConfidence($sourceObj, $candidateObj, $daysDiff); $potentialPairs[] = [ 'id' => $candidate->id, 'description' => $candidate->description, 'amount' => $candidate->planned_amount, 'date' => $candidate->planned_date, 'status' => $candidate->status, 'type' => $candidate->type, 'account_id' => $candidate->account_id, 'account_name' => $candidate->account_name, 'days_diff' => (int)$daysDiff, 'confidence' => $confidence, ]; } // Ordenar por confiança (maior primeiro), depois por diferença de dias (menor primeiro) usort($potentialPairs, function($a, $b) { if ($b['confidence']['percentage'] !== $a['confidence']['percentage']) { return $b['confidence']['percentage'] <=> $a['confidence']['percentage']; } return $a['days_diff'] <=> $b['days_diff']; }); return response()->json([ 'source' => [ 'id' => $transaction->id, 'description' => $transaction->description, 'amount' => $transaction->planned_amount, 'date' => $transaction->planned_date, 'status' => $transaction->status, 'type' => $transaction->type, 'account_id' => $transaction->account_id, 'account_name' => $transaction->account->name ?? null, ], 'pairs' => $potentialPairs, 'total' => count($potentialPairs), 'tolerance_days' => $toleranceDays, ]); } /** * Marca um par como transferência confirmada (vincula as duas transações) */ public function confirm(Request $request): JsonResponse { $request->validate([ 'debit_id' => 'required|integer|exists:transactions,id', 'credit_id' => 'required|integer|exists:transactions,id', ]); $userId = $request->user()->id; $debitId = $request->input('debit_id'); $creditId = $request->input('credit_id'); // Verificar se ambas transações pertencem ao usuário $debit = Transaction::where('id', $debitId)->where('user_id', $userId)->first(); $credit = Transaction::where('id', $creditId)->where('user_id', $userId)->first(); if (!$debit || !$credit) { return response()->json(['error' => 'Transaction not found'], 404); } // Marcar como transferência vinculada $debit->transfer_linked_id = $creditId; $debit->is_transfer = true; $debit->save(); $credit->transfer_linked_id = $debitId; $credit->is_transfer = true; $credit->save(); return response()->json([ 'success' => true, 'message' => 'Transfer confirmed and linked', 'debit_id' => $debitId, 'credit_id' => $creditId, ]); } /** * Ignora um par de transferência (não sugerir novamente) */ public function ignore(Request $request): JsonResponse { $request->validate([ 'debit_id' => 'required|integer|exists:transactions,id', 'credit_id' => 'required|integer|exists:transactions,id', ]); $userId = $request->user()->id; // Salvar na tabela de pares ignorados DB::table('ignored_transfer_pairs')->insertOrIgnore([ 'user_id' => $userId, 'debit_transaction_id' => $request->input('debit_id'), 'credit_transaction_id' => $request->input('credit_id'), 'created_at' => now(), ]); return response()->json([ 'success' => true, 'message' => 'Transfer pair ignored', ]); } /** * Deleta ambas as transações (débito e crédito) de uma transferência */ public function deleteBoth(Request $request): JsonResponse { $request->validate([ 'debit_id' => 'required|integer|exists:transactions,id', 'credit_id' => 'required|integer|exists:transactions,id', ]); $userId = $request->user()->id; $debitId = $request->input('debit_id'); $creditId = $request->input('credit_id'); $deleted = Transaction::whereIn('id', [$debitId, $creditId]) ->where('user_id', $userId) ->delete(); return response()->json([ 'success' => true, 'message' => 'Both transactions deleted', 'deleted_count' => $deleted, ]); } /** * Confirmar múltiplas transferências em lote */ public function confirmBatch(Request $request): JsonResponse { $request->validate([ 'transfers' => 'required|array|min:1', 'transfers.*.debit_id' => 'required|integer|exists:transactions,id', 'transfers.*.credit_id' => 'required|integer|exists:transactions,id', ]); $userId = $request->user()->id; $transfers = $request->input('transfers'); $confirmed = 0; $errors = []; foreach ($transfers as $index => $transfer) { $debitId = $transfer['debit_id']; $creditId = $transfer['credit_id']; // Verificar se ambas transações pertencem ao usuário $debit = Transaction::where('id', $debitId)->where('user_id', $userId)->first(); $credit = Transaction::where('id', $creditId)->where('user_id', $userId)->first(); if (!$debit || !$credit) { $errors[] = "Transfer {$index}: Transaction not found"; continue; } // Marcar como transferência vinculada $debit->transfer_linked_id = $creditId; $debit->is_transfer = true; $debit->save(); $credit->transfer_linked_id = $debitId; $credit->is_transfer = true; $credit->save(); $confirmed++; } return response()->json([ 'success' => true, 'message' => "Confirmed {$confirmed} transfers", 'confirmed_count' => $confirmed, 'errors' => $errors, ]); } /** * Estatísticas de transferências */ public function stats(Request $request): JsonResponse { $userId = $request->user()->id; $confirmed = Transaction::where('user_id', $userId) ->where('is_transfer', true) ->whereNull('deleted_at') ->count(); $ignored = DB::table('ignored_transfer_pairs') ->where('user_id', $userId) ->count(); return response()->json([ 'confirmed_transfers' => $confirmed / 2, // Dividir por 2 pois cada transferência tem 2 transações 'ignored_pairs' => $ignored, ]); } /** * Detecta possíveis reembolsos (gastos que foram devolvidos) * Critérios: mesmo valor, mesma conta, datas próximas, tipos opostos (debit/credit) */ public function refunds(Request $request): JsonResponse { $userId = $request->user()->id; $toleranceDays = min(max((int) $request->input('tolerance_days', 7), 1), 30); // Buscar débitos (gastos) $debits = DB::select(" SELECT t.id, t.description, t.original_description, t.planned_amount, t.amount, t.planned_date, t.effective_date, t.status, t.account_id, a.name as account_name, a.currency as account_currency FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.deleted_at IS NULL AND t.is_split_child = 0 AND t.type = 'debit' AND COALESCE(t.amount, t.planned_amount) > 0 AND (t.is_transfer = 0 OR t.is_transfer IS NULL) AND (t.is_refund_pair = 0 OR t.is_refund_pair IS NULL) ORDER BY t.planned_date DESC, t.planned_amount DESC ", [$userId]); // Buscar créditos (reembolsos potenciais) $credits = DB::select(" SELECT t.id, t.description, t.original_description, t.planned_amount, t.amount, t.planned_date, t.effective_date, t.status, t.account_id, a.name as account_name, a.currency as account_currency FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id WHERE t.user_id = ? AND t.deleted_at IS NULL AND t.is_split_child = 0 AND t.type = 'credit' AND COALESCE(t.amount, t.planned_amount) > 0 AND (t.is_transfer = 0 OR t.is_transfer IS NULL) AND (t.is_refund_pair = 0 OR t.is_refund_pair IS NULL) ORDER BY t.planned_date DESC, t.planned_amount DESC ", [$userId]); $potentialRefunds = []; $usedDebitIds = []; $usedCreditIds = []; // Verificar pares ignorados $ignoredPairs = DB::table('ignored_refund_pairs') ->where('user_id', $userId) ->get() ->map(fn($p) => "{$p->debit_id}-{$p->credit_id}") ->toArray(); foreach ($debits as $debit) { if (in_array($debit->id, $usedDebitIds)) continue; foreach ($credits as $credit) { if (in_array($credit->id, $usedCreditIds)) continue; // Deve ser na MESMA conta (diferente de transferência) if ($debit->account_id !== $credit->account_id) continue; // Mesmo valor (usar amount se disponível, senão planned_amount) $debitAmount = $debit->amount > 0 ? $debit->amount : $debit->planned_amount; $creditAmount = $credit->amount > 0 ? $credit->amount : $credit->planned_amount; if (abs((float)$debitAmount - (float)$creditAmount) > 0.01) continue; // Data dentro da tolerância $debitDate = strtotime($debit->effective_date ?? $debit->planned_date); $creditDate = strtotime($credit->effective_date ?? $credit->planned_date); $daysDiff = abs(($creditDate - $debitDate) / 86400); if ($daysDiff > $toleranceDays) continue; // Verificar se foi ignorado $pairKey = "{$debit->id}-{$credit->id}"; if (in_array($pairKey, $ignoredPairs)) continue; // Calcular confiança e similaridade de descrição $confidence = $this->calculateRefundConfidence($debit, $credit, $daysDiff); $potentialRefunds[] = [ 'debit' => [ 'id' => $debit->id, 'description' => $debit->description, 'original_description' => $debit->original_description, 'amount' => (float) $debitAmount, 'date' => $debit->effective_date ?? $debit->planned_date, 'status' => $debit->status, 'account_id' => $debit->account_id, 'account_name' => $debit->account_name, ], 'credit' => [ 'id' => $credit->id, 'description' => $credit->description, 'original_description' => $credit->original_description, 'amount' => (float) $creditAmount, 'date' => $credit->effective_date ?? $credit->planned_date, 'status' => $credit->status, 'account_id' => $credit->account_id, 'account_name' => $credit->account_name, ], 'amount' => (float) $debitAmount, 'currency' => $debit->account_currency ?? 'EUR', 'days_diff' => (int) $daysDiff, 'confidence' => $confidence, ]; $usedDebitIds[] = $debit->id; $usedCreditIds[] = $credit->id; break; } } // Ordenar por confiança (maior primeiro) usort($potentialRefunds, function($a, $b) { return $b['confidence']['percentage'] <=> $a['confidence']['percentage']; }); return response()->json([ 'data' => $potentialRefunds, 'total' => count($potentialRefunds), 'tolerance_days' => $toleranceDays, ]); } /** * Calcula confiança de que é um par gasto/reembolso */ private function calculateRefundConfidence($debit, $credit, $daysDiff): array { $confidence = 40; // Base: mesmo valor na mesma conta $reasons = ['same_amount_same_account']; // Mesma data = +25% if ($daysDiff == 0) { $confidence += 25; $reasons[] = 'same_date'; } elseif ($daysDiff == 1) { $confidence += 20; $reasons[] = 'next_day'; } elseif ($daysDiff <= 3) { $confidence += 15; $reasons[] = 'within_3_days'; } elseif ($daysDiff <= 7) { $confidence += 10; $reasons[] = 'within_week'; } // Similaridade de descrição $debitDesc = strtolower($debit->original_description ?? $debit->description ?? ''); $creditDesc = strtolower($credit->original_description ?? $credit->description ?? ''); // Extrair palavras significativas (>3 caracteres) $debitWords = array_filter(preg_split('/\s+/', $debitDesc), fn($w) => strlen($w) > 3); $creditWords = array_filter(preg_split('/\s+/', $creditDesc), fn($w) => strlen($w) > 3); if (!empty($debitWords) && !empty($creditWords)) { $commonWords = array_intersect($debitWords, $creditWords); $totalWords = count(array_unique(array_merge($debitWords, $creditWords))); $similarity = $totalWords > 0 ? (count($commonWords) / $totalWords) * 100 : 0; if ($similarity >= 50) { $confidence += 25; $reasons[] = 'high_description_similarity'; } elseif ($similarity >= 30) { $confidence += 15; $reasons[] = 'medium_description_similarity'; } elseif ($similarity >= 15) { $confidence += 10; $reasons[] = 'low_description_similarity'; } } // Keywords de reembolso $refundKeywords = ['bizum', 'devolucion', 'devolución', 'reembolso', 'refund', 'return', 'abono', 'favor']; foreach ($refundKeywords as $keyword) { if (strpos($debitDesc, $keyword) !== false || strpos($creditDesc, $keyword) !== false) { $confidence += 10; $reasons[] = 'refund_keyword'; break; } } // Mesmo status = +5% if ($debit->status === $credit->status) { $confidence += 5; $reasons[] = 'same_status'; } $confidence = min(100, $confidence); if ($confidence >= 85) { $level = 'high'; } elseif ($confidence >= 65) { $level = 'medium'; } else { $level = 'low'; } return [ 'percentage' => $confidence, 'level' => $level, 'reasons' => $reasons, ]; } /** * Confirma um par como reembolso (anula ambas transações) */ public function confirmRefund(Request $request): JsonResponse { $request->validate([ 'debit_id' => 'required|integer|exists:transactions,id', 'credit_id' => 'required|integer|exists:transactions,id', ]); $userId = $request->user()->id; $debitId = $request->input('debit_id'); $creditId = $request->input('credit_id'); $debit = Transaction::where('id', $debitId)->where('user_id', $userId)->first(); $credit = Transaction::where('id', $creditId)->where('user_id', $userId)->first(); if (!$debit || !$credit) { return response()->json(['error' => 'Transaction not found'], 404); } // Marcar ambas como par de reembolso (soft-anulação) $debit->is_refund_pair = true; $debit->refund_linked_id = $creditId; $debit->save(); $credit->is_refund_pair = true; $credit->refund_linked_id = $debitId; $credit->save(); return response()->json([ 'success' => true, 'message' => 'Refund pair confirmed. Both transactions are now linked and excluded from calculations.', 'debit_id' => $debitId, 'credit_id' => $creditId, ]); } /** * Confirma múltiplos pares como reembolso */ public function confirmRefundBatch(Request $request): JsonResponse { $request->validate([ 'pairs' => 'required|array|min:1', 'pairs.*.debit_id' => 'required|integer|exists:transactions,id', 'pairs.*.credit_id' => 'required|integer|exists:transactions,id', ]); $userId = $request->user()->id; $pairs = $request->input('pairs'); $confirmed = 0; foreach ($pairs as $pair) { $debit = Transaction::where('id', $pair['debit_id'])->where('user_id', $userId)->first(); $credit = Transaction::where('id', $pair['credit_id'])->where('user_id', $userId)->first(); if (!$debit || !$credit) continue; $debit->is_refund_pair = true; $debit->refund_linked_id = $pair['credit_id']; $debit->save(); $credit->is_refund_pair = true; $credit->refund_linked_id = $pair['debit_id']; $credit->save(); $confirmed++; } return response()->json([ 'success' => true, 'message' => "Confirmed {$confirmed} refund pairs", 'confirmed_count' => $confirmed, ]); } /** * Ignorar um par de reembolso */ public function ignoreRefund(Request $request): JsonResponse { $request->validate([ 'debit_id' => 'required|integer|exists:transactions,id', 'credit_id' => 'required|integer|exists:transactions,id', ]); $userId = $request->user()->id; DB::table('ignored_refund_pairs')->insert([ 'user_id' => $userId, 'debit_id' => $request->input('debit_id'), 'credit_id' => $request->input('credit_id'), 'created_at' => now(), ]); return response()->json([ 'success' => true, 'message' => 'Refund pair ignored', ]); } /** * Desfazer um par de reembolso confirmado */ public function undoRefund(Request $request): JsonResponse { $request->validate([ 'debit_id' => 'required|integer|exists:transactions,id', 'credit_id' => 'required|integer|exists:transactions,id', ]); $userId = $request->user()->id; $debit = Transaction::where('id', $request->input('debit_id')) ->where('user_id', $userId) ->first(); $credit = Transaction::where('id', $request->input('credit_id')) ->where('user_id', $userId) ->first(); if ($debit) { $debit->is_refund_pair = false; $debit->refund_linked_id = null; $debit->save(); } if ($credit) { $credit->is_refund_pair = false; $credit->refund_linked_id = null; $credit->save(); } return response()->json([ 'success' => true, 'message' => 'Refund pair undone', ]); } }