Facturas de cliente

This commit is contained in:
David Arranz 2025-06-24 20:38:57 +02:00
parent f47a97bfa0
commit 4a73456c46
163 changed files with 3899 additions and 1630 deletions

View File

@ -1,3 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "biomejs.biome"]
"recommendations": ["biomejs.biome"]
}

View File

@ -16,6 +16,7 @@
"typescript.suggest.includeAutomaticOptionalChainCompletions": true,
"typescript.suggestionActions.enabled": true,
"typescript.preferences.importModuleSpecifier": "shortest",
"typescript.autoClosingTags": true,
"editor.quickSuggestions": {
"strings": "on"

View File

@ -1,4 +1,4 @@
import { z } from "zod";
import * as z from "zod/v4";
export const ListAccountsRequestSchema = z.object({});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,3 @@
import { z } from "zod";
import * as z from "zod/v4";
export const ListUsersSchema = z.object({});

View File

@ -1,3 +1,3 @@
import { z } from "zod";
import * as z from "zod/v4";
export const ListContactsSchema = z.object({});

View File

@ -1,4 +1,4 @@
import { z } from "zod";
import * as z from "zod/v4";
export const ListCustomerInvoicesSchema = z.object({});
export const GetCustomerInvoiceSchema = z.object({});

View File

@ -1,3 +1,3 @@
import { z } from "zod";
import * as z from "zod/v4";
export const ListCustomersSchema = z.object({});

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@
"recommended": true,
"correctness": {
"useExhaustiveDependencies": "info",
"noUnreachable": "off"
"noUnreachable": "warn"
},
"complexity": {
"noForEach": "off",

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { z } from "zod";
import * as z from "zod/v4";
export const ICreateInvoiceRequestSchema = z.object({
id: z.string().uuid(),

View File

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

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

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

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

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

View File

@ -0,0 +1,3 @@
export * from "./domain-validation-error";
export * from "./error-mapper";
export * from "./validation-error-collection";

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

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

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

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

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

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

View File

@ -1,3 +1,4 @@
export * from "./errors";
export * from "./infrastructure";
export * from "./logger";
export * from "./modules";

View File

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

View File

@ -1,4 +1,2 @@
export * from "./api-error";
export * from "./express-controller";
export * from "./middlewares";
export * from "./validate-request-dto";

View File

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

View File

@ -1 +1,2 @@
export * from "./global-error-handler";
export * from "./validate-request";

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from "./create-customer-invoice.use-case";

View File

@ -0,0 +1 @@
export * from "./delete-customer-invoice.use-case";

View File

@ -0,0 +1 @@
export * from "./get-customer-invoice.use-case";

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from "./build-customer-invoice-from-dto";

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from "./list-customer-invoices.use-case";

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from "./update-customer-invoice.use-case";

View File

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

View File

@ -0,0 +1 @@
export * from "./create-customer-invoice";

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
export * from "./aggregates";
export * from "./entities";
export * from "./errors";
export * from "./repositories";
export * from "./services";
export * from "./value-objects";

View File

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

View File

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

View File

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

View File

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