Skip to content

Esquema Multimoneda de Satelites — Facturacion por Lotes

Modulo: Membresias Tipo: Process Estado: Implementado Fecha Creacion: 2026-05-28 Fecha Implementacion: 2026-05-28


Descripcion

Problema que resuelve

El sistema de facturacion por lotes genera comprobantes en moneda base ARS. Cuando una organizacion opera con precios en dolares (USD) para sus categorias de membresia, necesita registrar tanto el importe en ARS (para compliance fiscal con ARCA) como el importe en la moneda original para trazabilidad interna.

Sin un esquema multimoneda, el sistema no podia:

  • Registrar el importe en dolares al momento de facturar sin re-derivarlo despues
  • Mantener la cotizacion exacta usada en cada operacion
  • Mostrar a los socios el desglose en moneda alternativa

Solucion implementada

Se implemento un patron de tablas satelite que preserva el importe en moneda alternativa junto al esquema transaccional ARS existente. Las tablas base permanecen intactas (ARS); las tablas satelite acumulan la informacion complementaria en moneda alternativa.


Arquitectura

Patron base + satelite

Las tablas base del esquema transaccional (factura, credito, debito, itefac, devoluci) siempre contienen importes en ARS. Cada tabla tiene una tabla satelite correspondiente con sufijo _moneda_alt que almacena los importes en moneda alternativa:

Tabla baseTabla satelite
facturafactura_moneda_alt
creditocredito_moneda_alt
debitodebito_moneda_alt
itefacitefac_moneda_alt
devolucidevoluci_moneda_alt

Las tablas satelite tienen clave foranea hacia la tabla base (factura_id, credito_id, etc.) y son opcionales: si no hay datos en moneda alternativa, simplemente no existe la fila satelite.

itefac y devoluci eran tablas legacy sin PK. Las migraciones 20260529000005 y 20260529000006 agregan id BIGSERIAL PRIMARY KEY a ambas — prerequisito para que las FK de sus satelites existan. factura, credito y debito ya tenian id.

Por que satelites y no columnas adicionales

  • Rollback limpio: DROP las tablas satelite + desactivar el flag → la base ARS queda intacta, sin columnas residuales.
  • Extension sin regresion: Flujos que no soportan multimoneda (ventas generales, factura electronica manual) no necesitan cambios.
  • Auditoria independiente: La informacion en moneda alternativa tiene su propio ciclo de vida y puede truncarse/migrarse sin afectar los comprobantes fiscales.

Catalogo monedas

La tabla monedas vive en el schema public del tenant (nivel LEVEL_EMPRESA en la nomenclatura de migraciones) y contiene el catalogo de monedas habilitadas para la organizacion.

sql
CREATE TABLE monedas (
    id        SERIAL PRIMARY KEY,
    codigo    CHAR(3)      NOT NULL UNIQUE,  -- ISO 4217: ARS, USD
    nombre    VARCHAR(100) NOT NULL,
    simbolo   VARCHAR(10),
    activa    BOOLEAN      NOT NULL DEFAULT true
);

El seed inicial inserta ARS y USD al crear el schema del tenant. El codigo de moneda ARS es la moneda base del sistema y no puede eliminarse.


Flag empres.multimoneda

La columna multimoneda BOOLEAN en la tabla empres (dentro del schema del tenant) actua como feature flag que habilita el path de facturacion alternativa.

Comportamiento

FlagComportamiento
false (default)Facturacion normal: solo se escriben tablas base ARS. Las tablas satelite no se tocan.
trueFacturacion multimoneda: se escriben tablas base ARS Y tablas satelite cuando la categoria tiene moneda = 'USD' y se provee una cotizacion.

Lectura del flag

El flag se lee via ConfiguracionEmpresaRepository::isMultimonedaMembresiaEnabled() en el dominio. No se lee directamente del modelo ORM en Application ni Presentation para mantener la regla de dependencias.

Visibilidad condicional en formularios (UI)

El mismo flag gobierna la visibilidad de los selectores de moneda en la UI (SDD multimoneda-condicional-flujos). El campo currency_code en los formularios de lista de precios:

  • ListaPrecioForm (React): lee config.empresa.multimoneda via useConfig(). Cuando multimoneda = false, el select NO se renderiza en el DOM y el payload envia siempre currency_code = 'ARS', incluso si el initialData traia otra moneda. Cuando multimoneda = true, el campo aparece y se puebla desde useMonedas().
  • form-producto.js (legacy vanilla JS): el select #idSelectMonedaLista se oculta cuando multimonedaEnabled = false, y MonedaService.fetchMonedas() solo se invoca cuando el flag esta activo. El <option value="ARS"> por defecto garantiza que el payload incluya moneda: 'ARS' aun con el select oculto.

TIP

No se requieren cambios de schema ni de backend: currency_code ya es opcional con default 'ARS'. La condicionalidad es puramente de presentacion.


Calculo de importes en moneda alternativa

Regla fundamental

El importe en moneda alternativa se calcula en tiempo de escritura (write time) y se almacena persistido. Nunca se re-deriva a posteriori.

La conversion la centraliza DeudaMembresiaCalculator::calcularAltFields() y se aplica de forma uniforme en los tres procesadores de items: procesarCategoriaTitular(), procesarDisciplinas() y procesarProductos(). La formula depende del tipo_precio del item:

tipo_precioFormula de altAmount
'N' (precio neto)ROUND((arsNeto * (1 + alicuota/100)) / cotizacion, 5)
'F' (precio final, IVA incluido)ROUND(arsFinal / cotizacion, 5)

Donde cotizacion es el valor ARS/USD provisto por el usuario en el request de facturacion por lotes, y alicuota es el porcentaje de IVA del item.

Precision y formula corregida

La precision es de 5 decimales (ROUND(x, 5)), no 2. Ademas, para tipo_precio='N' el altAmount se calcula sobre el total con IVA incluido, NO sobre el neto solo.

La formula previa ROUND(importe_ars / cotizacion, 2) era incorrecta y fue corregida por el SDD lotes-cotizacion-conversion-moneda.

Invariante de descomposicion

Para todo item, independientemente del tipo_precio o el procesador usado, se cumple:

altNeto + altIva == altAmount   (tolerancia < 1e-6)

El IVA alternativo se calcula como residual (altIva = altAmount - altNeto) para que la invariante sea exacta.

Por que write time y no derivacion on-the-fly

  • La cotizacion al momento de facturar es unica e irrepetible. Derivarla mas tarde requeriria almacenarla de todas formas.
  • Evita discrepancias si la cotizacion usada difiere de la cotizacion vigente al consultar.
  • Simplifica las queries de reporte: no requieren joins adicionales para obtener el tipo de cambio historico.

Exposicion en la API

Campos del item cuando multimoneda esta activo

Cuando el item se calculo con una cotizacion (multimoneda activo), InvoiceItem::toDetailedArray() expone los siguientes campos opcionales, que ComprobanteMapper::toDetailedItems() propaga sin modificar hacia la respuesta de la API:

Campo (API, snake_case)Descripcion
alt_netoNeto del item en moneda alternativa
alt_ivaIVA del item en moneda alternativa
alt_totalTotal del item en moneda alternativa (alt_neto + alt_iva == alt_total)
monedaCodigo ISO de la moneda alternativa (ej: USD)
cotizacionCotizacion ARS/moneda usada en el item

Campos condicionales, no null-padded

Cuando multimoneda NO esta activo (isMultimonedaMembresiaEnabled() = false o sin cotizacion), estos campos quedan ausentes de la respuesta — no se incluyen como null.

En el frontend, el mapper del servicio convierte estos campos snake_case a camelCase en BatchInvoicingItem: alt_neto → netoAlt, alt_iva → ivaAlt, alt_total → totalAlt, moneda → monedaAlt, cotizacion → cotizacion. Cuando los campos estan ausentes, las propiedades quedan undefined.

Endpoint GET /monedas

El catalogo de monedas se expone via GET /monedas, que devuelve las monedas activas del tenant. Cada entrada incluye code, signo, nombre y decimales. La respuesta incluye al menos ARS y USD.

Los formularios del frontend (select de moneda en lista de precios) poblan sus opciones desde este endpoint en lugar de usar valores hardcodeados.


Par de auditoria membresia_facturacion

La tabla membresia_facturacion (registro de que socio fue facturado en que periodo) incorpora dos columnas de auditoria:

ColumnaTipoDescripcion
cotizacionDECIMAL(16,5)Cotizacion ARS/USD usada al facturar
currency_codeCHAR(3)Codigo ISO de la moneda alternativa (ej: USD)

Rename de campo

La columna se llamaba originalmente cotizacion_dolar y fue renombrada a cotizacion (tanto en la tabla como en los contratos de la API: BatchInvoicingRequest, MembresiaFacturacionRequest, BatchInvoicingValidator) por el SDD facturacion-lotes-moneda-principal. El nombre cotizacion_dolar ya no existe ni en DB ni en la API.

Por que ambas columnas

currency_code identifica que moneda se uso. cotizacion registra a que valor se convirtio. Ambas son necesarias para un audit trail completo: si en el futuro se soportan mas monedas (EUR, BRL), currency_code distingue entre ellas mientras que cotizacion preserva el tipo de cambio exacto.

Si la facturacion fue en ARS puro (flag OFF o categoria ARS), ambas columnas quedan NULL.


Patron de join header → item

La tabla itefac_moneda_alt (items de factura en moneda alternativa) no almacena la cotizacion. Esta se obtiene via join con factura_moneda_alt:

sql
SELECT
    i.id_factura,
    i.importe_alt,
    f.cotizacion,
    f.currency_code
FROM itefac_moneda_alt i
JOIN factura_moneda_alt f ON f.factura_id = i.factura_id
WHERE i.factura_id = :id;

Razon del diseno

Almacenar la cotizacion en el header (factura_moneda_alt) y no en cada item evita redundancia: todos los items de una misma factura usan la misma cotizacion. El join es siempre 1:N entre header e items.


Rollback del esquema multimoneda

Si se necesita revertir la funcionalidad multimoneda:

  1. Desactivar el flag: UPDATE empres SET multimoneda = false en el schema del tenant.
  2. Opcional: DROP de las tablas satelite (los datos ya no se escriben con el flag en false):
sql
DROP TABLE IF EXISTS itefac_moneda_alt;
DROP TABLE IF EXISTS factura_moneda_alt;
DROP TABLE IF EXISTS credito_moneda_alt;
DROP TABLE IF EXISTS debito_moneda_alt;
DROP TABLE IF EXISTS devoluci_moneda_alt;
  1. Las tablas base (factura, credito, debito, itefac, devoluci) permanecen intactas con todos sus datos ARS.
  2. La columna dolar original en credito y debito ya fue eliminada en la migracion 20260529000007_drop_column_dolar. Si se necesita el rollback completo, restaurar desde backup.

Extension futura

Para agregar soporte multimoneda en flujos no-batch (ventas generales, factura electronica manual):

  1. El mismo patron satelite aplica: agregar una tabla {tabla}_moneda_alt con FK a la tabla base.
  2. El flag empres.multimoneda ya existe — reutilizarlo o crear flags granulares por flujo.
  3. El catalogo monedas ya esta sembrado — no requiere cambios.
  4. El campo cotizacion debe proveerse en el request del flujo correspondiente.
  5. Los servicios de dominio pueden reutilizar DeudaMembresiaCalculator::setAltFields() como patron de referencia.

Flujos pendientes de extension

  • Ventas generales (modulo mod-ventas)
  • Factura electronica manual
  • Notas de credito/debito manuales

Archivos relevantes

Backend

ArchivoDescripcion
migrations/migrations/tenancy/20260529000001_new_table_monedas.phpCatalogo de monedas
migrations/migrations/tenancy/20260529000002_new_table_factura_moneda_alt.phpSatelite de facturas
migrations/migrations/tenancy/20260529000003_new_table_credito_moneda_alt.phpSatelite de creditos
migrations/migrations/tenancy/20260529000004_new_table_debito_moneda_alt.phpSatelite de debitos
migrations/migrations/tenancy/20260529000005_add_id_to_itefac.phpAgrega id BIGSERIAL PRIMARY KEY a itefac
migrations/migrations/tenancy/20260529000006_add_id_to_devoluci.phpAgrega id BIGSERIAL PRIMARY KEY a devoluci
migrations/migrations/tenancy/20260529000007_new_table_itefac_moneda_alt.phpSatelite de items
migrations/migrations/tenancy/20260529000008_new_table_devoluci_moneda_alt.phpSatelite de devoluciones
migrations/migrations/tenancy/20260529000009_drop_column_dolar.phpLimpieza de columnas legacy
migrations/migrations/tenancy/20260529000010_add_column_currency_code_cotizacion_membresia_facturacion.phpPar de auditoria
Modules/Membresia/Infrastructure/Persistence/Repositories/DoctrineConfiguracionEmpresaRepository.phpLectura del flag multimoneda
Modules/Membresia/Domain/Facturacion/Services/DeudaMembresiaCalculator.phpCalculo de importe alt (setAltFields)
Modules/Membresia/Application/Services/Facturacion/BatchFacturaRegistrationService.phpEscritura de satelite factura
Modules/Membresia/Application/Services/Facturacion/BatchNotaCreditoRegistrationService.phpEscritura de satelite credito
Presentation/DTOs/Facturacion/BatchInvoicingRequest.phpcotizacion + currency_code en request

Frontend

ArchivoDescripcion
ts/mod-membresias/FacturacionLotes/components/FacturacionLotesForm/index.tsxCampo cotizacion + tabla referencia ambito
ts/mod-membresias/FacturacionLotes/hooks/useCotizacionDolar.tsQuery cotizacion actual + ambito referencia
ts/bases/Moneda/services/moneda.service.tsServicio shared de monedas
ts/bases/Moneda/hooks/useMonedas.tsHook shared de monedas