webmoney/backend/app/Http/Controllers/Api/TransferDetectionController.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',
]);
}
}