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 { 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 { IAuthService } from "./auth-service.interface";

View File

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

View File

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

View File

@ -23,7 +23,7 @@ export class InvoiceItemDescription extends ValueObject<IInvoiceItemDescriptionP
const valueIsValid = InvoiceItemDescription.validate(value);
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 }));
}

View File

@ -23,7 +23,7 @@ export class InvoiceNumber extends ValueObject<IInvoiceNumberProps> {
const valueIsValid = InvoiceNumber.validate(value);
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 }));
}

View File

@ -23,7 +23,7 @@ export class InvoiceSerie extends ValueObject<IInvoiceSerieProps> {
const valueIsValid = InvoiceSerie.validate(value);
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 }));
}

View File

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

View File

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

View File

@ -1,2 +1,4 @@
export * from "./auth-types";
export * from "./mock-user.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 { RequestWithAuth } from "./auth-types";
@ -9,22 +10,8 @@ export function enforceTenant() {
return (req: RequestWithAuth, res: Response, next: NextFunction) => {
// Validación básica del tenant
if (!req.user || !req.user.companyId) {
return res.status(401).json({ error: "Unauthorized" });
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
}
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";
const database = getDatabase();

View File

@ -1,14 +1,13 @@
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 { ExtractJwt, Strategy as JwtStrategy } from "passport-jwt";
import { TabContext } from "../../domain";
import { IAuthService, ITabContextService } from "../../domain/services";
import { TabContextRequest } from "../express/types";
import { TabContext } from "../../../../../../apps/server/archive/contexts/auth/domain";
import {
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";

View File

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

View File

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

View File

@ -40,6 +40,8 @@ export interface ApiErrorContext {
instance?: string; // p.ej. req.originalUrl
correlationId?: string; // p.ej. header 'x-correlation-id'
method?: string; // GET/POST/PUT/DELETE
userId?: string;
tenantId?: string;
}
// ────────────────────────────────────────────────────────────────────────────────
@ -166,12 +168,18 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// Fallback genérico (500)
function defaultFallback(e: unknown): ApiError {
const message = typeof (e as any)?.message === "string" ? (e as any).message : "Unexpected error";
return new InternalApiError(`Unexpected error: ${message}`);
if (e instanceof ApiError) {
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) {
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,
status: apiError.status,
detail: apiError.detail,
...(ctx?.userId ? { userId: ctx.userId } : {}),
...(ctx?.tenantId ? { tenantId: ctx.tenantId } : {}),
...(ctx?.instance ? { instance: ctx.instance } : {}),
...(ctx?.correlationId ? { correlationId: ctx.correlationId } : {}),
...(ctx?.method ? { method: ctx.method } : {}),

View File

@ -1,6 +1,8 @@
import { Criteria, CriteriaFromUrlConverter } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
import { ApiErrorContext, ApiErrorMapper, toProblemJson } from "./api-error-mapper";
import {
ApiError,
ConflictApiError,
@ -11,45 +13,92 @@ import {
UnavailableApiError,
ValidationApiError,
} from "./errors";
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 };
}
import { GuardFn } from "./express-guards";
export abstract class ExpressController {
protected req!: Request;
protected res!: Response;
protected next!: NextFunction;
protected criteria!: Criteria;
protected url!: URL;
protected errorMapper!: ApiErrorMapper;
// 🔹 Guards configurables por controlador
private guards: GuardFn[] = [];
static errorResponse(apiError: ApiError, res: Response) {
return res.status(apiError.status).json(apiError);
static errorResponse(apiError: ApiError, req: Request, res: Response) {
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 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);
}
protected created<T>(dto?: T) {
protected created<T>(dto?: T, location?: string) {
if (location) this.res.setHeader("Location", location);
return dto
? this.res.status(httpStatus.CREATED).json(dto)
: this.res.sendStatus(httpStatus.CREATED);
@ -60,46 +109,51 @@ export abstract class ExpressController {
}
protected clientError(message: string, errors?: any[] | any) {
return ExpressController.errorResponse(
new ValidationApiError(message, Array.isArray(errors) ? errors : [errors]),
this.res
return this.handleApiError(
new ValidationApiError(message, Array.isArray(errors) ? errors : [errors])
);
}
protected unauthorizedError(message?: string) {
return ExpressController.errorResponse(
new UnauthorizedApiError(message ?? "Unauthorized"),
this.res
);
return this.handleApiError(new UnauthorizedApiError(message ?? "Unauthorized"));
}
protected forbiddenError(message?: string) {
return ExpressController.errorResponse(
new ForbiddenApiError(message ?? "You do not have permission to perform this action."),
this.res
return this.handleApiError(
new ForbiddenApiError(message ?? "You do not have permission to perform this action.")
);
}
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[]) {
return ExpressController.errorResponse(new ValidationApiError(message, errors), this.res);
return this.handleApiError(new ValidationApiError(message, errors));
}
protected unavailableError(message?: string) {
return ExpressController.errorResponse(
new UnavailableApiError(message ?? "Service temporarily unavailable."),
this.res
return this.handleApiError(
new UnavailableApiError(message ?? "Service temporarily unavailable.")
);
}
protected internalServerError(message?: string) {
return ExpressController.errorResponse(
new InternalApiError(message ?? "Internal Server Error"),
this.res
);
return this.handleApiError(new InternalApiError(message ?? "Internal Server Error"));
}
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;
}
// ───────────────────────────────────────────────────────────────────────────
// 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 { GuardContext, GuardFn, guardFail, guardOk } from "./express-controller";
import { Criteria } from "@repo/rdx-criteria/server";
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

View File

@ -1,6 +1,8 @@
import { NextFunction, Request, Response } from "express";
import { ApiErrorMapper, toProblemJson } from "../api-error-mapper";
import { ApiError } from "../errors/api-error";
import { ApiErrorContext, ApiErrorMapper, toProblemJson } from "../api-error-mapper";
// ✅ Construye tu mapper una vez (composition root del adaptador HTTP)
export const apiErrorMapper = ApiErrorMapper.default();
export const globalErrorHandler = async (
error: Error,
@ -8,45 +10,23 @@ export const globalErrorHandler = async (
res: Response,
next: NextFunction
) => {
console.error(`❌ Global unhandled error: ${error.message}`);
// Si ya se envió una respuesta, delegamos al siguiente error handler
if (res.headersSent) {
return next(error);
}
const ctx = {
instance: req.originalUrl,
correlationId: (req.headers["x-correlation-id"] as string) || undefined,
correlationId: req.get("x-correlation-id") || undefined,
method: req.method,
};
} satisfies ApiErrorContext;
const apiError = ApiErrorMapper.map(err, ctx);
const apiError = apiErrorMapper.map(error, ctx);
const body = toProblemJson(apiError, ctx);
// 👇 Log interno con cause/traza (no lo exponemos al cliente)
// logger.error({ err, cause: (err as any)?.cause, ...ctx }, `❌ Unhandled API error: ${error.message}`);
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 "./validate-request";
export * from "./validate-request.middleware";

View File

@ -45,7 +45,7 @@ export const validateRequest = <T extends "body" | "query" | "params">(
if (!schema) {
console.debug("ERROR: Undefined schema!!");
return ExpressController.errorResponse(new InternalApiError("Undefined schema"), res);
return ExpressController.errorResponse(new InternalApiError("Undefined schema"), req, res);
}
try {
@ -62,6 +62,7 @@ export const validateRequest = <T extends "body" | "query" | "params">(
return ExpressController.errorResponse(
new ValidationApiError("Validation failed", validationErrors),
req,
res
);
}
@ -71,12 +72,14 @@ export const validateRequest = <T extends "body" | "query" | "params">(
req[source] = result.data;
}
console.debug(`Request ${source} is valid.`);
next();
} catch (err) {
const error = err as 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,
ListCustomerInvoicesUseCase,
} from "../application";
import { CustomerInvoiceService } from "../domain";
import { CustomerInvoiceService, ICustomerInvoiceService } from "../domain";
import { CustomerInvoiceMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize";
@ -17,7 +17,7 @@ type InvoiceDeps = {
transactionManager: SequelizeTransactionManager;
repo: CustomerInvoiceRepository;
mapper: CustomerInvoiceMapper;
service: CustomerInvoiceService;
service: ICustomerInvoiceService;
assemblers: {
list: ListCustomerInvoicesAssembler;
get: GetCustomerInvoiceAssembler;
@ -38,7 +38,7 @@ type InvoiceDeps = {
let _repo: CustomerInvoiceRepository | null = null;
let _mapper: CustomerInvoiceMapper | null = null;
let _service: CustomerInvoiceService | null = null;
let _service: ICustomerInvoiceService | null = null;
let _assemblers: InvoiceDeps["assemblers"] | null = null;
export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps {

View File

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

View File

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

View File

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

View File

@ -1,10 +1,4 @@
import {
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { ListCustomerInvoicesUseCase } from "../../../application";
export class ListCustomerInvoicesController extends ExpressController {
@ -23,7 +17,7 @@ export class ListCustomerInvoicesController extends ExpressController {
return result.match(
(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 { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
@ -58,7 +58,7 @@ export class CustomerInvoiceRepository
return Result.ok(Boolean(result));
} 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 });
return Result.ok(invoice);
} 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);
} 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);
} 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);
return Result.ok<void>();
} 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 { CustomersCreationResultDTO } from "@erp/customers/common/dto";
import { CustomerCreationResponseDTO } from "../../../../common";
import { Customer } from "../../../domain";
export class CreateCustomersAssembler {
public toDTO(customer: Customer): CustomersCreationResultDTO {
public toDTO(customer: Customer): CustomerCreationResponseDTO {
return {
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(),
customer_number: customer.customerNumber.toString(),
customer_series: customer.customerSeries.toString(),
issue_date: customer.issueDate.toISOString(),
operation_date: customer.operationDate.toISOString(),
language_code: "ES",
currency: "EUR",
email: customer.email.toPrimitive(),
phone: customer.phone.toPrimitive(),
fax: customer.fax.toPrimitive(),
website: customer.website,
//subtotal_price: customer.calculateSubtotal().toPrimitive(),
//total_price: customer.calculateTotal().toPrimitive(),
default_tax: customer.defaultTax,
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: {
entity: "customer",

View File

@ -1,14 +1,14 @@
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 { Transaction } from "sequelize";
import { CreateCustomerRequestDTO } from "../../../common";
import { ICustomerService } from "../../domain";
import { mapDTOToCustomerProps } from "../helpers";
import { mapDTOToCustomerProps } from "../../helpers";
import { CreateCustomersAssembler } from "./assembler";
type CreateCustomerUseCaseInput = {
tenantId: string;
dto: CreateCustomerCommandDTO;
dto: CreateCustomerRequestDTO;
};
export class CreateCustomerUseCase {
@ -19,46 +19,68 @@ export class CreateCustomerUseCase {
) {}
public execute(params: CreateCustomerUseCaseInput) {
const { dto, tenantId: companyId } = params;
const { dto } = params;
const customerPropsOrError = mapDTOToCustomerProps(dto);
if (customerPropsOrError.isFailure) {
return Result.fail(customerPropsOrError.error);
// 1) Mapear DTO → props de dominio
const dtoResult = mapDTOToCustomerProps(dto);
if (dtoResult.isFailure) {
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) {
return Result.fail(customerOrError.error);
// 3) Construir entidad de dominio
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) => {
try {
const duplicateCheck = await this.service.existsById(id, transaction);
console.debug("Built new customer entity:", newCustomer);
if (duplicateCheck.isFailure) {
return Result.fail(duplicateCheck.error);
}
if (duplicateCheck.data) {
return Result.fail(new DuplicateEntityError("Customer", id.toString()));
}
const result = await this.service.save(newCustomer, idCompany, transaction);
if (result.isFailure) {
return Result.fail(result.error);
}
const viewDTO = this.assembler.toDTO(newCustomer);
return Result.ok(viewDTO);
} catch (error: unknown) {
return Result.fail(error as Error);
// 4) Ejecutar bajo transacción: verificar duplicado → persistir → ensamblar vista
return this.transactionManager.complete(async (tx: Transaction) => {
const existsGuard = await this.ensureNotExists(companyId, id, tx);
if (existsGuard.isFailure) {
return Result.fail(existsGuard.error);
}
console.debug("No existing customer with same ID found, proceeding to save.");
const saveResult = await this.service.saveCustomer(newCustomer, tx);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
const viewDTO = this.assembler.toDTO(saveResult.data);
console.debug("Assembled view DTO:", viewDTO);
return Result.ok(viewDTO);
});
}
/**
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,
UniqueID,
} 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 {
companyId: UniqueID;
status: CustomerStatus;
reference: string;
isCompany: boolean;
name: string;
tradeName: string;
tin: TINNumber;
address: PostalAddress;
email: EmailAddress;
phone: PhoneNumber;
fax: PhoneNumber;
website: string;
legalRecord: string;
defaultTax: number;
status: string;
defaultTax: string[];
langCode: string;
currencyCode: string;
tradeName: Maybe<string>;
website: Maybe<string>;
fax: Maybe<PhoneNumber>;
}
export interface ICustomer {
id: UniqueID;
companyId: UniqueID;
reference: string;
name: string;
tin: TINNumber;
name: string;
tradeName: string;
address: PostalAddress;
email: EmailAddress;
phone: PhoneNumber;
fax: PhoneNumber;
website: string;
legalRecord: string;
defaultTax: number;
defaultTax: string[];
langCode: string;
currencyCode: string;
tradeName: Maybe<string>;
fax: Maybe<PhoneNumber>;
website: Maybe<string>;
isIndividual: boolean;
isCompany: boolean;
isActive: boolean;
@ -103,11 +112,11 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
return this.props.phone;
}
get fax(): Maybe<PhoneNumber> {
get fax(): PhoneNumber {
return this.props.fax;
}
get website() {
get website(): string {
return this.props.website;
}
@ -136,6 +145,6 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
}
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.
*/
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.

View File

@ -7,13 +7,6 @@ import { ICustomerService } from "./customer-service.interface";
export class CustomerService implements ICustomerService {
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.
@ -34,14 +27,11 @@ export class CustomerService implements ICustomerService {
/**
* 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.
* @returns Result<Customer, Error> - El agregado guardado o un error si falla la operación.
*/
async saveCustomerInCompany(
customer: Customer,
transaction: any
): Promise<Result<Customer, Error>> {
async saveCustomer(customer: Customer, transaction: any): Promise<Result<Customer, Error>> {
return this.repository.save(customer, transaction);
}
@ -71,11 +61,11 @@ export class CustomerService implements ICustomerService {
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<Customer>, Error> - Colección de clientes o error.
*/
async findCustomersByCriteriaInCompany(
async findCustomerByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: any
): Promise<Result<Collection<Customer>>> {
): Promise<Result<Collection<Customer>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}
@ -123,7 +113,7 @@ export class CustomerService implements ICustomerService {
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;
}
export enum INVOICE_STATUS {
DRAFT = "draft",
EMITTED = "emitted",
SENT = "sent",
RECEIVED = "received",
REJECTED = "rejected",
export enum CUSTOMER_STATUS {
ACTIVE = "active",
INACTIVE = "inactive",
}
export class CustomerStatus extends ValueObject<ICustomerStatusProps> {
private static readonly ALLOWED_STATUSES = ["draft", "emitted", "sent", "received", "rejected"];
private static readonly FIELD = "invoiceStatus";
private static readonly ERROR_CODE = "INVALID_INVOICE_STATUS";
private static readonly ALLOWED_STATUSES = ["active", "inactive"];
private static readonly FIELD = "status";
private static readonly ERROR_CODE = "INVALID_STATUS";
private static readonly TRANSITIONS: Record<string, string[]> = {
draft: [INVOICE_STATUS.EMITTED],
emitted: [INVOICE_STATUS.SENT, INVOICE_STATUS.REJECTED, INVOICE_STATUS.DRAFT],
sent: [INVOICE_STATUS.RECEIVED, INVOICE_STATUS.REJECTED],
received: [],
rejected: [],
active: [CUSTOMER_STATUS.INACTIVE],
inactive: [CUSTOMER_STATUS.ACTIVE],
};
static create(value: string): Result<CustomerStatus, Error> {
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(
new DomainValidationError(CustomerStatus.ERROR_CODE, CustomerStatus.FIELD, detail)
);
}
return Result.ok(
value === "rejected"
? CustomerStatus.createRejected()
: value === "sent"
? CustomerStatus.createSent()
: value === "emitted"
? CustomerStatus.createSent()
: value === ""
? CustomerStatus.createReceived()
: CustomerStatus.createDraft()
value === "active" ? CustomerStatus.createActive() : CustomerStatus.createInactive()
);
}
public static createDraft(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.DRAFT });
public static createActive(): CustomerStatus {
return new CustomerStatus({ value: CUSTOMER_STATUS.ACTIVE });
}
public static createEmitted(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.EMITTED });
public static createInactive(): CustomerStatus {
return new CustomerStatus({ value: CUSTOMER_STATUS.INACTIVE });
}
public static createSent(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.SENT });
}
public static createReceived(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.RECEIVED });
}
public static createRejected(): CustomerStatus {
return new CustomerStatus({ value: INVOICE_STATUS.REJECTED });
isActive(): boolean {
return this.props.value === CUSTOMER_STATUS.ACTIVE;
}
getValue(): string {
@ -82,7 +60,7 @@ export class CustomerStatus extends ValueObject<ICustomerStatusProps> {
transitionTo(nextStatus: string): Result<CustomerStatus, Error> {
if (!this.canTransitionTo(nextStatus)) {
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);

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;
repo: CustomerRepository;
mapper: CustomerMapper;
service: CustomerService;
service: ICustomerService;
assemblers: {
list: ListCustomersAssembler;
get: GetCustomerAssembler;

View File

@ -1,10 +1,4 @@
import {
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { CreateCustomerRequestDTO } from "../../../../common/dto";
import { CreateCustomerUseCase } from "../../../application";
@ -16,18 +10,17 @@ export class CreateCustomerController extends ExpressController {
}
protected async executeImpl() {
const tenantId = this.getTenantId()!; // garantizado por tenantGuard
const companyId = this.getTenantId()!; // garantizado por tenantGuard
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(
(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 {
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { DeleteCustomerUseCase } from "../../../application";
export class DeleteCustomerController extends ExpressController {
@ -22,7 +16,7 @@ export class DeleteCustomerController extends ExpressController {
return result.match(
(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 {
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { GetCustomerUseCase } from "../../../application";
export class GetCustomerController extends ExpressController {
@ -22,7 +16,7 @@ export class GetCustomerController extends ExpressController {
return result.match(
(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 {
ExpressController,
authGuard,
errorMapper,
forbidQueryFieldGuard,
tenantGuard,
} from "@erp/core/api";
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { ListCustomersUseCase } from "../../../application";
export class ListCustomersController extends ExpressController {
@ -20,7 +14,7 @@ export class ListCustomersController extends ExpressController {
return result.match(
(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 { Application, NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize";
@ -23,11 +24,17 @@ export const customersRouter = (params: ModuleParams) => {
logger: ILogger;
};
const router: Router = Router({ mergeParams: true });
const deps = getCustomerDependencies(params);
const router: Router = Router({ mergeParams: true });
// 🔐 Autenticación + Tenancy para TODO el router
//router.use(/* authenticateJWT(), */ enforceTenant() /*checkTabContext*/);
if (process.env.NODE_ENV === "development") {
router.use(mockUser); // Debe ir antes de las rutas protegidas
}
//router.use(/*authenticateJWT(),*/ enforceTenant() /*checkTabContext*/);
router.use([enforceUser(), enforceTenant()]);
router.get(
"/",

View File

@ -1,9 +1,15 @@
import { ISequelizeMapper, MapperParamsType, SequelizeMapper } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import {
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 { Customer, CustomerNumber, CustomerSerie, CustomerStatus } from "../../domain";
import { Customer, CustomerProps, CustomerStatus } from "../../domain";
import { CustomerCreationAttributes, CustomerModel } from "../sequelize";
import { CustomerItemMapper } from "./customer-item.mapper";
export interface ICustomerMapper
extends ISequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer> {}
@ -12,83 +18,108 @@ export class CustomerMapper
extends SequelizeMapper<CustomerModel, CustomerCreationAttributes, Customer>
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> {
const idOrError = UniqueID.create(source.id);
const statusOrError = CustomerStatus.create(source.invoice_status);
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);
try {
const errors: ValidationErrorDetail[] = [];
const result = Result.combine([
idOrError,
statusOrError,
customerSeriesOrError,
customerNumberOrError,
issueDateOrError,
operationDateOrError,
]);
const customerId = extractOrPushError(UniqueID.create(source.id), "id", errors);
const companyId = extractOrPushError(
UniqueID.create(source.company_id),
"company_id",
errors
);
const status = extractOrPushError(CustomerStatus.create(source.status), "status", errors);
const reference = source.reference?.trim() === "" ? undefined : source.reference;
if (result.isFailure) {
return Result.fail(result.error);
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);
}
// 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
);
}
public mapToPersistence(source: Customer, params?: MapperParamsType): CustomerCreationAttributes {
const subtotal = source.calculateSubtotal();
const total = source.calculateTotal();
const items = this.customerItemMapper.mapCollectionToPersistence(source.items, params);
return {
id: source.id.toString(),
invoice_status: source.status.toPrimitive(),
invoice_series: source.invoiceSeries.toPrimitive(),
invoice_number: source.invoiceNumber.toPrimitive(),
issue_date: source.issueDate.toPrimitive(),
operation_date: source.operationDate.toPrimitive(),
invoice_language: "es",
invoice_currency: source.currency || "EUR",
id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(),
reference: source.reference,
is_company: source.isCompany,
name: source.name,
trade_name: source.tradeName,
tin: source.tin.toPrimitive(),
subtotal_amount: subtotal.amount,
subtotal_scale: subtotal.scale,
email: source.email.toPrimitive(),
phone: source.phone.toPrimitive(),
fax: source.fax.toPrimitive(),
website: source.website,
total_amount: total.amount,
total_scale: total.scale,
default_tax: source.defaultTax.toString(),
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 {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
export type CustomerCreationAttributes = InferCreationAttributes<CustomerModel, {}> & {};
@ -20,14 +13,15 @@ export class CustomerModel extends Model<
declare id: string;
declare company_id: string;
declare reference: CreationOptional<string>;
declare reference: string;
declare is_company: boolean;
declare name: string;
declare trade_name: CreationOptional<string>;
declare trade_name: string;
declare tin: string;
declare street: string;
declare street2: string;
declare city: string;
declare state: string;
declare postal_code: string;
@ -35,12 +29,12 @@ export class CustomerModel extends Model<
declare email: string;
declare phone: string;
declare fax: CreationOptional<string>;
declare website: CreationOptional<string>;
declare fax: string;
declare website: string;
declare legal_record: string;
declare default_tax: number;
declare default_tax: string;
declare status: string;
declare lang_code: string;
declare currency_code: string;
@ -64,10 +58,12 @@ export default (database: Sequelize) => {
reference: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
is_company: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
name: {
type: DataTypes.STRING,
@ -75,38 +71,50 @@ export default (database: Sequelize) => {
},
trade_name: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: "",
},
tin: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
street: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
street2: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
city: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
state: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
postal_code: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
country: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
email: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
validate: {
isEmail: true,
},
@ -114,16 +122,17 @@ export default (database: Sequelize) => {
phone: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "",
},
fax: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: "",
},
website: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
allowNull: false,
defaultValue: "",
validate: {
isUrl: true,
},
@ -131,12 +140,13 @@ export default (database: Sequelize) => {
legal_record: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: "",
},
default_tax: {
type: new DataTypes.SMALLINT(),
type: DataTypes.STRING,
allowNull: false,
defaultValue: 2100,
defaultValue: "",
},
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 { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
@ -30,10 +30,18 @@ export class CustomerRepository
async save(customer: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
try {
const data = this.mapper.mapToPersistence(customer);
console.debug("Saving customer to database:", data);
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) {
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));
} 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);
} 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);
} catch (err: unknown) {
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>();
} catch (err: unknown) {
// , `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({
id: z.uuid(),
reference: z.string().optional(),
company_id: z.uuid(),
reference: z.string().default(""),
is_company: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
is_company: z.boolean().default(true),
name: z.string().default(""),
trade_name: z.string().default(""),
tin: z.string().default(""),
street: z.string(),
city: z.string(),
state: z.string(),
postal_code: z.string(),
country: z.string(),
street: z.string().default(""),
city: z.string().default(""),
state: z.string().default(""),
postal_code: z.string().default(""),
country: z.string().default(""),
email: z.string(),
phone: z.string(),
fax: z.string(),
website: z.string(),
email: z.string().default(""),
phone: z.string().default(""),
fax: z.string().default(""),
website: z.string().default(""),
legal_record: z.string(),
legal_record: z.string().default(""),
default_tax: z.array(z.string()),
status: z.string(),
lang_code: z.string(),
currency_code: z.string(),
default_tax: z.array(z.string()).default([]),
status: z.string().default("active"),
lang_code: z.string().default("es"),
currency_code: z.string().default("EUR"),
});
export type CreateCustomerRequestDTO = z.infer<typeof CreateCustomerRequestSchema>;

View File

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

View File

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

View File

@ -47,7 +47,7 @@ export class Percentage extends ValueObject<IPercentageProps> implements IPercen
const validationResult = Percentage.validate({ amount, scale });
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

View File

@ -26,7 +26,7 @@ export class Slug extends ValueObject<SlugProps> {
const valueIsValid = Slug.validate(value);
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! }));
}

View File

@ -11,7 +11,7 @@ export class EmailAddress extends ValueObject<EmailAddressProps> {
const valueIsValid = EmailAddress.validate(value);
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 }));

View File

@ -21,7 +21,7 @@ export class Name extends ValueObject<INameProps> {
const valueIsValid = Name.validate(value);
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 }));
}

View File

@ -53,7 +53,7 @@ export class Percentage extends ValueObject<IPercentageProps> implements IPercen
const validationResult = Percentage.validate({ amount, scale });
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

View File

@ -1,6 +1,5 @@
import { Result } from "@repo/rdx-utils";
import { Maybe } from "@repo/rdx-utils";
import { isValidPhoneNumber, parsePhoneNumberWithError } from "libphonenumber-js";
import { isPossiblePhoneNumber, parsePhoneNumberWithError } from "libphonenumber-js";
import * as z from "zod/v4";
import { ValueObject } from "./value-object";
@ -9,34 +8,49 @@ interface 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> {
const valueIsValid = PhoneNumber.validate(value);
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 }));
}
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 {
return this.props.value;
}

View File

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

View File

@ -26,7 +26,7 @@ export class Slug extends ValueObject<SlugProps> {
const valueIsValid = Slug.validate(value);
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>
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);
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 }));
}

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))
ts-jest:
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:
specifier: ^4.2.0
version: 4.2.0
@ -289,6 +289,9 @@ importers:
'@erp/core':
specifier: workspace:*
version: link:../core
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
'@repo/rdx-ui':
specifier: workspace:*
version: link:../../packages/rdx-ui
@ -332,6 +335,9 @@ importers:
'@biomejs/biome':
specifier: 1.9.4
version: 1.9.4
'@types/express':
specifier: ^4.17.21
version: 4.17.23
'@types/react':
specifier: ^19.1.2
version: 19.1.8
@ -11668,7 +11674,7 @@ snapshots:
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:
bs-logger: 0.2.6
ejs: 3.1.10
@ -11686,6 +11692,7 @@ snapshots:
'@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
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):