Skip to content

Cotización de dólar en facturación por lotes

Módulo: Membresías > Facturación por Lotes Tipo: Process Estado: Implementado Origen SDD: facturacion-multimoneda (2026-05-26)


Descripción

Problema que resuelve

Algunas listas de precios de membresías están denominadas en dólares (USD). La facturación por lotes emite comprobantes fiscales en pesos (ARS), por lo que cada ítem en USD debe convertirse a ARS con una cotización conocida en el momento de facturar. La cotización aplicada también debe quedar persistida para trazabilidad contable y fiscal.

Sin este mecanismo:

  • No habría forma de facturar listas en USD dentro del flujo por lotes.
  • La conversión quedaría a criterio manual del operador, sin registro de qué cotización se usó.
  • No habría validación que impida emitir comprobantes USD sin una cotización informada.

Solución implementada

La facturación por lotes acepta un campo cotizacion (cotización del dólar) que se propaga por todo el pipeline de facturación. Cuando un ítem proviene de una lista en USD, el sistema convierte el importe a ARS antes de calcular el IVA y persiste la cotización aplicada en los registros de membresía y en los comprobantes legacy (factura / crédito / débito). Si el lote contiene ítems USD pero no se informó una cotización válida, el backend rechaza la operación con un error 422 orientado al operador.


Cuándo aplica

La conversión USD→ARS sólo se activa cuando la configuración de empresa tiene la flag membresia.multimoneda en '1'.

Esta flag es ortogonal a membresia.servicios_empresariales: cada una habilita un comportamiento independiente y pueden combinarse. La flag se siembra por defecto en '0' (rollout silencioso) y se habilita por empresa desde la configuración.

En backend, la lectura se expone vía:

php
ConfiguracionEmpresaRepository::isMultimonedaMembresiaEnabled(): bool
// '1' → true; '0' o ausente → false

En frontend, el campo de cotización y sus botones de ayuda sólo se renderizan cuando config.configuraciones['membresia.multimoneda'] === '1'.

Matriz de combinaciones de flags

servicios_empresarialesmultimonedaResultado esperado
00Legacy total — sin cambios, todo en ARS
10Multi-categoría activa, todo en ARS
01USD habilitado, factura single-item por socio
11Flujo completo: multi-categoría + USD + cotización

Cuando multimoneda = 0, el campo cotizacion se acepta en el request pero el enricher lo ignora: el OFF-path es bit-idéntico al comportamiento legacy. Las columnas de cotización se persisten NULL.


Campo cotizacion

El campo que transporta la cotización del dólar se llama cotizacion en todas las capas.

Nota histórica: en el SDD original facturacion-multimoneda el campo se llamaba cotizacion_dolar. Un cambio posterior lo renombró a cotizacion (migración 20260529000010_add_column_currency_code_cotizacion_membresia_facturacion.php, que además agrega currency_code). Toda referencia a cotizacion_dolar en artefactos SDD antiguos corresponde al campo hoy llamado cotizacion.

Wire contract — naming por capa

CapaNombreEjemplo
JSON request/responsecotizacion (snake_case){ "cotizacion": 1250.5 }
PHP DTO (BatchInvoicingRequest)cotizacionpublic ?float $cotizacion = null
PHP servicios internoscotizacion$ctx->cotizacion
Columna DBcotizacion (DECIMAL(16,5) NULL)membresia_facturacion.cotizacion
Zod schema / RHF (frontend)cotizacioncotizacion: z.number().positive().optional().nullable()
Tipo TypeScriptcotizacion (opcional)cotizacion?: number | null

Regla de payload (frontend): cuando cotizacion está vacío, la key se omite del payload (no se envía null). Esto alinea con la regla sometimes del validador backend.

typescript
// facturacionLotes.service.ts — la key sólo se incluye si tiene valor
...(data.cotizacion != null ? { cotizacion: data.cotizacion } : {})

Endpoints de cotización

Ambos endpoints están montados en el route group de Membresía (Slim):

php
// MembresiaRoutes.php
$group->get('/cotizacion-dolar/actual', [CotizacionDolarController::class, 'getActual']);
$group->get('/cotizacion-dolar/ambito', [CotizacionDolarController::class, 'getAmbito']);

GET /mod-membresia/cotizacion-dolar/actual

Devuelve la última cotización registrada en la tabla interna dolar (SELECT valor, fecha FROM dolar ORDER BY fecha DESC LIMIT 1).

  • Cuándo usarlo: el operador quiere precargar el campo con la cotización interna del sistema (botón "traer de tabla interna").

  • Respuesta (200) cuando hay registro:

    json
    { "cotizacion": 1250.5, "fecha": "2026-05-26" }
  • Respuesta (200) cuando la tabla está vacía:

    json
    { "cotizacion": null, "fecha": null }

    En este caso el frontend deja el campo vacío para que el operador lo cargue manualmente.

GET /mod-membresia/cotizacion-dolar/ambito

Devuelve una cotización de referencia desde un proveedor externo (parámetro opcional ?fecha=YYYY-MM-DD, default fecha de hoy).

  • Cuándo usarlo: el operador quiere una cotización de mercado de referencia antes de confirmar.

  • Degradación: cuando el proveedor externo no está configurado o no responde, el endpoint devuelve 503:

    json
    { "status": 503, "error": "Proveedor de cotizacion no disponible" }

    La UI degrada a ingreso manual del campo.

Nota de implementación frontend: el hook actual obtiene la referencia de ámbito consultando directamente dolarapi.com (variantes oficial y blue) además del endpoint Slim para la cotización actual. Esto puede consolidarse contra /cotizacion-dolar/ambito a futuro.


Validación

El validador de lote (BatchInvoicingValidator) sólo valida formato:

php
'cotizacion' => 'sometimes|numeric|min:0.00001',
// Mensajes:
// 'cotizacion.numeric' => 'La cotización debe ser numérica',
// 'cotizacion.min'     => 'La cotización debe ser mayor a cero',

La validación semántica (¿hay ítems USD que requieran cotización?) vive en el enricher (CategoriaMembresiaEnricher), porque es la única capa que conoce la moneda real de cada lista de precios. Cuando un ítem proviene de una lista en USD y la cotización es null o ≤ 0, el enricher lanza 422 con el nombre de la lista afectada:

La lista de precios {nombreLista} está en USD y requiere cotización de dólar

Contrato de validación

CasoResultado
Ítem USD + cotizacion null422 con nombre de lista
Ítem USD + cotizacion ≤ 0422 "la cotización debe ser mayor a cero"
Todos ARS + cotizacion nullOK
Todos ARS + cotizacion informadaOK (se persiste para trazabilidad)

Flujo backend (flag ON, lote con ítems USD)

POST /mod-membresia/comprobantes  body: { ..., "cotizacion": 1250.5 }

BatchInvoicingRequest (cotizacion)

BatchInvoicingValidator → sometimes|numeric|min:0.00001 (sólo formato)

BatchInvoicingOrchestrator → FacturacionContexto(..., cotizacion: 1250.5)

CategoriaMembresiaEnricher.enrichBatch(...) por cada relación:
    si multimonedaEnabled && moneda === 'USD':
        si cotizacion ∈ {null, ≤0} → throw 422 con nombre de lista
        precio = round(precio_usd * cantidad * cotizacion, 5)   // antes de IVA
    si no (ARS):
        precio = precio_ars * cantidad                          // sin conversión

DeudaMembresiaCalculator → IVA se calcula aguas abajo sobre el precio convertido

BatchFacturaRegistrationService.registrarLote:
    ├─ FacturaDTO::make([..., 'dolar' => $ctx->cotizacion])   → INSERT factura(..., dolar)
    └─ MembresiaFacturacion::bulkInsert([..., 'cotizacion' => 1250.5])
BatchNotaCreditoRegistrationService → análogo con credito.dolar (anulaciones)

OFF-path: con la flag apagada, el enricher ignora cotizacion; las columnas dolar (factura/credito/debito) y cotizacion (membresia_facturacion) se insertan NULL.


Frontend

Hook useCotizacionDolar

Ubicación: bautista-app/ts/mod-membresias/FacturacionLotes/hooks/useCotizacionDolar.ts.

Expone dos queries de TanStack Query (v5):

  • actualQuery — consume CotizacionDolarService.getCotizacionActual (endpoint /cotizacion-dolar/actual). Alimenta el botón "traer de tabla interna".
  • ambitoQuery — obtiene la referencia de ámbito (oficial / blue). Alimenta el botón de cotización de referencia. Usa retry: false para degradar limpio cuando el proveedor no responde.

Ambas con staleTime de 5 minutos. El componente FacturacionLotesForm usa los valores resueltos para hacer setValue('cotizacion', ...) sobre el formulario.

Campo en FacturacionLotesForm

El campo cotizacion (input numérico) más sus botones de ayuda se renderizan únicamente cuando membresia.multimoneda === '1'. Si el operador lo deja vacío, el service omite la key del payload. El manejo del 422 muestra al operador el nombre de la lista USD que requiere cotización.


Migraciones

MigraciónTablaCambio
M-04creditoADD COLUMN dolar DECIMAL(16,5) NULL (cotización aplicada en anulaciones)
M-05debitoADD COLUMN dolar DECIMAL(16,5) NULL (preventiva; se persiste por flujos legacy)
M-06membresia_facturacionADD COLUMN cotizacion_dolar DECIMAL(16,5) NULL
20260529000010_*membresia_facturacionRenombra cotizacion_dolarcotizacion y agrega currency_code CHAR(3) NULL

Todas las columnas son nullable, sin FK ni UNIQUE. Son aditivas: el rollback (git revert) deja las columnas inertes sin romper datos existentes.


Consideraciones técnicas

  • Multi-tenant: las columnas se aplican en los schemas TRANSACCIONAL de EMPRESA y SUCURSAL vía migraciones idempotentes (hasColumn(...) guard).
  • Frontera de validación: el formato lo valida el validador; la semántica USD↔cotización la valida el enricher, que es la única capa con acceso a metadata.moneda por lista de precios.
  • Trazabilidad: la cotización aplicada queda persistida tanto en membresia_facturacion.cotizacion como en factura.dolar / credito.dolar, garantizando consistencia entre el registro de membresía y el comprobante fiscal.
  • Compatibilidad OFF: con multimoneda = 0 el comportamiento es bit-idéntico al legacy; los golden tests sobre la rama OFF deben pasar sin cambios.

Referencias