webmoney/backend/app/Http/Controllers/Api/TransactionController.php
marco f2e032f002 feat: adicionar filtro 'Sem Categoria' nas transações
- Backend: suporte para category_id=uncategorized nos endpoints index e byWeek
- Frontend: opção 'Sem Categoria' no CategorySelector com prop showUncategorized
- Permite filtrar 525 transações importadas que ainda não foram categorizadas
2025-12-19 12:12:07 +01:00

1334 lines
55 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Models\Account;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
class TransactionController extends Controller
{
/**
* Listar transações com filtros
*/
public function index(Request $request): JsonResponse
{
$query = Transaction::ofUser($request->user()->id)
->with(['account:id,name,currency', 'category:id,name,color,icon', 'costCenter:id,name,color']);
// Filtros
if ($request->has('account_id')) {
$query->ofAccount($request->account_id);
}
if ($request->has('category_id')) {
if ($request->category_id === 'uncategorized') {
$query->whereNull('category_id');
} else {
$query->ofCategory($request->category_id);
}
}
if ($request->has('cost_center_id')) {
$query->ofCostCenter($request->cost_center_id);
}
if ($request->has('type')) {
$query->where('type', $request->type);
}
if ($request->has('status')) {
$query->where('status', $request->status);
}
// Filtro por período
if ($request->has('start_date') && $request->has('end_date')) {
$dateField = $request->get('date_field', 'planned_date');
$query->inPeriod($request->start_date, $request->end_date, $dateField);
} else {
// Sem filtro de data: não mostrar transações futuras
$query->where('planned_date', '<=', now()->toDateString());
}
// Busca por descrição e valores
if ($request->has('search')) {
$search = $request->search;
// Limpar formatação de número para busca por valor
$searchNumber = preg_replace('/[^\d.,\-]/', '', $search);
$searchNumber = str_replace(',', '.', $searchNumber);
$query->where(function($q) use ($search, $searchNumber) {
$q->where('description', 'like', "%{$search}%")
->orWhere('reference', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
// Se for um número válido, buscar também nos campos de valor
if (is_numeric($searchNumber)) {
$numericValue = (float) $searchNumber;
// Busca exata
$q->orWhere('planned_amount', '=', $numericValue)
->orWhere('amount', '=', $numericValue);
// Busca por valor sem decimais (ex: "147" encontra 147.00, 147.50, etc.)
if (strpos($searchNumber, '.') === false) {
$q->orWhereBetween('planned_amount', [$numericValue, $numericValue + 0.99])
->orWhereBetween('amount', [$numericValue, $numericValue + 0.99]);
}
}
});
}
// Ordenação
$sortField = $request->get('sort_by', 'planned_date');
$sortOrder = $request->get('sort_order', 'desc');
$query->orderBy($sortField, $sortOrder);
// Paginação ou todos
if ($request->has('per_page')) {
$transactions = $query->paginate($request->per_page);
} else {
$transactions = $query->get();
}
return response()->json($transactions);
}
/**
* Criar nova transação
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'account_id' => 'required|exists:accounts,id',
'category_id' => 'nullable|exists:categories,id',
'cost_center_id' => 'nullable|exists:cost_centers,id',
'amount' => 'nullable|numeric|min:0',
'planned_amount' => 'required|numeric|min:0',
'type' => 'required|in:credit,debit',
'description' => 'required|string|max:255',
'notes' => 'nullable|string',
'effective_date' => 'nullable|date',
'planned_date' => 'required|date',
'status' => 'sometimes|in:pending,completed,cancelled',
'reference' => 'nullable|string|max:100',
'is_recurring' => 'sometimes|boolean',
]);
// Verificar se a conta pertence ao usuário
$account = Account::where('id', $validated['account_id'])
->where('user_id', $request->user()->id)
->firstOrFail();
$validated['user_id'] = $request->user()->id;
// Se 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;
$perPage = $request->get('per_page', 10); // Semanas por página
$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
if ($request->has('start_date') && $request->has('end_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]);
}
}
});
}
// 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);
}
}
}