Muchos cambios

This commit is contained in:
David Arranz 2026-04-20 19:20:42 +02:00
parent 1ce77814b5
commit 53eb33376c
139 changed files with 2722 additions and 2412 deletions

View File

@ -7,6 +7,7 @@
"formulahendry.auto-rename-tag",
"cweijan.dbclient-jdbc",
"pkief.material-icon-theme",
"fralle.copy-code-context"
"fralle.copy-code-context",
"imgildev.vscode-auto-barrel"
]
}

View File

@ -27,8 +27,8 @@ export const ICreateAccountRequestSchema = z.object({
default_tax: z.number(),
status: z.string(),
language_code: z.string(),
currency_code: z.string(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
logo: z.string(),
});
@ -55,8 +55,8 @@ export const IUpdateAccountRequestSchema = z.object({
default_tax: z.number(),
status: z.string(),
language_code: z.string(),
currency_code: z.string(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
logo: z.string(),
});

View File

@ -1,150 +0,0 @@
# Guía de estilo — DTOs y organización de carpetas
> **Objetivo:** asegurar que **todos** los equipos usen un vocabulario y una estructura de ficheros idéntica al modelar, versionar y serializar los *Data Transfer Objects* (DTO).
> Esta guía aplica a cualquier API HTTP/JSON implementada en Express + TypeScript que siga DDD, SOLID y CQRS.
---
## 1. Estructura de carpetas obligatoria
```text
src/
└─ <bounded-context>/ (ej. billing/)
└─ api/
└─ dto/
├─ common/ ← Tipos reutilizables (MoneyDTO, AddressDTO…)
├─ request/ ← Sólo comandos y queries
│ ├─ *.command.dto.ts
│ └─ *.query.dto.ts
└─ response/ ← Sólo resultados/vistas
├─ *.result.dto.ts
└─ *.view.dto.ts
```
*Alias TS recomendado*: `@<context>/dto/*``src/<context>/api/dto/*`.
---
## 2. Convención de nombres
| Categoría | Sufijo **obligatorio** | Descripción & ejemplos |
|-----------|------------------------|------------------------|
| **Comandos** (mutaciones) | `…CommandDTO` | `CreateInvoiceCommandDTO`, `UpdateInvoiceCommandDTO`, `DeleteInvoiceCommandDTO`, `ChangeInvoiceStatusCommandDTO` |
| **Queries** (lecturas con filtros) | `…QueryDTO` | `ListInvoicesQueryDTO`, `GetInvoiceByIdQueryDTO` |
| **Resultados** (respuesta de comandos) | `…ResultDTO` | `InvoiceCreationResultDTO`, `InvoiceDeletionResultDTO` |
| **Vistas** (respuesta de queries) | `…ViewDTO` | `InvoiceViewDTO`, `InvoiceSummaryViewDTO` |
| **Tipos comunes** | `…DTO` | `MoneyDTO`, `PaginationMetaDTO`, `AddressDTO` |
*Regla de oro:* **No existe ningún DTO sin sufijo, salvo los tipos comunes dentro de `common/`.**
---
## 3. Reglas de contenido
1. **Solo datos planos**: números, cadenas, literales, arrays; nada de lógica.
2. **Fechas** en ISO-8601 UTC (`yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`).
3. **Enums** expuestos como _string literal_ en `snake_case` o `UPPER_SNAKE_CASE`; evita números mágicos.
4. **Moneda**
- **Siempre** con la estructura común:
```ts
export interface MoneyDTO {
amount: number | null; // unidades mínimas (ej. céntimos)
scale: number; // nº de decimales (2 = céntimos)
currency_code: string; // ISO-4217 (“EUR”)
}
```
- Se importa desde `dto/common/money.dto.ts`.
---
## 4. Guía de mapeo
| Dirección | Componente responsable | Ubicación |
|-----------|------------------------|-----------|
| **DTO → Dominio** | `…CommandMapper` / `…QueryMapper` | `src/<context>/application/mappers/` |
| **Dominio → DTO** | `…ResultMapper` / `…ViewMapper` | mismo directorio |
Cada mapper implementa **una** función pública:
```ts
interface InvoiceCreationResultMapper {
toResult(entity: Invoice): InvoiceCreationResultDTO;
}
```
---
## 5. Validación y versiones
1. **Validación de entrada**
- Usa `class-validator` o Zod en el *controller*; nunca en el dominio.
- Convierte a Value Objects una vez que el DTO pasó la validación.
2. **Versionado**
- Añade sufijos de versión en el **archivo**, no en el nombre de la interfaz:
`invoice.view.v2.dto.ts``export interface InvoiceViewV2DTO { … }`
- Mantén las versiones anteriores durante **≥1 release** o hasta que los consumidores migren.
---
## 6. Ejemplo completo (creación de factura)
```text
billing/
├─ api/
│ └─ dto/
│ ├─ input/create-invoice.command.dto.ts
│ ├─ output/invoice-creation.result.dto.ts
│ └─ common/money.dto.ts
└─ application/
└─ mappers/
└─ invoice-creation.result.mapper.ts
```
```ts
// create-invoice.command.dto.ts
export interface CreateInvoiceCommandDTO {
customerId: string;
issueDate: string;
lines: ReadonlyArray<{
description: string;
quantity: number;
unitPrice: MoneyDTO;
}>;
}
// invoice-creation.result.dto.ts
export interface InvoiceCreationResultDTO {
invoiceId: string;
number: string;
totalAmount: MoneyDTO;
createdAt: string;
}
```
---
## 7. Checklist antes de hacer *merge*
- [ ] Archivo ubicado en la carpeta correcta.
- [ ] Sufijo conforme (**CommandDTO**, **QueryDTO**, **ResultDTO**, **ViewDTO**).
- [ ] Todos los importes usan `MoneyDTO`.
- [ ] Tipos opcionales marcados con `?` y comentados.
- [ ] **Sin** lógica, constructores ni métodos.
- [ ] PR incluye al menos un test de mapper (input ⇄ dominio ⇄ output).
---
## 8. Tabla resumen
| Carpeta | Sufijo | Ejemplo clásico |
|---------|--------|-----------------|
| `dto/input/` | `CommandDTO` | `DeleteInvoiceCommandDTO` |
| `dto/input/` | `QueryDTO` | `ListInvoicesQueryDTO` |
| `dto/output/` | `ResultDTO` | `InvoiceDeletionResultDTO` |
| `dto/output/` | `ViewDTO` | `InvoiceSummaryViewDTO` |
| `dto/common/` | `DTO` | `MoneyDTO` |

View File

@ -1,49 +1,20 @@
## Guía de diseño de DTOs para el ERP
# Guía de diseño de DTOs para el ERP
---
## 1. Principios generales
* Un DTO debe representar **únicamente** el **contrato de transporte** entre procesos.
* DTOs request/response separados por endpoint, aunque internamente reutilice piezas.
* No reutilizar el mismo DTO para varios endpoints salvo que el contrato sea realmente idéntico y estable.
* Sin `defaults` en requests de negocio. Deben evitarse salvo casos muy concretos y no ambiguos.
* En la medida de lo posible, usar esquemas Zod v4 para luego inferir el tipo del DTO:
Ejemplo:
- Un DTO representa únicamente el contrato de transporte entre cliente y servidor.
- Cada endpoint debe tener sus DTOs de `request` y `response`, aunque internamente reutilice subesquemas.
- No reutilizar el mismo DTO entre endpoints salvo que el contrato sea realmente idéntico y estable.
- Siempre que sea posible, definir DTOs a partir de esquemas Zod v4 e inferir el tipo desde el esquema.
```ts
export type ListIssuedInvoicesRequestDTO = z.infer<typeof ListIssuedInvoicesRequestSchema>;
```
## 1. Esquemas Zod y DTOs
* **El esquema valida, no “arregla” entradas**. Evitar `.default()`, `.transform()` y similares salvo excepción muy justificada.
* Las operaciones `list`, `get`, `create`, `update` y `report` no deben compartir el mismo esquema completo pero sí deben compartir `naming`, reglas de tipos, convenciones y reutilizar subesquemas comunes.
* No usar `z.string()` si se puede precisar más el tipo semántico. El schema debe expresar lo que **es** el dato, no solo cómo serializarlo.
Ejemplos:
- booleano -> `z.boolean()`
- UUID -> `z.uuid()`
- estado cerrado -> `z.enum([...])`
- fecha ISO -> schema específico
- código ISO -> schema específico o restricción mínima consistente
* Conservar el mismo tipo para el mismo campo en todos los esquemas. Si `customer_id` es UUID, debe ser UUID siempre.
* Conservar el mismo nombre para campos con la misma semántica a no ser que esté justificado.
Ejeemplo. No alternar:
- `recipient_id`
- `customer_id`
- `recipient_customer_id`
---
## 2. Estructura recomendada de DTOs
## 2.1. Organización por módulo
* Agrupar endpoints en `request/` y `response/`
* `shared/` para fragmentos reutilizables de shape
* no meter lógica de mapping en `dto/`
- El esquema valida; no debe “arreglar” entradas. Evitar `.default()`, `.transform()` y similares salvo excepción justificada.
- No mezclar DTOs con lógica de negocio, mapping ni estado de formulario.
## 2. Organización y naming
```plaintext
common/dto/
@ -67,138 +38,62 @@ common/dto/
recipient.dto.ts
```
---
## 3. Convenciones de naming
## 3.1. Ficheros
* Siempre en `kebab-case`.
* Uso de singular/plural: colección -> plural (`proformas`), elemento -> singular (`proforma`)
* Patrón:
* Estructurar DTOs por módulo en `request/`, `response/` y `shared/`.
* `shared/` solo para subesquemas reutilizables y estables.
* Nombres de fichero en `kebab-case`.
* Patrón de nombres:
```plaintext
<action>-<resource>[...].request.dto.ts
<action>-<resource>[...].response.dto.ts
```
Ejemplos:
* Usar singular/plural de forma consistente:
* `list-proformas.request.dto.ts`
* `get-proforma-by-id.response.dto.ts`
* `update-proforma-by-id.request.dto.ts`
---
## 4. Reglas de tipos serializados
## 4.1. Cuándo usar string
Usar `string` cuando el dato de transporte sea realmente textual o cuando convenga preservar exactitud/neutralidad:
* ids externos textuales
* códigos
* fechas ISO serializadas
* importes
* cantidades
* porcentajes
* referencias documentales
* descripciones
* notas
## 4.2. Cuándo no usar string
No serializar como string si el tipo semántico natural debe conservarse y no hay ventaja en degradarlo.
* colección -> plural
* elemento -> singular
Ejemplos:
* booleanos -> `z.boolean()`
* enteros de paginación -> `z.number().int()`
```plaintext
list-proformas.request.dto.ts
get-proforma-by-id.response.dto.ts
update-proforma-by-id.request.dto.ts
```
* `<resource>-summary.dto.ts` para vistas de resumen reutilizables de list.
* `<resource>-detail.dto.ts` para vistas de detalle reutilizables de get.
* `<action>-<resource>.response.dto.ts` para la response completa del endpoint.
* `<resource>-summary.snapshot-builder.ts` para builders que materializan un item/resumen.
* Evitar nombres como `I<Resource>Snapshot` si representan el mismo contrato que un DTO ya definido por schema.
## 3. Reglas de tipado
* No usar `z.string()` si el tipo semántico del campo puede concretarse mejor.
* El mismo campo debe conservar siempre el mismo nombre y el mismo tipo en todos los DTOs.
* Ejemplos de tipado semántico:
* booleano -> `z.boolean()`
* UUID -> `z.uuid()`
* conjunto cerrado -> `z.enum([...])`
* fecha/timestamp ISO -> schema específico
* arrays -> `z.array(...)`
* objetos -> `z.object(...)`
## 4. Reglas para primitivas de negocio
---
* Si un identificador es UUID, usar siempre `z.uuid()`.
* Si un identificador no es UUID, definir un schema específico con nombre explícito.
* Todo estado de negocio cerrado debe modelarse con `z.enum(...)`.
* No usar `z.string()` para booleanos ni para estados cerrados.
* Distinguir explícitamente entre fecha, hora y timestamp mediante schemas específicos.
## 5. Reglas para primitivas de negocio
## 5. Fechas, porcentajes, importes, cantidades
* Un VO monetario/cuantitativo siempre debe tener una sola forma de transporte en todo el ERP.
### 5.1. Amount, Money, Percentage y Quantity
## 5.1. Identificadores
* Si el identificador es UUID, usar `z.uuid()`.
* Si existe un id no UUID, definir un schema específico con nombre explícito.
* No mezclar `z.string()` y `z.uuid()` para el mismo campo según endpoint.
* Un mismo identificador debe tener el mismo schema en todos los DTOs.
Ejemplo:
```ts
proforma_id: z.uuid()
customer_id: z.uuid()
company_id: z.uuid()
```
---
## 5.2. Fechas y timestamps
Distinguir entre:
* fecha de calendario sin hora -> "2020-01-01"
* timestamp con fecha y hora ->
```ts
export const IsoDateSchema = z.iso.date(); // "2020-01-01"
export const IsoTimeSchema = z.iso.time(); // "03:15:00", "03:15"
export const OffsetDateTimeSchema = z.iso.datetime({ offset: true }); // "2020-01-01T06:15:00+02:00"
export const LocalDateTimeSchema = z.iso.datetime({ local: true }); // "2020-01-01T06:15:01"
export const DateTimeSchema1 = z.iso.datetime({ precision: -1 }); // minute precision (no seconds) -> "2020-01-01T06:15Z"
```
Si `z.iso.date()` no está disponible o no os convence, crear schema propio.
---
## 5.3. Enums y estados
* Todo campo de conjunto cerrado debe ser `z.enum(...)`. Ejemplos:
```
* `status`
* `language_code` si el conjunto es acotado
* `currency_code` si estáis acotando monedas soportadas
```
* No usar `z.string()` para estados de negocio cerrados.
* Si el conjunto no está cerrado aún, al menos documentarlo y preparar schema específico.
Ejemplo:
```ts
export const ProformaStatusSchema = z.enum([
"draft",
"pending",
"issued",
"cancelled",
]);
```
---
## 5.4. Booleanos
Ejemplo:
```ts
z.boolean()
```
---
## 5.5. Amount, Money, Percentage y Quantity
* Estos valores deben viajar como objetos explícitos:
* Los valores exactos no deben viajar como `number`.
* Deben viajar siempre con una única forma de transporte para toda la API.
```ts
type MoneyDTO = {
@ -223,96 +118,81 @@ type QuantityDTO = {
}
```
## 5.6. Listas
### 5.2. Fechas y timestamps
Distinguir entre:
* fecha de calendario sin hora -> "2020-01-01"
* timestamp con fecha y hora ->
```ts
type ListIssuedInvoicesResponseDTO = {
page: number,
per_page: number,
total_pages: number,
total_items: number,
items: {
...
}
}
export const IsoDateSchema = z.iso.date(); // "2020-01-01"
export const IsoTimeSchema = z.iso.time(); // "03:15:00", "03:15"
export const OffsetDateTimeSchema = z.iso.datetime({ offset: true }); // "2020-01-01T06:15:00+02:00"
export const LocalDateTimeSchema = z.iso.datetime({ local: true }); // "2020-01-01T06:15:01"
export const DateTimeSchema1 = z.iso.datetime({ precision: -1 }); // minute precision (no seconds) -> "2020-01-01T06:15Z"
```
---
Si se prefiere no usar `z.iso.date()`, crear schema propio.
## 6. Nullabilidad, opcionalidad y semántica de ausencia
Significado:
## 6. Opcionalidad, nullabilidad y ausencia
- `undefined` / campo omitido: no enviado
- `null`: enviado explícitamente sin valor
- `""`: cadena vacía, no ausencia
- `[]`: colección vacía, no ausencia
Significados:
**No cambiar estos significados salvo decisión explícita de contrato.**
* `undefined` / campo omitido -> no enviado
* `null` -> enviado explícitamente sin valor
* `""` -> cadena vacía, no indica ausencia de ese campo
* `[]` -> colección vacía, no indica ausencia de esa colección
### 6.1. Para `requests`
No cambiar estos significados salvo decisión explícita de contrato.
* Preferir `optional()` para campos no obligatorios.
* Usar `nullable()` solo cuando tenga significado de negocio “borrar/desvincular”.
### 6.1. Para requests
* Usar `optional()` para campos no obligatorios.
* Usar `nullable()` solo cuando tenga semántica de negocio, por ejemplo “borrar” o “desvincular”.
Ejemplo:
```ts
payment_method_id: z.uuid().nullable().optional()
```
Interpretación:
* omitido -> no tocar
* `null` -> quitar método de pago
* UUID -> asignar id de método de pago
* `null` -> quitar relación
* UUID -> asignar relación
### 6.2. Para responses
* Decidir de forma explícita si una ausencia semántica se representa con `null` o con omisión.
* Para relaciones opcionales, suele ser más claro usar `null` que omitir la relación.
## 7. Reglas para CREATE
* El request de `create` debe contener solo los datos necesarios para crear el recurso. El resto, como opcionales => `optional()`
* No reutilizar el DTO de detalle como DTO de creación.
* No usar defaults en requests de negocio.
* Si hace falta normalización, hacerla fuera del DTO:
```plaintext
validación DTO => normalización input => mapeo a command/props
```
### 6.3. Para `responses`
## 8. Reglas para UPDATE
* si el campo puede no existir semánticamente, decidir si se usa `null` o ausencia `""`
* para relaciones opcionales, suele ser más claro usar `null` que omitir la relación.
* `update` debe seguir semántica tipo `PATCH`:
---
## 7. Reglas para requests de CREATE
* Definir como obligatorios en el esquema los campos necesarios para crear el recurso. El resto, como opcionales => `optional()`
* No reutilizar el DTO de detalle como DTO de creación.
* No usar defaults
* solo viajan los campos a modificar
* los campos omitidos no se tocan
* La opcionalidad debe declararse en el esquema con `optional()`.
* No usar `Partial<>` sobre tipos inferidos si el esquema ya expresa opcionalidad.
* No usar defaults en `update`, especialmente en colecciones.
Incorrecto:
```ts
description: z.string().default("")
```
Correcto:
```ts
description: z.string().optional()
```
* Si el backend necesita normalización, hacerla fuera del DTO. Secuencia: `validación DTO` => `normalización input` => ' '
---
## 8. Reglas para requests de UPDATE
* Usar semántica tipo **PATCH**:
- solo viajan los campos a modificar
- los campos omitidos no se tocan
* Si un campo es opcional, usar `optional()` en el esquema:
```ts
series: z.string().optional()
```
* No usar `defaults` en update:
Esto es incorrecto porque `[]` puede significar “vaciar items”, no “campo omitido”.
```ts
items: z.array(...).default([])
```
@ -323,239 +203,210 @@ Correcto:
items: z.array(...).optional()
```
* no usar `Partial<>`:
### 8.1. UPDATE de colecciones
Incorrecto:
```ts
type UpdateProformaByIdRequestDTO = Partial<z.infer<typeof UpdateProformaByIdRequestSchema>>;
```
## 8.1. Reglas para request de UPDATE en **colecciones**
* En `update` de colecciones, mantener reemplazo completo de la colección
* Si se necesitara granularidad real en el `update` de items, valorar usar en la API una estructura explícita:
* Por defecto, mantener reemplazo completo de la colección.
* Si se necesita granularidad real, usar una estructura explícita:
```ts
items: {
create: [...]
update: [...]
delete: [...]
create: [...],
update: [...],
delete: [...],
}
```
## 9. Responses de LIST y DETAIL
* `list` y `get` no deben compartir el mismo esquema completo.
* Separar vistas de resumen y detalle:
* `List` -> summary
* `Get` -> detail
* `list` debe contener solo lo necesario para el listado.
* Puede existir un esquema base compartido solo si mejora claridad y estabilidad.
## 10. Subesquemas reutilizables
* Extraer a `shared/` toda estructura no trivial que se repita y sea estable.
* No extraer abstracciones prematuramente.
Ejemplos típicos:
* `recipient`
* `item`
* `tax-breakdown`
* `payment-method-ref`
## 10.bis. Snapshot builders y materialización de DTOs
* Los snapshot builders de API existen para materializar objetos de salida serializables a partir de modelos de aplicación, agregados o read models.
* Un snapshot builder **no define el contrato HTTP**; el contrato lo define el schema DTO.
* El schema Zod es la **única fuente de verdad (SSOT)** del contrato de salida.
---
### Regla principal
> Si un snapshot builder genera un DTO de API, su tipo de salida debe ser el `z.infer` del schema exacto que representa.
Ejemplo:
```ts
export const CustomerSummarySchema = z.object({ ... });
export type CustomerSummaryDTO = z.infer<typeof CustomerSummarySchema>;
export class CustomerSummarySnapshotBuilder
implements ISnapshotBuilder<CustomerSummary, CustomerSummaryDTO> {
public toOutput(model: CustomerSummary): CustomerSummaryDTO {
...
}
}
```
---
## 11. Reglas para responses de **LIST** y **DETAIL**
### Reglas obligatorias
* Separar summary y detail:
- List => summary => `ProformaSummaryDTO`
- Get <=> detail => `ProformaDetailDTO`
* No duplicar el contrato mediante interfaces manuales paralelas si ya existe un schema Zod equivalente.
* Eliminar tipos como `ICustomerSummarySnapshot` si representan el mismo contrato que un DTO.
* Si el builder construye una subvista reutilizable, debe tiparse contra el subschema compartido correspondiente (`shared/`).
* Si el builder construye la response completa, debe tiparse contra el response DTO completo.
* El builder debe devolver exactamente la forma del schema (no “aproximada”).
* `list` debe contener solo lo imprescindible para la tabla/listado
* Puede definirse un esquema base compartido **si aporta claridad**. Ejemplo:
---
### Responsabilidades del snapshot builder
Puede:
* Mapear `Maybe``null`
* Convertir Value Objects → primitives o DTOs (`string`, `{ value, scale }`, etc.)
* Adaptar enums internos → valores de contrato
* Construir vistas (`summary`, `detail`, etc.)
No puede:
* Definir contratos
* Validar reglas de negocio
* Acceder a repositorios
* Ejecutar lógica de dominio
* “corregir” datos fuera del contrato definido
---
### Separación por niveles
Si el endpoint es:
```ts
const ProformaBaseDTOSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
customer_id: z.uuid(),
invoice_number: z.string(),
status: ProformaStatusSchema,
series: z.string(),
invoice_date: IsoDateSchema,
operation_date: IsoDateSchema,
language_code: z.string(),
currency_code: z.string(),
reference: z.string().nullable(),
description: z.string().nullable(),
});
ListCustomersResponseSchema
```
Despues:
Entonces:
* `summary` extiende añadiendo algún campos más
* `detail` extiende añadiendo taxes, items, notes, etc.
* Subschema:
```ts
CustomerSummarySchema
```
* Builder:
```ts
CustomerSummarySnapshotBuilder -> CustomerSummaryDTO
```
No:
```ts
Builder -> ListCustomersResponseDTO ❌
```
---
## 12. Reglas para subesquemas reutilizables
### Naming obligatorio
* Estructura no trivial que se repite => definir subesquema en `shared/`.
* Deben ser estructuras estables no sujetas a cambios. En caso contrario, mejor no reutilizar
DTO:
Ejemplos claros en tu caso:
```plaintext
<resource>-summary.dto.ts
<resource>-detail.dto.ts
<action>-<resource>.response.dto.ts
```
* `recipient`
* `proforma-item`
* `proforma-tax-breakdown`
* `payment-method-ref`
Builders:
```plaintext
<resource>-summary.snapshot-builder.ts
<resource>-detail.snapshot-builder.ts
<action>-<resource>.response.snapshot-builder.ts
```
Evitar:
```plaintext
I<Resource>Snapshot ❌
<Resource>Output ❌
<Resource>Interface ❌
```
---
## 13. Reglas específicas para errores
### Regla de consistencia
> Si cambia el schema DTO, el builder debe romper en compile-time.
Esto garantiza:
* contrato consistente
* ausencia de drift
* eliminación de duplicación
---
## 11. Errores
* El DTO de error debe ser estable, tipado y predecible.
* No usar shapes excesivamente genéricos si ya se conoce la estructura mínima útil.
* El cliente debe poder representar errores sin heurísticas frágiles.
## 13.1. Estructura base recomendada
## 12. Metadata
```ts
export const ErrorDetailDTOSchema = z.object({
code: z.string(),
path: z.string().optional(),
message: z.string(),
});
* `metadata` solo debe contener datos auxiliares, trazabilidad no crítica, versionado o hints no funcionales.
* Si un dato es necesario para que el cliente funcione, no debe ir en `metadata`, sino en el DTO principal.
export const ErrorResponseDTOSchema = z.object({
type: z.string().optional(),
title: z.string(),
status: z.number().int(),
detail: z.string().optional(),
instance: z.string().optional(),
context: z
.object({
params: z.record(z.string(), z.unknown()).optional(),
query: z.record(z.string(), z.unknown()).optional(),
body: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
extra: z
.object({
errors: z.array(ErrorDetailDTOSchema).default([]),
})
.optional(),
});
```
## 13. Evolución de la API
## 13.2. Regla
* Es mejor añadir campos que reinterpretar campos existentes.
* Cambiar el significado o la estructura de un campo implica cambio de contrato.
No dejar `errors: Record<string, unknown>[]` si ya conoces la forma mínima útil.
## 14. Criterio para detectar overengineering
El cliente necesita poder renderizar errores sin heurísticas frágiles.
Un diseño de DTO está sobreingenierizado si:
---
## 14. Reglas de metadata
* Si un consumidor necesita ese dato para funcionar, no va en `metadata`: va en el DTO principal.
## 14.1. Cuándo usarla
Solo para:
* datos auxiliares
* trazabilidad no crítica
* versionado
* hints no funcionales
## 14.2. Cuándo no usarla
No usar `metadata` para:
* reglas de negocio
* campos que condicionan UI principal
* datos que el cliente necesita interpretar de forma contractual
---
## 15. Evolución de la API
* Es mejor añadir un campo nuevo que cambiar el significado de uno existente.
* Si un campo cambia de semántica o estructura, debe tratarse como cambio de contrato.
## 16. Criterio para detectar `overengineering`
Considera que un diseño de DTO está `sobreingenierizado` si ocurre alguna de estas:
* necesitas abrir 5 ficheros para entender un endpoint simple
* el schema hace validación, normalización, defaults y transformación a la vez
* hacer abstracciones compartidas en `shared` que lueo solo se utilizan en un caso
* para entender un endpoint simple hay que abrir demasiados ficheros
* el esquema valida, normaliza y transforma a la vez
* se extraen subesquemas que apenas se reutilizan
* el DTO intenta reflejar demasiado fielmente el dominio interno
Regla práctica:
* preferir duplicación pequeña y clara frente a abstracción prematura
---
## 15. Checklist de revisión
## 21. Plantilla base recomendada
Antes de cerrar un DTO, comprobar:
## 21.1. Request simple por id
```ts
import { z } from "zod/v4";
export const GetProformaByIdRequestSchema = z.object({
proforma_id: z.uuid(),
});
export type GetProformaByIdRequestDTO = z.infer<typeof GetProformaByIdRequestSchema>;
```
## 21.2. Fragmento shared
```ts
import { z } from "zod/v4";
import { MoneyDTOSchema, PercentageDTOSchema } from "@erp/core";
export const ProformaTaxDTOSchema = z.object({
taxable_amount: MoneyDTOSchema,
iva_code: z.string(),
iva_percentage: PercentageDTOSchema,
iva_amount: MoneyDTOSchema,
rec_code: z.string(),
rec_percentage: PercentageDTOSchema,
rec_amount: MoneyDTOSchema,
retention_code: z.string(),
retention_percentage: PercentageDTOSchema,
retention_amount: MoneyDTOSchema,
taxes_amount: MoneyDTOSchema,
});
export type ProformaTaxDTO = z.infer<typeof ProformaTaxDTOSchema>;
```
## 21.3. Update parcial sin defaults
```ts
import { z } from "zod/v4";
export const UpdateProformaByIdRequestSchema = z.object({
series: z.string().optional(),
invoice_date: z.iso.date().optional(),
operation_date: z.iso.date().optional(),
customer_id: z.uuid().optional(),
reference: z.string().optional(),
description: z.string().optional(),
notes: z.string().optional(),
language_code: z.string().optional(),
currency_code: z.string().optional(),
items: z.array(UpdateProformaItemRequestSchema).optional(),
});
export type UpdateProformaByIdRequestDTO = z.infer<typeof UpdateProformaByIdRequestSchema>;
```
---
## 22. Checklist de revisión de un DTO
Antes de dar un DTO por bueno, comprobar:
1. ¿Representa contrato de transporte y nada más?
2. ¿Cada campo tiene el tipo semántico más preciso posible?
3. ¿Se distingue bien entre omitido, null, vacío y colección vacía?
4. ¿Hay defaults que estén ocultando intención?
5. ¿El naming es consistente con el resto?
6. ¿Hay subestructuras repetidas que merezcan extraerse?
7. ¿Hay abstracciones extraídas demasiado pronto?
1. ¿Representa solo contrato de transporte?
2. ¿Cada campo usa el tipo semántico más preciso posible?
3. ¿Se distingue bien entre omitido, `null`, vacío y colección vacía?
4. ¿Hay defaults ocultando intención?
5. ¿El naming es consistente?
6. ¿Las estructuras repetidas merecen extraerse?
7. ¿Hay abstracciones prematuras?
8. ¿El cliente puede consumirlo sin heurísticas frágiles?
9. ¿El mismo campo mantiene mismo tipo en todo el módulo?
10. ¿El DTO refleja una vista del endpoint y no una mezcla de varias?
9. ¿El mismo campo mantiene el mismo tipo en todo el módulo?
10. ¿El DTO refleja una vista concreta del endpoint?
---

View File

@ -17,6 +17,7 @@ import {
isDomainValidationError,
isValidationErrorCollection,
} from "@repo/rdx-ddd";
import type { ZodError } from "zod";
import { isSchemaError } from "../../../common/schemas";
import { type DocumentGenerationError, isDocumentGenerationError } from "../../application";
@ -33,7 +34,6 @@ import {
isInfrastructureRepositoryError,
isInfrastructureUnavailableError,
} from "../errors";
import type { InfrastructureAPIContractError } from "../errors/infrastructure-api-contract-error";
import {
ApiError,
@ -214,8 +214,10 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
{
priority: 30,
matches: (e) => isSchemaError(e),
build: (e) =>
new InternalApiError((e as InfrastructureAPIContractError).message, "API contract violation"),
build: (e) => {
const schemaError = e as ZodError;
return new ValidationApiError("Schema validation failed", schemaError.issues);
},
},
];

View File

@ -1,6 +1,7 @@
import { z } from "zod/v4";
import type { z } from "zod/v4";
import { AmountBaseSchema } from "./base.schemas";
import { CurrencyCodeSchema } from "./currency-code.dto";
/**
Esquema del DTO para valores monetarios con/sin código de moneda.
@ -13,7 +14,7 @@ import { AmountBaseSchema } from "./base.schemas";
// 🔹 Con moneda
export const MoneySchema = AmountBaseSchema.extend({
currency_code: z.string(),
currency_code: CurrencyCodeSchema,
});
// 🔹 Sin moneda

View File

@ -0,0 +1,13 @@
import { z } from "zod/v4";
/**
* Código de país ISO 3166-1 alpha-2 en mayúsculas.
*
* Valida exactamente 2 letras ASCII mayúsculas.
* Ejemplos válidos: "ES", "FR", "PT".
*/
export const CountryCodeSchema = z
.string()
.regex(/^[A-Z]{2}$/, "Country code must be a valid ISO 3166-1 alpha-2 uppercase code.");
export type CountryCodeDTO = z.infer<typeof CountryCodeSchema>;

View File

@ -0,0 +1,13 @@
import { z } from "zod/v4";
/**
* Código de moneda ISO 4217 (alpha-3).
*
* Valida exactamente 3 letras ASCII mayúsculas.
* Ejemplos válidos: "EUR", "USD", "GBP".
*/
export const CurrencyCodeSchema = z
.string()
.regex(/^[A-Z]{3}$/, "Currency code must be a valid ISO 4217 alpha-3 uppercase code.");
export type CurrencyCodeDTO = z.infer<typeof CurrencyCodeSchema>;

View File

@ -0,0 +1,5 @@
import { z } from "zod/v4";
export const EmailSchema = z.string().email();
export type EmailDTO = z.infer<typeof EmailSchema>;

View File

@ -1,8 +1,18 @@
export * from "./amount-money.dto";
export * from "./base.schemas";
export * from "./country-code.dto";
export * from "./critera.dto";
export * from "./currency-code.dto";
export * from "./email.dto";
export * from "./error.dto";
export * from "./iso-date-dto";
export * from "./land-phone.dto";
export * from "./language-code.dto";
export * from "./list-view.response.dto";
export * from "./metadata.dto";
export * from "./mobile-phone.dto";
export * from "./percentage.dto";
export * from "./postal-code.dto";
export * from "./quantity.dto";
export * from "./tin.dto";
export * from "./url.dto";

View File

@ -0,0 +1,51 @@
import { z } from "zod/v4";
/**
* Esquema para validar una fecha en formato ISO 8601 (solo fecha).
*
* @remarks
* Formato esperado: `YYYY-MM-DD`
*
* @example
* "2020-01-01"
*/
export const IsoDateSchema = z.iso.date();
/**
* Esquema para validar una hora en formato ISO 8601.
*
* @remarks
* Acepta horas con o sin segundos.
* Formatos válidos:
* - `HH:mm`
* - `HH:mm:ss`
*
* @example
* "03:15"
* "03:15:00"
*/
export const IsoTimeSchema = z.iso.time();
/**
* Esquema para validar una fecha y hora con offset de zona horaria.
*
* @remarks
* Requiere un desplazamiento explícito respecto a UTC.
* Formato: `YYYY-MM-DDTHH:mm:ss±HH:mm`
*
* @example
* "2020-01-01T06:15:00+02:00"
*/
export const OffsetDateTimeSchema = z.iso.datetime({ offset: true });
/**
* Esquema para validar una fecha y hora local (sin información de zona horaria).
*
* @remarks
* No incluye offset ni información de zona.
* Formato: `YYYY-MM-DDTHH:mm:ss`
*
* @example
* "2020-01-01T06:15:01"
*/
export const LocalDateTimeSchema = z.iso.datetime({ local: true });

View File

@ -0,0 +1,5 @@
import { z } from "zod/v4";
export const LandPhoneSchema = z.string().regex(/^\d{3}-\d{3}-\d{4}$/);
export type LandPhoneDTO = z.infer<typeof LandPhoneSchema>;

View File

@ -0,0 +1,13 @@
import { z } from "zod/v4";
/**
* Código de idioma ISO 639-1.
*
* Valida exactamente 2 letras ASCII minúsculas.
* Ejemplos válidos: "es", "en", "fr".
*/
export const LanguageCodeSchema = z
.string()
.regex(/^[a-z]{2}$/, "Language code must be a valid ISO 639-1 lowercase code.");
export type LanguageCodeDTO = z.infer<typeof LanguageCodeSchema>;

View File

@ -0,0 +1,5 @@
import { z } from "zod/v4";
export const MobilePhoneSchema = z.string().regex(/^\d{3}-\d{3}-\d{4}$/);
export type MobilePhoneDTO = z.infer<typeof MobilePhoneSchema>;

View File

@ -0,0 +1,21 @@
import { z } from "zod/v4";
/**
* Código postal genérico (global).
*
* Permite letras, números, espacios y guiones.
* No valida formato específico por país.
*
* Ejemplos válidos:
* - "28013" (ES)
* - "SW1A 1AA" (UK)
* - "75008" (FR)
* - "10115" (DE)
*/
export const PostalCodeSchema = z
.string()
.min(1, "Postal code cannot be empty")
.max(16, "Postal code too long")
.regex(/^[A-Za-z0-9\- ]+$/, "Invalid postal code format");
export type PostalCodeDTO = z.infer<typeof PostalCodeSchema>;

View File

@ -0,0 +1,23 @@
import { z } from "zod/v4";
/**
* Tax Identification Number (TIN) genérico internacional.
*
* Permite letras y números ASCII, además de separadores frecuentes.
* No valida reglas específicas por país.
*
* Ejemplos válidos:
* - "B83999441"
* - "ESB83999441"
* - "48086V"
* - "FR40303265045"
* - "DE123456789"
*/
export const TinSchema = z
.string()
.trim()
.min(1, "TIN cannot be empty.")
.max(32, "TIN is too long.")
.regex(/^(?=.*[A-Za-z0-9])[A-Za-z0-9.\-/ ]+$/, "TIN has an invalid format.");
export type TinDTO = z.infer<typeof TinSchema>;

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const URLSchema = z.url({
message: "Invalid URL format",
});
export type URLDTO = z.infer<typeof URLSchema>;

View File

@ -38,4 +38,6 @@ export type IssuedInvoiceSummary = {
totalAmount: InvoiceAmount;
verifactu: Maybe<VerifactuRecord>;
linkedProformaId: Maybe<UniqueID>;
};

View File

@ -1,5 +1,5 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import { maybeToNullable } from "@repo/rdx-ddd";
import type { IssuedInvoice } from "../../../../domain";
@ -26,31 +26,26 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
const verifactu = this.verifactuBuilder.toOutput(invoice);
const taxes = this.taxesBuilder.toOutput(invoice.taxes);
const payment = invoice.paymentMethod.match(
(payment) => {
const { id, payment_description } = payment.toObjectString();
return {
payment_id: id,
payment_description,
};
},
() => undefined
);
const payment_method = maybeToNullable(invoice.paymentMethod, (p) => ({
payment_id: p.id.toString(),
payment_description: p.paymentDescription.toString(),
}));
return {
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),
is_proforma: false,
invoice_number: invoice.invoiceNumber.toString(),
status: invoice.status.toPrimitive(),
series: maybeToEmptyString(invoice.series, (value) => value.toString()),
series: invoice.series.toString(),
invoice_date: invoice.invoiceDate.toDateString(),
operation_date: maybeToEmptyString(invoice.operationDate, (value) => value.toDateString()),
operation_date: maybeToNullable(invoice.operationDate, (value) => value.toDateString()),
reference: maybeToEmptyString(invoice.reference, (value) => value.toString()),
description: maybeToEmptyString(invoice.description, (value) => value.toString()),
notes: maybeToEmptyString(invoice.notes, (value) => value.toString()),
reference: maybeToNullable(invoice.reference, (value) => value.toString()),
description: invoice.description.toString(),
notes: maybeToNullable(invoice.notes, (value) => value.toString()),
language_code: invoice.languageCode.toString(),
currency_code: invoice.currencyCode.toString(),
@ -58,14 +53,19 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
customer_id: invoice.customerId.toString(),
recipient,
payment_method: payment,
linked_proforma_id: maybeToNullable(invoice.linkedProformaId, (linkedId) =>
linkedId.toString()
),
taxes,
payment_method,
subtotal_amount: invoice.subtotalAmount.toObjectString(),
items_discount_amount: invoice.itemsDiscountAmount.toObjectString(),
items_discount_amount: invoice.itemsDiscountAmount.toObjectString(),
global_discount_percentage: invoice.globalDiscountPercentage.toObjectString(),
global_discount_amount: invoice.globalDiscountAmount.toObjectString(),
total_discount_amount: invoice.totalDiscountAmount.toObjectString(),
taxable_amount: invoice.taxableAmount.toObjectString(),
@ -77,8 +77,6 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
taxes_amount: invoice.taxesAmount.toObjectString(),
total_amount: invoice.totalAmount.toObjectString(),
taxes,
verifactu,
items,

View File

@ -3,20 +3,32 @@ import type { IIssuedInvoiceRecipientFullSnapshot } from "./issued-invoice-recip
import type { IIssuedInvoiceTaxFullSnapshot } from "./issued-invoice-tax-full-snapshot-interface";
import type { IIssuedInvoiceVerifactuFullSnapshot } from "./issued-invoice-verifactu-full-snapshot.interface";
/**
* Utilizar GetIssuedInvoiceByIdResponseDTO o
* definir un tipo específico para el snapshot de factura emitida,
* ya que GetIssuedInvoiceByIdResponseDTO tiene campos que no se
* corresponden exactamente con el snapshot
* (por ejemplo, tiene un campo "linked_invoice" que en el snapshot se representa
* como "linked_invoice_id").
*/
//export type IIssuedInvoiceFullSnapshot = GetIssuedInvoiceByIdResponseDTO;
export interface IIssuedInvoiceFullSnapshot {
id: string;
company_id: string;
is_proforma: boolean;
invoice_number: string;
status: string;
series: string;
invoice_date: string;
operation_date: string;
operation_date: string | null;
reference: string;
reference: string | null;
description: string;
notes: string;
notes: string | null;
language_code: string;
currency_code: string;
@ -24,18 +36,20 @@ export interface IIssuedInvoiceFullSnapshot {
customer_id: string;
recipient: IIssuedInvoiceRecipientFullSnapshot;
payment_method?: {
linked_proforma_id: string | null;
taxes: IIssuedInvoiceTaxFullSnapshot[];
payment_method: {
payment_id: string;
payment_description: string;
};
} | null;
subtotal_amount: { value: string; scale: string; currency_code: string };
items_discount_amount: { value: string; scale: string; currency_code: string };
global_discount_percentage: { value: string; scale: string };
global_discount_amount: { value: string; scale: string; currency_code: string };
total_discount_amount: { value: string; scale: string; currency_code: string };
taxable_amount: { value: string; scale: string; currency_code: string };
@ -47,8 +61,6 @@ export interface IIssuedInvoiceFullSnapshot {
taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string };
taxes: IIssuedInvoiceTaxFullSnapshot[];
verifactu: IIssuedInvoiceVerifactuFullSnapshot;
items: IIssuedInvoiceItemFullSnapshot[];

View File

@ -1,8 +1,8 @@
export interface IIssuedInvoiceItemFullSnapshot {
id: string;
is_valued: string;
position: string;
description: string;
is_valued: boolean;
position: number;
description: string | null;
quantity: { value: string; scale: string };
unit_amount: { value: string; scale: string; currency_code: string };

View File

@ -4,6 +4,7 @@ import {
maybeToEmptyPercentageObjectString,
maybeToEmptyQuantityObjectString,
maybeToEmptyString,
maybeToNullable,
} from "@repo/rdx-ddd";
import { type IssuedInvoiceItem, type IssuedInvoiceItems, ItemAmount } from "../../../../domain";
@ -21,10 +22,10 @@ export class IssuedInvoiceItemsFullSnapshotBuilder
return {
id: invoiceItem.id.toPrimitive(),
is_valued: String(isValued),
position: String(index),
is_valued: isValued,
position: index,
description: maybeToEmptyString(invoiceItem.description, (value) => value.toString()),
description: maybeToNullable(invoiceItem.description, (value) => value.toString()),
quantity: maybeToEmptyQuantityObjectString(invoiceItem.quantity),
unit_amount: maybeToEmptyMoneyObjectString(invoiceItem.unitAmount),

View File

@ -1,7 +1,7 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { DomainValidationError, maybeToEmptyString } from "@repo/rdx-ddd";
import { DomainValidationError, maybeToNullable } from "@repo/rdx-ddd";
import type { InvoiceRecipient, IssuedInvoice } from "../../../../domain";
import type { IssuedInvoice } from "../../../../domain";
import type { IIssuedInvoiceRecipientFullSnapshot } from "./issued-invoice-recipient-full-snapshot.interface";
@ -17,30 +17,18 @@ export class IssuedInvoiceRecipientFullSnapshotBuilder
cause: invoice,
});
}
const recipient = invoice.recipient;
return invoice.recipient.match(
(recipient: InvoiceRecipient) => ({
return {
id: invoice.customerId.toString(),
name: recipient.name.toString(),
tin: recipient.tin.toString(),
street: maybeToEmptyString(recipient.street, (v) => v.toString()),
street2: maybeToEmptyString(recipient.street2, (v) => v.toString()),
city: maybeToEmptyString(recipient.city, (v) => v.toString()),
province: maybeToEmptyString(recipient.province, (v) => v.toString()),
postal_code: maybeToEmptyString(recipient.postalCode, (v) => v.toString()),
country: maybeToEmptyString(recipient.country, (v) => v.toString()),
}),
() => ({
id: "",
name: "",
tin: "",
street: "",
street2: "",
city: "",
province: "",
postal_code: "",
country: "",
})
);
street: maybeToNullable(recipient.street, (v) => v.toString()),
street2: maybeToNullable(recipient.street2, (v) => v.toString()),
city: maybeToNullable(recipient.city, (v) => v.toString()),
province: maybeToNullable(recipient.province, (v) => v.toString()),
postal_code: maybeToNullable(recipient.postalCode, (v) => v.toString()),
country: maybeToNullable(recipient.country, (v) => v.toString()),
};
}
}

View File

@ -1,11 +1,19 @@
export interface IIssuedInvoiceRecipientFullSnapshot {
id: string;
name: string;
tin: string;
street: string;
street2: string;
city: string;
province: string;
postal_code: string;
country: string;
/**
* Fijarse en IssuedInvoiceRecipientSummarySchema
*/
import type { IssuedInvoiceRecipientSummaryDTO } from "../../../../../common/dto";
export type IIssuedInvoiceRecipientFullSnapshot = IssuedInvoiceRecipientSummaryDTO;
interface IIssuedInvoiceRecipientFullSnapshot2 {
id: string | null;
name: string | null;
tin: string | null;
street: string | null;
street2: string | null;
city: string | null;
province: string | null;
postal_code: string | null;
country: string | null;
}

View File

@ -5,11 +5,11 @@ export interface IIssuedInvoiceTaxFullSnapshot {
iva_percentage: { value: string; scale: string };
iva_amount: { value: string; scale: string; currency_code: string };
rec_code: string;
rec_code: string | null;
rec_percentage: { value: string; scale: string };
rec_amount: { value: string; scale: string; currency_code: string };
retention_code: string;
retention_code: string | null;
retention_percentage: { value: string; scale: string };
retention_amount: { value: string; scale: string; currency_code: string };

View File

@ -1,5 +1,5 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyPercentageObjectString, maybeToEmptyString } from "@repo/rdx-ddd";
import { maybeToEmptyPercentageObjectString, maybeToNullable } from "@repo/rdx-ddd";
import type { IssuedInvoiceTax, IssuedInvoiceTaxes } from "../../../../domain";
@ -19,11 +19,11 @@ export class IssuedInvoiceTaxesFullSnapshotBuilder
iva_percentage: invoiceTax.ivaPercentage.toObjectString(),
iva_amount: invoiceTax.ivaAmount.toObjectString(),
rec_code: maybeToEmptyString(invoiceTax.recCode),
rec_code: maybeToNullable(invoiceTax.recCode, (v) => v.toString()),
rec_percentage: maybeToEmptyPercentageObjectString(invoiceTax.recPercentage),
rec_amount: invoiceTax.recAmount.toObjectString(),
retention_code: maybeToEmptyString(invoiceTax.retentionCode),
retention_code: maybeToNullable(invoiceTax.retentionCode, (v) => v.toString()),
retention_percentage: maybeToEmptyPercentageObjectString(invoiceTax.retentionPercentage),
retention_amount: invoiceTax.retentionAmount.toObjectString(),

View File

@ -1,6 +1,10 @@
/**
* Fijarse en VerifactuRecordSchema
*/
export interface IIssuedInvoiceVerifactuFullSnapshot {
id: string;
status: string;
url: string;
qr_code: string;
url: string | null;
qr_code: string | null;
}

View File

@ -1,5 +1,6 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import { VERIFACTU_RECORD_STATUS } from "@erp/customer-invoices/api/domain";
import { maybeToEmptyString, maybeToNullable } from "@repo/rdx-ddd";
import type { IssuedInvoiceSummary } from "../../models";
@ -13,19 +14,24 @@ export class IssuedInvoiceSummarySnapshotBuilder implements IIssuedInvoiceSummar
const recipient = invoice.recipient.toObjectString();
const verifactu = invoice.verifactu.match(
(v) => v.toObjectString(),
(v) => {
return {
status: v.estado.toString(),
url: maybeToNullable(v.url, (u) => u.toString()),
qr_code: maybeToNullable(v.qrCode, (q) => q.toString()),
};
},
() => ({
status: "",
url: "",
qr_code: "",
status: VERIFACTU_RECORD_STATUS.PENDIENTE,
url: null,
qr_code: null,
})
);
return {
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),
customer_id: invoice.customerId.toString(),
is_proforma: invoice.isProforma,
invoice_number: invoice.invoiceNumber.toString(),
status: invoice.status.toPrimitive(),
@ -36,7 +42,12 @@ export class IssuedInvoiceSummarySnapshotBuilder implements IIssuedInvoiceSummar
reference: maybeToEmptyString(invoice.reference, (v) => v.toString()),
description: maybeToEmptyString(invoice.description, (v) => v.toString()),
recipient,
customer_id: invoice.customerId.toString(),
recipient: {
id: invoice.customerId.toString(),
...recipient,
},
language_code: invoice.languageCode.code,
currency_code: invoice.currencyCode.code,
@ -49,9 +60,7 @@ export class IssuedInvoiceSummarySnapshotBuilder implements IIssuedInvoiceSummar
verifactu,
metadata: {
entity: "issued-invoice",
},
linked_proforma_id: maybeToNullable(invoice.linkedProformaId, (v) => v.toString()),
};
}
}

View File

@ -1,8 +1,10 @@
/**
* Fijarse en ListIssuedInvoicesResponseDTO["items"]
*/
export interface IIssuedInvoiceSummarySnapshot {
id: string;
company_id: string;
customer_id: string;
is_proforma: boolean;
invoice_number: string;
status: string;
@ -14,18 +16,20 @@ export interface IIssuedInvoiceSummarySnapshot {
language_code: string;
currency_code: string;
reference: string;
reference: string | null;
description: string;
customer_id: string;
recipient: {
id: string;
tin: string;
name: string;
street: string;
street2: string;
city: string;
postal_code: string;
province: string;
country: string;
street: string | null;
street2: string | null;
city: string | null;
postal_code: string | null;
province: string | null;
country: string | null;
};
subtotal_amount: { value: string; scale: string; currency_code: string };
@ -36,9 +40,9 @@ export interface IIssuedInvoiceSummarySnapshot {
verifactu: {
status: string;
url: string;
qr_code: string;
url: string | null;
qr_code: string | null;
};
metadata?: Record<string, string>;
linked_proforma_id: string | null;
}

View File

@ -1,5 +1,5 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { DiscountPercentage, Tax } from "@erp/core/api";
import { type JsonTaxCatalogProvider, NumberHelper } from "@erp/core";
import { DiscountPercentage } from "@erp/core/api";
import {
CurrencyCode,
DomainError,
@ -15,7 +15,7 @@ import {
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../common";
import type { CreateProformaRequestDTO } from "../../../../common";
import {
type IProformaCreateProps,
type IProformaItemCreateProps,
@ -27,6 +27,7 @@ import {
ItemAmount,
ItemDescription,
ItemQuantity,
ProformaItemTaxes,
type ProformaItemTaxesProps,
} from "../../../domain";
@ -221,19 +222,25 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
);
const quantity = extractOrPushError(
maybeFromNullableResult(item.quantity, (v) => ItemQuantity.create(v)),
maybeFromNullableResult(item.quantity, (v) =>
ItemQuantity.create({ value: NumberHelper.toSafeNumber(v) })
),
`items[${index}].quantity`,
params.errors
);
const unitAmount = extractOrPushError(
maybeFromNullableResult(item.unit_amount, (v) => ItemAmount.create(v)),
maybeFromNullableResult(item.unit_amount, (v) =>
ItemAmount.create({ value: NumberHelper.toSafeNumber(v) })
),
`items[${index}].unit_amount`,
params.errors
);
const discountPercentage = extractOrPushError(
maybeFromNullableResult(item.item_discount_percentage, (v) => DiscountPercentage.create(v)),
maybeFromNullableResult(item.item_discount_percentage, (v) =>
DiscountPercentage.create({ value: NumberHelper.toSafeNumber(v.value) })
),
`items[${index}].discount_percentage`,
params.errors
);
@ -264,10 +271,14 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
/* Devuelve las propiedades de los impustos de una línea de detalle */
private mapTaxesProps(
taxesDTO: Pick<CreateProformaItemRequestDTO, "taxes">["taxes"],
taxesDTO: NonNullable<CreateProformaRequestDTO["items"]>[number]["taxes"],
params: { itemIndex: number; errors: ValidationErrorDetail[] }
): ProformaItemTaxesProps {
const { itemIndex, errors } = params;
// TODO: POR AHORA SE QUEDA ASÍ
return ProformaItemTaxes.empty().getProps();
/*const { itemIndex, errors } = params;
const taxesProps: ProformaItemTaxesProps = {
iva: Maybe.none(),
@ -275,7 +286,6 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
rec: Maybe.none(),
};
// Normaliza: "" -> []
const taxStrCodes = taxesDTO
.split(",")
.map((s) => s.trim())
@ -328,5 +338,6 @@ export class CreateProformaInputMapper /*implements ICreateProformaInputMapper*/
this.throwIfValidationErrors(errors);
return taxesProps;
*/
}
}

View File

@ -21,6 +21,8 @@ import {
ItemDescription,
ItemQuantity,
type ProformaItemPatchProps,
ProformaItemTaxes,
type ProformaItemTaxesProps,
type ProformaPatchProps,
} from "../../../domain";
@ -198,10 +200,10 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
params.errors
);
/*const taxes = this.mapTaxesProps(item.taxes, {
const taxes = this.mapTaxesProps(item.taxes, {
itemIndex: index,
errors: params.errors,
});*/
});
this.throwIfValidationErrors(params.errors);
@ -210,20 +212,24 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
quantity: quantity!,
unitAmount: unitAmount!,
itemDiscountPercentage: discountPercentage!,
//taxes,
taxes,
});
});
return itemsProps;
}
/* Devuelve las propiedades de los impustos de una línea de detalle */
/* Devuelve las propiedades de los impuestos de una línea de detalle */
/*private mapTaxesProps(
taxesDTO: Pick<ProformaItemRequestDTO, "taxes">["taxes"],
private mapTaxesProps(
taxesDTO: NonNullable<UpdateProformaByIdRequestDTO["items"]>[number]["taxes"],
params: { itemIndex: number; errors: ValidationErrorDetail[] }
): ProformaItemTaxesProps {
const { itemIndex, errors } = params;
// TODO: POR AHORA SE QUEDA ASÍ
return ProformaItemTaxes.empty().getProps();
/*const { itemIndex, errors } = params;
const taxesProps: ProformaItemTaxesProps = {
iva: Maybe.none(),
@ -231,7 +237,6 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
rec: Maybe.none(),
};
// Normaliza: "" -> []
const taxStrCodes = taxesDTO
.split(",")
.map((s) => s.trim())
@ -283,6 +288,6 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
this.throwIfValidationErrors(errors);
return taxesProps;
}*/
return taxesProps;*/
}
}

View File

@ -1,5 +1,5 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import { maybeToNullable } from "@repo/rdx-ddd";
import type { Proforma } from "../../../../domain";
@ -31,7 +31,7 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
payment_description,
};
},
() => undefined
() => null
);
const allTotals = proforma.totals();
@ -40,16 +40,17 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
id: proforma.id.toString(),
company_id: proforma.companyId.toString(),
is_proforma: true,
invoice_number: proforma.invoiceNumber.toString(),
status: proforma.status.toPrimitive(),
series: maybeToEmptyString(proforma.series, (value) => value.toString()),
series: maybeToNullable(proforma.series, (value) => value.toString()),
invoice_date: proforma.invoiceDate.toDateString(),
operation_date: maybeToEmptyString(proforma.operationDate, (value) => value.toDateString()),
operation_date: maybeToNullable(proforma.operationDate, (value) => value.toDateString()),
reference: maybeToEmptyString(proforma.reference, (value) => value.toString()),
description: maybeToEmptyString(proforma.description, (value) => value.toString()),
notes: maybeToEmptyString(proforma.notes, (value) => value.toString()),
reference: maybeToNullable(proforma.reference, (value) => value.toString()),
description: maybeToNullable(proforma.description, (value) => value.toString()),
notes: maybeToNullable(proforma.notes, (value) => value.toString()),
language_code: proforma.languageCode.toString(),
currency_code: proforma.currencyCode.toString(),
@ -57,6 +58,8 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
customer_id: proforma.customerId.toString(),
recipient,
linked_invoice_id: maybeToNullable(proforma.linkedInvoiceId, (value) => value.toString()),
payment_method: payment,
subtotal_amount: allTotals.subtotalAmount.toObjectString(),

View File

@ -2,20 +2,25 @@ import type { IProformaItemFullSnapshot } from "./proforma-item-full-snapshot.in
import type { IProformaRecipientFullSnapshot } from "./proforma-recipient-full-snapshot.interface";
import type { IProformaTaxFullSnapshot } from "./proforma-tax-full-snapshot-interface";
/**
* Fijarse en GetProformaByIdResponseDTO
*/
export interface IProformaFullSnapshot {
id: string;
company_id: string;
is_proforma: boolean;
invoice_number: string;
status: string;
series: string;
series: string | null;
invoice_date: string;
operation_date: string;
operation_date: string | null;
reference: string;
description: string;
notes: string;
reference: string | null;
description: string | null;
notes: string | null;
language_code: string;
currency_code: string;
@ -23,10 +28,14 @@ export interface IProformaFullSnapshot {
customer_id: string;
recipient: IProformaRecipientFullSnapshot;
payment_method?: {
linked_invoice_id: string | null;
taxes: IProformaTaxFullSnapshot[];
payment_method: {
payment_id: string;
payment_description: string;
};
} | null;
subtotal_amount: { value: string; scale: string; currency_code: string };
items_discount_amount: { value: string; scale: string; currency_code: string };
@ -45,9 +54,7 @@ export interface IProformaFullSnapshot {
taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string };
taxes: IProformaTaxFullSnapshot[];
items: IProformaItemFullSnapshot[];
metadata?: Record<string, string>;
metadata: Record<string, string> | null;
}

View File

@ -1,8 +1,8 @@
export interface IProformaItemFullSnapshot {
id: string;
is_valued: string;
position: string;
description: string;
is_valued: boolean;
position: number;
description: string | null;
quantity: { value: string; scale: string };
unit_amount: { value: string; scale: string; currency_code: string };

View File

@ -4,6 +4,7 @@ import {
maybeToEmptyPercentageObjectString,
maybeToEmptyQuantityObjectString,
maybeToEmptyString,
maybeToNullable,
} from "@repo/rdx-ddd";
import { ItemAmount, type ProformaItem, type ProformaItems } from "../../../../domain";
@ -20,10 +21,10 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps
return {
id: proformaItem.id.toPrimitive(),
is_valued: String(isValued),
position: String(index),
is_valued: isValued,
position: index,
description: maybeToEmptyString(proformaItem.description, (value) => value.toString()),
description: maybeToNullable(proformaItem.description, (value) => value.toString()),
quantity: maybeToEmptyQuantityObjectString(proformaItem.quantity),
unit_amount: maybeToEmptyMoneyObjectString(proformaItem.unitAmount),

View File

@ -1,5 +1,5 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { DomainValidationError, maybeToEmptyString } from "@repo/rdx-ddd";
import { DomainValidationError, maybeToNullable } from "@repo/rdx-ddd";
import type { InvoiceRecipient, Proforma } from "../../../../domain";
@ -21,24 +21,25 @@ export class ProformaRecipientFullSnapshotBuilder implements IProformaRecipientF
id: proforma.customerId.toString(),
name: recipient.name.toString(),
tin: recipient.tin.toString(),
street: maybeToEmptyString(recipient.street, (v) => v.toString()),
street2: maybeToEmptyString(recipient.street2, (v) => v.toString()),
city: maybeToEmptyString(recipient.city, (v) => v.toString()),
province: maybeToEmptyString(recipient.province, (v) => v.toString()),
postal_code: maybeToEmptyString(recipient.postalCode, (v) => v.toString()),
country: maybeToEmptyString(recipient.country, (v) => v.toString()),
street: maybeToNullable(recipient.street, (v) => v.toString()),
street2: maybeToNullable(recipient.street2, (v) => v.toString()),
city: maybeToNullable(recipient.city, (v) => v.toString()),
province: maybeToNullable(recipient.province, (v) => v.toString()),
postal_code: maybeToNullable(recipient.postalCode, (v) => v.toString()),
country: maybeToNullable(recipient.country, (v) => v.toString()),
}),
() => ({
id: "",
name: "",
tin: "",
street: "",
street2: "",
city: "",
province: "",
postal_code: "",
country: "",
})
() =>
({
id: null,
name: null,
tin: null,
street: null,
street2: null,
city: null,
province: null,
postal_code: null,
country: null,
}) as IProformaRecipientFullSnapshot
);
}
}

View File

@ -1,11 +1,15 @@
/**
* Fijarse en ProformaRecipientSummarySchema
*/
export interface IProformaRecipientFullSnapshot {
id: string;
name: string;
tin: string;
street: string;
street2: string;
city: string;
province: string;
postal_code: string;
country: string;
id: string | null;
name: string | null;
tin: string | null;
street: string | null;
street2: string | null;
city: string | null;
province: string | null;
postal_code: string | null;
country: string | null;
}

View File

@ -1,5 +1,5 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import { maybeToEmptyString, maybeToNullable } from "@repo/rdx-ddd";
import type { ProformaSummary } from "../../models";
@ -15,18 +15,22 @@ export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotB
return {
id: proforma.id.toString(),
company_id: proforma.companyId.toString(),
customer_id: proforma.customerId.toString(),
is_proforma: proforma.isProforma,
invoice_number: proforma.invoiceNumber.toString(),
status: proforma.status.toPrimitive(),
series: maybeToEmptyString(proforma.series, (value) => value.toString()),
series: maybeToNullable(proforma.series, (value) => value.toString()),
invoice_date: proforma.invoiceDate.toDateString(),
operation_date: maybeToEmptyString(proforma.operationDate, (value) => value.toDateString()),
operation_date: maybeToNullable(proforma.operationDate, (value) => value.toDateString()),
reference: maybeToEmptyString(proforma.reference, (value) => value.toString()),
description: maybeToEmptyString(proforma.description, (value) => value.toString()),
description: maybeToNullable(proforma.description, (value) => value.toString()),
recipient,
customer_id: proforma.customerId.toString(),
recipient: {
id: proforma.customerId.toString(),
...recipient,
},
language_code: proforma.languageCode.code,
currency_code: proforma.currencyCode.code,
@ -37,11 +41,7 @@ export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotB
taxes_amount: proforma.taxesAmount.toObjectString(),
total_amount: proforma.totalAmount.toObjectString(),
linked_invoice_id: maybeToEmptyString(proforma.linkedInvoiceId, (value) => value.toString()),
metadata: {
entity: "proforma",
},
linked_invoice_id: maybeToNullable(proforma.linkedInvoiceId, (value) => value.toString()),
};
}
}

View File

@ -1,31 +1,35 @@
/**
* Fijarse en ListProformasResponseDTO["items"]
*/
export interface IProformaSummarySnapshot {
id: string;
company_id: string;
customer_id: string;
is_proforma: boolean;
invoice_number: string;
status: string;
series: string;
series: string | null;
invoice_date: string;
operation_date: string;
operation_date: string | null;
language_code: string;
currency_code: string;
reference: string;
description: string;
reference: string | null;
description: string | null;
customer_id: string;
recipient: {
id: string;
tin: string;
name: string;
street: string;
street2: string;
city: string;
postal_code: string;
province: string;
country: string;
street: string | null;
street2: string | null;
city: string | null;
postal_code: string | null;
province: string | null;
country: string | null;
};
subtotal_amount: { value: string; scale: string; currency_code: string };
@ -34,7 +38,5 @@ export interface IProformaSummarySnapshot {
taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string };
linked_invoice_id: string;
metadata?: Record<string, string>;
linked_invoice_id: string | null;
}

View File

@ -7,7 +7,7 @@ import {
type Street,
type TINNumber,
ValueObject,
maybeToEmptyString,
maybeToNullable,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
@ -90,12 +90,12 @@ export class InvoiceRecipient extends ValueObject<InvoiceRecipientProps> {
return {
tin: this.tin.toString(),
name: this.name.toString(),
street: maybeToEmptyString(this.street, (value) => value.toString()),
street2: maybeToEmptyString(this.street2, (value) => value.toString()),
city: maybeToEmptyString(this.city, (value) => value.toString()),
postal_code: maybeToEmptyString(this.postalCode, (value) => value.toString()),
province: maybeToEmptyString(this.province, (value) => value.toString()),
country: maybeToEmptyString(this.country, (value) => value.toString()),
street: maybeToNullable(this.street, (value) => value.toString()),
street2: maybeToNullable(this.street2, (value) => value.toString()),
city: maybeToNullable(this.city, (value) => value.toString()),
postal_code: maybeToNullable(this.postalCode, (value) => value.toString()),
province: maybeToNullable(this.province, (value) => value.toString()),
country: maybeToNullable(this.country, (value) => value.toString()),
};
}
}

View File

@ -32,19 +32,19 @@ export interface IIssuedInvoiceCreateProps {
companyId: UniqueID;
status: InvoiceStatus;
proformaId: UniqueID; // <- id de la proforma padre en caso de issue
linkedProformaId: Maybe<UniqueID>; // <- id de la proforma padre en caso de issue
series: Maybe<InvoiceSerie>;
series: InvoiceSerie;
invoiceNumber: InvoiceNumber;
invoiceDate: UtcDate;
operationDate: Maybe<UtcDate>;
customerId: UniqueID;
recipient: Maybe<InvoiceRecipient>;
recipient: InvoiceRecipient;
reference: Maybe<string>;
description: Maybe<string>;
description: string;
notes: Maybe<TextValue>;
languageCode: LanguageCode;
@ -178,15 +178,15 @@ export class IssuedInvoice
return this.props.customerId;
}
public get proformaId(): UniqueID {
return this.props.proformaId;
public get linkedProformaId(): Maybe<UniqueID> {
return this.props.linkedProformaId;
}
public get status(): InvoiceStatus {
return this.props.status;
}
public get series(): Maybe<InvoiceSerie> {
public get series(): InvoiceSerie {
return this.props.series;
}
@ -206,7 +206,7 @@ export class IssuedInvoice
return this.props.reference;
}
public get description(): Maybe<string> {
public get description(): string {
return this.props.description;
}
@ -214,7 +214,7 @@ export class IssuedInvoice
return this.props.notes;
}
public get recipient(): Maybe<InvoiceRecipient> {
public get recipient(): InvoiceRecipient {
return this.props.recipient;
}
@ -287,10 +287,6 @@ export class IssuedInvoice
return this._items;
}
public get hasRecipient() {
return this.recipient.isSome();
}
public get hasPaymentMethod() {
return this.paymentMethod.isSome();
}

View File

@ -35,8 +35,8 @@ export interface IProformaCreateProps {
companyId: UniqueID;
status: InvoiceStatus;
series: Maybe<InvoiceSerie>;
invoiceNumber: InvoiceNumber;
series: Maybe<InvoiceSerie>;
invoiceDate: UtcDate;
operationDate: Maybe<UtcDate>;
@ -51,6 +51,8 @@ export interface IProformaCreateProps {
languageCode: LanguageCode;
currencyCode: CurrencyCode;
linkedInvoiceId: Maybe<UniqueID>;
paymentMethod: Maybe<InvoicePaymentMethod>;
items: IProformaItemCreateProps[];
@ -100,17 +102,19 @@ export interface IProforma {
paymentMethod: Maybe<InvoicePaymentMethod>;
linkedInvoiceId: Maybe<UniqueID>;
items: IProformaItems; // <- Colección
taxes(): Collection<IProformaTaxTotals>;
totals(): IProformaTotals;
}
export type InternalProformaProps = Omit<IProformaCreateProps, "items">;
export type ProformaInternalProps = Omit<IProformaCreateProps, "items">;
export class Proforma extends AggregateRoot<InternalProformaProps> implements IProforma {
private readonly _items: ProformaItems;
export class Proforma extends AggregateRoot<ProformaInternalProps> implements IProforma {
private _items: ProformaItems;
protected constructor(props: InternalProformaProps, items: ProformaItems, id?: UniqueID) {
protected constructor(props: ProformaInternalProps, items: ProformaItems, id?: UniqueID) {
super(props, id);
this._items = items;
}
@ -153,7 +157,7 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
}
// Rehidratación desde persistencia
static rehydrate(props: InternalProformaProps, items: ProformaItems, id: UniqueID): Proforma {
static rehydrate(props: ProformaInternalProps, items: ProformaItems, id: UniqueID): Proforma {
return new Proforma(props, items, id);
}
@ -161,7 +165,7 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
public update(patchProps: ProformaPatchProps): Result<Proforma, Error> {
const { items, ...otherProps } = patchProps;
const candidateProps: InternalProformaProps = {
const candidateProps: ProformaInternalProps = {
...this.props,
...otherProps,
};
@ -261,6 +265,10 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
return this.props.paymentMethod;
}
public get linkedInvoiceId(): Maybe<UniqueID> {
return this.props.linkedInvoiceId;
}
public get languageCode(): LanguageCode {
return this.props.languageCode;
}

View File

@ -33,6 +33,7 @@ export class GetIssuedInvoiceByIdController extends ExpressController {
return result.match(
(data) => {
console.log(data);
const dto = GetIssuedInvoiceByIdResponseSchema.parse(data);
return this.ok(dto);
},

View File

@ -63,20 +63,18 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
// Para issued invoices, proforma_id debe estar relleno
const proformaId = extractOrPushError(
UniqueID.create(String(raw.proforma_id)),
const linkedProformaId = extractOrPushError(
maybeFromNullableResult(raw.proforma_id, (v) => UniqueID.create(String(v))),
"proforma_id",
errors
);
const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors);
const series = extractOrPushError(
maybeFromNullableResult(raw.series, (v) => InvoiceSerie.create(v)),
"series",
errors
);
// En el caso de "serie", al ser un campo opcional en BD
// pero obligatorio en el dominio,
// se le asignará un valor por defecto (cadena vacía) en caso de venir como nullish, para que así el VO lo valide y lance el error correspondiente. De esta forma evitamos que el mapper devuelva un null o undefined en el campo 'series' del dominio, lo cual podría generar errores difíciles de depurar posteriormente en el código.
const series = extractOrPushError(InvoiceSerie.create(raw.series ?? ""), "series", errors);
const invoiceNumber = extractOrPushError(
InvoiceNumber.create(raw.invoice_number),
@ -117,8 +115,11 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
errors
);
// En el caso de "description", al ser un campo opcional en BD
// pero obligatorio en el dominio,
// se le asignará un valor por defecto (cadena vacía) en caso de venir como nullish, para que así el VO lo valide y lance el error correspondiente. De esta forma evitamos que el mapper devuelva un null o undefined en el campo 'series' del dominio, lo cual podría generar errores difíciles de depurar posteriormente en el código.
const description = extractOrPushError(
maybeFromNullableResult(raw.description, (value) => Result.ok(String(value))),
Result.ok(String(raw.description ?? "")),
"description",
errors
);
@ -258,7 +259,6 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
invoiceId,
companyId,
customerId,
proformaId,
status,
series,
invoiceNumber,
@ -282,6 +282,8 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
retentionAmount,
taxesAmount,
totalAmount,
linkedProformaId,
};
}
@ -350,7 +352,6 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
const invoiceProps: InternalIssuedInvoiceProps = {
companyId: attributes.companyId!,
proformaId: attributes.proformaId!,
status: attributes.status!,
series: attributes.series!,
invoiceNumber: attributes.invoiceNumber!,
@ -386,6 +387,8 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
taxes,
verifactu,
linkedProformaId: attributes.linkedProformaId!,
};
const invoiceId = attributes.invoiceId!;
@ -464,9 +467,9 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
// Flags / estado / serie / número
is_proforma: false,
status: source.status.toPrimitive(),
proforma_id: source.proformaId.toPrimitive(),
proforma_id: maybeToNullable(source.linkedProformaId, (v) => v.toPrimitive()),
series: maybeToNullable(source.series, (v) => v.toPrimitive()),
series: source.series.toPrimitive(),
invoice_number: source.invoiceNumber.toPrimitive(),
invoice_date: source.invoiceDate.toPrimitive(),
operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()),
@ -474,7 +477,7 @@ export class SequelizeIssuedInvoiceDomainMapper extends SequelizeDomainMapper<
currency_code: source.currencyCode.toPrimitive(),
reference: maybeToNullable(source.reference, (reference) => reference),
description: maybeToNullable(source.description, (description) => description),
description: source.description,
notes: maybeToNullable(source.notes, (v) => v.toPrimitive()),
payment_method_id: maybeToNullable(

View File

@ -13,7 +13,7 @@ import {
maybeFromNullableResult,
maybeToNullable,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceCreateProps,
@ -26,7 +26,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
public mapToDomain(
source: CustomerInvoiceModel,
params?: MapperParamsType
): Result<Maybe<InvoiceRecipient>, Error> {
): Result<InvoiceRecipient, Error> {
/**
* - Issued invoice -> snapshot de los datos (campos customer_*)
*/
@ -105,7 +105,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
);
}
return Result.ok(Maybe.some(createResult.data));
return Result.ok(createResult.data);
}
/**
@ -117,7 +117,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
* - Si la factura no es proforma (`isProforma === false`), debe existir `recipient`.
* En caso contrario, se agrega un error de validación.
*/
mapToPersistence(source: Maybe<InvoiceRecipient>, params?: MapperParamsType) {
mapToPersistence(source: InvoiceRecipient, params?: MapperParamsType) {
const { errors, parent } = params as {
parent: IssuedInvoice;
errors: ValidationErrorDetail[];
@ -140,7 +140,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
);
}
const recipient = source.unwrap();
const recipient = source;
return {
customer_tin: recipient.tin.toPrimitive(),

View File

@ -28,7 +28,7 @@ export class SequelizeIssuedInvoiceVerifactuDomainMapper extends SequelizeDomain
Maybe<VerifactuRecord>
> {
public mapToDomain(
source: VerifactuRecordModel,
source: VerifactuRecordModel | null | undefined,
params?: MapperParamsType
): Result<Maybe<VerifactuRecord>, Error> {
const { errors, attributes } = params as {

View File

@ -110,6 +110,8 @@ export class SequelizeIssuedInvoiceSummaryMapper extends SequelizeQueryMapper<
taxesAmount: attributes.taxesAmount!,
totalAmount: attributes.totalAmount!,
linkedProformaId: attributes.linkedProformaId!,
verifactu,
});
}
@ -221,6 +223,12 @@ export class SequelizeIssuedInvoiceSummaryMapper extends SequelizeQueryMapper<
errors
);
const linkedProformaId = extractOrPushError(
maybeFromNullableResult(raw.proforma_id, (value) => UniqueID.create(value)),
"linked_proforma_id",
errors
);
return {
invoiceId,
companyId,
@ -241,6 +249,8 @@ export class SequelizeIssuedInvoiceSummaryMapper extends SequelizeQueryMapper<
taxableAmount,
taxesAmount,
totalAmount,
linkedProformaId,
};
}
}

View File

@ -14,12 +14,12 @@ import {
import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import {
type InternalProformaProps,
InvoiceNumber,
InvoicePaymentMethod,
InvoiceSerie,
InvoiceStatus,
Proforma,
type ProformaInternalProps,
ProformaItems,
} from "../../../../../../domain";
import type {
@ -150,6 +150,12 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
errors
);
const linkedInvoiceId = extractOrPushError(
maybeFromNullableResult(raw.linked_invoice?.id, (value) => UniqueID.create(value)),
"linked_invoice_id",
errors
);
return {
invoiceId,
companyId,
@ -167,6 +173,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
paymentMethod,
globalDiscountPercentage,
linkedInvoiceId,
};
}
@ -213,7 +220,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
items: itemCollectionResults.data.getAll(),
});
const invoiceProps: InternalProformaProps = {
const invoiceProps: ProformaInternalProps = {
companyId: attributes.companyId!,
status: attributes.status!,
@ -235,6 +242,8 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
globalDiscountPercentage: attributes.globalDiscountPercentage!,
paymentMethod: attributes.paymentMethod!,
linkedInvoiceId: attributes.linkedInvoiceId!, // El id de la factura emitida (linked_invoice) se asigna al hacer issue() desde la proforma, no viene en el modelo de persistencia.
};
const proformaId = attributes.invoiceId!;

View File

@ -58,7 +58,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
const { errors, index, parent } = params as {
index: number;
errors: ValidationErrorDetail[];
parent: Partial<ProformaCreateProps>;
parent: Partial<IProformaCreateProps>;
};
const itemId = extractOrPushError(

View File

@ -303,6 +303,12 @@ export class ProformaRepository
as: "taxes",
required: false,
},
{
model: CustomerInvoiceModel,
as: "linked_invoice",
required: false,
attributes: ["id"],
},
],
transaction,
};

View File

@ -1,2 +1,3 @@
export * from "./request";
export * from "./response";
export * from "./shared";

View File

@ -1,119 +1,68 @@
import { MetadataSchema, MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import {
CurrencyCodeSchema,
IsoDateSchema,
LanguageCodeSchema,
MetadataSchema,
MoneySchema,
PercentageSchema,
} from "@erp/core";
import { z } from "zod/v4";
import {
IssuedInvoiceItemDetailSchema,
IssuedInvoiceRecipientSummarySchema,
IssuedInvoiceStatusSchema,
PaymentMethodRefSchema,
TaxesBreakdownSchema,
VerifactuRecordSchema,
} from "../../shared";
export const GetIssuedInvoiceByIdResponseSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
is_proforma: z.boolean(),
invoice_number: z.string(),
status: z.string(),
status: IssuedInvoiceStatusSchema,
series: z.string(),
invoice_date: z.string(),
operation_date: z.string(),
invoice_date: IsoDateSchema,
operation_date: IsoDateSchema.nullable(),
reference: z.string(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
reference: z.string().nullable(),
description: z.string(),
notes: z.string(),
notes: z.string().nullable(),
language_code: z.string(),
currency_code: z.string(),
customer_id: z.uuid(),
recipient: IssuedInvoiceRecipientSummarySchema,
customer_id: z.string(),
recipient: z.object({
id: z.string(),
name: z.string(),
tin: z.string(),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postal_code: z.string(),
country: z.string(),
}),
linked_proforma_id: z.uuid().nullable(),
taxes: z.array(
z.object({
taxable_amount: MoneySchema,
taxes: z.array(TaxesBreakdownSchema),
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
rec_code: z.string(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
})
),
payment_method: z
.object({
payment_id: z.string(),
payment_description: z.string(),
})
.optional(),
payment_method: PaymentMethodRefSchema.nullable(),
subtotal_amount: MoneySchema,
items_discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,
total_discount_amount: MoneySchema,
taxable_amount: MoneySchema,
iva_amount: MoneySchema,
rec_amount: MoneySchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
verifactu: z.object({
status: z.string(),
url: z.string(),
qr_code: z.string(),
}),
items: z.array(
z.object({
id: z.uuid(),
is_valued: z.string(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
subtotal_amount: MoneySchema,
item_discount_percentage: PercentageSchema,
item_discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,
taxable_amount: MoneySchema,
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
rec_code: z.string(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
})
),
verifactu: VerifactuRecordSchema,
items: z.array(IssuedInvoiceItemDetailSchema),
metadata: MetadataSchema.optional(),
});

View File

@ -1,36 +1,39 @@
import { MetadataSchema, MoneySchema, createPaginatedListSchema } from "@erp/core";
import {
CurrencyCodeSchema,
IsoDateSchema,
LanguageCodeSchema,
MoneySchema,
createPaginatedListSchema,
} from "@erp/core";
import { z } from "zod/v4";
import {
IssuedInvoiceRecipientSummarySchema,
IssuedInvoiceStatusSchema,
VerifactuRecordSchema,
} from "../../shared";
export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema(
z.object({
id: z.uuid(),
company_id: z.uuid(),
customer_id: z.string(),
is_proforma: z.boolean(),
invoice_number: z.string(),
status: z.string(),
status: IssuedInvoiceStatusSchema,
series: z.string(),
invoice_date: z.string(),
operation_date: z.string(),
invoice_date: IsoDateSchema,
operation_date: IsoDateSchema.nullable(),
language_code: z.string(),
currency_code: z.string(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
reference: z.string(),
reference: z.string().nullable(),
description: z.string(),
recipient: z.object({
tin: z.string(),
name: z.string(),
street: z.string(),
street2: z.string(),
city: z.string(),
postal_code: z.string(),
province: z.string(),
country: z.string(),
}),
customer_id: z.uuid(),
recipient: IssuedInvoiceRecipientSummarySchema,
subtotal_amount: MoneySchema,
total_discount_amount: MoneySchema,
@ -38,13 +41,9 @@ export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema(
taxes_amount: MoneySchema,
total_amount: MoneySchema,
verifactu: z.object({
status: z.string(),
url: z.string(),
qr_code: z.string(),
}),
verifactu: VerifactuRecordSchema,
metadata: MetadataSchema.optional(),
linked_proforma_id: z.uuid().nullable(),
})
);

View File

@ -1,113 +1,60 @@
import { MetadataSchema, MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import {
CurrencyCodeSchema,
IsoDateSchema,
LanguageCodeSchema,
MetadataSchema,
MoneySchema,
PercentageSchema,
} from "@erp/core";
import { z } from "zod/v4";
import { PaymentMethodRefSchema, TaxesBreakdownSchema } from "../../shared";
import {
ProformaItemDetailSchema,
ProformaRecipientSummarySchema,
ProformaStatusSchema,
} from "../../shared/proforma";
export const GetProformaByIdResponseSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
is_proforma: z.string(),
is_proforma: z.boolean(),
invoice_number: z.string(),
status: z.string(),
series: z.string(),
status: ProformaStatusSchema,
series: z.string().nullable(),
invoice_date: z.string(),
operation_date: z.string(),
invoice_date: IsoDateSchema,
operation_date: IsoDateSchema.nullable(),
reference: z.string(),
description: z.string(),
notes: z.string(),
reference: z.string().nullable(),
description: z.string().nullable(),
notes: z.string().nullable(),
language_code: z.string(),
currency_code: z.string(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
customer_id: z.string(),
recipient: z.object({
id: z.string(),
name: z.string(),
tin: z.string(),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postal_code: z.string(),
country: z.string(),
}),
customer_id: z.uuid(),
recipient: ProformaRecipientSummarySchema,
taxes: z.array(
z.object({
taxable_amount: MoneySchema,
linked_invoice_id: z.uuid().nullable(),
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
taxes: TaxesBreakdownSchema,
rec_code: z.string(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
})
),
payment_method: z
.object({
payment_id: z.string(),
payment_description: z.string(),
})
.optional(),
payment_method: PaymentMethodRefSchema.nullable(),
subtotal_amount: MoneySchema,
items_discount_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
taxable_amount: MoneySchema,
iva_amount: MoneySchema,
rec_amount: MoneySchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
items: z.array(
z.object({
id: z.uuid(),
is_valued: z.string(),
position: z.string(),
description: z.string(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
subtotal_amount: MoneySchema,
item_discount_percentage: PercentageSchema,
item_discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,
taxable_amount: MoneySchema,
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
rec_code: z.string(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
})
),
items: z.array(ProformaItemDetailSchema),
metadata: MetadataSchema.optional(),
});

View File

@ -1,42 +1,36 @@
import {
MetadataSchema,
CurrencyCodeSchema,
IsoDateSchema,
MoneySchema,
LanguageCodeSchema,
PercentageSchema,
createPaginatedListSchema,
} from "@erp/core";
import { z } from "zod/v4";
import { ProformaRecipientSummarySchema, ProformaStatusSchema } from "../../shared/proforma";
export const ListProformasResponseSchema = createPaginatedListSchema(
z.object({
id: z.uuid(),
company_id: z.uuid(),
is_proforma: z.string(),
customer_id: z.string(),
is_proforma: z.boolean(),
invoice_number: z.string(),
status: z.string(),
series: z.string(),
status: ProformaStatusSchema,
series: z.string().nullable(),
invoice_date: z.string(),
operation_date: z.string(),
invoice_date: IsoDateSchema,
operation_date: IsoDateSchema.nullable(),
language_code: z.string(),
currency_code: z.string(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
reference: z.string(),
description: z.string(),
reference: z.string().nullable(),
description: z.string().nullable(),
recipient: z.object({
tin: z.string(),
name: z.string(),
street: z.string(),
street2: z.string(),
city: z.string(),
postal_code: z.string(),
province: z.string(),
country: z.string(),
}),
customer_id: z.uuid(),
recipient: ProformaRecipientSummarySchema,
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
@ -45,9 +39,7 @@ export const ListProformasResponseSchema = createPaginatedListSchema(
taxes_amount: MoneySchema,
total_amount: MoneySchema,
linked_invoice_id: z.string(),
metadata: MetadataSchema.optional(),
linked_invoice_id: z.uuid().nullable(),
})
);

View File

@ -0,0 +1,5 @@
export * from "./issued-invoices";
export * from "./item-taxes-breakdown.dto";
export * from "./payment-methof-ref.dto";
export * from "./proforma";
export * from "./taxes-breakdown.dto";

View File

@ -0,0 +1,4 @@
export * from "./issued-invoice-item-detail.dto";
export * from "./issued-invoice-recipient-summary.dto";
export * from "./issued-invoice-status.dto";
export * from "./verifactu-record.dto";

View File

@ -0,0 +1,28 @@
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { z } from "zod/v4";
import { ItemTaxesBreakdownSchema } from "../item-taxes-breakdown.dto";
export const IssuedInvoiceItemDetailSchema = z.object({
id: z.uuid(),
is_valued: z.boolean(),
position: z.number(),
description: z.string().nullable(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
subtotal_amount: MoneySchema,
item_discount_percentage: PercentageSchema,
item_discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,
...ItemTaxesBreakdownSchema.shape,
total_amount: MoneySchema,
});
export type IssuedInvoiceItemDetailDTO = z.infer<typeof IssuedInvoiceItemDetailSchema>;

View File

@ -0,0 +1,16 @@
import { CountryCodeSchema, PostalCodeSchema, TinSchema } from "@erp/core";
import { z } from "zod/v4";
export const IssuedInvoiceRecipientSummarySchema = z.object({
id: z.uuid(),
tin: TinSchema,
name: z.string(),
street: z.string().nullable(),
street2: z.string().nullable(),
city: z.string().nullable(),
province: z.string().nullable(),
postal_code: PostalCodeSchema.nullable(),
country: CountryCodeSchema.nullable(),
});
export type IssuedInvoiceRecipientSummaryDTO = z.infer<typeof IssuedInvoiceRecipientSummarySchema>;

View File

@ -0,0 +1,5 @@
import { z } from "zod/v4";
export const IssuedInvoiceStatusSchema = z.templateLiteral(["issued"]);
export type IssuedInvoiceStatusDTO = z.infer<typeof IssuedInvoiceStatusSchema>;

View File

@ -0,0 +1,13 @@
import { URLSchema } from "@erp/core";
import { z } from "zod/v4";
export const VerifactuRecordStatusSchema = z.enum(["pending", "verified", "rejected"]);
export const VerifactuRecordSchema = z.object({
status: VerifactuRecordStatusSchema,
url: URLSchema.nullable(),
qr_code: z.string().nullable(),
});
export type VerifactuRecordStatusDTO = z.infer<typeof VerifactuRecordStatusSchema>;
export type VerifactuRecordDTO = z.infer<typeof VerifactuRecordSchema>;

View File

@ -0,0 +1,6 @@
import type { z } from "zod/v4";
import { TaxesBreakdownSchema } from "./taxes-breakdown.dto";
export const ItemTaxesBreakdownSchema = TaxesBreakdownSchema;
export type ItemTaxesBreakdownDTO = z.infer<typeof ItemTaxesBreakdownSchema>;

View File

@ -0,0 +1,8 @@
import { z } from "zod/v4";
export const PaymentMethodRefSchema = z.object({
payment_id: z.uuid(),
payment_description: z.string(),
});
export type PaymentMethodRefDTO = z.infer<typeof PaymentMethodRefSchema>;

View File

@ -0,0 +1,3 @@
export * from "./proforma-item-detail.dto";
export * from "./proforma-recipient-summary.dto";
export * from "./proforma-status.dto";

View File

@ -0,0 +1,28 @@
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { z } from "zod/v4";
import { ItemTaxesBreakdownSchema } from "../item-taxes-breakdown.dto";
export const ProformaItemDetailSchema = z.object({
id: z.uuid(),
is_valued: z.boolean(),
position: z.number(),
description: z.string().nullable(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
subtotal_amount: MoneySchema,
item_discount_percentage: PercentageSchema,
item_discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema,
global_discount_amount: MoneySchema,
...ItemTaxesBreakdownSchema.shape,
total_amount: MoneySchema,
});
export type ProformaItemDetailDTO = z.infer<typeof ProformaItemDetailSchema>;

View File

@ -0,0 +1,16 @@
import { CountryCodeSchema, PostalCodeSchema, TinSchema } from "@erp/core";
import { z } from "zod/v4";
export const ProformaRecipientSummarySchema = z.object({
id: z.uuid().nullable(),
tin: TinSchema.nullable(),
name: z.string().nullable(),
street: z.string().nullable(),
street2: z.string().nullable(),
city: z.string().nullable(),
province: z.string().nullable(),
postal_code: PostalCodeSchema.nullable(),
country: CountryCodeSchema.nullable(),
});
export type ProformaRecipientSummaryDTO = z.infer<typeof ProformaRecipientSummarySchema>;

View File

@ -0,0 +1,5 @@
import { z } from "zod/v4";
export const ProformaStatusSchema = z.enum(["draft", "sent", "approved", "rejected", "issued"]);
export type ProformaStatusDTO = z.infer<typeof ProformaStatusSchema>;

View File

@ -0,0 +1,22 @@
import { MoneySchema, PercentageSchema } from "@erp/core";
import { z } from "zod/v4";
export const TaxesBreakdownSchema = z.object({
taxable_amount: MoneySchema,
iva_code: z.string(),
iva_percentage: PercentageSchema,
iva_amount: MoneySchema,
rec_code: z.string().nullable(),
rec_percentage: PercentageSchema,
rec_amount: MoneySchema,
retention_code: z.string().nullable(),
retention_percentage: PercentageSchema,
retention_amount: MoneySchema,
taxes_amount: MoneySchema,
});
export type TaxesBreakdownDTO = z.infer<typeof TaxesBreakdownSchema>;

View File

@ -6,7 +6,7 @@ export const CustomerInvoiceItemDataFormSchema = CreateProformaRequestSchema.ext
subtotal_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
currency_code: CurrencyCodeSchema,
}),
discount: z.object({
amount: z.number().nullable(),
@ -15,12 +15,12 @@ export const CustomerInvoiceItemDataFormSchema = CreateProformaRequestSchema.ext
discount_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
currency_code: CurrencyCodeSchema,
}),
before_tax_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
currency_code: CurrencyCodeSchema,
}),
tax: z.object({
amount: z.number().nullable(),
@ -29,12 +29,12 @@ export const CustomerInvoiceItemDataFormSchema = CreateProformaRequestSchema.ext
tax_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
currency_code: CurrencyCodeSchema,
}),
total_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
currency_code: CurrencyCodeSchema,
}),
});

View File

@ -2,6 +2,4 @@ export * from "./items-editor";
export * from "./proforma-basic-info-fields";
export * from "./proforma-form-field-shell";
export * from "./proforma-header-fields-card";
export * from "./proforma-header-form-grid";
export * from "./proforma-section-card";
export * from "./selected-recipient";

View File

@ -6,7 +6,6 @@ import { Button } from "@repo/shadcn-ui/components";
import { ProformaUpdateRecipientEditor } from ".";
import { useTranslation } from "../../../../i18n";
import type { Proforma } from "../../../shared/entities";
import type { UseUpdateProformaItemsControllerResult } from "../../controllers";
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
@ -14,7 +13,6 @@ import { ProformaUpdateItemsEditor } from "./proforma-update-items-editor";
type ProformaUpdateEditorProps = {
formId: string;
proforma?: Proforma;
isSubmitting: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
onReset: () => void;
@ -24,8 +22,6 @@ type ProformaUpdateEditorProps = {
onCreateCustomerClick: () => void;
itemsCtrl: UseUpdateProformaItemsControllerResult;
className?: string;
};
export const ProformaUpdateEditorForm = ({
@ -37,7 +33,6 @@ export const ProformaUpdateEditorForm = ({
onChangeCustomerClick,
onCreateCustomerClick,
itemsCtrl,
className,
}: ProformaUpdateEditorProps) => {
const { t } = useTranslation();

View File

@ -1,7 +1,12 @@
import { DatePickerField, SelectField, TextField } from "@repo/rdx-ui/components";
import {
DatePickerField,
FormSectionCard,
FormSectionGrid,
SelectField,
TextField,
} from "@repo/rdx-ui/components";
import { useTranslation } from "../../../../i18n";
import { ProformaHeaderFormGrid, ProformaSectionCard } from "../blocks";
interface ProformaUpdateHeaderEditorProps {
disabled?: boolean;
@ -15,11 +20,11 @@ export const ProformaUpdateHeaderEditor = ({
const { t } = useTranslation();
return (
<ProformaSectionCard
<FormSectionCard
description={t("form_groups.proformas.basic_info.description")}
title={t("form_groups.proformas.basic_info.title")}
>
<ProformaHeaderFormGrid>
<FormSectionGrid>
<SelectField
className="md:col-span-2"
disabled={disabled}
@ -67,7 +72,7 @@ export const ProformaUpdateHeaderEditor = ({
placeholder={t("form_fields.proformas.description.placeholder")}
readOnly={readOnly}
/>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
</FormSectionGrid>
</FormSectionCard>
);
};

View File

@ -1,10 +1,10 @@
import { FormSectionCard } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useTranslation } from "../../../../i18n";
import type { UseUpdateProformaItemsControllerResult } from "../../controllers/use-update-proforma-items-controller";
import { ProformaSectionCard } from "../blocks";
import { ProformaUpdateItemRowEditor } from "./proforma-update-item-row-editor";
import { ProformaUpdateItemsTotals } from "./proforma-update-items-totals";
@ -21,7 +21,7 @@ export const ProformaUpdateItemsEditor = ({
const { t } = useTranslation();
return (
<ProformaSectionCard
<FormSectionCard
description={t("form_groups.items.description")}
title={t("form_groups.items.title")}
>
@ -57,6 +57,6 @@ export const ProformaUpdateItemsEditor = ({
<ProformaUpdateItemsTotals totals={itemsCtrl.totals} />
</fieldset>
</ProformaSectionCard>
</FormSectionCard>
);
};

View File

@ -1,7 +1,7 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { useTranslation } from "../../../../i18n";
import { ProformaSectionCard, SelectedRecipientSummary } from "../blocks";
import { FormSectionCard, SelectedRecipientSummary } from "../blocks";
interface ProformaUpdateRecipientEditorProps {
disabled?: boolean;
@ -23,7 +23,7 @@ export const ProformaUpdateRecipientEditor = ({
const { t } = useTranslation();
return (
<ProformaSectionCard title={t("form_groups.proformas.customer.title")}>
<FormSectionCard title={t("form_groups.proformas.customer.title")}>
<SelectedRecipientSummary
disabled={disabled}
onChangeClick={onChangeCustomerClick}
@ -31,6 +31,6 @@ export const ProformaUpdateRecipientEditor = ({
readOnly={readOnly}
recipient={selectedCustomer}
/>
</ProformaSectionCard>
</FormSectionCard>
);
};

View File

@ -43,7 +43,6 @@ export const ProformaUpdatePage = () => {
if (!updateCtrl.proformaData)
return (
<>
<AppContent>
<NotFoundCard
message={t(
@ -53,7 +52,6 @@ export const ProformaUpdatePage = () => {
title={t("pages.proformas.update.not_found_title", "Proforma no encontrada")}
/>
</AppContent>
</>
);
return (
@ -66,7 +64,7 @@ export const ProformaUpdatePage = () => {
<UpdateCommitButtonGroup
cancel={{
formId: updateCtrl.formId,
to: "/customers/list",
to: "/proformas/list",
disabled: updateCtrl.isUpdating,
}}
disabled={updateCtrl.isUpdating}

View File

@ -12,7 +12,6 @@ import {
Province,
Street,
TINNumber,
type TaxCode,
TextValue,
URLAddress,
type UniqueID,
@ -21,9 +20,13 @@ import {
extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { Collection, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
import { Result, toPatchField } from "@repo/rdx-utils";
import type { UpdateCustomerByIdRequestDTO } from "../../../common";
import type {
UpdateCustomerAddressPatchRequestDTO,
UpdateCustomerByIdRequestDTO,
UpdateCustomerContactPatchRequestDTO,
} from "../../../common";
import type { CustomerPatchProps } from "../../domain";
/**
@ -44,18 +47,21 @@ export interface IUpdateCustomerInputMapper {
map(
dto: UpdateCustomerByIdRequestDTO,
params: { companyId: UniqueID }
): Result<CustomerPatchProps>;
): Result<CustomerPatchProps, Error>;
}
export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
public map(dto: UpdateCustomerByIdRequestDTO, params: { companyId: UniqueID }) {
public map(
dto: UpdateCustomerByIdRequestDTO,
params: { companyId: UniqueID }
): Result<CustomerPatchProps, Error> {
console.log("Mapping UpdateCustomerByIdRequestDTO to CustomerPatchProps:", dto);
try {
const errors: ValidationErrorDetail[] = [];
const customerPatchProps: CustomerPatchProps = {};
console.log(dto);
toPatchField(dto.reference).ifSet((reference) => {
toPatchField(dto.reference).ifSetOrNull((reference) => {
customerPatchProps.reference = extractOrPushError(
maybeFromNullableResult(reference, (value) => Name.create(value)),
"reference",
@ -63,35 +69,23 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
);
});
toPatchField(dto.is_company).ifSet((is_company) => {
if (isNullishOrEmpty(is_company)) {
errors.push({ path: "is_company", message: "is_company cannot be empty" });
return;
}
customerPatchProps.isCompany = extractOrPushError(
Result.ok(Boolean(is_company!)),
"is_company",
errors
);
toPatchField(dto.is_company).ifSet((isCompany) => {
customerPatchProps.isCompany = isCompany;
});
toPatchField(dto.name).ifSet((name) => {
if (isNullishOrEmpty(name)) {
errors.push({ path: "name", message: "Name cannot be empty" });
return;
}
customerPatchProps.name = extractOrPushError(Name.create(name!), "name", errors);
customerPatchProps.name = extractOrPushError(Name.create(name), "name", errors);
});
toPatchField(dto.trade_name).ifSet((trade_name) => {
toPatchField(dto.trade_name).ifSetOrNull((tradeName) => {
customerPatchProps.tradeName = extractOrPushError(
maybeFromNullableResult(trade_name, (value) => Name.create(value)),
maybeFromNullableResult(tradeName, (value) => Name.create(value)),
"trade_name",
errors
);
});
toPatchField(dto.tin).ifSet((tin) => {
toPatchField(dto.tin).ifSetOrNull((tin) => {
customerPatchProps.tin = extractOrPushError(
maybeFromNullableResult(tin, (value) => TINNumber.create(value)),
"tin",
@ -99,71 +93,7 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
);
});
toPatchField(dto.email_primary).ifSet((email_primary) => {
customerPatchProps.emailPrimary = extractOrPushError(
maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)),
"email_primary",
errors
);
});
toPatchField(dto.email_secondary).ifSet((email_secondary) => {
customerPatchProps.emailSecondary = extractOrPushError(
maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)),
"email_secondary",
errors
);
});
toPatchField(dto.mobile_primary).ifSet((mobile_primary) => {
customerPatchProps.mobilePrimary = extractOrPushError(
maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)),
"mobile_primary",
errors
);
});
toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => {
customerPatchProps.mobilePrimary = extractOrPushError(
maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)),
"mobile_secondary",
errors
);
});
toPatchField(dto.phone_primary).ifSet((phone_primary) => {
customerPatchProps.phonePrimary = extractOrPushError(
maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)),
"phone_primary",
errors
);
});
toPatchField(dto.phone_secondary).ifSet((phone_secondary) => {
customerPatchProps.phoneSecondary = extractOrPushError(
maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)),
"phone_secondary",
errors
);
});
toPatchField(dto.fax).ifSet((fax) => {
customerPatchProps.fax = extractOrPushError(
maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)),
"fax",
errors
);
});
toPatchField(dto.website).ifSet((website) => {
customerPatchProps.website = extractOrPushError(
maybeFromNullableResult(website, (value) => URLAddress.create(value)),
"website",
errors
);
});
toPatchField(dto.legal_record).ifSet((legalRecord) => {
toPatchField(dto.legal_record).ifSetOrNull((legalRecord) => {
customerPatchProps.legalRecord = extractOrPushError(
maybeFromNullableResult(legalRecord, (value) => TextValue.create(value)),
"legal_record",
@ -172,120 +102,186 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
});
toPatchField(dto.language_code).ifSet((languageCode) => {
if (isNullishOrEmpty(languageCode)) {
errors.push({ path: "language_code", message: "Language code cannot be empty" });
return;
}
customerPatchProps.languageCode = extractOrPushError(
LanguageCode.create(languageCode!),
LanguageCode.create(languageCode),
"language_code",
errors
);
});
toPatchField(dto.currency_code).ifSet((currencyCode) => {
if (isNullishOrEmpty(currencyCode)) {
errors.push({ path: "currency_code", message: "Currency code cannot be empty" });
return;
}
customerPatchProps.currencyCode = extractOrPushError(
CurrencyCode.create(currencyCode!),
CurrencyCode.create(currencyCode),
"currency_code",
errors
);
});
// Default taxes
const defaultTaxesCollection = new Collection<TaxCode>();
/*toPatchField(dto.default_taxes).ifSet((defaultTaxes) => {
customerPatchProps.defaultTaxes = defaultTaxesCollection;
if (isNullishOrEmpty(defaultTaxes)) {
return;
}
defaultTaxes!.forEach((taxCode, index) => {
const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors);
if (tax && customerPatchProps.defaultTaxes) {
customerPatchProps.defaultTaxes.add(tax);
}
/**
* Se mantiene la compatibilidad con el contrato actual.
* Cuando defaultTaxes tenga un contrato final estable, aquí conviene
* mapearlo a CustomerTaxes/Collection<TaxCode> con su schema semántico real.
*/
toPatchField(dto.default_taxes).ifSet((_defaultTaxes) => {
errors.push({
path: "default_taxes",
message: "default_taxes mapping is not implemented yet",
});
});
});*/
// PostalAddress
const addressPatchProps = this.mapPostalAddress(dto, errors);
const addressPatchProps = this.mapPostalAddress(dto.address, errors);
if (addressPatchProps) {
customerPatchProps.address = addressPatchProps;
}
this.mapContact(dto.contact, customerPatchProps, errors);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer props mapping failed (update)", errors)
);
}
console.log("Mapped CustomerPatchProps:", customerPatchProps);
return Result.ok(customerPatchProps);
} catch (err: unknown) {
return Result.fail(new DomainError("Customer props mapping failed", { cause: err }));
return Result.fail(new DomainError("Customer props mapping failed (update)", { cause: err }));
}
}
public mapPostalAddress(
dto: UpdateCustomerByIdRequestDTO,
private mapPostalAddress(
dto: UpdateCustomerAddressPatchRequestDTO | undefined,
errors: ValidationErrorDetail[]
): PostalAddressPatchProps | undefined {
if (!dto) {
return undefined;
}
const postalAddressPatchProps: PostalAddressPatchProps = {};
toPatchField(dto.street).ifSet((street) => {
toPatchField(dto.street).ifSetOrNull((street) => {
postalAddressPatchProps.street = extractOrPushError(
maybeFromNullableResult(street, (value) => Street.create(value)),
"street",
"address.street",
errors
);
});
toPatchField(dto.street2).ifSet((street2) => {
toPatchField(dto.street2).ifSetOrNull((street2) => {
postalAddressPatchProps.street2 = extractOrPushError(
maybeFromNullableResult(street2, (value) => Street.create(value)),
"street2",
"address.street2",
errors
);
});
toPatchField(dto.city).ifSet((city) => {
toPatchField(dto.city).ifSetOrNull((city) => {
postalAddressPatchProps.city = extractOrPushError(
maybeFromNullableResult(city, (value) => City.create(value)),
"city",
"address.city",
errors
);
});
toPatchField(dto.province).ifSet((province) => {
toPatchField(dto.province).ifSetOrNull((province) => {
postalAddressPatchProps.province = extractOrPushError(
maybeFromNullableResult(province, (value) => Province.create(value)),
"province",
"address.province",
errors
);
});
toPatchField(dto.postal_code).ifSet((postalCode) => {
toPatchField(dto.postal_code).ifSetOrNull((postalCode) => {
postalAddressPatchProps.postalCode = extractOrPushError(
maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)),
"postal_code",
"address.postal_code",
errors
);
});
toPatchField(dto.country).ifSet((country) => {
toPatchField(dto.country).ifSetOrNull((country) => {
postalAddressPatchProps.country = extractOrPushError(
maybeFromNullableResult(country, (value) => Country.create(value)),
"country",
"address.country",
errors
);
});
return Object.keys(postalAddressPatchProps).length > 0 ? postalAddressPatchProps : undefined;
}
private mapContact(
dto: UpdateCustomerContactPatchRequestDTO | undefined,
customerPatchProps: CustomerPatchProps,
errors: ValidationErrorDetail[]
): void {
if (!dto) {
return;
}
toPatchField(dto.email_primary).ifSetOrNull((emailPrimary) => {
customerPatchProps.emailPrimary = extractOrPushError(
maybeFromNullableResult(emailPrimary, (value) => EmailAddress.create(value)),
"contact.email_primary",
errors
);
});
toPatchField(dto.email_secondary).ifSetOrNull((emailSecondary) => {
customerPatchProps.emailSecondary = extractOrPushError(
maybeFromNullableResult(emailSecondary, (value) => EmailAddress.create(value)),
"contact.email_secondary",
errors
);
});
toPatchField(dto.phone_primary).ifSetOrNull((phonePrimary) => {
customerPatchProps.phonePrimary = extractOrPushError(
maybeFromNullableResult(phonePrimary, (value) => PhoneNumber.create(value)),
"contact.phone_primary",
errors
);
});
toPatchField(dto.phone_secondary).ifSetOrNull((phoneSecondary) => {
customerPatchProps.phoneSecondary = extractOrPushError(
maybeFromNullableResult(phoneSecondary, (value) => PhoneNumber.create(value)),
"contact.phone_secondary",
errors
);
});
toPatchField(dto.mobile_primary).ifSetOrNull((mobilePrimary) => {
customerPatchProps.mobilePrimary = extractOrPushError(
maybeFromNullableResult(mobilePrimary, (value) => PhoneNumber.create(value)),
"contact.mobile_primary",
errors
);
});
toPatchField(dto.mobile_secondary).ifSetOrNull((mobileSecondary) => {
customerPatchProps.mobileSecondary = extractOrPushError(
maybeFromNullableResult(mobileSecondary, (value) => PhoneNumber.create(value)),
"contact.mobile_secondary",
errors
);
});
toPatchField(dto.fax).ifSetOrNull((fax) => {
customerPatchProps.fax = extractOrPushError(
maybeFromNullableResult(fax, (value) => PhoneNumber.create(value)),
"contact.fax",
errors
);
});
toPatchField(dto.website).ifSetOrNull((website) => {
customerPatchProps.website = extractOrPushError(
maybeFromNullableResult(website, (value) => URLAddress.create(value)),
"contact.website",
errors
);
});
}
}

View File

@ -1,59 +0,0 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import type { Customer } from "../../../domain";
import type { ICustomerFullSnapshot } from "./customer-snapshot.interface";
export interface ICustomerFullSnapshotBuilder
extends ISnapshotBuilder<Customer, ICustomerFullSnapshot> {}
export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder {
toOutput(customer: Customer): ICustomerFullSnapshot {
const address = customer.address.toPrimitive();
return {
id: customer.id.toPrimitive(),
company_id: customer.companyId.toPrimitive(),
status: customer.isActive ? "active" : "inactive",
reference: maybeToEmptyString(customer.reference, (value) => value.toPrimitive()),
is_company: String(customer.isCompany),
name: customer.name.toPrimitive(),
trade_name: maybeToEmptyString(customer.tradeName, (value) => value.toPrimitive()),
tin: maybeToEmptyString(customer.tin, (value) => value.toPrimitive()),
street: maybeToEmptyString(address.street, (value) => value.toPrimitive()),
street2: maybeToEmptyString(address.street2, (value) => value.toPrimitive()),
city: maybeToEmptyString(address.city, (value) => value.toPrimitive()),
province: maybeToEmptyString(address.province, (value) => value.toPrimitive()),
postal_code: maybeToEmptyString(address.postalCode, (value) => value.toPrimitive()),
country: maybeToEmptyString(address.country, (value) => value.toPrimitive()),
email_primary: maybeToEmptyString(customer.emailPrimary, (value) => value.toPrimitive()),
email_secondary: maybeToEmptyString(customer.emailSecondary, (value) => value.toPrimitive()),
phone_primary: maybeToEmptyString(customer.phonePrimary, (value) => value.toPrimitive()),
phone_secondary: maybeToEmptyString(customer.phoneSecondary, (value) => value.toPrimitive()),
mobile_primary: maybeToEmptyString(customer.mobilePrimary, (value) => value.toPrimitive()),
mobile_secondary: maybeToEmptyString(customer.mobileSecondary, (value) =>
value.toPrimitive()
),
fax: maybeToEmptyString(customer.fax, (value) => value.toPrimitive()),
website: maybeToEmptyString(customer.website, (value) => value.toPrimitive()),
legal_record: maybeToEmptyString(customer.legalRecord, (value) => value.toPrimitive()),
default_taxes: customer.defaultTaxes.toKey(),
language_code: customer.languageCode.toPrimitive(),
currency_code: customer.currencyCode.toPrimitive(),
metadata: {
entity: "customer",
},
};
}
}

View File

@ -1,39 +0,0 @@
export interface ICustomerFullSnapshot {
id: string;
company_id: string;
status: string;
reference: string;
is_company: string;
name: string;
trade_name: string;
tin: string;
street: string;
street2: string;
city: string;
province: string;
postal_code: string;
country: string;
email_primary: string;
email_secondary: string;
phone_primary: string;
phone_secondary: string;
mobile_primary: string;
mobile_secondary: string;
fax: string;
website: string;
legal_record: string;
default_taxes: string;
language_code: string;
currency_code: string;
metadata?: Record<string, string>;
}

View File

@ -1,2 +0,0 @@
export * from "./customer-snapshot.interface";
export * from "./customer-snapshot-builder";

View File

@ -0,0 +1,60 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { toNullable } from "@repo/rdx-ddd";
import type { GetCustomerByIdResponseDTO } from "../../../../common";
import type { Customer } from "../../../domain";
export interface ICustomerFullSnapshotBuilder
extends ISnapshotBuilder<Customer, GetCustomerByIdResponseDTO> {}
export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder {
toOutput(customer: Customer): GetCustomerByIdResponseDTO {
const address = customer.address.toPrimitive();
return {
id: customer.id.toPrimitive(),
company_id: customer.companyId.toPrimitive(),
status: customer.isActive ? "active" : "inactive",
reference: toNullable(customer.reference, (value) => value.toPrimitive()),
is_company: customer.isCompany,
name: customer.name.toPrimitive(),
trade_name: toNullable(customer.tradeName, (value) => value.toPrimitive()),
tin: toNullable(customer.tin, (value) => value.toPrimitive()),
address: {
street: toNullable(address.street, (value) => value.toPrimitive()),
street2: toNullable(address.street2, (value) => value.toPrimitive()),
city: toNullable(address.city, (value) => value.toPrimitive()),
province: toNullable(address.province, (value) => value.toPrimitive()),
postal_code: toNullable(address.postalCode, (value) => value.toPrimitive()),
country: toNullable(address.country, (value) => value.toPrimitive()),
},
contact: {
email_primary: toNullable(customer.emailPrimary, (value) => value.toPrimitive()),
email_secondary: toNullable(customer.emailSecondary, (value) => value.toPrimitive()),
phone_primary: toNullable(customer.phonePrimary, (value) => value.toPrimitive()),
phone_secondary: toNullable(customer.phoneSecondary, (value) => value.toPrimitive()),
mobile_primary: toNullable(customer.mobilePrimary, (value) => value.toPrimitive()),
mobile_secondary: toNullable(customer.mobileSecondary, (value) => value.toPrimitive()),
fax: toNullable(customer.fax, (value) => value.toPrimitive()),
website: toNullable(customer.website, (value) => value.toPrimitive()),
},
legal_record: toNullable(customer.legalRecord, (value) => value.toPrimitive()),
default_taxes: customer.defaultTaxes.toKey(),
language_code: customer.languageCode.toPrimitive(),
currency_code: customer.currencyCode.toPrimitive(),
metadata: {
entity: "customer",
},
};
}
}

View File

@ -0,0 +1 @@
export * from "./customer-full-snapshot-builder";

View File

@ -1,2 +1,2 @@
export * from "./domain";
export * from "./full";
export * from "./summary";

View File

@ -1,49 +1,40 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import { toNullable } from "@repo/rdx-ddd";
import type { CustomerSummaryDTO } from "../../../../common";
import type { CustomerSummary } from "../../models";
import type { ICustomerSummarySnapshot } from "./customer-summary-snapshot.interface";
export interface ICustomerSummarySnapshotBuilder
extends ISnapshotBuilder<CustomerSummary, ICustomerSummarySnapshot> {}
extends ISnapshotBuilder<CustomerSummary, CustomerSummaryDTO> {}
export class CustomerSummarySnapshotBuilder implements ICustomerSummarySnapshotBuilder {
toOutput(customer: CustomerSummary): ICustomerSummarySnapshot {
toOutput(customer: CustomerSummary): CustomerSummaryDTO {
const { address } = customer;
return {
id: customer.id.toString(),
company_id: customer.companyId.toString(),
status: customer.isActive ? "active" : "inactive",
reference: maybeToEmptyString(customer.reference, (value) => value.toString()),
reference: toNullable(customer.reference, (value) => value.toString()),
is_company: String(customer.isCompany),
is_company: customer.isCompany,
name: customer.name.toString(),
trade_name: maybeToEmptyString(customer.tradeName, (value) => value.toString()),
tin: maybeToEmptyString(customer.tin, (value) => value.toString()),
trade_name: toNullable(customer.tradeName, (value) => value.toString()),
tin: toNullable(customer.tin, (value) => value.toString()),
street: maybeToEmptyString(address.street, (value) => value.toString()),
street2: maybeToEmptyString(address.street2, (value) => value.toString()),
city: maybeToEmptyString(address.city, (value) => value.toString()),
postal_code: maybeToEmptyString(address.postalCode, (value) => value.toString()),
province: maybeToEmptyString(address.province, (value) => value.toString()),
country: maybeToEmptyString(address.country, (value) => value.toString()),
address: {
street: toNullable(address.street, (value) => value.toString()),
city: toNullable(address.city, (value) => value.toString()),
postal_code: toNullable(address.postalCode, (value) => value.toString()),
province: toNullable(address.province, (value) => value.toString()),
country: toNullable(address.country, (value) => value.toString()),
},
email_primary: maybeToEmptyString(customer.emailPrimary, (value) => value.toString()),
email_secondary: maybeToEmptyString(customer.emailSecondary, (value) => value.toString()),
phone_primary: maybeToEmptyString(customer.phonePrimary, (value) => value.toString()),
phone_secondary: maybeToEmptyString(customer.phoneSecondary, (value) => value.toString()),
mobile_primary: maybeToEmptyString(customer.mobilePrimary, (value) => value.toString()),
mobile_secondary: maybeToEmptyString(customer.mobileSecondary, (value) => value.toString()),
fax: maybeToEmptyString(customer.fax, (value) => value.toString()),
website: maybeToEmptyString(customer.website, (value) => value.toString()),
language_code: customer.languageCode.code,
currency_code: customer.currencyCode.code,
contact: {
email_primary: toNullable(customer.emailPrimary, (value) => value.toString()),
phone_primary: toNullable(customer.phonePrimary, (value) => value.toString()),
mobile_primary: toNullable(customer.mobilePrimary, (value) => value.toString()),
},
};
}
}

View File

@ -1,35 +0,0 @@
export type ICustomerSummarySnapshot = {
id: string;
company_id: string;
status: string;
reference: string;
is_company: string;
name: string;
trade_name: string;
tin: string;
street: string;
street2: string;
city: string;
postal_code: string;
province: string;
country: string;
email_primary: string;
email_secondary: string;
phone_primary: string;
phone_secondary: string;
mobile_primary: string;
mobile_secondary: string;
fax: string;
website: string;
language_code: string;
currency_code: string;
metadata?: Record<string, string>;
};

View File

@ -1,2 +1 @@
export * from "./customer-summary-snapshot.interface";
export * from "./customer-summary-snapshot-builder";

View File

@ -93,12 +93,23 @@ export interface ICustomer {
readonly currencyCode: CurrencyCode;
}
type CustomerInternalProps = Omit<ICustomerCreateProps, "address" | "defaultTaxes"> & {
readonly address: PostalAddress;
readonly defaultTaxes: CustomerTaxes;
};
export type CustomerInternalProps = Omit<ICustomerCreateProps, "address" | "defaultTaxes">;
export class Customer extends AggregateRoot<CustomerInternalProps> implements ICustomer {
private _address: PostalAddress;
private _defaultTaxes: CustomerTaxes;
protected constructor(
props: CustomerInternalProps,
address: PostalAddress,
defaultTaxes: CustomerTaxes,
id?: UniqueID
) {
super(props, id);
this._address = address;
this._defaultTaxes = defaultTaxes;
}
static create(props: ICustomerCreateProps, id?: UniqueID): Result<Customer, Error> {
const validationResult = Customer.validateCreateProps(props);
@ -108,30 +119,27 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
const { address, defaultTaxes, ...internalProps } = props;
// Postal Address
const postalAddressResult = PostalAddress.create(address);
if (postalAddressResult.isFailure) {
return Result.fail(postalAddressResult.error);
}
const postalAddress = postalAddressResult.data;
const taxes = CustomerTaxes.create(defaultTaxes);
if (taxes.isFailure) {
return Result.fail(taxes.error);
// Customer Taxes
const taxesResult = CustomerTaxes.create(defaultTaxes);
if (taxesResult.isFailure) {
return Result.fail(taxesResult.error);
}
const contact = new Customer(
{
...internalProps,
defaultTaxes: taxes.data,
address: postalAddressResult.data,
},
id
);
const taxes = taxesResult.data;
// Reglas de negocio / validaciones
// ...
// ...
// Crear instancia de Customer
const contact = new Customer(internalProps, postalAddress, taxes, id);
// Disparar eventos de dominio
// ...
// ...
@ -144,31 +152,56 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
}
// Rehidratación desde persistencia
static rehydrate(props: CustomerInternalProps, id: UniqueID): Customer {
return new Customer(props, id);
static rehydrate(
props: CustomerInternalProps,
address: PostalAddress,
defaultTaxes: CustomerTaxes,
id: UniqueID
): Customer {
return new Customer(props, address, defaultTaxes, id);
}
public update(partialCustomer: CustomerPatchProps): Result<void, Error> {
const { address: partialAddress, defaultTaxes: partialTaxes, ...rest } = partialCustomer;
Object.assign(this.props, rest);
const nextProps: CustomerInternalProps = {
...this.props,
...rest,
};
let nextAddress = this._address;
let nextDefaultTaxes = this._defaultTaxes;
if (partialAddress) {
const addressResult = this.address.update(partialAddress);
const nextAddressResult = PostalAddress.create({
...this._address.getProps(),
...partialAddress,
});
if (addressResult.isFailure) {
return Result.fail(addressResult.error);
if (nextAddressResult.isFailure) {
return Result.fail(nextAddressResult.error);
}
nextAddress = nextAddressResult.data;
}
if (partialTaxes) {
const taxesResult = this.defaultTaxes.update(partialTaxes);
const nextTaxesResult = CustomerTaxes.create({
...this._defaultTaxes.getProps(),
...partialTaxes,
});
if (taxesResult.isFailure) {
return Result.fail(taxesResult.error);
if (nextTaxesResult.isFailure) {
return Result.fail(nextTaxesResult.error);
}
nextDefaultTaxes = nextTaxesResult.data;
}
Object.assign(this.props, nextProps);
this._address = nextAddress;
this._defaultTaxes = nextDefaultTaxes;
return Result.ok();
}
@ -207,7 +240,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
}
public get address(): PostalAddress {
return this.props.address;
return this._address;
}
public get emailPrimary(): Maybe<EmailAddress> {
@ -247,7 +280,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
}
public get defaultTaxes(): CustomerTaxes {
return this.props.defaultTaxes;
return this._defaultTaxes;
}
public get languageCode(): LanguageCode {

View File

@ -24,11 +24,6 @@ export class CustomerTaxes extends ValueObject<CustomerTaxesProps> implements IC
return Result.ok(new CustomerTaxes(props));
}
public update(partial: CustomerTaxesPatchProps): Result<CustomerTaxes, Error> {
Object.assign(this.props, partial);
return Result.ok();
}
/**
* Reconstruye una instancia de CustomerTaxes a partir de una clave serializada.
* Este método es la operación inversa de toKey().

View File

@ -1 +1 @@
export * from "./sequelize-customer.mapper";
export * from "./sequelize-customer-domain.mapper";

View File

@ -26,9 +26,9 @@ import { Result } from "@repo/rdx-utils";
import {
Customer,
type CustomerInternalProps,
CustomerStatus,
CustomerTaxes,
type ICustomerCreateProps,
} from "../../../../../domain";
import type { CustomerCreationAttributes, CustomerModel } from "../../models";
@ -206,7 +206,7 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors));
}
const customerProps: ICustomerCreateProps = {
const customerProps: CustomerInternalProps = {
companyId: companyId!,
status: status!,
reference: reference!,
@ -216,8 +216,6 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
tradeName: tradeName!,
tin: tinNumber!,
address: postalAddress!,
emailPrimary: emailPrimaryAddress!,
emailSecondary: emailSecondaryAddress!,
phonePrimary: phonePrimaryNumber!,
@ -228,12 +226,17 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
website: website!,
legalRecord: legalRecord!,
defaultTaxes: defaultTaxes!,
languageCode: languageCode!,
currencyCode: currencyCode!,
};
return Customer.create(customerProps, customerId);
const customer = Customer.rehydrate(
customerProps,
postalAddress!,
defaultTaxes!,
customerId!
);
return Result.ok(customer);
} catch (err: unknown) {
return Result.fail(err as Error);
}

View File

@ -1,39 +1,67 @@
import {
CountryCodeSchema,
EmailSchema,
LandPhoneSchema,
MobilePhoneSchema,
PostalCodeSchema,
TinSchema,
URLSchema,
} from "@erp/core";
import { z } from "zod/v4";
export const UpdateCustomerByIdParamsRequestSchema = z.object({
customer_id: z.string(),
customer_id: z.uuid(),
});
export type UpdateCustomerByIdParamsRequestDTO = z.infer<
typeof UpdateCustomerByIdParamsRequestSchema
>;
export const UpdateCustomerAddressPatchRequestSchema = z.object({
street: z.string().nullable().optional(),
street2: z.string().nullable().optional(),
city: z.string().nullable().optional(),
province: z.string().nullable().optional(),
postal_code: PostalCodeSchema.nullable().optional(),
country: CountryCodeSchema.nullable().optional(),
});
export const UpdateCustomerContactPatchRequestSchema = z.object({
email_primary: EmailSchema.nullable().optional(),
email_secondary: EmailSchema.nullable().optional(),
phone_primary: LandPhoneSchema.nullable().optional(),
phone_secondary: LandPhoneSchema.nullable().optional(),
mobile_primary: MobilePhoneSchema.nullable().optional(),
mobile_secondary: MobilePhoneSchema.nullable().optional(),
fax: LandPhoneSchema.nullable().optional(),
website: URLSchema.nullable().optional(),
});
export const UpdateCustomerByIdRequestSchema = z.object({
reference: z.string().optional(),
reference: z.string().nullable().optional(),
is_company: z.string().optional(),
is_company: z.boolean().optional(),
name: z.string().optional(),
trade_name: z.string().optional(),
tin: z.string().optional(),
default_taxes: z.string().optional(), // completo (sustituye), o null => vaciar
trade_name: z.string().nullable().optional(),
tin: TinSchema.nullable().optional(),
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().optional(),
default_taxes: z.string().nullable().optional(),
email_primary: z.string().optional(),
email_secondary: z.string().optional(),
phone_primary: z.string().optional(),
phone_secondary: z.string().optional(),
mobile_primary: z.string().optional(),
mobile_secondary: z.string().optional(),
address: UpdateCustomerAddressPatchRequestSchema.optional(),
contact: UpdateCustomerContactPatchRequestSchema.optional(),
fax: z.string().optional(),
website: z.string().optional(),
legal_record: z.string().optional(),
legal_record: z.string().nullable().optional(),
language_code: z.string().optional(),
currency_code: z.string().optional(),
});
export type UpdateCustomerByIdRequestDTO = Partial<z.infer<typeof UpdateCustomerByIdRequestSchema>>;
export type UpdateCustomerAddressPatchRequestDTO = z.infer<
typeof UpdateCustomerAddressPatchRequestSchema
>;
export type UpdateCustomerContactPatchRequestDTO = z.infer<
typeof UpdateCustomerContactPatchRequestSchema
>;
export type UpdateCustomerByIdRequestDTO = z.infer<typeof UpdateCustomerByIdRequestSchema>;

View File

@ -1,39 +1,57 @@
import { MetadataSchema } from "@erp/core";
import {
CountryCodeSchema,
CurrencyCodeSchema,
EmailSchema,
LandPhoneSchema,
LanguageCodeSchema,
MetadataSchema,
MobilePhoneSchema,
PostalCodeSchema,
TinSchema,
URLSchema,
} from "@erp/core";
import { z } from "zod/v4";
import { CustomerStatusSchema } from "../shared/customer-status.dto";
export const GetCustomerByIdResponseSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
reference: z.string(),
status: CustomerStatusSchema,
reference: z.string().nullable(),
is_company: z.string(),
is_company: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
trade_name: z.string().nullable(),
tin: TinSchema.nullable(),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postal_code: z.string(),
country: z.string(),
address: z.object({
street: z.string().nullable(),
street2: z.string().nullable(),
city: z.string().nullable(),
province: z.string().nullable(),
postal_code: PostalCodeSchema.nullable(),
country: CountryCodeSchema.nullable(),
}),
email_primary: z.string(),
email_secondary: z.string(),
phone_primary: z.string(),
phone_secondary: z.string(),
mobile_primary: z.string(),
mobile_secondary: z.string(),
contact: z.object({
email_primary: EmailSchema.nullable(),
email_secondary: EmailSchema.nullable(),
phone_primary: LandPhoneSchema.nullable(),
phone_secondary: LandPhoneSchema.nullable(),
mobile_primary: MobilePhoneSchema.nullable(),
mobile_secondary: MobilePhoneSchema.nullable(),
fax: z.string(),
website: z.string(),
fax: LandPhoneSchema.nullable(),
website: URLSchema.nullable(),
}),
legal_record: z.string(),
legal_record: z.string().nullable(),
default_taxes: z.string(),
status: z.string(),
language_code: z.string(),
currency_code: z.string(),
default_taxes: z.string().nullable(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
metadata: MetadataSchema.optional(),
});

View File

@ -1,3 +1,5 @@
export * from "../shared/customer-summary.dto";
export * from "./create-customer.result.dto";
export * from "./get-customer-by-id.response.dto";
export * from "./list-customers.response.dto";

View File

@ -1,39 +1,8 @@
import { MetadataSchema, createPaginatedListSchema } from "@erp/core";
import { z } from "zod/v4";
import { createPaginatedListSchema } from "@erp/core";
import type { z } from "zod/v4";
export const ListCustomersResponseSchema = createPaginatedListSchema(
z.object({
id: z.uuid(),
company_id: z.uuid(),
status: z.string(),
reference: z.string(),
import { CustomerSummarySchema } from "../shared";
is_company: z.string(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postal_code: z.string(),
country: z.string(),
email_primary: z.string(),
email_secondary: z.string(),
phone_primary: z.string(),
phone_secondary: z.string(),
mobile_primary: z.string(),
mobile_secondary: z.string(),
fax: z.string(),
website: z.string(),
language_code: z.string(),
currency_code: z.string(),
metadata: MetadataSchema.optional(),
})
);
export const ListCustomersResponseSchema = createPaginatedListSchema(CustomerSummarySchema);
export type ListCustomersResponseDTO = z.infer<typeof ListCustomersResponseSchema>;

View File

@ -0,0 +1,5 @@
import { z } from "zod/v4";
export const CustomerStatusSchema = z.enum(["active", "inactive"]);
export type CustomerStatusDTO = z.infer<typeof CustomerStatusSchema>;

View File

@ -0,0 +1,41 @@
import {
CountryCodeSchema,
EmailSchema,
LandPhoneSchema,
MobilePhoneSchema,
PostalCodeSchema,
TinSchema,
} from "@erp/core";
import { z } from "zod/v4";
import { CustomerStatusSchema } from "./customer-status.dto";
export const CustomerSummarySchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
status: CustomerStatusSchema,
reference: z.string().nullable(),
is_company: z.boolean(),
name: z.string(),
trade_name: z.string().nullable(),
tin: TinSchema.nullable(),
address: z.object({
street: z.string().nullable(),
city: z.string().nullable(),
province: z.string().nullable(),
postal_code: PostalCodeSchema.nullable(),
country: CountryCodeSchema.nullable(),
}),
contact: z.object({
email_primary: EmailSchema.nullable(),
phone_primary: LandPhoneSchema.nullable(),
mobile_primary: MobilePhoneSchema.nullable(),
}),
});
export type CustomerSummaryDTO = z.infer<typeof CustomerSummarySchema>;

View File

@ -0,0 +1,2 @@
export * from "./customer-status.dto";
export * from "./customer-summary.dto";

View File

@ -1,4 +1,3 @@
import { safeHTTPUrl } from "@erp/core/client";
import { DataTableColumnHeader, InitialsAvatar } from "@repo/rdx-ui/components";
import {
Badge,
@ -101,11 +100,11 @@ export function useCustomersGridColumns(
/>
),
accessorFn: (row) =>
`${row.primaryEmail} ${row.primaryPhone} ${row.primaryMobile} ${row.website}`,
`${row.contact.primaryEmail} ${row.contact.primaryPhone} ${row.contact.primaryMobile}`,
enableSorting: false,
size: 140,
minSize: 120,
cell: ({ row }) => <ContactCell customer={row.original} />,
cell: ({ row }) => <ContactCell contact={row.original.contact} />,
},
{
id: "address",
@ -117,11 +116,11 @@ export function useCustomersGridColumns(
/>
),
accessorFn: (row) =>
`${row.street} ${row.street2} ${row.city} ${row.postalCode} ${row.province} ${row.country}`,
`${row.address.street} ${row.address.city} ${row.address.postalCode} ${row.address.province} ${row.address.country}`,
enableSorting: false,
size: 140,
minSize: 120,
cell: ({ row }) => <AddressCell address={row.original} />,
cell: ({ row }) => <AddressCell address={row.original.address} />,
},
{
id: "actions",
@ -138,7 +137,7 @@ export function useCustomersGridColumns(
enableHiding: false,
cell: ({ row }) => {
const customer = row.original;
const { website, primaryEmail: email_primary } = customer;
const { primaryEmail } = customer.contact;
return (
<div className="flex justify-end">
@ -177,23 +176,17 @@ export function useCustomersGridColumns(
<DropdownMenuSeparator />
{primaryEmail && (
<>
<DropdownMenuItem
disabled={!website}
onClick={() =>
window.open(safeHTTPUrl(website), "_blank", "noopener,noreferrer")
}
>
{t("pages.list.actions.visit_website")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!email_primary}
onClick={() => navigator.clipboard.writeText(email_primary)}
disabled={!primaryEmail}
onClick={() => navigator.clipboard.writeText(primaryEmail)}
>
{t("pages.list.actions.copy_email")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
className="text-destructive"

View File

@ -3,24 +3,41 @@ import { ExternalLinkIcon, MapPinIcon } from "lucide-react";
import { useTranslation } from "../../../i18n";
export interface CustomerAddress {
street: string;
street2: string;
postalCode: string;
city: string;
province: string;
country: string;
street?: string | null;
street2?: string | null;
city?: string | null;
province?: string | null;
postalCode?: string | null;
country?: string | null;
}
const getGoogleMapsUrl = (adress: CustomerAddress) => {
const fullAddress = `${adress.street}, ${adress.postalCode} ${adress.city}, ${adress.province}, ${adress.country}`;
const clean = (value?: string | null) => value?.trim() ?? "";
const join = (parts: Array<string | null | undefined>, separator: string) =>
parts.map(clean).filter(Boolean).join(separator);
const getGoogleMapsUrl = (address: CustomerAddress) => {
const fullAddress = join(
[
join([address.street, address.street2], ", "),
join([address.postalCode, address.city], " "),
address.province,
address.country,
],
", "
);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`;
};
export const AddressCell = ({ address }: { address: CustomerAddress }) => {
const { t } = useTranslation();
const line1 = [address.street, address.street2].filter(Boolean).join(", ");
const line2 = [address.postalCode, address.city].filter(Boolean).join(" ");
const line3 = [address.province, address.country].filter(Boolean).join(", ");
const line1 = join([address.street, address.street2], ", ");
const line2 = join([address.postalCode, address.city], " ");
const line3 = join([address.province, address.country], ", ");
const line23 = join([line2, line3], " - ");
return (
<address className="not-italic flex items-start gap-2 text-muted-foreground hover:text-primary transition-colors">
<a
@ -32,24 +49,14 @@ export const AddressCell = ({ address }: { address: CustomerAddress }) => {
title={t("components.address_cell.open_in_google_maps")}
>
<MapPinIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground group-hover:text-primary" />
<div className="text-sm text-muted-foreground group-hover:text-foreground">
<p>{line1}</p>
<p>
{line2} - {line3}
</p>
{line1 && <p>{line1}</p>}
{line23 && <p>{line23}</p>}
</div>
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
</a>
</address>
);
};
/*
<address className="not-italic grid gap-1 text-foreground text-sm">
<div>{line1 || <Soft>-</Soft>}</div>
<div>{[line2, line3].filter(Boolean).join(" • ")}</div>
</address>
*/

View File

@ -1,67 +1,81 @@
import type { CustomerListRow } from "@erp/customers/web/shared";
import { MailIcon, PhoneIcon } from "lucide-react";
import { GlobeIcon, MailIcon, PhoneIcon, PrinterIcon, SmartphoneIcon } from "lucide-react";
export const ContactCell = ({ customer }: { customer: CustomerListRow }) => (
interface ContactCellData {
primaryEmail?: string | null;
secondaryEmail?: string | null;
primaryPhone?: string | null;
secondaryPhone?: string | null;
primaryMobile?: string | null;
secondaryMobile?: string | null;
website?: string | null;
fax?: string | null;
}
const clean = (value?: string | null) => value?.trim() ?? "";
type ContactItem = {
value: string;
href: string;
icon: React.ReactNode;
};
export const ContactCell = ({ contact }: { contact: ContactCellData }) => {
const emails = [contact.primaryEmail, contact.secondaryEmail].map(clean).filter(Boolean);
const mobiles = [contact.primaryMobile, contact.secondaryMobile].map(clean).filter(Boolean);
const phones = [contact.primaryPhone, contact.secondaryPhone].map(clean).filter(Boolean);
const website = clean(contact.website);
const fax = clean(contact.fax);
const items: ContactItem[] = [
...emails.map((v) => ({
value: v,
href: `mailto:${v}`,
icon: <MailIcon className="size-3.5" />,
})),
...mobiles.map((v) => ({
value: v,
href: `tel:${v}`,
icon: <SmartphoneIcon className="size-3.5" />,
})),
...phones.map((v) => ({
value: v,
href: `tel:${v}`,
icon: <PhoneIcon className="size-3.5" />,
})),
...(website
? [
{
value: website,
href: website.startsWith("http") ? website : `https://${website}`,
icon: <GlobeIcon className="size-3.5" />,
},
]
: []),
...(fax
? [
{
value: fax,
href: `tel:${fax}`,
icon: <PrinterIcon className="size-3.5" />,
},
]
: []),
];
if (items.length === 0) return null;
return (
<div className="flex flex-col gap-1.5 text-sm text-muted-foreground transition-colors">
{customer.primaryEmail && (
<a
className="flex items-center gap-2 hover:text-foreground"
href={`mailto:${customer.primaryEmail}`}
>
<MailIcon className="size-3.5" />
{customer.primaryEmail}
{items.map((item, idx) => (
<a className="flex items-center gap-2 hover:text-foreground" href={item.href} key={idx}>
{item.icon}
{item.value}
</a>
)}
{customer.secondaryEmail && (
<a
className="flex items-center gap-2 hover:text-foreground"
href={`mailto:${customer.secondaryEmail}`}
>
<MailIcon className="size-3.5" />
{customer.secondaryEmail}
</a>
)}
{customer.primaryPhone && (
<a
className="flex items-center gap-2 hover:text-foreground"
href={`tel:${customer.primaryPhone}`}
>
<PhoneIcon className="size-3.5" />
{customer.primaryPhone}
</a>
)}
))}
</div>
);
/*
<div className="grid gap-1 text-foreground text-sm my-1.5">
{customer.email_primary && (
<div className="flex items-center gap-2">
<MailIcon className="size-3.5" />
<a className="group" href={`mailto:${customer.email_primary}`}>
{customer.email_primary}
</a>
</div>
)}
{customer.email_secondary && (
<div className="flex items-center gap-2">
<MailIcon className="size-3.5" />
{customer.email_secondary}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<PhoneIcon className="size-3.5 group" />
<span>{customer.phone_primary || customer.mobile_primary || <Soft>-</Soft>}</span>
{customer.phone_secondary && <Soft> {customer.phone_secondary}</Soft>}
{customer.mobile_secondary && <Soft> {customer.mobile_secondary}</Soft>}
{false}
</div>
{false}
</div>
*/
};

View File

@ -12,8 +12,8 @@ import type { Customer } from "../entities";
export const GetCustomerByIdAdapter = {
fromDTO(dto: GetCustomerByIdResult, context?: unknown): Customer {
const taxesAdapter = (taxes: string) =>
taxes.split(";").filter((item) => item !== "#" && item.trim() !== "");
const taxesAdapter = (taxes: string | null) =>
taxes?.split(";").filter((item) => item !== "#" && item.trim() !== "") || null;
const defaultTaxes = taxesAdapter(dto.default_taxes);
@ -22,27 +22,30 @@ export const GetCustomerByIdAdapter = {
companyId: dto.company_id,
reference: dto.reference,
isCompany: dto.is_company === "1",
isCompany: dto.is_company,
name: dto.name,
tradeName: dto.trade_name,
tin: dto.tin,
street: dto.street,
street2: dto.street2,
city: dto.city,
province: dto.province,
postalCode: dto.postal_code,
country: dto.country,
address: {
street: dto.address.street,
street2: dto.address.street2,
city: dto.address.city,
province: dto.address.province,
postalCode: dto.address.postal_code,
country: dto.address.country,
},
contact: {
primaryEmail: dto.contact.email_primary,
secondaryEmail: dto.contact.email_secondary,
primaryPhone: dto.contact.phone_primary,
secondaryPhone: dto.contact.phone_secondary,
primaryMobile: dto.contact.mobile_primary,
secondaryMobile: dto.contact.mobile_secondary,
primaryEmail: dto.email_primary,
secondaryEmail: dto.email_secondary,
primaryPhone: dto.phone_primary,
secondaryPhone: dto.phone_secondary,
primaryMobile: dto.mobile_primary,
secondaryMobile: dto.mobile_secondary,
fax: dto.fax,
website: dto.website,
fax: dto.contact.fax,
website: dto.contact.website,
},
legalRecord: dto.legal_record,

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