Facturas de cliente
This commit is contained in:
parent
f47a97bfa0
commit
4a73456c46
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "biomejs.biome"]
|
||||
"recommendations": ["biomejs.biome"]
|
||||
}
|
||||
|
||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -16,6 +16,7 @@
|
||||
"typescript.suggest.includeAutomaticOptionalChainCompletions": true,
|
||||
"typescript.suggestionActions.enabled": true,
|
||||
"typescript.preferences.importModuleSpecifier": "shortest",
|
||||
"typescript.autoClosingTags": true,
|
||||
|
||||
"editor.quickSuggestions": {
|
||||
"strings": "on"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const ListAccountsRequestSchema = z.object({});
|
||||
|
||||
|
||||
@ -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"]);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
import { z } from "zod";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const ListUsersSchema = z.object({});
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
import { z } from "zod";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const ListContactsSchema = z.object({});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const ListCustomerInvoicesSchema = z.object({});
|
||||
export const GetCustomerInvoiceSchema = z.object({});
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
import { z } from "zod";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const ListCustomersSchema = z.object({});
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="factuges"></div>
|
||||
<div id="factuges" class="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -38,6 +38,7 @@ export const AppRoutes = (): JSX.Element => {
|
||||
<Router>
|
||||
<ScrollToTop />
|
||||
|
||||
{/* Fallback Route */}
|
||||
<Suspense fallback={<LoadingOverlay />}>
|
||||
<Routes>
|
||||
{/* Auth Layout */}
|
||||
@ -56,11 +57,7 @@ export const AppRoutes = (): JSX.Element => {
|
||||
<Route path='/settings' element={<ErrorPage />} />
|
||||
<Route path='/catalog' element={<ErrorPage />} />
|
||||
<Route path='/quotes' element={<ErrorPage />} />
|
||||
<Route path='*' element={<ErrorPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback Route */}
|
||||
<Route path='*' element={<ErrorPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "info",
|
||||
"noUnreachable": "off"
|
||||
"noUnreachable": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
|
||||
150
docs/DTOS - GUIA DE ESTILO.md
Normal file
150
docs/DTOS - GUIA DE ESTILO.md
Normal file
@ -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/
|
||||
└─ <bounded-context>/ (ej. billing/)
|
||||
└─ api/
|
||||
└─ dto/
|
||||
├─ common/ ← Tipos reutilizables (MoneyDTO, AddressDTO…)
|
||||
├─ request/ ← Sólo comandos y queries
|
||||
│ ├─ *.command.dto.ts
|
||||
│ └─ *.query.dto.ts
|
||||
│
|
||||
└─ response/ ← Sólo resultados/vistas
|
||||
├─ *.result.dto.ts
|
||||
└─ *.view.dto.ts
|
||||
|
||||
```
|
||||
|
||||
*Alias TS recomendado*: `@<context>/dto/*` → `src/<context>/api/dto/*`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Convención de nombres
|
||||
|
||||
| Categoría | Sufijo **obligatorio** | Descripción & ejemplos |
|
||||
|-----------|------------------------|------------------------|
|
||||
| **Comandos** (mutaciones) | `…CommandDTO` | `CreateInvoiceCommandDTO`, `UpdateInvoiceCommandDTO`, `DeleteInvoiceCommandDTO`, `ChangeInvoiceStatusCommandDTO` |
|
||||
| **Queries** (lecturas con filtros) | `…QueryDTO` | `ListInvoicesQueryDTO`, `GetInvoiceByIdQueryDTO` |
|
||||
| **Resultados** (respuesta de comandos) | `…ResultDTO` | `InvoiceCreationResultDTO`, `InvoiceDeletionResultDTO` |
|
||||
| **Vistas** (respuesta de queries) | `…ViewDTO` | `InvoiceViewDTO`, `InvoiceSummaryViewDTO` |
|
||||
| **Tipos comunes** | `…DTO` | `MoneyDTO`, `PaginationMetaDTO`, `AddressDTO` |
|
||||
|
||||
*Regla de oro:* **No existe ningún DTO sin sufijo, salvo los tipos comunes dentro de `common/`.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Reglas de contenido
|
||||
|
||||
1. **Solo datos planos**: números, cadenas, literales, arrays; nada de lógica.
|
||||
2. **Fechas** en ISO-8601 UTC (`yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`).
|
||||
3. **Enums** expuestos como _string literal_ en `snake_case` o `UPPER_SNAKE_CASE`; evita números mágicos.
|
||||
4. **Moneda**
|
||||
- **Siempre** con la estructura común:
|
||||
|
||||
```ts
|
||||
export interface MoneyDTO {
|
||||
amount: number | null; // unidades mínimas (ej. céntimos)
|
||||
scale: number; // nº de decimales (2 = céntimos)
|
||||
currency_code: string; // ISO-4217 (“EUR”)
|
||||
}
|
||||
```
|
||||
|
||||
- Se importa desde `dto/common/money.dto.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Guía de mapeo
|
||||
|
||||
| Dirección | Componente responsable | Ubicación |
|
||||
|-----------|------------------------|-----------|
|
||||
| **DTO → Dominio** | `…CommandMapper` / `…QueryMapper` | `src/<context>/application/mappers/` |
|
||||
| **Dominio → DTO** | `…ResultMapper` / `…ViewMapper` | mismo directorio |
|
||||
|
||||
Cada mapper implementa **una** función pública:
|
||||
|
||||
```ts
|
||||
interface InvoiceCreationResultMapper {
|
||||
toResult(entity: Invoice): InvoiceCreationResultDTO;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Validación y versiones
|
||||
|
||||
1. **Validación de entrada**
|
||||
- Usa `class-validator` o Zod en el *controller*; nunca en el dominio.
|
||||
- Convierte a Value Objects una vez que el DTO pasó la validación.
|
||||
|
||||
2. **Versionado**
|
||||
- Añade sufijos de versión en el **archivo**, no en el nombre de la interfaz:
|
||||
`invoice.view.v2.dto.ts` → `export interface InvoiceViewV2DTO { … }`
|
||||
- Mantén las versiones anteriores durante **≥1 release** o hasta que los consumidores migren.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ejemplo completo (creación de factura)
|
||||
|
||||
```text
|
||||
billing/
|
||||
├─ api/
|
||||
│ └─ dto/
|
||||
│ ├─ input/create-invoice.command.dto.ts
|
||||
│ ├─ output/invoice-creation.result.dto.ts
|
||||
│ └─ common/money.dto.ts
|
||||
└─ application/
|
||||
└─ mappers/
|
||||
└─ invoice-creation.result.mapper.ts
|
||||
```
|
||||
|
||||
```ts
|
||||
// create-invoice.command.dto.ts
|
||||
export interface CreateInvoiceCommandDTO {
|
||||
customerId: string;
|
||||
issueDate: string;
|
||||
lines: ReadonlyArray<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: MoneyDTO;
|
||||
}>;
|
||||
}
|
||||
|
||||
// invoice-creation.result.dto.ts
|
||||
export interface InvoiceCreationResultDTO {
|
||||
invoiceId: string;
|
||||
number: string;
|
||||
totalAmount: MoneyDTO;
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Checklist antes de hacer *merge*
|
||||
|
||||
- [ ] Archivo ubicado en la carpeta correcta.
|
||||
- [ ] Sufijo conforme (**CommandDTO**, **QueryDTO**, **ResultDTO**, **ViewDTO**).
|
||||
- [ ] Todos los importes usan `MoneyDTO`.
|
||||
- [ ] Tipos opcionales marcados con `?` y comentados.
|
||||
- [ ] **Sin** lógica, constructores ni métodos.
|
||||
- [ ] PR incluye al menos un test de mapper (input ⇄ dominio ⇄ output).
|
||||
|
||||
---
|
||||
|
||||
## 8. Tabla resumen
|
||||
|
||||
| Carpeta | Sufijo | Ejemplo clásico |
|
||||
|---------|--------|-----------------|
|
||||
| `dto/input/` | `CommandDTO` | `DeleteInvoiceCommandDTO` |
|
||||
| `dto/input/` | `QueryDTO` | `ListInvoicesQueryDTO` |
|
||||
| `dto/output/` | `ResultDTO` | `InvoiceDeletionResultDTO` |
|
||||
| `dto/output/` | `ViewDTO` | `InvoiceSummaryViewDTO` |
|
||||
| `dto/common/` | `DTO` | `MoneyDTO` |
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const ICreateInvoiceRequestSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
12
modules/core/src/api/errors/conflict-api-error.ts
Normal file
12
modules/core/src/api/errors/conflict-api-error.ts
Normal file
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
29
modules/core/src/api/errors/domain-validation-error.ts
Normal file
29
modules/core/src/api/errors/domain-validation-error.ts
Normal file
@ -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";
|
||||
}
|
||||
}
|
||||
96
modules/core/src/api/errors/error-mapper.ts
Normal file
96
modules/core/src/api/errors/error-mapper.ts
Normal file
@ -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}`);
|
||||
},
|
||||
};
|
||||
12
modules/core/src/api/errors/forbidden-api-error.ts
Normal file
12
modules/core/src/api/errors/forbidden-api-error.ts
Normal file
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
3
modules/core/src/api/errors/index.ts
Normal file
3
modules/core/src/api/errors/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./domain-validation-error";
|
||||
export * from "./error-mapper";
|
||||
export * from "./validation-error-collection";
|
||||
12
modules/core/src/api/errors/internal-api-error.ts
Normal file
12
modules/core/src/api/errors/internal-api-error.ts
Normal file
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
12
modules/core/src/api/errors/not-found-api-error.ts
Normal file
12
modules/core/src/api/errors/not-found-api-error.ts
Normal file
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
12
modules/core/src/api/errors/unauthorized-api-error.ts
Normal file
12
modules/core/src/api/errors/unauthorized-api-error.ts
Normal file
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
12
modules/core/src/api/errors/unavailable-api-error.ts
Normal file
12
modules/core/src/api/errors/unavailable-api-error.ts
Normal file
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
13
modules/core/src/api/errors/validation-api-error.ts
Normal file
13
modules/core/src/api/errors/validation-api-error.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
33
modules/core/src/api/errors/validation-error-collection.ts
Normal file
33
modules/core/src/api/errors/validation-error-collection.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./errors";
|
||||
export * from "./infrastructure";
|
||||
export * from "./logger";
|
||||
export * from "./modules";
|
||||
|
||||
@ -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<unknown>;
|
||||
|
||||
protected ok<T>(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<T>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
export * from "./api-error";
|
||||
export * from "./express-controller";
|
||||
export * from "./middlewares";
|
||||
export * from "./validate-request-dto";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./global-error-handler";
|
||||
export * from "./validate-request";
|
||||
|
||||
@ -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 = <T extends "body" | "query" | "params">(
|
||||
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();
|
||||
};
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
export interface IListResponseDTO<T> {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
total_items: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export const isResponseAListDTO = <T>(data: any): data is IListResponseDTO<T> => {
|
||||
return data && typeof data.total_items === "number";
|
||||
};
|
||||
|
||||
export const existsMoreReponsePages = <T>(response: any): response is IListResponseDTO<T> => {
|
||||
return isResponseAListDTO(response) && response.page + 1 < response.total_pages;
|
||||
};
|
||||
18
modules/core/src/common/dto/list.view.dto.ts
Normal file
18
modules/core/src/common/dto/list.view.dto.ts
Normal file
@ -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<T>
|
||||
*/
|
||||
export const createListViewSchema = <T extends z.ZodTypeAny>(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(),
|
||||
});
|
||||
@ -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<typeof MetadataSchema>;
|
||||
|
||||
// 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;
|
||||
|
||||
@ -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<IMoneyRequestDTO>(schema, money);
|
||||
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
return Result.ok(true);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 {}
|
||||
};
|
||||
|
||||
@ -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<IQuantityRequestDTO>(schema, quantity);
|
||||
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
export interface IQuantityRequestDTO extends IQuantityDTO {}
|
||||
export interface IQuantityResponseDTO extends IQuantityDTO {}
|
||||
};
|
||||
|
||||
@ -14,9 +14,9 @@ export interface ICustomParams {
|
||||
|
||||
export interface IDataSource {
|
||||
getBaseUrl(): string;
|
||||
getList<T>(resource: string, params?: Record<string, any>): Promise<T[]>;
|
||||
getList<T>(resource: string, params?: Record<string, any>): Promise<T>;
|
||||
getOne<T>(resource: string, id: string | number): Promise<T>;
|
||||
getMany<T>(resource: string, ids: Array<string | number>): Promise<T[]>;
|
||||
getMany<T>(resource: string, ids: Array<string | number>): Promise<T>;
|
||||
createOne<T>(resource: string, data: Partial<T>): Promise<T>;
|
||||
updateOne<T>(resource: string, id: string | number, data: Partial<T>): Promise<T>;
|
||||
deleteOne<T>(resource: string, id: string | number): Promise<void>;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Result<CustomerInvoice, Error>> {
|
||||
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) {
|
||||
@ -0,0 +1 @@
|
||||
export * from "./create-customer-invoice.use-case";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./delete-customer-invoice.use-case";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./get-customer-invoice.use-case";
|
||||
@ -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<Result<CustomerInvoiceProps, Error>> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@ -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<CreateCustomerInvoiceCommandDTO, "items">["items"]
|
||||
): Result<CustomerInvoiceItem[], ValidationErrorCollection> {
|
||||
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);
|
||||
}
|
||||
@ -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<T>(
|
||||
result: Result<T, Error>,
|
||||
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;
|
||||
}
|
||||
@ -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<T extends Record<string, any>>(
|
||||
obj: T
|
||||
): obj is { [K in keyof T]-?: Exclude<T[K], undefined> } {
|
||||
return Object.values(obj).every((value) => value !== undefined);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./build-customer-invoice-from-dto";
|
||||
@ -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";
|
||||
|
||||
@ -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<Result<Collection<CustomerInvoice>, 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./list-customer-invoices.use-case";
|
||||
@ -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<Result<ListCustomerInvoicesResultDTO, Error>> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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<CustomerInvoice>,
|
||||
criteria: Criteria
|
||||
) => IListResponseDTO<IListCustomerInvoicesResponseDTO>;
|
||||
) => ListCustomerInvoicesViewDTO;
|
||||
}
|
||||
|
||||
export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
|
||||
export const listCustomerInvoicesPresenter: ListCustomerInvoicesPresenter = {
|
||||
toDTO: (
|
||||
customerInvoices: Collection<CustomerInvoice>,
|
||||
criteria: Criteria
|
||||
): IListResponseDTO<IListCustomerInvoicesResponseDTO> => {
|
||||
): 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}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./update-customer-invoice.use-case";
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./create-customer-invoice";
|
||||
5
modules/customer-invoices/src/api/controllers/index.ts
Normal file
5
modules/customer-invoices/src/api/controllers/index.ts
Normal file
@ -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";
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<ICustomerInvoiceProps>
|
||||
extends AggregateRoot<CustomerInvoiceProps>
|
||||
implements ICustomerInvoice
|
||||
{
|
||||
private _items!: Collection<CustomerInvoiceItem>;
|
||||
//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<CustomerInvoice, Error> {
|
||||
static create(props: CustomerInvoiceProps, id?: UniqueID): Result<CustomerInvoice, Error> {
|
||||
const customerInvoice = new CustomerInvoice(props, id);
|
||||
|
||||
// Reglas de negocio / validaciones
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export * from "./aggregates";
|
||||
export * from "./entities";
|
||||
export * from "./errors";
|
||||
export * from "./repositories";
|
||||
export * from "./services";
|
||||
export * from "./value-objects";
|
||||
|
||||
@ -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<ICustomerInvoiceProps>,
|
||||
data: Partial<CustomerInvoiceProps>,
|
||||
transaction?: any
|
||||
): Promise<Result<CustomerInvoice, Error>>;
|
||||
|
||||
createCustomerInvoice(
|
||||
customerInvoiceId: UniqueID,
|
||||
data: ICustomerInvoiceProps,
|
||||
data: CustomerInvoiceProps,
|
||||
transaction?: any
|
||||
): Promise<Result<CustomerInvoice, Error>>;
|
||||
|
||||
|
||||
@ -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<ICustomerInvoiceProps>,
|
||||
data: Partial<CustomerInvoiceProps>,
|
||||
transaction?: Transaction
|
||||
): Promise<Result<CustomerInvoice, Error>> {
|
||||
// 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<Result<CustomerInvoice, Error>> {
|
||||
// Verificar si la factura existe
|
||||
|
||||
@ -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<ICustomerInvoiceItemDescriptionProps> {
|
||||
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<ICustomerInvoice
|
||||
}
|
||||
|
||||
static create(value: string) {
|
||||
const valueIsValid = CustomerInvoiceItemDescription.validate(value);
|
||||
const result = CustomerInvoiceItemDescription.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(
|
||||
CustomerInvoiceItemDescription.ERROR_CODE,
|
||||
CustomerInvoiceItemDescription.FIELD,
|
||||
detail
|
||||
)
|
||||
);
|
||||
}
|
||||
return Result.ok(new CustomerInvoiceItemDescription({ value }));
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { DomainValidationError } from "@erp/core/api";
|
||||
import { ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
interface ICustomerInvoiceNumberProps {
|
||||
value: string;
|
||||
@ -8,22 +9,31 @@ interface ICustomerInvoiceNumberProps {
|
||||
|
||||
export class CustomerInvoiceNumber extends ValueObject<ICustomerInvoiceNumberProps> {
|
||||
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 }));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user