diff --git a/AGENTS.md b/AGENTS.md index 119b394c..6ef9196b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,19 @@ Documentar mediante TSDoc las APIs públicas cuando aporten información sobre: Después de un refactor, actualizar o eliminar los comentarios que hayan quedado obsoletos. +La documentación del proyecto debe redactarse en castellano. + +Reglas de estilo para documentación: + +- usar lenguaje claro; +- evitar redacción excesivamente técnica cuando no sea necesaria; +- usar frases directas; +- evitar frases rebuscadas, largas o ambiguas; +- priorizar explicar qué hace el sistema, por qué existe una regla y qué comportamiento debe esperar el usuario o el desarrollador; +- usar términos técnicos cuando sean necesarios para ser precisos, pero explicarlos de forma sencilla; +- no convertir la documentación en una transcripción del código; +- evitar párrafos densos si una explicación breve y directa es suficiente. + ## Disciplina de cambios - Realizar cambios incrementales. diff --git a/apps/web/package.json b/apps/web/package.json index 5b80ab69..b659c91e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@erp/auth": "workspace:*", "@erp/core": "workspace:*", "@erp/catalogs": "workspace:*", + "@erp/companies": "workspace:*", "@erp/customer-invoices": "workspace:*", "@erp/customers": "workspace:*", "@erp/identity": "workspace:*", diff --git a/apps/web/src/register-modules.tsx b/apps/web/src/register-modules.tsx index 785ee267..3247deaf 100644 --- a/apps/web/src/register-modules.tsx +++ b/apps/web/src/register-modules.tsx @@ -1,4 +1,5 @@ import { CatalogsModuleManifest } from "@erp/catalogs/client"; +import { CompaniesModuleManifest } from "@erp/companies/client"; import CoreModuleManifest, { type IModuleClient } from "@erp/core/client"; import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client"; import { CustomersModuleManifest } from "@erp/customers/client"; @@ -7,6 +8,7 @@ import { IdentityModuleManifest } from "@erp/identity/client"; export const modules: IModuleClient[] = [ IdentityModuleManifest, CoreModuleManifest, + CompaniesModuleManifest, CatalogsModuleManifest, CustomersModuleManifest, CustomerInvoicesModuleManifest, diff --git a/docs/README.md b/docs/README.md index ba3a51b3..048ef073 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP. #### Customer invoices y proformas - [Índice de `customer-invoices`](./customer-invoices/README.md) +- [Plazos de pago](./catalogs/payment-terms.md) - [Diseño backend del perfil VeriFactu empresarial](./customer-invoices/settings/verifactu-company-profile-backend-design.md) - [Diseño frontend de Settings para VeriFactu](./customer-invoices/settings/frontend-erp-settings-verifactu-design.md) - [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md) diff --git a/docs/catalogs/payment-terms.md b/docs/catalogs/payment-terms.md new file mode 100644 index 00000000..666cd28a --- /dev/null +++ b/docs/catalogs/payment-terms.md @@ -0,0 +1,494 @@ +# Plazos de pago + +## Objetivo + +Este documento resume cómo funciona `Payment Terms` en el ERP y qué reglas debe respetar cada módulo. + +La cadena funcional final es: + +```text +Company.defaultPaymentTermId + ↓ valor inicial +Customer.paymentTermId + ↓ valor inicial +Proforma.paymentTermId + ↓ snapshot al emitir +IssuedInvoice.paymentTerm +``` + +## Modelo general + +`PaymentTerm` define una plantilla de vencimientos. + +Sus `dueRules` describen la distribución teórica del plazo, por ejemplo: + +- 100% a 30 días +- 50% contado y 50% a 30 días + +Estas reglas no crean todavía vencimientos reales, cuotas monetarias ni estado de cobro. Eso queda fuera de alcance hasta un futuro módulo de pagos y cobros. + +## Referencia viva y snapshot + +| Entidad | Modelo | +|---|---| +| Company | referencia viva | +| Customer | referencia viva | +| Proforma | referencia viva | +| Issued Invoice | snapshot histórico | + +La diferencia es simple: + +- Company, Customer y Proforma siguen usando configuración viva. +- Issued Invoice debe conservar exactamente lo que se emitió. + +## Empresa + +`Company` tiene: + +```ts +defaultPaymentTermId: string | null +``` + +Es una referencia viva al catálogo de `Payment Terms`. + +Reglas: + +- puede ser `null`; +- puede apuntar a un `PaymentTerm` activo de la misma empresa; +- no puede apuntar a otra empresa; +- `isSystem` está permitido. + +Cambiar el default de Company: + +- no modifica Customers existentes; +- no modifica Proformas existentes. + +Solo afecta a futuros valores iniciales. + +### Desactivación del default + +Si un `PaymentTerm` está configurado como `Company.defaultPaymentTermId`, no puede desactivarse. + +Antes hay que: + +- cambiar el default; +- o limpiarlo. + +Esto evita que la empresa conserve un default que ya no podría reutilizar en nuevas asignaciones. + +## Clientes + +`Customer` tiene: + +```ts +paymentTermId: string | null +``` + +Es una referencia viva propia. + +### Create + +Semántica: + +- campo omitido o `undefined` -> aplicar `Company.defaultPaymentTermId`; +- `null` explícito -> crear el Customer sin plazo; +- `id` -> usar ese `PaymentTerm` si existe, pertenece a la misma empresa y está activo. + +Una vez creado el Customer, su `paymentTermId` pasa a ser propio. + +Cambiar después el default de Company no modifica ese Customer. + +### Update + +Semántica: + +- `undefined` -> no cambiar; +- `null` -> limpiar; +- `id` -> asignar si es activo y de la misma empresa. + +### Inactivos + +Si un `PaymentTerm` ya asignado al Customer se desactiva después: + +- el Customer puede conservarlo; +- no puede volver a seleccionarse como nueva asignación. + +### Quick create + +El quick create no necesita pedir plazo de pago al usuario. + +Si no envía un valor explícito, el backend aplica la misma regla que en el alta completa: + +```text +Company.defaultPaymentTermId +``` + +No debe haber reglas distintas entre: + +- create completo; +- quick create; +- API directa. + +## Proformas + +`Proforma.paymentTermId` es una referencia viva. + +No es un snapshot. + +Mientras la Proforma no se haya emitido, cualquier cambio posterior en la definición del catálogo afecta a la lectura actual de ese plazo. + +### Create + +Prioridad de valor inicial: + +1. `Customer.paymentTermId` +2. `Company.defaultPaymentTermId` +3. `null` + +Reglas: + +- si el usuario selecciona manualmente otro plazo, ese valor prevalece; +- `null` explícito significa “sin plazo de pago”; +- una selección manual de `null` no debe reactivar después un default automático. + +### Update + +Semántica: + +- `undefined` -> no cambiar; +- `null` -> limpiar; +- `id` -> nueva asignación válida. + +Si la Proforma ya tenía un `PaymentTerm` que luego quedó inactivo: + +- puede conservarlo; +- puede emitirse; +- no pueden elegirse nuevos inactivos. + +## Cambio de Customer en una Proforma + +Cambiar el Customer de una Proforma existente siempre requiere confirmación. + +No basta con cambiar `customerId`. Antes hay que cargar el Customer completo y evaluar compatibilidad. + +### Moneda + +Si: + +```text +newCustomer.currencyCode !== proforma.currencyCode +``` + +el cambio se bloquea. + +No se convierte moneda ni se reinterpretan importes. + +### Idioma + +Si: + +```text +newCustomer.languageCode !== proforma.languageCode +``` + +el cambio también se bloquea. + +Idioma y moneda se consideran características estructurales del documento. + +### Estados del modal + +Incompatible: + +- no puede confirmarse; +- debe informarse del motivo. + +Compatible: + +- informa qué condiciones se adaptarán; +- informa qué valores se conservarán. + +### Plazo de pago al cambiar Customer + +Política: + +```text +REFRESH_IF_NOT_OVERRIDDEN +``` + +Si el usuario no lo modificó manualmente: + +```text +newCustomer.paymentTermId +-> Company.defaultPaymentTermId +-> null +``` + +Si lo modificó manualmente: + +- se conserva el valor actual. + +### Forma de pago al cambiar Customer + +La forma de pago usa la misma política: + +```text +REFRESH_IF_NOT_OVERRIDDEN +``` + +No se añade un default de Company para `PaymentMethod` porque esa regla no existe en este flujo. + +### Dirty y override manual + +Hay dos conceptos distintos: + +- cambio pendiente de guardar; +- override manual. + +Ejemplo: + +- Customer A -> plazo 30 días +- cambio a Customer B -> se adapta automáticamente a 60 días + +Ese cambio: + +- debe guardarse; +- no cuenta como override manual. + +Si luego cambia a Customer C, puede volver a adaptarse. + +En cambio, si el usuario selecciona manualmente “Contado”: + +- sí existe override manual; +- futuros cambios de Customer no deben sobrescribir ese valor. + +La misma explicación aplica a: + +- `paymentTermId`; +- `paymentMethodId`. + +### Fiscalidad + +La fiscalidad no se cambia silenciosamente. + +Si el nuevo Customer tiene una configuración fiscal distinta: + +- el modal debe advertirlo; +- el usuario debe confirmarlo; +- al confirmar, se adapta la configuración fiscal; +- se recalculan impuestos y totales con la infraestructura fiscal existente. + +La existencia de líneas no bloquea por sí sola el cambio. + +### Recipient + +Si el cambio de Customer se confirma, el `recipient` snapshot de la Proforma siempre se refresca con el nuevo Customer. + +Nunca debe quedar un documento con: + +```text +customerId = Customer B +recipient = Customer A +``` + +El preview y el PDF usan ese snapshot documental. + +## Duplicación de Proformas + +Duplicar una Proforma crea un documento nuevo. Por eso no puede reutilizar una nueva asignación inactiva solo porque el original la conservaba. + +### Original con Payment Term activo + +- se copia el mismo `paymentTermId`. + +### Original con Payment Term inactivo + +No se copia como nueva asignación. + +Se resuelve así: + +1. `Customer.paymentTermId`, solo si está activo; +2. `Company.defaultPaymentTermId`, solo si está activo; +3. `null`. + +Reglas adicionales: + +- si el Customer tiene un término inactivo, se ignora; +- si la Company tiene por datos legacy un default inactivo, no se usa; +- la duplicación no falla por esa inconsistencia legacy. + +### Feedback de duplicación + +Si el plazo original estaba inactivo y hubo que sustituirlo o eliminarlo, el usuario recibe feedback después de duplicar. + +La respuesta expone metadata funcional equivalente a: + +```yaml +payment_term_adaptation: + null + o + { + source: "inactive_original", + resolution: "customer" | "company" | "none" + } +``` + +La intención de esa metadata es explicar si el sistema: + +- conservó el mismo plazo; +- usó el del Customer; +- usó el default de Company; +- o creó la nueva Proforma sin plazo. + +## Facturas emitidas + +Issued Invoice no mantiene una referencia viva para interpretar el plazo. + +Al emitir una Proforma, el sistema guarda un snapshot histórico. + +Shape conceptual: + +```ts +paymentTerm = { + sourceId, + name, + description, + dueRules +} +``` + +### Qué se conserva + +- `payment_term_id` para trazabilidad; +- `payment_term_name` como snapshot; +- `payment_term_description` como snapshot; +- `payment_term_snapshot_json` con las reglas congeladas. + +Los porcentajes conservan precisión escalada. No se convierten a `float` para persistir el histórico. + +### Inmutabilidad histórica + +Ejemplo: + +- una factura se emite con “30 días” y 100% a 30 días; +- después el catálogo cambia a “30 / 60 días” con 50% / 50%. + +La factura emitida sigue mostrando: + +- “30 días”; +- 100% a 30 días. + +El detail y el reporting de Issued Invoice no consultan el catálogo vivo para reconstruir ese plazo. + +### Payment Term inactivo en emisión + +Si una Proforma ya tenía asignado un `PaymentTerm` y después se desactiva: + +- la Proforma puede emitirse. + +La inactividad impide nuevas asignaciones. No invalida una referencia ya existente en la Proforma. + +### Referencia perdida en emisión + +Si la Proforma tiene `paymentTermId` pero al emitir no puede resolverse dentro de la misma empresa: + +- la emisión falla. + +No se crea una factura sin snapshot de forma silenciosa y tampoco se usa un fallback nuevo desde Customer o Company. + +## Activos e inactivos + +| Contexto | Puede conservar inactivo | Puede asignar inactivo | +|---|---:|---:| +| Company default | NO | NO | +| Customer | SÍ | NO | +| Proforma | SÍ | NO | +| Issued Invoice | snapshot histórico | N/A | + +Nota: + +`Company.defaultPaymentTermId` no puede quedar inactivo porque la desactivación se bloquea. + +## Aislamiento por empresa + +Toda nueva asignación o resolución debe quedar limitada a la empresa activa. + +Aplica a: + +- Company default; +- Customer create; +- Customer update; +- Proforma create; +- Proforma update; +- emisión; +- duplicación. + +## isSystem + +`isSystem` afecta a la gestión del catálogo. + +No impide usar un `PaymentTerm` en: + +- Company default; +- Customer; +- Proforma; +- emisión + +si el término es válido para esa operación. + +## Reporting + +En reporting: + +- Proforma usa el `PaymentTerm` vivo; +- Issued Invoice usa el snapshot histórico. + +Además, para seleccionar templates FastReport: + +```text +es-ES -> es +``` + +## Qué no está incluido todavía + +Este feature no implementa todavía: + +- `dueDate` real; +- calendario de cobros; +- cuotas monetarias; +- estado pagado o pendiente; +- pagos parciales; +- conciliación. + +No debe confundirse `dueRules` de `PaymentTerm` con vencimientos reales de una factura. + +## Ejemplos funcionales + +### Ejemplo 1: Customer sin plazo + +- Company default = 30 días +- Customer create no envía `paymentTermId` + +Resultado: + +- el Customer nace con 30 días. + +### Ejemplo 2: Customer sin plazo explícito + +- Company default = 30 días +- Customer create envía `paymentTermId = null` + +Resultado: + +- el Customer nace sin plazo. + +### Ejemplo 3: Proforma con override manual + +- Customer A = 30 días +- cambio a Customer B = 60 días +- el sistema adapta automáticamente a 60 días +- el usuario elige manualmente “Contado” +- cambio a Customer C + +Resultado: + +- la Proforma conserva “Contado”. diff --git a/docs/companies/sql/add-company-default-payment-term.sql b/docs/companies/sql/add-company-default-payment-term.sql new file mode 100644 index 00000000..c3600e51 --- /dev/null +++ b/docs/companies/sql/add-company-default-payment-term.sql @@ -0,0 +1,24 @@ +-- DDL incremental para introducir el plazo de pago por defecto de Company. +-- Mantiene la referencia como valor vivo y evita forzar FKs cross-module. +-- Ejecutar solo en una BD de desarrollo segura o mediante procedimiento controlado. +-- No ejecutar automaticamente en produccion. + +ALTER TABLE companies +ADD COLUMN default_payment_term_id CHAR(36) NULL AFTER website; + +ALTER TABLE companies +ADD KEY idx_companies_default_payment_term (default_payment_term_id); + +-- Validacion minima posterior a la DDL. +-- SELECT column_name +-- FROM information_schema.columns +-- WHERE table_schema = DATABASE() +-- AND table_name = 'companies' +-- AND column_name = 'default_payment_term_id'; +-- +-- SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns +-- FROM information_schema.statistics +-- WHERE table_schema = DATABASE() +-- AND table_name = 'companies' +-- AND index_name = 'idx_companies_default_payment_term' +-- GROUP BY index_name; diff --git a/docs/customer-invoices/README.md b/docs/customer-invoices/README.md index a9bba0f2..cf81ab79 100644 --- a/docs/customer-invoices/README.md +++ b/docs/customer-invoices/README.md @@ -6,6 +6,7 @@ ### Contratos funcionales y de transporte +- [Plazos de pago](../catalogs/payment-terms.md) - [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md) - [Contrato de series de proformas](./proforma-series-contract.md) - [Contrato de create de proformas](./proforma-create-contract.md) diff --git a/docs/customer-invoices/proforma-create-contract.md b/docs/customer-invoices/proforma-create-contract.md index 32dbea91..4194d998 100644 --- a/docs/customer-invoices/proforma-create-contract.md +++ b/docs/customer-invoices/proforma-create-contract.md @@ -4,6 +4,10 @@ Definir la semantica correcta de `create proforma` como creacion de cabecera inicial y separarla del calculo fiscal real que depende de lineas valoradas. +Para la semántica vigente de `payment_term_id`, herencia desde Company o Customer y diferencia entre referencia viva y snapshot, usar como fuente principal: + +- [Plazos de pago](../catalogs/payment-terms.md) + ## Regla principal `tax_regime_code` no es un impuesto aplicado. @@ -63,6 +67,12 @@ Notas: - no se usan defaults ocultos en Zod para disfrazar la intencion del request - si no se informa `tax_config`, el backend resuelve defaults iniciales y los persiste - si el cliente cambia después, la proforma conserva su `tax_config` persistido +- `payment_term_id` sigue la prioridad vigente: + - `Customer.paymentTermId` + - `Company.defaultPaymentTermId` + - `null` +- `payment_term_id = null` significa “sin plazo de pago” +- una selección manual de `payment_term_id = null` no debe volver a activar un default después ## Response esperada al crear sin lineas @@ -149,7 +159,6 @@ Si devuelve filas: ## Pendientes conocidos -- `payment_term_id` aparece ya en contratos de transporte, pero el dominio/snapshot de proformas sigue sin soporte funcional completo en esta fase - no se introduce un motor fiscal nuevo en este ajuste ## Archivado diff --git a/docs/customer-invoices/proforma-duplicate-contract.md b/docs/customer-invoices/proforma-duplicate-contract.md index b77b7b62..190c99d9 100644 --- a/docs/customer-invoices/proforma-duplicate-contract.md +++ b/docs/customer-invoices/proforma-duplicate-contract.md @@ -14,15 +14,38 @@ - Se intenta reutilizar la misma serie documental del origen si sigue activa; si no, se usa la serie por defecto de `document_type=proforma`. - `target_invoice_series_code` solo se conserva si sigue siendo una serie activa de `issued_invoice`; si no, pasa a `NULL`. - Se copian datos comerciales y fiscales del origen. +- Si el `PaymentTerm` original sigue activo, se copia el mismo `paymentTermId`. +- Si el `PaymentTerm` original está inactivo, no se reutiliza como nueva asignación. El duplicado resuelve: + 1. `Customer.paymentTermId`, solo si está activo + 2. `Company.defaultPaymentTermId`, solo si está activo + 3. `null` +- Si el Customer tiene un plazo inactivo, se ignora para esta nueva asignación. +- Si Company tiene por datos legacy un default inactivo, no se usa y el flujo cae a `null`. - Se copian las líneas con nuevos IDs y se recalculan impuestos y totales con el motor actual. +Fuente funcional principal para `Payment Terms`: + +- [Plazos de pago](../catalogs/payment-terms.md) + ## Response - `201 Created` - Devuelve el snapshot completo de la nueva proforma. +- Si el plazo de pago original estaba inactivo y hubo adaptación, la respuesta incluye metadata funcional equivalente a: + +```yaml +payment_term_adaptation: + null + o + { + source: "inactive_original", + resolution: "customer" | "company" | "none" + } +``` ## UI - Acción de fila `Duplicar` en `/proformas` y `/proformas/archived`. - No muestra `Duplicar` en `/proformas/deleted`. - Tras duplicar redirige a `/proformas/:id/edit`. +- Si el plazo original estaba inactivo y fue sustituido o eliminado, la UI informa del ajuste después de duplicar. diff --git a/docs/customer-invoices/proformas-reports.md b/docs/customer-invoices/proformas-reports.md index a6afe19f..49e1b4d8 100644 --- a/docs/customer-invoices/proformas-reports.md +++ b/docs/customer-invoices/proformas-reports.md @@ -283,6 +283,21 @@ Prioridad funcional deseada: No debe repetirse la incoherencia anterior entre `languageCode` y `locale`. +Regla práctica de templates FastReport en el estado actual: + +```text +es-ES -> es +``` + +Sobre `Payment Terms`: + +- la Proforma usa el plazo vivo actual; +- la Issued Invoice usa el snapshot histórico emitido. + +Fuente funcional principal: + +- [Plazos de pago](../catalogs/payment-terms.md) + ## Ficheros principales Rutas y controllers: diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md index 4ce20c71..5ee7a0cd 100644 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ b/docs/customer-invoices/split-proformas-issued-invoices-migration.md @@ -7,6 +7,7 @@ No describe el runtime minimo vigente por si solo; la fuente operativa actual pa - `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` 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 @@ -46,7 +47,7 @@ Preparar el esquema fisico para separar: - 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. -- `payment_term` sigue sin soporte real en el dominio actual de customer invoices; en persistencia V2 queda preparado pero no conectado funcionalmente. +- 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 diff --git a/docs/customers/customer-selection-and-quick-create.md b/docs/customers/customer-selection-and-quick-create.md index c311c53d..d48fe6af 100644 --- a/docs/customers/customer-selection-and-quick-create.md +++ b/docs/customers/customer-selection-and-quick-create.md @@ -251,6 +251,26 @@ useCustomerSelectionFlow ## Detalle de `quick-create-customer` +## Default de plazo de pago + +La alta rápida no necesita pedir el plazo de pago al usuario. + +Si no envía un valor explícito, el backend aplica la misma regla que en el alta completa: + +```text +Company.defaultPaymentTermId +``` + +No debe haber diferencias entre: + +- create completo; +- quick create; +- API directa. + +Fuente funcional principal: + +- [Plazos de pago](../catalogs/payment-terms.md) + ### Formulario reducido La alta rápida no intenta sustituir al CRUD completo de `customers`. diff --git a/modules/catalogs/src/api/application/payment-terms/di/payment-term-status-changer.di.ts b/modules/catalogs/src/api/application/payment-terms/di/payment-term-status-changer.di.ts index 9dc29207..0d466aab 100644 --- a/modules/catalogs/src/api/application/payment-terms/di/payment-term-status-changer.di.ts +++ b/modules/catalogs/src/api/application/payment-terms/di/payment-term-status-changer.di.ts @@ -1,10 +1,20 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + import type { IPaymentTermRepository } from "../repositories"; import { type IPaymentTermStatusChanger, PaymentTermStatusChanger } from "../services"; export const buildPaymentTermStatusChanger = (params: { repository: IPaymentTermRepository; + companyDefaultUsageChecker?: { + isUsedAsCompanyDefault(params: { + companyId: UniqueID; + paymentTermId: UniqueID; + transaction?: unknown; + }): Promise>; + }; }): IPaymentTermStatusChanger => { - const { repository } = params; + const { repository, companyDefaultUsageChecker } = params; - return new PaymentTermStatusChanger(repository); + return new PaymentTermStatusChanger({ repository, companyDefaultUsageChecker }); }; diff --git a/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts b/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts index cc850182..5b939d00 100644 --- a/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts +++ b/modules/catalogs/src/api/application/payment-terms/public/mappers/payment-term-public-model.mapper.ts @@ -9,6 +9,10 @@ export class PaymentTermPublicModelMapper { name: paymentTerm.name.toPrimitive(), description: paymentTerm.description.map((value) => value.toPrimitive()), isActive: paymentTerm.isActive, + dueRules: paymentTerm.dueRules.getAll().map((rule) => ({ + dueDays: String(rule.dueDays.toPrimitive()), + percentage: rule.percentage.toPrimitive(), + })), }; } } diff --git a/modules/catalogs/src/api/application/payment-terms/public/models/payment-term-public.model.ts b/modules/catalogs/src/api/application/payment-terms/public/models/payment-term-public.model.ts index 627fb255..3f1230ed 100644 --- a/modules/catalogs/src/api/application/payment-terms/public/models/payment-term-public.model.ts +++ b/modules/catalogs/src/api/application/payment-terms/public/models/payment-term-public.model.ts @@ -9,4 +9,11 @@ export interface PaymentTermPublicModel { description: Maybe; isActive: boolean; + dueRules: Array<{ + dueDays: string; + percentage: { + value: string; + scale: number; + }; + }>; } diff --git a/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts b/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts index febccb34..4f2280dc 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/__tests__/payment-term-services.test.ts @@ -12,6 +12,7 @@ import { PaymentTermPercentage } from "../../../../domain/payment-terms/payment- import type { PaymentTermSummary } from "../../models/payment-term-summary.model"; import type { IPaymentTermRepository } from "../../repositories/payment-term-repository.interface"; import { PaymentTermDeleter } from "../payment-term-deleter.service"; +import { PaymentTermStatusChanger } from "../payment-term-status-changer.service"; class InMemoryPaymentTermRepository implements IPaymentTermRepository { private readonly items = new Map(); @@ -194,4 +195,46 @@ describe("Payment term services", () => { assert.equal(result.isFailure, true); }); + + test("disable permite desactivar un payment term que no es default de company", async () => { + const repository = new InMemoryPaymentTermRepository(); + const paymentTerm = createPaymentTerm(); + await repository.create(paymentTerm); + + const changer = new PaymentTermStatusChanger({ + repository, + companyDefaultUsageChecker: { + isUsedAsCompanyDefault: async () => Result.ok(false), + }, + }); + + const result = await changer.changeStatus({ + paymentTerm, + action: "disable", + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.isActive, false); + }); + + test("disable rechaza desactivar un payment term usado como default de company", async () => { + const repository = new InMemoryPaymentTermRepository(); + const paymentTerm = createPaymentTerm(); + await repository.create(paymentTerm); + + const changer = new PaymentTermStatusChanger({ + repository, + companyDefaultUsageChecker: { + isUsedAsCompanyDefault: async () => Result.ok(true), + }, + }); + + const result = await changer.changeStatus({ + paymentTerm, + action: "disable", + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /plazo por defecto/i); + }); }); diff --git a/modules/catalogs/src/api/application/payment-terms/services/payment-term-status-changer.service.ts b/modules/catalogs/src/api/application/payment-terms/services/payment-term-status-changer.service.ts index a68dc6fe..27ccf971 100644 --- a/modules/catalogs/src/api/application/payment-terms/services/payment-term-status-changer.service.ts +++ b/modules/catalogs/src/api/application/payment-terms/services/payment-term-status-changer.service.ts @@ -1,6 +1,7 @@ import { Result } from "@repo/rdx-utils"; import type { PaymentTerm } from "../../../domain"; +import { PaymentTermCannotBeDisabledError } from "../../../domain"; import type { IPaymentTermRepository } from "../repositories"; export type PaymentTermStatusChangeAction = "enable" | "disable"; @@ -14,7 +15,18 @@ export interface IPaymentTermStatusChanger { } export class PaymentTermStatusChanger implements IPaymentTermStatusChanger { - public constructor(private readonly repository: IPaymentTermRepository) {} + public constructor( + private readonly deps: { + repository: IPaymentTermRepository; + companyDefaultUsageChecker?: { + isUsedAsCompanyDefault(params: { + companyId: PaymentTerm["companyId"]; + paymentTermId: PaymentTerm["id"]; + transaction?: unknown; + }): Promise>; + }; + } + ) {} public async changeStatus(params: { paymentTerm: PaymentTerm; @@ -23,6 +35,27 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger { }): Promise> { const { paymentTerm, action, transaction } = params; + if (action === "disable" && this.deps.companyDefaultUsageChecker) { + const companyDefaultUsageResult = + await this.deps.companyDefaultUsageChecker.isUsedAsCompanyDefault({ + companyId: paymentTerm.companyId, + paymentTermId: paymentTerm.id, + transaction, + }); + + if (companyDefaultUsageResult.isFailure) { + return Result.fail(companyDefaultUsageResult.error); + } + + if (companyDefaultUsageResult.data) { + return Result.fail( + new PaymentTermCannotBeDisabledError( + "Este plazo de pago está configurado como plazo por defecto de la empresa. Cambia o elimina primero el plazo por defecto antes de desactivarlo." + ) + ); + } + } + const statusResult = action === "enable" ? paymentTerm.enable() : paymentTerm.disable(); if (statusResult.isFailure) { return Result.fail(statusResult.error); @@ -32,7 +65,7 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger { return Result.ok(paymentTerm); } - const persistenceResult = await this.repository.update(paymentTerm, transaction); + const persistenceResult = await this.deps.repository.update(paymentTerm, transaction); if (persistenceResult.isFailure) { return Result.fail(persistenceResult.error); } diff --git a/modules/catalogs/src/api/index.ts b/modules/catalogs/src/api/index.ts index 1dd6632a..b11a763e 100644 --- a/modules/catalogs/src/api/index.ts +++ b/modules/catalogs/src/api/index.ts @@ -27,7 +27,7 @@ export type CatalogsPublicServicesType = ReturnType { // ---------------------------------------------- - router.use(...requireIdentityTenant(params)); + router.use(...requireIdentityTenantMembership(params)); // ---------------------------------------------- diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts b/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts index bb8d86cc..9960490f 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/di/payment-terms.di.ts @@ -21,10 +21,13 @@ import { buildPaymentTermUpdater, } from "../../../application/payment-terms"; import type { IPaymentTermRepository } from "../../../application/payment-terms/repositories"; +import { + buildCompanyDefaultPaymentTermUsageChecker, + buildPaymentTermReferenceUsageChecker, +} from "../payment-term-reference-usage-checker"; import { buildPaymentTermPersistenceMappers } from "./payment-term-persistence-mappers.di"; import { buildPaymentTermRepository } from "./payment-term-repositories.di"; -import { buildPaymentTermReferenceUsageChecker } from "../payment-term-reference-usage-checker"; export type PaymentTermsInternalDeps = { repository: IPaymentTermRepository; @@ -47,6 +50,9 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm const repository = buildPaymentTermRepository({ database, mappers: persistenceMappers }); const usageChecker = buildPaymentTermReferenceUsageChecker(database as Sequelize); + const companyDefaultUsageChecker = buildCompanyDefaultPaymentTermUsageChecker( + database as Sequelize + ); const inputMappers = buildPaymentTermInputMappers(); @@ -54,7 +60,10 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm const creator = buildPaymentTermCreator({ repository }); const updater = buildPaymentTermUpdater({ repository }); const deleter = buildPaymentTermDeleter({ repository, usageChecker }); - const statusChanger = buildPaymentTermStatusChanger({ repository }); + const statusChanger = buildPaymentTermStatusChanger({ + repository, + companyDefaultUsageChecker, + }); const snapshotBuilders = buildPaymentTermSnapshotBuilders(); return { diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/express/payment-terms.routes.ts b/modules/catalogs/src/api/infrastructure/payment-terms/express/payment-terms.routes.ts index b4a2cfb1..8b706e85 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/express/payment-terms.routes.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/express/payment-terms.routes.ts @@ -1,5 +1,5 @@ import { type StartParams, validateRequest } from "@erp/core/api"; -import { requireIdentityTenant } from "@erp/identity/api"; +import { requireIdentityTenantMembership } from "@erp/identity/api"; import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; @@ -29,7 +29,7 @@ export const paymentTermsRouter = (params: StartParams) => { const router = Router({ mergeParams: true }); - router.use(...requireIdentityTenant(params)); + router.use(...requireIdentityTenantMembership(params)); router.get( "/", diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.test.ts b/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.test.ts new file mode 100644 index 00000000..2f5245a3 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import { buildCompanyDefaultPaymentTermUsageChecker } from "./payment-term-reference-usage-checker"; + +test("company default usage checker mantiene aislamiento por company", async () => { + const companyAId = UniqueID.generateNewID(); + const companyBId = UniqueID.generateNewID(); + const paymentTermId = UniqueID.generateNewID(); + let lastWhere: Record | null = null; + + const checker = buildCompanyDefaultPaymentTermUsageChecker({ + models: { + CompanyModel: { + count: async ({ where }) => { + lastWhere = where; + + return where.id === companyBId.toString() && + where.default_payment_term_id === paymentTermId.toString() + ? 1 + : 0; + }, + }, + }, + } as never); + + const resultForCompanyA = await checker.isUsedAsCompanyDefault({ + companyId: companyAId, + paymentTermId, + }); + + assert.equal(resultForCompanyA.isSuccess, true); + assert.equal(resultForCompanyA.data, false); + assert.deepEqual(lastWhere, { + id: companyAId.toString(), + default_payment_term_id: paymentTermId.toString(), + }); + + const resultForCompanyB = await checker.isUsedAsCompanyDefault({ + companyId: companyBId, + paymentTermId, + }); + + assert.equal(resultForCompanyB.isSuccess, true); + assert.equal(resultForCompanyB.data, true); +}); diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.ts b/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.ts index a2a3f8af..2d0f5a36 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/payment-term-reference-usage-checker.ts @@ -63,3 +63,40 @@ export const buildPaymentTermReferenceUsageChecker = (database: Sequelize) => { }, }); }; + +export const buildCompanyDefaultPaymentTermUsageChecker = (database: Sequelize) => { + return { + isUsedAsCompanyDefault: async (input: { + companyId: UniqueID; + paymentTermId: UniqueID; + transaction?: unknown; + }) => { + const { companyId, paymentTermId, transaction } = input; + + try { + const models = database.models as SequelizeModelsRegistry; + const model = models.CompanyModel; + + if (!model) { + return Result.ok(false); + } + + const count = await model.count({ + where: { + id: companyId.toString(), + default_payment_term_id: paymentTermId.toString(), + }, + transaction: transaction as Transaction | undefined, + }); + + return Result.ok(count > 0); + } catch (error: unknown) { + return Result.fail( + error instanceof Error + ? error + : new Error("Failed checking company default payment term references.") + ); + } + }, + }; +}; diff --git a/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions.routes.ts b/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions.routes.ts index 72db0d2b..055c9593 100644 --- a/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions.routes.ts +++ b/modules/catalogs/src/api/infrastructure/tax-definitions/express/tax-definitions.routes.ts @@ -1,5 +1,5 @@ import { type StartParams, validateRequest } from "@erp/core/api"; -import { requireIdentityTenant } from "@erp/identity/api"; +import { requireIdentityTenantMembership } from "@erp/identity/api"; import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; @@ -29,7 +29,7 @@ export const taxDefinitionsRouter = (params: StartParams) => { const router = Router({ mergeParams: true }); - router.use(...requireIdentityTenant(params)); + router.use(...requireIdentityTenantMembership(params)); router.get( "/", diff --git a/modules/catalogs/src/api/infrastructure/tax-regimes/express/tax-regimes.routes.ts b/modules/catalogs/src/api/infrastructure/tax-regimes/express/tax-regimes.routes.ts index 4b0833ad..7e646f73 100644 --- a/modules/catalogs/src/api/infrastructure/tax-regimes/express/tax-regimes.routes.ts +++ b/modules/catalogs/src/api/infrastructure/tax-regimes/express/tax-regimes.routes.ts @@ -1,5 +1,5 @@ import { type StartParams, validateRequest } from "@erp/core/api"; -import { requireIdentityTenant } from "@erp/identity/api"; +import { requireIdentityTenantMembership } from "@erp/identity/api"; import type { NextFunction, Request, Response } from "express"; import { Router } from "express"; @@ -29,7 +29,7 @@ export const taxRegimesRouter = (params: StartParams) => { const router = Router({ mergeParams: true }); - router.use(...requireIdentityTenant(params)); + router.use(...requireIdentityTenantMembership(params)); router.get( "/", diff --git a/modules/companies/package.json b/modules/companies/package.json index 0acfc522..e28f3a31 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -14,20 +14,38 @@ "exports": { ".": "./src/common/index.ts", "./common": "./src/common/index.ts", - "./api": "./src/api/index.ts" + "./api": "./src/api/index.ts", + "./client": "./src/web/manifest.ts" + }, + "peerDependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" }, "devDependencies": { "@types/express": "^4.17.21", + "@types/react": "^19.2.14", + "@types/react-router-dom": "^5.3.3", + "react": "^19.2.5", + "react-dom": "^19.2.5", "rimraf": "^6.1.3", "typescript": "^6.0.2" }, "dependencies": { + "@erp/catalogs": "workspace:*", "@erp/core": "workspace:*", "@erp/identity": "workspace:*", + "@hookform/resolvers": "^5.2.2", + "@repo/i18next": "workspace:*", "@repo/rdx-ddd": "workspace:*", + "@repo/rdx-ui": "workspace:*", "@repo/rdx-utils": "workspace:*", + "@repo/shadcn-ui": "workspace:*", + "@tanstack/react-query": "^5.98.0", "express": "^4.22.1", + "react-hook-form": "^7.72.1", + "react-i18next": "^17.0.2", + "react-router-dom": "^7.14.0", "sequelize": "^6.37.8", "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts index efff1ca9..16c20d28 100644 --- a/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts +++ b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.ts @@ -14,6 +14,9 @@ export class CompanyPublicModelMapper { email: maybeToNullable(company.email, (value) => value.toPrimitive()), phone: maybeToNullable(company.phone, (value) => value.toPrimitive()), website: maybeToNullable(company.website, (value) => value.toPrimitive()), + defaultPaymentTermId: maybeToNullable(company.defaultPaymentTermId, (value) => + value.toPrimitive() + ), status: company.status.toPrimitive() as "active" | "disabled", }; } diff --git a/modules/companies/src/api/application/companies/public/models/company-public.model.ts b/modules/companies/src/api/application/companies/public/models/company-public.model.ts index 2fe97b19..8d9bc9ba 100644 --- a/modules/companies/src/api/application/companies/public/models/company-public.model.ts +++ b/modules/companies/src/api/application/companies/public/models/company-public.model.ts @@ -7,5 +7,6 @@ export interface CompanyPublicModel { email: string | null; phone: string | null; website: string | null; + defaultPaymentTermId: string | null; status: "active" | "disabled"; } diff --git a/modules/companies/src/api/application/companies/services/company-payment-term-resolver.test.ts b/modules/companies/src/api/application/companies/services/company-payment-term-resolver.test.ts new file mode 100644 index 00000000..383076c7 --- /dev/null +++ b/modules/companies/src/api/application/companies/services/company-payment-term-resolver.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { PaymentTermPublicModel } from "@erp/catalogs/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { CompanyPaymentTermResolver } from "./company-payment-term-resolver"; + +const buildPaymentTerm = (overrides?: Partial): PaymentTermPublicModel => ({ + id: UniqueID.generateNewID(), + companyId: UniqueID.generateNewID(), + name: "30 dias", + description: Maybe.none(), + isActive: true, + ...overrides, +}); + +test("acepta un payment term activo de la misma company", async () => { + const paymentTerm = buildPaymentTerm(); + const resolver = new CompanyPaymentTermResolver({ + paymentTermFinder: { + existsByIdInCompany: async () => Result.ok(true), + getByIdInCompany: async () => Result.ok(paymentTerm), + findByIdInCompany: async () => Result.ok(Maybe.some(paymentTerm)), + }, + }); + + const result = await resolver.ensureAssignable({ + companyId: paymentTerm.companyId, + id: Maybe.some(paymentTerm.id), + }); + + assert.equal(result.isSuccess, true); +}); + +test("acepta un payment term de sistema si esta activo", async () => { + const systemPaymentTerm: PaymentTermPublicModel & { isSystem: true } = { + ...buildPaymentTerm(), + isSystem: true, + }; + + const resolver = new CompanyPaymentTermResolver({ + paymentTermFinder: { + existsByIdInCompany: async () => Result.ok(true), + getByIdInCompany: async () => Result.ok(systemPaymentTerm), + findByIdInCompany: async () => Result.ok(Maybe.some(systemPaymentTerm)), + }, + }); + + const result = await resolver.ensureAssignable({ + companyId: systemPaymentTerm.companyId, + id: Maybe.some(systemPaymentTerm.id), + }); + + assert.equal(result.isSuccess, true); +}); + +test("permite limpiar el valor con null", async () => { + const resolver = new CompanyPaymentTermResolver({ + paymentTermFinder: { + existsByIdInCompany: async () => Result.ok(true), + getByIdInCompany: async () => Result.fail(new Error("unused")), + findByIdInCompany: async () => Result.fail(new Error("unused")), + }, + }); + + const result = await resolver.ensureAssignable({ + companyId: UniqueID.generateNewID(), + id: Maybe.none(), + }); + + assert.equal(result.isSuccess, true); +}); + +test("rechaza un payment term de otra company", async () => { + const resolver = new CompanyPaymentTermResolver({ + paymentTermFinder: { + existsByIdInCompany: async () => Result.ok(false), + getByIdInCompany: async () => Result.fail(new Error("unused")), + findByIdInCompany: async () => Result.ok(Maybe.none()), + }, + }); + + const result = await resolver.ensureAssignable({ + companyId: UniqueID.generateNewID(), + id: Maybe.some(UniqueID.generateNewID()), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /not found/i); +}); + +test("rechaza un payment term inactivo como nueva asignacion", async () => { + const paymentTerm = buildPaymentTerm({ + isActive: false, + }); + + const resolver = new CompanyPaymentTermResolver({ + paymentTermFinder: { + existsByIdInCompany: async () => Result.ok(true), + getByIdInCompany: async () => Result.ok(paymentTerm), + findByIdInCompany: async () => Result.ok(Maybe.some(paymentTerm)), + }, + }); + + const result = await resolver.ensureAssignable({ + companyId: paymentTerm.companyId, + id: Maybe.some(paymentTerm.id), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /must be active/i); +}); + +test("rechaza un id inexistente", async () => { + const resolver = new CompanyPaymentTermResolver({ + paymentTermFinder: { + existsByIdInCompany: async () => Result.ok(false), + getByIdInCompany: async () => Result.fail(new Error("unused")), + findByIdInCompany: async () => Result.ok(Maybe.none()), + }, + }); + + const result = await resolver.ensureAssignable({ + companyId: UniqueID.generateNewID(), + id: Maybe.some(UniqueID.generateNewID()), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /not found/i); +}); diff --git a/modules/companies/src/api/application/companies/services/company-payment-term-resolver.ts b/modules/companies/src/api/application/companies/services/company-payment-term-resolver.ts new file mode 100644 index 00000000..a8ff3742 --- /dev/null +++ b/modules/companies/src/api/application/companies/services/company-payment-term-resolver.ts @@ -0,0 +1,55 @@ +import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { type Maybe, Result } from "@repo/rdx-utils"; + +export interface ICompanyPaymentTermResolver { + ensureAssignable(params: { + companyId: UniqueID; + id: Maybe; + transaction?: unknown; + }): Promise>; +} + +/** + * Valida referencias externas a catálogos antes de persistirlas en Company. + * + * El contrato evita depender de Sequelize o repositorios cross-module y asegura + * que una nueva asignación solo apunte a un plazo activo de la misma empresa. + */ +export class CompanyPaymentTermResolver implements ICompanyPaymentTermResolver { + public constructor( + private readonly deps: { + paymentTermFinder: IPaymentTermPublicFinder; + } + ) {} + + public async ensureAssignable(params: { + companyId: UniqueID; + id: Maybe; + transaction?: unknown; + }): Promise> { + if (params.id.isNone()) { + return Result.ok(undefined); + } + + const result = await this.deps.paymentTermFinder.findByIdInCompany({ + companyId: params.companyId, + id: params.id.unwrap(), + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.fail(new Error("Default payment term not found in current company.")); + } + + if (!result.data.unwrap().isActive) { + return Result.fail(new Error("Default payment term must be active.")); + } + + return Result.ok(undefined); + } +} diff --git a/modules/companies/src/api/application/companies/services/index.ts b/modules/companies/src/api/application/companies/services/index.ts index c0a1b098..84da0475 100644 --- a/modules/companies/src/api/application/companies/services/index.ts +++ b/modules/companies/src/api/application/companies/services/index.ts @@ -1,4 +1,5 @@ export * from "./company-creator"; export * from "./company-finder"; +export * from "./company-payment-term-resolver"; export * from "./company-status-changer"; export * from "./company-updater"; diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts new file mode 100644 index 00000000..c9ec6ddf --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ITransactionManager } from "@erp/core/api"; +import { + EmailAddress, + Name, + PhoneNumber, + TextValue, + URLAddress, + UniqueID, + UtcDateTime, +} from "@repo/rdx-ddd"; +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 => ({ + start: async () => undefined, + getTransaction: () => undefined, + complete: async (work: (transaction: unknown) => Promise) => work(undefined), + commit: async () => undefined, + rollback: async () => undefined, +}); + +const buildCompany = () => { + const now = UtcDateTime.createFromISO("2026-08-16T10:00:00.000Z"); + const id = UniqueID.generateNewID(); + const legalName = Name.create("Rodax Labs"); + const slug = CompanySlug.create("rodax-labs"); + const email = EmailAddress.create("hello@rodax.test"); + const phone = PhoneNumber.create("+34911111111"); + const website = URLAddress.create("https://rodax.test"); + const tin = TextValue.create("B12345678"); + + assert.equal(now.isSuccess, true); + assert.equal(legalName.isSuccess, true); + assert.equal(slug.isSuccess, true); + assert.equal(email.isSuccess, true); + assert.equal(phone.isSuccess, true); + 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); + + assert.equal(companyResult.isSuccess, true); + return companyResult.data; +}; + +test("create devuelve la company sin default payment term", async () => { + const company = buildCompany(); + + 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.equal(result.data.default_payment_term_id, null); +}); diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts index 4e0cb7d0..739c321a 100644 --- a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts @@ -9,7 +9,7 @@ import { UtcDateTime, maybeFromNullableResult, } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; +import { Maybe, Result } from "@repo/rdx-utils"; import type { CreateCompanyRequestDTO, CompanyResponseDTO } from "../../../../common"; import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain"; @@ -99,6 +99,7 @@ function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) { email: emailResult.data, phone: phoneResult.data, website: websiteResult.data, + defaultPaymentTermId: Maybe.none(), status: CompanyStatus.createActive(), createdAt: now, updatedAt: now, @@ -119,6 +120,7 @@ function toCompanyResponseDTO(model: { email: string | null; phone: string | null; website: string | null; + defaultPaymentTermId: string | null; status: "active" | "disabled"; }): CompanyResponseDTO { return { @@ -130,6 +132,7 @@ function toCompanyResponseDTO(model: { email: model.email, phone: model.phone, website: model.website, + default_payment_term_id: model.defaultPaymentTermId, status: model.status, }; } diff --git a/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts index 97a46871..deb89521 100644 --- a/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/disable-company.use-case.ts @@ -56,6 +56,7 @@ function toCompanyResponseDTO(model: { email: string | null; phone: string | null; website: string | null; + defaultPaymentTermId: string | null; status: "active" | "disabled"; }): CompanyResponseDTO { return { @@ -67,6 +68,7 @@ function toCompanyResponseDTO(model: { email: model.email, phone: model.phone, website: model.website, + default_payment_term_id: model.defaultPaymentTermId, status: model.status, }; } diff --git a/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts index 38f5fc4f..5d6993f1 100644 --- a/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/enable-company.use-case.ts @@ -56,6 +56,7 @@ function toCompanyResponseDTO(model: { email: string | null; phone: string | null; website: string | null; + defaultPaymentTermId: string | null; status: "active" | "disabled"; }): CompanyResponseDTO { return { @@ -67,6 +68,7 @@ function toCompanyResponseDTO(model: { email: model.email, phone: model.phone, website: model.website, + default_payment_term_id: model.defaultPaymentTermId, status: model.status, }; } diff --git a/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts b/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts index 1d0db461..aa01ed0c 100644 --- a/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/get-company-by-id.use-case.ts @@ -45,6 +45,7 @@ function toCompanyResponseDTO(model: { email: string | null; phone: string | null; website: string | null; + defaultPaymentTermId: string | null; status: "active" | "disabled"; }): CompanyResponseDTO { return { @@ -56,6 +57,7 @@ function toCompanyResponseDTO(model: { email: model.email, phone: model.phone, website: model.website, + default_payment_term_id: model.defaultPaymentTermId, status: model.status, }; } diff --git a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts new file mode 100644 index 00000000..6391702e --- /dev/null +++ b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ITransactionManager } from "@erp/core/api"; +import { + EmailAddress, + Name, + PhoneNumber, + TextValue, + URLAddress, + UniqueID, + UtcDateTime, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { Company, CompanySlug, CompanyStatus, type CompanyPatchProps } from "../../../domain"; +import { CompanyPublicModelMapper } from "../public"; +import { UpdateCompanyUseCase } from "./update-company.use-case"; + +const buildTransactionManager = (): ITransactionManager => ({ + start: async () => undefined, + getTransaction: () => undefined, + complete: async (work: (transaction: unknown) => Promise) => work(undefined), + commit: async () => undefined, + rollback: async () => undefined, +}); + +const buildCompany = () => { + const now = UtcDateTime.createFromISO("2026-08-16T10:00:00.000Z"); + const id = UniqueID.generateNewID(); + const legalName = Name.create("Rodax Labs"); + const slug = CompanySlug.create("rodax-labs"); + const email = EmailAddress.create("hello@rodax.test"); + const phone = PhoneNumber.create("+34911111111"); + const website = URLAddress.create("https://rodax.test"); + const tin = TextValue.create("B12345678"); + + assert.equal(now.isSuccess, true); + assert.equal(legalName.isSuccess, true); + assert.equal(slug.isSuccess, true); + assert.equal(email.isSuccess, true); + assert.equal(phone.isSuccess, true); + 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); + + assert.equal(companyResult.isSuccess, true); + return companyResult.data; +}; + +test("update permite limpiar el default payment term con null", async () => { + const company = buildCompany(); + let receivedPatch: CompanyPatchProps | null = null; + let receivedId: Maybe | undefined; + + const useCase = new UpdateCompanyUseCase({ + updater: { + update: async ({ patchProps }) => { + receivedPatch = patchProps; + + const updateResult = company.update(patchProps); + assert.equal(updateResult.isSuccess, true); + + return Result.ok(company); + }, + }, + paymentTermResolver: { + ensureAssignable: async ({ id }) => { + receivedId = id; + return Result.ok(undefined); + }, + }, + responseMapper: new CompanyPublicModelMapper(), + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + company_id: company.id.toPrimitive(), + dto: { + default_payment_term_id: null, + }, + }); + + assert.equal(result.isSuccess, true); + assert.equal(receivedPatch?.defaultPaymentTermId?.isNone(), true); + assert.equal(receivedId?.isNone(), true); + assert.equal(result.data.default_payment_term_id, null); +}); diff --git a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts index cd41b686..2e336ebf 100644 --- a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts @@ -9,17 +9,18 @@ import { UtcDateTime, maybeFromNullableResult, } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; +import { Maybe, Result } from "@repo/rdx-utils"; import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common"; import { CompanySlug, type CompanyPatchProps } from "../../../domain"; import type { CompanyPublicModelMapper } from "../public"; -import type { ICompanyUpdater } from "../services"; +import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services"; export class UpdateCompanyUseCase { public constructor( private readonly deps: { updater: ICompanyUpdater; + paymentTermResolver: ICompanyPaymentTermResolver; responseMapper: CompanyPublicModelMapper; transactionManager: ITransactionManager; } @@ -39,8 +40,25 @@ export class UpdateCompanyUseCase { return Promise.resolve(Result.fail(patchPropsResult.error)); } + const defaultPaymentTermIdResult = mapDefaultPaymentTermId(params.dto); + if (defaultPaymentTermIdResult.isFailure) { + return Promise.resolve(Result.fail(defaultPaymentTermIdResult.error)); + } + return this.deps.transactionManager.complete(async (transaction: unknown) => { try { + if (defaultPaymentTermIdResult.data !== undefined) { + const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({ + companyId: companyIdResult.data, + id: defaultPaymentTermIdResult.data, + transaction, + }); + + if (ensureResult.isFailure) { + return Result.fail(ensureResult.error); + } + } + const updateResult = await this.deps.updater.update({ id: companyIdResult.data, patchProps: patchPropsResult.data, @@ -120,9 +138,39 @@ function mapUpdateCompanyProps(dto: UpdateCompanyRequestDTO): Result UniqueID.create(value) + ); + if (defaultPaymentTermIdResult.isFailure) { + return Result.fail(defaultPaymentTermIdResult.error); + } + patchProps.defaultPaymentTermId = defaultPaymentTermIdResult.data; + } + return Result.ok(patchProps); } +function mapDefaultPaymentTermId( + dto: UpdateCompanyRequestDTO +): Result | undefined, Error> { + if (dto.default_payment_term_id === undefined) { + return Result.ok(undefined); + } + + if (dto.default_payment_term_id === null) { + return Result.ok(Maybe.none()); + } + + const defaultPaymentTermIdResult = UniqueID.create(dto.default_payment_term_id); + if (defaultPaymentTermIdResult.isFailure) { + return Result.fail(defaultPaymentTermIdResult.error); + } + + return Result.ok(Maybe.some(defaultPaymentTermIdResult.data)); +} + function toCompanyResponseDTO(model: { id: string; legalName: string; @@ -132,6 +180,7 @@ function toCompanyResponseDTO(model: { email: string | null; phone: string | null; website: string | null; + defaultPaymentTermId: string | null; status: "active" | "disabled"; }): CompanyResponseDTO { return { @@ -143,6 +192,7 @@ function toCompanyResponseDTO(model: { email: model.email, phone: model.phone, website: model.website, + default_payment_term_id: model.defaultPaymentTermId, status: model.status, }; } diff --git a/modules/companies/src/api/domain/companies/company.aggregate.ts b/modules/companies/src/api/domain/companies/company.aggregate.ts index b12bb4b7..8f960781 100644 --- a/modules/companies/src/api/domain/companies/company.aggregate.ts +++ b/modules/companies/src/api/domain/companies/company.aggregate.ts @@ -22,6 +22,7 @@ export interface ICompanyCreateProps { email: Maybe; phone: Maybe; website: Maybe; + defaultPaymentTermId: Maybe; status: CompanyStatus; createdAt: UtcDate; updatedAt: UtcDate; @@ -35,6 +36,7 @@ export type CompanyPatchProps = Partial<{ email: Maybe; phone: Maybe; website: Maybe; + defaultPaymentTermId: Maybe; }> & { updatedAt: UtcDate; }; @@ -107,6 +109,10 @@ export class Company extends AggregateRoot { return this.props.website; } + public get defaultPaymentTermId(): Maybe { + return this.props.defaultPaymentTermId; + } + public get status(): CompanyStatus { return this.props.status; } @@ -167,6 +173,14 @@ export class Company extends AggregateRoot { hasChanges = true; } + if ( + patch.defaultPaymentTermId !== undefined && + !Company.sameMaybeValueObject(this.props.defaultPaymentTermId, patch.defaultPaymentTermId) + ) { + this.props.defaultPaymentTermId = patch.defaultPaymentTermId; + hasChanges = true; + } + if (!hasChanges) { return Result.ok(false); } diff --git a/modules/companies/src/api/index.ts b/modules/companies/src/api/index.ts index e2f778f0..c3526004 100644 --- a/modules/companies/src/api/index.ts +++ b/modules/companies/src/api/index.ts @@ -12,7 +12,7 @@ export type CompaniesPublicServicesType = ICompanyPublicServices; export const companiesAPIModule: IModuleServer = { name: "companies", version: "1.0.0", - dependencies: ["identity"], + dependencies: ["identity", "catalogs"], /** * Fase de SETUP diff --git a/modules/companies/src/api/infrastructure/di/companies.di.ts b/modules/companies/src/api/infrastructure/di/companies.di.ts index 4ddf7a4b..3390f7b8 100644 --- a/modules/companies/src/api/infrastructure/di/companies.di.ts +++ b/modules/companies/src/api/infrastructure/di/companies.di.ts @@ -3,6 +3,7 @@ import type { IIdentityPublicServices } from "@erp/identity/api"; import type { Sequelize } from "sequelize"; import { + CompanyPaymentTermResolver, CompanyCreator, CompanyFinder, CompanyPublicFinder, @@ -19,6 +20,7 @@ import { buildCompanyPublicServices, } from "../../application"; import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence"; +import { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di"; export type CompaniesInternalDeps = { repository: SequelizeCompanyRepository; @@ -38,10 +40,14 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter const transactionManager = buildTransactionManager(database as Sequelize); const responseMapper = new CompanyPublicModelMapper(); const identityServices = getService("identity:general"); + const catalogsDeps = resolveCompanyCatalogsDeps(params); const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database); const finder = new CompanyFinder(repository); const creator = new CompanyCreator(repository); + const paymentTermResolver = new CompanyPaymentTermResolver({ + paymentTermFinder: catalogsDeps.paymentTerm.finder, + }); const updater = new CompanyUpdater({ finder, repository }); const statusChanger = new CompanyStatusChanger(repository); const publicFinder = new CompanyPublicFinder({ @@ -75,6 +81,7 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter updateCompany: () => new UpdateCompanyUseCase({ updater, + paymentTermResolver, responseMapper, transactionManager, }), diff --git a/modules/companies/src/api/infrastructure/di/company-catalog-deps.di.ts b/modules/companies/src/api/infrastructure/di/company-catalog-deps.di.ts new file mode 100644 index 00000000..5973c443 --- /dev/null +++ b/modules/companies/src/api/infrastructure/di/company-catalog-deps.di.ts @@ -0,0 +1,21 @@ +import type { CatalogsPublicServicesType } from "@erp/catalogs/api"; +import type { ModuleParams } from "@erp/core/api"; + +export function resolveCompanyCatalogsDeps(params: ModuleParams): { + paymentTerm: { + finder: CatalogsPublicServicesType["paymentTerms"]["finder"]; + }; +} { + const paymentTerm = + params.getService("catalogs:paymentTerms"); + + if (!paymentTerm?.finder) { + throw new Error("Missing public service: catalogs:paymentTerms.finder"); + } + + return { + paymentTerm: { + finder: paymentTerm.finder, + }, + }; +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts index 02f11d6d..9ec178eb 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts @@ -59,6 +59,11 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper< "website", errors ); + const defaultPaymentTermId = extractOrPushError( + maybeFromNullableResult(source.default_payment_term_id, (value) => UniqueID.create(value)), + "default_payment_term_id", + errors + ); const status = extractOrPushError(CompanyStatus.create(source.status), "status", errors); const createdAt = extractOrPushError( UtcDateTime.createFromISO(toUtcIsoString(source.created_at)), @@ -83,6 +88,7 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper< email: email!, phone: phone!, website: website!, + defaultPaymentTermId: defaultPaymentTermId!, status: status!, createdAt: createdAt!, updatedAt: updatedAt!, @@ -107,6 +113,9 @@ 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()), + default_payment_term_id: maybeToNullable(source.defaultPaymentTermId, (value) => + value.toPrimitive() + ), status: source.status.toPrimitive(), created_at: source.createdAt.toISOString(), updated_at: source.updatedAt.toISOString(), diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts index d3356d56..dc6faf30 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts @@ -20,6 +20,7 @@ export class CompanyModel extends Model< declare email: string | null; declare phone: string | null; declare website: string | null; + declare default_payment_term_id: string | null; declare status: string; declare created_at: string; declare updated_at: string; @@ -71,6 +72,11 @@ export default (database: Sequelize) => { allowNull: true, defaultValue: null, }, + default_payment_term_id: { + type: DataTypes.UUID, + allowNull: true, + defaultValue: null, + }, status: { type: DataTypes.STRING, allowNull: false, @@ -106,6 +112,10 @@ export default (database: Sequelize) => { name: "idx_companies_tin", fields: ["tin"], }, + { + name: "idx_companies_default_payment_term", + fields: ["default_payment_term_id"], + }, ], defaultScope: {}, scopes: {}, diff --git a/modules/companies/src/common/dto/request/update-company.request.dto.ts b/modules/companies/src/common/dto/request/update-company.request.dto.ts index 77f240e7..d8d48d9f 100644 --- a/modules/companies/src/common/dto/request/update-company.request.dto.ts +++ b/modules/companies/src/common/dto/request/update-company.request.dto.ts @@ -15,6 +15,7 @@ export const UpdateCompanyRequestSchema = z.object({ email: EmailSchema.nullable().optional(), phone: z.string().nullable().optional(), website: WebsiteSchema.nullable().optional(), + default_payment_term_id: z.uuid().nullable().optional(), }); export type UpdateCompanyRequestDTO = z.infer; diff --git a/modules/companies/src/common/dto/response/company.response.dto.ts b/modules/companies/src/common/dto/response/company.response.dto.ts index b9994d0d..dd7a4bca 100644 --- a/modules/companies/src/common/dto/response/company.response.dto.ts +++ b/modules/companies/src/common/dto/response/company.response.dto.ts @@ -12,6 +12,7 @@ export const CompanyResponseSchema = z.object({ email: EmailSchema.nullable(), phone: z.string().nullable(), website: WebsiteSchema.nullable(), + default_payment_term_id: z.uuid().nullable(), status: CompanyStatusResponseSchema, }); diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json new file mode 100644 index 00000000..83f4a625 --- /dev/null +++ b/modules/companies/src/common/locales/en.json @@ -0,0 +1,52 @@ +{ + "settings": { + "companyDetails": { + "title": "Company details", + "description": "Configure the active company general data and its default payment term.", + "sections": { + "paymentTerm": { + "title": "Commercial settings", + "description": "This term will only be used as the initial value for future configurations.", + "field": { + "label": "Default payment term", + "description": "It must belong to the active company and stay active for new assignments.", + "placeholder": "Select a payment term", + "none": "No default payment term", + "currentInactive": "{{name}} (current inactive)" + } + } + }, + "actions": { + "save": "Save changes", + "saving": "Saving...", + "reset": "Reset" + }, + "feedback": { + "idle": "No pending changes.", + "saving": "Saving company settings.", + "success": { + "title": "Settings updated", + "message": "The default payment term was saved successfully." + }, + "error": { + "title": "Could not save", + "message": "Review the configuration and try again." + }, + "validation": { + "title": "Review the data", + "message": "Fix the highlighted fields before saving." + }, + "noChanges": { + "title": "No changes", + "message": "There are no pending changes to save." + } + }, + "pageError": { + "title": "Could not load the active company", + "description": "Reload the page to try again.", + "retry": "Retry", + "retrying": "Retrying..." + } + } + } +} diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json new file mode 100644 index 00000000..8352fe13 --- /dev/null +++ b/modules/companies/src/common/locales/es.json @@ -0,0 +1,52 @@ +{ + "settings": { + "companyDetails": { + "title": "Datos de empresa", + "description": "Configura los datos generales de la empresa activa y su plazo de pago por defecto.", + "sections": { + "paymentTerm": { + "title": "Configuración comercial", + "description": "Este plazo solo se usará como valor inicial en futuras configuraciones.", + "field": { + "label": "Plazo de pago por defecto", + "description": "Debe pertenecer a la empresa activa y estar activo para nuevas asignaciones.", + "placeholder": "Selecciona un plazo de pago", + "none": "Sin plazo por defecto", + "currentInactive": "{{name}} (inactivo actual)" + } + } + }, + "actions": { + "save": "Guardar cambios", + "saving": "Guardando...", + "reset": "Restablecer" + }, + "feedback": { + "idle": "Sin cambios pendientes.", + "saving": "Guardando configuración de empresa.", + "success": { + "title": "Configuración actualizada", + "message": "El plazo de pago por defecto se ha guardado correctamente." + }, + "error": { + "title": "No se pudo guardar", + "message": "Revisa la configuración e inténtalo de nuevo." + }, + "validation": { + "title": "Revisa los datos", + "message": "Corrige los campos marcados antes de guardar." + }, + "noChanges": { + "title": "Sin cambios", + "message": "No hay cambios pendientes de guardar." + } + }, + "pageError": { + "title": "No se pudo cargar la empresa activa", + "description": "Recarga la pantalla para volver a intentarlo.", + "retry": "Reintentar", + "retrying": "Reintentando..." + } + } + } +} diff --git a/modules/companies/src/web/company-routes.tsx b/modules/companies/src/web/company-routes.tsx new file mode 100644 index 00000000..8fbc34ea --- /dev/null +++ b/modules/companies/src/web/company-routes.tsx @@ -0,0 +1,22 @@ +import { lazy } from "react"; + +import type { ModuleClientParams, ModuleRouteObject } from "@erp/core/client"; + +const CompanyDetailsSettingsPage = lazy(() => + import("./settings/company-details").then((module) => ({ + default: module.CompanyDetailsSettingsPage, + })) +); + +export const CompanyRoutes = (_params: ModuleClientParams): ModuleRouteObject[] => { + return [ + { + path: "settings/company/details", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: , + }, + ]; +}; diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts new file mode 100644 index 00000000..f692b060 --- /dev/null +++ b/modules/companies/src/web/i18n.ts @@ -0,0 +1,24 @@ +import { i18n, registerTranslations } from "@repo/i18next"; +import { useEffect } from "react"; +import { useTranslation as useI18NextTranslation } from "react-i18next"; + +import enResources from "../common/locales/en.json"; +import esResources from "../common/locales/es.json"; + +import { MODULE_NAME } from "./manifest"; + +export const ensureModuleTranslations = () => { + registerTranslations(MODULE_NAME, "en", enResources); + registerTranslations(MODULE_NAME, "es", esResources); +}; + +export const useTranslation = () => { + // biome-ignore lint/correctness/useExhaustiveDependencies: registra traducciones de forma idempotente + useEffect(() => { + ensureModuleTranslations(); + }, [i18n]); + + return useI18NextTranslation(MODULE_NAME, { + i18n, + }); +}; diff --git a/modules/companies/src/web/manifest.ts b/modules/companies/src/web/manifest.ts new file mode 100644 index 00000000..1091381e --- /dev/null +++ b/modules/companies/src/web/manifest.ts @@ -0,0 +1,30 @@ +import type { IModuleClient } from "@erp/core/client"; + +import { CompanyRoutes } from "./company-routes"; +import { ensureModuleTranslations } from "./i18n"; + +export const MODULE_NAME = "Companies"; +const MODULE_VERSION = "1.0.0"; + +ensureModuleTranslations(); + +export const CompaniesModuleManifest: IModuleClient = { + name: MODULE_NAME, + version: MODULE_VERSION, + dependencies: ["auth", "Core", "Catalogs"], + protected: true, + layout: "app-sidebar", + settings: { + entries: [ + { + id: "company-details", + sectionId: "company", + labelKey: "settings.companyDetails.title", + path: "/settings/company/details", + }, + ], + }, + routes: (params) => CompanyRoutes(params), +}; + +export default CompaniesModuleManifest; diff --git a/modules/companies/src/web/settings/company-details/controllers/index.ts b/modules/companies/src/web/settings/company-details/controllers/index.ts new file mode 100644 index 00000000..27133d1e --- /dev/null +++ b/modules/companies/src/web/settings/company-details/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-company-details-settings-controller"; diff --git a/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts b/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts new file mode 100644 index 00000000..452091e6 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/controllers/use-company-details-settings-controller.ts @@ -0,0 +1,218 @@ +import { applyValidationErrorCollection } from "@erp/core"; +import { formHasAnyDirty } from "@erp/core/client"; +import { useHookForm } from "@erp/core/hooks"; +import { + getPaymentTermOptions, + usePaymentTermDetailQuery, + usePaymentTermListQuery, +} from "@erp/catalogs/client/payment-terms"; +import { useIdentityAuthSession } from "@erp/identity/client"; +import { + focusFirstInputFormError, + getFirstFormErrorMessage, + showErrorToast, + showSuccessToast, + showWarningToast, +} from "@repo/rdx-ui/helpers"; +import type { SelectFieldItem } from "@repo/rdx-ui/components"; +import { isValidationErrorCollection, type ValidationErrorCollection } from "@repo/rdx-ddd"; +import { useEffect, useId, useMemo, type SubmitEvent } from "react"; +import type { FieldErrors } from "react-hook-form"; + +import { useTranslation } from "../../../i18n"; +import { CompanySettingsFormSchema, type CompanySettingsForm } from "../entities"; +import { useCompanySettingsQuery, useCompanySettingsUpdateMutation } from "../shared/hooks"; +import { buildCompanySettingsForm, buildUpdateCompanySettingsParams } from "../utils"; + +const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => { + if (isValidationErrorCollection(error)) { + return error; + } + + if (error instanceof Error) { + return error; + } + + return new Error("Unknown error"); +}; + +export const useCompanyDetailsSettingsController = () => { + const { t } = useTranslation(); + const { activeCompany } = useIdentityAuthSession(); + const companyId = activeCompany?.id; + const formId = useId(); + + const companyQuery = useCompanySettingsQuery(companyId); + const mutation = useCompanySettingsUpdateMutation(); + + const paymentTermsQuery = usePaymentTermListQuery({ + criteria: { + pageSize: 200, + filters: [ + { + field: "isActive", + operator: "EQUALS", + value: "true", + }, + ], + }, + }); + + const company = companyQuery.data; + const currentPaymentTermId = company?.defaultPaymentTermId ?? undefined; + const activePaymentTermIds = paymentTermsQuery.data?.items.map((item) => item.id) ?? []; + const needsCurrentPaymentTerm = Boolean( + currentPaymentTermId && !activePaymentTermIds.includes(currentPaymentTermId) + ); + + const currentPaymentTermQuery = usePaymentTermDetailQuery(currentPaymentTermId, { + enabled: needsCurrentPaymentTerm, + }); + + const initialValues = useMemo(() => { + if (!company) { + return { + defaultPaymentTermId: null, + }; + } + + return buildCompanySettingsForm(company); + }, [company]); + + const form = useHookForm({ + resolverSchema: CompanySettingsFormSchema, + initialValues, + disabled: mutation.isPending, + }); + + useEffect(() => { + if (!company) { + return; + } + + form.reset(buildCompanySettingsForm(company), { + keepDirty: false, + }); + }, [company, form]); + + const paymentTermOptions = useMemo(() => { + const options = getPaymentTermOptions(paymentTermsQuery.data?.items ?? []); + + if (currentPaymentTermQuery.data && needsCurrentPaymentTerm) { + options.unshift({ + value: currentPaymentTermQuery.data.id, + label: t("settings.companyDetails.sections.paymentTerm.field.currentInactive", { + name: currentPaymentTermQuery.data.name, + }), + }); + } + + return [ + { + value: "", + label: t("settings.companyDetails.sections.paymentTerm.field.none"), + }, + ...options, + ]; + }, [currentPaymentTermQuery.data, needsCurrentPaymentTerm, paymentTermsQuery.data?.items, t]); + + const submitHandler = form.handleSubmit( + async (formData) => { + if (!companyId) { + return; + } + + if (!formHasAnyDirty(form.formState.dirtyFields)) { + showWarningToast( + t("settings.companyDetails.feedback.noChanges.title"), + t("settings.companyDetails.feedback.noChanges.message") + ); + return; + } + + try { + const updated = await mutation.mutateAsync( + buildUpdateCompanySettingsParams({ + companyId, + form: formData, + }) + ); + + form.reset(buildCompanySettingsForm(updated), { + keepDirty: false, + }); + + showSuccessToast( + t("settings.companyDetails.feedback.success.title"), + t("settings.companyDetails.feedback.success.message") + ); + } catch (error: unknown) { + const normalizedError = normalizeSubmitError(error); + + if (isValidationErrorCollection(normalizedError)) { + applyValidationErrorCollection(form, normalizedError); + focusFirstInputFormError(form.formState.errors, form); + showWarningToast( + t("settings.companyDetails.feedback.validation.title"), + t("settings.companyDetails.feedback.validation.message") + ); + return; + } + + showErrorToast( + t("settings.companyDetails.feedback.error.title"), + normalizedError.message || t("settings.companyDetails.feedback.error.message") + ); + } + }, + (errors: FieldErrors) => { + focusFirstInputFormError(errors, form); + showWarningToast( + t("settings.companyDetails.feedback.validation.title"), + getFirstFormErrorMessage(errors, form) ?? + t("settings.companyDetails.feedback.validation.message") + ); + } + ); + + const onSubmit = (event: SubmitEvent) => { + event.stopPropagation(); + submitHandler(event); + }; + + return { + activeCompany, + company, + companyQuery, + form, + formId, + onSubmit, + paymentTermOptions, + resetForm: () => { + if (!company) { + return; + } + + form.reset(buildCompanySettingsForm(company), { + keepDirty: false, + }); + }, + isLoading: + companyQuery.isLoading || + paymentTermsQuery.isLoading || + (needsCurrentPaymentTerm && currentPaymentTermQuery.isLoading), + isRetrying: companyQuery.isFetching && !companyQuery.isLoading, + isSaving: mutation.isPending, + pageError: + companyQuery.error ?? + paymentTermsQuery.error ?? + (needsCurrentPaymentTerm ? currentPaymentTermQuery.error : null), + retry: async () => { + await Promise.all([ + companyQuery.refetch(), + paymentTermsQuery.refetch(), + needsCurrentPaymentTerm ? currentPaymentTermQuery.refetch() : Promise.resolve(undefined), + ]); + }, + }; +}; diff --git a/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts b/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts new file mode 100644 index 00000000..eaf184f8 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/entities/company-settings-form.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const CompanySettingsFormSchema = z.object({ + defaultPaymentTermId: z.union([z.uuid(), z.literal(""), z.null()]), +}); + +export type CompanySettingsForm = z.infer; diff --git a/modules/companies/src/web/settings/company-details/entities/index.ts b/modules/companies/src/web/settings/company-details/entities/index.ts new file mode 100644 index 00000000..a89b3b6f --- /dev/null +++ b/modules/companies/src/web/settings/company-details/entities/index.ts @@ -0,0 +1 @@ +export * from "./company-settings-form.schema"; diff --git a/modules/companies/src/web/settings/company-details/index.ts b/modules/companies/src/web/settings/company-details/index.ts new file mode 100644 index 00000000..bedf3adf --- /dev/null +++ b/modules/companies/src/web/settings/company-details/index.ts @@ -0,0 +1 @@ +export * from "./ui/pages/company-details-settings-page"; diff --git a/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts new file mode 100644 index 00000000..68946093 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/adapters/company-settings.adapter.ts @@ -0,0 +1,13 @@ +import type { CompanyResponseDTO } from "../../../../../common"; +import type { CompanySettings } from "../entities"; + +export const CompanySettingsAdapter = { + fromDto(dto: CompanyResponseDTO): CompanySettings { + return { + id: dto.id, + legalName: dto.legal_name, + tradeName: dto.trade_name, + defaultPaymentTermId: dto.default_payment_term_id, + }; + }, +}; diff --git a/modules/companies/src/web/settings/company-details/shared/adapters/index.ts b/modules/companies/src/web/settings/company-details/shared/adapters/index.ts new file mode 100644 index 00000000..4559d62f --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/adapters/index.ts @@ -0,0 +1 @@ +export * from "./company-settings.adapter"; diff --git a/modules/companies/src/web/settings/company-details/shared/api/get-company-settings.api.ts b/modules/companies/src/web/settings/company-details/shared/api/get-company-settings.api.ts new file mode 100644 index 00000000..09c2b7b1 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/api/get-company-settings.api.ts @@ -0,0 +1,17 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { CompanyResponseDTO } from "../../../../../common"; + +export interface GetCompanySettingsParams { + companyId: string; + signal?: AbortSignal; +} + +export const getCompanySettings = ( + dataSource: IDataSource, + params: GetCompanySettingsParams +): Promise => { + return dataSource.getOne("companies", params.companyId, { + signal: params.signal, + }); +}; diff --git a/modules/companies/src/web/settings/company-details/shared/api/index.ts b/modules/companies/src/web/settings/company-details/shared/api/index.ts new file mode 100644 index 00000000..641190a7 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/api/index.ts @@ -0,0 +1,2 @@ +export * from "./get-company-settings.api"; +export * from "./update-company-settings.api"; diff --git a/modules/companies/src/web/settings/company-details/shared/api/update-company-settings.api.ts b/modules/companies/src/web/settings/company-details/shared/api/update-company-settings.api.ts new file mode 100644 index 00000000..4f99dc94 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/api/update-company-settings.api.ts @@ -0,0 +1,19 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../../common"; + +export interface UpdateCompanySettingsParams { + companyId: string; + data: UpdateCompanyRequestDTO; +} + +export const updateCompanySettings = ( + dataSource: IDataSource, + params: UpdateCompanySettingsParams +): Promise => { + return dataSource.updateOne( + "companies", + params.companyId, + params.data + ); +}; diff --git a/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts b/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts new file mode 100644 index 00000000..07f1914e --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/entities/company-settings.entity.ts @@ -0,0 +1,6 @@ +export interface CompanySettings { + id: string; + legalName: string; + tradeName: string | null; + defaultPaymentTermId: string | null; +} diff --git a/modules/companies/src/web/settings/company-details/shared/entities/index.ts b/modules/companies/src/web/settings/company-details/shared/entities/index.ts new file mode 100644 index 00000000..61df2992 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/entities/index.ts @@ -0,0 +1 @@ +export * from "./company-settings.entity"; diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/index.ts b/modules/companies/src/web/settings/company-details/shared/hooks/index.ts new file mode 100644 index 00000000..2aab8561 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./use-company-settings-query"; +export * from "./use-company-settings-update-mutation"; diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/keys.ts b/modules/companies/src/web/settings/company-details/shared/hooks/keys.ts new file mode 100644 index 00000000..c439b65d --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/hooks/keys.ts @@ -0,0 +1,8 @@ +export const COMPANY_SETTINGS_QUERY_KEY = (companyId?: string) => + ["companies", "settings", "detail", { companyId }] as const; + +export const UPDATE_COMPANY_SETTINGS_MUTATION_KEY = [ + "companies", + "settings", + "update", +] as const; diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts new file mode 100644 index 00000000..1749323d --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-query.ts @@ -0,0 +1,27 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; + +import { CompanySettingsAdapter } from "../adapters"; +import { getCompanySettings } from "../api"; +import type { CompanySettings } from "../entities"; + +import { COMPANY_SETTINGS_QUERY_KEY } from "./keys"; + +export const useCompanySettingsQuery = ( + companyId?: string +): UseQueryResult => { + const dataSource = useDataSource(); + + return useQuery({ + queryKey: COMPANY_SETTINGS_QUERY_KEY(companyId), + enabled: Boolean(companyId), + queryFn: async ({ signal }) => { + const dto = await getCompanySettings(dataSource, { + companyId: companyId as string, + signal, + }); + + return CompanySettingsAdapter.fromDto(dto); + }, + }); +}; diff --git a/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts new file mode 100644 index 00000000..7abb6a6e --- /dev/null +++ b/modules/companies/src/web/settings/company-details/shared/hooks/use-company-settings-update-mutation.ts @@ -0,0 +1,39 @@ +import { useDataSource } from "@erp/core/hooks"; +import { ValidationErrorCollection } from "@repo/rdx-ddd"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { UpdateCompanyRequestSchema } from "../../../../../common"; +import { CompanySettingsAdapter } from "../adapters"; +import type { UpdateCompanySettingsParams } from "../api"; +import { updateCompanySettings } from "../api"; +import type { CompanySettings } from "../entities"; + +import { COMPANY_SETTINGS_QUERY_KEY, UPDATE_COMPANY_SETTINGS_MUTATION_KEY } from "./keys"; + +const toValidationErrors = (issues: { path: PropertyKey[]; message: string }[]) => + issues.map((issue) => ({ + path: issue.path.map(String).join("."), + message: issue.message, + })); + +export const useCompanySettingsUpdateMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: UPDATE_COMPANY_SETTINGS_MUTATION_KEY, + mutationFn: async (params) => { + const result = UpdateCompanyRequestSchema.safeParse(params.data); + + if (!result.success) { + throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error.issues)); + } + + const dto = await updateCompanySettings(dataSource, params); + return CompanySettingsAdapter.fromDto(dto); + }, + onSuccess: async (company) => { + queryClient.setQueryData(COMPANY_SETTINGS_QUERY_KEY(company.id), company); + }, + }); +}; diff --git a/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx new file mode 100644 index 00000000..4790aec2 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/ui/pages/company-details-settings-page.tsx @@ -0,0 +1,117 @@ +import { ErrorAlert, PageErrorState } from "@erp/core/components"; +import { UnsavedChangesProvider } from "@erp/core/hooks"; +import { + AppContent, + CancelActionButton, + RdxEditorSection, + RdxEditorStickyFooter, + RdxFormGrid, + RhfSubmitActionButton, + SelectField, +} from "@repo/rdx-ui/components"; +import { LandmarkIcon } from "lucide-react"; +import { FormProvider } from "react-hook-form"; + +import { useTranslation } from "../../../../i18n"; +import { useCompanyDetailsSettingsController } from "../../controllers"; + +export const CompanyDetailsSettingsPage = () => { + const { t } = useTranslation(); + const ctrl = useCompanyDetailsSettingsController(); + + if (ctrl.isLoading) { + return ; + } + + if (ctrl.pageError) { + return ( + + + + ); + } + + return ( + + +
+
+

+ {t("settings.companyDetails.title")} +

+

+ {t("settings.companyDetails.description")} +

+
+ + {ctrl.companyQuery.isError ? ( + + ) : null} + +
+ } + title={t("settings.companyDetails.sections.paymentTerm.title")} + variant="panel" + > + + + + +
+ +

+ {ctrl.isSaving + ? t("settings.companyDetails.feedback.saving") + : t("settings.companyDetails.feedback.idle")} +

+ + + } + secondary={ + + } + /> +
+
+
+ ); +}; diff --git a/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts b/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts new file mode 100644 index 00000000..d24bccef --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/build-company-settings-form.ts @@ -0,0 +1,6 @@ +import type { CompanySettingsForm } from "../entities"; +import type { CompanySettings } from "../shared/entities"; + +export const buildCompanySettingsForm = (company: CompanySettings): CompanySettingsForm => ({ + defaultPaymentTermId: company.defaultPaymentTermId, +}); diff --git a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts new file mode 100644 index 00000000..6c04ef03 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildUpdateCompanySettingsParams } from "./build-update-company-settings-params"; + +test("normaliza vacio a null", () => { + const result = buildUpdateCompanySettingsParams({ + companyId: "11111111-1111-4111-8111-111111111111", + form: { + defaultPaymentTermId: "", + }, + }); + + assert.deepEqual(result, { + companyId: "11111111-1111-4111-8111-111111111111", + data: { + default_payment_term_id: null, + }, + }); +}); + +test("conserva null explicito", () => { + const result = buildUpdateCompanySettingsParams({ + companyId: "11111111-1111-4111-8111-111111111111", + form: { + defaultPaymentTermId: null, + }, + }); + + assert.equal(result.data.default_payment_term_id, null); +}); + +test("envia la seleccion valida", () => { + const result = buildUpdateCompanySettingsParams({ + companyId: "11111111-1111-4111-8111-111111111111", + form: { + defaultPaymentTermId: "22222222-2222-4222-8222-222222222222", + }, + }); + + assert.equal( + result.data.default_payment_term_id, + "22222222-2222-4222-8222-222222222222" + ); +}); diff --git a/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts new file mode 100644 index 00000000..1df9ab06 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/build-update-company-settings-params.ts @@ -0,0 +1,20 @@ +import type { UpdateCompanyRequestDTO } from "../../../../../common"; +import type { CompanySettingsForm } from "../entities"; + +export const buildUpdateCompanySettingsParams = (params: { + companyId: string; + form: CompanySettingsForm; +}): { + companyId: string; + data: UpdateCompanyRequestDTO; +} => { + return { + companyId: params.companyId, + data: { + default_payment_term_id: + params.form.defaultPaymentTermId == null || params.form.defaultPaymentTermId === "" + ? null + : params.form.defaultPaymentTermId, + }, + }; +}; diff --git a/modules/companies/src/web/settings/company-details/utils/index.ts b/modules/companies/src/web/settings/company-details/utils/index.ts new file mode 100644 index 00000000..64d9c204 --- /dev/null +++ b/modules/companies/src/web/settings/company-details/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./build-company-settings-form"; +export * from "./build-update-company-settings-params"; diff --git a/modules/companies/tsconfig.json b/modules/companies/tsconfig.json index 6d3984b5..3926998c 100644 --- a/modules/companies/tsconfig.json +++ b/modules/companies/tsconfig.json @@ -7,7 +7,7 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2022"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", @@ -15,6 +15,7 @@ "isolatedModules": true, "moduleDetection": "force", "noEmit": true, + "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, diff --git a/modules/customer-invoices/src/api/application/common/models/index.ts b/modules/customer-invoices/src/api/application/common/models/index.ts index e69e3f7c..8f2e2f52 100644 --- a/modules/customer-invoices/src/api/application/common/models/index.ts +++ b/modules/customer-invoices/src/api/application/common/models/index.ts @@ -1,2 +1,3 @@ export * from "./invoice-payment-method-read.model"; +export * from "./invoice-payment-term-read.model"; export * from "./invoice-tax-regime-full-read.model"; diff --git a/modules/customer-invoices/src/api/application/common/models/invoice-payment-term-read.model.ts b/modules/customer-invoices/src/api/application/common/models/invoice-payment-term-read.model.ts new file mode 100644 index 00000000..ca04a3a7 --- /dev/null +++ b/modules/customer-invoices/src/api/application/common/models/invoice-payment-term-read.model.ts @@ -0,0 +1,16 @@ +/** + * Datos del plazo de pago que completan a la proforma. + */ +export interface InvoicePaymentTermReadModel { + id: string; + name: string; + description: string; + isActive: boolean; + dueRules: Array<{ + dueDays: string; + percentage: { + value: string; + scale: number; + }; + }>; +} diff --git a/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts b/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts index 88a1371c..45e15719 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/models/proforma-issue-read.model.ts @@ -3,7 +3,9 @@ import type { Proforma } from "../../../domain"; import type { InvocieTaxRegimeFullReadModel, InvoicePaymentMethodReadModel, + InvoicePaymentTermReadModel, } from "../../common/models"; +import type { Maybe } from "@repo/rdx-utils"; /** * Modelo de lectura usado exclusivamente durante la emisión de una proforma. @@ -14,6 +16,7 @@ import type { export interface ProformaIssueReadModel { proforma: Proforma; paymentMethod: InvoicePaymentMethodReadModel; + paymentTerm: Maybe; taxRegime: InvocieTaxRegimeFullReadModel; fiscalClassification: { operationQualificationCode: string | null; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.test.ts new file mode 100644 index 00000000..41177b70 --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { IssuedInvoiceDocumentMetadataFactory } from "./issued-invoice-document-metadata-factory"; + +test("normaliza locales compuestos para resolver el template FastReport efectivo", () => { + const factory = new IssuedInvoiceDocumentMetadataFactory(); + + const metadata = factory.build({ + id: "11111111-1111-4111-8111-111111111111", + company_id: "22222222-2222-4222-8222-222222222222", + company_slug: "rodax", + invoice_number: "12", + invoice_reference: "A-12", + series: "A", + status: "issued", + reference: null, + language_code: "es-ES", + currency_code: "EUR", + invoice_date: "17/08/2026", + payment_method: "Transferencia", + payment_term: "30 días", + notes: "", + recipient: { + name: "Acme Labs", + tin: "B12345678", + format_address: "Calle Mayor 1", + }, + items: [], + taxes: [], + subtotal_amount: "100,00", + discount_percentage: "0,00 %", + discount_amount: "0,00", + taxable_amount: "100,00", + taxes_amount: "21,00", + total_amount: "121,00", + verifactu: { + status: "Correcto", + url: "", + qr_code: "", + }, + }); + + assert.equal(metadata.languageCode, "es"); +}); diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.ts index 050cdbf7..75e77cf7 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.ts @@ -27,23 +27,28 @@ export class IssuedInvoiceDocumentMetadataFactory companyId: snapshot.company_id, companySlug: snapshot.company_slug, format: "PDF", - languageCode: snapshot.language_code ?? "es", + languageCode: this.normalizeLanguageCode(snapshot.language_code), filename: this.buildFilename(snapshot), storageKey: this.buildCacheKey(snapshot), }; } + private normalizeLanguageCode(languageCode: string | undefined): string { + const normalized = (languageCode ?? "es").trim(); + + if (!normalized) { + return "es"; + } + + return normalized.split(/[-_]/, 1)[0]?.toLowerCase() ?? "es"; + } + private buildFilename(snapshot: IIssuedInvoiceReportSnapshot): string { return `factura-${snapshot.invoice_reference}-${snapshot.recipient.name}.pdf`; } private buildCacheKey(snapshot: IIssuedInvoiceReportSnapshot): string { // Versionado explícito para invalidaciones futuras - return [ - "issued-invoice", - snapshot.company_id, - snapshot.invoice_reference, - "v1", - ].join(":"); + return ["issued-invoice", snapshot.company_id, snapshot.invoice_reference, "v1"].join(":"); } } diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts index ce349d34..a1271b21 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts @@ -1,11 +1,12 @@ // modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts -import { UtcDate } from "@repo/rdx-ddd"; +import { UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; import { type IIssuedInvoiceCreateProps, InvoicePaymentMethod, + InvoicePaymentTerm, type InvoiceSerie, InvoiceStatus, InvoiceTaxRegime, @@ -66,6 +67,12 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic return Result.fail(paymentResult.error); } + const paymentTermResult = this.resolvePaymentTerm(source); + + if (paymentTermResult.isFailure) { + return Result.fail(paymentTermResult.error); + } + const taxRegimeResult = this.resolveTaxRegime(source); if (taxRegimeResult.isFailure) { @@ -105,6 +112,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic reference: proforma.reference, paymentMethod: paymentResult.data, + paymentTerm: paymentTermResult.data, taxRegime: taxRegimeResult.data, fiscalClassification: Maybe.some(fiscalClassificationResult.data), verifactuPolicy: Maybe.some(verifactuPolicyResult.data), @@ -282,6 +290,45 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic return Result.ok(paymentMethodResult.data); } + private resolvePaymentTerm( + source: ProformaIssueReadModel + ): Result, Error> { + if (source.paymentTerm.isNone()) { + return Result.ok(Maybe.none()); + } + + const paymentTerm = source.paymentTerm.unwrap(); + const paymentTermIdResult = UniqueID.create(paymentTerm.id); + + if (paymentTermIdResult.isFailure) { + return Result.fail(paymentTermIdResult.error); + } + + const paymentTermResult = InvoicePaymentTerm.create( + { + name: paymentTerm.name, + description: + paymentTerm.description.trim().length > 0 + ? Maybe.some(paymentTerm.description) + : Maybe.none(), + dueRules: paymentTerm.dueRules.map((rule) => ({ + dueDays: rule.dueDays, + percentage: { + value: rule.percentage.value, + scale: rule.percentage.scale, + }, + })), + }, + paymentTermIdResult.data + ); + + if (paymentTermResult.isFailure) { + return Result.fail(paymentTermResult.error); + } + + return Result.ok(Maybe.some(paymentTermResult.data)); + } + private resolveTaxRegime(source: ProformaIssueReadModel): Result { const taxRegimeResult = InvoiceTaxRegime.create({ code: source.taxRegime.code, diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts index 4278749d..0f0933ff 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot-builder.ts @@ -32,6 +32,18 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps name: invoice.paymentMethod.name.toString(), description: invoice.paymentMethod.name.toString(), }; + const payment_term = maybeToNullable(invoice.paymentTerm, (paymentTerm) => ({ + id: paymentTerm.id.toString(), + name: paymentTerm.name, + description: paymentTerm.description.getOrUndefined() ?? null, + due_rules: paymentTerm.dueRules.map((rule) => ({ + due_days: rule.dueDays, + percentage: { + value: rule.percentage.value, + scale: rule.percentage.scale, + }, + })), + })); const tax_regime = { code: invoice.taxRegime.code, @@ -74,6 +86,7 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps taxes, payment_method, + payment_term, tax_regime, fiscal_classification, verifactu_policy, diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts index 5a793986..05dfdaac 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/full/issued-invoice-full-snapshot.interface.ts @@ -47,6 +47,18 @@ export interface IIssuedInvoiceFullSnapshot { name: string; description: string; } | null; + payment_term: { + id: string; + name: string; + description: string | null; + due_rules: Array<{ + due_days: string; + percentage: { + value: string; + scale: number; + }; + }>; + } | null; tax_regime: { code: string; description: string | null; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.test.ts new file mode 100644 index 00000000..a35cfe30 --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MoneyDTOHelper, PercentageDTOHelper } from "@erp/core"; + +import { IssuedInvoiceReportSnapshotBuilder } from "./issued-invoice-report-snapshot-builder"; + +test("reporting usa el payment term histórico del snapshot emitido", () => { + const builder = new IssuedInvoiceReportSnapshotBuilder( + { + toOutput: () => [], + } as never, + { + toOutput: () => [], + } as never + ); + + const snapshot = builder.toOutput({ + id: "11111111-1111-4111-8111-111111111111", + company_id: "22222222-2222-4222-8222-222222222222", + document_series_id: null, + invoice_series_code: "A", + invoice_number: "12", + invoice_reference: "A-12", + status: "issued", + series: "A", + invoice_date: "2026-08-10", + operation_date: null, + reference: null, + description: "Factura de prueba", + notes: null, + language_code: "es-ES", + currency_code: "EUR", + customer_id: "33333333-3333-4333-8333-333333333333", + recipient: { + id: "44444444-4444-4444-8444-444444444444", + name: "Acme Labs", + tin: "B12345678", + street: "Calle Mayor 1", + street2: null, + city: "Madrid", + province: "Madrid", + postal_code: "28001", + country: "es", + }, + linked_proforma_id: "55555555-5555-4555-8555-555555555555", + taxes: [], + payment_method: { + id: "66666666-6666-4666-8666-666666666666", + name: "Transferencia", + description: "Transferencia bancaria", + }, + payment_term: { + id: "77777777-7777-4777-8777-777777777777", + name: "30 días", + description: "Pago estándar", + due_rules: [ + { + due_days: "30", + percentage: { + value: "10000", + scale: 2, + }, + }, + ], + }, + tax_regime: { + code: "general", + description: "Régimen general", + }, + fiscal_classification: null, + verifactu_policy: null, + subtotal_amount: MoneyDTOHelper.fromNumber(100, "EUR"), + items_discount_amount: MoneyDTOHelper.fromNumber(0, "EUR"), + global_discount_percentage: PercentageDTOHelper.fromNumber(0), + global_discount_amount: MoneyDTOHelper.fromNumber(0, "EUR"), + total_discount_amount: MoneyDTOHelper.fromNumber(0, "EUR"), + taxable_amount: MoneyDTOHelper.fromNumber(100, "EUR"), + iva_amount: MoneyDTOHelper.fromNumber(21, "EUR"), + rec_amount: MoneyDTOHelper.fromNumber(0, "EUR"), + retention_amount: MoneyDTOHelper.fromNumber(0, "EUR"), + taxes_amount: MoneyDTOHelper.fromNumber(21, "EUR"), + total_amount: MoneyDTOHelper.fromNumber(121, "EUR"), + verifactu: { + id: "88888888-8888-4888-8888-888888888888", + status: "Correcto", + url: null, + qr_code: null, + }, + items: [], + metadata: { + entity: "issued-invoices", + }, + }); + + assert.equal(snapshot.payment_method, "Transferencia bancaria"); + assert.equal(snapshot.payment_term, "30 días"); +}); diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.ts index c4871fcd..27c7a463 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.ts @@ -43,7 +43,8 @@ export class IssuedInvoiceReportSnapshotBuilder implements IIssuedInvoiceReportS invoice_date: DateHelper.format(snapshot.invoice_date, locale), - payment_method: snapshot.payment_method?.payment_description ?? "", + payment_method: snapshot.payment_method?.description ?? snapshot.payment_method?.name ?? "", + payment_term: snapshot.payment_term?.name ?? "", notes: snapshot.notes ?? "", recipient: { diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot.interface.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot.interface.ts index 70e3ed37..39a5d625 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot.interface.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot.interface.ts @@ -16,6 +16,7 @@ export interface IIssuedInvoiceReportSnapshot { invoice_date: string; payment_method: string; + payment_term: string; notes: string; recipient: { diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts index e72ae510..af087dbb 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts @@ -1,3 +1,4 @@ +import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; import type { IProformaRepository } from "../repositories"; @@ -18,6 +19,7 @@ export const buildProformaCreator = (params: { repository: IProformaRepository; proformaSeriesValidator: ProformaSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; + companiesServices: ICompanyPublicServices; customerServices: ICustomerPublicServices; }): IProformaCreator => { return new ProformaCreator(params); diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts index 57d6c887..d4dd27be 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-issuer.di.ts @@ -1,4 +1,8 @@ -import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; +import type { + IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices"; @@ -15,12 +19,14 @@ export const buildProformaIssuer = (params: { export const buildProformaIssueReadModelAssembler = (params: { paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; taxRegimeFinder: ITaxRegimePublicFinder; customerServices: ICustomerPublicServices; companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository; }) => { return new ProformaIssueReadModelAssembler({ paymentMethodFinder: params.paymentMethodFinder, + paymentTermFinder: params.paymentTermFinder, taxRegimeFinder: params.taxRegimeFinder, customerServices: params.customerServices, companyVerifactuProfileRepository: params.companyVerifactuProfileRepository, diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-read-model-assemblers.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-read-model-assemblers.di.ts index 32d834a9..384d9a1a 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-read-model-assemblers.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-read-model-assemblers.di.ts @@ -1,4 +1,8 @@ -import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; +import type { + IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; import { type IProformaFullReadModelAssembler, ProformaFullReadModelAssembler } from "../services"; @@ -8,11 +12,13 @@ export interface IProformaReadModelAssemblers { export function buildProformaReadModelAssemblers(params: { paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; taxRegimeFinder: ITaxRegimePublicFinder; }): IProformaReadModelAssemblers { return { full: new ProformaFullReadModelAssembler({ paymentMethodFinder: params.paymentMethodFinder, + paymentTermFinder: params.paymentTermFinder, taxRegimeFinder: params.taxRegimeFinder, }), }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts index 7dfaaa54..c10f242e 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts @@ -1,4 +1,6 @@ // modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts +import type { ICustomerPublicServices } from "@erp/customers/api"; + import type { IProformaRepository } from "../repositories"; import type { ProformaPaymentResolver } from "../services"; import { @@ -13,6 +15,7 @@ export const buildProformaUpdater = (params: { taxResolver: ProformaTaxResolver; paymentResolver: ProformaPaymentResolver; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; + customerServices: ICustomerPublicServices; }): IProformaUpdater => { return new ProformaUpdater(params); }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts index de8fa3c5..434bbf9f 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts @@ -1,4 +1,7 @@ +import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; +import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ITransactionManager } from "@erp/core/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers"; @@ -127,6 +130,9 @@ export function buildDuplicateProformaByIdUseCase(deps: { fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; documentSeriesServices: import("@erp/document-series/api").DocumentSeriesPublicServicesType; + companiesServices: ICompanyPublicServices; + customerServices: ICustomerPublicServices; + paymentTermFinder: IPaymentTermPublicFinder; transactionManager: ITransactionManager; }) { return new DuplicateProformaByIdUseCase({ @@ -135,6 +141,9 @@ export function buildDuplicateProformaByIdUseCase(deps: { fullReadModelAssembler: deps.fullReadModelAssembler, fullSnapshotBuilder: deps.fullSnapshotBuilder, documentSeriesServices: deps.documentSeriesServices, + companiesServices: deps.companiesServices, + customerServices: deps.customerServices, + paymentTermFinder: deps.paymentTermFinder, transactionManager: deps.transactionManager, }); } diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts index 9d0f4398..5b078045 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/create-proforma-input.mapper.ts @@ -130,6 +130,12 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { errors ); + const paymentTermId = extractOrPushError( + maybeFromNullableResult(dto.payment_term_id, (value) => UniqueID.create(value)), + "payment_term_id", + errors + ); + const taxRegimeCode = extractOrPushError( maybeFromNullableResult(dto.tax_regime_code, (value) => Result.ok(String(value))), "tax_regime_code", @@ -172,6 +178,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper { taxRegimeCode: taxRegimeCode!, taxConfig, paymentMethodId: paymentMethodId!, + paymentTermId: dto.payment_term_id === undefined ? undefined : paymentTermId!, globalDiscountPercentage: globalDiscountPercentage!, items, diff --git a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts index 0b2af75c..832acc4b 100644 --- a/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts +++ b/modules/customer-invoices/src/api/application/proformas/mappers/update-proforma-input.mapper.ts @@ -182,6 +182,14 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { ); }); + toPatchField(dto.payment_term_id).ifSet((paymentTermId) => { + proformaPatchProps.paymentTermId = extractOrPushError( + maybeFromNullableResult(paymentTermId, (value) => UniqueID.create(value)), + "payment_term_id", + errors + ); + }); + toPatchField(dto.tax_regime_code).ifSet((taxRegimeCode) => { proformaPatchProps.taxRegimeCode = extractOrPushError( maybeFromNullableResult(taxRegimeCode, (value) => Result.ok(String(value))), diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts index 88f0db92..9e508910 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-create-input.model.ts @@ -1,3 +1,4 @@ +import type { UniqueID } from "@repo/rdx-ddd"; import type { Maybe } from "@repo/rdx-utils"; import type { @@ -37,9 +38,11 @@ export type ProformaCreateInputProps = Omit< | "series" | "taxConfig" | "archivedAt" + | "paymentTermId" > & { proformaSeriesCode: Maybe; targetInvoiceSeriesCode: Maybe; + paymentTermId?: Maybe; taxConfig?: ProformaTaxConfigInputProps; items: ProformaItemCreateInputProps[]; }; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-full-read.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-full-read.model.ts index c2be60d6..80b78938 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-full-read.model.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-full-read.model.ts @@ -4,6 +4,7 @@ import type { Proforma } from "../../../domain"; import type { InvocieTaxRegimeFullReadModel, InvoicePaymentMethodReadModel, + InvoicePaymentTermReadModel, } from "../../common/models"; /** @@ -15,5 +16,6 @@ import type { export interface ProformaFullReadModel { proforma: Proforma; paymentMethod: Maybe; + paymentTerm: Maybe; taxRegime: Maybe; } diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-full-read-model.assembler.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-full-read-model.assembler.ts index 38e7b97e..ec1e61fb 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-full-read-model.assembler.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-full-read-model.assembler.ts @@ -1,4 +1,8 @@ -import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; +import type { + IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; import type { UniqueID } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; @@ -6,6 +10,7 @@ import type { Proforma } from "../../../../domain"; import type { InvocieTaxRegimeFullReadModel, InvoicePaymentMethodReadModel, + InvoicePaymentTermReadModel, } from "../../../common/models"; import type { ProformaFullReadModel } from "../../models"; @@ -30,6 +35,7 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss public constructor( private readonly deps: { paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; taxRegimeFinder: ITaxRegimePublicFinder; } ) {} @@ -45,6 +51,12 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss return Result.fail(paymentMethod.error); } + const paymentTerm = await this.resolvePaymentTerm(params); + + if (paymentTerm.isFailure) { + return Result.fail(paymentTerm.error); + } + const taxRegime = await this.resolveTaxRegime(params); if (taxRegime.isFailure) { @@ -54,6 +66,7 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss return Result.ok({ proforma: params.proforma, paymentMethod: paymentMethod.data, + paymentTerm: paymentTerm.data, taxRegime: taxRegime.data, }); } @@ -100,6 +113,46 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss ); } + private async resolvePaymentTerm(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise, Error>> { + if (params.proforma.paymentTermId.isNone()) { + return Result.ok(Maybe.none()); + } + + const paymentTermId = params.proforma.paymentTermId.unwrap(); + + const result = await this.deps.paymentTermFinder.findByIdInCompany({ + companyId: params.companyId, + id: paymentTermId, + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.fail( + new Error(`Proforma payment term reference is inconsistent: ${paymentTermId.toString()}`) + ); + } + + const paymentTerm = result.data.unwrap(); + + return Result.ok( + Maybe.some({ + id: paymentTerm.id.toString(), + name: paymentTerm.name, + description: paymentTerm.description.getOrUndefined() ?? "", + isActive: paymentTerm.isActive, + dueRules: paymentTerm.dueRules, + }) + ); + } + private async resolveTaxRegime(params: { companyId: UniqueID; proforma: Proforma; diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.test.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.test.ts new file mode 100644 index 00000000..52ddf523 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { ProformaIssueReadModelAssembler } from "./proforma-issue-read-model.assembler"; + +const buildAssembler = (overrides?: { + paymentTermResult?: Maybe<{ + id: UniqueID; + companyId: UniqueID; + name: string; + description: Maybe; + isActive: boolean; + dueRules: Array<{ + dueDays: string; + percentage: { + value: string; + scale: number; + }; + }>; + }>; +}) => { + const paymentMethodId = UniqueID.generateNewID(); + const paymentTermId = UniqueID.generateNewID(); + + const assembler = new ProformaIssueReadModelAssembler({ + paymentMethodFinder: { + getByIdInCompany: async () => + Result.ok({ + id: paymentMethodId.toString(), + name: "Transferencia", + description: null, + }), + } as never, + paymentTermFinder: { + findByIdInCompany: async () => + Result.ok( + overrides?.paymentTermResult ?? + Maybe.some({ + id: paymentTermId, + companyId: UniqueID.generateNewID(), + name: "30 días", + description: Maybe.some("Pago estándar"), + isActive: false, + dueRules: [ + { + dueDays: "30", + percentage: { + value: "10000", + scale: 2, + }, + }, + ], + }) + ), + } as never, + taxRegimeFinder: { + findByCodeInCompany: async () => + Result.ok( + Maybe.some({ + code: "01", + description: "Régimen general", + }) + ), + } as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + taxRegimeCode: Maybe.some("01"), + }), + } as never, + companyVerifactuProfileRepository: { + findByCompanyId: async () => Result.ok(Maybe.none()), + } as never, + }); + + return { + assembler, + paymentMethodId, + paymentTermId, + }; +}; + +const buildProforma = (params?: { paymentTermId?: Maybe }) => { + return { + paymentMethodId: Maybe.some(UniqueID.generateNewID()), + paymentTermId: params?.paymentTermId ?? Maybe.some(UniqueID.generateNewID()), + customerId: UniqueID.generateNewID(), + taxRegimeCode: Maybe.some("01"), + } as never; +}; + +test("assemble permite emitir con payment term inactivo ya referenciado y congela sus reglas", async () => { + const { assembler } = buildAssembler(); + + const result = await assembler.assemble({ + companyId: UniqueID.generateNewID(), + proforma: buildProforma(), + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.paymentTerm.isSome(), true); + assert.equal(result.data.paymentTerm.unwrap().name, "30 días"); + assert.equal(result.data.paymentTerm.unwrap().isActive, false); + assert.deepEqual(result.data.paymentTerm.unwrap().dueRules, [ + { + dueDays: "30", + percentage: { + value: "10000", + scale: 2, + }, + }, + ]); +}); + +test("assemble mantiene payment term vacío cuando la proforma no tiene referencia", async () => { + const { assembler } = buildAssembler(); + + const result = await assembler.assemble({ + companyId: UniqueID.generateNewID(), + proforma: buildProforma({ paymentTermId: Maybe.none() }), + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.paymentTerm.isNone(), true); +}); + +test("assemble rechaza una referencia inconsistente de payment term", async () => { + const { assembler } = buildAssembler({ + paymentTermResult: Maybe.none(), + }); + + const result = await assembler.assemble({ + companyId: UniqueID.generateNewID(), + proforma: buildProforma(), + transaction: undefined, + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /inconsistent/i); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts index 86943e3c..17ae5c53 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts @@ -1,9 +1,14 @@ -import type { IPaymentMethodPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api"; +import type { + IPaymentMethodPublicFinder, + IPaymentTermPublicFinder, + ITaxRegimePublicFinder, +} from "@erp/catalogs/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; import type { UniqueID } from "@repo/rdx-ddd"; -import { type Maybe, Result } from "@repo/rdx-utils"; +import { Maybe, Result } from "@repo/rdx-utils"; import type { Proforma } from "../../../../domain"; +import type { InvoicePaymentTermReadModel } from "../../../common/models"; import type { ICompanyVerifactuProfileRepository } from "../../../company-verifactu-profile"; import type { ProformaIssueReadModel } from "../../../issued-invoices"; import { resolveProformaIssueVerifactuSnapshots } from "../../../issued-invoices"; @@ -26,6 +31,7 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA public constructor( private readonly deps: { paymentMethodFinder: IPaymentMethodPublicFinder; + paymentTermFinder: IPaymentTermPublicFinder; taxRegimeFinder: ITaxRegimePublicFinder; customerServices: ICustomerPublicServices; companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository; @@ -53,20 +59,28 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA return Result.fail(paymentMethodResult.error); } - const customerResult = await this.deps.customerServices.getCustomerById(params.proforma.customerId, { - companyId: params.companyId, - transaction: params.transaction, - }); + const paymentTermResult = await this.resolvePaymentTerm(params); + + if (paymentTermResult.isFailure) { + return Result.fail(paymentTermResult.error); + } + + const customerResult = await this.deps.customerServices.getCustomerById( + params.proforma.customerId, + { + companyId: params.companyId, + transaction: params.transaction, + } + ); if (customerResult.isFailure) { return Result.fail(customerResult.error); } - const companyProfileResult = - await this.deps.companyVerifactuProfileRepository.findByCompanyId( - params.companyId, - params.transaction - ); + const companyProfileResult = await this.deps.companyVerifactuProfileRepository.findByCompanyId( + params.companyId, + params.transaction + ); if (companyProfileResult.isFailure) { return Result.fail(companyProfileResult.error); @@ -96,6 +110,7 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA name: paymentMethod.name, description: paymentMethod.description, }, + paymentTerm: paymentTermResult.data, taxRegime: taxRegimeResult.data, fiscalClassification: { operationQualificationCode: @@ -111,6 +126,46 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA }); } + private async resolvePaymentTerm(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise, Error>> { + if (params.proforma.paymentTermId.isNone()) { + return Result.ok(Maybe.none()); + } + + const paymentTermId = params.proforma.paymentTermId.unwrap(); + + const result = await this.deps.paymentTermFinder.findByIdInCompany({ + companyId: params.companyId, + id: paymentTermId, + transaction: params.transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + if (result.data.isNone()) { + return Result.fail( + new Error(`Proforma payment term reference is inconsistent: ${paymentTermId.toString()}`) + ); + } + + const paymentTerm = result.data.unwrap(); + + return Result.ok( + Maybe.some({ + id: paymentTerm.id.toString(), + name: paymentTerm.name, + description: paymentTerm.description.getOrUndefined() ?? "", + isActive: paymentTerm.isActive, + dueRules: paymentTerm.dueRules, + }) + ); + } + private async resolveTaxRegime(params: { companyId: UniqueID; proforma: Proforma; diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.test.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.test.ts new file mode 100644 index 00000000..e12b2cc1 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { ProformaPaymentResolver } from "./proforma-payment-resolver"; + +test("acepta un payment term activo aunque sea de sistema", async () => { + const paymentTermId = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID(); + + const resolver = new ProformaPaymentResolver({ + paymentMethodFinder: {} as never, + paymentTermFinder: { + findByIdInCompany: async () => + Result.ok( + Maybe.some({ + id: paymentTermId, + companyId, + name: "Contado", + description: Maybe.none(), + isActive: true, + isSystem: true, + }) + ), + }, + }); + + const result = await resolver.ensurePaymentTermById({ + companyId, + id: Maybe.some(paymentTermId), + }); + + assert.equal(result.isSuccess, true); +}); + +test("permite limpiar el payment term con null", async () => { + const resolver = new ProformaPaymentResolver({ + paymentMethodFinder: {} as never, + paymentTermFinder: { + findByIdInCompany: async () => Result.fail(new Error("unused")), + }, + }); + + const result = await resolver.ensurePaymentTermById({ + companyId: UniqueID.generateNewID(), + id: Maybe.none(), + }); + + assert.equal(result.isSuccess, true); +}); + +test("rechaza un payment term de otra company o inexistente", async () => { + const resolver = new ProformaPaymentResolver({ + paymentMethodFinder: {} as never, + paymentTermFinder: { + findByIdInCompany: async () => Result.ok(Maybe.none()), + }, + }); + + const result = await resolver.ensurePaymentTermById({ + companyId: UniqueID.generateNewID(), + id: Maybe.some(UniqueID.generateNewID()), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /not found/i); +}); + +test("rechaza nuevas asignaciones a payment terms inactivos", async () => { + const paymentTermId = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID(); + + const resolver = new ProformaPaymentResolver({ + paymentMethodFinder: {} as never, + paymentTermFinder: { + findByIdInCompany: async () => + Result.ok( + Maybe.some({ + id: paymentTermId, + companyId, + name: "30 dias", + description: Maybe.none(), + isActive: false, + isSystem: false, + }) + ), + }, + }); + + const result = await resolver.ensurePaymentTermById({ + companyId, + id: Maybe.some(paymentTermId), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /inactive/i); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts index 90b9427a..2dc80bae 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-payment-resolver.ts @@ -34,6 +34,7 @@ export class ProformaPaymentResolver { public async ensurePaymentMethodById(params: { companyId: UniqueID; id: Maybe; + transaction?: unknown; }): Promise> { if (params.id.isNone()) { return Result.ok(undefined); @@ -44,6 +45,7 @@ export class ProformaPaymentResolver { const result = await this.deps.paymentMethodFinder.findByIdInCompany({ companyId: params.companyId, id, + transaction: params.transaction, }); if (result.isFailure) { @@ -61,6 +63,7 @@ export class ProformaPaymentResolver { public async ensurePaymentTermById(params: { companyId: UniqueID; id: Maybe; + transaction?: unknown; }): Promise> { if (params.id.isNone()) { return Result.ok(undefined); @@ -71,6 +74,7 @@ export class ProformaPaymentResolver { const result = await this.deps.paymentTermFinder.findByIdInCompany({ companyId: params.companyId, id, + transaction: params.transaction, }); if (result.isFailure) { @@ -81,6 +85,10 @@ export class ProformaPaymentResolver { return Result.fail(new Error(`Payment term not found: ${id.toString()}`)); } + if (!result.data.unwrap().isActive) { + return Result.fail(new Error(`Payment term is inactive: ${id.toString()}`)); + } + return Result.ok(undefined); } } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.test.ts new file mode 100644 index 00000000..f52e756a --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.test.ts @@ -0,0 +1,267 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DiscountPercentage } from "@erp/core/api"; +import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { ProformaCreator } from "./proforma-creator"; + +const buildCreateProps = () => { + const proformaDateResult = UtcDate.createFromISO("2026-08-16"); + const languageCodeResult = LanguageCode.create("es"); + const currencyCodeResult = CurrencyCode.create("EUR"); + const globalDiscountPercentageResult = DiscountPercentage.create({ value: 0 }); + + assert.equal(proformaDateResult.isSuccess, true); + assert.equal(languageCodeResult.isSuccess, true); + assert.equal(currencyCodeResult.isSuccess, true); + assert.equal(globalDiscountPercentageResult.isSuccess, true); + + return { + companyId: UniqueID.generateNewID(), + status: {} as never, + proformaSeriesCode: Maybe.none(), + targetInvoiceSeriesCode: Maybe.none(), + proformaDate: proformaDateResult.data, + operationDate: Maybe.none(), + customerId: UniqueID.generateNewID(), + recipient: Maybe.none(), + reference: Maybe.none(), + description: Maybe.none(), + notes: Maybe.none(), + languageCode: languageCodeResult.data, + currencyCode: currencyCodeResult.data, + linkedInvoiceId: Maybe.none(), + paymentMethodId: Maybe.none(), + taxRegimeCode: Maybe.some("01"), + taxConfig: { + taxMode: "single" as const, + defaultIvaCode: Maybe.some("iva_21"), + usesEquivalenceSurcharge: false, + defaultRecCode: Maybe.none(), + usesRetention: false, + defaultRetentionCode: Maybe.none(), + }, + globalDiscountPercentage: globalDiscountPercentageResult.data, + items: [], + }; +}; + +test("create hereda payment term del customer cuando no llega explicito", async () => { + const customerPaymentTermId = UniqueID.generateNewID(); + const props = buildCreateProps(); + const proformaId = UniqueID.generateNewID(); + let capturedPaymentTermId = Maybe.none(); + let companyLookups = 0; + + const creator = new ProformaCreator({ + repository: { + create: async (proforma) => { + capturedPaymentTermId = proforma.paymentTermId; + return Result.ok(undefined); + }, + getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never), + } as never, + numberService: { + getNextForCompany: async () => + Result.ok({ + documentSeriesId: UniqueID.generateNewID().toPrimitive(), + documentSeriesCode: "PF", + number: "1", + reference: "1", + }), + }, + taxResolver: { + ensureTaxRegimeByCode: async () => Result.ok(undefined), + ensureItemTaxes: async () => Result.fail(new Error("unused")), + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + proformaSeriesValidator: { + ensureValidProformaSeries: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + companiesServices: { + finder: { + findById: async () => { + companyLookups += 1; + return Result.fail(new Error("should not resolve company default")); + }, + }, + } as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + paymentTermId: Maybe.some(customerPaymentTermId), + taxRegimeCode: Maybe.none(), + usesEquivalenceSurcharge: false, + usesRetention: false, + }), + } as never, + }); + + const result = await creator.create({ + companyId: props.companyId, + id: proformaId, + props, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(capturedPaymentTermId.isSome(), true); + assert.equal(capturedPaymentTermId.unwrap().equals(customerPaymentTermId), true); + assert.equal(companyLookups, 0); +}); + +test("create usa fallback a company default cuando customer no tiene payment term", async () => { + const companyDefaultPaymentTermId = UniqueID.generateNewID(); + const props = buildCreateProps(); + const proformaId = UniqueID.generateNewID(); + let capturedPaymentTermId = Maybe.none(); + + const creator = new ProformaCreator({ + repository: { + create: async (proforma) => { + capturedPaymentTermId = proforma.paymentTermId; + return Result.ok(undefined); + }, + getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never), + } as never, + numberService: { + getNextForCompany: async () => + Result.ok({ + documentSeriesId: UniqueID.generateNewID().toPrimitive(), + documentSeriesCode: "PF", + number: "1", + reference: "1", + }), + }, + taxResolver: { + ensureTaxRegimeByCode: async () => Result.ok(undefined), + ensureItemTaxes: async () => Result.fail(new Error("unused")), + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + proformaSeriesValidator: { + ensureValidProformaSeries: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + companiesServices: { + finder: { + findById: async () => + Result.ok( + Maybe.some({ + id: props.companyId.toPrimitive(), + legalName: "Acme", + tradeName: null, + tin: null, + slug: "acme", + email: null, + phone: null, + website: null, + defaultPaymentTermId: companyDefaultPaymentTermId.toPrimitive(), + status: "active", + }) + ), + }, + } as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + paymentTermId: Maybe.none(), + taxRegimeCode: Maybe.none(), + usesEquivalenceSurcharge: false, + usesRetention: false, + }), + } as never, + }); + + const result = await creator.create({ + companyId: props.companyId, + id: proformaId, + props, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(capturedPaymentTermId.isSome(), true); + assert.equal(capturedPaymentTermId.unwrap().equals(companyDefaultPaymentTermId), true); +}); + +test("create con payment_term_id null explicito conserva el clear", async () => { + const props = { + ...buildCreateProps(), + paymentTermId: Maybe.none(), + }; + const proformaId = UniqueID.generateNewID(); + let capturedPaymentTermId = Maybe.some(UniqueID.generateNewID()); + + const creator = new ProformaCreator({ + repository: { + create: async (proforma) => { + capturedPaymentTermId = proforma.paymentTermId; + return Result.ok(undefined); + }, + getByIdInCompany: async () => Result.ok({ paymentTermId: capturedPaymentTermId, id: proformaId } as never), + } as never, + numberService: { + getNextForCompany: async () => + Result.ok({ + documentSeriesId: UniqueID.generateNewID().toPrimitive(), + documentSeriesCode: "PF", + number: "1", + reference: "1", + }), + }, + taxResolver: { + ensureTaxRegimeByCode: async () => Result.ok(undefined), + ensureItemTaxes: async () => Result.fail(new Error("unused")), + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + proformaSeriesValidator: { + ensureValidProformaSeries: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + companiesServices: { + finder: { + findById: async () => Result.fail(new Error("should not resolve company default")), + }, + } as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + paymentTermId: Maybe.some(UniqueID.generateNewID()), + taxRegimeCode: Maybe.none(), + usesEquivalenceSurcharge: false, + usesRetention: false, + }), + } as never, + }); + + const result = await creator.create({ + companyId: props.companyId, + id: proformaId, + props, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(capturedPaymentTermId.isNone(), true); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts index 4c51f66c..522217ab 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts @@ -1,5 +1,6 @@ +import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; -import type { UniqueID } from "@repo/rdx-ddd"; +import { UniqueID, type UniqueID as UniqueIDType } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; import { @@ -39,6 +40,7 @@ export class ProformaCreator implements IProformaCreator { paymentResolver: ProformaPaymentResolver; proformaSeriesValidator: ProformaSeriesValidator; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; + companiesServices: ICompanyPublicServices; customerServices: ICustomerPublicServices; } ) {} @@ -168,8 +170,20 @@ export class ProformaCreator implements IProformaCreator { const customer = customerResult.data; + const paymentTermIdResult = await this.resolveEffectivePaymentTermId({ + companyId: props.companyId, + explicitPaymentTermId: props.paymentTermId, + customerPaymentTermId: customer.paymentTermId, + transaction, + }); + + if (paymentTermIdResult.isFailure) { + return Result.fail(paymentTermIdResult.error); + } + const _newProps = { ...props, + paymentTermId: paymentTermIdResult.data, taxRegimeCode: props.taxRegimeCode.isNone() ? customer.taxRegimeCode.isSome() ? customer.taxRegimeCode @@ -191,6 +205,7 @@ export class ProformaCreator implements IProformaCreator { const paymentMethodResult = await this.deps.paymentResolver.ensurePaymentMethodById({ companyId: _newProps.companyId, id: _newProps.paymentMethodId, + transaction, }); if (paymentMethodResult.isFailure) { @@ -237,6 +252,90 @@ export class ProformaCreator implements IProformaCreator { }); } + private async resolveEffectivePaymentTermId(params: { + companyId: UniqueID; + explicitPaymentTermId: Maybe | undefined; + customerPaymentTermId: Maybe; + transaction?: unknown; + }): Promise, Error>> { + if (params.explicitPaymentTermId !== undefined) { + const ensureResult = await this.deps.paymentResolver.ensurePaymentTermById({ + companyId: params.companyId, + id: params.explicitPaymentTermId, + transaction: params.transaction, + }); + + if (ensureResult.isFailure) { + return Result.fail(ensureResult.error); + } + + return Result.ok(params.explicitPaymentTermId); + } + + if (params.customerPaymentTermId.isSome()) { + const ensureResult = await this.deps.paymentResolver.ensurePaymentTermById({ + companyId: params.companyId, + id: params.customerPaymentTermId, + transaction: params.transaction, + }); + + if (ensureResult.isFailure) { + return Result.fail( + new Error("Customer payment term is inconsistent.", { + cause: ensureResult.error, + }) + ); + } + + return Result.ok(params.customerPaymentTermId); + } + + const companyResult = await this.deps.companiesServices.finder.findById({ + companyId: params.companyId, + transaction: params.transaction, + }); + + if (companyResult.isFailure) { + return Result.fail(companyResult.error); + } + + if (companyResult.data.isNone()) { + return Result.fail(new Error("Company not found while resolving proforma payment term.")); + } + + const company = companyResult.data.unwrap(); + + if (company.defaultPaymentTermId == null) { + return Result.ok(Maybe.none()); + } + + const companyDefaultPaymentTermIdResult = UniqueID.create(company.defaultPaymentTermId); + if (companyDefaultPaymentTermIdResult.isFailure) { + return Result.fail( + new Error("Company default payment term is invalid.", { + cause: companyDefaultPaymentTermIdResult.error, + }) + ); + } + + const resolvedDefaultPaymentTermId = Maybe.some(companyDefaultPaymentTermIdResult.data); + const ensureResult = await this.deps.paymentResolver.ensurePaymentTermById({ + companyId: params.companyId, + id: resolvedDefaultPaymentTermId, + transaction: params.transaction, + }); + + if (ensureResult.isFailure) { + return Result.fail( + new Error("Company default payment term is inconsistent.", { + cause: ensureResult.error, + }) + ); + } + + return Result.ok(resolvedDefaultPaymentTermId); + } + private async resolveTaxConfig(params: { companyId: UniqueID; atDate: IProformaCreateProps["proformaDate"]; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.test.ts new file mode 100644 index 00000000..1e42c823 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Result } from "@repo/rdx-utils"; +import { Maybe } from "@repo/rdx-utils"; +import { UniqueID, UtcDate } from "@repo/rdx-ddd"; + +import { ProformaUpdater } from "./proforma-updater"; + +const buildProformaDouble = (paymentTermId?: UniqueID) => { + const proformaDateResult = UtcDate.createFromISO("2026-08-16"); + assert.equal(proformaDateResult.isSuccess, true); + + let receivedPatch: unknown; + + return { + proforma: { + status: { + isIssued: () => false, + isApproved: () => false, + }, + proformaDate: proformaDateResult.data, + paymentTermId: paymentTermId ? Maybe.some(paymentTermId) : Maybe.none(), + taxConfig: { + taxMode: "single", + defaultIvaCode: Maybe.none(), + usesEquivalenceSurcharge: false, + defaultRecCode: Maybe.none(), + usesRetention: false, + defaultRetentionCode: Maybe.none(), + }, + update: (patch: unknown) => { + receivedPatch = patch; + return Result.ok(undefined); + }, + } as never, + getReceivedPatch: () => receivedPatch, + }; +}; + +test("update conserva un payment term inactivo ya asignado si no cambia", async () => { + const existingPaymentTermId = UniqueID.generateNewID(); + const { proforma } = buildProformaDouble(existingPaymentTermId); + let validationCalls = 0; + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => Result.ok(undefined), + } as never, + taxResolver: { + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => { + validationCalls += 1; + return Result.fail(new Error("should not validate unchanged payment term")); + }, + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + customerServices: { + getCustomerById: async () => Result.fail(new Error("unused")), + } as never, + }); + + const result = await updater.update({ + companyId: UniqueID.generateNewID(), + id: UniqueID.generateNewID(), + patchProps: { + paymentTermId: Maybe.some(existingPaymentTermId), + }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(validationCalls, 0); +}); + +test("update permite limpiar el payment term con null explicito", async () => { + const existingPaymentTermId = UniqueID.generateNewID(); + const { proforma } = buildProformaDouble(existingPaymentTermId); + let validationCalls = 0; + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => Result.ok(undefined), + } as never, + taxResolver: { + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => { + validationCalls += 1; + return Result.fail(new Error("should not validate explicit clear")); + }, + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + customerServices: { + getCustomerById: async () => Result.fail(new Error("unused")), + } as never, + }); + + const result = await updater.update({ + companyId: UniqueID.generateNewID(), + id: UniqueID.generateNewID(), + patchProps: { + paymentTermId: Maybe.none(), + }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(validationCalls, 0); +}); + +test("update rechaza asignar un nuevo payment term inactivo", async () => { + const { proforma } = buildProformaDouble(UniqueID.generateNewID()); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => Result.ok(undefined), + } as never, + taxResolver: { + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => Result.fail(new Error("Payment term is inactive")), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + customerServices: { + getCustomerById: async () => Result.fail(new Error("unused")), + } as never, + }); + + const result = await updater.update({ + companyId: UniqueID.generateNewID(), + id: UniqueID.generateNewID(), + patchProps: { + paymentTermId: Maybe.some(UniqueID.generateNewID()), + }, + transaction: undefined, + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /inactive/i); +}); + +test("update refresca el snapshot del destinatario al cambiar el customer", async () => { + const { proforma, getReceivedPatch } = buildProformaDouble(); + const customerId = UniqueID.generateNewID(); + + const updater = new ProformaUpdater({ + repository: { + getByIdInCompany: async () => Result.ok(proforma), + update: async () => Result.ok(undefined), + } as never, + taxResolver: { + findActiveTaxDefinitionByCode: async () => Result.fail(new Error("unused")), + } as never, + paymentResolver: { + ensurePaymentMethodById: async () => Result.ok(undefined), + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + targetInvoiceSeriesValidator: { + ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined), + } as never, + customerServices: { + getCustomerById: async () => + Result.ok({ + name: "Cliente nuevo", + tin: "ES11111111B", + address: { + street: "Calle Dos", + street2: "Portal B", + city: "Barcelona", + postalCode: "08001", + province: "Barcelona", + country: "ES", + }, + }), + } as never, + }); + + const result = await updater.update({ + companyId: UniqueID.generateNewID(), + id: UniqueID.generateNewID(), + patchProps: { + customerId, + }, + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(getReceivedPatch() !== undefined, true); + assert.equal((getReceivedPatch() as { customerId: UniqueID }).customerId.equals(customerId), true); + assert.equal((getReceivedPatch() as { recipient: Maybe }).recipient.isSome(), true); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts index 0a17033a..0714e41c 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts @@ -1,11 +1,13 @@ import { DomainValidationError, type UniqueID, type UtcDate } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; +import type { ICustomerPublicServices } from "@erp/customers/api"; import { type Proforma, type ProformaItemPatchProps, type ProformaPatchProps, ProformaTaxConfig, + ProformaRecipient, } from "../../../domain"; import type { ProformaPatchInputProps } from "../models"; import type { IProformaRepository } from "../repositories"; @@ -30,6 +32,7 @@ export class ProformaUpdater implements IProformaUpdater { taxResolver: ProformaTaxResolver; paymentResolver: ProformaPaymentResolver; targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator; + customerServices: ICustomerPublicServices; } ) {} @@ -77,6 +80,7 @@ export class ProformaUpdater implements IProformaUpdater { const resolvedPatch = await this.resolvePatchProps({ companyId, currentInvoiceDate: proforma.proformaDate, + currentPaymentTermId: proforma.paymentTermId, currentTaxConfig: proforma.taxConfig, patch: patchProps, transaction, @@ -113,6 +117,7 @@ export class ProformaUpdater implements IProformaUpdater { private async resolvePatchProps(params: { companyId: UniqueID; currentInvoiceDate: UtcDate; + currentPaymentTermId: Proforma["paymentTermId"]; currentTaxConfig: Proforma["taxConfig"]; patch: ProformaPatchInputProps; transaction: unknown; @@ -134,6 +139,7 @@ export class ProformaUpdater implements IProformaUpdater { const paymentMethodResult = await this.deps.paymentResolver.ensurePaymentMethodById({ companyId, id: patch.paymentMethodId, + transaction, }); if (paymentMethodResult.isFailure) { @@ -141,6 +147,57 @@ export class ProformaUpdater implements IProformaUpdater { } } + if (patch.paymentTermId !== undefined) { + if ( + patch.paymentTermId.isSome() && + params.currentPaymentTermId.isSome() && + patch.paymentTermId.unwrap().equals(params.currentPaymentTermId.unwrap()) + ) { + // Mantener una referencia existente no debe revalidar actividad. + } else { + const paymentTermResult = await this.deps.paymentResolver.ensurePaymentTermById({ + companyId, + id: patch.paymentTermId, + transaction, + }); + + if (paymentTermResult.isFailure) { + return Result.fail(paymentTermResult.error); + } + } + } + + let recipientPatch = patch.recipient; + + if (patch.customerId !== undefined) { + const customerResult = await this.deps.customerServices.getCustomerById(patch.customerId, { + companyId, + transaction, + }); + + if (customerResult.isFailure) { + return Result.fail(customerResult.error); + } + + const customer = customerResult.data; + const recipientResult = ProformaRecipient.create({ + name: customer.name, + tin: customer.tin, + street: customer.address.street, + street2: customer.address.street2, + city: customer.address.city, + postalCode: customer.address.postalCode, + province: customer.address.province, + country: customer.address.country, + }); + + if (recipientResult.isFailure) { + return Result.fail(recipientResult.error); + } + + recipientPatch = Maybe.some(recipientResult.data); + } + const taxConfigResult = await this.resolveTaxConfig({ companyId, currentInvoiceDate, @@ -168,6 +225,7 @@ export class ProformaUpdater implements IProformaUpdater { if (patch.items === undefined) { const resolvedPatch: ProformaPatchProps = { ...patch, + recipient: recipientPatch, targetInvoiceSeriesCode: patch.targetInvoiceSeriesCode, }; @@ -200,6 +258,7 @@ export class ProformaUpdater implements IProformaUpdater { const resolvedPatch: ProformaPatchProps = { ...patch, + recipient: recipientPatch, targetInvoiceSeriesCode: patch.targetInvoiceSeriesCode, items: resolvedItems, }; diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.test.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.test.ts new file mode 100644 index 00000000..aa3f359a --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Maybe } from "@repo/rdx-utils"; + +import { ProformaFullSnapshotBuilder } from "./proforma-full-snapshot-builder"; + +test("detail devuelve payment_term enriquecido cuando existe", () => { + const builder = new ProformaFullSnapshotBuilder( + { + toOutput: () => [], + } as never, + { + toOutput: () => null, + } as never, + { + toOutput: () => [], + } as never + ); + + const snapshot = builder.toOutput({ + proforma: { + id: { toString: () => "11111111-1111-4111-8111-111111111111" }, + companyId: { toString: () => "22222222-2222-4222-8222-222222222222" }, + documentSeriesId: Maybe.none(), + proformaSeriesCode: Maybe.none(), + proformaNumber: Maybe.none(), + proformaReference: { toString: () => "PF-1" }, + status: { toPrimitive: () => "draft" }, + archivedAt: Maybe.none(), + targetInvoiceSeriesCode: Maybe.none(), + proformaDate: { toDateString: () => "2026-08-16" }, + operationDate: Maybe.none(), + reference: Maybe.none(), + description: Maybe.none(), + notes: Maybe.none(), + languageCode: { toString: () => "es" }, + currencyCode: { toString: () => "EUR" }, + customerId: { toString: () => "33333333-3333-4333-8333-333333333333" }, + linkedInvoiceId: Maybe.none(), + paymentMethodId: Maybe.none(), + recipient: Maybe.none(), + taxRegimeCode: Maybe.none(), + taxConfig: { + taxMode: "single", + defaultIvaCode: Maybe.none(), + usesEquivalenceSurcharge: false, + defaultRecCode: Maybe.none(), + usesRetention: false, + defaultRetentionCode: Maybe.none(), + }, + globalDiscountPercentage: { + toObjectString: () => ({ value: "0", scale: 2 }), + }, + items: [], + taxes: () => [], + totals: () => ({ + subtotalAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + itemsDiscountAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + globalDiscountAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + totalDiscountAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + taxableAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + ivaAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + recAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + retentionAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + taxesAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + totalAmount: { toObjectString: () => ({ value: "0", scale: 2 }) }, + }), + } as never, + paymentMethod: Maybe.none(), + paymentTerm: Maybe.some({ + id: "44444444-4444-4444-8444-444444444444", + name: "50/50", + description: "Dos vencimientos", + isActive: false, + dueRules: [ + { + dueDays: "0", + percentage: { value: "5000", scale: 2 }, + }, + ], + }), + taxRegime: Maybe.none(), + }); + + assert.equal(snapshot.payment_term?.id, "44444444-4444-4444-8444-444444444444"); + assert.equal(snapshot.payment_term?.name, "50/50"); + assert.equal(snapshot.payment_term?.is_active, false); + assert.equal(snapshot.payment_term?.due_rules.length, 1); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts index 82934044..ee0f3f1e 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts @@ -21,7 +21,7 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder ) {} toOutput(source: ProformaFullReadModel): ProformaFullSnapshot { - const { proforma, paymentMethod, taxRegime } = source; + const { proforma, paymentMethod, paymentTerm, taxRegime } = source; const calculationContext = { languageCode: proforma.languageCode, @@ -76,7 +76,16 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder tax_regime: maybeToNullable(taxRegime, (taxRegime) => taxRegime), - payment_term: null, + payment_term: maybeToNullable(paymentTerm, (paymentTerm) => ({ + id: paymentTerm.id, + name: paymentTerm.name, + description: paymentTerm.description, + is_active: paymentTerm.isActive, + due_rules: paymentTerm.dueRules.map((rule) => ({ + due_days: rule.dueDays, + percentage: rule.percentage, + })), + })), tax_config: { tax_mode: proforma.taxConfig.taxMode === "perLine" ? "per_line" : "single", default_iva_code: maybeToNullable(proforma.taxConfig.defaultIvaCode, (value) => value), diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.test.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.test.ts new file mode 100644 index 00000000..4997c599 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; +import type { ICompanyPublicServices } from "@erp/companies/api"; +import type { ITransactionManager } from "@erp/core/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { DuplicateProformaByIdUseCase } from "./duplicate-proforma-by-id.use-case"; + +const buildTransactionManager = (): ITransactionManager => ({ + start: async () => undefined, + getTransaction: () => undefined, + complete: async (work: (transaction: unknown) => Promise) => work(undefined), + commit: async () => undefined, + rollback: async () => undefined, +}); + +const buildUseCase = (params?: { + sourcePaymentTermId?: Maybe; + customerPaymentTermId?: Maybe; + companyDefaultPaymentTermId?: string | null; + activePaymentTermIds?: Set; +}) => { + const companyId = UniqueID.generateNewID(); + const sourceId = UniqueID.generateNewID(); + const customerId = UniqueID.generateNewID(); + const activePaymentTermIds = params?.activePaymentTermIds ?? new Set(); + let receivedPaymentTermId: Maybe | undefined; + + const paymentTermFinder: IPaymentTermPublicFinder = { + findByIdInCompany: async (params: { id: UniqueID }) => + Result.ok( + activePaymentTermIds.has(params.id.toString()) + ? Maybe.some({ + id: params.id, + companyId, + name: "Plazo", + description: Maybe.none(), + isActive: true, + isSystem: false, + }) + : Maybe.none() + ), + } as never; + + const customerServices: ICustomerPublicServices = { + findCustomerByTIN: async () => Result.fail(new Error("unused")), + createCustomer: async () => Result.fail(new Error("unused")), + getCustomerById: async () => + Result.ok({ + paymentTermId: params?.customerPaymentTermId ?? Maybe.none(), + } as never), + }; + + const companiesServices: ICompanyPublicServices = { + finder: { + findById: async () => + Result.ok( + Maybe.some({ + id: companyId.toString(), + defaultPaymentTermId: params?.companyDefaultPaymentTermId ?? null, + } as never) + ), + }, + } as never; + + const useCase = new DuplicateProformaByIdUseCase({ + creator: { + create: async (input: { props: { paymentTermId?: Maybe }; id: UniqueID }) => { + receivedPaymentTermId = input.props.paymentTermId; + return Result.ok({ id: input.id } as never); + }, + } as never, + finder: { + findProformaByIdIncludingArchived: async () => + Result.ok({ + documentSeriesId: Maybe.some(UniqueID.generateNewID().toPrimitive()), + targetInvoiceSeriesCode: Maybe.none(), + customerId, + reference: Maybe.none(), + description: Maybe.none(), + notes: Maybe.none(), + languageCode: { toPrimitive: () => "es" }, + currencyCode: { toPrimitive: () => "EUR" }, + paymentMethodId: Maybe.none(), + paymentTermId: params?.sourcePaymentTermId ?? Maybe.none(), + taxRegimeCode: Maybe.some("01"), + taxConfig: { + taxMode: "single", + defaultIvaCode: Maybe.none(), + usesEquivalenceSurcharge: false, + defaultRecCode: Maybe.none(), + usesRetention: false, + defaultRetentionCode: Maybe.none(), + }, + globalDiscountPercentage: { toPrimitive: () => ({ value: 0, scale: 2 }) }, + items: { + getAll: () => [], + }, + } as never), + findProformaById: async () => Result.ok({ id: sourceId } as never), + } as never, + fullReadModelAssembler: { + assemble: async () => Result.ok({} as never), + } as never, + fullSnapshotBuilder: { + toOutput: () => ({ id: sourceId.toPrimitive() }), + } as never, + documentSeriesServices: { + listActiveSeries: async (params: { documentType: string }) => + Result.ok([ + { + id: UniqueID.generateNewID().toPrimitive(), + code: params.documentType === "proforma" ? "PF" : "FV", + is_default: true, + }, + ]), + } as never, + companiesServices, + customerServices, + paymentTermFinder, + transactionManager: buildTransactionManager(), + }); + + return { + companyId, + sourceId, + useCase, + getReceivedPaymentTermId: () => receivedPaymentTermId, + }; +}; + +test("duplicate conserva el payment term original si sigue activo", async () => { + const paymentTermId = UniqueID.generateNewID(); + const setup = buildUseCase({ + sourcePaymentTermId: Maybe.some(paymentTermId), + activePaymentTermIds: new Set([paymentTermId.toString()]), + }); + + const result = await setup.useCase.execute({ + companyId: setup.companyId, + params: { proforma_id: setup.sourceId.toPrimitive() }, + }); + + assert.equal(result.isSuccess, true); + assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(paymentTermId), true); + assert.equal(result.data.payment_term_adaptation, null); +}); + +test("duplicate usa el payment term activo del customer si el original está inactivo", async () => { + const originalPaymentTermId = UniqueID.generateNewID(); + const customerPaymentTermId = UniqueID.generateNewID(); + const setup = buildUseCase({ + sourcePaymentTermId: Maybe.some(originalPaymentTermId), + customerPaymentTermId: Maybe.some(customerPaymentTermId), + activePaymentTermIds: new Set([customerPaymentTermId.toString()]), + }); + + const result = await setup.useCase.execute({ + companyId: setup.companyId, + params: { proforma_id: setup.sourceId.toPrimitive() }, + }); + + assert.equal(result.isSuccess, true); + assert.equal(setup.getReceivedPaymentTermId()?.unwrap().equals(customerPaymentTermId), true); + assert.deepEqual(result.data.payment_term_adaptation, { + source: "inactive_original", + resolution: "customer", + }); +}); + +test("duplicate usa el default activo de company si el original y el customer no son válidos", async () => { + const originalPaymentTermId = UniqueID.generateNewID(); + const inactiveCustomerPaymentTermId = UniqueID.generateNewID(); + const companyDefaultPaymentTermId = UniqueID.generateNewID(); + const setup = buildUseCase({ + sourcePaymentTermId: Maybe.some(originalPaymentTermId), + customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId), + companyDefaultPaymentTermId: companyDefaultPaymentTermId.toPrimitive(), + activePaymentTermIds: new Set([companyDefaultPaymentTermId.toString()]), + }); + + const result = await setup.useCase.execute({ + companyId: setup.companyId, + params: { proforma_id: setup.sourceId.toPrimitive() }, + }); + + assert.equal(result.isSuccess, true); + assert.equal( + setup.getReceivedPaymentTermId()?.unwrap().equals(companyDefaultPaymentTermId), + true + ); + assert.deepEqual(result.data.payment_term_adaptation, { + source: "inactive_original", + resolution: "company", + }); +}); + +test("duplicate crea sin payment term si no hay fallback válido", async () => { + const originalPaymentTermId = UniqueID.generateNewID(); + const inactiveCustomerPaymentTermId = UniqueID.generateNewID(); + const inactiveCompanyDefaultPaymentTermId = UniqueID.generateNewID(); + const setup = buildUseCase({ + sourcePaymentTermId: Maybe.some(originalPaymentTermId), + customerPaymentTermId: Maybe.some(inactiveCustomerPaymentTermId), + companyDefaultPaymentTermId: inactiveCompanyDefaultPaymentTermId.toPrimitive(), + activePaymentTermIds: new Set(), + }); + + const result = await setup.useCase.execute({ + companyId: setup.companyId, + params: { proforma_id: setup.sourceId.toPrimitive() }, + }); + + assert.equal(result.isSuccess, true); + assert.equal(setup.getReceivedPaymentTermId()?.isNone(), true); + assert.deepEqual(result.data.payment_term_adaptation, { + source: "inactive_original", + resolution: "none", + }); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts index a37a5640..e925a560 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/duplicate-proforma-by-id.use-case.ts @@ -1,4 +1,7 @@ +import type { IPaymentTermPublicFinder } from "@erp/catalogs/api"; +import type { ICompanyPublicServices } from "@erp/companies/api"; import type { ITransactionManager } from "@erp/core/api"; +import type { ICustomerPublicServices } from "@erp/customers/api"; import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; import { UniqueID, UtcDate } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; @@ -14,6 +17,11 @@ import type { } from "../services"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; +type DuplicatePaymentTermAdaptation = { + source: "inactive_original"; + resolution: "customer" | "company" | "none"; +} | null; + type DuplicateProformaByIdUseCaseInput = { companyId: UniqueID; params: DuplicateProformaByIdParamsRequestDTO; @@ -27,6 +35,9 @@ export class DuplicateProformaByIdUseCase { fullReadModelAssembler: IProformaFullReadModelAssembler; fullSnapshotBuilder: IProformaFullSnapshotBuilder; documentSeriesServices: DocumentSeriesPublicServicesType; + companiesServices: ICompanyPublicServices; + customerServices: ICustomerPublicServices; + paymentTermFinder: IPaymentTermPublicFinder; transactionManager: ITransactionManager; } ) {} @@ -77,6 +88,17 @@ export class DuplicateProformaByIdUseCase { return Result.fail(targetInvoiceSeriesCodeResult.error); } + const paymentTermResolutionResult = await this.resolveDuplicatePaymentTermId({ + companyId: params.companyId, + sourceCustomerId: source.customerId, + sourcePaymentTermId: source.paymentTermId, + transaction, + }); + + if (paymentTermResolutionResult.isFailure) { + return Result.fail(paymentTermResolutionResult.error); + } + const props: ProformaCreateInputProps = { companyId: params.companyId, status: InvoiceStatus.draft(), @@ -93,6 +115,7 @@ export class DuplicateProformaByIdUseCase { currencyCode: source.currencyCode, linkedInvoiceId: Maybe.none(), paymentMethodId: source.paymentMethodId, + paymentTermId: paymentTermResolutionResult.data.paymentTermId, taxRegimeCode: source.taxRegimeCode, taxConfig: { taxMode: source.taxConfig.taxMode, @@ -103,7 +126,6 @@ export class DuplicateProformaByIdUseCase { defaultRetentionCode: source.taxConfig.defaultRetentionCode, }, globalDiscountPercentage: source.globalDiscountPercentage, - archivedAt: Maybe.none(), items: source.items.getAll().map((item) => ({ description: item.description, quantity: item.quantity, @@ -148,7 +170,10 @@ export class DuplicateProformaByIdUseCase { return Result.fail(readModelResult.error); } - return Result.ok(this.deps.fullSnapshotBuilder.toOutput(readModelResult.data)); + return Result.ok({ + ...this.deps.fullSnapshotBuilder.toOutput(readModelResult.data), + payment_term_adaptation: paymentTermResolutionResult.data.adaptation, + }); } catch (error: unknown) { return Result.fail(error as Error); } @@ -229,4 +254,171 @@ export class DuplicateProformaByIdUseCase { return Result.ok(params.targetInvoiceSeriesCode); } + + private async resolveDuplicatePaymentTermId(params: { + companyId: UniqueID; + sourceCustomerId: UniqueID; + sourcePaymentTermId: Maybe; + transaction?: unknown; + }): Promise< + Result< + { + paymentTermId: Maybe | undefined; + adaptation: DuplicatePaymentTermAdaptation; + }, + Error + > + > { + if (params.sourcePaymentTermId.isNone()) { + return Result.ok({ + paymentTermId: params.sourcePaymentTermId, + adaptation: null, + }); + } + + const activeSourcePaymentTermResult = await this.resolveActivePaymentTermId({ + companyId: params.companyId, + paymentTermId: params.sourcePaymentTermId, + transaction: params.transaction, + }); + + if (activeSourcePaymentTermResult.isFailure) { + return Result.fail(activeSourcePaymentTermResult.error); + } + + if (activeSourcePaymentTermResult.data.isSome()) { + return Result.ok({ + paymentTermId: activeSourcePaymentTermResult.data, + adaptation: null, + }); + } + + const customerResult = await this.deps.customerServices.getCustomerById( + params.sourceCustomerId, + { + companyId: params.companyId, + transaction: params.transaction, + } + ); + + if (customerResult.isFailure) { + return Result.fail(customerResult.error); + } + + const activeCustomerPaymentTermResult = await this.resolveActivePaymentTermId({ + companyId: params.companyId, + paymentTermId: customerResult.data.paymentTermId, + transaction: params.transaction, + }); + + if (activeCustomerPaymentTermResult.isFailure) { + return Result.fail(activeCustomerPaymentTermResult.error); + } + + if (activeCustomerPaymentTermResult.data.isSome()) { + return Result.ok({ + paymentTermId: activeCustomerPaymentTermResult.data, + adaptation: { + source: "inactive_original", + resolution: "customer", + }, + }); + } + + const companyResult = await this.deps.companiesServices.finder.findById({ + companyId: params.companyId, + transaction: params.transaction, + }); + + if (companyResult.isFailure) { + return Result.fail(companyResult.error); + } + + if (companyResult.data.isNone()) { + return Result.fail(new Error("Company not found while duplicating proforma.")); + } + + const companyDefaultPaymentTermIdResult = this.toMaybeUniqueId( + companyResult.data.unwrap().defaultPaymentTermId + ); + + if (companyDefaultPaymentTermIdResult.isFailure) { + return Result.fail( + new Error("Company default payment term is invalid while duplicating proforma.", { + cause: companyDefaultPaymentTermIdResult.error, + }) + ); + } + + const activeCompanyDefaultPaymentTermResult = await this.resolveActivePaymentTermId({ + companyId: params.companyId, + paymentTermId: companyDefaultPaymentTermIdResult.data, + transaction: params.transaction, + }); + + if (activeCompanyDefaultPaymentTermResult.isFailure) { + return Result.fail(activeCompanyDefaultPaymentTermResult.error); + } + + if (activeCompanyDefaultPaymentTermResult.data.isSome()) { + return Result.ok({ + paymentTermId: activeCompanyDefaultPaymentTermResult.data, + adaptation: { + source: "inactive_original", + resolution: "company", + }, + }); + } + + return Result.ok({ + paymentTermId: Maybe.none(), + adaptation: { + source: "inactive_original", + resolution: "none", + }, + }); + } + + private async resolveActivePaymentTermId(params: { + companyId: UniqueID; + paymentTermId: Maybe; + transaction?: unknown; + }): Promise, Error>> { + if (params.paymentTermId.isNone()) { + return Result.ok(Maybe.none()); + } + + const paymentTermResult = await this.deps.paymentTermFinder.findByIdInCompany({ + companyId: params.companyId, + id: params.paymentTermId.unwrap(), + transaction: params.transaction, + }); + + if (paymentTermResult.isFailure) { + return Result.fail(paymentTermResult.error); + } + + if (paymentTermResult.data.isNone()) { + return Result.ok(Maybe.none()); + } + + if (!paymentTermResult.data.unwrap().isActive) { + return Result.ok(Maybe.none()); + } + + return Result.ok(params.paymentTermId); + } + + private toMaybeUniqueId(value: string | null): Result, Error> { + if (value == null) { + return Result.ok(Maybe.none()); + } + + const idResult = UniqueID.create(value); + if (idResult.isFailure) { + return Result.fail(idResult.error); + } + + return Result.ok(Maybe.some(idResult.data)); + } } diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts new file mode 100644 index 00000000..23c7fe6c --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { IssueProformaUseCase } from "./issue-proforma.use-case"; + +const buildTransactionManager = (): ITransactionManager => ({ + start: async () => undefined, + getTransaction: () => undefined, + complete: async (work: (transaction: unknown) => Promise) => work(undefined), + commit: async () => undefined, + rollback: async () => undefined, +}); + +test("issue rechaza un payment term cross-tenant y no crea factura parcial", async () => { + const companyId = UniqueID.generateNewID(); + const proformaId = UniqueID.generateNewID(); + const crossTenantPaymentTermId = UniqueID.generateNewID(); + + let createIssuedInvoiceCalls = 0; + let markAsIssuedCalls = 0; + + const useCase = new IssueProformaUseCase({ + issuedInvoiceServices: { + createIssuedInvoice: async () => { + createIssuedInvoiceCalls += 1; + return Result.ok({ id: UniqueID.generateNewID() } as never); + }, + } as never, + finder: { + findProformaById: async () => + Result.ok({ + id: proformaId, + companyId, + paymentMethodId: { isNone: () => false, unwrap: () => UniqueID.generateNewID() }, + paymentTermId: { isNone: () => false, unwrap: () => crossTenantPaymentTermId }, + } as never), + } as never, + issuer: { + issueProforma: () => Result.ok({} as never), + } as never, + issueReadModelAssembler: { + assemble: async () => + Result.fail( + new Error( + `Proforma payment term reference is inconsistent: ${crossTenantPaymentTermId.toString()}` + ) + ), + } as never, + issueResponseSnapshotBuilder: { + toOutput: () => ({ id: "unused" }), + } as never, + repository: { + markAsIssuedByIdInCompany: async () => { + markAsIssuedCalls += 1; + return Result.ok(undefined); + }, + } as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId: companyId.toPrimitive(), + proformaId: proformaId.toPrimitive(), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /payment term reference is inconsistent/i); + assert.equal(createIssuedInvoiceCalls, 0); + assert.equal(markAsIssuedCalls, 0); +}); diff --git a/modules/customer-invoices/src/api/domain/common/entities/index.ts b/modules/customer-invoices/src/api/domain/common/entities/index.ts index 66fa2ce8..e3875a04 100644 --- a/modules/customer-invoices/src/api/domain/common/entities/index.ts +++ b/modules/customer-invoices/src/api/domain/common/entities/index.ts @@ -1,2 +1,3 @@ export * from "./invoice-payment-method.entity"; +export * from "./invoice-payment-term.entity"; export * from "./invoice-tax-regime.entity"; diff --git a/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.ts b/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.ts new file mode 100644 index 00000000..d7e39d53 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/common/entities/invoice-payment-term.entity.ts @@ -0,0 +1,157 @@ +import { DomainEntity, DomainValidationError, type UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +export interface InvoicePaymentTermDueRuleSnapshot { + dueDays: string; + percentage: { + value: string; + scale: number; + }; +} + +export interface InvoicePaymentTermProps { + name: string; + description: Maybe; + dueRules: InvoicePaymentTermDueRuleSnapshot[]; +} + +export class InvoicePaymentTerm extends DomainEntity { + public static create( + props: InvoicePaymentTermProps, + id?: UniqueID + ): Result { + const name = props.name.trim(); + + if (!name) { + return Result.fail( + new DomainValidationError( + "INVALID_INVOICE_PAYMENT_TERM", + "paymentTerm.name", + "Issued invoice payment term requires a name" + ) + ); + } + + if (props.dueRules.length === 0) { + return Result.fail( + new DomainValidationError( + "INVALID_INVOICE_PAYMENT_TERM", + "paymentTerm.dueRules", + "Issued invoice payment term requires at least one due rule" + ) + ); + } + + const dueRulesResult = InvoicePaymentTerm.normalizeDueRules(props.dueRules); + + if (dueRulesResult.isFailure) { + return Result.fail(dueRulesResult.error); + } + + return Result.ok( + (() => { + const normalizedDescription = props.description.isSome() + ? props.description.unwrap().trim() + : null; + + return new InvoicePaymentTerm( + { + name, + description: + normalizedDescription != null && normalizedDescription.length > 0 + ? Maybe.some(normalizedDescription) + : Maybe.none(), + dueRules: dueRulesResult.data, + }, + id + ); + })() + ); + } + + private static normalizeDueRules( + dueRules: InvoicePaymentTermDueRuleSnapshot[] + ): Result { + const normalizedRules: InvoicePaymentTermDueRuleSnapshot[] = []; + + for (const rule of dueRules) { + const dueDays = rule.dueDays.trim(); + const percentageValue = rule.percentage.value.trim(); + + if (!/^\d+$/.test(dueDays)) { + return Result.fail( + new DomainValidationError( + "INVALID_INVOICE_PAYMENT_TERM", + "paymentTerm.dueRules.dueDays", + "Issued invoice payment term dueDays must be a numeric string" + ) + ); + } + + if (!/^-?\d+$/.test(percentageValue)) { + return Result.fail( + new DomainValidationError( + "INVALID_INVOICE_PAYMENT_TERM", + "paymentTerm.dueRules.percentage.value", + "Issued invoice payment term percentage value must be an integer string" + ) + ); + } + + if (!Number.isInteger(rule.percentage.scale) || rule.percentage.scale < 0) { + return Result.fail( + new DomainValidationError( + "INVALID_INVOICE_PAYMENT_TERM", + "paymentTerm.dueRules.percentage.scale", + "Issued invoice payment term percentage scale must be a non-negative integer" + ) + ); + } + + normalizedRules.push({ + dueDays, + percentage: { + value: percentageValue, + scale: rule.percentage.scale, + }, + }); + } + + return Result.ok(normalizedRules); + } + + public get name(): string { + return this.props.name; + } + + public get description(): Maybe { + return this.props.description; + } + + public get dueRules(): InvoicePaymentTermDueRuleSnapshot[] { + return this.props.dueRules.map((rule) => ({ + dueDays: rule.dueDays, + percentage: { + value: rule.percentage.value, + scale: rule.percentage.scale, + }, + })); + } + + public getProps(): InvoicePaymentTermProps { + return { + name: this.name, + description: this.description, + dueRules: this.dueRules, + }; + } + + public toObjectString() { + return { + id: String(this.id), + name: this.name, + description: this.description.getOrUndefined() ?? null, + dueRules: this.dueRules, + }; + } +} diff --git a/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts b/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts index 5f9b0538..ba7a7303 100644 --- a/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/issued-invoices/aggregates/issued-invoice.aggregate.ts @@ -15,6 +15,7 @@ import type { InvoiceAmount, InvoiceNumber, InvoicePaymentMethod, + InvoicePaymentTerm, InvoiceSerie, InvoiceStatus, InvoiceTaxRegime, @@ -58,6 +59,7 @@ export interface IIssuedInvoiceCreateProps { currencyCode: CurrencyCode; paymentMethod: InvoicePaymentMethod; + paymentTerm: Maybe; taxRegime: InvoiceTaxRegime; items: IIssuedInvoiceItemCreateProps[]; @@ -281,6 +283,10 @@ export class IssuedInvoice return this.props.paymentMethod; } + public get paymentTerm(): Maybe { + return this.props.paymentTerm; + } + public get taxRegime(): InvoiceTaxRegime { return this.props.taxRegime; } diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index deb1f2e3..897e2e87 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -72,6 +72,7 @@ export interface IProformaCreateProps { linkedInvoiceId: Maybe; paymentMethodId: Maybe; + paymentTermId: Maybe; taxRegimeCode: Maybe; taxConfig: ProformaTaxConfig; @@ -125,6 +126,7 @@ export interface IProforma { currencyCode: CurrencyCode; paymentMethodId: Maybe; + paymentTermId: Maybe; taxRegimeCode: Maybe; taxConfig: ProformaTaxConfig; @@ -308,6 +310,10 @@ export class Proforma extends AggregateRoot implements IP return this.props.paymentMethodId; } + public get paymentTermId(): Maybe { + return this.props.paymentTermId; + } + public get taxRegimeCode(): Maybe { return this.props.taxRegimeCode; } @@ -344,6 +350,10 @@ export class Proforma extends AggregateRoot implements IP return this.paymentMethodId.isSome(); } + public get hasPaymentTerm() { + return this.paymentTermId.isSome(); + } + public get hasTaxRegime() { return this.taxRegimeCode.isSome(); } diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.test.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.test.ts new file mode 100644 index 00000000..ba71caed --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.test.ts @@ -0,0 +1,285 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Maybe, Result } from "@repo/rdx-utils"; + +import { SequelizeIssuedInvoiceV2DomainMapper } from "./sequelize-issued-invoice-v2-domain.mapper"; + +type PaymentTermRule = { + dueDays: string; + percentage: { + value: string; + scale: number; + }; +}; + +type PaymentTermRoundTripReadResult = { + isSuccess: boolean; + isFailure: boolean; + data: Maybe<{ + name: string; + description: Maybe; + dueRules: PaymentTermRule[]; + }>; + error: Error; +}; + +const buildMapper = () => { + const mapper = new SequelizeIssuedInvoiceV2DomainMapper({} as never) as unknown as { + _itemsMapper: { + mapToPersistenceArray: () => ReturnType>; + }; + _taxesMapper: { + mapToPersistenceArray: () => ReturnType>; + }; + _recipientMapper: { + mapToPersistence: () => Record; + }; + _verifactuMapper: { + mapToPersistence: () => ReturnType>; + }; + mapToPersistence: (source: unknown) => { + isSuccess: boolean; + isFailure: boolean; + data: { + payment_term_id: string | null; + payment_term_name: string | null; + payment_term_description: string | null; + payment_term_snapshot_json: string | null; + }; + error: Error; + }; + mapPaymentTerm: (raw: unknown) => PaymentTermRoundTripReadResult; + }; + + mapper._itemsMapper = { + mapToPersistenceArray: () => Result.ok([]), + }; + mapper._taxesMapper = { + mapToPersistenceArray: () => Result.ok([]), + }; + mapper._recipientMapper = { + mapToPersistence: () => ({ + recipient_id: null, + recipient_name: null, + recipient_tin: null, + recipient_street: null, + recipient_street2: null, + recipient_city: null, + recipient_postal_code: null, + recipient_province: null, + recipient_country: null, + }), + }; + mapper._verifactuMapper = { + mapToPersistence: () => Result.ok(null), + }; + + return mapper; +}; + +const buildIssuedInvoiceSource = (params: { + paymentTermName: string; + paymentTermDescription: string | null; + dueRules: PaymentTermRule[]; +}) => ({ + id: { toPrimitive: () => "11111111-1111-4111-8111-111111111111" }, + companyId: { toPrimitive: () => "22222222-2222-4222-8222-222222222222" }, + status: { toPrimitive: () => "issued" }, + linkedProformaId: { toPrimitive: () => "33333333-3333-4333-8333-333333333333" }, + documentSeriesId: Maybe.none(), + series: { toPrimitive: () => "A", toString: () => "A" }, + invoiceNumber: { toPrimitive: () => "12", toString: () => "12" }, + invoiceReference: "A-12", + invoiceDate: { toPrimitive: () => "2026-08-17" }, + operationDate: Maybe.none(), + languageCode: { toPrimitive: () => "es-ES" }, + currencyCode: { toPrimitive: () => "EUR" }, + reference: Maybe.none(), + description: "Factura de prueba", + notes: Maybe.none(), + paymentMethod: { + toObjectString: () => ({ + id: "44444444-4444-4444-8444-444444444444", + name: "Transferencia", + }), + }, + paymentTerm: Maybe.some({ + toObjectString: () => ({ + id: "55555555-5555-4555-8555-555555555555", + name: params.paymentTermName, + description: params.paymentTermDescription, + dueRules: params.dueRules, + }), + }), + taxRegime: { + code: "general", + description: "Régimen general", + }, + fiscalClassification: Maybe.none(), + verifactuPolicy: Maybe.none(), + subtotalAmount: { value: "10000", scale: 2 }, + itemsDiscountAmount: { value: "0", scale: 2 }, + globalDiscountPercentage: { + toPrimitive: () => ({ + value: 0, + scale: 2, + }), + }, + globalDiscountAmount: { value: "0", scale: 2 }, + totalDiscountAmount: { value: "0", scale: 2 }, + taxableAmount: { value: "10000", scale: 2 }, + ivaAmount: { value: "2100", scale: 2 }, + recAmount: { value: "0", scale: 2 }, + retentionAmount: { value: "0", scale: 2 }, + taxesAmount: { value: "2100", scale: 2 }, + totalAmount: { value: "12100", scale: 2 }, + customerId: { toPrimitive: () => "66666666-6666-4666-8666-666666666666" }, + items: [], + taxes: [], + recipient: {}, + verifactu: Maybe.none(), +}); + +test("snapshot payment term hace round-trip exacto domain -> persistence -> read mapper", () => { + const mapper = buildMapper(); + const scenarios = [ + { + name: "100.00%", + description: "Pago estándar", + dueRules: [ + { + dueDays: "30", + percentage: { value: "10000", scale: 2 }, + }, + ], + }, + { + name: "50 / 50", + description: null, + dueRules: [ + { + dueDays: "0", + percentage: { value: "5000", scale: 2 }, + }, + { + dueDays: "30", + percentage: { value: "5000", scale: 2 }, + }, + ], + }, + { + name: "33.33 / 33.33 / 33.34", + description: "Tres vencimientos", + dueRules: [ + { + dueDays: "0", + percentage: { value: "3333", scale: 2 }, + }, + { + dueDays: "30", + percentage: { value: "3333", scale: 2 }, + }, + { + dueDays: "60", + percentage: { value: "3334", scale: 2 }, + }, + ], + }, + ]; + + for (const scenario of scenarios) { + const persistenceResult = mapper.mapToPersistence( + buildIssuedInvoiceSource({ + paymentTermName: scenario.name, + paymentTermDescription: scenario.description, + dueRules: scenario.dueRules, + }) as never + ); + + assert.equal(persistenceResult.isSuccess, true); + assert.equal(persistenceResult.data.payment_term_name, scenario.name); + assert.equal(persistenceResult.data.payment_term_description, scenario.description); + assert.deepEqual(JSON.parse(persistenceResult.data.payment_term_snapshot_json ?? "{}"), { + version: 1, + dueRules: scenario.dueRules, + }); + + const readResult = mapper.mapPaymentTerm(persistenceResult.data); + + assert.equal(readResult.isSuccess, true); + assert.equal(readResult.data.isSome(), true); + assert.equal(readResult.data.unwrap().name, scenario.name); + assert.equal( + readResult.data.unwrap().description.getOrUndefined() ?? null, + scenario.description + ); + assert.deepEqual(readResult.data.unwrap().dueRules, scenario.dueRules); + } +}); + +test("rehidrata el snapshot persistido histórico sin depender del catálogo vivo actual", () => { + const mapper = buildMapper(); + const persistenceResult = mapper.mapToPersistence( + buildIssuedInvoiceSource({ + paymentTermName: "30 días", + paymentTermDescription: "Pago estándar", + dueRules: [ + { + dueDays: "30", + percentage: { value: "10000", scale: 2 }, + }, + ], + }) as never + ); + + assert.equal(persistenceResult.isSuccess, true); + + const liveCatalogVersion = { + name: "30 / 60 días", + dueRules: [ + { + dueDays: "30", + percentage: { value: "5000", scale: 2 }, + }, + { + dueDays: "60", + percentage: { value: "5000", scale: 2 }, + }, + ], + }; + + assert.equal(liveCatalogVersion.name, "30 / 60 días"); + + const readResult = mapper.mapPaymentTerm(persistenceResult.data); + + assert.equal(readResult.isSuccess, true); + assert.equal(readResult.data.isSome(), true); + assert.equal(readResult.data.unwrap().name, "30 días"); + assert.deepEqual(readResult.data.unwrap().dueRules, [ + { + dueDays: "30", + percentage: { + value: "10000", + scale: 2, + }, + }, + ]); +}); + +test("rechaza snapshots persistidos inconsistentes de payment term", () => { + const mapper = buildMapper(); + + const result = mapper.mapPaymentTerm({ + payment_term_id: "11111111-1111-4111-8111-111111111111", + payment_term_name: "30 días", + payment_term_description: null, + payment_term_snapshot_json: JSON.stringify({ + version: 2, + dueRules: [], + }), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /version/i); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts index a1f98e3f..4383437b 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/mappers/domain/sequelize-issued-invoice-v2-domain.mapper.ts @@ -20,6 +20,7 @@ import { InvoiceAmount, InvoiceNumber, InvoicePaymentMethod, + InvoicePaymentTerm, InvoiceSerie, InvoiceStatus, InvoiceTaxRegime, @@ -121,6 +122,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper< "notes", errors ); + const paymentTerm = extractOrPushError(this.mapPaymentTerm(raw), "payment_term", errors); let paymentMethod: InvoicePaymentMethod | undefined; if (!isNullishOrEmpty(raw.payment_method_id)) { @@ -241,6 +243,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper< reference, description, notes, + paymentTerm, languageCode, currencyCode, paymentMethod, @@ -374,6 +377,7 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper< reference: attributes.reference!, description: attributes.description!, notes: attributes.notes!, + paymentTerm: attributes.paymentTerm ?? Maybe.none(), languageCode: attributes.languageCode!, currencyCode: attributes.currencyCode!, subtotalAmount: attributes.subtotalAmount!, @@ -459,10 +463,21 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper< payment_method_id: source.paymentMethod.toObjectString().id, payment_method_name: source.paymentMethod.toObjectString().name, payment_method_description: source.paymentMethod.toObjectString().name, - payment_term_id: null, - payment_term_name: null, - payment_term_description: null, - payment_term_snapshot_json: null, + payment_term_id: source.paymentTerm.isSome() + ? source.paymentTerm.unwrap().toObjectString().id + : null, + payment_term_name: source.paymentTerm.isSome() + ? source.paymentTerm.unwrap().toObjectString().name + : null, + payment_term_description: source.paymentTerm.isSome() + ? source.paymentTerm.unwrap().toObjectString().description + : null, + payment_term_snapshot_json: source.paymentTerm.isSome() + ? JSON.stringify({ + version: 1, + dueRules: source.paymentTerm.unwrap().toObjectString().dueRules, + }) + : null, tax_regime_code: source.taxRegime.code, tax_regime_description: source.taxRegime.description, operation_qualification_code: source.fiscalClassification.isSome() @@ -511,4 +526,142 @@ export class SequelizeIssuedInvoiceV2DomainMapper extends SequelizeDomainMapper< return Result.ok(persistence); } + + private mapPaymentTerm(raw: IssuedInvoiceModel): Result, Error> { + const hasAnyPaymentTermData = + !isNullishOrEmpty(raw.payment_term_id) || + !isNullishOrEmpty(raw.payment_term_name) || + !isNullishOrEmpty(raw.payment_term_description) || + !isNullishOrEmpty(raw.payment_term_snapshot_json); + + if (!hasAnyPaymentTermData) { + return Result.ok(Maybe.none()); + } + + if (isNullishOrEmpty(raw.payment_term_id)) { + return Result.fail( + new DomainValidationError( + "INVALID_ISSUED_INVOICE_PAYMENT_TERM", + "payment_term_id", + "Issued invoice payment term snapshot requires payment_term_id" + ) + ); + } + + if (isNullishOrEmpty(raw.payment_term_name)) { + return Result.fail( + new DomainValidationError( + "INVALID_ISSUED_INVOICE_PAYMENT_TERM", + "payment_term_name", + "Issued invoice payment term snapshot requires payment_term_name" + ) + ); + } + + if (isNullishOrEmpty(raw.payment_term_snapshot_json)) { + return Result.fail( + new DomainValidationError( + "INVALID_ISSUED_INVOICE_PAYMENT_TERM", + "payment_term_snapshot_json", + "Issued invoice payment term snapshot requires payment_term_snapshot_json" + ) + ); + } + + const paymentTermIdResult = UniqueID.create(String(raw.payment_term_id)); + + if (paymentTermIdResult.isFailure) { + return Result.fail(paymentTermIdResult.error); + } + + const parsedSnapshotResult = this.parsePaymentTermSnapshotJson( + String(raw.payment_term_snapshot_json) + ); + + if (parsedSnapshotResult.isFailure) { + return Result.fail(parsedSnapshotResult.error); + } + + const paymentTermResult = InvoicePaymentTerm.create( + { + name: String(raw.payment_term_name), + description: isNullishOrEmpty(raw.payment_term_description) + ? Maybe.none() + : Maybe.some(String(raw.payment_term_description)), + dueRules: parsedSnapshotResult.data.dueRules, + }, + paymentTermIdResult.data + ); + + if (paymentTermResult.isFailure) { + return Result.fail(paymentTermResult.error); + } + + return Result.ok(Maybe.some(paymentTermResult.data)); + } + + private parsePaymentTermSnapshotJson(source: string): Result<{ + version: 1; + dueRules: Array<{ + dueDays: string; + percentage: { + value: string; + scale: number; + }; + }>; + }, Error> { + try { + const parsed = JSON.parse(source) as { + version?: unknown; + dueRules?: Array<{ + dueDays?: unknown; + percentage?: { + value?: unknown; + scale?: unknown; + }; + }>; + }; + + if (parsed.version !== 1) { + return Result.fail( + new DomainValidationError( + "INVALID_ISSUED_INVOICE_PAYMENT_TERM", + "payment_term_snapshot_json.version", + "Issued invoice payment term snapshot version is invalid" + ) + ); + } + + if (!Array.isArray(parsed.dueRules) || parsed.dueRules.length === 0) { + return Result.fail( + new DomainValidationError( + "INVALID_ISSUED_INVOICE_PAYMENT_TERM", + "payment_term_snapshot_json.dueRules", + "Issued invoice payment term snapshot requires dueRules" + ) + ); + } + + return Result.ok({ + version: 1, + dueRules: parsed.dueRules.map((rule) => ({ + dueDays: String(rule.dueDays ?? ""), + percentage: { + value: String(rule.percentage?.value ?? ""), + scale: Number(rule.percentage?.scale ?? Number.NaN), + }, + })), + }); + } catch (error: unknown) { + return Result.fail( + new DomainValidationError( + "INVALID_ISSUED_INVOICE_PAYMENT_TERM", + "payment_term_snapshot_json", + error instanceof Error + ? error.message + : "Issued invoice payment term snapshot JSON is invalid" + ) + ); + } + } } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts index 33e108eb..94c49a77 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proforma-public-services.ts @@ -1,3 +1,4 @@ +import type { ICompanyPublicServices } from "@erp/companies/api"; import type { SetupParams } from "@erp/core/api"; import type { ICustomerPublicServices } from "@erp/customers/api"; import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"; @@ -52,6 +53,7 @@ export function buildProformaPublicServices( const { database } = params; const catalogs = resolveProformaCatalogsDeps(params); + const companiesServices = params.getService("companies:general"); const customerServices = params.getService("customers:general"); const documentSeriesServices = params.getService("document-series:general"); @@ -76,6 +78,7 @@ export function buildProformaPublicServices( const readModelAssemblers = buildProformaReadModelAssemblers({ paymentMethodFinder: catalogs.paymentMethod.finder, + paymentTermFinder: catalogs.paymentTerm.finder, taxRegimeFinder: catalogs.taxRegime.finder, }); @@ -94,6 +97,7 @@ export function buildProformaPublicServices( paymentResolver: catalogResolvers.paymentResolver, proformaSeriesValidator, targetInvoiceSeriesValidator, + companiesServices, customerServices, }); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index ebc95a9a..0998cc59 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -110,13 +110,12 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter const readModelAssemblers = buildProformaReadModelAssemblers({ paymentMethodFinder: catalogs.paymentMethod.finder, + paymentTermFinder: catalogs.paymentTerm.finder, taxRegimeFinder: catalogs.taxRegime.finder, }); const finder = buildProformaFinder(repository); - const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator( - documentSeriesServices - ); + const proformaSeriesValidator = new DocumentSeriesProformaSeriesValidator(documentSeriesServices); const targetInvoiceSeriesValidator = new DocumentSeriesProformaTargetInvoiceSeriesValidator( documentSeriesServices ); @@ -128,6 +127,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter repository, proformaSeriesValidator, targetInvoiceSeriesValidator, + companiesServices, customerServices, }); @@ -136,6 +136,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter taxResolver: catalogResolvers.taxResolver, paymentResolver: catalogResolvers.paymentResolver, targetInvoiceSeriesValidator, + customerServices, }); const deleter = buildProformaDeleter({ repository, @@ -156,6 +157,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter const issueReadModelAssembler = buildProformaIssueReadModelAssembler({ paymentMethodFinder: catalogs.paymentMethod.finder, + paymentTermFinder: catalogs.paymentTerm.finder, taxRegimeFinder: catalogs.taxRegime.finder, customerServices, companyVerifactuProfileRepository, @@ -200,6 +202,9 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, documentSeriesServices, + companiesServices, + customerServices, + paymentTermFinder: catalogs.paymentTerm.finder, transactionManager, }), diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts index 492b9793..c4940091 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts @@ -123,6 +123,11 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< "payment_method_id", errors ); + const paymentTermId = extractOrPushError( + maybeFromNullableResult(raw.payment_term_id, (value) => UniqueID.create(String(value))), + "payment_term_id", + errors + ); const taxRegimeCode = extractOrPushError( maybeFromNullableResult(raw.tax_regime_code, (value) => Result.ok(String(value))), "tax_regime_code", @@ -184,6 +189,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< languageCode, currencyCode, paymentMethodId, + paymentTermId, taxRegimeCode, taxConfig, globalDiscountPercentage, @@ -241,6 +247,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< currencyCode: attributes.currencyCode!, globalDiscountPercentage: attributes.globalDiscountPercentage!, paymentMethodId: attributes.paymentMethodId!, + paymentTermId: attributes.paymentTermId!, taxRegimeCode: attributes.taxRegimeCode!, taxConfig: attributes.taxConfig!, linkedInvoiceId: attributes.linkedInvoiceId!, @@ -310,7 +317,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< description: maybeToNullable(source.description, (description) => description), notes: maybeToNullable(source.notes, (v) => v.toPrimitive()), payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()), - payment_term_id: null, + payment_term_id: maybeToNullable(source.paymentTermId, (value) => value.toPrimitive()), tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value), tax_mode: source.taxConfig.taxMode === "perLine" ? "per_line" : "single", default_iva_code: maybeToNullable(source.taxConfig.defaultIvaCode, (value) => value), diff --git a/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts index 6066dd63..9abf9167 100644 --- a/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/issued-invoices/get-issued-invoice-by-id.response.dto.ts @@ -11,6 +11,7 @@ import { z } from "zod/v4"; import { IssuedInvoiceFiscalClassificationSchema, IssuedInvoiceItemDetailSchema, + IssuedInvoicePaymentTermSchema, IssuedInvoiceRecipientSummarySchema, IssuedInvoiceStatusSchema, PaymentMethodRefSchema, @@ -49,6 +50,7 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({ taxes: z.array(TaxesBreakdownSchema), payment_method: PaymentMethodRefSchema.nullable(), + payment_term: IssuedInvoicePaymentTermSchema.nullable(), tax_regime: TaxRegimeRefSchema, fiscal_classification: IssuedInvoiceFiscalClassificationSchema.nullable(), diff --git a/modules/customer-invoices/src/common/dto/response/proformas/duplicate-proforma-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/duplicate-proforma-by-id.response.dto.ts index b897d230..6c3d0679 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/duplicate-proforma-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/duplicate-proforma-by-id.response.dto.ts @@ -1,7 +1,21 @@ +import { z } from "zod/v4"; + import { type GetProformaByIdResponseDTO, GetProformaByIdResponseSchema, } from "./get-proforma-by-id.response.dto"; -export const DuplicateProformaByIdResponseSchema = GetProformaByIdResponseSchema; -export type DuplicateProformaByIdResponseDTO = GetProformaByIdResponseDTO; +export const DuplicateProformaPaymentTermAdaptationSchema = z.object({ + source: z.literal("inactive_original"), + resolution: z.enum(["customer", "company", "none"]), +}); + +export const DuplicateProformaByIdResponseSchema = GetProformaByIdResponseSchema.extend({ + payment_term_adaptation: DuplicateProformaPaymentTermAdaptationSchema.nullable(), +}); +export type DuplicateProformaByIdResponseDTO = Omit< + GetProformaByIdResponseDTO, + "payment_term_adaptation" +> & { + payment_term_adaptation: z.infer | null; +}; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts index 31b98c82..3478fad5 100644 --- a/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/index.ts @@ -1,5 +1,6 @@ export * from "./issued-invoice-fiscal-classification.dto"; export * from "./issued-invoice-item-detail.dto"; +export * from "./issued-invoice-payment-term.dto"; export * from "./issued-invoice-recipient-summary.dto"; export * from "./issued-invoice-status.dto"; export * from "./issued-invoice-summary.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-payment-term.dto.ts b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-payment-term.dto.ts new file mode 100644 index 00000000..4b0dfca4 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/issued-invoices/issued-invoice-payment-term.dto.ts @@ -0,0 +1,11 @@ +import { PaymentTermDueRuleSchema } from "@erp/catalogs/common"; +import { z } from "zod/v4"; + +export const IssuedInvoicePaymentTermSchema = z.object({ + id: z.uuid(), + name: z.string(), + description: z.string().nullable(), + due_rules: z.array(PaymentTermDueRuleSchema), +}); + +export type IssuedInvoicePaymentTermDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/payment-term-ref.dto.ts b/modules/customer-invoices/src/common/dto/shared/payment-term-ref.dto.ts index 3fbe7b07..bd28dcc4 100644 --- a/modules/customer-invoices/src/common/dto/shared/payment-term-ref.dto.ts +++ b/modules/customer-invoices/src/common/dto/shared/payment-term-ref.dto.ts @@ -1,8 +1,12 @@ +import { PaymentTermDueRuleSchema } from "@erp/catalogs/common"; import { z } from "zod/v4"; export const PaymentTermRefSchema = z.object({ id: z.uuid(), + name: z.string(), description: z.string(), + is_active: z.boolean(), + due_rules: z.array(PaymentTermDueRuleSchema), }); export type PaymentTermRefDTO = z.infer; diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 3a4e778e..a54f52b0 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -197,6 +197,8 @@ "success_title": "Proforma duplicated", "success_description": "The proforma was duplicated successfully.", "success_with_reference": "Proforma duplicated as {{reference}}.", + "payment_term_replaced_with_term": "The proforma was duplicated. The original payment term was inactive and has been replaced with \"{{name}}\".", + "payment_term_replaced_without_term": "The proforma was duplicated. The original payment term was inactive and the new proforma was created without a payment term.", "error_title": "The proforma could not be duplicated", "error_not_found_message": "The proforma no longer exists or is not available.", "error_unknown_message": "An unexpected error occurred while duplicating the proforma." @@ -513,6 +515,42 @@ "title": "Unable to load the proforma", "description": "The response does not contain a valid proforma." } + }, + "customer_change": { + "dialog": { + "compatible_title": "Change customer", + "incompatible_title": "Customer cannot be changed", + "loading": "Checking customer compatibility...", + "error": "The selected customer information could not be loaded.", + "incompatible_description": "The selected customer is not compatible with this proforma.", + "changes_title": "The proforma will be updated with these changes:", + "fiscal_title": "The tax configuration will also be adapted and taxes and totals will be recalculated.", + "preserved_title": "These values will be preserved because they were changed manually:", + "cancel": "Cancel", + "applying": "Applying changes...", + "confirm": "Confirm change" + }, + "blockers": { + "currency": "Currency", + "language": "Language" + }, + "fields": { + "recipient": "Recipient", + "paymentTermId": "Payment term", + "paymentMethodId": "Payment method", + "taxRegimeCode": "Tax regime", + "usesEquivalenceSurcharge": "Equivalence surcharge", + "usesRetention": "Withholding" + }, + "values": { + "empty": "No value", + "enabled": "Yes", + "disabled": "No" + }, + "feedback": { + "error_title": "Customer could not be changed", + "apply_error": "The proforma could not be adapted to the new customer." + } } }, "delete": { @@ -682,6 +720,7 @@ "customer_tin": "TIN", "address": "Address", "payment_method": "Payment method", + "payment_term": "Payment term", "language": "Language", "internal_reference": "Internal reference", "tax_regime": "Tax regime", @@ -707,6 +746,26 @@ "unknown_tax_code": "Tax" } } + }, + "create": { + "initial_info": { + "title": "Initial information", + "proforma_date": { + "label": "Date", + "placeholder": "Select a date" + }, + "proforma_series": { + "label": "This proforma series", + "loading": "Loading series...", + "placeholder": "Select a series", + "empty": "No series" + }, + "invoice_series": { + "label": "Invoice series", + "loading": "Loading series...", + "placeholder": "Automatic" + } + } } }, "form_groups": { @@ -799,6 +858,13 @@ "placeholder": "Additional notes about the proforma", "description": "Additional notes that can be included in the proforma" }, + "payment_term": { + "label": "Payment term", + "placeholder": "Select a payment term", + "loading": "Loading payment terms...", + "clear_label": "No payment term", + "inactive": "Inactive" + }, "item": { "quantity": { "label": "Quantity", diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 869b09fb..d978aeee 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -198,6 +198,8 @@ "success_title": "Proforma duplicada", "success_description": "La proforma se ha duplicado correctamente.", "success_with_reference": "Proforma duplicada como {{reference}}.", + "payment_term_replaced_with_term": "La proforma se ha duplicado. El plazo de pago original estaba inactivo y se ha sustituido por «{{name}}».", + "payment_term_replaced_without_term": "La proforma se ha duplicado. El plazo de pago original estaba inactivo y la nueva proforma se ha creado sin plazo de pago.", "error_title": "No se pudo duplicar la proforma", "error_not_found_message": "La proforma ya no existe o no está disponible.", "error_unknown_message": "Ha ocurrido un error inesperado al duplicar la proforma." @@ -515,6 +517,42 @@ "title": "No se pudo cargar la proforma", "description": "La respuesta recibida no contiene una proforma válida." } + }, + "customer_change": { + "dialog": { + "compatible_title": "Cambiar cliente", + "incompatible_title": "No es posible cambiar de cliente", + "loading": "Comprobando compatibilidad del cliente...", + "error": "No se pudo cargar la información del cliente seleccionado.", + "incompatible_description": "El cliente seleccionado no es compatible con esta proforma.", + "changes_title": "La proforma se actualizará con estos cambios:", + "fiscal_title": "También se adaptará la configuración fiscal y se recalcularán impuestos y totales.", + "preserved_title": "Se conservarán estos valores porque fueron modificados manualmente:", + "cancel": "Cancelar", + "applying": "Aplicando cambios...", + "confirm": "Confirmar cambio" + }, + "blockers": { + "currency": "Moneda", + "language": "Idioma" + }, + "fields": { + "recipient": "Destinatario", + "paymentTermId": "Plazo de pago", + "paymentMethodId": "Método de pago", + "taxRegimeCode": "Régimen fiscal", + "usesEquivalenceSurcharge": "Recargo de equivalencia", + "usesRetention": "Retención" + }, + "values": { + "empty": "Sin valor", + "enabled": "Sí", + "disabled": "No" + }, + "feedback": { + "error_title": "No se pudo cambiar el cliente", + "apply_error": "No se pudo adaptar la proforma al nuevo cliente." + } } }, "delete": { @@ -684,6 +722,7 @@ "customer_tin": "NIF", "address": "Dirección", "payment_method": "Método de pago", + "payment_term": "Plazo de pago", "language": "Idioma", "internal_reference": "Referencia interna", "tax_regime": "Régimen fiscal", @@ -709,6 +748,26 @@ "unknown_tax_code": "Impuesto" } } + }, + "create": { + "initial_info": { + "title": "Información inicial", + "proforma_date": { + "label": "Fecha", + "placeholder": "Selecciona una fecha" + }, + "proforma_series": { + "label": "Serie de esta proforma", + "loading": "Cargando series...", + "placeholder": "Selecciona una serie", + "empty": "Sin serie" + }, + "invoice_series": { + "label": "Serie de la factura", + "loading": "Cargando series...", + "placeholder": "Automática" + } + } } }, "form_groups": { @@ -792,6 +851,13 @@ "placeholder": "Notas adicionales sobre la proforma", "description": "Notas adicionales que se pueden incluir en la proforma" }, + "payment_term": { + "label": "Plazo de pago", + "placeholder": "Selecciona un plazo de pago", + "loading": "Cargando plazos...", + "clear_label": "Sin plazo de pago", + "inactive": "Inactivo" + }, "item": { "quantity": { "label": "Cantidad", diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts b/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts index bc8a2980..0f3b9039 100644 --- a/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts +++ b/modules/customer-invoices/src/web/issued-invoices/detail/entities/issued-invoice-detail-view-models.entity.ts @@ -65,6 +65,7 @@ export interface IssuedInvoiceDetailCustomerViewModel { export interface IssuedInvoiceDetailCommercialViewModel { paymentMethodLabel: string | null; + paymentTermLabel: string | null; languageLabel: string; internalReferenceLabel: string | null; } diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx index 1e370481..15722db2 100644 --- a/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/detail/ui/blocks/issued-invoice-general-panel.tsx @@ -62,6 +62,11 @@ export const IssuedInvoiceGeneralPanel = ({ value={general.commercial.paymentMethodLabel ?? EMPTY_VALUE} valueClassName="sm:text-right" /> + ({ paymentMethodId: null, paymentMethodName: null, paymentMethodDescription: null, + paymentTerm: { + id: "88888888-8888-4888-8888-888888888888", + name: "30 días", + description: "Pago estándar", + dueRules: [ + { + dueDays: "30", + percentage: { + value: "10000", + scale: 2, + }, + }, + ], + }, taxRegimeCode: "general", taxRegimeDescription: null, subtotalAmount: 150, @@ -177,6 +191,10 @@ test("construye el view model general con resumen fiscal, unidades y enlace a pr assert.equal(viewModel.summary.totalUnitsLabel, "3.5"); assert.equal(viewModel.customer.addressLines.length, 3); assert.equal(viewModel.commercial.paymentMethodLabel, null); + assert.equal( + viewModel.commercial.paymentTermLabel, + "30 días — Pago estándar — 100.00% a 30 días" + ); assert.equal(viewModel.fiscal.taxRegimeLabel, "general"); assert.equal(viewModel.fiscal.usesRec, true); assert.equal(viewModel.fiscal.usesRetention, true); diff --git a/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts index ec919327..0a7e908c 100644 --- a/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts +++ b/modules/customer-invoices/src/web/issued-invoices/detail/utils/build-issued-invoice-detail-view-models.ts @@ -1,3 +1,4 @@ +import { PercentageDTOHelper } from "@erp/core"; import { isApiClientError } from "@erp/core/client"; import { DateHelper, MoneyHelper, NumberHelper } from "@repo/rdx-utils"; @@ -16,9 +17,10 @@ import type { IssuedInvoiceDetailVerifactuViewModel, } from "../entities"; -export interface IssuedInvoiceDetailTranslate { - (key: string, params?: Record): string; -} +export type IssuedInvoiceDetailTranslate = ( + key: string, + params?: Record +) => string; export interface BuildIssuedInvoiceDetailViewModelsContext { translate: IssuedInvoiceDetailTranslate; @@ -49,7 +51,9 @@ const formatDate = (value: string | null) => { const buildLanguageLabel = (languageCode: string) => { try { - return new Intl.DisplayNames([languageCode], { type: "language" }).of(languageCode) ?? languageCode; + return ( + new Intl.DisplayNames([languageCode], { type: "language" }).of(languageCode) ?? languageCode + ); } catch { return languageCode; } @@ -87,12 +91,40 @@ const buildTaxLabel = ( translate: IssuedInvoiceDetailTranslate ): string => { const percentage = - item.ivaPercentage !== null ? `${NumberHelper.formatNumber(item.ivaPercentage, 2)}%` : null; + item.ivaPercentage === null ? null : `${NumberHelper.formatNumber(item.ivaPercentage, 2)}%`; const code = item.ivaCode ?? translate("pages.issued_invoices.detail.fiscal.unknown_tax_code"); return percentage ? `${code} · ${percentage}` : code; }; +const buildPaymentTermLabel = (issuedInvoice: IssuedInvoice): string | null => { + if (issuedInvoice.paymentTerm == null) { + return null; + } + + const dueRulesSummary = issuedInvoice.paymentTerm.dueRules + .map((rule) => { + const percentageLabel = NumberHelper.formatNumber( + PercentageDTOHelper.toNumber({ + value: rule.percentage.value, + scale: String(rule.percentage.scale), + }), + rule.percentage.scale + ); + + return `${percentageLabel}% a ${rule.dueDays} días`; + }) + .join(" / "); + + const segments = [ + issuedInvoice.paymentTerm.name, + issuedInvoice.paymentTerm.description, + dueRulesSummary.length > 0 ? dueRulesSummary : null, + ].filter(Boolean); + + return segments.length > 0 ? segments.join(" — ") : null; +}; + const buildPageErrorCopy = ( variant: IssuedInvoiceDetailPageErrorViewModel["variant"], translate: IssuedInvoiceDetailTranslate @@ -142,7 +174,8 @@ export const buildIssuedInvoiceDetailPageErrorViewModel = ( variant, title: copy.title, description: copy.description, - primaryAction: variant === "notFound" || variant === "forbidden" ? undefined : context.primaryAction, + primaryAction: + variant === "notFound" || variant === "forbidden" ? undefined : context.primaryAction, secondaryAction: context.secondaryAction, reference: isApiClientError(error) ? error.correlationId : undefined, }; @@ -156,7 +189,9 @@ export const buildIssuedInvoiceDetailPageInconsistentViewModel = ( return { variant: "unknown", title: copy.title, - description: context.translate("pages.issued_invoices.detail.page_error.inconsistent.description"), + description: context.translate( + "pages.issued_invoices.detail.page_error.inconsistent.description" + ), primaryAction: context.primaryAction, secondaryAction: context.secondaryAction, }; @@ -173,9 +208,12 @@ export const buildIssuedInvoiceDetailHeaderViewModel = ( documentReference: issuedInvoice.invoiceReference, customerName: issuedInvoice.recipient.name, status: issuedInvoice.status, - statusLabel: context.translate(`catalog.issued_invoices.document_status.${issuedInvoice.status}.label`, { - defaultValue: issuedInvoice.status, - }), + statusLabel: context.translate( + `catalog.issued_invoices.document_status.${issuedInvoice.status}.label`, + { + defaultValue: issuedInvoice.status, + } + ), invoiceDateLabel: DateHelper.format(issuedInvoice.invoiceDate), }; }; @@ -211,18 +249,16 @@ export const buildIssuedInvoiceDetailGeneralViewModel = ( taxableAmountLabel: formatMoney(issuedInvoice, tax.taxableAmount), taxAmountLabel: formatMoney(issuedInvoice, tax.ivaAmount), recLabel: - tax.recPercentage !== null - ? `${tax.recCode ?? context.translate("pages.issued_invoices.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.recPercentage, 2)}%` - : null, - recAmountLabel: tax.recPercentage !== null ? formatMoney(issuedInvoice, tax.recAmount) : null, + tax.recPercentage === null + ? null + : `${tax.recCode ?? context.translate("pages.issued_invoices.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.recPercentage, 2)}%`, + recAmountLabel: tax.recPercentage === null ? null : formatMoney(issuedInvoice, tax.recAmount), retentionLabel: - tax.retentionPercentage !== null - ? `${tax.retentionCode ?? context.translate("pages.issued_invoices.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.retentionPercentage, 2)}%` - : null, + tax.retentionPercentage === null + ? null + : `${tax.retentionCode ?? context.translate("pages.issued_invoices.detail.fiscal.unknown_tax_code")} · ${NumberHelper.formatNumber(tax.retentionPercentage, 2)}%`, retentionAmountLabel: - tax.retentionPercentage !== null - ? formatMoney(issuedInvoice, tax.retentionAmount) - : null, + tax.retentionPercentage === null ? null : formatMoney(issuedInvoice, tax.retentionAmount), }) ); @@ -232,9 +268,12 @@ export const buildIssuedInvoiceDetailGeneralViewModel = ( seriesLabel: issuedInvoice.series, invoiceDateLabel: DateHelper.format(issuedInvoice.invoiceDate), operationDateLabel: formatDate(issuedInvoice.operationDate), - statusLabel: context.translate(`catalog.issued_invoices.document_status.${issuedInvoice.status}.label`, { - defaultValue: issuedInvoice.status, - }), + statusLabel: context.translate( + `catalog.issued_invoices.document_status.${issuedInvoice.status}.label`, + { + defaultValue: issuedInvoice.status, + } + ), lineCount: issuedInvoice.items.length, totalUnitsLabel: buildTotalUnitsLabel(issuedInvoice), }; @@ -247,6 +286,7 @@ export const buildIssuedInvoiceDetailGeneralViewModel = ( const commercial: IssuedInvoiceDetailCommercialViewModel = { paymentMethodLabel: issuedInvoice.paymentMethodName, + paymentTermLabel: buildPaymentTermLabel(issuedInvoice), languageLabel: buildLanguageLabel(issuedInvoice.languageCode), internalReferenceLabel: issuedInvoice.reference, }; diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts index 7805f694..71ccfc1d 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.test.ts @@ -55,6 +55,17 @@ const buildDto = (): GetIssuedInvoiceByIdResponseDTO => ({ name: "Transferencia", description: "Transferencia bancaria", }, + payment_term: { + id: "99999999-9999-4999-8999-999999999999", + name: "30 días", + description: "Pago aplazado", + due_rules: [ + { + due_days: "30", + percentage: PercentageDTOHelper.fromNumber(100), + }, + ], + }, tax_regime: { code: "general", description: "Régimen general", @@ -112,6 +123,8 @@ test("adapta el DTO de detalle a una entidad con nullables y valores numéricos assert.equal(invoice.recipient.tin, null); assert.equal(invoice.recipient.street, null); assert.equal(invoice.paymentMethodName, "Transferencia"); + assert.equal(invoice.paymentTerm?.name, "30 días"); + assert.equal(invoice.paymentTerm?.dueRules[0]?.dueDays, "30"); assert.equal(invoice.globalDiscountPercentage, 10); assert.equal(invoice.taxableAmount, 85); assert.equal(invoice.totalAmount, 91); diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts index cad674c1..01ba42f7 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/adapters/get-issued-invoice-by-id.adapter.ts @@ -5,12 +5,13 @@ import type { GetIssuedInvoiceByIdResult } from "../api"; import type { IssuedInvoice, IssuedInvoiceItem, + IssuedInvoicePaymentTermDetail, IssuedInvoiceRecipientDetail, IssuedInvoiceStatus, IssuedInvoiceTaxSummary, IssuedInvoiceVerifactuRecord, + VerifactuRecordStatus, } from "../entities"; -import type { VerifactuRecordStatus } from "../entities"; export const GetIssuedInvoiceByIdAdapter = { fromDto(dto: GetIssuedInvoiceByIdResult): IssuedInvoice { @@ -37,6 +38,7 @@ export const GetIssuedInvoiceByIdAdapter = { paymentMethodId: dto.payment_method?.id ?? null, paymentMethodName: dto.payment_method?.name ?? null, paymentMethodDescription: dto.payment_method?.description ?? null, + paymentTerm: mapPaymentTerm(dto.payment_term), taxRegimeCode: dto.tax_regime.code, taxRegimeDescription: dto.tax_regime.description, subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount), @@ -116,6 +118,27 @@ const mapItem = (dto: GetIssuedInvoiceByIdResponseDTO["items"][number]): IssuedI }; }; +const mapPaymentTerm = ( + dto: GetIssuedInvoiceByIdResponseDTO["payment_term"] +): IssuedInvoicePaymentTermDetail | null => { + if (dto == null) { + return null; + } + + return { + id: dto.id, + name: dto.name, + description: dto.description, + dueRules: dto.due_rules.map((rule) => ({ + dueDays: rule.due_days, + percentage: { + value: rule.percentage.value, + scale: Number(rule.percentage.scale), + }, + })), + }; +}; + const mapVerifactu = ( dto: GetIssuedInvoiceByIdResponseDTO["verifactu"] ): IssuedInvoiceVerifactuRecord => { diff --git a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts index 0765b83a..2bd7d164 100644 --- a/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts +++ b/modules/customer-invoices/src/web/issued-invoices/shared/entities/issued-invoice.entity.ts @@ -59,6 +59,21 @@ export interface IssuedInvoiceVerifactuRecord { qrCode: string | null; } +export interface IssuedInvoicePaymentTermDueRule { + dueDays: string; + percentage: { + value: string; + scale: number; + }; +} + +export interface IssuedInvoicePaymentTermDetail { + id: string; + name: string; + description: string | null; + dueRules: IssuedInvoicePaymentTermDueRule[]; +} + /** * Snapshot completo de una factura emitida para pantallas de lectura. */ @@ -85,6 +100,7 @@ export interface IssuedInvoice { paymentMethodId: string | null; paymentMethodName: string | null; paymentMethodDescription: string | null; + paymentTerm: IssuedInvoicePaymentTermDetail | null; taxRegimeCode: string; taxRegimeDescription: string | null; subtotalAmount: number; diff --git a/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.test.ts b/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.test.ts new file mode 100644 index 00000000..8862ac5e --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ProformaCreateForm } from "../entities"; + +import { buildCreateProformaParams } from "./build-create-proforma-params"; + +const buildFormData = (value: Partial = {}): ProformaCreateForm => ({ + customerId: "customer-1", + proformaDate: "2026-08-17", + proformaSeriesCode: "PF", + targetInvoiceSeriesCode: "", + languageCode: "es", + currencyCode: "EUR", + paymentTermId: null, + ...value, +}); + +test("incluye payment_term_id cuando el formulario tiene un plazo seleccionado", () => { + const result = buildCreateProformaParams( + buildFormData({ paymentTermId: "term-30-days" }), + "proforma-1" + ); + + assert.equal(result.data.payment_term_id, "term-30-days"); +}); + +test("envía payment_term_id null cuando el usuario limpia el plazo", () => { + const result = buildCreateProformaParams(buildFormData({ paymentTermId: null }), "proforma-1"); + + assert.equal(result.data.payment_term_id, null); +}); diff --git a/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.ts b/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.ts index c7fb5b52..29b420da 100644 --- a/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.ts +++ b/modules/customer-invoices/src/web/proformas/create/adapters/build-create-proforma-params.ts @@ -20,7 +20,7 @@ export const buildCreateProformaParams = ( currency_code: formData.currencyCode, global_discount_percentage: PercentageDTOHelper.fromNumber(0, 2), payment_method_id: null, - payment_term_id: null, + payment_term_id: formData.paymentTermId, tax_regime_code: null, }, }; diff --git a/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts index 21b7dba8..215574f5 100644 --- a/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/create/controllers/use-create-proforma-controller.ts @@ -1,6 +1,10 @@ +import { usePaymentTermListQuery } from "@erp/catalogs/client/payment-terms"; +import type { CompanyResponseDTO } from "@erp/companies/common"; import { applyValidationErrorCollection } from "@erp/core"; -import { useHookForm } from "@erp/core/hooks"; +import { useDataSource, useHookForm } from "@erp/core/hooks"; import type { CustomerSelectionOption } from "@erp/customers"; +import type { GetCustomerByIdResponseDTO } from "@erp/customers/common"; +import { useIdentityAuthSession } from "@erp/identity/client"; import { UniqueID, type ValidationErrorCollection, @@ -13,6 +17,7 @@ import { showSuccessToast, showWarningToast, } from "@repo/rdx-ui/helpers"; +import { type DefaultError, useQuery } from "@tanstack/react-query"; import { useEffect, useId, useMemo, useState } from "react"; import type { FieldErrors } from "react-hook-form"; @@ -20,6 +25,7 @@ import { useTranslation } from "../../../i18n"; import type { Proforma } from "../../shared"; import { buildDocumentSeriesSelectItems, + buildProformaPaymentTermSelectItems, useDocumentSeriesQuery, useProformaCreateMutation, } from "../../shared"; @@ -31,6 +37,20 @@ export interface UseCreateProformaControllerOptions { onCreated?(proforma: Proforma): void; } +const ACTIVE_ONLY_PAYMENT_TERM_FILTERS = [ + { + field: "isActive", + operator: "EQUALS" as const, + value: "true", + }, +]; + +const buildCompanySettingsQueryKey = (companyId?: string) => + ["proformas", "create", "company-settings", companyId] as const; + +const buildCustomerDetailQueryKey = (customerId?: string) => + ["proformas", "create", "customer-detail", customerId] as const; + const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => { if (isValidationErrorCollection(error)) { return error satisfies ValidationErrorCollection; @@ -45,8 +65,11 @@ const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection export const useCreateProformaController = (options?: UseCreateProformaControllerOptions) => { const { t } = useTranslation(); + const dataSource = useDataSource(); + const { activeCompany } = useIdentityAuthSession(); const formId = useId(); const [selectedCustomer, setSelectedCustomer] = useState(null); + const [isPaymentTermManuallyOverridden, setIsPaymentTermManuallyOverridden] = useState(false); const { mutateAsync, @@ -57,6 +80,47 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle const proformaSeriesQuery = useDocumentSeriesQuery("proforma"); const targetInvoiceSeriesQuery = useDocumentSeriesQuery("issued_invoice"); + const paymentTermsQuery = usePaymentTermListQuery({ + criteria: { + filters: ACTIVE_ONLY_PAYMENT_TERM_FILTERS, + }, + }); + + const companySettingsQuery = useQuery<{ defaultPaymentTermId: string | null }, DefaultError>({ + queryKey: buildCompanySettingsQueryKey(activeCompany?.id), + enabled: Boolean(activeCompany?.id), + queryFn: async ({ signal }) => { + const dto = await dataSource.getOne( + "companies", + activeCompany?.id ?? "", + { + signal, + } + ); + + return { + defaultPaymentTermId: dto.default_payment_term_id, + }; + }, + }); + + const selectedCustomerQuery = useQuery<{ paymentTermId: string | null }, DefaultError>({ + queryKey: buildCustomerDetailQueryKey(selectedCustomer?.id), + enabled: Boolean(selectedCustomer?.id), + queryFn: async ({ signal }) => { + const dto = await dataSource.getOne( + "customers", + selectedCustomer?.id ?? "", + { + signal, + } + ); + + return { + paymentTermId: dto.payment_term?.id ?? null, + }; + }, + }); const initialValues = useMemo(() => buildProformaCreateDefault(), []); @@ -79,6 +143,14 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle [targetInvoiceSeriesQuery.data] ); + const paymentTermOptions = useMemo( + () => + buildProformaPaymentTermSelectItems({ + paymentTerms: paymentTermsQuery.data?.items ?? [], + }), + [paymentTermsQuery.data?.items] + ); + const defaultProformaSeries = useMemo(() => { const invoiceSeries = proformaSeriesQuery.data ?? []; @@ -96,6 +168,37 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle }); }, [defaultProformaSeries, form]); + useEffect(() => { + if (isPaymentTermManuallyOverridden) { + return; + } + + if (selectedCustomer && !selectedCustomerQuery.data) { + return; + } + + const nextPaymentTermId = + selectedCustomerQuery.data?.paymentTermId ?? + companySettingsQuery.data?.defaultPaymentTermId ?? + null; + + if (form.getValues("paymentTermId") === nextPaymentTermId) { + return; + } + + form.setValue("paymentTermId", nextPaymentTermId, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + }, [ + companySettingsQuery.data?.defaultPaymentTermId, + form, + isPaymentTermManuallyOverridden, + selectedCustomer, + selectedCustomerQuery.data, + ]); + const setCustomer = (customer: CustomerSelectionOption) => { setSelectedCustomer(customer); @@ -126,6 +229,10 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle }); }; + const handlePaymentTermChange = () => { + setIsPaymentTermManuallyOverridden(true); + }; + const submitHandler = form.handleSubmit( async (formData: ProformaCreateForm) => { const proformaId = UniqueID.generateNewID().toString(); @@ -142,6 +249,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle { keepDirty: false } ); setSelectedCustomer(null); + setIsPaymentTermManuallyOverridden(false); showSuccessToast("Proforma creada", "Proforma creada como borrador."); options?.onCreated?.(created); @@ -188,10 +296,13 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle selectedCustomer, setCustomer, clearCustomer, + handlePaymentTermChange, proformaSeriesOptions, targetInvoiceSeriesOptions, + paymentTermOptions, isProformaSeriesLoading: proformaSeriesQuery.isLoading, isTargetInvoiceSeriesLoading: targetInvoiceSeriesQuery.isLoading, + isPaymentTermsLoading: paymentTermsQuery.isLoading, isSeriesLoadError: proformaSeriesQuery.isError || targetInvoiceSeriesQuery.isError, seriesLoadError: proformaSeriesQuery.error ?? targetInvoiceSeriesQuery.error, }; diff --git a/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.entity.ts b/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.entity.ts index abcf3536..aae8d56e 100644 --- a/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.entity.ts +++ b/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.entity.ts @@ -5,4 +5,5 @@ export interface ProformaCreateForm { targetInvoiceSeriesCode: string; languageCode: string; currencyCode: string; + paymentTermId: string | null; } diff --git a/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.schema.ts b/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.schema.ts index f157d1e8..66b5a4c6 100644 --- a/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.schema.ts +++ b/modules/customer-invoices/src/web/proformas/create/entities/proforma-create-form.schema.ts @@ -12,6 +12,7 @@ export const ProformaCreateFormSchema = z.object({ targetInvoiceSeriesCode: z.string(), languageCode: z.string().min(1), currencyCode: z.string().min(1), + paymentTermId: z.string().nullable(), }); export type ProformaCreateFormSchemaType = z.infer; diff --git a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-form.tsx b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-form.tsx index 2f4b5548..5604b8fa 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-form.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-form.tsx @@ -11,12 +11,15 @@ interface ProformaCreateFormProps { isSubmitting: boolean; isProformaSeriesLoading?: boolean; isTargetInvoiceSeriesLoading?: boolean; + isPaymentTermsLoading?: boolean; onSubmit: React.SubmitEventHandler; selectedCustomer?: CustomerSelectionOption | null; onSelectCustomer: () => void; onClearCustomer: () => void; + onPaymentTermChange: () => void; proformaSeriesOptions: { value: string; label: string }[]; targetInvoiceSeriesOptions: { value: string; label: string }[]; + paymentTermOptions: { value: string; label: string }[]; className?: string; } @@ -25,12 +28,15 @@ export const ProformaCreateForm = ({ isSubmitting, isProformaSeriesLoading = false, isTargetInvoiceSeriesLoading = false, + isPaymentTermsLoading = false, onSubmit, selectedCustomer, onSelectCustomer, onClearCustomer, + onPaymentTermChange, proformaSeriesOptions, targetInvoiceSeriesOptions, + paymentTermOptions, className, }: ProformaCreateFormProps) => { return ( @@ -39,10 +45,13 @@ export const ProformaCreateForm = ({
void; onClearCustomer: () => void; + onPaymentTermChange: () => void; proformaSeriesOptions: SelectFieldItem[]; targetInvoiceSeriesOptions: SelectFieldItem[]; + paymentTermOptions: SelectFieldItem[]; } export const ProformaCreateInitialInfoCard = ({ disabled = false, isProformaSeriesLoading = false, isTargetInvoiceSeriesLoading = false, + isPaymentTermsLoading = false, selectedCustomer, onSelectCustomer, onClearCustomer, + onPaymentTermChange, proformaSeriesOptions, targetInvoiceSeriesOptions, + paymentTermOptions, }: ProformaCreateInitialInfoCardProps) => { + const { t } = useTranslation(); + return ( - } title="Información inicial"> + } + title={t("proformas.create.initial_info.title", "Información inicial")} + > className="md:col-span-2" disabled={disabled} - label="Fecha" + label={t("proformas.create.initial_info.proforma_date.label", "Fecha")} name="proformaDate" - placeholder="Selecciona una fecha" + placeholder={t( + "proformas.create.initial_info.proforma_date.placeholder", + "Selecciona una fecha" + )} required /> @@ -57,14 +72,17 @@ export const ProformaCreateInitialInfoCard = ({ className="md:col-span-2" disabled={disabled || isProformaSeriesLoading} items={proformaSeriesOptions} - label="Serie de esta proforma" + label={t("proformas.create.initial_info.proforma_series.label", "Serie de esta proforma")} name="proformaSeriesCode" placeholder={ isProformaSeriesLoading - ? "Cargando series..." + ? t("proformas.create.initial_info.proforma_series.loading", "Cargando series...") : proformaSeriesOptions.length > 0 - ? "Selecciona una serie" - : "Sin serie" + ? t( + "proformas.create.initial_info.proforma_series.placeholder", + "Selecciona una serie" + ) + : t("proformas.create.initial_info.proforma_series.empty", "Sin serie") } /> @@ -72,9 +90,30 @@ export const ProformaCreateInitialInfoCard = ({ className="md:col-span-2" disabled={disabled || isTargetInvoiceSeriesLoading} items={targetInvoiceSeriesOptions} - label="Serie de la factura" + label={t("proformas.create.initial_info.invoice_series.label", "Serie de la factura")} name="targetInvoiceSeriesCode" - placeholder={isTargetInvoiceSeriesLoading ? "Cargando series..." : "Automática"} + placeholder={ + isTargetInvoiceSeriesLoading + ? t("proformas.create.initial_info.invoice_series.loading", "Cargando series...") + : t("proformas.create.initial_info.invoice_series.placeholder", "Automática") + } + /> + + + className="md:col-span-4" + clearable + clearLabel={t("form_fields.proformas.payment_term.clear_label", "Sin plazo de pago")} + disabled={disabled || isPaymentTermsLoading} + emptyValue={null} + items={paymentTermOptions} + label={t("form_fields.proformas.payment_term.label", "Plazo de pago")} + name="paymentTermId" + onChange={onPaymentTermChange} + placeholder={ + isPaymentTermsLoading + ? t("form_fields.proformas.payment_term.loading", "Cargando plazos...") + : t("form_fields.proformas.payment_term.placeholder", "Selecciona un plazo de pago") + } /> diff --git a/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx b/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx index 8fb70c6b..60567ba8 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx @@ -54,12 +54,15 @@ export const ProformaCreatePage = () => {
{ targetInvoiceSeriesCode: "", languageCode: "es", currencyCode: "EUR", + paymentTermId: null, }; }; diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts index 92ec2710..626191a5 100644 --- a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts @@ -2,13 +2,9 @@ import { getPaymentMethodOptions, usePaymentMethodsListQuery, } from "@erp/catalogs/client/payment-methods"; -import { - getPaymentTermOptions, - usePaymentTermsListQuery, -} from "@erp/catalogs/client/payment-terms"; import { useReturnToNavigation } from "@erp/core/hooks"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { createSearchParams, useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "../../../i18n"; @@ -43,7 +39,6 @@ import { const PROFORMAS_FALLBACK_PATH = "/proformas"; // biome-ignore lint/suspicious/noControlCharactersInRegex: const INVALID_FILENAME_CHARACTERS = /[<>:"/\\|?*\u0000-\u001F]/g; - const ACTIVE_ONLY_FILTERS = [ { field: "isActive", @@ -125,28 +120,15 @@ export const useProformaDetailPageController = () => { }, }); - const paymentTermsQuery = usePaymentTermsListQuery({ - criteria: { - pageSize: 999, - filters: ACTIVE_ONLY_FILTERS, - }, - }); - const paymentMethodOptions = useMemo(() => { return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []); }, [paymentMethodsQuery.data?.items]); - const paymentTermOptions = useMemo(() => { - return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []); - }, [paymentTermsQuery.data?.items]); - const proforma = proformaQuery.data; const paymentMethodLabel = paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ?? null; - - const paymentTermLabel = - paymentTermOptions.find((option) => option.value === proforma?.paymentTermId)?.label ?? null; + const paymentTermLabel = proforma?.paymentTerm?.name ?? null; const paymentContext = useMemo( () => ({ @@ -158,17 +140,17 @@ export const useProformaDetailPageController = () => { [paymentMethodLabel, paymentTermLabel, t] ); - const handleOpenList = () => { + const handleOpenList = useCallback(() => { navigate(PROFORMAS_FALLBACK_PATH); - }; + }, [navigate]); const handleNavigateBack = () => { navigateBack(); }; - const handleRetryPage = async () => { + const handleRetryPage = useCallback(async () => { await proformaQuery.refetch(); - }; + }, [proformaQuery]); const pageErrorContext = useMemo( () => ({ @@ -184,7 +166,7 @@ export const useProformaDetailPageController = () => { onClick: handleOpenList, }, }), - [proformaQuery.isFetching, proformaQuery.isLoading, t] + [handleOpenList, handleRetryPage, proformaQuery.isFetching, proformaQuery.isLoading, t] ); const state: ProformaDetailPageState = { @@ -348,18 +330,24 @@ export const useProformaDetailPageController = () => { try { const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id }); + const duplicatedProforma = duplicated.proforma; + const successMessage = + duplicated.paymentTermAdaptation?.source === "inactive_original" + ? duplicatedProforma.paymentTerm?.name + ? t("proformas.duplicate.payment_term_replaced_with_term", { + name: duplicatedProforma.paymentTerm.name, + }) + : t("proformas.duplicate.payment_term_replaced_without_term") + : duplicatedProforma.proformaReference + ? t("proformas.duplicate.success_with_reference", { + reference: duplicatedProforma.proformaReference, + }) + : t("proformas.duplicate.success_description"); - showSuccessToast( - t("proformas.duplicate.success_title"), - duplicated.proformaReference - ? t("proformas.duplicate.success_with_reference", { - reference: duplicated.proformaReference, - }) - : t("proformas.duplicate.success_description") - ); + showSuccessToast(t("proformas.duplicate.success_title"), successMessage); navigate({ - pathname: `/proformas/${duplicated.id}/edit`, + pathname: `/proformas/${duplicatedProforma.id}/edit`, search: createSearchParams({ returnTo: currentReturnTo, }).toString(), @@ -474,7 +462,7 @@ export const useProformaDetailPageController = () => { changeStatusDialogCtrl.openDialog([ { id: proforma.id, - reference: proforma.reference!, + reference: proforma.proformaReference, status: proforma.status, }, ]); diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index 1ca6436e..4cc6b316 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -1,6 +1,6 @@ import { PageErrorState, SimpleSearchInput } from "@erp/core/components"; import { useDataSource, useReturnToNavigation } from "@erp/core/hooks"; -import { RdxPageContainer, type DataTableMeta } from "@repo/rdx-ui/components"; +import { type DataTableMeta, RdxPageContainer } from "@repo/rdx-ui/components"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import { Button, @@ -50,6 +50,7 @@ import { ProformaStatusSegmentedFilter, UnarchiveProformaDialog, } from "../components"; + import { ScopedListProformasPageView } from "./scoped-list-proformas-page-view"; interface ScopedListProformasPageProps { @@ -263,17 +264,23 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { const handleDuplicateClick = async (proforma: ProformaListRow) => { try { const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id }); + const duplicatedProforma = duplicated.proforma; + const successMessage = + duplicated.paymentTermAdaptation?.source === "inactive_original" + ? duplicatedProforma.paymentTerm?.name + ? t("proformas.duplicate.payment_term_replaced_with_term", { + name: duplicatedProforma.paymentTerm.name, + }) + : t("proformas.duplicate.payment_term_replaced_without_term") + : duplicatedProforma.proformaReference + ? t("proformas.duplicate.success_with_reference", { + reference: duplicatedProforma.proformaReference, + }) + : t("proformas.duplicate.success_description"); - showSuccessToast( - t("proformas.duplicate.success_title"), - duplicated.proformaReference - ? t("proformas.duplicate.success_with_reference", { - reference: duplicated.proformaReference, - }) - : t("proformas.duplicate.success_description") - ); + showSuccessToast(t("proformas.duplicate.success_title"), successMessage); - handleEditClick(duplicated.id); + handleEditClick(duplicatedProforma.id); } catch (error: unknown) { const status = (error as { status?: number } | undefined)?.status; diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts index 2492af7f..c17ba351 100644 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts @@ -57,6 +57,14 @@ export const GetProformaByIdAdapter = { paymentMethodId: dto.payment_method?.id ?? null, paymentTermId: dto.payment_term?.id ?? null, + paymentTerm: dto.payment_term + ? { + id: dto.payment_term.id, + name: dto.payment_term.name, + description: dto.payment_term.description, + isActive: dto.payment_term.is_active, + } + : null, taxRegimeCode: dto.tax_regime?.code ?? null, diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/duplicate-proforma-result.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/duplicate-proforma-result.entity.ts new file mode 100644 index 00000000..cb55cee5 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/entities/duplicate-proforma-result.entity.ts @@ -0,0 +1,8 @@ +import type { DuplicateProformaByIdResponseDTO } from "../../../../common"; + +import type { Proforma } from "./proforma.entity"; + +export interface DuplicateProformaResult { + proforma: Proforma; + paymentTermAdaptation: DuplicateProformaByIdResponseDTO["payment_term_adaptation"]; +} diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/index.ts b/modules/customer-invoices/src/web/proformas/shared/entities/index.ts index dd9a9d03..d5f41360 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/index.ts @@ -1,5 +1,6 @@ -export * from "./forms/proforma-item-form.entity"; export * from "./document-series-summary.entity"; +export * from "./duplicate-proforma-result.entity"; +export * from "./forms/proforma-item-form.entity"; export * from "./proforma.entity"; export * from "./proforma-item.entity"; export * from "./proforma-list.entity"; diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts index b2dd49a2..b7485e71 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/proforma.entity.ts @@ -3,6 +3,13 @@ import type { ProformaRecipient } from "./proforma-recipient.entity"; import type { ProformaStatus } from "./proforma-status.entity"; import type { ProformaTaxSummary } from "./proforma-tax-summary.entity"; +export interface ProformaPaymentTermRef { + id: string; + name: string; + description: string; + isActive: boolean; +} + export interface ProformaTaxConfig { taxMode: "single" | "perLine"; defaultIvaCode: string | null; @@ -49,6 +56,7 @@ export interface Proforma { paymentMethodId: string | null; paymentTermId: string | null; + paymentTerm: ProformaPaymentTermRef | null; subtotalAmount: number; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts index 2f78f3df..4cac29e3 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-duplicate-proforma-by-id-mutation.ts @@ -3,7 +3,7 @@ import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react- import { GetProformaByIdAdapter } from "../adapters"; import { type DuplicateProformaByIdParams, duplicateProformaById } from "../api"; -import type { Proforma } from "../entities"; +import type { DuplicateProformaResult } from "../entities"; import { DUPLICATE_PROFORMA_MUTATION_KEY } from "./keys"; import { syncCreatedProformaCaches } from "./proforma-cache-strategy"; @@ -16,10 +16,15 @@ export const useDuplicateProformaByIdMutation = () => { mutationKey: DUPLICATE_PROFORMA_MUTATION_KEY, mutationFn: async (params: DuplicateProformaByIdParams) => { const dto = await duplicateProformaById(dataSource, params); - return GetProformaByIdAdapter.fromDto(dto); + return { + proforma: GetProformaByIdAdapter.fromDto(dto), + paymentTermAdaptation: dto.payment_term_adaptation, + } satisfies DuplicateProformaResult; }, - onSuccess: async (proforma) => { - await syncCreatedProformaCaches(queryClient, proforma); + onSuccess: async (result) => { + await syncCreatedProformaCaches(queryClient, result.proforma); }, - }) as ReturnType>; + }) as ReturnType< + typeof useMutation + >; }; diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/build-proforma-payment-term-select-items.test.ts b/modules/customer-invoices/src/web/proformas/shared/utils/build-proforma-payment-term-select-items.test.ts new file mode 100644 index 00000000..e6490b9b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/utils/build-proforma-payment-term-select-items.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { PaymentTermListRow } from "@erp/catalogs/client/payment-terms"; + +import { buildProformaPaymentTermSelectItems } from "./build-proforma-payment-term-select-items"; + +const buildPaymentTerm = (value: Partial = {}): PaymentTermListRow => ({ + id: "term-1", + companyId: "company-1", + name: "30 días", + description: "", + isSystem: false, + isActive: true, + dueRules: [], + ...value, +}); + +test("devuelve solo los plazos activos como nuevas opciones", () => { + const result = buildProformaPaymentTermSelectItems({ + paymentTerms: [ + buildPaymentTerm({ id: "term-active", name: "Activo", isActive: true }), + buildPaymentTerm({ id: "term-inactive", name: "Inactivo", isActive: false }), + ], + }); + + assert.deepEqual(result, [{ value: "term-active", label: "Activo" }]); +}); + +test("mantiene visible el plazo actual si está inactivo", () => { + const result = buildProformaPaymentTermSelectItems({ + paymentTerms: [buildPaymentTerm({ id: "term-active", name: "Activo", isActive: true })], + currentPaymentTerm: { + id: "term-inactive-current", + name: "90 días", + isActive: false, + }, + inactiveLabel: "Inactivo", + }); + + assert.deepEqual(result, [ + { value: "term-inactive-current", label: "90 días (Inactivo)" }, + { value: "term-active", label: "Activo" }, + ]); +}); diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/build-proforma-payment-term-select-items.ts b/modules/customer-invoices/src/web/proformas/shared/utils/build-proforma-payment-term-select-items.ts new file mode 100644 index 00000000..b74e606b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/utils/build-proforma-payment-term-select-items.ts @@ -0,0 +1,45 @@ +import type { PaymentTermListRow } from "@erp/catalogs/client/payment-terms"; +import type { SelectFieldItem } from "@repo/rdx-ui/components"; + +export interface ProformaCurrentPaymentTermOption { + id: string; + name: string; + isActive: boolean; +} + +interface BuildProformaPaymentTermSelectItemsParams { + paymentTerms: PaymentTermListRow[]; + currentPaymentTerm?: ProformaCurrentPaymentTermOption | null; + inactiveLabel?: string; +} + +export const buildProformaPaymentTermSelectItems = ( + params: BuildProformaPaymentTermSelectItemsParams +): SelectFieldItem[] => { + const activeItems = params.paymentTerms + .filter((paymentTerm) => paymentTerm.isActive) + .map((paymentTerm) => ({ + value: paymentTerm.id, + label: paymentTerm.name, + })); + + const currentPaymentTerm = params.currentPaymentTerm; + + if (!currentPaymentTerm || currentPaymentTerm.isActive) { + return activeItems; + } + + if (activeItems.some((item) => item.value === currentPaymentTerm.id)) { + return activeItems; + } + + const inactiveSuffix = params.inactiveLabel ? ` (${params.inactiveLabel})` : ""; + + return [ + { + value: currentPaymentTerm.id, + label: `${currentPaymentTerm.name}${inactiveSuffix}`, + }, + ...activeItems, + ]; +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/index.ts b/modules/customer-invoices/src/web/proformas/shared/utils/index.ts index 38bfae76..d9a21bf8 100644 --- a/modules/customer-invoices/src/web/proformas/shared/utils/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/utils/index.ts @@ -1,4 +1,5 @@ export * from "./build-document-series-select-items"; +export * from "./build-proforma-payment-term-select-items"; export * from "./calculate-proforma-due-dates"; export * from "./ensure-current-value-in-select-items"; export * from "./proforma-fiscal-options.utils"; diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/index.ts b/modules/customer-invoices/src/web/proformas/update/controllers/index.ts index e01d3f58..370e485c 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/index.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/index.ts @@ -1,4 +1,5 @@ export * from "./use-update-proforma-controller"; +export * from "./use-update-proforma-customer-change-controller"; export * from "./use-update-proforma-items-controller"; export * from "./use-update-proforma-page-controller"; export * from "./use-update-proforma-payment-controller"; diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts index da621f12..fc32fb77 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts @@ -112,6 +112,7 @@ export const useUpdateProformaController = ( }); const [selectedCustomer, setSelectedCustomer] = useState(null); + const [isPaymentTermManuallyOverridden, setIsPaymentTermManuallyOverridden] = useState(false); /** Reiniciar el form al recibir datos */ useEffect(() => { @@ -121,6 +122,7 @@ export const useUpdateProformaController = ( form.reset(mapProformaToProformaUpdateForm(proformaData), { keepDirty: false, // <-- importante: no marca el form como "dirty" al cargar los datos reales }); + setIsPaymentTermManuallyOverridden(false); setSelectedCustomer((currentSelectedCustomer) => resolveSelectedCustomerState(proformaData, currentSelectedCustomer) @@ -135,11 +137,16 @@ export const useUpdateProformaController = ( : buildProformaUpdateDefault(); form.reset(initialData, { keepDirty: false }); + setIsPaymentTermManuallyOverridden(false); setSelectedCustomer((currentSelectedCustomer) => resolveSelectedCustomerState(proformaData, currentSelectedCustomer) ); }; + const replaceSelectedCustomer = (customer: CustomerSelectionOption | null) => { + setSelectedCustomer(customer); + }; + const setCustomer = (customer: CustomerSelectionOption) => { setSelectedCustomer(customer); @@ -160,6 +167,10 @@ export const useUpdateProformaController = ( }); }; + const handlePaymentTermChange = () => { + setIsPaymentTermManuallyOverridden(true); + }; + const submitHandler = form.handleSubmit( async (formData: ProformaUpdateForm) => { if (!proformaId) { @@ -198,6 +209,7 @@ export const useUpdateProformaController = ( form.reset(mapProformaToProformaUpdateForm(updated), { keepDirty: false, }); + setIsPaymentTermManuallyOverridden(false); setSelectedCustomer((currentSelectedCustomer) => resolveSelectedCustomerState(updated, currentSelectedCustomer) @@ -294,7 +306,7 @@ export const useUpdateProformaController = ( form, }); - const paymentCtrl = useUpdateProformaPaymentController(); + const paymentCtrl = useUpdateProformaPaymentController(proformaData?.paymentTerm ?? null); return { // form @@ -332,8 +344,11 @@ export const useUpdateProformaController = ( updateError, selectedCustomer, + replaceSelectedCustomer, setCustomer, clearCustomer, + handlePaymentTermChange, + isPaymentTermManuallyOverridden, // No devolver FormProvider, así el controller es más // flexible y reusable (p.ej. para un modal) diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts new file mode 100644 index 00000000..07511d03 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-customer-change-controller.ts @@ -0,0 +1,316 @@ +import type { CompanyResponseDTO } from "@erp/companies/common"; +import { useDataSource } from "@erp/core/hooks"; +import type { CustomerSelectionOption } from "@erp/customers"; +import type { GetCustomerByIdResponseDTO } from "@erp/customers/common"; +import { useIdentityAuthSession } from "@erp/identity/client"; +import { showErrorToast } from "@repo/rdx-ui/helpers"; +import { type DefaultError, useQuery } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; +import type { UseFormReturn } from "react-hook-form"; + +import { useTranslation } from "../../../i18n"; +import type { Proforma } from "../../shared/entities"; +import type { ProformaUpdateForm } from "../entities"; +import { + type ProformaCustomerChangeDetail, + evaluateProformaCustomerChange, +} from "../utils/evaluate-proforma-customer-change"; + +import type { UseUpdateProformaPaymentControllerResult } from "./use-update-proforma-payment-controller"; +import type { UseUpdateProformaTaxControllerResult } from "./use-update-proforma-tax-controller"; + +interface UseUpdateProformaCustomerChangeControllerParams { + form: UseFormReturn; + proforma: Proforma | undefined; + replaceSelectedCustomer(customer: CustomerSelectionOption | null): void; + paymentCtrl: UseUpdateProformaPaymentControllerResult; + taxCtrl: UseUpdateProformaTaxControllerResult; + isPaymentTermManuallyOverridden: boolean; +} + +const mapCustomerDetail = (dto: GetCustomerByIdResponseDTO): ProformaCustomerChangeDetail => { + return { + id: dto.id, + name: dto.name, + tradeName: dto.trade_name, + tin: dto.tin, + street: dto.address.street, + street2: dto.address.street2, + postalCode: dto.address.postal_code, + city: dto.address.city, + province: dto.address.province, + country: dto.address.country, + languageCode: dto.language_code, + currencyCode: dto.currency_code, + paymentMethodId: dto.payment_method?.id ?? null, + paymentMethodLabel: dto.payment_method?.name ?? dto.payment_method?.description ?? null, + paymentTermId: dto.payment_term?.id ?? null, + paymentTermLabel: dto.payment_term?.description ?? null, + taxRegimeCode: dto.tax_regime?.code ?? null, + taxRegimeLabel: dto.tax_regime?.description ?? null, + usesEquivalenceSurcharge: dto.fiscal_defaults.uses_equivalence_surcharge, + usesRetention: dto.fiscal_defaults.uses_retention, + }; +}; + +const mapDetailToSelectionOption = (params: { + candidateCustomer: CustomerSelectionOption; + candidateCustomerDetail: ProformaCustomerChangeDetail; +}): CustomerSelectionOption => { + const { candidateCustomer, candidateCustomerDetail } = params; + + return { + id: candidateCustomerDetail.id, + status: candidateCustomer.status, + isCompany: candidateCustomer.isCompany, + name: candidateCustomerDetail.name, + tradeName: candidateCustomerDetail.tradeName, + tin: candidateCustomerDetail.tin, + street: candidateCustomerDetail.street, + street2: candidateCustomerDetail.street2, + city: candidateCustomerDetail.city, + province: candidateCustomerDetail.province, + postalCode: candidateCustomerDetail.postalCode, + country: candidateCustomerDetail.country, + primaryEmail: candidateCustomer.primaryEmail, + primaryPhone: candidateCustomer.primaryPhone, + primaryMobile: candidateCustomer.primaryMobile, + }; +}; + +const buildCompanySettingsQueryKey = (companyId?: string) => + ["proformas", "update", "company-settings", companyId] as const; + +const buildCustomerDetailQueryKey = (customerId?: string) => + ["proformas", "update", "customer-detail", customerId] as const; + +export const useUpdateProformaCustomerChangeController = ( + params: UseUpdateProformaCustomerChangeControllerParams +) => { + const { t } = useTranslation(); + const dataSource = useDataSource(); + const { activeCompany } = useIdentityAuthSession(); + const [candidateCustomer, setCandidateCustomer] = useState(null); + const [isApplying, setIsApplying] = useState(false); + + const companyId = params.proforma?.companyId ?? activeCompany?.id; + + const companySettingsQuery = useQuery<{ defaultPaymentTermId: string | null }, DefaultError>({ + queryKey: buildCompanySettingsQueryKey(companyId), + enabled: Boolean(companyId), + queryFn: async ({ signal }) => { + const dto = await dataSource.getOne("companies", companyId as string, { + signal, + }); + + return { + defaultPaymentTermId: dto.default_payment_term_id, + }; + }, + }); + + const candidateCustomerQuery = useQuery({ + queryKey: buildCustomerDetailQueryKey(candidateCustomer?.id), + enabled: Boolean(candidateCustomer?.id), + queryFn: async ({ signal }) => { + const dto = await dataSource.getOne( + "customers", + candidateCustomer?.id ?? "", + { + signal, + } + ); + + return mapCustomerDetail(dto); + }, + }); + + const evaluation = useMemo(() => { + if (!(params.proforma && candidateCustomerQuery.data)) { + return null; + } + + return evaluateProformaCustomerChange({ + currentProforma: params.proforma, + currentFormValues: params.form.getValues(), + dirtyFields: params.form.formState.dirtyFields, + isPaymentTermManuallyOverridden: params.isPaymentTermManuallyOverridden, + newCustomer: candidateCustomerQuery.data, + companyDefaultPaymentTermId: companySettingsQuery.data?.defaultPaymentTermId ?? null, + paymentMethodLabelResolver: params.paymentCtrl.resolvePaymentMethodLabel, + paymentTermLabelResolver: params.paymentCtrl.resolvePaymentTermLabel, + }); + }, [ + candidateCustomerQuery.data, + companySettingsQuery.data?.defaultPaymentTermId, + params.form, + params.form.formState.dirtyFields, + params.isPaymentTermManuallyOverridden, + params.paymentCtrl.resolvePaymentMethodLabel, + params.paymentCtrl.resolvePaymentTermLabel, + params.proforma, + ]); + + const closeDialog = useCallback(() => { + if (isApplying) { + return; + } + + setCandidateCustomer(null); + }, [isApplying]); + + const openCandidateCustomer = useCallback( + (customer: CustomerSelectionOption) => { + if (!params.proforma) { + return; + } + + if (customer.id === params.proforma.customerId) { + return; + } + + setCandidateCustomer(customer); + }, + [params.proforma] + ); + + const applyCustomerChange = useCallback(() => { + if (!(candidateCustomerQuery.data && candidateCustomer && evaluation)) { + return; + } + + const currentValues = params.form.getValues(); + + params.form.setValue("customerId", candidateCustomer.id, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + + if ( + !params.isPaymentTermManuallyOverridden && + currentValues.paymentTermId !== evaluation.nextPaymentTermId + ) { + params.form.setValue("paymentTermId", evaluation.nextPaymentTermId, { + shouldDirty: true, + shouldTouch: false, + shouldValidate: true, + }); + } + + if ( + !params.form.formState.dirtyFields.paymentMethodId && + currentValues.paymentMethodId !== evaluation.nextPaymentMethodId + ) { + params.form.setValue("paymentMethodId", evaluation.nextPaymentMethodId, { + shouldDirty: true, + shouldTouch: false, + shouldValidate: true, + }); + } + + const nextFiscalTarget = evaluation.nextFiscalTarget; + + if (currentValues.taxRegimeCode !== nextFiscalTarget.taxRegimeCode) { + params.form.setValue("taxRegimeCode", nextFiscalTarget.taxRegimeCode, { + shouldDirty: true, + shouldTouch: false, + shouldValidate: true, + }); + } + + if (currentValues.hasRecPercentage !== nextFiscalTarget.hasRecPercentage) { + params.taxCtrl.updateRecPercentage(nextFiscalTarget.hasRecPercentage); + } + + if (!nextFiscalTarget.hasRetentionPercentage) { + if (currentValues.hasRetentionPercentage) { + params.form.setValue("hasRetentionPercentage", false, { + shouldDirty: true, + shouldTouch: false, + shouldValidate: true, + }); + } + + if (currentValues.retentionPercentage !== null) { + params.form.setValue("retentionPercentage", null, { + shouldDirty: true, + shouldTouch: false, + shouldValidate: true, + }); + } + } else if (nextFiscalTarget.retentionPercentage !== null) { + params.taxCtrl.updateRetentionPercentage( + nextFiscalTarget.retentionPercentage as Parameters< + typeof params.taxCtrl.updateRetentionPercentage + >[0] + ); + } + + params.replaceSelectedCustomer( + mapDetailToSelectionOption({ + candidateCustomer, + candidateCustomerDetail: candidateCustomerQuery.data, + }) + ); + }, [ + candidateCustomer, + candidateCustomerQuery.data, + evaluation, + params.form, + params.isPaymentTermManuallyOverridden, + params.replaceSelectedCustomer, + params.taxCtrl, + ]); + + const confirmChange = useCallback(async () => { + if (!evaluation?.compatible) { + return; + } + + try { + setIsApplying(true); + applyCustomerChange(); + setCandidateCustomer(null); + } catch (error) { + const message = + error instanceof Error + ? error.message + : t( + "proformas.update.customer_change.feedback.apply_error", + "No se pudo adaptar la proforma al nuevo cliente." + ); + + showErrorToast( + t("proformas.update.customer_change.feedback.error_title", "No se pudo cambiar el cliente"), + message + ); + } finally { + setIsApplying(false); + } + }, [applyCustomerChange, evaluation?.compatible, t]); + + const candidateStatus: "idle" | "loading" | "error" | "incompatible" | "compatible" = + candidateCustomer == null + ? "idle" + : candidateCustomerQuery.isLoading || companySettingsQuery.isLoading + ? "loading" + : candidateCustomerQuery.isError || companySettingsQuery.isError + ? "error" + : evaluation?.compatible === false + ? "incompatible" + : "compatible"; + + return { + open: candidateCustomer !== null, + candidateCustomer, + candidateCustomerDetail: candidateCustomerQuery.data ?? null, + evaluation, + candidateStatus, + isApplying, + loadError: candidateCustomerQuery.error ?? companySettingsQuery.error ?? null, + openCandidateCustomer, + closeDialog, + confirmChange, + }; +}; diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts index ddb66893..9ac26072 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts @@ -1,7 +1,7 @@ -import { useCallback, useMemo } from "react"; import { useUrlParamId } from "@erp/core/hooks"; -import { useNavigate, useSearchParams } from "react-router-dom"; import { useCustomerSelectionFlow } from "@erp/customers/common"; +import { useCallback, useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "../../../i18n"; import { @@ -9,7 +9,9 @@ import { buildProformaUpdatePageErrorViewModel, resolveProformaUpdatePageFeedbackState, } from "../utils"; + import { useUpdateProformaController } from "./use-update-proforma-controller"; +import { useUpdateProformaCustomerChangeController } from "./use-update-proforma-customer-change-controller"; export const useUpdateProformaPageController = () => { const { t } = useTranslation(); @@ -27,11 +29,20 @@ export const useUpdateProformaPageController = () => { error: updateCtrl.loadError, }); + const customerChangeCtrl = useUpdateProformaCustomerChangeController({ + form: updateCtrl.form, + proforma: updateCtrl.proformaData, + replaceSelectedCustomer: updateCtrl.replaceSelectedCustomer, + paymentCtrl: updateCtrl.paymentCtrl, + taxCtrl: updateCtrl.taxCtrl, + isPaymentTermManuallyOverridden: updateCtrl.isPaymentTermManuallyOverridden, + }); + const selectCustomerCtrl = useCustomerSelectionFlow({ defaultLanguageCode: updateCtrl.form.watch("languageCode"), defaultCurrencyCode: updateCtrl.form.watch("currencyCode"), onCustomerSelected: (customer) => { - updateCtrl.setCustomer(customer); + customerChangeCtrl.openCandidateCustomer(customer); }, }); @@ -61,6 +72,7 @@ export const useUpdateProformaPageController = () => { return { updateCtrl, + customerChangeCtrl, selectCustomerCtrl, returnTo, pageError, diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-payment-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-payment-controller.ts index e1465b8c..570cb32f 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-payment-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-payment-controller.ts @@ -2,13 +2,17 @@ import { getPaymentMethodOptions, usePaymentMethodsListQuery, } from "@erp/catalogs/client/payment-methods"; -import { - getPaymentTermOptions, - usePaymentTermsListQuery, -} from "@erp/catalogs/client/payment-terms"; +import { usePaymentTermListQuery } from "@erp/catalogs/client/payment-terms"; import { useMemo } from "react"; -export const useUpdateProformaPaymentController = () => { +import { useTranslation } from "../../../i18n"; +import type { ProformaPaymentTermRef } from "../../shared/entities"; +import { buildProformaPaymentTermSelectItems } from "../../shared/utils"; + +export const useUpdateProformaPaymentController = ( + currentPaymentTerm?: ProformaPaymentTermRef | null +) => { + const { t } = useTranslation(); const paymentMethodsQuery = usePaymentMethodsListQuery({ criteria: { pageSize: 999, @@ -22,7 +26,7 @@ export const useUpdateProformaPaymentController = () => { }, }); - const paymentTermsQuery = usePaymentTermsListQuery({ + const paymentTermsQuery = usePaymentTermListQuery({ criteria: { filters: [ { @@ -39,12 +43,35 @@ export const useUpdateProformaPaymentController = () => { }, [paymentMethodsQuery.data?.items]); const paymentTermOptions = useMemo(() => { - return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []); - }, [paymentTermsQuery.data?.items]); + return buildProformaPaymentTermSelectItems({ + paymentTerms: paymentTermsQuery.data?.items ?? [], + currentPaymentTerm: + currentPaymentTerm && currentPaymentTerm.id + ? { + id: currentPaymentTerm.id, + name: currentPaymentTerm.name, + isActive: currentPaymentTerm.isActive, + } + : null, + inactiveLabel: t("form_fields.proformas.payment_term.inactive", "Inactivo"), + }); + }, [currentPaymentTerm, paymentTermsQuery.data?.items, t]); + + const paymentMethodLabelById = useMemo(() => { + return new Map(paymentMethodOptions.map((option) => [option.value, option.label])); + }, [paymentMethodOptions]); + + const paymentTermLabelById = useMemo(() => { + return new Map(paymentTermOptions.map((option) => [option.value, option.label])); + }, [paymentTermOptions]); return { paymentMethodOptions, paymentTermOptions, + resolvePaymentMethodLabel: (id: string | null) => + id ? (paymentMethodLabelById.get(id) ?? id) : null, + resolvePaymentTermLabel: (id: string | null) => + id ? (paymentTermLabelById.get(id) ?? id) : null, isLoading: paymentMethodsQuery.isLoading || paymentTermsQuery.isLoading, diff --git a/modules/customer-invoices/src/web/proformas/update/ui/components/index.ts b/modules/customer-invoices/src/web/proformas/update/ui/components/index.ts index 5dd68072..dee12237 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/components/index.ts +++ b/modules/customer-invoices/src/web/proformas/update/ui/components/index.ts @@ -1,2 +1,3 @@ export * from "./proforma-info-alert"; +export * from "./proforma-update-customer-change-dialog"; export * from "./proforma-update-skeleton"; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/components/proforma-update-customer-change-dialog.tsx b/modules/customer-invoices/src/web/proformas/update/ui/components/proforma-update-customer-change-dialog.tsx new file mode 100644 index 00000000..f33d34b4 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/ui/components/proforma-update-customer-change-dialog.tsx @@ -0,0 +1,191 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Spinner, +} from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../../i18n"; +import type { ProformaCustomerChangeEvaluation } from "../../utils/evaluate-proforma-customer-change"; + +interface ProformaUpdateCustomerChangeDialogProps { + open: boolean; + status: "idle" | "loading" | "error" | "incompatible" | "compatible"; + evaluation: ProformaCustomerChangeEvaluation | null; + loadError: Error | null; + isSubmitting: boolean; + onOpenChange(open: boolean): void; + onConfirm(): void; +} + +export const ProformaUpdateCustomerChangeDialog = ({ + open, + status, + evaluation, + loadError, + isSubmitting, + onOpenChange, + onConfirm, +}: ProformaUpdateCustomerChangeDialogProps) => { + const { t } = useTranslation(); + + const getFieldLabel = (field: string) => { + return t(`proformas.update.customer_change.fields.${field}`, field); + }; + + const getValueLabel = (value: string | null) => { + if (value === null) { + return t("proformas.update.customer_change.values.empty", "Sin valor"); + } + + if (value === "true") { + return t("proformas.update.customer_change.values.enabled", "Sí"); + } + + if (value === "false") { + return t("proformas.update.customer_change.values.disabled", "No"); + } + + return value; + }; + + return ( + + + + + {status === "incompatible" + ? t("proformas.update.customer_change.dialog.incompatible_title", "No es posible cambiar de cliente") + : t("proformas.update.customer_change.dialog.compatible_title", "Cambiar cliente")} + + + {status === "loading" ? ( + + + {t("proformas.update.customer_change.dialog.loading", "Comprobando compatibilidad del cliente...")} + + ) : null} + + {status === "error" ? ( + + {loadError?.message ?? + t( + "proformas.update.customer_change.dialog.error", + "No se pudo cargar la información del cliente seleccionado." + )} + + ) : null} + + {status === "incompatible" && evaluation ? ( +
+

+ {t( + "proformas.update.customer_change.dialog.incompatible_description", + "El cliente seleccionado no es compatible con esta proforma." + )} +

+
    + {evaluation.blockers.map((blocker) => ( +
  • + {blocker.type === "currency" + ? t("proformas.update.customer_change.blockers.currency", "Moneda") + : t("proformas.update.customer_change.blockers.language", "Idioma")} + : {blocker.currentValue} a {blocker.nextValue} +
  • + ))} +
+
+ ) : null} + + {status === "compatible" && evaluation ? ( +
+ {evaluation.changes.length > 0 ? ( +
+

+ {t( + "proformas.update.customer_change.dialog.changes_title", + "La proforma se actualizará con estos cambios:" + )} +

+
    + {evaluation.changes.map((change) => ( +
  • + {getFieldLabel(change.field)}: {getValueLabel(change.currentValue)} + {" a "} + {getValueLabel(change.nextValue)} +
  • + ))} +
+
+ ) : null} + + {evaluation.fiscalChanges.length > 0 ? ( +
+

+ {t( + "proformas.update.customer_change.dialog.fiscal_title", + "También se adaptará la configuración fiscal y se recalcularán impuestos y totales." + )} +

+
    + {evaluation.fiscalChanges.map((change) => ( +
  • + {getFieldLabel(change.field)}: {getValueLabel(change.currentValue)} + {" a "} + {getValueLabel(change.nextValue)} +
  • + ))} +
+
+ ) : null} + + {evaluation.preservedOverrides.length > 0 ? ( +
+

+ {t( + "proformas.update.customer_change.dialog.preserved_title", + "Se conservarán estos valores porque fueron modificados manualmente:" + )} +

+
    + {evaluation.preservedOverrides.map((change) => ( +
  • + {getFieldLabel(change.field)}: {getValueLabel(change.currentValue)} +
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+
+ + + + + {status === "compatible" ? ( + + ) : null} + +
+
+ ); +}; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-payment-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-payment-editor.tsx index 882fadf4..c000b0f3 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-payment-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-payment-editor.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "../../../../i18n"; interface ProformaUpdatePaymentEditorProps { paymentMethodOptions: SelectFieldItem[]; paymentTermOptions: SelectFieldItem[]; + onPaymentTermChange: () => void; disabled?: boolean; readOnly?: boolean; @@ -21,6 +22,7 @@ interface ProformaUpdatePaymentEditorProps { export const ProformaUpdatePaymentEditor = ({ paymentMethodOptions, paymentTermOptions, + onPaymentTermChange, disabled = false, readOnly = false, className, @@ -50,14 +52,18 @@ export const ProformaUpdatePaymentEditor = ({ diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx index 89b3d87b..3ec2446f 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; + import { renderToStaticMarkup } from "react-dom/server"; import { FormProvider, useForm } from "react-hook-form"; @@ -12,6 +13,7 @@ import type { } from "../../controllers"; import type { ProformaTotals, ProformaUpdateForm } from "../../entities"; import { buildProformaUpdateDefault } from "../../utils"; + import { ProformaUpdateEditorForm } from "./proforma-update-editor-form"; const emptyTotals: ProformaTotals = { @@ -82,6 +84,8 @@ const taxCtrl: UseUpdateProformaTaxControllerResult = { const paymentCtrl: UseUpdateProformaPaymentControllerResult = { paymentMethodOptions: [], paymentTermOptions: [], + resolvePaymentMethodLabel: () => null, + resolvePaymentTermLabel: () => null, isLoading: false, isFetching: false, isError: false, @@ -106,6 +110,7 @@ const renderEditor = () => { itemsCtrl={itemsCtrl} onChangeCustomerClick={() => undefined} onCreateCustomerClick={() => undefined} + onPaymentTermChange={() => undefined} onReset={() => undefined} onSubmit={(event) => { event.preventDefault(); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx index 6350ed56..51d8e25c 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.tsx @@ -25,6 +25,7 @@ type ProformaUpdateEditorProps = { selectedCustomer?: CustomerSelectionOption | null; onChangeCustomerClick: () => void; onCreateCustomerClick: () => void; + onPaymentTermChange: () => void; seriesOptions: SelectFieldItem[]; itemsCtrl: UseUpdateProformaItemsControllerResult; @@ -43,6 +44,7 @@ export const ProformaUpdateEditorForm = ({ selectedCustomer, onChangeCustomerClick, onCreateCustomerClick, + onPaymentTermChange, seriesOptions, itemsCtrl, taxCtrl, @@ -66,6 +68,7 @@ export const ProformaUpdateEditorForm = ({ diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx index 92da51ca..6db8e88c 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx @@ -83,6 +83,7 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb isUpdating: false, isUpdateError: false, isSeriesLoadError: false, + handlePaymentTermChange: () => undefined, itemsCtrl: {}, onSubmit: () => undefined, paymentCtrl: {}, @@ -114,6 +115,15 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb }, currencyCode: "EUR", }, + customerChangeCtrl: { + open: false, + candidateStatus: "idle", + evaluation: null, + isApplying: false, + loadError: null, + closeDialog: () => undefined, + confirmChange: async () => undefined, + }, selectCustomerCtrl: { selectCtrl: { openDialog: openSelectDialog, @@ -143,6 +153,7 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb mock.module("../components", { namedExports: { ProformaInfoAlert: ({ children }: { children: ReactNode }) => children, + ProformaUpdateCustomerChangeDialog: () => null, ProformaUpdateSkeleton: () => null, }, }); @@ -162,6 +173,7 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb assert.equal(capturedEditorProps?.onChangeCustomerClick, openSelectDialog); assert.equal(capturedEditorProps?.onCreateCustomerClick, openQuickCreateDialog); + assert.equal(typeof capturedEditorProps?.onPaymentTermChange, "function"); assert.equal(capturedSelectDialogProps?.onCreateNewCustomerClick, openQuickCreateFromSelection); assert.equal(capturedQuickCreateDialogProps?.ctrl, quickCreateCtrl); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx index 307fc9ca..77ec0c45 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx @@ -13,14 +13,18 @@ import { FormProvider } from "react-hook-form"; import { useTranslation } from "../../../../i18n"; import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller"; import { ProformaCompactTotals, ProformaUpdateHeader } from "../blocks"; -import { ProformaInfoAlert, ProformaUpdateSkeleton } from "../components"; +import { + ProformaInfoAlert, + ProformaUpdateCustomerChangeDialog, + ProformaUpdateSkeleton, +} from "../components"; import { ProformaUpdateEditorForm } from "../editors"; export const ProformaUpdatePage = () => { const { t } = useTranslation(); const ctrl = useUpdateProformaPageController(); - const { updateCtrl, selectCustomerCtrl, returnTo } = ctrl; + const { updateCtrl, customerChangeCtrl, selectCustomerCtrl, returnTo } = ctrl; const { navigateBack } = useReturnToNavigation({ fallbackPath: returnTo, @@ -142,6 +146,7 @@ export const ProformaUpdatePage = () => { itemsCtrl={updateCtrl.itemsCtrl} onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog} onCreateCustomerClick={selectCustomerCtrl.openQuickCreateDialog} + onPaymentTermChange={updateCtrl.handlePaymentTermChange} onReset={updateCtrl.resetForm} onSubmit={updateCtrl.onSubmit} paymentCtrl={updateCtrl.paymentCtrl} @@ -199,6 +204,19 @@ export const ProformaUpdatePage = () => { onCreateNewCustomerClick={selectCustomerCtrl.openQuickCreateFromSelection} /> + { + if (!open) { + customerChangeCtrl.closeDialog(); + } + }} + open={customerChangeCtrl.open} + status={customerChangeCtrl.candidateStatus} + />
diff --git a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.test.ts b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.test.ts new file mode 100644 index 00000000..90485c37 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ProformaUpdateForm } from "../entities"; +import { buildUpdateProformaByIdParams } from "./build-update-proforma-by-id-params"; + +const buildFormData = (value: Partial = {}): ProformaUpdateForm => + ({ + proformaReference: "PF-1", + targetInvoiceSeriesCode: "", + proformaDate: "2026-08-16", + operationDate: "2026-08-16", + customerId: "customer-1", + description: "", + reference: "", + notes: "", + languageCode: "es", + currencyCode: "EUR", + globalDiscountPercentage: 0, + taxMode: "single", + taxRegimeCode: null, + hasTaxPercentage: true, + taxPercentage: 21, + hasRecPercentage: false, + recPercentage: null, + hasRetentionPercentage: false, + retentionPercentage: null, + paymentMethodId: null, + paymentTermId: null, + items: [], + ...value, + }) as ProformaUpdateForm; + +test("excluye items vacíos no valorados al construir el PUT de update", () => { + const formData = buildFormData({ + items: [ + { + id: "item-empty", + position: 0, + description: "", + quantity: null, + unitAmount: null, + itemDiscountPercentage: null, + taxPercentage: 21, + recPercentage: null, + }, + { + id: "item-valued", + position: 1, + description: "Servicio", + quantity: 1, + unitAmount: 100, + itemDiscountPercentage: null, + taxPercentage: 21, + recPercentage: null, + }, + ], + }); + + const result = buildUpdateProformaByIdParams( + "proforma-1", + { + customerId: "customer-2", + items: formData.items, + }, + formData + ); + + assert.equal(result.data.items?.length, 1); + assert.equal(result.data.items?.[0]?.description, "Servicio"); + assert.equal(result.data.items?.[0]?.position, 0); +}); diff --git a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts index 6f6b8f74..7348f58a 100644 --- a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts +++ b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts @@ -109,7 +109,9 @@ export const buildUpdateProformaByIdParams = ( // Si se han tocado los detalles, se envían todos if (shouldReplaceItems(patch)) { const context = buildProformaItemUpdateDTOContext(formData); - data.items = formData.items.map((item, index) => toProformaItemUpdateDTO(item, index, context)); + data.items = formData.items + .filter(isValuedProformaItem) + .map((item, index) => toProformaItemUpdateDTO(item, index, context)); } return { @@ -144,6 +146,10 @@ const toProformaItemUpdateDTO = ( }; }; +const isValuedProformaItem = (item: ProformaItemUpdateForm): boolean => { + return item.quantity !== null && item.unitAmount !== null; +}; + const buildProformaItemUpdateDTOContext = ( formData: ProformaUpdateForm ): ProformaItemUpdateDTOContext => { diff --git a/modules/customer-invoices/src/web/proformas/update/utils/evaluate-proforma-customer-change.test.ts b/modules/customer-invoices/src/web/proformas/update/utils/evaluate-proforma-customer-change.test.ts new file mode 100644 index 00000000..cbce2867 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/utils/evaluate-proforma-customer-change.test.ts @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { FieldNamesMarkedBoolean } from "react-hook-form"; + +import type { Proforma } from "../../shared/entities"; +import type { ProformaUpdateForm } from "../entities"; + +import { + type ProformaCustomerChangeDetail, + evaluateProformaCustomerChange, +} from "./evaluate-proforma-customer-change"; + +const buildDirtyFields = ( + value: Partial> = {} +): FieldNamesMarkedBoolean => + value as FieldNamesMarkedBoolean; + +const buildFormValues = (value: Partial = {}): ProformaUpdateForm => + ({ + customerId: "customer-current", + languageCode: "es", + currencyCode: "EUR", + paymentTermId: "term-current", + paymentMethodId: "method-current", + taxRegimeCode: "01", + hasRecPercentage: false, + recPercentage: null, + hasRetentionPercentage: false, + retentionPercentage: null, + ...value, + }) as ProformaUpdateForm; + +const buildProforma = (value: Partial = {}): Proforma => + ({ + customerId: "customer-current", + recipient: { + name: "Cliente actual", + tin: "ES00000000A", + street: "Calle Uno", + street2: null, + postalCode: "28001", + city: "Madrid", + province: "Madrid", + country: "ES", + }, + ...value, + }) as Proforma; + +const buildCustomer = ( + value: Partial = {} +): ProformaCustomerChangeDetail => ({ + id: "customer-next", + name: "Cliente nuevo", + tradeName: null, + tin: "ES11111111B", + street: "Calle Dos", + street2: null, + postalCode: "08001", + city: "Barcelona", + province: "Barcelona", + country: "ES", + languageCode: "es", + currencyCode: "EUR", + paymentMethodId: "method-next", + paymentMethodLabel: "Transferencia", + paymentTermId: "term-next", + paymentTermLabel: "30 días", + taxRegimeCode: "02", + taxRegimeLabel: "Intracomunitario", + usesEquivalenceSurcharge: true, + usesRetention: true, + ...value, +}); + +test("evalúa cambio compatible y detecta cambios principales", () => { + const result = evaluateProformaCustomerChange({ + currentProforma: buildProforma(), + currentFormValues: buildFormValues(), + dirtyFields: buildDirtyFields(), + isPaymentTermManuallyOverridden: false, + newCustomer: buildCustomer(), + companyDefaultPaymentTermId: null, + }); + + assert.equal(result.compatible, true); + assert.equal(result.blockers.length, 0); + assert.equal( + result.changes.some((change) => change.field === "recipient"), + true + ); + assert.equal( + result.changes.some((change) => change.field === "paymentTermId"), + true + ); + assert.equal( + result.changes.some((change) => change.field === "paymentMethodId"), + true + ); + assert.equal( + result.fiscalChanges.some((change) => change.field === "taxRegimeCode"), + true + ); +}); + +test("bloquea cambio cuando cambia la moneda", () => { + const result = evaluateProformaCustomerChange({ + currentProforma: buildProforma(), + currentFormValues: buildFormValues(), + dirtyFields: buildDirtyFields(), + isPaymentTermManuallyOverridden: false, + newCustomer: buildCustomer({ currencyCode: "USD" }), + companyDefaultPaymentTermId: null, + }); + + assert.equal(result.compatible, false); + assert.deepEqual( + result.blockers.map((blocker) => blocker.type), + ["currency"] + ); +}); + +test("bloquea cambio cuando cambia el idioma", () => { + const result = evaluateProformaCustomerChange({ + currentProforma: buildProforma(), + currentFormValues: buildFormValues(), + dirtyFields: buildDirtyFields(), + isPaymentTermManuallyOverridden: false, + newCustomer: buildCustomer({ languageCode: "en" }), + companyDefaultPaymentTermId: null, + }); + + assert.equal(result.compatible, false); + assert.deepEqual( + result.blockers.map((blocker) => blocker.type), + ["language"] + ); +}); + +test("preserva el plazo manual cuando se marca override explícito", () => { + const result = evaluateProformaCustomerChange({ + currentProforma: buildProforma(), + currentFormValues: buildFormValues({ paymentTermId: "term-manual" }), + dirtyFields: buildDirtyFields({ paymentTermId: true }), + isPaymentTermManuallyOverridden: true, + newCustomer: buildCustomer(), + companyDefaultPaymentTermId: null, + paymentTermLabelResolver: (id) => (id === "term-manual" ? "Manual" : id), + }); + + assert.equal(result.preservedOverrides.length, 1); + assert.equal(result.preservedOverrides[0]?.field, "paymentTermId"); + assert.equal( + result.changes.some((change) => change.field === "paymentTermId"), + false + ); +}); + +test("un plazo dirty pero auto-adaptado no se trata como override manual", () => { + const result = evaluateProformaCustomerChange({ + currentProforma: buildProforma(), + currentFormValues: buildFormValues({ paymentTermId: "term-auto" }), + dirtyFields: buildDirtyFields({ paymentTermId: true }), + isPaymentTermManuallyOverridden: false, + newCustomer: buildCustomer({ paymentTermId: "term-next-2", paymentTermLabel: "60 días" }), + companyDefaultPaymentTermId: null, + paymentTermLabelResolver: (id) => (id === "term-auto" ? "Auto" : id), + }); + + assert.equal( + result.preservedOverrides.some((item) => item.field === "paymentTermId"), + false + ); + assert.equal( + result.changes.some((change) => change.field === "paymentTermId"), + true + ); +}); + +test("preserva el método manual cuando el campo está dirty", () => { + const result = evaluateProformaCustomerChange({ + currentProforma: buildProforma(), + currentFormValues: buildFormValues({ paymentMethodId: "method-manual" }), + dirtyFields: buildDirtyFields({ paymentMethodId: true }), + isPaymentTermManuallyOverridden: false, + newCustomer: buildCustomer(), + companyDefaultPaymentTermId: null, + paymentMethodLabelResolver: (id) => (id === "method-manual" ? "Manual" : id), + }); + + assert.equal(result.preservedOverrides.length, 1); + assert.equal(result.preservedOverrides[0]?.field, "paymentMethodId"); + assert.equal( + result.changes.some((change) => change.field === "paymentMethodId"), + false + ); +}); + +test("usa el default de company cuando el nuevo cliente no tiene plazo", () => { + const result = evaluateProformaCustomerChange({ + currentProforma: buildProforma(), + currentFormValues: buildFormValues({ paymentTermId: null }), + dirtyFields: buildDirtyFields(), + isPaymentTermManuallyOverridden: false, + newCustomer: buildCustomer({ paymentTermId: null, paymentTermLabel: null }), + companyDefaultPaymentTermId: "term-company-default", + paymentTermLabelResolver: (id) => (id === "term-company-default" ? "Default empresa" : id), + }); + + assert.equal(result.nextPaymentTermId, "term-company-default"); + assert.equal( + result.changes.some((change) => change.field === "paymentTermId"), + true + ); +}); diff --git a/modules/customer-invoices/src/web/proformas/update/utils/evaluate-proforma-customer-change.ts b/modules/customer-invoices/src/web/proformas/update/utils/evaluate-proforma-customer-change.ts new file mode 100644 index 00000000..cbec80f4 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/utils/evaluate-proforma-customer-change.ts @@ -0,0 +1,313 @@ +import type { FieldNamesMarkedBoolean } from "react-hook-form"; + +import { getProformaRecPercentage } from "../../shared"; +import type { Proforma } from "../../shared/entities"; +import type { ProformaUpdateForm } from "../entities"; + +export interface ProformaCustomerChangeDetail { + id: string; + name: string; + tradeName: string | null; + tin: string | null; + street: string | null; + street2: string | null; + postalCode: string | null; + city: string | null; + province: string | null; + country: string | null; + languageCode: string; + currencyCode: string; + paymentMethodId: string | null; + paymentMethodLabel: string | null; + paymentTermId: string | null; + paymentTermLabel: string | null; + taxRegimeCode: string | null; + taxRegimeLabel: string | null; + usesEquivalenceSurcharge: boolean; + usesRetention: boolean; +} + +export type ProformaCustomerChangeBlockerType = "currency" | "language"; + +export interface ProformaCustomerChangeBlocker { + type: ProformaCustomerChangeBlockerType; + currentValue: string; + nextValue: string; +} + +export interface ProformaCustomerChangeFieldDiff { + field: + | "recipient" + | "paymentTermId" + | "paymentMethodId" + | "taxRegimeCode" + | "usesEquivalenceSurcharge" + | "usesRetention"; + currentValue: string | null; + nextValue: string | null; +} + +export interface ProformaCustomerChangePreservedField { + field: "paymentTermId" | "paymentMethodId"; + currentValue: string | null; + suggestedValue: string | null; +} + +export interface ProformaCustomerChangeFiscalTarget { + taxRegimeCode: string | null; + hasRecPercentage: boolean; + recPercentage: number | null; + hasRetentionPercentage: boolean; + retentionPercentage: number | null; +} + +export interface ProformaCustomerChangeEvaluation { + compatible: boolean; + blockers: ProformaCustomerChangeBlocker[]; + changes: ProformaCustomerChangeFieldDiff[]; + preservedOverrides: ProformaCustomerChangePreservedField[]; + fiscalChanges: ProformaCustomerChangeFieldDiff[]; + nextPaymentTermId: string | null; + nextPaymentMethodId: string | null; + nextFiscalTarget: ProformaCustomerChangeFiscalTarget; +} + +interface EvaluateProformaCustomerChangeParams { + currentProforma: Proforma; + currentFormValues: ProformaUpdateForm; + dirtyFields: FieldNamesMarkedBoolean; + isPaymentTermManuallyOverridden: boolean; + newCustomer: ProformaCustomerChangeDetail; + companyDefaultPaymentTermId: string | null; + paymentTermLabelResolver?(id: string | null): string | null; + paymentMethodLabelResolver?(id: string | null): string | null; +} + +const normalizeText = (value: string | null | undefined): string | null => { + if (!value) { + return null; + } + + return value.trim(); +}; + +const sameText = (left: string | null | undefined, right: string | null | undefined): boolean => { + return normalizeText(left) === normalizeText(right); +}; + +const formatRecipient = (params: { + name: string | null | undefined; + tin: string | null | undefined; + city: string | null | undefined; + country: string | null | undefined; +}): string | null => { + const parts = [ + params.name ?? null, + params.tin ?? null, + params.city ?? null, + params.country ?? null, + ].filter(Boolean) as string[]; + + return parts.length > 0 ? parts.join(" · ") : null; +}; + +const resolvePaymentTermLabel = ( + id: string | null, + params: { + newCustomer: ProformaCustomerChangeDetail; + resolver?(id: string | null): string | null; + } +): string | null => { + if (!id) { + return null; + } + + if (params.newCustomer.paymentTermId === id) { + return params.newCustomer.paymentTermLabel ?? id; + } + + return params.resolver?.(id) ?? id; +}; + +const resolvePaymentMethodLabel = ( + id: string | null, + params: { + newCustomer: ProformaCustomerChangeDetail; + resolver?(id: string | null): string | null; + } +): string | null => { + if (!id) { + return null; + } + + if (params.newCustomer.paymentMethodId === id) { + return params.newCustomer.paymentMethodLabel ?? id; + } + + return params.resolver?.(id) ?? id; +}; + +export const evaluateProformaCustomerChange = ( + params: EvaluateProformaCustomerChangeParams +): ProformaCustomerChangeEvaluation => { + const blockers: ProformaCustomerChangeBlocker[] = []; + + if (params.newCustomer.currencyCode !== params.currentFormValues.currencyCode) { + blockers.push({ + type: "currency", + currentValue: params.currentFormValues.currencyCode, + nextValue: params.newCustomer.currencyCode, + }); + } + + if (params.newCustomer.languageCode !== params.currentFormValues.languageCode) { + blockers.push({ + type: "language", + currentValue: params.currentFormValues.languageCode, + nextValue: params.newCustomer.languageCode, + }); + } + + const nextPaymentTermId = + params.newCustomer.paymentTermId ?? params.companyDefaultPaymentTermId ?? null; + const nextPaymentMethodId = params.newCustomer.paymentMethodId ?? null; + + const changes: ProformaCustomerChangeFieldDiff[] = []; + const preservedOverrides: ProformaCustomerChangePreservedField[] = []; + const fiscalChanges: ProformaCustomerChangeFieldDiff[] = []; + + const currentRecipientSummary = formatRecipient({ + name: params.currentProforma.recipient.name, + tin: params.currentProforma.recipient.tin, + city: params.currentProforma.recipient.city, + country: params.currentProforma.recipient.country, + }); + const nextRecipientSummary = formatRecipient({ + name: params.newCustomer.name, + tin: params.newCustomer.tin, + city: params.newCustomer.city, + country: params.newCustomer.country, + }); + + const recipientChanged = !( + sameText(params.currentProforma.recipient.name, params.newCustomer.name) && + sameText(params.currentProforma.recipient.tin, params.newCustomer.tin) && + sameText(params.currentProforma.recipient.street, params.newCustomer.street) && + sameText(params.currentProforma.recipient.street2, params.newCustomer.street2) && + sameText(params.currentProforma.recipient.postalCode, params.newCustomer.postalCode) && + sameText(params.currentProforma.recipient.city, params.newCustomer.city) && + sameText(params.currentProforma.recipient.province, params.newCustomer.province) && + sameText(params.currentProforma.recipient.country, params.newCustomer.country) + ); + + if (recipientChanged) { + changes.push({ + field: "recipient", + currentValue: currentRecipientSummary, + nextValue: nextRecipientSummary, + }); + } + + if (params.isPaymentTermManuallyOverridden) { + if (params.currentFormValues.paymentTermId !== nextPaymentTermId) { + preservedOverrides.push({ + field: "paymentTermId", + currentValue: resolvePaymentTermLabel(params.currentFormValues.paymentTermId, { + newCustomer: params.newCustomer, + resolver: params.paymentTermLabelResolver, + }), + suggestedValue: resolvePaymentTermLabel(nextPaymentTermId, { + newCustomer: params.newCustomer, + resolver: params.paymentTermLabelResolver, + }), + }); + } + } else if (params.currentFormValues.paymentTermId !== nextPaymentTermId) { + changes.push({ + field: "paymentTermId", + currentValue: resolvePaymentTermLabel(params.currentFormValues.paymentTermId, { + newCustomer: params.newCustomer, + resolver: params.paymentTermLabelResolver, + }), + nextValue: resolvePaymentTermLabel(nextPaymentTermId, { + newCustomer: params.newCustomer, + resolver: params.paymentTermLabelResolver, + }), + }); + } + + if (params.dirtyFields.paymentMethodId) { + if (params.currentFormValues.paymentMethodId !== nextPaymentMethodId) { + preservedOverrides.push({ + field: "paymentMethodId", + currentValue: resolvePaymentMethodLabel(params.currentFormValues.paymentMethodId, { + newCustomer: params.newCustomer, + resolver: params.paymentMethodLabelResolver, + }), + suggestedValue: resolvePaymentMethodLabel(nextPaymentMethodId, { + newCustomer: params.newCustomer, + resolver: params.paymentMethodLabelResolver, + }), + }); + } + } else if (params.currentFormValues.paymentMethodId !== nextPaymentMethodId) { + changes.push({ + field: "paymentMethodId", + currentValue: resolvePaymentMethodLabel(params.currentFormValues.paymentMethodId, { + newCustomer: params.newCustomer, + resolver: params.paymentMethodLabelResolver, + }), + nextValue: resolvePaymentMethodLabel(nextPaymentMethodId, { + newCustomer: params.newCustomer, + resolver: params.paymentMethodLabelResolver, + }), + }); + } + + const nextTaxRegimeCode = params.newCustomer.taxRegimeCode ?? "01"; + const nextRecPercentage = params.newCustomer.usesEquivalenceSurcharge + ? getProformaRecPercentage(21) + : null; + const nextRetentionPercentage = params.newCustomer.usesRetention ? 15 : null; + + if (params.currentFormValues.taxRegimeCode !== nextTaxRegimeCode) { + fiscalChanges.push({ + field: "taxRegimeCode", + currentValue: params.currentFormValues.taxRegimeCode, + nextValue: nextTaxRegimeCode, + }); + } + + if (params.currentFormValues.hasRecPercentage !== params.newCustomer.usesEquivalenceSurcharge) { + fiscalChanges.push({ + field: "usesEquivalenceSurcharge", + currentValue: params.currentFormValues.hasRecPercentage ? "true" : "false", + nextValue: params.newCustomer.usesEquivalenceSurcharge ? "true" : "false", + }); + } + + if (params.currentFormValues.hasRetentionPercentage !== params.newCustomer.usesRetention) { + fiscalChanges.push({ + field: "usesRetention", + currentValue: params.currentFormValues.hasRetentionPercentage ? "true" : "false", + nextValue: params.newCustomer.usesRetention ? "true" : "false", + }); + } + + return { + compatible: blockers.length === 0, + blockers, + changes, + preservedOverrides, + fiscalChanges, + nextPaymentTermId, + nextPaymentMethodId, + nextFiscalTarget: { + taxRegimeCode: nextTaxRegimeCode, + hasRecPercentage: params.newCustomer.usesEquivalenceSurcharge, + recPercentage: nextRecPercentage, + hasRetentionPercentage: params.newCustomer.usesRetention, + retentionPercentage: nextRetentionPercentage, + }, + }; +}; diff --git a/modules/customer-invoices/src/web/proformas/update/utils/index.ts b/modules/customer-invoices/src/web/proformas/update/utils/index.ts index acb9c7dd..af485dc1 100644 --- a/modules/customer-invoices/src/web/proformas/update/utils/index.ts +++ b/modules/customer-invoices/src/web/proformas/update/utils/index.ts @@ -3,5 +3,6 @@ export * from "./build-proforma-update-default"; export * from "./build-proforma-update-page-error-view-model"; export * from "./build-proforma-update-patch"; export * from "./build-update-proforma-by-id-params"; +export * from "./evaluate-proforma-customer-change"; export * from "./calculate-proforma-totals"; export * from "./resolve-proforma-update-page-feedback-state"; diff --git a/modules/customer-invoices/templates/rodax/es/issued-invoice.frx b/modules/customer-invoices/templates/rodax/es/issued-invoice.frx index b59c6fad..0e52398f 100644 --- a/modules/customer-invoices/templates/rodax/es/issued-invoice.frx +++ b/modules/customer-invoices/templates/rodax/es/issued-invoice.frx @@ -51,7 +51,8 @@ namespace FastReport - + + @@ -217,9 +218,11 @@ namespace FastReport - - - + + + + + diff --git a/modules/customers/package.json b/modules/customers/package.json index 6a47899d..fa47adf5 100644 --- a/modules/customers/package.json +++ b/modules/customers/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@erp/catalogs": "workspace:*", + "@erp/companies": "workspace:*", "@erp/core": "workspace:*", "@erp/identity": "workspace:*", "@hookform/resolvers": "^5.2.2", @@ -56,4 +57,4 @@ "use-debounce": "^10.1.1", "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/modules/customers/src/api/application/di/customer-use-cases.di.ts b/modules/customers/src/api/application/di/customer-use-cases.di.ts index 7d73df01..fde222c5 100644 --- a/modules/customers/src/api/application/di/customer-use-cases.di.ts +++ b/modules/customers/src/api/application/di/customer-use-cases.di.ts @@ -1,7 +1,13 @@ import type { ITransactionManager } from "@erp/core/api"; import type { ICreateCustomerInputMapper, IUpdateCustomerInputMapper } from "../mappers"; -import type { ICustomerCreator, ICustomerFinder, ICustomerUpdater } from "../services"; +import type { + ICustomerCreator, + ICustomerDefaultPaymentTermResolver, + ICustomerFinder, + ICustomerUpdater, +} from "../services"; +import type { CustomerPaymentResolver } from "../services/catalog-resolver"; import type { ICustomerFullReadModelAssembler } from "../services/assemblers"; import type { ICustomerFullSnapshotBuilder, @@ -38,6 +44,7 @@ export function buildListCustomersUseCase(deps: { export function buildCreateCustomerUseCase(deps: { creator: ICustomerCreator; dtoMapper: ICreateCustomerInputMapper; + defaultPaymentTermResolver: ICustomerDefaultPaymentTermResolver; fullReadModelAssembler: ICustomerFullReadModelAssembler; fullSnapshotBuilder: ICustomerFullSnapshotBuilder; transactionManager: ITransactionManager; @@ -46,7 +53,9 @@ export function buildCreateCustomerUseCase(deps: { } export function buildUpdateCustomerUseCase(deps: { + finder: ICustomerFinder; updater: ICustomerUpdater; + paymentResolver: CustomerPaymentResolver; dtoMapper: IUpdateCustomerInputMapper; fullReadModelAssembler: ICustomerFullReadModelAssembler; fullSnapshotBuilder: ICustomerFullSnapshotBuilder; diff --git a/modules/customers/src/api/application/services/assemblers/customer-full-read-model.assembler.ts b/modules/customers/src/api/application/services/assemblers/customer-full-read-model.assembler.ts index 2e70e60e..14e3862a 100644 --- a/modules/customers/src/api/application/services/assemblers/customer-full-read-model.assembler.ts +++ b/modules/customers/src/api/application/services/assemblers/customer-full-read-model.assembler.ts @@ -134,12 +134,8 @@ export class CustomerFullReadModelAssembler implements ICustomerFullReadModelAss } if (result.data.isNone()) { - return Result.ok( - Maybe.some({ - id: paymentTermId, - name: "", - description: Maybe.none(), - }) + return Result.fail( + new Error(`Customer payment term reference is inconsistent: ${paymentTermId.toString()}`) ); } diff --git a/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.test.ts b/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.test.ts new file mode 100644 index 00000000..24b260d0 --- /dev/null +++ b/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { CustomerPaymentResolver } from "./customer-payment-resolver"; + +test("acepta un payment term activo aunque sea de sistema", async () => { + const paymentTermId = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID(); + + const resolver = new CustomerPaymentResolver({ + paymentMethodFinder: {} as never, + paymentTermFinder: { + findByIdInCompany: async () => + Result.ok( + Maybe.some({ + id: paymentTermId, + companyId, + name: "Contado", + description: Maybe.none(), + isActive: true, + }) + ), + }, + }); + + const result = await resolver.ensurePaymentTermById({ + companyId, + id: Maybe.some(paymentTermId), + }); + + assert.equal(result.isSuccess, true); +}); + +test("permite limpiar el payment term con null", async () => { + const resolver = new CustomerPaymentResolver({ + paymentMethodFinder: {} as never, + paymentTermFinder: { + findByIdInCompany: async () => Result.fail(new Error("unused")), + }, + }); + + const result = await resolver.ensurePaymentTermById({ + companyId: UniqueID.generateNewID(), + id: Maybe.none(), + }); + + assert.equal(result.isSuccess, true); +}); + +test("rechaza un payment term de otra company o inexistente", async () => { + const resolver = new CustomerPaymentResolver({ + paymentMethodFinder: {} as never, + paymentTermFinder: { + findByIdInCompany: async () => Result.ok(Maybe.none()), + }, + }); + + const result = await resolver.ensurePaymentTermById({ + companyId: UniqueID.generateNewID(), + id: Maybe.some(UniqueID.generateNewID()), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /not found/i); +}); + +test("rechaza nuevas asignaciones a payment terms inactivos", async () => { + const paymentTermId = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID(); + + const resolver = new CustomerPaymentResolver({ + paymentMethodFinder: {} as never, + paymentTermFinder: { + findByIdInCompany: async () => + Result.ok( + Maybe.some({ + id: paymentTermId, + companyId, + name: "30 dias", + description: Maybe.none(), + isActive: false, + }) + ), + }, + }); + + const result = await resolver.ensurePaymentTermById({ + companyId, + id: Maybe.some(paymentTermId), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /inactive/i); +}); diff --git a/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.ts b/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.ts index 9888a21e..54e3de56 100644 --- a/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.ts +++ b/modules/customers/src/api/application/services/catalog-resolver/customer-payment-resolver.ts @@ -31,6 +31,7 @@ export class CustomerPaymentResolver { public async ensurePaymentTermById(params: { companyId: UniqueID; id: Maybe; + transaction?: unknown; }): Promise> { if (params.id.isNone()) { return Result.ok(undefined); @@ -41,6 +42,7 @@ export class CustomerPaymentResolver { const result = await this.deps.paymentTermFinder.findByIdInCompany({ companyId: params.companyId, id, + transaction: params.transaction, }); if (result.isFailure) { @@ -51,6 +53,10 @@ export class CustomerPaymentResolver { return Result.fail(new Error(`Payment term not found: ${id.toString()}`)); } + if (!result.data.unwrap().isActive) { + return Result.fail(new Error(`Payment term is inactive: ${id.toString()}`)); + } + return Result.ok(undefined); } } diff --git a/modules/customers/src/api/application/services/customer-default-payment-term-resolver.test.ts b/modules/customers/src/api/application/services/customer-default-payment-term-resolver.test.ts new file mode 100644 index 00000000..c7c30cfa --- /dev/null +++ b/modules/customers/src/api/application/services/customer-default-payment-term-resolver.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { CustomerDefaultPaymentTermResolver } from "./customer-default-payment-term-resolver"; + +test("create sin payment_term_id usa el default de company", async () => { + const paymentTermId = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID(); + + const resolver = new CustomerDefaultPaymentTermResolver({ + companyFinder: { + findById: async () => + Result.ok( + Maybe.some({ + id: companyId.toPrimitive(), + legalName: "Acme", + tradeName: null, + tin: null, + slug: "acme", + email: null, + phone: null, + website: null, + defaultPaymentTermId: paymentTermId.toPrimitive(), + status: "active", + }) + ), + existsActiveById: async () => Result.ok(true), + }, + paymentResolver: { + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + }); + + const result = await resolver.resolveForCreate({ + companyId, + explicitPaymentTermId: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.isSome(), true); + assert.equal(result.data.unwrap().equals(paymentTermId), true); +}); + +test("create sin default devuelve null semantico", async () => { + const companyId = UniqueID.generateNewID(); + + const resolver = new CustomerDefaultPaymentTermResolver({ + companyFinder: { + findById: async () => + Result.ok( + Maybe.some({ + id: companyId.toPrimitive(), + legalName: "Acme", + tradeName: null, + tin: null, + slug: "acme", + email: null, + phone: null, + website: null, + defaultPaymentTermId: null, + status: "active", + }) + ), + existsActiveById: async () => Result.ok(true), + }, + paymentResolver: { + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + }); + + const result = await resolver.resolveForCreate({ + companyId, + explicitPaymentTermId: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.isNone(), true); +}); + +test("create con override explicito no usa el default de company", async () => { + const paymentTermId = UniqueID.generateNewID(); + const companyId = UniqueID.generateNewID(); + let ensureCalls = 0; + + const resolver = new CustomerDefaultPaymentTermResolver({ + companyFinder: { + findById: async () => Result.fail(new Error("should not load company default")), + existsActiveById: async () => Result.ok(true), + }, + paymentResolver: { + ensurePaymentTermById: async () => { + ensureCalls += 1; + return Result.ok(undefined); + }, + } as never, + }); + + const result = await resolver.resolveForCreate({ + companyId, + explicitPaymentTermId: Maybe.some(paymentTermId), + }); + + assert.equal(result.isSuccess, true); + assert.equal(ensureCalls, 1); + assert.equal(result.data.unwrap().equals(paymentTermId), true); +}); + +test("create falla si el default de company es inconsistente", async () => { + const companyId = UniqueID.generateNewID(); + + const resolver = new CustomerDefaultPaymentTermResolver({ + companyFinder: { + findById: async () => + Result.ok( + Maybe.some({ + id: companyId.toPrimitive(), + legalName: "Acme", + tradeName: null, + tin: null, + slug: "acme", + email: null, + phone: null, + website: null, + defaultPaymentTermId: "not-a-uuid", + status: "active", + }) + ), + existsActiveById: async () => Result.ok(true), + }, + paymentResolver: { + ensurePaymentTermById: async () => Result.ok(undefined), + } as never, + }); + + const result = await resolver.resolveForCreate({ + companyId, + explicitPaymentTermId: undefined, + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /invalid/i); +}); diff --git a/modules/customers/src/api/application/services/customer-default-payment-term-resolver.ts b/modules/customers/src/api/application/services/customer-default-payment-term-resolver.ts new file mode 100644 index 00000000..a17952ea --- /dev/null +++ b/modules/customers/src/api/application/services/customer-default-payment-term-resolver.ts @@ -0,0 +1,100 @@ +import type { ICompanyPublicFinder } from "@erp/companies/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import type { UniqueID as UniqueIDType } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import type { Maybe as MaybeType } from "@repo/rdx-utils"; + +import type { CustomerPaymentResolver } from "./catalog-resolver"; + +export interface ICustomerDefaultPaymentTermResolver { + resolveForCreate(params: { + companyId: UniqueIDType; + explicitPaymentTermId: MaybeType | undefined; + transaction?: unknown; + }): Promise, Error>>; +} + +/** + * Resuelve el plazo efectivo para altas de customer. + * + * - Si el caller envía `paymentTermId`, ese valor manda. + * - Si lo omite, se intenta aplicar el default de Company. + * - El valor final siempre se valida contra catálogos antes de persistir. + */ +export class CustomerDefaultPaymentTermResolver + implements ICustomerDefaultPaymentTermResolver +{ + public constructor( + private readonly deps: { + companyFinder: ICompanyPublicFinder; + paymentResolver: CustomerPaymentResolver; + } + ) {} + + public async resolveForCreate(params: { + companyId: UniqueIDType; + explicitPaymentTermId: MaybeType | undefined; + transaction?: unknown; + }): Promise, Error>> { + if (params.explicitPaymentTermId !== undefined) { + const ensureResult = await this.deps.paymentResolver.ensurePaymentTermById({ + companyId: params.companyId, + id: params.explicitPaymentTermId, + transaction: params.transaction, + }); + + if (ensureResult.isFailure) { + return Result.fail(ensureResult.error); + } + + return Result.ok(params.explicitPaymentTermId); + } + + const companyResult = await this.deps.companyFinder.findById({ + companyId: params.companyId, + transaction: params.transaction, + }); + + if (companyResult.isFailure) { + return Result.fail(companyResult.error); + } + + if (companyResult.data.isNone()) { + return Result.fail( + new Error("Company not found while resolving customer default payment term.") + ); + } + + const company = companyResult.data.unwrap(); + + if (company.defaultPaymentTermId == null) { + return Result.ok(Maybe.none()); + } + + const companyDefaultPaymentTermIdResult = UniqueID.create(company.defaultPaymentTermId); + if (companyDefaultPaymentTermIdResult.isFailure) { + return Result.fail( + new Error("Company default payment term is invalid.", { + cause: companyDefaultPaymentTermIdResult.error, + }) + ); + } + + const resolvedDefault = Maybe.some(companyDefaultPaymentTermIdResult.data); + const ensureResult = await this.deps.paymentResolver.ensurePaymentTermById({ + companyId: params.companyId, + id: resolvedDefault, + transaction: params.transaction, + }); + + if (ensureResult.isFailure) { + return Result.fail( + new Error("Company default payment term is inconsistent.", { + cause: ensureResult.error, + }) + ); + } + + return Result.ok(resolvedDefault); + } +} diff --git a/modules/customers/src/api/application/services/index.ts b/modules/customers/src/api/application/services/index.ts index 54e13177..ce35dcb0 100644 --- a/modules/customers/src/api/application/services/index.ts +++ b/modules/customers/src/api/application/services/index.ts @@ -1,4 +1,5 @@ export * from "./customer-creator"; +export * from "./customer-default-payment-term-resolver"; export * from "./customer-finder"; export * from "./customer-public-services.interface"; export * from "./customer-updater"; diff --git a/modules/customers/src/api/application/use-cases/create-customer.use-case.test.ts b/modules/customers/src/api/application/use-cases/create-customer.use-case.test.ts new file mode 100644 index 00000000..5ed45d3a --- /dev/null +++ b/modules/customers/src/api/application/use-cases/create-customer.use-case.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { CreateCustomerUseCase } from "./create-customer.use-case"; + +const buildTransactionManager = (): ITransactionManager => ({ + start: async () => undefined, + getTransaction: () => undefined, + complete: async (work: (transaction: unknown) => Promise) => work(undefined), + commit: async () => undefined, + rollback: async () => undefined, +}); + +const buildCreateDto = (customerId: UniqueID) => ({ + id: customerId.toPrimitive(), + is_company: true, + name: "Acme", + address: { + country: "ES", + }, + language_code: "es", + currency_code: "EUR", +}); + +test("create sin payment_term_id explicito usa el valor resuelto por company", async () => { + const companyId = UniqueID.generateNewID(); + const customerId = UniqueID.generateNewID(); + const defaultPaymentTermId = UniqueID.generateNewID(); + let receivedPaymentTermId = Maybe.none(); + + const useCase = new CreateCustomerUseCase({ + dtoMapper: { + map: () => + Result.ok({ + id: customerId, + props: { + paymentTermId: Maybe.none(), + }, + }), + } as never, + creator: { + create: async (params) => { + receivedPaymentTermId = params.props.paymentTermId; + return Result.ok({ id: customerId } as never); + }, + } as never, + defaultPaymentTermResolver: { + resolveForCreate: async (params) => { + assert.equal(params.explicitPaymentTermId, undefined); + return Result.ok(Maybe.some(defaultPaymentTermId)); + }, + }, + fullReadModelAssembler: { + assemble: async () => Result.ok({ customer: { id: customerId } } as never), + } as never, + fullSnapshotBuilder: { + toOutput: () => ({ id: customerId.toPrimitive() }), + } as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId, + dto: buildCreateDto(customerId), + }); + + assert.equal(result.isSuccess, true); + assert.equal(receivedPaymentTermId.isSome(), true); + assert.equal(receivedPaymentTermId.unwrap().equals(defaultPaymentTermId), true); +}); + +test("create con payment_term_id null explicito conserva el clear intencional", async () => { + const companyId = UniqueID.generateNewID(); + const customerId = UniqueID.generateNewID(); + let resolverReceivedExplicitPaymentTermId: Maybe | undefined; + let receivedPaymentTermId = Maybe.some(UniqueID.generateNewID()); + + const useCase = new CreateCustomerUseCase({ + dtoMapper: { + map: () => + Result.ok({ + id: customerId, + props: { + paymentTermId: Maybe.none(), + }, + }), + } as never, + creator: { + create: async (params) => { + receivedPaymentTermId = params.props.paymentTermId; + return Result.ok({ id: customerId } as never); + }, + } as never, + defaultPaymentTermResolver: { + resolveForCreate: async (params) => { + resolverReceivedExplicitPaymentTermId = params.explicitPaymentTermId; + return Result.ok(Maybe.none()); + }, + }, + fullReadModelAssembler: { + assemble: async () => Result.ok({ customer: { id: customerId } } as never), + } as never, + fullSnapshotBuilder: { + toOutput: () => ({ id: customerId.toPrimitive() }), + } as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId, + dto: { + ...buildCreateDto(customerId), + payment_term_id: null, + }, + }); + + assert.equal(result.isSuccess, true); + assert.notEqual(resolverReceivedExplicitPaymentTermId, undefined); + assert.equal(resolverReceivedExplicitPaymentTermId?.isNone(), true); + assert.equal(receivedPaymentTermId.isNone(), true); +}); diff --git a/modules/customers/src/api/application/use-cases/create-customer.use-case.ts b/modules/customers/src/api/application/use-cases/create-customer.use-case.ts index fac5d958..3ef8cd54 100644 --- a/modules/customers/src/api/application/use-cases/create-customer.use-case.ts +++ b/modules/customers/src/api/application/use-cases/create-customer.use-case.ts @@ -4,7 +4,10 @@ import { Result } from "@repo/rdx-utils"; import type { CreateCustomerRequestDTO } from "../../../common"; import type { ICreateCustomerInputMapper } from "../mappers"; -import type { ICustomerCreator } from "../services"; +import type { + ICustomerCreator, + ICustomerDefaultPaymentTermResolver, +} from "../services"; import type { ICustomerFullReadModelAssembler } from "../services/assemblers"; import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders"; @@ -18,6 +21,7 @@ export class CreateCustomerUseCase { private readonly deps: { dtoMapper: ICreateCustomerInputMapper; creator: ICustomerCreator; + defaultPaymentTermResolver: ICustomerDefaultPaymentTermResolver; fullReadModelAssembler: ICustomerFullReadModelAssembler; fullSnapshotBuilder: ICustomerFullSnapshotBuilder; transactionManager: ITransactionManager; @@ -36,7 +40,25 @@ export class CreateCustomerUseCase { return this.deps.transactionManager.complete(async (transaction: unknown) => { try { - const createResult = await this.deps.creator.create({ companyId, id, props, transaction }); + const effectivePaymentTermResult = await this.deps.defaultPaymentTermResolver.resolveForCreate({ + companyId, + explicitPaymentTermId: dto.payment_term_id === undefined ? undefined : props.paymentTermId, + transaction, + }); + + if (effectivePaymentTermResult.isFailure) { + return Result.fail(effectivePaymentTermResult.error); + } + + const createResult = await this.deps.creator.create({ + companyId, + id, + props: { + ...props, + paymentTermId: effectivePaymentTermResult.data, + }, + transaction, + }); if (createResult.isFailure) { return createResult; diff --git a/modules/customers/src/api/application/use-cases/update-customer.use-case.test.ts b/modules/customers/src/api/application/use-cases/update-customer.use-case.test.ts new file mode 100644 index 00000000..0dfb611d --- /dev/null +++ b/modules/customers/src/api/application/use-cases/update-customer.use-case.test.ts @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ITransactionManager } from "@erp/core/api"; +import { + Country, + CurrencyCode, + LanguageCode, + Name, + UniqueID, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { Customer, CustomerStatus } from "../../domain"; +import { UpdateCustomerUseCase } from "./update-customer.use-case"; + +const buildTransactionManager = (): ITransactionManager => ({ + start: async () => undefined, + getTransaction: () => undefined, + complete: async (work: (transaction: unknown) => Promise) => work(undefined), + commit: async () => undefined, + rollback: async () => undefined, +}); + +const buildCustomer = (paymentTermId?: UniqueID) => { + const companyId = UniqueID.generateNewID(); + const customerId = UniqueID.generateNewID(); + const nameResult = Name.create("Acme"); + const countryResult = Country.create("ES"); + const languageCodeResult = LanguageCode.create("es"); + const currencyCodeResult = CurrencyCode.create("EUR"); + + assert.equal(nameResult.isSuccess, true); + assert.equal(countryResult.isSuccess, true); + assert.equal(languageCodeResult.isSuccess, true); + assert.equal(currencyCodeResult.isSuccess, true); + + const customerResult = Customer.create( + { + companyId, + status: CustomerStatus.createActive(), + reference: Maybe.none(), + isCompany: true, + name: nameResult.data, + tradeName: Maybe.none(), + tin: Maybe.none(), + address: { + street: Maybe.none(), + street2: Maybe.none(), + city: Maybe.none(), + province: Maybe.none(), + postalCode: Maybe.none(), + country: Maybe.some(countryResult.data), + }, + emailPrimary: Maybe.none(), + emailSecondary: Maybe.none(), + phonePrimary: Maybe.none(), + phoneSecondary: Maybe.none(), + mobilePrimary: Maybe.none(), + mobileSecondary: Maybe.none(), + fax: Maybe.none(), + website: Maybe.none(), + legalRecord: Maybe.none(), + paymentMethodId: Maybe.none(), + paymentTermId: paymentTermId ? Maybe.some(paymentTermId) : Maybe.none(), + taxRegimeCode: Maybe.none(), + usesEquivalenceSurcharge: false, + usesRetention: false, + languageCode: languageCodeResult.data, + currencyCode: currencyCodeResult.data, + }, + customerId + ); + + assert.equal(customerResult.isSuccess, true); + + return { + companyId, + customerId, + customer: customerResult.data, + }; +}; + +test("update conserva un payment term inactivo ya asignado si no cambia", async () => { + const existingPaymentTermId = UniqueID.generateNewID(); + const { companyId, customerId, customer } = buildCustomer(existingPaymentTermId); + let validationCalls = 0; + + const useCase = new UpdateCustomerUseCase({ + dtoMapper: { + map: () => + Result.ok({ + paymentTermId: Maybe.some(existingPaymentTermId), + }), + }, + finder: { + findCustomerById: async () => Result.ok(customer), + findCustomerByTIN: async () => Result.fail(new Error("unused")), + customerExists: async () => Result.ok(true), + findCustomersByCriteria: async () => Result.fail(new Error("unused")), + }, + updater: { + update: async () => Result.ok(customer), + }, + paymentResolver: { + ensurePaymentTermById: async () => { + validationCalls += 1; + return Result.fail(new Error("should not validate unchanged payment term")); + }, + } as never, + fullReadModelAssembler: { + assemble: async () => + Result.ok({ + customer, + paymentMethod: Maybe.none(), + paymentTerm: Maybe.none(), + taxRegime: Maybe.none(), + }), + }, + fullSnapshotBuilder: { + toOutput: () => ({ id: customerId.toPrimitive() }), + } as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId, + customer_id: customerId.toPrimitive(), + dto: { + payment_term_id: existingPaymentTermId.toPrimitive(), + }, + }); + + assert.equal(result.isSuccess, true); + assert.equal(validationCalls, 0); +}); + +test("update rechaza asignar un nuevo payment term inactivo", async () => { + const existingPaymentTermId = UniqueID.generateNewID(); + const nextPaymentTermId = UniqueID.generateNewID(); + const { companyId, customerId, customer } = buildCustomer(existingPaymentTermId); + + const useCase = new UpdateCustomerUseCase({ + dtoMapper: { + map: () => + Result.ok({ + paymentTermId: Maybe.some(nextPaymentTermId), + }), + }, + finder: { + findCustomerById: async () => Result.ok(customer), + findCustomerByTIN: async () => Result.fail(new Error("unused")), + customerExists: async () => Result.ok(true), + findCustomersByCriteria: async () => Result.fail(new Error("unused")), + }, + updater: { + update: async () => Result.ok(customer), + }, + paymentResolver: { + ensurePaymentTermById: async () => Result.fail(new Error("Payment term is inactive")), + } as never, + fullReadModelAssembler: { + assemble: async () => Result.fail(new Error("unused")), + } as never, + fullSnapshotBuilder: { + toOutput: () => ({ id: customerId.toPrimitive() }), + } as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId, + customer_id: customerId.toPrimitive(), + dto: { + payment_term_id: nextPaymentTermId.toPrimitive(), + }, + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /inactive/i); +}); + +test("update permite limpiar el payment term con null explicito", async () => { + const existingPaymentTermId = UniqueID.generateNewID(); + const { companyId, customerId, customer } = buildCustomer(existingPaymentTermId); + let validationCalls = 0; + + const useCase = new UpdateCustomerUseCase({ + dtoMapper: { + map: () => + Result.ok({ + paymentTermId: Maybe.none(), + }), + }, + finder: { + findCustomerById: async () => Result.ok(customer), + findCustomerByTIN: async () => Result.fail(new Error("unused")), + customerExists: async () => Result.ok(true), + findCustomersByCriteria: async () => Result.fail(new Error("unused")), + }, + updater: { + update: async () => Result.ok(customer), + }, + paymentResolver: { + ensurePaymentTermById: async () => { + validationCalls += 1; + return Result.fail(new Error("should not validate explicit clear")); + }, + } as never, + fullReadModelAssembler: { + assemble: async () => + Result.ok({ + customer, + paymentMethod: Maybe.none(), + paymentTerm: Maybe.none(), + taxRegime: Maybe.none(), + }), + }, + fullSnapshotBuilder: { + toOutput: () => ({ id: customerId.toPrimitive() }), + } as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId, + customer_id: customerId.toPrimitive(), + dto: { + payment_term_id: null, + }, + }); + + assert.equal(result.isSuccess, true); + assert.equal(validationCalls, 0); +}); diff --git a/modules/customers/src/api/application/use-cases/update-customer.use-case.ts b/modules/customers/src/api/application/use-cases/update-customer.use-case.ts index 01fa45cf..89f2a15c 100644 --- a/modules/customers/src/api/application/use-cases/update-customer.use-case.ts +++ b/modules/customers/src/api/application/use-cases/update-customer.use-case.ts @@ -3,9 +3,11 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { UpdateCustomerByIdRequestDTO } from "../../../common/dto"; +import type { Customer } from "../../domain"; import type { CustomerPatchProps } from "../../domain"; import type { IUpdateCustomerInputMapper } from "../mappers"; -import type { ICustomerUpdater } from "../services"; +import type { ICustomerFinder, ICustomerUpdater } from "../services"; +import type { CustomerPaymentResolver } from "../services/catalog-resolver"; import type { ICustomerFullReadModelAssembler } from "../services/assemblers"; import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders"; @@ -19,7 +21,9 @@ export class UpdateCustomerUseCase { constructor( private readonly deps: { dtoMapper: IUpdateCustomerInputMapper; + finder: ICustomerFinder; updater: ICustomerUpdater; + paymentResolver: CustomerPaymentResolver; fullReadModelAssembler: ICustomerFullReadModelAssembler; fullSnapshotBuilder: ICustomerFullSnapshotBuilder; transactionManager: ITransactionManager; @@ -45,6 +49,28 @@ export class UpdateCustomerUseCase { return this.deps.transactionManager.complete(async (transaction: unknown) => { try { + const existingCustomerResult = await this.deps.finder.findCustomerById( + companyId, + id, + transaction + ); + + if (existingCustomerResult.isFailure) { + return Result.fail(existingCustomerResult.error); + } + + const paymentTermValidationResult = await this.validatePaymentTermChange({ + companyId, + dto, + existingCustomer: existingCustomerResult.data, + patchProps, + transaction, + }); + + if (paymentTermValidationResult.isFailure) { + return Result.fail(paymentTermValidationResult.error); + } + const updateResult = await this.deps.updater.update({ companyId, id, @@ -72,4 +98,38 @@ export class UpdateCustomerUseCase { } }); } + + private async validatePaymentTermChange(params: { + companyId: UniqueID; + dto: UpdateCustomerByIdRequestDTO; + existingCustomer: Customer; + patchProps: CustomerPatchProps; + transaction: unknown; + }): Promise> { + if (params.dto.payment_term_id === undefined) { + return Result.ok(undefined); + } + + if (params.patchProps.paymentTermId?.isNone()) { + return Result.ok(undefined); + } + + const nextPaymentTermId = params.patchProps.paymentTermId; + if (!nextPaymentTermId || nextPaymentTermId.isNone()) { + return Result.ok(undefined); + } + + if ( + params.existingCustomer.paymentTermId.isSome() && + params.existingCustomer.paymentTermId.unwrap().equals(nextPaymentTermId.unwrap()) + ) { + return Result.ok(undefined); + } + + return this.deps.paymentResolver.ensurePaymentTermById({ + companyId: params.companyId, + id: nextPaymentTermId, + transaction: params.transaction, + }); + } } diff --git a/modules/customers/src/api/infrastructure/di/customer-company-deps.di.ts b/modules/customers/src/api/infrastructure/di/customer-company-deps.di.ts new file mode 100644 index 00000000..a65ced15 --- /dev/null +++ b/modules/customers/src/api/infrastructure/di/customer-company-deps.di.ts @@ -0,0 +1,20 @@ +import type { CompaniesPublicServicesType } from "@erp/companies/api"; +import type { ModuleParams } from "@erp/core/api"; + +export function resolveCustomerCompanyDeps(params: ModuleParams): { + company: { + finder: CompaniesPublicServicesType["finder"]; + }; +} { + const company = params.getService("companies:general"); + + if (!company?.finder) { + throw new Error("Missing public service: companies:general.finder"); + } + + return { + company: { + finder: company.finder, + }, + }; +} diff --git a/modules/customers/src/api/infrastructure/di/customers.di.ts b/modules/customers/src/api/infrastructure/di/customers.di.ts index a554da07..9757a73b 100644 --- a/modules/customers/src/api/infrastructure/di/customers.di.ts +++ b/modules/customers/src/api/infrastructure/di/customers.di.ts @@ -17,8 +17,10 @@ import { } from "../../application"; import { buildCustomerReadModelAssemblers } from "../../application/di"; import { buildCustomerCatalogResolvers } from "../../application/di/customer-catalog-resolvers.di"; +import { CustomerDefaultPaymentTermResolver } from "../../application/services"; import { resolveCustomerCatalogsDeps } from "./customer-catalog-deps.di"; +import { resolveCustomerCompanyDeps } from "./customer-company-deps.di"; import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di"; import { buildCustomerRepository } from "./customer-repositories.di"; @@ -43,6 +45,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter const { database } = params; const catalogs = resolveCustomerCatalogsDeps(params); + const companies = resolveCustomerCompanyDeps(params); const transactionManager = buildTransactionManager(database); const persistenceMappers = buildCustomerPersistenceMappers(); @@ -68,6 +71,10 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter const finder = buildCustomerFinder({ repository }); const creator = buildCustomerCreator({ repository }); const updater = buildCustomerUpdater({ repository }); + const defaultPaymentTermResolver = new CustomerDefaultPaymentTermResolver({ + companyFinder: companies.company.finder, + paymentResolver: catalogResolvers.paymentResolver, + }); //const documentGeneratorPipeline = buildCustomerDocumentService(params); @@ -93,6 +100,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter buildCreateCustomerUseCase({ creator, dtoMapper: inputMappers.createInputMapper, + defaultPaymentTermResolver, fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, @@ -100,7 +108,9 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter updateCustomer: () => buildUpdateCustomerUseCase({ + finder, updater, + paymentResolver: catalogResolvers.paymentResolver, dtoMapper: inputMappers.updateInputMapper, fullReadModelAssembler: readModelAssemblers.full, fullSnapshotBuilder: snapshotBuilders.full, diff --git a/modules/customers/src/web/create/controllers/use-customer-create-fiscal-controller.ts b/modules/customers/src/web/create/controllers/use-customer-create-fiscal-controller.ts index 20e502b0..41990f82 100644 --- a/modules/customers/src/web/create/controllers/use-customer-create-fiscal-controller.ts +++ b/modules/customers/src/web/create/controllers/use-customer-create-fiscal-controller.ts @@ -1,15 +1,36 @@ +import type { CompanyResponseDTO } from "@erp/companies"; +import { useDataSource } from "@erp/core/hooks"; import { getPaymentMethodOptions, usePaymentMethodsListQuery, } from "@erp/catalogs/client/payment-methods"; import { getPaymentTermOptions, - usePaymentTermsListQuery, + usePaymentTermListQuery, } from "@erp/catalogs/client/payment-terms"; import { getTaxRegimeOptions, useTaxRegimesListQuery } from "@erp/catalogs/client/tax-regimes"; +import { useIdentityAuthSession } from "@erp/identity/client"; +import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; export const useCustomerCreateFiscalController = () => { + const dataSource = useDataSource(); + const { activeCompany } = useIdentityAuthSession(); + + const companyQuery = useQuery({ + queryKey: ["customers", "create", "company-default-payment-term", activeCompany?.id], + enabled: Boolean(activeCompany?.id), + queryFn: async ({ signal }) => { + const dto = await dataSource.getOne( + "companies", + activeCompany?.id as string, + { signal } + ); + + return dto.default_payment_term_id; + }, + }); + const paymentMethodsQuery = usePaymentMethodsListQuery({ criteria: { pageSize: 999, @@ -23,7 +44,7 @@ export const useCustomerCreateFiscalController = () => { }, }); - const paymentTermsQuery = usePaymentTermsListQuery({ + const paymentTermsQuery = usePaymentTermListQuery({ criteria: { filters: [ { @@ -63,14 +84,17 @@ export const useCustomerCreateFiscalController = () => { paymentMethodOptions, paymentTermOptions, taxRegimeOptions, + companyDefaultPaymentTermId: companyQuery.data ?? null, - isLoading: paymentMethodsQuery.isLoading || paymentTermsQuery.isLoading, + isLoading: + paymentMethodsQuery.isLoading || paymentTermsQuery.isLoading || companyQuery.isLoading, - isFetching: paymentMethodsQuery.isFetching || paymentTermsQuery.isFetching, + isFetching: + paymentMethodsQuery.isFetching || paymentTermsQuery.isFetching || companyQuery.isFetching, - isError: paymentMethodsQuery.isError || paymentTermsQuery.isError, + isError: paymentMethodsQuery.isError || paymentTermsQuery.isError || companyQuery.isError, - error: paymentMethodsQuery.error ?? paymentTermsQuery.error, + error: paymentMethodsQuery.error ?? paymentTermsQuery.error ?? companyQuery.error, }; }; diff --git a/modules/customers/src/web/create/controllers/use-customer-create.controller.ts b/modules/customers/src/web/create/controllers/use-customer-create.controller.ts index fbb1e797..d297ada4 100644 --- a/modules/customers/src/web/create/controllers/use-customer-create.controller.ts +++ b/modules/customers/src/web/create/controllers/use-customer-create.controller.ts @@ -12,7 +12,7 @@ import { showSuccessToast, showWarningToast, } from "@repo/rdx-ui/helpers"; -import { useId } from "react"; +import { useEffect, useId } from "react"; import type { FieldErrors } from "react-hook-form"; import { useTranslation } from "../../i18n"; @@ -62,6 +62,30 @@ export const useCreateCustomerController = (options?: UseCustomerCreateControlle disabled: isCreating, }); + const fiscalCtrl = useCustomerCreateFiscalController(); + + useEffect(() => { + const companyDefaultPaymentTermId = fiscalCtrl.companyDefaultPaymentTermId; + + if (!companyDefaultPaymentTermId) { + return; + } + + if (form.formState.dirtyFields.paymentTermId) { + return; + } + + if (form.getValues("paymentTermId") !== "") { + return; + } + + form.setValue("paymentTermId", companyDefaultPaymentTermId, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + }, [fiscalCtrl.companyDefaultPaymentTermId, form]); + /** Handlers */ const resetForm = () => { @@ -78,7 +102,10 @@ export const useCreateCustomerController = (options?: UseCustomerCreateControlle return; } - const data = buildCustomerCreateData(formData); + const data = buildCustomerCreateData(formData, { + companyDefaultPaymentTermId: fiscalCtrl.companyDefaultPaymentTermId, + isPaymentTermDirty: Boolean(form.formState.dirtyFields.paymentTermId), + }); const customerId = UniqueID.generateNewID().toString(); const params = buildCreateCustomerParams(customerId, data); @@ -150,8 +177,6 @@ export const useCreateCustomerController = (options?: UseCustomerCreateControlle submitHandler(event); }; - const fiscalCtrl = useCustomerCreateFiscalController(); - return { // form form, diff --git a/modules/customers/src/web/create/entities/customer-create-data.entity.ts b/modules/customers/src/web/create/entities/customer-create-data.entity.ts index 7b2c67b1..3c1fa134 100644 --- a/modules/customers/src/web/create/entities/customer-create-data.entity.ts +++ b/modules/customers/src/web/create/entities/customer-create-data.entity.ts @@ -35,7 +35,7 @@ export type CustomerCreateData = { legalRecord?: string; paymentMethodId?: string; - paymentTermId?: string; + paymentTermId?: string | null; taxRegimeCode?: string; usesEquivalenceSurcharge: boolean; diff --git a/modules/customers/src/web/create/utils/build-customer-create-data.test.ts b/modules/customers/src/web/create/utils/build-customer-create-data.test.ts new file mode 100644 index 00000000..8dd68190 --- /dev/null +++ b/modules/customers/src/web/create/utils/build-customer-create-data.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildCustomerCreateData } from "./build-customer-create-data"; +import { buildCustomerCreateDefault } from "./build-customer-create-defaults"; + +test("si el usuario limpia el payment term heredado envia null explicito", () => { + const form = { + ...buildCustomerCreateDefault(), + name: "Acme", + paymentTermId: "", + }; + + const result = buildCustomerCreateData(form, { + companyDefaultPaymentTermId: "22222222-2222-4222-8222-222222222222", + isPaymentTermDirty: true, + }); + + assert.equal(result.paymentTermId, null); +}); + +test("si no hay seleccion ni cambio explicito omite paymentTermId", () => { + const form = { + ...buildCustomerCreateDefault(), + name: "Acme", + paymentTermId: "", + }; + + const result = buildCustomerCreateData(form, { + companyDefaultPaymentTermId: null, + isPaymentTermDirty: false, + }); + + assert.equal(result.paymentTermId, undefined); +}); diff --git a/modules/customers/src/web/create/utils/build-customer-create-data.ts b/modules/customers/src/web/create/utils/build-customer-create-data.ts index fb002ada..5d62e80f 100644 --- a/modules/customers/src/web/create/utils/build-customer-create-data.ts +++ b/modules/customers/src/web/create/utils/build-customer-create-data.ts @@ -15,8 +15,16 @@ import { buildCustomerCreateDefault } from "./build-customer-create-defaults"; * @returns */ -export const buildCustomerCreateData = (formData: CustomerCreateForm): CustomerCreateData => { +export const buildCustomerCreateData = ( + formData: CustomerCreateForm, + options?: { + companyDefaultPaymentTermId?: string | null; + isPaymentTermDirty?: boolean; + } +): CustomerCreateData => { const defaults = buildCustomerCreateDefault(); + const resolvedPaymentTermId = toNullableText(formData.paymentTermId); + return { reference: toNullableText(formData.reference) ?? undefined, isCompany: formData.isCompany, @@ -44,7 +52,12 @@ export const buildCustomerCreateData = (formData: CustomerCreateForm): CustomerC legalRecord: toNullableText(formData.legalRecord) ?? undefined, paymentMethodId: toNullableText(formData.paymentMethodId) ?? undefined, - paymentTermId: toNullableText(formData.paymentTermId) ?? undefined, + paymentTermId: + resolvedPaymentTermId !== null + ? resolvedPaymentTermId + : options?.isPaymentTermDirty && options.companyDefaultPaymentTermId + ? null + : undefined, taxRegimeCode: toNullableText(formData.taxRegimeCode) ?? undefined, usesEquivalenceSurcharge: diff --git a/modules/customers/src/web/shared/entities/customer.entity.ts b/modules/customers/src/web/shared/entities/customer.entity.ts index c08e7c40..11966cf8 100644 --- a/modules/customers/src/web/shared/entities/customer.entity.ts +++ b/modules/customers/src/web/shared/entities/customer.entity.ts @@ -46,6 +46,7 @@ export interface Customer { paymentTerm: { id: string; + name: string; description: string; } | null; diff --git a/modules/customers/src/web/update/controllers/use-customer-update-fiscal-controller.ts b/modules/customers/src/web/update/controllers/use-customer-update-fiscal-controller.ts index 1e5e2634..44028c5e 100644 --- a/modules/customers/src/web/update/controllers/use-customer-update-fiscal-controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update-fiscal-controller.ts @@ -4,12 +4,17 @@ import { } from "@erp/catalogs/client/payment-methods"; import { getPaymentTermOptions, - usePaymentTermsListQuery, + usePaymentTermListQuery, } from "@erp/catalogs/client/payment-terms"; import { getTaxRegimeOptions, useTaxRegimesListQuery } from "@erp/catalogs/client/tax-regimes"; +import type { Customer } from "../../shared"; import { useMemo } from "react"; +import { useTranslation } from "../../i18n"; -export const useCustomerUpdateFiscalController = () => { +export const useCustomerUpdateFiscalController = (params?: { + currentPaymentTerm?: Customer["paymentTerm"]; +}) => { + const { t } = useTranslation(); const paymentMethodsQuery = usePaymentMethodsListQuery({ criteria: { pageSize: 999, @@ -23,7 +28,7 @@ export const useCustomerUpdateFiscalController = () => { }, }); - const paymentTermsQuery = usePaymentTermsListQuery({ + const paymentTermsQuery = usePaymentTermListQuery({ criteria: { filters: [ { @@ -52,8 +57,25 @@ export const useCustomerUpdateFiscalController = () => { }, [paymentMethodsQuery.data?.items]); const paymentTermOptions = useMemo(() => { - return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []); - }, [paymentTermsQuery.data?.items]); + const options = getPaymentTermOptions(paymentTermsQuery.data?.items ?? []); + const currentPaymentTerm = params?.currentPaymentTerm; + + if (!currentPaymentTerm) { + return options; + } + + if (options.some((option) => option.value === currentPaymentTerm.id)) { + return options; + } + + return [ + { + value: currentPaymentTerm.id, + label: `${currentPaymentTerm.name} (${t("common.states.inactive")})`, + }, + ...options, + ]; + }, [params?.currentPaymentTerm, paymentTermsQuery.data?.items, t]); const taxRegimeOptions = useMemo(() => { return getTaxRegimeOptions(taxRegimesQuery.data?.items ?? []); diff --git a/modules/customers/src/web/update/controllers/use-customer-update.controller.ts b/modules/customers/src/web/update/controllers/use-customer-update.controller.ts index 6e10c461..321b8d6d 100644 --- a/modules/customers/src/web/update/controllers/use-customer-update.controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update.controller.ts @@ -213,7 +213,9 @@ export const useCustomerUpdateController = ( submitHandler(event); }; - const fiscalCtrl = useCustomerUpdateFiscalController(); + const fiscalCtrl = useCustomerUpdateFiscalController({ + currentPaymentTerm: customerData?.paymentTerm ?? null, + }); return { // form diff --git a/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts b/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts index 55e773be..32948e04 100644 --- a/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts +++ b/modules/identity/src/api/infrastructure/express/middlewares/require-company-context.middleware.ts @@ -14,7 +14,7 @@ const COMPANY_HEADER_NAME = "X-Company-Id"; export function requireCompanyContext(params: { companyAccessChecker: IIdentityCompanyAccessChecker; - companyFinder: IIdentityTenantCompanyFinder; + companyFinder?: IIdentityTenantCompanyFinder; }) { return async (req: Request, res: Response, next: NextFunction) => { const user = (req as RequestWithAuth).user; @@ -71,23 +71,27 @@ export function requireCompanyContext(params: { ); } - const companyExistsResult = await params.companyFinder.existsActiveById({ - companyId, - }); - if (companyExistsResult.isFailure) { - return ExpressController.errorResponse( - new InternalApiError(companyExistsResult.error.message), - req, - res - ); - } + // Algunos módulos solo necesitan validar membership y no pueden depender + // de companies sin introducir un ciclo de módulos. + if (params.companyFinder) { + const companyExistsResult = await params.companyFinder.existsActiveById({ + companyId, + }); + if (companyExistsResult.isFailure) { + return ExpressController.errorResponse( + new InternalApiError(companyExistsResult.error.message), + req, + res + ); + } - if (!companyExistsResult.data) { - return ExpressController.errorResponse( - new ForbiddenApiError("Company context required"), - req, - res - ); + if (!companyExistsResult.data) { + return ExpressController.errorResponse( + new ForbiddenApiError("Company context required"), + req, + res + ); + } } user.companyId = companyId; diff --git a/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts b/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts index e1162563..db9647c6 100644 --- a/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts +++ b/modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts @@ -2,6 +2,7 @@ import type { StartParams } from "@erp/core/api"; import type { RequestHandler } from "express"; import type { IIdentityPublicServices } from "../../../application"; +import { requireCompanyContext } from "./require-company-context.middleware"; type CompaniesGeneralServices = { finder: { @@ -27,3 +28,14 @@ export function requireIdentityTenant(params: StartParams): RequestHandler[] { }), ]; } + +export function requireIdentityTenantMembership(params: StartParams): RequestHandler[] { + const identityServices = params.getService("identity:general"); + + return [ + ...identityServices.auth.authenticatedOnly(), + requireCompanyContext({ + companyAccessChecker: identityServices.companyAccess, + }), + ]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36ada777..20af854d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,9 @@ importers: '@erp/catalogs': specifier: workspace:* version: link:../../modules/catalogs + '@erp/companies': + specifier: workspace:* + version: link:../../modules/companies '@erp/core': specifier: workspace:* version: link:../../modules/core @@ -486,21 +489,48 @@ importers: modules/companies: dependencies: + '@erp/catalogs': + specifier: workspace:* + version: link:../catalogs '@erp/core': specifier: workspace:* version: link:../core '@erp/identity': specifier: workspace:* version: link:../identity + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) + '@repo/i18next': + specifier: workspace:* + version: link:../../packages/i18n '@repo/rdx-ddd': specifier: workspace:* version: link:../../packages/rdx-ddd + '@repo/rdx-ui': + specifier: workspace:* + version: link:../../packages/rdx-ui '@repo/rdx-utils': specifier: workspace:* version: link:../../packages/rdx-utils + '@repo/shadcn-ui': + specifier: workspace:* + version: link:../../packages/shadcn-ui + '@tanstack/react-query': + specifier: ^5.98.0 + version: 5.100.14(react@19.2.6) express: specifier: ^4.22.1 version: 4.22.2 + react-hook-form: + specifier: ^7.72.1 + version: 7.76.1(react@19.2.6) + react-i18next: + specifier: ^17.0.2 + version: 17.0.8(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.2) + react-router-dom: + specifier: ^7.14.0 + version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4) @@ -511,6 +541,18 @@ importers: '@types/express': specifier: ^4.17.21 version: 4.17.25 + '@types/react': + specifier: ^19.2.14 + version: 19.2.15 + '@types/react-router-dom': + specifier: ^5.3.3 + version: 5.3.3 + react: + specifier: ^19.2.5 + version: 19.2.6 + react-dom: + specifier: ^19.2.5 + version: 19.2.6(react@19.2.6) rimraf: specifier: ^6.1.3 version: 6.1.3 @@ -747,6 +789,9 @@ importers: '@erp/catalogs': specifier: workspace:* version: link:../catalogs + '@erp/companies': + specifier: workspace:* + version: link:../companies '@erp/core': specifier: workspace:* version: link:../core