- Removido README.md padrão do Laravel (backend) - Removidos scripts de deploy (não mais necessários) - Atualizado copilot-instructions.md para novo fluxo - Adicionada documentação de auditoria do servidor - Sincronizado código de produção com repositório Novo workflow: - Trabalhamos diretamente em /root/webmoney (symlink para /var/www/webmoney) - Mudanças PHP são instantâneas - Mudanças React requerem 'npm run build' - Commit após validação funcional
973 lines
39 KiB
PHP
Executable File
973 lines
39 KiB
PHP
Executable File
<?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(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Download template Excel para importação
|
|
*/
|
|
public function downloadTemplate()
|
|
{
|
|
$service = new \App\Services\LiabilityTemplateService();
|
|
$spreadsheet = $service->generateTemplate();
|
|
|
|
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
|
|
|
|
$filename = 'plantilla_importacion_pasivo.xlsx';
|
|
|
|
// Criar response com stream
|
|
return response()->streamDownload(function () use ($writer) {
|
|
$writer->save('php://output');
|
|
}, $filename, [
|
|
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
|
'Cache-Control' => 'max-age=0',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Obter tipos de contrato disponíveis
|
|
*/
|
|
public function contractTypes(): JsonResponse
|
|
{
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => LiabilityAccount::CONTRACT_TYPES,
|
|
'amortization_systems' => LiabilityAccount::AMORTIZATION_SYSTEMS,
|
|
'index_types' => LiabilityAccount::INDEX_TYPES,
|
|
'guarantee_types' => LiabilityAccount::GUARANTEE_TYPES,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Criar conta passivo com wizard (formulário completo)
|
|
*/
|
|
public function storeWithWizard(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
// Dados básicos
|
|
'name' => 'required|string|max:150',
|
|
'contract_type' => ['required', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::CONTRACT_TYPES))],
|
|
'creditor' => 'nullable|string|max:150',
|
|
'contract_number' => 'nullable|string|max:100',
|
|
'description' => 'nullable|string',
|
|
'currency' => 'nullable|string|size:3',
|
|
'color' => 'nullable|string|max:7',
|
|
|
|
// Dados do contrato
|
|
'principal_amount' => 'required|numeric|min:0.01',
|
|
'annual_interest_rate' => 'nullable|numeric|min:0|max:100',
|
|
'monthly_interest_rate' => 'nullable|numeric|min:0|max:20',
|
|
'amortization_system' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::AMORTIZATION_SYSTEMS))],
|
|
'total_installments' => 'nullable|integer|min:1|max:600',
|
|
'start_date' => 'required|date',
|
|
'first_due_date' => 'required|date',
|
|
'end_date' => 'nullable|date',
|
|
|
|
// Opções básicas
|
|
'has_grace_period' => 'nullable|boolean',
|
|
'grace_period_months' => 'nullable|integer|min:0|max:12',
|
|
'include_insurance' => 'nullable|boolean',
|
|
'insurance_amount' => 'nullable|numeric|min:0',
|
|
'include_admin_fee' => 'nullable|boolean',
|
|
'admin_fee_amount' => 'nullable|numeric|min:0',
|
|
|
|
// ============================================
|
|
// CAMPOS AVANÇADOS (opcionais)
|
|
// ============================================
|
|
// Indexadores
|
|
'index_type' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::INDEX_TYPES))],
|
|
'index_spread' => 'nullable|numeric|min:-50|max:50',
|
|
'total_effective_cost' => 'nullable|numeric|min:0|max:500',
|
|
|
|
// Garantias
|
|
'guarantee_type' => ['nullable', \Illuminate\Validation\Rule::in(array_keys(LiabilityAccount::GUARANTEE_TYPES))],
|
|
'guarantee_value' => 'nullable|numeric|min:0',
|
|
'guarantee_description' => 'nullable|string|max:500',
|
|
'guarantor_name' => 'nullable|string|max:150',
|
|
|
|
// Penalidades
|
|
'late_fee_percent' => 'nullable|numeric|min:0|max:100',
|
|
'daily_penalty_percent' => 'nullable|numeric|min:0|max:10',
|
|
'grace_days_for_penalty' => 'nullable|integer|min:0|max:30',
|
|
|
|
// Específicos por tipo
|
|
'asset_value' => 'nullable|numeric|min:0',
|
|
'asset_description' => 'nullable|string|max:300',
|
|
'residual_value' => 'nullable|numeric|min:0',
|
|
'admin_fee_percent' => 'nullable|numeric|min:0|max:50',
|
|
'reserve_fund_percent' => 'nullable|numeric|min:0|max:20',
|
|
|
|
// Covenants e gestão
|
|
'covenants' => 'nullable|array',
|
|
'covenants.*.name' => 'required_with:covenants|string|max:100',
|
|
'covenants.*.condition' => 'required_with:covenants|string|max:50',
|
|
'covenants.*.value' => 'required_with:covenants|string|max:100',
|
|
'alert_days_before' => 'nullable|integer|min:0|max:60',
|
|
'internal_responsible' => 'nullable|string|max:150',
|
|
'internal_notes' => 'nullable|string',
|
|
'document_number' => 'nullable|string|max:100',
|
|
'registry_office' => 'nullable|string|max:200',
|
|
|
|
// Parcelas (opcional - se não enviado, será calculado)
|
|
'installments' => 'nullable|array',
|
|
'installments.*.installment_number' => 'required|integer|min:1',
|
|
'installments.*.due_date' => 'required|date',
|
|
'installments.*.installment_amount' => 'required|numeric|min:0',
|
|
'installments.*.principal_amount' => 'nullable|numeric|min:0',
|
|
'installments.*.interest_amount' => 'nullable|numeric|min:0',
|
|
'installments.*.fee_amount' => 'nullable|numeric|min:0',
|
|
'installments.*.status' => 'nullable|string',
|
|
]);
|
|
|
|
try {
|
|
DB::beginTransaction();
|
|
|
|
// Calcular taxa mensal se não informada
|
|
$monthlyRate = $validated['monthly_interest_rate'] ?? null;
|
|
if (!$monthlyRate && isset($validated['annual_interest_rate'])) {
|
|
$monthlyRate = $validated['annual_interest_rate'] / 12;
|
|
}
|
|
|
|
// Criar conta passivo com todos os campos
|
|
$account = LiabilityAccount::create([
|
|
'user_id' => Auth::id(),
|
|
'name' => $validated['name'],
|
|
'contract_type' => $validated['contract_type'],
|
|
'creditor' => $validated['creditor'] ?? null,
|
|
'contract_number' => $validated['contract_number'] ?? null,
|
|
'description' => $validated['description'] ?? null,
|
|
'currency' => $validated['currency'] ?? 'EUR',
|
|
'color' => $validated['color'] ?? null,
|
|
'principal_amount' => $validated['principal_amount'],
|
|
'annual_interest_rate' => $validated['annual_interest_rate'] ?? null,
|
|
'monthly_interest_rate' => $monthlyRate,
|
|
'amortization_system' => $validated['amortization_system'] ?? 'price',
|
|
'total_installments' => $validated['total_installments'] ?? null,
|
|
'start_date' => $validated['start_date'],
|
|
'first_due_date' => $validated['first_due_date'],
|
|
'end_date' => $validated['end_date'] ?? null,
|
|
'has_grace_period' => $validated['has_grace_period'] ?? false,
|
|
'grace_period_months' => $validated['grace_period_months'] ?? 0,
|
|
'status' => LiabilityAccount::STATUS_ACTIVE,
|
|
// Campos avançados - Indexadores
|
|
'index_type' => $validated['index_type'] ?? 'fixed',
|
|
'index_spread' => $validated['index_spread'] ?? null,
|
|
'total_effective_cost' => $validated['total_effective_cost'] ?? null,
|
|
// Campos avançados - Garantias
|
|
'guarantee_type' => $validated['guarantee_type'] ?? 'none',
|
|
'guarantee_value' => $validated['guarantee_value'] ?? null,
|
|
'guarantee_description' => $validated['guarantee_description'] ?? null,
|
|
'guarantor_name' => $validated['guarantor_name'] ?? null,
|
|
// Campos avançados - Penalidades
|
|
'late_fee_percent' => $validated['late_fee_percent'] ?? null,
|
|
'daily_penalty_percent' => $validated['daily_penalty_percent'] ?? null,
|
|
'grace_days_for_penalty' => $validated['grace_days_for_penalty'] ?? 0,
|
|
// Campos avançados - Específicos
|
|
'asset_value' => $validated['asset_value'] ?? null,
|
|
'asset_description' => $validated['asset_description'] ?? null,
|
|
'residual_value' => $validated['residual_value'] ?? null,
|
|
'admin_fee_percent' => $validated['admin_fee_percent'] ?? null,
|
|
'reserve_fund_percent' => $validated['reserve_fund_percent'] ?? null,
|
|
// Campos avançados - Covenants e gestão
|
|
'covenants' => $validated['covenants'] ?? null,
|
|
'alert_days_before' => $validated['alert_days_before'] ?? 5,
|
|
'internal_responsible' => $validated['internal_responsible'] ?? null,
|
|
'internal_notes' => $validated['internal_notes'] ?? null,
|
|
'document_number' => $validated['document_number'] ?? null,
|
|
'registry_office' => $validated['registry_office'] ?? null,
|
|
]);
|
|
|
|
// Se parcelas foram enviadas, criar diretamente
|
|
if (!empty($validated['installments'])) {
|
|
foreach ($validated['installments'] as $inst) {
|
|
LiabilityInstallment::create([
|
|
'liability_account_id' => $account->id,
|
|
'installment_number' => $inst['installment_number'],
|
|
'due_date' => $inst['due_date'],
|
|
'installment_amount' => $inst['installment_amount'],
|
|
'principal_amount' => $inst['principal_amount'] ?? 0,
|
|
'interest_amount' => $inst['interest_amount'] ?? 0,
|
|
'fee_amount' => $inst['fee_amount'] ?? 0,
|
|
'status' => $inst['status'] ?? 'pending',
|
|
]);
|
|
}
|
|
} else {
|
|
// Gerar parcelas automaticamente
|
|
$this->generateInstallments($account, $validated);
|
|
}
|
|
|
|
// Recalcular totais
|
|
$account->recalculateTotals();
|
|
|
|
DB::commit();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Cuenta pasivo creada con éxito',
|
|
'data' => $account->load('installments'),
|
|
], 201);
|
|
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Error al crear cuenta: ' . $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gerar parcelas automaticamente baseado no sistema de amortização
|
|
*/
|
|
private function generateInstallments(LiabilityAccount $account, array $data): void
|
|
{
|
|
$principal = $account->principal_amount;
|
|
$monthlyRate = ($account->monthly_interest_rate ?? 0) / 100;
|
|
$totalInstallments = $data['total_installments'] ?? 12;
|
|
$amortizationSystem = $account->amortization_system ?? 'price';
|
|
$hasGracePeriod = $data['has_grace_period'] ?? false;
|
|
$gracePeriodMonths = $data['grace_period_months'] ?? 0;
|
|
$insuranceAmount = $data['insurance_amount'] ?? 0;
|
|
$adminFeeAmount = $data['admin_fee_amount'] ?? 0;
|
|
|
|
$firstDueDate = new \DateTime($data['first_due_date']);
|
|
$remainingPrincipal = $principal;
|
|
|
|
// Para sistema PRICE, calcular parcela fixa
|
|
$fixedInstallment = 0;
|
|
if ($amortizationSystem === 'price' && $monthlyRate > 0) {
|
|
$fixedInstallment = $principal * ($monthlyRate * pow(1 + $monthlyRate, $totalInstallments)) /
|
|
(pow(1 + $monthlyRate, $totalInstallments) - 1);
|
|
} elseif ($amortizationSystem === 'price') {
|
|
$fixedInstallment = $principal / $totalInstallments;
|
|
}
|
|
|
|
// Para sistema SAC, calcular amortização fixa
|
|
$fixedAmortization = $principal / $totalInstallments;
|
|
|
|
for ($i = 1; $i <= $totalInstallments; $i++) {
|
|
$dueDate = clone $firstDueDate;
|
|
$dueDate->modify('+' . ($i - 1) . ' months');
|
|
|
|
// Carência
|
|
if ($hasGracePeriod && $i <= $gracePeriodMonths) {
|
|
$interestAmount = $remainingPrincipal * $monthlyRate;
|
|
$principalAmount = 0;
|
|
$installmentAmount = $interestAmount + $insuranceAmount + $adminFeeAmount;
|
|
} else {
|
|
if ($amortizationSystem === 'price') {
|
|
// Sistema PRICE - parcela fixa
|
|
$interestAmount = $remainingPrincipal * $monthlyRate;
|
|
$principalAmount = $fixedInstallment - $interestAmount;
|
|
$installmentAmount = $fixedInstallment + $insuranceAmount + $adminFeeAmount;
|
|
} elseif ($amortizationSystem === 'sac') {
|
|
// Sistema SAC - amortização constante
|
|
$principalAmount = $fixedAmortization;
|
|
$interestAmount = $remainingPrincipal * $monthlyRate;
|
|
$installmentAmount = $principalAmount + $interestAmount + $insuranceAmount + $adminFeeAmount;
|
|
} else {
|
|
// Americano - só juros, principal no final
|
|
$interestAmount = $remainingPrincipal * $monthlyRate;
|
|
$principalAmount = ($i === $totalInstallments) ? $remainingPrincipal : 0;
|
|
$installmentAmount = $interestAmount + $principalAmount + $insuranceAmount + $adminFeeAmount;
|
|
}
|
|
|
|
$remainingPrincipal -= $principalAmount;
|
|
}
|
|
|
|
LiabilityInstallment::create([
|
|
'liability_account_id' => $account->id,
|
|
'installment_number' => $i,
|
|
'due_date' => $dueDate->format('Y-m-d'),
|
|
'installment_amount' => round($installmentAmount, 2),
|
|
'principal_amount' => round(max(0, $principalAmount), 2),
|
|
'interest_amount' => round($interestAmount, 2),
|
|
'fee_amount' => round($insuranceAmount + $adminFeeAmount, 2),
|
|
'status' => 'pending',
|
|
]);
|
|
}
|
|
}
|
|
}
|