From 6ad0b10294d9e360308ae9745539aac7130bb560 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 25 Apr 2026 23:41:54 +0200 Subject: [PATCH] Publicar V-01.04 --- Atlas Balance/AGENTS.md | 2 +- Atlas Balance/Actualizar Atlas Balance.cmd | 1 + Atlas Balance/CLAUDE.md | 2 +- Atlas Balance/Directory.Build.props | 8 +- Atlas Balance/Instalar Atlas Balance.cmd | 1 + Atlas Balance/README_RELEASE.md | 40 +- Atlas Balance/VERSION | 2 +- .../GestionCaja.API/Constants/AuditActions.cs | 3 + .../Controllers/AlertasController.cs | 129 +- .../Controllers/CuentasController.cs | 368 +- .../Controllers/ExtractosController.cs | 44 +- .../Controllers/ImportacionController.cs | 24 + .../Controllers/TitularesController.cs | 6 + .../Controllers/UsuariosController.cs | 4 + .../src/GestionCaja.API/DTOs/AlertasDtos.cs | 8 + .../src/GestionCaja.API/DTOs/AuthDtos.cs | 1 + .../src/GestionCaja.API/DTOs/CuentasDtos.cs | 59 + .../src/GestionCaja.API/DTOs/DashboardDtos.cs | 18 + .../src/GestionCaja.API/DTOs/ExtractosDtos.cs | 2 + .../GestionCaja.API/DTOs/ImportacionDtos.cs | 20 + .../src/GestionCaja.API/DTOs/UsuariosDtos.cs | 1 + .../src/GestionCaja.API/Data/AppDbContext.cs | 24 + .../src/GestionCaja.API/Data/SeedData.cs | 2 +- .../Jobs/PlazoFijoVencimientoJob.cs | 22 + ...0139_AddPuedeVerCuentasPermiso.Designer.cs | 1538 +++ ...0260425130139_AddPuedeVerCuentasPermiso.cs | 40 + ...6_AddPlazoFijoAutonomosAlertas.Designer.cs | 1671 +++ ...0425145516_AddPlazoFijoAutonomosAlertas.cs | 211 + .../Migrations/AppDbContextModelSnapshot.cs | 141 +- .../src/GestionCaja.API/Models/Entities.cs | 24 + .../src/GestionCaja.API/Models/Enums.cs | 21 +- .../backend/src/GestionCaja.API/Program.cs | 11 +- .../GestionCaja.API/Services/AlertaService.cs | 24 +- .../GestionCaja.API/Services/AuthService.cs | 1 + .../Services/DashboardService.cs | 111 +- .../GestionCaja.API/Services/EmailService.cs | 67 + .../Services/ImportacionService.cs | 211 +- .../Services/PlazoFijoService.cs | 240 + .../Services/UserAccessService.cs | 7 +- .../AlertaServiceTests.cs | 51 + .../ConfiguracionControllerTests.cs | 10 + .../CuentasControllerTests.cs | 159 +- .../DashboardServiceTests.cs | 57 + .../ExtractosControllerTests.cs | 67 + .../ImportacionServiceTests.cs | 232 + .../PlazoFijoServiceTests.cs | 129 + .../UserAccessServiceTests.cs | 34 + .../UsuariosControllerTests.cs | 2 + Atlas Balance/frontend/package-lock.json | 34 +- Atlas Balance/frontend/package.json | 10 +- .../src/components/common/AppSelect.tsx | 5 + .../src/components/common/ConfirmDialog.tsx | 26 + .../src/components/common/DatePickerField.tsx | 254 + .../dashboard/SaldoPorDivisaCard.tsx | 20 +- .../src/components/extractos/AddRowForm.tsx | 3 +- .../src/components/usuarios/UsuarioModal.tsx | 77 +- .../frontend/src/pages/AlertasPage.tsx | 186 +- .../frontend/src/pages/AuditoriaPage.tsx | 33 +- .../frontend/src/pages/CuentaDetailPage.tsx | 41 +- .../frontend/src/pages/CuentasPage.tsx | 204 +- .../frontend/src/pages/DashboardPage.tsx | 113 +- .../frontend/src/pages/ImportacionPage.tsx | 149 +- .../frontend/src/pages/TitularesPage.tsx | 23 +- .../frontend/src/stores/permisosStore.ts | 5 +- Atlas Balance/frontend/src/styles/auth.css | 31 +- Atlas Balance/frontend/src/styles/global.css | 293 +- Atlas Balance/frontend/src/styles/layout.css | 3238 +---- .../frontend/src/styles/layout/admin.css | 488 + .../frontend/src/styles/layout/dashboard.css | 384 + .../frontend/src/styles/layout/entities.css | 677 + .../frontend/src/styles/layout/extractos.css | 293 + .../src/styles/layout/importacion.css | 205 + .../frontend/src/styles/layout/shell.css | 784 ++ .../src/styles/layout/system-coherence.css | 313 + .../frontend/src/styles/layout/users.css | 477 + .../frontend/src/styles/variables.css | 39 +- Atlas Balance/frontend/src/types/index.ts | 54 +- Atlas Balance/install.cmd | 1 + .../scripts/Actualizar-AtlasBalance.ps1 | 100 +- Atlas Balance/scripts/Build-Release.ps1 | 4 +- .../scripts/Instalar-AtlasBalance.ps1 | 134 +- Atlas Balance/scripts/Reset-AdminPassword.ps1 | 246 + Atlas Balance/scripts/install.ps1 | 11 +- Atlas Balance/scripts/update.ps1 | 22 +- Atlas Balance/update.cmd | 1 + CLAUDE.md | 2 +- ...A_USO_BUGS_SEGURIDAD_V-01.04_2026-04-25.md | 163 + Documentacion/DOCUMENTACION_CAMBIOS.md | 10967 +++++++++------- Documentacion/DOCUMENTACION_TECNICA.md | 422 + Documentacion/DOCUMENTACION_USUARIO.md | 92 + ...NSTALACION_WINDOWS_SERVER_2019_V-01.04.txt | 400 + Documentacion/LOG_ERRORES_INCIDENCIAS.md | 100 +- Documentacion/REGISTRO_BUGS.md | 72 + Documentacion/SEGURIDAD_AUDITORIA_V-01.04.md | 54 + Documentacion/SPEC.md | 6 + Documentacion/Versiones/v-01.03.md | 25 +- ...-funciones-plazo-fijo-autonomos-alertas.md | 674 + Documentacion/Versiones/v-01.04.md | 184 + Documentacion/Versiones/version_actual.md | 10 +- Documentacion/documentacion.md | 78 +- Documentacion/mejoradiseno.md | 46 + 101 files changed, 19578 insertions(+), 8545 deletions(-) create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Jobs/PlazoFijoVencimientoJob.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425130139_AddPuedeVerCuentasPermiso.Designer.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425130139_AddPuedeVerCuentasPermiso.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425145516_AddPlazoFijoAutonomosAlertas.Designer.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425145516_AddPlazoFijoAutonomosAlertas.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Services/PlazoFijoService.cs create mode 100644 Atlas Balance/backend/tests/GestionCaja.API.Tests/PlazoFijoServiceTests.cs create mode 100644 Atlas Balance/frontend/src/components/common/DatePickerField.tsx create mode 100644 Atlas Balance/frontend/src/styles/layout/admin.css create mode 100644 Atlas Balance/frontend/src/styles/layout/dashboard.css create mode 100644 Atlas Balance/frontend/src/styles/layout/entities.css create mode 100644 Atlas Balance/frontend/src/styles/layout/extractos.css create mode 100644 Atlas Balance/frontend/src/styles/layout/importacion.css create mode 100644 Atlas Balance/frontend/src/styles/layout/shell.css create mode 100644 Atlas Balance/frontend/src/styles/layout/system-coherence.css create mode 100644 Atlas Balance/frontend/src/styles/layout/users.css create mode 100644 Atlas Balance/scripts/Reset-AdminPassword.ps1 create mode 100644 Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.04_2026-04-25.md create mode 100644 Documentacion/INCIDENCIAS_INSTALACION_WINDOWS_SERVER_2019_V-01.04.txt create mode 100644 Documentacion/SEGURIDAD_AUDITORIA_V-01.04.md create mode 100644 Documentacion/Versiones/v-01.04-nuevas-funciones-plazo-fijo-autonomos-alertas.md create mode 100644 Documentacion/Versiones/v-01.04.md diff --git a/Atlas Balance/AGENTS.md b/Atlas Balance/AGENTS.md index 5dbdd8b..acec0e4 100644 --- a/Atlas Balance/AGENTS.md +++ b/Atlas Balance/AGENTS.md @@ -227,7 +227,7 @@ npm run build # Release Windows x64 cd "Atlas Balance" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.03 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04 # Conectar a PostgreSQL psql -h localhost -p 5433 -U app_user -d atlas_balance diff --git a/Atlas Balance/Actualizar Atlas Balance.cmd b/Atlas Balance/Actualizar Atlas Balance.cmd index e62b936..94a7df4 100644 --- a/Atlas Balance/Actualizar Atlas Balance.cmd +++ b/Atlas Balance/Actualizar Atlas Balance.cmd @@ -1,2 +1,3 @@ @echo off powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Actualizar-AtlasBalance.ps1" %* +exit /b %ERRORLEVEL% diff --git a/Atlas Balance/CLAUDE.md b/Atlas Balance/CLAUDE.md index c0b6a8a..707d447 100644 --- a/Atlas Balance/CLAUDE.md +++ b/Atlas Balance/CLAUDE.md @@ -227,7 +227,7 @@ npm run build # Release Windows x64 cd "Atlas Balance" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.03 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04 # Conectar a PostgreSQL psql -h localhost -p 5433 -U app_user -d atlas_balance diff --git a/Atlas Balance/Directory.Build.props b/Atlas Balance/Directory.Build.props index aed92e0..17b94e1 100644 --- a/Atlas Balance/Directory.Build.props +++ b/Atlas Balance/Directory.Build.props @@ -2,10 +2,10 @@ Atlas Labs Atlas Balance - 1.3.0 - 1.3.0.0 - 1.3.0.0 - V-01.03 + 1.4.0 + 1.4.0.0 + 1.4.0.0 + V-01.04 false diff --git a/Atlas Balance/Instalar Atlas Balance.cmd b/Atlas Balance/Instalar Atlas Balance.cmd index 776b8d9..ed3345e 100644 --- a/Atlas Balance/Instalar Atlas Balance.cmd +++ b/Atlas Balance/Instalar Atlas Balance.cmd @@ -1,2 +1,3 @@ @echo off powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Instalar-AtlasBalance.ps1" %* +exit /b %ERRORLEVEL% diff --git a/Atlas Balance/README_RELEASE.md b/Atlas Balance/README_RELEASE.md index 3254788..14b5b4f 100644 --- a/Atlas Balance/README_RELEASE.md +++ b/Atlas Balance/README_RELEASE.md @@ -1,7 +1,9 @@ -# Atlas Balance V-01.03 - release Windows x64 +# Atlas Balance V-01.04 - release Windows x64 Este paquete es autonomo para servidor Windows: el frontend ya esta compilado, el backend y Watchdog van publicados self-contained y la base de datos se prepara desde el instalador. +El ZIP `main` de GitHub no sirve como instalador. Usa `AtlasBalance-V-01.04-win-x64.zip`; dentro deben existir `api\GestionCaja.API.exe` y `watchdog\GestionCaja.Watchdog.exe`. + ## Scripts de un clic - `install.cmd`: instala dependencias, prepara PostgreSQL, crea base de datos, copia API/Watchdog/frontend estatico, genera configuracion de produccion, certificado local, servicios Windows y atajos. @@ -31,7 +33,11 @@ Por defecto la instalacion queda en `C:\AtlasBalance`. Para usar otra ruta: ## Base de datos -El instalador intenta preparar PostgreSQL 16 con `winget` cuando no se pasa una instancia existente. Genera passwords fuertes y guarda las credenciales iniciales en: +El requisito real es PostgreSQL 16+. PostgreSQL 17 es valido. + +En Windows Server 2019, instala PostgreSQL manualmente si `winget` falla o no esta disponible. `winget` no es una base fiable para prometer instalacion "one click" en servidores limpios. + +El instalador genera passwords fuertes y guarda las credenciales iniciales en: ```text C:\AtlasBalance\INSTALL_CREDENTIALS_ONCE.txt @@ -42,7 +48,35 @@ Guarda ese contenido en un gestor de passwords y borra el archivo despues del pr Si ya tienes PostgreSQL y quieres usarlo: ```powershell -.\install.cmd -PostgresAdminPassword "PASSWORD_POSTGRES" -PostgresBinPath "C:\Program Files\PostgreSQL\16\bin" +.\install.cmd -PostgresAdminPassword "PASSWORD_POSTGRES" -PostgresBinPath "C:\Program Files\PostgreSQL\17\bin" ``` No documentes passwords reales en tickets, docs ni chats. + +Si reinstalas sobre una BD existente, las credenciales iniciales no se regeneran. Usa el admin ya creado o: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Reset-AdminPassword.ps1" -InstallPath C:\AtlasBalance -AdminEmail admin@atlasbalance.local -GeneratePassword +``` + +Health check recomendado: + +```powershell +curl.exe -k -v https://localhost/api/health +``` + +## Actualizar una instalacion existente + +Desde la carpeta descomprimida de este paquete: + +```powershell +.\update.cmd -InstallPath C:\AtlasBalance +``` + +Si la instalacion ya tiene los scripts nuevos, tambien puedes lanzar desde la carpeta instalada apuntando al paquete: + +```powershell +C:\AtlasBalance\update.cmd -PackagePath C:\Temp\AtlasBalance-V-01.04-win-x64 -InstallPath C:\AtlasBalance +``` + +El actualizador crea backup previo, conserva configuracion, reemplaza API/Watchdog, actualiza scripts operativos instalados, actualiza `VERSION`/runtime y valida `/api/health` con `curl.exe -k`. diff --git a/Atlas Balance/VERSION b/Atlas Balance/VERSION index 12d3cb0..f179ef7 100644 --- a/Atlas Balance/VERSION +++ b/Atlas Balance/VERSION @@ -1 +1 @@ -V-01.03 +V-01.04 diff --git a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs index df8ee8e..0256966 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs @@ -16,6 +16,9 @@ public static class AuditActions public const string CambioPermisos = "CAMBIO_PERMISOS"; public const string ConfigAlerta = "CONFIG_ALERTA"; public const string AlertaSaldoDisparada = "ALERTA_SALDO_DISPARADA"; + public const string PlazoFijoProximoVencer = "PLAZO_FIJO_PROXIMO_VENCER"; + public const string PlazoFijoVencido = "PLAZO_FIJO_VENCIDO"; + public const string PlazoFijoRenovado = "PLAZO_FIJO_RENOVADO"; public const string BackupGenerado = "BACKUP_GENERADO"; public const string BackupRetencionAutomatica = "BACKUP_RETENCION_AUTOMATICA"; public const string ExportacionGenerada = "EXPORTACION_GENERADA"; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/AlertasController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/AlertasController.cs index 55016ed..010a8fd 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/AlertasController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/AlertasController.cs @@ -43,7 +43,7 @@ join c in _dbContext.Cuentas on a.CuentaId equals c.Id into cuentaJoin from cuenta in cuentaJoin.DefaultIfEmpty() join t in _dbContext.Titulares on cuenta.TitularId equals t.Id into titularJoin from titular in titularJoin.DefaultIfEmpty() - orderby a.CuentaId == null descending, titular.Nombre, cuenta.Nombre + orderby a.CuentaId == null descending, a.TipoTitular, titular.Nombre, cuenta.Nombre select new { Alerta = a, @@ -85,6 +85,8 @@ where alertaIds.Contains(d.AlertaId) Id = x.Alerta.Id, CuentaId = x.Alerta.CuentaId, CuentaNombre = x.Cuenta?.Nombre, + Alcance = ResolveAlertaAlcance(x.Alerta), + TipoTitular = x.Alerta.TipoTitular?.ToString(), TitularId = x.Cuenta?.TitularId, TitularNombre = x.Titular?.Nombre, Divisa = x.Cuenta?.Divisa, @@ -152,46 +154,42 @@ public async Task Activas(CancellationToken cancellationToken) [Authorize(Roles = "ADMIN")] public async Task Crear([FromBody] SaveAlertaSaldoRequest request, CancellationToken cancellationToken) { - if (request.SaldoMinimo < 0) + if (request is null) { - return BadRequest(new { error = "Saldo mínimo inválido" }); + return BadRequest(new { error = "Request invalido" }); } - if (request.CuentaId.HasValue) + if (request.SaldoMinimo < 0) { - var cuentaExists = await _dbContext.Cuentas.AnyAsync( - x => x.Id == request.CuentaId.Value && x.Activa, - cancellationToken); - if (!cuentaExists) - { - return BadRequest(new { error = "Cuenta inválida o inactiva" }); - } + return BadRequest(new { error = "Saldo minimo invalido" }); } - var duplicate = await _dbContext.AlertasSaldo.AnyAsync( - x => x.CuentaId == request.CuentaId, - cancellationToken); - if (duplicate) + var validationError = await ValidateAlertaRequestAsync(request, null, cancellationToken); + if (validationError is not null) { - return Conflict(new { error = "Ya existe una alerta para esa cuenta (o global)" }); + return validationError.Status == StatusCodes.Status409Conflict + ? Conflict(new { error = validationError.Error }) + : BadRequest(new { error = validationError.Error }); } - var invalidUsers = await ValidateDestinatariosAsync(request.DestinatarioUsuarioIds, cancellationToken); + var destinatarios = request.DestinatarioUsuarioIds ?? []; + var invalidUsers = await ValidateDestinatariosAsync(destinatarios, cancellationToken); if (invalidUsers.Count > 0) { - return BadRequest(new { error = "Hay destinatarios inválidos" }); + return BadRequest(new { error = "Hay destinatarios invalidos" }); } var alerta = new AlertaSaldo { Id = Guid.NewGuid(), CuentaId = request.CuentaId, + TipoTitular = request.CuentaId.HasValue ? null : request.TipoTitular, SaldoMinimo = request.SaldoMinimo, Activa = request.Activa, FechaCreacion = DateTime.UtcNow }; _dbContext.AlertasSaldo.Add(alerta); - await UpsertDestinatariosAsync(alerta.Id, request.DestinatarioUsuarioIds, cancellationToken); + await UpsertDestinatariosAsync(alerta.Id, destinatarios, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); await LogAlertaAuditAsync(AuditActions.ConfigAlerta, alerta.Id, before: null, after: request, cancellationToken); @@ -202,6 +200,11 @@ public async Task Crear([FromBody] SaveAlertaSaldoRequest request [Authorize(Roles = "ADMIN")] public async Task Actualizar(Guid id, [FromBody] SaveAlertaSaldoRequest request, CancellationToken cancellationToken) { + if (request is null) + { + return BadRequest(new { error = "Request invalido" }); + } + var alerta = await _dbContext.AlertasSaldo.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (alerta is null) { @@ -210,46 +213,38 @@ public async Task Actualizar(Guid id, [FromBody] SaveAlertaSaldoR if (request.SaldoMinimo < 0) { - return BadRequest(new { error = "Saldo mínimo inválido" }); + return BadRequest(new { error = "Saldo minimo invalido" }); } - if (request.CuentaId.HasValue) + var validationError = await ValidateAlertaRequestAsync(request, id, cancellationToken); + if (validationError is not null) { - var cuentaExists = await _dbContext.Cuentas.AnyAsync( - x => x.Id == request.CuentaId.Value && x.Activa, - cancellationToken); - if (!cuentaExists) - { - return BadRequest(new { error = "Cuenta inválida o inactiva" }); - } + return validationError.Status == StatusCodes.Status409Conflict + ? Conflict(new { error = validationError.Error }) + : BadRequest(new { error = validationError.Error }); } - var duplicate = await _dbContext.AlertasSaldo.AnyAsync( - x => x.Id != id && x.CuentaId == request.CuentaId, - cancellationToken); - if (duplicate) - { - return Conflict(new { error = "Ya existe una alerta para esa cuenta (o global)" }); - } - - var invalidUsers = await ValidateDestinatariosAsync(request.DestinatarioUsuarioIds, cancellationToken); + var destinatarios = request.DestinatarioUsuarioIds ?? []; + var invalidUsers = await ValidateDestinatariosAsync(destinatarios, cancellationToken); if (invalidUsers.Count > 0) { - return BadRequest(new { error = "Hay destinatarios inválidos" }); + return BadRequest(new { error = "Hay destinatarios invalidos" }); } var before = new { alerta.CuentaId, + alerta.TipoTitular, alerta.SaldoMinimo, alerta.Activa, destinatarios = await _dbContext.AlertaDestinatarios.Where(x => x.AlertaId == id).Select(x => x.UsuarioId).ToListAsync(cancellationToken) }; alerta.CuentaId = request.CuentaId; + alerta.TipoTitular = request.CuentaId.HasValue ? null : request.TipoTitular; alerta.SaldoMinimo = request.SaldoMinimo; alerta.Activa = request.Activa; - await UpsertDestinatariosAsync(alerta.Id, request.DestinatarioUsuarioIds, cancellationToken); + await UpsertDestinatariosAsync(alerta.Id, destinatarios, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); await LogAlertaAuditAsync(AuditActions.ConfigAlerta, alerta.Id, before, request, cancellationToken); @@ -269,6 +264,7 @@ public async Task Eliminar(Guid id, CancellationToken cancellatio var before = new { alerta.CuentaId, + alerta.TipoTitular, alerta.SaldoMinimo, alerta.Activa, destinatarios = await _dbContext.AlertaDestinatarios.Where(x => x.AlertaId == id).Select(x => x.UsuarioId).ToListAsync(cancellationToken) @@ -299,6 +295,49 @@ private async Task> ValidateDestinatariosAsync(IReadOnlyList de return unique.Except(existing).ToList(); } + private async Task ValidateAlertaRequestAsync(SaveAlertaSaldoRequest request, Guid? currentId, CancellationToken cancellationToken) + { + if (request.CuentaId.HasValue && request.TipoTitular.HasValue) + { + return new AlertaValidationError("La alerta debe ser por cuenta, por tipo de titular o global; no mezcles alcances", StatusCodes.Status400BadRequest); + } + + if (request.CuentaId.HasValue) + { + var cuentaExists = await _dbContext.Cuentas.AnyAsync( + x => x.Id == request.CuentaId.Value && x.Activa, + cancellationToken); + if (!cuentaExists) + { + return new AlertaValidationError("Cuenta invalida o inactiva", StatusCodes.Status400BadRequest); + } + + var duplicateCuenta = await _dbContext.AlertasSaldo.AnyAsync( + x => x.Id != currentId && x.CuentaId == request.CuentaId, + cancellationToken); + return duplicateCuenta + ? new AlertaValidationError("Ya existe una alerta para esa cuenta", StatusCodes.Status409Conflict) + : null; + } + + if (request.TipoTitular.HasValue) + { + var duplicateTipo = await _dbContext.AlertasSaldo.AnyAsync( + x => x.Id != currentId && x.CuentaId == null && x.TipoTitular == request.TipoTitular, + cancellationToken); + return duplicateTipo + ? new AlertaValidationError("Ya existe una alerta para ese tipo de titular", StatusCodes.Status409Conflict) + : null; + } + + var duplicateGlobal = await _dbContext.AlertasSaldo.AnyAsync( + x => x.Id != currentId && x.CuentaId == null && x.TipoTitular == null, + cancellationToken); + return duplicateGlobal + ? new AlertaValidationError("Ya existe una alerta global", StatusCodes.Status409Conflict) + : null; + } + private async Task UpsertDestinatariosAsync(Guid alertaId, IReadOnlyList destinatarioUsuarioIds, CancellationToken cancellationToken) { var existing = await _dbContext.AlertaDestinatarios @@ -335,4 +374,16 @@ await _auditService.LogAsync( var raw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); return Guid.TryParse(raw, out var userId) ? userId : null; } + + private static string ResolveAlertaAlcance(AlertaSaldo alerta) + { + if (alerta.CuentaId.HasValue) + { + return "CUENTA"; + } + + return alerta.TipoTitular.HasValue ? "TIPO_TITULAR" : "GLOBAL"; + } + + private sealed record AlertaValidationError(string Error, int Status); } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs index 09dd8aa..eb10905 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs @@ -18,12 +18,14 @@ public sealed class CuentasController : ControllerBase private readonly AppDbContext _dbContext; private readonly IUserAccessService _userAccessService; private readonly IAuditService _auditService; + private readonly IPlazoFijoService _plazoFijoService; - public CuentasController(AppDbContext dbContext, IUserAccessService userAccessService, IAuditService auditService) + public CuentasController(AppDbContext dbContext, IUserAccessService userAccessService, IAuditService auditService, IPlazoFijoService plazoFijoService) { _dbContext = dbContext; _userAccessService = userAccessService; _auditService = auditService; + _plazoFijoService = plazoFijoService; } [HttpGet("divisas-activas")] @@ -51,6 +53,8 @@ public async Task Listar( [FromQuery] string sortDir = "desc", [FromQuery] string? search = null, [FromQuery] Guid? titularId = null, + [FromQuery] TipoTitular? tipoTitular = null, + [FromQuery] TipoCuenta? tipoCuenta = null, [FromQuery] bool incluirEliminados = false, CancellationToken cancellationToken = default) { @@ -75,6 +79,16 @@ public async Task Listar( query = query.Where(c => c.TitularId == titularId.Value); } + if (tipoCuenta.HasValue) + { + query = query.Where(c => c.TipoCuenta == tipoCuenta.Value); + } + + if (tipoTitular.HasValue) + { + query = query.Where(c => _dbContext.Titulares.Any(t => t.Id == c.TitularId && t.Tipo == tipoTitular.Value)); + } + if (!string.IsNullOrWhiteSpace(search)) { var term = search.Trim().ToLowerInvariant(); @@ -89,32 +103,40 @@ public async Task Listar( query = ApplySorting(query, sortBy, desc); var total = await query.CountAsync(cancellationToken); - var data = await query + var pageRows = await query .Join( _dbContext.Titulares.IgnoreQueryFilters(), c => c.TitularId, t => t.Id, - (c, t) => new CuentaListItemResponse - { - Id = c.Id, - TitularId = c.TitularId, - TitularNombre = t.Nombre, - Nombre = c.Nombre, - NumeroCuenta = c.NumeroCuenta, - Iban = c.Iban, - BancoNombre = c.BancoNombre, - Divisa = c.Divisa, - FormatoId = c.FormatoId, - EsEfectivo = c.EsEfectivo, - Activa = c.Activa, - Notas = c.Notas, - FechaCreacion = c.FechaCreacion, - DeletedAt = c.DeletedAt - }) + (c, t) => new { Cuenta = c, Titular = t }) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); + var plazoMap = await BuildPlazoFijoMapAsync(pageRows.Select(x => x.Cuenta.Id).ToList(), cancellationToken); + var data = pageRows + .Select(x => new CuentaListItemResponse + { + Id = x.Cuenta.Id, + TitularId = x.Cuenta.TitularId, + TitularNombre = x.Titular.Nombre, + TitularTipo = x.Titular.Tipo.ToString(), + Nombre = x.Cuenta.Nombre, + NumeroCuenta = x.Cuenta.NumeroCuenta, + Iban = x.Cuenta.Iban, + BancoNombre = x.Cuenta.BancoNombre, + Divisa = x.Cuenta.Divisa, + FormatoId = x.Cuenta.FormatoId, + EsEfectivo = x.Cuenta.EsEfectivo, + TipoCuenta = ResolveTipoCuenta(x.Cuenta).ToString(), + PlazoFijo = plazoMap.GetValueOrDefault(x.Cuenta.Id), + Activa = x.Cuenta.Activa, + Notas = x.Cuenta.Notas, + FechaCreacion = x.Cuenta.FechaCreacion, + DeletedAt = x.Cuenta.DeletedAt + }) + .ToList(); + return Ok(new PaginatedResponse { Data = data, @@ -152,14 +174,16 @@ public async Task Obtener(Guid id, [FromQuery] bool incluirElimin var titular = await _dbContext.Titulares.IgnoreQueryFilters() .Where(t => t.Id == cuenta.TitularId) - .Select(t => t.Nombre) + .Select(t => new { t.Nombre, t.Tipo }) .FirstOrDefaultAsync(cancellationToken); + var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], cancellationToken); return Ok(new CuentaListItemResponse { Id = cuenta.Id, TitularId = cuenta.TitularId, - TitularNombre = titular ?? string.Empty, + TitularNombre = titular?.Nombre ?? string.Empty, + TitularTipo = titular?.Tipo.ToString() ?? string.Empty, Nombre = cuenta.Nombre, NumeroCuenta = cuenta.NumeroCuenta, Iban = cuenta.Iban, @@ -167,6 +191,8 @@ public async Task Obtener(Guid id, [FromQuery] bool incluirElimin Divisa = cuenta.Divisa, FormatoId = cuenta.FormatoId, EsEfectivo = cuenta.EsEfectivo, + TipoCuenta = ResolveTipoCuenta(cuenta).ToString(), + PlazoFijo = plazoMap.GetValueOrDefault(cuenta.Id), Activa = cuenta.Activa, Notas = cuenta.Notas, FechaCreacion = cuenta.FechaCreacion, @@ -184,12 +210,29 @@ public async Task Resumen(Guid id, [FromQuery] string periodo = " return Forbid(); } - var exists = await _dbContext.Cuentas.AnyAsync(c => c.Id == id, cancellationToken); - if (!exists) + var cuenta = await _dbContext.Cuentas + .Where(c => c.Id == id) + .Select(c => new + { + c.Id, + c.Nombre, + c.Divisa, + c.EsEfectivo, + c.TipoCuenta, + c.TitularId, + c.Notas + }) + .FirstOrDefaultAsync(cancellationToken); + if (cuenta is null) { return NotFound(new { error = "Cuenta no encontrada" }); } + var titular = await _dbContext.Titulares + .Where(t => t.Id == cuenta.TitularId) + .Select(t => t.Nombre) + .FirstOrDefaultAsync(cancellationToken); + var latest = await _dbContext.Extractos .Where(e => e.CuentaId == id) .OrderByDescending(e => e.Fecha) @@ -208,13 +251,29 @@ public async Task Resumen(Guid id, [FromQuery] string periodo = " Egresos = g.Sum(x => x.Monto < 0 ? -x.Monto : 0) }) .FirstOrDefaultAsync(cancellationToken); + var last = await _dbContext.Extractos + .Where(e => e.CuentaId == id) + .OrderByDescending(e => e.FechaModificacion ?? e.FechaCreacion) + .Select(e => (DateTime?)(e.FechaModificacion ?? e.FechaCreacion)) + .FirstOrDefaultAsync(cancellationToken); + var tipoCuenta = ResolveTipoCuenta(new Cuenta { TipoCuenta = cuenta.TipoCuenta, EsEfectivo = cuenta.EsEfectivo }); + var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], cancellationToken); return Ok(new CuentaResumenResponse { - CuentaId = id, + CuentaId = cuenta.Id, + CuentaNombre = cuenta.Nombre, + Divisa = cuenta.Divisa, + TitularId = cuenta.TitularId, + TitularNombre = titular ?? string.Empty, + EsEfectivo = cuenta.EsEfectivo, + TipoCuenta = tipoCuenta.ToString(), + PlazoFijo = plazoMap.GetValueOrDefault(cuenta.Id), + Notas = cuenta.Notas, SaldoActual = latest?.Saldo ?? 0m, IngresosMes = resumenMensual?.Ingresos ?? 0, - EgresosMes = resumenMensual?.Egresos ?? 0 + EgresosMes = resumenMensual?.Egresos ?? 0, + UltimaActualizacion = last }); } @@ -270,23 +329,40 @@ public async Task Crear([FromBody] SaveCuentaRequest request, Can Iban = validation.Iban, BancoNombre = validation.BancoNombre, Divisa = validation.Divisa!, - FormatoId = request.EsEfectivo ? null : request.FormatoId, - EsEfectivo = request.EsEfectivo, + FormatoId = validation.TipoCuenta == TipoCuenta.NORMAL ? request.FormatoId : null, + TipoCuenta = validation.TipoCuenta, + EsEfectivo = validation.TipoCuenta == TipoCuenta.EFECTIVO, Activa = request.Activa, Notas = NormalizeOptionalText(request.Notas), FechaCreacion = DateTime.UtcNow }; _dbContext.Cuentas.Add(cuenta); + if (validation.PlazoFijo is not null) + { + _dbContext.PlazosFijos.Add(new PlazoFijo + { + Id = Guid.NewGuid(), + CuentaId = cuenta.Id, + CuentaReferenciaId = validation.PlazoFijo.CuentaReferenciaId, + FechaInicio = validation.PlazoFijo.FechaInicio!.Value, + FechaVencimiento = validation.PlazoFijo.FechaVencimiento!.Value, + InteresPrevisto = validation.PlazoFijo.InteresPrevisto, + Renovable = validation.PlazoFijo.Renovable, + Estado = EstadoPlazoFijo.ACTIVO, + Notas = NormalizeOptionalText(validation.PlazoFijo.Notas), + FechaCreacion = DateTime.UtcNow + }); + } await _dbContext.SaveChangesAsync(cancellationToken); await _auditService.LogAsync( GetCurrentUserId(), - "cuenta_creada", + validation.TipoCuenta == TipoCuenta.PLAZO_FIJO ? "cuenta_plazo_fijo_creada" : "cuenta_creada", "CUENTAS", cuenta.Id, HttpContext, - JsonSerializer.Serialize(new { cuenta.Nombre, cuenta.Divisa, cuenta.EsEfectivo, cuenta.Notas }), + JsonSerializer.Serialize(new { cuenta.Nombre, cuenta.Divisa, tipo_cuenta = cuenta.TipoCuenta.ToString(), cuenta.Notas, plazo_fijo = validation.PlazoFijo }), cancellationToken); return CreatedAtAction(nameof(Obtener), new { id = cuenta.Id }, new { id = cuenta.Id }); @@ -308,31 +384,90 @@ public async Task Actualizar(Guid id, [FromBody] SaveCuentaReques return BadRequest(new { error = validation.Error }); } + var previousTipoCuenta = ResolveTipoCuenta(cuenta); + if (previousTipoCuenta == TipoCuenta.PLAZO_FIJO && validation.TipoCuenta != TipoCuenta.PLAZO_FIJO) + { + return BadRequest(new { error = "No se puede convertir una cuenta de plazo fijo a otro tipo; crea una cuenta nueva" }); + } + cuenta.TitularId = request.TitularId; cuenta.Nombre = request.Nombre.Trim(); cuenta.NumeroCuenta = validation.NumeroCuenta; cuenta.Iban = validation.Iban; cuenta.BancoNombre = validation.BancoNombre; cuenta.Divisa = validation.Divisa!; - cuenta.FormatoId = request.EsEfectivo ? null : request.FormatoId; - cuenta.EsEfectivo = request.EsEfectivo; + cuenta.FormatoId = validation.TipoCuenta == TipoCuenta.NORMAL ? request.FormatoId : null; + cuenta.TipoCuenta = validation.TipoCuenta; + cuenta.EsEfectivo = validation.TipoCuenta == TipoCuenta.EFECTIVO; cuenta.Activa = request.Activa; cuenta.Notas = NormalizeOptionalText(request.Notas); + if (validation.TipoCuenta == TipoCuenta.PLAZO_FIJO && validation.PlazoFijo is not null) + { + var plazo = await _dbContext.PlazosFijos.FirstOrDefaultAsync(p => p.CuentaId == cuenta.Id, cancellationToken); + if (plazo is null) + { + plazo = new PlazoFijo + { + Id = Guid.NewGuid(), + CuentaId = cuenta.Id, + FechaCreacion = DateTime.UtcNow + }; + _dbContext.PlazosFijos.Add(plazo); + } + + plazo.CuentaReferenciaId = validation.PlazoFijo.CuentaReferenciaId; + plazo.FechaInicio = validation.PlazoFijo.FechaInicio!.Value; + plazo.FechaVencimiento = validation.PlazoFijo.FechaVencimiento!.Value; + plazo.InteresPrevisto = validation.PlazoFijo.InteresPrevisto; + plazo.Renovable = validation.PlazoFijo.Renovable; + plazo.Notas = NormalizeOptionalText(validation.PlazoFijo.Notas); + plazo.FechaModificacion = DateTime.UtcNow; + if (plazo.Estado == EstadoPlazoFijo.VENCIDO && plazo.FechaVencimiento > DateOnly.FromDateTime(DateTime.UtcNow.Date)) + { + plazo.Estado = EstadoPlazoFijo.ACTIVO; + plazo.FechaUltimaNotificacion = null; + } + } + await _dbContext.SaveChangesAsync(cancellationToken); await _auditService.LogAsync( GetCurrentUserId(), - "cuenta_actualizada", + validation.TipoCuenta == TipoCuenta.PLAZO_FIJO ? "cuenta_plazo_fijo_actualizada" : "cuenta_actualizada", "CUENTAS", cuenta.Id, HttpContext, - JsonSerializer.Serialize(new { cuenta.Nombre, cuenta.Divisa, cuenta.EsEfectivo, cuenta.Activa, cuenta.Notas }), + JsonSerializer.Serialize(new { cuenta.Nombre, cuenta.Divisa, tipo_cuenta = cuenta.TipoCuenta.ToString(), cuenta.Activa, cuenta.Notas, plazo_fijo = validation.PlazoFijo }), cancellationToken); return Ok(new { message = "Cuenta actualizada" }); } + [HttpPost("{id:guid}/plazo-fijo/renovar")] + [Authorize(Roles = "ADMIN")] + public async Task RenovarPlazoFijo(Guid id, [FromBody] RenovarPlazoFijoRequest request, CancellationToken cancellationToken) + { + if (request is null) + { + return BadRequest(new { error = "Request invalido" }); + } + + try + { + var result = await _plazoFijoService.RenovarAsync(id, request, GetCurrentUserId(), HttpContext, cancellationToken); + return Ok(result); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + [HttpPatch("{id:guid}/notas")] public async Task ActualizarNotas(Guid id, [FromBody] UpdateCuentaNotasRequest request, CancellationToken cancellationToken) { @@ -431,46 +566,83 @@ private static IQueryable ApplySorting(IQueryable query, string return Guid.TryParse(raw, out var userId) ? userId : null; } - private async Task<(string? Error, string? Divisa, string? NumeroCuenta, string? Iban, string? BancoNombre)> ValidateCuentaRequestAsync( + private async Task ValidateCuentaRequestAsync( SaveCuentaRequest request, Guid? currentId, CancellationToken cancellationToken) { + var tipoCuenta = ResolveRequestedTipoCuenta(request); + var plazoFijo = ResolvePlazoFijoRequest(request); + if (string.IsNullOrWhiteSpace(request.Nombre)) { - return ("Nombre es obligatorio", null, null, null, null); + return CuentaValidationResult.Fail("Nombre es obligatorio", tipoCuenta); } var titularExists = await _dbContext.Titulares.AnyAsync(t => t.Id == request.TitularId, cancellationToken); if (!titularExists) { - return ("Titular invalido", null, null, null, null); + return CuentaValidationResult.Fail("Titular invalido", tipoCuenta); } var divisa = request.Divisa?.Trim().ToUpperInvariant(); if (string.IsNullOrWhiteSpace(divisa)) { - return ("Divisa es obligatoria", null, null, null, null); + return CuentaValidationResult.Fail("Divisa es obligatoria", tipoCuenta); } var divisaExists = await _dbContext.DivisasActivas.AnyAsync(d => d.Activa && d.Codigo == divisa, cancellationToken); if (!divisaExists) { - return ("La divisa indicada no esta activa", null, null, null, null); + return CuentaValidationResult.Fail("La divisa indicada no esta activa", tipoCuenta); } - if (!request.EsEfectivo && request.FormatoId.HasValue) + if (tipoCuenta == TipoCuenta.NORMAL && request.FormatoId.HasValue) { var formato = await _dbContext.FormatosImportacion.FirstOrDefaultAsync(f => f.Id == request.FormatoId.Value, cancellationToken); if (formato is null) { - return ("Formato de importacion invalido", null, null, null, null); + return CuentaValidationResult.Fail("Formato de importacion invalido", tipoCuenta); } if (!string.IsNullOrWhiteSpace(formato.Divisa) && !string.Equals(formato.Divisa, divisa, StringComparison.OrdinalIgnoreCase)) { - return ("La divisa de la cuenta debe coincidir con la del formato de importacion", null, null, null, null); + return CuentaValidationResult.Fail("La divisa de la cuenta debe coincidir con la del formato de importacion", tipoCuenta); + } + } + + if (tipoCuenta == TipoCuenta.PLAZO_FIJO) + { + if (plazoFijo?.FechaInicio is null || plazoFijo.FechaVencimiento is null) + { + return CuentaValidationResult.Fail("Fecha de inicio y fecha de vencimiento son obligatorias para plazo fijo", tipoCuenta); + } + + if (plazoFijo.FechaVencimiento < plazoFijo.FechaInicio) + { + return CuentaValidationResult.Fail("La fecha de vencimiento no puede ser anterior a la fecha de inicio", tipoCuenta); + } + + if (plazoFijo.InteresPrevisto.HasValue && plazoFijo.InteresPrevisto.Value < 0) + { + return CuentaValidationResult.Fail("El interes previsto no puede ser negativo", tipoCuenta); + } + + if (plazoFijo.CuentaReferenciaId.HasValue) + { + if (plazoFijo.CuentaReferenciaId == currentId) + { + return CuentaValidationResult.Fail("La cuenta de referencia no puede ser la misma cuenta", tipoCuenta); + } + + var referencia = await _dbContext.Cuentas.FirstOrDefaultAsync( + c => c.Id == plazoFijo.CuentaReferenciaId.Value && c.Activa, + cancellationToken); + if (referencia is null) + { + return CuentaValidationResult.Fail("Cuenta de referencia invalida o inactiva", tipoCuenta); + } } } @@ -484,15 +656,18 @@ private static IQueryable ApplySorting(IQueryable query, string if (duplicateName) { - return ("Ya existe una cuenta con ese nombre para el titular indicado", null, null, null, null); + return CuentaValidationResult.Fail("Ya existe una cuenta con ese nombre para el titular indicado", tipoCuenta); } - return ( - null, - divisa, - request.EsEfectivo ? null : request.NumeroCuenta?.Trim(), - request.EsEfectivo ? null : request.Iban?.Trim(), - request.EsEfectivo ? null : request.BancoNombre?.Trim()); + return new CuentaValidationResult + { + TipoCuenta = tipoCuenta, + Divisa = divisa, + NumeroCuenta = tipoCuenta == TipoCuenta.NORMAL ? request.NumeroCuenta?.Trim() : null, + Iban = tipoCuenta == TipoCuenta.NORMAL ? request.Iban?.Trim() : null, + BancoNombre = tipoCuenta == TipoCuenta.NORMAL ? request.BancoNombre?.Trim() : null, + PlazoFijo = tipoCuenta == TipoCuenta.PLAZO_FIJO ? plazoFijo : null + }; } private async Task CanEditCuentaAsync(UserAccessScope scope, Cuenta cuenta, CancellationToken cancellationToken) @@ -520,4 +695,101 @@ private async Task CanEditCuentaAsync(UserAccessScope scope, Cuenta cuenta var normalized = value?.Trim(); return string.IsNullOrWhiteSpace(normalized) ? null : normalized; } + + private static TipoCuenta ResolveTipoCuenta(Cuenta cuenta) + { + if (cuenta.TipoCuenta == TipoCuenta.NORMAL && cuenta.EsEfectivo) + { + return TipoCuenta.EFECTIVO; + } + + return cuenta.TipoCuenta; + } + + private static TipoCuenta ResolveRequestedTipoCuenta(SaveCuentaRequest request) + { + if (request.TipoCuenta.HasValue) + { + return request.TipoCuenta.Value; + } + + return request.EsEfectivo ? TipoCuenta.EFECTIVO : TipoCuenta.NORMAL; + } + + private static SavePlazoFijoRequest? ResolvePlazoFijoRequest(SaveCuentaRequest request) + { + if (request.PlazoFijo is not null) + { + return request.PlazoFijo; + } + + if (request.FechaInicio is null && + request.FechaVencimiento is null && + request.InteresPrevisto is null && + request.Renovable is null && + request.CuentaReferenciaId is null && + string.IsNullOrWhiteSpace(request.PlazoFijoNotas)) + { + return null; + } + + return new SavePlazoFijoRequest + { + FechaInicio = request.FechaInicio, + FechaVencimiento = request.FechaVencimiento, + InteresPrevisto = request.InteresPrevisto, + Renovable = request.Renovable ?? false, + CuentaReferenciaId = request.CuentaReferenciaId, + Notas = request.PlazoFijoNotas + }; + } + + private async Task> BuildPlazoFijoMapAsync(IReadOnlyList cuentaIds, CancellationToken cancellationToken) + { + if (cuentaIds.Count == 0) + { + return []; + } + + var rows = await ( + from plazo in _dbContext.PlazosFijos + join refCuenta in _dbContext.Cuentas.IgnoreQueryFilters() on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin + from cuentaReferencia in refJoin.DefaultIfEmpty() + where cuentaIds.Contains(plazo.CuentaId) + select new PlazoFijoResponse + { + Id = plazo.Id, + CuentaId = plazo.CuentaId, + CuentaReferenciaId = plazo.CuentaReferenciaId, + CuentaReferenciaNombre = cuentaReferencia != null ? cuentaReferencia.Nombre : null, + FechaInicio = plazo.FechaInicio, + FechaVencimiento = plazo.FechaVencimiento, + InteresPrevisto = plazo.InteresPrevisto, + Renovable = plazo.Renovable, + Estado = plazo.Estado.ToString(), + FechaUltimaNotificacion = plazo.FechaUltimaNotificacion, + FechaRenovacion = plazo.FechaRenovacion, + Notas = plazo.Notas + }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary(x => x.CuentaId); + } + + private sealed class CuentaValidationResult + { + public string? Error { get; init; } + public TipoCuenta TipoCuenta { get; init; } + public string? Divisa { get; init; } + public string? NumeroCuenta { get; init; } + public string? Iban { get; init; } + public string? BancoNombre { get; init; } + public SavePlazoFijoRequest? PlazoFijo { get; init; } + + public static CuentaValidationResult Fail(string error, TipoCuenta tipoCuenta) => new() + { + Error = error, + TipoCuenta = tipoCuenta + }; + } } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs index 39fa3aa..b6e6b1a 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs @@ -396,10 +396,10 @@ public async Task GetCuentaResumen(Guid cuentaId, [FromQuery] str { if (!TryGetUser(out var actor)) return Unauthorized(new { error = "Usuario no autenticado" }); if (!await CanView(actor, cuentaId, ct)) return Forbid(); - var cuenta = await _db.Cuentas.Where(c => c.Id == cuentaId).Select(c => new { c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TitularId, c.Notas }).FirstOrDefaultAsync(ct); + var cuenta = await _db.Cuentas.Where(c => c.Id == cuentaId).Select(c => new { c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, c.TitularId, c.Notas }).FirstOrDefaultAsync(ct); if (cuenta is null) return NotFound(new { error = "Cuenta no encontrada" }); var titular = await _db.Titulares.Where(t => t.Id == cuenta.TitularId).Select(t => t.Nombre).FirstOrDefaultAsync(ct); - return Ok(await BuildSummary(cuenta.Id, cuenta.Nombre, cuenta.Divisa, cuenta.EsEfectivo, cuenta.TitularId, titular ?? string.Empty, cuenta.Notas, periodo, ct)); + return Ok(await BuildSummary(cuenta.Id, cuenta.Nombre, cuenta.Divisa, cuenta.EsEfectivo, cuenta.TipoCuenta, cuenta.TitularId, titular ?? string.Empty, cuenta.Notas, periodo, ct)); } [HttpGet("titulares/{titularId:guid}/cuentas")] @@ -414,7 +414,7 @@ public async Task GetCuentasTitular(Guid titularId, [FromQuery] s var summary = new List(); foreach (var c in cuentas) { - summary.Add(await BuildSummary(c.Id, c.Nombre, c.Divisa, c.EsEfectivo, titular.Id, titular.Nombre, c.Notas, periodo, ct)); + summary.Add(await BuildSummary(c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, titular.Id, titular.Nombre, c.Notas, periodo, ct)); } return Ok(new TitularConCuentasResponse { TitularId = titular.Id, TitularNombre = titular.Nombre, Cuentas = summary }); } @@ -432,7 +432,7 @@ public async Task GetTitularesResumen([FromQuery] string periodo { var tc = cuentas.Where(c => c.TitularId == t.Id).ToList(); var s = new List(); - foreach (var c in tc) s.Add(await BuildSummary(c.Id, c.Nombre, c.Divisa, c.EsEfectivo, t.Id, t.Nombre, c.Notas, periodo, ct)); + foreach (var c in tc) s.Add(await BuildSummary(c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, t.Id, t.Nombre, c.Notas, periodo, ct)); outData.Add(new TitularConCuentasResponse { TitularId = t.Id, TitularNombre = t.Nombre, Cuentas = s }); } return Ok(outData); @@ -481,7 +481,7 @@ public async Task SaveColumnasVisibles([FromBody] SaveColumnasVis return Ok(new { message = "Preferencias guardadas" }); } - private async Task BuildSummary(Guid cuentaId, string cuentaNombre, string divisa, bool esEfectivo, Guid titularId, string titularNombre, string? notas, string periodo, CancellationToken ct) + private async Task BuildSummary(Guid cuentaId, string cuentaNombre, string divisa, bool esEfectivo, TipoCuenta tipoCuenta, Guid titularId, string titularNombre, string? notas, string periodo, CancellationToken ct) { var q = _db.Extractos.Where(e => e.CuentaId == cuentaId); var latest = await q @@ -495,6 +495,30 @@ private async Task BuildSummary(Guid cuentaId, string var ingresos = await periodRows.Where(e => e.Monto > 0).SumAsync(e => (decimal?)e.Monto, ct) ?? 0m; var egresos = await periodRows.Where(e => e.Monto < 0).SumAsync(e => (decimal?)e.Monto, ct) ?? 0m; var last = await q.OrderByDescending(e => e.FechaModificacion ?? e.FechaCreacion).Select(e => (DateTime?)(e.FechaModificacion ?? e.FechaCreacion)).FirstOrDefaultAsync(ct); + var plazoFijo = tipoCuenta == TipoCuenta.PLAZO_FIJO + ? await ( + from plazo in _db.PlazosFijos + join refCuenta in _db.Cuentas.IgnoreQueryFilters() on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin + from cuentaReferencia in refJoin.DefaultIfEmpty() + where plazo.CuentaId == cuentaId + select new PlazoFijoResponse + { + Id = plazo.Id, + CuentaId = plazo.CuentaId, + CuentaReferenciaId = plazo.CuentaReferenciaId, + CuentaReferenciaNombre = cuentaReferencia != null ? cuentaReferencia.Nombre : null, + FechaInicio = plazo.FechaInicio, + FechaVencimiento = plazo.FechaVencimiento, + InteresPrevisto = plazo.InteresPrevisto, + Renovable = plazo.Renovable, + Estado = plazo.Estado.ToString(), + FechaUltimaNotificacion = plazo.FechaUltimaNotificacion, + FechaRenovacion = plazo.FechaRenovacion, + Notas = plazo.Notas + }) + .FirstOrDefaultAsync(ct) + : null; + return new CuentaResumenKpiResponse { CuentaId = cuentaId, @@ -503,6 +527,8 @@ private async Task BuildSummary(Guid cuentaId, string TitularId = titularId, TitularNombre = titularNombre, EsEfectivo = esEfectivo, + TipoCuenta = tipoCuenta.ToString(), + PlazoFijo = plazoFijo, Notas = notas, SaldoActual = latest?.Saldo ?? 0m, IngresosMes = ingresos, @@ -588,7 +614,7 @@ private async Task> GetAllowedAccountIds(Actor actor, Cancellation if (actor.IsAdmin) return [.. await _db.Cuentas.Select(c => c.Id).ToListAsync(ct)]; var perms = await _db.PermisosUsuario.Where(p => p.UsuarioId == actor.Id).ToListAsync(ct); if (!perms.Any()) return []; - if (perms.Any(p => p.CuentaId is null && p.TitularId is null && GrantsDataAccess(p))) + if (perms.Any(p => p.CuentaId is null && p.TitularId is null && GrantsAccountAccess(p))) { return [.. await _db.Cuentas.Select(c => c.Id).ToListAsync(ct)]; } @@ -614,7 +640,7 @@ private async Task CanViewTitular(Actor actor, Guid titularId, Cancellatio return false; } - if (perms.Any(p => p.CuentaId is null && p.TitularId is null && GrantsDataAccess(p))) + if (perms.Any(p => p.CuentaId is null && p.TitularId is null && GrantsAccountAccess(p))) { return true; } @@ -634,8 +660,8 @@ private async Task CanViewTitular(Actor actor, Guid titularId, Cancellatio await _db.Cuentas.AnyAsync(c => c.TitularId == titularId && permittedCuentaIds.Contains(c.Id), ct); } - private static bool GrantsDataAccess(PermisoUsuario permiso) => - permiso.PuedeAgregarLineas || permiso.PuedeEditarLineas || permiso.PuedeEliminarLineas || permiso.PuedeImportar; + private static bool GrantsAccountAccess(PermisoUsuario permiso) => + permiso.PuedeVerCuentas || permiso.PuedeAgregarLineas || permiso.PuedeEditarLineas || permiso.PuedeEliminarLineas || permiso.PuedeImportar; private async Task GetPermission(Actor actor, Cuenta cuenta, CancellationToken ct) { diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ImportacionController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ImportacionController.cs index ff34736..48e085d 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ImportacionController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ImportacionController.cs @@ -68,6 +68,30 @@ public async Task Confirmar([FromBody] ImportacionConfirmarReques } } + [HttpPost("plazo-fijo/movimiento")] + public async Task RegistrarMovimientoPlazoFijo([FromBody] ImportacionPlazoFijoMovimientoRequest request, CancellationToken cancellationToken) + { + if (request is null) + { + return BadRequest(new { error = "Request invalido" }); + } + + if (!TryGetActor(out var userId, out var rol)) + { + return Unauthorized(new { error = "Usuario no autenticado" }); + } + + try + { + var result = await _importacionService.RegistrarMovimientoPlazoFijoAsync(userId, rol, request, HttpContext, cancellationToken); + return Ok(result); + } + catch (ImportacionException ex) + { + return StatusCode(ex.StatusCode, new { error = ex.Message }); + } + } + private bool TryGetActor(out Guid userId, out string rol) { rol = User.FindFirstValue(ClaimTypes.Role) ?? string.Empty; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/TitularesController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/TitularesController.cs index a5dd629..a87d4c6 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/TitularesController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/TitularesController.cs @@ -33,6 +33,7 @@ public async Task Listar( [FromQuery] string sortBy = "nombre", [FromQuery] string sortDir = "asc", [FromQuery] string? search = null, + [FromQuery] TipoTitular? tipoTitular = null, [FromQuery] bool incluirEliminados = false, CancellationToken cancellationToken = default) { @@ -52,6 +53,11 @@ public async Task Listar( query = _userAccessService.ApplyTitularScope(query, scope); + if (tipoTitular.HasValue) + { + query = query.Where(t => t.Tipo == tipoTitular.Value); + } + if (!string.IsNullOrWhiteSpace(search)) { var term = search.Trim().ToLowerInvariant(); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs index 363687d..d22700e 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs @@ -219,6 +219,7 @@ public async Task GuardarPermisoCuenta(Guid id, Guid cuentaId, [F { CuentaId = cuentaId, TitularId = request.TitularId, + PuedeVerCuentas = request.PuedeVerCuentas, PuedeAgregarLineas = request.PuedeAgregarLineas, PuedeEditarLineas = request.PuedeEditarLineas, PuedeEliminarLineas = request.PuedeEliminarLineas, @@ -768,6 +769,7 @@ private async Task> LoadPermisosAuditSnapshotAsync(Guid usuarioId, { permiso.CuentaId, permiso.TitularId, + permiso.PuedeVerCuentas, permiso.PuedeAgregarLineas, permiso.PuedeEditarLineas, permiso.PuedeEliminarLineas, @@ -838,6 +840,7 @@ private Task AddPermisoAsync(Guid usuarioId, SavePermisoUsuarioRequest item) UsuarioId = usuarioId, CuentaId = item.CuentaId, TitularId = item.TitularId, + PuedeVerCuentas = item.PuedeVerCuentas, PuedeAgregarLineas = item.PuedeAgregarLineas, PuedeEditarLineas = item.PuedeEditarLineas, PuedeEliminarLineas = item.PuedeEliminarLineas, @@ -887,6 +890,7 @@ private static PermisoUsuarioResponse MapPermiso(PermisoUsuario permiso, Prefere UsuarioId = permiso.UsuarioId, CuentaId = permiso.CuentaId, TitularId = permiso.TitularId, + PuedeVerCuentas = permiso.PuedeVerCuentas, PuedeAgregarLineas = permiso.PuedeAgregarLineas, PuedeEditarLineas = permiso.PuedeEditarLineas, PuedeEliminarLineas = permiso.PuedeEliminarLineas, diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/AlertasDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/AlertasDtos.cs index 4838c22..5ee4246 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/AlertasDtos.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/AlertasDtos.cs @@ -1,8 +1,13 @@ +using System.Text.Json.Serialization; +using GestionCaja.API.Models; + namespace GestionCaja.API.DTOs; public sealed class SaveAlertaSaldoRequest { public Guid? CuentaId { get; set; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public TipoTitular? TipoTitular { get; set; } public decimal SaldoMinimo { get; set; } public bool Activa { get; set; } = true; public IReadOnlyList DestinatarioUsuarioIds { get; set; } = []; @@ -20,6 +25,8 @@ public sealed class AlertaSaldoItemResponse public Guid Id { get; set; } public Guid? CuentaId { get; set; } public string? CuentaNombre { get; set; } + public string Alcance { get; set; } = "GLOBAL"; + public string? TipoTitular { get; set; } public Guid? TitularId { get; set; } public string? TitularNombre { get; set; } public string? Divisa { get; set; } @@ -37,6 +44,7 @@ public sealed class AlertaActivaItemResponse public Guid TitularId { get; set; } public string CuentaNombre { get; set; } = string.Empty; public string TitularNombre { get; set; } = string.Empty; + public string TipoTitular { get; set; } = string.Empty; public string Divisa { get; set; } = string.Empty; public decimal SaldoActual { get; set; } public decimal SaldoMinimo { get; set; } diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs index ec3d9fd..005a779 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs @@ -37,6 +37,7 @@ public sealed class PermisoUsuarioResponse public Guid UsuarioId { get; set; } public Guid? CuentaId { get; set; } public Guid? TitularId { get; set; } + public bool PuedeVerCuentas { get; set; } public bool PuedeAgregarLineas { get; set; } public bool PuedeEditarLineas { get; set; } public bool PuedeEliminarLineas { get; set; } diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/CuentasDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/CuentasDtos.cs index cc6616a..ac15dbc 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/CuentasDtos.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/CuentasDtos.cs @@ -1,5 +1,24 @@ +using System.Text.Json.Serialization; +using GestionCaja.API.Models; + namespace GestionCaja.API.DTOs; +public sealed class PlazoFijoResponse +{ + public Guid Id { get; set; } + public Guid CuentaId { get; set; } + public Guid? CuentaReferenciaId { get; set; } + public string? CuentaReferenciaNombre { get; set; } + public DateOnly FechaInicio { get; set; } + public DateOnly FechaVencimiento { get; set; } + public decimal? InteresPrevisto { get; set; } + public bool Renovable { get; set; } + public string Estado { get; set; } = string.Empty; + public DateOnly? FechaUltimaNotificacion { get; set; } + public DateOnly? FechaRenovacion { get; set; } + public string? Notas { get; set; } +} + public sealed class CuentaListItemResponse { public Guid Id { get; set; } @@ -12,6 +31,9 @@ public sealed class CuentaListItemResponse public string Divisa { get; set; } = "EUR"; public Guid? FormatoId { get; set; } public bool EsEfectivo { get; set; } + public string TipoCuenta { get; set; } = "NORMAL"; + public string TitularTipo { get; set; } = string.Empty; + public PlazoFijoResponse? PlazoFijo { get; set; } public bool Activa { get; set; } public string? Notas { get; set; } public DateTime FechaCreacion { get; set; } @@ -21,9 +43,18 @@ public sealed class CuentaListItemResponse public sealed class CuentaResumenResponse { public Guid CuentaId { get; set; } + public string CuentaNombre { get; set; } = string.Empty; + public string Divisa { get; set; } = "EUR"; + public Guid TitularId { get; set; } + public string TitularNombre { get; set; } = string.Empty; + public bool EsEfectivo { get; set; } + public string TipoCuenta { get; set; } = "NORMAL"; + public PlazoFijoResponse? PlazoFijo { get; set; } + public string? Notas { get; set; } public decimal SaldoActual { get; set; } public decimal IngresosMes { get; set; } public decimal EgresosMes { get; set; } + public DateTime? UltimaActualizacion { get; set; } } public sealed class SaveCuentaRequest @@ -35,12 +66,40 @@ public sealed class SaveCuentaRequest public string? BancoNombre { get; set; } public string Divisa { get; set; } = "EUR"; public Guid? FormatoId { get; set; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public TipoCuenta? TipoCuenta { get; set; } public bool EsEfectivo { get; set; } public bool Activa { get; set; } = true; public string? Notas { get; set; } + public SavePlazoFijoRequest? PlazoFijo { get; set; } + public DateOnly? FechaInicio { get; set; } + public DateOnly? FechaVencimiento { get; set; } + public decimal? InteresPrevisto { get; set; } + public bool? Renovable { get; set; } + public Guid? CuentaReferenciaId { get; set; } + public string? PlazoFijoNotas { get; set; } } public sealed class UpdateCuentaNotasRequest { public string? Notas { get; set; } } + +public sealed class SavePlazoFijoRequest +{ + public DateOnly? FechaInicio { get; set; } + public DateOnly? FechaVencimiento { get; set; } + public decimal? InteresPrevisto { get; set; } + public bool Renovable { get; set; } + public Guid? CuentaReferenciaId { get; set; } + public string? Notas { get; set; } +} + +public sealed class RenovarPlazoFijoRequest +{ + public DateOnly NuevaFechaInicio { get; set; } + public DateOnly NuevaFechaVencimiento { get; set; } + public decimal? InteresPrevisto { get; set; } + public bool Renovable { get; set; } + public string? Notas { get; set; } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/DashboardDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/DashboardDtos.cs index df8c6c8..90c68a6 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/DashboardDtos.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/DashboardDtos.cs @@ -7,10 +7,20 @@ public sealed class DashboardPrincipalResponse public decimal IngresosMes { get; set; } public decimal EgresosMes { get; set; } public decimal TotalConvertido { get; set; } + public DashboardPlazosFijosResumenResponse PlazosFijos { get; set; } = new(); public IReadOnlyList SaldosPorTitular { get; set; } = []; public DashboardChartColorsResponse ChartColors { get; set; } = new(); } +public sealed class DashboardPlazosFijosResumenResponse +{ + public decimal MontoTotalConvertido { get; set; } + public decimal InteresesPrevistosConvertidos { get; set; } + public int? DiasHastaProximoVencimiento { get; set; } + public DateOnly? ProximoVencimiento { get; set; } + public int TotalCuentas { get; set; } +} + public sealed class DashboardTitularResponse { public Guid TitularId { get; set; } @@ -28,8 +38,11 @@ public sealed class DashboardSaldoTitularResponse { public Guid TitularId { get; set; } public string TitularNombre { get; set; } = string.Empty; + public string TipoTitular { get; set; } = string.Empty; public IReadOnlyDictionary SaldosPorDivisa { get; set; } = new Dictionary(); public decimal TotalConvertido { get; set; } + public decimal SaldoInmovilizadoConvertido { get; set; } + public decimal SaldoDisponibleConvertido { get; set; } } public sealed class DashboardSaldoCuentaResponse @@ -39,6 +52,7 @@ public sealed class DashboardSaldoCuentaResponse public string? BancoNombre { get; set; } public string Divisa { get; set; } = string.Empty; public bool EsEfectivo { get; set; } + public string TipoCuenta { get; set; } = string.Empty; public decimal SaldoActual { get; set; } public decimal SaldoConvertido { get; set; } } @@ -55,6 +69,10 @@ public sealed class DashboardSaldoDivisaResponse public string Divisa { get; set; } = string.Empty; public decimal Saldo { get; set; } public decimal SaldoConvertido { get; set; } + public decimal SaldoDisponible { get; set; } + public decimal SaldoInmovilizado { get; set; } + public decimal SaldoTotal { get; set; } + public decimal SaldoTotalConvertido { get; set; } } public sealed class DashboardEvolucionResponse diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/ExtractosDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/ExtractosDtos.cs index 1048761..88243c4 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/ExtractosDtos.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/ExtractosDtos.cs @@ -79,6 +79,8 @@ public sealed class CuentaResumenKpiResponse public Guid TitularId { get; set; } public string TitularNombre { get; set; } = string.Empty; public bool EsEfectivo { get; set; } + public string TipoCuenta { get; set; } = "NORMAL"; + public PlazoFijoResponse? PlazoFijo { get; set; } public string? Notas { get; set; } public decimal SaldoActual { get; set; } public decimal IngresosMes { get; set; } diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/ImportacionDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/ImportacionDtos.cs index a711c0a..aeef154 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/ImportacionDtos.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/ImportacionDtos.cs @@ -35,12 +35,31 @@ public sealed class ImportacionConfirmarRequest public IReadOnlyList? FilasAImportar { get; set; } } +public sealed class ImportacionPlazoFijoMovimientoRequest +{ + public Guid CuentaId { get; set; } + public string TipoMovimiento { get; set; } = "INGRESO"; + public DateOnly Fecha { get; set; } + public decimal Monto { get; set; } + public string? Concepto { get; set; } +} + +public sealed class ImportacionPlazoFijoMovimientoResponse +{ + public Guid ExtractoId { get; set; } + public int FilaNumero { get; set; } + public decimal Monto { get; set; } + public decimal SaldoAnterior { get; set; } + public decimal SaldoActual { get; set; } +} + public sealed class FilaValidacionResponse { public int Indice { get; set; } public bool Valida { get; set; } public Dictionary Datos { get; set; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyList Errores { get; set; } = []; + public IReadOnlyList Advertencias { get; set; } = []; } public sealed class ErrorFilaResponse @@ -73,6 +92,7 @@ public sealed class CuentaImportacionContextoResponse public string TitularNombre { get; set; } = string.Empty; public string Divisa { get; set; } = string.Empty; public bool EsEfectivo { get; set; } + public string TipoCuenta { get; set; } = string.Empty; public Guid? FormatoId { get; set; } public MapeoColumnasRequest? FormatoPredefinido { get; set; } } diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/UsuariosDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/UsuariosDtos.cs index 44a7542..f59012a 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/UsuariosDtos.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/UsuariosDtos.cs @@ -36,6 +36,7 @@ public sealed class SavePermisoUsuarioRequest { public Guid? CuentaId { get; set; } public Guid? TitularId { get; set; } + public bool PuedeVerCuentas { get; set; } public bool PuedeAgregarLineas { get; set; } public bool PuedeEditarLineas { get; set; } public bool PuedeEliminarLineas { get; set; } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs index 5f55784..fad0886 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs @@ -13,6 +13,7 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet RefreshTokens => Set(); public DbSet Titulares => Set(); public DbSet Cuentas => Set(); + public DbSet PlazosFijos => Set(); public DbSet FormatosImportacion => Set(); public DbSet Extractos => Set(); public DbSet ExtractosColumnasExtra => Set(); @@ -38,6 +39,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.HasPostgresExtension("pgcrypto"); modelBuilder.HasPostgresEnum(); modelBuilder.HasPostgresEnum(); + modelBuilder.HasPostgresEnum(); + modelBuilder.HasPostgresEnum(); modelBuilder.HasPostgresEnum(); modelBuilder.HasPostgresEnum(); modelBuilder.HasPostgresEnum(); @@ -91,6 +94,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.TitularId); entity.HasIndex(e => e.Divisa); entity.HasIndex(e => e.EsEfectivo); + entity.HasIndex(e => e.TipoCuenta); entity.HasIndex(e => e.Activa); entity.HasIndex(e => e.DeletedAt); entity.HasOne(e => e.Titular).WithMany().HasForeignKey(e => e.TitularId).OnDelete(DeleteBehavior.Restrict); @@ -98,6 +102,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasOne().WithMany().HasForeignKey(e => e.DeletedById).OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(entity => + { + entity.ToTable("PLAZOS_FIJOS"); + entity.HasKey(e => e.Id); + entity.Property(e => e.InteresPrevisto).HasPrecision(18, 2); + entity.HasIndex(e => e.CuentaId).IsUnique(); + entity.HasIndex(e => e.FechaVencimiento); + entity.HasIndex(e => e.Estado); + entity.HasIndex(e => e.CuentaReferenciaId); + entity.HasIndex(e => e.DeletedAt); + entity.HasOne(e => e.Cuenta).WithOne().HasForeignKey(e => e.CuentaId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.CuentaReferencia).WithMany().HasForeignKey(e => e.CuentaReferenciaId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(e => e.DeletedById).OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => { entity.ToTable("FORMATOS_IMPORTACION"); @@ -171,7 +190,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.SaldoMinimo).HasPrecision(18, 4); entity.HasIndex(e => e.CuentaId) .IsUnique() + .HasDatabaseName("ix_alertas_saldo_cuenta_id_unique") .HasFilter("\"cuenta_id\" IS NOT NULL"); + entity.HasIndex(e => e.TipoTitular) + .IsUnique() + .HasDatabaseName("ix_alertas_saldo_tipo_titular_unique") + .HasFilter("\"cuenta_id\" IS NULL AND \"tipo_titular\" IS NOT NULL"); entity.HasOne().WithMany().HasForeignKey(e => e.CuentaId).OnDelete(DeleteBehavior.Restrict); }); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs index 881eee2..5220b92 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs @@ -69,7 +69,7 @@ public static void Initialize(AppDbContext context, IConfiguration? configuratio ["backup_retention_weeks"] = ("6", "int", "Semanas de retención de backups"), ["backup_path"] = ("C:/AtlasBalance/backups", "string", "Ruta de almacenamiento de backups"), ["export_path"] = ("C:/AtlasBalance/exports", "string", "Ruta de exportaciones"), - ["app_version"] = ("V-01.03", "string", "Versión instalada"), + ["app_version"] = ("V-01.04", "string", "Versión instalada"), ["app_update_check_url"] = (ConfigurationDefaults.UpdateCheckUrl, "string", "URL del servidor de actualizaciones"), ["smtp_host"] = ("", "string", "Host SMTP"), ["smtp_port"] = ("587", "int", "Puerto SMTP"), diff --git a/Atlas Balance/backend/src/GestionCaja.API/Jobs/PlazoFijoVencimientoJob.cs b/Atlas Balance/backend/src/GestionCaja.API/Jobs/PlazoFijoVencimientoJob.cs new file mode 100644 index 0000000..a4360b4 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Jobs/PlazoFijoVencimientoJob.cs @@ -0,0 +1,22 @@ +using GestionCaja.API.Services; + +namespace GestionCaja.API.Jobs; + +public sealed class PlazoFijoVencimientoJob +{ + private readonly IPlazoFijoService _plazoFijoService; + private readonly ILogger _logger; + + public PlazoFijoVencimientoJob(IPlazoFijoService plazoFijoService, ILogger logger) + { + _plazoFijoService = plazoFijoService; + _logger = logger; + } + + public async Task ExecuteAsync() + { + var hoy = DateOnly.FromDateTime(DateTime.UtcNow.Date); + var cambios = await _plazoFijoService.ProcesarVencimientosAsync(hoy, CancellationToken.None); + _logger.LogInformation("Job de plazos fijos completado. cambios={Cambios}", cambios); + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425130139_AddPuedeVerCuentasPermiso.Designer.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425130139_AddPuedeVerCuentasPermiso.Designer.cs new file mode 100644 index 0000000..08152d8 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425130139_AddPuedeVerCuentasPermiso.Designer.cs @@ -0,0 +1,1538 @@ +// +using System; +using System.Net; +using GestionCaja.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GestionCaja.API.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260425130139_AddPuedeVerCuentasPermiso")] + partial class AddPuedeVerCuentasPermiso + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_proceso", new[] { "pending", "success", "failed" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_token_integracion", new[] { "activo", "revocado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "fuente_tipo_cambio", new[] { "api", "manual" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "rol_usuario", new[] { "admin", "gerente", "empleado_ultra", "empleado_plus", "empleado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_proceso", new[] { "auto", "manual" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_titular", new[] { "empresa", "particular" }); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaDestinatario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlertaId") + .HasColumnType("uuid") + .HasColumnName("alerta_id"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_alerta_destinatarios"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_alerta_destinatarios_usuario_id"); + + b.HasIndex("AlertaId", "UsuarioId") + .IsUnique() + .HasDatabaseName("ix_alerta_destinatarios_alerta_id_usuario_id"); + + b.ToTable("ALERTA_DESTINATARIOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaSaldo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaUltimaAlerta") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_alerta"); + + b.Property("SaldoMinimo") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("saldo_minimo"); + + b.HasKey("Id") + .HasName("pk_alertas_saldo"); + + b.HasIndex("CuentaId") + .IsUnique() + .HasDatabaseName("ix_alertas_saldo_cuenta_id") + .HasFilter("\"cuenta_id\" IS NOT NULL"); + + b.ToTable("ALERTAS_SALDO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Auditoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CeldaReferencia") + .HasColumnType("text") + .HasColumnName("celda_referencia"); + + b.Property("ColumnaNombre") + .HasColumnType("text") + .HasColumnName("columna_nombre"); + + b.Property("DetallesJson") + .HasColumnType("jsonb") + .HasColumnName("detalles_json"); + + b.Property("EntidadId") + .HasColumnType("uuid") + .HasColumnName("entidad_id"); + + b.Property("EntidadTipo") + .HasColumnType("text") + .HasColumnName("entidad_tipo"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TipoAccion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo_accion"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.Property("ValorAnterior") + .HasColumnType("text") + .HasColumnName("valor_anterior"); + + b.Property("ValorNuevo") + .HasColumnType("text") + .HasColumnName("valor_nuevo"); + + b.HasKey("Id") + .HasName("pk_auditorias"); + + b.HasIndex("EntidadId") + .HasDatabaseName("ix_auditorias_entidad_id"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_auditorias_timestamp"); + + b.HasIndex("TipoAccion") + .HasDatabaseName("ix_auditorias_tipo_accion"); + + b.HasIndex("UsuarioId", "Timestamp") + .HasDatabaseName("ix_auditorias_usuario_id_timestamp"); + + b.ToTable("AUDITORIAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AuditoriaIntegracion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CodigoRespuesta") + .HasColumnType("integer") + .HasColumnName("codigo_respuesta"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("Metodo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("metodo"); + + b.Property("Parametros") + .HasColumnType("jsonb") + .HasColumnName("parametros"); + + b.Property("TiempoEjecucionMs") + .HasColumnType("integer") + .HasColumnName("tiempo_ejecucion_ms"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasColumnName("token_id"); + + b.HasKey("Id") + .HasName("pk_auditoria_integraciones"); + + b.HasIndex("CodigoRespuesta") + .HasDatabaseName("ix_auditoria_integraciones_codigo_respuesta"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_auditoria_integraciones_timestamp"); + + b.HasIndex("TokenId") + .HasDatabaseName("ix_auditoria_integraciones_token_id"); + + b.ToTable("AUDITORIA_INTEGRACIONES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Backup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("IniciadoPorId") + .HasColumnType("uuid") + .HasColumnName("iniciado_por_id"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("RutaArchivo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ruta_archivo"); + + b.Property("TamanioBytes") + .HasColumnType("bigint") + .HasColumnName("tamanio_bytes"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_backups"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_backups_deleted_by_id"); + + b.HasIndex("IniciadoPorId") + .HasDatabaseName("ix_backups_iniciado_por_id"); + + b.ToTable("BACKUPS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Configuracion", b => + { + b.Property("Clave") + .HasColumnType("text") + .HasColumnName("clave"); + + b.Property("Descripcion") + .HasColumnType("text") + .HasColumnName("descripcion"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("Tipo") + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("UsuarioModificacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_modificacion_id"); + + b.Property("Valor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("valor"); + + b.HasKey("Clave") + .HasName("pk_configuracion"); + + b.HasIndex("UsuarioModificacionId") + .HasDatabaseName("ix_configuracion_usuario_modificacion_id"); + + b.ToTable("CONFIGURACION", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Cuenta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("BancoNombre") + .HasColumnType("text") + .HasColumnName("banco_nombre"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Divisa") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa"); + + b.Property("EsEfectivo") + .HasColumnType("boolean") + .HasColumnName("es_efectivo"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FormatoId") + .HasColumnType("uuid") + .HasColumnName("formato_id"); + + b.Property("Iban") + .HasColumnType("text") + .HasColumnName("iban"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("NumeroCuenta") + .HasColumnType("text") + .HasColumnName("numero_cuenta"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.HasKey("Id") + .HasName("pk_cuentas"); + + b.HasIndex("Activa") + .HasDatabaseName("ix_cuentas_activa"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_cuentas_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_cuentas_deleted_by_id"); + + b.HasIndex("Divisa") + .HasDatabaseName("ix_cuentas_divisa"); + + b.HasIndex("EsEfectivo") + .HasDatabaseName("ix_cuentas_es_efectivo"); + + b.HasIndex("FormatoId") + .HasDatabaseName("ix_cuentas_formato_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_cuentas_titular_id"); + + b.ToTable("CUENTAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.DivisaActiva", b => + { + b.Property("Codigo") + .HasColumnType("text") + .HasColumnName("codigo"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("EsBase") + .HasColumnType("boolean") + .HasColumnName("es_base"); + + b.Property("Nombre") + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Simbolo") + .HasColumnType("text") + .HasColumnName("simbolo"); + + b.HasKey("Codigo") + .HasName("pk_divisas_activas"); + + b.ToTable("DIVISAS_ACTIVAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Exportacion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaExportacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_exportacion"); + + b.Property("IniciadoPorId") + .HasColumnType("uuid") + .HasColumnName("iniciado_por_id"); + + b.Property("RutaArchivo") + .HasColumnType("text") + .HasColumnName("ruta_archivo"); + + b.Property("TamanioBytes") + .HasColumnType("bigint") + .HasColumnName("tamanio_bytes"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_exportaciones"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_exportaciones_cuenta_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_exportaciones_deleted_by_id"); + + b.HasIndex("IniciadoPorId") + .HasDatabaseName("ix_exportaciones_iniciado_por_id"); + + b.ToTable("EXPORTACIONES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Extracto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Checked") + .HasColumnType("boolean") + .HasColumnName("checked"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checked_at"); + + b.Property("CheckedById") + .HasColumnType("uuid") + .HasColumnName("checked_by_id"); + + b.Property("Comentarios") + .HasColumnType("text") + .HasColumnName("comentarios"); + + b.Property("Concepto") + .HasColumnType("text") + .HasColumnName("concepto"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Fecha") + .HasColumnType("date") + .HasColumnName("fecha"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("FilaNumero") + .HasColumnType("integer") + .HasColumnName("fila_numero"); + + b.Property("Flagged") + .HasColumnType("boolean") + .HasColumnName("flagged"); + + b.Property("FlaggedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("flagged_at"); + + b.Property("FlaggedById") + .HasColumnType("uuid") + .HasColumnName("flagged_by_id"); + + b.Property("FlaggedNota") + .HasColumnType("text") + .HasColumnName("flagged_nota"); + + b.Property("Monto") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("monto"); + + b.Property("Saldo") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("saldo"); + + b.Property("UsuarioCreacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_creacion_id"); + + b.Property("UsuarioModificacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_modificacion_id"); + + b.HasKey("Id") + .HasName("pk_extractos"); + + b.HasIndex("Checked") + .HasDatabaseName("ix_extractos_checked"); + + b.HasIndex("CheckedById") + .HasDatabaseName("ix_extractos_checked_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_extractos_deleted_by_id"); + + b.HasIndex("Fecha") + .HasDatabaseName("ix_extractos_fecha"); + + b.HasIndex("Flagged") + .HasDatabaseName("ix_extractos_flagged"); + + b.HasIndex("FlaggedById") + .HasDatabaseName("ix_extractos_flagged_by_id"); + + b.HasIndex("UsuarioCreacionId") + .HasDatabaseName("ix_extractos_usuario_creacion_id"); + + b.HasIndex("UsuarioModificacionId") + .HasDatabaseName("ix_extractos_usuario_modificacion_id"); + + b.HasIndex("CuentaId", "DeletedAt") + .HasDatabaseName("ix_extractos_cuenta_id_deleted_at"); + + b.HasIndex("CuentaId", "Fecha") + .HasDatabaseName("ix_extractos_cuenta_id_fecha"); + + b.HasIndex("CuentaId", "FilaNumero") + .IsUnique() + .HasDatabaseName("ix_extractos_cuenta_id_fila_numero"); + + b.ToTable("EXTRACTOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.ExtractoColumnaExtra", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExtractoId") + .HasColumnType("uuid") + .HasColumnName("extracto_id"); + + b.Property("NombreColumna") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre_columna"); + + b.Property("Valor") + .HasColumnType("text") + .HasColumnName("valor"); + + b.HasKey("Id") + .HasName("pk_extractos_columnas_extra"); + + b.HasIndex("ExtractoId") + .HasDatabaseName("ix_extractos_columnas_extra_extracto_id"); + + b.HasIndex("NombreColumna") + .HasDatabaseName("ix_extractos_columnas_extra_nombre_columna"); + + b.ToTable("EXTRACTOS_COLUMNAS_EXTRA", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.FormatoImportacion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activo") + .HasColumnType("boolean") + .HasColumnName("activo"); + + b.Property("BancoNombre") + .HasColumnType("text") + .HasColumnName("banco_nombre"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Divisa") + .HasColumnType("text") + .HasColumnName("divisa"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("MapeoJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("mapeo_json"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("UsuarioCreadorId") + .HasColumnType("uuid") + .HasColumnName("usuario_creador_id"); + + b.HasKey("Id") + .HasName("pk_formatos_importacion"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_formatos_importacion_deleted_by_id"); + + b.HasIndex("UsuarioCreadorId") + .HasDatabaseName("ix_formatos_importacion_usuario_creador_id"); + + b.ToTable("FORMATOS_IMPORTACION", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccesoTipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acceso_tipo"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasColumnName("token_id"); + + b.HasKey("Id") + .HasName("pk_integration_permissions"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_integration_permissions_cuenta_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_integration_permissions_titular_id"); + + b.HasIndex("TokenId") + .HasDatabaseName("ix_integration_permissions_token_id"); + + b.ToTable("INTEGRATION_PERMISSIONS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Descripcion") + .HasColumnType("text") + .HasColumnName("descripcion"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaRevocacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_revocacion"); + + b.Property("FechaUltimaUso") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_uso"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("PermisoEscritura") + .HasColumnType("boolean") + .HasColumnName("permiso_escritura"); + + b.Property("PermisoLectura") + .HasColumnType("boolean") + .HasColumnName("permiso_lectura"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UsuarioCreadorId") + .HasColumnType("uuid") + .HasColumnName("usuario_creador_id"); + + b.HasKey("Id") + .HasName("pk_integration_tokens"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_integration_tokens_deleted_by_id"); + + b.HasIndex("Estado") + .HasDatabaseName("ix_integration_tokens_estado"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_integration_tokens_token_hash"); + + b.HasIndex("UsuarioCreadorId") + .HasDatabaseName("ix_integration_tokens_usuario_creador_id"); + + b.ToTable("INTEGRATION_TOKENS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.NotificacionAdmin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DetallesJson") + .HasColumnType("jsonb") + .HasColumnName("detalles_json"); + + b.Property("Fecha") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha"); + + b.Property("Leida") + .HasColumnType("boolean") + .HasColumnName("leida"); + + b.Property("Mensaje") + .HasColumnType("text") + .HasColumnName("mensaje"); + + b.Property("Tipo") + .HasColumnType("text") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_notificaciones_admin"); + + b.ToTable("NOTIFICACIONES_ADMIN", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PermisoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("PuedeAgregarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_agregar_lineas"); + + b.Property("PuedeEditarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_editar_lineas"); + + b.Property("PuedeEliminarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_eliminar_lineas"); + + b.Property("PuedeImportar") + .HasColumnType("boolean") + .HasColumnName("puede_importar"); + + b.Property("PuedeVerCuentas") + .HasColumnType("boolean") + .HasColumnName("puede_ver_cuentas"); + + b.Property("PuedeVerDashboard") + .HasColumnType("boolean") + .HasColumnName("puede_ver_dashboard"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_permisos_usuario"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_permisos_usuario_cuenta_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_permisos_usuario_titular_id"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_permisos_usuario_usuario_id"); + + b.HasIndex("UsuarioId", "CuentaId") + .HasDatabaseName("ix_permisos_usuario_usuario_id_cuenta_id"); + + b.ToTable("PERMISOS_USUARIO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ColumnasEditables") + .HasColumnType("jsonb") + .HasColumnName("columnas_editables"); + + b.Property("ColumnasVisibles") + .HasColumnType("jsonb") + .HasColumnName("columnas_visibles"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_preferencias_usuario_cuenta"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_preferencias_usuario_cuenta_cuenta_id"); + + b.HasIndex("UsuarioId") + .IsUnique() + .HasDatabaseName("ix_preferencias_usuario_cuenta_usuario_id") + .HasFilter("\"cuenta_id\" IS NULL"); + + b.HasIndex("UsuarioId", "CuentaId") + .IsUnique() + .HasDatabaseName("ix_preferencias_usuario_cuenta_usuario_id_cuenta_id") + .HasFilter("\"cuenta_id\" IS NOT NULL"); + + b.ToTable("PREFERENCIAS_USUARIO_CUENTA", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreadoEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("creado_en"); + + b.Property("ExpiraEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_en"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("ReemplazadoPor") + .HasColumnType("text") + .HasColumnName("reemplazado_por"); + + b.Property("RevocadoEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revocado_en"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("ExpiraEn") + .HasDatabaseName("ix_refresh_tokens_expira_en"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_refresh_tokens_usuario_id"); + + b.ToTable("REFRESH_TOKENS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.TipoCambio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DivisaDestino") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa_destino"); + + b.Property("DivisaOrigen") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa_origen"); + + b.Property("FechaActualizacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_actualizacion"); + + b.Property("Fuente") + .HasColumnType("integer") + .HasColumnName("fuente"); + + b.Property("Tasa") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)") + .HasColumnName("tasa"); + + b.HasKey("Id") + .HasName("pk_tipos_cambio"); + + b.HasIndex("DivisaOrigen", "DivisaDestino") + .IsUnique() + .HasDatabaseName("ix_tipos_cambio_divisa_origen_divisa_destino"); + + b.ToTable("TIPOS_CAMBIO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Titular", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContactoEmail") + .HasColumnType("text") + .HasColumnName("contacto_email"); + + b.Property("ContactoTelefono") + .HasColumnType("text") + .HasColumnName("contacto_telefono"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("Identificacion") + .HasColumnType("text") + .HasColumnName("identificacion"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_titulares"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_titulares_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_titulares_deleted_by_id"); + + b.HasIndex("Nombre") + .HasDatabaseName("ix_titulares_nombre"); + + b.HasIndex("Tipo") + .HasDatabaseName("ix_titulares_tipo"); + + b.ToTable("TITULARES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Activo") + .HasColumnType("boolean") + .HasColumnName("activo"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("FailedLoginAttempts") + .HasColumnType("integer") + .HasColumnName("failed_login_attempts"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaUltimaLogin") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_login"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("NombreCompleto") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre_completo"); + + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_changed_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PrimerLogin") + .HasColumnType("boolean") + .HasColumnName("primer_login"); + + b.Property("Rol") + .HasColumnType("integer") + .HasColumnName("rol"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + + b.HasKey("Id") + .HasName("pk_usuarios"); + + b.HasIndex("Activo") + .HasDatabaseName("ix_usuarios_activo"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_usuarios_email"); + + b.HasIndex("Rol") + .HasDatabaseName("ix_usuarios_rol"); + + b.ToTable("USUARIOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.UsuarioEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EsPrincipal") + .HasColumnType("boolean") + .HasColumnName("es_principal"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_usuario_emails"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_usuario_emails_usuario_id"); + + b.ToTable("USUARIO_EMAILS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaDestinatario", b => + { + b.HasOne("GestionCaja.API.Models.AlertaSaldo", null) + .WithMany() + .HasForeignKey("AlertaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_alerta_destinatarios_alertas_saldo_alerta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_alerta_destinatarios_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaSaldo", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_alertas_saldo_cuentas_cuenta_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Auditoria", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_auditorias_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AuditoriaIntegracion", b => + { + b.HasOne("GestionCaja.API.Models.IntegrationToken", null) + .WithMany() + .HasForeignKey("TokenId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_auditoria_integraciones_integration_tokens_token_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Backup", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_backups_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("IniciadoPorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_backups_usuarios_iniciado_por_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Configuracion", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioModificacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_configuracion_usuarios_usuario_modificacion_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Cuenta", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_cuentas_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.FormatoImportacion", null) + .WithMany() + .HasForeignKey("FormatoId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_cuentas_formatos_importacion_formato_id"); + + b.HasOne("GestionCaja.API.Models.Titular", "Titular") + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_cuentas_titulares_titular_id"); + + b.Navigation("Titular"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Exportacion", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_exportaciones_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exportaciones_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("IniciadoPorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exportaciones_usuarios_iniciado_por_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Extracto", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("CheckedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_checked_by_id"); + + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_extractos_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("FlaggedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_flagged_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_usuario_creacion_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioModificacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_usuario_modificacion_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.ExtractoColumnaExtra", b => + { + b.HasOne("GestionCaja.API.Models.Extracto", null) + .WithMany() + .HasForeignKey("ExtractoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_extractos_columnas_extra_extractos_extracto_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.FormatoImportacion", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_formatos_importacion_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreadorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_formatos_importacion_usuarios_usuario_creador_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationPermission", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_permissions_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Titular", null) + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_permissions_titulares_titular_id"); + + b.HasOne("GestionCaja.API.Models.IntegrationToken", null) + .WithMany() + .HasForeignKey("TokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_integration_permissions_integration_tokens_token_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationToken", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_tokens_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_integration_tokens_usuarios_usuario_creador_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PermisoUsuario", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permisos_usuario_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Titular", null) + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permisos_usuario_titulares_titular_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_permisos_usuario_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_preferencias_usuario_cuenta_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_preferencias_usuario_cuenta_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.RefreshToken", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_usuarios_usuario_id"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Titular", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_titulares_usuarios_deleted_by_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.UsuarioEmail", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_usuario_emails_usuarios_usuario_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425130139_AddPuedeVerCuentasPermiso.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425130139_AddPuedeVerCuentasPermiso.cs new file mode 100644 index 0000000..290a4a0 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425130139_AddPuedeVerCuentasPermiso.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GestionCaja.API.Migrations +{ + /// + public partial class AddPuedeVerCuentasPermiso : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "puede_ver_cuentas", + table: "PERMISOS_USUARIO", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql(""" + UPDATE "PERMISOS_USUARIO" + SET "puede_ver_cuentas" = TRUE + WHERE "cuenta_id" IS NOT NULL + OR "titular_id" IS NOT NULL + OR "puede_agregar_lineas" = TRUE + OR "puede_editar_lineas" = TRUE + OR "puede_eliminar_lineas" = TRUE + OR "puede_importar" = TRUE; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "puede_ver_cuentas", + table: "PERMISOS_USUARIO"); + } + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425145516_AddPlazoFijoAutonomosAlertas.Designer.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425145516_AddPlazoFijoAutonomosAlertas.Designer.cs new file mode 100644 index 0000000..3af838b --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425145516_AddPlazoFijoAutonomosAlertas.Designer.cs @@ -0,0 +1,1671 @@ +// +using System; +using System.Net; +using GestionCaja.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GestionCaja.API.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260425145516_AddPlazoFijoAutonomosAlertas")] + partial class AddPlazoFijoAutonomosAlertas + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_plazo_fijo", new[] { "activo", "proximo_vencer", "vencido", "renovado", "cancelado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_proceso", new[] { "pending", "success", "failed" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_token_integracion", new[] { "activo", "revocado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "fuente_tipo_cambio", new[] { "api", "manual" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "rol_usuario", new[] { "admin", "gerente", "empleado_ultra", "empleado_plus", "empleado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_cuenta", new[] { "normal", "efectivo", "plazo_fijo" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_proceso", new[] { "auto", "manual" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_titular", new[] { "empresa", "particular", "autonomo" }); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaDestinatario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlertaId") + .HasColumnType("uuid") + .HasColumnName("alerta_id"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_alerta_destinatarios"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_alerta_destinatarios_usuario_id"); + + b.HasIndex("AlertaId", "UsuarioId") + .IsUnique() + .HasDatabaseName("ix_alerta_destinatarios_alerta_id_usuario_id"); + + b.ToTable("ALERTA_DESTINATARIOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaSaldo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaUltimaAlerta") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_alerta"); + + b.Property("SaldoMinimo") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("saldo_minimo"); + + b.Property("TipoTitular") + .HasColumnType("integer") + .HasColumnName("tipo_titular"); + + b.HasKey("Id") + .HasName("pk_alertas_saldo"); + + b.HasIndex("CuentaId") + .IsUnique() + .HasDatabaseName("ix_alertas_saldo_cuenta_id_unique") + .HasFilter("\"cuenta_id\" IS NOT NULL"); + + b.HasIndex("TipoTitular") + .IsUnique() + .HasDatabaseName("ix_alertas_saldo_tipo_titular_unique") + .HasFilter("\"cuenta_id\" IS NULL AND \"tipo_titular\" IS NOT NULL"); + + b.ToTable("ALERTAS_SALDO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Auditoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CeldaReferencia") + .HasColumnType("text") + .HasColumnName("celda_referencia"); + + b.Property("ColumnaNombre") + .HasColumnType("text") + .HasColumnName("columna_nombre"); + + b.Property("DetallesJson") + .HasColumnType("jsonb") + .HasColumnName("detalles_json"); + + b.Property("EntidadId") + .HasColumnType("uuid") + .HasColumnName("entidad_id"); + + b.Property("EntidadTipo") + .HasColumnType("text") + .HasColumnName("entidad_tipo"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TipoAccion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo_accion"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.Property("ValorAnterior") + .HasColumnType("text") + .HasColumnName("valor_anterior"); + + b.Property("ValorNuevo") + .HasColumnType("text") + .HasColumnName("valor_nuevo"); + + b.HasKey("Id") + .HasName("pk_auditorias"); + + b.HasIndex("EntidadId") + .HasDatabaseName("ix_auditorias_entidad_id"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_auditorias_timestamp"); + + b.HasIndex("TipoAccion") + .HasDatabaseName("ix_auditorias_tipo_accion"); + + b.HasIndex("UsuarioId", "Timestamp") + .HasDatabaseName("ix_auditorias_usuario_id_timestamp"); + + b.ToTable("AUDITORIAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AuditoriaIntegracion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CodigoRespuesta") + .HasColumnType("integer") + .HasColumnName("codigo_respuesta"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("Metodo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("metodo"); + + b.Property("Parametros") + .HasColumnType("jsonb") + .HasColumnName("parametros"); + + b.Property("TiempoEjecucionMs") + .HasColumnType("integer") + .HasColumnName("tiempo_ejecucion_ms"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasColumnName("token_id"); + + b.HasKey("Id") + .HasName("pk_auditoria_integraciones"); + + b.HasIndex("CodigoRespuesta") + .HasDatabaseName("ix_auditoria_integraciones_codigo_respuesta"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_auditoria_integraciones_timestamp"); + + b.HasIndex("TokenId") + .HasDatabaseName("ix_auditoria_integraciones_token_id"); + + b.ToTable("AUDITORIA_INTEGRACIONES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Backup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("IniciadoPorId") + .HasColumnType("uuid") + .HasColumnName("iniciado_por_id"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("RutaArchivo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ruta_archivo"); + + b.Property("TamanioBytes") + .HasColumnType("bigint") + .HasColumnName("tamanio_bytes"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_backups"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_backups_deleted_by_id"); + + b.HasIndex("IniciadoPorId") + .HasDatabaseName("ix_backups_iniciado_por_id"); + + b.ToTable("BACKUPS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Configuracion", b => + { + b.Property("Clave") + .HasColumnType("text") + .HasColumnName("clave"); + + b.Property("Descripcion") + .HasColumnType("text") + .HasColumnName("descripcion"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("Tipo") + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("UsuarioModificacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_modificacion_id"); + + b.Property("Valor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("valor"); + + b.HasKey("Clave") + .HasName("pk_configuracion"); + + b.HasIndex("UsuarioModificacionId") + .HasDatabaseName("ix_configuracion_usuario_modificacion_id"); + + b.ToTable("CONFIGURACION", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Cuenta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("BancoNombre") + .HasColumnType("text") + .HasColumnName("banco_nombre"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Divisa") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa"); + + b.Property("EsEfectivo") + .HasColumnType("boolean") + .HasColumnName("es_efectivo"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FormatoId") + .HasColumnType("uuid") + .HasColumnName("formato_id"); + + b.Property("Iban") + .HasColumnType("text") + .HasColumnName("iban"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("NumeroCuenta") + .HasColumnType("text") + .HasColumnName("numero_cuenta"); + + b.Property("TipoCuenta") + .HasColumnType("integer") + .HasColumnName("tipo_cuenta"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.HasKey("Id") + .HasName("pk_cuentas"); + + b.HasIndex("Activa") + .HasDatabaseName("ix_cuentas_activa"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_cuentas_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_cuentas_deleted_by_id"); + + b.HasIndex("Divisa") + .HasDatabaseName("ix_cuentas_divisa"); + + b.HasIndex("EsEfectivo") + .HasDatabaseName("ix_cuentas_es_efectivo"); + + b.HasIndex("FormatoId") + .HasDatabaseName("ix_cuentas_formato_id"); + + b.HasIndex("TipoCuenta") + .HasDatabaseName("ix_cuentas_tipo_cuenta"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_cuentas_titular_id"); + + b.ToTable("CUENTAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.DivisaActiva", b => + { + b.Property("Codigo") + .HasColumnType("text") + .HasColumnName("codigo"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("EsBase") + .HasColumnType("boolean") + .HasColumnName("es_base"); + + b.Property("Nombre") + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Simbolo") + .HasColumnType("text") + .HasColumnName("simbolo"); + + b.HasKey("Codigo") + .HasName("pk_divisas_activas"); + + b.ToTable("DIVISAS_ACTIVAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Exportacion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaExportacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_exportacion"); + + b.Property("IniciadoPorId") + .HasColumnType("uuid") + .HasColumnName("iniciado_por_id"); + + b.Property("RutaArchivo") + .HasColumnType("text") + .HasColumnName("ruta_archivo"); + + b.Property("TamanioBytes") + .HasColumnType("bigint") + .HasColumnName("tamanio_bytes"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_exportaciones"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_exportaciones_cuenta_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_exportaciones_deleted_by_id"); + + b.HasIndex("IniciadoPorId") + .HasDatabaseName("ix_exportaciones_iniciado_por_id"); + + b.ToTable("EXPORTACIONES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Extracto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Checked") + .HasColumnType("boolean") + .HasColumnName("checked"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checked_at"); + + b.Property("CheckedById") + .HasColumnType("uuid") + .HasColumnName("checked_by_id"); + + b.Property("Comentarios") + .HasColumnType("text") + .HasColumnName("comentarios"); + + b.Property("Concepto") + .HasColumnType("text") + .HasColumnName("concepto"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Fecha") + .HasColumnType("date") + .HasColumnName("fecha"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("FilaNumero") + .HasColumnType("integer") + .HasColumnName("fila_numero"); + + b.Property("Flagged") + .HasColumnType("boolean") + .HasColumnName("flagged"); + + b.Property("FlaggedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("flagged_at"); + + b.Property("FlaggedById") + .HasColumnType("uuid") + .HasColumnName("flagged_by_id"); + + b.Property("FlaggedNota") + .HasColumnType("text") + .HasColumnName("flagged_nota"); + + b.Property("Monto") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("monto"); + + b.Property("Saldo") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("saldo"); + + b.Property("UsuarioCreacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_creacion_id"); + + b.Property("UsuarioModificacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_modificacion_id"); + + b.HasKey("Id") + .HasName("pk_extractos"); + + b.HasIndex("Checked") + .HasDatabaseName("ix_extractos_checked"); + + b.HasIndex("CheckedById") + .HasDatabaseName("ix_extractos_checked_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_extractos_deleted_by_id"); + + b.HasIndex("Fecha") + .HasDatabaseName("ix_extractos_fecha"); + + b.HasIndex("Flagged") + .HasDatabaseName("ix_extractos_flagged"); + + b.HasIndex("FlaggedById") + .HasDatabaseName("ix_extractos_flagged_by_id"); + + b.HasIndex("UsuarioCreacionId") + .HasDatabaseName("ix_extractos_usuario_creacion_id"); + + b.HasIndex("UsuarioModificacionId") + .HasDatabaseName("ix_extractos_usuario_modificacion_id"); + + b.HasIndex("CuentaId", "DeletedAt") + .HasDatabaseName("ix_extractos_cuenta_id_deleted_at"); + + b.HasIndex("CuentaId", "Fecha") + .HasDatabaseName("ix_extractos_cuenta_id_fecha"); + + b.HasIndex("CuentaId", "FilaNumero") + .IsUnique() + .HasDatabaseName("ix_extractos_cuenta_id_fila_numero"); + + b.ToTable("EXTRACTOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.ExtractoColumnaExtra", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExtractoId") + .HasColumnType("uuid") + .HasColumnName("extracto_id"); + + b.Property("NombreColumna") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre_columna"); + + b.Property("Valor") + .HasColumnType("text") + .HasColumnName("valor"); + + b.HasKey("Id") + .HasName("pk_extractos_columnas_extra"); + + b.HasIndex("ExtractoId") + .HasDatabaseName("ix_extractos_columnas_extra_extracto_id"); + + b.HasIndex("NombreColumna") + .HasDatabaseName("ix_extractos_columnas_extra_nombre_columna"); + + b.ToTable("EXTRACTOS_COLUMNAS_EXTRA", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.FormatoImportacion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activo") + .HasColumnType("boolean") + .HasColumnName("activo"); + + b.Property("BancoNombre") + .HasColumnType("text") + .HasColumnName("banco_nombre"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Divisa") + .HasColumnType("text") + .HasColumnName("divisa"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("MapeoJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("mapeo_json"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("UsuarioCreadorId") + .HasColumnType("uuid") + .HasColumnName("usuario_creador_id"); + + b.HasKey("Id") + .HasName("pk_formatos_importacion"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_formatos_importacion_deleted_by_id"); + + b.HasIndex("UsuarioCreadorId") + .HasDatabaseName("ix_formatos_importacion_usuario_creador_id"); + + b.ToTable("FORMATOS_IMPORTACION", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccesoTipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acceso_tipo"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasColumnName("token_id"); + + b.HasKey("Id") + .HasName("pk_integration_permissions"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_integration_permissions_cuenta_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_integration_permissions_titular_id"); + + b.HasIndex("TokenId") + .HasDatabaseName("ix_integration_permissions_token_id"); + + b.ToTable("INTEGRATION_PERMISSIONS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Descripcion") + .HasColumnType("text") + .HasColumnName("descripcion"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaRevocacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_revocacion"); + + b.Property("FechaUltimaUso") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_uso"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("PermisoEscritura") + .HasColumnType("boolean") + .HasColumnName("permiso_escritura"); + + b.Property("PermisoLectura") + .HasColumnType("boolean") + .HasColumnName("permiso_lectura"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UsuarioCreadorId") + .HasColumnType("uuid") + .HasColumnName("usuario_creador_id"); + + b.HasKey("Id") + .HasName("pk_integration_tokens"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_integration_tokens_deleted_by_id"); + + b.HasIndex("Estado") + .HasDatabaseName("ix_integration_tokens_estado"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_integration_tokens_token_hash"); + + b.HasIndex("UsuarioCreadorId") + .HasDatabaseName("ix_integration_tokens_usuario_creador_id"); + + b.ToTable("INTEGRATION_TOKENS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.NotificacionAdmin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DetallesJson") + .HasColumnType("jsonb") + .HasColumnName("detalles_json"); + + b.Property("Fecha") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha"); + + b.Property("Leida") + .HasColumnType("boolean") + .HasColumnName("leida"); + + b.Property("Mensaje") + .HasColumnType("text") + .HasColumnName("mensaje"); + + b.Property("Tipo") + .HasColumnType("text") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_notificaciones_admin"); + + b.ToTable("NOTIFICACIONES_ADMIN", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PermisoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("PuedeAgregarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_agregar_lineas"); + + b.Property("PuedeEditarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_editar_lineas"); + + b.Property("PuedeEliminarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_eliminar_lineas"); + + b.Property("PuedeImportar") + .HasColumnType("boolean") + .HasColumnName("puede_importar"); + + b.Property("PuedeVerCuentas") + .HasColumnType("boolean") + .HasColumnName("puede_ver_cuentas"); + + b.Property("PuedeVerDashboard") + .HasColumnType("boolean") + .HasColumnName("puede_ver_dashboard"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_permisos_usuario"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_permisos_usuario_cuenta_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_permisos_usuario_titular_id"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_permisos_usuario_usuario_id"); + + b.HasIndex("UsuarioId", "CuentaId") + .HasDatabaseName("ix_permisos_usuario_usuario_id_cuenta_id"); + + b.ToTable("PERMISOS_USUARIO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PlazoFijo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("CuentaReferenciaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_referencia_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaInicio") + .HasColumnType("date") + .HasColumnName("fecha_inicio"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("FechaRenovacion") + .HasColumnType("date") + .HasColumnName("fecha_renovacion"); + + b.Property("FechaUltimaNotificacion") + .HasColumnType("date") + .HasColumnName("fecha_ultima_notificacion"); + + b.Property("FechaVencimiento") + .HasColumnType("date") + .HasColumnName("fecha_vencimiento"); + + b.Property("InteresPrevisto") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("interes_previsto"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("Renovable") + .HasColumnType("boolean") + .HasColumnName("renovable"); + + b.HasKey("Id") + .HasName("pk_plazos_fijos"); + + b.HasIndex("CuentaId") + .IsUnique() + .HasDatabaseName("ix_plazos_fijos_cuenta_id"); + + b.HasIndex("CuentaReferenciaId") + .HasDatabaseName("ix_plazos_fijos_cuenta_referencia_id"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_plazos_fijos_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_plazos_fijos_deleted_by_id"); + + b.HasIndex("Estado") + .HasDatabaseName("ix_plazos_fijos_estado"); + + b.HasIndex("FechaVencimiento") + .HasDatabaseName("ix_plazos_fijos_fecha_vencimiento"); + + b.ToTable("PLAZOS_FIJOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ColumnasEditables") + .HasColumnType("jsonb") + .HasColumnName("columnas_editables"); + + b.Property("ColumnasVisibles") + .HasColumnType("jsonb") + .HasColumnName("columnas_visibles"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_preferencias_usuario_cuenta"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_preferencias_usuario_cuenta_cuenta_id"); + + b.HasIndex("UsuarioId") + .IsUnique() + .HasDatabaseName("ix_preferencias_usuario_cuenta_usuario_id") + .HasFilter("\"cuenta_id\" IS NULL"); + + b.HasIndex("UsuarioId", "CuentaId") + .IsUnique() + .HasDatabaseName("ix_preferencias_usuario_cuenta_usuario_id_cuenta_id") + .HasFilter("\"cuenta_id\" IS NOT NULL"); + + b.ToTable("PREFERENCIAS_USUARIO_CUENTA", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreadoEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("creado_en"); + + b.Property("ExpiraEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_en"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("ReemplazadoPor") + .HasColumnType("text") + .HasColumnName("reemplazado_por"); + + b.Property("RevocadoEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revocado_en"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("ExpiraEn") + .HasDatabaseName("ix_refresh_tokens_expira_en"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_refresh_tokens_usuario_id"); + + b.ToTable("REFRESH_TOKENS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.TipoCambio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DivisaDestino") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa_destino"); + + b.Property("DivisaOrigen") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa_origen"); + + b.Property("FechaActualizacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_actualizacion"); + + b.Property("Fuente") + .HasColumnType("integer") + .HasColumnName("fuente"); + + b.Property("Tasa") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)") + .HasColumnName("tasa"); + + b.HasKey("Id") + .HasName("pk_tipos_cambio"); + + b.HasIndex("DivisaOrigen", "DivisaDestino") + .IsUnique() + .HasDatabaseName("ix_tipos_cambio_divisa_origen_divisa_destino"); + + b.ToTable("TIPOS_CAMBIO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Titular", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContactoEmail") + .HasColumnType("text") + .HasColumnName("contacto_email"); + + b.Property("ContactoTelefono") + .HasColumnType("text") + .HasColumnName("contacto_telefono"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("Identificacion") + .HasColumnType("text") + .HasColumnName("identificacion"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_titulares"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_titulares_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_titulares_deleted_by_id"); + + b.HasIndex("Nombre") + .HasDatabaseName("ix_titulares_nombre"); + + b.HasIndex("Tipo") + .HasDatabaseName("ix_titulares_tipo"); + + b.ToTable("TITULARES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Activo") + .HasColumnType("boolean") + .HasColumnName("activo"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("FailedLoginAttempts") + .HasColumnType("integer") + .HasColumnName("failed_login_attempts"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaUltimaLogin") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_login"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("NombreCompleto") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre_completo"); + + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_changed_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PrimerLogin") + .HasColumnType("boolean") + .HasColumnName("primer_login"); + + b.Property("Rol") + .HasColumnType("integer") + .HasColumnName("rol"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + + b.HasKey("Id") + .HasName("pk_usuarios"); + + b.HasIndex("Activo") + .HasDatabaseName("ix_usuarios_activo"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_usuarios_email"); + + b.HasIndex("Rol") + .HasDatabaseName("ix_usuarios_rol"); + + b.ToTable("USUARIOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.UsuarioEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EsPrincipal") + .HasColumnType("boolean") + .HasColumnName("es_principal"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_usuario_emails"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_usuario_emails_usuario_id"); + + b.ToTable("USUARIO_EMAILS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaDestinatario", b => + { + b.HasOne("GestionCaja.API.Models.AlertaSaldo", null) + .WithMany() + .HasForeignKey("AlertaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_alerta_destinatarios_alertas_saldo_alerta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_alerta_destinatarios_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaSaldo", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_alertas_saldo_cuentas_cuenta_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Auditoria", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_auditorias_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AuditoriaIntegracion", b => + { + b.HasOne("GestionCaja.API.Models.IntegrationToken", null) + .WithMany() + .HasForeignKey("TokenId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_auditoria_integraciones_integration_tokens_token_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Backup", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_backups_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("IniciadoPorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_backups_usuarios_iniciado_por_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Configuracion", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioModificacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_configuracion_usuarios_usuario_modificacion_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Cuenta", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_cuentas_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.FormatoImportacion", null) + .WithMany() + .HasForeignKey("FormatoId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_cuentas_formatos_importacion_formato_id"); + + b.HasOne("GestionCaja.API.Models.Titular", "Titular") + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_cuentas_titulares_titular_id"); + + b.Navigation("Titular"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Exportacion", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_exportaciones_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exportaciones_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("IniciadoPorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exportaciones_usuarios_iniciado_por_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Extracto", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("CheckedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_checked_by_id"); + + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_extractos_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("FlaggedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_flagged_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_usuario_creacion_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioModificacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_usuario_modificacion_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.ExtractoColumnaExtra", b => + { + b.HasOne("GestionCaja.API.Models.Extracto", null) + .WithMany() + .HasForeignKey("ExtractoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_extractos_columnas_extra_extractos_extracto_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.FormatoImportacion", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_formatos_importacion_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreadorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_formatos_importacion_usuarios_usuario_creador_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationPermission", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_permissions_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Titular", null) + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_permissions_titulares_titular_id"); + + b.HasOne("GestionCaja.API.Models.IntegrationToken", null) + .WithMany() + .HasForeignKey("TokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_integration_permissions_integration_tokens_token_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationToken", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_tokens_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_integration_tokens_usuarios_usuario_creador_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PermisoUsuario", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permisos_usuario_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Titular", null) + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permisos_usuario_titulares_titular_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_permisos_usuario_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PlazoFijo", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", "Cuenta") + .WithOne() + .HasForeignKey("GestionCaja.API.Models.PlazoFijo", "CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_plazos_fijos_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Cuenta", "CuentaReferencia") + .WithMany() + .HasForeignKey("CuentaReferenciaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_plazos_fijos_cuentas_cuenta_referencia_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_plazos_fijos_usuarios_deleted_by_id"); + + b.Navigation("Cuenta"); + + b.Navigation("CuentaReferencia"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_preferencias_usuario_cuenta_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_preferencias_usuario_cuenta_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.RefreshToken", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_usuarios_usuario_id"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Titular", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_titulares_usuarios_deleted_by_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.UsuarioEmail", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_usuario_emails_usuarios_usuario_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425145516_AddPlazoFijoAutonomosAlertas.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425145516_AddPlazoFijoAutonomosAlertas.cs new file mode 100644 index 0000000..cffd325 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425145516_AddPlazoFijoAutonomosAlertas.cs @@ -0,0 +1,211 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GestionCaja.API.Migrations +{ + /// + public partial class AddPlazoFijoAutonomosAlertas : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "ix_alertas_saldo_cuenta_id", + table: "ALERTAS_SALDO", + newName: "ix_alertas_saldo_cuenta_id_unique"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:estado_plazo_fijo", "activo,proximo_vencer,vencido,renovado,cancelado") + .Annotation("Npgsql:Enum:estado_proceso", "pending,success,failed") + .Annotation("Npgsql:Enum:estado_token_integracion", "activo,revocado") + .Annotation("Npgsql:Enum:fuente_tipo_cambio", "api,manual") + .Annotation("Npgsql:Enum:rol_usuario", "admin,gerente,empleado_ultra,empleado_plus,empleado") + .Annotation("Npgsql:Enum:tipo_cuenta", "normal,efectivo,plazo_fijo") + .Annotation("Npgsql:Enum:tipo_proceso", "auto,manual") + .Annotation("Npgsql:Enum:tipo_titular", "empresa,particular,autonomo") + .Annotation("Npgsql:PostgresExtension:pgcrypto", ",,") + .OldAnnotation("Npgsql:Enum:estado_proceso", "pending,success,failed") + .OldAnnotation("Npgsql:Enum:estado_token_integracion", "activo,revocado") + .OldAnnotation("Npgsql:Enum:fuente_tipo_cambio", "api,manual") + .OldAnnotation("Npgsql:Enum:rol_usuario", "admin,gerente,empleado_ultra,empleado_plus,empleado") + .OldAnnotation("Npgsql:Enum:tipo_proceso", "auto,manual") + .OldAnnotation("Npgsql:Enum:tipo_titular", "empresa,particular") + .OldAnnotation("Npgsql:PostgresExtension:pgcrypto", ",,"); + + migrationBuilder.AddColumn( + name: "tipo_cuenta", + table: "CUENTAS", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql(""" + UPDATE "CUENTAS" + SET "tipo_cuenta" = 1 + WHERE "es_efectivo" = TRUE; + """); + + migrationBuilder.AddColumn( + name: "tipo_titular", + table: "ALERTAS_SALDO", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "PLAZOS_FIJOS", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + cuenta_id = table.Column(type: "uuid", nullable: false), + cuenta_referencia_id = table.Column(type: "uuid", nullable: true), + fecha_inicio = table.Column(type: "date", nullable: false), + fecha_vencimiento = table.Column(type: "date", nullable: false), + interes_previsto = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true), + renovable = table.Column(type: "boolean", nullable: false), + estado = table.Column(type: "integer", nullable: false), + fecha_ultima_notificacion = table.Column(type: "date", nullable: true), + fecha_renovacion = table.Column(type: "date", nullable: true), + notas = table.Column(type: "text", nullable: true), + fecha_creacion = table.Column(type: "timestamp with time zone", nullable: false), + fecha_modificacion = table.Column(type: "timestamp with time zone", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by_id = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_plazos_fijos", x => x.id); + table.ForeignKey( + name: "fk_plazos_fijos_cuentas_cuenta_id", + column: x => x.cuenta_id, + principalTable: "CUENTAS", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_plazos_fijos_cuentas_cuenta_referencia_id", + column: x => x.cuenta_referencia_id, + principalTable: "CUENTAS", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_plazos_fijos_usuarios_deleted_by_id", + column: x => x.deleted_by_id, + principalTable: "USUARIOS", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "ix_cuentas_tipo_cuenta", + table: "CUENTAS", + column: "tipo_cuenta"); + + migrationBuilder.CreateIndex( + name: "ix_alertas_saldo_tipo_titular_unique", + table: "ALERTAS_SALDO", + column: "tipo_titular", + unique: true, + filter: "\"cuenta_id\" IS NULL AND \"tipo_titular\" IS NOT NULL"); + + migrationBuilder.Sql(""" + CREATE UNIQUE INDEX ix_alertas_saldo_global_unique + ON "ALERTAS_SALDO" ((1)) + WHERE "cuenta_id" IS NULL AND "tipo_titular" IS NULL; + """); + + migrationBuilder.Sql(""" + ALTER TABLE "PLAZOS_FIJOS" + ADD CONSTRAINT ck_plazos_fijos_fechas + CHECK ("fecha_vencimiento" >= "fecha_inicio"); + """); + + migrationBuilder.Sql(""" + ALTER TABLE "PLAZOS_FIJOS" + ADD CONSTRAINT ck_plazos_fijos_interes_no_negativo + CHECK ("interes_previsto" IS NULL OR "interes_previsto" >= 0); + """); + + migrationBuilder.CreateIndex( + name: "ix_plazos_fijos_cuenta_id", + table: "PLAZOS_FIJOS", + column: "cuenta_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_plazos_fijos_cuenta_referencia_id", + table: "PLAZOS_FIJOS", + column: "cuenta_referencia_id"); + + migrationBuilder.CreateIndex( + name: "ix_plazos_fijos_deleted_at", + table: "PLAZOS_FIJOS", + column: "deleted_at"); + + migrationBuilder.CreateIndex( + name: "ix_plazos_fijos_deleted_by_id", + table: "PLAZOS_FIJOS", + column: "deleted_by_id"); + + migrationBuilder.CreateIndex( + name: "ix_plazos_fijos_estado", + table: "PLAZOS_FIJOS", + column: "estado"); + + migrationBuilder.CreateIndex( + name: "ix_plazos_fijos_fecha_vencimiento", + table: "PLAZOS_FIJOS", + column: "fecha_vencimiento"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PLAZOS_FIJOS"); + + migrationBuilder.DropIndex( + name: "ix_cuentas_tipo_cuenta", + table: "CUENTAS"); + + migrationBuilder.DropIndex( + name: "ix_alertas_saldo_tipo_titular_unique", + table: "ALERTAS_SALDO"); + + migrationBuilder.Sql(""" + DROP INDEX IF EXISTS ix_alertas_saldo_global_unique; + """); + + migrationBuilder.DropColumn( + name: "tipo_cuenta", + table: "CUENTAS"); + + migrationBuilder.DropColumn( + name: "tipo_titular", + table: "ALERTAS_SALDO"); + + migrationBuilder.RenameIndex( + name: "ix_alertas_saldo_cuenta_id_unique", + table: "ALERTAS_SALDO", + newName: "ix_alertas_saldo_cuenta_id"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:estado_proceso", "pending,success,failed") + .Annotation("Npgsql:Enum:estado_token_integracion", "activo,revocado") + .Annotation("Npgsql:Enum:fuente_tipo_cambio", "api,manual") + .Annotation("Npgsql:Enum:rol_usuario", "admin,gerente,empleado_ultra,empleado_plus,empleado") + .Annotation("Npgsql:Enum:tipo_proceso", "auto,manual") + .Annotation("Npgsql:Enum:tipo_titular", "empresa,particular") + .Annotation("Npgsql:PostgresExtension:pgcrypto", ",,") + .OldAnnotation("Npgsql:Enum:estado_plazo_fijo", "activo,proximo_vencer,vencido,renovado,cancelado") + .OldAnnotation("Npgsql:Enum:estado_proceso", "pending,success,failed") + .OldAnnotation("Npgsql:Enum:estado_token_integracion", "activo,revocado") + .OldAnnotation("Npgsql:Enum:fuente_tipo_cambio", "api,manual") + .OldAnnotation("Npgsql:Enum:rol_usuario", "admin,gerente,empleado_ultra,empleado_plus,empleado") + .OldAnnotation("Npgsql:Enum:tipo_cuenta", "normal,efectivo,plazo_fijo") + .OldAnnotation("Npgsql:Enum:tipo_proceso", "auto,manual") + .OldAnnotation("Npgsql:Enum:tipo_titular", "empresa,particular,autonomo") + .OldAnnotation("Npgsql:PostgresExtension:pgcrypto", ",,"); + } + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs index 0477ed7..f192cfa 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs @@ -21,12 +21,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasAnnotation("ProductVersion", "8.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_plazo_fijo", new[] { "activo", "proximo_vencer", "vencido", "renovado", "cancelado" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_proceso", new[] { "pending", "success", "failed" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_token_integracion", new[] { "activo", "revocado" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "fuente_tipo_cambio", new[] { "api", "manual" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "rol_usuario", new[] { "admin", "gerente", "empleado_ultra", "empleado_plus", "empleado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_cuenta", new[] { "normal", "efectivo", "plazo_fijo" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_proceso", new[] { "auto", "manual" }); - NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_titular", new[] { "empresa", "particular" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_titular", new[] { "empresa", "particular", "autonomo" }); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -86,14 +88,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("numeric(18,4)") .HasColumnName("saldo_minimo"); + b.Property("TipoTitular") + .HasColumnType("integer") + .HasColumnName("tipo_titular"); + b.HasKey("Id") .HasName("pk_alertas_saldo"); b.HasIndex("CuentaId") .IsUnique() - .HasDatabaseName("ix_alertas_saldo_cuenta_id") + .HasDatabaseName("ix_alertas_saldo_cuenta_id_unique") .HasFilter("\"cuenta_id\" IS NOT NULL"); + b.HasIndex("TipoTitular") + .IsUnique() + .HasDatabaseName("ix_alertas_saldo_tipo_titular_unique") + .HasFilter("\"cuenta_id\" IS NULL AND \"tipo_titular\" IS NOT NULL"); + b.ToTable("ALERTAS_SALDO", (string)null); }); @@ -372,6 +383,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("numero_cuenta"); + b.Property("TipoCuenta") + .HasColumnType("integer") + .HasColumnName("tipo_cuenta"); + b.Property("TitularId") .HasColumnType("uuid") .HasColumnName("titular_id"); @@ -397,6 +412,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("FormatoId") .HasDatabaseName("ix_cuentas_formato_id"); + b.HasIndex("TipoCuenta") + .HasDatabaseName("ix_cuentas_tipo_cuenta"); + b.HasIndex("TitularId") .HasDatabaseName("ix_cuentas_titular_id"); @@ -891,6 +909,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("puede_importar"); + b.Property("PuedeVerCuentas") + .HasColumnType("boolean") + .HasColumnName("puede_ver_cuentas"); + b.Property("PuedeVerDashboard") .HasColumnType("boolean") .HasColumnName("puede_ver_dashboard"); @@ -921,6 +943,95 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PERMISOS_USUARIO", (string)null); }); + modelBuilder.Entity("GestionCaja.API.Models.PlazoFijo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("CuentaReferenciaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_referencia_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaInicio") + .HasColumnType("date") + .HasColumnName("fecha_inicio"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("FechaRenovacion") + .HasColumnType("date") + .HasColumnName("fecha_renovacion"); + + b.Property("FechaUltimaNotificacion") + .HasColumnType("date") + .HasColumnName("fecha_ultima_notificacion"); + + b.Property("FechaVencimiento") + .HasColumnType("date") + .HasColumnName("fecha_vencimiento"); + + b.Property("InteresPrevisto") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("interes_previsto"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("Renovable") + .HasColumnType("boolean") + .HasColumnName("renovable"); + + b.HasKey("Id") + .HasName("pk_plazos_fijos"); + + b.HasIndex("CuentaId") + .IsUnique() + .HasDatabaseName("ix_plazos_fijos_cuenta_id"); + + b.HasIndex("CuentaReferenciaId") + .HasDatabaseName("ix_plazos_fijos_cuenta_referencia_id"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_plazos_fijos_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_plazos_fijos_deleted_by_id"); + + b.HasIndex("Estado") + .HasDatabaseName("ix_plazos_fijos_estado"); + + b.HasIndex("FechaVencimiento") + .HasDatabaseName("ix_plazos_fijos_fecha_vencimiento"); + + b.ToTable("PLAZOS_FIJOS", (string)null); + }); + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => { b.Property("Id") @@ -1479,6 +1590,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasConstraintName("fk_permisos_usuario_usuarios_usuario_id"); }); + modelBuilder.Entity("GestionCaja.API.Models.PlazoFijo", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", "Cuenta") + .WithOne() + .HasForeignKey("GestionCaja.API.Models.PlazoFijo", "CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_plazos_fijos_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Cuenta", "CuentaReferencia") + .WithMany() + .HasForeignKey("CuentaReferenciaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_plazos_fijos_cuentas_cuenta_referencia_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_plazos_fijos_usuarios_deleted_by_id"); + + b.Navigation("Cuenta"); + + b.Navigation("CuentaReferencia"); + }); + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => { b.HasOne("GestionCaja.API.Models.Cuenta", null) diff --git a/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs b/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs index 0a55546..3bd612a 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs @@ -71,6 +71,7 @@ public class Cuenta : ISoftDelete public string? BancoNombre { get; set; } public string Divisa { get; set; } = "EUR"; public Guid? FormatoId { get; set; } + public TipoCuenta TipoCuenta { get; set; } = TipoCuenta.NORMAL; public bool EsEfectivo { get; set; } public bool Activa { get; set; } = true; public string? Notas { get; set; } @@ -79,6 +80,27 @@ public class Cuenta : ISoftDelete public Guid? DeletedById { get; set; } } +public class PlazoFijo : ISoftDelete +{ + public Guid Id { get; set; } + public Guid CuentaId { get; set; } + public Cuenta? Cuenta { get; set; } + public Guid? CuentaReferenciaId { get; set; } + public Cuenta? CuentaReferencia { get; set; } + public DateOnly FechaInicio { get; set; } + public DateOnly FechaVencimiento { get; set; } + public decimal? InteresPrevisto { get; set; } + public bool Renovable { get; set; } + public EstadoPlazoFijo Estado { get; set; } = EstadoPlazoFijo.ACTIVO; + public DateOnly? FechaUltimaNotificacion { get; set; } + public DateOnly? FechaRenovacion { get; set; } + public string? Notas { get; set; } + public DateTime FechaCreacion { get; set; } = DateTime.UtcNow; + public DateTime? FechaModificacion { get; set; } + public DateTime? DeletedAt { get; set; } + public Guid? DeletedById { get; set; } +} + public class FormatoImportacion : ISoftDelete { public Guid Id { get; set; } @@ -132,6 +154,7 @@ public class PermisoUsuario public Guid UsuarioId { get; set; } public Guid? CuentaId { get; set; } public Guid? TitularId { get; set; } + public bool PuedeVerCuentas { get; set; } public bool PuedeAgregarLineas { get; set; } public bool PuedeEditarLineas { get; set; } public bool PuedeEliminarLineas { get; set; } @@ -154,6 +177,7 @@ public class AlertaSaldo { public Guid Id { get; set; } public Guid? CuentaId { get; set; } + public TipoTitular? TipoTitular { get; set; } public decimal SaldoMinimo { get; set; } public bool Activa { get; set; } = true; public DateTime FechaCreacion { get; set; } = DateTime.UtcNow; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Models/Enums.cs b/Atlas Balance/backend/src/GestionCaja.API/Models/Enums.cs index 05ad48a..d5ffb62 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Models/Enums.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Models/Enums.cs @@ -11,8 +11,25 @@ public enum RolUsuario public enum TipoTitular { - EMPRESA, - PARTICULAR + EMPRESA = 0, + PARTICULAR = 1, + AUTONOMO = 2 +} + +public enum TipoCuenta +{ + NORMAL = 0, + EFECTIVO = 1, + PLAZO_FIJO = 2 +} + +public enum EstadoPlazoFijo +{ + ACTIVO = 0, + PROXIMO_VENCER = 1, + VENCIDO = 2, + RENOVADO = 3, + CANCELADO = 4 } public enum EstadoTokenIntegracion diff --git a/Atlas Balance/backend/src/GestionCaja.API/Program.cs b/Atlas Balance/backend/src/GestionCaja.API/Program.cs index d08ef05..f7fd3fb 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Program.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Program.cs @@ -154,6 +154,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -166,6 +167,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -204,6 +206,11 @@ "export-mensual", job => job.ExecuteAsync(), "0 1 1 * *"); + + recurringJobManager.AddOrUpdate( + "plazo-fijo-vencimientos", + job => job.ExecuteAsync(), + Cron.Daily()); } app.UseSerilogRequestLogging(); @@ -219,7 +226,7 @@ { var headers = context.Response.Headers; headers["X-Content-Type-Options"] = "nosniff"; - headers["X-Frame-Options"] = "DENY"; + headers["X-Frame-Options"] = "SAMEORIGIN"; headers["Referrer-Policy"] = "no-referrer"; headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=()"; headers["Cross-Origin-Opener-Policy"] = "same-origin"; @@ -234,7 +241,7 @@ $"connect-src {connectSrc}; " + "font-src 'self' data:; " + "form-action 'self'; " + - "frame-ancestors 'none'; " + + "frame-ancestors 'self'; " + "img-src 'self' data: blob:; " + "object-src 'none'; " + "script-src 'self'; " + diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/AlertaService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/AlertaService.cs index 6ea8ffa..62c31d3 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/AlertaService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/AlertaService.cs @@ -40,7 +40,8 @@ join t in _dbContext.Titulares on c.TitularId equals t.Id c.Nombre, c.Divisa, TitularId = t.Id, - TitularNombre = t.Nombre + TitularNombre = t.Nombre, + TitularTipo = t.Tipo }) .FirstOrDefaultAsync(cancellationToken); @@ -62,8 +63,9 @@ join t in _dbContext.Titulares on c.TitularId equals t.Id } var alertas = await _dbContext.AlertasSaldo - .Where(x => x.Activa && (x.CuentaId == null || x.CuentaId == cuentaId)) + .Where(x => x.Activa && (x.CuentaId == cuentaId || (x.CuentaId == null && (x.TipoTitular == null || x.TipoTitular == cuenta.TitularTipo)))) .OrderByDescending(x => x.CuentaId == cuentaId) + .ThenByDescending(x => x.TipoTitular == cuenta.TitularTipo) .ThenByDescending(x => x.FechaCreacion) .ToListAsync(cancellationToken); @@ -138,9 +140,10 @@ await _auditService.LogAsync( cuenta_nombre = cuenta.Nombre, titular_id = cuenta.TitularId, titular_nombre = cuenta.TitularNombre, + tipo_titular = cuenta.TitularTipo.ToString(), saldo_actual = saldoActual.Value, saldo_minimo = alertaAplicable.SaldoMinimo, - alerta_global = alertaAplicable.CuentaId is null, + alcance = alertaAplicable.CuentaId.HasValue ? "CUENTA" : alertaAplicable.TipoTitular.HasValue ? "TIPO_TITULAR" : "GLOBAL", destinatarios = recipients.Count }), cancellationToken); @@ -173,7 +176,8 @@ join t in _dbContext.Titulares on c.TitularId equals t.Id c.Nombre, c.Divisa, TitularId = t.Id, - TitularNombre = t.Nombre + TitularNombre = t.Nombre, + TitularTipo = t.Tipo }) .ToListAsync(cancellationToken); @@ -198,10 +202,15 @@ join t in _dbContext.Titulares on c.TitularId equals t.Id var alertas = await _dbContext.AlertasSaldo .Where(x => x.Activa && (x.CuentaId == null || cuentaIds.Contains(x.CuentaId.Value))) .OrderByDescending(x => x.CuentaId.HasValue) + .ThenByDescending(x => x.TipoTitular.HasValue) .ThenByDescending(x => x.FechaCreacion) .ToListAsync(cancellationToken); - var globalAlert = alertas.FirstOrDefault(x => x.CuentaId == null); + var globalAlert = alertas.FirstOrDefault(x => x.CuentaId == null && x.TipoTitular == null); + var alertByTipoTitular = alertas + .Where(x => x.CuentaId == null && x.TipoTitular.HasValue) + .GroupBy(x => x.TipoTitular!.Value) + .ToDictionary(g => g.Key, g => g.First()); var alertByCuenta = alertas .Where(x => x.CuentaId.HasValue) .GroupBy(x => x.CuentaId!.Value) @@ -220,6 +229,10 @@ join t in _dbContext.Titulares on c.TitularId equals t.Id { alertaAplicable = alertaCuenta; } + else if (alertByTipoTitular.TryGetValue(cuenta.TitularTipo, out var alertaTipo)) + { + alertaAplicable = alertaTipo; + } else if (globalAlert is not null) { alertaAplicable = globalAlert; @@ -237,6 +250,7 @@ join t in _dbContext.Titulares on c.TitularId equals t.Id TitularId = cuenta.TitularId, CuentaNombre = cuenta.Nombre, TitularNombre = cuenta.TitularNombre, + TipoTitular = cuenta.TitularTipo.ToString(), Divisa = cuenta.Divisa, SaldoActual = saldoActual, SaldoMinimo = alertaAplicable.SaldoMinimo diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs index 45e3ba7..1ae8087 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs @@ -391,6 +391,7 @@ private async Task BuildAuthResultAsync(Usuario usuario, string? acc UsuarioId = p.UsuarioId, CuentaId = p.CuentaId, TitularId = p.TitularId, + PuedeVerCuentas = p.PuedeVerCuentas, PuedeAgregarLineas = p.PuedeAgregarLineas, PuedeEditarLineas = p.PuedeEditarLineas, PuedeEliminarLineas = p.PuedeEliminarLineas, diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs index 842b8f5..ce72186 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs @@ -31,6 +31,7 @@ public async Task GetPrincipalAsync(Guid userId, str var chartColors = await ResolveChartColorsAsync(cancellationToken); var cuentas = await GetScopedCuentasAsync(scope, null, cancellationToken); var metrics = await BuildMetricsAsync(cuentas, targetCurrency, cancellationToken); + var plazosFijos = await BuildPlazosFijosResumenAsync(cuentas, metrics, targetCurrency, cancellationToken); var titulares = cuentas .GroupBy(x => new { x.TitularId, x.TitularNombre }) @@ -43,16 +44,24 @@ public async Task GetPrincipalAsync(Guid userId, str x => x.Sum(c => metrics.SaldoByCuentaId.TryGetValue(c.CuentaId, out var saldo) ? saldo : 0m)); var totalConvertido = group.Sum(c => metrics.SaldoConvertidoByCuentaId.TryGetValue(c.CuentaId, out var saldo) ? saldo : 0m); + var inmovilizadoConvertido = group + .Where(c => c.TipoCuenta == TipoCuenta.PLAZO_FIJO) + .Sum(c => metrics.SaldoConvertidoByCuentaId.TryGetValue(c.CuentaId, out var saldo) ? saldo : 0m); + var disponibleConvertido = totalConvertido - inmovilizadoConvertido; return new DashboardSaldoTitularResponse { TitularId = group.Key.TitularId, TitularNombre = group.Key.TitularNombre, + TipoTitular = group.First().TipoTitular.ToString(), SaldosPorDivisa = saldosPorDivisa, - TotalConvertido = Decimal.Round(totalConvertido, 2) + TotalConvertido = Decimal.Round(totalConvertido, 2), + SaldoInmovilizadoConvertido = Decimal.Round(inmovilizadoConvertido, 2), + SaldoDisponibleConvertido = Decimal.Round(disponibleConvertido, 2) }; }) - .OrderByDescending(x => x.TotalConvertido) + .OrderBy(x => GetTipoTitularOrder(x.TipoTitular)) + .ThenByDescending(x => x.TotalConvertido) .ToList(); return new DashboardPrincipalResponse @@ -63,6 +72,7 @@ public async Task GetPrincipalAsync(Guid userId, str IngresosMes = Decimal.Round(metrics.IngresosMes, 2), EgresosMes = Decimal.Round(metrics.EgresosMes, 2), TotalConvertido = Decimal.Round(metrics.TotalConvertido, 2), + PlazosFijos = plazosFijos, SaldosPorTitular = titulares, ChartColors = chartColors }; @@ -103,6 +113,7 @@ public async Task GetTitularAsync(Guid userId, Guid ti BancoNombre = c.BancoNombre, Divisa = c.Divisa, EsEfectivo = c.EsEfectivo, + TipoCuenta = c.TipoCuenta.ToString(), SaldoActual = Decimal.Round(saldo, 2), SaldoConvertido = Decimal.Round(saldoConvertido, 2) }; @@ -142,11 +153,17 @@ public async Task GetSaldosDivisaAsync(Guid userI foreach (var entry in metrics.SaldosPorDivisa.OrderBy(x => x.Key)) { var converted = await _tiposCambioService.ConvertAsync(entry.Value, entry.Key, targetCurrency, cancellationToken); + var disponible = metrics.SaldosDisponiblesPorDivisa.GetValueOrDefault(entry.Key, 0m); + var inmovilizado = metrics.SaldosInmovilizadosPorDivisa.GetValueOrDefault(entry.Key, 0m); items.Add(new DashboardSaldoDivisaResponse { Divisa = entry.Key, Saldo = Decimal.Round(entry.Value, 2), - SaldoConvertido = Decimal.Round(converted, 2) + SaldoConvertido = Decimal.Round(converted, 2), + SaldoDisponible = Decimal.Round(disponible, 2), + SaldoInmovilizado = Decimal.Round(inmovilizado, 2), + SaldoTotal = Decimal.Round(entry.Value, 2), + SaldoTotalConvertido = Decimal.Round(converted, 2) }); } @@ -306,6 +323,8 @@ private async Task BuildMetricsAsync(IReadOnlyList x.CuentaId, x => x.Saldo); var saldoConvertidoByCuenta = new Dictionary(); var saldosPorDivisa = new Dictionary(StringComparer.OrdinalIgnoreCase); + var saldosDisponiblesPorDivisa = new Dictionary(StringComparer.OrdinalIgnoreCase); + var saldosInmovilizadosPorDivisa = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var cuenta in cuentas) { @@ -320,6 +339,14 @@ private async Task BuildMetricsAsync(IReadOnlyList BuildMetricsAsync(IReadOnlyList BuildMetricsAsync(IReadOnlyList BuildPlazosFijosResumenAsync( + IReadOnlyList cuentas, + DashboardMetrics metrics, + string targetCurrency, + CancellationToken cancellationToken) + { + var plazoCuentaIds = cuentas + .Where(c => c.TipoCuenta == TipoCuenta.PLAZO_FIJO) + .Select(c => c.CuentaId) + .ToHashSet(); + + if (plazoCuentaIds.Count == 0) + { + return new DashboardPlazosFijosResumenResponse(); + } + + var cuentaDivisas = cuentas.ToDictionary(c => c.CuentaId, c => c.Divisa); + var plazos = await _dbContext.PlazosFijos + .AsNoTracking() + .Where(p => plazoCuentaIds.Contains(p.CuentaId) && p.Estado != EstadoPlazoFijo.CANCELADO && p.Estado != EstadoPlazoFijo.RENOVADO) + .Select(p => new + { + p.CuentaId, + p.FechaVencimiento, + p.InteresPrevisto + }) + .ToListAsync(cancellationToken); + + var hoy = DateOnly.FromDateTime(DateTime.UtcNow.Date); + decimal interesesConvertidos = 0m; + foreach (var plazo in plazos) + { + if (!plazo.InteresPrevisto.HasValue || !cuentaDivisas.TryGetValue(plazo.CuentaId, out var divisa)) + { + continue; + } + + interesesConvertidos += await _tiposCambioService.ConvertAsync(plazo.InteresPrevisto.Value, divisa, targetCurrency, cancellationToken); + } + + var proximo = plazos + .Where(p => p.FechaVencimiento >= hoy) + .OrderBy(p => p.FechaVencimiento) + .FirstOrDefault(); + + var montoTotal = plazoCuentaIds.Sum(id => metrics.SaldoConvertidoByCuentaId.GetValueOrDefault(id, 0m)); + + return new DashboardPlazosFijosResumenResponse + { + MontoTotalConvertido = Decimal.Round(montoTotal, 2), + InteresesPrevistosConvertidos = Decimal.Round(interesesConvertidos, 2), + ProximoVencimiento = proximo?.FechaVencimiento, + DiasHastaProximoVencimiento = proximo is null ? null : proximo.FechaVencimiento.DayNumber - hoy.DayNumber, + TotalCuentas = plazoCuentaIds.Count + }; + } + private async Task> GetScopedCuentasAsync(DashboardScope scope, Guid? titularId, CancellationToken cancellationToken) { var query = from cuenta in _dbContext.Cuentas.AsNoTracking() @@ -380,7 +466,11 @@ join titular in _dbContext.Titulares.AsNoTracking() on cuenta.TitularId equals t TitularId = titular.Id, TitularNombre = titular.Nombre, Divisa = cuenta.Divisa, - EsEfectivo = cuenta.EsEfectivo + EsEfectivo = cuenta.EsEfectivo, + TipoCuenta = cuenta.TipoCuenta == TipoCuenta.NORMAL && cuenta.EsEfectivo + ? TipoCuenta.EFECTIVO + : cuenta.TipoCuenta, + TipoTitular = titular.Tipo }; if (titularId.HasValue) @@ -607,9 +697,20 @@ private static DateOnly AlignToMonday(DateOnly date) return divisa.Trim().ToUpperInvariant(); } + private static int GetTipoTitularOrder(string tipoTitular) => + tipoTitular switch + { + nameof(TipoTitular.EMPRESA) => 0, + nameof(TipoTitular.AUTONOMO) => 1, + nameof(TipoTitular.PARTICULAR) => 2, + _ => 3 + }; + private sealed class DashboardMetrics { public Dictionary SaldosPorDivisa { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary SaldosDisponiblesPorDivisa { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary SaldosInmovilizadosPorDivisa { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary SaldoByCuentaId { get; set; } = []; public Dictionary SaldoConvertidoByCuentaId { get; set; } = []; public decimal IngresosMes { get; set; } @@ -643,6 +744,8 @@ private sealed class CuentaScopeItem public string TitularNombre { get; set; } = string.Empty; public string Divisa { get; set; } = "EUR"; public bool EsEfectivo { get; set; } + public TipoCuenta TipoCuenta { get; set; } = TipoCuenta.NORMAL; + public TipoTitular TipoTitular { get; set; } = TipoTitular.EMPRESA; } private sealed class EvolucionExtractRow diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/EmailService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/EmailService.cs index 246ce20..3353476 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/EmailService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/EmailService.cs @@ -1,4 +1,5 @@ using GestionCaja.API.Data; +using GestionCaja.API.Models; using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.EntityFrameworkCore; @@ -18,6 +19,14 @@ Task SendSaldoBajoAlertAsync( decimal saldoMinimo, string? conceptoUltimoMovimiento, CancellationToken cancellationToken); + Task SendPlazoFijoVencimientoAsync( + IReadOnlyList recipients, + string titularNombre, + string cuentaNombre, + Guid cuentaId, + DateOnly fechaVencimiento, + EstadoPlazoFijo estado, + CancellationToken cancellationToken); Task SendTestEmailAsync(string recipient, CancellationToken cancellationToken); } @@ -95,6 +104,64 @@ public async Task SendSaldoBajoAlertAsync( await SendMessageAsync(message, smtpHost, smtpPort, smtpUser, smtpPassword, cancellationToken); } + public async Task SendPlazoFijoVencimientoAsync( + IReadOnlyList recipients, + string titularNombre, + string cuentaNombre, + Guid cuentaId, + DateOnly fechaVencimiento, + EstadoPlazoFijo estado, + CancellationToken cancellationToken) + { + if (recipients.Count == 0) + { + return; + } + + var smtpHost = await GetConfigValueAsync("smtp_host", cancellationToken); + if (string.IsNullOrWhiteSpace(smtpHost)) + { + _logger.LogWarning("No se envia alerta de plazo fijo: smtp_host no configurado"); + return; + } + + var smtpPortRaw = await GetConfigValueAsync("smtp_port", cancellationToken) ?? "587"; + var smtpUser = await GetConfigValueAsync("smtp_user", cancellationToken); + var smtpPassword = _secretProtector.UnprotectFromStorage(await GetConfigValueAsync("smtp_password", cancellationToken)); + var smtpFrom = await GetConfigValueAsync("smtp_from", cancellationToken); + var appBaseUrl = (await GetConfigValueAsync("app_base_url", cancellationToken))?.TrimEnd('/') + ?? "https://localhost:5000"; + + if (string.IsNullOrWhiteSpace(smtpFrom)) + { + smtpFrom = "noreply@atlasbalance.local"; + } + + var smtpPort = int.TryParse(smtpPortRaw, out var parsedPort) ? parsedPort : 587; + var cuentaUrl = EscapeHtml($"{appBaseUrl}/cuentas/{cuentaId}"); + var estadoTexto = estado == EstadoPlazoFijo.VENCIDO ? "vencido" : "proximo a vencer"; + + var message = new MimeMessage(); + message.From.Add(MailboxAddress.Parse(smtpFrom)); + foreach (var recipient in recipients) + { + message.To.Add(MailboxAddress.Parse(recipient)); + } + + message.Subject = $"[Atlas Balance] Plazo fijo {estadoTexto}: {cuentaNombre}"; + message.Body = new BodyBuilder + { + HtmlBody = + $"

Plazo fijo {EscapeHtml(estadoTexto)}

" + + $"

Titular: {EscapeHtml(titularNombre)}

" + + $"

Cuenta: {EscapeHtml(cuentaNombre)}

" + + $"

Vencimiento: {fechaVencimiento:yyyy-MM-dd}

" + + $"

Abrir cuenta

" + }.ToMessageBody(); + + await SendMessageAsync(message, smtpHost, smtpPort, smtpUser, smtpPassword, cancellationToken); + } + public async Task SendTestEmailAsync(string recipient, CancellationToken cancellationToken) { var smtpHost = await GetConfigValueAsync("smtp_host", cancellationToken); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs index 18e568d..0413faa 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs @@ -15,6 +15,7 @@ public interface IImportacionService Task GetContextoAsync(Guid usuarioId, string rol, CancellationToken cancellationToken); Task ValidarAsync(Guid usuarioId, string rol, ImportacionValidarRequest request, CancellationToken cancellationToken); Task ConfirmarAsync(Guid usuarioId, string rol, ImportacionConfirmarRequest request, HttpContext httpContext, CancellationToken cancellationToken); + Task RegistrarMovimientoPlazoFijoAsync(Guid usuarioId, string rol, ImportacionPlazoFijoMovimientoRequest request, HttpContext httpContext, CancellationToken cancellationToken); } public sealed class ImportacionService : IImportacionService @@ -93,6 +94,9 @@ public async Task GetContextoAsync(Guid usuarioId, TitularNombre = x.titular.Nombre, x.cuenta.Divisa, x.cuenta.EsEfectivo, + TipoCuenta = x.cuenta.TipoCuenta == TipoCuenta.NORMAL && x.cuenta.EsEfectivo + ? TipoCuenta.EFECTIVO + : x.cuenta.TipoCuenta, x.cuenta.FormatoId, MapeoJson = x.cuenta.FormatoId == null ? null @@ -112,6 +116,7 @@ public async Task GetContextoAsync(Guid usuarioId, TitularNombre = c.TitularNombre, Divisa = c.Divisa, EsEfectivo = c.EsEfectivo, + TipoCuenta = c.TipoCuenta.ToString(), FormatoId = c.FormatoId, FormatoPredefinido = ParseMapeoJson(c.MapeoJson) }).ToList() @@ -120,7 +125,8 @@ public async Task GetContextoAsync(Guid usuarioId, public async Task ValidarAsync(Guid usuarioId, string rol, ImportacionValidarRequest request, CancellationToken cancellationToken) { - await EnsureCuentaPermitidaAsync(usuarioId, rol, request.CuentaId, requireImportPermission: true, cancellationToken); + var cuenta = await EnsureCuentaPermitidaAsync(usuarioId, rol, request.CuentaId, requireImportPermission: true, cancellationToken); + EnsureNotPlazoFijoForFormattedImport(cuenta); var normalizedMap = NormalizeMap(request.Mapeo); var (rows, separator) = ParseRows(request.RawData, request.Separador); @@ -146,6 +152,7 @@ public async Task ValidarAsync(Guid usuarioId, strin public async Task ConfirmarAsync(Guid usuarioId, string rol, ImportacionConfirmarRequest request, HttpContext httpContext, CancellationToken cancellationToken) { var cuenta = await EnsureCuentaPermitidaAsync(usuarioId, rol, request.CuentaId, requireImportPermission: true, cancellationToken); + EnsureNotPlazoFijoForFormattedImport(cuenta); var normalizedMap = NormalizeMap(request.Mapeo); var (rows, separator) = ParseRows(request.RawData, request.Separador); var validationRows = ValidateRows(rows, normalizedMap); @@ -298,6 +305,109 @@ public async Task ConfirmarAsync(Guid usuarioId, s }; } + public async Task RegistrarMovimientoPlazoFijoAsync(Guid usuarioId, string rol, ImportacionPlazoFijoMovimientoRequest request, HttpContext httpContext, CancellationToken cancellationToken) + { + var cuenta = await EnsureCuentaPermitidaAsync(usuarioId, rol, request.CuentaId, requireImportPermission: true, cancellationToken); + if (ResolveTipoCuenta(cuenta) != TipoCuenta.PLAZO_FIJO) + { + throw new ImportacionException("Esta operacion solo aplica a cuentas de plazo fijo", StatusCodes.Status400BadRequest); + } + + if (request.Monto <= 0) + { + throw new ImportacionException("El monto debe ser mayor que cero", StatusCodes.Status400BadRequest); + } + + if (request.Fecha == default) + { + throw new ImportacionException("La fecha es obligatoria", StatusCodes.Status400BadRequest); + } + + var tipo = NormalizeMovimientoPlazoFijo(request.TipoMovimiento); + var signedAmount = tipo == "EGRESO" ? -request.Monto : request.Monto; + var now = DateTime.UtcNow; + + var isRelational = _dbContext.Database.IsRelational(); + IDbContextTransaction? tx = null; + if (isRelational) + { + tx = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + var lockBytes = cuenta.Id.ToByteArray(); + var lockKey = BitConverter.ToInt64(lockBytes, 0) ^ BitConverter.ToInt64(lockBytes, 8); + await _dbContext.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock({0})", [lockKey], cancellationToken); + } + + var latest = await _dbContext.Extractos + .IgnoreQueryFilters() + .Where(e => e.CuentaId == cuenta.Id) + .OrderByDescending(e => e.Fecha) + .ThenByDescending(e => e.FilaNumero) + .Select(e => new { e.Saldo }) + .FirstOrDefaultAsync(cancellationToken); + + var maxFila = await _dbContext.Extractos + .IgnoreQueryFilters() + .Where(e => e.CuentaId == cuenta.Id) + .Select(e => (int?)e.FilaNumero) + .MaxAsync(cancellationToken) ?? 0; + + var saldoAnterior = latest?.Saldo ?? 0m; + var saldoActual = saldoAnterior + signedAmount; + var concepto = NormalizeOptionalText(request.Concepto) + ?? (tipo == "EGRESO" ? "Salida plazo fijo" : "Entrada plazo fijo"); + + var extracto = new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuenta.Id, + Fecha = request.Fecha, + Concepto = concepto, + Monto = signedAmount, + Saldo = saldoActual, + FilaNumero = maxFila + 1, + UsuarioCreacionId = usuarioId, + FechaCreacion = now + }; + + _dbContext.Extractos.Add(extracto); + try + { + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (IsFilaNumeroUniqueViolation(ex)) + { + throw new ImportacionException( + "Otra operacion asigno el mismo numero de fila. Vuelve a intentarlo.", + StatusCodes.Status409Conflict); + } + + var auditDetails = JsonSerializer.Serialize(new + { + cuenta_id = cuenta.Id, + cuenta = cuenta.Nombre, + tipo_movimiento = tipo, + monto = signedAmount, + saldo_anterior = saldoAnterior, + saldo_actual = saldoActual + }); + await _auditService.LogAsync(usuarioId, "importacion_plazo_fijo_movimiento", "EXTRACTOS", cuenta.Id, httpContext, auditDetails, cancellationToken); + + if (tx is not null) + { + await tx.CommitAsync(cancellationToken); + await tx.DisposeAsync(); + } + + return new ImportacionPlazoFijoMovimientoResponse + { + ExtractoId = extracto.Id, + FilaNumero = extracto.FilaNumero, + Monto = Decimal.Round(signedAmount, 2), + SaldoAnterior = Decimal.Round(saldoAnterior, 2), + SaldoActual = Decimal.Round(saldoActual, 2) + }; + } + private async Task EnsureCuentaPermitidaAsync(Guid usuarioId, string rol, Guid cuentaId, bool requireImportPermission, CancellationToken cancellationToken) { var cuenta = await _dbContext.Cuentas @@ -339,6 +449,36 @@ private static bool IsFilaNumeroUniqueViolation(DbUpdateException exception) string.Equals(postgresException.ConstraintName, "ix_extractos_cuenta_id_fila_numero", StringComparison.OrdinalIgnoreCase); } + private static string? NormalizeOptionalText(string? value) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + private static void EnsureNotPlazoFijoForFormattedImport(Cuenta cuenta) + { + if (ResolveTipoCuenta(cuenta) == TipoCuenta.PLAZO_FIJO) + { + throw new ImportacionException("Las cuentas de plazo fijo solo permiten anadir o sacar dinero sin formato de importacion", StatusCodes.Status400BadRequest); + } + } + + private static TipoCuenta ResolveTipoCuenta(Cuenta cuenta) => + cuenta.TipoCuenta == TipoCuenta.NORMAL && cuenta.EsEfectivo + ? TipoCuenta.EFECTIVO + : cuenta.TipoCuenta; + + private static string NormalizeMovimientoPlazoFijo(string? raw) + { + var normalized = (raw ?? string.Empty).Trim().ToUpperInvariant(); + return normalized switch + { + "INGRESO" or "ENTRADA" or "ADD" or "ANADIR" => "INGRESO", + "EGRESO" or "SALIDA" or "REMOVE" or "SACAR" => "EGRESO", + _ => throw new ImportacionException("Tipo de movimiento invalido", StatusCodes.Status400BadRequest) + }; + } + private static MapeoColumnasRequest NormalizeMap(MapeoColumnasRequest? map) { if (map is null) @@ -569,23 +709,43 @@ private static List ParseDelimitedLine(string line, char separator) private static List ValidateRows(IReadOnlyList rows, MapeoColumnasRequest map) { var validation = new List(rows.Count); + string? lastValidDateRaw = null; + string? lastValidSaldoRaw = null; for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) { var lineNumber = rowIndex + 1; var row = rows[rowIndex]; var errors = new List(); + var warnings = new List(); var data = new Dictionary(StringComparer.OrdinalIgnoreCase); data["fecha"] = GetCell(row, map.Fecha); data["concepto"] = GetCell(row, map.Concepto); data["saldo"] = GetCell(row, map.Saldo); + var hasConcept = !string.IsNullOrWhiteSpace(data["concepto"]); + var allowIncompleteConceptRow = false; if (map.TipoMonto is "dos_columnas" or "tres_columnas") { data["ingreso"] = GetCell(row, map.Ingreso!.Value); data["egreso"] = GetCell(row, map.Egreso!.Value); - if (TryBuildSignedMonto(data["ingreso"], data["egreso"], errors, out var signedMonto)) + allowIncompleteConceptRow = + hasConcept && + string.IsNullOrWhiteSpace(data["fecha"]) && + string.IsNullOrWhiteSpace(data["saldo"]) && + IsBlankAmountRow(data["ingreso"], data["egreso"]); + + if (allowIncompleteConceptRow) + { + data["monto"] = "0"; + warnings.Add("Importe vacio; se importara como 0."); + if (map.TipoMonto == "tres_columnas") + { + data["monto_banco"] = GetCell(row, map.Monto!.Value); + } + } + else if (TryBuildSignedMonto(data["ingreso"], data["egreso"], errors, out var signedMonto)) { data["monto"] = signedMonto.ToString(CultureInfo.InvariantCulture); if (map.TipoMonto == "tres_columnas") @@ -606,7 +766,18 @@ private static List ValidateRows(IReadOnlyList else { data["monto"] = GetCell(row, map.Monto!.Value); - if (!TryParseDecimalSmart(data["monto"], out _)) + allowIncompleteConceptRow = + hasConcept && + string.IsNullOrWhiteSpace(data["fecha"]) && + string.IsNullOrWhiteSpace(data["saldo"]) && + string.IsNullOrWhiteSpace(data["monto"]); + + if (allowIncompleteConceptRow) + { + data["monto"] = "0"; + warnings.Add("Monto vacio; se importara como 0."); + } + else if (!TryParseDecimalSmart(data["monto"], out _)) { errors.Add(BuildDecimalError("Monto", data["monto"])); } @@ -619,12 +790,36 @@ private static List ValidateRows(IReadOnlyList if (!ParseDate(data["fecha"], out var dateError, out _)) { - errors.Add(dateError!); + if (allowIncompleteConceptRow && lastValidDateRaw is not null) + { + data["fecha"] = lastValidDateRaw; + warnings.Add($"Fecha vacia; se usara la fecha anterior ({lastValidDateRaw})."); + } + else + { + errors.Add(dateError!); + } + } + else + { + lastValidDateRaw = data["fecha"]; } if (!TryParseDecimalSmart(data["saldo"], out _)) { - errors.Add(BuildDecimalError("Saldo", data["saldo"])); + if (allowIncompleteConceptRow && lastValidSaldoRaw is not null) + { + data["saldo"] = lastValidSaldoRaw; + warnings.Add($"Saldo vacio; se usara el saldo anterior ({lastValidSaldoRaw})."); + } + else + { + errors.Add(BuildDecimalError("Saldo", data["saldo"])); + } + } + else + { + lastValidSaldoRaw = data["saldo"]; } validation.Add(new FilaValidacionResponse @@ -632,13 +827,17 @@ private static List ValidateRows(IReadOnlyList Indice = lineNumber, Valida = errors.Count == 0, Datos = data, - Errores = errors + Errores = errors, + Advertencias = warnings }); } return validation; } + private static bool IsBlankAmountRow(params string?[] values) => + values.All(string.IsNullOrWhiteSpace); + private static bool TryBuildSignedMonto(string? rawIngreso, string? rawEgreso, List errors, out decimal monto) { monto = 0m; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/PlazoFijoService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/PlazoFijoService.cs new file mode 100644 index 0000000..6db7dcf --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/PlazoFijoService.cs @@ -0,0 +1,240 @@ +using System.Text.Json; +using GestionCaja.API.Constants; +using GestionCaja.API.Data; +using GestionCaja.API.DTOs; +using GestionCaja.API.Models; +using Microsoft.EntityFrameworkCore; + +namespace GestionCaja.API.Services; + +public interface IPlazoFijoService +{ + Task ProcesarVencimientosAsync(DateOnly hoy, CancellationToken cancellationToken); + Task RenovarAsync(Guid cuentaId, RenovarPlazoFijoRequest request, Guid? actorUserId, HttpContext httpContext, CancellationToken cancellationToken); +} + +public sealed class PlazoFijoService : IPlazoFijoService +{ + private readonly AppDbContext _dbContext; + private readonly IEmailService _emailService; + private readonly IAuditService _auditService; + private readonly ILogger _logger; + + public PlazoFijoService(AppDbContext dbContext, IEmailService emailService, IAuditService auditService, ILogger logger) + { + _dbContext = dbContext; + _emailService = emailService; + _auditService = auditService; + _logger = logger; + } + + public async Task ProcesarVencimientosAsync(DateOnly hoy, CancellationToken cancellationToken) + { + var plazos = await ( + from plazo in _dbContext.PlazosFijos + join cuenta in _dbContext.Cuentas on plazo.CuentaId equals cuenta.Id + join titular in _dbContext.Titulares on cuenta.TitularId equals titular.Id + where cuenta.Activa && plazo.Estado != EstadoPlazoFijo.CANCELADO && plazo.Estado != EstadoPlazoFijo.RENOVADO + select new { Plazo = plazo, Cuenta = cuenta, Titular = titular }) + .ToListAsync(cancellationToken); + + var cambios = 0; + foreach (var item in plazos) + { + var nuevoEstado = ResolveEstado(item.Plazo.FechaVencimiento, hoy); + if (nuevoEstado is null) + { + continue; + } + + var debeNotificar = item.Plazo.FechaUltimaNotificacion != hoy && + (item.Plazo.Estado != nuevoEstado.Value || nuevoEstado.Value == EstadoPlazoFijo.VENCIDO); + + item.Plazo.Estado = nuevoEstado.Value; + item.Plazo.FechaModificacion = DateTime.UtcNow; + if (debeNotificar) + { + item.Plazo.FechaUltimaNotificacion = hoy; + _dbContext.NotificacionesAdmin.Add(new NotificacionAdmin + { + Id = Guid.NewGuid(), + Tipo = "PLAZO_FIJO", + Mensaje = BuildNotificationMessage(item.Cuenta.Nombre, item.Plazo.FechaVencimiento, nuevoEstado.Value), + Leida = false, + Fecha = DateTime.UtcNow, + DetallesJson = JsonSerializer.Serialize(new + { + cuenta_id = item.Cuenta.Id, + cuenta_nombre = item.Cuenta.Nombre, + titular_id = item.Titular.Id, + titular_nombre = item.Titular.Nombre, + fecha_vencimiento = item.Plazo.FechaVencimiento, + estado = nuevoEstado.Value.ToString() + }) + }); + } + + await _auditService.LogAsync( + null, + nuevoEstado.Value == EstadoPlazoFijo.VENCIDO ? AuditActions.PlazoFijoVencido : AuditActions.PlazoFijoProximoVencer, + "PLAZOS_FIJOS", + item.Plazo.Id, + ipAddress: null, + detallesJson: JsonSerializer.Serialize(new + { + cuenta_id = item.Cuenta.Id, + fecha_vencimiento = item.Plazo.FechaVencimiento, + estado = nuevoEstado.Value.ToString() + }), + cancellationToken); + + if (debeNotificar) + { + await TrySendEmailAsync(item.Cuenta, item.Titular, item.Plazo.FechaVencimiento, nuevoEstado.Value, cancellationToken); + } + + cambios++; + } + + await _dbContext.SaveChangesAsync(cancellationToken); + return cambios; + } + + public async Task RenovarAsync(Guid cuentaId, RenovarPlazoFijoRequest request, Guid? actorUserId, HttpContext httpContext, CancellationToken cancellationToken) + { + if (request.NuevaFechaVencimiento < request.NuevaFechaInicio) + { + throw new InvalidOperationException("La fecha de vencimiento no puede ser anterior a la fecha de inicio"); + } + + if (request.InteresPrevisto.HasValue && request.InteresPrevisto.Value < 0) + { + throw new InvalidOperationException("El interes previsto no puede ser negativo"); + } + + var plazo = await _dbContext.PlazosFijos + .Include(p => p.Cuenta) + .FirstOrDefaultAsync(p => p.CuentaId == cuentaId, cancellationToken); + + if (plazo?.Cuenta is null || plazo.Cuenta.TipoCuenta != TipoCuenta.PLAZO_FIJO) + { + throw new KeyNotFoundException("Cuenta de plazo fijo no encontrada"); + } + + if (plazo.Estado == EstadoPlazoFijo.CANCELADO) + { + throw new InvalidOperationException("No se puede renovar un plazo fijo cancelado"); + } + + var before = new + { + plazo.FechaInicio, + plazo.FechaVencimiento, + plazo.InteresPrevisto, + plazo.Renovable, + Estado = plazo.Estado.ToString(), + plazo.Notas + }; + + plazo.FechaInicio = request.NuevaFechaInicio; + plazo.FechaVencimiento = request.NuevaFechaVencimiento; + plazo.InteresPrevisto = request.InteresPrevisto; + plazo.Renovable = request.Renovable; + plazo.Notas = NormalizeOptionalText(request.Notas); + plazo.Estado = EstadoPlazoFijo.ACTIVO; + plazo.FechaUltimaNotificacion = null; + plazo.FechaRenovacion = DateOnly.FromDateTime(DateTime.UtcNow.Date); + plazo.FechaModificacion = DateTime.UtcNow; + + await _dbContext.SaveChangesAsync(cancellationToken); + + await _auditService.LogAsync( + actorUserId, + AuditActions.PlazoFijoRenovado, + "PLAZOS_FIJOS", + plazo.Id, + httpContext, + JsonSerializer.Serialize(new { before, after = request }), + cancellationToken); + + return await BuildResponseAsync(plazo.CuentaId, cancellationToken) + ?? throw new KeyNotFoundException("Cuenta de plazo fijo no encontrada"); + } + + private async Task TrySendEmailAsync(Cuenta cuenta, Titular titular, DateOnly fechaVencimiento, EstadoPlazoFijo estado, CancellationToken cancellationToken) + { + var recipients = await _dbContext.Usuarios + .Where(u => u.Activo && u.Rol == RolUsuario.ADMIN) + .Select(u => u.Email.ToLower()) + .ToListAsync(cancellationToken); + + if (recipients.Count == 0) + { + return; + } + + try + { + await _emailService.SendPlazoFijoVencimientoAsync( + recipients, + titular.Nombre, + cuenta.Nombre, + cuenta.Id, + fechaVencimiento, + estado, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fallo al enviar email de plazo fijo. cuenta_id={CuentaId}", cuenta.Id); + } + } + + private static EstadoPlazoFijo? ResolveEstado(DateOnly fechaVencimiento, DateOnly hoy) + { + if (fechaVencimiento <= hoy) + { + return EstadoPlazoFijo.VENCIDO; + } + + return fechaVencimiento.DayNumber - hoy.DayNumber <= 14 + ? EstadoPlazoFijo.PROXIMO_VENCER + : null; + } + + private static string BuildNotificationMessage(string cuentaNombre, DateOnly fechaVencimiento, EstadoPlazoFijo estado) => + estado == EstadoPlazoFijo.VENCIDO + ? $"El plazo fijo {cuentaNombre} vencio el {fechaVencimiento:yyyy-MM-dd}." + : $"El plazo fijo {cuentaNombre} vence el {fechaVencimiento:yyyy-MM-dd}."; + + private async Task BuildResponseAsync(Guid cuentaId, CancellationToken cancellationToken) + { + return await ( + from plazo in _dbContext.PlazosFijos + join refCuenta in _dbContext.Cuentas on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin + from cuentaReferencia in refJoin.DefaultIfEmpty() + where plazo.CuentaId == cuentaId + select new PlazoFijoResponse + { + Id = plazo.Id, + CuentaId = plazo.CuentaId, + CuentaReferenciaId = plazo.CuentaReferenciaId, + CuentaReferenciaNombre = cuentaReferencia != null ? cuentaReferencia.Nombre : null, + FechaInicio = plazo.FechaInicio, + FechaVencimiento = plazo.FechaVencimiento, + InteresPrevisto = plazo.InteresPrevisto, + Renovable = plazo.Renovable, + Estado = plazo.Estado.ToString(), + FechaUltimaNotificacion = plazo.FechaUltimaNotificacion, + FechaRenovacion = plazo.FechaRenovacion, + Notas = plazo.Notas + }) + .FirstOrDefaultAsync(cancellationToken); + } + + private static string? NormalizeOptionalText(string? value) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs index b1d8eb1..c15465d 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs @@ -62,6 +62,7 @@ public async Task GetScopeAsync(ClaimsPrincipal user, Cancellat { p.TitularId, p.CuentaId, + p.PuedeVerCuentas, p.PuedeAgregarLineas, p.PuedeEditarLineas, p.PuedeEliminarLineas, @@ -84,7 +85,7 @@ public async Task GetScopeAsync(ClaimsPrincipal user, Cancellat var hasGlobalAccess = permisos.Any(p => p.TitularId is null && p.CuentaId is null && - GrantsDataAccess(p.PuedeAgregarLineas, p.PuedeEditarLineas, p.PuedeEliminarLineas, p.PuedeImportar)); + GrantsAccountAccess(p.PuedeVerCuentas, p.PuedeAgregarLineas, p.PuedeEditarLineas, p.PuedeEliminarLineas, p.PuedeImportar)); return new UserAccessScope { @@ -188,6 +189,6 @@ private static bool TryGetUserId(ClaimsPrincipal user, out Guid userId) return Guid.TryParse(raw, out userId); } - private static bool GrantsDataAccess(bool canAdd, bool canEdit, bool canDelete, bool canImport) => - canAdd || canEdit || canDelete || canImport; + private static bool GrantsAccountAccess(bool canViewAccounts, bool canAdd, bool canEdit, bool canDelete, bool canImport) => + canViewAccounts || canAdd || canEdit || canDelete || canImport; } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/AlertaServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/AlertaServiceTests.cs index 06fad76..8002d68 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/AlertaServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/AlertaServiceTests.cs @@ -214,6 +214,45 @@ public async Task GetAlertasActivasAsync_Should_Ignore_InactiveAccounts_And_Appl result[0].SaldoActual.Should().Be(90m); } + [Fact] + public async Task GetAlertasActivasAsync_Should_Apply_Account_Then_TipoTitular_Then_Global_Priority() + { + await using var db = BuildDbContext(); + var titularAutonomoId = Guid.NewGuid(); + var titularEmpresaId = Guid.NewGuid(); + var cuentaAutonomoId = Guid.NewGuid(); + var cuentaEmpresaId = Guid.NewGuid(); + var alertaGlobalId = Guid.NewGuid(); + var alertaTipoId = Guid.NewGuid(); + var alertaCuentaId = Guid.NewGuid(); + + db.Titulares.AddRange( + new Titular { Id = titularAutonomoId, Nombre = "Autonomo", Tipo = TipoTitular.AUTONOMO }, + new Titular { Id = titularEmpresaId, Nombre = "Empresa", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.AddRange( + new Cuenta { Id = cuentaAutonomoId, TitularId = titularAutonomoId, Nombre = "Cuenta Autonomo", Divisa = "EUR", Activa = true }, + new Cuenta { Id = cuentaEmpresaId, TitularId = titularEmpresaId, Nombre = "Cuenta Empresa", Divisa = "EUR", Activa = true }); + db.Extractos.AddRange( + new Extracto { Id = Guid.NewGuid(), CuentaId = cuentaAutonomoId, Fecha = DateOnly.FromDateTime(DateTime.UtcNow), Monto = -1m, Saldo = 75m, FilaNumero = 1 }, + new Extracto { Id = Guid.NewGuid(), CuentaId = cuentaEmpresaId, Fecha = DateOnly.FromDateTime(DateTime.UtcNow), Monto = -1m, Saldo = 40m, FilaNumero = 1 }); + db.AlertasSaldo.AddRange( + new AlertaSaldo { Id = alertaGlobalId, SaldoMinimo = 50m, Activa = true, FechaCreacion = DateTime.UtcNow.AddMinutes(-3) }, + new AlertaSaldo { Id = alertaTipoId, TipoTitular = TipoTitular.AUTONOMO, SaldoMinimo = 100m, Activa = true, FechaCreacion = DateTime.UtcNow.AddMinutes(-2) }, + new AlertaSaldo { Id = alertaCuentaId, CuentaId = cuentaEmpresaId, SaldoMinimo = 30m, Activa = true, FechaCreacion = DateTime.UtcNow.AddMinutes(-1) }); + await db.SaveChangesAsync(); + + var sut = new AlertaService(db, new RecordingEmailService(), new RecordingAuditService(), NullLogger.Instance); + + var result = await sut.GetAlertasActivasAsync( + new UserAccessScope { UserId = Guid.NewGuid(), IsAdmin = true, HasPermissions = true, HasGlobalAccess = true }, + CancellationToken.None); + + result.Should().ContainSingle(); + result.Single().CuentaId.Should().Be(cuentaAutonomoId); + result.Single().AlertaId.Should().Be(alertaTipoId); + result.Single().TipoTitular.Should().Be(nameof(TipoTitular.AUTONOMO)); + } + private sealed class RecordingEmailService : IEmailService { public List Messages { get; } = []; @@ -246,6 +285,18 @@ public Task SendTestEmailAsync(string recipient, CancellationToken cancellationT { return Task.CompletedTask; } + + public Task SendPlazoFijoVencimientoAsync( + IReadOnlyList recipients, + string titularNombre, + string cuentaNombre, + Guid cuentaId, + DateOnly fechaVencimiento, + EstadoPlazoFijo estado, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } } private sealed record EmailMessage( diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs index f336de3..675aa2b 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs @@ -169,5 +169,15 @@ public Task SendSaldoBajoAlertAsync( public Task SendTestEmailAsync(string recipient, CancellationToken cancellationToken) => Task.CompletedTask; + + public Task SendPlazoFijoVencimientoAsync( + IReadOnlyList recipients, + string titularNombre, + string cuentaNombre, + Guid cuentaId, + DateOnly fechaVencimiento, + EstadoPlazoFijo estado, + CancellationToken cancellationToken) + => Task.CompletedTask; } } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs index 4b124ab..f470bce 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs @@ -80,14 +80,71 @@ public async Task Resumen_Should_Anchor_Selected_Period_To_Latest_Movement() var ok = result.Should().BeOfType().Subject; var summary = ok.Value.Should().BeOfType().Subject; + summary.CuentaId.Should().Be(cuentaId); + summary.CuentaNombre.Should().Be("Cuenta Resumen"); + summary.TitularId.Should().Be(titularId); + summary.TitularNombre.Should().Be("Titular Resumen"); + summary.TipoCuenta.Should().Be(nameof(TipoCuenta.NORMAL)); summary.SaldoActual.Should().Be(1069m); summary.IngresosMes.Should().Be(100m); summary.EgresosMes.Should().Be(30m); } + [Fact] + public async Task Resumen_Should_Expose_PlazoFijo_Metadata() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + var referenciaId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "admin.cuenta.plazo.resumen@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Cuenta Plazo Resumen", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Plazo", Tipo = TipoTitular.AUTONOMO }); + db.Cuentas.AddRange( + new Cuenta { Id = referenciaId, TitularId = titularId, Nombre = "Cuenta Referencia", Divisa = "EUR", TipoCuenta = TipoCuenta.NORMAL, Activa = true }, + new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Deposito Resumen", Divisa = "EUR", TipoCuenta = TipoCuenta.PLAZO_FIJO, Activa = true, Notas = "Notas cuenta" }); + db.PlazosFijos.Add(new PlazoFijo + { + Id = Guid.NewGuid(), + CuentaId = cuentaId, + CuentaReferenciaId = referenciaId, + FechaInicio = new DateOnly(2026, 4, 25), + FechaVencimiento = new DateOnly(2026, 10, 25), + InteresPrevisto = 150m, + Renovable = true, + Estado = EstadoPlazoFijo.PROXIMO_VENCER, + Notas = "Notas plazo", + FechaCreacion = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId); + + var result = await controller.Resumen(cuentaId, "1m", CancellationToken.None); + + var summary = result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + summary.TipoCuenta.Should().Be(nameof(TipoCuenta.PLAZO_FIJO)); + summary.PlazoFijo.Should().NotBeNull(); + summary.PlazoFijo!.CuentaReferenciaNombre.Should().Be("Cuenta Referencia"); + summary.PlazoFijo.FechaVencimiento.Should().Be(new DateOnly(2026, 10, 25)); + summary.PlazoFijo.Estado.Should().Be(nameof(EstadoPlazoFijo.PROXIMO_VENCER)); + summary.Notas.Should().Be("Notas cuenta"); + } + private static CuentasController BuildController(AppDbContext db, Guid userId) { - var controller = new CuentasController(db, new UserAccessService(db), new AuditService(db)); + var controller = new CuentasController(db, new UserAccessService(db), new AuditService(db), new NoOpPlazoFijoService()); var identity = new ClaimsIdentity( [ new Claim(ClaimTypes.NameIdentifier, userId.ToString()), @@ -103,4 +160,104 @@ private static CuentasController BuildController(AppDbContext db, Guid userId) return controller; } + + [Fact] + public async Task Crear_Should_Create_PlazoFijo_With_Metadata() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var referenciaId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "admin.plazo@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Plazo", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Autonomo Uno", Tipo = TipoTitular.AUTONOMO }); + db.DivisasActivas.Add(new DivisaActiva { Codigo = "EUR", Activa = true, EsBase = true }); + db.Cuentas.Add(new Cuenta { Id = referenciaId, TitularId = titularId, Nombre = "Cuenta Referencia", Divisa = "EUR", Activa = true }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId); + + var result = await controller.Crear(new SaveCuentaRequest + { + TitularId = titularId, + Nombre = "Deposito 6 meses", + Divisa = "EUR", + TipoCuenta = TipoCuenta.PLAZO_FIJO, + PlazoFijo = new SavePlazoFijoRequest + { + FechaInicio = new DateOnly(2026, 4, 25), + FechaVencimiento = new DateOnly(2026, 10, 25), + InteresPrevisto = 120m, + Renovable = true, + CuentaReferenciaId = referenciaId, + Notas = "Renovar si compensa" + } + }, CancellationToken.None); + + result.Should().BeOfType(); + var cuenta = await db.Cuentas.SingleAsync(c => c.Nombre == "Deposito 6 meses"); + cuenta.TipoCuenta.Should().Be(TipoCuenta.PLAZO_FIJO); + cuenta.EsEfectivo.Should().BeFalse(); + cuenta.FormatoId.Should().BeNull(); + + var plazo = await db.PlazosFijos.SingleAsync(p => p.CuentaId == cuenta.Id); + plazo.FechaVencimiento.Should().Be(new DateOnly(2026, 10, 25)); + plazo.Estado.Should().Be(EstadoPlazoFijo.ACTIVO); + } + + [Fact] + public async Task Listar_Should_Filter_By_TipoTitular_And_TipoCuenta() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var empresaId = Guid.NewGuid(); + var autonomoId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "admin.filtros@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Filtros", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }); + db.Titulares.AddRange( + new Titular { Id = empresaId, Nombre = "Empresa", Tipo = TipoTitular.EMPRESA }, + new Titular { Id = autonomoId, Nombre = "Autonomo", Tipo = TipoTitular.AUTONOMO }); + db.Cuentas.AddRange( + new Cuenta { Id = Guid.NewGuid(), TitularId = empresaId, Nombre = "Banco Empresa", Divisa = "EUR", TipoCuenta = TipoCuenta.NORMAL, Activa = true }, + new Cuenta { Id = Guid.NewGuid(), TitularId = autonomoId, Nombre = "Deposito Autonomo", Divisa = "EUR", TipoCuenta = TipoCuenta.PLAZO_FIJO, Activa = true }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId); + + var result = await controller.Listar(tipoTitular: TipoTitular.AUTONOMO, tipoCuenta: TipoCuenta.PLAZO_FIJO, cancellationToken: CancellationToken.None); + + var page = result.Should().BeOfType().Subject.Value + .Should().BeOfType>().Subject; + page.Data.Should().ContainSingle(); + page.Data.Single().Nombre.Should().Be("Deposito Autonomo"); + page.Data.Single().TitularTipo.Should().Be(nameof(TipoTitular.AUTONOMO)); + page.Data.Single().TipoCuenta.Should().Be(nameof(TipoCuenta.PLAZO_FIJO)); + } + + private sealed class NoOpPlazoFijoService : IPlazoFijoService + { + public Task ProcesarVencimientosAsync(DateOnly hoy, CancellationToken cancellationToken) + => Task.FromResult(0); + + public Task RenovarAsync(Guid cuentaId, RenovarPlazoFijoRequest request, Guid? actorUserId, HttpContext httpContext, CancellationToken cancellationToken) + => Task.FromResult(new PlazoFijoResponse { CuentaId = cuentaId }); + } } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs index 82a5827..21fcef7 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs @@ -296,6 +296,63 @@ public async Task GetPrincipalAsync_Should_Prioritize_Active_Base_Currency_Over_ result.TotalConvertido.Should().Be(120m); } + [Fact] + public async Task GetSaldosDivisaAsync_Should_Separate_Disponible_And_Inmovilizado() + { + await using var db = BuildDbContext(); + var adminId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var normalId = Guid.NewGuid(); + var plazoId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = adminId, + Email = "admin.inmovilizado@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Inmovilizado", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }); + SeedDashboardConfig(db, adminId); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Mixto", Tipo = TipoTitular.AUTONOMO }); + db.Cuentas.AddRange( + new Cuenta { Id = normalId, TitularId = titularId, Nombre = "Operativa", Divisa = "EUR", TipoCuenta = TipoCuenta.NORMAL, Activa = true }, + new Cuenta { Id = plazoId, TitularId = titularId, Nombre = "Plazo", Divisa = "EUR", TipoCuenta = TipoCuenta.PLAZO_FIJO, Activa = true }); + db.PlazosFijos.Add(new PlazoFijo + { + Id = Guid.NewGuid(), + CuentaId = plazoId, + FechaInicio = DateOnly.FromDateTime(DateTime.UtcNow.Date), + FechaVencimiento = DateOnly.FromDateTime(DateTime.UtcNow.Date).AddDays(30), + InteresPrevisto = 12.5m, + Estado = EstadoPlazoFijo.ACTIVO + }); + db.Extractos.AddRange( + new Extracto { Id = Guid.NewGuid(), CuentaId = normalId, Fecha = DateOnly.FromDateTime(DateTime.UtcNow), Monto = 80m, Saldo = 80m, FilaNumero = 1 }, + new Extracto { Id = Guid.NewGuid(), CuentaId = plazoId, Fecha = DateOnly.FromDateTime(DateTime.UtcNow), Monto = 200m, Saldo = 200m, FilaNumero = 1 }); + await db.SaveChangesAsync(); + + var sut = BuildService(db); + + var divisas = await sut.GetSaldosDivisaAsync(adminId, "EUR", null, CancellationToken.None); + var principal = await sut.GetPrincipalAsync(adminId, "EUR", CancellationToken.None); + + divisas.Divisas.Should().ContainSingle(); + divisas.Divisas[0].SaldoDisponible.Should().Be(80m); + divisas.Divisas[0].SaldoInmovilizado.Should().Be(200m); + divisas.Divisas[0].SaldoTotal.Should().Be(280m); + principal.SaldosPorTitular.Should().ContainSingle(); + principal.SaldosPorTitular[0].TipoTitular.Should().Be(nameof(TipoTitular.AUTONOMO)); + principal.SaldosPorTitular[0].SaldoDisponibleConvertido.Should().Be(80m); + principal.SaldosPorTitular[0].SaldoInmovilizadoConvertido.Should().Be(200m); + principal.PlazosFijos.MontoTotalConvertido.Should().Be(200m); + principal.PlazosFijos.InteresesPrevistosConvertidos.Should().Be(12.5m); + principal.PlazosFijos.DiasHastaProximoVencimiento.Should().Be(30); + principal.PlazosFijos.TotalCuentas.Should().Be(1); + } + private sealed class StaticHttpClientFactory : IHttpClientFactory { private readonly HttpClient _client = new() diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs index 808347b..c883630 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs @@ -264,6 +264,73 @@ public async Task Listar_Should_Return_Empty_For_DashboardOnly_GlobalPermission( page.Data.Should().BeEmpty(); } + [Fact] + public async Task Listar_Should_Return_All_Rows_For_ViewAccounts_GlobalPermission() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularAId = Guid.NewGuid(); + var titularBId = Guid.NewGuid(); + var cuentaAId = Guid.NewGuid(); + var cuentaBId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "gerente.all-accounts@test.local", + PasswordHash = "hash", + NombreCompleto = "Gerente Todas Cuentas", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + db.Titulares.AddRange( + new Titular { Id = titularAId, Nombre = "Titular A", Tipo = TipoTitular.EMPRESA }, + new Titular { Id = titularBId, Nombre = "Titular B", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.AddRange( + new Cuenta { Id = cuentaAId, TitularId = titularAId, Nombre = "Cuenta A", Divisa = "EUR", Activa = true }, + new Cuenta { Id = cuentaBId, TitularId = titularBId, Nombre = "Cuenta B", Divisa = "USD", Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = null, + TitularId = null, + PuedeVerCuentas = true + }); + db.Extractos.AddRange( + new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuentaAId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Concepto = "Cuenta A", + Monto = 10m, + Saldo = 10m, + FilaNumero = 1 + }, + new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuentaBId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Concepto = "Cuenta B", + Monto = 20m, + Saldo = 20m, + FilaNumero = 1 + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId, RolUsuario.GERENTE); + + var result = await controller.Listar(ct: CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var page = ok.Value.Should().BeOfType>().Subject; + page.Total.Should().Be(2); + page.Data.Select(row => row.Concepto).Should().BeEquivalentTo("Cuenta A", "Cuenta B"); + } + [Fact] public async Task GetCuentasTitular_Should_Forbid_Unauthorized_Titular() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs index 1204e67..d485734 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs @@ -64,6 +64,34 @@ public async Task GetContextoAsync_Should_Respect_Titular_And_Cuenta_Scoped_Impo result.Cuentas.Select(c => c.Id).Should().NotContain(cuentaB2.Id); } + [Fact] + public async Task GetContextoAsync_Should_Return_TipoCuenta_For_PlazoFijo() + { + await using var db = BuildDbContext(); + + var titular = new Titular { Id = Guid.NewGuid(), Nombre = "Titular Plazo", Tipo = TipoTitular.EMPRESA }; + var cuenta = new Cuenta + { + Id = Guid.NewGuid(), + TitularId = titular.Id, + Nombre = "Deposito", + Divisa = "EUR", + TipoCuenta = TipoCuenta.PLAZO_FIJO, + Activa = true + }; + + db.Titulares.Add(titular); + db.Cuentas.Add(cuenta); + await db.SaveChangesAsync(); + + var service = new ImportacionService(db, new AuditService(db)); + + var result = await service.GetContextoAsync(Guid.NewGuid(), RolUsuario.ADMIN.ToString(), CancellationToken.None); + + result.Cuentas.Should().ContainSingle(); + result.Cuentas[0].TipoCuenta.Should().Be(nameof(TipoCuenta.PLAZO_FIJO)); + } + [Fact] public async Task GetContextoAsync_Should_Parse_SnakeCase_Mapeo_From_Active_Formato() { @@ -167,6 +195,99 @@ public async Task ValidarAsync_Should_Parse_All_Required_Date_Formats_And_ExtraC result.Filas.Should().OnlyContain(row => row.Datos.ContainsKey("extra:referencia")); } + [Fact] + public async Task ValidarAsync_Should_Reject_Formatted_Import_For_PlazoFijo() + { + await using var db = BuildDbContext(); + + var userId = Guid.NewGuid(); + var titular = new Titular { Id = Guid.NewGuid(), Nombre = "Titular Plazo", Tipo = TipoTitular.EMPRESA }; + var cuenta = new Cuenta { Id = Guid.NewGuid(), TitularId = titular.Id, Nombre = "Deposito", Divisa = "EUR", TipoCuenta = TipoCuenta.PLAZO_FIJO, Activa = true }; + + db.Titulares.Add(titular); + db.Cuentas.Add(cuenta); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuenta.Id, + PuedeImportar = true + }); + await db.SaveChangesAsync(); + + var service = new ImportacionService(db, new AuditService(db)); + var request = new ImportacionValidarRequest + { + CuentaId = cuenta.Id, + RawData = "01/04/2026\tMovimiento\t100\t100", + Separador = "tab", + Mapeo = DefaultMapeo() + }; + + var act = () => service.ValidarAsync(userId, RolUsuario.EMPLEADO.ToString(), request, CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + ex.Which.Message.Should().Contain("plazo fijo"); + } + + [Fact] + public async Task RegistrarMovimientoPlazoFijoAsync_Should_Create_Signed_Extracto_And_Update_Balance() + { + await using var db = BuildDbContext(); + + var userId = Guid.NewGuid(); + var titular = new Titular { Id = Guid.NewGuid(), Nombre = "Titular Plazo", Tipo = TipoTitular.EMPRESA }; + var cuenta = new Cuenta { Id = Guid.NewGuid(), TitularId = titular.Id, Nombre = "Deposito", Divisa = "EUR", TipoCuenta = TipoCuenta.PLAZO_FIJO, Activa = true }; + + db.Titulares.Add(titular); + db.Cuentas.Add(cuenta); + db.Extractos.Add(new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuenta.Id, + Fecha = new DateOnly(2026, 4, 1), + Concepto = "Apertura", + Monto = 1000m, + Saldo = 1000m, + FilaNumero = 1 + }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuenta.Id, + PuedeImportar = true + }); + await db.SaveChangesAsync(); + + var service = new ImportacionService(db, new AuditService(db)); + + var result = await service.RegistrarMovimientoPlazoFijoAsync( + userId, + RolUsuario.EMPLEADO.ToString(), + new ImportacionPlazoFijoMovimientoRequest + { + CuentaId = cuenta.Id, + TipoMovimiento = "EGRESO", + Fecha = new DateOnly(2026, 4, 2), + Monto = 150m, + Concepto = "Retirada parcial" + }, + new DefaultHttpContext(), + CancellationToken.None); + + result.Monto.Should().Be(-150m); + result.SaldoAnterior.Should().Be(1000m); + result.SaldoActual.Should().Be(850m); + + var imported = await db.Extractos.OrderBy(e => e.FilaNumero).LastAsync(); + imported.Monto.Should().Be(-150m); + imported.Saldo.Should().Be(850m); + imported.FilaNumero.Should().Be(2); + imported.Concepto.Should().Be("Retirada parcial"); + } + [Fact] public async Task ConfirmarAsync_Should_Normalize_Ingreso_Egreso_Columns_To_Signed_Monto() { @@ -658,6 +779,117 @@ public async Task ValidarAsync_Should_Return_Specific_Messages_For_Empty_And_Non result.Filas[1].Errores.Should().Contain("Saldo no numerico"); } + [Fact] + public async Task ValidarAsync_Should_Allow_Concept_Rows_With_Missing_Amount_Date_And_Balance_As_Warnings() + { + await using var db = BuildDbContext(); + + var userId = Guid.NewGuid(); + var titular = new Titular { Id = Guid.NewGuid(), Nombre = "Titular Import", Tipo = TipoTitular.EMPRESA }; + var cuenta = new Cuenta { Id = Guid.NewGuid(), TitularId = titular.Id, Nombre = "Cuenta Import", Divisa = "EUR", Activa = true }; + + db.Titulares.Add(titular); + db.Cuentas.Add(cuenta); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuenta.Id, + TitularId = titular.Id, + PuedeImportar = true + }); + await db.SaveChangesAsync(); + + var request = new ImportacionValidarRequest + { + CuentaId = cuenta.Id, + RawData = string.Join('\n', [ + "22/04/2026\tMovimiento completo\t100\t500", + "\tEGARARECYCLING\t\t" + ]), + Separador = "tab", + Mapeo = new MapeoColumnasRequest + { + Fecha = 0, + Concepto = 1, + Monto = 2, + Saldo = 3 + } + }; + + var service = new ImportacionService(db, new AuditService(db)); + var result = await service.ValidarAsync(userId, RolUsuario.EMPLEADO.ToString(), request, CancellationToken.None); + + result.FilasOk.Should().Be(2); + result.FilasError.Should().Be(0); + result.Filas[1].Valida.Should().BeTrue(); + result.Filas[1].Datos["fecha"].Should().Be("22/04/2026"); + result.Filas[1].Datos["monto"].Should().Be("0"); + result.Filas[1].Datos["saldo"].Should().Be("500"); + result.Filas[1].Advertencias.Should().BeEquivalentTo([ + "Monto vacio; se importara como 0.", + "Fecha vacia; se usara la fecha anterior (22/04/2026).", + "Saldo vacio; se usara el saldo anterior (500)." + ]); + } + + [Fact] + public async Task ConfirmarAsync_Should_Import_Concept_Rows_With_Warning_Fallbacks() + { + await using var db = BuildDbContext(); + + var userId = Guid.NewGuid(); + var titular = new Titular { Id = Guid.NewGuid(), Nombre = "Titular Import", Tipo = TipoTitular.EMPRESA }; + var cuenta = new Cuenta { Id = Guid.NewGuid(), TitularId = titular.Id, Nombre = "Cuenta Import", Divisa = "EUR", Activa = true }; + + db.Titulares.Add(titular); + db.Cuentas.Add(cuenta); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuenta.Id, + TitularId = titular.Id, + PuedeImportar = true + }); + await db.SaveChangesAsync(); + + var request = new ImportacionConfirmarRequest + { + CuentaId = cuenta.Id, + RawData = string.Join('\n', [ + "22/04/2026\tMovimiento completo\t100\t500", + "\tEGARARECYCLING\t\t" + ]), + Separador = "tab", + FilasAImportar = [1, 2], + Mapeo = new MapeoColumnasRequest + { + Fecha = 0, + Concepto = 1, + Monto = 2, + Saldo = 3 + } + }; + + var service = new ImportacionService(db, new AuditService(db)); + var result = await service.ConfirmarAsync( + userId, + RolUsuario.EMPLEADO.ToString(), + request, + new DefaultHttpContext(), + CancellationToken.None); + + result.FilasImportadas.Should().Be(2); + result.FilasConError.Should().Be(0); + + var imported = await db.Extractos.OrderBy(e => e.FilaNumero).ToListAsync(); + imported[1].Fecha.Should().Be(new DateOnly(2026, 4, 22)); + imported[1].Concepto.Should().Be("EGARARECYCLING"); + imported[1].Monto.Should().Be(0m); + imported[1].Saldo.Should().Be(500m); + } + [Fact] public async Task ConfirmarAsync_Should_Import_Only_Selected_Valid_Rows_And_Audit_The_Batch() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/PlazoFijoServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/PlazoFijoServiceTests.cs new file mode 100644 index 0000000..988a536 --- /dev/null +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/PlazoFijoServiceTests.cs @@ -0,0 +1,129 @@ +using FluentAssertions; +using GestionCaja.API.Data; +using GestionCaja.API.Models; +using GestionCaja.API.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GestionCaja.API.Tests; + +public sealed class PlazoFijoServiceTests +{ + private static AppDbContext BuildDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new AppDbContext(options); + } + + [Fact] + public async Task ProcesarVencimientosAsync_Should_Mark_ProximoVencer_At_14_Days() + { + await using var db = BuildDbContext(); + var adminId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + var plazoId = Guid.NewGuid(); + var hoy = new DateOnly(2026, 4, 25); + + db.Usuarios.Add(new Usuario + { + Id = adminId, + Email = "admin.plazos@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Plazos", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Empresa Plazo", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.Add(new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Plazo 14", Divisa = "EUR", TipoCuenta = TipoCuenta.PLAZO_FIJO, Activa = true }); + db.PlazosFijos.Add(new PlazoFijo + { + Id = plazoId, + CuentaId = cuentaId, + FechaInicio = hoy.AddMonths(-6), + FechaVencimiento = hoy.AddDays(14), + Renovable = true, + Estado = EstadoPlazoFijo.ACTIVO + }); + await db.SaveChangesAsync(); + + var sut = new PlazoFijoService( + db, + new RecordingEmailService(), + new AuditService(db), + NullLogger.Instance); + + var changes = await sut.ProcesarVencimientosAsync(hoy, CancellationToken.None); + + changes.Should().Be(1); + var plazo = await db.PlazosFijos.SingleAsync(p => p.Id == plazoId); + plazo.Estado.Should().Be(EstadoPlazoFijo.PROXIMO_VENCER); + plazo.FechaUltimaNotificacion.Should().Be(hoy); + (await db.NotificacionesAdmin.CountAsync(n => n.Tipo == "PLAZO_FIJO")).Should().Be(1); + } + + [Fact] + public async Task ProcesarVencimientosAsync_Should_Mark_Vencido_On_Due_Date() + { + await using var db = BuildDbContext(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + var plazoId = Guid.NewGuid(); + var hoy = new DateOnly(2026, 4, 25); + + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Empresa Vencida", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.Add(new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Plazo Hoy", Divisa = "EUR", TipoCuenta = TipoCuenta.PLAZO_FIJO, Activa = true }); + db.PlazosFijos.Add(new PlazoFijo + { + Id = plazoId, + CuentaId = cuentaId, + FechaInicio = hoy.AddMonths(-6), + FechaVencimiento = hoy, + Estado = EstadoPlazoFijo.ACTIVO + }); + await db.SaveChangesAsync(); + + var sut = new PlazoFijoService( + db, + new RecordingEmailService(), + new AuditService(db), + NullLogger.Instance); + + await sut.ProcesarVencimientosAsync(hoy, CancellationToken.None); + + var plazo = await db.PlazosFijos.SingleAsync(p => p.Id == plazoId); + plazo.Estado.Should().Be(EstadoPlazoFijo.VENCIDO); + } + + private sealed class RecordingEmailService : IEmailService + { + public Task SendSaldoBajoAlertAsync( + IReadOnlyList recipients, + string titularNombre, + string cuentaNombre, + Guid cuentaId, + string divisa, + decimal saldoActual, + decimal saldoMinimo, + string? conceptoUltimoMovimiento, + CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task SendPlazoFijoVencimientoAsync( + IReadOnlyList recipients, + string titularNombre, + string cuentaNombre, + Guid cuentaId, + DateOnly fechaVencimiento, + EstadoPlazoFijo estado, + CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task SendTestEmailAsync(string recipient, CancellationToken cancellationToken) + => Task.CompletedTask; + } +} diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs index cb2cb75..b0c0541 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs @@ -120,4 +120,38 @@ public async Task GetScopeAsync_Should_Not_Grant_Global_Data_Access_For_Dashboar scope.HasPermissions.Should().BeTrue(); scope.HasGlobalAccess.Should().BeFalse(); } + + [Fact] + public async Task GetScopeAsync_Should_Grant_Global_Access_For_ViewAccounts_GlobalPermission() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = null, + TitularId = null, + PuedeVerCuentas = true, + PuedeAgregarLineas = false, + PuedeEditarLineas = false, + PuedeEliminarLineas = false, + PuedeImportar = false, + PuedeVerDashboard = false + }); + await db.SaveChangesAsync(); + + var identity = new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim(ClaimTypes.Role, nameof(RolUsuario.GERENTE)) + ], "TestAuth"); + + var principal = new ClaimsPrincipal(identity); + var service = new UserAccessService(db); + var scope = await service.GetScopeAsync(principal, CancellationToken.None); + + scope.HasPermissions.Should().BeTrue(); + scope.HasGlobalAccess.Should().BeTrue(); + } } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs index 0a6aac2..c2fa46b 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs @@ -59,6 +59,7 @@ public async Task Crear_Should_Create_User_With_Emails_And_Permissions_And_Audit { new SavePermisoUsuarioRequest { + PuedeVerCuentas = true, PuedeAgregarLineas = true, PuedeEditarLineas = true, PuedeEliminarLineas = false, @@ -84,6 +85,7 @@ public async Task Crear_Should_Create_User_With_Emails_And_Permissions_And_Audit var permisos = await db.PermisosUsuario.Where(x => x.UsuarioId == created.Id).ToListAsync(); permisos.Should().HaveCount(1); + permisos[0].PuedeVerCuentas.Should().BeTrue(); permisos[0].PuedeEditarLineas.Should().BeTrue(); var auditRows = await db.Auditorias.Where(x => x.EntidadId == created.Id && x.TipoAccion == AuditActions.CreateUsuario).ToListAsync(); diff --git a/Atlas Balance/frontend/package-lock.json b/Atlas Balance/frontend/package-lock.json index 7ef249c..7a01e21 100644 --- a/Atlas Balance/frontend/package-lock.json +++ b/Atlas Balance/frontend/package-lock.json @@ -1,19 +1,21 @@ { "name": "atlas-balance-frontend", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "atlas-balance-frontend", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { + "@fontsource-variable/geist": "^5.2.8", "@tanstack/react-virtual": "^3.11.2", - "axios": "^1.7.9", + "axios": "^1.15.2", + "lucide-react": "^1.11.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.1", - "react-router-dom": "^6.28.0", + "react-router-dom": "^6.30.3", "recharts": "^2.13.3", "zustand": "^4.5.5" }, @@ -162,6 +164,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz", + "integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1053,9 +1064,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -2581,6 +2592,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", + "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/Atlas Balance/frontend/package.json b/Atlas Balance/frontend/package.json index 5102069..5bcc02b 100644 --- a/Atlas Balance/frontend/package.json +++ b/Atlas Balance/frontend/package.json @@ -1,8 +1,8 @@ { "name": "atlas-balance-frontend", "private": true, - "version": "1.3.0", - "appVersion": "V-01.03", + "version": "1.4.0", + "appVersion": "V-01.04", "type": "module", "scripts": { "dev": "vite", @@ -12,12 +12,14 @@ "test:e2e": "playwright test" }, "dependencies": { + "@fontsource-variable/geist": "^5.2.8", "@tanstack/react-virtual": "^3.11.2", - "axios": "^1.7.9", + "axios": "^1.15.2", + "lucide-react": "^1.11.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.1", - "react-router-dom": "^6.28.0", + "react-router-dom": "^6.30.3", "recharts": "^2.13.3", "zustand": "^4.5.5" }, diff --git a/Atlas Balance/frontend/src/components/common/AppSelect.tsx b/Atlas Balance/frontend/src/components/common/AppSelect.tsx index b73d0c6..b1355cd 100644 --- a/Atlas Balance/frontend/src/components/common/AppSelect.tsx +++ b/Atlas Balance/frontend/src/components/common/AppSelect.tsx @@ -85,6 +85,11 @@ export function AppSelect({ value, options, onChange, label, ariaLabel, classNam } } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setOpen((current) => !current); + } + if (event.key === 'Home' || event.key === 'End') { event.preventDefault(); const enabled = options.filter((option) => !option.disabled); diff --git a/Atlas Balance/frontend/src/components/common/ConfirmDialog.tsx b/Atlas Balance/frontend/src/components/common/ConfirmDialog.tsx index cf9ebdd..968f61b 100644 --- a/Atlas Balance/frontend/src/components/common/ConfirmDialog.tsx +++ b/Atlas Balance/frontend/src/components/common/ConfirmDialog.tsx @@ -35,6 +35,32 @@ export default function ConfirmDialog({ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && !loading) { onCancel(); + return; + } + + if (event.key !== 'Tab') { + return; + } + + const dialog = cancelButtonRef.current?.closest('[role="dialog"]'); + const focusable = Array.from( + dialog?.querySelectorAll( + 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' + ) ?? [] + ); + + if (focusable.length === 0) { + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); } }; diff --git a/Atlas Balance/frontend/src/components/common/DatePickerField.tsx b/Atlas Balance/frontend/src/components/common/DatePickerField.tsx new file mode 100644 index 0000000..add248c --- /dev/null +++ b/Atlas Balance/frontend/src/components/common/DatePickerField.tsx @@ -0,0 +1,254 @@ +import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'; + +interface DatePickerFieldProps { + value: string; + onChange: (value: string) => void; + ariaLabel: string; + disabled?: boolean; + placeholder?: string; + allowClear?: boolean; +} + +const WEEKDAYS = ['L', 'M', 'X', 'J', 'V', 'S', 'D']; +const MONTH_FORMATTER = new Intl.DateTimeFormat('es-ES', { month: 'long', year: 'numeric' }); +const DISPLAY_FORMATTER = new Intl.DateTimeFormat('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' }); +const FULL_DATE_FORMATTER = new Intl.DateTimeFormat('es-ES', { dateStyle: 'full' }); + +function parseIsoDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + + const [, year, month, day] = match; + const date = new Date(Number(year), Number(month) - 1, Number(day)); + return Number.isNaN(date.getTime()) ? null : date; +} + +function toIsoDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function startOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function addMonths(date: Date, amount: number): Date { + return new Date(date.getFullYear(), date.getMonth() + amount, 1); +} + +function sameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +function buildMonthDays(viewMonth: Date): Array { + const firstDay = startOfMonth(viewMonth); + const leadingBlanks = (firstDay.getDay() + 6) % 7; + const daysInMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 0).getDate(); + const days: Array = Array.from({ length: leadingBlanks }, () => null); + + for (let day = 1; day <= daysInMonth; day += 1) { + days.push(new Date(viewMonth.getFullYear(), viewMonth.getMonth(), day)); + } + + while (days.length % 7 !== 0) { + days.push(null); + } + + return days; +} + +export function DatePickerField({ + value, + onChange, + ariaLabel, + disabled = false, + placeholder = 'Selecciona fecha', + allowClear = true, +}: DatePickerFieldProps) { + const dialogId = useId(); + const selectedDate = useMemo(() => parseIsoDate(value), [value]); + const [isOpen, setIsOpen] = useState(false); + const [viewMonth, setViewMonth] = useState(() => startOfMonth(selectedDate ?? new Date())); + const [placement, setPlacement] = useState<'bottom' | 'top'>('bottom'); + const rootRef = useRef(null); + const popoverRef = useRef(null); + const today = useMemo(() => new Date(), []); + const days = useMemo(() => buildMonthDays(viewMonth), [viewMonth]); + const displayValue = selectedDate ? DISPLAY_FORMATTER.format(selectedDate) : placeholder; + + const focusDate = (date: Date) => { + const iso = toIsoDate(date); + window.setTimeout(() => { + rootRef.current?.querySelector(`[data-date="${iso}"]`)?.focus(); + }, 0); + }; + + const handleDayKeyDown = (event: ReactKeyboardEvent, date: Date) => { + const offsets: Record = { + ArrowLeft: -1, + ArrowRight: 1, + ArrowUp: -7, + ArrowDown: 7, + }; + + if (event.key in offsets) { + event.preventDefault(); + const nextDate = new Date(date.getFullYear(), date.getMonth(), date.getDate() + offsets[event.key]); + if (nextDate.getMonth() !== viewMonth.getMonth() || nextDate.getFullYear() !== viewMonth.getFullYear()) { + setViewMonth(startOfMonth(nextDate)); + } + focusDate(nextDate); + return; + } + + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + const weekStartIndex = days.findIndex((item) => item && sameDay(item, date)); + const rowStart = Math.floor(weekStartIndex / 7) * 7; + const row = days.slice(rowStart, rowStart + 7).filter((item): item is Date => item !== null); + const target = event.key === 'Home' ? row[0] : row[row.length - 1]; + if (target) focusDate(target); + } + }; + + useEffect(() => { + if (isOpen) { + setViewMonth(startOfMonth(selectedDate ?? new Date())); + } + }, [isOpen, selectedDate]); + + useLayoutEffect(() => { + if (!isOpen || !rootRef.current || !popoverRef.current) return; + + const triggerRect = rootRef.current.getBoundingClientRect(); + const popoverHeight = popoverRef.current.offsetHeight; + const spaceBelow = window.innerHeight - triggerRect.bottom; + const spaceAbove = triggerRect.top; + const shouldOpenUp = spaceBelow < popoverHeight + 16 && spaceAbove > spaceBelow; + + setPlacement(shouldOpenUp ? 'top' : 'bottom'); + }, [isOpen, viewMonth]); + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + + return ( +
+ + + {isOpen ? ( + + ) : null} +
+ ); +} diff --git a/Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx b/Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx index 18b73ea..8781b76 100644 --- a/Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx +++ b/Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx @@ -22,13 +22,27 @@ export function SaldoPorDivisaCard({ items, divisaPrincipal }: SaldoPorDivisaCar

{item.divisa}

- {formatCurrency(item.saldo, item.divisa)} + + {formatCurrency(item.saldo_total ?? item.saldo, item.divisa)} +

+ + Disponible:{' '} + + {formatCurrency(item.saldo_disponible ?? item.saldo, item.divisa)} + + + + Inmovilizado:{' '} + + {formatCurrency(item.saldo_inmovilizado ?? 0, item.divisa)} + + {item.divisa !== divisaPrincipal ? ( Equivale a{' '} - - {formatCurrency(item.saldo_convertido, divisaPrincipal)} + + {formatCurrency(item.saldo_total_convertido ?? item.saldo_convertido, divisaPrincipal)} ) : null} diff --git a/Atlas Balance/frontend/src/components/extractos/AddRowForm.tsx b/Atlas Balance/frontend/src/components/extractos/AddRowForm.tsx index 5878448..ae389cd 100644 --- a/Atlas Balance/frontend/src/components/extractos/AddRowForm.tsx +++ b/Atlas Balance/frontend/src/components/extractos/AddRowForm.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { AppSelect } from '@/components/common/AppSelect'; +import { DatePickerField } from '@/components/common/DatePickerField'; interface AddRowFormProps { cuentas: Array<{ id: string; nombre: string; titular_nombre: string; divisa: string }>; @@ -62,7 +63,7 @@ export default function AddRowForm({ cuentas, extraColumns, onCreate }: AddRowFo ]} onChange={setCuentaId} /> - setFecha(e.target.value)} /> + setConcepto(e.target.value)} /> setComentarios(e.target.value)} /> setMonto(e.target.value)} /> diff --git a/Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx b/Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx index 2377b5d..65a8e89 100644 --- a/Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx +++ b/Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx @@ -19,6 +19,7 @@ interface PermisoFormRow { key: string; titular_id: string; cuenta_id: string; + puede_ver_cuentas: boolean; puede_agregar_lineas: boolean; puede_editar_lineas: boolean; puede_eliminar_lineas: boolean; @@ -42,6 +43,7 @@ interface UserFormState { interface PermisoApiRow { titular_id?: string | null; cuenta_id?: string | null; + puede_ver_cuentas?: boolean; puede_agregar_lineas?: boolean; puede_editar_lineas?: boolean; puede_eliminar_lineas?: boolean; @@ -76,6 +78,7 @@ const emptyPermiso = (): PermisoFormRow => ({ key: crypto.randomUUID(), titular_id: '', cuenta_id: '', + puede_ver_cuentas: false, puede_agregar_lineas: false, puede_editar_lineas: false, puede_eliminar_lineas: false, @@ -120,6 +123,12 @@ const getPermisoScopeLabel = ( return 'Permiso global'; }; +const globalAccessPermiso = (): PermisoFormRow => ({ + ...emptyPermiso(), + puede_ver_cuentas: true, + puede_ver_dashboard: true, +}); + export default function UsuarioModal({ open, editingId, @@ -168,6 +177,7 @@ export default function UsuarioModal({ key: crypto.randomUUID(), titular_id: permiso.titular_id ?? '', cuenta_id: permiso.cuenta_id ?? '', + puede_ver_cuentas: permiso.puede_ver_cuentas ?? false, puede_agregar_lineas: permiso.puede_agregar_lineas ?? false, puede_editar_lineas: permiso.puede_editar_lineas ?? false, puede_eliminar_lineas: permiso.puede_eliminar_lineas ?? false, @@ -238,6 +248,7 @@ export default function UsuarioModal({ const columnasVisibles = parseColumns(permiso.columnas_visibles); const columnasEditables = parseColumns(permiso.columnas_editables); const hasFlags = + permiso.puede_ver_cuentas || permiso.puede_agregar_lineas || permiso.puede_editar_lineas || permiso.puede_eliminar_lineas || @@ -252,6 +263,7 @@ export default function UsuarioModal({ return { cuenta_id: permiso.cuenta_id || null, titular_id: permiso.titular_id || null, + puede_ver_cuentas: permiso.puede_ver_cuentas, puede_agregar_lineas: permiso.puede_agregar_lineas, puede_editar_lineas: permiso.puede_editar_lineas, puede_eliminar_lineas: permiso.puede_eliminar_lineas, @@ -279,6 +291,45 @@ export default function UsuarioModal({ })); }; + const grantAllAccounts = () => { + setForm((prev) => { + const globalIndex = prev.permisos.findIndex( + (permiso) => !permiso.titular_id && !permiso.cuenta_id + ); + + if (globalIndex >= 0) { + return { + ...prev, + permisos: prev.permisos.map((permiso, index) => + index === globalIndex + ? { ...permiso, puede_ver_cuentas: true, puede_ver_dashboard: true } + : permiso + ), + }; + } + + const hasOnlyBlankRow = + prev.permisos.length === 1 && + !prev.permisos[0].titular_id && + !prev.permisos[0].cuenta_id && + !prev.permisos[0].puede_ver_cuentas && + !prev.permisos[0].puede_agregar_lineas && + !prev.permisos[0].puede_editar_lineas && + !prev.permisos[0].puede_eliminar_lineas && + !prev.permisos[0].puede_importar && + !prev.permisos[0].puede_ver_dashboard && + !prev.permisos[0].columnas_visibles && + !prev.permisos[0].columnas_editables; + + return { + ...prev, + permisos: hasOnlyBlankRow + ? [globalAccessPermiso()] + : [globalAccessPermiso(), ...prev.permisos], + }; + }); + }; + const removePermiso = (key: string) => { setForm((prev) => { const next = prev.permisos.filter((permiso) => permiso.key !== key); @@ -489,11 +540,16 @@ export default function UsuarioModal({

Permisos

-

Globales, por titular o por cuenta específica.

+

Usa acceso global para leer todas las cuentas sin regalar edición.

+
+
+ +
-
@@ -580,6 +636,19 @@ export default function UsuarioModal({
+
+
+

{editingTipoAlertId ? 'Editar alerta por tipo de titular' : 'Nueva alerta por tipo de titular'}

+ +
+ setTipoForm({ ...tipoForm, tipo_titular: next as TipoTitular })} + /> + + +
+

Destinatarios

+ {usuarios.map((user) => ( + + ))} +
+
+ + {editingTipoAlertId ? ( + + ) : null} +
+ +
+

{editingCuentaAlertId ? 'Editar alerta por cuenta' : 'Nueva alerta por cuenta'}

@@ -445,6 +570,53 @@ export default function AlertasPage() {
+
+

Alertas por Tipo de Titular

+ {configLoading ?

Cargando alertas configuradas...

: null} + + {!configLoading && tipoAlerts.length === 0 ? ( +

No hay alertas por tipo configuradas.

+ ) : null} + + {tipoAlerts.length > 0 ? ( +
+ + + + + + + + + + + + + {tipoAlerts.map((item) => ( + + + + + + + + + ))} + +
AlcanceMínimoActivaDestinatariosÚltima alertaAcciones
Tipo: {item.tipo_titular}{item.saldo_minimo.toFixed(2)}{item.activa ? 'Sí' : 'No'}{item.destinatarios.map((d) => d.nombre_completo).join(', ') || '—'}{item.fecha_ultima_alerta ? new Date(item.fecha_ultima_alerta).toLocaleString() : '—'} +
+ + +
+
+
+ ) : null} +
+

Alertas por Cuenta

{configLoading ?

Cargando alertas configuradas...

: null} diff --git a/Atlas Balance/frontend/src/pages/AuditoriaPage.tsx b/Atlas Balance/frontend/src/pages/AuditoriaPage.tsx index 14f90ce..ab6901a 100644 --- a/Atlas Balance/frontend/src/pages/AuditoriaPage.tsx +++ b/Atlas Balance/frontend/src/pages/AuditoriaPage.tsx @@ -1,5 +1,6 @@ import { Fragment, useEffect, useMemo, useState } from 'react'; import { AppSelect } from '@/components/common/AppSelect'; +import { DatePickerField } from '@/components/common/DatePickerField'; import { EmptyState } from '@/components/common/EmptyState'; import { PageSizeSelect } from '@/components/common/PageSizeSelect'; import { SignedAmount } from '@/components/common/SignedAmount'; @@ -238,15 +239,29 @@ export default function AuditoriaPage() { }} /> - - - +
+ Fecha desde + { + setFechaDesde(next); + setPage(1); + }} + /> +
+ +
+ Fecha hasta + { + setFechaHasta(next); + setPage(1); + }} + /> +
- ) : null} + {isAdmin ? ( + + ) : null} + {isAdmin && item.tipo_cuenta === 'PLAZO_FIJO' ? ( + + ) : null} {isAdmin && !item.deleted_at ? (