feat: add proforma customer change evaluation utility
- Implemented `evaluateProformaCustomerChange` function to assess changes in customer details for proforma invoices. - Introduced types for customer change details, blockers, field differences, and fiscal targets. - Added utility functions for normalizing text and formatting recipient information. test: add unit tests for customer payment resolver - Created tests for `CustomerPaymentResolver` to validate payment term retrieval and handling of inactive terms. - Ensured proper behavior when clearing payment terms and handling non-existent terms. test: add unit tests for customer default payment term resolver - Developed tests for `CustomerDefaultPaymentTermResolver` to verify default payment term resolution logic. - Included scenarios for creating customers with and without explicit payment term IDs. feat: implement customer default payment term resolver - Created `CustomerDefaultPaymentTermResolver` to manage default payment term resolution during customer creation. - Integrated with company finder and payment resolver services. test: add unit tests for create customer use case - Implemented tests for `CreateCustomerUseCase` to ensure correct handling of payment term IDs during customer creation. - Validated behavior when no explicit payment term ID is provided. test: add unit tests for update customer use case - Developed tests for `UpdateCustomerUseCase` to verify payment term handling during customer updates. - Ensured that inactive payment terms are not assigned and that explicit null values are respected. feat: add dependency injection for customer company services - Implemented DI for customer company dependencies to facilitate service resolution. test: add tests for customer create data utility - Created tests for `buildCustomerCreateData` to validate payment term handling based on user input.
This commit is contained in:
parent
ee48b19610
commit
cf07de4836
13
AGENTS.md
13
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.
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
494
docs/catalogs/payment-terms.md
Normal file
494
docs/catalogs/payment-terms.md
Normal file
@ -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”.
|
||||
24
docs/companies/sql/add-company-default-payment-term.sql
Normal file
24
docs/companies/sql/add-company-default-payment-term.sql
Normal file
@ -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;
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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<Result<boolean, Error>>;
|
||||
};
|
||||
}): IPaymentTermStatusChanger => {
|
||||
const { repository } = params;
|
||||
const { repository, companyDefaultUsageChecker } = params;
|
||||
|
||||
return new PaymentTermStatusChanger(repository);
|
||||
return new PaymentTermStatusChanger({ repository, companyDefaultUsageChecker });
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,4 +9,11 @@ export interface PaymentTermPublicModel {
|
||||
description: Maybe<string>;
|
||||
|
||||
isActive: boolean;
|
||||
dueRules: Array<{
|
||||
dueDays: string;
|
||||
percentage: {
|
||||
value: string;
|
||||
scale: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -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<string, PaymentTerm>();
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Result<boolean, Error>>;
|
||||
};
|
||||
}
|
||||
) {}
|
||||
|
||||
public async changeStatus(params: {
|
||||
paymentTerm: PaymentTerm;
|
||||
@ -23,6 +35,27 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger {
|
||||
}): Promise<Result<PaymentTerm, Error>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicSe
|
||||
export const catalogsAPIModule: IModuleServer = {
|
||||
name: "catalogs",
|
||||
version: "1.0.0",
|
||||
dependencies: ["identity", "companies"],
|
||||
dependencies: ["identity"],
|
||||
|
||||
/**
|
||||
* Fase de SETUP
|
||||
|
||||
@ -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, type Request, type Response, Router } from "express";
|
||||
|
||||
import {
|
||||
@ -34,7 +34,7 @@ export const paymentMethodsRouter = (params: StartParams) => {
|
||||
|
||||
// ----------------------------------------------
|
||||
|
||||
router.use(...requireIdentityTenant(params));
|
||||
router.use(...requireIdentityTenantMembership(params));
|
||||
|
||||
// ----------------------------------------------
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
"/",
|
||||
|
||||
@ -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<string, string> | 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);
|
||||
});
|
||||
@ -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.")
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -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(
|
||||
"/",
|
||||
|
||||
@ -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(
|
||||
"/",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,5 +7,6 @@ export interface CompanyPublicModel {
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
defaultPaymentTermId: string | null;
|
||||
status: "active" | "disabled";
|
||||
}
|
||||
|
||||
@ -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>): 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);
|
||||
});
|
||||
@ -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<UniqueID>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<void, Error>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<UniqueID>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<void, Error>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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 <T>(work: (transaction: unknown) => Promise<T>) => 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);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 <T>(work: (transaction: unknown) => Promise<T>) => 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<UniqueID> | 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);
|
||||
});
|
||||
@ -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<CompanyPatc
|
||||
patchProps.website = websiteResult.data;
|
||||
}
|
||||
|
||||
if (dto.default_payment_term_id !== undefined) {
|
||||
const defaultPaymentTermIdResult = maybeFromNullableResult(
|
||||
dto.default_payment_term_id,
|
||||
(value) => UniqueID.create(value)
|
||||
);
|
||||
if (defaultPaymentTermIdResult.isFailure) {
|
||||
return Result.fail(defaultPaymentTermIdResult.error);
|
||||
}
|
||||
patchProps.defaultPaymentTermId = defaultPaymentTermIdResult.data;
|
||||
}
|
||||
|
||||
return Result.ok(patchProps);
|
||||
}
|
||||
|
||||
function mapDefaultPaymentTermId(
|
||||
dto: UpdateCompanyRequestDTO
|
||||
): Result<Maybe<UniqueID> | 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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ export interface ICompanyCreateProps {
|
||||
email: Maybe<EmailAddress>;
|
||||
phone: Maybe<PhoneNumber>;
|
||||
website: Maybe<URLAddress>;
|
||||
defaultPaymentTermId: Maybe<UniqueID>;
|
||||
status: CompanyStatus;
|
||||
createdAt: UtcDate;
|
||||
updatedAt: UtcDate;
|
||||
@ -35,6 +36,7 @@ export type CompanyPatchProps = Partial<{
|
||||
email: Maybe<EmailAddress>;
|
||||
phone: Maybe<PhoneNumber>;
|
||||
website: Maybe<URLAddress>;
|
||||
defaultPaymentTermId: Maybe<UniqueID>;
|
||||
}> & {
|
||||
updatedAt: UtcDate;
|
||||
};
|
||||
@ -107,6 +109,10 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
||||
return this.props.website;
|
||||
}
|
||||
|
||||
public get defaultPaymentTermId(): Maybe<UniqueID> {
|
||||
return this.props.defaultPaymentTermId;
|
||||
}
|
||||
|
||||
public get status(): CompanyStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
@ -167,6 +173,14 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<IIdentityPublicServices>("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,
|
||||
}),
|
||||
|
||||
@ -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<CatalogsPublicServicesType["paymentTerms"]>("catalogs:paymentTerms");
|
||||
|
||||
if (!paymentTerm?.finder) {
|
||||
throw new Error("Missing public service: catalogs:paymentTerms.finder");
|
||||
}
|
||||
|
||||
return {
|
||||
paymentTerm: {
|
||||
finder: paymentTerm.finder,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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<typeof UpdateCompanyRequestSchema>;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
52
modules/companies/src/common/locales/en.json
Normal file
52
modules/companies/src/common/locales/en.json
Normal file
@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
modules/companies/src/common/locales/es.json
Normal file
52
modules/companies/src/common/locales/es.json
Normal file
@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
modules/companies/src/web/company-routes.tsx
Normal file
22
modules/companies/src/web/company-routes.tsx
Normal file
@ -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: <CompanyDetailsSettingsPage />,
|
||||
},
|
||||
];
|
||||
};
|
||||
24
modules/companies/src/web/i18n.ts
Normal file
24
modules/companies/src/web/i18n.ts
Normal file
@ -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,
|
||||
});
|
||||
};
|
||||
30
modules/companies/src/web/manifest.ts
Normal file
30
modules/companies/src/web/manifest.ts
Normal file
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-company-details-settings-controller";
|
||||
@ -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<CompanySettingsForm>(() => {
|
||||
if (!company) {
|
||||
return {
|
||||
defaultPaymentTermId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return buildCompanySettingsForm(company);
|
||||
}, [company]);
|
||||
|
||||
const form = useHookForm<CompanySettingsForm>({
|
||||
resolverSchema: CompanySettingsFormSchema,
|
||||
initialValues,
|
||||
disabled: mutation.isPending,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!company) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(buildCompanySettingsForm(company), {
|
||||
keepDirty: false,
|
||||
});
|
||||
}, [company, form]);
|
||||
|
||||
const paymentTermOptions = useMemo<SelectFieldItem[]>(() => {
|
||||
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<CompanySettingsForm>) => {
|
||||
focusFirstInputFormError(errors, form);
|
||||
showWarningToast(
|
||||
t("settings.companyDetails.feedback.validation.title"),
|
||||
getFirstFormErrorMessage(errors, form) ??
|
||||
t("settings.companyDetails.feedback.validation.message")
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const onSubmit = (event: SubmitEvent<HTMLFormElement>) => {
|
||||
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),
|
||||
]);
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -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<typeof CompanySettingsFormSchema>;
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-settings-form.schema";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./ui/pages/company-details-settings-page";
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-settings.adapter";
|
||||
@ -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<CompanyResponseDTO> => {
|
||||
return dataSource.getOne<CompanyResponseDTO>("companies", params.companyId, {
|
||||
signal: params.signal,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./get-company-settings.api";
|
||||
export * from "./update-company-settings.api";
|
||||
@ -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<CompanyResponseDTO> => {
|
||||
return dataSource.updateOne<UpdateCompanyRequestDTO, CompanyResponseDTO>(
|
||||
"companies",
|
||||
params.companyId,
|
||||
params.data
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
export interface CompanySettings {
|
||||
id: string;
|
||||
legalName: string;
|
||||
tradeName: string | null;
|
||||
defaultPaymentTermId: string | null;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./company-settings.entity";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./use-company-settings-query";
|
||||
export * from "./use-company-settings-update-mutation";
|
||||
@ -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;
|
||||
@ -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<CompanySettings, DefaultError> => {
|
||||
const dataSource = useDataSource();
|
||||
|
||||
return useQuery<CompanySettings, DefaultError>({
|
||||
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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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<CompanySettings, DefaultError, UpdateCompanySettingsParams>({
|
||||
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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 <AppContent className="py-2" />;
|
||||
}
|
||||
|
||||
if (ctrl.pageError) {
|
||||
return (
|
||||
<AppContent className="py-2">
|
||||
<PageErrorState
|
||||
description={t("settings.companyDetails.pageError.description")}
|
||||
primaryAction={{
|
||||
label: ctrl.isRetrying
|
||||
? t("settings.companyDetails.pageError.retrying")
|
||||
: t("settings.companyDetails.pageError.retry"),
|
||||
onClick: ctrl.retry,
|
||||
isLoading: ctrl.isRetrying,
|
||||
}}
|
||||
title={t("settings.companyDetails.pageError.title")}
|
||||
variant="server"
|
||||
/>
|
||||
</AppContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...ctrl.form}>
|
||||
<UnsavedChangesProvider isDirty={ctrl.form.formState.isDirty}>
|
||||
<div className="flex min-h-[24rem] flex-col gap-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("settings.companyDetails.title")}
|
||||
</h2>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
{t("settings.companyDetails.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{ctrl.companyQuery.isError ? (
|
||||
<ErrorAlert
|
||||
message={(ctrl.companyQuery.error as Error)?.message ?? undefined}
|
||||
title={t("settings.companyDetails.feedback.error.title")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form className="flex flex-col gap-6" id={ctrl.formId} noValidate onSubmit={ctrl.onSubmit}>
|
||||
<RdxEditorSection
|
||||
description={t("settings.companyDetails.sections.paymentTerm.description")}
|
||||
icon={<LandmarkIcon className="size-5" />}
|
||||
title={t("settings.companyDetails.sections.paymentTerm.title")}
|
||||
variant="panel"
|
||||
>
|
||||
<RdxFormGrid className="md:grid-cols-2">
|
||||
<SelectField
|
||||
clearable
|
||||
className="col-span-full"
|
||||
description={t("settings.companyDetails.sections.paymentTerm.field.description")}
|
||||
disabled={ctrl.isSaving}
|
||||
emptyValue=""
|
||||
items={ctrl.paymentTermOptions}
|
||||
label={t("settings.companyDetails.sections.paymentTerm.field.label")}
|
||||
name="defaultPaymentTermId"
|
||||
placeholder={t("settings.companyDetails.sections.paymentTerm.field.placeholder")}
|
||||
/>
|
||||
</RdxFormGrid>
|
||||
</RdxEditorSection>
|
||||
</form>
|
||||
|
||||
<p aria-live="polite" className="sr-only">
|
||||
{ctrl.isSaving
|
||||
? t("settings.companyDetails.feedback.saving")
|
||||
: t("settings.companyDetails.feedback.idle")}
|
||||
</p>
|
||||
|
||||
<RdxEditorStickyFooter
|
||||
primary={
|
||||
<RhfSubmitActionButton
|
||||
busyLabel={t("settings.companyDetails.actions.saving")}
|
||||
disabled={ctrl.isSaving}
|
||||
formId={ctrl.formId}
|
||||
isBusy={ctrl.form.formState.isSubmitting}
|
||||
label={t("settings.companyDetails.actions.save")}
|
||||
/>
|
||||
}
|
||||
secondary={
|
||||
<CancelActionButton
|
||||
disabled={ctrl.isSaving || !ctrl.form.formState.isDirty}
|
||||
label={t("settings.companyDetails.actions.reset")}
|
||||
onCancel={ctrl.resetForm}
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</UnsavedChangesProvider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import type { CompanySettingsForm } from "../entities";
|
||||
import type { CompanySettings } from "../shared/entities";
|
||||
|
||||
export const buildCompanySettingsForm = (company: CompanySettings): CompanySettingsForm => ({
|
||||
defaultPaymentTermId: company.defaultPaymentTermId,
|
||||
});
|
||||
@ -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"
|
||||
);
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./build-company-settings-form";
|
||||
export * from "./build-update-company-settings-params";
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
@ -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<InvoicePaymentTermReadModel>;
|
||||
taxRegime: InvocieTaxRegimeFullReadModel;
|
||||
fiscalClassification: {
|
||||
operationQualificationCode: string | null;
|
||||
|
||||
@ -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");
|
||||
});
|
||||
@ -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(":");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Maybe<InvoicePaymentTerm>, 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<string>(),
|
||||
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<InvoiceTaxRegime, Error> {
|
||||
const taxRegimeResult = InvoiceTaxRegime.create({
|
||||
code: source.taxRegime.code,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
});
|
||||
@ -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: {
|
||||
|
||||
@ -16,6 +16,7 @@ export interface IIssuedInvoiceReportSnapshot {
|
||||
|
||||
invoice_date: string;
|
||||
payment_method: string;
|
||||
payment_term: string;
|
||||
notes: string;
|
||||
|
||||
recipient: {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))),
|
||||
|
||||
@ -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<InvoiceSerie>;
|
||||
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
|
||||
paymentTermId?: Maybe<UniqueID>;
|
||||
taxConfig?: ProformaTaxConfigInputProps;
|
||||
items: ProformaItemCreateInputProps[];
|
||||
};
|
||||
|
||||
@ -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<InvoicePaymentMethodReadModel>;
|
||||
paymentTerm: Maybe<InvoicePaymentTermReadModel>;
|
||||
taxRegime: Maybe<InvocieTaxRegimeFullReadModel>;
|
||||
}
|
||||
|
||||
@ -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<Result<Maybe<InvoicePaymentTermReadModel>, 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;
|
||||
|
||||
@ -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<string>;
|
||||
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<UniqueID> }) => {
|
||||
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);
|
||||
});
|
||||
@ -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<Result<Maybe<InvoicePaymentTermReadModel>, 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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -34,6 +34,7 @@ export class ProformaPaymentResolver {
|
||||
public async ensurePaymentMethodById(params: {
|
||||
companyId: UniqueID;
|
||||
id: Maybe<UniqueID>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<void, Error>> {
|
||||
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<UniqueID>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<void, Error>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<UniqueID>();
|
||||
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<UniqueID>();
|
||||
|
||||
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<UniqueID>(),
|
||||
};
|
||||
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);
|
||||
});
|
||||
@ -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<UniqueIDType> | undefined;
|
||||
customerPaymentTermId: Maybe<UniqueIDType>;
|
||||
transaction?: unknown;
|
||||
}): Promise<Result<Maybe<UniqueIDType>, 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"];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user