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:
David Arranz 2026-08-17 11:25:34 +02:00
parent ee48b19610
commit cf07de4836
192 changed files with 7586 additions and 216 deletions

View File

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

View File

@ -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:*",

View File

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

View File

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

View 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”.

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
"/", "/",

View File

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

View File

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

View File

@ -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(
"/", "/",

View File

@ -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(
"/", "/",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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..."
}
}
}
}

View 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..."
}
}
}
}

View 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 />,
},
];
};

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

View 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;

View File

@ -0,0 +1 @@
export * from "./use-company-details-settings-controller";

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from "./company-settings-form.schema";

View File

@ -0,0 +1 @@
export * from "./ui/pages/company-details-settings-page";

View File

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

View File

@ -0,0 +1 @@
export * from "./company-settings.adapter";

View File

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

View File

@ -0,0 +1,2 @@
export * from "./get-company-settings.api";
export * from "./update-company-settings.api";

View File

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

View File

@ -0,0 +1,6 @@
export interface CompanySettings {
id: string;
legalName: string;
tradeName: string | null;
defaultPaymentTermId: string | null;
}

View File

@ -0,0 +1 @@
export * from "./company-settings.entity";

View File

@ -0,0 +1,2 @@
export * from "./use-company-settings-query";
export * from "./use-company-settings-update-mutation";

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import type { CompanySettingsForm } from "../entities";
import type { CompanySettings } from "../shared/entities";
export const buildCompanySettingsForm = (company: CompanySettings): CompanySettingsForm => ({
defaultPaymentTermId: company.defaultPaymentTermId,
});

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export * from "./build-company-settings-form";
export * from "./build-update-company-settings-params";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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