Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Atlas Balance/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Atlas Balance/Actualizar Atlas Balance.cmd
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Actualizar-AtlasBalance.ps1" %*
exit /b %ERRORLEVEL%
2 changes: 1 addition & 1 deletion Atlas Balance/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Atlas Balance/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
<PropertyGroup>
<Company>Atlas Labs</Company>
<Product>Atlas Balance</Product>
<Version>1.3.0</Version>
<AssemblyVersion>1.3.0.0</AssemblyVersion>
<FileVersion>1.3.0.0</FileVersion>
<InformationalVersion>V-01.03</InformationalVersion>
<Version>1.4.0</Version>
<AssemblyVersion>1.4.0.0</AssemblyVersion>
<FileVersion>1.4.0.0</FileVersion>
<InformationalVersion>V-01.04</InformationalVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
</Project>
1 change: 1 addition & 0 deletions Atlas Balance/Instalar Atlas Balance.cmd
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Instalar-AtlasBalance.ps1" %*
exit /b %ERRORLEVEL%
40 changes: 37 additions & 3 deletions Atlas Balance/README_RELEASE.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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`.
2 changes: 1 addition & 1 deletion Atlas Balance/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
V-01.03
V-01.04
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -152,46 +154,42 @@ public async Task<IActionResult> Activas(CancellationToken cancellationToken)
[Authorize(Roles = "ADMIN")]
public async Task<IActionResult> 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);
Expand All @@ -202,6 +200,11 @@ public async Task<IActionResult> Crear([FromBody] SaveAlertaSaldoRequest request
[Authorize(Roles = "ADMIN")]
public async Task<IActionResult> 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)
{
Expand All @@ -210,46 +213,38 @@ public async Task<IActionResult> 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);
Expand All @@ -269,6 +264,7 @@ public async Task<IActionResult> 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)
Expand Down Expand Up @@ -299,6 +295,49 @@ private async Task<List<Guid>> ValidateDestinatariosAsync(IReadOnlyList<Guid> de
return unique.Except(existing).ToList();
}

private async Task<AlertaValidationError?> 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<Guid> destinatarioUsuarioIds, CancellationToken cancellationToken)
{
var existing = await _dbContext.AlertaDestinatarios
Expand Down Expand Up @@ -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);
}
Loading
Loading