Appearance
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 → falseEn 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_empresariales | multimoneda | Resultado esperado |
|---|---|---|
| 0 | 0 | Legacy total — sin cambios, todo en ARS |
| 1 | 0 | Multi-categoría activa, todo en ARS |
| 0 | 1 | USD habilitado, factura single-item por socio |
| 1 | 1 | Flujo 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-multimonedael campo se llamabacotizacion_dolar. Un cambio posterior lo renombró acotizacion(migración20260529000010_add_column_currency_code_cotizacion_membresia_facturacion.php, que además agregacurrency_code). Toda referencia acotizacion_dolaren artefactos SDD antiguos corresponde al campo hoy llamadocotizacion.
Wire contract — naming por capa
| Capa | Nombre | Ejemplo |
|---|---|---|
| JSON request/response | cotizacion (snake_case) | { "cotizacion": 1250.5 } |
PHP DTO (BatchInvoicingRequest) | cotizacion | public ?float $cotizacion = null |
| PHP servicios internos | cotizacion | $ctx->cotizacion |
| Columna DB | cotizacion (DECIMAL(16,5) NULL) | membresia_facturacion.cotizacion |
| Zod schema / RHF (frontend) | cotizacion | cotizacion: z.number().positive().optional().nullable() |
| Tipo TypeScript | cotizacion (opcional) | cotizacion?: number | null |
Regla de payload (frontend): cuando
cotizacionestá vacío, la key se omite del payload (no se envíanull). Esto alinea con la reglasometimesdel 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(variantesoficialyblue) además del endpoint Slim para la cotización actual. Esto puede consolidarse contra/cotizacion-dolar/ambitoa 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
| Caso | Resultado |
|---|---|
Ítem USD + cotizacion null | 422 con nombre de lista |
Ítem USD + cotizacion ≤ 0 | 422 "la cotización debe ser mayor a cero" |
Todos ARS + cotizacion null | OK |
Todos ARS + cotizacion informada | OK (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— consumeCotizacionDolarService.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. Usaretry: falsepara 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ón | Tabla | Cambio |
|---|---|---|
| M-04 | credito | ADD COLUMN dolar DECIMAL(16,5) NULL (cotización aplicada en anulaciones) |
| M-05 | debito | ADD COLUMN dolar DECIMAL(16,5) NULL (preventiva; se persiste por flujos legacy) |
| M-06 | membresia_facturacion | ADD COLUMN cotizacion_dolar DECIMAL(16,5) NULL |
20260529000010_* | membresia_facturacion | Renombra cotizacion_dolar → cotizacion 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.monedapor lista de precios. - Trazabilidad: la cotización aplicada queda persistida tanto en
membresia_facturacion.cotizacioncomo enfactura.dolar/credito.dolar, garantizando consistencia entre el registro de membresía y el comprobante fiscal. - Compatibilidad OFF: con
multimoneda = 0el comportamiento es bit-idéntico al legacy; los golden tests sobre la rama OFF deben pasar sin cambios.
Referencias
- Proceso general: Facturación por lotes de membresías
- Esquema multimoneda: Multimoneda — schema
- SDD origen:
openspec/changes/archive/2026-05-26-facturacion-multimoneda/