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.
|
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
|
## Disciplina de cambios
|
||||||
|
|
||||||
- Realizar cambios incrementales.
|
- Realizar cambios incrementales.
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
"@erp/auth": "workspace:*",
|
"@erp/auth": "workspace:*",
|
||||||
"@erp/core": "workspace:*",
|
"@erp/core": "workspace:*",
|
||||||
"@erp/catalogs": "workspace:*",
|
"@erp/catalogs": "workspace:*",
|
||||||
|
"@erp/companies": "workspace:*",
|
||||||
"@erp/customer-invoices": "workspace:*",
|
"@erp/customer-invoices": "workspace:*",
|
||||||
"@erp/customers": "workspace:*",
|
"@erp/customers": "workspace:*",
|
||||||
"@erp/identity": "workspace:*",
|
"@erp/identity": "workspace:*",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { CatalogsModuleManifest } from "@erp/catalogs/client";
|
import { CatalogsModuleManifest } from "@erp/catalogs/client";
|
||||||
|
import { CompaniesModuleManifest } from "@erp/companies/client";
|
||||||
import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
|
import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
|
||||||
import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client";
|
import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client";
|
||||||
import { CustomersModuleManifest } from "@erp/customers/client";
|
import { CustomersModuleManifest } from "@erp/customers/client";
|
||||||
@ -7,6 +8,7 @@ import { IdentityModuleManifest } from "@erp/identity/client";
|
|||||||
export const modules: IModuleClient[] = [
|
export const modules: IModuleClient[] = [
|
||||||
IdentityModuleManifest,
|
IdentityModuleManifest,
|
||||||
CoreModuleManifest,
|
CoreModuleManifest,
|
||||||
|
CompaniesModuleManifest,
|
||||||
CatalogsModuleManifest,
|
CatalogsModuleManifest,
|
||||||
CustomersModuleManifest,
|
CustomersModuleManifest,
|
||||||
CustomerInvoicesModuleManifest,
|
CustomerInvoicesModuleManifest,
|
||||||
|
|||||||
@ -31,6 +31,7 @@ Este directorio reúne la documentación técnica, operativa y de apoyo del ERP.
|
|||||||
#### Customer invoices y proformas
|
#### Customer invoices y proformas
|
||||||
|
|
||||||
- [Índice de `customer-invoices`](./customer-invoices/README.md)
|
- [Í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 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)
|
- [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)
|
- [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
|
### 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)
|
- [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 series de proformas](./proforma-series-contract.md)
|
||||||
- [Contrato de create de proformas](./proforma-create-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.
|
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
|
## Regla principal
|
||||||
|
|
||||||
`tax_regime_code` no es un impuesto aplicado.
|
`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
|
- 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 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
|
- 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
|
## Response esperada al crear sin lineas
|
||||||
|
|
||||||
@ -149,7 +159,6 @@ Si devuelve filas:
|
|||||||
|
|
||||||
## Pendientes conocidos
|
## 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
|
- no se introduce un motor fiscal nuevo en este ajuste
|
||||||
## Archivado
|
## 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`.
|
- 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`.
|
- `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.
|
- 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.
|
- 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
|
## Response
|
||||||
|
|
||||||
- `201 Created`
|
- `201 Created`
|
||||||
- Devuelve el snapshot completo de la nueva proforma.
|
- 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
|
## UI
|
||||||
|
|
||||||
- Acción de fila `Duplicar` en `/proformas` y `/proformas/archived`.
|
- Acción de fila `Duplicar` en `/proformas` y `/proformas/archived`.
|
||||||
- No muestra `Duplicar` en `/proformas/deleted`.
|
- No muestra `Duplicar` en `/proformas/deleted`.
|
||||||
- Tras duplicar redirige a `/proformas/:id/edit`.
|
- 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`.
|
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
|
## Ficheros principales
|
||||||
|
|
||||||
Rutas y controllers:
|
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-series-contract.md`
|
||||||
- `docs/customer-invoices/proforma-create-contract.md`
|
- `docs/customer-invoices/proforma-create-contract.md`
|
||||||
- `docs/customer-invoices/proforma-tax-config.md`
|
- `docs/customer-invoices/proforma-tax-config.md`
|
||||||
|
- `docs/catalogs/payment-terms.md`
|
||||||
|
|
||||||
Estado: diseno tecnico / fases 1A-1D + 2A-2B
|
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
|
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.
|
- Existen repositorios Sequelize V2 listos para convivir con los repositorios legacy.
|
||||||
- El wiring activo legacy no se ha sustituido todavia.
|
- 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.
|
- 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
|
## Estado de fase 1C
|
||||||
|
|
||||||
|
|||||||
@ -251,6 +251,26 @@ useCustomerSelectionFlow
|
|||||||
|
|
||||||
## Detalle de `quick-create-customer`
|
## 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
|
### Formulario reducido
|
||||||
|
|
||||||
La alta rápida no intenta sustituir al CRUD completo de `customers`.
|
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 { IPaymentTermRepository } from "../repositories";
|
||||||
import { type IPaymentTermStatusChanger, PaymentTermStatusChanger } from "../services";
|
import { type IPaymentTermStatusChanger, PaymentTermStatusChanger } from "../services";
|
||||||
|
|
||||||
export const buildPaymentTermStatusChanger = (params: {
|
export const buildPaymentTermStatusChanger = (params: {
|
||||||
repository: IPaymentTermRepository;
|
repository: IPaymentTermRepository;
|
||||||
|
companyDefaultUsageChecker?: {
|
||||||
|
isUsedAsCompanyDefault(params: {
|
||||||
|
companyId: UniqueID;
|
||||||
|
paymentTermId: UniqueID;
|
||||||
|
transaction?: unknown;
|
||||||
|
}): Promise<Result<boolean, Error>>;
|
||||||
|
};
|
||||||
}): IPaymentTermStatusChanger => {
|
}): 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(),
|
name: paymentTerm.name.toPrimitive(),
|
||||||
description: paymentTerm.description.map((value) => value.toPrimitive()),
|
description: paymentTerm.description.map((value) => value.toPrimitive()),
|
||||||
isActive: paymentTerm.isActive,
|
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>;
|
description: Maybe<string>;
|
||||||
|
|
||||||
isActive: boolean;
|
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 { PaymentTermSummary } from "../../models/payment-term-summary.model";
|
||||||
import type { IPaymentTermRepository } from "../../repositories/payment-term-repository.interface";
|
import type { IPaymentTermRepository } from "../../repositories/payment-term-repository.interface";
|
||||||
import { PaymentTermDeleter } from "../payment-term-deleter.service";
|
import { PaymentTermDeleter } from "../payment-term-deleter.service";
|
||||||
|
import { PaymentTermStatusChanger } from "../payment-term-status-changer.service";
|
||||||
|
|
||||||
class InMemoryPaymentTermRepository implements IPaymentTermRepository {
|
class InMemoryPaymentTermRepository implements IPaymentTermRepository {
|
||||||
private readonly items = new Map<string, PaymentTerm>();
|
private readonly items = new Map<string, PaymentTerm>();
|
||||||
@ -194,4 +195,46 @@ describe("Payment term services", () => {
|
|||||||
|
|
||||||
assert.equal(result.isFailure, true);
|
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 { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { PaymentTerm } from "../../../domain";
|
import type { PaymentTerm } from "../../../domain";
|
||||||
|
import { PaymentTermCannotBeDisabledError } from "../../../domain";
|
||||||
import type { IPaymentTermRepository } from "../repositories";
|
import type { IPaymentTermRepository } from "../repositories";
|
||||||
|
|
||||||
export type PaymentTermStatusChangeAction = "enable" | "disable";
|
export type PaymentTermStatusChangeAction = "enable" | "disable";
|
||||||
@ -14,7 +15,18 @@ export interface IPaymentTermStatusChanger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PaymentTermStatusChanger implements 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: {
|
public async changeStatus(params: {
|
||||||
paymentTerm: PaymentTerm;
|
paymentTerm: PaymentTerm;
|
||||||
@ -23,6 +35,27 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger {
|
|||||||
}): Promise<Result<PaymentTerm, Error>> {
|
}): Promise<Result<PaymentTerm, Error>> {
|
||||||
const { paymentTerm, action, transaction } = params;
|
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();
|
const statusResult = action === "enable" ? paymentTerm.enable() : paymentTerm.disable();
|
||||||
if (statusResult.isFailure) {
|
if (statusResult.isFailure) {
|
||||||
return Result.fail(statusResult.error);
|
return Result.fail(statusResult.error);
|
||||||
@ -32,7 +65,7 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger {
|
|||||||
return Result.ok(paymentTerm);
|
return Result.ok(paymentTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistenceResult = await this.repository.update(paymentTerm, transaction);
|
const persistenceResult = await this.deps.repository.update(paymentTerm, transaction);
|
||||||
if (persistenceResult.isFailure) {
|
if (persistenceResult.isFailure) {
|
||||||
return Result.fail(persistenceResult.error);
|
return Result.fail(persistenceResult.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicSe
|
|||||||
export const catalogsAPIModule: IModuleServer = {
|
export const catalogsAPIModule: IModuleServer = {
|
||||||
name: "catalogs",
|
name: "catalogs",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["identity", "companies"],
|
dependencies: ["identity"],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fase de SETUP
|
* Fase de SETUP
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { type StartParams, validateRequest } from "@erp/core/api";
|
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 { type NextFunction, type Request, type Response, Router } from "express";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -34,7 +34,7 @@ export const paymentMethodsRouter = (params: StartParams) => {
|
|||||||
|
|
||||||
// ----------------------------------------------
|
// ----------------------------------------------
|
||||||
|
|
||||||
router.use(...requireIdentityTenant(params));
|
router.use(...requireIdentityTenantMembership(params));
|
||||||
|
|
||||||
// ----------------------------------------------
|
// ----------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@ -21,10 +21,13 @@ import {
|
|||||||
buildPaymentTermUpdater,
|
buildPaymentTermUpdater,
|
||||||
} from "../../../application/payment-terms";
|
} from "../../../application/payment-terms";
|
||||||
import type { IPaymentTermRepository } from "../../../application/payment-terms/repositories";
|
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 { buildPaymentTermPersistenceMappers } from "./payment-term-persistence-mappers.di";
|
||||||
import { buildPaymentTermRepository } from "./payment-term-repositories.di";
|
import { buildPaymentTermRepository } from "./payment-term-repositories.di";
|
||||||
import { buildPaymentTermReferenceUsageChecker } from "../payment-term-reference-usage-checker";
|
|
||||||
|
|
||||||
export type PaymentTermsInternalDeps = {
|
export type PaymentTermsInternalDeps = {
|
||||||
repository: IPaymentTermRepository;
|
repository: IPaymentTermRepository;
|
||||||
@ -47,6 +50,9 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm
|
|||||||
|
|
||||||
const repository = buildPaymentTermRepository({ database, mappers: persistenceMappers });
|
const repository = buildPaymentTermRepository({ database, mappers: persistenceMappers });
|
||||||
const usageChecker = buildPaymentTermReferenceUsageChecker(database as Sequelize);
|
const usageChecker = buildPaymentTermReferenceUsageChecker(database as Sequelize);
|
||||||
|
const companyDefaultUsageChecker = buildCompanyDefaultPaymentTermUsageChecker(
|
||||||
|
database as Sequelize
|
||||||
|
);
|
||||||
|
|
||||||
const inputMappers = buildPaymentTermInputMappers();
|
const inputMappers = buildPaymentTermInputMappers();
|
||||||
|
|
||||||
@ -54,7 +60,10 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm
|
|||||||
const creator = buildPaymentTermCreator({ repository });
|
const creator = buildPaymentTermCreator({ repository });
|
||||||
const updater = buildPaymentTermUpdater({ repository });
|
const updater = buildPaymentTermUpdater({ repository });
|
||||||
const deleter = buildPaymentTermDeleter({ repository, usageChecker });
|
const deleter = buildPaymentTermDeleter({ repository, usageChecker });
|
||||||
const statusChanger = buildPaymentTermStatusChanger({ repository });
|
const statusChanger = buildPaymentTermStatusChanger({
|
||||||
|
repository,
|
||||||
|
companyDefaultUsageChecker,
|
||||||
|
});
|
||||||
|
|
||||||
const snapshotBuilders = buildPaymentTermSnapshotBuilders();
|
const snapshotBuilders = buildPaymentTermSnapshotBuilders();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { type StartParams, validateRequest } from "@erp/core/api";
|
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 type { NextFunction, Request, Response } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ export const paymentTermsRouter = (params: StartParams) => {
|
|||||||
|
|
||||||
const router = Router({ mergeParams: true });
|
const router = Router({ mergeParams: true });
|
||||||
|
|
||||||
router.use(...requireIdentityTenant(params));
|
router.use(...requireIdentityTenantMembership(params));
|
||||||
|
|
||||||
router.get(
|
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 { 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 type { NextFunction, Request, Response } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ export const taxDefinitionsRouter = (params: StartParams) => {
|
|||||||
|
|
||||||
const router = Router({ mergeParams: true });
|
const router = Router({ mergeParams: true });
|
||||||
|
|
||||||
router.use(...requireIdentityTenant(params));
|
router.use(...requireIdentityTenantMembership(params));
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { type StartParams, validateRequest } from "@erp/core/api";
|
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 type { NextFunction, Request, Response } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ export const taxRegimesRouter = (params: StartParams) => {
|
|||||||
|
|
||||||
const router = Router({ mergeParams: true });
|
const router = Router({ mergeParams: true });
|
||||||
|
|
||||||
router.use(...requireIdentityTenant(params));
|
router.use(...requireIdentityTenantMembership(params));
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
|
|||||||
@ -14,19 +14,37 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/common/index.ts",
|
".": "./src/common/index.ts",
|
||||||
"./common": "./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": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@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",
|
"rimraf": "^6.1.3",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@erp/catalogs": "workspace:*",
|
||||||
"@erp/core": "workspace:*",
|
"@erp/core": "workspace:*",
|
||||||
"@erp/identity": "workspace:*",
|
"@erp/identity": "workspace:*",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@repo/i18next": "workspace:*",
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
|
"@repo/rdx-ui": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
|
"@repo/shadcn-ui": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.98.0",
|
||||||
"express": "^4.22.1",
|
"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",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,9 @@ export class CompanyPublicModelMapper {
|
|||||||
email: maybeToNullable(company.email, (value) => value.toPrimitive()),
|
email: maybeToNullable(company.email, (value) => value.toPrimitive()),
|
||||||
phone: maybeToNullable(company.phone, (value) => value.toPrimitive()),
|
phone: maybeToNullable(company.phone, (value) => value.toPrimitive()),
|
||||||
website: maybeToNullable(company.website, (value) => value.toPrimitive()),
|
website: maybeToNullable(company.website, (value) => value.toPrimitive()),
|
||||||
|
defaultPaymentTermId: maybeToNullable(company.defaultPaymentTermId, (value) =>
|
||||||
|
value.toPrimitive()
|
||||||
|
),
|
||||||
status: company.status.toPrimitive() as "active" | "disabled",
|
status: company.status.toPrimitive() as "active" | "disabled",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,5 +7,6 @@ export interface CompanyPublicModel {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
defaultPaymentTermId: string | null;
|
||||||
status: "active" | "disabled";
|
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-creator";
|
||||||
export * from "./company-finder";
|
export * from "./company-finder";
|
||||||
|
export * from "./company-payment-term-resolver";
|
||||||
export * from "./company-status-changer";
|
export * from "./company-status-changer";
|
||||||
export * from "./company-updater";
|
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,
|
UtcDateTime,
|
||||||
maybeFromNullableResult,
|
maybeFromNullableResult,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { CreateCompanyRequestDTO, CompanyResponseDTO } from "../../../../common";
|
import type { CreateCompanyRequestDTO, CompanyResponseDTO } from "../../../../common";
|
||||||
import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
|
import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
|
||||||
@ -99,6 +99,7 @@ function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) {
|
|||||||
email: emailResult.data,
|
email: emailResult.data,
|
||||||
phone: phoneResult.data,
|
phone: phoneResult.data,
|
||||||
website: websiteResult.data,
|
website: websiteResult.data,
|
||||||
|
defaultPaymentTermId: Maybe.none(),
|
||||||
status: CompanyStatus.createActive(),
|
status: CompanyStatus.createActive(),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@ -119,6 +120,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
defaultPaymentTermId: string | null;
|
||||||
status: "active" | "disabled";
|
status: "active" | "disabled";
|
||||||
}): CompanyResponseDTO {
|
}): CompanyResponseDTO {
|
||||||
return {
|
return {
|
||||||
@ -130,6 +132,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: model.email,
|
email: model.email,
|
||||||
phone: model.phone,
|
phone: model.phone,
|
||||||
website: model.website,
|
website: model.website,
|
||||||
|
default_payment_term_id: model.defaultPaymentTermId,
|
||||||
status: model.status,
|
status: model.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,6 +56,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
defaultPaymentTermId: string | null;
|
||||||
status: "active" | "disabled";
|
status: "active" | "disabled";
|
||||||
}): CompanyResponseDTO {
|
}): CompanyResponseDTO {
|
||||||
return {
|
return {
|
||||||
@ -67,6 +68,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: model.email,
|
email: model.email,
|
||||||
phone: model.phone,
|
phone: model.phone,
|
||||||
website: model.website,
|
website: model.website,
|
||||||
|
default_payment_term_id: model.defaultPaymentTermId,
|
||||||
status: model.status,
|
status: model.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,6 +56,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
defaultPaymentTermId: string | null;
|
||||||
status: "active" | "disabled";
|
status: "active" | "disabled";
|
||||||
}): CompanyResponseDTO {
|
}): CompanyResponseDTO {
|
||||||
return {
|
return {
|
||||||
@ -67,6 +68,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: model.email,
|
email: model.email,
|
||||||
phone: model.phone,
|
phone: model.phone,
|
||||||
website: model.website,
|
website: model.website,
|
||||||
|
default_payment_term_id: model.defaultPaymentTermId,
|
||||||
status: model.status,
|
status: model.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,6 +45,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
defaultPaymentTermId: string | null;
|
||||||
status: "active" | "disabled";
|
status: "active" | "disabled";
|
||||||
}): CompanyResponseDTO {
|
}): CompanyResponseDTO {
|
||||||
return {
|
return {
|
||||||
@ -56,6 +57,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: model.email,
|
email: model.email,
|
||||||
phone: model.phone,
|
phone: model.phone,
|
||||||
website: model.website,
|
website: model.website,
|
||||||
|
default_payment_term_id: model.defaultPaymentTermId,
|
||||||
status: model.status,
|
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,
|
UtcDateTime,
|
||||||
maybeFromNullableResult,
|
maybeFromNullableResult,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common";
|
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common";
|
||||||
import { CompanySlug, type CompanyPatchProps } from "../../../domain";
|
import { CompanySlug, type CompanyPatchProps } from "../../../domain";
|
||||||
import type { CompanyPublicModelMapper } from "../public";
|
import type { CompanyPublicModelMapper } from "../public";
|
||||||
import type { ICompanyUpdater } from "../services";
|
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
|
||||||
|
|
||||||
export class UpdateCompanyUseCase {
|
export class UpdateCompanyUseCase {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
updater: ICompanyUpdater;
|
updater: ICompanyUpdater;
|
||||||
|
paymentTermResolver: ICompanyPaymentTermResolver;
|
||||||
responseMapper: CompanyPublicModelMapper;
|
responseMapper: CompanyPublicModelMapper;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
}
|
}
|
||||||
@ -39,8 +40,25 @@ export class UpdateCompanyUseCase {
|
|||||||
return Promise.resolve(Result.fail(patchPropsResult.error));
|
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) => {
|
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||||
try {
|
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({
|
const updateResult = await this.deps.updater.update({
|
||||||
id: companyIdResult.data,
|
id: companyIdResult.data,
|
||||||
patchProps: patchPropsResult.data,
|
patchProps: patchPropsResult.data,
|
||||||
@ -120,9 +138,39 @@ function mapUpdateCompanyProps(dto: UpdateCompanyRequestDTO): Result<CompanyPatc
|
|||||||
patchProps.website = websiteResult.data;
|
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);
|
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: {
|
function toCompanyResponseDTO(model: {
|
||||||
id: string;
|
id: string;
|
||||||
legalName: string;
|
legalName: string;
|
||||||
@ -132,6 +180,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
defaultPaymentTermId: string | null;
|
||||||
status: "active" | "disabled";
|
status: "active" | "disabled";
|
||||||
}): CompanyResponseDTO {
|
}): CompanyResponseDTO {
|
||||||
return {
|
return {
|
||||||
@ -143,6 +192,7 @@ function toCompanyResponseDTO(model: {
|
|||||||
email: model.email,
|
email: model.email,
|
||||||
phone: model.phone,
|
phone: model.phone,
|
||||||
website: model.website,
|
website: model.website,
|
||||||
|
default_payment_term_id: model.defaultPaymentTermId,
|
||||||
status: model.status,
|
status: model.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export interface ICompanyCreateProps {
|
|||||||
email: Maybe<EmailAddress>;
|
email: Maybe<EmailAddress>;
|
||||||
phone: Maybe<PhoneNumber>;
|
phone: Maybe<PhoneNumber>;
|
||||||
website: Maybe<URLAddress>;
|
website: Maybe<URLAddress>;
|
||||||
|
defaultPaymentTermId: Maybe<UniqueID>;
|
||||||
status: CompanyStatus;
|
status: CompanyStatus;
|
||||||
createdAt: UtcDate;
|
createdAt: UtcDate;
|
||||||
updatedAt: UtcDate;
|
updatedAt: UtcDate;
|
||||||
@ -35,6 +36,7 @@ export type CompanyPatchProps = Partial<{
|
|||||||
email: Maybe<EmailAddress>;
|
email: Maybe<EmailAddress>;
|
||||||
phone: Maybe<PhoneNumber>;
|
phone: Maybe<PhoneNumber>;
|
||||||
website: Maybe<URLAddress>;
|
website: Maybe<URLAddress>;
|
||||||
|
defaultPaymentTermId: Maybe<UniqueID>;
|
||||||
}> & {
|
}> & {
|
||||||
updatedAt: UtcDate;
|
updatedAt: UtcDate;
|
||||||
};
|
};
|
||||||
@ -107,6 +109,10 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
|||||||
return this.props.website;
|
return this.props.website;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get defaultPaymentTermId(): Maybe<UniqueID> {
|
||||||
|
return this.props.defaultPaymentTermId;
|
||||||
|
}
|
||||||
|
|
||||||
public get status(): CompanyStatus {
|
public get status(): CompanyStatus {
|
||||||
return this.props.status;
|
return this.props.status;
|
||||||
}
|
}
|
||||||
@ -167,6 +173,14 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
|||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
patch.defaultPaymentTermId !== undefined &&
|
||||||
|
!Company.sameMaybeValueObject(this.props.defaultPaymentTermId, patch.defaultPaymentTermId)
|
||||||
|
) {
|
||||||
|
this.props.defaultPaymentTermId = patch.defaultPaymentTermId;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasChanges) {
|
if (!hasChanges) {
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export type CompaniesPublicServicesType = ICompanyPublicServices;
|
|||||||
export const companiesAPIModule: IModuleServer = {
|
export const companiesAPIModule: IModuleServer = {
|
||||||
name: "companies",
|
name: "companies",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["identity"],
|
dependencies: ["identity", "catalogs"],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fase de SETUP
|
* Fase de SETUP
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { IIdentityPublicServices } from "@erp/identity/api";
|
|||||||
import type { Sequelize } from "sequelize";
|
import type { Sequelize } from "sequelize";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CompanyPaymentTermResolver,
|
||||||
CompanyCreator,
|
CompanyCreator,
|
||||||
CompanyFinder,
|
CompanyFinder,
|
||||||
CompanyPublicFinder,
|
CompanyPublicFinder,
|
||||||
@ -19,6 +20,7 @@ import {
|
|||||||
buildCompanyPublicServices,
|
buildCompanyPublicServices,
|
||||||
} from "../../application";
|
} from "../../application";
|
||||||
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
|
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
|
||||||
|
import { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di";
|
||||||
|
|
||||||
export type CompaniesInternalDeps = {
|
export type CompaniesInternalDeps = {
|
||||||
repository: SequelizeCompanyRepository;
|
repository: SequelizeCompanyRepository;
|
||||||
@ -38,10 +40,14 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
|||||||
const transactionManager = buildTransactionManager(database as Sequelize);
|
const transactionManager = buildTransactionManager(database as Sequelize);
|
||||||
const responseMapper = new CompanyPublicModelMapper();
|
const responseMapper = new CompanyPublicModelMapper();
|
||||||
const identityServices = getService<IIdentityPublicServices>("identity:general");
|
const identityServices = getService<IIdentityPublicServices>("identity:general");
|
||||||
|
const catalogsDeps = resolveCompanyCatalogsDeps(params);
|
||||||
|
|
||||||
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
|
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
|
||||||
const finder = new CompanyFinder(repository);
|
const finder = new CompanyFinder(repository);
|
||||||
const creator = new CompanyCreator(repository);
|
const creator = new CompanyCreator(repository);
|
||||||
|
const paymentTermResolver = new CompanyPaymentTermResolver({
|
||||||
|
paymentTermFinder: catalogsDeps.paymentTerm.finder,
|
||||||
|
});
|
||||||
const updater = new CompanyUpdater({ finder, repository });
|
const updater = new CompanyUpdater({ finder, repository });
|
||||||
const statusChanger = new CompanyStatusChanger(repository);
|
const statusChanger = new CompanyStatusChanger(repository);
|
||||||
const publicFinder = new CompanyPublicFinder({
|
const publicFinder = new CompanyPublicFinder({
|
||||||
@ -75,6 +81,7 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
|||||||
updateCompany: () =>
|
updateCompany: () =>
|
||||||
new UpdateCompanyUseCase({
|
new UpdateCompanyUseCase({
|
||||||
updater,
|
updater,
|
||||||
|
paymentTermResolver,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
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",
|
"website",
|
||||||
errors
|
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 status = extractOrPushError(CompanyStatus.create(source.status), "status", errors);
|
||||||
const createdAt = extractOrPushError(
|
const createdAt = extractOrPushError(
|
||||||
UtcDateTime.createFromISO(toUtcIsoString(source.created_at)),
|
UtcDateTime.createFromISO(toUtcIsoString(source.created_at)),
|
||||||
@ -83,6 +88,7 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper<
|
|||||||
email: email!,
|
email: email!,
|
||||||
phone: phone!,
|
phone: phone!,
|
||||||
website: website!,
|
website: website!,
|
||||||
|
defaultPaymentTermId: defaultPaymentTermId!,
|
||||||
status: status!,
|
status: status!,
|
||||||
createdAt: createdAt!,
|
createdAt: createdAt!,
|
||||||
updatedAt: updatedAt!,
|
updatedAt: updatedAt!,
|
||||||
@ -107,6 +113,9 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper<
|
|||||||
email: maybeToNullable(source.email, (value: EmailAddress) => value.toPrimitive()),
|
email: maybeToNullable(source.email, (value: EmailAddress) => value.toPrimitive()),
|
||||||
phone: maybeToNullable(source.phone, (value: PhoneNumber) => value.toPrimitive()),
|
phone: maybeToNullable(source.phone, (value: PhoneNumber) => value.toPrimitive()),
|
||||||
website: maybeToNullable(source.website, (value: URLAddress) => value.toPrimitive()),
|
website: maybeToNullable(source.website, (value: URLAddress) => value.toPrimitive()),
|
||||||
|
default_payment_term_id: maybeToNullable(source.defaultPaymentTermId, (value) =>
|
||||||
|
value.toPrimitive()
|
||||||
|
),
|
||||||
status: source.status.toPrimitive(),
|
status: source.status.toPrimitive(),
|
||||||
created_at: source.createdAt.toISOString(),
|
created_at: source.createdAt.toISOString(),
|
||||||
updated_at: source.updatedAt.toISOString(),
|
updated_at: source.updatedAt.toISOString(),
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export class CompanyModel extends Model<
|
|||||||
declare email: string | null;
|
declare email: string | null;
|
||||||
declare phone: string | null;
|
declare phone: string | null;
|
||||||
declare website: string | null;
|
declare website: string | null;
|
||||||
|
declare default_payment_term_id: string | null;
|
||||||
declare status: string;
|
declare status: string;
|
||||||
declare created_at: string;
|
declare created_at: string;
|
||||||
declare updated_at: string;
|
declare updated_at: string;
|
||||||
@ -71,6 +72,11 @@ export default (database: Sequelize) => {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
},
|
},
|
||||||
|
default_payment_term_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null,
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
@ -106,6 +112,10 @@ export default (database: Sequelize) => {
|
|||||||
name: "idx_companies_tin",
|
name: "idx_companies_tin",
|
||||||
fields: ["tin"],
|
fields: ["tin"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "idx_companies_default_payment_term",
|
||||||
|
fields: ["default_payment_term_id"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
defaultScope: {},
|
defaultScope: {},
|
||||||
scopes: {},
|
scopes: {},
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const UpdateCompanyRequestSchema = z.object({
|
|||||||
email: EmailSchema.nullable().optional(),
|
email: EmailSchema.nullable().optional(),
|
||||||
phone: z.string().nullable().optional(),
|
phone: z.string().nullable().optional(),
|
||||||
website: WebsiteSchema.nullable().optional(),
|
website: WebsiteSchema.nullable().optional(),
|
||||||
|
default_payment_term_id: z.uuid().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateCompanyRequestDTO = z.infer<typeof UpdateCompanyRequestSchema>;
|
export type UpdateCompanyRequestDTO = z.infer<typeof UpdateCompanyRequestSchema>;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const CompanyResponseSchema = z.object({
|
|||||||
email: EmailSchema.nullable(),
|
email: EmailSchema.nullable(),
|
||||||
phone: z.string().nullable(),
|
phone: z.string().nullable(),
|
||||||
website: WebsiteSchema.nullable(),
|
website: WebsiteSchema.nullable(),
|
||||||
|
default_payment_term_id: z.uuid().nullable(),
|
||||||
status: CompanyStatusResponseSchema,
|
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",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
@ -15,6 +15,7 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./invoice-payment-method-read.model";
|
export * from "./invoice-payment-method-read.model";
|
||||||
|
export * from "./invoice-payment-term-read.model";
|
||||||
export * from "./invoice-tax-regime-full-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 {
|
import type {
|
||||||
InvocieTaxRegimeFullReadModel,
|
InvocieTaxRegimeFullReadModel,
|
||||||
InvoicePaymentMethodReadModel,
|
InvoicePaymentMethodReadModel,
|
||||||
|
InvoicePaymentTermReadModel,
|
||||||
} from "../../common/models";
|
} from "../../common/models";
|
||||||
|
import type { Maybe } from "@repo/rdx-utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modelo de lectura usado exclusivamente durante la emisión de una proforma.
|
* Modelo de lectura usado exclusivamente durante la emisión de una proforma.
|
||||||
@ -14,6 +16,7 @@ import type {
|
|||||||
export interface ProformaIssueReadModel {
|
export interface ProformaIssueReadModel {
|
||||||
proforma: Proforma;
|
proforma: Proforma;
|
||||||
paymentMethod: InvoicePaymentMethodReadModel;
|
paymentMethod: InvoicePaymentMethodReadModel;
|
||||||
|
paymentTerm: Maybe<InvoicePaymentTermReadModel>;
|
||||||
taxRegime: InvocieTaxRegimeFullReadModel;
|
taxRegime: InvocieTaxRegimeFullReadModel;
|
||||||
fiscalClassification: {
|
fiscalClassification: {
|
||||||
operationQualificationCode: string | null;
|
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,
|
companyId: snapshot.company_id,
|
||||||
companySlug: snapshot.company_slug,
|
companySlug: snapshot.company_slug,
|
||||||
format: "PDF",
|
format: "PDF",
|
||||||
languageCode: snapshot.language_code ?? "es",
|
languageCode: this.normalizeLanguageCode(snapshot.language_code),
|
||||||
filename: this.buildFilename(snapshot),
|
filename: this.buildFilename(snapshot),
|
||||||
storageKey: this.buildCacheKey(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 {
|
private buildFilename(snapshot: IIssuedInvoiceReportSnapshot): string {
|
||||||
return `factura-${snapshot.invoice_reference}-${snapshot.recipient.name}.pdf`;
|
return `factura-${snapshot.invoice_reference}-${snapshot.recipient.name}.pdf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCacheKey(snapshot: IIssuedInvoiceReportSnapshot): string {
|
private buildCacheKey(snapshot: IIssuedInvoiceReportSnapshot): string {
|
||||||
// Versionado explícito para invalidaciones futuras
|
// Versionado explícito para invalidaciones futuras
|
||||||
return [
|
return ["issued-invoice", snapshot.company_id, snapshot.invoice_reference, "v1"].join(":");
|
||||||
"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
|
// 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 { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type IIssuedInvoiceCreateProps,
|
type IIssuedInvoiceCreateProps,
|
||||||
InvoicePaymentMethod,
|
InvoicePaymentMethod,
|
||||||
|
InvoicePaymentTerm,
|
||||||
type InvoiceSerie,
|
type InvoiceSerie,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
InvoiceTaxRegime,
|
InvoiceTaxRegime,
|
||||||
@ -66,6 +67,12 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
|
|||||||
return Result.fail(paymentResult.error);
|
return Result.fail(paymentResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paymentTermResult = this.resolvePaymentTerm(source);
|
||||||
|
|
||||||
|
if (paymentTermResult.isFailure) {
|
||||||
|
return Result.fail(paymentTermResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
const taxRegimeResult = this.resolveTaxRegime(source);
|
const taxRegimeResult = this.resolveTaxRegime(source);
|
||||||
|
|
||||||
if (taxRegimeResult.isFailure) {
|
if (taxRegimeResult.isFailure) {
|
||||||
@ -105,6 +112,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
|
|||||||
reference: proforma.reference,
|
reference: proforma.reference,
|
||||||
|
|
||||||
paymentMethod: paymentResult.data,
|
paymentMethod: paymentResult.data,
|
||||||
|
paymentTerm: paymentTermResult.data,
|
||||||
taxRegime: taxRegimeResult.data,
|
taxRegime: taxRegimeResult.data,
|
||||||
fiscalClassification: Maybe.some(fiscalClassificationResult.data),
|
fiscalClassification: Maybe.some(fiscalClassificationResult.data),
|
||||||
verifactuPolicy: Maybe.some(verifactuPolicyResult.data),
|
verifactuPolicy: Maybe.some(verifactuPolicyResult.data),
|
||||||
@ -282,6 +290,45 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
|
|||||||
return Result.ok(paymentMethodResult.data);
|
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> {
|
private resolveTaxRegime(source: ProformaIssueReadModel): Result<InvoiceTaxRegime, Error> {
|
||||||
const taxRegimeResult = InvoiceTaxRegime.create({
|
const taxRegimeResult = InvoiceTaxRegime.create({
|
||||||
code: source.taxRegime.code,
|
code: source.taxRegime.code,
|
||||||
|
|||||||
@ -32,6 +32,18 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
|
|||||||
name: invoice.paymentMethod.name.toString(),
|
name: invoice.paymentMethod.name.toString(),
|
||||||
description: 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 = {
|
const tax_regime = {
|
||||||
code: invoice.taxRegime.code,
|
code: invoice.taxRegime.code,
|
||||||
@ -74,6 +86,7 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
|
|||||||
taxes,
|
taxes,
|
||||||
|
|
||||||
payment_method,
|
payment_method,
|
||||||
|
payment_term,
|
||||||
tax_regime,
|
tax_regime,
|
||||||
fiscal_classification,
|
fiscal_classification,
|
||||||
verifactu_policy,
|
verifactu_policy,
|
||||||
|
|||||||
@ -47,6 +47,18 @@ export interface IIssuedInvoiceFullSnapshot {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
payment_term: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
due_rules: Array<{
|
||||||
|
due_days: string;
|
||||||
|
percentage: {
|
||||||
|
value: string;
|
||||||
|
scale: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
tax_regime: {
|
tax_regime: {
|
||||||
code: string;
|
code: string;
|
||||||
description: string | null;
|
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),
|
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 ?? "",
|
notes: snapshot.notes ?? "",
|
||||||
|
|
||||||
recipient: {
|
recipient: {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export interface IIssuedInvoiceReportSnapshot {
|
|||||||
|
|
||||||
invoice_date: string;
|
invoice_date: string;
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
|
payment_term: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
|
||||||
recipient: {
|
recipient: {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { ICompanyPublicServices } from "@erp/companies/api";
|
||||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
|
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
@ -18,6 +19,7 @@ export const buildProformaCreator = (params: {
|
|||||||
repository: IProformaRepository;
|
repository: IProformaRepository;
|
||||||
proformaSeriesValidator: ProformaSeriesValidator;
|
proformaSeriesValidator: ProformaSeriesValidator;
|
||||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||||
|
companiesServices: ICompanyPublicServices;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
}): IProformaCreator => {
|
}): IProformaCreator => {
|
||||||
return new ProformaCreator(params);
|
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 { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
|
|
||||||
import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
|
import type { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
|
||||||
@ -15,12 +19,14 @@ export const buildProformaIssuer = (params: {
|
|||||||
|
|
||||||
export const buildProformaIssueReadModelAssembler = (params: {
|
export const buildProformaIssueReadModelAssembler = (params: {
|
||||||
paymentMethodFinder: IPaymentMethodPublicFinder;
|
paymentMethodFinder: IPaymentMethodPublicFinder;
|
||||||
|
paymentTermFinder: IPaymentTermPublicFinder;
|
||||||
taxRegimeFinder: ITaxRegimePublicFinder;
|
taxRegimeFinder: ITaxRegimePublicFinder;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
|
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
|
||||||
}) => {
|
}) => {
|
||||||
return new ProformaIssueReadModelAssembler({
|
return new ProformaIssueReadModelAssembler({
|
||||||
paymentMethodFinder: params.paymentMethodFinder,
|
paymentMethodFinder: params.paymentMethodFinder,
|
||||||
|
paymentTermFinder: params.paymentTermFinder,
|
||||||
taxRegimeFinder: params.taxRegimeFinder,
|
taxRegimeFinder: params.taxRegimeFinder,
|
||||||
customerServices: params.customerServices,
|
customerServices: params.customerServices,
|
||||||
companyVerifactuProfileRepository: params.companyVerifactuProfileRepository,
|
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";
|
import { type IProformaFullReadModelAssembler, ProformaFullReadModelAssembler } from "../services";
|
||||||
|
|
||||||
@ -8,11 +12,13 @@ export interface IProformaReadModelAssemblers {
|
|||||||
|
|
||||||
export function buildProformaReadModelAssemblers(params: {
|
export function buildProformaReadModelAssemblers(params: {
|
||||||
paymentMethodFinder: IPaymentMethodPublicFinder;
|
paymentMethodFinder: IPaymentMethodPublicFinder;
|
||||||
|
paymentTermFinder: IPaymentTermPublicFinder;
|
||||||
taxRegimeFinder: ITaxRegimePublicFinder;
|
taxRegimeFinder: ITaxRegimePublicFinder;
|
||||||
}): IProformaReadModelAssemblers {
|
}): IProformaReadModelAssemblers {
|
||||||
return {
|
return {
|
||||||
full: new ProformaFullReadModelAssembler({
|
full: new ProformaFullReadModelAssembler({
|
||||||
paymentMethodFinder: params.paymentMethodFinder,
|
paymentMethodFinder: params.paymentMethodFinder,
|
||||||
|
paymentTermFinder: params.paymentTermFinder,
|
||||||
taxRegimeFinder: params.taxRegimeFinder,
|
taxRegimeFinder: params.taxRegimeFinder,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
// modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts
|
// 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 { IProformaRepository } from "../repositories";
|
||||||
import type { ProformaPaymentResolver } from "../services";
|
import type { ProformaPaymentResolver } from "../services";
|
||||||
import {
|
import {
|
||||||
@ -13,6 +15,7 @@ export const buildProformaUpdater = (params: {
|
|||||||
taxResolver: ProformaTaxResolver;
|
taxResolver: ProformaTaxResolver;
|
||||||
paymentResolver: ProformaPaymentResolver;
|
paymentResolver: ProformaPaymentResolver;
|
||||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||||
|
customerServices: ICustomerPublicServices;
|
||||||
}): IProformaUpdater => {
|
}): IProformaUpdater => {
|
||||||
return new ProformaUpdater(params);
|
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 { ITransactionManager } from "@erp/core/api";
|
||||||
|
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
|
|
||||||
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
|
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
|
||||||
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
|
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
|
||||||
@ -127,6 +130,9 @@ export function buildDuplicateProformaByIdUseCase(deps: {
|
|||||||
fullReadModelAssembler: IProformaFullReadModelAssembler;
|
fullReadModelAssembler: IProformaFullReadModelAssembler;
|
||||||
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
||||||
documentSeriesServices: import("@erp/document-series/api").DocumentSeriesPublicServicesType;
|
documentSeriesServices: import("@erp/document-series/api").DocumentSeriesPublicServicesType;
|
||||||
|
companiesServices: ICompanyPublicServices;
|
||||||
|
customerServices: ICustomerPublicServices;
|
||||||
|
paymentTermFinder: IPaymentTermPublicFinder;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
}) {
|
}) {
|
||||||
return new DuplicateProformaByIdUseCase({
|
return new DuplicateProformaByIdUseCase({
|
||||||
@ -135,6 +141,9 @@ export function buildDuplicateProformaByIdUseCase(deps: {
|
|||||||
fullReadModelAssembler: deps.fullReadModelAssembler,
|
fullReadModelAssembler: deps.fullReadModelAssembler,
|
||||||
fullSnapshotBuilder: deps.fullSnapshotBuilder,
|
fullSnapshotBuilder: deps.fullSnapshotBuilder,
|
||||||
documentSeriesServices: deps.documentSeriesServices,
|
documentSeriesServices: deps.documentSeriesServices,
|
||||||
|
companiesServices: deps.companiesServices,
|
||||||
|
customerServices: deps.customerServices,
|
||||||
|
paymentTermFinder: deps.paymentTermFinder,
|
||||||
transactionManager: deps.transactionManager,
|
transactionManager: deps.transactionManager,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,6 +130,12 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
|
|||||||
errors
|
errors
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const paymentTermId = extractOrPushError(
|
||||||
|
maybeFromNullableResult(dto.payment_term_id, (value) => UniqueID.create(value)),
|
||||||
|
"payment_term_id",
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
|
||||||
const taxRegimeCode = extractOrPushError(
|
const taxRegimeCode = extractOrPushError(
|
||||||
maybeFromNullableResult(dto.tax_regime_code, (value) => Result.ok(String(value))),
|
maybeFromNullableResult(dto.tax_regime_code, (value) => Result.ok(String(value))),
|
||||||
"tax_regime_code",
|
"tax_regime_code",
|
||||||
@ -172,6 +178,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
|
|||||||
taxRegimeCode: taxRegimeCode!,
|
taxRegimeCode: taxRegimeCode!,
|
||||||
taxConfig,
|
taxConfig,
|
||||||
paymentMethodId: paymentMethodId!,
|
paymentMethodId: paymentMethodId!,
|
||||||
|
paymentTermId: dto.payment_term_id === undefined ? undefined : paymentTermId!,
|
||||||
|
|
||||||
globalDiscountPercentage: globalDiscountPercentage!,
|
globalDiscountPercentage: globalDiscountPercentage!,
|
||||||
items,
|
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) => {
|
toPatchField(dto.tax_regime_code).ifSet((taxRegimeCode) => {
|
||||||
proformaPatchProps.taxRegimeCode = extractOrPushError(
|
proformaPatchProps.taxRegimeCode = extractOrPushError(
|
||||||
maybeFromNullableResult(taxRegimeCode, (value) => Result.ok(String(value))),
|
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 { Maybe } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -37,9 +38,11 @@ export type ProformaCreateInputProps = Omit<
|
|||||||
| "series"
|
| "series"
|
||||||
| "taxConfig"
|
| "taxConfig"
|
||||||
| "archivedAt"
|
| "archivedAt"
|
||||||
|
| "paymentTermId"
|
||||||
> & {
|
> & {
|
||||||
proformaSeriesCode: Maybe<InvoiceSerie>;
|
proformaSeriesCode: Maybe<InvoiceSerie>;
|
||||||
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
|
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
|
||||||
|
paymentTermId?: Maybe<UniqueID>;
|
||||||
taxConfig?: ProformaTaxConfigInputProps;
|
taxConfig?: ProformaTaxConfigInputProps;
|
||||||
items: ProformaItemCreateInputProps[];
|
items: ProformaItemCreateInputProps[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { Proforma } from "../../../domain";
|
|||||||
import type {
|
import type {
|
||||||
InvocieTaxRegimeFullReadModel,
|
InvocieTaxRegimeFullReadModel,
|
||||||
InvoicePaymentMethodReadModel,
|
InvoicePaymentMethodReadModel,
|
||||||
|
InvoicePaymentTermReadModel,
|
||||||
} from "../../common/models";
|
} from "../../common/models";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,5 +16,6 @@ import type {
|
|||||||
export interface ProformaFullReadModel {
|
export interface ProformaFullReadModel {
|
||||||
proforma: Proforma;
|
proforma: Proforma;
|
||||||
paymentMethod: Maybe<InvoicePaymentMethodReadModel>;
|
paymentMethod: Maybe<InvoicePaymentMethodReadModel>;
|
||||||
|
paymentTerm: Maybe<InvoicePaymentTermReadModel>;
|
||||||
taxRegime: Maybe<InvocieTaxRegimeFullReadModel>;
|
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 type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -6,6 +10,7 @@ import type { Proforma } from "../../../../domain";
|
|||||||
import type {
|
import type {
|
||||||
InvocieTaxRegimeFullReadModel,
|
InvocieTaxRegimeFullReadModel,
|
||||||
InvoicePaymentMethodReadModel,
|
InvoicePaymentMethodReadModel,
|
||||||
|
InvoicePaymentTermReadModel,
|
||||||
} from "../../../common/models";
|
} from "../../../common/models";
|
||||||
import type { ProformaFullReadModel } from "../../models";
|
import type { ProformaFullReadModel } from "../../models";
|
||||||
|
|
||||||
@ -30,6 +35,7 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
paymentMethodFinder: IPaymentMethodPublicFinder;
|
paymentMethodFinder: IPaymentMethodPublicFinder;
|
||||||
|
paymentTermFinder: IPaymentTermPublicFinder;
|
||||||
taxRegimeFinder: ITaxRegimePublicFinder;
|
taxRegimeFinder: ITaxRegimePublicFinder;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
@ -45,6 +51,12 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss
|
|||||||
return Result.fail(paymentMethod.error);
|
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);
|
const taxRegime = await this.resolveTaxRegime(params);
|
||||||
|
|
||||||
if (taxRegime.isFailure) {
|
if (taxRegime.isFailure) {
|
||||||
@ -54,6 +66,7 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss
|
|||||||
return Result.ok({
|
return Result.ok({
|
||||||
proforma: params.proforma,
|
proforma: params.proforma,
|
||||||
paymentMethod: paymentMethod.data,
|
paymentMethod: paymentMethod.data,
|
||||||
|
paymentTerm: paymentTerm.data,
|
||||||
taxRegime: taxRegime.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: {
|
private async resolveTaxRegime(params: {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
proforma: Proforma;
|
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 { ICustomerPublicServices } from "@erp/customers/api";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
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 { Proforma } from "../../../../domain";
|
||||||
|
import type { InvoicePaymentTermReadModel } from "../../../common/models";
|
||||||
import type { ICompanyVerifactuProfileRepository } from "../../../company-verifactu-profile";
|
import type { ICompanyVerifactuProfileRepository } from "../../../company-verifactu-profile";
|
||||||
import type { ProformaIssueReadModel } from "../../../issued-invoices";
|
import type { ProformaIssueReadModel } from "../../../issued-invoices";
|
||||||
import { resolveProformaIssueVerifactuSnapshots } from "../../../issued-invoices";
|
import { resolveProformaIssueVerifactuSnapshots } from "../../../issued-invoices";
|
||||||
@ -26,6 +31,7 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
paymentMethodFinder: IPaymentMethodPublicFinder;
|
paymentMethodFinder: IPaymentMethodPublicFinder;
|
||||||
|
paymentTermFinder: IPaymentTermPublicFinder;
|
||||||
taxRegimeFinder: ITaxRegimePublicFinder;
|
taxRegimeFinder: ITaxRegimePublicFinder;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
|
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
|
||||||
@ -53,20 +59,28 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
|
|||||||
return Result.fail(paymentMethodResult.error);
|
return Result.fail(paymentMethodResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerResult = await this.deps.customerServices.getCustomerById(params.proforma.customerId, {
|
const paymentTermResult = await this.resolvePaymentTerm(params);
|
||||||
companyId: params.companyId,
|
|
||||||
transaction: params.transaction,
|
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) {
|
if (customerResult.isFailure) {
|
||||||
return Result.fail(customerResult.error);
|
return Result.fail(customerResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyProfileResult =
|
const companyProfileResult = await this.deps.companyVerifactuProfileRepository.findByCompanyId(
|
||||||
await this.deps.companyVerifactuProfileRepository.findByCompanyId(
|
params.companyId,
|
||||||
params.companyId,
|
params.transaction
|
||||||
params.transaction
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (companyProfileResult.isFailure) {
|
if (companyProfileResult.isFailure) {
|
||||||
return Result.fail(companyProfileResult.error);
|
return Result.fail(companyProfileResult.error);
|
||||||
@ -96,6 +110,7 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
|
|||||||
name: paymentMethod.name,
|
name: paymentMethod.name,
|
||||||
description: paymentMethod.description,
|
description: paymentMethod.description,
|
||||||
},
|
},
|
||||||
|
paymentTerm: paymentTermResult.data,
|
||||||
taxRegime: taxRegimeResult.data,
|
taxRegime: taxRegimeResult.data,
|
||||||
fiscalClassification: {
|
fiscalClassification: {
|
||||||
operationQualificationCode:
|
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: {
|
private async resolveTaxRegime(params: {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
proforma: Proforma;
|
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: {
|
public async ensurePaymentMethodById(params: {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
id: Maybe<UniqueID>;
|
id: Maybe<UniqueID>;
|
||||||
|
transaction?: unknown;
|
||||||
}): Promise<Result<void, Error>> {
|
}): Promise<Result<void, Error>> {
|
||||||
if (params.id.isNone()) {
|
if (params.id.isNone()) {
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
@ -44,6 +45,7 @@ export class ProformaPaymentResolver {
|
|||||||
const result = await this.deps.paymentMethodFinder.findByIdInCompany({
|
const result = await this.deps.paymentMethodFinder.findByIdInCompany({
|
||||||
companyId: params.companyId,
|
companyId: params.companyId,
|
||||||
id,
|
id,
|
||||||
|
transaction: params.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
@ -61,6 +63,7 @@ export class ProformaPaymentResolver {
|
|||||||
public async ensurePaymentTermById(params: {
|
public async ensurePaymentTermById(params: {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
id: Maybe<UniqueID>;
|
id: Maybe<UniqueID>;
|
||||||
|
transaction?: unknown;
|
||||||
}): Promise<Result<void, Error>> {
|
}): Promise<Result<void, Error>> {
|
||||||
if (params.id.isNone()) {
|
if (params.id.isNone()) {
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
@ -71,6 +74,7 @@ export class ProformaPaymentResolver {
|
|||||||
const result = await this.deps.paymentTermFinder.findByIdInCompany({
|
const result = await this.deps.paymentTermFinder.findByIdInCompany({
|
||||||
companyId: params.companyId,
|
companyId: params.companyId,
|
||||||
id,
|
id,
|
||||||
|
transaction: params.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
@ -81,6 +85,10 @@ export class ProformaPaymentResolver {
|
|||||||
return Result.fail(new Error(`Payment term not found: ${id.toString()}`));
|
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);
|
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 { 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 { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -39,6 +40,7 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
paymentResolver: ProformaPaymentResolver;
|
paymentResolver: ProformaPaymentResolver;
|
||||||
proformaSeriesValidator: ProformaSeriesValidator;
|
proformaSeriesValidator: ProformaSeriesValidator;
|
||||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||||
|
companiesServices: ICompanyPublicServices;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
@ -168,8 +170,20 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
|
|
||||||
const customer = customerResult.data;
|
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 = {
|
const _newProps = {
|
||||||
...props,
|
...props,
|
||||||
|
paymentTermId: paymentTermIdResult.data,
|
||||||
taxRegimeCode: props.taxRegimeCode.isNone()
|
taxRegimeCode: props.taxRegimeCode.isNone()
|
||||||
? customer.taxRegimeCode.isSome()
|
? customer.taxRegimeCode.isSome()
|
||||||
? customer.taxRegimeCode
|
? customer.taxRegimeCode
|
||||||
@ -191,6 +205,7 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
const paymentMethodResult = await this.deps.paymentResolver.ensurePaymentMethodById({
|
const paymentMethodResult = await this.deps.paymentResolver.ensurePaymentMethodById({
|
||||||
companyId: _newProps.companyId,
|
companyId: _newProps.companyId,
|
||||||
id: _newProps.paymentMethodId,
|
id: _newProps.paymentMethodId,
|
||||||
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (paymentMethodResult.isFailure) {
|
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: {
|
private async resolveTaxConfig(params: {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
atDate: IProformaCreateProps["proformaDate"];
|
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