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.
La documentación del proyecto debe redactarse en castellano.
Reglas de estilo para documentación:
- usar lenguaje claro;
- evitar redacción excesivamente técnica cuando no sea necesaria;
- usar frases directas;
- evitar frases rebuscadas, largas o ambiguas;
- priorizar explicar qué hace el sistema, por qué existe una regla y qué comportamiento debe esperar el usuario o el desarrollador;
- usar términos técnicos cuando sean necesarios para ser precisos, pero explicarlos de forma sencilla;
- no convertir la documentación en una transcripción del código;
- evitar párrafos densos si una explicación breve y directa es suficiente.
## Disciplina de cambios
- Realizar cambios incrementales.

View File

@ -32,6 +32,7 @@
"@erp/auth": "workspace:*",
"@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/companies": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/identity": "workspace:*",

View File

@ -1,4 +1,5 @@
import { CatalogsModuleManifest } from "@erp/catalogs/client";
import { CompaniesModuleManifest } from "@erp/companies/client";
import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client";
import { CustomersModuleManifest } from "@erp/customers/client";
@ -7,6 +8,7 @@ import { IdentityModuleManifest } from "@erp/identity/client";
export const modules: IModuleClient[] = [
IdentityModuleManifest,
CoreModuleManifest,
CompaniesModuleManifest,
CatalogsModuleManifest,
CustomersModuleManifest,
CustomerInvoicesModuleManifest,

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
- [Índice de `customer-invoices`](./customer-invoices/README.md)
- [Plazos de pago](./catalogs/payment-terms.md)
- [Diseño backend del perfil VeriFactu empresarial](./customer-invoices/settings/verifactu-company-profile-backend-design.md)
- [Diseño frontend de Settings para VeriFactu](./customer-invoices/settings/frontend-erp-settings-verifactu-design.md)
- [Contrato de series de proformas](./customer-invoices/proforma-series-contract.md)

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
- [Plazos de pago](../catalogs/payment-terms.md)
- [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md)
- [Contrato de series de proformas](./proforma-series-contract.md)
- [Contrato de create de proformas](./proforma-create-contract.md)

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.
Para la semántica vigente de `payment_term_id`, herencia desde Company o Customer y diferencia entre referencia viva y snapshot, usar como fuente principal:
- [Plazos de pago](../catalogs/payment-terms.md)
## Regla principal
`tax_regime_code` no es un impuesto aplicado.
@ -63,6 +67,12 @@ Notas:
- no se usan defaults ocultos en Zod para disfrazar la intencion del request
- si no se informa `tax_config`, el backend resuelve defaults iniciales y los persiste
- si el cliente cambia después, la proforma conserva su `tax_config` persistido
- `payment_term_id` sigue la prioridad vigente:
- `Customer.paymentTermId`
- `Company.defaultPaymentTermId`
- `null`
- `payment_term_id = null` significa “sin plazo de pago”
- una selección manual de `payment_term_id = null` no debe volver a activar un default después
## Response esperada al crear sin lineas
@ -149,7 +159,6 @@ Si devuelve filas:
## Pendientes conocidos
- `payment_term_id` aparece ya en contratos de transporte, pero el dominio/snapshot de proformas sigue sin soporte funcional completo en esta fase
- no se introduce un motor fiscal nuevo en este ajuste
## Archivado

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`.
- `target_invoice_series_code` solo se conserva si sigue siendo una serie activa de `issued_invoice`; si no, pasa a `NULL`.
- Se copian datos comerciales y fiscales del origen.
- Si el `PaymentTerm` original sigue activo, se copia el mismo `paymentTermId`.
- Si el `PaymentTerm` original está inactivo, no se reutiliza como nueva asignación. El duplicado resuelve:
1. `Customer.paymentTermId`, solo si está activo
2. `Company.defaultPaymentTermId`, solo si está activo
3. `null`
- Si el Customer tiene un plazo inactivo, se ignora para esta nueva asignación.
- Si Company tiene por datos legacy un default inactivo, no se usa y el flujo cae a `null`.
- Se copian las líneas con nuevos IDs y se recalculan impuestos y totales con el motor actual.
Fuente funcional principal para `Payment Terms`:
- [Plazos de pago](../catalogs/payment-terms.md)
## Response
- `201 Created`
- Devuelve el snapshot completo de la nueva proforma.
- Si el plazo de pago original estaba inactivo y hubo adaptación, la respuesta incluye metadata funcional equivalente a:
```yaml
payment_term_adaptation:
null
o
{
source: "inactive_original",
resolution: "customer" | "company" | "none"
}
```
## UI
- Acción de fila `Duplicar` en `/proformas` y `/proformas/archived`.
- No muestra `Duplicar` en `/proformas/deleted`.
- Tras duplicar redirige a `/proformas/:id/edit`.
- Si el plazo original estaba inactivo y fue sustituido o eliminado, la UI informa del ajuste después de duplicar.

View File

@ -283,6 +283,21 @@ Prioridad funcional deseada:
No debe repetirse la incoherencia anterior entre `languageCode` y `locale`.
Regla práctica de templates FastReport en el estado actual:
```text
es-ES -> es
```
Sobre `Payment Terms`:
- la Proforma usa el plazo vivo actual;
- la Issued Invoice usa el snapshot histórico emitido.
Fuente funcional principal:
- [Plazos de pago](../catalogs/payment-terms.md)
## Ficheros principales
Rutas y controllers:

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-create-contract.md`
- `docs/customer-invoices/proforma-tax-config.md`
- `docs/catalogs/payment-terms.md`
Estado: diseno tecnico / fases 1A-1D + 2A-2B
Estado del backend: persistencia V2 activada; integracion con `document-series` activa para numeracion nueva; sin ejecucion confirmada de migracion DB
@ -46,7 +47,7 @@ Preparar el esquema fisico para separar:
- Existen repositorios Sequelize V2 listos para convivir con los repositorios legacy.
- El wiring activo legacy no se ha sustituido todavia.
- El dominio/application sigue arrastrando naming legacy en algunos contratos (`series`, `invoiceNumber`, `linkedProformaId`); la traduccion queda encapsulada en Infrastructure V2.
- `payment_term` sigue sin soporte real en el dominio actual de customer invoices; en persistencia V2 queda preparado pero no conectado funcionalmente.
- Esta afirmación quedó obsoleta. El runtime actual ya soporta `payment_term` en Company, Customer, Proforma e Issued Invoice según `docs/catalogs/payment-terms.md`.
## Estado de fase 1C

View File

@ -251,6 +251,26 @@ useCustomerSelectionFlow
## Detalle de `quick-create-customer`
## Default de plazo de pago
La alta rápida no necesita pedir el plazo de pago al usuario.
Si no envía un valor explícito, el backend aplica la misma regla que en el alta completa:
```text
Company.defaultPaymentTermId
```
No debe haber diferencias entre:
- create completo;
- quick create;
- API directa.
Fuente funcional principal:
- [Plazos de pago](../catalogs/payment-terms.md)
### Formulario reducido
La alta rápida no intenta sustituir al CRUD completo de `customers`.

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 IPaymentTermStatusChanger, PaymentTermStatusChanger } from "../services";
export const buildPaymentTermStatusChanger = (params: {
repository: IPaymentTermRepository;
companyDefaultUsageChecker?: {
isUsedAsCompanyDefault(params: {
companyId: UniqueID;
paymentTermId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
};
}): IPaymentTermStatusChanger => {
const { repository } = params;
const { repository, companyDefaultUsageChecker } = params;
return new PaymentTermStatusChanger(repository);
return new PaymentTermStatusChanger({ repository, companyDefaultUsageChecker });
};

View File

@ -9,6 +9,10 @@ export class PaymentTermPublicModelMapper {
name: paymentTerm.name.toPrimitive(),
description: paymentTerm.description.map((value) => value.toPrimitive()),
isActive: paymentTerm.isActive,
dueRules: paymentTerm.dueRules.getAll().map((rule) => ({
dueDays: String(rule.dueDays.toPrimitive()),
percentage: rule.percentage.toPrimitive(),
})),
};
}
}

View File

@ -9,4 +9,11 @@ export interface PaymentTermPublicModel {
description: Maybe<string>;
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 { IPaymentTermRepository } from "../../repositories/payment-term-repository.interface";
import { PaymentTermDeleter } from "../payment-term-deleter.service";
import { PaymentTermStatusChanger } from "../payment-term-status-changer.service";
class InMemoryPaymentTermRepository implements IPaymentTermRepository {
private readonly items = new Map<string, PaymentTerm>();
@ -194,4 +195,46 @@ describe("Payment term services", () => {
assert.equal(result.isFailure, true);
});
test("disable permite desactivar un payment term que no es default de company", async () => {
const repository = new InMemoryPaymentTermRepository();
const paymentTerm = createPaymentTerm();
await repository.create(paymentTerm);
const changer = new PaymentTermStatusChanger({
repository,
companyDefaultUsageChecker: {
isUsedAsCompanyDefault: async () => Result.ok(false),
},
});
const result = await changer.changeStatus({
paymentTerm,
action: "disable",
});
assert.equal(result.isSuccess, true);
assert.equal(result.data.isActive, false);
});
test("disable rechaza desactivar un payment term usado como default de company", async () => {
const repository = new InMemoryPaymentTermRepository();
const paymentTerm = createPaymentTerm();
await repository.create(paymentTerm);
const changer = new PaymentTermStatusChanger({
repository,
companyDefaultUsageChecker: {
isUsedAsCompanyDefault: async () => Result.ok(true),
},
});
const result = await changer.changeStatus({
paymentTerm,
action: "disable",
});
assert.equal(result.isFailure, true);
assert.match(result.error.message, /plazo por defecto/i);
});
});

View File

@ -1,6 +1,7 @@
import { Result } from "@repo/rdx-utils";
import type { PaymentTerm } from "../../../domain";
import { PaymentTermCannotBeDisabledError } from "../../../domain";
import type { IPaymentTermRepository } from "../repositories";
export type PaymentTermStatusChangeAction = "enable" | "disable";
@ -14,7 +15,18 @@ export interface IPaymentTermStatusChanger {
}
export class PaymentTermStatusChanger implements IPaymentTermStatusChanger {
public constructor(private readonly repository: IPaymentTermRepository) {}
public constructor(
private readonly deps: {
repository: IPaymentTermRepository;
companyDefaultUsageChecker?: {
isUsedAsCompanyDefault(params: {
companyId: PaymentTerm["companyId"];
paymentTermId: PaymentTerm["id"];
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
};
}
) {}
public async changeStatus(params: {
paymentTerm: PaymentTerm;
@ -23,6 +35,27 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger {
}): Promise<Result<PaymentTerm, Error>> {
const { paymentTerm, action, transaction } = params;
if (action === "disable" && this.deps.companyDefaultUsageChecker) {
const companyDefaultUsageResult =
await this.deps.companyDefaultUsageChecker.isUsedAsCompanyDefault({
companyId: paymentTerm.companyId,
paymentTermId: paymentTerm.id,
transaction,
});
if (companyDefaultUsageResult.isFailure) {
return Result.fail(companyDefaultUsageResult.error);
}
if (companyDefaultUsageResult.data) {
return Result.fail(
new PaymentTermCannotBeDisabledError(
"Este plazo de pago está configurado como plazo por defecto de la empresa. Cambia o elimina primero el plazo por defecto antes de desactivarlo."
)
);
}
}
const statusResult = action === "enable" ? paymentTerm.enable() : paymentTerm.disable();
if (statusResult.isFailure) {
return Result.fail(statusResult.error);
@ -32,7 +65,7 @@ export class PaymentTermStatusChanger implements IPaymentTermStatusChanger {
return Result.ok(paymentTerm);
}
const persistenceResult = await this.repository.update(paymentTerm, transaction);
const persistenceResult = await this.deps.repository.update(paymentTerm, transaction);
if (persistenceResult.isFailure) {
return Result.fail(persistenceResult.error);
}

View File

@ -27,7 +27,7 @@ export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicSe
export const catalogsAPIModule: IModuleServer = {
name: "catalogs",
version: "1.0.0",
dependencies: ["identity", "companies"],
dependencies: ["identity"],
/**
* Fase de SETUP

View File

@ -1,5 +1,5 @@
import { type StartParams, validateRequest } from "@erp/core/api";
import { requireIdentityTenant } from "@erp/identity/api";
import { requireIdentityTenantMembership } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import {
@ -34,7 +34,7 @@ export const paymentMethodsRouter = (params: StartParams) => {
// ----------------------------------------------
router.use(...requireIdentityTenant(params));
router.use(...requireIdentityTenantMembership(params));
// ----------------------------------------------

View File

@ -21,10 +21,13 @@ import {
buildPaymentTermUpdater,
} from "../../../application/payment-terms";
import type { IPaymentTermRepository } from "../../../application/payment-terms/repositories";
import {
buildCompanyDefaultPaymentTermUsageChecker,
buildPaymentTermReferenceUsageChecker,
} from "../payment-term-reference-usage-checker";
import { buildPaymentTermPersistenceMappers } from "./payment-term-persistence-mappers.di";
import { buildPaymentTermRepository } from "./payment-term-repositories.di";
import { buildPaymentTermReferenceUsageChecker } from "../payment-term-reference-usage-checker";
export type PaymentTermsInternalDeps = {
repository: IPaymentTermRepository;
@ -47,6 +50,9 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm
const repository = buildPaymentTermRepository({ database, mappers: persistenceMappers });
const usageChecker = buildPaymentTermReferenceUsageChecker(database as Sequelize);
const companyDefaultUsageChecker = buildCompanyDefaultPaymentTermUsageChecker(
database as Sequelize
);
const inputMappers = buildPaymentTermInputMappers();
@ -54,7 +60,10 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm
const creator = buildPaymentTermCreator({ repository });
const updater = buildPaymentTermUpdater({ repository });
const deleter = buildPaymentTermDeleter({ repository, usageChecker });
const statusChanger = buildPaymentTermStatusChanger({ repository });
const statusChanger = buildPaymentTermStatusChanger({
repository,
companyDefaultUsageChecker,
});
const snapshotBuilders = buildPaymentTermSnapshotBuilders();
return {

View File

@ -1,5 +1,5 @@
import { type StartParams, validateRequest } from "@erp/core/api";
import { requireIdentityTenant } from "@erp/identity/api";
import { requireIdentityTenantMembership } from "@erp/identity/api";
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
@ -29,7 +29,7 @@ export const paymentTermsRouter = (params: StartParams) => {
const router = Router({ mergeParams: true });
router.use(...requireIdentityTenant(params));
router.use(...requireIdentityTenantMembership(params));
router.get(
"/",

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 { requireIdentityTenant } from "@erp/identity/api";
import { requireIdentityTenantMembership } from "@erp/identity/api";
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
@ -29,7 +29,7 @@ export const taxDefinitionsRouter = (params: StartParams) => {
const router = Router({ mergeParams: true });
router.use(...requireIdentityTenant(params));
router.use(...requireIdentityTenantMembership(params));
router.get(
"/",

View File

@ -1,5 +1,5 @@
import { type StartParams, validateRequest } from "@erp/core/api";
import { requireIdentityTenant } from "@erp/identity/api";
import { requireIdentityTenantMembership } from "@erp/identity/api";
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
@ -29,7 +29,7 @@ export const taxRegimesRouter = (params: StartParams) => {
const router = Router({ mergeParams: true });
router.use(...requireIdentityTenant(params));
router.use(...requireIdentityTenantMembership(params));
router.get(
"/",

View File

@ -14,20 +14,38 @@
"exports": {
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
"./api": "./src/api/index.ts"
"./api": "./src/api/index.ts",
"./client": "./src/web/manifest.ts"
},
"peerDependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/react": "^19.2.14",
"@types/react-router-dom": "^5.3.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"rimraf": "^6.1.3",
"typescript": "^6.0.2"
},
"dependencies": {
"@erp/catalogs": "workspace:*",
"@erp/core": "workspace:*",
"@erp/identity": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@repo/i18next": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.98.0",
"express": "^4.22.1",
"react-hook-form": "^7.72.1",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0",
"sequelize": "^6.37.8",
"zod": "^4.3.6"
}
}
}

View File

@ -14,6 +14,9 @@ export class CompanyPublicModelMapper {
email: maybeToNullable(company.email, (value) => value.toPrimitive()),
phone: maybeToNullable(company.phone, (value) => value.toPrimitive()),
website: maybeToNullable(company.website, (value) => value.toPrimitive()),
defaultPaymentTermId: maybeToNullable(company.defaultPaymentTermId, (value) =>
value.toPrimitive()
),
status: company.status.toPrimitive() as "active" | "disabled",
};
}

View File

@ -7,5 +7,6 @@ export interface CompanyPublicModel {
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
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-finder";
export * from "./company-payment-term-resolver";
export * from "./company-status-changer";
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,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import type { CreateCompanyRequestDTO, CompanyResponseDTO } from "../../../../common";
import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
@ -99,6 +99,7 @@ function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) {
email: emailResult.data,
phone: phoneResult.data,
website: websiteResult.data,
defaultPaymentTermId: Maybe.none(),
status: CompanyStatus.createActive(),
createdAt: now,
updatedAt: now,
@ -119,6 +120,7 @@ function toCompanyResponseDTO(model: {
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
@ -130,6 +132,7 @@ function toCompanyResponseDTO(model: {
email: model.email,
phone: model.phone,
website: model.website,
default_payment_term_id: model.defaultPaymentTermId,
status: model.status,
};
}

View File

@ -56,6 +56,7 @@ function toCompanyResponseDTO(model: {
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
@ -67,6 +68,7 @@ function toCompanyResponseDTO(model: {
email: model.email,
phone: model.phone,
website: model.website,
default_payment_term_id: model.defaultPaymentTermId,
status: model.status,
};
}

View File

@ -56,6 +56,7 @@ function toCompanyResponseDTO(model: {
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
@ -67,6 +68,7 @@ function toCompanyResponseDTO(model: {
email: model.email,
phone: model.phone,
website: model.website,
default_payment_term_id: model.defaultPaymentTermId,
status: model.status,
};
}

View File

@ -45,6 +45,7 @@ function toCompanyResponseDTO(model: {
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
@ -56,6 +57,7 @@ function toCompanyResponseDTO(model: {
email: model.email,
phone: model.phone,
website: model.website,
default_payment_term_id: model.defaultPaymentTermId,
status: model.status,
};
}

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,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common";
import { CompanySlug, type CompanyPatchProps } from "../../../domain";
import type { CompanyPublicModelMapper } from "../public";
import type { ICompanyUpdater } from "../services";
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
export class UpdateCompanyUseCase {
public constructor(
private readonly deps: {
updater: ICompanyUpdater;
paymentTermResolver: ICompanyPaymentTermResolver;
responseMapper: CompanyPublicModelMapper;
transactionManager: ITransactionManager;
}
@ -39,8 +40,25 @@ export class UpdateCompanyUseCase {
return Promise.resolve(Result.fail(patchPropsResult.error));
}
const defaultPaymentTermIdResult = mapDefaultPaymentTermId(params.dto);
if (defaultPaymentTermIdResult.isFailure) {
return Promise.resolve(Result.fail(defaultPaymentTermIdResult.error));
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
if (defaultPaymentTermIdResult.data !== undefined) {
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
companyId: companyIdResult.data,
id: defaultPaymentTermIdResult.data,
transaction,
});
if (ensureResult.isFailure) {
return Result.fail(ensureResult.error);
}
}
const updateResult = await this.deps.updater.update({
id: companyIdResult.data,
patchProps: patchPropsResult.data,
@ -120,9 +138,39 @@ function mapUpdateCompanyProps(dto: UpdateCompanyRequestDTO): Result<CompanyPatc
patchProps.website = websiteResult.data;
}
if (dto.default_payment_term_id !== undefined) {
const defaultPaymentTermIdResult = maybeFromNullableResult(
dto.default_payment_term_id,
(value) => UniqueID.create(value)
);
if (defaultPaymentTermIdResult.isFailure) {
return Result.fail(defaultPaymentTermIdResult.error);
}
patchProps.defaultPaymentTermId = defaultPaymentTermIdResult.data;
}
return Result.ok(patchProps);
}
function mapDefaultPaymentTermId(
dto: UpdateCompanyRequestDTO
): Result<Maybe<UniqueID> | undefined, Error> {
if (dto.default_payment_term_id === undefined) {
return Result.ok(undefined);
}
if (dto.default_payment_term_id === null) {
return Result.ok(Maybe.none());
}
const defaultPaymentTermIdResult = UniqueID.create(dto.default_payment_term_id);
if (defaultPaymentTermIdResult.isFailure) {
return Result.fail(defaultPaymentTermIdResult.error);
}
return Result.ok(Maybe.some(defaultPaymentTermIdResult.data));
}
function toCompanyResponseDTO(model: {
id: string;
legalName: string;
@ -132,6 +180,7 @@ function toCompanyResponseDTO(model: {
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
status: "active" | "disabled";
}): CompanyResponseDTO {
return {
@ -143,6 +192,7 @@ function toCompanyResponseDTO(model: {
email: model.email,
phone: model.phone,
website: model.website,
default_payment_term_id: model.defaultPaymentTermId,
status: model.status,
};
}

View File

@ -22,6 +22,7 @@ export interface ICompanyCreateProps {
email: Maybe<EmailAddress>;
phone: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
defaultPaymentTermId: Maybe<UniqueID>;
status: CompanyStatus;
createdAt: UtcDate;
updatedAt: UtcDate;
@ -35,6 +36,7 @@ export type CompanyPatchProps = Partial<{
email: Maybe<EmailAddress>;
phone: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
defaultPaymentTermId: Maybe<UniqueID>;
}> & {
updatedAt: UtcDate;
};
@ -107,6 +109,10 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
return this.props.website;
}
public get defaultPaymentTermId(): Maybe<UniqueID> {
return this.props.defaultPaymentTermId;
}
public get status(): CompanyStatus {
return this.props.status;
}
@ -167,6 +173,14 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
hasChanges = true;
}
if (
patch.defaultPaymentTermId !== undefined &&
!Company.sameMaybeValueObject(this.props.defaultPaymentTermId, patch.defaultPaymentTermId)
) {
this.props.defaultPaymentTermId = patch.defaultPaymentTermId;
hasChanges = true;
}
if (!hasChanges) {
return Result.ok(false);
}

View File

@ -12,7 +12,7 @@ export type CompaniesPublicServicesType = ICompanyPublicServices;
export const companiesAPIModule: IModuleServer = {
name: "companies",
version: "1.0.0",
dependencies: ["identity"],
dependencies: ["identity", "catalogs"],
/**
* Fase de SETUP

View File

@ -3,6 +3,7 @@ import type { IIdentityPublicServices } from "@erp/identity/api";
import type { Sequelize } from "sequelize";
import {
CompanyPaymentTermResolver,
CompanyCreator,
CompanyFinder,
CompanyPublicFinder,
@ -19,6 +20,7 @@ import {
buildCompanyPublicServices,
} from "../../application";
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
import { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di";
export type CompaniesInternalDeps = {
repository: SequelizeCompanyRepository;
@ -38,10 +40,14 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
const transactionManager = buildTransactionManager(database as Sequelize);
const responseMapper = new CompanyPublicModelMapper();
const identityServices = getService<IIdentityPublicServices>("identity:general");
const catalogsDeps = resolveCompanyCatalogsDeps(params);
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
const finder = new CompanyFinder(repository);
const creator = new CompanyCreator(repository);
const paymentTermResolver = new CompanyPaymentTermResolver({
paymentTermFinder: catalogsDeps.paymentTerm.finder,
});
const updater = new CompanyUpdater({ finder, repository });
const statusChanger = new CompanyStatusChanger(repository);
const publicFinder = new CompanyPublicFinder({
@ -75,6 +81,7 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
updateCompany: () =>
new UpdateCompanyUseCase({
updater,
paymentTermResolver,
responseMapper,
transactionManager,
}),

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",
errors
);
const defaultPaymentTermId = extractOrPushError(
maybeFromNullableResult(source.default_payment_term_id, (value) => UniqueID.create(value)),
"default_payment_term_id",
errors
);
const status = extractOrPushError(CompanyStatus.create(source.status), "status", errors);
const createdAt = extractOrPushError(
UtcDateTime.createFromISO(toUtcIsoString(source.created_at)),
@ -83,6 +88,7 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper<
email: email!,
phone: phone!,
website: website!,
defaultPaymentTermId: defaultPaymentTermId!,
status: status!,
createdAt: createdAt!,
updatedAt: updatedAt!,
@ -107,6 +113,9 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper<
email: maybeToNullable(source.email, (value: EmailAddress) => value.toPrimitive()),
phone: maybeToNullable(source.phone, (value: PhoneNumber) => value.toPrimitive()),
website: maybeToNullable(source.website, (value: URLAddress) => value.toPrimitive()),
default_payment_term_id: maybeToNullable(source.defaultPaymentTermId, (value) =>
value.toPrimitive()
),
status: source.status.toPrimitive(),
created_at: source.createdAt.toISOString(),
updated_at: source.updatedAt.toISOString(),

View File

@ -20,6 +20,7 @@ export class CompanyModel extends Model<
declare email: string | null;
declare phone: string | null;
declare website: string | null;
declare default_payment_term_id: string | null;
declare status: string;
declare created_at: string;
declare updated_at: string;
@ -71,6 +72,11 @@ export default (database: Sequelize) => {
allowNull: true,
defaultValue: null,
},
default_payment_term_id: {
type: DataTypes.UUID,
allowNull: true,
defaultValue: null,
},
status: {
type: DataTypes.STRING,
allowNull: false,
@ -106,6 +112,10 @@ export default (database: Sequelize) => {
name: "idx_companies_tin",
fields: ["tin"],
},
{
name: "idx_companies_default_payment_term",
fields: ["default_payment_term_id"],
},
],
defaultScope: {},
scopes: {},

View File

@ -15,6 +15,7 @@ export const UpdateCompanyRequestSchema = z.object({
email: EmailSchema.nullable().optional(),
phone: z.string().nullable().optional(),
website: WebsiteSchema.nullable().optional(),
default_payment_term_id: z.uuid().nullable().optional(),
});
export type UpdateCompanyRequestDTO = z.infer<typeof UpdateCompanyRequestSchema>;

View File

@ -12,6 +12,7 @@ export const CompanyResponseSchema = z.object({
email: EmailSchema.nullable(),
phone: z.string().nullable(),
website: WebsiteSchema.nullable(),
default_payment_term_id: z.uuid().nullable(),
status: CompanyStatusResponseSchema,
});

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",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
@ -15,6 +15,7 @@
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,

View File

@ -1,2 +1,3 @@
export * from "./invoice-payment-method-read.model";
export * from "./invoice-payment-term-read.model";
export * from "./invoice-tax-regime-full-read.model";

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 {
InvocieTaxRegimeFullReadModel,
InvoicePaymentMethodReadModel,
InvoicePaymentTermReadModel,
} from "../../common/models";
import type { Maybe } from "@repo/rdx-utils";
/**
* Modelo de lectura usado exclusivamente durante la emisión de una proforma.
@ -14,6 +16,7 @@ import type {
export interface ProformaIssueReadModel {
proforma: Proforma;
paymentMethod: InvoicePaymentMethodReadModel;
paymentTerm: Maybe<InvoicePaymentTermReadModel>;
taxRegime: InvocieTaxRegimeFullReadModel;
fiscalClassification: {
operationQualificationCode: string | null;

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,
companySlug: snapshot.company_slug,
format: "PDF",
languageCode: snapshot.language_code ?? "es",
languageCode: this.normalizeLanguageCode(snapshot.language_code),
filename: this.buildFilename(snapshot),
storageKey: this.buildCacheKey(snapshot),
};
}
private normalizeLanguageCode(languageCode: string | undefined): string {
const normalized = (languageCode ?? "es").trim();
if (!normalized) {
return "es";
}
return normalized.split(/[-_]/, 1)[0]?.toLowerCase() ?? "es";
}
private buildFilename(snapshot: IIssuedInvoiceReportSnapshot): string {
return `factura-${snapshot.invoice_reference}-${snapshot.recipient.name}.pdf`;
}
private buildCacheKey(snapshot: IIssuedInvoiceReportSnapshot): string {
// Versionado explícito para invalidaciones futuras
return [
"issued-invoice",
snapshot.company_id,
snapshot.invoice_reference,
"v1",
].join(":");
return ["issued-invoice", snapshot.company_id, snapshot.invoice_reference, "v1"].join(":");
}
}

View File

@ -1,11 +1,12 @@
// modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts
import { UtcDate } from "@repo/rdx-ddd";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceCreateProps,
InvoicePaymentMethod,
InvoicePaymentTerm,
type InvoiceSerie,
InvoiceStatus,
InvoiceTaxRegime,
@ -66,6 +67,12 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
return Result.fail(paymentResult.error);
}
const paymentTermResult = this.resolvePaymentTerm(source);
if (paymentTermResult.isFailure) {
return Result.fail(paymentTermResult.error);
}
const taxRegimeResult = this.resolveTaxRegime(source);
if (taxRegimeResult.isFailure) {
@ -105,6 +112,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
reference: proforma.reference,
paymentMethod: paymentResult.data,
paymentTerm: paymentTermResult.data,
taxRegime: taxRegimeResult.data,
fiscalClassification: Maybe.some(fiscalClassificationResult.data),
verifactuPolicy: Maybe.some(verifactuPolicyResult.data),
@ -282,6 +290,45 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
return Result.ok(paymentMethodResult.data);
}
private resolvePaymentTerm(
source: ProformaIssueReadModel
): Result<Maybe<InvoicePaymentTerm>, Error> {
if (source.paymentTerm.isNone()) {
return Result.ok(Maybe.none());
}
const paymentTerm = source.paymentTerm.unwrap();
const paymentTermIdResult = UniqueID.create(paymentTerm.id);
if (paymentTermIdResult.isFailure) {
return Result.fail(paymentTermIdResult.error);
}
const paymentTermResult = InvoicePaymentTerm.create(
{
name: paymentTerm.name,
description:
paymentTerm.description.trim().length > 0
? Maybe.some(paymentTerm.description)
: Maybe.none<string>(),
dueRules: paymentTerm.dueRules.map((rule) => ({
dueDays: rule.dueDays,
percentage: {
value: rule.percentage.value,
scale: rule.percentage.scale,
},
})),
},
paymentTermIdResult.data
);
if (paymentTermResult.isFailure) {
return Result.fail(paymentTermResult.error);
}
return Result.ok(Maybe.some(paymentTermResult.data));
}
private resolveTaxRegime(source: ProformaIssueReadModel): Result<InvoiceTaxRegime, Error> {
const taxRegimeResult = InvoiceTaxRegime.create({
code: source.taxRegime.code,

View File

@ -32,6 +32,18 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
name: invoice.paymentMethod.name.toString(),
description: invoice.paymentMethod.name.toString(),
};
const payment_term = maybeToNullable(invoice.paymentTerm, (paymentTerm) => ({
id: paymentTerm.id.toString(),
name: paymentTerm.name,
description: paymentTerm.description.getOrUndefined() ?? null,
due_rules: paymentTerm.dueRules.map((rule) => ({
due_days: rule.dueDays,
percentage: {
value: rule.percentage.value,
scale: rule.percentage.scale,
},
})),
}));
const tax_regime = {
code: invoice.taxRegime.code,
@ -74,6 +86,7 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
taxes,
payment_method,
payment_term,
tax_regime,
fiscal_classification,
verifactu_policy,

View File

@ -47,6 +47,18 @@ export interface IIssuedInvoiceFullSnapshot {
name: string;
description: string;
} | null;
payment_term: {
id: string;
name: string;
description: string | null;
due_rules: Array<{
due_days: string;
percentage: {
value: string;
scale: number;
};
}>;
} | null;
tax_regime: {
code: string;
description: string | null;

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),
payment_method: snapshot.payment_method?.payment_description ?? "",
payment_method: snapshot.payment_method?.description ?? snapshot.payment_method?.name ?? "",
payment_term: snapshot.payment_term?.name ?? "",
notes: snapshot.notes ?? "",
recipient: {

View File

@ -16,6 +16,7 @@ export interface IIssuedInvoiceReportSnapshot {
invoice_date: string;
payment_method: string;
payment_term: string;
notes: string;
recipient: {

View File

@ -1,3 +1,4 @@
import type { ICompanyPublicServices } from "@erp/companies/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IProformaRepository } from "../repositories";
@ -18,6 +19,7 @@ export const buildProformaCreator = (params: {
repository: IProformaRepository;
proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices;
}): IProformaCreator => {
return new ProformaCreator(params);

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 { IProformaToIssuedInvoiceConverter } from "../../issued-invoices";
@ -15,12 +19,14 @@ export const buildProformaIssuer = (params: {
export const buildProformaIssueReadModelAssembler = (params: {
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
customerServices: ICustomerPublicServices;
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
}) => {
return new ProformaIssueReadModelAssembler({
paymentMethodFinder: params.paymentMethodFinder,
paymentTermFinder: params.paymentTermFinder,
taxRegimeFinder: params.taxRegimeFinder,
customerServices: params.customerServices,
companyVerifactuProfileRepository: params.companyVerifactuProfileRepository,

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";
@ -8,11 +12,13 @@ export interface IProformaReadModelAssemblers {
export function buildProformaReadModelAssemblers(params: {
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
}): IProformaReadModelAssemblers {
return {
full: new ProformaFullReadModelAssembler({
paymentMethodFinder: params.paymentMethodFinder,
paymentTermFinder: params.paymentTermFinder,
taxRegimeFinder: params.taxRegimeFinder,
}),
};

View File

@ -1,4 +1,6 @@
// modules/customer-invoices/src/api/application/proformas/di/proforma-updater.di.ts
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "../services";
import {
@ -13,6 +15,7 @@ export const buildProformaUpdater = (params: {
taxResolver: ProformaTaxResolver;
paymentResolver: ProformaPaymentResolver;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
customerServices: ICustomerPublicServices;
}): IProformaUpdater => {
return new ProformaUpdater(params);
};

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 { ICustomerPublicServices } from "@erp/customers/api";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
@ -127,6 +130,9 @@ export function buildDuplicateProformaByIdUseCase(deps: {
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
documentSeriesServices: import("@erp/document-series/api").DocumentSeriesPublicServicesType;
companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices;
paymentTermFinder: IPaymentTermPublicFinder;
transactionManager: ITransactionManager;
}) {
return new DuplicateProformaByIdUseCase({
@ -135,6 +141,9 @@ export function buildDuplicateProformaByIdUseCase(deps: {
fullReadModelAssembler: deps.fullReadModelAssembler,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
documentSeriesServices: deps.documentSeriesServices,
companiesServices: deps.companiesServices,
customerServices: deps.customerServices,
paymentTermFinder: deps.paymentTermFinder,
transactionManager: deps.transactionManager,
});
}

View File

@ -130,6 +130,12 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
errors
);
const paymentTermId = extractOrPushError(
maybeFromNullableResult(dto.payment_term_id, (value) => UniqueID.create(value)),
"payment_term_id",
errors
);
const taxRegimeCode = extractOrPushError(
maybeFromNullableResult(dto.tax_regime_code, (value) => Result.ok(String(value))),
"tax_regime_code",
@ -172,6 +178,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
taxRegimeCode: taxRegimeCode!,
taxConfig,
paymentMethodId: paymentMethodId!,
paymentTermId: dto.payment_term_id === undefined ? undefined : paymentTermId!,
globalDiscountPercentage: globalDiscountPercentage!,
items,

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) => {
proformaPatchProps.taxRegimeCode = extractOrPushError(
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 {
@ -37,9 +38,11 @@ export type ProformaCreateInputProps = Omit<
| "series"
| "taxConfig"
| "archivedAt"
| "paymentTermId"
> & {
proformaSeriesCode: Maybe<InvoiceSerie>;
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
paymentTermId?: Maybe<UniqueID>;
taxConfig?: ProformaTaxConfigInputProps;
items: ProformaItemCreateInputProps[];
};

View File

@ -4,6 +4,7 @@ import type { Proforma } from "../../../domain";
import type {
InvocieTaxRegimeFullReadModel,
InvoicePaymentMethodReadModel,
InvoicePaymentTermReadModel,
} from "../../common/models";
/**
@ -15,5 +16,6 @@ import type {
export interface ProformaFullReadModel {
proforma: Proforma;
paymentMethod: Maybe<InvoicePaymentMethodReadModel>;
paymentTerm: Maybe<InvoicePaymentTermReadModel>;
taxRegime: Maybe<InvocieTaxRegimeFullReadModel>;
}

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 { Maybe, Result } from "@repo/rdx-utils";
@ -6,6 +10,7 @@ import type { Proforma } from "../../../../domain";
import type {
InvocieTaxRegimeFullReadModel,
InvoicePaymentMethodReadModel,
InvoicePaymentTermReadModel,
} from "../../../common/models";
import type { ProformaFullReadModel } from "../../models";
@ -30,6 +35,7 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss
public constructor(
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
}
) {}
@ -45,6 +51,12 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss
return Result.fail(paymentMethod.error);
}
const paymentTerm = await this.resolvePaymentTerm(params);
if (paymentTerm.isFailure) {
return Result.fail(paymentTerm.error);
}
const taxRegime = await this.resolveTaxRegime(params);
if (taxRegime.isFailure) {
@ -54,6 +66,7 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss
return Result.ok({
proforma: params.proforma,
paymentMethod: paymentMethod.data,
paymentTerm: paymentTerm.data,
taxRegime: taxRegime.data,
});
}
@ -100,6 +113,46 @@ export class ProformaFullReadModelAssembler implements IProformaFullReadModelAss
);
}
private async resolvePaymentTerm(params: {
companyId: UniqueID;
proforma: Proforma;
transaction?: unknown;
}): Promise<Result<Maybe<InvoicePaymentTermReadModel>, Error>> {
if (params.proforma.paymentTermId.isNone()) {
return Result.ok(Maybe.none());
}
const paymentTermId = params.proforma.paymentTermId.unwrap();
const result = await this.deps.paymentTermFinder.findByIdInCompany({
companyId: params.companyId,
id: paymentTermId,
transaction: params.transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.fail(
new Error(`Proforma payment term reference is inconsistent: ${paymentTermId.toString()}`)
);
}
const paymentTerm = result.data.unwrap();
return Result.ok(
Maybe.some({
id: paymentTerm.id.toString(),
name: paymentTerm.name,
description: paymentTerm.description.getOrUndefined() ?? "",
isActive: paymentTerm.isActive,
dueRules: paymentTerm.dueRules,
})
);
}
private async resolveTaxRegime(params: {
companyId: UniqueID;
proforma: Proforma;

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 { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Proforma } from "../../../../domain";
import type { InvoicePaymentTermReadModel } from "../../../common/models";
import type { ICompanyVerifactuProfileRepository } from "../../../company-verifactu-profile";
import type { ProformaIssueReadModel } from "../../../issued-invoices";
import { resolveProformaIssueVerifactuSnapshots } from "../../../issued-invoices";
@ -26,6 +31,7 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
public constructor(
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
customerServices: ICustomerPublicServices;
companyVerifactuProfileRepository: ICompanyVerifactuProfileRepository;
@ -53,20 +59,28 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
return Result.fail(paymentMethodResult.error);
}
const customerResult = await this.deps.customerServices.getCustomerById(params.proforma.customerId, {
companyId: params.companyId,
transaction: params.transaction,
});
const paymentTermResult = await this.resolvePaymentTerm(params);
if (paymentTermResult.isFailure) {
return Result.fail(paymentTermResult.error);
}
const customerResult = await this.deps.customerServices.getCustomerById(
params.proforma.customerId,
{
companyId: params.companyId,
transaction: params.transaction,
}
);
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
const companyProfileResult =
await this.deps.companyVerifactuProfileRepository.findByCompanyId(
params.companyId,
params.transaction
);
const companyProfileResult = await this.deps.companyVerifactuProfileRepository.findByCompanyId(
params.companyId,
params.transaction
);
if (companyProfileResult.isFailure) {
return Result.fail(companyProfileResult.error);
@ -96,6 +110,7 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
name: paymentMethod.name,
description: paymentMethod.description,
},
paymentTerm: paymentTermResult.data,
taxRegime: taxRegimeResult.data,
fiscalClassification: {
operationQualificationCode:
@ -111,6 +126,46 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA
});
}
private async resolvePaymentTerm(params: {
companyId: UniqueID;
proforma: Proforma;
transaction?: unknown;
}): Promise<Result<Maybe<InvoicePaymentTermReadModel>, Error>> {
if (params.proforma.paymentTermId.isNone()) {
return Result.ok(Maybe.none());
}
const paymentTermId = params.proforma.paymentTermId.unwrap();
const result = await this.deps.paymentTermFinder.findByIdInCompany({
companyId: params.companyId,
id: paymentTermId,
transaction: params.transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.fail(
new Error(`Proforma payment term reference is inconsistent: ${paymentTermId.toString()}`)
);
}
const paymentTerm = result.data.unwrap();
return Result.ok(
Maybe.some({
id: paymentTerm.id.toString(),
name: paymentTerm.name,
description: paymentTerm.description.getOrUndefined() ?? "",
isActive: paymentTerm.isActive,
dueRules: paymentTerm.dueRules,
})
);
}
private async resolveTaxRegime(params: {
companyId: UniqueID;
proforma: Proforma;

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: {
companyId: UniqueID;
id: Maybe<UniqueID>;
transaction?: unknown;
}): Promise<Result<void, Error>> {
if (params.id.isNone()) {
return Result.ok(undefined);
@ -44,6 +45,7 @@ export class ProformaPaymentResolver {
const result = await this.deps.paymentMethodFinder.findByIdInCompany({
companyId: params.companyId,
id,
transaction: params.transaction,
});
if (result.isFailure) {
@ -61,6 +63,7 @@ export class ProformaPaymentResolver {
public async ensurePaymentTermById(params: {
companyId: UniqueID;
id: Maybe<UniqueID>;
transaction?: unknown;
}): Promise<Result<void, Error>> {
if (params.id.isNone()) {
return Result.ok(undefined);
@ -71,6 +74,7 @@ export class ProformaPaymentResolver {
const result = await this.deps.paymentTermFinder.findByIdInCompany({
companyId: params.companyId,
id,
transaction: params.transaction,
});
if (result.isFailure) {
@ -81,6 +85,10 @@ export class ProformaPaymentResolver {
return Result.fail(new Error(`Payment term not found: ${id.toString()}`));
}
if (!result.data.unwrap().isActive) {
return Result.fail(new Error(`Payment term is inactive: ${id.toString()}`));
}
return Result.ok(undefined);
}
}

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 { UniqueID } from "@repo/rdx-ddd";
import { UniqueID, type UniqueID as UniqueIDType } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
@ -39,6 +40,7 @@ export class ProformaCreator implements IProformaCreator {
paymentResolver: ProformaPaymentResolver;
proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices;
}
) {}
@ -168,8 +170,20 @@ export class ProformaCreator implements IProformaCreator {
const customer = customerResult.data;
const paymentTermIdResult = await this.resolveEffectivePaymentTermId({
companyId: props.companyId,
explicitPaymentTermId: props.paymentTermId,
customerPaymentTermId: customer.paymentTermId,
transaction,
});
if (paymentTermIdResult.isFailure) {
return Result.fail(paymentTermIdResult.error);
}
const _newProps = {
...props,
paymentTermId: paymentTermIdResult.data,
taxRegimeCode: props.taxRegimeCode.isNone()
? customer.taxRegimeCode.isSome()
? customer.taxRegimeCode
@ -191,6 +205,7 @@ export class ProformaCreator implements IProformaCreator {
const paymentMethodResult = await this.deps.paymentResolver.ensurePaymentMethodById({
companyId: _newProps.companyId,
id: _newProps.paymentMethodId,
transaction,
});
if (paymentMethodResult.isFailure) {
@ -237,6 +252,90 @@ export class ProformaCreator implements IProformaCreator {
});
}
private async resolveEffectivePaymentTermId(params: {
companyId: UniqueID;
explicitPaymentTermId: Maybe<UniqueIDType> | undefined;
customerPaymentTermId: Maybe<UniqueIDType>;
transaction?: unknown;
}): Promise<Result<Maybe<UniqueIDType>, Error>> {
if (params.explicitPaymentTermId !== undefined) {
const ensureResult = await this.deps.paymentResolver.ensurePaymentTermById({
companyId: params.companyId,
id: params.explicitPaymentTermId,
transaction: params.transaction,
});
if (ensureResult.isFailure) {
return Result.fail(ensureResult.error);
}
return Result.ok(params.explicitPaymentTermId);
}
if (params.customerPaymentTermId.isSome()) {
const ensureResult = await this.deps.paymentResolver.ensurePaymentTermById({
companyId: params.companyId,
id: params.customerPaymentTermId,
transaction: params.transaction,
});
if (ensureResult.isFailure) {
return Result.fail(
new Error("Customer payment term is inconsistent.", {
cause: ensureResult.error,
})
);
}
return Result.ok(params.customerPaymentTermId);
}
const companyResult = await this.deps.companiesServices.finder.findById({
companyId: params.companyId,
transaction: params.transaction,
});
if (companyResult.isFailure) {
return Result.fail(companyResult.error);
}
if (companyResult.data.isNone()) {
return Result.fail(new Error("Company not found while resolving proforma payment term."));
}
const company = companyResult.data.unwrap();
if (company.defaultPaymentTermId == null) {
return Result.ok(Maybe.none());
}
const companyDefaultPaymentTermIdResult = UniqueID.create(company.defaultPaymentTermId);
if (companyDefaultPaymentTermIdResult.isFailure) {
return Result.fail(
new Error("Company default payment term is invalid.", {
cause: companyDefaultPaymentTermIdResult.error,
})
);
}
const resolvedDefaultPaymentTermId = Maybe.some(companyDefaultPaymentTermIdResult.data);
const ensureResult = await this.deps.paymentResolver.ensurePaymentTermById({
companyId: params.companyId,
id: resolvedDefaultPaymentTermId,
transaction: params.transaction,
});
if (ensureResult.isFailure) {
return Result.fail(
new Error("Company default payment term is inconsistent.", {
cause: ensureResult.error,
})
);
}
return Result.ok(resolvedDefaultPaymentTermId);
}
private async resolveTaxConfig(params: {
companyId: UniqueID;
atDate: IProformaCreateProps["proformaDate"];

Some files were not shown because too many files have changed in this diff Show More