webmoney/backend/app/Http/Controllers/Api/LiabilityAccountController.php

687 lines
25 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LiabilityAccount;
use App\Models\LiabilityInstallment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use PhpOffice\PhpSpreadsheet\IOFactory;
class LiabilityAccountController extends Controller
{
/**
* Listar todas as contas passivo do usuário
*/
public function index(Request $request): JsonResponse
{
$query = LiabilityAccount::where('user_id', Auth::id())
->with(['installments' => function ($q) {
$q->orderBy('installment_number');
}]);
// Filtros opcionais
if ($request->has('status')) {
$query->where('status', $request->status);
}
if ($request->has('is_active')) {
$query->where('is_active', $request->boolean('is_active'));
}
$accounts = $query->orderBy('name')->get();
// Calcular resumo
$summary = [
'total_principal' => $accounts->sum('principal_amount'),
'total_paid' => $accounts->sum('total_paid'),
'total_pending' => $accounts->sum('total_pending'),
'total_interest' => $accounts->sum('total_interest'),
'total_fees' => $accounts->sum('total_fees'),
'contracts_count' => $accounts->count(),
'active_contracts' => $accounts->where('status', 'active')->count(),
];
return response()->json([
'success' => true,
'data' => $accounts,
'summary' => $summary,
'statuses' => LiabilityAccount::STATUSES,
]);
}
/**
* Criar nova conta passivo manualmente
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:150',
'contract_number' => 'nullable|string|max:100',
'creditor' => 'nullable|string|max:150',
'description' => 'nullable|string',
'principal_amount' => 'required|numeric|min:0',
'currency' => 'nullable|string|size:3',
'color' => 'nullable|string|max:7',
'icon' => 'nullable|string|max:50',
'start_date' => 'nullable|date',
]);
$validated['user_id'] = Auth::id();
$validated['total_pending'] = $validated['principal_amount'];
$validated['status'] = LiabilityAccount::STATUS_ACTIVE;
$account = LiabilityAccount::create($validated);
return response()->json([
'success' => true,
'message' => 'Conta passivo criada com sucesso',
'data' => $account,
], 201);
}
/**
* Exibir uma conta passivo específica com todas as parcelas
*/
public function show(int $id): JsonResponse
{
$account = LiabilityAccount::where('user_id', Auth::id())
->with(['installments' => function ($q) {
$q->orderBy('installment_number');
}])
->findOrFail($id);
return response()->json([
'success' => true,
'data' => $account,
]);
}
/**
* Atualizar uma conta passivo
*/
public function update(Request $request, int $id): JsonResponse
{
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($id);
$validated = $request->validate([
'name' => 'sometimes|required|string|max:150',
'contract_number' => 'nullable|string|max:100',
'creditor' => 'nullable|string|max:150',
'description' => 'nullable|string',
'currency' => 'nullable|string|size:3',
'color' => 'nullable|string|max:7',
'icon' => 'nullable|string|max:50',
'status' => ['sometimes', Rule::in(array_keys(LiabilityAccount::STATUSES))],
'is_active' => 'nullable|boolean',
]);
$account->update($validated);
return response()->json([
'success' => true,
'message' => 'Conta passivo atualizada com sucesso',
'data' => $account->fresh(),
]);
}
/**
* Excluir uma conta passivo
*/
public function destroy(int $id): JsonResponse
{
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($id);
$account->delete();
return response()->json([
'success' => true,
'message' => 'Conta passivo excluída com sucesso',
]);
}
/**
* Importar contrato de arquivo Excel
*/
public function import(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls',
'name' => 'required|string|max:150',
'creditor' => 'nullable|string|max:150',
'contract_number' => 'nullable|string|max:100',
'currency' => 'nullable|string|size:3',
'description' => 'nullable|string',
]);
try {
DB::beginTransaction();
// Criar conta passivo
$liabilityAccount = LiabilityAccount::create([
'user_id' => Auth::id(),
'name' => $request->name,
'creditor' => $request->creditor,
'contract_number' => $request->contract_number,
'currency' => $request->currency ?? 'EUR',
'description' => $request->description,
'principal_amount' => 0, // Será calculado
'status' => LiabilityAccount::STATUS_ACTIVE,
]);
// Processar arquivo Excel
$file = $request->file('file');
$spreadsheet = IOFactory::load($file->getPathname());
$worksheet = $spreadsheet->getActiveSheet();
$rows = $worksheet->toArray();
// Pular cabeçalho
$header = array_shift($rows);
// Mapear colunas (baseado no formato do arquivo exemplo)
// Colunas: Pago, Fecha, Cuota, Intereses, Capital, Estado
$columnMap = $this->mapColumns($header);
$installments = [];
foreach ($rows as $row) {
if (empty($row[$columnMap['installment_number']])) {
continue;
}
$installmentNumber = (int) $row[$columnMap['installment_number']];
$dueDate = $this->parseDate($row[$columnMap['due_date']]);
$installmentAmount = $this->parseAmount($row[$columnMap['installment_amount']]);
$interestAmount = $this->parseAmount($row[$columnMap['interest_amount']]);
$principalAmount = $this->parseAmount($row[$columnMap['principal_amount']]);
$status = $this->parseStatus($row[$columnMap['status']]);
// Calcular taxa extra (se cuota > capital + juros)
$normalAmount = $principalAmount + $interestAmount;
$feeAmount = max(0, $installmentAmount - $normalAmount);
$installments[] = [
'liability_account_id' => $liabilityAccount->id,
'installment_number' => $installmentNumber,
'due_date' => $dueDate,
'installment_amount' => $installmentAmount,
'principal_amount' => $principalAmount,
'interest_amount' => $interestAmount,
'fee_amount' => $feeAmount,
'status' => $status,
'paid_amount' => $status === 'paid' ? $installmentAmount : 0,
'paid_date' => $status === 'paid' ? $dueDate : null,
'created_at' => now(),
'updated_at' => now(),
];
}
// Inserir parcelas
LiabilityInstallment::insert($installments);
// Recalcular totais
$liabilityAccount->recalculateTotals();
DB::commit();
// Recarregar com parcelas
$liabilityAccount = LiabilityAccount::with('installments')->find($liabilityAccount->id);
return response()->json([
'success' => true,
'message' => 'Contrato importado com sucesso',
'data' => $liabilityAccount,
'imported_installments' => count($installments),
], 201);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
'success' => false,
'message' => 'Erro ao importar arquivo: ' . $e->getMessage(),
], 422);
}
}
/**
* Mapear colunas do Excel para campos do sistema
*/
private function mapColumns(array $header): array
{
$map = [
'installment_number' => 0, // Pago (número da parcela)
'due_date' => 1, // Fecha
'installment_amount' => 2, // Cuota
'interest_amount' => 3, // Intereses
'principal_amount' => 4, // Capital
'status' => 5, // Estado
];
// Tentar mapear automaticamente baseado nos nomes das colunas
foreach ($header as $index => $columnName) {
$columnName = strtolower(trim($columnName));
if (in_array($columnName, ['pago', 'numero', 'nº', 'n', 'parcela', 'installment'])) {
$map['installment_number'] = $index;
} elseif (in_array($columnName, ['fecha', 'date', 'data', 'vencimiento', 'due_date'])) {
$map['due_date'] = $index;
} elseif (in_array($columnName, ['cuota', 'quota', 'valor', 'amount', 'installment_amount'])) {
$map['installment_amount'] = $index;
} elseif (in_array($columnName, ['intereses', 'interest', 'juros'])) {
$map['interest_amount'] = $index;
} elseif (in_array($columnName, ['capital', 'principal', 'amortización', 'amortizacion'])) {
$map['principal_amount'] = $index;
} elseif (in_array($columnName, ['estado', 'status', 'situação', 'situacion'])) {
$map['status'] = $index;
}
}
return $map;
}
/**
* Converter string de data para formato válido
*/
private function parseDate($value): string
{
if ($value instanceof \DateTime) {
return $value->format('Y-m-d');
}
// Se for número (Excel serial date)
if (is_numeric($value)) {
$date = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value);
return $date->format('Y-m-d');
}
// Tentar parsear como string
try {
return date('Y-m-d', strtotime($value));
} catch (\Exception $e) {
return date('Y-m-d');
}
}
/**
* Converter string de valor monetário para float
*/
private function parseAmount($value): float
{
if (is_numeric($value)) {
return (float) $value;
}
// Remover símbolos de moeda e espaços
$value = preg_replace('/[€$R\s]/', '', $value);
// Converter vírgula para ponto (formato europeu)
$value = str_replace(',', '.', $value);
// Remover pontos de milhar
if (substr_count($value, '.') > 1) {
$parts = explode('.', $value);
$last = array_pop($parts);
$value = implode('', $parts) . '.' . $last;
}
return (float) $value;
}
/**
* Converter status do Excel para status do sistema
*/
private function parseStatus($value): string
{
$value = strtolower(trim($value));
$paidStatuses = ['abonado', 'paid', 'pago', 'pagado', 'liquidado'];
$pendingStatuses = ['pendiente', 'pending', 'pendente', 'a pagar'];
$overdueStatuses = ['atrasado', 'overdue', 'vencido', 'mora'];
if (in_array($value, $paidStatuses)) {
return LiabilityInstallment::STATUS_PAID;
}
if (in_array($value, $overdueStatuses)) {
return LiabilityInstallment::STATUS_OVERDUE;
}
return LiabilityInstallment::STATUS_PENDING;
}
/**
* Obter parcelas de uma conta passivo
*/
public function installments(int $id): JsonResponse
{
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($id);
$installments = $account->installments()
->orderBy('installment_number')
->get();
return response()->json([
'success' => true,
'data' => $installments,
'statuses' => LiabilityInstallment::STATUSES,
]);
}
/**
* Atualizar status de uma parcela
*/
public function updateInstallment(Request $request, int $accountId, int $installmentId): JsonResponse
{
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId);
$installment = LiabilityInstallment::where('liability_account_id', $account->id)
->findOrFail($installmentId);
$validated = $request->validate([
'status' => ['sometimes', Rule::in(array_keys(LiabilityInstallment::STATUSES))],
'paid_amount' => 'nullable|numeric|min:0',
'paid_date' => 'nullable|date',
'payment_account_id' => 'nullable|exists:accounts,id',
'notes' => 'nullable|string',
]);
// Se marcar como pago
if (isset($validated['status']) && $validated['status'] === 'paid') {
$installment->markAsPaid(
$validated['paid_amount'] ?? null,
isset($validated['paid_date']) ? new \DateTime($validated['paid_date']) : null,
$validated['payment_account_id'] ?? null
);
} else {
$installment->update($validated);
$account->recalculateTotals();
}
return response()->json([
'success' => true,
'message' => 'Parcela atualizada com sucesso',
'data' => $installment->fresh(),
]);
}
/**
* Obter resumo de todas as contas passivo
*/
public function summary(): JsonResponse
{
$accounts = LiabilityAccount::where('user_id', Auth::id())
->where('is_active', true)
->get();
// Agrupar por moeda
$byCurrency = $accounts->groupBy('currency')->map(function ($group) {
return [
'total_principal' => $group->sum('principal_amount'),
'total_paid' => $group->sum('total_paid'),
'total_pending' => $group->sum('total_pending'),
'total_interest' => $group->sum('total_interest'),
'remaining_balance' => $group->sum('remaining_balance'),
'contracts_count' => $group->count(),
];
});
// Próximas parcelas a vencer
$upcomingInstallments = LiabilityInstallment::whereHas('liabilityAccount', function ($q) {
$q->where('user_id', Auth::id())->where('is_active', true);
})
->where('status', 'pending')
->where('due_date', '>=', now())
->where('due_date', '<=', now()->addDays(30))
->with('liabilityAccount:id,name,currency')
->orderBy('due_date')
->limit(10)
->get();
// Parcelas atrasadas
$overdueInstallments = LiabilityInstallment::whereHas('liabilityAccount', function ($q) {
$q->where('user_id', Auth::id())->where('is_active', true);
})
->where('status', '!=', 'paid')
->where('due_date', '<', now())
->with('liabilityAccount:id,name,currency')
->orderBy('due_date')
->get();
return response()->json([
'success' => true,
'data' => [
'by_currency' => $byCurrency,
'upcoming_installments' => $upcomingInstallments,
'overdue_installments' => $overdueInstallments,
'overdue_count' => $overdueInstallments->count(),
],
]);
}
/**
* Conciliar uma parcela com uma transação existente
*
* Vincula uma parcela de conta passivo a uma transação já registrada
*/
public function reconcile(Request $request, int $accountId, int $installmentId): JsonResponse
{
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId);
$installment = LiabilityInstallment::where('liability_account_id', $account->id)
->findOrFail($installmentId);
$validated = $request->validate([
'transaction_id' => 'required|exists:transactions,id',
'mark_as_paid' => 'nullable|boolean',
]);
// Verificar se a transação pertence ao usuário
$transaction = \App\Models\Transaction::where('user_id', Auth::id())
->findOrFail($validated['transaction_id']);
try {
DB::beginTransaction();
// Atualizar parcela com referência à transação
$installment->reconciled_transaction_id = $transaction->id;
$installment->payment_account_id = $transaction->account_id;
// Opcionalmente marcar como paga
if ($request->boolean('mark_as_paid', true)) {
$installment->status = LiabilityInstallment::STATUS_PAID;
$installment->paid_amount = abs($transaction->amount);
$installment->paid_date = $transaction->date;
}
$installment->save();
// Recalcular totais da conta passivo
$account->recalculateTotals();
DB::commit();
return response()->json([
'success' => true,
'message' => 'Parcela conciliada com sucesso',
'data' => $installment->fresh()->load('liabilityAccount'),
]);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
'success' => false,
'message' => 'Erro ao conciliar: ' . $e->getMessage(),
], 422);
}
}
/**
* Remover conciliação de uma parcela
*/
public function unreconcile(int $accountId, int $installmentId): JsonResponse
{
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId);
$installment = LiabilityInstallment::where('liability_account_id', $account->id)
->findOrFail($installmentId);
if (!$installment->reconciled_transaction_id) {
return response()->json([
'success' => false,
'message' => 'Parcela não está conciliada',
], 422);
}
try {
DB::beginTransaction();
// Calcular o sobrepagamento que foi registrado (paid_amount - installment_amount)
$paidAmount = (float) $installment->paid_amount;
$plannedAmount = (float) $installment->installment_amount;
$overpaymentToRemove = max(0, $paidAmount - $plannedAmount);
// Remover referência à transação
$installment->reconciled_transaction_id = null;
$installment->status = LiabilityInstallment::STATUS_PENDING;
$installment->paid_amount = 0;
$installment->paid_date = null;
// Remover o cargo extra (sobrepagamento) que foi adicionado na conciliação
if ($overpaymentToRemove > 0 && $installment->fee_amount >= $overpaymentToRemove) {
$installment->fee_amount = $installment->fee_amount - $overpaymentToRemove;
}
$installment->save();
// Recalcular totais
$account->recalculateTotals();
DB::commit();
return response()->json([
'success' => true,
'message' => 'Conciliação removida com sucesso',
'data' => $installment->fresh(),
'fee_removed' => $overpaymentToRemove > 0 ? $overpaymentToRemove : null,
]);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
'success' => false,
'message' => 'Erro ao remover conciliação: ' . $e->getMessage(),
], 422);
}
}
/**
* Buscar transações elegíveis para conciliação
*
* Retorna transações que podem ser vinculadas a uma parcela
* Ordenadas por similaridade de valor com a parcela
*/
public function eligibleTransactions(Request $request, int $accountId, int $installmentId): JsonResponse
{
$account = LiabilityAccount::where('user_id', Auth::id())->findOrFail($accountId);
$installment = LiabilityInstallment::where('liability_account_id', $account->id)
->findOrFail($installmentId);
// Buscar transações dentro de uma janela de tempo (+/- 45 dias da data de vencimento)
// Janela ampla para capturar pagamentos atrasados ou antecipados
$startDate = (clone $installment->due_date)->subDays(45);
$endDate = (clone $installment->due_date)->addDays(45);
$installmentAmount = (float) $installment->installment_amount;
// Usar effective_date se existir, senão planned_date
$query = \App\Models\Transaction::where('user_id', Auth::id())
->where(function ($q) use ($startDate, $endDate) {
$q->whereBetween('effective_date', [$startDate, $endDate])
->orWhere(function ($q2) use ($startDate, $endDate) {
$q2->whereNull('effective_date')
->whereBetween('planned_date', [$startDate, $endDate]);
});
})
->where('type', 'debit') // Pagamentos são débitos (saídas)
->with('account:id,name,currency');
// Por padrão, filtrar por valores próximos (±20% do valor da parcela)
// Permite encontrar transações mesmo com pequenas diferenças
$minAmount = $installmentAmount * 0.8;
$maxAmount = $installmentAmount * 1.2;
// Se strict_amount = false ou não informado, ainda assim filtrar por faixa
// Usa COALESCE para considerar amount ou planned_amount
if (!$request->has('no_amount_filter')) {
$query->whereRaw('COALESCE(amount, planned_amount) BETWEEN ? AND ?', [$minAmount, $maxAmount]);
}
// Se tiver filtro por conta específica
if ($request->has('account_id')) {
$query->where('account_id', $request->account_id);
}
// Busca por descrição
if ($request->has('search')) {
$query->where(function ($q) use ($request) {
$q->where('description', 'like', '%' . $request->search . '%')
->orWhere('original_description', 'like', '%' . $request->search . '%');
});
}
// Ordenar por similaridade de valor (mais próximo primeiro) e depois por data
// ABS(COALESCE(amount, planned_amount) - valor_parcela) = diferença absoluta
$query->orderByRaw("ABS(COALESCE(amount, planned_amount) - ?) ASC", [$installmentAmount])
->orderByRaw('COALESCE(effective_date, planned_date) DESC');
$transactions = $query->limit(30)->get();
// Adicionar campo de diferença percentual para cada transação
$transactions->transform(function ($transaction) use ($installmentAmount) {
$transactionAmount = (float) ($transaction->amount ?? $transaction->planned_amount);
$diff = abs($transactionAmount - $installmentAmount);
$diffPercent = $installmentAmount > 0 ? ($diff / $installmentAmount) * 100 : 0;
$transaction->amount_difference = round($diff, 2);
$transaction->amount_difference_percent = round($diffPercent, 1);
return $transaction;
});
return response()->json([
'success' => true,
'data' => $transactions,
'installment' => [
'id' => $installment->id,
'installment_number' => $installment->installment_number,
'due_date' => $installment->due_date->format('Y-m-d'),
'installment_amount' => $installmentAmount,
],
'search_period' => [
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
],
'amount_range' => [
'min' => round($minAmount, 2),
'max' => round($maxAmount, 2),
],
]);
}
/**
* Listar parcelas pendentes de conciliação
*/
public function pendingReconciliation(): JsonResponse
{
$installments = LiabilityInstallment::whereHas('liabilityAccount', function ($q) {
$q->where('user_id', Auth::id())->where('is_active', true);
})
->whereNull('reconciled_transaction_id')
->where('status', '!=', 'cancelled')
->with('liabilityAccount:id,name,currency,creditor')
->orderBy('due_date')
->get();
return response()->json([
'success' => true,
'data' => $installments,
'count' => $installments->count(),
]);
}
}