832 lines
29 KiB
PHP
832 lines
29 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Transaction;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class TransferDetectionController extends Controller
|
|
{
|
|
/**
|
|
* Detecta possíveis transferências entre contas
|
|
* Critérios: mesmo valor absoluto, datas próximas, tipos opostos (debit/credit), contas diferentes
|
|
*/
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$userId = $request->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',
|
|
]);
|
|
}
|
|
}
|