Skip to content

Cotización Dólar - Documentación Técnica Backend

⚠️ DOCUMENTACIÓN RETROSPECTIVA - Generada a partir de código implementado el 2026-02-11

Módulo: Ventas Feature: Cotización Dólar Fecha: 2026-02-11 Documento de negocio: Cotización Dólar - Resource


Estado de Arquitectura

ARQUITECTURA LEGACY - Este recurso NO sigue la arquitectura 5-layer DDD moderna del sistema (API → Service → Domain → Model → DB). Está implementado con un script PHP procedimental sin separación de capas.

Capa EsperadaEstadoUbicación
Route (Slim)No existeN/A
ControllerNo existeN/A
ServiceNo existeN/A
DomainNo existeN/A
ModelNo existeN/A
ValidatorNo existeN/A
Script LegacyImplementadobackend/dolar.php

Patrón implementado: Script PHP procedimental con switch por REQUEST_METHOD


Implementación Actual

Script: backend/dolar.php

Ubicación: /var/www/Bautista/server/backend/dolar.php

Responsabilidades:

  • Manejo de endpoints GET y POST para cotización del dólar
  • Conexión directa a base de datos usando clase Database legacy
  • Lógica de negocio embebida (upsert pattern: SELECT + UPDATE/INSERT)
  • Autenticación via JWT payload global
  • Sin validación de input (delegada al proxy frontend)
  • Sin auditoría

Dependencias:

  • JwtHandler.php - Manejo de tokens JWT
  • exceptions.php - Excepciones personalizadas
  • exceptionHandler.php - Manejo global de excepciones
  • success.php - Helpers de respuesta exitosa
  • methods.php - Métodos utilitarios
  • validator.php - Validador (NO usado en este endpoint)
  • connect.php - Clase Database para conexión PDO

Flujo de autenticación:

  • Lee $GLOBALS['payload'] (inyectado por middleware JWT)
  • Extrae db (nombre de base de datos) y schema (schema PostgreSQL)
  • Crea instancia de Database($db, $schema) para configurar search_path

API Endpoints Implementados

GET /api/dolar

Responsabilidad: Obtener la cotización más reciente del dólar

Autenticación: JWT requerido (token en Authorization: Bearer)

Request:

  • Method: GET
  • Headers:
    • Authorization: Bearer {token}
    • X-Schema: {schema} (opcional, puede venir del JWT)
  • Query params: Ninguno
  • Body: N/A

Response (200 OK):

json
{
  "status": 200,
  "message": "Datos recibidos correctamente.",
  "data": {
    "id": 5,
    "fecha": "2026-02-10",
    "valor": 1250.5
  }
}

Si no hay registros:

json
{
  "status": 200,
  "message": "Datos recibidos correctamente.",
  "data": []
}

Lógica implementada:

  1. Conexión a base de datos usando schema del payload
  2. Query SQL: SELECT * FROM dolar ORDER BY fecha DESC LIMIT 1
  3. Ejecuta prepared statement
  4. Si encuentra 1 fila:
    • Extrae datos
    • Convierte valor a float
    • Retorna con status 200
  5. Si no encuentra filas:
    • Retorna array vacío con status 200

Códigos de status:

  • 200 OK - Operación exitosa (con o sin datos)
  • 401 Unauthorized - Token JWT inválido/expirado
  • 500 Internal Server Error - Error en BD o excepción no controlada

POST /api/dolar

Responsabilidad: Registrar o actualizar cotización del dólar para una fecha

Autenticación: JWT requerido

Request:

  • Method: POST
  • Headers:
    • Authorization: Bearer {token}
    • Content-Type: application/json
    • X-Schema: {schema} (opcional)
  • Body:
json
{
  "fecha": "2026-02-11",
  "valor": 1255.75
}

Request DTO:

CampoTipoObligatorioDescripción
fechastring (date)Fecha de la cotización (formato YYYY-MM-DD)
valornumberValor del dólar en pesos argentinos

Response (204 No Content):

Sin body (status code 204)

json
{
  "status": 204,
  "message": "Datos recibidos correctamente."
}

Lógica implementada (Upsert Pattern):

  1. Decodifica JSON del body
  2. Conexión a base de datos
  3. SELECT: Verifica si existe cotización para la fecha:
    • SELECT * FROM dolar WHERE fecha = :fecha
  4. Decisión basada en rowCount:
    • Si rowCount() > 0: Ejecuta UPDATE (sobreescribe valor existente)
      • UPDATE dolar SET valor = :valor WHERE fecha = :fecha
    • Si rowCount() === 0: Ejecuta INSERT (nueva cotización)
      • INSERT INTO dolar (fecha, valor) VALUES (:fecha, :valor)
  5. Ejecuta prepared statement con parámetros
  6. Si éxito:
    • Retorna status 204 (No Content)
  7. Si falla:
    • Lanza UpdateError("Error al actualizar la cotización del dólar")

Códigos de status:

  • 204 No Content - Operación exitosa (INSERT o UPDATE)
  • 401 Unauthorized - Token JWT inválido
  • 500 Internal Server Error - Error en BD o lógica

⚠️ Problemas identificados:

  1. Race condition potencial: El patrón SELECT + UPDATE/INSERT no es atómico. Dos requests simultáneos para la misma fecha podrían generar duplicados.

    • Solución recomendada: Usar INSERT ... ON CONFLICT (fecha) DO UPDATE SET valor = EXCLUDED.valor
    • Mitigación necesaria: Agregar constraint UNIQUE(fecha) en la tabla
  2. Sin validación de input: El script confía en validación del proxy frontend. Si se invoca directamente, no valida tipos ni obligatoriedad.

    • Solución recomendada: Implementar Validator middleware
  3. Sin auditoría: No registra quién modificó la cotización ni cuándo (falta campo updated_at y registro en tabla de auditoría).


Modelo de Datos

Tabla: dolar

Nivel de schema: EMPRESA y SUCURSAL (multi-tenant)

Definida en migración: migrations/tenancy/20240823200729_new_table_dolar.php

Descripción: Registro de cotización de dólar

CampoTipoConstraintsDescripción
idSERIALPRIMARY KEY, NOT NULLIdentificador único auto-incremental
fechaDATENOT NULLFecha de actualización del valor del dólar
valorDECIMAL(16,5)NOT NULLPrecio del dólar en pesos argentinos

Índices implementados: Ninguno (solo PRIMARY KEY)

Índices recomendados:

  • UNIQUE INDEX idx_dolar_fecha ON dolar(fecha) - Para garantizar unicidad y optimizar SELECT con WHERE fecha

Foreign Keys: Ninguna

Constraints adicionales: Ninguno

Nivel de configuración:

  • La migración se ejecuta solo si isVentasEnabled() || isComprasEnabled()
  • Se crea en niveles EMPRESA y SUCURSAL (cada sucursal puede tener su propia cotización)

Soft delete: No implementado (no tiene columna deleted_at)

Timestamps: No implementados (no tiene created_at ni updated_at)


Validaciones Implementadas

Validación Estructural (Nivel de Proxy Frontend)

El proxy en public/php/backend/dolar.php valida:

CampoReglaMensaje de error
valorrequired|numeric"El campo valor es obligatorio" / "El campo valor debe ser numérico"
fecharequired|date"El campo fecha es obligatorio" / "El campo fecha debe ser una fecha válida"

Observación: Esta validación NO está en el backend real (backend/dolar.php), sino en el proxy del frontend. El endpoint backend NO valida input.

Validación de Negocio

Implementada en frontend (JavaScript):

  • valor >= 0 - No puede ser negativo

Sin validación backend de reglas de negocio


Integración con Base de Datos

Clase Database (Legacy)

Ubicación: connection/connect.php

Uso en el script:

php
$database = new Database($db, $schema);
$conn = $database->getConnection();

Funcionalidad:

  • Crea conexión PDO a PostgreSQL
  • Configura search_path al schema especificado (multi-tenancy)
  • Retorna instancia PDO para queries
  • No maneja transacciones en este script (cada query es auto-commit)

Queries SQL Ejecutadas

Query 1: Obtener última cotización

sql
SELECT * FROM dolar ORDER BY fecha DESC LIMIT 1
  • Retorna la fila más reciente por fecha
  • Sin filtro adicional (podría estar en nivel EMPRESA o SUCURSAL según schema activo)
  • Preparada con PDO (prepare + execute)

Query 2: Verificar existencia por fecha

sql
SELECT * FROM dolar WHERE fecha = :fecha
  • Retorna todas las filas con la fecha especificada
  • Debería retornar máximo 1 fila (pero no garantizado por constraint)

Query 3: Actualizar valor existente

sql
UPDATE dolar SET valor = :valor WHERE fecha = :fecha
  • Actualiza todas las filas que coincidan con la fecha (debería ser solo 1)
  • Sin validación de rowCount después del UPDATE

Query 4: Insertar nueva cotización

sql
INSERT INTO dolar (fecha, valor) VALUES (:fecha, :valor)
  • Inserta nuevo registro
  • El campo id se genera automáticamente (SERIAL)

Seguridad

Autenticación

  • JWT requerido: El script lee $GLOBALS['payload'] que debe ser inyectado por middleware JWT previo
  • Middleware externo: El JWT se valida en JwtHandler.php ANTES de invocar este script
  • Sin validación redundante: El script no valida el token, confía en que el middleware lo hizo

Autorización

  • Sin validación de permisos: El script NO valida si el usuario tiene permiso VENTAS_BASES_COT-DOLAR
  • Permisos delegados: Se asume que la validación de permisos se hace en:
    1. Frontend (ocultar menú si no tiene permiso)
    2. Proxy PHP (puede validar sesión/permisos)

⚠️ Problema de seguridad: Si se invoca directamente el endpoint backend, no valida permisos

SQL Injection

  • Protegido: Usa prepared statements con bindParam o array de parámetros
  • Sin concatenación de SQL: Todo correcto

Multi-tenancy

  • Schema isolation: Usa search_path via clase Database
  • Inyección de schema: El schema viene del JWT payload ($GLOBALS['payload']['schema'])
  • Header X-Schema: El frontend puede enviar X-Schema para cambiar schema (validado por middleware)

⚠️ Consideración: Como la tabla existe en niveles EMPRESA y SUCURSAL, cada sucursal puede tener cotizaciones independientes. Verificar si esto es intencional o debería estar solo en EMPRESA.


Auditoría

Sin auditoría implementada: Este script legacy NO registra:

  • Quién modificó la cotización
  • Cuándo se modificó
  • Qué valores anteriores tenía

Recomendación: Al migrar a 5-layer DDD, implementar:

  • Trait Auditable en Service
  • AuditLogger para registrar operaciones CUD
  • Campos created_at, updated_at, deleted_at en tabla

Integración con Otros Módulos

Módulo Compras (Subdiario de Compras)

Uso: Lee la última cotización del dólar al registrar comprobantes de compra

Query probable: Similar a GET endpoint (SELECT * FROM dolar ORDER BY fecha DESC LIMIT 1)

Almacenamiento: Guarda el valor del dólar vigente en el comprobante de compra

Sensores Legacy (Dolarización de Montos)

Uso: Lee cotizaciones históricas para dolarizar montos de ventas y stock

Query probable: Consulta tabla dolar filtrada por fechas específicas

Tabla relacionada: factura.dolar (campo marcado "sin uso" pero consultado por sensor)

⚠️ Pregunta pendiente: Ver Preguntas sobre Cotización Dólar - Pregunta #3 sobre el campo factura.dolar


Manejo de Errores

Excepciones Lanzadas

ExcepciónCuándoStatus HTTP
UpdateErrorFalla el UPDATE o INSERT500
Cualquier ExceptionError de BD, conexión, etc.500

Manejo Global

  • ExceptionHandler::handle($e) - Captura y formatea todas las excepciones
  • Respuesta JSON con estructura: {"error": "mensaje"}

Errores NO Manejados

  • Método HTTP no soportado: Retorna 404 con mensaje "Recurso no encontrado" (debería ser 405 Method Not Allowed)
  • Parámetros faltantes/inválidos: Sin validación backend, falla silenciosamente o retorna error de PDO

Performance

Índices

  • Índice único natural: Campo fecha debería tener UNIQUE constraint + índice
  • Consulta de última cotización: LIMIT 1 + ORDER BY fecha DESC es eficiente
  • Sin paginación: No aplica (siempre retorna 1 registro o array vacío)

Transacciones

  • Sin transacciones explícitas: Cada query es auto-commit
  • Race condition: El patrón SELECT + UPDATE/INSERT debería ejecutarse en transacción

Recomendación:

php
$conn->beginTransaction();
try {
    // SELECT FOR UPDATE + UPDATE/INSERT
    $conn->commit();
} catch (Exception $e) {
    $conn->rollback();
    throw $e;
}

Caching

  • Sin cache: Cada request ejecuta query en BD
  • Oportunidad de mejora: Cachear última cotización (invalidar al POST)

Testing

Sin tests implementados: El código legacy no tiene tests automatizados

Tests recomendados al migrar a 5-layer DDD:

Unit Tests:

  • DolarService::insert() - Verificar upsert pattern
  • DolarService::getUltima() - Verificar query correcta
  • DolarModel::findByFecha() - Verificar query parametrizada
  • DolarValidator::validate() - Reglas de validación

Integration Tests:

  • POST crea nueva cotización
  • POST actualiza cotización existente (upsert)
  • GET retorna última cotización
  • GET sin datos retorna array vacío
  • Verificar multi-tenancy (cotización en schema correcto)

Migración a Arquitectura Moderna (Roadmap)

Estructura Objetivo (5-Layer DDD)

Routes/Ventas/DolarRoutes.php

controller/modulo-ventas/DolarController.php

service/Ventas/DolarService.php

models/modulo-ventas/DolarModel.php

Tabla: dolar

Adicionales:

  • Validators/Ventas/DolarValidator.php - Validación estructural
  • Resources/Ventas/DolarRequestDTO.php - DTO de entrada
  • Resources/Ventas/DolarResponseDTO.php - DTO de salida

Cambios Necesarios

Routes:

php
$group->get('', [DolarController::class, 'getUltima']);
$group->post('', [DolarController::class, 'insert'])
    ->add(new ValidationMiddleware(DolarValidator::class));

Controller:

php
class DolarController extends Controller
{
    public function __construct(
        private DolarService $service,
        private AuditLogger $audit
    ) {
        parent::__construct();
    }

    public function getUltima(Request $request, Response $response): Response
    {
        $result = $this->service->getUltima();
        return $this->success($response, 200, $result);
    }

    public function insert(Request $request, Response $response): Response
    {
        $data = DolarRequestDTO::fromArray($request->getParsedBody());
        $result = $this->service->insert($data);
        return $this->success($response, 201, $result);
    }
}

Service (con auditoría y upsert atómico):

php
class DolarService implements AuditableInterface
{
    use Conectable, Auditable;

    private DolarModel $model;

    public function __construct(ConnectionManager $manager, ?AuditLogger $audit = null)
    {
        $this->setConnectionManager($manager);
        $this->setAuditLogger($audit);
        $this->model = new DolarModel($manager->get('oficial'));
    }

    public function getUltima(): ?DolarResponseDTO
    {
        return $this->model->findUltima();
    }

    public function insert(DolarRequestDTO $data): DolarResponseDTO
    {
        $this->connections->beginTransaction('oficial');
        try {
            // Upsert atómico
            $result = $this->model->upsert($data);

            // Auditoría
            $this->registrarAuditoria(
                "UPSERT",
                "VENTAS",
                $this->model->getTable(),
                $result->id
            );

            $this->connections->commit('oficial');
            return $result;
        } catch (Exception $e) {
            $this->connections->rollback('oficial');
            throw $e;
        }
    }
}

Model (con upsert PostgreSQL nativo):

php
class DolarModel extends Model
{
    protected string $table = 'dolar';

    public function findUltima(): ?DolarResponseDTO
    {
        $sql = "SELECT * FROM {$this->table}
                WHERE deleted_at IS NULL
                ORDER BY fecha DESC
                LIMIT 1";

        $stmt = $this->conn->prepare($sql);
        $stmt->execute();

        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? DolarResponseDTO::fromArray($row) : null;
    }

    public function upsert(DolarRequestDTO $data): DolarResponseDTO
    {
        $sql = "INSERT INTO {$this->table} (fecha, valor)
                VALUES (:fecha, :valor)
                ON CONFLICT (fecha)
                DO UPDATE SET valor = EXCLUDED.valor
                RETURNING id, fecha, valor";

        $stmt = $this->conn->prepare($sql);
        $stmt->execute([
            'fecha' => $data->fecha,
            'valor' => $data->valor
        ]);

        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return DolarResponseDTO::fromArray($row);
    }
}

Validator:

php
class DolarValidator
{
    public static function rules(): array
    {
        return [
            'fecha' => 'required|date',
            'valor' => 'required|numeric|min:0'
        ];
    }
}

Migraciones de Base de Datos Requeridas

php
// Migration: AddConstraintUniqueFechaDolar
$table->addIndex(['fecha'], ['unique' => true, 'name' => 'idx_dolar_fecha_unique']);
php
// Migration: AddTimestampsToDolar
$table->addColumn('created_at', 'timestamp', ['null' => true, 'default' => 'CURRENT_TIMESTAMP'])
      ->addColumn('updated_at', 'timestamp', ['null' => true])
      ->addColumn('deleted_at', 'timestamp', ['null' => true])
      ->update();

Preguntas Técnicas Pendientes

⚠️ Información Faltante: Hay preguntas técnicas sobre esta funcionalidad que requieren validación.

Ver: Preguntas sobre Cotización Dólar

Resumen de preguntas técnicas:

  • #5: Upsert sin constraint UNIQUE - riesgo de duplicados
  • #6: API externa dolarapi.com - confiabilidad y contingencia
  • #7: Redirección post-guardado - flujo de usuario

Referencias


⚠️ NOTA IMPORTANTE: Esta documentación fue generada automáticamente analizando el código implementado. Este recurso se encuentra en arquitectura legacy (script PHP procedimental) y NO sigue los patrones 5-layer DDD del sistema. Se recomienda migración a arquitectura moderna para incorporar validaciones, auditoría, tests y mejores prácticas de seguridad.