diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 9069b50c..699ed733 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,3 +1,3 @@
{
- "recommendations": ["esbenp.prettier-vscode", "biomejs.biome"]
+ "recommendations": ["biomejs.biome"]
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 853691d3..78bcc5e5 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -16,6 +16,7 @@
"typescript.suggest.includeAutomaticOptionalChainCompletions": true,
"typescript.suggestionActions.enabled": true,
"typescript.preferences.importModuleSpecifier": "shortest",
+ "typescript.autoClosingTags": true,
"editor.quickSuggestions": {
"strings": "on"
diff --git a/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts b/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts
index fc0aa9ad..de6e5cda 100644
--- a/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts
+++ b/apps/server/archive/contexts/accounts/presentation/dto/accounts.schemas.ts
@@ -1,4 +1,4 @@
-import { z } from "zod";
+import * as z from "zod/v4";
export const ListAccountsRequestSchema = z.object({});
diff --git a/apps/server/archive/contexts/auth/domain/value-objects/auth-user-roles.ts b/apps/server/archive/contexts/auth/domain/value-objects/auth-user-roles.ts
index 716584d3..ff328bf4 100644
--- a/apps/server/archive/contexts/auth/domain/value-objects/auth-user-roles.ts
+++ b/apps/server/archive/contexts/auth/domain/value-objects/auth-user-roles.ts
@@ -1,6 +1,6 @@
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
-import { z } from "zod";
+import * as z from "zod/v4";
const RoleSchema = z.enum(["Admin", "User", "Manager", "Editor"]);
diff --git a/apps/server/archive/contexts/auth/domain/value-objects/hash-password.ts b/apps/server/archive/contexts/auth/domain/value-objects/hash-password.ts
index 21c37c9d..f7c2511d 100644
--- a/apps/server/archive/contexts/auth/domain/value-objects/hash-password.ts
+++ b/apps/server/archive/contexts/auth/domain/value-objects/hash-password.ts
@@ -1,7 +1,7 @@
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import bcrypt from "bcrypt";
-import { z } from "zod";
+import * as z from "zod/v4";
interface HashPasswordProps {
value: string;
diff --git a/apps/server/archive/contexts/auth/domain/value-objects/plain-password.ts b/apps/server/archive/contexts/auth/domain/value-objects/plain-password.ts
index 8d666a1e..cdd92a53 100644
--- a/apps/server/archive/contexts/auth/domain/value-objects/plain-password.ts
+++ b/apps/server/archive/contexts/auth/domain/value-objects/plain-password.ts
@@ -1,6 +1,6 @@
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
-import { z } from "zod";
+import * as z from "zod/v4";
interface PlainPasswordProps {
value: string;
diff --git a/apps/server/archive/contexts/auth/domain/value-objects/token.ts b/apps/server/archive/contexts/auth/domain/value-objects/token.ts
index c6ebe57f..d989904b 100644
--- a/apps/server/archive/contexts/auth/domain/value-objects/token.ts
+++ b/apps/server/archive/contexts/auth/domain/value-objects/token.ts
@@ -1,6 +1,6 @@
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
-import { z } from "zod";
+import * as z from "zod/v4";
interface TokenProps {
value: string;
diff --git a/apps/server/archive/contexts/auth/domain/value-objects/username.ts b/apps/server/archive/contexts/auth/domain/value-objects/username.ts
index bec7b4ea..072e9b4c 100644
--- a/apps/server/archive/contexts/auth/domain/value-objects/username.ts
+++ b/apps/server/archive/contexts/auth/domain/value-objects/username.ts
@@ -1,6 +1,6 @@
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
-import { z } from "zod";
+import * as z from "zod/v4";
interface UsernameProps {
value: string;
diff --git a/apps/server/archive/contexts/auth/presentation/dto/auth.validation.dto.ts b/apps/server/archive/contexts/auth/presentation/dto/auth.validation.dto.ts
index 96b1a3a8..4ebccdf5 100644
--- a/apps/server/archive/contexts/auth/presentation/dto/auth.validation.dto.ts
+++ b/apps/server/archive/contexts/auth/presentation/dto/auth.validation.dto.ts
@@ -1,4 +1,4 @@
-import { z } from "zod";
+import * as z from "zod/v4";
export const RegisterUserSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters long"),
diff --git a/apps/server/archive/contexts/auth/presentation/dto/user.validation.dto.ts b/apps/server/archive/contexts/auth/presentation/dto/user.validation.dto.ts
index 2ddd2864..f5852158 100644
--- a/apps/server/archive/contexts/auth/presentation/dto/user.validation.dto.ts
+++ b/apps/server/archive/contexts/auth/presentation/dto/user.validation.dto.ts
@@ -1,3 +1,3 @@
-import { z } from "zod";
+import * as z from "zod/v4";
export const ListUsersSchema = z.object({});
diff --git a/apps/server/archive/contexts/contacts/presentation/dto/contacts.validation.dto.ts b/apps/server/archive/contexts/contacts/presentation/dto/contacts.validation.dto.ts
index c844dc9c..16b1b220 100644
--- a/apps/server/archive/contexts/contacts/presentation/dto/contacts.validation.dto.ts
+++ b/apps/server/archive/contexts/contacts/presentation/dto/contacts.validation.dto.ts
@@ -1,3 +1,3 @@
-import { z } from "zod";
+import * as z from "zod/v4";
export const ListContactsSchema = z.object({});
diff --git a/apps/server/archive/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts b/apps/server/archive/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts
index e133753f..5de35fd1 100644
--- a/apps/server/archive/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts
+++ b/apps/server/archive/contexts/customer-billing/presentation/dto/customer-invoices/customer-invoices.validation.dto.ts
@@ -1,4 +1,4 @@
-import { z } from "zod";
+import * as z from "zod/v4";
export const ListCustomerInvoicesSchema = z.object({});
export const GetCustomerInvoiceSchema = z.object({});
diff --git a/apps/server/archive/contexts/customer-billing/presentation/dto/customers.validation.dto.ts b/apps/server/archive/contexts/customer-billing/presentation/dto/customers.validation.dto.ts
index ba8d4eec..43dfc2b5 100644
--- a/apps/server/archive/contexts/customer-billing/presentation/dto/customers.validation.dto.ts
+++ b/apps/server/archive/contexts/customer-billing/presentation/dto/customers.validation.dto.ts
@@ -1,3 +1,3 @@
-import { z } from "zod";
+import * as z from "zod/v4";
export const ListCustomersSchema = z.object({});
diff --git a/apps/web/index.html b/apps/web/index.html
index 692e8b38..10318ef9 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -13,7 +13,7 @@
-
+
diff --git a/apps/web/package.json b/apps/web/package.json
index 8b50fbbc..656b9cdd 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -41,7 +41,7 @@
"i18next-browser-languagedetector": "^8.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
- "react-hook-form": "^7.56.2",
+ "react-hook-form": "^7.56.4",
"react-hook-form-persist": "^3.0.0",
"react-i18next": "^15.0.1",
"react-router-dom": "^6.26.0",
diff --git a/apps/web/src/routes/app-routes.tsx b/apps/web/src/routes/app-routes.tsx
index a43700b1..42479ff8 100644
--- a/apps/web/src/routes/app-routes.tsx
+++ b/apps/web/src/routes/app-routes.tsx
@@ -38,6 +38,7 @@ export const AppRoutes = (): JSX.Element => {
+ {/* Fallback Route */}
}>
{/* Auth Layout */}
@@ -56,11 +57,7 @@ export const AppRoutes = (): JSX.Element => {
} />
} />
} />
- } />
-
- {/* Fallback Route */}
- } />
diff --git a/biome.json b/biome.json
index 75b48fde..2c39dc01 100644
--- a/biome.json
+++ b/biome.json
@@ -20,7 +20,7 @@
"recommended": true,
"correctness": {
"useExhaustiveDependencies": "info",
- "noUnreachable": "off"
+ "noUnreachable": "warn"
},
"complexity": {
"noForEach": "off",
diff --git a/docs/DTOS - GUIA DE ESTILO.md b/docs/DTOS - GUIA DE ESTILO.md
new file mode 100644
index 00000000..f2cfdcfe
--- /dev/null
+++ b/docs/DTOS - GUIA DE ESTILO.md
@@ -0,0 +1,150 @@
+# 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/
+└─ / (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*: `@/dto/*` → `src//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//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` |
diff --git a/modules.bak/invoices/src/server/domain/value-objects/invoice-item-description.ts b/modules.bak/invoices/src/server/domain/value-objects/invoice-item-description.ts
index 9cd47653..0e3aae95 100644
--- a/modules.bak/invoices/src/server/domain/value-objects/invoice-item-description.ts
+++ b/modules.bak/invoices/src/server/domain/value-objects/invoice-item-description.ts
@@ -1,6 +1,6 @@
import { ValueObject } from "core/common/domain";
import { Maybe, Result } from "core/common/helpers";
-import { z } from "zod";
+import * as z from "zod/v4";
interface IInvoiceItemDescriptionProps {
value: string;
diff --git a/modules.bak/invoices/src/server/domain/value-objects/invoice-number.ts b/modules.bak/invoices/src/server/domain/value-objects/invoice-number.ts
index 92341077..87bcbc9b 100644
--- a/modules.bak/invoices/src/server/domain/value-objects/invoice-number.ts
+++ b/modules.bak/invoices/src/server/domain/value-objects/invoice-number.ts
@@ -1,6 +1,6 @@
import { ValueObject } from "core/common/domain";
import { Result } from "core/common/helpers";
-import { z } from "zod";
+import * as z from "zod/v4";
interface IInvoiceNumberProps {
value: string;
diff --git a/modules.bak/invoices/src/server/domain/value-objects/invoice-serie.ts b/modules.bak/invoices/src/server/domain/value-objects/invoice-serie.ts
index a4da6559..f0ead545 100644
--- a/modules.bak/invoices/src/server/domain/value-objects/invoice-serie.ts
+++ b/modules.bak/invoices/src/server/domain/value-objects/invoice-serie.ts
@@ -1,6 +1,6 @@
import { ValueObject } from "core/common/domain";
import { Maybe, Result } from "core/common/helpers";
-import { z } from "zod";
+import * as z from "zod/v4";
interface IInvoiceSerieProps {
value: string;
diff --git a/modules.bak/invoices/src/server/presentation/dto/invoices.schemas.ts b/modules.bak/invoices/src/server/presentation/dto/invoices.schemas.ts
index e07ec582..b887ec47 100644
--- a/modules.bak/invoices/src/server/presentation/dto/invoices.schemas.ts
+++ b/modules.bak/invoices/src/server/presentation/dto/invoices.schemas.ts
@@ -1,4 +1,4 @@
-import { z } from "zod";
+import * as z from "zod/v4";
export const ICreateInvoiceRequestSchema = z.object({
id: z.string().uuid(),
diff --git a/modules/core/package.json b/modules/core/package.json
index ed7fe2e7..654276ca 100644
--- a/modules/core/package.json
+++ b/modules/core/package.json
@@ -14,18 +14,21 @@
"@types/jest": "29.5.14",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
- "typescript": "^5.8.3"
+ "ts-to-zod": "^3.15.0",
+ "typescript": "^5.8.3",
+ "zod-to-ts": "^1.2.0"
},
"dependencies": {
+ "@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*",
- "@repo/rdx-criteria": "workspace:*",
"@tanstack/react-query": "^5.75.4",
"axios": "^1.9.0",
"http-status": "^2.1.0",
"joi": "^17.13.3",
"libphonenumber-js": "^1.11.20",
"react-router-dom": "^6.26.0",
- "sequelize": "^6.37.5"
+ "sequelize": "^6.37.5",
+ "zod": "^3.25.67"
}
}
diff --git a/modules/core/src/api/infrastructure/express/api-error.ts b/modules/core/src/api/errors/api-error.ts
similarity index 100%
rename from modules/core/src/api/infrastructure/express/api-error.ts
rename to modules/core/src/api/errors/api-error.ts
diff --git a/modules/core/src/api/errors/conflict-api-error.ts b/modules/core/src/api/errors/conflict-api-error.ts
new file mode 100644
index 00000000..bd903221
--- /dev/null
+++ b/modules/core/src/api/errors/conflict-api-error.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "./api-error";
+
+export class ConflictApiError extends ApiError {
+ constructor(detail: string) {
+ super({
+ status: 409,
+ title: "Conflict",
+ detail,
+ type: "https://httpstatuses.com/409",
+ });
+ }
+}
\ No newline at end of file
diff --git a/modules/core/src/api/errors/domain-validation-error.ts b/modules/core/src/api/errors/domain-validation-error.ts
new file mode 100644
index 00000000..ed4fa698
--- /dev/null
+++ b/modules/core/src/api/errors/domain-validation-error.ts
@@ -0,0 +1,29 @@
+/**
+ * Clase DomainValidationError
+ * Representa un error de validación de dominio.
+ *
+ * Esta clase extiende la clase Error de JavaScript y se utiliza para manejar errores
+ * específicos de validación dentro del dominio de la aplicación. Permite identificar
+ * el código de error, el campo afectado y un detalle descriptivo del error.
+ *
+ * @class DomainValidationError
+ * @extends {Error}
+ * @property {string} code - Código del error de validación.
+ * @property {string} field - Campo afectado por el error de validación.
+ * @property {string} detail - Detalle descriptivo del error de validación.
+ *
+ * @example
+ * const error = new DomainValidationError("INVALID_EMAIL", "email", "El email no es válido");
+ * console.error(error);
+ */
+
+export class DomainValidationError extends Error {
+ constructor(
+ public readonly code: string,
+ public readonly field: string,
+ public readonly detail: string
+ ) {
+ super(`[${field}] ${detail}`);
+ this.name = "DomainValidationError";
+ }
+}
diff --git a/modules/core/src/api/errors/error-mapper.ts b/modules/core/src/api/errors/error-mapper.ts
new file mode 100644
index 00000000..e585c8ac
--- /dev/null
+++ b/modules/core/src/api/errors/error-mapper.ts
@@ -0,0 +1,96 @@
+import { ConnectionError, UniqueConstraintError } from "sequelize";
+
+import { ApiError } from "./api-error";
+import { ConflictApiError } from "./conflict-api-error";
+import { DomainValidationError } from "./domain-validation-error";
+import { ForbiddenApiError } from "./forbidden-api-error";
+import { InternalApiError } from "./internal-api-error";
+import { NotFoundApiError } from "./not-found-api-error";
+import { UnauthorizedApiError } from "./unauthorized-api-error";
+import { UnavailableApiError } from "./unavailable-api-error";
+import { ValidationApiError } from "./validation-api-error";
+import { ValidationErrorCollection } from "./validation-error-collection";
+
+/**
+ * Mapea errores de la aplicación a errores de la API.
+ *
+ * Esta función toma un error de la aplicación y lo convierte en un objeto ApiError
+ * adecuado para enviar como respuesta HTTP. Maneja errores comunes como validación,
+ * conflictos, no encontrados, autenticación y errores de infraestructura.
+ *
+ * @param error - El error de la aplicación a mapear.
+ * @returns Un objeto ApiError que representa el error mapeado.
+ * @example
+ * const error = new Error("Invalid input");
+ * const apiError = errorMapper.toApiError(error);
+ * console.log(apiError);
+ * // Output: ValidationApiError { status: 422, title: 'Validation Failed', detail: 'Invalid input', type: 'https://httpstatuses.com/422' }
+ * @throws {ApiError} Si el error no puede ser mapeado a un tipo conocido.
+ * @see ApiError
+ * @see ValidationApiError
+ */
+
+export const errorMapper = {
+ toApiError: (error: Error): ApiError => {
+ const message = error.message || "An unexpected error occurred";
+
+ // 1. 🔍 Errores de validación complejos (agrupados)
+ if (error instanceof ValidationErrorCollection) {
+ return new ValidationApiError(error.message, error.details);
+ }
+
+ // 2. 🔍 Errores individuales de validación de dominio
+ if (error instanceof DomainValidationError) {
+ return new ValidationApiError(error.detail, [{ path: error.field, message: error.detail }]);
+ }
+
+ // 3. 🔍 Errores individuales de validación
+ if (
+ message.includes("invalid") ||
+ message.includes("is not valid") ||
+ message.includes("must be") ||
+ message.includes("cannot be") ||
+ message.includes("empty")
+ ) {
+ return new ValidationApiError(message);
+ }
+
+ // 4. 🔍 Recurso no encontrado
+ if (error.name === "NotFoundError" || message.includes("not found")) {
+ return new NotFoundApiError(message);
+ }
+
+ // 5. 🔍 Conflicto (por ejemplo, duplicado)
+ if (
+ error.name === "ConflictError" ||
+ error instanceof UniqueConstraintError ||
+ message.includes("already exists") ||
+ message.includes("duplicate key")
+ ) {
+ return new ConflictApiError(message);
+ }
+
+ // 6. 🔍 No autenticado
+ if (error.name === "UnauthorizedError" || message.includes("unauthorized")) {
+ return new UnauthorizedApiError(message);
+ }
+
+ // 7. 🔍 Prohibido
+ if (error.name === "ForbiddenError" || message.includes("forbidden")) {
+ return new ForbiddenApiError(message);
+ }
+
+ // 8. 🔍 Error de conexión o indisponibilidad de servicio
+ if (
+ error instanceof ConnectionError ||
+ message.includes("Database connection lost") ||
+ message.includes("timeout") ||
+ message.includes("ECONNREFUSED")
+ ) {
+ return new UnavailableApiError("Service temporarily unavailable.");
+ }
+
+ // 9. 🔍 Fallback: error no identificado
+ return new InternalApiError(`Unexpected error: ${message}`);
+ },
+};
diff --git a/modules/core/src/api/errors/forbidden-api-error.ts b/modules/core/src/api/errors/forbidden-api-error.ts
new file mode 100644
index 00000000..c833751f
--- /dev/null
+++ b/modules/core/src/api/errors/forbidden-api-error.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "./api-error";
+
+export class ForbiddenApiError extends ApiError {
+ constructor(detail: string) {
+ super({
+ status: 403,
+ title: "Forbidden",
+ detail,
+ type: "https://httpstatuses.com/403",
+ });
+ }
+}
\ No newline at end of file
diff --git a/modules/core/src/api/errors/index.ts b/modules/core/src/api/errors/index.ts
new file mode 100644
index 00000000..6601beee
--- /dev/null
+++ b/modules/core/src/api/errors/index.ts
@@ -0,0 +1,3 @@
+export * from "./domain-validation-error";
+export * from "./error-mapper";
+export * from "./validation-error-collection";
diff --git a/modules/core/src/api/errors/internal-api-error.ts b/modules/core/src/api/errors/internal-api-error.ts
new file mode 100644
index 00000000..2c511d0c
--- /dev/null
+++ b/modules/core/src/api/errors/internal-api-error.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "./api-error";
+
+export class InternalApiError extends ApiError {
+ constructor(detail: string) {
+ super({
+ status: 500,
+ title: "Internal Server Error",
+ detail,
+ type: "https://httpstatuses.com/500",
+ });
+ }
+}
\ No newline at end of file
diff --git a/modules/core/src/api/errors/not-found-api-error.ts b/modules/core/src/api/errors/not-found-api-error.ts
new file mode 100644
index 00000000..f2cd605d
--- /dev/null
+++ b/modules/core/src/api/errors/not-found-api-error.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "./api-error";
+
+export class NotFoundApiError extends ApiError {
+ constructor(detail: string) {
+ super({
+ status: 404,
+ title: "Resource Not Found",
+ detail,
+ type: "https://httpstatuses.com/404",
+ });
+ }
+}
\ No newline at end of file
diff --git a/modules/core/src/api/errors/unauthorized-api-error.ts b/modules/core/src/api/errors/unauthorized-api-error.ts
new file mode 100644
index 00000000..d36453cd
--- /dev/null
+++ b/modules/core/src/api/errors/unauthorized-api-error.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "./api-error";
+
+export class UnauthorizedApiError extends ApiError {
+ constructor(detail: string) {
+ super({
+ status: 401,
+ title: "Unauthorized",
+ detail,
+ type: "https://httpstatuses.com/401",
+ });
+ }
+}
\ No newline at end of file
diff --git a/modules/core/src/api/errors/unavailable-api-error.ts b/modules/core/src/api/errors/unavailable-api-error.ts
new file mode 100644
index 00000000..8edf9edd
--- /dev/null
+++ b/modules/core/src/api/errors/unavailable-api-error.ts
@@ -0,0 +1,12 @@
+import { ApiError } from "./api-error";
+
+export class UnavailableApiError extends ApiError {
+ constructor(detail: string) {
+ super({
+ status: 503,
+ title: "Service Unavailable",
+ detail,
+ type: "https://httpstatuses.com/503",
+ });
+ }
+}
\ No newline at end of file
diff --git a/modules/core/src/api/errors/validation-api-error.ts b/modules/core/src/api/errors/validation-api-error.ts
new file mode 100644
index 00000000..9498e62b
--- /dev/null
+++ b/modules/core/src/api/errors/validation-api-error.ts
@@ -0,0 +1,13 @@
+import { ApiError } from "./api-error";
+
+export class ValidationApiError extends ApiError {
+ constructor(detail: string, errors?: any[]) {
+ super({
+ status: 422,
+ title: "Validation Failed",
+ detail,
+ type: "https://httpstatuses.com/422",
+ errors,
+ });
+ }
+}
\ No newline at end of file
diff --git a/modules/core/src/api/errors/validation-error-collection.ts b/modules/core/src/api/errors/validation-error-collection.ts
new file mode 100644
index 00000000..890f8b5b
--- /dev/null
+++ b/modules/core/src/api/errors/validation-error-collection.ts
@@ -0,0 +1,33 @@
+/**
+ * ValidationErrorCollection
+ *
+ * Esta clase representa un error de validación que agrega múltiples detalles de errores
+ * en un formato estructurado. Es útil para manejar múltiples errores de validación
+ * en una sola respuesta, permitiendo a los clientes entender qué campos fallaron y por qué.
+ *
+ * Ejemplo de uso:
+ *
+ * const errors: ValidationErrorDetail[] = [
+ * { path: "lines[1].unitPrice.amount", message: "Amount must be a positive number" },
+ * { path: "lines[1].unitPrice.scale", message: "Scale must be a non-negative integer" },
+ * ];
+ * const validationError = new ValidationErrorCollection(errors);
+ *
+ */
+
+export interface ValidationErrorDetail {
+ path: string; // ejemplo: "lines[1].unitPrice.amount"
+ message: string; // ejemplo: "Amount must be a positive number"
+}
+
+export class ValidationErrorCollection extends Error {
+ public readonly details: ValidationErrorDetail[];
+
+ constructor(details: ValidationErrorDetail[]) {
+ super("Validation failed");
+ Object.setPrototypeOf(this, ValidationErrorCollection.prototype);
+
+ this.name = "ValidationErrorCollection";
+ this.details = details;
+ }
+}
diff --git a/modules/core/src/api/index.ts b/modules/core/src/api/index.ts
index 078bda6c..d140fc71 100644
--- a/modules/core/src/api/index.ts
+++ b/modules/core/src/api/index.ts
@@ -1,3 +1,4 @@
+export * from "./errors";
export * from "./infrastructure";
export * from "./logger";
export * from "./modules";
diff --git a/modules/core/src/api/infrastructure/express/express-controller.ts b/modules/core/src/api/infrastructure/express/express-controller.ts
index 12cb0712..0f3f5eca 100644
--- a/modules/core/src/api/infrastructure/express/express-controller.ts
+++ b/modules/core/src/api/infrastructure/express/express-controller.ts
@@ -1,7 +1,16 @@
import { Criteria, CriteriaFromUrlConverter } from "@repo/rdx-criteria/server";
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
-import { ApiError } from "./api-error";
+import {
+ ApiError,
+ ConflictApiError,
+ ForbiddenApiError,
+ InternalApiError,
+ NotFoundApiError,
+ UnauthorizedApiError,
+ UnavailableApiError,
+ ValidationApiError,
+} from "../../errors";
export abstract class ExpressController {
protected req!: Request; //| AuthenticatedRequest | TabContextRequest;
@@ -16,140 +25,99 @@ export abstract class ExpressController {
protected abstract executeImpl(): Promise;
protected ok(dto?: T) {
- return dto ? this.res.status(httpStatus.OK).json(dto) : this.res.status(httpStatus.OK).send();
+ return dto ? this.res.status(httpStatus.OK).json(dto) : this.res.sendStatus(httpStatus.OK);
}
protected created(dto?: T) {
return dto
? this.res.status(httpStatus.CREATED).json(dto)
- : this.res.status(httpStatus.CREATED).send();
+ : this.res.sendStatus(httpStatus.CREATED);
}
protected noContent() {
- return this.res.status(httpStatus.NO_CONTENT).send();
+ return this.res.sendStatus(httpStatus.NO_CONTENT);
}
/**
- * 🔹 Respuesta para errores de cliente (400 Bad Request)
+ * Respuesta para errores de cliente (400 Bad Request)
*/
- public clientError(message: string, errors?: any[] | any) {
+ protected clientError(message: string, errors?: any[] | any) {
return ExpressController.errorResponse(
- new ApiError({
- status: 400,
- title: "Bad Request",
- detail: message,
- errors: Array.isArray(errors) ? errors : [errors],
- }),
+ new ValidationApiError(message, Array.isArray(errors) ? errors : [errors]),
this.res
);
}
/**
- * 🔹 Respuesta para errores de autenticación (401 Unauthorized)
+ * Respuesta para errores de autenticación (401 Unauthorized)
*/
protected unauthorizedError(message?: string) {
return ExpressController.errorResponse(
- new ApiError({
- status: 401,
- title: httpStatus["401"],
- name: httpStatus["401_NAME"],
- detail: message ?? httpStatus["401_MESSAGE"],
- }),
+ new UnauthorizedApiError(message ?? "Unauthorized"),
this.res
);
}
-
/**
- * 🔹 Respuesta para errores de autorización (403 Forbidden)
+ * Respuesta para errores de autorización (403 Forbidden)
*/
protected forbiddenError(message?: string) {
return ExpressController.errorResponse(
- new ApiError({
- status: 403,
- title: "Forbidden",
- detail: message ?? "You do not have permission to perform this action.",
- }),
+ new ForbiddenApiError(message ?? "You do not have permission to perform this action."),
this.res
);
}
/**
- * 🔹 Respuesta para recursos no encontrados (404 Not Found)
+ * Respuesta para recursos no encontrados (404 Not Found)
*/
protected notFoundError(message: string) {
- return ExpressController.errorResponse(
- new ApiError({
- status: 404,
- title: "Not Found",
- detail: message,
- }),
- this.res
- );
+ return ExpressController.errorResponse(new NotFoundApiError(message), this.res);
}
/**
- * 🔹 Respuesta para conflictos (409 Conflict)
+ * Respuesta para conflictos (409 Conflict)
*/
protected conflictError(message: string, errors?: any[]) {
- return ExpressController.errorResponse(
- new ApiError({
- status: 409,
- title: "Conflict",
- detail: message,
- errors,
- }),
- this.res
- );
+ return ExpressController.errorResponse(new ConflictApiError(message), this.res);
}
/**
- * 🔹 Respuesta para errores de validación de entrada (422 Unprocessable Entity)
+ * Respuesta para errores de validación de entrada (422 Unprocessable Entity)
*/
protected invalidInputError(message: string, errors?: any[]) {
- return ExpressController.errorResponse(
- new ApiError({
- status: 422,
- title: httpStatus["422"],
- name: httpStatus["422_NAME"],
- detail: message ?? httpStatus["422_MESSAGE"],
- errors,
- }),
- this.res
- );
+ return ExpressController.errorResponse(new ValidationApiError(message, errors), this.res);
}
/**
* Respuesta para errores de servidor no disponible (503 Service Unavailable)
- * @param message
- * @returns
*/
protected unavailableError(message?: string) {
return ExpressController.errorResponse(
- new ApiError({
- status: 503,
- title: httpStatus["503"],
- name: httpStatus["503_NAME"],
- detail: message ?? httpStatus["503_MESSAGE"],
- }),
+ new UnavailableApiError(message ?? "Service temporarily unavailable."),
+ this.res
+ );
+ }
+ /**
+ * Respuesta para errores internos del servidor (500 Internal Server Error)
+ */
+ protected internalServerError(message?: string) {
+ return ExpressController.errorResponse(
+ new InternalApiError(message ?? "Internal Server Error"),
this.res
);
}
/**
- * 🔹 Respuesta para errores internos del servidor (500 Internal Server Error)
+ * Respuesta para cualquier error de la API
*/
- protected internalServerError(message?: string) {
- return ExpressController.errorResponse(
- new ApiError({
- status: 500,
- title: httpStatus["500"],
- name: httpStatus["500_NAME"],
- detail: message ?? httpStatus["500_MESSAGE"],
- }),
- this.res
- );
+ protected handleApiError(apiError: ApiError) {
+ return ExpressController.errorResponse(apiError, this.res);
}
+ /**
+ * Método principal que se invoca desde el router de Express.
+ * Maneja la conversión de la URL a criterios y llama a executeImpl.
+ */
public execute(req: Request, res: Response, next: NextFunction): void {
this.req = req;
this.res = res;
@@ -161,8 +129,12 @@ export abstract class ExpressController {
this.executeImpl();
} catch (error: unknown) {
- const _error = error as Error;
- this.internalServerError(_error.message);
+ const err = error as Error;
+ if (err instanceof ApiError) {
+ ExpressController.errorResponse(err, this.res);
+ } else {
+ ExpressController.errorResponse(new InternalApiError(err.message), this.res);
+ }
}
}
}
diff --git a/modules/core/src/api/infrastructure/express/index.ts b/modules/core/src/api/infrastructure/express/index.ts
index 3331f2c3..05f51dad 100644
--- a/modules/core/src/api/infrastructure/express/index.ts
+++ b/modules/core/src/api/infrastructure/express/index.ts
@@ -1,4 +1,2 @@
-export * from "./api-error";
export * from "./express-controller";
export * from "./middlewares";
-export * from "./validate-request-dto";
diff --git a/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts b/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts
index a3dd599e..59742485 100644
--- a/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts
+++ b/modules/core/src/api/infrastructure/express/middlewares/global-error-handler.ts
@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from "express";
-import { ApiError } from "../api-error";
+import { ApiError } from "../../../errors/api-error";
export const globalErrorHandler = async (
error: Error,
diff --git a/modules/core/src/api/infrastructure/express/middlewares/index.ts b/modules/core/src/api/infrastructure/express/middlewares/index.ts
index de655522..0d5d108a 100644
--- a/modules/core/src/api/infrastructure/express/middlewares/index.ts
+++ b/modules/core/src/api/infrastructure/express/middlewares/index.ts
@@ -1 +1,2 @@
export * from "./global-error-handler";
+export * from "./validate-request";
diff --git a/modules/core/src/api/infrastructure/express/middlewares/validate-request.ts b/modules/core/src/api/infrastructure/express/middlewares/validate-request.ts
new file mode 100644
index 00000000..b17d61a4
--- /dev/null
+++ b/modules/core/src/api/infrastructure/express/middlewares/validate-request.ts
@@ -0,0 +1,63 @@
+// src/common/middlewares/validate-dto.ts
+import { ValidationApiError } from "@erp/core/api";
+import { RequestHandler } from "express";
+import { ZodSchema } from "zod/v4";
+
+/**
+ * Middleware genérico para validar un objeto de Express
+ * (`body`, `query`, o `params`) mediante un esquema Zod.
+ *
+ * @param schema Esquema Zod que valida la entrada.
+ * @param source Parte del request que se valida ('body' | 'query' | 'params').
+ * Por defecto, 'body'.
+ * @param options Opciones adicionales:
+ * - `sanitize`: Si se debe reescribir `req.body` con los datos validados.
+ *
+ * @example
+ * router.post('/invoices',
+ * validateZod(CreateInvoiceCommandSchema), // body
+ * controller
+ * );
+ *
+ * router.get('/invoices',
+ * validateZod(ListInvoicesQuerySchema, 'query'), // query
+ * controller
+ * );
+ *
+ * router.patch('/invoices/:id/status',
+ * validateZod(ChangeStatusParamsSchema, 'params'), // params
+ * controller
+ * );
+ */
+
+export type ValidateRequestWithSchemaOptions = {
+ sanitize?: boolean; // Si se debe reescribir req.body con los datos validados
+};
+
+export const validateRequest = (
+ schema: ZodSchema,
+ source: T = "body" as T,
+ options: ValidateRequestWithSchemaOptions = { sanitize: true }
+): RequestHandler => {
+ return async (req, res, next) => {
+ console.debug(`Validating request ${source} with schema:`, schema);
+ const result = schema.safeParse(req[source]);
+
+ if (!result.success) {
+ // Construye errores detallados
+ const validationErrors = result.error.errors.map((err) => ({
+ field: err.path.join("."),
+ message: err.message,
+ }));
+
+ return new ValidationApiError("Validation failed", validationErrors);
+ }
+
+ // Si pasa la validación, opcionalmente reescribe req.body
+ if (options?.sanitize ?? true) {
+ req[source] = result.data;
+ }
+
+ next();
+ };
+};
diff --git a/modules/core/src/api/infrastructure/express/validate-request-dto.ts b/modules/core/src/api/infrastructure/express/validate-request-dto.ts
deleted file mode 100644
index 7ff5f042..00000000
--- a/modules/core/src/api/infrastructure/express/validate-request-dto.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { NextFunction, Request, Response } from "express";
-import httpStatus from "http-status";
-import { ZodSchema } from "zod";
-import { ApiError } from "./api-error";
-
-export const validateAndParseBody =
- (schema: ZodSchema, options?: { sanitize?: boolean }) =>
- (req: Request, res: Response, next: NextFunction) => {
- const result = schema.safeParse(req.body);
- try {
- if (!result.success) {
- // Construye errores detallados
- const validationErrors = result.error.errors.map((err) => ({
- field: err.path.join("."),
- message: err.message,
- }));
-
- throw new ApiError({
- status: httpStatus.BAD_REQUEST, //400
- title: "Validation Error",
- detail: "Algunos campos no cumplen con los criterios de validación.",
- type: "https://example.com/probs/validation-error",
- instance: req.originalUrl,
- errors: validationErrors,
- });
- }
-
- // Si pasa la validación, opcionalmente reescribe req.body
- if (options?.sanitize ?? true) {
- req.body = result.data;
- }
-
- next();
- } catch (error: unknown) {
- // Si ocurre un error, delega al manejador de errores global
- next(error as ApiError);
- }
- };
diff --git a/modules/core/src/common/dto/index.ts b/modules/core/src/common/dto/index.ts
index a02fd240..87908f38 100644
--- a/modules/core/src/common/dto/index.ts
+++ b/modules/core/src/common/dto/index.ts
@@ -1,5 +1,5 @@
export * from "./error.dto";
-export * from "./list.dto";
+export * from "./list.view.dto";
export * from "./metadata.dto";
export * from "./money.dto";
export * from "./percentage.dto";
diff --git a/modules/core/src/common/dto/list.dto.ts b/modules/core/src/common/dto/list.dto.ts
deleted file mode 100644
index 1608bdb8..00000000
--- a/modules/core/src/common/dto/list.dto.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export interface IListResponseDTO {
- page: number;
- per_page: number;
- total_pages: number;
- total_items: number;
- items: T[];
-}
-
-export const isResponseAListDTO = (data: any): data is IListResponseDTO => {
- return data && typeof data.total_items === "number";
-};
-
-export const existsMoreReponsePages = (response: any): response is IListResponseDTO => {
- return isResponseAListDTO(response) && response.page + 1 < response.total_pages;
-};
diff --git a/modules/core/src/common/dto/list.view.dto.ts b/modules/core/src/common/dto/list.view.dto.ts
new file mode 100644
index 00000000..07dc3ae2
--- /dev/null
+++ b/modules/core/src/common/dto/list.view.dto.ts
@@ -0,0 +1,18 @@
+import * as z from "zod/v4";
+import { MetadataSchema } from "./metadata.dto";
+
+/**
+ * Crea un esquema Zod que representa un ListViewDTO genérico.
+ *
+ * @param itemSchema Esquema Zod del elemento T
+ * @returns Zod schema para ListViewDTO
+ */
+export const createListViewSchema = (itemSchema: T) =>
+ z.object({
+ page: z.number().int().min(1, "Page must be a positive integer"),
+ per_page: z.number().int().min(1, "Items per page must be a positive integer"),
+ total_pages: z.number().int().min(0, "Total pages must be a non-negative integer"),
+ total_items: z.number().int().min(0, "Total items must be a non-negative integer"),
+ items: z.array(itemSchema),
+ metadata: MetadataSchema.optional(),
+ });
diff --git a/modules/core/src/common/dto/metadata.dto.ts b/modules/core/src/common/dto/metadata.dto.ts
index 74974baa..bd1d2afd 100644
--- a/modules/core/src/common/dto/metadata.dto.ts
+++ b/modules/core/src/common/dto/metadata.dto.ts
@@ -1,16 +1,28 @@
-export interface IMetadataDTO {
- entity: string;
- version: string;
- [key: string]: any; // <- para campos adicionales futuros
+import * as z from "zod/v4";
- // Futuros campos opcionales que podrían ser útiles:
- // source?: 'api' | 'manual' | 'imported' | string;
- // related_id?: string;
- // related_entity?: string;
- // created_by?: string;
- // created_at?: string;
- // updated_by?: string;
- // updated_at?: string;
- // permissions?: Array<'read' | 'edit' | 'delete' | string>;
- // visibility?: 'public' | 'private' | 'restricted' | string;
-}
+export const MetadataSchema = z
+ .object({
+ entity: z.string(),
+ version: z.string().optional(),
+ })
+ .catchall(z.any());
+
+export type MetadataDTO = z.infer;
+
+// Ejemplo de uso:
+// const metadata: IMetadataDTO = {
+// entity: "customer_invoice",
+// version: "1.0",
+// custom_field: "value",
+// };
+//
+// Futuros campos opcionales que podrían ser útiles:
+// source?: 'api' | 'manual' | 'imported' | string;
+// related_id?: string;
+// related_entity?: string;
+// created_by?: string;
+// created_at?: string;
+// updated_by?: string;
+// updated_at?: string;
+// permissions?: Array<'read' | 'edit' | 'delete' | string>;
+// visibility?: 'public' | 'private' | 'restricted' | string;
diff --git a/modules/core/src/common/dto/money.dto.ts b/modules/core/src/common/dto/money.dto.ts
index 3d3c1035..1e675f51 100644
--- a/modules/core/src/common/dto/money.dto.ts
+++ b/modules/core/src/common/dto/money.dto.ts
@@ -1,27 +1,5 @@
-import { Result, RuleValidator } from "@repo/rdx-utils";
-import Joi from "joi";
-
-export interface IMoneyDTO {
+export type MoneyDTO = {
amount: number | null;
scale: number;
currency_code: string;
-}
-
-export interface IMoneyRequestDTO extends IMoneyDTO {}
-export interface IMoneyResponseDTO extends IMoneyDTO {}
-
-export function ensureMoneyDTOIsValid(money: IMoneyRequestDTO) {
- const schema = Joi.object({
- amount: Joi.number(),
- scale: Joi.number(),
- currencycode: Joi.string(),
- });
-
- const result = RuleValidator.validate(schema, money);
-
- if (result.isFailure) {
- return Result.fail(result.error);
- }
-
- return Result.ok(true);
-}
+};
diff --git a/modules/core/src/common/dto/percentage.dto.ts b/modules/core/src/common/dto/percentage.dto.ts
index 65d006db..668fd1ca 100644
--- a/modules/core/src/common/dto/percentage.dto.ts
+++ b/modules/core/src/common/dto/percentage.dto.ts
@@ -1,7 +1,4 @@
-export interface IPercentageDTO {
+export type IPercentageDTO = {
amount: number | null;
scale: number;
-}
-
-export interface IPercentageRequestDTO extends IPercentageDTO {}
-export interface IPercentageResponseDTO extends IPercentageDTO {}
+};
diff --git a/modules/core/src/common/dto/quantity.dto.ts b/modules/core/src/common/dto/quantity.dto.ts
index eda16b07..20e75ea7 100644
--- a/modules/core/src/common/dto/quantity.dto.ts
+++ b/modules/core/src/common/dto/quantity.dto.ts
@@ -1,25 +1,4 @@
-import { Result, RuleValidator } from "@repo/rdx-utils";
-import Joi from "joi";
-
-export interface IQuantityDTO {
+export type IQuantityDTO = {
amount: number | null;
scale: number;
-}
-
-export function ensureQuantityDTOIsValid(quantity: IQuantityRequestDTO) {
- const schema = Joi.object({
- amount: Joi.number(),
- scale: Joi.number(),
- });
-
- const result = RuleValidator.validate(schema, quantity);
-
- if (result.isFailure) {
- return Result.fail(result.error);
- }
-
- return Result.ok(true);
-}
-
-export interface IQuantityRequestDTO extends IQuantityDTO {}
-export interface IQuantityResponseDTO extends IQuantityDTO {}
+};
diff --git a/modules/core/src/web/lib/data-source/datasource.interface.ts b/modules/core/src/web/lib/data-source/datasource.interface.ts
index bd17526f..d0a3f3fc 100644
--- a/modules/core/src/web/lib/data-source/datasource.interface.ts
+++ b/modules/core/src/web/lib/data-source/datasource.interface.ts
@@ -14,9 +14,9 @@ export interface ICustomParams {
export interface IDataSource {
getBaseUrl(): string;
- getList(resource: string, params?: Record): Promise;
+ getList(resource: string, params?: Record): Promise;
getOne(resource: string, id: string | number): Promise;
- getMany(resource: string, ids: Array): Promise;
+ getMany(resource: string, ids: Array): Promise;
createOne(resource: string, data: Partial): Promise;
updateOne(resource: string, id: string | number, data: Partial): Promise;
deleteOne(resource: string, id: string | number): Promise;
diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json
index 84b8148f..0af1e0dc 100644
--- a/modules/customer-invoices/package.json
+++ b/modules/customer-invoices/package.json
@@ -18,23 +18,26 @@
},
"dependencies": {
"@erp/core": "workspace:*",
+ "@hookform/resolvers": "^5.0.1",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
- "@repo/rdx-utils": "workspace:*",
"@repo/rdx-ui": "workspace:*",
+ "@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.74.11",
"ag-grid-community": "^33.3.0",
"ag-grid-react": "^33.3.0",
+ "date-fns": "^4.1.0",
"express": "^4.18.2",
"i18next": "^25.1.1",
"lucide-react": "^0.503.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0",
"sequelize": "^6.37.5",
"slugify": "^1.6.6",
- "zod": "^3.24.4"
+ "zod": "^3.25.67"
}
}
diff --git a/modules/customer-invoices/src/api/application/create-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/create-customer-invoice/create-customer-invoice.use-case.ts
similarity index 83%
rename from modules/customer-invoices/src/api/application/create-customer-invoice.use-case.ts
rename to modules/customer-invoices/src/api/application/create-customer-invoice/create-customer-invoice.use-case.ts
index 82d51037..cdf07a76 100644
--- a/modules/customer-invoices/src/api/application/create-customer-invoice.use-case.ts
+++ b/modules/customer-invoices/src/api/application/create-customer-invoice/create-customer-invoice.use-case.ts
@@ -1,17 +1,8 @@
-import { UniqueID, UtcDate } from "@/core/common/domain";
-
-import {
- type CustomerInvoice,
- CustomerInvoiceNumber,
- CustomerInvoiceSerie,
- CustomerInvoiceStatus,
- type ICustomerInvoiceProps,
- type ICustomerInvoiceService,
-} from "@/contexts/customer-invoices/domain";
-import { ITransactionManager } from "@/core/common/infrastructure/database";
-import { logger } from "@/core/logger";
-import { Result } from "@repo/rdx-utils";
+import { ITransactionManager } from "@erp/core/api";
+import { UniqueID } from "@repo/rdx-ddd";
+import { Transaction } from "sequelize";
import { ICreateCustomerInvoiceRequestDTO } from "../../common/dto";
+import { ICustomerInvoiceProps, ICustomerInvoiceService } from "../domain";
export class CreateCustomerInvoiceUseCase {
constructor(
@@ -19,23 +10,28 @@ export class CreateCustomerInvoiceUseCase {
private readonly transactionManager: ITransactionManager
) {}
- public execute(
- customerInvoiceID: UniqueID,
- dto: ICreateCustomerInvoiceRequestDTO
- ): Promise> {
- return this.transactionManager.complete(async (transaction) => {
+ public execute(customerInvoiceID: UniqueID, data: ICreateCustomerInvoiceRequestDTO) {
+ return this.transactionManager.complete(async (transaction: Transaction) => {
try {
- const validOrErrors = this.validateCustomerInvoiceData(dto);
+ /*const validOrErrors = this.validateCustomerInvoiceData(dto);
if (validOrErrors.isFailure) {
return Result.fail(validOrErrors.error);
}
- const data = validOrErrors.data;
+ const data = validOrErrors.data;*/
+
+ const invoiceProps: ICustomerInvoiceProps = {
+ customerInvoiceNumber: data.customerInvoice_number,
+ customerInvoiceSeries: data.customerInvoice_series,
+ issueDate: data.issue_date,
+ operationDate: data.operation_date,
+ customerInvoiceCurrency: data.currency,
+ };
// Update customerInvoice with dto
return await this.customerInvoiceService.createCustomerInvoice(
customerInvoiceID,
- data,
+ invoiceProps,
transaction
);
} catch (error: unknown) {
diff --git a/modules/customer-invoices/src/api/application/create-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/create-customer-invoice/index.ts
new file mode 100644
index 00000000..e92f8281
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/create-customer-invoice/index.ts
@@ -0,0 +1 @@
+export * from "./create-customer-invoice.use-case";
diff --git a/modules/customer-invoices/src/api/application/delete-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/delete-customer-invoice/delete-customer-invoice.use-case.ts
similarity index 100%
rename from modules/customer-invoices/src/api/application/delete-customer-invoice.use-case.ts
rename to modules/customer-invoices/src/api/application/delete-customer-invoice/delete-customer-invoice.use-case.ts
diff --git a/modules/customer-invoices/src/api/application/delete-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/delete-customer-invoice/index.ts
new file mode 100644
index 00000000..abc66bde
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/delete-customer-invoice/index.ts
@@ -0,0 +1 @@
+export * from "./delete-customer-invoice.use-case";
diff --git a/modules/customer-invoices/src/api/application/get-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/get-customer-invoice/get-customer-invoice.use-case.ts
similarity index 100%
rename from modules/customer-invoices/src/api/application/get-customer-invoice.use-case.ts
rename to modules/customer-invoices/src/api/application/get-customer-invoice/get-customer-invoice.use-case.ts
diff --git a/modules/customer-invoices/src/api/application/get-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/get-customer-invoice/index.ts
new file mode 100644
index 00000000..960446a8
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/get-customer-invoice/index.ts
@@ -0,0 +1 @@
+export * from "./get-customer-invoice.use-case";
diff --git a/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-from-dto.ts b/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-from-dto.ts
new file mode 100644
index 00000000..ab73e6c5
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-from-dto.ts
@@ -0,0 +1,54 @@
+import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
+import { UniqueID, UtcDate } from "@repo/rdx-ddd";
+import { Result } from "@repo/rdx-utils";
+import { CreateCustomerInvoiceCommandDTO } from "../../../common/dto";
+import { CustomerInvoiceNumber, CustomerInvoiceProps, CustomerInvoiceSerie } from "../../domain";
+import { buildInvoiceItemsFromDTO } from "./build-customer-invoice-items-from-dto";
+import { extractOrPushError } from "./extract-or-push-error";
+
+export async function buildInvoiceFromDTO(
+ dto: CreateCustomerInvoiceCommandDTO
+): Promise> {
+ const errors: ValidationErrorDetail[] = [];
+
+ const invoiceNumber = extractOrPushError(
+ CustomerInvoiceNumber.create(dto.invoice_number),
+ "invoice_number",
+ errors
+ );
+ const invoiceSeries = extractOrPushError(
+ CustomerInvoiceSerie.create(dto.invoice_series),
+ "invoice_series",
+ errors
+ );
+ const issueDate = extractOrPushError(UtcDate.createFromISO(dto.issue_date), "issue_date", errors);
+ const operationDate = extractOrPushError(
+ UtcDate.createFromISO(dto.operation_date),
+ "operation_date",
+ errors
+ );
+
+ // 🔄 Validar y construir los items de factura con helper especializado
+ const itemsResult = await buildInvoiceItemsFromDTO(dto.items);
+ if (itemsResult.isFailure) {
+ return Result.fail(itemsResult.error);
+ }
+
+ if (errors.length > 0) {
+ return Result.fail(new ValidationErrorCollection(errors));
+ }
+
+ return Result.ok({
+ id: UniqueID.create(),
+ customerId: customerId.data,
+ invoiceNumber: invoiceNumber.data,
+ invoiceSeries: invoiceSeries.data,
+ issueDate: issueDate.data,
+ operationDate: operationDate.data,
+ subtotalPrice: subtotalPrice.data,
+ discount: discount.data,
+ tax: tax.data,
+ totalAmount: totalAmount.data,
+ lines: itemsResult.data,
+ });
+}
diff --git a/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-items-from-dto.ts b/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-items-from-dto.ts
new file mode 100644
index 00000000..8e087c4b
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/helpers/build-customer-invoice-items-from-dto.ts
@@ -0,0 +1,83 @@
+import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
+import { CreateCustomerInvoiceCommandDTO } from "@erp/customer-invoices/common/dto";
+import { Result } from "@repo/rdx-utils";
+import {
+ CustomerInvoiceItem,
+ CustomerInvoiceItemDescription,
+ CustomerInvoiceItemDiscount,
+ CustomerInvoiceItemQuantity,
+ CustomerInvoiceItemUnitPrice,
+} from "../../domain";
+import { extractOrPushError } from "./extract-or-push-error";
+import { hasNoUndefinedFields } from "./has-no-undefined-fields";
+
+export function buildInvoiceItemsFromDTO(
+ dtoItems: Pick["items"]
+): Result {
+ const errors: ValidationErrorDetail[] = [];
+ const items: CustomerInvoiceItem[] = [];
+
+ dtoItems.forEach((item, index) => {
+ const path = (field: string) => `items[${index}].${field}`;
+
+ const description = extractOrPushError(
+ CustomerInvoiceItemDescription.create(item.description),
+ path("description"),
+ errors
+ );
+
+ const quantity = extractOrPushError(
+ CustomerInvoiceItemQuantity.create({
+ amount: item.quantity.amount,
+ scale: item.quantity.scale,
+ }),
+ path("quantity"),
+ errors
+ );
+
+ const unitPrice = extractOrPushError(
+ CustomerInvoiceItemUnitPrice.create({
+ amount: item.unitPrice.amount,
+ scale: item.unitPrice.scale,
+ currency_code: item.unitPrice.currency,
+ }),
+ path("unit_price"),
+ errors
+ );
+
+ const discount = extractOrPushError(
+ CustomerInvoiceItemDiscount.create({
+ amount: item.discount.amount,
+ scale: item.discount.scale,
+ }),
+ path("discount"),
+ errors
+ );
+
+ if (errors.length === 0) {
+ const itemProps = {
+ description: description,
+ quantity: quantity,
+ unitPrice: unitPrice,
+ discount: discount,
+ };
+
+ if (hasNoUndefinedFields(itemProps)) {
+ // Validar y crear el item de factura
+ const itemOrError = CustomerInvoiceItem.create(itemProps);
+
+ if (itemOrError.isSuccess) {
+ items.push(itemOrError.data);
+ } else {
+ errors.push({ path: `items[${index}]`, message: itemOrError.error.message });
+ }
+ }
+ }
+
+ if (errors.length > 0) {
+ return Result.fail(new ValidationErrorCollection(errors));
+ }
+ });
+
+ return Result.ok(items);
+}
diff --git a/modules/customer-invoices/src/api/application/helpers/extract-or-push-error.ts b/modules/customer-invoices/src/api/application/helpers/extract-or-push-error.ts
new file mode 100644
index 00000000..809eb671
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/helpers/extract-or-push-error.ts
@@ -0,0 +1,45 @@
+import { DomainValidationError, ValidationErrorDetail } from "@erp/core/api";
+import { Result } from "@repo/rdx-utils";
+
+/**
+ * Extrae un valor de un Result si es válido.
+ * Si es un fallo, agrega un ValidationErrorDetail al array proporcionado.
+ * @param result - El resultado a evaluar.
+ * @param path - La ruta del error para el detalle de validación.
+ * @param errors - El array donde se agregarán los errores de validación.
+ * @returns El valor extraído si el resultado es exitoso, o undefined si es un fallo.
+ * @template T - El tipo de dato esperado en el resultado exitoso.
+ * @throws {Error} Si el resultado es un fallo y no es una instancia de DomainValidationError.
+ * @example
+ * const result = Result.ok(42);
+ * const value = extractOrPushError(result, 'some.path', []);
+ * console.log(value); // 42
+ * const errorResult = Result.fail(new Error('Something went wrong'));
+ * const value = extractOrPushError(errorResult, 'some.path', []);
+ * console.log(value); // undefined
+ * // errors will contain [{ path: 'some.path', message: 'Something went wrong' }]
+ *
+ * @see Result
+ * @see DomainValidationError
+ * @see ValidationErrorDetail
+
+ */
+export function extractOrPushError(
+ result: Result,
+ path: string,
+ errors: ValidationErrorDetail[]
+): T | undefined {
+ if (result.isFailure) {
+ const error = result.error;
+
+ if (error instanceof DomainValidationError) {
+ errors.push({ path, message: error.detail });
+ } else {
+ errors.push({ path, message: error.message });
+ }
+
+ return undefined;
+ }
+
+ return result.data;
+}
diff --git a/modules/customer-invoices/src/api/application/helpers/has-no-undefined-fields.ts b/modules/customer-invoices/src/api/application/helpers/has-no-undefined-fields.ts
new file mode 100644
index 00000000..d6a2ff3f
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/helpers/has-no-undefined-fields.ts
@@ -0,0 +1,26 @@
+/**
+ *
+ * @param obj - El objeto a evaluar.
+ * @template T - El tipo del objeto.
+ * @description Verifica si un objeto no tiene campos con valor undefined.
+ *
+ * Esta función recorre los valores del objeto y devuelve true si todos los valores son diferentes de undefined.
+ * Si al menos un valor es undefined, devuelve false.
+ *
+ * @example
+ * const obj = { a: 1, b: 'test', c: null };
+ * console.log(hasNoUndefinedFields(obj)); // true
+ *
+ * const objWithUndefined = { a: 1, b: undefined, c: null };
+ * console.log(hasNoUndefinedFields(objWithUndefined)); // false
+ *
+ * @template T - El tipo del objeto.
+ * @param obj - El objeto a evaluar.
+ * @returns true si el objeto no tiene campos undefined, false en caso contrario.
+ */
+
+export function hasNoUndefinedFields>(
+ obj: T
+): obj is { [K in keyof T]-?: Exclude } {
+ return Object.values(obj).every((value) => value !== undefined);
+}
diff --git a/modules/customer-invoices/src/api/application/helpers/index.ts b/modules/customer-invoices/src/api/application/helpers/index.ts
new file mode 100644
index 00000000..711ba4b1
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/helpers/index.ts
@@ -0,0 +1 @@
+export * from "./build-customer-invoice-from-dto";
diff --git a/modules/customer-invoices/src/api/application/index.ts b/modules/customer-invoices/src/api/application/index.ts
index 0b228bae..7401f0ba 100644
--- a/modules/customer-invoices/src/api/application/index.ts
+++ b/modules/customer-invoices/src/api/application/index.ts
@@ -1,5 +1,5 @@
-//export * from "./create-customer-invoice.use-case";
-//export * from "./delete-customer-invoice.use-case";
-export * from "./get-customer-invoice.use-case";
-export * from "./list-customer-invoices.use-case";
-//export * from "./update-customer-invoice.use-case";
+//export * from "./create-customer-invoice";
+//export * from "./delete-customer-invoice";
+//export * from "./get-customer-invoice";
+export * from "./list-customer-invoices";
+//export * from "./update-customer-invoice";
diff --git a/modules/customer-invoices/src/api/application/list-customer-invoices.use-case.ts b/modules/customer-invoices/src/api/application/list-customer-invoices.use-case.ts
deleted file mode 100644
index 88416d6e..00000000
--- a/modules/customer-invoices/src/api/application/list-customer-invoices.use-case.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { ITransactionManager } from "@erp/core/api";
-import { Criteria } from "@repo/rdx-criteria/server";
-import { Collection, Result } from "@repo/rdx-utils";
-import { Transaction } from "sequelize";
-import { CustomerInvoice, ICustomerInvoiceService } from "../domain";
-
-export class ListCustomerInvoicesUseCase {
- constructor(
- private readonly customerInvoiceService: ICustomerInvoiceService,
- private readonly transactionManager: ITransactionManager
- ) {}
-
- public execute(criteria: Criteria): Promise, Error>> {
- return this.transactionManager.complete(async (transaction: Transaction) => {
- try {
- return await this.customerInvoiceService.findCustomerInvoices(criteria, transaction);
- } catch (error: unknown) {
- return Result.fail(error as Error);
- }
- });
- }
-}
diff --git a/modules/customer-invoices/src/api/application/list-customer-invoices/index.ts b/modules/customer-invoices/src/api/application/list-customer-invoices/index.ts
new file mode 100644
index 00000000..af0c7f6e
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/list-customer-invoices/index.ts
@@ -0,0 +1 @@
+export * from "./list-customer-invoices.use-case";
diff --git a/modules/customer-invoices/src/api/application/list-customer-invoices/list-customer-invoices.use-case.ts b/modules/customer-invoices/src/api/application/list-customer-invoices/list-customer-invoices.use-case.ts
new file mode 100644
index 00000000..feb17b27
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/list-customer-invoices/list-customer-invoices.use-case.ts
@@ -0,0 +1,35 @@
+import { ITransactionManager } from "@erp/core/api";
+import { ListCustomerInvoicesResultDTO } from "@erp/customer-invoices/common/dto";
+import { Criteria } from "@repo/rdx-criteria/server";
+import { Result } from "@repo/rdx-utils";
+import { Transaction } from "sequelize";
+import { ICustomerInvoiceService } from "../../domain";
+import { ListCustomerInvoicesPresenter } from "./presenter";
+
+export class ListCustomerInvoicesUseCase {
+ constructor(
+ private readonly customerInvoiceService: ICustomerInvoiceService,
+ private readonly transactionManager: ITransactionManager,
+ private readonly presenter: ListCustomerInvoicesPresenter
+ ) {}
+
+ public execute(criteria: Criteria): Promise> {
+ return this.transactionManager.complete(async (transaction: Transaction) => {
+ try {
+ const result = await this.customerInvoiceService.findCustomerInvoices(
+ criteria,
+ transaction
+ );
+
+ if (result.isFailure) {
+ return Result.fail(result.error);
+ }
+
+ const dto: ListCustomerInvoicesResultDTO = this.presenter.toDTO(result.data, criteria);
+ return Result.ok(dto);
+ } catch (error: unknown) {
+ return Result.fail(error as Error);
+ }
+ });
+ }
+}
diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/InvoiceParticipant.presenter.ts.bak b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/InvoiceParticipant.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/list-invoices/presenter/InvoiceParticipant.presenter.ts.bak
rename to modules/customer-invoices/src/api/application/list-customer-invoices/presenter/InvoiceParticipant.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/InvoiceParticipantAddress.presenter.ts.bak b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/InvoiceParticipantAddress.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/list-invoices/presenter/InvoiceParticipantAddress.presenter.ts.bak
rename to modules/customer-invoices/src/api/application/list-customer-invoices/presenter/InvoiceParticipantAddress.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/index.ts b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/index.ts
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/list-invoices/presenter/index.ts
rename to modules/customer-invoices/src/api/application/list-customer-invoices/presenter/index.ts
diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/list-invoices.presenter.ts b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/list-invoices.presenter.ts
similarity index 59%
rename from modules/customer-invoices/src/api/presentation/list-invoices/presenter/list-invoices.presenter.ts
rename to modules/customer-invoices/src/api/application/list-customer-invoices/presenter/list-invoices.presenter.ts
index b83cf2f9..b28048f1 100644
--- a/modules/customer-invoices/src/api/presentation/list-invoices/presenter/list-invoices.presenter.ts
+++ b/modules/customer-invoices/src/api/application/list-customer-invoices/presenter/list-invoices.presenter.ts
@@ -1,21 +1,20 @@
-import { IListResponseDTO } from "@erp/core";
+import { ListCustomerInvoicesViewDTO } from "@erp/customer-invoices/common/dto";
import { Criteria } from "@repo/rdx-criteria/server";
import { Collection } from "@repo/rdx-utils";
-import { IListCustomerInvoicesResponseDTO } from "../../../../common/dto";
import { CustomerInvoice } from "../../../domain";
-export interface IListCustomerInvoicesPresenter {
+export interface ListCustomerInvoicesPresenter {
toDTO: (
customerInvoices: Collection,
criteria: Criteria
- ) => IListResponseDTO;
+ ) => ListCustomerInvoicesViewDTO;
}
-export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
+export const listCustomerInvoicesPresenter: ListCustomerInvoicesPresenter = {
toDTO: (
customerInvoices: Collection,
criteria: Criteria
- ): IListResponseDTO => {
+ ): ListCustomerInvoicesViewDTO => {
const items = customerInvoices.map((invoice) => {
return {
id: invoice.id.toPrimitive(),
@@ -26,17 +25,17 @@ export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
issue_date: invoice.issueDate.toISOString(),
operation_date: invoice.operationDate.toISOString(),
language_code: "ES",
-
currency: invoice.customerInvoiceCurrency.toString(),
- subtotal: invoice.calculateSubtotal().toPrimitive(),
- total: invoice.calculateTotal().toPrimitive(),
+
+ subtotal_price: invoice.calculateSubtotal().toPrimitive(),
+ total_price: invoice.calculateTotal().toPrimitive(),
//recipient: CustomerInvoiceParticipantPresenter(customerInvoice.recipient),
metadata: {
entity: "customer-invoice",
},
- } as IListCustomerInvoicesResponseDTO;
+ };
});
const totalItems = customerInvoices.total();
@@ -47,6 +46,15 @@ export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
total_pages: Math.ceil(totalItems / criteria.pageSize),
total_items: totalItems,
items: items,
+ metadata: {
+ entity: "customer-invoices",
+ criteria: criteria.toJSON(),
+ links: {
+ self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,
+ first: `/api/customer-invoices?page=1&per_page=${criteria.pageSize}`,
+ last: `/api/customer-invoices?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`,
+ },
+ },
};
},
};
diff --git a/modules/customer-invoices/src/api/application/update-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/update-customer-invoice/index.ts
new file mode 100644
index 00000000..002aceac
--- /dev/null
+++ b/modules/customer-invoices/src/api/application/update-customer-invoice/index.ts
@@ -0,0 +1 @@
+export * from "./update-customer-invoice.use-case";
diff --git a/modules/customer-invoices/src/api/application/update-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/update-customer-invoice/update-customer-invoice.use-case.ts
similarity index 100%
rename from modules/customer-invoices/src/api/application/update-customer-invoice.use-case.ts
rename to modules/customer-invoices/src/api/application/update-customer-invoice/update-customer-invoice.use-case.ts
diff --git a/modules/customer-invoices/src/api/controllers/create-customer-invoice/create-customer-invoice.ts b/modules/customer-invoices/src/api/controllers/create-customer-invoice/create-customer-invoice.ts
new file mode 100644
index 00000000..242fd04c
--- /dev/null
+++ b/modules/customer-invoices/src/api/controllers/create-customer-invoice/create-customer-invoice.ts
@@ -0,0 +1,39 @@
+import { ExpressController, errorMapper } from "@erp/core/api";
+import { CreateCustomerInvoiceCommandDTO } from "../../../common/dto";
+
+export class CreateCustomerInvoiceController extends ExpressController {
+ public constructor(
+ private readonly createCustomerInvoice: any, // Replace with actual type
+ private readonly presenter: any // Replace with actual type
+ ) {
+ super();
+ }
+
+ protected async executeImpl() {
+ const dto = this.req.body as CreateCustomerInvoiceCommandDTO;
+ /*
+ const user = this.req.user; // asumimos middleware authenticateJWT inyecta user
+
+ if (!user || !user.companyId) {
+ this.unauthorized(res, "Unauthorized: user or company not found");
+ return;
+ }
+
+ // Inyectar empresa del usuario autenticado (ownership)
+ dto.customerCompanyId = user.companyId;
+ */
+
+ const result = await this.createCustomerInvoice.execute(dto);
+
+ if (result.isFailure) {
+ /*if (error instanceof AggregatedValidationError) {
+ return this.invalidInputError(error.message, error.details);
+ }*/
+
+ const apiError = errorMapper.toApiError(result.error);
+ return this.handleApiError(apiError);
+ }
+
+ return this.created(this.presenter.toDTO(result.data));
+ }
+}
diff --git a/modules/customer-invoices/src/api/controllers/create-customer-invoice/index.ts b/modules/customer-invoices/src/api/controllers/create-customer-invoice/index.ts
new file mode 100644
index 00000000..aba6da9a
--- /dev/null
+++ b/modules/customer-invoices/src/api/controllers/create-customer-invoice/index.ts
@@ -0,0 +1 @@
+export * from "./create-customer-invoice";
diff --git a/modules/customer-invoices/src/api/presentation/delete-invoice/delete-invoice.controller.ts.bak b/modules/customer-invoices/src/api/controllers/delete-customer-invoice/delete-invoice.controller.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/delete-invoice/delete-invoice.controller.ts.bak
rename to modules/customer-invoices/src/api/controllers/delete-customer-invoice/delete-invoice.controller.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/delete-invoice/index.ts.bak b/modules/customer-invoices/src/api/controllers/delete-customer-invoice/index.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/delete-invoice/index.ts.bak
rename to modules/customer-invoices/src/api/controllers/delete-customer-invoice/index.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/get-invoice.controller.ts b/modules/customer-invoices/src/api/controllers/get-customer-invoice/get-invoice.controller.ts
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/get-invoice/get-invoice.controller.ts
rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/get-invoice.controller.ts
diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/index.ts b/modules/customer-invoices/src/api/controllers/get-customer-invoice/index.ts
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/get-invoice/index.ts
rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/index.ts
diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceItem.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceItem.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceItem.presenter.ts.bak
rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceItem.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceParticipant.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceParticipant.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceParticipant.presenter.ts.bak
rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceParticipant.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak
rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/get-invoice.presenter.ts b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/get-invoice.presenter.ts
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/get-invoice.presenter.ts
rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/get-invoice.presenter.ts
diff --git a/modules/customer-invoices/src/api/presentation/get-invoice/presenter/index.ts b/modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/index.ts
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/get-invoice/presenter/index.ts
rename to modules/customer-invoices/src/api/controllers/get-customer-invoice/presenter/index.ts
diff --git a/modules/customer-invoices/src/api/controllers/index.ts b/modules/customer-invoices/src/api/controllers/index.ts
new file mode 100644
index 00000000..bf49ebc7
--- /dev/null
+++ b/modules/customer-invoices/src/api/controllers/index.ts
@@ -0,0 +1,5 @@
+export * from "./create-customer-invoice";
+//export * from "./delete-customer-invoice";
+export * from "./get-customer-invoice";
+export * from "./list-customer-invoices";
+///export * from "./update-customer-invoice";
diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/index.ts b/modules/customer-invoices/src/api/controllers/list-customer-invoices/index.ts
similarity index 66%
rename from modules/customer-invoices/src/api/presentation/list-invoices/index.ts
rename to modules/customer-invoices/src/api/controllers/list-customer-invoices/index.ts
index 69d7b827..cc89304f 100644
--- a/modules/customer-invoices/src/api/presentation/list-invoices/index.ts
+++ b/modules/customer-invoices/src/api/controllers/list-customer-invoices/index.ts
@@ -1,18 +1,22 @@
import { SequelizeTransactionManager } from "@erp/core/api";
import { Sequelize } from "sequelize";
import { ListCustomerInvoicesUseCase } from "../../application";
+import { listCustomerInvoicesPresenter } from "../../application/list-customer-invoices/presenter";
import { CustomerInvoiceService } from "../../domain";
import { CustomerInvoiceRepository, customerInvoiceMapper } from "../../infrastructure";
-import { ListCustomerInvoicesController } from "./list-invoices.controller";
-import { listCustomerInvoicesPresenter } from "./presenter";
+import { ListCustomerInvoicesController } from "./list-customer-invoices.controller";
export const buildListCustomerInvoicesController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const customerInvoiceRepository = new CustomerInvoiceRepository(database, customerInvoiceMapper);
const customerInvoiceService = new CustomerInvoiceService(customerInvoiceRepository);
-
- const useCase = new ListCustomerInvoicesUseCase(customerInvoiceService, transactionManager);
const presenter = listCustomerInvoicesPresenter;
- return new ListCustomerInvoicesController(useCase, presenter);
+ const useCase = new ListCustomerInvoicesUseCase(
+ customerInvoiceService,
+ transactionManager,
+ presenter
+ );
+
+ return new ListCustomerInvoicesController(useCase);
};
diff --git a/modules/customer-invoices/src/api/controllers/list-customer-invoices/list-customer-invoices.controller.ts b/modules/customer-invoices/src/api/controllers/list-customer-invoices/list-customer-invoices.controller.ts
new file mode 100644
index 00000000..6c2f8715
--- /dev/null
+++ b/modules/customer-invoices/src/api/controllers/list-customer-invoices/list-customer-invoices.controller.ts
@@ -0,0 +1,37 @@
+import { ExpressController, errorMapper } from "@erp/core/api";
+import { ListCustomerInvoicesUseCase } from "../../application";
+
+export class ListCustomerInvoicesController extends ExpressController {
+ public constructor(private readonly listCustomerInvoices: ListCustomerInvoicesUseCase) {
+ super();
+ }
+
+ protected async executeImpl() {
+ const criteria = this.criteria;
+
+ /*
+ const user = this.req.user; // asumimos middleware authenticateJWT inyecta user
+
+ if (!user || !user.companyId) {
+ this.unauthorized(res, "Unauthorized: user or company not found");
+ return;
+ }
+
+ // Inyectar empresa del usuario autenticado (ownership)
+ this.criteria.addFilter("companyId", "=", companyId);
+ */
+
+ const result = await this.listCustomerInvoices.execute(criteria);
+
+ if (result.isFailure) {
+ /*if (error instanceof AggregatedValidationError) {
+ return this.invalidInputError(error.message, error.details);
+ }*/
+
+ const apiError = errorMapper.toApiError(result.error);
+ return this.handleApiError(apiError);
+ }
+
+ return this.ok(result.data);
+ }
+}
diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/index.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/index.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/update-invoice/index.ts.bak
rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/index.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceItem.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceItem.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceItem.presenter.ts.bak
rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceItem.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceParticipant.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceParticipant.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceParticipant.presenter.ts.bak
rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceParticipant.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak
rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/InvoiceParticipantAddress.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/UpdateInvoice.presenter.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/UpdateInvoice.presenter.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/UpdateInvoice.presenter.ts.bak
rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/UpdateInvoice.presenter.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/presenter/index.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/index.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/update-invoice/presenter/index.ts.bak
rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/presenter/index.ts.bak
diff --git a/modules/customer-invoices/src/api/presentation/update-invoice/update-invoice.controller.ts.bak b/modules/customer-invoices/src/api/controllers/update-customer-invoice/update-invoice.controller.ts.bak
similarity index 100%
rename from modules/customer-invoices/src/api/presentation/update-invoice/update-invoice.controller.ts.bak
rename to modules/customer-invoices/src/api/controllers/update-customer-invoice/update-invoice.controller.ts.bak
diff --git a/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts b/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts
index 6c2fc7e7..4b02e64e 100644
--- a/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts
+++ b/modules/customer-invoices/src/api/domain/aggregates/customer-invoice.ts
@@ -7,7 +7,7 @@ import {
CustomerInvoiceStatus,
} from "../value-objects";
-export interface ICustomerInvoiceProps {
+export interface CustomerInvoiceProps {
customerInvoiceNumber: CustomerInvoiceNumber;
customerInvoiceSeries: CustomerInvoiceSerie;
@@ -69,19 +69,19 @@ export interface ICustomerInvoice {
}
export class CustomerInvoice
- extends AggregateRoot
+ extends AggregateRoot
implements ICustomerInvoice
{
private _items!: Collection;
//protected _status: CustomerInvoiceStatus;
- protected constructor(props: ICustomerInvoiceProps, id?: UniqueID) {
+ protected constructor(props: CustomerInvoiceProps, id?: UniqueID) {
super(props, id);
this._items = props.items || CustomerInvoiceItems.create();
}
- static create(props: ICustomerInvoiceProps, id?: UniqueID): Result {
+ static create(props: CustomerInvoiceProps, id?: UniqueID): Result {
const customerInvoice = new CustomerInvoice(props, id);
// Reglas de negocio / validaciones
diff --git a/modules/customer-invoices/src/api/domain/errors/index.ts b/modules/customer-invoices/src/api/domain/errors/index.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/customer-invoices/src/api/domain/index.ts b/modules/customer-invoices/src/api/domain/index.ts
index 2c5c423d..ed8d70d5 100644
--- a/modules/customer-invoices/src/api/domain/index.ts
+++ b/modules/customer-invoices/src/api/domain/index.ts
@@ -1,5 +1,6 @@
export * from "./aggregates";
export * from "./entities";
+export * from "./errors";
export * from "./repositories";
export * from "./services";
export * from "./value-objects";
diff --git a/modules/customer-invoices/src/api/domain/services/customer-invoice-service.interface.ts b/modules/customer-invoices/src/api/domain/services/customer-invoice-service.interface.ts
index 80f71b9b..50f66775 100644
--- a/modules/customer-invoices/src/api/domain/services/customer-invoice-service.interface.ts
+++ b/modules/customer-invoices/src/api/domain/services/customer-invoice-service.interface.ts
@@ -1,7 +1,7 @@
import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
-import { CustomerInvoice, ICustomerInvoiceProps } from "../aggregates";
+import { CustomerInvoice, CustomerInvoiceProps } from "../aggregates";
export interface ICustomerInvoiceService {
findCustomerInvoices(
@@ -15,13 +15,13 @@ export interface ICustomerInvoiceService {
updateCustomerInvoiceById(
customerInvoiceId: UniqueID,
- data: Partial,
+ data: Partial,
transaction?: any
): Promise>;
createCustomerInvoice(
customerInvoiceId: UniqueID,
- data: ICustomerInvoiceProps,
+ data: CustomerInvoiceProps,
transaction?: any
): Promise>;
diff --git a/modules/customer-invoices/src/api/domain/services/customer-invoice.service.ts b/modules/customer-invoices/src/api/domain/services/customer-invoice.service.ts
index d9111248..f9b1a42b 100644
--- a/modules/customer-invoices/src/api/domain/services/customer-invoice.service.ts
+++ b/modules/customer-invoices/src/api/domain/services/customer-invoice.service.ts
@@ -2,7 +2,7 @@ import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
-import { CustomerInvoice, ICustomerInvoiceProps } from "../aggregates";
+import { CustomerInvoice, CustomerInvoiceProps } from "../aggregates";
import { ICustomerInvoiceRepository } from "../repositories";
import { ICustomerInvoiceService } from "./customer-invoice-service.interface";
@@ -34,7 +34,7 @@ export class CustomerInvoiceService implements ICustomerInvoiceService {
async updateCustomerInvoiceById(
customerInvoiceId: UniqueID,
- data: Partial,
+ data: Partial,
transaction?: Transaction
): Promise> {
// Verificar si la factura existe
@@ -60,7 +60,7 @@ export class CustomerInvoiceService implements ICustomerInvoiceService {
async createCustomerInvoice(
customerInvoiceId: UniqueID,
- data: ICustomerInvoiceProps,
+ data: CustomerInvoiceProps,
transaction?: Transaction
): Promise> {
// Verificar si la factura existe
diff --git a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-item-description.ts b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-item-description.ts
index b046526b..5f1131c2 100644
--- a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-item-description.ts
+++ b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-item-description.ts
@@ -1,6 +1,7 @@
+import { DomainValidationError } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
-import { z } from "zod";
+import * as z from "zod/v4";
interface ICustomerInvoiceItemDescriptionProps {
value: string;
@@ -8,6 +9,8 @@ interface ICustomerInvoiceItemDescriptionProps {
export class CustomerInvoiceItemDescription extends ValueObject {
private static readonly MAX_LENGTH = 255;
+ private static readonly FIELD = "invoiceItemDescription";
+ private static readonly ERROR_CODE = "INVALID_INVOICE_ITEM_DESCRIPTION";
protected static validate(value: string) {
const schema = z
@@ -20,10 +23,17 @@ export class CustomerInvoiceItemDescription extends ValueObject {
private static readonly MAX_LENGTH = 255;
+ private static readonly FIELD = "invoiceNumber";
+ private static readonly ERROR_CODE = "INVALID_INVOICE_NUMBER";
protected static validate(value: string) {
const schema = z
.string()
.trim()
.max(CustomerInvoiceNumber.MAX_LENGTH, {
- message: `Name must be at most ${CustomerInvoiceNumber.MAX_LENGTH} characters long`,
+ message: `String must be at most ${CustomerInvoiceNumber.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string) {
- const valueIsValid = CustomerInvoiceNumber.validate(value);
+ const result = CustomerInvoiceNumber.validate(value);
- if (!valueIsValid.success) {
- return Result.fail(new Error(valueIsValid.error.errors[0].message));
+ if (!result.success) {
+ const detail = result.error.message;
+ return Result.fail(
+ new DomainValidationError(
+ CustomerInvoiceNumber.ERROR_CODE,
+ CustomerInvoiceNumber.FIELD,
+ detail
+ )
+ );
}
return Result.ok(new CustomerInvoiceNumber({ value }));
}
diff --git a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-serie.ts b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-serie.ts
index bad007f5..aa38a42d 100644
--- a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-serie.ts
+++ b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-serie.ts
@@ -1,6 +1,7 @@
+import { DomainValidationError } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
-import { z } from "zod";
+import * as z from "zod/v4";
interface ICustomerInvoiceSerieProps {
value: string;
@@ -8,22 +9,31 @@ interface ICustomerInvoiceSerieProps {
export class CustomerInvoiceSerie extends ValueObject {
private static readonly MAX_LENGTH = 255;
+ private static readonly FIELD = "invoiceSeries";
+ private static readonly ERROR_CODE = "INVALID_INVOICE_SERIE";
protected static validate(value: string) {
const schema = z
.string()
.trim()
.max(CustomerInvoiceSerie.MAX_LENGTH, {
- message: `Name must be at most ${CustomerInvoiceSerie.MAX_LENGTH} characters long`,
+ message: `String must be at most ${CustomerInvoiceSerie.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string) {
- const valueIsValid = CustomerInvoiceSerie.validate(value);
+ const result = CustomerInvoiceSerie.validate(value);
- if (!valueIsValid.success) {
- return Result.fail(new Error(valueIsValid.error.errors[0].message));
+ if (!result.success) {
+ const detail = result.error.message;
+ return Result.fail(
+ new DomainValidationError(
+ CustomerInvoiceSerie.ERROR_CODE,
+ CustomerInvoiceSerie.FIELD,
+ detail
+ )
+ );
}
return Result.ok(new CustomerInvoiceSerie({ value }));
}
diff --git a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts
index 7524df11..3ea0d979 100644
--- a/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts
+++ b/modules/customer-invoices/src/api/domain/value-objects/customer-invoice-status.ts
@@ -1,3 +1,4 @@
+import { DomainValidationError } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
@@ -13,6 +14,8 @@ export enum INVOICE_STATUS {
}
export class CustomerInvoiceStatus extends ValueObject {
private static readonly ALLOWED_STATUSES = ["draft", "emitted", "sent", "rejected"];
+ private static readonly FIELD = "invoiceStatus";
+ private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS";
private static readonly TRANSITIONS: Record = {
draft: [INVOICE_STATUS.EMITTED],
@@ -23,7 +26,14 @@ export class CustomerInvoiceStatus extends ValueObject {
if (!CustomerInvoiceStatus.ALLOWED_STATUSES.includes(value)) {
- return Result.fail(new Error(`Estado de la factura no válido: ${value}`));
+ const detail = `Estado de la factura no válido: ${value}`;
+ return Result.fail(
+ new DomainValidationError(
+ CustomerInvoiceStatus.ERROR_CODE,
+ CustomerInvoiceStatus.FIELD,
+ detail
+ )
+ );
}
return Result.ok(
diff --git a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts
index 03513de7..e6634c79 100644
--- a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts
+++ b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts
@@ -1,7 +1,11 @@
-import { ILogger, ModuleParams } from "@erp/core/api";
+import { ILogger, ModuleParams, validateRequest } from "@erp/core/api";
import { Application, NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize";
-import { buildListCustomerInvoicesController } from "../../presentation";
+import {
+ CreateCustomerInvoiceCommandSchema,
+ ListCustomerInvoicesQuerySchema,
+} from "../../../common/dto";
+import { buildListCustomerInvoicesController } from "../../controllers";
export const customerInvoicesRouter = (params: ModuleParams) => {
const { app, database, baseRoutePath, logger } = params as {
@@ -17,33 +21,33 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
"/",
//checkTabContext,
//checkUser,
+ validateRequest(ListCustomerInvoicesQuerySchema, "query"),
(req: Request, res: Response, next: NextFunction) => {
buildListCustomerInvoicesController(database).execute(req, res, next);
}
);
- app.use(`${baseRoutePath}/customer-invoices`, routes);
-
/*routes.get(
"/:customerInvoiceId",
//checkTabContext,
//checkUser,
+ validateRequest(GetCustomerInvoiceByIdQuerySchema, "query"),
(req: Request, res: Response, next: NextFunction) => {
buildGetCustomerInvoiceController(database).execute(req, res, next);
}
);*/
- /*routes.post(
+ routes.post(
"/",
- validateAndParseBody(ICreateCustomerInvoiceRequestSchema, { sanitize: false }),
//checkTabContext,
//checkUser,
+ validateRequest(CreateCustomerInvoiceCommandSchema),
(req: Request, res: Response, next: NextFunction) => {
buildCreateCustomerInvoiceController(database).execute(req, res, next);
}
);
- routes.put(
+ /*routes.put(
"/:customerInvoiceId",
validateAndParseBody(IUpdateCustomerInvoiceRequestSchema),
checkTabContext,
@@ -62,4 +66,6 @@ export const customerInvoicesRouter = (params: ModuleParams) => {
buildDeleteCustomerInvoiceController().execute(req, res, next);
}
);*/
+
+ app.use(`${baseRoutePath}/customer-invoices`, routes);
};
diff --git a/modules/customer-invoices/src/api/infrastructure/mappers/customer-invoice.mapper.ts b/modules/customer-invoices/src/api/infrastructure/mappers/customer-invoice.mapper.ts
index 8616461a..fa1039f3 100644
--- a/modules/customer-invoices/src/api/infrastructure/mappers/customer-invoice.mapper.ts
+++ b/modules/customer-invoices/src/api/infrastructure/mappers/customer-invoice.mapper.ts
@@ -36,8 +36,8 @@ export class CustomerInvoiceMapper
const statusOrError = CustomerInvoiceStatus.create(source.invoice_status);
const customerInvoiceSeriesOrError = CustomerInvoiceSerie.create(source.invoice_series);
const customerInvoiceNumberOrError = CustomerInvoiceNumber.create(source.invoice_number);
- const issueDateOrError = UtcDate.create(source.issue_date);
- const operationDateOrError = UtcDate.create(source.operation_date);
+ const issueDateOrError = UtcDate.createFromISO(source.issue_date);
+ const operationDateOrError = UtcDate.createFromISO(source.operation_date);
const result = Result.combine([
idOrError,
diff --git a/modules/customer-invoices/src/api/presentation/index.ts b/modules/customer-invoices/src/api/presentation/index.ts
deleted file mode 100644
index 635099c6..00000000
--- a/modules/customer-invoices/src/api/presentation/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-//export * from "./create-customer-invoice";
-//export * from "./delete-customer-invoice";
-export * from "./get-invoice";
-export * from "./list-invoices";
-///export * from "./update-customer-invoice";
diff --git a/modules/customer-invoices/src/api/presentation/list-invoices/list-invoices.controller.ts b/modules/customer-invoices/src/api/presentation/list-invoices/list-invoices.controller.ts
deleted file mode 100644
index 4c335bff..00000000
--- a/modules/customer-invoices/src/api/presentation/list-invoices/list-invoices.controller.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ExpressController } from "@erp/core/api";
-import { ListCustomerInvoicesUseCase } from "../../application";
-import { IListCustomerInvoicesPresenter } from "./presenter/list-invoices.presenter";
-
-export class ListCustomerInvoicesController extends ExpressController {
- public constructor(
- private readonly listCustomerInvoices: ListCustomerInvoicesUseCase,
- private readonly presenter: IListCustomerInvoicesPresenter
- ) {
- super();
- }
-
- protected async executeImpl() {
- const criteria = this.criteria;
- const customerInvoicesOrError = await this.listCustomerInvoices.execute(criteria);
-
- if (customerInvoicesOrError.isFailure) {
- return this.handleError(customerInvoicesOrError.error);
- }
-
- return this.ok(this.presenter.toDTO(customerInvoicesOrError.data, criteria));
- }
-
- private handleError(error: Error) {
- const message = error.message;
-
- if (
- message.includes("Database connection lost") ||
- message.includes("Database request timed out")
- ) {
- return this.unavailableError(
- "Database service is currently unavailable. Please try again later."
- );
- }
-
- return this.conflictError(message);
- }
-}
diff --git a/modules/customer-invoices/src/common/dto/common/index.ts b/modules/customer-invoices/src/common/dto/common/index.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/customer-invoices/src/common/dto/customer-invoices.request.dto.ts b/modules/customer-invoices/src/common/dto/customer-invoices.request.dto.ts
index 130fb36e..b1766dbf 100644
--- a/modules/customer-invoices/src/common/dto/customer-invoices.request.dto.ts
+++ b/modules/customer-invoices/src/common/dto/customer-invoices.request.dto.ts
@@ -1,16 +1,3 @@
-export type IListCustomerInvoicesRequestDTO = {};
-
-export interface ICreateCustomerInvoiceRequestDTO {
- id: string;
-
- customerInvoice_number: string;
- customerInvoice_series: string;
- issue_date: string;
- operation_date: string;
- language_code: string;
- currency: string;
-}
-
export interface IUpdateCustomerInvoiceRequestDTO {
is_freelancer: boolean;
name: string;
diff --git a/modules/customer-invoices/src/common/dto/customer-invoices.schemas.ts b/modules/customer-invoices/src/common/dto/customer-invoices.schemas.ts
index 7c2c6e4f..f4385bd0 100644
--- a/modules/customer-invoices/src/common/dto/customer-invoices.schemas.ts
+++ b/modules/customer-invoices/src/common/dto/customer-invoices.schemas.ts
@@ -1,4 +1,4 @@
-import { z } from "zod";
+import * as z from "zod/v4";
export const ICreateCustomerInvoiceRequestSchema = z.object({
id: z.string().uuid(),
diff --git a/modules/customer-invoices/src/common/dto/index.ts b/modules/customer-invoices/src/common/dto/index.ts
index 44c99a3c..346dac3b 100644
--- a/modules/customer-invoices/src/common/dto/index.ts
+++ b/modules/customer-invoices/src/common/dto/index.ts
@@ -1,3 +1,2 @@
-export * from "./customer-invoices.request.dto";
-export * from "./customer-invoices.response.dto";
-export * from "./customer-invoices.schemas";
+export * from "./request";
+export * from "./response";
diff --git a/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts b/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts
new file mode 100644
index 00000000..98e75053
--- /dev/null
+++ b/modules/customer-invoices/src/common/dto/request/create-customer-invoice.command.dto.ts
@@ -0,0 +1,31 @@
+import * as z from "zod/v4";
+
+export const CreateCustomerInvoiceCommandSchema = z.object({
+ id: z.string().uuid(),
+ invoice_number: z.string().min(1, "Customer invoice number is required"),
+ invoice_series: z.string().min(1, "Customer invoice series is required"),
+ issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }),
+ operation_date: z.string().datetime({ offset: true, message: "Invalid operation date format" }),
+ language_code: z.string().min(2, "Language code must be at least 2 characters long"),
+ currency: z.string().min(3, "Currency code must be at least 3 characters long"),
+ items: z.array(
+ z.object({
+ description: z.string().min(1, "Item description is required"),
+ quantity: z.object({
+ amount: z.number().positive("Quantity amount must be positive"),
+ scale: z.number().int().nonnegative("Quantity scale must be a non-negative integer"),
+ }),
+ unitPrice: z.object({
+ amount: z.number().positive("Unit price amount must be positive"),
+ scale: z.number().int().nonnegative("Unit price scale must be a non-negative integer"),
+ currency: z.string().min(3, "Unit price currency code must be at least 3 characters long"),
+ }),
+ discount: z.object({
+ amount: z.number().nonnegative("Discount amount cannot be negative"),
+ scale: z.number().int().nonnegative("Discount scale must be a non-negative integer"),
+ }),
+ })
+ ),
+});
+
+export type CreateCustomerInvoiceCommandDTO = z.infer;
diff --git a/modules/customer-invoices/src/common/dto/request/index.ts b/modules/customer-invoices/src/common/dto/request/index.ts
new file mode 100644
index 00000000..8abb398f
--- /dev/null
+++ b/modules/customer-invoices/src/common/dto/request/index.ts
@@ -0,0 +1,2 @@
+export * from "./create-customer-invoice.command.dto";
+export * from "./list-customer-invoices.query.dto";
diff --git a/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts b/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts
new file mode 100644
index 00000000..8d5b59b6
--- /dev/null
+++ b/modules/customer-invoices/src/common/dto/request/list-customer-invoices.query.dto.ts
@@ -0,0 +1,33 @@
+import * as z from "zod/v4";
+
+/**
+ * DTO que transporta los parámetros de la consulta (paginación, filtros, etc.)
+ * para la búsqueda de facturas de cliente.
+ *
+ * Este DTO es utilizado por el endpoint:
+ * `GET /customer-invoices` (listado / búsqueda de facturas).
+ *
+ */
+
+export const ListCustomerInvoicesQuerySchema = z.object({
+ page: z.number().int().min(1).default(1),
+ pageSize: z.number().int().min(1).max(100).default(25),
+ fromDate: z
+ .string()
+ .optional()
+ .refine((val) => !val || !Number.isNaN(Date.parse(val)), {
+ message: "Invalid date format for fromDate",
+ }),
+ toDate: z
+ .string()
+ .optional()
+ .refine((val) => !val || !Number.isNaN(Date.parse(val)), {
+ message: "Invalid date format for toDate",
+ }),
+ status: z.enum(["DRAFT", "POSTED", "PAID", "CANCELLED"]).default("DRAFT"),
+ customerId: z.string().optional(),
+ sortBy: z.enum(["issueDate", "totalAmount", "number"]).default("issueDate"),
+ sortDir: z.enum(["ASC", "DESC"]).default("DESC"),
+});
+
+export type ListCustomerInvoicesQueryDTO = z.infer;
diff --git a/modules/customer-invoices/src/common/dto/request/update-customer-invoice.command.dto.ts b/modules/customer-invoices/src/common/dto/request/update-customer-invoice.command.dto.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/customer-invoices/src/common/dto/response/customer-invoice-creation.result.dto.ts b/modules/customer-invoices/src/common/dto/response/customer-invoice-creation.result.dto.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/customer-invoices/src/common/dto/response/index.ts b/modules/customer-invoices/src/common/dto/response/index.ts
new file mode 100644
index 00000000..fed6daf7
--- /dev/null
+++ b/modules/customer-invoices/src/common/dto/response/index.ts
@@ -0,0 +1 @@
+export * from "./list-customer-invoices.result.dto";
diff --git a/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts b/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts
new file mode 100644
index 00000000..0023e1d5
--- /dev/null
+++ b/modules/customer-invoices/src/common/dto/response/list-customer-invoices.result.dto.ts
@@ -0,0 +1,30 @@
+import { MetadataSchema, createListViewSchema } from "@erp/core";
+import * as z from "zod/v4";
+
+export const ListCustomerInvoicesResultSchema = createListViewSchema(
+ z.object({
+ id: z.uuid(),
+ invoice_status: z.string(),
+ invoice_number: z.string(),
+ invoice_series: z.string(),
+ issue_date: z.iso.datetime({ offset: true }),
+ operation_date: z.iso.datetime({ offset: true }),
+ language_code: z.string(),
+ currency: z.string(),
+
+ subtotal_price: z.object({
+ amount: z.number(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ total_price: z.object({
+ amount: z.number(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+
+ metadata: MetadataSchema.optional(),
+ })
+);
+
+export type ListCustomerInvoicesResultDTO = z.infer;
diff --git a/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx b/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx
index fca94db7..7a0241cc 100644
--- a/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx
+++ b/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx
@@ -9,7 +9,7 @@ ModuleRegistry.registerModules([AllCommunityModule]);
// Core CSS
import { AgGridReact } from "ag-grid-react";
-import { useCustomerInvoices } from "../hooks";
+import { useCustomerInvoicesQuery } from "../hooks";
/**
* Fetch example Json data
@@ -50,7 +50,7 @@ interface IRow {
export const CustomerInvoicesGrid = () => {
//const { useList } = useCustomerInvoices();
- const { data, isLoading, isPending, isError, error } = useCustomerInvoices({});
+ const { data, isLoading, isPending, isError, error } = useCustomerInvoicesQuery({});
// Column Definitions: Defines & controls grid columns.
const [colDefs] = useState([
@@ -87,8 +87,6 @@ export const CustomerInvoicesGrid = () => {
};
}, []);
- console.log(isError, error);
-
// Container: Defines the grid's theme & dimensions.
return (
{
}}
>
({});
diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx
index 455403b5..4afc28de 100644
--- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx
+++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx
@@ -6,7 +6,14 @@ import { Outlet, RouteObject } from "react-router-dom";
const CustomerInvoicesLayout = lazy(() =>
import("./components").then((m) => ({ default: m.CustomerInvoicesLayout }))
);
-const CustomerInvoicesList = lazy(() => import("./pages").then((m) => ({ default: m.CustomerInvoicesList })));
+
+const CustomerInvoicesList = lazy(() =>
+ import("./pages").then((m) => ({ default: m.CustomerInvoicesList }))
+);
+
+const CustomerInvoiceAdd = lazy(() =>
+ import("./pages").then((m) => ({ default: m.CustomerInvoiceCreate }))
+);
//const LogoutPage = lazy(() => import("./app").then((m) => ({ default: m.LogoutPage })));
@@ -17,7 +24,7 @@ const LoginPageWithLanguageSelector = lazy(() =>
import("./app").then((m) => ({ default: m.LoginPageWithLanguageSelector }))
);
-const CustomerInvoiceCreate = lazy(() => import("./app").then((m) => ({ default: m.CustomerInvoiceCreate })));
+
const CustomerInvoiceEdit = lazy(() => import("./app").then((m) => ({ default: m.CustomerInvoiceEdit })));
const SettingsEditor = lazy(() => import("./app").then((m) => ({ default: m.SettingsEditor })));
const SettingsLayout = lazy(() => import("./app").then((m) => ({ default: m.SettingsLayout })));
@@ -30,16 +37,16 @@ const CustomerInvoicesList = lazy(() => import("./app").then((m) => ({ default:
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
return [
{
- path: "*",
+ path: "customer-invoices",
element: (
),
children: [
- { path: "", element: }, // index
+ { path: "", index: true, element: }, // index
{ path: "list", element: },
- { path: "*", element: },
+ { path: "create", element: },
//
/*{ path: "create", element: },
diff --git a/modules/customer-invoices/src/web/hooks/index.ts b/modules/customer-invoices/src/web/hooks/index.ts
index 1e78c67a..7cfd15d4 100644
--- a/modules/customer-invoices/src/web/hooks/index.ts
+++ b/modules/customer-invoices/src/web/hooks/index.ts
@@ -1,2 +1,3 @@
-export * from "./customer-invoices-context";
-export * from "./use-customer-invoices";
+export * from "./use-create-customer-invoice-mutation";
+export * from "./use-customer-invoices-context";
+export * from "./use-customer-invoices-query";
diff --git a/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts b/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts
new file mode 100644
index 00000000..5a6892b6
--- /dev/null
+++ b/modules/customer-invoices/src/web/hooks/use-create-customer-invoice-mutation.ts
@@ -0,0 +1,23 @@
+import { useDataSource, useQueryKey } from "@erp/core/client";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { ICreateCustomerInvoiceRequestDTO } from "../../common/dto";
+
+export const useCreateCustomerInvoiceMutation = () => {
+ const queryClient = useQueryClient();
+ const dataSource = useDataSource();
+ const keys = useQueryKey();
+
+ return useMutation<
+ ICreateCustomerInvoiceRequestDTO,
+ Error,
+ Partial
+ >({
+ mutationFn: (data) => {
+ console.log(data);
+ return dataSource.createOne("customer-invoices", data);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["customer-invoices"] });
+ },
+ });
+};
diff --git a/modules/customer-invoices/src/web/hooks/customer-invoices-context.tsx b/modules/customer-invoices/src/web/hooks/use-customer-invoices-context.tsx
similarity index 100%
rename from modules/customer-invoices/src/web/hooks/customer-invoices-context.tsx
rename to modules/customer-invoices/src/web/hooks/use-customer-invoices-context.tsx
diff --git a/modules/customer-invoices/src/web/hooks/use-customer-invoices-query.tsx b/modules/customer-invoices/src/web/hooks/use-customer-invoices-query.tsx
new file mode 100644
index 00000000..32945b28
--- /dev/null
+++ b/modules/customer-invoices/src/web/hooks/use-customer-invoices-query.tsx
@@ -0,0 +1,25 @@
+import { IListResponseDTO } from "@erp/core";
+import { useDataSource, useQueryKey } from "@erp/core/client";
+import { useQuery } from "@tanstack/react-query";
+import { IListCustomerInvoicesResponseDTO } from "../../common/dto";
+
+// Obtener todas las facturas
+export const useCustomerInvoicesQuery = (params: any) => {
+ const dataSource = useDataSource();
+ const keys = useQueryKey();
+
+ return useQuery({
+ queryKey: keys().data().resource("customer-invoices").action("list").params(params).get(),
+ queryFn: (context) => {
+ console.log(dataSource.getBaseUrl());
+ const { signal } = context;
+ return dataSource.getList>(
+ "customer-invoices",
+ {
+ signal,
+ ...params,
+ }
+ );
+ },
+ });
+};
diff --git a/modules/customer-invoices/src/web/hooks/use-customer-invoices.tsx b/modules/customer-invoices/src/web/hooks/use-customer-invoices.tsx
deleted file mode 100644
index 2ae0070a..00000000
--- a/modules/customer-invoices/src/web/hooks/use-customer-invoices.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { useDataSource, useQueryKey } from "@erp/core/client";
-import { useQuery } from "@tanstack/react-query";
-
-// Obtener todas las facturas
-export const useCustomerInvoices = (params: any) => {
- const dataSource = useDataSource();
- const keys = useQueryKey();
-
- return useQuery({
- queryKey: keys().data().resource("invoices").action("list").params(params).get(),
- queryFn: (context) => {
- console.log(dataSource.getBaseUrl());
- const { signal } = context;
- return dataSource.getList("customer-invoices", {
- signal,
- ...params,
- });
- },
- });
-};
diff --git a/modules/customer-invoices/src/web/invoice-routes.tsx b/modules/customer-invoices/src/web/invoice-routes.tsx
deleted file mode 100644
index af239765..00000000
--- a/modules/customer-invoices/src/web/invoice-routes.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { ModuleClientParams } from "@erp/core/client";
-import { lazy } from "react";
-import { Outlet, RouteObject } from "react-router-dom";
-
-// Lazy load components
-const InvoicesLayout = lazy(() =>
- import("./components").then((m) => ({ default: m.CustomerInvoicesLayout }))
-);
-const InvoicesList = lazy(() =>
- import("./pages").then((m) => ({ default: m.CustomerInvoicesList }))
-);
-
-//const LogoutPage = lazy(() => import("./app").then((m) => ({ default: m.LogoutPage })));
-
-/*const DealerLayout = lazy(() => import("./app").then((m) => ({ default: m.DealerLayout })));
-const DealersList = lazy(() => import("./app").then((m) => ({ default: m.DealersList })));
-
-const LoginPageWithLanguageSelector = lazy(() =>
- import("./app").then((m) => ({ default: m.LoginPageWithLanguageSelector }))
-);
-
-const InvoiceCreate = lazy(() => import("./app").then((m) => ({ default: m.InvoiceCreate })));
-const InvoiceEdit = lazy(() => import("./app").then((m) => ({ default: m.InvoiceEdit })));
-const SettingsEditor = lazy(() => import("./app").then((m) => ({ default: m.SettingsEditor })));
-const SettingsLayout = lazy(() => import("./app").then((m) => ({ default: m.SettingsLayout })));
-const CatalogLayout = lazy(() => import("./app").then((m) => ({ default: m.CatalogLayout })));
-const CatalogList = lazy(() => import("./app").then((m) => ({ default: m.CatalogList })));
-const DashboardPage = lazy(() => import("./app").then((m) => ({ default: m.DashboardPage })));
-const InvoicesLayout = lazy(() => import("./app").then((m) => ({ default: m.InvoicesLayout })));
-const InvoicesList = lazy(() => import("./app").then((m) => ({ default: m.InvoicesList })));*/
-
-export const InvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
- return [
- {
- path: "*",
- element: (
-
-
-
- ),
- children: [
- { path: "", element: }, // index
- { path: "list", element: },
- { path: "*", element: },
-
- //
- /*{ path: "create", element: },
- { path: ":id", element: },
- { path: ":id/edit", element: },
- { path: ":id/delete", element: },
- { path: ":id/view", element: },
- { path: ":id/print", element: },
- { path: ":id/email", element: },
- { path: ":id/download", element: },
- { path: ":id/duplicate", element: },
- { path: ":id/preview", element: },*/
- ],
- },
- ];
-};
diff --git a/modules/customer-invoices/src/web/pages/create/create.tsx b/modules/customer-invoices/src/web/pages/create/create.tsx
new file mode 100644
index 00000000..254b2a76
--- /dev/null
+++ b/modules/customer-invoices/src/web/pages/create/create.tsx
@@ -0,0 +1,133 @@
+import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
+import { Button } from "@repo/shadcn-ui/components";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { useCreateCustomerInvoiceMutation } from "../../hooks";
+import { MODULE_NAME } from "../../manifest";
+import { InvoiceEditForm } from "./invoice-edit-form";
+
+export const CustomerInvoiceCreate = () => {
+ const { t } = useTranslation(MODULE_NAME);
+ const navigate = useNavigate();
+
+ const { mutate, isPending, isError, error } = useCreateCustomerInvoiceMutation();
+
+ const handleSubmit = (data: any) => {
+ // Handle form submission logic here
+ console.log("Form submitted with data:", data);
+ mutate(data);
+
+ // Navigate to the list page after submission
+ navigate("/customer-invoices/list");
+ };
+
+ if (isError) {
+ console.error("Error creating customer invoice:", error);
+ // Optionally, you can show an error message to the user
+ }
+
+ // Render the component
+ // You can also handle loading state if needed
+ // For example, you can disable the submit button while the mutation is in progress
+ // const isLoading = useCreateCustomerInvoiceMutation().isLoading;
+
+ // Return the JSX for the component
+ // You can customize the form and its fields as needed
+ // For example, you can use a form library like react-hook-form or Formik to handle form state and validation
+ // Here, we are using a simple form with a submit button
+
+ // Note: Make sure to replace the form fields with your actual invoice fields
+ // and handle validation as needed.
+ // This is just a basic example to demonstrate the structure of the component.
+
+ // If you are using a form library, you can pass the handleSubmit function to the form's onSubmit prop
+ // and use the form library's methods to handle form state and validation.
+
+ // Example of a simple form submission handler
+ // You can replace this with your actual form handling logic
+ // const handleSubmit = (event: React.FormEvent) => {
+ // event.preventDefault();
+ // const formData = new FormData(event.currentTarget);
+
+ return (
+ <>
+
+
+
+
+
+ {t("customerInvoices.create.title")}
+
+
{t("customerInvoices.create.description")}
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+/*
+ return (
+ <>
+
+
+
+ {t('customerInvoices.list.title' />
+
+
+ {t('CustomerInvoices.list.subtitle' />
+
+
+
+
+
+
+
+
+
+
+
+ {CustomerInvoiceStatuses.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+
+
+
+
+
+ {CustomerInvoiceStatuses.map((s) => (
+
+
+
+ ))}
+
+ >
+ );
+};
+*/
diff --git a/modules/customer-invoices/src/web/pages/create/index.ts b/modules/customer-invoices/src/web/pages/create/index.ts
new file mode 100644
index 00000000..c6262006
--- /dev/null
+++ b/modules/customer-invoices/src/web/pages/create/index.ts
@@ -0,0 +1 @@
+export * from "./create";
diff --git a/modules/customer-invoices/src/web/pages/create/invoice-edit-form.tsx b/modules/customer-invoices/src/web/pages/create/invoice-edit-form.tsx
new file mode 100644
index 00000000..39c99bab
--- /dev/null
+++ b/modules/customer-invoices/src/web/pages/create/invoice-edit-form.tsx
@@ -0,0 +1,908 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useFieldArray, useForm } from "react-hook-form";
+import * as z from "zod";
+
+import {
+ Button,
+ Calendar,
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ Input,
+ Label,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Separator,
+ Textarea,
+} from "@repo/shadcn-ui/components";
+import { format } from "date-fns";
+import { es } from "date-fns/locale";
+import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react";
+import { InvoiceData } from "./types";
+import { formatCurrency } from "./utils";
+
+const invoiceSchema = z.object({
+ id: z.string(),
+ invoice_status: z.string(),
+ invoice_number: z.string().min(1, "Número de factura requerido"),
+ invoice_series: z.string().min(1, "Serie requerida"),
+ issue_date: z.string(),
+ operation_date: z.string(),
+ language_code: z.string(),
+ currency: z.string(),
+ customer_id: z.string().min(1, "ID de cliente requerido"),
+ items: z
+ .array(
+ z.object({
+ id_article: z.string(),
+ description: z.string(),
+ quantity: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ }),
+ unit_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ subtotal_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ discount: z.object({
+ amount: z.number().min(0).max(100).nullable(),
+ scale: z.number(),
+ }),
+ discount_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ total_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ })
+ )
+ .min(1, "Al menos un item es requerido"),
+ subtotal_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ discount: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ }),
+ discount_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ before_tax_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ tax: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ }),
+ tax_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ total_price: z.object({
+ amount: z.number().nullable(),
+ scale: z.number(),
+ currency_code: z.string(),
+ }),
+ metadata: z.object({
+ entity: z.string(),
+ }),
+});
+
+const defaultInvoiceData: InvoiceData = {
+ id: "893b2c74-e80f-4015-b0ed-6111b9c36ad2",
+ invoice_status: "draft",
+ invoice_number: "1",
+ invoice_series: "A",
+ issue_date: "2025-04-30T00:00:00.000Z",
+ operation_date: "2025-04-30T00:00:00.000Z",
+ language_code: "ES",
+ currency: "EUR",
+ customer_id: "c1d2e3f4-5678-90ab-cdef-1234567890ab",
+ items: [
+ {
+ id_article: "",
+ description: "Item 1",
+ quantity: {
+ amount: 100,
+ scale: 2,
+ },
+ unit_price: {
+ amount: 100,
+ scale: 2,
+ currency_code: "EUR",
+ },
+ subtotal_price: {
+ amount: 100,
+ scale: 2,
+ currency_code: "EUR",
+ },
+ discount: {
+ amount: 0,
+ scale: 2,
+ },
+ discount_price: {
+ amount: 0,
+ scale: 2,
+ currency_code: "EUR",
+ },
+ total_price: {
+ amount: 100,
+ scale: 2,
+ currency_code: "EUR",
+ },
+ },
+ ],
+ subtotal_price: {
+ amount: 0,
+ scale: 2,
+ currency_code: "EUR",
+ },
+ discount: {
+ amount: 0,
+ scale: 0,
+ },
+ discount_price: {
+ amount: 0,
+ scale: 0,
+ currency_code: "EUR",
+ },
+ before_tax_price: {
+ amount: 0,
+ scale: 2,
+ currency_code: "EUR",
+ },
+ tax: {
+ amount: 2100,
+ scale: 2,
+ },
+ tax_price: {
+ amount: 0,
+ scale: 2,
+ currency_code: "EUR",
+ },
+ total_price: {
+ amount: 0,
+ scale: 2,
+ currency_code: "EUR",
+ },
+ metadata: {
+ entity: "customer-invoice",
+ },
+};
+
+interface InvoiceFormProps {
+ initialData?: InvoiceData;
+ isPending?: boolean;
+ /**
+ * Callback function to handle form submission.
+ * @param data - The invoice data submitted by the form.
+ */
+ onSubmit?: (data: InvoiceData) => void;
+}
+
+export const InvoiceEditForm = ({
+ initialData = defaultInvoiceData,
+ onSubmit,
+ isPending,
+}: InvoiceFormProps) => {
+ const form = useForm({
+ resolver: zodResolver(invoiceSchema),
+ defaultValues: initialData,
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ });
+
+ const watchedItems = form.watch("items");
+ const watchedTaxRate = form.watch("tax.amount");
+
+ const addItem = () => {
+ append({
+ id_article: "",
+ description: "",
+ quantity: { amount: 100, scale: 2 },
+ unit_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") },
+ subtotal_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") },
+ discount: { amount: 0, scale: 2 },
+ discount_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") },
+ total_price: { amount: 0, scale: 2, currency_code: form.getValues("currency") },
+ });
+ };
+
+ const handleSubmit = (data: InvoiceData) => {
+ console.log("Datos del formulario:", data);
+ onSubmit?.(data);
+ };
+
+ const handleError = (errors: any) => {
+ console.error("Errores en el formulario:", errors);
+ // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
+ };
+
+ const handleCancel = () => {
+ form.reset(initialData);
+ };
+
+ return (
+
+
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/modules/customer-invoices/src/web/pages/create/types.ts b/modules/customer-invoices/src/web/pages/create/types.ts
new file mode 100644
index 00000000..50f01b04
--- /dev/null
+++ b/modules/customer-invoices/src/web/pages/create/types.ts
@@ -0,0 +1,35 @@
+import { IMoneyDTO, IPercentageDTO, IQuantityDTO } from "@erp/core";
+
+export interface InvoiceItem {
+ id_article: string;
+ description: string;
+ quantity: IQuantityDTO;
+ unit_price: IMoneyDTO;
+ subtotal_price: IMoneyDTO;
+ discount: IPercentageDTO;
+ discount_price: IMoneyDTO;
+ total_price: IMoneyDTO;
+}
+
+export interface InvoiceData {
+ id: string;
+ invoice_status: string;
+ invoice_number: string;
+ invoice_series: string;
+ issue_date: string;
+ operation_date: string;
+ language_code: string;
+ currency: string;
+ customer_id: string;
+ items: InvoiceItem[];
+ subtotal_price: IMoneyDTO;
+ discount: IPercentageDTO;
+ discount_price: IMoneyDTO;
+ before_tax_price: IMoneyDTO;
+ tax: IPercentageDTO;
+ tax_price: IMoneyDTO;
+ total_price: IMoneyDTO;
+ metadata: {
+ entity: string;
+ };
+}
diff --git a/modules/customer-invoices/src/web/pages/create/utils.ts b/modules/customer-invoices/src/web/pages/create/utils.ts
new file mode 100644
index 00000000..ce8469c8
--- /dev/null
+++ b/modules/customer-invoices/src/web/pages/create/utils.ts
@@ -0,0 +1,41 @@
+import type { InvoiceItem } from "@/types/invoice";
+
+export function calculateItemTotal(quantity: number, unitPrice: number, discount = 0): number {
+ const subtotal = quantity * unitPrice;
+ const discountAmount = (subtotal * discount) / 100;
+ return subtotal - discountAmount;
+}
+
+export function calculateInvoiceTotals(items: InvoiceItem[], taxRate = 21) {
+ const subtotal = items.reduce((sum, item) => {
+ return (
+ sum + (item.quantity.amount * item.unit_price.amount) / Math.pow(10, item.unit_price.scale)
+ );
+ }, 0);
+
+ const totalDiscount = items.reduce((sum, item) => {
+ const itemSubtotal =
+ (item.quantity.amount * item.unit_price.amount) / Math.pow(10, item.unit_price.scale);
+ return sum + (itemSubtotal * item.discount.amount) / Math.pow(10, item.discount.scale) / 100;
+ }, 0);
+
+ const beforeTax = subtotal - totalDiscount;
+ const taxAmount = (beforeTax * taxRate) / 100;
+ const total = beforeTax + taxAmount;
+
+ return {
+ subtotal: Math.round(subtotal * 100),
+ totalDiscount: Math.round(totalDiscount * 100),
+ beforeTax: Math.round(beforeTax * 100),
+ taxAmount: Math.round(taxAmount * 100),
+ total: Math.round(total * 100),
+ };
+}
+
+export function formatCurrency(amount: number, scale = 2, currency = "EUR"): string {
+ const value = amount / Math.pow(10, scale);
+ return new Intl.NumberFormat("es-ES", {
+ style: "currency",
+ currency: currency,
+ }).format(value);
+}
diff --git a/modules/customer-invoices/src/web/pages/index.tsx b/modules/customer-invoices/src/web/pages/index.tsx
index 491ccf0c..d1c12f23 100644
--- a/modules/customer-invoices/src/web/pages/index.tsx
+++ b/modules/customer-invoices/src/web/pages/index.tsx
@@ -1 +1,2 @@
+export * from "./create";
export * from "./list";
diff --git a/modules/customer-invoices/src/web/pages/list.tsx b/modules/customer-invoices/src/web/pages/list.tsx
index 7aff6a39..501fd0a0 100644
--- a/modules/customer-invoices/src/web/pages/list.tsx
+++ b/modules/customer-invoices/src/web/pages/list.tsx
@@ -34,7 +34,7 @@ export const CustomerInvoicesList = () => {
{t("customerInvoices.list.description")}
-