Facturas de cliente y clientes

This commit is contained in:
David Arranz 2025-08-25 19:42:56 +02:00
parent 5b7ee437ff
commit 4b93815985
94 changed files with 930 additions and 501 deletions

View File

@ -74,7 +74,7 @@ export class CreateAccountUseCase {
const validatedData: IAccountProps = {
status: AccountStatus.createInactive(),
isFreelancer: dto.is_freelancer,
isFreelancer: dto.is_companyr,
name: dto.name,
tradeName: dto.trade_name ? Maybe.some(dto.trade_name) : Maybe.none(),
tin: tinOrError.data,

View File

@ -46,8 +46,8 @@ export class UpdateAccountUseCase {
const errors: Error[] = [];
const validatedData: Partial<IAccountProps> = {};
if (dto.is_freelancer) {
validatedData.isFreelancer = dto.is_freelancer;
if (dto.is_companyr) {
validatedData.isFreelancer = dto.is_companyr;
}
if (dto.name) {

View File

@ -22,7 +22,7 @@ const mockAccountRepository: IAccountRepository = {
const sampleAccountPrimitives = {
id: "c5743279-e1cf-4dd5-baae-6698c8c6183c",
is_freelancer: false,
is_companyr: false,
name: "Empresa XYZ",
trade_name: "XYZ Trading",
tin: "123456789",
@ -72,7 +72,7 @@ const accountBuilder = (accountData: any) => {
const validatedData: IAccountProps = {
status: AccountStatus.createInactive(),
isFreelancer: sampleAccountPrimitives.is_freelancer,
isFreelancer: sampleAccountPrimitives.is_companyr,
name: sampleAccountPrimitives.name,
tradeName: sampleAccountPrimitives.trade_name
? Maybe.some(sampleAccountPrimitives.trade_name)

View File

@ -14,7 +14,7 @@ const mockAccountRepository: IAccountRepository = {
const sampleAccount = {
id: "c5743279-e1cf-4dd5-baae-6698c8c6183c",
is_freelancer: false,
is_companyr: false,
name: "Empresa XYZ",
trade_name: "XYZ Trading",
tin: "123456789",

View File

@ -53,7 +53,7 @@ export class AccountMapper
return Account.create(
{
status: statusOrError.data,
isFreelancer: source.is_freelancer,
isFreelancer: source.is_companyr,
name: source.name,
tradeName: source.trade_name ? Maybe.some(source.trade_name) : Maybe.none(),
tin: tinOrError.data,
@ -75,7 +75,7 @@ export class AccountMapper
public mapToPersistence(source: Account, params?: MapperParamsType): AccountCreationAttributes {
return {
id: source.id.toPrimitive(),
is_freelancer: source.isFreelancer,
is_companyr: source.isFreelancer,
name: source.name,
trade_name: source.tradeName.getOrUndefined(),
tin: source.tin.toPrimitive(),

View File

@ -17,7 +17,7 @@ export class AccountModel extends Model<InferAttributes<AccountModel>, AccountCr
declare id: string;
declare is_freelancer: boolean;
declare is_companyr: boolean;
declare name: string;
declare trade_name: CreationOptional<string>;
declare tin: string;
@ -49,7 +49,7 @@ export default (sequelize: Sequelize) => {
type: DataTypes.UUID,
primaryKey: true,
},
is_freelancer: {
is_companyr: {
type: DataTypes.BOOLEAN,
allowNull: false,
},

View File

@ -10,7 +10,7 @@ export const createAccountPresenter: ICreateAccountPresenter = {
toDTO: (account: Account): ICreateAccountResponseDTO => ({
id: ensureString(account.id.toString()),
is_freelancer: ensureBoolean(account.isFreelancer),
is_companyr: ensureBoolean(account.isFreelancer),
name: ensureString(account.name),
trade_name: ensureString(account.tradeName.getOrUndefined()),
tin: ensureString(account.tin.toString()),

View File

@ -10,7 +10,7 @@ export const getAccountPresenter: IGetAccountPresenter = {
toDTO: (account: Account): IGetAccountResponseDTO => ({
id: ensureString(account.id.toPrimitive()),
is_freelancer: ensureBoolean(account.isFreelancer),
is_companyr: ensureBoolean(account.isFreelancer),
name: ensureString(account.name),
trade_name: ensureString(account.tradeName.getOrUndefined()),
tin: ensureString(account.tin.toPrimitive()),

View File

@ -11,7 +11,7 @@ export const listAccountsPresenter: IListAccountsPresenter = {
accounts.map((account) => ({
id: ensureString(account.id.toString()),
is_freelancer: ensureBoolean(account.isFreelancer),
is_companyr: ensureBoolean(account.isFreelancer),
name: ensureString(account.name),
trade_name: ensureString(account.tradeName.getOrUndefined()),
tin: ensureString(account.tin.toString()),

View File

@ -10,7 +10,7 @@ export const updateAccountPresenter: IUpdateAccountPresenter = {
toDTO: (account: Account): IUpdateAccountResponseDTO => ({
id: ensureString(account.id.toString()),
is_freelancer: ensureBoolean(account.isFreelancer),
is_companyr: ensureBoolean(account.isFreelancer),
name: ensureString(account.name),
trade_name: ensureString(account.tradeName.getOrUndefined()),
tin: ensureString(account.tin.toString()),

View File

@ -1,8 +1,8 @@
export type IListAccountsRequestDTO = {}
export type IListAccountsRequestDTO = {};
export interface ICreateAccountRequestDTO {
id: string;
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;
@ -27,7 +27,7 @@ export interface ICreateAccountRequestDTO {
}
export interface IUpdateAccountRequestDTO {
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;

View File

@ -1,7 +1,7 @@
export interface IListAccountsResponseDTO {
id: string;
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;
@ -29,7 +29,7 @@ export interface IListAccountsResponseDTO {
export interface IGetAccountResponseDTO {
id: string;
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;
@ -57,7 +57,7 @@ export interface IGetAccountResponseDTO {
export interface ICreateAccountResponseDTO {
id: string;
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;
@ -88,7 +88,7 @@ export interface ICreateAccountResponseDTO {
export interface IUpdateAccountResponseDTO {
id: string;
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;

View File

@ -7,7 +7,7 @@ export const IGetAccountRequestSchema = z.object({});
export const ICreateAccountRequestSchema = z.object({
id: z.string(),
is_freelancer: z.boolean(),
is_companyr: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
@ -35,7 +35,7 @@ export const ICreateAccountRequestSchema = z.object({
export const IUpdateAccountRequestSchema = z.object({
id: z.string(),
is_freelancer: z.boolean(),
is_companyr: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),

View File

@ -50,7 +50,7 @@ export class ContactMapper
return Contact.create(
{
isFreelancer: source.is_freelancer,
isFreelancer: source.is_companyr,
reference: source.reference,
name: source.name,
tradeName: source.trade_name ? Maybe.some(source.trade_name) : Maybe.none(),
@ -77,7 +77,7 @@ export class ContactMapper
return Result.ok({
id: source.id.toString(),
reference: source.reference,
is_freelancer: source.isFreelancer,
is_companyr: source.isFreelancer,
name: source.name,
trade_name: source.tradeName.getOrUndefined(),
tin: source.tin.toString(),

View File

@ -21,7 +21,7 @@ export class ContactModel extends Model<
declare id: string;
declare reference: CreationOptional<string>;
declare is_freelancer: boolean;
declare is_companyr: boolean;
declare name: string;
declare trade_name: CreationOptional<string>;
declare tin: string;
@ -56,7 +56,7 @@ export default (sequelize: Sequelize) => {
type: DataTypes.STRING,
allowNull: false,
},
is_freelancer: {
is_companyr: {
type: DataTypes.BOOLEAN,
allowNull: false,
},

View File

@ -12,7 +12,7 @@ export const listContactsPresenter: IListContactsPresenter = {
id: ensureString(contact.id.toString()),
reference: ensureString(contact.reference),
is_freelancer: ensureBoolean(contact.isFreelancer),
is_companyr: ensureBoolean(contact.isFreelancer),
name: ensureString(contact.name),
trade_name: ensureString(contact.tradeName.getOrUndefined()),
tin: ensureString(contact.tin.toString()),

View File

@ -2,7 +2,7 @@ export interface IListContactsResponseDTO {
id: string;
reference: string;
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;

View File

@ -21,7 +21,7 @@ export class CustomerModel extends Model<
declare id: string;
declare reference: CreationOptional<string>;
declare is_freelancer: boolean;
declare is_companyr: boolean;
declare name: string;
declare trade_name: CreationOptional<string>;
declare tin: string;
@ -56,7 +56,7 @@ export default (sequelize: Sequelize) => {
type: DataTypes.STRING,
allowNull: false,
},
is_freelancer: {
is_companyr: {
type: DataTypes.BOOLEAN,
allowNull: false,
},

View File

@ -13,7 +13,7 @@ export const listCustomerInvoicesPresenter: IListCustomerInvoicesPresenter = {
id: ensureString(customer.id.toString()),
/*reference: ensureString(customer.),
is_freelancer: ensureBoolean(customer.isFreelancer),
is_companyr: ensureBoolean(customer.isFreelancer),
name: ensureString(customer.name),
trade_name: ensureString(customer.tradeName.getValue()),
tin: ensureString(customer.tin.toString()),

View File

@ -2,7 +2,7 @@ export interface IListCustomerInvoicesResponseDTO {
id: string;
/*reference: string;
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;
@ -26,4 +26,4 @@ export interface IListCustomerInvoicesResponseDTO {
currency_code: string;*/
}
export type IGetCustomerInvoiceResponseDTO = {}
export type IGetCustomerInvoiceResponseDTO = {};

View File

@ -2,7 +2,7 @@ export interface IListCustomersResponseDTO {
id: string;
reference: string;
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;

View File

@ -79,7 +79,7 @@
"entry": ["src/index.ts"],
"outDir": "dist",
"format": ["esm", "cjs"],
"target": "es2020",
"target": "ES2022",
"sourcemap": true,
"clean": true,
"dts": true,

View File

@ -33,7 +33,7 @@ export const App = () => {
baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken,
onAuthError: () => {
console.error("Error de autenticación");
console.error("APP, Error de autenticación");
clearAccessToken();
//window.location.href = "/login"; // o usar navegación programática
},

View File

@ -5,9 +5,9 @@
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

View File

@ -1,4 +1,4 @@
export interface IListInvoicesRequestDTO {}
export type IListInvoicesRequestDTO = {};
export interface ICreateInvoiceRequestDTO {
id: string;
@ -12,7 +12,7 @@ export interface ICreateInvoiceRequestDTO {
}
export interface IUpdateInvoiceRequestDTO {
is_freelancer: boolean;
is_companyr: boolean;
name: string;
trade_name: string;
tin: string;

View File

@ -7,9 +7,9 @@
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

View File

@ -0,0 +1,12 @@
/**
* Errores de capa de aplicación. No deben "filtrarse" a cliente tal cual.
*
* */
export class ApplicationError extends Error {
public readonly layer = "application" as const;
constructor(message: string, options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@ -0,0 +1 @@
export * from "./application-error";

View File

@ -0,0 +1 @@
export * from "./errors";

View File

@ -0,0 +1,12 @@
/**
* Errores de capa de dominio. No deben "filtrarse" a cliente tal cual.
*
* */
export class DomainError extends Error {
public readonly layer = "domain" as const;
constructor(message: string, options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@ -0,0 +1,52 @@
/**
* Clase DomainValidationError
* Representa un error de validación de dominio.
*
* Esta clase extiende la clase Error de JavaScript y se utiliza para manejar errores
* específicos de validación dentro del dominio de la aplicación. Permite identificar
* el código de error, el campo afectado y un detalle descriptivo del error.
*
* @class DomainValidationError
* @extends {Error}
* @property {string} code - Código del error de validación.
* @property {string} field - Campo afectado por el error de validación.
* @property {string} detail - Detalle descriptivo del error de validación.
*
* @example
* const error = new DomainValidationError("INVALID_EMAIL", "email", "El email no es válido");
* console.error(error);
*/
import { DomainError } from "./domain-error";
export class DomainValidationError extends DomainError {
// Discriminante estable para mapeo/telemetría
public readonly kind = "VALIDATION" as const;
constructor(
public readonly code: string, // id de regla del negocio (ej. 'INVALID_FORMAT')
public readonly field: string, // path: 'number' | 'date' | 'lines[0].quantity'
public readonly detail: string, // mensaje legible del negocio
options?: ErrorOptions
) {
super(`[${field}] ${detail}`, options);
this.name = "DomainValidationError";
Object.freeze(this);
}
// Constructores rápidos
static required(field: string, options?: ErrorOptions) {
return new DomainValidationError("REQUIRED", field, "cannot be empty", options);
}
static invalidFormat(field: string, detail = "invalid format", options?: ErrorOptions) {
return new DomainValidationError("INVALID_FORMAT", field, detail, options);
}
// Proyección útil para Problem+JSON o colecciones
toDetail() {
return { path: this.field, message: this.detail, rule: this.code };
}
}
export const isDomainValidationError = (e: unknown): e is DomainValidationError =>
e instanceof DomainValidationError;

View File

@ -0,0 +1,11 @@
import { DomainError } from "./domain-error";
export class DuplicateEntityError extends DomainError {
constructor(entity: string, field: string, value: string, options?: ErrorOptions) {
super(`Entity '${entity}' with field '${field}' and value '${value}' already exists.`, options);
this.name = "DuplicateEntityError";
}
}
export const isDuplicateEntityError = (e: unknown): e is DuplicateEntityError =>
e instanceof DuplicateEntityError;

View File

@ -0,0 +1,11 @@
import { DomainError } from "./domain-error";
export class EntityNotFoundError extends DomainError {
constructor(entity: string, field: string, value: string, options?: ErrorOptions) {
super(`Entity '${entity}' with ${field} '${value}' was not found.`, options);
this.name = "EntityNotFoundError";
}
}
export const isEntityNotFoundError = (e: unknown): e is EntityNotFoundError =>
e instanceof EntityNotFoundError;

View File

@ -0,0 +1,5 @@
export * from "./domain-error";
export * from "./domain-validation-error";
export * from "./duplicate-entity-error";
export * from "./entity-not-found-error";
export * from "./validation-error-collection";

View File

@ -15,19 +15,30 @@
*
*/
import { DomainError } from "./domain-error";
export interface ValidationErrorDetail {
path: string; // ejemplo: "lines[1].unitPrice.amount"
message: string; // ejemplo: "Amount must be a positive number"
message: string; // ejemplo: "Amount must be a positive number",
}
export class ValidationErrorCollection extends Error {
/**
* Error de validación múltiple. Agrega varios fallos de una sola vez.
*/
export class ValidationErrorCollection extends DomainError {
public readonly code = "VALIDATION" as const;
public readonly details: ValidationErrorDetail[];
constructor(details: ValidationErrorDetail[]) {
super("Validation failed");
constructor(message: string, details: ValidationErrorDetail[], options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, ValidationErrorCollection.prototype);
this.name = "ValidationErrorCollection";
this.details = details;
Object.freeze(this);
}
}
export const isValidationErrorCollection = (e: unknown): e is ValidationErrorCollection =>
e instanceof ValidationErrorCollection;

View File

@ -0,0 +1 @@
export * from "./errors";

View File

@ -1,29 +0,0 @@
/**
* Clase DomainValidationError
* Representa un error de validación de dominio.
*
* Esta clase extiende la clase Error de JavaScript y se utiliza para manejar errores
* específicos de validación dentro del dominio de la aplicación. Permite identificar
* el código de error, el campo afectado y un detalle descriptivo del error.
*
* @class DomainValidationError
* @extends {Error}
* @property {string} code - Código del error de validación.
* @property {string} field - Campo afectado por el error de validación.
* @property {string} detail - Detalle descriptivo del error de validación.
*
* @example
* const error = new DomainValidationError("INVALID_EMAIL", "email", "El email no es válido");
* console.error(error);
*/
export class DomainValidationError extends Error {
constructor(
public readonly code: string,
public readonly field: string,
public readonly detail: string
) {
super(`[${field}] ${detail}`);
this.name = "DomainValidationError";
}
}

View File

@ -1,6 +0,0 @@
export class DuplicateEntityError extends Error {
constructor(entity: string, id: string) {
super(`Entity '${entity}' with ID '${id}' already exists.`);
this.name = "DuplicateEntityError";
}
}

View File

@ -1,6 +0,0 @@
export class EntityNotFoundError extends Error {
constructor(entity: string, id: string | number) {
super(`Entity '${entity}' with ID '${id}' was not found.`);
this.name = "EntityNotFoundError";
}
}

View File

@ -1,136 +0,0 @@
import {
ConnectionError,
DatabaseError,
ForeignKeyConstraintError,
UniqueConstraintError,
ValidationError,
} from "sequelize";
import { ApiError } from "./api-error";
import { ConflictApiError } from "./conflict-api-error";
import { DomainValidationError } from "./domain-validation-error";
import { DuplicateEntityError } from "./duplicate-entity-error";
import { EntityNotFoundError } from "./entity-not-found-error";
import { ForbiddenApiError } from "./forbidden-api-error";
import { InternalApiError } from "./internal-api-error";
import { NotFoundApiError } from "./not-found-api-error";
import { UnauthorizedApiError } from "./unauthorized-api-error";
import { UnavailableApiError } from "./unavailable-api-error";
import { ValidationApiError } from "./validation-api-error";
import { ValidationErrorCollection } from "./validation-error-collection";
export const errorMapper = {
toDomainError(error: unknown): Error {
if (error instanceof UniqueConstraintError) {
const field = error.errors[0]?.path || "unknown_field";
return new Error(`A record with this ${field} already exists.`);
}
if (error instanceof ForeignKeyConstraintError) {
return new Error("A referenced entity was not found or is invalid.");
}
if (error instanceof ValidationError) {
return new Error(`Invalid data provided: ${error.message}`);
}
if (error instanceof DatabaseError) {
return new Error("Database error occurred.");
}
if (error instanceof Error) {
return error; // Fallback a error estándar
}
return new Error("Unknown persistence error.");
},
/**
* Mapea errores de la aplicación a errores de la API.
*
* Esta función toma un error de la aplicación y lo convierte en un objeto ApiError
* adecuado para enviar como respuesta HTTP. Maneja errores comunes como validación,
* conflictos, no encontrados, autenticación y errores de infraestructura.
*
* @param error - El error de la aplicación a mapear.
* @returns Un objeto ApiError que representa el error mapeado.
* @example
* const error = new Error("Invalid input");
* const apiError = errorMapper.toApiError(error);
* console.log(apiError);
* // Output: ValidationApiError { status: 422, title: 'Validation Failed', detail: 'Invalid input', type: 'https://httpstatuses.com/422' }
* @throws {ApiError} Si el error no puede ser mapeado a un tipo conocido.
* @see ApiError
* @see ValidationApiError
*/
toApiError: (error: Error): ApiError => {
const message = error.message || "An unexpected error occurred";
// 1. 🔍 Errores de validación complejos (agrupados)
if (error instanceof ValidationErrorCollection) {
return new ValidationApiError(error.message, error.details);
}
// 2. 🔍 Errores individuales de validación de dominio
if (error instanceof DomainValidationError) {
return new ValidationApiError(error.detail, [{ path: error.field, message: error.detail }]);
}
if (error instanceof DuplicateEntityError) {
return new ConflictApiError(error.message);
}
if (error instanceof EntityNotFoundError) {
return new NotFoundApiError(error.message);
}
// 3. 🔍 Errores individuales de validación
if (
message.includes("invalid") ||
message.includes("is not valid") ||
message.includes("must be") ||
message.includes("cannot be") ||
message.includes("empty")
) {
return new ValidationApiError(message);
}
// 4. 🔍 Recurso no encontrado
if (error.name === "NotFoundError" || message.includes("not found")) {
return new NotFoundApiError(message);
}
// 5. 🔍 Conflicto (por ejemplo, duplicado)
if (
error.name === "ConflictError" ||
error instanceof UniqueConstraintError ||
message.includes("already exists") ||
message.includes("duplicate key")
) {
return new ConflictApiError(message);
}
// 6. 🔍 No autenticado
if (error.name === "UnauthorizedError" || message.includes("unauthorized")) {
return new UnauthorizedApiError(message);
}
// 7. 🔍 Prohibido
if (error.name === "ForbiddenError" || message.includes("forbidden")) {
return new ForbiddenApiError(message);
}
// 8. 🔍 Error de conexión o indisponibilidad de servicio
if (
error instanceof ConnectionError ||
message.includes("Database connection lost") ||
message.includes("timeout") ||
message.includes("ECONNREFUSED")
) {
return new UnavailableApiError("Service temporarily unavailable.");
}
// 9. 🔍 Fallback: error no identificado
return new InternalApiError(`Unexpected error: ${message}`);
},
};

View File

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

View File

@ -0,0 +1,3 @@
export * from "./infrastructure-errors";
export * from "./infrastructure-repository-error";
export * from "./infrastructure-unavailable-error";

View File

@ -0,0 +1,11 @@
/**
* Errores de capa de infraestructura. No deben "filtrarse" a cliente tal cual.
* Se usan para decidir el código HTTP y para observabilidad (logs/tracing). */
export class InfrastructureError extends Error {
public readonly layer = "infrastructure" as const;
constructor(message: string, options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@ -0,0 +1,11 @@
import { InfrastructureError } from "./infrastructure-errors";
export class InfrastructureRepositoryError extends InfrastructureError {
public readonly code = "REPOSITORY_ERROR" as const;
constructor(message = "Repository operation failed", options?: ErrorOptions) {
super(message, options);
}
}
export const isInfrastructureRepositoryError = (e: unknown): e is InfrastructureRepositoryError =>
e instanceof InfrastructureRepositoryError;

View File

@ -0,0 +1,11 @@
import { InfrastructureError } from "./infrastructure-errors";
export class InfrastructureUnavailableError extends InfrastructureError {
public readonly code = "UNAVAILABLE" as const;
constructor(message = "Underlying service temporarily unavailable", options?: ErrorOptions) {
super(message, options);
}
}
export const isInfrastructureUnavailableError = (e: unknown): e is InfrastructureUnavailableError =>
e instanceof InfrastructureUnavailableError;

View File

@ -0,0 +1,188 @@
// Clase para mapear errores de Dominio/Aplicación/Infraestructura → ApiError (Problem+JSON).
// - Inmutable (register() devuelve una NUEVA instancia).
// - Extensible por reglas con prioridad.
// - Sin dependencias de vendors (p.ej. Sequelize).
//
// ─ Convenciones de carpetas sugeridas:
// domain/errors/* → errores semánticos (DDD)
// application/errors/* → errores de aplicación (servicios, lógica de negocio)
// infrastructure/errors/* → errores técnicos (DB, red, timeouts)
// infrastructure/express/errors/* → ApiError (RFC7807) y familia
//
// Nota: Todos los nombres de tipos/clases/archivos en inglés; comentarios en castellano.
import {
DomainValidationError,
DuplicateEntityError,
EntityNotFoundError,
ValidationErrorCollection,
isDomainValidationError,
isDuplicateEntityError,
isEntityNotFoundError,
isValidationErrorCollection,
} from "../../domain";
import { isInfrastructureRepositoryError, isInfrastructureUnavailableError } from "../errors";
import {
ApiError,
ConflictApiError,
ForbiddenApiError,
InternalApiError,
NotFoundApiError,
UnauthorizedApiError,
UnavailableApiError,
ValidationApiError,
} from "./errors";
// ────────────────────────────────────────────────────────────────────────────────
// Contexto opcional para enriquecer Problem+JSON (útil en middleware Express)
// ────────────────────────────────────────────────────────────────────────────────
export interface ApiErrorContext {
instance?: string; // p.ej. req.originalUrl
correlationId?: string; // p.ej. header 'x-correlation-id'
method?: string; // GET/POST/PUT/DELETE
}
// ────────────────────────────────────────────────────────────────────────────────
// Regla de mapeo: cómo reconocer y construir un ApiError
// ────────────────────────────────────────────────────────────────────────────────
export interface ErrorToApiRule {
priority?: number; // mayor valor ⇒ se evalúa antes (default 0)
matches: (e: unknown) => boolean;
build: (e: unknown, ctx?: ApiErrorContext) => ApiError;
}
// ────────────────────────────────────────────────────────────────────────────────
// ApiErrorMapper (inmutable)
// ────────────────────────────────────────────────────────────────────────────────
export class ApiErrorMapper {
private readonly rules: ReadonlyArray<ErrorToApiRule>;
private readonly fallback: (e: unknown, ctx?: ApiErrorContext) => ApiError;
private constructor(
rules: ReadonlyArray<ErrorToApiRule>,
fallback: (e: unknown, ctx?: ApiErrorContext) => ApiError
) {
this.rules = [...rules].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
this.fallback = fallback;
Object.freeze(this);
}
// Crea un mapper con reglas por defecto (cubren casos comunes)
static default(): ApiErrorMapper {
return new ApiErrorMapper(defaultRules, defaultFallback);
}
// Registra una regla adicional devolviendo una NUEVA instancia
register(rule: ErrorToApiRule): ApiErrorMapper {
return new ApiErrorMapper([...this.rules, rule], this.fallback);
}
// Mapea un error a un ApiError evaluando reglas por prioridad
map(error: unknown, ctx?: ApiErrorContext): ApiError {
for (const rule of this.rules) {
try {
if (rule.matches(error)) {
return rule.build(error, ctx);
}
} catch {
// ⚠️ Una regla no debe tumbar el mapper; continuamos con la siguiente.
// continue
}
}
return this.fallback(error, ctx);
}
// Útil en tests / introspección
getRules(): ReadonlyArray<ErrorToApiRule> {
return this.rules;
}
}
// ────────────────────────────────────────────────────────────────────────────────
// Reglas por defecto (prioridad alta a más específicas)
// ────────────────────────────────────────────────────────────────────────────────
const defaultRules: ReadonlyArray<ErrorToApiRule> = [
// 1) Validación múltiple (colección)
{
priority: 100,
matches: (e) => isValidationErrorCollection(e),
build: (e) =>
new ValidationApiError(
(e as ValidationErrorCollection).message,
(e as ValidationErrorCollection).details
),
},
// 2) Validación de dominio unitaria
{
priority: 90,
matches: (e) => isDomainValidationError(e),
build: (e) =>
new ValidationApiError((e as DomainValidationError).detail, [
{ path: (e as DomainValidationError).field, message: (e as DomainValidationError).detail },
]),
},
// 3) Duplicados / conflictos de unicidad
{
priority: 80,
matches: (e) => isDuplicateEntityError(e),
build: (e) => new ConflictApiError((e as DuplicateEntityError).message),
},
// 4) No encontrado
{
priority: 70,
matches: (e) => isEntityNotFoundError(e),
build: (e) => new NotFoundApiError((e as EntityNotFoundError).message),
},
// 5) Infra transitoria (DB/servicio caído, timeouts)
{
priority: 60,
matches: (e) => isInfrastructureUnavailableError(e),
build: () => new UnavailableApiError("Service temporarily unavailable."),
},
// 6) Infra no transitoria (errores de repositorio inesperados)
{
priority: 50,
matches: (e) => isInfrastructureRepositoryError(e),
build: () => new InternalApiError("Unexpected repository error."),
},
// 7) Autenticación/autorización por nombre (si no tienes clases dedicadas)
{
priority: 40,
matches: (e): e is Error => e instanceof Error && e.name === "UnauthorizedError",
build: (e) => new UnauthorizedApiError((e as Error).message || "Unauthorized"),
},
{
priority: 40,
matches: (e): e is Error => e instanceof Error && e.name === "ForbiddenError",
build: (e) => new ForbiddenApiError((e as Error).message || "Forbidden"),
},
];
// 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}`);
}
// ────────────────────────────────────────────────────────────────────────────────
// Serializador opcional a Problem+JSON (si tu ApiError no lo trae ya)
// ────────────────────────────────────────────────────────────────────────────────
export function toProblemJson(apiError: ApiError, ctx?: ApiErrorContext) {
const maybeErrors = (apiError as any).errors ? { errors: (apiError as any).errors } : {};
return {
type: apiError.type,
title: apiError.title,
status: apiError.status,
detail: apiError.detail,
...(ctx?.instance ? { instance: ctx.instance } : {}),
...(ctx?.correlationId ? { correlationId: ctx.correlationId } : {}),
...(ctx?.method ? { method: ctx.method } : {}),
...maybeErrors,
};
}

View File

@ -1,3 +1,5 @@
import { InfrastructureError } from "../../errors";
interface IApiErrorOptions {
status: number;
title: string;
@ -8,7 +10,7 @@ interface IApiErrorOptions {
[key: string]: any; // Para permitir añadir campos extra
}
export class ApiError extends Error {
export class ApiError extends InfrastructureError {
public status: number;
public title: string;
public detail: string;

View File

@ -9,4 +9,4 @@ export class ConflictApiError extends ApiError {
type: "https://httpstatuses.com/409",
});
}
}
}

View File

@ -9,4 +9,4 @@ export class ForbiddenApiError extends ApiError {
type: "https://httpstatuses.com/403",
});
}
}
}

View File

@ -1,13 +1,8 @@
export * from "./api-error";
export * from "./conflict-api-error";
export * from "./domain-validation-error";
export * from "./duplicate-entity-error";
export * from "./entity-not-found-error";
export * from "./error-mapper";
export * from "./forbidden-api-error";
export * from "./internal-api-error";
export * from "./not-found-api-error";
export * from "./unauthorized-api-error";
export * from "./unavailable-api-error";
export * from "./validation-api-error";
export * from "./validation-error-collection";

View File

@ -9,4 +9,4 @@ export class InternalApiError extends ApiError {
type: "https://httpstatuses.com/500",
});
}
}
}

View File

@ -9,4 +9,4 @@ export class NotFoundApiError extends ApiError {
type: "https://httpstatuses.com/404",
});
}
}
}

View File

@ -9,4 +9,4 @@ export class UnauthorizedApiError extends ApiError {
type: "https://httpstatuses.com/401",
});
}
}
}

View File

@ -9,4 +9,4 @@ export class UnavailableApiError extends ApiError {
type: "https://httpstatuses.com/503",
});
}
}
}

View File

@ -10,4 +10,4 @@ export class ValidationApiError extends ApiError {
errors,
});
}
}
}

View File

@ -10,7 +10,7 @@ import {
UnauthorizedApiError,
UnavailableApiError,
ValidationApiError,
} from "../../errors";
} from "./errors";
type GuardResultLike = { isFailure: boolean; error?: ApiError };
export type GuardContext = {

View File

@ -1,4 +1,4 @@
import { ForbiddenApiError, UnauthorizedApiError, ValidationApiError } from "../../errors";
import { ForbiddenApiError, UnauthorizedApiError, ValidationApiError } from "./errors";
import { GuardContext, GuardFn, guardFail, guardOk } from "./express-controller";
// ───────────────────────────────────────────────────────────────────────────

View File

@ -1,3 +1,5 @@
export * from "./api-error-mapper";
export * from "./errors";
export * from "./express-controller";
export * from "./express-guards";
export * from "./middlewares";

View File

@ -1,5 +1,6 @@
import { NextFunction, Request, Response } from "express";
import { ApiError } from "../../../errors/api-error";
import { ApiErrorMapper, toProblemJson } from "../api-error-mapper";
import { ApiError } from "../errors/api-error";
export const globalErrorHandler = async (
error: Error,
@ -11,6 +12,19 @@ export const globalErrorHandler = async (
if (res.headersSent) {
return next(error);
}
const ctx = {
instance: req.originalUrl,
correlationId: (req.headers["x-correlation-id"] as string) || undefined,
method: req.method,
};
const apiError = ApiErrorMapper.map(err, 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}`);

View File

@ -1,6 +1,6 @@
import { RequestHandler } from "express";
import { ZodSchema } from "zod/v4";
import { InternalApiError, ValidationApiError } from "../../../errors";
import { InternalApiError, ValidationApiError } from "../errors";
import { ExpressController } from "../express-controller";
/**

View File

@ -1,3 +1,4 @@
export * from "./database";
export * from "./errors";
export * from "./express";
export * from "./sequelize";

View File

@ -1,3 +1,4 @@
export * from "./sequelize-error-translator";
export * from "./sequelize-mapper";
export * from "./sequelize-repository";
export * from "./sequelize-transaction-manager";

View File

@ -0,0 +1,77 @@
import {
ConnectionError,
DatabaseError,
ForeignKeyConstraintError,
ValidationError as SequelizeValidationError,
UniqueConstraintError,
} from "sequelize";
import {
DomainValidationError,
DuplicateEntityError,
EntityNotFoundError,
ValidationErrorCollection,
} from "../../domain";
import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-error";
import { InfrastructureUnavailableError } from "../errors/infrastructure-unavailable-error";
/**
* Traduce errores específicos de Sequelize a errores de dominio/infraestructura
* entendibles por el resto de capas.
*
* 👉 Este traductor pertenece a la infraestructura (persistencia)
*/
export function translateSequelizeError(err: unknown): Error {
// 1) Duplicados (índices únicos)
if (err instanceof UniqueConstraintError) {
// Tomamos el primer detalle (puede haber varios)
const detail = err.errors?.[0];
const entity = detail?.instance?.constructor.name ?? "unknown_entity";
const value = detail?.value ?? "unknown_value";
const field = detail?.path ?? "unknown_field";
// ⚠️ Si los nombres de campo son sensibles, normaliza/whitelistea antes de exponerlos a dominio
return new DuplicateEntityError(entity, field, value, { cause: err });
}
// 2) Violación de FK → error de validación de dominio (referencia inválida)
if (err instanceof ForeignKeyConstraintError) {
// Sequelize expone `fields` (obj) y `index`. Extraemos el campo si está.
const entity = err.index ?? "unknown_entity";
const field = err.fields ? Object.keys(err.fields)[0] : "foreign_key";
const value = err.fields ? Object.values(err.fields)[0] : "unknown_value";
return new EntityNotFoundError(entity, field, value, { cause: err });
}
// 3) Validaciones de Sequelize (pueden venir varias)
if (err instanceof SequelizeValidationError) {
const details = (err.errors ?? []).map((e) => ({
path: e.path ?? "unknown",
message: e.message,
// Algunas props útiles: e.validatorKey / e.validatorName
rule: (e as any).validatorKey ?? undefined,
}));
// Si sólo hay 1, puedes preferir DomainValidationError
if (details.length === 1) {
const d = details[0];
return DomainValidationError.invalidFormat(d.path, d.message, { cause: err });
}
return new ValidationErrorCollection("Invalid data provided", details, { cause: err });
}
// 4) Conectividad / indisponibilidad (transitorio)
if (err instanceof ConnectionError) {
return new InfrastructureUnavailableError("Database connection unavailable", { cause: err });
}
// 5) Otros errores de base de datos (no transitorios)
if (err instanceof DatabaseError) {
return new InfrastructureRepositoryError("Database error occurred", { cause: err });
}
// 6) Fallback: deja pasar si ya es un Error tipado de tu app, si no wrap
if (err instanceof Error) return err;
return new InfrastructureRepositoryError("Unknown persistence error", { cause: err as any });
}

View File

@ -7,9 +7,9 @@
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

View File

@ -33,7 +33,7 @@ export class DeleteCustomerInvoiceUseCase {
}
if (!existsCheck.data) {
return Result.fail(new EntityNotFoundError("CustomerInvoice", id.toString()));
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
}
return await this.service.deleteById(invoiceId, transaction);

View File

@ -0,0 +1,9 @@
// Ejemplo: regla específica para Billing → InvoiceIdAlreadyExistsError
// (si defines un error más ubicuo dentro del BC con su propia clase)
import { DomainError } from "@erp/core/api";
// Suponemos que existe esta clase en tu dominio de Billing:
export class CustomerInvoiceIdAlreadyExistsError extends DomainError {
public readonly code = "DUPLICATE_INVOICE_ID" as const;
}

View File

@ -0,0 +1 @@
export * from "./customer-invoice-id-already-exits-error";

View File

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

View File

@ -0,0 +1,21 @@
// Ejemplo: regla específica para Billing → InvoiceIdAlreadyExistsError
// (si defines un error más ubicuo dentro del BC con su propia clase)
import { ApiErrorMapper, ConflictApiError, ErrorToApiRule } from "@erp/core/api";
import { CustomerInvoiceIdAlreadyExistsError } from "../../domain";
// Crea una regla específica (prioridad alta para sobreescribir mensajes)
const invoiceDuplicateRule: ErrorToApiRule = {
priority: 120,
matches: (e): e is CustomerInvoiceIdAlreadyExistsError =>
e instanceof CustomerInvoiceIdAlreadyExistsError,
build: (e) =>
new ConflictApiError(
(e as CustomerInvoiceIdAlreadyExistsError).message ||
"Invoice with the provided id already exists."
),
};
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const customerInvoicesApiErrorMapper: ApiErrorMapper =
ApiErrorMapper.default().register(invoiceDuplicateRule);

View File

@ -7,9 +7,9 @@
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

View File

@ -6,6 +6,11 @@ import { ICustomerService } from "../../domain";
import { mapDTOToCustomerProps } from "../helpers";
import { CreateCustomersAssembler } from "./assembler";
type CreateCustomerUseCaseInput = {
tenantId: string;
dto: CreateCustomerCommandDTO;
};
export class CreateCustomerUseCase {
constructor(
private readonly service: ICustomerService,
@ -13,7 +18,9 @@ export class CreateCustomerUseCase {
private readonly assembler: CreateCustomersAssembler
) {}
public execute(dto: CreateCustomerCommandDTO) {
public execute(params: CreateCustomerUseCaseInput) {
const { dto, tenantId: companyId } = params;
const customerPropsOrError = mapDTOToCustomerProps(dto);
if (customerPropsOrError.isFailure) {
@ -42,7 +49,7 @@ export class CreateCustomerUseCase {
return Result.fail(new DuplicateEntityError("Customer", id.toString()));
}
const result = await this.service.save(newCustomer, transaction);
const result = await this.service.save(newCustomer, idCompany, transaction);
if (result.isFailure) {
return Result.fail(result.error);
}

View File

@ -1,5 +1,4 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { DeleteCustomerByIdQueryDTO } from "@erp/customers/common/dto";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { ICustomerService } from "../../domain";
@ -21,17 +20,17 @@ export class DeleteCustomerUseCase {
return this.transactionManager.complete(async (transaction) => {
try {
const existsCheck = await this.service.existsById(id, transaction);
const existsCheck = await this.service.existsByIdInCompany(id, transaction);
if (existsCheck.isFailure) {
return Result.fail(existsCheck.error);
}
if (!existsCheck.data) {
return Result.fail(new EntityNotFoundError("Customer", id.toString()));
return Result.fail(new EntityNotFoundError("Customer", "id", id.toString()));
}
return await this.service.deleteById(id, transaction);
return await this.service.deleteCustomerByIdInCompany(id, transaction);
} catch (error: unknown) {
return Result.fail(error as Error);
}

View File

@ -7,7 +7,7 @@ export class GetCustomerAssembler {
id: customer.id.toPrimitive(),
reference: customer.reference,
is_freelancer: customer.isFreelancer,
is_companyr: customer.isFreelancer,
name: customer.name,
trade_name: customer.tradeName.getOrUndefined(),
tin: customer.tin.toPrimitive(),

View File

@ -1,10 +1,14 @@
import { ITransactionManager } from "@erp/core/api";
import { GetCustomerByIdQueryDTO } from "@erp/customers/common/dto";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { ICustomerService } from "../../domain";
import { GetCustomerAssembler } from "./assembler";
type GetCustomerUseCaseInput = {
tenantId: string;
id: string;
};
export class GetCustomerUseCase {
constructor(
private readonly service: ICustomerService,
@ -12,16 +16,28 @@ export class GetCustomerUseCase {
private readonly assembler: GetCustomerAssembler
) {}
public execute(dto: GetCustomerByIdQueryDTO) {
const idOrError = UniqueID.create(dto.id);
public execute(params: GetCustomerUseCaseInput) {
const { id, tenantId: companyId } = params;
const idOrError = UniqueID.create(id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const companyIdOrError = UniqueID.create(companyId);
if (companyIdOrError.isFailure) {
return Result.fail(companyIdOrError.error);
}
return this.transactionManager.complete(async (transaction) => {
try {
const customerOrError = await this.service.getById(idOrError.data, transaction);
const customerOrError = await this.service.getCustomerByIdInCompany(
companyIdOrError.data,
idOrError.data,
transaction
);
if (customerOrError.isFailure) {
return Result.fail(customerOrError.error);
}

View File

@ -12,7 +12,7 @@ export class ListCustomersAssembler {
id: customer.id.toPrimitive(),
reference: customer.reference,
is_freelancer: customer.isFreelancer,
is_companyr: customer.isFreelancer,
name: customer.name,
trade_name: customer.tradeName.getOrUndefined() || "",
tin: customer.tin.toString(),

View File

@ -9,8 +9,9 @@ import {
import { Maybe, Result } from "@repo/rdx-utils";
export interface CustomerProps {
companyId: UniqueID;
reference: string;
isFreelancer: boolean;
isCompany: boolean;
name: string;
tin: TINNumber;
address: PostalAddress;
@ -29,6 +30,7 @@ export interface CustomerProps {
export interface ICustomer {
id: UniqueID;
companyId: UniqueID;
reference: string;
name: string;
tin: TINNumber;
@ -44,8 +46,8 @@ export interface ICustomer {
fax: Maybe<PhoneNumber>;
website: Maybe<string>;
isCustomer: boolean;
isFreelancer: boolean;
isIndividual: boolean;
isCompany: boolean;
isActive: boolean;
}
@ -64,6 +66,15 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
return Result.ok(contact);
}
update(partial: Partial<Omit<CustomerProps, "companyId">>): Result<Customer, Error> {
const updatedCustomer = new Customer({ ...this.props, ...partial }, this.id);
return Result.ok(updatedCustomer);
}
get companyId(): UniqueID {
return this.props.companyId;
}
get reference() {
return this.props.reference;
}
@ -116,12 +127,12 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
return this.props.currencyCode;
}
get isCustomer(): boolean {
return !this.props.isFreelancer;
get isIndividual(): boolean {
return !this.props.isCompany;
}
get isFreelancer(): boolean {
return this.props.isFreelancer;
get isCompany(): boolean {
return this.props.isCompany;
}
get isActive(): boolean {

View File

@ -3,48 +3,50 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Customer } from "../aggregates";
/**
* Contrato del repositorio de Customers.
* Define la interfaz de persistencia para el agregado `Customer`.
* El escopado multitenant está representado por `companyId`.
*/
export interface ICustomerRepository {
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
/**
* Guarda (crea o actualiza) un Customer en la base de datos.
* Retorna el objeto actualizado tras la operación.
*/
save(customer: Customer, transaction?: any): Promise<Result<Customer, Error>>;
/**
*
* Persiste una nueva factura o actualiza una existente.
*
* @param customer - El agregado a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
* Comprueba si existe un Customer con un `id` dentro de una `company`.
*/
save(customer: Customer, transaction: any): Promise<Result<Customer, Error>>;
existsByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: any
): Promise<Result<boolean, Error>>;
/**
*
* Busca una factura por su identificador único.
* @param id - UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
* Recupera un Customer por su ID y companyId.
* Devuelve un `NotFoundError` si no se encuentra.
*/
findById(id: UniqueID, transaction: any): Promise<Result<Customer, Error>>;
getByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: any
): Promise<Result<Customer, Error>>;
/**
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer[], Error>
*
* @see Criteria
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
* El resultado está encapsulado en un objeto `Collection<T>`.
*/
findByCriteria(
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: any
): Promise<Result<Collection<Customer>, Error>>;
transaction?: any
): Promise<Result<Collection<Customer>>>;
/**
*
* Elimina o marca como eliminada una factura.
* @param id - UUID de la factura a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
* Elimina un Customer por su ID, dentro de una empresa.
* Retorna `void` si se elimina correctamente, o `NotFoundError` si no existía.
*/
deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>>;
deleteByIdInCompany(companyId: UniqueID, id: UniqueID, transaction?: any): Promise<Result<void>>;
}

View File

@ -4,30 +4,63 @@ import { Collection, Result } from "@repo/rdx-utils";
import { Customer, CustomerProps } from "../aggregates";
export interface ICustomerService {
build(props: CustomerProps, id?: UniqueID): Result<Customer, Error>;
/**
* Construye un nuevo Customer validando todos sus value objects.
*/
buildCustomerInCompany(
companyId: UniqueID,
props: Omit<CustomerProps, "companyId">,
customerId?: UniqueID
): Result<Customer, Error>;
save(invoice: Customer, transaction: any): Promise<Result<Customer, Error>>;
/**
* Guarda un Customer (nuevo o modificado) en base de datos.
*/
saveCustomerInCompany(customer: Customer, transaction: any): Promise<Result<Customer, Error>>;
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
/**
* Comprueba si existe un Customer con ese ID en la empresa indicada.
*/
existsByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: any
): Promise<Result<boolean, Error>>;
findByCriteria(
/**
* Lista todos los customers que cumplan el criterio, dentro de una empresa.
*/
findCustomerByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: any
): Promise<Result<Collection<Customer>, Error>>;
getById(id: UniqueID, transaction?: any): Promise<Result<Customer>>;
/**
* Recupera un Customer por su ID dentro de una empresa.
*/
getCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: any
): Promise<Result<Customer>>;
updateById(
id: UniqueID,
data: Partial<CustomerProps>,
/**
* Actualiza parcialmente los datos de un Customer.
*/
updateCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
partial: Partial<Omit<CustomerProps, "companyId">>,
transaction?: any
): Promise<Result<Customer, Error>>;
createCustomer(
id: UniqueID,
data: CustomerProps,
/**
* Elimina un Customer por ID dentro de una empresa.
*/
deleteCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: any
): Promise<Result<Customer, Error>>;
deleteById(id: UniqueID, transaction?: any): Promise<Result<void, Error>>;
): Promise<Result<void, Error>>;
}

View File

@ -1,23 +1,34 @@
import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { Customer, CustomerProps } from "../aggregates";
import { ICustomerRepository } from "../repositories";
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.
*
* @param props - Las propiedades ya validadas para crear la factura.
* @param id - Identificador UUID de la factura (opcional).
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
* @param props - Las propiedades ya validadas para crear el cliente.
* @param customerId - Identificador UUID del cliente (opcional).
* @returns Result<Customer, Error> - El agregado construido o un error si falla la creación.
*/
build(props: CustomerProps, id?: UniqueID): Result<Customer, Error> {
return Customer.create(props, id);
buildCustomerInCompany(
companyId: UniqueID,
props: Omit<CustomerProps, "companyId">,
customerId?: UniqueID
): Result<Customer, Error> {
return Customer.create({ ...props, companyId }, customerId);
}
/**
@ -27,123 +38,107 @@ export class CustomerService implements ICustomerService {
* @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 save(invoice: Customer, transaction: any): Promise<Result<Customer, Error>> {
const saved = await this.repository.save(invoice, transaction);
return saved.isSuccess ? Result.ok(invoice) : Result.fail(saved.error);
async saveCustomerInCompany(
customer: Customer,
transaction: any
): Promise<Result<Customer, Error>> {
return this.repository.save(customer, transaction);
}
/**
*
* Comprueba si existe o no en persistencia una factura con el ID proporcionado
* Comprueba si existe o no en persistencia un cliente con el ID proporcionado
*
* @param id - Identificador UUID de la factura.
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param customerId - Identificador UUID del cliente
* @param transaction - Transacción activa para la operación.
* @returns Result<Boolean, Error> - Existe la factura o no.
* @returns Result<Boolean, Error> - Existe el cliente o no.
*/
async existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>> {
return this.repository.existsById(id, transaction);
existsByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: any
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, customerId, transaction);
}
/**
* Obtiene una colección de facturas que cumplen con los filtros definidos en un objeto Criteria.
* Obtiene una colección de clientes que cumplen con los filtros definidos en un objeto Criteria.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Objeto con condiciones de filtro, paginación y orden.
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<Customer>, Error> - Colección de facturas o error.
* @returns Result<Collection<Customer>, Error> - Colección de clientes o error.
*/
async findByCriteria(
async findCustomersByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<Customer>, Error>> {
const customersOrError = await this.repository.findByCriteria(criteria, transaction);
if (customersOrError.isFailure) {
console.error(customersOrError.error);
return Result.fail(customersOrError.error);
}
// Solo devolver usuarios activos
//const allCustomers = customersOrError.data.filter((customer) => customer.isActive);
//return Result.ok(new Collection(allCustomers));
return customersOrError;
transaction?: any
): Promise<Result<Collection<Customer>>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}
/**
* Recupera una factura por su identificador único.
* Recupera un cliente por su identificador único.
*
* @param id - Identificador UUID de la factura.
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param customerId - Identificador UUID del cliente.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - Factura encontrada o error.
* @returns Result<Customer, Error> - Cliente encontradoF o error.
*/
async getById(id: UniqueID, transaction?: Transaction): Promise<Result<Customer>> {
return await this.repository.findById(id, transaction);
async getCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: any
): Promise<Result<Customer>> {
return this.repository.getByIdInCompany(companyId, customerId, transaction);
}
/**
* Actualiza parcialmente una factura existente con nuevos datos.
* Actualiza parcialmente un cliente existente con nuevos datos.
*
* @param id - Identificador de la factura a actualizar.
* @param changes - Subconjunto de props válidas para aplicar.
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
* @param customerId - Identificador del cliente a actualizar.
* @param partial - Subconjunto de props válidas para aplicar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - Factura actualizada o error.
* @returns Result<Customer, Error> - Cliente actualizado o error.
*/
async updateById(
async updateCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
changes: Partial<CustomerProps>,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
// Verificar si la factura existe
const customerOrError = await this.repository.findById(customerId, transaction);
if (customerOrError.isFailure) {
return Result.fail(new Error("Customer not found"));
partial: Partial<Omit<CustomerProps, "companyId">>,
transaction?: any
): Promise<Result<Customer>> {
const customerResult = await this.getCustomerByIdInCompany(companyId, customerId, transaction);
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
return Result.fail(new Error("No implementado"));
const customer = customerResult.data;
const updatedCustomer = customer.update(partial);
/*const updatedCustomerOrError = Customer.update(customerOrError.data, data);
if (updatedCustomerOrError.isFailure) {
return Result.fail(
new Error(`Error updating customer: ${updatedCustomerOrError.error.message}`)
);
if (updatedCustomer.isFailure) {
return Result.fail(updatedCustomer.error);
}
const updateCustomer = updatedCustomerOrError.data;
await this.repo.update(updateCustomer, transaction);
return Result.ok(updateCustomer);*/
}
async createCustomer(
customerId: UniqueID,
data: CustomerProps,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
// Verificar si la factura existe
const customerOrError = await this.repository.findById(customerId, transaction);
if (customerOrError.isSuccess) {
return Result.fail(new Error("Customer exists"));
}
const newCustomerOrError = Customer.create(data, customerId);
if (newCustomerOrError.isFailure) {
return Result.fail(new Error(`Error creating customer: ${newCustomerOrError.error.message}`));
}
const newCustomer = newCustomerOrError.data;
await this.repository.create(newCustomer, transaction);
return Result.ok(newCustomer);
return this.saveCustomerInCompany(updatedCustomer.data, transaction);
}
/**
* Elimina (o marca como eliminada) una factura según su ID.
* Elimina (o marca como eliminado) un cliente según su ID.
*
* @param id - Identificador UUID de la factura.
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
* @param customerId - Identificador UUID del cliente.
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error> - Resultado de la operación.
*/
async deleteById(id: UniqueID, transaction?: Transaction): Promise<Result<void, Error>> {
return this.repository.deleteById(id, transaction);
async deleteCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: any
): Promise<Result<void>> {
return this.repository.deleteByIdInCompany(companyId, customerId, transaction);
}
}

View File

@ -10,7 +10,7 @@ import {
ListCustomersAssembler,
ListCustomersUseCase,
} from "../application";
import { CustomerService } from "../domain";
import { CustomerService, ICustomerService } from "../domain";
import { CustomerMapper } from "./mappers";
import { CustomerRepository } from "./sequelize";
@ -39,7 +39,7 @@ type CustomerDeps = {
let _repo: CustomerRepository | null = null;
let _mapper: CustomerMapper | null = null;
let _service: CustomerService | null = null;
let _service: ICustomerService | null = null;
let _assemblers: CustomerDeps["assemblers"] | null = null;
export function getCustomerDependencies(params: ModuleParams): CustomerDeps {

View File

@ -23,7 +23,7 @@ export class CreateCustomerController extends ExpressController {
dto.customerCompanyId = user.companyId;
*/
const result = await this.useCase.execute(dto);
const result = await this.useCase.execute({ tenantId, dto });
return result.match(
(data) => this.created(data),

View File

@ -1,4 +1,3 @@
import { enforceTenant } from "@erp/auth/api";
import { ILogger, ModuleParams, validateRequest } from "@erp/core/api";
import { Application, NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize";
@ -28,7 +27,7 @@ export const customersRouter = (params: ModuleParams) => {
const deps = getCustomerDependencies(params);
// 🔐 Autenticación + Tenancy para TODO el router
router.use(/* authenticateJWT(), */ enforceTenant() /*checkTabContext*/);
//router.use(/* authenticateJWT(), */ enforceTenant() /*checkTabContext*/);
router.get(
"/",

View File

@ -19,9 +19,10 @@ export class CustomerModel extends Model<
}*/
declare id: string;
declare company_id: string;
declare reference: CreationOptional<string>;
declare is_freelancer: boolean;
declare is_company: boolean;
declare name: string;
declare trade_name: CreationOptional<string>;
declare tin: string;
@ -43,20 +44,28 @@ export class CustomerModel extends Model<
declare status: string;
declare lang_code: string;
declare currency_code: string;
static associate(database: Sequelize) {}
static hooks(database: Sequelize) {}
}
export default (sequelize: Sequelize) => {
export default (database: Sequelize) => {
CustomerModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
company_id: {
type: DataTypes.UUID,
allowNull: false,
},
reference: {
type: DataTypes.STRING,
allowNull: false,
},
is_freelancer: {
is_company: {
type: DataTypes.BOOLEAN,
allowNull: false,
},
@ -149,7 +158,7 @@ export default (sequelize: Sequelize) => {
},
},
{
sequelize,
sequelize: database,
tableName: "customers",
paranoid: true, // softs deletes
@ -160,8 +169,8 @@ export default (sequelize: Sequelize) => {
deletedAt: "deleted_at",
indexes: [
{ name: "company_idx", fields: ["company_id"], unique: false },
{ name: "email_idx", fields: ["email"], unique: true },
{ name: "reference_idx", fields: ["reference"], unique: true },
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -19,81 +19,108 @@ export class CustomerRepository
this.mapper = mapper;
}
async existsById(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
/**
*
* Guarda un nuevo cliente o actualiza uno existente.
*
* @param customer - El cliente a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
*/
async save(customer: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
try {
const result = await this._exists(CustomerModel, "id", id.toString(), transaction);
return Result.ok(Boolean(result));
const data = this.mapper.mapToPersistence(customer);
const [instance] = await CustomerModel.upsert(data, { transaction, returning: true });
return this.mapper.mapToDomain(instance);
} catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err));
}
}
/**
* Comprueba si existe un Customer con un `id` dentro de una `company`.
*
* Persiste una nueva factura o actualiza una existente.
*
* @param invoice - El agregado a guardar.
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - Identificador UUID del cliente.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
* @returns Result<boolean, Error>
*/
async save(invoice: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
async existsByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: any
): Promise<Result<boolean, Error>> {
try {
const data = this.mapper.mapToPersistence(invoice);
await CustomerModel.upsert(data, { transaction });
return Result.ok(invoice);
} catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err));
const count = await CustomerModel.count({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
return Result.ok(Boolean(count > 0));
} catch (error: any) {
return Result.fail(errorMapper.toDomainError(error));
}
}
/**
* Recupera un cliente por su ID y companyId.
*
* Busca una factura por su identificador único.
* @param id - UUID de la factura.
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - Identificador UUID del cliente.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
*/
async findById(id: UniqueID, transaction: Transaction): Promise<Result<Customer, Error>> {
async getByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: any
): Promise<Result<Customer, Error>> {
try {
const rawData = await this._findById(CustomerModel, id.toString(), { transaction });
const row = await CustomerModel.findOne({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
if (!rawData) {
return Result.fail(new Error(`Invoice with id ${id} not found.`));
if (!row) {
return Result.fail(new Error(`Customer ${id.toString()} not found`));
}
return this.mapper.mapToDomain(rawData);
} catch (err: unknown) {
return Result.fail(errorMapper.toDomainError(err));
return this.mapper.mapToDomain(row);
} catch (error: any) {
return Result.fail(errorMapper.toDomainError(error));
}
}
/**
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer[], Error>
* @returns Result<Collection<Customer>, Error>
*
* @see Criteria
*/
public async findByCriteria(
async findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: Transaction
): Promise<Result<Collection<Customer>, Error>> {
transaction?: any
): Promise<Result<Collection<Customer>>> {
try {
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria);
console.debug({ criteria, transaction, query, CustomerModel });
query.where = {
...query.where,
company_id: companyId.toString(),
};
console.debug({ model: "CustomerModel", criteria, query });
const instances = await CustomerModel.findAll({
...query,
transaction,
});
console.debug(instances);
return this.mapper.mapArrayToDomain(instances);
} catch (err: unknown) {
console.error(err);
@ -103,16 +130,30 @@ export class CustomerRepository
/**
*
* Elimina o marca como eliminada una factura.
* @param id - UUID de la factura a eliminar.
* Elimina o marca como eliminado un cliente.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - UUID del cliente a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>> {
async deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: any
): Promise<Result<void>> {
try {
await this._deleteById(CustomerModel, id, false, transaction);
const deleted = await CustomerModel.destroy({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
if (deleted === 0) {
return Result.fail(new Error(`Customer with id ${id} not found in company ${companyId}.`));
}
return Result.ok<void>();
} catch (err: unknown) {
// , `Error deleting customer ${id} in company ${companyId}`
return Result.fail(errorMapper.toDomainError(err));
}
}

View File

@ -2,9 +2,9 @@ import * as z from "zod/v4";
export const CreateCustomerRequestSchema = z.object({
id: z.uuid(),
reference: z.string(),
reference: z.string().optional(),
is_freelancer: z.boolean(),
is_company: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
@ -22,7 +22,7 @@ export const CreateCustomerRequestSchema = 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

@ -5,7 +5,7 @@ export const CustomerCreationResponseSchema = z.object({
id: z.uuid(),
reference: z.string(),
is_freelancer: z.boolean(),
is_companyr: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),

View File

@ -6,7 +6,7 @@ export const CustomerListResponseSchema = createListViewResponseSchema(
id: z.uuid(),
reference: z.string(),
is_freelancer: z.boolean(),
is_companyr: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),

View File

@ -5,7 +5,7 @@ export const GetCustomerByIdResponseSchema = z.object({
id: z.uuid(),
reference: z.string(),
is_freelancer: z.boolean(),
is_companyr: z.boolean(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),

View File

@ -1,6 +1,6 @@
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useBlocker, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useCreateCustomerMutation } from "../../hooks/use-create-customer-mutation";
import { useTranslation } from "../../i18n";
@ -9,7 +9,6 @@ import { CustomerEditForm } from "./customer-edit-form";
export const CustomerCreate = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { block, unblock } = useBlocker(1);
const { mutate, isPending, isError, error } = useCreateCustomerMutation();
@ -56,12 +55,17 @@ export const CustomerCreate = () => {
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>{t("pages.create.title")}</h2>
<p className='text-muted-foreground'>{t("pages.create.description")}</p>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.create.title")}
</h2>
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
{t("pages.create.description")}
</p>
</div>
<div className='flex items-center justify-end mb-4'>
<Button className='cursor-pointer' onClick={() => navigate("/customers/list")}>
{t("pages.create.back_to_list")}
<BackHistoryButton />
<Button type='submit' className='cursor-pointer'>
{t("pages.create.submit")}
</Button>
</div>
</div>

View File

@ -4,13 +4,18 @@ import { useForm } from "react-hook-form";
import { TaxesMultiSelectField } from "@erp/core/components";
import { SelectField, TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
Label,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
@ -21,10 +26,24 @@ import { CustomerData, CustomerDataFormSchema } from "./customer.schema";
const defaultCustomerData = {
id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
is_company: true,
status: "active",
name: "1",
language_code: "ES",
currency: "EUR",
tin: "B12345678",
name: "Pepe",
trade_name: "Pepe's Shop",
email: "pepe@example.com",
phone: "+34 123 456 789",
website: "https://pepe.com",
fax: "+34 123 456 789",
street: "Calle Falsa 123",
city: "Madrid",
country: "ES",
postal_code: "28080",
state: "Madrid",
lang_code: "es",
currency_code: "EUR",
legal_record: "Registro Mercantil de Madrid, Tomo 12345, Folio 67, Hoja M-123456",
default_tax: ["iva_21", "rec_5_2"],
};
interface CustomerFormProps {
@ -71,44 +90,49 @@ export const CustomerEditForm = ({
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='grid grid-cols-1 space-y-6'>
<div className='w-full grid grid-cols-1 space-y-8 space-x-8 xl:grid-cols-2'>
{/* Información básica */}
<Card className='border-0 shadow-none'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<div className='space-y-3 xl:col-span-2'>
<Label className='text-sm font-medium'>
{t("form_fields.customer_type.label")}
</Label>
<RadioGroup
value={"customer_type"}
onValueChange={(value: "company" | "individual") => {
// Usar setValue del form
form.setValue("customer_type", value);
}}
className='flex gap-6'
>
<div className='flex items-center space-x-2'>
<RadioGroupItem
value='company'
id='company'
{...form.register("customer_type")}
/>
<Label htmlFor='company'>{t("form_fields.customer_type.company")}</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem
value='individual'
id='individual'
{...form.register("customer_type")}
/>
<Label htmlFor='individual'>{t("form_fields.customer_type.individual")}</Label>
</div>
</RadioGroup>
</div>
<FormField
control={form.control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "1" : "0"}
className='flex gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value={"1"} />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value={"0"} />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TextField
control={form.control}
@ -140,7 +164,6 @@ export const CustomerEditForm = ({
<TextField
control={form.control}
name='reference'
required
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
@ -149,7 +172,7 @@ export const CustomerEditForm = ({
</Card>
{/* Dirección */}
<Card className='border-0 shadow-none'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.address.title")}</CardTitle>
<CardDescription>{t("form_groups.address.description")}</CardDescription>
@ -214,7 +237,7 @@ export const CustomerEditForm = ({
</Card>
{/* Contacto */}
<Card className='border-0 shadow-none'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.contact_info.title")}</CardTitle>
<CardDescription>{t("form_groups.contact_info.description")}</CardDescription>
@ -259,7 +282,7 @@ export const CustomerEditForm = ({
</Card>
{/* Configuraciones Adicionales */}
<Card className='border-0 shadow-none'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.additional_config.title")}</CardTitle>
<CardDescription>{t("form_groups.additional_config.description")}</CardDescription>
@ -320,6 +343,7 @@ export const CustomerEditForm = ({
</CardContent>
</Card>
</div>
<Button type='submit'>Submit</Button>
</form>
</Form>
);

View File

@ -7,9 +7,9 @@
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

View File

@ -15,6 +15,6 @@
"incremental": false,
"declaration": true,
"exactOptionalPropertyTypes": true,
"target": "es2020"
"target": "ES2022"
}
}

View File

@ -120,17 +120,17 @@ const data = {
],
navSecondary: [
{
title: "Settings",
title: "Ajustes",
url: "#",
icon: SettingsIcon,
},
{
title: "Get Help",
title: "Soporte",
url: "#",
icon: HelpCircleIcon,
},
{
title: "Search",
title: "Buscar",
url: "#",
icon: SearchIcon,
},
@ -180,8 +180,8 @@ const data2 = {
],
navMain: [
{
title: "Playground",
url: "#",
title: "Clientes",
url: "/customers",
icon: SquareTerminal,
isActive: true,
items: [
@ -200,8 +200,8 @@ const data2 = {
],
},
{
title: "Models",
url: "#",
title: "Facturas de cliente",
url: "/customer-invoices",
icon: Bot,
items: [
{

View File

@ -21,7 +21,7 @@ import {
ToggleGroup,
ToggleGroupItem,
} from "@repo/shadcn-ui/components";
import { useIsMobile } from "@repo/shadcn-ui/hooks";
import { useIsMobile } from "@repo/shadcn-ui/hooks/";
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },