diff --git a/docs/customer-invoices/README.md b/docs/customer-invoices/README.md index ca983d07..6951a044 100644 --- a/docs/customer-invoices/README.md +++ b/docs/customer-invoices/README.md @@ -45,11 +45,6 @@ Regla crítica vigente: - [Índice SQL de `customer-invoices`](./sql/README.md) -## Histórico o transicional (`HISTORICAL`) - -- [Split Proformas / Issued Invoices](./split-proformas-issued-invoices-migration.md) -- [InvoiceSeries legacy](./invoice-series.md) - ## Criterio de lectura - Para semántica vigente de negocio, empieza por `proforma-series-contract.md`, `proforma-create-contract.md` y `proforma-tax-config.md`. diff --git a/docs/customer-invoices/invoice-series.md b/docs/customer-invoices/invoice-series.md deleted file mode 100644 index af710984..00000000 --- a/docs/customer-invoices/invoice-series.md +++ /dev/null @@ -1,173 +0,0 @@ -# InvoiceSeries (Histórico / Obsoleto) - -> Documento mantenido solo como referencia histórica previa a la retirada de `/catalogs/invoice-series`. -> Estado actual: la API canónica es `/document-series` y la tabla canónica es `document_series`. -> Las tablas `customer_invoices`, `customer_invoice_items` y `customer_invoice_taxes` -> ya no forman parte del runtime actual. - -## Decisión consolidada - -Antes de la migración, el recurso backend para las series de facturación de cliente se llamaba: - -```txt -InvoiceSeries -``` - -Su tabla Sequelize asociada era: - -```txt -customer_invoice_series -``` - -Este contenido ya no describe el runtime activo del ERP. - -No usar este documento para desarrollo nuevo. La referencia vigente es `docs/document-series/*`. - -## Alcance funcional - -`InvoiceSeries` representa la serie usada para numerar facturas emitidas de cliente. - -La proforma: - -```txt -- guarda una referencia legacy en Proforma.series -- ese valor corresponde a InvoiceSeries.code -- no consume numeración de factura emitida -``` - -## Modelo mínimo - -Campos activos en V1: - -```txt -id -company_id -code -next_number -padding_length -is_default -is_active -created_at -updated_at -``` - -No existen en V1: - -```txt -prefix -name -description -document_type -``` - -## Reglas de numeración - -`next_number` es obligatorio y es la fuente de verdad para asignar el siguiente número de factura emitida. - -El flujo de emisión desde proforma es: - -```txt -1. leer la proforma -2. tomar Proforma.series como InvoiceSeries.code -3. buscar InvoiceSeries por company_id + code -4. validar que la serie existe y está activa -5. bloquear la fila de customer_invoice_series dentro de la misma transacción -6. leer next_number -7. asignar ese número a la issued invoice -8. incrementar next_number -9. confirmar issued invoice + increment en una sola transacción -``` - -No se permite: - -```txt -- MAX(invoice_number) + 1 -- consumir numeración al crear o actualizar proformas -- separar el lock y el update en transacciones distintas -``` - -## Formato visible - -El formato visible deriva de: - -```txt -formatted_number = code + "-" + number.padStart(padding_length, "0") -``` - -Ejemplo: - -```txt -code = A -number = 23 -padding_length = 6 - -=> A-000023 -``` - -En V1 no existe `prefix`. -`code` actúa a la vez como identificador funcional y prefijo visible. - -## Integridad defensiva - -En `customer_invoice_series` debe existir: - -```sql -UNIQUE (company_id, code) -``` - -En `customer_invoices` la regla defensiva para numeración emitida es: - -```txt -company + series + invoice_number debe ser único -``` - -Actualmente el modelo Sequelize usa: - -```sql -UNIQUE (company_id, series, invoice_number, is_proforma) -``` - -Esto evita colisiones entre proformas y facturas emitidas mientras ambas comparten `customer_invoices`. - -## Seeds e inicialización - -No se ha añadido seed automático por empresa porque el repo no expone un sistema real de seeders versionados ni una estrategia fiable para inicializar todas las companies existentes. - -Regla operativa: - -```txt -cada company necesita al menos una InvoiceSeries activa antes de emitir facturas -``` - -Serie mínima recomendada: - -```txt -code = A -next_number = 1 -padding_length = 6 -is_default = true -is_active = true -``` - -## Estado técnico del repo - -En este repo no se localizó un framework de migraciones versionadas con `sequelize-cli`, `umzug` o equivalente. - -El arranque del servidor registra modelos Sequelize y sincroniza base de datos mediante: - -```txt -database.sync({ alter: true }) -``` - -Por eso el SQL manual asociado a `customer_invoice_series` vive en: - -[customer-invoice-series.sql](/Z:/docs/dev/customer-invoice-series.sql) - -Ese archivo debe integrarse en el pipeline real de despliegue DB cuando exista. - -## Sustitución vigente - -- API canónica: `/document-series` -- tabla canónica: `document_series` -- proformas: `proforma_series_code` y `target_invoice_series_code` -- SQL histórico de apoyo: `docs/dev/customer-invoice-series.sql` diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md deleted file mode 100644 index e7a7d03c..00000000 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ /dev/null @@ -1,919 +0,0 @@ -# Split Proformas / Issued Invoices - -Estado: historico / transicional. -No describe el runtime minimo vigente por si solo; la fuente operativa actual para proformas es la combinación de: - -- `docs/document-series/README.md` -- `docs/customer-invoices/proforma-series-contract.md` -- `docs/customer-invoices/proforma-create-contract.md` -- `docs/customer-invoices/proforma-tax-config.md` -- `docs/catalogs/payment-terms.md` - -Nota SSOT VeriFactu: - -- cardinalidad vigente implementada: `IssuedInvoice 1 -> 0..1 VerifactuRecord`; -- direccion futura deseada: `IssuedInvoice 1 -> 0..N VerifactuRecord`; -- este documento no debe usarse para reintroducir `IssuedInvoice.verifactuPolicy`. - -Estado: diseno tecnico / fases 1A-1D + 2A-2B -Estado del backend: persistencia V2 activada; integracion con `document-series` activa para numeracion nueva; sin ejecucion confirmada de migracion DB - -## Objetivo - -Preparar el esquema fisico para separar: - -- `customer_invoices` -> `proformas` -- `customer_invoices` -> `issued_invoices` -- `customer_invoice_items` -> `proforma_items` -- `customer_invoice_items` -> `issued_invoice_items` -- `customer_invoice_taxes` -> `proforma_taxes` -- `customer_invoice_taxes` -> `issued_invoice_taxes` -- `verifactu_records.invoice_id` -> `verifactu_records.issued_invoice_id` - -## Decisiones de fase 1A - -- `proforma_reference` es el identificador visible canonico de proforma. -- `target_invoice_series_code` sustituye la semantica legacy de `series` en proformas. -- `document_series_id` identifica la serie documental usada para numerar la proforma. -- `proforma_number` guarda el numero propio asignado a la proforma. -- `target_invoice_series_code` es opcional y solo puede apuntar a una serie de `issued_invoice`. -- una serie `PF` o cualquier otra serie de `document_type = proforma` nunca debe persistirse en `target_invoice_series_code`. -- si `target_invoice_series_code` queda `NULL`, la emision de la factura usara la serie default activa de `issued_invoice`. -- `issued_invoices` usa `invoice_series_code`, `invoice_number`, `invoice_reference`, `invoice_date` y `source_proforma_id`. -- `proformas` no guarda `issued_invoice_id`; la relacion se resuelve con `issued_invoices.source_proforma_id`. -- Los impuestos del esquema actual son por documento, no por linea: `proforma_taxes` y `issued_invoice_taxes`. -- `payment_method` y `payment_term` en `issued_invoices` son snapshot historico, no estado vivo de cobro. -- No hay doble escritura. -- Legacy queda congelado solo para validacion y rollback cuando se haga el cutover. - -## Estado de fase 1B - -- Existen mappers Sequelize V2 sobre `proformas`, `proforma_items`, `proforma_taxes`. -- Existen mappers Sequelize V2 sobre `issued_invoices`, `issued_invoice_items`, `issued_invoice_taxes`. -- Existen repositorios Sequelize V2 listos para convivir con los repositorios legacy. -- El wiring activo legacy no se ha sustituido todavia. -- El dominio/application sigue arrastrando naming legacy en algunos contratos (`series`, `invoiceNumber`, `linkedProformaId`); la traduccion queda encapsulada en Infrastructure V2. -- Esta afirmación quedó obsoleta. El runtime actual ya soporta `payment_term` en Company, Customer, Proforma e Issued Invoice según `docs/catalogs/payment-terms.md`. - -## Estado de fase 1C - -- El wiring activo del modulo usa persistencia V2 en los composition roots principales. -- `buildV2ProformaPersistence(...)` queda activo en: - - `modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts` - - `modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts` -- `buildV2IssuedInvoicePersistence(...)` queda activo en: - - `modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoices.di.ts` - - `modules/customer-invoices/src/api/infrastructure/issued-invoices/di/issued-invoice-public-services.ts` -- La persistencia legacy no se borra, pero deja de ser la ruta operativa principal. -- La emision `Proforma -> IssuedInvoice` ahora encadena sobre V2: - - lee `proformas` - - crea `issued_invoices` - - crea `issued_invoice_items` - - crea `issued_invoice_taxes` - - marca la proforma como `issued` - - usa `issued_invoices.source_proforma_id` -- No hay doble escritura: cada composition root sigue instanciando una sola familia de repositorios. -- `VerifactuRecord` queda preparado para la ruta V2 mediante `issued_invoice_id` como relacion principal. -- La migracion/backfill real sigue pendiente. Si la BD real no tiene las tablas nuevas, el backend no podra arrancar correctamente con este wiring. - -## Estado de fase 1D - -- Existen scripts SQL separados para desarrollo: - - `docs/customer-invoices/sql/dev-split-proformas-issued-invoices.sql` - - `docs/customer-invoices/sql/validate-split-proformas-issued-invoices.sql` -- El SQL de desarrollo es idempotente y conservador: - - crea tablas V2 con `CREATE TABLE IF NOT EXISTS` - - hace backfill con `NOT EXISTS` - - no usa `DROP`, `TRUNCATE` ni `DELETE` - - no sobrescribe filas ya migradas - - no toca datos legacy salvo la columna transicional `verifactu_records.issued_invoice_id` -- Se documenta el orden de ejecucion en `docs/customer-invoices/sql/README.md`. -- En este workspace no se encontro una configuracion de BD de desarrollo inequivoca (`.env*` no presente en raiz del repo), por lo que no se ejecuto SQL real. -- Tampoco se hizo validacion funcional contra runtime real porque falta una BD de desarrollo confirmada y migrada para esta fase. - -## Dependencias compartidas y exclusivas - -### Compartidas entre legacy y V2 - -- contratos de Application (`IProformaRepository`, `IIssuedInvoiceRepository`) -- use cases, servicios de dominio y assemblers -- `TransactionManager` -- adaptadores legacy de `InvoiceSeriesNumberAssigner` para `IssuedInvoice` -- resolvers de catalogos -- servicios de documentos PDF/preview - -### Exclusivas de legacy - -- `CustomerInvoiceModel` como storage real de Proforma e IssuedInvoice -- numerador de proformas sobre `customer_invoices.invoice_number` con `is_proforma = true` -- repositorios Sequelize legacy de `customer_invoices` -- `CustomerInvoiceSeriesModel` y `SequelizeInvoiceSeriesRepository` como wiring transicional no prioritario - -### Exclusivas de V2 - -- tablas `proformas`, `proforma_items`, `proforma_taxes` -- tablas `issued_invoices`, `issued_invoice_items`, `issued_invoice_taxes` -- numerador V2 de proformas sobre `document-series`, con snapshot en `proformas.document_series_id`, `proforma_number` y `proforma_reference` -- mappers y repositorios Sequelize V2 - -## Regla operativa actual de series en proformas - -La semantica operativa vigente ya no se mantiene en este documento historico. - -Fuente canonica actual: - -- `docs/customer-invoices/proforma-series-contract.md` -- `docs/customer-invoices/proforma-create-contract.md` -- `docs/customer-invoices/proforma-tax-config.md` - -Este documento conserva solo el contexto de migracion, fases y SQL historico. - -## Estado Fase 2B - -- `customer-invoices` ya no usa `customer_invoice_series` como fuente primaria para listar series activas ni para asignar numeracion nueva en `issued_invoice` y `proforma` -- `/catalogs/invoice-series` fue transicional y queda eliminado antes de produccion -- `document-series` queda como API canonica para listar series activas y asignar numeracion -- la DDL de `proformas` y la migracion/validacion SQL quedan documentadas, no ejecutadas -- la propagacion de `branchId` sigue pendiente porque el contexto de sucursal no llega todavia a los casos de uso activos - -## Estado Fase 2C - -- se detecto un entorno de desarrollo en `apps/server/.env.development` -- produccion quedo descartada para ejecucion en esta fase -- no se ejecuto SQL real desde esta terminal por bloqueo operativo del cliente de BD local -- la DDL de proformas se endurecio para compatibilidad MariaDB/MySQL mediante `information_schema` y SQL dinamico -- la validacion funcional y la prueba de concurrencia quedaron documentadas para ejecucion manual sobre la BD de desarrollo segura - -## Estado Fase 2D - -- el contrato publico de proformas ya soporta `target_invoice_series_code` en create y update -- la semantica detallada del alias legacy `series` se consolido despues en `proforma-series-contract.md` -- el cliente interno del modulo ya emite `target_invoice_series_code` en requests nuevos - -## Estado Fase 2E - -- create separa `proforma_series_code` de `target_invoice_series_code` -- las reglas operativas exactas se consolidaron despues en `proforma-series-contract.md` -- la UI nueva de proformas ya envia `proforma_series_code` y `target_invoice_series_code` - -## Estado Fase 2F - -- create de proforma se consolida como creacion de cabecera/documento inicial -- la semantica funcional exacta de create se consolido despues en `proforma-create-contract.md` - -## Estado Fase 2G - -- `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2 -- el borrado es logico mediante `deleted_at` -- el archivado es logico mediante `archived_at` -- el listado V2 de proformas usa `Criteria filters[]` para `archived_at` y `status` -- `GET /proformas/:id` permite abrir proformas archivadas -- las reglas funcionales exactas de borrado y numeracion se consolidaron despues en `proforma-series-contract.md` -- la validacion de borrado comprueba tambien `linked_invoice_id` y `issued_invoices.source_proforma_id` -- la numeracion de `document-series` no se reutiliza ni se decrementa tras un borrado - -## Rollback recomendado - -- no aplicar nada en produccion en esta fase -- si en desarrollo se ejecuta la DDL/migracion y hay que revertir, hacerlo con backup previo o clon de desarrollo, nunca con borrado destructivo improvisado -- conservar `customer_invoice_series` solo como legado historico hasta ejecutar la limpieza operativa documentada - -## Tablas nuevas previstas - -### proformas - -- `id` -- `company_id` -- `status` -- `proforma_reference` -- `proforma_date` -- `operation_date` -- `target_invoice_series_code` -- `reference` -- `description` -- `notes` -- `language_code` -- `currency_code` -- `customer_id` -- snapshot cliente -- `payment_method_id` -- `payment_term_id` -- `tax_regime_code` -- totales monetarios actuales -- `created_at` -- `updated_at` -- `deleted_at` - -### issued_invoices - -- `id` -- `company_id` -- `status` -- `source_proforma_id` -- `invoice_series_code` -- `invoice_number` -- `invoice_reference` -- `invoice_date` -- `operation_date` -- `reference` -- `description` -- `notes` -- `language_code` -- `currency_code` -- `customer_id` -- snapshot cliente -- `payment_method_id` -- `payment_method_name` -- `payment_method_description` -- `payment_term_id` -- `payment_term_name` -- `payment_term_description` -- `payment_term_snapshot_json` -- `tax_regime_code` -- `tax_regime_description` -- totales monetarios actuales -- `created_at` -- `updated_at` -- `deleted_at` - -### Items y taxes - -- `proforma_items.proforma_id` -- `issued_invoice_items.issued_invoice_id` -- `proforma_taxes.proforma_id` -- `issued_invoice_taxes.issued_invoice_id` - -### Verifactu - -- `verifactu_records.issued_invoice_id` nuevo -- `verifactu_records.invoice_id` legacy temporal durante la transicion de fase 1A - -## SQL orientativo de backfill - -### Proformas - -```sql -INSERT INTO proformas ( - id, - company_id, - status, - proforma_reference, - proforma_date, - operation_date, - target_invoice_series_code, - reference, - description, - notes, - language_code, - currency_code, - customer_id, - payment_method_id, - payment_term_id, - tax_regime_code, - subtotal_amount_value, - subtotal_amount_scale, - items_discount_amount_value, - items_discount_amount_scale, - global_discount_percentage_value, - global_discount_percentage_scale, - global_discount_amount_value, - global_discount_amount_scale, - total_discount_amount_value, - total_discount_amount_scale, - taxable_amount_value, - taxable_amount_scale, - iva_amount_value, - iva_amount_scale, - rec_amount_value, - rec_amount_scale, - retention_amount_value, - retention_amount_scale, - taxes_amount_value, - taxes_amount_scale, - total_amount_value, - total_amount_scale, - created_at, - updated_at, - deleted_at -) -SELECT - id, - company_id, - status, - CASE - WHEN series IS NULL OR series = '' THEN invoice_number - ELSE CONCAT(series, '-', invoice_number) - END AS proforma_reference, - invoice_date, - operation_date, - series, - reference, - description, - notes, - language_code, - currency_code, - customer_id, - payment_method_id, - NULL AS payment_term_id, - tax_regime_code, - subtotal_amount_value, - subtotal_amount_scale, - items_discount_amount_value, - items_discount_amount_scale, - global_discount_percentage_value, - global_discount_percentage_scale, - global_discount_amount_value, - global_discount_amount_scale, - total_discount_amount_value, - total_discount_amount_scale, - taxable_amount_value, - taxable_amount_scale, - iva_amount_value, - iva_amount_scale, - rec_amount_value, - rec_amount_scale, - retention_amount_value, - retention_amount_scale, - taxes_amount_value, - taxes_amount_scale, - total_amount_value, - total_amount_scale, - created_at, - updated_at, - deleted_at -FROM customer_invoices -WHERE is_proforma = 1; -``` - -### Issued invoices - -```sql -INSERT INTO issued_invoices ( - id, - company_id, - status, - source_proforma_id, - invoice_series_code, - invoice_number, - invoice_date, - operation_date, - reference, - description, - notes, - language_code, - currency_code, - customer_id, - customer_tin, - customer_name, - customer_street, - customer_street2, - customer_city, - customer_province, - customer_postal_code, - customer_country, - payment_method_id, - payment_method_name, - payment_method_description, - payment_term_id, - payment_term_name, - payment_term_description, - payment_term_snapshot_json, - tax_regime_code, - tax_regime_description, - subtotal_amount_value, - subtotal_amount_scale, - items_discount_amount_value, - items_discount_amount_scale, - global_discount_percentage_value, - global_discount_percentage_scale, - global_discount_amount_value, - global_discount_amount_scale, - total_discount_amount_value, - total_discount_amount_scale, - taxable_amount_value, - taxable_amount_scale, - iva_amount_value, - iva_amount_scale, - rec_amount_value, - rec_amount_scale, - retention_amount_value, - retention_amount_scale, - taxes_amount_value, - taxes_amount_scale, - total_amount_value, - total_amount_scale, - created_at, - updated_at, - deleted_at -) -SELECT - id, - company_id, - status, - proforma_id, - series, - invoice_number, - invoice_date, - operation_date, - reference, - description, - notes, - language_code, - currency_code, - customer_id, - customer_tin, - customer_name, - customer_street, - customer_street2, - customer_city, - customer_province, - customer_postal_code, - customer_country, - payment_method_id, - payment_method_description, - payment_method_description, - NULL AS payment_term_id, - NULL AS payment_term_name, - NULL AS payment_term_description, - NULL AS payment_term_snapshot_json, - COALESCE(tax_regime_code, "01"), - COALESCE(tax_regime_description, "01: Operación de régimen general."), - subtotal_amount_value, - subtotal_amount_scale, - items_discount_amount_value, - items_discount_amount_scale, - global_discount_percentage_value, - global_discount_percentage_scale, - global_discount_amount_value, - global_discount_amount_scale, - total_discount_amount_value, - total_discount_amount_scale, - taxable_amount_value, - taxable_amount_scale, - iva_amount_value, - iva_amount_scale, - rec_amount_value, - rec_amount_scale, - retention_amount_value, - retention_amount_scale, - taxes_amount_value, - taxes_amount_scale, - total_amount_value, - total_amount_scale, - created_at, - updated_at, - deleted_at -FROM customer_invoices -WHERE is_proforma = 0; -``` - -### Items - -```sql -INSERT INTO proforma_items ( - item_id, - proforma_id, - position, - description, - quantity_value, - quantity_scale, - unit_amount_value, - unit_amount_scale, - subtotal_amount_value, - subtotal_amount_scale, - item_discount_percentage_value, - item_discount_percentage_scale, - item_discount_amount_value, - item_discount_amount_scale, - global_discount_percentage_value, - global_discount_percentage_scale, - global_discount_amount_value, - global_discount_amount_scale, - total_discount_amount_value, - total_discount_amount_scale, - taxable_amount_value, - taxable_amount_scale, - iva_code, - iva_percentage_value, - iva_percentage_scale, - iva_amount_value, - iva_amount_scale, - rec_code, - rec_percentage_value, - rec_percentage_scale, - rec_amount_value, - rec_amount_scale, - retention_code, - retention_percentage_value, - retention_percentage_scale, - retention_amount_value, - retention_amount_scale, - taxes_amount_value, - taxes_amount_scale, - total_amount_value, - total_amount_scale, - created_at, - updated_at -) -SELECT - i.item_id, - i.invoice_id, - i.position, - i.description, - i.quantity_value, - i.quantity_scale, - i.unit_amount_value, - i.unit_amount_scale, - i.subtotal_amount_value, - i.subtotal_amount_scale, - i.item_discount_percentage_value, - i.item_discount_percentage_scale, - i.item_discount_amount_value, - i.item_discount_amount_scale, - i.global_discount_percentage_value, - i.global_discount_percentage_scale, - i.global_discount_amount_value, - i.global_discount_amount_scale, - i.total_discount_amount_value, - i.total_discount_amount_scale, - i.taxable_amount_value, - i.taxable_amount_scale, - i.iva_code, - i.iva_percentage_value, - i.iva_percentage_scale, - i.iva_amount_value, - i.iva_amount_scale, - i.rec_code, - i.rec_percentage_value, - i.rec_percentage_scale, - i.rec_amount_value, - i.rec_amount_scale, - i.retention_code, - i.retention_percentage_value, - i.retention_percentage_scale, - i.retention_amount_value, - i.retention_amount_scale, - i.taxes_amount_value, - i.taxes_amount_scale, - i.total_amount_value, - i.total_amount_scale, - now(), - now() -FROM customer_invoice_items i -JOIN customer_invoices ci ON ci.id = i.invoice_id -WHERE ci.is_proforma = 1; - - - - -INSERT INTO issued_invoice_items ( - item_id, - issued_invoice_id, - position, - description, - quantity_value, - quantity_scale, - unit_amount_value, - unit_amount_scale, - subtotal_amount_value, - subtotal_amount_scale, - item_discount_percentage_value, - item_discount_percentage_scale, - item_discount_amount_value, - item_discount_amount_scale, - global_discount_percentage_value, - global_discount_percentage_scale, - global_discount_amount_value, - global_discount_amount_scale, - total_discount_amount_value, - total_discount_amount_scale, - taxable_amount_value, - taxable_amount_scale, - iva_code, - iva_percentage_value, - iva_percentage_scale, - iva_amount_value, - iva_amount_scale, - rec_code, - rec_percentage_value, - rec_percentage_scale, - rec_amount_value, - rec_amount_scale, - retention_code, - retention_percentage_value, - retention_percentage_scale, - retention_amount_value, - retention_amount_scale, - taxes_amount_value, - taxes_amount_scale, - total_amount_value, - total_amount_scale, - created_at, - updated_at - ) -SELECT - i.item_id, - i.invoice_id, - i.position, - i.description, - i.quantity_value, - i.quantity_scale, - i.unit_amount_value, - i.unit_amount_scale, - i.subtotal_amount_value, - i.subtotal_amount_scale, - i.item_discount_percentage_value, - i.item_discount_percentage_scale, - i.item_discount_amount_value, - i.item_discount_amount_scale, - i.global_discount_percentage_value, - i.global_discount_percentage_scale, - i.global_discount_amount_value, - i.global_discount_amount_scale, - i.total_discount_amount_value, - i.total_discount_amount_scale, - i.taxable_amount_value, - i.taxable_amount_scale, - i.iva_code, - i.iva_percentage_value, - i.iva_percentage_scale, - i.iva_amount_value, - i.iva_amount_scale, - i.rec_code, - i.rec_percentage_value, - i.rec_percentage_scale, - i.rec_amount_value, - i.rec_amount_scale, - i.retention_code, - i.retention_percentage_value, - i.retention_percentage_scale, - i.retention_amount_value, - i.retention_amount_scale, - i.taxes_amount_value, - i.taxes_amount_scale, - i.total_amount_value, - i.total_amount_scale, - now(), - now() -FROM customer_invoice_items i -JOIN customer_invoices ci ON ci.id = i.invoice_id -WHERE ci.is_proforma = 0; -``` - -### Taxes - -```sql -INSERT INTO proforma_taxes ( - tax_id, - proforma_id, - taxable_amount_value, - taxable_amount_scale, - iva_code, - iva_percentage_value, - iva_percentage_scale, - iva_amount_value, - iva_amount_scale, - rec_code, - rec_percentage_value, - rec_percentage_scale, - rec_amount_value, - rec_amount_scale, - retention_code, - retention_percentage_value, - retention_percentage_scale, - retention_amount_value, - retention_amount_scale, - taxes_amount_value, - taxes_amount_scale, - created_at, - updated_at -) -SELECT - t.tax_id, - t.invoice_id, - t.taxable_amount_value, - t.taxable_amount_scale, - t.iva_code, - t.iva_percentage_value, - t.iva_percentage_scale, - t.iva_amount_value, - t.iva_amount_scale, - t.rec_code, - t.rec_percentage_value, - t.rec_percentage_scale, - t.rec_amount_value, - t.rec_amount_scale, - t.retention_code, - t.retention_percentage_value, - t.retention_percentage_scale, - t.retention_amount_value, - t.retention_amount_scale, - t.taxes_amount_value, - t.taxes_amount_scale, - now(), - now() -FROM customer_invoice_taxes t -JOIN customer_invoices ci ON ci.id = t.invoice_id -WHERE ci.is_proforma = 1; - -INSERT INTO issued_invoice_taxes (tax_id, - issued_invoice_id, - taxable_amount_value, - taxable_amount_scale, - iva_code, - iva_percentage_value, - iva_percentage_scale, - iva_amount_value, - iva_amount_scale, - rec_code, - rec_percentage_value, - rec_percentage_scale, - rec_amount_value, - rec_amount_scale, - retention_code, - retention_percentage_value, - retention_percentage_scale, - retention_amount_value, - retention_amount_scale, - taxes_amount_value, - taxes_amount_scale, - created_at, - updated_at) - SELECT - t.tax_id, - t.invoice_id, - t.taxable_amount_value, - t.taxable_amount_scale, - t.iva_code, - t.iva_percentage_value, - t.iva_percentage_scale, - t.iva_amount_value, - t.iva_amount_scale, - t.rec_code, - t.rec_percentage_value, - t.rec_percentage_scale, - t.rec_amount_value, - t.rec_amount_scale, - t.retention_code, - t.retention_percentage_value, - t.retention_percentage_scale, - t.retention_amount_value, - t.retention_amount_scale, - t.taxes_amount_value, - t.taxes_amount_scale, - now(), - now() -FROM customer_invoice_taxes t -JOIN customer_invoices ci ON ci.id = t.invoice_id -WHERE ci.is_proforma = 0; -``` - -### Verifactu - -```sql -UPDATE verifactu_records vr -JOIN customer_invoices ci ON ci.id = vr.invoice_id -SET vr.issued_invoice_id = vr.invoice_id -WHERE ci.is_proforma = 0; -``` - -## Validaciones SQL - -### Conteos - -```sql -SELECT COUNT(*) FROM customer_invoices WHERE is_proforma = 1; -SELECT COUNT(*) FROM proformas; - -SELECT COUNT(*) FROM customer_invoices WHERE is_proforma = 0; -SELECT COUNT(*) FROM issued_invoices; - -SELECT COUNT(*) -FROM customer_invoice_items i -JOIN customer_invoices ci ON ci.id = i.invoice_id -WHERE ci.is_proforma = 1; -SELECT COUNT(*) FROM proforma_items; - -SELECT COUNT(*) -FROM customer_invoice_taxes t -JOIN customer_invoices ci ON ci.id = t.invoice_id -WHERE ci.is_proforma = 0; -SELECT COUNT(*) FROM issued_invoice_taxes; -``` - -### Duplicados fiscales - -```sql -SELECT company_id, invoice_series_code, invoice_number, COUNT(*) -FROM issued_invoices -GROUP BY company_id, invoice_series_code, invoice_number -HAVING COUNT(*) > 1; -``` - -### Huerfanos - -```sql -SELECT COUNT(*) -FROM issued_invoices ii -LEFT JOIN proformas p ON p.id = ii.source_proforma_id -WHERE ii.source_proforma_id IS NOT NULL - AND p.id IS NULL; - -SELECT COUNT(*) -FROM verifactu_records vr -LEFT JOIN issued_invoices ii ON ii.id = vr.issued_invoice_id -WHERE vr.issued_invoice_id IS NOT NULL - AND ii.id IS NULL; -``` - -### Totales - -```sql -SELECT SUM(total_amount_value) FROM customer_invoices WHERE is_proforma = 1; -SELECT SUM(total_amount_value) FROM proformas; - -SELECT SUM(total_amount_value) FROM customer_invoices WHERE is_proforma = 0; -SELECT SUM(total_amount_value) FROM issued_invoices; -``` - -### Proformas sin lineas con impuestos materializados - -```sql -SELECT - p.id, - p.proforma_reference, - COUNT(DISTINCT pi.item_id) AS item_rows, - COUNT(DISTINCT pt.tax_id) AS tax_rows -FROM proformas p -LEFT JOIN proforma_items pi - ON pi.proforma_id = p.id -LEFT JOIN proforma_taxes pt - ON pt.proforma_id = p.id -GROUP BY p.id, p.proforma_reference -HAVING COUNT(DISTINCT pi.item_id) = 0 - AND COUNT(DISTINCT pt.tax_id) > 0; -``` - -Resultado esperado: - -- `0` filas - -## No incluido en fase 1A - -- no cutover del backend -- no borrado de tablas legacy -- no doble escritura -- no ejecucion de migraciones en una BD real -- no integracion Verifactu externa -- no dominio de cobros, vencimientos vivos o conciliacion - -## No incluido en fase 1C - -- no borrado de legacy -- no backfill real -- no ejecucion de migraciones -- no doble escritura -- no integracion Verifactu funcional nueva -- no cambios en controllers, rutas o DTOs publicos - -## No incluido en fase 1D - -- no ejecucion en produccion -- no borrado de tablas legacy -- no eliminacion de modelos/repositorios legacy -- no integracion Verifacti funcional -- no dominio de cobros -- no doble escritura - -## Resultado de validacion tecnica fase 1D - -- `tsc` modulo `customer-invoices`: esperado ejecutar en esta fase -- `biome lint` sobre `src/api/infrastructure` y `docs/customer-invoices`: esperado ejecutar en esta fase -- `tsc` raiz: esperado ejecutar en esta fase -- Validacion SQL real: no ejecutada por falta de entorno de desarrollo inequivoco -- Validacion runtime real contra BD migrada: no ejecutada por la misma razon - -## Siguiente fase recomendada - -- confirmar `.env`/credenciales de una BD de desarrollo segura -- ejecutar solo los runbooks SQL que sigan siendo necesarios para el entorno real afectado -- levantar el backend contra esa BD y validar `create`, `update`, `get`, `list` e `issue proforma` -- validar numeracion de proformas V2 y asignacion de serie/numero de factura emitida -- revisar la ruta `VerifactuRecord -> issued_invoice_id` ya con persistencia V2 activa - -## Nota document-series - -`customer-invoices` ya migró funcionalmente desde `CustomerInvoiceSeriesModel` hacia `document-series`. Como nota histórica de Fase 2A: - -- `issued_invoice` ya asigna numeración nueva mediante `document-series` -- `proforma` ya asigna referencia nueva mediante `document-series` -- la UI debe consumir `GET /document-series` filtrando por `document_type` -- `CustomerInvoiceSeriesModel` deja de existir como modelo runtime activo - -## Fase 2B no incluida - -- no ejecucion de SQL en una BD real -- no borrado de tablas/modelos/repositorios legacy -- no propagacion completa de `branchId` -- no cambios de frontend salvo compatibilidad ya existente -- no nueva infraestructura de tests automatizados para estos modulos diff --git a/docs/customers/customer-selection-and-quick-create.md b/docs/customers/customer-selection-and-quick-create.md index d48fe6af..2384251b 100644 --- a/docs/customers/customer-selection-and-quick-create.md +++ b/docs/customers/customer-selection-and-quick-create.md @@ -107,7 +107,7 @@ Campos reales actuales: - `city` - `province` - `postalCode` -- `country` +- `countryCode` - `primaryEmail` - `primaryPhone` - `primaryMobile` @@ -286,13 +286,13 @@ El schema público de formulario rápido actual (`QuickCustomerCreateFormSchema` La feature reutiliza `buildCustomerCreateDefault()` del área interna de create y resuelve por defecto: -- `country = ES` +- `countryCode = ES` - `languageCode = es` - `currencyCode = EUR` Además, `useQuickCreateCustomerFlow` y `useQuickCreateCustomerDialogController` admiten contexto opcional: -- `defaultCountry?` +- `defaultCountryCode?` - `defaultLanguageCode?` - `defaultCurrencyCode?` diff --git a/modules/companies/src/api/application/companies/mappers/company-address.mapper.test.ts b/modules/companies/src/api/application/companies/mappers/company-address.mapper.test.ts new file mode 100644 index 00000000..d699e038 --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/company-address.mapper.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { toPostalAddress } from "./company-address.mapper"; + +test("maps a complete request address to PostalAddress", () => { + const result = toPostalAddress({ + street: "Calle Mayor 1", + street_2: null, + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.street.unwrap().toPrimitive(), "Calle Mayor 1"); + assert.equal(result.data.street2.isNone(), true); + assert.equal(result.data.city.unwrap().toPrimitive(), "Madrid"); + assert.equal(result.data.postalCode.unwrap().toPrimitive(), "28001"); + assert.equal(result.data.province.unwrap().toPrimitive(), "Madrid"); + assert.equal(result.data.countryCode.unwrap().toPrimitive(), "ES"); +}); diff --git a/modules/companies/src/api/application/companies/mappers/company-address.mapper.ts b/modules/companies/src/api/application/companies/mappers/company-address.mapper.ts new file mode 100644 index 00000000..b02e92db --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/company-address.mapper.ts @@ -0,0 +1,35 @@ +import { + City, + CountryCode, + PostalAddress, + PostalCode, + Province, + Street, + maybeFromNullableResult, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { CompanyAddressRequestDTO } from "../../../../common"; + +export function toPostalAddress(dto: CompanyAddressRequestDTO): Result { + const streetResult = Street.create(dto.street); + if (streetResult.isFailure) return Result.fail(streetResult.error); + const street2Result = maybeFromNullableResult(dto.street_2, (value) => Street.create(value)); + if (street2Result.isFailure) return Result.fail(street2Result.error); + const cityResult = City.create(dto.city); + if (cityResult.isFailure) return Result.fail(cityResult.error); + const postalCodeResult = PostalCode.create(dto.postal_code); + if (postalCodeResult.isFailure) return Result.fail(postalCodeResult.error); + const provinceResult = Province.create(dto.province); + if (provinceResult.isFailure) return Result.fail(provinceResult.error); + const countryCodeResult = CountryCode.create(dto.country_code); + if (countryCodeResult.isFailure) return Result.fail(countryCodeResult.error); + return PostalAddress.create({ + street: Maybe.some(streetResult.data), + street2: street2Result.data, + city: Maybe.some(cityResult.data), + postalCode: Maybe.some(postalCodeResult.data), + province: Maybe.some(provinceResult.data), + countryCode: Maybe.some(countryCodeResult.data), + }); +} diff --git a/modules/companies/src/api/application/companies/mappers/company-response.mapper.test.ts b/modules/companies/src/api/application/companies/mappers/company-response.mapper.test.ts new file mode 100644 index 00000000..9a371229 --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/company-response.mapper.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CompanyPublicModel } from "../public"; + +import { toCompanyResponseDTO } from "./company-response.mapper"; + +const company: Omit = { + id: "company-id", + legalName: "Rodax Labs", + tradeName: "Rodax", + tin: "B12345678", + slug: "rodax-labs", + email: "hello@rodax.test", + phone: "+34911111111", + website: "https://rodax.test", + defaultPaymentTermId: "payment-term-id", + status: "active", +}; + +test("maps a company without address", () => { + const response = toCompanyResponseDTO({ ...company, address: null }); + + assert.equal(response.address, null); + assert.equal(response.legal_name, "Rodax Labs"); + assert.equal(response.default_payment_term_id, "payment-term-id"); +}); + +test("maps a company address to the response contract", () => { + const response = toCompanyResponseDTO({ + ...company, + address: { + street: "Calle Mayor 1", + street2: null, + city: "Madrid", + postalCode: "28001", + province: "Madrid", + countryCode: "ES", + }, + }); + + assert.deepEqual(response.address, { + street: "Calle Mayor 1", + street_2: null, + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }); +}); diff --git a/modules/companies/src/api/application/companies/mappers/company-response.mapper.ts b/modules/companies/src/api/application/companies/mappers/company-response.mapper.ts new file mode 100644 index 00000000..78817bb5 --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/company-response.mapper.ts @@ -0,0 +1,28 @@ +import type { CompanyResponseDTO } from "../../../../common"; +import type { CompanyPublicModel } from "../public"; + +export function toCompanyResponseDTO(model: CompanyPublicModel): CompanyResponseDTO { + return { + id: model.id, + legal_name: model.legalName, + trade_name: model.tradeName, + tin: model.tin, + slug: model.slug, + email: model.email, + phone: model.phone, + website: model.website, + address: + model.address === null + ? null + : { + street: model.address.street, + street_2: model.address.street2, + city: model.address.city, + postal_code: model.address.postalCode, + province: model.address.province, + country_code: model.address.countryCode, + }, + default_payment_term_id: model.defaultPaymentTermId, + status: model.status, + }; +} diff --git a/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.test.ts b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.test.ts new file mode 100644 index 00000000..f5e4bdf5 --- /dev/null +++ b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { City, CountryCode, Name, PostalAddress, PostalCode, Province, Street, UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { Company, CompanySlug, CompanyStatus } from "../../../../domain"; +import { CompanyPublicModelMapper } from "./company-public-model.mapper"; + +const unwrap = (result: { data: T }): T => result.data; +const buildAddress = (street2 = Maybe.some(unwrap(Street.create("Suite 2")))) => unwrap(PostalAddress.create({ street: Maybe.some(unwrap(Street.create("Street 1"))), street2, city: Maybe.some(unwrap(City.create("Madrid"))), postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), province: Maybe.some(unwrap(Province.create("Madrid"))), countryCode: Maybe.some(unwrap(CountryCode.create("ES"))) })); +const buildCompany = (address = Maybe.none()) => unwrap(Company.create({ legalName: unwrap(Name.create("Rodax")), tradeName: Maybe.none(), tin: Maybe.none(), slug: unwrap(CompanySlug.create("rodax")), email: Maybe.none(), phone: Maybe.none(), website: Maybe.none(), address, defaultPaymentTermId: Maybe.none(), status: CompanyStatus.createActive(), createdAt: unwrap(UtcDate.createFromISO("2026-01-01")), updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")) }, UniqueID.generateNewID())); +const mapper = new CompanyPublicModelMapper(); + +test("maps Company address to public primitives", () => { + assert.equal(mapper.toPublicModel(buildCompany()).address, null); + const full = mapper.toPublicModel(buildCompany(Maybe.some(buildAddress()))).address; + assert.deepEqual(full, { street: "Street 1", street2: "Suite 2", city: "Madrid", postalCode: "28001", province: "Madrid", countryCode: "ES" }); + assert.equal("countryCode" in full!, true); + assert.equal("country" in full!, false); + assert.equal(mapper.toPublicModel(buildCompany(Maybe.some(buildAddress(Maybe.none())))).address?.street2, null); +}); diff --git a/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts index 16c20d28..e741a87e 100644 --- a/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts +++ b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts @@ -14,6 +14,17 @@ export class CompanyPublicModelMapper { email: maybeToNullable(company.email, (value) => value.toPrimitive()), phone: maybeToNullable(company.phone, (value) => value.toPrimitive()), website: maybeToNullable(company.website, (value) => value.toPrimitive()), + address: company.address.match( + (address) => ({ + street: address.street.unwrap().toPrimitive(), + street2: maybeToNullable(address.street2, (value) => value.toPrimitive()), + city: address.city.unwrap().toPrimitive(), + postalCode: address.postalCode.unwrap().toPrimitive(), + province: address.province.unwrap().toPrimitive(), + countryCode: address.countryCode.unwrap().toPrimitive(), + }), + () => null + ), defaultPaymentTermId: maybeToNullable(company.defaultPaymentTermId, (value) => value.toPrimitive() ), diff --git a/modules/companies/src/api/application/companies/public/models/company-public.model.ts b/modules/companies/src/api/application/companies/public/models/company-public.model.ts index 8d9bc9ba..2a2e233d 100644 --- a/modules/companies/src/api/application/companies/public/models/company-public.model.ts +++ b/modules/companies/src/api/application/companies/public/models/company-public.model.ts @@ -1,3 +1,12 @@ +export interface CompanyPublicAddress { + street: string; + street2: string | null; + city: string; + postalCode: string; + province: string; + countryCode: string; +} + export interface CompanyPublicModel { id: string; legalName: string; @@ -7,6 +16,7 @@ export interface CompanyPublicModel { email: string | null; phone: string | null; website: string | null; + address: CompanyPublicAddress | null; defaultPaymentTermId: string | null; status: "active" | "disabled"; } diff --git a/modules/companies/src/api/application/companies/services/company-audit.services.test.ts b/modules/companies/src/api/application/companies/services/company-audit.services.test.ts index d7f2788a..6572289c 100644 --- a/modules/companies/src/api/application/companies/services/company-audit.services.test.ts +++ b/modules/companies/src/api/application/companies/services/company-audit.services.test.ts @@ -47,6 +47,7 @@ const buildCompany = (status = CompanyStatus.createActive()) => { email: Maybe.some(email.data), phone: Maybe.some(phone.data), website: Maybe.some(website.data), + address: Maybe.none(), defaultPaymentTermId: Maybe.none(), status, createdAt: now.data, diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts index c9ec6ddf..1139fd90 100644 --- a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts @@ -3,9 +3,15 @@ import test from "node:test"; import type { ITransactionManager } from "@erp/core/api"; import { + City, + CountryCode, EmailAddress, Name, PhoneNumber, + PostalAddress, + PostalCode, + Province, + Street, TextValue, URLAddress, UniqueID, @@ -15,6 +21,7 @@ import { Maybe, Result } from "@repo/rdx-utils"; import { Company, CompanySlug, CompanyStatus } from "../../../domain"; import { CompanyPublicModelMapper } from "../public"; + import { CreateCompanyUseCase } from "./create-company.use-case"; const buildTransactionManager = (): ITransactionManager => ({ @@ -43,19 +50,23 @@ const buildCompany = () => { assert.equal(website.isSuccess, true); assert.equal(tin.isSuccess, true); - const companyResult = Company.create({ - legalName: legalName.data, - tradeName: Maybe.none(), - tin: Maybe.some(tin.data), - slug: slug.data, - email: Maybe.some(email.data), - phone: Maybe.some(phone.data), - website: Maybe.some(website.data), - defaultPaymentTermId: Maybe.none(), - status: CompanyStatus.createActive(), - createdAt: now.data, - updatedAt: now.data, - }, id); + const companyResult = Company.create( + { + legalName: legalName.data, + tradeName: Maybe.none(), + tin: Maybe.some(tin.data), + slug: slug.data, + email: Maybe.some(email.data), + phone: Maybe.some(phone.data), + website: Maybe.some(website.data), + address: Maybe.none(), + defaultPaymentTermId: Maybe.none(), + status: CompanyStatus.createActive(), + createdAt: now.data, + updatedAt: now.data, + }, + id + ); assert.equal(companyResult.isSuccess, true); return companyResult.data; @@ -88,3 +99,86 @@ test("create devuelve la company sin default payment term", async () => { assert.equal(result.isSuccess, true); assert.equal(result.data.default_payment_term_id, null); }); + +test("create serializes the company public address", async () => { + const company = buildCompany(); + const address = PostalAddress.create({ + street: Maybe.some(Street.create("Calle Mayor 1").data), + street2: Maybe.none(), + city: Maybe.some(City.create("Madrid").data), + postalCode: Maybe.some(PostalCode.create("28001").data), + province: Maybe.some(Province.create("Madrid").data), + countryCode: Maybe.some(CountryCode.create("ES").data), + }); + assert.equal(address.isSuccess, true); + assert.equal( + company.update({ address: Maybe.some(address.data), updatedAt: company.updatedAt }).data, + true + ); + const useCase = new CreateCompanyUseCase({ + creator: { create: async () => Result.ok(company) }, + responseMapper: new CompanyPublicModelMapper(), + transactionManager: buildTransactionManager(), + }); + const result = await useCase.execute({ + dto: { + id: company.id.toPrimitive(), + legal_name: company.legalName.toPrimitive(), + trade_name: null, + tin: company.tin.unwrap().toPrimitive(), + slug: company.slug.toPrimitive(), + email: company.email.unwrap().toPrimitive(), + phone: company.phone.unwrap().toPrimitive(), + website: company.website.unwrap().toPrimitive(), + }, + }); + assert.equal(result.isSuccess, true); + assert.deepEqual(result.data.address, { + street: "Calle Mayor 1", + street_2: null, + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }); +}); + +test("create maps a complete request address to Company props", async () => { + const company = buildCompany(); + let capturedAddress: typeof company.address | undefined; + const useCase = new CreateCompanyUseCase({ + creator: { + create: async ({ props }) => { + capturedAddress = props.address; + return Result.ok(company); + }, + }, + responseMapper: new CompanyPublicModelMapper(), + transactionManager: buildTransactionManager(), + }); + const result = await useCase.execute({ + dto: { + id: company.id.toPrimitive(), + legal_name: company.legalName.toPrimitive(), + trade_name: null, + tin: company.tin.unwrap().toPrimitive(), + slug: company.slug.toPrimitive(), + email: company.email.unwrap().toPrimitive(), + phone: company.phone.unwrap().toPrimitive(), + website: company.website.unwrap().toPrimitive(), + address: { + street: "Calle Mayor 1", + street_2: null, + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }, + }, + }); + assert.equal(result.isSuccess, true); + assert.equal(capturedAddress?.isSome(), true); + assert.equal(capturedAddress?.unwrap().street.unwrap().toPrimitive(), "Calle Mayor 1"); + assert.equal(capturedAddress?.unwrap().street2.isNone(), true); + assert.equal(capturedAddress?.unwrap().countryCode.unwrap().toPrimitive(), "ES"); +}); diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts index 739c321a..3f5eb12b 100644 --- a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts @@ -1,4 +1,5 @@ import type { ITransactionManager } from "@erp/core/api"; +import type { PostalAddress } from "@repo/rdx-ddd"; import { EmailAddress, Name, @@ -11,8 +12,10 @@ import { } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import type { CreateCompanyRequestDTO, CompanyResponseDTO } from "../../../../common"; +import type { CompanyResponseDTO, CreateCompanyRequestDTO } from "../../../../common"; import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain"; +import { toPostalAddress } from "../mappers/company-address.mapper"; +import { toCompanyResponseDTO } from "../mappers/company-response.mapper"; import type { CompanyPublicModelMapper } from "../public"; import type { ICompanyCreator } from "../services"; @@ -25,7 +28,9 @@ export class CreateCompanyUseCase { } ) {} - public execute(params: { dto: CreateCompanyRequestDTO }): Promise> { + public execute(params: { + dto: CreateCompanyRequestDTO; + }): Promise> { const mappedResult = mapCreateCompanyProps(params.dto); if (mappedResult.isFailure) { return Promise.resolve(Result.fail(mappedResult.error)); @@ -40,7 +45,9 @@ export class CreateCompanyUseCase { return Result.fail(createResult.error); } - return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(createResult.data))); + return Result.ok( + toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(createResult.data)) + ); } catch (error: unknown) { return Result.fail(error as Error); } @@ -59,7 +66,9 @@ function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) { return Result.fail(legalNameResult.error); } - const tradeNameResult = maybeFromNullableResult(dto.trade_name ?? null, (value) => Name.create(value)); + const tradeNameResult = maybeFromNullableResult(dto.trade_name ?? null, (value) => + Name.create(value) + ); if (tradeNameResult.isFailure) { return Result.fail(tradeNameResult.error); } @@ -74,21 +83,36 @@ function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) { return Result.fail(slugResult.error); } - const emailResult = maybeFromNullableResult(dto.email ?? null, (value) => EmailAddress.create(value)); + const emailResult = maybeFromNullableResult(dto.email ?? null, (value) => + EmailAddress.create(value) + ); if (emailResult.isFailure) { return Result.fail(emailResult.error); } - const phoneResult = maybeFromNullableResult(dto.phone ?? null, (value) => PhoneNumber.create(value)); + const phoneResult = maybeFromNullableResult(dto.phone ?? null, (value) => + PhoneNumber.create(value) + ); if (phoneResult.isFailure) { return Result.fail(phoneResult.error); } - const websiteResult = maybeFromNullableResult(dto.website ?? null, (value) => URLAddress.create(value)); + const websiteResult = maybeFromNullableResult(dto.website ?? null, (value) => + URLAddress.create(value) + ); if (websiteResult.isFailure) { return Result.fail(websiteResult.error); } + let address: Maybe = Maybe.none(); + if (dto.address !== undefined && dto.address !== null) { + const addressResult = toPostalAddress(dto.address); + if (addressResult.isFailure) { + return Result.fail(addressResult.error); + } + address = Maybe.some(addressResult.data); + } + const now = UtcDateTime.now(); const props: ICompanyCreateProps = { @@ -99,6 +123,7 @@ function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) { email: emailResult.data, phone: phoneResult.data, website: websiteResult.data, + address, defaultPaymentTermId: Maybe.none(), status: CompanyStatus.createActive(), createdAt: now, @@ -110,29 +135,3 @@ function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) { props, }); } - -function toCompanyResponseDTO(model: { - id: string; - legalName: string; - tradeName: string | null; - tin: string | null; - slug: string; - email: string | null; - phone: string | null; - website: string | null; - defaultPaymentTermId: string | null; - status: "active" | "disabled"; -}): CompanyResponseDTO { - return { - id: model.id, - legal_name: model.legalName, - trade_name: model.tradeName, - tin: model.tin, - slug: model.slug, - email: model.email, - phone: model.phone, - website: model.website, - default_payment_term_id: model.defaultPaymentTermId, - status: model.status, - }; -} diff --git a/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts index deb89521..e371ed8a 100644 --- a/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts @@ -3,6 +3,7 @@ import { UniqueID, UtcDateTime } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { CompanyResponseDTO } from "../../../../common"; +import { toCompanyResponseDTO } from "../mappers/company-response.mapper"; import type { CompanyPublicModelMapper } from "../public"; import type { ICompanyFinder, ICompanyStatusChanger } from "../services"; @@ -46,29 +47,3 @@ export class DisableCompanyUseCase { }); } } - -function toCompanyResponseDTO(model: { - id: string; - legalName: string; - tradeName: string | null; - tin: string | null; - slug: string; - email: string | null; - phone: string | null; - website: string | null; - defaultPaymentTermId: string | null; - status: "active" | "disabled"; -}): CompanyResponseDTO { - return { - id: model.id, - legal_name: model.legalName, - trade_name: model.tradeName, - tin: model.tin, - slug: model.slug, - email: model.email, - phone: model.phone, - website: model.website, - default_payment_term_id: model.defaultPaymentTermId, - status: model.status, - }; -} diff --git a/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts index 5d6993f1..73dddaeb 100644 --- a/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts @@ -3,6 +3,7 @@ import { UniqueID, UtcDateTime } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { CompanyResponseDTO } from "../../../../common"; +import { toCompanyResponseDTO } from "../mappers/company-response.mapper"; import type { CompanyPublicModelMapper } from "../public"; import type { ICompanyFinder, ICompanyStatusChanger } from "../services"; @@ -46,29 +47,3 @@ export class EnableCompanyUseCase { }); } } - -function toCompanyResponseDTO(model: { - id: string; - legalName: string; - tradeName: string | null; - tin: string | null; - slug: string; - email: string | null; - phone: string | null; - website: string | null; - defaultPaymentTermId: string | null; - status: "active" | "disabled"; -}): CompanyResponseDTO { - return { - id: model.id, - legal_name: model.legalName, - trade_name: model.tradeName, - tin: model.tin, - slug: model.slug, - email: model.email, - phone: model.phone, - website: model.website, - default_payment_term_id: model.defaultPaymentTermId, - status: model.status, - }; -} diff --git a/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts b/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts index aa01ed0c..5a04849f 100644 --- a/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts @@ -3,6 +3,7 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { CompanyResponseDTO } from "../../../../common"; +import { toCompanyResponseDTO } from "../mappers/company-response.mapper"; import type { CompanyPublicModelMapper } from "../public"; import type { ICompanyFinder } from "../services"; @@ -35,29 +36,3 @@ export class GetCompanyByIdUseCase { }); } } - -function toCompanyResponseDTO(model: { - id: string; - legalName: string; - tradeName: string | null; - tin: string | null; - slug: string; - email: string | null; - phone: string | null; - website: string | null; - defaultPaymentTermId: string | null; - status: "active" | "disabled"; -}): CompanyResponseDTO { - return { - id: model.id, - legal_name: model.legalName, - trade_name: model.tradeName, - tin: model.tin, - slug: model.slug, - email: model.email, - phone: model.phone, - website: model.website, - default_payment_term_id: model.defaultPaymentTermId, - status: model.status, - }; -} diff --git a/modules/companies/src/api/application/companies/use-cases/get-current-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/get-current-company.use-case.ts index 42b04bed..3e151c52 100644 --- a/modules/companies/src/api/application/companies/use-cases/get-current-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/get-current-company.use-case.ts @@ -3,6 +3,7 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { CompanyResponseDTO } from "../../../../common"; +import { toCompanyResponseDTO } from "../mappers/company-response.mapper"; import type { CompanyPublicModelMapper } from "../public"; import type { ICompanyFinder } from "../services"; @@ -35,29 +36,3 @@ export class GetCurrentCompanyUseCase { }); } } - -function toCompanyResponseDTO(model: { - id: string; - legalName: string; - tradeName: string | null; - tin: string | null; - slug: string; - email: string | null; - phone: string | null; - website: string | null; - defaultPaymentTermId: string | null; - status: "active" | "disabled"; -}): CompanyResponseDTO { - return { - id: model.id, - legal_name: model.legalName, - trade_name: model.tradeName, - tin: model.tin, - slug: model.slug, - email: model.email, - phone: model.phone, - website: model.website, - default_payment_term_id: model.defaultPaymentTermId, - status: model.status, - }; -} diff --git a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts index 6391702e..829f4739 100644 --- a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts +++ b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts @@ -13,8 +13,9 @@ import { } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; -import { Company, CompanySlug, CompanyStatus, type CompanyPatchProps } from "../../../domain"; +import { Company, type CompanyPatchProps, CompanySlug, CompanyStatus } from "../../../domain"; import { CompanyPublicModelMapper } from "../public"; + import { UpdateCompanyUseCase } from "./update-company.use-case"; const buildTransactionManager = (): ITransactionManager => ({ @@ -43,19 +44,23 @@ const buildCompany = () => { assert.equal(website.isSuccess, true); assert.equal(tin.isSuccess, true); - const companyResult = Company.create({ - legalName: legalName.data, - tradeName: Maybe.none(), - tin: Maybe.some(tin.data), - slug: slug.data, - email: Maybe.some(email.data), - phone: Maybe.some(phone.data), - website: Maybe.some(website.data), - defaultPaymentTermId: Maybe.some(UniqueID.generateNewID()), - status: CompanyStatus.createActive(), - createdAt: now.data, - updatedAt: now.data, - }, id); + const companyResult = Company.create( + { + legalName: legalName.data, + tradeName: Maybe.none(), + tin: Maybe.some(tin.data), + slug: slug.data, + email: Maybe.some(email.data), + phone: Maybe.some(phone.data), + website: Maybe.some(website.data), + address: Maybe.none(), + defaultPaymentTermId: Maybe.some(UniqueID.generateNewID()), + status: CompanyStatus.createActive(), + createdAt: now.data, + updatedAt: now.data, + }, + id + ); assert.equal(companyResult.isSuccess, true); return companyResult.data; @@ -99,3 +104,49 @@ test("update permite limpiar el default payment term con null", async () => { assert.equal(receivedId?.isNone(), true); assert.equal(result.data.default_payment_term_id, null); }); + +test("update company maps the address tri-state", async () => { + const company = buildCompany(); + const captured: Array = []; + const useCase = new UpdateCompanyUseCase({ + updater: { + update: async ({ patchProps }) => { + captured.push(patchProps.address); + return Result.ok(company); + }, + }, + paymentTermResolver: { ensureAssignable: async () => Result.ok(undefined) }, + responseMapper: new CompanyPublicModelMapper(), + transactionManager: buildTransactionManager(), + }); + const base = { company_id: company.id.toPrimitive() }; + assert.equal((await useCase.execute({ ...base, dto: {} })).isSuccess, true); + assert.equal((await useCase.execute({ ...base, dto: { address: null } })).isSuccess, true); + assert.equal( + ( + await useCase.execute({ + ...base, + dto: { + address: { + street: "Calle Mayor 1", + street_2: null, + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }, + }, + }) + ).isSuccess, + true + ); + assert.equal(captured[0], undefined); + assert.equal((captured[1] as Maybe).isNone(), true); + assert.equal( + (captured[2] as Maybe<{ countryCode: Maybe<{ toPrimitive(): string }> }>) + .unwrap() + .countryCode.unwrap() + .toPrimitive(), + "ES" + ); +}); diff --git a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts index 2e336ebf..98d3c618 100644 --- a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts @@ -12,7 +12,9 @@ import { import { Maybe, Result } from "@repo/rdx-utils"; import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common"; -import { CompanySlug, type CompanyPatchProps } from "../../../domain"; +import { type CompanyPatchProps, CompanySlug } from "../../../domain"; +import { toPostalAddress } from "../mappers/company-address.mapper"; +import { toCompanyResponseDTO } from "../mappers/company-response.mapper"; import type { CompanyPublicModelMapper } from "../public"; import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services"; @@ -69,7 +71,9 @@ export class UpdateCompanyUseCase { return Result.fail(updateResult.error); } - return Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data))); + return Result.ok( + toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data)) + ); } catch (error: unknown) { return Result.fail(error as Error); } @@ -138,6 +142,18 @@ function mapUpdateCompanyProps(dto: UpdateCompanyRequestDTO): Result { email: Maybe.some(email.data), phone: Maybe.some(phone.data), website: Maybe.some(website.data), + address: Maybe.none(), defaultPaymentTermId: Maybe.some(UniqueID.generateNewID()), status: CompanyStatus.createActive(), createdAt: now.data, @@ -139,3 +140,49 @@ test("update current company no permite cambiar el slug", async () => { assert.equal(result.isSuccess, true); assert.equal(result.data.slug, company.slug.toPrimitive()); }); + +test("update current company preserves omitted address and maps null or a replacement", async () => { + const company = buildCompany(); + const captured: Array = []; + const useCase = new UpdateCurrentCompanyUseCase({ + updater: { + update: async ({ patchProps }) => { + captured.push(patchProps.address); + return Result.ok(company); + }, + }, + paymentTermResolver: { ensureAssignable: async () => Result.ok(undefined) }, + responseMapper: new CompanyPublicModelMapper(), + transactionManager: buildTransactionManager(), + }); + const base = { companyId: company.id.toPrimitive() }; + assert.equal((await useCase.execute({ ...base, dto: {} })).isSuccess, true); + assert.equal((await useCase.execute({ ...base, dto: { address: null } })).isSuccess, true); + assert.equal( + ( + await useCase.execute({ + ...base, + dto: { + address: { + street: "Calle Mayor 1", + street_2: null, + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }, + }, + }) + ).isSuccess, + true + ); + assert.equal(captured[0], undefined); + assert.equal((captured[1] as Maybe).isNone(), true); + assert.equal( + (captured[2] as Maybe<{ countryCode: Maybe<{ toPrimitive(): string }> }>) + .unwrap() + .countryCode.unwrap() + .toPrimitive(), + "ES" + ); +}); diff --git a/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts index aa7f7e63..cc271a28 100644 --- a/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts @@ -13,6 +13,8 @@ import { Maybe, Result } from "@repo/rdx-utils"; import type { CompanyResponseDTO, UpdateCurrentCompanyRequestDTO } from "../../../../common"; import type { CompanyPatchProps } from "../../../domain"; +import { toPostalAddress } from "../mappers/company-address.mapper"; +import { toCompanyResponseDTO } from "../mappers/company-response.mapper"; import type { CompanyPublicModelMapper } from "../public"; import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services"; @@ -92,6 +94,7 @@ function mapUpdateCurrentCompanyProps( assignPhonePatch(patchProps, dto), assignWebsitePatch(patchProps, dto), assignDefaultPaymentTermPatch(patchProps, dto), + assignAddressPatch(patchProps, dto), ]; for (const assignment of assignments) { @@ -205,6 +208,25 @@ function assignWebsitePatch( return Result.ok(undefined); } +function assignAddressPatch( + patchProps: CompanyPatchProps, + dto: UpdateCurrentCompanyRequestDTO +): Result { + if (dto.address === undefined) { + return Result.ok(undefined); + } + if (dto.address === null) { + patchProps.address = Maybe.none(); + return Result.ok(undefined); + } + const addressResult = toPostalAddress(dto.address); + if (addressResult.isFailure) { + return Result.fail(addressResult.error); + } + patchProps.address = Maybe.some(addressResult.data); + return Result.ok(undefined); +} + function assignDefaultPaymentTermPatch( patchProps: CompanyPatchProps, dto: UpdateCurrentCompanyRequestDTO @@ -242,29 +264,3 @@ function mapDefaultPaymentTermId( return Result.ok(Maybe.some(defaultPaymentTermIdResult.data)); } - -function toCompanyResponseDTO(model: { - id: string; - legalName: string; - tradeName: string | null; - tin: string | null; - slug: string; - email: string | null; - phone: string | null; - website: string | null; - defaultPaymentTermId: string | null; - status: "active" | "disabled"; -}): CompanyResponseDTO { - return { - id: model.id, - legal_name: model.legalName, - trade_name: model.tradeName, - tin: model.tin, - slug: model.slug, - email: model.email, - phone: model.phone, - website: model.website, - default_payment_term_id: model.defaultPaymentTermId, - status: model.status, - }; -} diff --git a/modules/companies/src/api/domain/companies/company.aggregate.test.ts b/modules/companies/src/api/domain/companies/company.aggregate.test.ts new file mode 100644 index 00000000..3fca5a23 --- /dev/null +++ b/modules/companies/src/api/domain/companies/company.aggregate.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + City, + CountryCode, + Name, + PostalAddress, + PostalCode, + Province, + Street, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { Company } from "./company.aggregate"; +import { CompanySlug } from "./company-slug.vo"; +import { CompanyStatus } from "./company-status.vo"; + +const unwrap = (result: { data: T }): T => result.data; +const address = (street = "Street 1") => + unwrap( + PostalAddress.create({ + street: Maybe.some(unwrap(Street.create(street))), + street2: Maybe.none(), + city: Maybe.some(unwrap(City.create("Madrid"))), + postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), + province: Maybe.some(unwrap(Province.create("Madrid"))), + countryCode: Maybe.some(unwrap(CountryCode.create("ES"))), + }) + ); +const props = (value = Maybe.none()) => ({ + legalName: unwrap(Name.create("Rodax")), + tradeName: Maybe.none(), + tin: Maybe.none(), + slug: unwrap(CompanySlug.create("rodax")), + email: Maybe.none(), + phone: Maybe.none(), + website: Maybe.none(), + address: value, + defaultPaymentTermId: Maybe.none(), + status: CompanyStatus.createActive(), + createdAt: unwrap(UtcDate.createFromISO("2026-01-01")), + updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")), +}); +const company = (value = Maybe.none()) => + unwrap(Company.create(props(value), UniqueID.generateNewID())); + +test("Company address supports create, getters and rehydrate", () => { + assert.equal(company().address.isNone(), true); + const postalAddress = address(); + assert.equal( + company(Maybe.some(postalAddress)).address.unwrap().countryCode.unwrap().toPrimitive(), + "ES" + ); + assert.equal(Company.rehydrate(props(), UniqueID.generateNewID()).address.isNone(), true); + assert.equal( + Company.rehydrate(props(Maybe.some(postalAddress)), UniqueID.generateNewID()).address.isSome(), + true + ); +}); + +test("Company address update preserves, sets, replaces, clears and compares primitives", () => { + const initial = address("Street A"); + const target = company(Maybe.some(initial)); + assert.equal( + target.update({ updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")) }).data, + false + ); + const equivalent = address("Street A"); + assert.notEqual(initial, equivalent); + assert.equal( + target.update({ + address: Maybe.some(equivalent), + updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")), + }).data, + false + ); + assert.equal( + target.update({ + address: Maybe.some(address("Street B")), + updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")), + }).data, + true + ); + assert.equal( + target.update({ address: Maybe.none(), updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")) }) + .data, + true + ); + assert.equal( + target.update({ + address: Maybe.some(address()), + updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")), + }).data, + true + ); +}); diff --git a/modules/companies/src/api/domain/companies/company.aggregate.ts b/modules/companies/src/api/domain/companies/company.aggregate.ts index 8f960781..f787c1c5 100644 --- a/modules/companies/src/api/domain/companies/company.aggregate.ts +++ b/modules/companies/src/api/domain/companies/company.aggregate.ts @@ -3,6 +3,7 @@ import { type EmailAddress, type Name, type PhoneNumber, + type PostalAddress, type TextValue, type URLAddress, type UniqueID, @@ -22,6 +23,7 @@ export interface ICompanyCreateProps { email: Maybe; phone: Maybe; website: Maybe; + address: Maybe; defaultPaymentTermId: Maybe; status: CompanyStatus; createdAt: UtcDate; @@ -36,6 +38,7 @@ export type CompanyPatchProps = Partial<{ email: Maybe; phone: Maybe; website: Maybe; + address: Maybe; defaultPaymentTermId: Maybe; }> & { updatedAt: UtcDate; @@ -109,6 +112,10 @@ export class Company extends AggregateRoot { return this.props.website; } + public get address(): Maybe { + return this.props.address; + } + public get defaultPaymentTermId(): Maybe { return this.props.defaultPaymentTermId; } @@ -173,6 +180,14 @@ export class Company extends AggregateRoot { hasChanges = true; } + if ( + patch.address !== undefined && + !Company.sameMaybePostalAddress(this.props.address, patch.address) + ) { + this.props.address = patch.address; + hasChanges = true; + } + if ( patch.defaultPaymentTermId !== undefined && !Company.sameMaybeValueObject(this.props.defaultPaymentTermId, patch.defaultPaymentTermId) @@ -213,6 +228,32 @@ export class Company extends AggregateRoot { return this.status.isActive(); } + private static sameMaybePostalAddress( + current: Maybe, + next: Maybe + ): boolean { + return current.match( + (currentAddress) => + next.match( + (nextAddress) => + currentAddress.street.unwrap().toPrimitive() === + nextAddress.street.unwrap().toPrimitive() && + currentAddress.street2.getOrUndefined()?.toPrimitive() === + nextAddress.street2.getOrUndefined()?.toPrimitive() && + currentAddress.city.unwrap().toPrimitive() === + nextAddress.city.unwrap().toPrimitive() && + currentAddress.postalCode.unwrap().toPrimitive() === + nextAddress.postalCode.unwrap().toPrimitive() && + currentAddress.province.unwrap().toPrimitive() === + nextAddress.province.unwrap().toPrimitive() && + currentAddress.countryCode.unwrap().toPrimitive() === + nextAddress.countryCode.unwrap().toPrimitive(), + () => false + ), + () => next.isNone() + ); + } + private static sameMaybeValueObject( current: Maybe, next: Maybe diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.test.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.test.ts new file mode 100644 index 00000000..3893ae27 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + City, + CountryCode, + Name, + PostalAddress, + PostalCode, + Province, + Street, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { Company } from "../../../../domain"; +import { CompanySlug } from "../../../../domain/companies/company-slug.vo"; +import { CompanyStatus } from "../../../../domain/companies/company-status.vo"; +import type { CompanyModel } from "../models"; + +import { SequelizeCompanyDomainMapper } from "./sequelize-company-domain.mapper"; + +const unwrap = (result: { data: T }): T => result.data; +const postalAddress = (street2 = "Suite 2") => + unwrap( + PostalAddress.create({ + street: Maybe.some(unwrap(Street.create("Street 1"))), + street2: Maybe.some(unwrap(Street.create(street2))), + city: Maybe.some(unwrap(City.create("Madrid"))), + postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), + province: Maybe.some(unwrap(Province.create("Madrid"))), + countryCode: Maybe.some(unwrap(CountryCode.create("ES"))), + }) + ); +const company = (address = Maybe.none()) => + unwrap( + Company.create( + { + legalName: unwrap(Name.create("Rodax")), + tradeName: Maybe.none(), + tin: Maybe.none(), + slug: unwrap(CompanySlug.create("rodax")), + email: Maybe.none(), + phone: Maybe.none(), + website: Maybe.none(), + address, + defaultPaymentTermId: Maybe.none(), + status: CompanyStatus.createActive(), + createdAt: unwrap(UtcDate.createFromISO("2026-01-01")), + updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")), + }, + UniqueID.generateNewID() + ) + ); +const row = (patch: Record = {}) => + ({ + id: UniqueID.generateNewID().toPrimitive(), + legal_name: "Rodax", + trade_name: null, + tin: null, + slug: "rodax", + email: null, + phone: null, + website: null, + default_payment_term_id: null, + status: "active", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + address_street: null, + address_street_2: null, + address_city: null, + address_postal_code: null, + address_province: null, + address_country_code: null, + ...patch, + }) as CompanyModel; +const mapper = new SequelizeCompanyDomainMapper(); + +test("mapper persists none, complete and missing street2", () => { + const none = unwrap(mapper.mapToPersistence(company())); + assert.deepEqual( + [ + none.address_street, + none.address_street_2, + none.address_city, + none.address_postal_code, + none.address_province, + none.address_country_code, + ], + [null, null, null, null, null, null] + ); + const full = unwrap(mapper.mapToPersistence(company(Maybe.some(postalAddress())))); + assert.deepEqual( + [ + full.address_street, + full.address_street_2, + full.address_city, + full.address_postal_code, + full.address_province, + full.address_country_code, + ], + ["Street 1", "Suite 2", "Madrid", "28001", "Madrid", "ES"] + ); + assert.equal( + unwrap( + mapper.mapToPersistence( + company( + Maybe.some( + unwrap(PostalAddress.create({ ...postalAddress().getProps(), street2: Maybe.none() })) + ) + ) + ) + ).address_street_2, + null + ); +}); + +test("mapper rehydrates valid states and rejects partial states", () => { + assert.equal(unwrap(mapper.mapToDomain(row())).address.isNone(), true); + const complete = row({ + address_street: "Street 1", + address_street_2: "Suite 2", + address_city: "Madrid", + address_postal_code: "28001", + address_province: "Madrid", + address_country_code: "ES", + }); + assert.equal( + unwrap(mapper.mapToDomain(complete)).address.unwrap().countryCode.unwrap().toPrimitive(), + "ES" + ); + assert.equal( + unwrap(mapper.mapToDomain(row({ ...complete, address_street_2: null }))) + .address.unwrap() + .street2.isNone(), + true + ); + for (const key of ["address_street", "address_postal_code", "address_country_code"]) { + const partial = row(complete as unknown as Record); + partial[key as keyof CompanyModel] = null as never; + assert.equal(mapper.mapToDomain(partial).isFailure, true); + } +}); diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts index 9ec178eb..3ded0a87 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts @@ -1,8 +1,14 @@ import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import { + City, + CountryCode, EmailAddress, Name, PhoneNumber, + PostalAddress, + PostalCode, + Province, + Street, TextValue, URLAddress, UniqueID, @@ -13,15 +19,46 @@ import { maybeFromNullableResult, maybeToNullable, } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; +import { Maybe, Result } from "@repo/rdx-utils"; -import { Company, CompanySlug, CompanyStatus, type CompanyInternalProps } from "../../../../domain"; +import { Company, type CompanyInternalProps, CompanySlug, CompanyStatus } from "../../../../domain"; import type { CompanyCreationAttributes, CompanyModel } from "../models"; function toUtcIsoString(value: Date | string): string { return value instanceof Date ? value.toISOString() : value; } +function mapAddress(source: CompanyModel): Result, Error> { + const values = [ + source.address_street, + source.address_city, + source.address_postal_code, + source.address_province, + source.address_country_code, + ]; + if (values.every((value) => value === null) && source.address_street_2 === null) + return Result.ok(Maybe.none()); + if (values.some((value) => value === null)) + return Result.fail(new Error("Company address persistence is partial.")); + const street = Street.create(source.address_street as string); + const city = City.create(source.address_city as string); + const postalCode = PostalCode.create(source.address_postal_code as string); + const province = Province.create(source.address_province as string); + const countryCode = CountryCode.create(source.address_country_code as string); + const street2 = maybeFromNullableResult(source.address_street_2, (value) => Street.create(value)); + for (const result of [street, city, postalCode, province, countryCode, street2]) + if (result.isFailure) return Result.fail(result.error); + const address = PostalAddress.create({ + street: Maybe.some(street.data), + street2: street2.data, + city: Maybe.some(city.data), + postalCode: Maybe.some(postalCode.data), + province: Maybe.some(province.data), + countryCode: Maybe.some(countryCode.data), + }); + return address.isFailure ? Result.fail(address.error) : Result.ok(Maybe.some(address.data)); +} + export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper< CompanyModel, CompanyCreationAttributes, @@ -64,6 +101,8 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper< "default_payment_term_id", errors ); + const address = mapAddress(source); + if (address.isFailure) return Result.fail(address.error); const status = extractOrPushError(CompanyStatus.create(source.status), "status", errors); const createdAt = extractOrPushError( UtcDateTime.createFromISO(toUtcIsoString(source.created_at)), @@ -88,6 +127,7 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper< email: email!, phone: phone!, website: website!, + address: address.data, defaultPaymentTermId: defaultPaymentTermId!, status: status!, createdAt: createdAt!, @@ -113,6 +153,22 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper< email: maybeToNullable(source.email, (value: EmailAddress) => value.toPrimitive()), phone: maybeToNullable(source.phone, (value: PhoneNumber) => value.toPrimitive()), website: maybeToNullable(source.website, (value: URLAddress) => value.toPrimitive()), + address_street: maybeToNullable(source.address, (value) => + value.street.unwrap().toPrimitive() + ), + address_street_2: maybeToNullable(source.address, (value) => + maybeToNullable(value.street2, (part) => part.toPrimitive()) + ), + address_city: maybeToNullable(source.address, (value) => value.city.unwrap().toPrimitive()), + address_postal_code: maybeToNullable(source.address, (value) => + value.postalCode.unwrap().toPrimitive() + ), + address_province: maybeToNullable(source.address, (value) => + value.province.unwrap().toPrimitive() + ), + address_country_code: maybeToNullable(source.address, (value) => + value.countryCode.unwrap().toPrimitive() + ), default_payment_term_id: maybeToNullable(source.defaultPaymentTermId, (value) => value.toPrimitive() ), diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts index dc6faf30..cbd58f66 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts @@ -20,6 +20,12 @@ export class CompanyModel extends Model< declare email: string | null; declare phone: string | null; declare website: string | null; + declare address_street: string | null; + declare address_street_2: string | null; + declare address_city: string | null; + declare address_postal_code: string | null; + declare address_province: string | null; + declare address_country_code: string | null; declare default_payment_term_id: string | null; declare status: string; declare created_at: string; @@ -72,6 +78,12 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + address_street: { type: DataTypes.STRING, allowNull: true, defaultValue: null }, + address_street_2: { type: DataTypes.STRING, allowNull: true, defaultValue: null }, + address_city: { type: DataTypes.STRING, allowNull: true, defaultValue: null }, + address_postal_code: { type: DataTypes.STRING, allowNull: true, defaultValue: null }, + address_province: { type: DataTypes.STRING, allowNull: true, defaultValue: null }, + address_country_code: { type: DataTypes.CHAR(2), allowNull: true, defaultValue: null }, default_payment_term_id: { type: DataTypes.UUID, allowNull: true, diff --git a/modules/companies/src/common/dto/index.ts b/modules/companies/src/common/dto/index.ts index 346dac3b..32497d2a 100644 --- a/modules/companies/src/common/dto/index.ts +++ b/modules/companies/src/common/dto/index.ts @@ -1,2 +1,3 @@ export * from "./request"; export * from "./response"; +export * from "./shared"; diff --git a/modules/companies/src/common/dto/request/create-company.request.dto.ts b/modules/companies/src/common/dto/request/create-company.request.dto.ts index 70c84ebd..81dbb920 100644 --- a/modules/companies/src/common/dto/request/create-company.request.dto.ts +++ b/modules/companies/src/common/dto/request/create-company.request.dto.ts @@ -1,6 +1,8 @@ import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core"; import { z } from "zod/v4"; +import { CompanyAddressRequestSchema } from "../shared"; + export const CreateCompanyRequestSchema = z.object({ id: z.uuid(), legal_name: z.string().min(1), @@ -10,6 +12,7 @@ export const CreateCompanyRequestSchema = z.object({ email: EmailSchema.nullable().optional(), phone: z.string().nullable().optional(), website: WebsiteSchema.nullable().optional(), + address: CompanyAddressRequestSchema.nullable().optional(), }); export type CreateCompanyRequestDTO = z.infer; diff --git a/modules/companies/src/common/dto/request/update-company.request.dto.ts b/modules/companies/src/common/dto/request/update-company.request.dto.ts index d8d48d9f..26882451 100644 --- a/modules/companies/src/common/dto/request/update-company.request.dto.ts +++ b/modules/companies/src/common/dto/request/update-company.request.dto.ts @@ -1,11 +1,15 @@ import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core"; import { z } from "zod/v4"; +import { CompanyAddressRequestSchema } from "../shared"; + export const UpdateCompanyByIdParamsRequestSchema = z.object({ company_id: z.uuid(), }); -export type UpdateCompanyByIdParamsRequestDTO = z.infer; +export type UpdateCompanyByIdParamsRequestDTO = z.infer< + typeof UpdateCompanyByIdParamsRequestSchema +>; export const UpdateCompanyRequestSchema = z.object({ legal_name: z.string().min(1).optional(), @@ -16,6 +20,7 @@ export const UpdateCompanyRequestSchema = z.object({ phone: z.string().nullable().optional(), website: WebsiteSchema.nullable().optional(), default_payment_term_id: z.uuid().nullable().optional(), + address: CompanyAddressRequestSchema.nullable().optional(), }); export type UpdateCompanyRequestDTO = z.infer; diff --git a/modules/companies/src/common/dto/request/update-current-company.request.dto.ts b/modules/companies/src/common/dto/request/update-current-company.request.dto.ts index 95059ba3..0d170164 100644 --- a/modules/companies/src/common/dto/request/update-current-company.request.dto.ts +++ b/modules/companies/src/common/dto/request/update-current-company.request.dto.ts @@ -1,6 +1,8 @@ import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core"; import { z } from "zod/v4"; +import { CompanyAddressRequestSchema } from "../shared"; + export const UpdateCurrentCompanyRequestSchema = z.object({ legal_name: z.string().min(1).optional(), trade_name: z.string().nullable().optional(), @@ -9,6 +11,7 @@ export const UpdateCurrentCompanyRequestSchema = z.object({ phone: z.string().nullable().optional(), website: WebsiteSchema.nullable().optional(), default_payment_term_id: z.uuid().nullable().optional(), + address: CompanyAddressRequestSchema.nullable().optional(), }); export type UpdateCurrentCompanyRequestDTO = z.infer; diff --git a/modules/companies/src/common/dto/response/company.response.dto.test.ts b/modules/companies/src/common/dto/response/company.response.dto.test.ts new file mode 100644 index 00000000..5df2e845 --- /dev/null +++ b/modules/companies/src/common/dto/response/company.response.dto.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CompanyResponseSchema } from "./company.response.dto"; + +const response = (address: unknown) => ({ id: "550e8400-e29b-41d4-a716-446655440000", legal_name: "Rodax", trade_name: null, tin: null, slug: "rodax", email: null, phone: null, website: null, address, default_payment_term_id: null, status: "active" }); +const complete = { street: "Calle Mayor 1", street_2: null, city: "Madrid", postal_code: "28001", province: "Madrid", country_code: "ES" }; + +test("CompanyResponseSchema requires a complete nullable address", () => { + assert.equal(CompanyResponseSchema.safeParse(response(null)).success, true); + assert.equal(CompanyResponseSchema.safeParse(response(complete)).success, true); + const omitted = response(null); delete (omitted as { address?: unknown }).address; + assert.equal(CompanyResponseSchema.safeParse(omitted).success, false); + const partial = { ...complete }; delete (partial as { country_code?: unknown }).country_code; + assert.equal(CompanyResponseSchema.safeParse(response(partial)).success, false); +}); diff --git a/modules/companies/src/common/dto/response/company.response.dto.ts b/modules/companies/src/common/dto/response/company.response.dto.ts index dd7a4bca..7a7f7391 100644 --- a/modules/companies/src/common/dto/response/company.response.dto.ts +++ b/modules/companies/src/common/dto/response/company.response.dto.ts @@ -1,6 +1,15 @@ -import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core"; +import { CountryCodeSchema, EmailSchema, PostalCodeSchema, TinSchema, WebsiteSchema } from "@erp/core"; import { z } from "zod/v4"; +export const CompanyAddressResponseSchema = z.object({ + street: z.string(), + street_2: z.string().nullable(), + city: z.string(), + postal_code: PostalCodeSchema, + province: z.string(), + country_code: CountryCodeSchema, +}); + export const CompanyStatusResponseSchema = z.enum(["active", "disabled"]); export const CompanyResponseSchema = z.object({ @@ -12,6 +21,7 @@ export const CompanyResponseSchema = z.object({ email: EmailSchema.nullable(), phone: z.string().nullable(), website: WebsiteSchema.nullable(), + address: CompanyAddressResponseSchema.nullable(), default_payment_term_id: z.uuid().nullable(), status: CompanyStatusResponseSchema, }); diff --git a/modules/companies/src/common/dto/shared/company-address.request.dto.test.ts b/modules/companies/src/common/dto/shared/company-address.request.dto.test.ts new file mode 100644 index 00000000..94ba03b1 --- /dev/null +++ b/modules/companies/src/common/dto/shared/company-address.request.dto.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CreateCompanyRequestSchema } from "../request/create-company.request.dto"; +import { UpdateCompanyRequestSchema } from "../request/update-company.request.dto"; +import { UpdateCurrentCompanyRequestSchema } from "../request/update-current-company.request.dto"; + +const address = { + street: "Calle Mayor 1", + street_2: null, + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", +}; + +const create = { + id: "550e8400-e29b-41d4-a716-446655440000", + legal_name: "Rodax Labs", + slug: "rodax-labs", +}; + +test("create address accepts omitted, null and complete values", () => { + assert.equal(CreateCompanyRequestSchema.safeParse(create).success, true); + assert.equal(CreateCompanyRequestSchema.safeParse({ ...create, address: null }).success, true); + assert.equal(CreateCompanyRequestSchema.safeParse({ ...create, address }).success, true); +}); + +test("company address rejects partial and invalid values", () => { + assert.equal( + CreateCompanyRequestSchema.safeParse({ ...create, address: { city: "Madrid" } }).success, + false + ); + assert.equal( + CreateCompanyRequestSchema.safeParse({ + ...create, + address: { ...address, country_code: "ESP" }, + }).success, + false + ); + assert.equal( + CreateCompanyRequestSchema.safeParse({ + ...create, + address: { ...address, postal_code: "invalid" }, + }).success, + false + ); +}); + +test("patch schemas accept the address tri-state and reject partial values", () => { + for (const schema of [UpdateCurrentCompanyRequestSchema, UpdateCompanyRequestSchema]) { + assert.equal(schema.safeParse({}).success, true); + assert.equal(schema.safeParse({ address: null }).success, true); + assert.equal(schema.safeParse({ address }).success, true); + assert.equal(schema.safeParse({ address: { city: "Madrid" } }).success, false); + } +}); diff --git a/modules/companies/src/common/dto/shared/company-address.request.dto.ts b/modules/companies/src/common/dto/shared/company-address.request.dto.ts new file mode 100644 index 00000000..79082730 --- /dev/null +++ b/modules/companies/src/common/dto/shared/company-address.request.dto.ts @@ -0,0 +1,13 @@ +import { CountryCodeSchema, PostalCodeSchema } from "@erp/core"; +import { z } from "zod/v4"; + +export const CompanyAddressRequestSchema = z.object({ + street: z.string().trim().min(1), + street_2: z.string().trim().nullable(), + city: z.string().trim().min(1), + postal_code: PostalCodeSchema, + province: z.string().trim().min(1), + country_code: CountryCodeSchema, +}); + +export type CompanyAddressRequestDTO = z.infer; diff --git a/modules/companies/src/common/dto/shared/index.ts b/modules/companies/src/common/dto/shared/index.ts new file mode 100644 index 00000000..2bafdbaa --- /dev/null +++ b/modules/companies/src/common/dto/shared/index.ts @@ -0,0 +1 @@ +export * from "./company-address.request.dto"; diff --git a/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts b/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts index e87836b0..03267210 100644 --- a/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts +++ b/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts @@ -72,6 +72,14 @@ export const useCompanyDetailsSettingsController = () => { email: "", phone: "", website: "", + address: { + street: "", + street2: "", + city: "", + postalCode: "", + province: "", + countryCode: "", + }, defaultPaymentTermId: "", }; } @@ -102,7 +110,8 @@ export const useCompanyDetailsSettingsController = () => { paymentTermsQuery.error ?? (needsCurrentPaymentTerm ? currentPaymentTermQuery.error : null); - const hasRequiredData = Boolean(company) && + const hasRequiredData = + Boolean(company) && Boolean(paymentTermsQuery.data) && (!needsCurrentPaymentTerm || Boolean(currentPaymentTermQuery.data)); diff --git a/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.test.ts b/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.test.ts new file mode 100644 index 00000000..a89434dc --- /dev/null +++ b/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CompanySettingsFormSchema } from "./company-settings-form.schema"; + +const base = { + legalName: "Acme", + tradeName: "", + tin: "", + email: "", + phone: "", + website: "", + defaultPaymentTermId: "", +}; +const empty = { street: "", street2: "", city: "", postalCode: "", province: "", countryCode: "" }; +const complete = { + street: "Calle Mayor 1", + street2: "", + city: "Madrid", + postalCode: "28001", + province: "Madrid", + countryCode: "ES", +}; + +test("validates empty and complete addresses", () => { + assert.equal(CompanySettingsFormSchema.safeParse({ ...base, address: empty }).success, true); + assert.equal(CompanySettingsFormSchema.safeParse({ ...base, address: complete }).success, true); +}); + +test("rejects partial, invalid postal code and invalid country code", () => { + assert.equal( + CompanySettingsFormSchema.safeParse({ ...base, address: { ...empty, street: "Calle" } }) + .success, + false + ); + assert.equal( + CompanySettingsFormSchema.safeParse({ + ...base, + address: { ...complete, postalCode: "invalid" }, + }).success, + false + ); + assert.equal( + CompanySettingsFormSchema.safeParse({ ...base, address: { ...complete, countryCode: "ESP" } }) + .success, + false + ); +}); diff --git a/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts b/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts index 875fd2a1..1a0ab2f7 100644 --- a/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts +++ b/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts @@ -1,6 +1,46 @@ -import { EmailSchema, TinSchema, WebsiteSchema } from "@erp/core"; +import { + CountryCodeSchema, + EmailSchema, + PostalCodeSchema, + TinSchema, + WebsiteSchema, +} from "@erp/core"; import { z } from "zod/v4"; +const AddressFormSchema = z + .object({ + street: z.string(), + street2: z.string(), + city: z.string(), + postalCode: z.string(), + province: z.string(), + countryCode: z.string(), + }) + .superRefine((address, ctx) => { + const required = [ + address.street, + address.city, + address.postalCode, + address.province, + address.countryCode, + ]; + const empty = required.every((value) => value.trim() === ""); + if (empty) return; + if (address.street.trim() === "") + ctx.addIssue({ code: "custom", path: ["street"], message: "Required" }); + if (address.city.trim() === "") + ctx.addIssue({ code: "custom", path: ["city"], message: "Required" }); + if (address.postalCode.trim() === "" || !PostalCodeSchema.safeParse(address.postalCode).success) + ctx.addIssue({ code: "custom", path: ["postalCode"], message: "Invalid postal code" }); + if (address.province.trim() === "") + ctx.addIssue({ code: "custom", path: ["province"], message: "Required" }); + if ( + address.countryCode.trim() === "" || + !CountryCodeSchema.safeParse(address.countryCode).success + ) + ctx.addIssue({ code: "custom", path: ["countryCode"], message: "Invalid country code" }); + }); + export const CompanySettingsFormSchema = z.object({ legalName: z.string().min(1), tradeName: z.string(), @@ -9,6 +49,6 @@ export const CompanySettingsFormSchema = z.object({ phone: z.string(), website: WebsiteSchema.or(z.literal("")), defaultPaymentTermId: z.string(), + address: AddressFormSchema, }); - export type CompanySettingsForm = z.infer; diff --git a/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.test.ts b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.test.ts index 6d2b968c..c4ef22f1 100644 --- a/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.test.ts +++ b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.test.ts @@ -15,6 +15,7 @@ test("preserva null en los campos nullable del DTO", () => { email: null, phone: null, website: null, + address: null, default_payment_term_id: null, status: "active", }; @@ -27,6 +28,38 @@ test("preserva null en los campos nullable del DTO", () => { email: null, phone: null, website: null, + address: null, defaultPaymentTermId: null, }); }); + +test("maps API address to company settings camelCase", () => { + const result = CompanySettingsAdapter.fromDto({ + id: "11111111-1111-4111-8111-111111111111", + legal_name: "Acme", + trade_name: null, + tin: null, + slug: "acme", + email: null, + phone: null, + website: null, + address: { + street: "Calle Mayor 1", + street_2: "2º B", + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }, + default_payment_term_id: null, + status: "active", + }); + assert.deepEqual(result.address, { + street: "Calle Mayor 1", + street2: "2º B", + city: "Madrid", + postalCode: "28001", + province: "Madrid", + countryCode: "ES", + }); +}); diff --git a/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts index 6e0f967d..1d1fa868 100644 --- a/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts +++ b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts @@ -11,6 +11,17 @@ export const CompanySettingsAdapter = { email: dto.email, phone: dto.phone, website: dto.website, + address: + dto.address == null + ? null + : { + street: dto.address.street, + street2: dto.address.street_2, + city: dto.address.city, + postalCode: dto.address.postal_code, + province: dto.address.province, + countryCode: dto.address.country_code, + }, defaultPaymentTermId: dto.default_payment_term_id, }; }, diff --git a/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts b/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts index 738f08c8..58d34abc 100644 --- a/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts +++ b/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts @@ -6,5 +6,13 @@ export interface CompanySettings { email: string | null; phone: string | null; website: string | null; + address: { + street: string; + street2: string | null; + city: string; + postalCode: string; + province: string; + countryCode: string; + } | null; defaultPaymentTermId: string | null; } diff --git a/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx index 1ca333a9..5e884a7c 100644 --- a/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx +++ b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx @@ -166,7 +166,6 @@ export const CompanyDetailsSettingsPage = () => { )} typePreset="email" /> - description={t( "settings.companyDetails.sections.contact.fields.phone.description" @@ -197,6 +196,45 @@ export const CompanyDetailsSettingsPage = () => { /> + } + title="Dirección fiscal" + variant="plain" + > + + + disabled={ctrl.isSaving} + label="Dirección" + name="address.street" + /> + + disabled={ctrl.isSaving} + label="Dirección 2" + name="address.street2" + /> + + disabled={ctrl.isSaving} + label="Código postal" + name="address.postalCode" + /> + + disabled={ctrl.isSaving} + label="Localidad" + name="address.city" + /> + + disabled={ctrl.isSaving} + label="Provincia" + name="address.province" + /> + + disabled={ctrl.isSaving} + label="País" + name="address.countryCode" + /> + + { email: null, phone: null, website: null, + address: null, defaultPaymentTermId: null, }; @@ -24,6 +25,7 @@ test('convierte null del Entity a "" en el Form para campos nullable', () => { email: "", phone: "", website: "", + address: { street: "", street2: "", city: "", postalCode: "", province: "", countryCode: "" }, defaultPaymentTermId: "", }); }); diff --git a/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts b/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts index 3900a620..a45bc6fc 100644 --- a/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts +++ b/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts @@ -8,5 +8,16 @@ export const buildCompanySettingsForm = (company: CompanySettings): CompanySetti email: company.email ?? "", phone: company.phone ?? "", website: company.website ?? "", + address: + company.address == null + ? { street: "", street2: "", city: "", postalCode: "", province: "", countryCode: "" } + : { + street: company.address.street, + street2: company.address.street2 ?? "", + city: company.address.city, + postalCode: company.address.postalCode, + province: company.address.province, + countryCode: company.address.countryCode, + }, defaultPaymentTermId: company.defaultPaymentTermId ?? "", }); diff --git a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts index 9ba9792a..9053dec0 100644 --- a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts +++ b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts @@ -10,6 +10,7 @@ const buildForm = () => ({ email: "info@acme.test", phone: "+34 900 000 000", website: "https://acme.test", + address: { street: "", street2: "", city: "", postalCode: "", province: "", countryCode: "" }, defaultPaymentTermId: "22222222-2222-4222-8222-222222222222", }); @@ -150,6 +151,7 @@ test("convierte defaultPaymentTermId vacío dirty a null", () => { const result = buildUpdateCompanySettingsParams({ form: { ...buildForm(), + address: { street: "", street2: "", city: "", postalCode: "", province: "", countryCode: "" }, defaultPaymentTermId: "", }, dirtyFields: { @@ -187,3 +189,40 @@ test("envía varios campos dirty y omite el resto", () => { }, }); }); + +test("maps dirty empty address to null", () => { + const result = buildUpdateCompanySettingsParams({ + form: buildForm(), + dirtyFields: { address: { street: true } }, + }); + assert.deepEqual(result, { data: { address: null } }); +}); + +test("maps dirty address to the request contract", () => { + const result = buildUpdateCompanySettingsParams({ + form: { + ...buildForm(), + address: { + street: " Calle Mayor 1 ", + street2: "", + city: " Madrid ", + postalCode: "28001", + province: " Madrid ", + countryCode: "ES", + }, + }, + dirtyFields: { address: { street: true } }, + }); + assert.deepEqual(result, { + data: { + address: { + street: "Calle Mayor 1", + street_2: null, + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }, + }, + }); +}); diff --git a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts index c921531c..c95f7f89 100644 --- a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts +++ b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts @@ -1,6 +1,7 @@ +import type { FieldNamesMarkedBoolean } from "react-hook-form"; + import type { UpdateCurrentCompanyRequestDTO } from "../../../../common"; import type { CompanySettingsForm } from "../entities"; -import type { FieldNamesMarkedBoolean } from "react-hook-form"; export const buildUpdateCompanySettingsParams = (params: { form: CompanySettingsForm; @@ -38,9 +39,28 @@ export const buildUpdateCompanySettingsParams = (params: { data.default_payment_term_id = toNullableText(params.form.defaultPaymentTermId); } - return { - data, - }; + if (params.dirtyFields.address) { + const address = params.form.address; + const required = [ + address.street, + address.city, + address.postalCode, + address.province, + address.countryCode, + ]; + data.address = required.every((value) => value.trim() === "") + ? null + : { + street: address.street.trim(), + street_2: toNullableText(address.street2), + city: address.city.trim(), + postal_code: address.postalCode.trim(), + province: address.province.trim(), + country_code: address.countryCode.trim(), + }; + } + + return { data }; }; const toRequiredText = (value: string): string => { diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-recipient-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-recipient-domain.mapper.ts index 472f4f3c..493f4b2f 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-recipient-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-recipient-domain.mapper.ts @@ -34,7 +34,7 @@ export class SequelizeProformaV2RecipientDomainMapper { const _city = source.current_customer.city; const _postal_code = source.current_customer.postal_code; const _province = source.current_customer.province; - const _country = source.current_customer.country; + const _country_code = source.current_customer.country_code; const customerName = extractOrPushError(Name.create(_name), "current_customer.name", errors); const customerTin = extractOrPushError( @@ -68,8 +68,8 @@ export class SequelizeProformaV2RecipientDomainMapper { errors ); const customerCountry = extractOrPushError( - maybeFromNullableResult(_country, (value) => Country.create(value)), - "current_customer.country", + maybeFromNullableResult(_country_code, (value) => Country.create(value)), + "current_customer.country_code", errors ); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts index 75108873..3de40640 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts @@ -458,7 +458,7 @@ export class SequelizeProformaRepositoryV2 "city", "postal_code", "province", - "country", + "country_code", ], }, { diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-tax-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-tax-editor.tsx index c3b58657..dca51574 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-tax-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-tax-editor.tsx @@ -62,9 +62,7 @@ export const ProformaUpdateTaxEditor = ({ )} readOnly={readOnly} /> - - - (typeof value === "number" ? String(value) : "")} /> - - - (value === null || value === "" ? null : Number(value))} diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx index e54ab401..4f550173 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx @@ -185,7 +185,7 @@ export const ProformaUpdatePage = () => { /> } > - + {hasPageAlerts ? ( diff --git a/modules/customers/src/api/application/audit/customer-audit.test.ts b/modules/customers/src/api/application/audit/customer-audit.test.ts index 1cc69d31..ec314712 100644 --- a/modules/customers/src/api/application/audit/customer-audit.test.ts +++ b/modules/customers/src/api/application/audit/customer-audit.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import { City, - Country, + CountryCode, CurrencyCode, EmailAddress, LanguageCode, @@ -44,7 +44,7 @@ const buildCustomer = () => city: Maybe.some(unwrap(City.create("Madrid"))), province: Maybe.some(unwrap(Province.create("Madrid"))), postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), - country: Maybe.some(unwrap(Country.create("ES"))), + countryCode: Maybe.some(unwrap(CountryCode.create("ES"))), }, emailPrimary: Maybe.some(unwrap(EmailAddress.create("primary@example.test"))), emailSecondary: Maybe.none(), diff --git a/modules/customers/src/api/application/audit/customer-audit.ts b/modules/customers/src/api/application/audit/customer-audit.ts index 1e81a134..7392490e 100644 --- a/modules/customers/src/api/application/audit/customer-audit.ts +++ b/modules/customers/src/api/application/audit/customer-audit.ts @@ -64,7 +64,7 @@ export const toCustomerAuditState = (customer: Customer): CustomerAuditState => city: toNullablePrimitive(customer.address.getProps().city), province: toNullablePrimitive(customer.address.getProps().province), postalCode: toNullablePrimitive(customer.address.getProps().postalCode), - country: toNullablePrimitive(customer.address.getProps().country), + country: toNullablePrimitive(customer.address.getProps().countryCode), }, emailPrimary: toNullablePrimitive(customer.emailPrimary), emailSecondary: toNullablePrimitive(customer.emailSecondary), diff --git a/modules/customers/src/api/application/mappers/create-customer-input.mapper.ts b/modules/customers/src/api/application/mappers/create-customer-input.mapper.ts index b702bf1a..53fdb484 100644 --- a/modules/customers/src/api/application/mappers/create-customer-input.mapper.ts +++ b/modules/customers/src/api/application/mappers/create-customer-input.mapper.ts @@ -1,6 +1,6 @@ import { City, - Country, + CountryCode, CurrencyCode, DomainError, EmailAddress, @@ -97,7 +97,7 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { ); const country = extractOrPushError( - maybeFromNullableResult(dto.address.country, (value) => Country.create(value)), + maybeFromNullableResult(dto.address.countryCode, (value) => CountryCode.create(value)), "country", errors ); @@ -203,7 +203,7 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { city: city!, postalCode: postalCode!, province: province!, - country: country!, + countryCode: country!, }; const customerProps: ICustomerCreateProps = { diff --git a/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts b/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts index 1d48087e..69bcaf2d 100644 --- a/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts +++ b/modules/customers/src/api/application/mappers/update-customer-input.mapper.ts @@ -1,6 +1,6 @@ import { City, - Country, + CountryCode, CurrencyCode, DomainError, EmailAddress, @@ -219,9 +219,9 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper { }); toPatchField(dto.country).ifSetOrNull((country) => { - postalAddressPatchProps.country = extractOrPushError( - maybeFromNullableResult(country, (value) => Country.create(value)), - "address.country", + postalAddressPatchProps.countryCode = extractOrPushError( + maybeFromNullableResult(country, (value) => CountryCode.create(value)), + "address.countryCode", errors ); }); diff --git a/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts b/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts index 4e804438..3a0d491b 100644 --- a/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts +++ b/modules/customers/src/api/application/snapshot-builders/full/customer-full-snapshot-builder.ts @@ -31,7 +31,7 @@ export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder city: toNullable(address.city, (value) => value.toPrimitive()), province: toNullable(address.province, (value) => value.toPrimitive()), postal_code: toNullable(address.postalCode, (value) => value.toPrimitive()), - country: toNullable(address.country, (value) => value.toPrimitive()), + country: toNullable(address.countryCode, (value) => value.toPrimitive()), }, contact: { diff --git a/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot-builder.ts b/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot-builder.ts index 8ec5b69c..34acdc19 100644 --- a/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot-builder.ts +++ b/modules/customers/src/api/application/snapshot-builders/summary/customer-summary-snapshot-builder.ts @@ -27,7 +27,7 @@ export class CustomerSummarySnapshotBuilder implements ICustomerSummarySnapshotB city: toNullable(address.city, (value) => value.toString()), postal_code: toNullable(address.postalCode, (value) => value.toString()), province: toNullable(address.province, (value) => value.toString()), - country: toNullable(address.country, (value) => value.toString()), + country: toNullable(address.countryCode, (value) => value.toString()), }, contact: { diff --git a/modules/customers/src/api/application/use-cases/update-customer.use-case.test.ts b/modules/customers/src/api/application/use-cases/update-customer.use-case.test.ts index 0dfb611d..05057e31 100644 --- a/modules/customers/src/api/application/use-cases/update-customer.use-case.test.ts +++ b/modules/customers/src/api/application/use-cases/update-customer.use-case.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import type { ITransactionManager } from "@erp/core/api"; import { - Country, + CountryCode, CurrencyCode, LanguageCode, Name, @@ -26,7 +26,7 @@ const buildCustomer = (paymentTermId?: UniqueID) => { const companyId = UniqueID.generateNewID(); const customerId = UniqueID.generateNewID(); const nameResult = Name.create("Acme"); - const countryResult = Country.create("ES"); + const countryResult = CountryCode.create("ES"); const languageCodeResult = LanguageCode.create("es"); const currencyCodeResult = CurrencyCode.create("EUR"); @@ -50,7 +50,7 @@ const buildCustomer = (paymentTermId?: UniqueID) => { city: Maybe.none(), province: Maybe.none(), postalCode: Maybe.none(), - country: Maybe.some(countryResult.data), + countryCode: Maybe.some(countryResult.data), }, emailPrimary: Maybe.none(), emailSecondary: Maybe.none(), diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts index 6100e014..3148d3f1 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-customer-domain.mapper.ts @@ -1,7 +1,7 @@ import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import { City, - Country, + CountryCode, CurrencyCode, EmailAddress, LanguageCode, @@ -94,9 +94,9 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< errors ); - const country = extractOrPushError( - maybeFromNullableResult(source.country, (value) => Country.create(value)), - "country", + const countryCode = extractOrPushError( + maybeFromNullableResult(source.country_code, (value) => CountryCode.create(value)), + "country_code", errors ); @@ -197,7 +197,7 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< city: city!, postalCode: postalCode!, province: province!, - country: country!, + countryCode: countryCode!, }; const postalAddress = extractOrPushError( @@ -294,7 +294,7 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper< postal_code: maybeToNullable(source.address.postalCode, (postalCode) => postalCode.toPrimitive() ), - country: maybeToNullable(source.address.country, (country) => country.toPrimitive()), + country: maybeToNullable(source.address.countryCode, (country) => country.toPrimitive()), }); } diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-customer-summary.mapper.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-customer-summary.mapper.ts index 894a8dad..42f7d759 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-customer-summary.mapper.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-customer-summary.mapper.ts @@ -1,7 +1,7 @@ import { type MapperParamsType, SequelizeQueryMapper } from "@erp/core/api"; import { City, - Country, + CountryCode, CurrencyCode, EmailAddress, LanguageCode, @@ -92,9 +92,9 @@ export class SequelizeCustomerSummaryMapper extends SequelizeQueryMapper< errors ); - const country = extractOrPushError( - maybeFromNullableResult(raw.country, (value) => Country.create(value)), - "country", + const countryCode = extractOrPushError( + maybeFromNullableResult(raw.country_code, (value) => CountryCode.create(value)), + "country_code", errors ); @@ -171,7 +171,7 @@ export class SequelizeCustomerSummaryMapper extends SequelizeQueryMapper< city: city!, postalCode: postalCode!, province: province!, - country: country!, + countryCode: countryCode!, }; const postalAddress = extractOrPushError( diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model.ts index 714537f1..901c7084 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model.ts @@ -32,7 +32,7 @@ export class CustomerModel extends Model< declare city: CreationOptional; declare province: CreationOptional; declare postal_code: CreationOptional; - declare country: CreationOptional; + declare country_code: CreationOptional; // Correos electrónicos declare email_primary: CreationOptional; @@ -137,8 +137,8 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, - country: { - type: DataTypes.STRING, + country_code: { + type: DataTypes.STRING(2), allowNull: true, defaultValue: null, }, diff --git a/modules/customers/src/web/update/ui/editor/customer-edit-form.tsx b/modules/customers/src/web/update/ui/editor/customer-edit-form.tsx index b8e7aa6b..2e9d3dbd 100644 --- a/modules/customers/src/web/update/ui/editor/customer-edit-form.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-edit-form.tsx @@ -33,7 +33,7 @@ export const CustomerUpdateEditorForm = ({ }: CustomerUpdateEditorFormProps) => { return (
{ ); } + const hasPageAlerts = updateCtrl.isUpdateError; + return ( @@ -96,6 +103,28 @@ export const CustomerUpdatePage = () => { } > + {hasPageAlerts ? ( + + + {updateCtrl.isUpdateError && ( + + )} + + + ) : null} +
diff --git a/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts b/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts index 023a4ce9..0e4a69fd 100644 --- a/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts +++ b/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts @@ -15,7 +15,7 @@ import { } from "@erp/customer-invoices/api/domain"; import { City, - Country, + CountryCode, CurrencyCode, DomainError, EmailAddress, @@ -396,7 +396,7 @@ export class CreateProformaFromFactugesInputMapper ); const country = extractOrPushError( - maybeFromNullableResult(customer.country, (value) => Country.create(value)), + maybeFromNullableResult(customer.country, (value) => CountryCode.create(value)), "country", errors ); @@ -457,7 +457,7 @@ export class CreateProformaFromFactugesInputMapper city: city!, postalCode: postalCode!, province: province!, - country: country!, + countryCode: country!, }; const customerProps: ProformaCustomerDraft = { diff --git a/modules/suppliers/src/api/application/audit/supplier-audit.ts b/modules/suppliers/src/api/application/audit/supplier-audit.ts index b2904264..a7dc6d1f 100644 --- a/modules/suppliers/src/api/application/audit/supplier-audit.ts +++ b/modules/suppliers/src/api/application/audit/supplier-audit.ts @@ -61,7 +61,7 @@ export const toSupplierAuditState = (supplier: Supplier): SupplierAuditState => city: toNullablePrimitive(supplier.address.getProps().city), province: toNullablePrimitive(supplier.address.getProps().province), postalCode: toNullablePrimitive(supplier.address.getProps().postalCode), - country: toNullablePrimitive(supplier.address.getProps().country), + country: toNullablePrimitive(supplier.address.getProps().countryCode), }, emailPrimary: toNullablePrimitive(supplier.emailPrimary), emailSecondary: toNullablePrimitive(supplier.emailSecondary), diff --git a/modules/suppliers/src/api/application/mappers/create-supplier-input.mapper.ts b/modules/suppliers/src/api/application/mappers/create-supplier-input.mapper.ts index 7b30ccf4..0c4de5f8 100644 --- a/modules/suppliers/src/api/application/mappers/create-supplier-input.mapper.ts +++ b/modules/suppliers/src/api/application/mappers/create-supplier-input.mapper.ts @@ -1,6 +1,6 @@ import { City, - Country, + CountryCode, CurrencyCode, DomainError, EmailAddress, @@ -96,7 +96,7 @@ export class CreateSupplierInputMapper implements ICreateSupplierInputMapper { ); const country = extractOrPushError( - maybeFromNullableResult(command.country, (value) => Country.create(value)), + maybeFromNullableResult(command.country, (value) => CountryCode.create(value)), "country", errors ); @@ -171,7 +171,7 @@ export class CreateSupplierInputMapper implements ICreateSupplierInputMapper { city: city!, postalCode: postalCode!, province: province!, - country: country!, + countryCode: country!, }; const supplierProps: ISupplierCreateProps = { diff --git a/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.ts b/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.ts index 1c624e9b..dad33a53 100644 --- a/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.ts +++ b/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.ts @@ -1,6 +1,6 @@ import { City, - Country, + CountryCode, CurrencyCode, DomainError, EmailAddress, @@ -240,8 +240,8 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { }); toPatchField(dto.country).ifSet((country) => { - postalAddressPatchProps.country = extractOrPushError( - maybeFromNullableResult(country, (value) => Country.create(value)), + postalAddressPatchProps.countryCode = extractOrPushError( + maybeFromNullableResult(country, (value) => CountryCode.create(value)), "country", errors ); diff --git a/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts b/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts index 21c4c7ca..b450befa 100644 --- a/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts +++ b/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import type { IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit"; import { City, - Country, + CountryCode, CurrencyCode, EmailAddress, LanguageCode, @@ -47,7 +47,7 @@ const buildSupplier = (status = SupplierStatus.active()) => city: Maybe.some(unwrap(City.create("Madrid"))), province: Maybe.some(unwrap(Province.create("Madrid"))), postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), - country: Maybe.some(unwrap(Country.create("ES"))), + countryCode: Maybe.some(unwrap(CountryCode.create("ES"))), }, emailPrimary: Maybe.some(unwrap(EmailAddress.create("supplier@example.test"))), emailSecondary: Maybe.none(), diff --git a/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.test.ts b/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.test.ts index edd480ee..e3e0e424 100644 --- a/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.test.ts +++ b/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.test.ts @@ -25,7 +25,7 @@ test("el snapshot de detalle satisface el contrato de respuesta", () => { city: Maybe.none(), province: Maybe.none(), postalCode: Maybe.none(), - country: Maybe.none(), + countryCode: Maybe.none(), }), }, emailPrimary: Maybe.none(), diff --git a/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts b/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts index 5d40bf44..4b6543db 100644 --- a/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts +++ b/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts @@ -27,7 +27,7 @@ export class SupplierFullSnapshotBuilder implements ISupplierFullSnapshotBuilder city: maybeToEmptyString(address.city, (value) => value.toPrimitive()), province: maybeToEmptyString(address.province, (value) => value.toPrimitive()), postal_code: maybeToEmptyString(address.postalCode, (value) => value.toPrimitive()), - country: maybeToEmptyString(address.country, (value) => value.toPrimitive()), + country: maybeToEmptyString(address.countryCode, (value) => value.toPrimitive()), email_primary: maybeToEmptyString(supplier.emailPrimary, (value) => value.toPrimitive()), email_secondary: maybeToEmptyString(supplier.emailSecondary, (value) => value.toPrimitive()), diff --git a/modules/suppliers/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts b/modules/suppliers/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts index 08fb22cf..376873c9 100644 --- a/modules/suppliers/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts +++ b/modules/suppliers/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts @@ -27,7 +27,7 @@ export class SupplierSummarySnapshotBuilder implements ISupplierSummarySnapshotB city: maybeToEmptyString(address.city, (value) => value.toString()), postal_code: maybeToEmptyString(address.postalCode, (value) => value.toString()), province: maybeToEmptyString(address.province, (value) => value.toString()), - country: maybeToEmptyString(address.country, (value) => value.toString()), + country: maybeToEmptyString(address.countryCode, (value) => value.toString()), email_primary: maybeToEmptyString(supplier.emailPrimary, (value) => value.toString()), email_secondary: maybeToEmptyString(supplier.emailSecondary, (value) => value.toString()), diff --git a/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts b/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts index 3cad1322..50c9c15e 100644 --- a/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts +++ b/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts @@ -43,7 +43,7 @@ const buildSupplier = (companyId: UniqueID) => { city: Maybe.none(), province: Maybe.none(), postalCode: Maybe.none(), - country: Maybe.none(), + countryCode: Maybe.none(), }); const languageCode = LanguageCode.create("es"); const currencyCode = CurrencyCode.create("EUR"); diff --git a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts index 92a8b7ea..cad723dd 100644 --- a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts +++ b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts @@ -28,7 +28,7 @@ test("crea Supplier activo por defecto y actualiza su dirección", () => { city: Maybe.none(), postalCode: Maybe.none(), province: Maybe.none(), - country: Maybe.none(), + countryCode: Maybe.none(), }, emailPrimary: Maybe.none(), emailSecondary: Maybe.none(), @@ -70,7 +70,7 @@ test("activa y desactiva Supplier sin persistir transiciones repetidas", () => { city: Maybe.none(), postalCode: Maybe.none(), province: Maybe.none(), - country: Maybe.none(), + countryCode: Maybe.none(), }, emailPrimary: Maybe.none(), emailSecondary: Maybe.none(), diff --git a/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.test.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.test.ts index 69e87906..0e29552e 100644 --- a/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.test.ts +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.test.ts @@ -45,7 +45,7 @@ test("rehidrata identidad, estado, booleanos y opcionales desde persistencia", ( assert.equal(supplier.reference.isNone(), true); assert.equal(supplier.tradeName.isNone(), true); assert.equal(supplier.mobileSecondary.isNone(), true); - assert.equal(supplier.address.country.isNone(), true); + assert.equal(supplier.address.countryCode.isNone(), true); }); test("el round-trip conserva los campos persistibles relevantes", () => { @@ -62,7 +62,7 @@ test("el round-trip conserva los campos persistibles relevantes", () => { assert.equal(result.data.status, "inactive"); assert.equal(result.data.is_company, false); assert.equal(result.data.mobile_secondary, null); - assert.equal(result.data.country, null); + assert.equal(result.data.country_code, null); }); test("rehidrata status activo e is_company true sin conversiones legacy", () => { diff --git a/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts index 77948c2a..b44d1881 100644 --- a/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts @@ -1,7 +1,7 @@ import { SequelizeDomainMapper } from "@erp/core/api"; import { City, - Country, + CountryCode, CurrencyCode, EmailAddress, LanguageCode, @@ -23,10 +23,10 @@ import { import { Result } from "@repo/rdx-utils"; import { - type SupplierInternalProps, - Supplier, - SupplierStatus, SUPPLIER_STATUS, + Supplier, + type SupplierInternalProps, + SupplierStatus, } from "../../../../../domain"; import type { SupplierCreationAttributes, SupplierModel } from "../../../sequelize"; @@ -108,8 +108,8 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< ); const country = extractOrPushError( - maybeFromNullableResult(source.country, (value) => Country.create(value)), - "country", + maybeFromNullableResult(source.country_code, (value) => CountryCode.create(value)), + "country_code", errors ); @@ -180,7 +180,7 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< city: city!, postalCode: postalCode!, province: province!, - country: country!, + countryCode: country!, }; const postalAddress = extractOrPushError( @@ -259,7 +259,7 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< postal_code: maybeToNullable(source.address.postalCode, (postalCode) => postalCode.toPrimitive() ), - country: maybeToNullable(source.address.country, (country) => country.toPrimitive()), + country: maybeToNullable(source.address.countryCode, (country) => country.toPrimitive()), }); } diff --git a/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts index fd5a4dfc..c161ccc6 100644 --- a/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts @@ -1,7 +1,7 @@ import { SequelizeQueryMapper } from "@erp/core/api"; import { City, - Country, + CountryCode, CurrencyCode, EmailAddress, LanguageCode, @@ -22,7 +22,7 @@ import { import { Result } from "@repo/rdx-utils"; import type { SupplierSummary } from "../../../../../application"; -import { SupplierStatus, SUPPLIER_STATUS } from "../../../../../domain"; +import { SUPPLIER_STATUS, SupplierStatus } from "../../../../../domain"; import type { SupplierModel } from "../../models"; const PERSISTED_STATUS_TO_DOMAIN: Readonly> = { @@ -98,8 +98,8 @@ export class SequelizeSupplierSummaryMapper extends SequelizeQueryMapper< ); const country = extractOrPushError( - maybeFromNullableResult(raw.country, (value) => Country.create(value)), - "country", + maybeFromNullableResult(raw.country_code, (value) => CountryCode.create(value)), + "country_code", errors ); @@ -170,7 +170,7 @@ export class SequelizeSupplierSummaryMapper extends SequelizeQueryMapper< city: city!, postalCode: postalCode!, province: province!, - country: country!, + countryCode: country!, }; const postalAddress = extractOrPushError( diff --git a/modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts index ae100d64..d318b23c 100644 --- a/modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts @@ -32,7 +32,7 @@ export class SupplierModel extends Model< declare city: CreationOptional; declare province: CreationOptional; declare postal_code: CreationOptional; - declare country: CreationOptional; + declare country_code: CreationOptional; // Correos electrónicos declare email_primary: CreationOptional; @@ -125,8 +125,8 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, - country: { - type: DataTypes.STRING, + country_code: { + type: DataTypes.STRING(2), allowNull: true, defaultValue: null, }, diff --git a/packages/rdx-ddd/src/value-objects/__tests__/postal-address.test.ts b/packages/rdx-ddd/src/value-objects/__tests__/postal-address.test.ts index 27b44ad0..efdc8cf6 100644 --- a/packages/rdx-ddd/src/value-objects/__tests__/postal-address.test.ts +++ b/packages/rdx-ddd/src/value-objects/__tests__/postal-address.test.ts @@ -1,43 +1,37 @@ +import { Maybe } from "@repo/rdx-utils"; + +import { City } from "../city"; +import { CountryCode } from "../country-code"; import { PostalAddress } from "../postal-address"; +import { PostalCode } from "../postal-code"; +import { Province } from "../province"; +import { Street } from "../street"; -describe("PostalAddress Value Object", () => { - const validAddress = { - street: "123 Main St", - city: "Springfield", - postalCode: "12345", - state: "IL", - country: "USA", - }; +const unwrap = (result: { data: T }): T => result.data; - test("✅ Debería crear un PostalAddress con valores válidos", () => { - const result = PostalAddress.create(validAddress); +describe("PostalAddress", () => { + const address = () => + PostalAddress.create({ + street: Maybe.some(unwrap(Street.create("Main street 1"))), + street2: Maybe.none(), + city: Maybe.some(unwrap(City.create("Madrid"))), + postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), + province: Maybe.some(unwrap(Province.create("Madrid"))), + countryCode: Maybe.some(unwrap(CountryCode.create("es"))), + }); + + test("uses CountryCode and serializes its normalized primitive", () => { + const result = address(); expect(result.isSuccess).toBe(true); - expect(result.data).toBeInstanceOf(PostalAddress); - }); - - test("❌ Debería fallar al crear un PostalAddress con código postal inválido", () => { - const invalidAddress = { ...validAddress, postalCode: "abc" }; // Código postal inválido - const result = PostalAddress.create(invalidAddress); - - expect(result.isFailure).toBe(true); - expect(result.error?.message).toBe("Invalid postal code format"); - }); - - test("✅ Métodos getters deberían devolver valores esperados", () => { - const address = PostalAddress.create(validAddress).data; - - expect(address.street).toBe(validAddress.street); - expect(address.city).toBe(validAddress.city); - expect(address.postalCode).toBe(validAddress.postalCode); - expect(address.state).toBe(validAddress.state); - expect(address.country).toBe(validAddress.country); - }); - - test("✅ `toString()` debería devolver la representación correcta", () => { - const address = PostalAddress.create(validAddress).data; - const expectedString = `${validAddress.street}, ${validAddress.city}, ${validAddress.postalCode}, ${validAddress.state}, ${validAddress.country}`; - - expect(address.toString()).toBe(expectedString); + expect(result.data.countryCode.unwrap().toPrimitive()).toBe("ES"); + expect(result.data.toString()).toEqual({ + street: "Main street 1", + street2: "", + city: "Madrid", + postal_code: "28001", + province: "Madrid", + country_code: "ES", + }); }); }); diff --git a/packages/rdx-ddd/src/value-objects/postal-address.ts b/packages/rdx-ddd/src/value-objects/postal-address.ts index 238eb502..2ef05fb4 100644 --- a/packages/rdx-ddd/src/value-objects/postal-address.ts +++ b/packages/rdx-ddd/src/value-objects/postal-address.ts @@ -3,7 +3,7 @@ import { type Maybe, Result } from "@repo/rdx-utils"; import { maybeToEmptyString } from "../helpers"; import type { City } from "./city"; -import type { Country } from "./country"; +import type { CountryCode } from "./country-code"; import type { PostalCode } from "./postal-code"; import type { Province } from "./province"; import type { Street } from "./street"; @@ -15,7 +15,7 @@ export interface PostalAddressProps { city: Maybe; postalCode: Maybe; province: Maybe; - country: Maybe; + countryCode: Maybe; } export type PostalAddressPatchProps = Partial; @@ -27,48 +27,32 @@ export class PostalAddress extends ValueObject { static create(values: PostalAddressProps): Result { const valueIsValid = PostalAddress.validate(values); - - if (valueIsValid.isFailure) { - return Result.fail(valueIsValid.error); - } + if (valueIsValid.isFailure) return Result.fail(valueIsValid.error); return Result.ok(new PostalAddress(values)); } get street(): Maybe { return this.props.street; } - get street2(): Maybe { return this.props.street2; } - get city(): Maybe { return this.props.city; } - get postalCode(): Maybe { return this.props.postalCode; } - get province(): Maybe { return this.props.province; } - - get country(): Maybe { - return this.props.country; + get countryCode(): Maybe { + return this.props.countryCode; } getProps(): PostalAddressProps { - return { - street: this.street, - street2: this.street2, - city: this.city, - postalCode: this.postalCode, - province: this.province, - country: this.country, - }; + return { ...this.props }; } - toPrimitive() { return this.getProps(); } @@ -80,11 +64,18 @@ export class PostalAddress extends ValueObject { city: maybeToEmptyString(this.city, (value) => value.toString()), postal_code: maybeToEmptyString(this.postalCode, (value) => value.toString()), province: maybeToEmptyString(this.province, (value) => value.toString()), - country: maybeToEmptyString(this.country, (value) => value.toString()), + country_code: maybeToEmptyString(this.countryCode, (value) => value.toString()), }; } toFormat(): string { - return `${this.props.street}, ${this.props.street2}, ${this.props.city}, ${this.props.postalCode}, ${this.props.province}, ${this.props.country}`; + return [this.street, this.street2, this.city, this.postalCode, this.province, this.countryCode] + .map((value) => + value.match( + (item) => item.toString(), + () => "" + ) + ) + .join(", "); } }