Facturas de cliente y clientes

This commit is contained in:
David Arranz 2025-08-26 20:55:59 +02:00
parent 4b93815985
commit f53f71760b
68 changed files with 719 additions and 796 deletions

View File

@ -11,7 +11,7 @@ import {
import { UniqueID } from "@/core/common/domain"; import { UniqueID } from "@/core/common/domain";
import { IAuthenticatedUserRepository, JWTPayload } from ".."; import { IAuthenticatedUserRepository, JWTPayload } from "..";
import { JwtHelper } from "../../infraestructure/passport/jwt.helper"; import { JwtHelper } from "../../../../../../../modules/auth/src/api/lib/passport/jwt.helper";
import { ITabContextRepository } from "../repositories/tab-context-repository.interface"; import { ITabContextRepository } from "../repositories/tab-context-repository.interface";
import { IAuthService } from "./auth-service.interface"; import { IAuthService } from "./auth-service.interface";

View File

@ -1,4 +1,4 @@
export * from "../../../../../../modules/auth/src/api/lib/passport";
export * from "./mappers"; export * from "./mappers";
export * from "./middleware"; export * from "./middleware";
export * from "./passport";
export * from "./sequelize"; export * from "./sequelize";

View File

@ -2,9 +2,9 @@ import { UniqueID } from "@/core/common/domain";
import { ApiError, ExpressController } from "@/core/common/presentation"; import { ApiError, ExpressController } from "@/core/common/presentation";
//import { authProvider } from "@/contexts/auth/infraestructure"; //import { authProvider } from "@/contexts/auth/infraestructure";
import { NextFunction, Response } from "express"; import { NextFunction, Response } from "express";
import { authProvider } from "../../../../../../../modules/auth/src/api/lib/passport";
import { AuthenticatedUser } from "../../domain"; import { AuthenticatedUser } from "../../domain";
import { AuthenticatedRequest } from "../express/types"; import { AuthenticatedRequest } from "../express/types";
import { authProvider } from "../passport";
// Comprueba el rol del usuario // Comprueba el rol del usuario
const _authorizeUser = (condition: (user: AuthenticatedUser) => boolean) => { const _authorizeUser = (condition: (user: AuthenticatedUser) => boolean) => {
@ -63,6 +63,7 @@ export const checkUserIsAdminOrOwner = [
title: "Unauthorized", title: "Unauthorized",
detail: "You are not authorized to access this resource.", detail: "You are not authorized to access this resource.",
}), }),
req,
res res
); );
}, },

View File

@ -1,3 +1,3 @@
import { authProvider } from "../passport"; import { authProvider } from "../../../../../../../modules/auth/src/api/lib/passport";
export const checkTabContext = [authProvider.authenticateTabId()]; export const checkTabContext = [authProvider.authenticateTabId()];

View File

@ -23,7 +23,7 @@ export class InvoiceItemDescription extends ValueObject<IInvoiceItemDescriptionP
const valueIsValid = InvoiceItemDescription.validate(value); const valueIsValid = InvoiceItemDescription.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new InvoiceItemDescription({ value })); return Result.ok(new InvoiceItemDescription({ value }));
} }

View File

@ -23,7 +23,7 @@ export class InvoiceNumber extends ValueObject<IInvoiceNumberProps> {
const valueIsValid = InvoiceNumber.validate(value); const valueIsValid = InvoiceNumber.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new InvoiceNumber({ value })); return Result.ok(new InvoiceNumber({ value }));
} }

View File

@ -23,7 +23,7 @@ export class InvoiceSerie extends ValueObject<IInvoiceSerieProps> {
const valueIsValid = InvoiceSerie.validate(value); const valueIsValid = InvoiceSerie.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new InvoiceSerie({ value })); return Result.ok(new InvoiceSerie({ value }));
} }

View File

@ -9,12 +9,14 @@
"peerDependencies": { "peerDependencies": {
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"express": "^4.18.2",
"i18next": "^25.1.1",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"zod": "^3.25.67" "zod": "^3.25.67"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@types/express": "^4.17.21",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3", "@types/react-dom": "^19.1.3",
"@types/react-i18next": "^8.1.0", "@types/react-i18next": "^8.1.0",
@ -22,11 +24,10 @@
}, },
"dependencies": { "dependencies": {
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-ui": "workspace:*", "@repo/rdx-ui": "workspace:*",
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.74.11", "@tanstack/react-query": "^5.74.11",
"express": "^4.18.2",
"i18next": "^25.1.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.56.2", "react-hook-form": "^7.56.2",

View File

@ -1,10 +1,11 @@
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import { Request } from "express"; import { Request } from "express";
export type RequestUser = { export type RequestUser = {
userId: string; userId: UniqueID;
companyId: string; // tenant companyId: UniqueID;
roles?: string[]; roles?: string[];
email?: string; email?: EmailAddress;
}; };
export type RequestWithAuth<T = any> = Request & { export type RequestWithAuth<T = any> = Request & {

View File

@ -1,2 +1,4 @@
export * from "./auth-types"; export * from "./auth-types";
export * from "./mock-user.middleware";
export * from "./tenancy.middleware"; export * from "./tenancy.middleware";
export * from "./user.middleware";

View File

@ -0,0 +1,15 @@
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
import { NextFunction, Response } from "express";
import { RequestWithAuth } from "./auth-types";
export function mockUser(req: RequestWithAuth, res: Response, next: NextFunction) {
if (process.env.NODE_ENV === "development") {
req.user = {
id: UniqueID.create("9e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
email: EmailAddress.create("dev@example.com").data,
companyId: UniqueID.create("1e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
roles: ["admin"],
};
}
next();
}

View File

@ -1,3 +1,4 @@
import { ExpressController, UnauthorizedApiError } from "@erp/core/api";
import { NextFunction, Response } from "express"; import { NextFunction, Response } from "express";
import { RequestWithAuth } from "./auth-types"; import { RequestWithAuth } from "./auth-types";
@ -9,22 +10,8 @@ export function enforceTenant() {
return (req: RequestWithAuth, res: Response, next: NextFunction) => { return (req: RequestWithAuth, res: Response, next: NextFunction) => {
// Validación básica del tenant // Validación básica del tenant
if (!req.user || !req.user.companyId) { if (!req.user || !req.user.companyId) {
return res.status(401).json({ error: "Unauthorized" }); return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
} }
next(); next();
}; };
} }
/**
* Mezcla el companyId del usuario en el criteria de listados, ignorando cualquier companyId entrante.
* Evita evasión de tenant por parámetros manipulados.
*/
export function scopeCriteriaWithCompany<T extends Record<string, any>>(
criteria: T | undefined,
companyId: string
): T & { companyId: string } {
return {
...(criteria ?? ({} as T)),
companyId, // fuerza el scope
};
}

View File

@ -0,0 +1,16 @@
import { ExpressController, UnauthorizedApiError } from "@erp/core/api";
import { NextFunction, Response } from "express";
import { RequestWithAuth } from "./auth-types";
/**
* Middleware que exige presencia de usuario (sin validar companyId).
* Debe ir DESPUÉS del middleware de autenticación.
*/
export function enforceUser() {
return (req: RequestWithAuth, res: Response, next: NextFunction) => {
if (!req.user) {
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
next();
};
}

View File

@ -1,8 +1,3 @@
import { getDatabase } from "@/config";
import { SequelizeTransactionManager } from "@/core/common/infrastructure";
import { AuthService, TabContextService } from "../../domain/services";
import { authenticatedUserMapper, tabContextMapper } from "../mappers";
import { AuthenticatedUserRepository, TabContextRepository } from "../sequelize";
import { PassportAuthProvider } from "./passport-auth-provider"; import { PassportAuthProvider } from "./passport-auth-provider";
const database = getDatabase(); const database = getDatabase();

View File

@ -1,14 +1,13 @@
import { NextFunction, Response } from "express"; import { NextFunction, Response } from "express";
import { EmailAddress, UniqueID } from "@/core/common/domain";
import { ITransactionManager } from "@/core/common/infrastructure/database";
import { logger } from "@/core/logger";
import { Result } from "@repo/rdx-utils";
import passport from "passport"; import passport from "passport";
import { ExtractJwt, Strategy as JwtStrategy } from "passport-jwt"; import { ExtractJwt, Strategy as JwtStrategy } from "passport-jwt";
import { TabContext } from "../../domain"; import { TabContext } from "../../../../../../apps/server/archive/contexts/auth/domain";
import { IAuthService, ITabContextService } from "../../domain/services"; import {
import { TabContextRequest } from "../express/types"; IAuthService,
ITabContextService,
} from "../../../../../../apps/server/archive/contexts/auth/domain/services";
import { TabContextRequest } from "../../../../../../apps/server/archive/contexts/auth/infraestructure/express/types";
const SECRET_KEY = process.env.JWT_SECRET || "supersecretkey"; const SECRET_KEY = process.env.JWT_SECRET || "supersecretkey";

View File

@ -0,0 +1 @@
export * from "./extract-or-push-error";

View File

@ -1,5 +1,6 @@
export * from "./application"; export * from "./application";
export * from "./domain"; export * from "./domain";
export * from "./helpers";
export * from "./infrastructure"; export * from "./infrastructure";
export * from "./logger"; export * from "./logger";
export * from "./modules"; export * from "./modules";

View File

@ -40,6 +40,8 @@ export interface ApiErrorContext {
instance?: string; // p.ej. req.originalUrl instance?: string; // p.ej. req.originalUrl
correlationId?: string; // p.ej. header 'x-correlation-id' correlationId?: string; // p.ej. header 'x-correlation-id'
method?: string; // GET/POST/PUT/DELETE method?: string; // GET/POST/PUT/DELETE
userId?: string;
tenantId?: string;
} }
// ──────────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────────
@ -166,12 +168,18 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// Fallback genérico (500) // Fallback genérico (500)
function defaultFallback(e: unknown): ApiError { function defaultFallback(e: unknown): ApiError {
const message = typeof (e as any)?.message === "string" ? (e as any).message : "Unexpected error"; if (e instanceof ApiError) {
return new InternalApiError(`Unexpected error: ${message}`); return e; // ya es un ApiError
}
const message = typeof (e as any)?.message === "string" ? (e as any).message : "";
const detail = typeof (e as any)?.detail === "string" ? (e as any).detail : "";
return new InternalApiError(`${message} ${detail}`);
} }
// ──────────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────────
// Serializador opcional a Problem+JSON (si tu ApiError no lo trae ya) // Serializador opcional a Problem+JSON
// ──────────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────────
export function toProblemJson(apiError: ApiError, ctx?: ApiErrorContext) { export function toProblemJson(apiError: ApiError, ctx?: ApiErrorContext) {
const maybeErrors = (apiError as any).errors ? { errors: (apiError as any).errors } : {}; const maybeErrors = (apiError as any).errors ? { errors: (apiError as any).errors } : {};
@ -180,6 +188,8 @@ export function toProblemJson(apiError: ApiError, ctx?: ApiErrorContext) {
title: apiError.title, title: apiError.title,
status: apiError.status, status: apiError.status,
detail: apiError.detail, detail: apiError.detail,
...(ctx?.userId ? { userId: ctx.userId } : {}),
...(ctx?.tenantId ? { tenantId: ctx.tenantId } : {}),
...(ctx?.instance ? { instance: ctx.instance } : {}), ...(ctx?.instance ? { instance: ctx.instance } : {}),
...(ctx?.correlationId ? { correlationId: ctx.correlationId } : {}), ...(ctx?.correlationId ? { correlationId: ctx.correlationId } : {}),
...(ctx?.method ? { method: ctx.method } : {}), ...(ctx?.method ? { method: ctx.method } : {}),

View File

@ -1,6 +1,8 @@
import { Criteria, CriteriaFromUrlConverter } from "@repo/rdx-criteria/server"; import { Criteria, CriteriaFromUrlConverter } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status"; import httpStatus from "http-status";
import { ApiErrorContext, ApiErrorMapper, toProblemJson } from "./api-error-mapper";
import { import {
ApiError, ApiError,
ConflictApiError, ConflictApiError,
@ -11,45 +13,92 @@ import {
UnavailableApiError, UnavailableApiError,
ValidationApiError, ValidationApiError,
} from "./errors"; } from "./errors";
import { GuardFn } from "./express-guards";
type GuardResultLike = { isFailure: boolean; error?: ApiError };
export type GuardContext = {
req: Request;
res: Response;
next: NextFunction;
controller: ExpressController;
criteria: Criteria;
};
export type GuardFn = (ctx: GuardContext) => GuardResultLike | Promise<GuardResultLike>;
export function guardOk(): GuardResultLike {
return { isFailure: false };
}
export function guardFail(error: ApiError): GuardResultLike {
return { isFailure: true, error };
}
export abstract class ExpressController { export abstract class ExpressController {
protected req!: Request; protected req!: Request;
protected res!: Response; protected res!: Response;
protected next!: NextFunction; protected next!: NextFunction;
protected criteria!: Criteria; protected criteria!: Criteria;
protected url!: URL;
protected errorMapper!: ApiErrorMapper;
// 🔹 Guards configurables por controlador // 🔹 Guards configurables por controlador
private guards: GuardFn[] = []; private guards: GuardFn[] = [];
static errorResponse(apiError: ApiError, res: Response) { static errorResponse(apiError: ApiError, req: Request, res: Response) {
return res.status(apiError.status).json(apiError); const ctx = {
instance: req.originalUrl,
correlationId: req.get("x-correlation-id") || undefined,
method: req.method,
} satisfies ApiErrorContext;
const body = toProblemJson(apiError, ctx);
return res.type("application/problem+json").status(apiError.status).json(body);
}
public constructor() {
this.errorMapper = ApiErrorMapper.default();
}
// ───────────────────────────────────────────────────────────────────────────
// Método principal
public async execute(req: Request, res: Response, next: NextFunction): Promise<void> {
this.req = req;
this.res = res;
this.next = next;
this.url = this.buildUrlFromRequest();
try {
this.criteria = this.buildCriteriaFromURL();
// Ejecutar guards (auth/tenant/otros). Si alguno falla, se responde y no se entra al impl.
const ok = await this.runGuards();
if (!ok) return;
await this.executeImpl();
} catch (error: unknown) {
const err = error as Error;
console.debug("❌ Unhandled error executing controller:", err.message);
this.handleError(new InternalApiError(err.message));
}
} }
protected abstract executeImpl(): Promise<unknown>; protected abstract executeImpl(): Promise<unknown>;
protected ok<T>(dto?: T) { // ───────────────────────────────────────────────────────────────────────────
// Helpers de auth/tenant (opcionales para usar en executeImpl)
public getUser<T extends { userId?: UniqueID; companyId?: UniqueID } = any>(): T | undefined {
// Si usáis un tipo RequestWithAuth real, cámbialo aquí
return (this.req as any).user as T | undefined;
}
public getTenantId(): UniqueID | undefined {
const user = this.getUser<{ companyId?: UniqueID }>();
return user?.companyId;
}
// ───────────────────────────────────────────────────────────────────────────
protected buildUrlFromRequest(): URL {
// Si Express está tras un proxy y trust proxy=true, estos headers son fiables
const host = this.req.get("x-forwarded-host") ?? this.req.get("host") ?? "localhost";
const proto = this.req.get("x-forwarded-proto") ?? this.req.protocol ?? "http";
return new URL(this.req.originalUrl || "/", `${proto}://${host}`);
}
protected buildCriteriaFromURL() {
return new CriteriaFromUrlConverter().toCriteria(this.url);
}
protected ok<T>(dto?: T, headers?: Record<string, string>) {
if (headers) Object.entries(headers).forEach(([k, v]) => this.res.setHeader(k, v));
return dto ? this.res.status(httpStatus.OK).json(dto) : this.res.sendStatus(httpStatus.OK); return dto ? this.res.status(httpStatus.OK).json(dto) : this.res.sendStatus(httpStatus.OK);
} }
protected created<T>(dto?: T) { protected created<T>(dto?: T, location?: string) {
if (location) this.res.setHeader("Location", location);
return dto return dto
? this.res.status(httpStatus.CREATED).json(dto) ? this.res.status(httpStatus.CREATED).json(dto)
: this.res.sendStatus(httpStatus.CREATED); : this.res.sendStatus(httpStatus.CREATED);
@ -60,46 +109,51 @@ export abstract class ExpressController {
} }
protected clientError(message: string, errors?: any[] | any) { protected clientError(message: string, errors?: any[] | any) {
return ExpressController.errorResponse( return this.handleApiError(
new ValidationApiError(message, Array.isArray(errors) ? errors : [errors]), new ValidationApiError(message, Array.isArray(errors) ? errors : [errors])
this.res
); );
} }
protected unauthorizedError(message?: string) { protected unauthorizedError(message?: string) {
return ExpressController.errorResponse( return this.handleApiError(new UnauthorizedApiError(message ?? "Unauthorized"));
new UnauthorizedApiError(message ?? "Unauthorized"),
this.res
);
} }
protected forbiddenError(message?: string) { protected forbiddenError(message?: string) {
return ExpressController.errorResponse( return this.handleApiError(
new ForbiddenApiError(message ?? "You do not have permission to perform this action."), new ForbiddenApiError(message ?? "You do not have permission to perform this action.")
this.res
); );
} }
protected notFoundError(message: string) { protected notFoundError(message: string) {
return ExpressController.errorResponse(new NotFoundApiError(message), this.res); return this.handleApiError(new NotFoundApiError(message));
} }
protected conflictError(message: string, _errors?: any[]) {
return ExpressController.errorResponse(new ConflictApiError(message), this.res); protected conflictError(message: string) {
return this.handleApiError(new ConflictApiError(message));
} }
protected invalidInputError(message: string, errors?: any[]) { protected invalidInputError(message: string, errors?: any[]) {
return ExpressController.errorResponse(new ValidationApiError(message, errors), this.res); return this.handleApiError(new ValidationApiError(message, errors));
} }
protected unavailableError(message?: string) { protected unavailableError(message?: string) {
return ExpressController.errorResponse( return this.handleApiError(
new UnavailableApiError(message ?? "Service temporarily unavailable."), new UnavailableApiError(message ?? "Service temporarily unavailable.")
this.res
); );
} }
protected internalServerError(message?: string) { protected internalServerError(message?: string) {
return ExpressController.errorResponse( return this.handleApiError(new InternalApiError(message ?? "Internal Server Error"));
new InternalApiError(message ?? "Internal Server Error"),
this.res
);
} }
protected handleApiError(apiError: ApiError) { protected handleApiError(apiError: ApiError) {
return ExpressController.errorResponse(apiError, this.res); return ExpressController.errorResponse(apiError, this.req, this.res);
}
protected handleError(error: unknown, ctx?: ApiErrorContext) {
const err = error instanceof Error ? error : new Error("Unknown error");
const apiError = this.errorMapper.map(err, ctx);
return this.handleApiError(apiError);
} }
// ─────────────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────────
@ -126,47 +180,4 @@ export abstract class ExpressController {
} }
return true; return true;
} }
// ───────────────────────────────────────────────────────────────────────────
// Helpers de auth/tenant (opcionales para usar en executeImpl)
public getUser<T extends { userId?: string; companyId?: string } = any>(): T | undefined {
// Si usáis un tipo RequestWithAuth real, cámbialo aquí
return (this.req as any).user as T | undefined;
}
public getTenantId(): string | undefined {
const user = this.getUser<{ companyId?: string }>();
return user?.companyId;
}
// ───────────────────────────────────────────────────────────────────────────
// Método principal
public async execute(req: Request, res: Response, next: NextFunction): Promise<void> {
this.req = req;
this.res = res;
this.next = next;
try {
// Construcción robusta del URL base
const host = req.get("host") ?? "localhost";
const proto = req.protocol || "http";
const url = new URL(req.originalUrl, `${proto}://${host}`);
this.criteria = new CriteriaFromUrlConverter().toCriteria(url);
// Ejecutar guards (auth/tenant/otros). Si alguno falla, se responde y no se entra al impl.
const ok = await this.runGuards();
if (!ok) return;
await this.executeImpl();
} catch (error: unknown) {
const err = error as Error;
if (err instanceof ApiError) {
ExpressController.errorResponse(err as ApiError, this.res);
} else {
ExpressController.errorResponse(new InternalApiError(err.message), this.res);
}
}
}
} }

View File

@ -1,5 +1,26 @@
import { ForbiddenApiError, UnauthorizedApiError, ValidationApiError } from "./errors"; import { Criteria } from "@repo/rdx-criteria/server";
import { GuardContext, GuardFn, guardFail, guardOk } from "./express-controller"; import { NextFunction, Request, Response } from "express";
import { ApiError, ForbiddenApiError, UnauthorizedApiError, ValidationApiError } from "./errors";
import { ExpressController } from "./express-controller";
export type GuardResultLike = { isFailure: boolean; error?: ApiError };
export type GuardContext = {
req: Request;
res: Response;
next: NextFunction;
controller: ExpressController;
criteria: Criteria;
};
export type GuardFn = (ctx: GuardContext) => GuardResultLike | Promise<GuardResultLike>;
export function guardOk(): GuardResultLike {
return { isFailure: false };
}
export function guardFail(error: ApiError): GuardResultLike {
return { isFailure: true, error };
}
// ─────────────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────────
// Guards reutilizables (auth/tenancy). Si prefieres, muévelos a src/lib/http/express-guards.ts // Guards reutilizables (auth/tenancy). Si prefieres, muévelos a src/lib/http/express-guards.ts

View File

@ -1,6 +1,8 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { ApiErrorMapper, toProblemJson } from "../api-error-mapper"; import { ApiErrorContext, ApiErrorMapper, toProblemJson } from "../api-error-mapper";
import { ApiError } from "../errors/api-error";
// ✅ Construye tu mapper una vez (composition root del adaptador HTTP)
export const apiErrorMapper = ApiErrorMapper.default();
export const globalErrorHandler = async ( export const globalErrorHandler = async (
error: Error, error: Error,
@ -8,45 +10,23 @@ export const globalErrorHandler = async (
res: Response, res: Response,
next: NextFunction next: NextFunction
) => { ) => {
console.error(`❌ Global unhandled error: ${error.message}`);
// Si ya se envió una respuesta, delegamos al siguiente error handler // Si ya se envió una respuesta, delegamos al siguiente error handler
if (res.headersSent) { if (res.headersSent) {
return next(error); return next(error);
} }
const ctx = { const ctx = {
instance: req.originalUrl, instance: req.originalUrl,
correlationId: (req.headers["x-correlation-id"] as string) || undefined, correlationId: req.get("x-correlation-id") || undefined,
method: req.method, method: req.method,
}; } satisfies ApiErrorContext;
const apiError = ApiErrorMapper.map(err, ctx); const apiError = apiErrorMapper.map(error, ctx);
const body = toProblemJson(apiError, ctx); const body = toProblemJson(apiError, ctx);
// 👇 Log interno con cause/traza (no lo exponemos al cliente) // 👇 Log interno con cause/traza (no lo exponemos al cliente)
// logger.error({ err, cause: (err as any)?.cause, ...ctx }, `❌ Unhandled API error: ${error.message}`); // logger.error({ err, cause: (err as any)?.cause, ...ctx }, `❌ Unhandled API error: ${error.message}`);
res.status(apiError.status).json(body); res.status(apiError.status).json(body);
//logger.error(`❌ Unhandled API error: ${error.message}`);
// Verifica si el error es una instancia de ApiError
if (error instanceof ApiError) {
// Respuesta con formato RFC 7807
return res.status(error.status).json({
type: error.type,
title: error.title,
status: error.status,
detail: error.detail,
instance: error.instance ?? req.originalUrl,
errors: error.errors ?? [], // Aquí puedes almacenar validaciones, etc.
});
}
// Si no es un ApiError, lo tratamos como un error interno (500)
return res.status(500).json({
type: "https://example.com/probs/internal-server-error",
title: "Internal Server Error",
status: 500,
detail: error.message || "Ha ocurrido un error inesperado.",
instance: req.originalUrl,
});
}; };

View File

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

View File

@ -45,7 +45,7 @@ export const validateRequest = <T extends "body" | "query" | "params">(
if (!schema) { if (!schema) {
console.debug("ERROR: Undefined schema!!"); console.debug("ERROR: Undefined schema!!");
return ExpressController.errorResponse(new InternalApiError("Undefined schema"), res); return ExpressController.errorResponse(new InternalApiError("Undefined schema"), req, res);
} }
try { try {
@ -62,6 +62,7 @@ export const validateRequest = <T extends "body" | "query" | "params">(
return ExpressController.errorResponse( return ExpressController.errorResponse(
new ValidationApiError("Validation failed", validationErrors), new ValidationApiError("Validation failed", validationErrors),
req,
res res
); );
} }
@ -71,12 +72,14 @@ export const validateRequest = <T extends "body" | "query" | "params">(
req[source] = result.data; req[source] = result.data;
} }
console.debug(`Request ${source} is valid.`);
next(); next();
} catch (err) { } catch (err) {
const error = err as Error; const error = err as Error;
console.error(error); console.error(error);
return ExpressController.errorResponse(new InternalApiError(error.message), res); return ExpressController.errorResponse(new InternalApiError(error.message), req, res);
} }
}; };
}; };

View File

@ -9,7 +9,7 @@ import {
ListCustomerInvoicesAssembler, ListCustomerInvoicesAssembler,
ListCustomerInvoicesUseCase, ListCustomerInvoicesUseCase,
} from "../application"; } from "../application";
import { CustomerInvoiceService } from "../domain"; import { CustomerInvoiceService, ICustomerInvoiceService } from "../domain";
import { CustomerInvoiceMapper } from "./mappers"; import { CustomerInvoiceMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize"; import { CustomerInvoiceRepository } from "./sequelize";
@ -17,7 +17,7 @@ type InvoiceDeps = {
transactionManager: SequelizeTransactionManager; transactionManager: SequelizeTransactionManager;
repo: CustomerInvoiceRepository; repo: CustomerInvoiceRepository;
mapper: CustomerInvoiceMapper; mapper: CustomerInvoiceMapper;
service: CustomerInvoiceService; service: ICustomerInvoiceService;
assemblers: { assemblers: {
list: ListCustomerInvoicesAssembler; list: ListCustomerInvoicesAssembler;
get: GetCustomerInvoiceAssembler; get: GetCustomerInvoiceAssembler;
@ -38,7 +38,7 @@ type InvoiceDeps = {
let _repo: CustomerInvoiceRepository | null = null; let _repo: CustomerInvoiceRepository | null = null;
let _mapper: CustomerInvoiceMapper | null = null; let _mapper: CustomerInvoiceMapper | null = null;
let _service: CustomerInvoiceService | null = null; let _service: ICustomerInvoiceService | null = null;
let _assemblers: InvoiceDeps["assemblers"] | null = null; let _assemblers: InvoiceDeps["assemblers"] | null = null;
export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps { export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps {

View File

@ -1,10 +1,4 @@
import { import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto"; import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto";
import { CreateCustomerInvoiceUseCase } from "../../../application"; import { CreateCustomerInvoiceUseCase } from "../../../application";
@ -32,7 +26,7 @@ export class CreateCustomerInvoiceController extends ExpressController {
return result.match( return result.match(
(data) => this.created(data), (data) => this.created(data),
(err) => this.handleApiError(errorMapper.toApiError(err)) (err) => this.handleError(err)
); );
} }
} }

View File

@ -1,10 +1,4 @@
import { import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { DeleteCustomerInvoiceUseCase } from "../../../application"; import { DeleteCustomerInvoiceUseCase } from "../../../application";
export class DeleteCustomerInvoiceController extends ExpressController { export class DeleteCustomerInvoiceController extends ExpressController {
@ -25,7 +19,7 @@ export class DeleteCustomerInvoiceController extends ExpressController {
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(data),
(error) => this.handleApiError(errorMapper.toApiError(error)) (err) => this.handleError(err)
); );
} }
} }

View File

@ -1,10 +1,4 @@
import { import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { GetCustomerInvoiceUseCase } from "../../../application"; import { GetCustomerInvoiceUseCase } from "../../../application";
export class GetCustomerInvoiceController extends ExpressController { export class GetCustomerInvoiceController extends ExpressController {
@ -25,7 +19,7 @@ export class GetCustomerInvoiceController extends ExpressController {
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(data),
(error) => this.handleApiError(errorMapper.toApiError(error)) (err) => this.handleError(err)
); );
} }
} }

View File

@ -1,10 +1,4 @@
import { import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { ListCustomerInvoicesUseCase } from "../../../application"; import { ListCustomerInvoicesUseCase } from "../../../application";
export class ListCustomerInvoicesController extends ExpressController { export class ListCustomerInvoicesController extends ExpressController {
@ -23,7 +17,7 @@ export class ListCustomerInvoicesController extends ExpressController {
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(data),
(err) => this.handleApiError(errorMapper.toApiError(err)) (err) => this.handleError(err)
); );
} }
} }

View File

@ -1,4 +1,4 @@
import { SequelizeRepository, errorMapper } from "@erp/core/api"; import { SequelizeRepository } from "@erp/core/api";
import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils"; import { Collection, Result } from "@repo/rdx-utils";
@ -58,7 +58,7 @@ export class CustomerInvoiceRepository
return Result.ok(Boolean(result)); return Result.ok(Boolean(result));
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err)); return Result.fail(translateSequelizeError(err));
} }
} }
@ -79,7 +79,7 @@ export class CustomerInvoiceRepository
await CustomerInvoiceModel.upsert(data, { transaction }); await CustomerInvoiceModel.upsert(data, { transaction });
return Result.ok(invoice); return Result.ok(invoice);
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err)); return Result.fail(translateSequelizeError(err));
} }
} }
@ -100,7 +100,7 @@ export class CustomerInvoiceRepository
return this.mapper.mapToDomain(rawData); return this.mapper.mapToDomain(rawData);
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err)); return Result.fail(translateSequelizeError(err));
} }
} }
@ -128,7 +128,7 @@ export class CustomerInvoiceRepository
return this.mapper.mapArrayToDomain(instances); return this.mapper.mapArrayToDomain(instances);
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err)); return Result.fail(translateSequelizeError(err));
} }
} }
@ -144,7 +144,7 @@ export class CustomerInvoiceRepository
await this._deleteById(CustomerInvoiceModel, id, false, transaction); await this._deleteById(CustomerInvoiceModel, id, false, transaction);
return Result.ok<void>(); return Result.ok<void>();
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err)); return Result.fail(translateSequelizeError(err));
} }
} }
} }

View File

@ -1,23 +1,35 @@
import { Customer } from "@erp/customers/api/domain"; import { CustomerCreationResponseDTO } from "../../../../common";
import { CustomersCreationResultDTO } from "@erp/customers/common/dto"; import { Customer } from "../../../domain";
export class CreateCustomersAssembler { export class CreateCustomersAssembler {
public toDTO(customer: Customer): CustomersCreationResultDTO { public toDTO(customer: Customer): CustomerCreationResponseDTO {
return { return {
id: customer.id.toPrimitive(), id: customer.id.toPrimitive(),
company_id: customer.companyId.toPrimitive(),
reference: customer.reference,
is_company: customer.isCompany,
name: customer.name,
trade_name: customer.tradeName,
tin: customer.tin.toPrimitive(),
customer_status: customer.status.toString(), email: customer.email.toPrimitive(),
customer_number: customer.customerNumber.toString(), phone: customer.phone.toPrimitive(),
customer_series: customer.customerSeries.toString(), fax: customer.fax.toPrimitive(),
issue_date: customer.issueDate.toISOString(), website: customer.website,
operation_date: customer.operationDate.toISOString(),
language_code: "ES",
currency: "EUR",
//subtotal_price: customer.calculateSubtotal().toPrimitive(), default_tax: customer.defaultTax,
//total_price: customer.calculateTotal().toPrimitive(), legal_record: customer.legalRecord,
lang_code: customer.langCode,
currency_code: customer.currencyCode,
//recipient: CustomerParticipantAssembler(customer.recipient), status: customer.isActive ? "active" : "inactive",
street: customer.address.street,
street2: customer.address.street2,
city: customer.address.city,
state: customer.address.state,
postal_code: customer.address.postalCode,
country: customer.address.country,
metadata: { metadata: {
entity: "customer", entity: "customer",

View File

@ -1,14 +1,14 @@
import { DuplicateEntityError, ITransactionManager } from "@erp/core/api"; import { DuplicateEntityError, ITransactionManager } from "@erp/core/api";
import { CreateCustomerCommandDTO } from "@erp/customers/common/dto"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { CreateCustomerRequestDTO } from "../../../common";
import { ICustomerService } from "../../domain"; import { ICustomerService } from "../../domain";
import { mapDTOToCustomerProps } from "../helpers"; import { mapDTOToCustomerProps } from "../../helpers";
import { CreateCustomersAssembler } from "./assembler"; import { CreateCustomersAssembler } from "./assembler";
type CreateCustomerUseCaseInput = { type CreateCustomerUseCaseInput = {
tenantId: string; dto: CreateCustomerRequestDTO;
dto: CreateCustomerCommandDTO;
}; };
export class CreateCustomerUseCase { export class CreateCustomerUseCase {
@ -19,46 +19,68 @@ export class CreateCustomerUseCase {
) {} ) {}
public execute(params: CreateCustomerUseCaseInput) { public execute(params: CreateCustomerUseCaseInput) {
const { dto, tenantId: companyId } = params; const { dto } = params;
const customerPropsOrError = mapDTOToCustomerProps(dto); // 1) Mapear DTO → props de dominio
const dtoResult = mapDTOToCustomerProps(dto);
if (customerPropsOrError.isFailure) { if (dtoResult.isFailure) {
return Result.fail(customerPropsOrError.error); return Result.fail(dtoResult.error);
} }
const { props, id } = customerPropsOrError.data; const mapped = dtoResult.data;
const id = mapped.id;
const { companyId, ...customerProps } = mapped.props;
const customerOrError = this.service.build(props, id); console.debug("Creating customer with props:", customerProps);
if (customerOrError.isFailure) { // 3) Construir entidad de dominio
return Result.fail(customerOrError.error); const buildResult = this.service.buildCustomerInCompany(companyId, customerProps, id);
if (buildResult.isFailure) {
return Result.fail(buildResult.error);
} }
const newCustomer = customerOrError.data; const newCustomer = buildResult.data;
return this.transactionManager.complete(async (transaction: Transaction) => { console.debug("Built new customer entity:", newCustomer);
try {
const duplicateCheck = await this.service.existsById(id, transaction);
if (duplicateCheck.isFailure) { // 4) Ejecutar bajo transacción: verificar duplicado → persistir → ensamblar vista
return Result.fail(duplicateCheck.error); return this.transactionManager.complete(async (tx: Transaction) => {
const existsGuard = await this.ensureNotExists(companyId, id, tx);
if (existsGuard.isFailure) {
return Result.fail(existsGuard.error);
} }
if (duplicateCheck.data) { console.debug("No existing customer with same ID found, proceeding to save.");
return Result.fail(new DuplicateEntityError("Customer", id.toString()));
const saveResult = await this.service.saveCustomer(newCustomer, tx);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
} }
const result = await this.service.save(newCustomer, idCompany, transaction); const viewDTO = this.assembler.toDTO(saveResult.data);
if (result.isFailure) { console.debug("Assembled view DTO:", viewDTO);
return Result.fail(result.error);
}
const viewDTO = this.assembler.toDTO(newCustomer);
return Result.ok(viewDTO); return Result.ok(viewDTO);
} catch (error: unknown) {
return Result.fail(error as Error);
}
}); });
} }
/**
Verifica que no exista un Customer con el mismo id en la companyId.
*/
private async ensureNotExists(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<void, Error>> {
const existsResult = await this.service.existsByIdInCompany(companyId, id, transaction);
if (existsResult.isFailure) {
return Result.fail(existsResult.error);
}
if (existsResult.data) {
return Result.fail(new DuplicateEntityError("Customer", "id", String(id)));
}
return Result.ok<void>(undefined);
}
} }

View File

@ -1,50 +0,0 @@
/**
*
* @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);
}
/**
*
* @description Verifica si un objeto tiene campos con valor undefined.
* Esta función es el complemento de `hasNoUndefinedFields`.
*
* @example
* const obj = { a: 1, b: 'test', c: null };
* console.log(hasUndefinedFields(obj)); // false
*
* const objWithUndefined = { a: 1, b: undefined, c: null };
* console.log(hasUndefinedFields(objWithUndefined)); // true
*
* @template T - El tipo del objeto.
* @param obj - El objeto a evaluar.
* @returns true si el objeto tiene al menos un campo undefined, false en caso contrario.
*
*/
export function hasUndefinedFields<T extends Record<string, any>>(
obj: T
): obj is { [K in keyof T]-?: Exclude<T[K], undefined> } {
return !hasNoUndefinedFields(obj);
}

View File

@ -1,83 +0,0 @@
import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
import { CreateCustomerCommandDTO } from "@erp/customers/common/dto";
import { Result } from "@repo/rdx-utils";
import {
CustomerItem,
CustomerItemDescription,
CustomerItemDiscount,
CustomerItemQuantity,
CustomerItemUnitPrice,
} from "../../domain";
import { extractOrPushError } from "./extract-or-push-error";
import { hasNoUndefinedFields } from "./has-no-undefined-fields";
export function mapDTOToCustomerItemsProps(
dtoItems: Pick<CreateCustomerCommandDTO, "items">["items"]
): Result<CustomerItem[], ValidationErrorCollection> {
const errors: ValidationErrorDetail[] = [];
const items: CustomerItem[] = [];
dtoItems.forEach((item, index) => {
const path = (field: string) => `items[${index}].${field}`;
const description = extractOrPushError(
CustomerItemDescription.create(item.description),
path("description"),
errors
);
const quantity = extractOrPushError(
CustomerItemQuantity.create({
amount: item.quantity.amount,
scale: item.quantity.scale,
}),
path("quantity"),
errors
);
const unitPrice = extractOrPushError(
CustomerItemUnitPrice.create({
amount: item.unitPrice.amount,
scale: item.unitPrice.scale,
currency_code: item.unitPrice.currency,
}),
path("unit_price"),
errors
);
const discount = extractOrPushError(
CustomerItemDiscount.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 = CustomerItem.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

@ -1,78 +0,0 @@
import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CreateCustomerCommandDTO } from "../../../common/dto";
import { CustomerNumber, CustomerProps, CustomerSerie, CustomerStatus } from "../../domain";
import { extractOrPushError } from "./extract-or-push-error";
import { mapDTOToCustomerItemsProps } from "./map-dto-to-customer-items-props";
/**
* Convierte el DTO a las props validadas (CustomerProps).
* No construye directamente el agregado.
*
* @param dto - DTO con los datos de la factura de cliente
* @returns
*
*/
export function mapDTOToCustomerProps(dto: CreateCustomerCommandDTO) {
const errors: ValidationErrorDetail[] = [];
const customerId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const customerNumber = extractOrPushError(
CustomerNumber.create(dto.customer_number),
"customer_number",
errors
);
const customerSeries = extractOrPushError(
CustomerSerie.create(dto.customer_series),
"customer_series",
errors
);
const issueDate = extractOrPushError(UtcDate.createFromISO(dto.issue_date), "issue_date", errors);
const operationDate = extractOrPushError(
UtcDate.createFromISO(dto.operation_date),
"operation_date",
errors
);
//const currency = extractOrPushError(Currency.(dto.currency), "currency", errors);
const currency = dto.currency;
// 🔄 Validar y construir los items de factura con helper especializado
const itemsResult = mapDTOToCustomerItemsProps(dto.items);
if (itemsResult.isFailure) {
return Result.fail(itemsResult.error);
}
if (errors.length > 0) {
return Result.fail(new ValidationErrorCollection(errors));
}
const customerProps: CustomerProps = {
customerNumber: customerNumber!,
customerSeries: customerSeries!,
issueDate: issueDate!,
operationDate: operationDate!,
status: CustomerStatus.createDraft(),
currency,
};
return Result.ok({ id: customerId!, props: customerProps });
/*if (hasNoUndefinedFields(customerProps)) {
const customerOrError = Customer.create(customerProps, customerId);
if (customerOrError.isFailure) {
return Result.fail(customerOrError.error);
}
return Result.ok(customerOrError.data);
}
return Result.fail(
new ValidationErrorCollection([
{ path: "", message: "Error building from DTO: Some fields are undefined" },
])
);*/
}

View File

@ -6,46 +6,55 @@ import {
TINNumber, TINNumber,
UniqueID, UniqueID,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { CustomerStatus } from "../value-objects";
export interface CustomerProps { export interface CustomerProps {
companyId: UniqueID; companyId: UniqueID;
status: CustomerStatus;
reference: string; reference: string;
isCompany: boolean; isCompany: boolean;
name: string; name: string;
tradeName: string;
tin: TINNumber; tin: TINNumber;
address: PostalAddress; address: PostalAddress;
email: EmailAddress; email: EmailAddress;
phone: PhoneNumber; phone: PhoneNumber;
fax: PhoneNumber;
website: string;
legalRecord: string; legalRecord: string;
defaultTax: number; defaultTax: string[];
status: string;
langCode: string; langCode: string;
currencyCode: string; currencyCode: string;
tradeName: Maybe<string>;
website: Maybe<string>;
fax: Maybe<PhoneNumber>;
} }
export interface ICustomer { export interface ICustomer {
id: UniqueID; id: UniqueID;
companyId: UniqueID; companyId: UniqueID;
reference: string; reference: string;
name: string;
tin: TINNumber; tin: TINNumber;
name: string;
tradeName: string;
address: PostalAddress; address: PostalAddress;
email: EmailAddress; email: EmailAddress;
phone: PhoneNumber; phone: PhoneNumber;
fax: PhoneNumber;
website: string;
legalRecord: string; legalRecord: string;
defaultTax: number; defaultTax: string[];
langCode: string; langCode: string;
currencyCode: string; currencyCode: string;
tradeName: Maybe<string>;
fax: Maybe<PhoneNumber>;
website: Maybe<string>;
isIndividual: boolean; isIndividual: boolean;
isCompany: boolean; isCompany: boolean;
isActive: boolean; isActive: boolean;
@ -103,11 +112,11 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
return this.props.phone; return this.props.phone;
} }
get fax(): Maybe<PhoneNumber> { get fax(): PhoneNumber {
return this.props.fax; return this.props.fax;
} }
get website() { get website(): string {
return this.props.website; return this.props.website;
} }
@ -136,6 +145,6 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
} }
get isActive(): boolean { get isActive(): boolean {
return this.props.status === "active"; return this.props.status.isActive();
} }
} }

View File

@ -16,7 +16,7 @@ export interface ICustomerService {
/** /**
* Guarda un Customer (nuevo o modificado) en base de datos. * Guarda un Customer (nuevo o modificado) en base de datos.
*/ */
saveCustomerInCompany(customer: Customer, transaction: any): Promise<Result<Customer, Error>>; saveCustomer(customer: Customer, transaction: any): Promise<Result<Customer, Error>>;
/** /**
* Comprueba si existe un Customer con ese ID en la empresa indicada. * Comprueba si existe un Customer con ese ID en la empresa indicada.

View File

@ -7,13 +7,6 @@ import { ICustomerService } from "./customer-service.interface";
export class CustomerService implements ICustomerService { export class CustomerService implements ICustomerService {
constructor(private readonly repository: ICustomerRepository) {} constructor(private readonly repository: ICustomerRepository) {}
findCustomerByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: any
): Promise<Result<Collection<Customer>, Error>> {
throw new Error("Method not implemented.");
}
/** /**
* Construye un nuevo agregado Customer a partir de props validadas. * Construye un nuevo agregado Customer a partir de props validadas.
@ -34,14 +27,11 @@ export class CustomerService implements ICustomerService {
/** /**
* Guarda una instancia de Customer en persistencia. * Guarda una instancia de Customer en persistencia.
* *
* @param invoice - El agregado a guardar. * @param customer - El agregado a guardar.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - El agregado guardado o un error si falla la operación. * @returns Result<Customer, Error> - El agregado guardado o un error si falla la operación.
*/ */
async saveCustomerInCompany( async saveCustomer(customer: Customer, transaction: any): Promise<Result<Customer, Error>> {
customer: Customer,
transaction: any
): Promise<Result<Customer, Error>> {
return this.repository.save(customer, transaction); return this.repository.save(customer, transaction);
} }
@ -71,11 +61,11 @@ export class CustomerService implements ICustomerService {
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @returns Result<Collection<Customer>, Error> - Colección de clientes o error. * @returns Result<Collection<Customer>, Error> - Colección de clientes o error.
*/ */
async findCustomersByCriteriaInCompany( async findCustomerByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
transaction?: any transaction?: any
): Promise<Result<Collection<Customer>>> { ): Promise<Result<Collection<Customer>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction); return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
} }
@ -123,7 +113,7 @@ export class CustomerService implements ICustomerService {
return Result.fail(updatedCustomer.error); return Result.fail(updatedCustomer.error);
} }
return this.saveCustomerInCompany(updatedCustomer.data, transaction); return this.saveCustomer(updatedCustomer.data, transaction);
} }
/** /**

View File

@ -6,65 +6,43 @@ interface ICustomerStatusProps {
value: string; value: string;
} }
export enum INVOICE_STATUS { export enum CUSTOMER_STATUS {
DRAFT = "draft", ACTIVE = "active",
EMITTED = "emitted", INACTIVE = "inactive",
SENT = "sent",
RECEIVED = "received",
REJECTED = "rejected",
} }
export class CustomerStatus extends ValueObject<ICustomerStatusProps> { export class CustomerStatus extends ValueObject<ICustomerStatusProps> {
private static readonly ALLOWED_STATUSES = ["draft", "emitted", "sent", "received", "rejected"]; private static readonly ALLOWED_STATUSES = ["active", "inactive"];
private static readonly FIELD = "invoiceStatus"; private static readonly FIELD = "status";
private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS"; private static readonly ERROR_CODE = "INVALID_STATUS";
private static readonly TRANSITIONS: Record<string, string[]> = { private static readonly TRANSITIONS: Record<string, string[]> = {
draft: [INVOICE_STATUS.EMITTED], active: [CUSTOMER_STATUS.INACTIVE],
emitted: [INVOICE_STATUS.SENT, INVOICE_STATUS.REJECTED, INVOICE_STATUS.DRAFT], inactive: [CUSTOMER_STATUS.ACTIVE],
sent: [INVOICE_STATUS.RECEIVED, INVOICE_STATUS.REJECTED],
received: [],
rejected: [],
}; };
static create(value: string): Result<CustomerStatus, Error> { static create(value: string): Result<CustomerStatus, Error> {
if (!CustomerStatus.ALLOWED_STATUSES.includes(value)) { if (!CustomerStatus.ALLOWED_STATUSES.includes(value)) {
const detail = `Estado de la factura no válido: ${value}`; const detail = `Status value not valid: ${value}`;
return Result.fail( return Result.fail(
new DomainValidationError(CustomerStatus.ERROR_CODE, CustomerStatus.FIELD, detail) new DomainValidationError(CustomerStatus.ERROR_CODE, CustomerStatus.FIELD, detail)
); );
} }
return Result.ok( return Result.ok(
value === "rejected" value === "active" ? CustomerStatus.createActive() : CustomerStatus.createInactive()
? CustomerStatus.createRejected()
: value === "sent"
? CustomerStatus.createSent()
: value === "emitted"
? CustomerStatus.createSent()
: value === ""
? CustomerStatus.createReceived()
: CustomerStatus.createDraft()
); );
} }
public static createDraft(): CustomerStatus { public static createActive(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.DRAFT }); return new CustomerStatus({ value: CUSTOMER_STATUS.ACTIVE });
} }
public static createEmitted(): CustomerStatus { public static createInactive(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.EMITTED }); return new CustomerStatus({ value: CUSTOMER_STATUS.INACTIVE });
} }
public static createSent(): CustomerStatus { isActive(): boolean {
return new CustomerStatus({ value: INVOICE_STATUS.SENT }); return this.props.value === CUSTOMER_STATUS.ACTIVE;
}
public static createReceived(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.RECEIVED });
}
public static createRejected(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.REJECTED });
} }
getValue(): string { getValue(): string {
@ -82,7 +60,7 @@ export class CustomerStatus extends ValueObject<ICustomerStatusProps> {
transitionTo(nextStatus: string): Result<CustomerStatus, Error> { transitionTo(nextStatus: string): Result<CustomerStatus, Error> {
if (!this.canTransitionTo(nextStatus)) { if (!this.canTransitionTo(nextStatus)) {
return Result.fail( return Result.fail(
new Error(`Transición no permitida de ${this.props.value} a ${nextStatus}`) new Error(`Transition not allowed from ${this.props.value} to ${nextStatus}`)
); );
} }
return CustomerStatus.create(nextStatus); return CustomerStatus.create(nextStatus);

View File

@ -0,0 +1,109 @@
import {
DomainError,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { EmailAddress, PhoneNumber, PostalAddress, TINNumber, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CreateCustomerRequestDTO } from "../../common/dto";
import { CustomerProps, CustomerStatus } from "../domain";
/**
* Convierte el DTO a las props validadas (CustomerProps).
* No construye directamente el agregado.
*
* @param dto - DTO con los datos de la factura de cliente
* @returns
*
*/
export function mapDTOToCustomerProps(dto: CreateCustomerRequestDTO) {
try {
const errors: ValidationErrorDetail[] = [];
const customerId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const companyId = extractOrPushError(UniqueID.create(dto.company_id), "company_id", errors);
const status = extractOrPushError(CustomerStatus.create(dto.status), "status", errors);
const reference = dto.reference?.trim() === "" ? undefined : dto.reference;
const isCompany = dto.is_company ?? true;
const name = dto.name?.trim() === "" ? undefined : dto.name;
const tradeName = dto.trade_name?.trim() === "" ? undefined : dto.trade_name;
const tinNumber = extractOrPushError(TINNumber.create(dto.tin), "tin", errors);
const address = extractOrPushError(
PostalAddress.create({
street: dto.street,
city: dto.city,
postalCode: dto.postal_code,
state: dto.state,
country: dto.country,
}),
"address",
errors
);
const emailAddress = extractOrPushError(EmailAddress.create(dto.email), "email", errors);
const phoneNumber = extractOrPushError(PhoneNumber.create(dto.phone), "phone", errors);
const faxNumber = extractOrPushError(PhoneNumber.create(dto.fax), "fax", errors);
const website = dto.website?.trim() === "" ? undefined : dto.website;
const legalRecord = dto.legal_record?.trim() === "" ? undefined : dto.legal_record;
const langCode = dto.lang_code?.trim() === "" ? undefined : dto.lang_code;
const currencyCode = dto.currency_code?.trim() === "" ? undefined : dto.currency_code;
if (errors.length > 0) {
console.error(errors);
return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors));
}
console.debug("Mapped customer props:", {
companyId,
status,
reference,
isCompany,
name,
tradeName,
tinNumber,
address,
emailAddress,
phoneNumber,
faxNumber,
website,
legalRecord,
langCode,
currencyCode,
});
const customerProps: CustomerProps = {
companyId: companyId!,
status: status!,
reference: reference!,
isCompany: isCompany,
name: name!,
tradeName: tradeName!,
tin: tinNumber!,
address: address!,
email: emailAddress!,
phone: phoneNumber!,
fax: faxNumber!,
website: website!,
legalRecord: legalRecord!,
defaultTax: [],
langCode: langCode!,
currencyCode: currencyCode!,
};
return Result.ok({ id: customerId!, props: customerProps });
} catch (err: unknown) {
console.error(err);
return Result.fail(new DomainError("Customer props mapping failed", { cause: err }));
}
}

View File

@ -18,7 +18,7 @@ type CustomerDeps = {
transactionManager: SequelizeTransactionManager; transactionManager: SequelizeTransactionManager;
repo: CustomerRepository; repo: CustomerRepository;
mapper: CustomerMapper; mapper: CustomerMapper;
service: CustomerService; service: ICustomerService;
assemblers: { assemblers: {
list: ListCustomersAssembler; list: ListCustomersAssembler;
get: GetCustomerAssembler; get: GetCustomerAssembler;

View File

@ -1,10 +1,4 @@
import { import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { CreateCustomerRequestDTO } from "../../../../common/dto"; import { CreateCustomerRequestDTO } from "../../../../common/dto";
import { CreateCustomerUseCase } from "../../../application"; import { CreateCustomerUseCase } from "../../../application";
@ -16,18 +10,17 @@ export class CreateCustomerController extends ExpressController {
} }
protected async executeImpl() { protected async executeImpl() {
const tenantId = this.getTenantId()!; // garantizado por tenantGuard const companyId = this.getTenantId()!; // garantizado por tenantGuard
const dto = this.req.body as CreateCustomerRequestDTO; const dto = this.req.body as CreateCustomerRequestDTO;
/*
// Inyectar empresa del usuario autenticado (ownership)
dto.customerCompanyId = user.companyId;
*/
const result = await this.useCase.execute({ tenantId, dto }); // Inyectar empresa del usuario autenticado (ownership)
dto.company_id = companyId.toString();
const result = await this.useCase.execute({ dto });
return result.match( return result.match(
(data) => this.created(data), (data) => this.created(data),
(err) => this.handleApiError(errorMapper.toApiError(err)) (err) => this.handleError(err)
); );
} }
} }

View File

@ -1,17 +0,0 @@
import { SequelizeTransactionManager } from "@erp/core/api";
import { Sequelize } from "sequelize";
import { CustomerMapper } from "../../..";
import { CreateCustomerUseCase, CreateCustomersPresenter } from "../../../../application";
import { CustomerService } from "../../../../domain";
import { CreateCustomerController } from "./create-customer.controller";
export const buildCreateCustomersController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const customerRepository = new customerRepository(database, new CustomerMapper());
const customerService = new CustomerService(customerRepository);
const presenter = new CreateCustomersPresenter();
const useCase = new CreateCustomerUseCase(customerService, transactionManager, presenter);
return new CreateCustomerController(useCase);
};

View File

@ -1,10 +1,4 @@
import { import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { DeleteCustomerUseCase } from "../../../application"; import { DeleteCustomerUseCase } from "../../../application";
export class DeleteCustomerController extends ExpressController { export class DeleteCustomerController extends ExpressController {
@ -22,7 +16,7 @@ export class DeleteCustomerController extends ExpressController {
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(data),
(error) => this.handleApiError(errorMapper.toApiError(error)) (err) => this.handleError(err)
); );
} }
} }

View File

@ -1,9 +0,0 @@
import { ModuleParams } from "@erp/core/api";
import { DeleteCustomerController } from "./delete-customer.controller";
export const buildDeleteCustomerController = (params: ModuleParams) => {
const deps = getCustomerDependencies(params);
const useCase = deps.build.delete();
return new DeleteCustomerController(useCase /*, deps.presenters.delete */);
};

View File

@ -1,10 +1,4 @@
import { import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { GetCustomerUseCase } from "../../../application"; import { GetCustomerUseCase } from "../../../application";
export class GetCustomerController extends ExpressController { export class GetCustomerController extends ExpressController {
@ -22,7 +16,7 @@ export class GetCustomerController extends ExpressController {
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(data),
(error) => this.handleApiError(errorMapper.toApiError(error)) (err) => this.handleError(err)
); );
} }
} }

View File

@ -1,17 +0,0 @@
import { SequelizeTransactionManager } from "@erp/core/api";
import { Sequelize } from "sequelize";
import { CustomerRepository, customerMapper } from "../../..";
import { GetCustomerUseCase, getCustomerPresenter } from "../../../../application";
import { CustomerService } from "../../../../domain";
import { GetCustomerController } from "../get-customer.controller";
export const buildGetCustomerController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const repository = new CustomerRepository(database, customerMapper);
const customerService = new CustomerService(repository);
const presenter = getCustomerPresenter;
const useCase = new GetCustomerUseCase(customerService, transactionManager, presenter);
return new GetCustomerController(useCase);
};

View File

@ -1,10 +1,4 @@
import { import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { ListCustomersUseCase } from "../../../application"; import { ListCustomersUseCase } from "../../../application";
export class ListCustomersController extends ExpressController { export class ListCustomersController extends ExpressController {
@ -20,7 +14,7 @@ export class ListCustomersController extends ExpressController {
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(data),
(err) => this.handleApiError(errorMapper.toApiError(err)) (err) => this.handleError(err)
); );
} }
} }

View File

@ -1,18 +0,0 @@
import { SequelizeTransactionManager } from "@erp/core/api";
import { Sequelize } from "sequelize";
import { CustomerRepository, customerMapper } from "../../..";
import { ListCustomersUseCase } from "../../../../application";
import { listCustomersPresenter } from "../../../../application/list-customers/assembler";
import { CustomerService } from "../../../../domain";
import { ListCustomersController } from "./list-customers.controller";
export const buildListCustomersController = (database: Sequelize) => {
const transactionManager = new SequelizeTransactionManager(database);
const repository = new CustomerRepository(database, customerMapper);
const customerService = new CustomerService(repository);
const presenter = listCustomersPresenter;
const useCase = new ListCustomersUseCase(customerService, transactionManager, presenter);
return new ListCustomersController(useCase);
};

View File

@ -1,3 +1,4 @@
import { enforceTenant, enforceUser, mockUser } from "@erp/auth/api";
import { ILogger, ModuleParams, validateRequest } from "@erp/core/api"; import { ILogger, ModuleParams, validateRequest } from "@erp/core/api";
import { Application, NextFunction, Request, Response, Router } from "express"; import { Application, NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize"; import { Sequelize } from "sequelize";
@ -23,11 +24,17 @@ export const customersRouter = (params: ModuleParams) => {
logger: ILogger; logger: ILogger;
}; };
const router: Router = Router({ mergeParams: true });
const deps = getCustomerDependencies(params); const deps = getCustomerDependencies(params);
const router: Router = Router({ mergeParams: true });
// 🔐 Autenticación + Tenancy para TODO el router // 🔐 Autenticación + Tenancy para TODO el router
if (process.env.NODE_ENV === "development") {
router.use(mockUser); // Debe ir antes de las rutas protegidas
}
//router.use(/*authenticateJWT(),*/ enforceTenant() /*checkTabContext*/); //router.use(/*authenticateJWT(),*/ enforceTenant() /*checkTabContext*/);
router.use([enforceUser(), enforceTenant()]);
router.get( router.get(
"/", "/",

View File

@ -1,9 +1,15 @@
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api"; import {
import { UniqueID, UtcDate } from "@repo/rdx-ddd"; ISequelizeMapper,
MapperParamsType,
SequelizeMapper,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { EmailAddress, PhoneNumber, PostalAddress, TINNumber, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Customer, CustomerNumber, CustomerSerie, CustomerStatus } from "../../domain"; import { Customer, CustomerProps, CustomerStatus } from "../../domain";
import { CustomerCreationAttributes, CustomerModel } from "../sequelize"; import { CustomerCreationAttributes, CustomerModel } from "../sequelize";
import { CustomerItemMapper } from "./customer-item.mapper";
export interface ICustomerMapper export interface ICustomerMapper
extends ISequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer> {} extends ISequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer> {}
@ -12,83 +18,108 @@ export class CustomerMapper
extends SequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer> extends SequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer>
implements ICustomerMapper implements ICustomerMapper
{ {
private customerItemMapper: CustomerItemMapper;
constructor() {
super();
this.customerItemMapper = new CustomerItemMapper(); // Instanciar el mapper de items
}
public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result<Customer, Error> { public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result<Customer, Error> {
const idOrError = UniqueID.create(source.id); try {
const statusOrError = CustomerStatus.create(source.invoice_status); const errors: ValidationErrorDetail[] = [];
const customerSeriesOrError = CustomerSerie.create(source.invoice_series);
const customerNumberOrError = CustomerNumber.create(source.invoice_number);
const issueDateOrError = UtcDate.createFromISO(source.issue_date);
const operationDateOrError = UtcDate.createFromISO(source.operation_date);
const result = Result.combine([ const customerId = extractOrPushError(UniqueID.create(source.id), "id", errors);
idOrError, const companyId = extractOrPushError(
statusOrError, UniqueID.create(source.company_id),
customerSeriesOrError, "company_id",
customerNumberOrError, errors
issueDateOrError,
operationDateOrError,
]);
if (result.isFailure) {
return Result.fail(result.error);
}
// Mapear los items de la factura
const itemsOrErrors = this.customerItemMapper.mapArrayToDomain(source.items, {
sourceParent: source,
...params,
});
if (itemsOrErrors.isFailure) {
return Result.fail(itemsOrErrors.error);
}
const customerCurrency = source.invoice_currency || "EUR";
return Customer.create(
{
status: statusOrError.data,
invoiceSeries: customerSeriesOrError.data,
invoiceNumber: customerNumberOrError.data,
issueDate: issueDateOrError.data,
operationDate: operationDateOrError.data,
currency: customerCurrency,
items: itemsOrErrors.data,
},
idOrError.data
); );
const status = extractOrPushError(CustomerStatus.create(source.status), "status", errors);
const reference = source.reference?.trim() === "" ? undefined : source.reference;
const isCompany = source.is_company ?? true;
const name = source.name?.trim() === "" ? undefined : source.name;
const tradeName = source.trade_name?.trim() === "" ? undefined : source.trade_name;
const tinNumber = extractOrPushError(TINNumber.create(source.tin), "tin", errors);
const address = extractOrPushError(
PostalAddress.create({
street: source.street,
city: source.city,
postalCode: source.postal_code,
state: source.state,
country: source.country,
}),
"address",
errors
);
const emailAddress = extractOrPushError(EmailAddress.create(source.email), "email", errors);
const phoneNumber = extractOrPushError(PhoneNumber.create(source.phone), "phone", errors);
const faxNumber = extractOrPushError(PhoneNumber.create(source.fax), "fax", errors);
const website = source.website?.trim() === "" ? undefined : source.website;
const legalRecord = source.legal_record?.trim() === "" ? undefined : source.legal_record;
const langCode = source.lang_code?.trim() === "" ? undefined : source.lang_code;
const currencyCode = source.currency_code?.trim() === "" ? undefined : source.currency_code;
if (errors.length > 0) {
console.error(errors);
return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors));
}
const customerProps: CustomerProps = {
companyId: companyId!,
status: status!,
reference: reference!,
isCompany: isCompany,
name: name!,
tradeName: tradeName!,
tin: tinNumber!,
address: address!,
email: emailAddress!,
phone: phoneNumber!,
fax: faxNumber!,
website: website!,
legalRecord: legalRecord!,
defaultTax: [],
langCode: langCode!,
currencyCode: currencyCode!,
};
return Customer.create(customerProps, customerId);
} catch (err: unknown) {
return Result.fail(err as Error);
}
} }
public mapToPersistence(source: Customer, params?: MapperParamsType): CustomerCreationAttributes { public mapToPersistence(source: Customer, params?: MapperParamsType): CustomerCreationAttributes {
const subtotal = source.calculateSubtotal();
const total = source.calculateTotal();
const items = this.customerItemMapper.mapCollectionToPersistence(source.items, params);
return { return {
id: source.id.toString(), id: source.id.toPrimitive(),
invoice_status: source.status.toPrimitive(), company_id: source.companyId.toPrimitive(),
invoice_series: source.invoiceSeries.toPrimitive(), reference: source.reference,
invoice_number: source.invoiceNumber.toPrimitive(), is_company: source.isCompany,
issue_date: source.issueDate.toPrimitive(), name: source.name,
operation_date: source.operationDate.toPrimitive(), trade_name: source.tradeName,
invoice_language: "es", tin: source.tin.toPrimitive(),
invoice_currency: source.currency || "EUR",
subtotal_amount: subtotal.amount, email: source.email.toPrimitive(),
subtotal_scale: subtotal.scale, phone: source.phone.toPrimitive(),
fax: source.fax.toPrimitive(),
website: source.website,
total_amount: total.amount, default_tax: source.defaultTax.toString(),
total_scale: total.scale, legal_record: source.legalRecord,
lang_code: source.langCode,
currency_code: source.currencyCode,
items, status: source.isActive ? "active" : "inactive",
street: source.address.street,
street2: source.address.street2,
city: source.address.city,
state: source.address.state,
postal_code: source.address.postalCode,
country: source.address.country,
}; };
} }
} }

View File

@ -1,11 +1,4 @@
import { import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
export type CustomerCreationAttributes = InferCreationAttributes<CustomerModel, {}> & {}; export type CustomerCreationAttributes = InferCreationAttributes<CustomerModel, {}> & {};
@ -20,14 +13,15 @@ export class CustomerModel extends Model<
declare id: string; declare id: string;
declare company_id: string; declare company_id: string;
declare reference: CreationOptional<string>; declare reference: string;
declare is_company: boolean; declare is_company: boolean;
declare name: string; declare name: string;
declare trade_name: CreationOptional<string>; declare trade_name: string;
declare tin: string; declare tin: string;
declare street: string; declare street: string;
declare street2: string;
declare city: string; declare city: string;
declare state: string; declare state: string;
declare postal_code: string; declare postal_code: string;
@ -35,12 +29,12 @@ export class CustomerModel extends Model<
declare email: string; declare email: string;
declare phone: string; declare phone: string;
declare fax: CreationOptional<string>; declare fax: string;
declare website: CreationOptional<string>; declare website: string;
declare legal_record: string; declare legal_record: string;
declare default_tax: number; declare default_tax: string;
declare status: string; declare status: string;
declare lang_code: string; declare lang_code: string;
declare currency_code: string; declare currency_code: string;
@ -64,10 +58,12 @@ export default (database: Sequelize) => {
reference: { reference: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
is_company: { is_company: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: false,
}, },
name: { name: {
type: DataTypes.STRING, type: DataTypes.STRING,
@ -75,38 +71,50 @@ export default (database: Sequelize) => {
}, },
trade_name: { trade_name: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: false,
defaultValue: null, defaultValue: "",
}, },
tin: { tin: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
street: { street: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
},
street2: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
}, },
city: { city: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
state: { state: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
postal_code: { postal_code: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
country: { country: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
email: { email: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
validate: { validate: {
isEmail: true, isEmail: true,
}, },
@ -114,16 +122,17 @@ export default (database: Sequelize) => {
phone: { phone: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
fax: { fax: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: false,
defaultValue: null, defaultValue: "",
}, },
website: { website: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: false,
defaultValue: null, defaultValue: "",
validate: { validate: {
isUrl: true, isUrl: true,
}, },
@ -131,12 +140,13 @@ export default (database: Sequelize) => {
legal_record: { legal_record: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false, allowNull: false,
defaultValue: "",
}, },
default_tax: { default_tax: {
type: new DataTypes.SMALLINT(), type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: 2100, defaultValue: "",
}, },
lang_code: { lang_code: {

View File

@ -1,4 +1,4 @@
import { SequelizeRepository, errorMapper } from "@erp/core/api"; import { SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils"; import { Collection, Result } from "@repo/rdx-utils";
@ -30,10 +30,18 @@ export class CustomerRepository
async save(customer: Customer, transaction: Transaction): Promise<Result<Customer, Error>> { async save(customer: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
try { try {
const data = this.mapper.mapToPersistence(customer); const data = this.mapper.mapToPersistence(customer);
console.debug("Saving customer to database:", data);
const [instance] = await CustomerModel.upsert(data, { transaction, returning: true }); const [instance] = await CustomerModel.upsert(data, { transaction, returning: true });
return this.mapper.mapToDomain(instance);
console.debug("Customer saved successfully:", instance.toJSON());
const savedCustomer = this.mapper.mapToDomain(instance);
console.debug("Mapped customer to domain:", savedCustomer);
return savedCustomer;
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err)); console.error("Error saving customer:", err);
return Result.fail(translateSequelizeError(err));
} }
} }
@ -57,7 +65,7 @@ export class CustomerRepository
}); });
return Result.ok(Boolean(count > 0)); return Result.ok(Boolean(count > 0));
} catch (error: any) { } catch (error: any) {
return Result.fail(errorMapper.toDomainError(error)); return Result.fail(translateSequelizeError(error));
} }
} }
@ -86,7 +94,7 @@ export class CustomerRepository
return this.mapper.mapToDomain(row); return this.mapper.mapToDomain(row);
} catch (error: any) { } catch (error: any) {
return Result.fail(errorMapper.toDomainError(error)); return Result.fail(translateSequelizeError(error));
} }
} }
@ -124,7 +132,7 @@ export class CustomerRepository
return this.mapper.mapArrayToDomain(instances); return this.mapper.mapArrayToDomain(instances);
} catch (err: unknown) { } catch (err: unknown) {
console.error(err); console.error(err);
return Result.fail(errorMapper.toDomainError(err)); return Result.fail(translateSequelizeError(err));
} }
} }
@ -154,7 +162,7 @@ export class CustomerRepository
return Result.ok<void>(); return Result.ok<void>();
} catch (err: unknown) { } catch (err: unknown) {
// , `Error deleting customer ${id} in company ${companyId}` // , `Error deleting customer ${id} in company ${companyId}`
return Result.fail(errorMapper.toDomainError(err)); return Result.fail(translateSequelizeError(err));
} }
} }
} }

View File

@ -2,30 +2,31 @@ import * as z from "zod/v4";
export const CreateCustomerRequestSchema = z.object({ export const CreateCustomerRequestSchema = z.object({
id: z.uuid(), id: z.uuid(),
reference: z.string().optional(), company_id: z.uuid(),
reference: z.string().default(""),
is_company: z.boolean(), is_company: z.boolean().default(true),
name: z.string(), name: z.string().default(""),
trade_name: z.string(), trade_name: z.string().default(""),
tin: z.string(), tin: z.string().default(""),
street: z.string(), street: z.string().default(""),
city: z.string(), city: z.string().default(""),
state: z.string(), state: z.string().default(""),
postal_code: z.string(), postal_code: z.string().default(""),
country: z.string(), country: z.string().default(""),
email: z.string(), email: z.string().default(""),
phone: z.string(), phone: z.string().default(""),
fax: z.string(), fax: z.string().default(""),
website: z.string(), website: z.string().default(""),
legal_record: z.string(), legal_record: z.string().default(""),
default_tax: z.array(z.string()), default_tax: z.array(z.string()).default([]),
status: z.string(), status: z.string().default("active"),
lang_code: z.string(), lang_code: z.string().default("es"),
currency_code: z.string(), currency_code: z.string().default("EUR"),
}); });
export type CreateCustomerRequestDTO = z.infer<typeof CreateCustomerRequestSchema>; export type CreateCustomerRequestDTO = z.infer<typeof CreateCustomerRequestSchema>;

View File

@ -3,14 +3,16 @@ import * as z from "zod/v4";
export const CustomerCreationResponseSchema = z.object({ export const CustomerCreationResponseSchema = z.object({
id: z.uuid(), id: z.uuid(),
company_id: z.uuid(),
reference: z.string(), reference: z.string(),
is_companyr: z.boolean(), is_company: z.boolean(),
name: z.string(), name: z.string(),
trade_name: z.string(), trade_name: z.string(),
tin: z.string(), tin: z.string(),
street: z.string(), street: z.string(),
street2: z.string(),
city: z.string(), city: z.string(),
state: z.string(), state: z.string(),
postal_code: z.string(), postal_code: z.string(),
@ -23,7 +25,7 @@ export const CustomerCreationResponseSchema = z.object({
legal_record: z.string(), legal_record: z.string(),
default_tax: z.number(), default_tax: z.array(z.string()),
status: z.string(), status: z.string(),
lang_code: z.string(), lang_code: z.string(),
currency_code: z.string(), currency_code: z.string(),

View File

@ -28,6 +28,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"], "include": ["src", "../core/src/api/helpers/extract-or-push-error.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

@ -47,7 +47,7 @@ export class Percentage extends ValueObject<IPercentageProps> implements IPercen
const validationResult = Percentage.validate({ amount, scale }); const validationResult = Percentage.validate({ amount, scale });
if (!validationResult.success) { if (!validationResult.success) {
return Result.fail(new Error(validationResult.error.errors.map((e) => e.message).join(", "))); return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", ")));
} }
// Cálculo del valor real del porcentaje // Cálculo del valor real del porcentaje

View File

@ -26,7 +26,7 @@ export class Slug extends ValueObject<SlugProps> {
const valueIsValid = Slug.validate(value); const valueIsValid = Slug.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new Slug({ value: valueIsValid.data! })); return Result.ok(new Slug({ value: valueIsValid.data! }));
} }

View File

@ -11,7 +11,7 @@ export class EmailAddress extends ValueObject<EmailAddressProps> {
const valueIsValid = EmailAddress.validate(value); const valueIsValid = EmailAddress.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new EmailAddress({ value: valueIsValid.data })); return Result.ok(new EmailAddress({ value: valueIsValid.data }));

View File

@ -21,7 +21,7 @@ export class Name extends ValueObject<INameProps> {
const valueIsValid = Name.validate(value); const valueIsValid = Name.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new Name({ value })); return Result.ok(new Name({ value }));
} }

View File

@ -53,7 +53,7 @@ export class Percentage extends ValueObject<IPercentageProps> implements IPercen
const validationResult = Percentage.validate({ amount, scale }); const validationResult = Percentage.validate({ amount, scale });
if (!validationResult.success) { if (!validationResult.success) {
return Result.fail(new Error(validationResult.error.errors.map((e) => e.message).join(", "))); return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", ")));
} }
// Cálculo del valor real del porcentaje // Cálculo del valor real del porcentaje

View File

@ -1,6 +1,5 @@
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Maybe } from "@repo/rdx-utils"; import { isPossiblePhoneNumber, parsePhoneNumberWithError } from "libphonenumber-js";
import { isValidPhoneNumber, parsePhoneNumberWithError } from "libphonenumber-js";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { ValueObject } from "./value-object"; import { ValueObject } from "./value-object";
@ -9,34 +8,49 @@ interface PhoneNumberProps {
} }
export class PhoneNumber extends ValueObject<PhoneNumberProps> { export class PhoneNumber extends ValueObject<PhoneNumberProps> {
static validate(value: string) {
const schema = z
.string()
.optional()
.default("")
.refine(
(value: string) => (value === "" ? true : isPossiblePhoneNumber(value, "ES")),
"Please specify a valid phone number (include the international prefix)."
);
/*.transform((value: string) => {
console.log("transforming value", value);
if (value === "") return "";
try {
const phoneNumber = parsePhoneNumberWithError(value, "ES");
return phoneNumber.formatInternational();
} catch (error) {
console.log(error);
if (error instanceof ParseError) {
// Not a phone number, non-existent country, etc.
console.log(error.message);
} else {
return error;
}
return phoneNumber;
}
})*/
return schema.safeParse(value);
}
static create(value: string): Result<PhoneNumber> { static create(value: string): Result<PhoneNumber> {
const valueIsValid = PhoneNumber.validate(value); const valueIsValid = PhoneNumber.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new PhoneNumber({ value: valueIsValid.data })); return Result.ok(new PhoneNumber({ value: valueIsValid.data }));
} }
static createNullable(value?: string): Result<Maybe<PhoneNumber>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.none<PhoneNumber>());
}
return PhoneNumber.create(value).map((value) => Maybe.some(value));
}
static validate(value: string) {
const schema = z
.string()
.refine(
isValidPhoneNumber,
"Please specify a valid phone number (include the international prefix)."
)
.transform((value: string) => parsePhoneNumberWithError(value).number.toString());
return schema.safeParse(value);
}
getValue(): string { getValue(): string {
return this.props.value; return this.props.value;
} }

View File

@ -11,11 +11,11 @@ const postalCodeSchema = z
message: "Invalid postal code format", message: "Invalid postal code format",
}); });
const streetSchema = z.string().min(2).max(255); const streetSchema = z.string().max(255).default("");
const street2Schema = z.string().optional(); const street2Schema = z.string().default("");
const citySchema = z.string().min(2).max(50); const citySchema = z.string().max(50).default("");
const stateSchema = z.string().min(2).max(50); const stateSchema = z.string().max(50).default("");
const countrySchema = z.string().min(2).max(56); const countrySchema = z.string().max(56).default("");
interface IPostalAddressProps { interface IPostalAddressProps {
street: string; street: string;
@ -44,7 +44,7 @@ export class PostalAddress extends ValueObject<IPostalAddressProps> {
const valueIsValid = PostalAddress.validate(values); const valueIsValid = PostalAddress.validate(values);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new PostalAddress(values)); return Result.ok(new PostalAddress(values));
} }
@ -63,7 +63,7 @@ export class PostalAddress extends ValueObject<IPostalAddressProps> {
): Result<PostalAddress, Error> { ): Result<PostalAddress, Error> {
return PostalAddress.create({ return PostalAddress.create({
street: data.street ?? oldAddress.street, street: data.street ?? oldAddress.street,
street2: data.street2?.getOrUndefined() ?? oldAddress.street2.getOrUndefined(), street2: data.street2 ?? oldAddress.street2,
city: data.city ?? oldAddress.city, city: data.city ?? oldAddress.city,
postalCode: data.postalCode ?? oldAddress.postalCode, postalCode: data.postalCode ?? oldAddress.postalCode,
state: data.state ?? oldAddress.state, state: data.state ?? oldAddress.state,
@ -76,8 +76,8 @@ export class PostalAddress extends ValueObject<IPostalAddressProps> {
return this.props.street; return this.props.street;
} }
get street2(): Maybe<string> { get street2(): string {
return Maybe.fromNullable(this.props.street2); return this.props.street2 ?? "";
} }
get city(): string { get city(): string {

View File

@ -26,7 +26,7 @@ export class Slug extends ValueObject<SlugProps> {
const valueIsValid = Slug.validate(value); const valueIsValid = Slug.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
// biome-ignore lint/style/noNonNullAssertion: <explanation> // biome-ignore lint/style/noNonNullAssertion: <explanation>
return Result.ok(new Slug({ value: valueIsValid.data! })); return Result.ok(new Slug({ value: valueIsValid.data! }));

View File

@ -28,7 +28,7 @@ export class TINNumber extends ValueObject<TINNumberProps> {
const valueIsValid = TINNumber.validate(value); const valueIsValid = TINNumber.validate(value);
if (!valueIsValid.success) { if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.errors[0].message)); return Result.fail(new Error(valueIsValid.error.issues[0].message));
} }
return Result.ok(new TINNumber({ value: valueIsValid.data })); return Result.ok(new TINNumber({ value: valueIsValid.data }));
} }

View File

@ -170,7 +170,7 @@ importers:
version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)) version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3))
ts-jest: ts-jest:
specifier: ^29.2.5 specifier: ^29.2.5
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3) version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
tsconfig-paths: tsconfig-paths:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0 version: 4.2.0
@ -289,6 +289,9 @@ importers:
'@erp/core': '@erp/core':
specifier: workspace:* specifier: workspace:*
version: link:../core version: link:../core
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
'@repo/rdx-ui': '@repo/rdx-ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-ui version: link:../../packages/rdx-ui
@ -332,6 +335,9 @@ importers:
'@biomejs/biome': '@biomejs/biome':
specifier: 1.9.4 specifier: 1.9.4
version: 1.9.4 version: 1.9.4
'@types/express':
specifier: ^4.17.21
version: 4.17.23
'@types/react': '@types/react':
specifier: ^19.1.2 specifier: ^19.1.2
version: 19.1.8 version: 19.1.8
@ -11668,7 +11674,7 @@ snapshots:
ts-interface-checker@0.1.13: {} ts-interface-checker@0.1.13: {}
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3): ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
dependencies: dependencies:
bs-logger: 0.2.6 bs-logger: 0.2.6
ejs: 3.1.10 ejs: 3.1.10
@ -11686,6 +11692,7 @@ snapshots:
'@jest/transform': 29.7.0 '@jest/transform': 29.7.0
'@jest/types': 29.6.3 '@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.27.4) babel-jest: 29.7.0(@babel/core@7.27.4)
esbuild: 0.25.5
jest-util: 29.7.0 jest-util: 29.7.0
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3): ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):