refactor: update country references to use CountryCode in supplier and company modules

- Changed references from Country to CountryCode in supplier input mappers, audit services, and snapshot builders.
- Updated tests to reflect the changes in country handling.
- Refactored postal address to utilize CountryCode instead of Country.
- Added new company address mappers and tests to accommodate the updated structure.
- Ensured all related schemas and DTOs are aligned with the new country code implementation.
This commit is contained in:
David Arranz 2026-09-07 12:22:04 +02:00
parent 275fc0aca8
commit a4398e163f
79 changed files with 1356 additions and 1468 deletions

View File

@ -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`.

View File

@ -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`

View File

@ -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

View File

@ -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?`

View File

@ -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");
});

View File

@ -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<PostalAddress, Error> {
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),
});
}

View File

@ -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<CompanyPublicModel, "address"> = {
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",
});
});

View File

@ -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,
};
}

View File

@ -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 = <T>(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<PostalAddress>()) => 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);
});

View File

@ -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()
),

View File

@ -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";
}

View File

@ -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,

View File

@ -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");
});

View File

@ -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<Result<CompanyResponseDTO, Error>> {
public execute(params: {
dto: CreateCompanyRequestDTO;
}): Promise<Result<CompanyResponseDTO, Error>> {
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<PostalAddress> = 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,
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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<unknown> = [];
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<unknown>).isNone(), true);
assert.equal(
(captured[2] as Maybe<{ countryCode: Maybe<{ toPrimitive(): string }> }>)
.unwrap()
.countryCode.unwrap()
.toPrimitive(),
"ES"
);
});

View File

@ -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<CompanyPatc
patchProps.website = websiteResult.data;
}
if (dto.address !== undefined) {
if (dto.address === null) {
patchProps.address = Maybe.none();
} else {
const addressResult = toPostalAddress(dto.address);
if (addressResult.isFailure) {
return Result.fail(addressResult.error);
}
patchProps.address = Maybe.some(addressResult.data);
}
}
if (dto.default_payment_term_id !== undefined) {
const defaultPaymentTermIdResult = maybeFromNullableResult(
dto.default_payment_term_id,
@ -170,29 +186,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,
};
}

View File

@ -55,6 +55,7 @@ const buildCompany = () => {
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<unknown> = [];
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<unknown>).isNone(), true);
assert.equal(
(captured[2] as Maybe<{ countryCode: Maybe<{ toPrimitive(): string }> }>)
.unwrap()
.countryCode.unwrap()
.toPrimitive(),
"ES"
);
});

View File

@ -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<void, Error> {
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,
};
}

View File

@ -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 = <T>(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<PostalAddress>()) => ({
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<PostalAddress>()) =>
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
);
});

View File

@ -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<EmailAddress>;
phone: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
address: Maybe<PostalAddress>;
defaultPaymentTermId: Maybe<UniqueID>;
status: CompanyStatus;
createdAt: UtcDate;
@ -36,6 +38,7 @@ export type CompanyPatchProps = Partial<{
email: Maybe<EmailAddress>;
phone: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
address: Maybe<PostalAddress>;
defaultPaymentTermId: Maybe<UniqueID>;
}> & {
updatedAt: UtcDate;
@ -109,6 +112,10 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
return this.props.website;
}
public get address(): Maybe<PostalAddress> {
return this.props.address;
}
public get defaultPaymentTermId(): Maybe<UniqueID> {
return this.props.defaultPaymentTermId;
}
@ -173,6 +180,14 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
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<CompanyInternalProps> {
return this.status.isActive();
}
private static sameMaybePostalAddress(
current: Maybe<PostalAddress>,
next: Maybe<PostalAddress>
): 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<T extends { equals(other: T): boolean }>(
current: Maybe<T>,
next: Maybe<T>

View File

@ -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 = <T>(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<PostalAddress>()) =>
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<string, string | null> = {}) =>
({
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<string, string | null>);
partial[key as keyof CompanyModel] = null as never;
assert.equal(mapper.mapToDomain(partial).isFailure, true);
}
});

View File

@ -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<Maybe<PostalAddress>, 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()
),

View File

@ -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,

View File

@ -1,2 +1,3 @@
export * from "./request";
export * from "./response";
export * from "./shared";

View File

@ -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<typeof CreateCompanyRequestSchema>;

View File

@ -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<typeof UpdateCompanyByIdParamsRequestSchema>;
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<typeof UpdateCompanyRequestSchema>;

View File

@ -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<typeof UpdateCurrentCompanyRequestSchema>;

View File

@ -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);
});

View File

@ -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,
});

View File

@ -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);
}
});

View File

@ -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<typeof CompanyAddressRequestSchema>;

View File

@ -0,0 +1 @@
export * from "./company-address.request.dto";

View File

@ -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));

View File

@ -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
);
});

View File

@ -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<typeof CompanySettingsFormSchema>;

View File

@ -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",
});
});

View File

@ -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,
};
},

View File

@ -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;
}

View File

@ -166,7 +166,6 @@ export const CompanyDetailsSettingsPage = () => {
)}
typePreset="email"
/>
<TextField<CompanySettingsForm>
description={t(
"settings.companyDetails.sections.contact.fields.phone.description"
@ -197,6 +196,45 @@ export const CompanyDetailsSettingsPage = () => {
/>
</RdxFormGrid>
</RdxPanel>
<RdxPanel
description="Datos fiscales de la empresa"
icon={<ContactIcon className="size-5" />}
title="Dirección fiscal"
variant="plain"
>
<RdxFormGrid className="md:grid-cols-2">
<TextField<CompanySettingsForm>
disabled={ctrl.isSaving}
label="Dirección"
name="address.street"
/>
<TextField<CompanySettingsForm>
disabled={ctrl.isSaving}
label="Dirección 2"
name="address.street2"
/>
<TextField<CompanySettingsForm>
disabled={ctrl.isSaving}
label="Código postal"
name="address.postalCode"
/>
<TextField<CompanySettingsForm>
disabled={ctrl.isSaving}
label="Localidad"
name="address.city"
/>
<TextField<CompanySettingsForm>
disabled={ctrl.isSaving}
label="Provincia"
name="address.province"
/>
<TextField<CompanySettingsForm>
disabled={ctrl.isSaving}
label="País"
name="address.countryCode"
/>
</RdxFormGrid>
</RdxPanel>
<RdxPanel
description={t("settings.companyDetails.sections.paymentTerm.description")}

View File

@ -14,6 +14,7 @@ test('convierte null del Entity a "" en el Form para campos nullable', () => {
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: "",
});
});

View File

@ -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 ?? "",
});

View File

@ -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",
},
},
});
});

View File

@ -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 => {

View File

@ -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
);

View File

@ -458,7 +458,7 @@ export class SequelizeProformaRepositoryV2
"city",
"postal_code",
"province",
"country",
"country_code",
],
},
{

View File

@ -62,9 +62,7 @@ export const ProformaUpdateTaxEditor = ({
)}
readOnly={readOnly}
/>
<Separator className="w-full col-start-1 col-span-full" />
<SwitchField
className="md:col-span-12 md:col-start-1 not-disabled:cursor-pointer"
disabled={disabled}
@ -75,7 +73,6 @@ export const ProformaUpdateTaxEditor = ({
}}
readOnly={readOnly}
/>
<SelectField
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:[&>[data-slot=field-label]]:min-w-0 min-[480px]:[&>[data-slot=field-label]]:flex-auto min-[480px]:[&>[button]]:ml-auto min-[480px]:[&>[button]]:min-w-[11rem] min-[480px]:[&>[button]]:flex-none [&>[data-slot=field-description]]:basis-full [&>[data-slot=field-description]]:w-full [&>[data-slot=field-description]]:pl-0 [&>[data-slot=field-error]]:basis-full [&>[data-slot=field-error]]:w-full [&>[data-slot=field-error]]:pl-0"
description={
@ -105,7 +102,6 @@ export const ProformaUpdateTaxEditor = ({
readOnly={readOnly}
serialize={(value) => (typeof value === "number" ? String(value) : "")}
/>
<Separator className="w-full col-start-1 col-span-full" />
<SwitchField
@ -129,7 +125,6 @@ export const ProformaUpdateTaxEditor = ({
onCheckedChange={taxCtrl.updateRecPercentage}
readOnly={readOnly}
/>
<SwitchField
className="md:col-span-12 md:col-start-1"
disabled={disabled}
@ -137,7 +132,6 @@ export const ProformaUpdateTaxEditor = ({
name="hasRetentionPercentage"
readOnly={readOnly}
/>
<SelectField
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:[&>[data-slot=field-label]]:min-w-0 min-[480px]:[&>[data-slot=field-label]]:flex-auto min-[480px]:[&>[button]]:ml-auto min-[480px]:[&>[button]]:min-w-[11rem] min-[480px]:[&>[button]]:flex-none [&>[data-slot=field-description]]:basis-full [&>[data-slot=field-description]]:w-full [&>[data-slot=field-description]]:pl-0 [&>[data-slot=field-error]]:basis-full [&>[data-slot=field-error]]:w-full [&>[data-slot=field-error]]:pl-0"
deserialize={(value) => (value === null || value === "" ? null : Number(value))}

View File

@ -185,7 +185,7 @@ export const ProformaUpdatePage = () => {
/>
}
>
<RdxPageContainer padding="default" width="full">
<RdxPageContainer width="full">
{hasPageAlerts ? (
<RdxPageContainer className="pb-0" width="full">
<RdxPageStack spacing="compact">

View File

@ -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(),

View File

@ -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),

View File

@ -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 = {

View File

@ -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
);
});

View File

@ -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: {

View File

@ -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: {

View File

@ -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(),

View File

@ -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()),
});
}

View File

@ -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(

View File

@ -32,7 +32,7 @@ export class CustomerModel extends Model<
declare city: CreationOptional<string | null>;
declare province: CreationOptional<string | null>;
declare postal_code: CreationOptional<string | null>;
declare country: CreationOptional<string | null>;
declare country_code: CreationOptional<string | null>;
// Correos electrónicos
declare email_primary: CreationOptional<string | null>;
@ -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,
},

View File

@ -33,7 +33,7 @@ export const CustomerUpdateEditorForm = ({
}: CustomerUpdateEditorFormProps) => {
return (
<form
className={cn("flex min-w-0 flex-col gap-6", className)}
className={cn("space-y-6", className)}
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}

View File

@ -1,6 +1,11 @@
import { ErrorAlert, PageErrorState } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { RdxEditorLayout, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
import {
RdxEditorLayout,
RdxPageContainer,
RdxPageFrame,
RdxPageStack,
} from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../i18n";
@ -66,6 +71,8 @@ export const CustomerUpdatePage = () => {
);
}
const hasPageAlerts = updateCtrl.isUpdateError;
return (
<FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
@ -96,6 +103,28 @@ export const CustomerUpdatePage = () => {
}
>
<RdxPageContainer width="content">
{hasPageAlerts ? (
<RdxPageContainer className="pb-0" width="full">
<RdxPageStack spacing="compact">
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t(
"pages.customers.update.error_msg",
"Revisa los datos e inténtalo de nuevo."
)
}
title={t(
"pages.customers.update.error_title",
"No se pudo guardar los cambios"
)}
/>
)}
</RdxPageStack>
</RdxPageContainer>
) : null}
<RdxEditorLayout>
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<div className="flex min-w-0 flex-col gap-6">

View File

@ -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 = {

View File

@ -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),

View File

@ -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 = {

View File

@ -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
);

View File

@ -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(),

View File

@ -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(),

View File

@ -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()),

View File

@ -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()),

View File

@ -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");

View File

@ -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(),

View File

@ -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", () => {

View File

@ -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()),
});
}

View File

@ -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<Record<string, SUPPLIER_STATUS>> = {
@ -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(

View File

@ -32,7 +32,7 @@ export class SupplierModel extends Model<
declare city: CreationOptional<string | null>;
declare province: CreationOptional<string | null>;
declare postal_code: CreationOptional<string | null>;
declare country: CreationOptional<string | null>;
declare country_code: CreationOptional<string | null>;
// Correos electrónicos
declare email_primary: CreationOptional<string | null>;
@ -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,
},

View File

@ -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 = <T>(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",
});
});
});

View File

@ -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<City>;
postalCode: Maybe<PostalCode>;
province: Maybe<Province>;
country: Maybe<Country>;
countryCode: Maybe<CountryCode>;
}
export type PostalAddressPatchProps = Partial<PostalAddressProps>;
@ -27,48 +27,32 @@ export class PostalAddress extends ValueObject<PostalAddressProps> {
static create(values: PostalAddressProps): Result<PostalAddress, Error> {
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<Street> {
return this.props.street;
}
get street2(): Maybe<Street> {
return this.props.street2;
}
get city(): Maybe<City> {
return this.props.city;
}
get postalCode(): Maybe<PostalCode> {
return this.props.postalCode;
}
get province(): Maybe<Province> {
return this.props.province;
}
get country(): Maybe<Country> {
return this.props.country;
get countryCode(): Maybe<CountryCode> {
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<PostalAddressProps> {
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(", ");
}
}