Clientes y facturas de cliente

This commit is contained in:
David Arranz 2025-10-04 18:29:14 +02:00
parent 5534f06f21
commit 41ca0c2286
40 changed files with 304 additions and 183 deletions

View File

@ -8,7 +8,7 @@ Este módulo es para **facturas de cliente (Customer Invoices)** y debe cumplir
- Las **líneas de factura** (`InvoiceLine`) se modelan como entidades o value objects dentro del agregado.
- Se usará un `Mapper` para convertir entre dominio y persistencia.
- Repositorios (`ICustomerInvoiceRepository`) solo manejan agregados.
- Operaciones como `createInvoice`, `updateInvoice`, `getInvoiceById` serán gestionadas en `CustomerInvoiceService`.
- Operaciones como `createInvoice`, `updateInvoice`, `getInvoiceById` serán gestionadas en `CustomerInvoiceApplicationService`.
**SOLID**
- Usar SRP: cada clase con una responsabilidad clara.
@ -74,7 +74,7 @@ La entidad `CustomerInvoice` tendrá:
✅ Los repositorios deben capturar errores de Sequelize (`UniqueConstraintError`, etc.) y convertirlos a errores de dominio con mensajes claros y específicos (mediante `errorMapper`).
📌 TESTING:
✅ Los servicios (`CustomerInvoiceService`) serán testeados con mocks de repositorio.
✅ Los servicios (`CustomerInvoiceApplicationService`) serán testeados con mocks de repositorio.
✅ Las rutas serán testeadas con `supertest`.
✅ Las validaciones de ValueObjects tendrán pruebas unitarias.

View File

@ -46,6 +46,7 @@ export function translateSequelizeError(err: unknown): Error {
path: e.path ?? "unknown",
message: e.message,
// Algunas props útiles: e.validatorKey / e.validatorName
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
rule: (e as any).validatorKey ?? undefined,
}));
@ -55,7 +56,7 @@ export function translateSequelizeError(err: unknown): Error {
return DomainValidationError.invalidFormat(d.path, d.message, { cause: err });
}
return new ValidationErrorCollection("Invalid data provided", details, { cause: err });
return new ValidationErrorCollection(details, { cause: err });
}
// 4) Conectividad / indisponibilidad (transitorio)
@ -71,5 +72,5 @@ export function translateSequelizeError(err: unknown): Error {
// 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 });
return new InfrastructureRepositoryError("Unknown persistence error", { cause: err as unknown });
}

View File

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

View File

@ -2,11 +2,15 @@ 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 { CustomerInvoiceListDTO } from "../../infrastructure";
import { CustomerInvoice, CustomerInvoicePatchProps, CustomerInvoiceProps } from "../aggregates";
import { ICustomerInvoiceRepository } from "../repositories";
import {
CustomerInvoice,
CustomerInvoicePatchProps,
CustomerInvoiceProps,
} from "../domain/aggregates";
import { ICustomerInvoiceRepository } from "../domain/repositories";
import { CustomerInvoiceListDTO } from "../infrastructure";
export class CustomerInvoiceService {
export class CustomerInvoiceApplicationService {
constructor(private readonly repository: ICustomerInvoiceRepository) {}
/**
@ -33,7 +37,7 @@ export class CustomerInvoiceService {
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error> - El agregado guardado o un error si falla la operación.
*/
async createInvoice(
async createInvoiceInCompany(
companyId: UniqueID,
invoice: CustomerInvoice,
transaction: Transaction
@ -47,14 +51,14 @@ export class CustomerInvoiceService {
}
/**
* Actualiza una nueva factura y devuelve la factura actualizada.
* Actualiza una factura existente y devuelve la factura actualizada.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param invoice - El agregado a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error> - El agregado guardado o un error si falla la operación.
*/
async updateInvoice(
async updateInvoiceInCompany(
companyId: UniqueID,
invoice: CustomerInvoice,
transaction: Transaction
@ -126,27 +130,25 @@ export class CustomerInvoiceService {
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error> - Factura actualizada o error.
*/
async updateInvoiceByIdInCompany(
async patchInvoiceByIdInCompany(
companyId: UniqueID,
invoiceId: UniqueID,
changes: CustomerInvoicePatchProps,
transaction?: Transaction
): Promise<Result<CustomerInvoice, Error>> {
// Verificar si la factura existe
const invoiceResult = await this.getInvoiceByIdInCompany(companyId, invoiceId, transaction);
if (invoiceResult.isFailure) {
return Result.fail(invoiceResult.error);
}
const invoice = invoiceResult.data;
const updatedInvoice = invoice.update(changes);
const updated = invoiceResult.data.update(changes);
if (updatedInvoice.isFailure) {
return Result.fail(updatedInvoice.error);
if (updated.isFailure) {
return Result.fail(updated.error);
}
return Result.ok(updatedInvoice.data);
return Result.ok(updated.data);
}
/**
@ -161,7 +163,7 @@ export class CustomerInvoiceService {
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
): Promise<Result<void, Error>> {
): Promise<Result<boolean, Error>> {
return this.repository.deleteByIdInCompany(companyId, invoiceId, transaction);
}
}

View File

@ -4,7 +4,7 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto";
import { CustomerInvoiceService } from "../../../domain";
import { CustomerInvoiceApplicationService } from "../../../domain";
import { CustomerInvoiceFullPresenter } from "../../presenters";
import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-customer-invoice-props";
@ -15,7 +15,7 @@ type CreateCustomerInvoiceUseCaseInput = {
export class CreateCustomerInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry,
private readonly taxCatalog: JsonTaxCatalogProvider

View File

@ -1,7 +1,7 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceService } from "../../domain";
import { CustomerInvoiceApplicationService } from "../../application";
type DeleteCustomerInvoiceUseCaseInput = {
companyId: UniqueID;
@ -10,7 +10,7 @@ type DeleteCustomerInvoiceUseCaseInput = {
export class DeleteCustomerInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager
) {}

View File

@ -1,7 +1,7 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceService } from "../../domain";
import { CustomerInvoiceApplicationService } from "../../application";
import { CustomerInvoiceFullPresenter } from "../presenters/domain";
type GetCustomerInvoiceUseCaseInput = {
@ -11,7 +11,7 @@ type GetCustomerInvoiceUseCaseInput = {
export class GetCustomerInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}

View File

@ -1,7 +1,7 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceNumber, CustomerInvoiceService } from "../../domain";
import { CustomerInvoiceApplicationService, CustomerInvoiceNumber } from "../../domain";
import { StatusInvoiceIsApprovedSpecification } from "../specs";
type IssueCustomerInvoiceUseCaseInput = {
@ -11,7 +11,7 @@ type IssueCustomerInvoiceUseCaseInput = {
export class IssueCustomerInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager
) {}

View File

@ -4,7 +4,7 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { ListCustomerInvoicesResponseDTO } from "../../../common/dto";
import { CustomerInvoiceService } from "../../domain";
import { CustomerInvoiceApplicationService } from "../../application";
import { ListCustomerInvoicesPresenter } from "../presenters";
type ListCustomerInvoicesUseCaseInput = {
@ -14,7 +14,7 @@ type ListCustomerInvoicesUseCaseInput = {
export class ListCustomerInvoicesUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}

View File

@ -1,7 +1,7 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceService } from "../../../domain";
import { CustomerInvoiceApplicationService } from "../../../domain";
type DeleteCustomerInvoiceUseCaseInput = {
companyId: UniqueID;
@ -10,7 +10,7 @@ type DeleteCustomerInvoiceUseCaseInput = {
export class DeleteCustomerInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager
) {}

View File

@ -1,7 +1,7 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceService } from "../../../domain";
import { CustomerInvoiceApplicationService } from "../../../domain";
import { CustomerInvoiceReportPDFPresenter } from "./reporter";
type ReportCustomerInvoiceUseCaseInput = {
@ -11,7 +11,7 @@ type ReportCustomerInvoiceUseCaseInput = {
export class ReportCustomerInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}

View File

@ -3,7 +3,8 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { UpdateCustomerInvoiceByIdRequestDTO } from "../../../../common";
import { CustomerInvoicePatchProps, CustomerInvoiceService } from "../../../domain";
import { CustomerInvoicePatchProps } from "../../../domain";
import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service";
import { CustomerInvoiceFullPresenter } from "../../presenters";
import { mapDTOToUpdateCustomerInvoicePatchProps } from "./map-dto-to-update-customer-invoice-props";
@ -15,7 +16,7 @@ type UpdateCustomerInvoiceUseCaseInput = {
export class UpdateCustomerInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceService,
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
@ -44,7 +45,7 @@ export class UpdateCustomerInvoiceUseCase {
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const updatedInvoice = await this.service.updateInvoiceByIdInCompany(
const updatedInvoice = await this.service.patchInvoiceByIdInCompany(
companyId,
invoiceId,
patchProps,
@ -55,7 +56,7 @@ export class UpdateCustomerInvoiceUseCase {
return Result.fail(updatedInvoice.error);
}
const invoiceOrError = await this.service.updateInvoice(
const invoiceOrError = await this.service.updateInvoiceInCompany(
companyId,
updatedInvoice.data,
transaction

View File

@ -7,3 +7,7 @@ import { DomainError } from "@repo/rdx-ddd";
export class CustomerInvoiceIdAlreadyExistsError extends DomainError {
public readonly code = "DUPLICATE_INVOICE_ID" as const;
}
export const isCustomerInvoiceIdAlreadyExistsError = (
e: unknown
): e is CustomerInvoiceIdAlreadyExistsError => e instanceof CustomerInvoiceIdAlreadyExistsError;

View File

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

View File

@ -79,5 +79,5 @@ export interface ICustomerInvoiceRepository {
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<void, Error>>;
): Promise<Result<boolean, Error>>;
}

View File

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

View File

@ -24,8 +24,10 @@ import {
} from "../application";
import { JsonTaxCatalogProvider, spainTaxCatalogProvider } from "@erp/core";
import { CustomerInvoiceItemsReportPersenter } from "../application/presenters/queries/customer-invoice-items.report.presenter";
import { CustomerInvoiceService } from "../domain";
import {
CustomerInvoiceApplicationService,
CustomerInvoiceItemsReportPersenter,
} from "../application";
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize";
@ -34,7 +36,7 @@ export type CustomerInvoiceDeps = {
mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry;
repo: CustomerInvoiceRepository;
service: CustomerInvoiceService;
service: CustomerInvoiceApplicationService;
catalogs: {
taxes: JsonTaxCatalogProvider;
};
@ -71,7 +73,7 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
// Repository & Services
const repo = new CustomerInvoiceRepository({ mapperRegistry, database });
const service = new CustomerInvoiceService(repo);
const service = new CustomerInvoiceApplicationService(repo);
// Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry();

View File

@ -2,13 +2,15 @@
// (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";
import {
CustomerInvoiceIdAlreadyExistsError,
isCustomerInvoiceIdAlreadyExistsError,
} 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,
matches: (e) => isCustomerInvoiceIdAlreadyExistsError(e),
build: (e) =>
new ConflictApiError(
(e as CustomerInvoiceIdAlreadyExistsError).message ||

View File

@ -162,13 +162,13 @@ export class CustomerInvoiceRepository
id: UniqueID,
transaction: Transaction
): Promise<Result<CustomerInvoice, Error>> {
const { CustomerModel } = this._database.models;
try {
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
const { CustomerModel } = this._database.models;
const row = await CustomerInvoiceModel.findOne({
where: { id: id.toString(), company_id: companyId.toString() },
order: [[{ model: CustomerInvoiceItemModel, as: "items" }, "position", "ASC"]],
@ -203,8 +203,8 @@ export class CustomerInvoiceRepository
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
}
const customer = mapper.mapToDomain(row);
return customer;
const invoice = mapper.mapToDomain(row);
return invoice;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
@ -226,12 +226,14 @@ export class CustomerInvoiceRepository
criteria: Criteria,
transaction: Transaction
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
const { CustomerModel } = this._database.models;
try {
const mapper: ICustomerInvoiceListMapper = this._registry.getQueryMapper({
resource: "customer-invoice",
query: "LIST",
});
const { CustomerModel } = this._database.models;
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria);
@ -272,20 +274,24 @@ export class CustomerInvoiceRepository
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - UUID de la factura a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
* @returns Result<boolean, Error>
*/
async deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<void, Error>> {
): Promise<Result<boolean, Error>> {
try {
const deleted = await CustomerInvoiceModel.destroy({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
return Result.ok<void>();
if (deleted === 0) {
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
}
return Result.ok(true);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}

View File

@ -45,7 +45,7 @@ import { PropsWithChildren, createContext } from "react";
export type CustomerInvoicesContextType = {};
export type CustomerInvoicesContextParamsType = {
//service: CustomerInvoiceService;
//service: CustomerInvoiceApplicationService;
};
export const CustomerInvoicesContext = createContext<CustomerInvoicesContextType>({});

View File

@ -1,93 +1,55 @@
// application/customer-application-service.ts
import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { CustomerListDTO } from "../../infrastructure";
import { Customer, CustomerPatchProps, CustomerProps } from "../aggregates";
import { ICustomerRepository } from "../repositories";
import { Transaction } from "sequelize";
import { Customer, CustomerPatchProps, ICustomerRepository } from "../domain";
import { CustomerListDTO } from "../infrastructure";
export class CustomerService {
export class CustomerApplicationService {
constructor(private readonly repository: ICustomerRepository) {}
/**
* Construye un nuevo agregado Customer a partir de props validadas.
*
* @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.
*/
buildCustomerInCompany(
companyId: UniqueID,
props: Omit<CustomerProps, "companyId">,
customerId?: UniqueID
): Result<Customer, Error> {
return Customer.create({ ...props, companyId }, customerId);
}
/**
* Guarda una instancia de Customer en persistencia.
*
* @param customer - El agregado a guardar (con el companyId ya asignado)
* @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 saveCustomer(customer: Customer, transaction: unknown): Promise<Result<Customer, Error>> {
return this.repository.save(customer, transaction);
}
/**
*
* Comprueba si existe o no en persistencia un cliente con el ID proporcionado
* Guarda un nuevo cliente y devuelve el cliente guardado.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param customerId - Identificador UUID del cliente
* @param customer - El cliente a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Boolean, Error> - Existe el cliente o no.
* @returns Result<Customer, Error> - El cliente guardado o un error si falla la operación.
*/
existsByIdInCompany(
async createCustomerInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, customerId, transaction);
customer: Customer,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
const result = await this.repository.create(customer, transaction);
if (result.isFailure) return Result.fail(result.error);
return this.getCustomerByIdInCompany(companyId, customer.id, transaction);
}
/**
* Obtiene una colección de clientes que cumplen con los filtros definidos en un objeto Criteria.
* Actualiza un cliente existente y devuelve el cliente actualizado.
*
* @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 customer - El cliente a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<Customer>, Error> - Colección de clientes o error.
* @returns Result<Customer, Error> - El cliente guardado o un error si falla la operación.
*/
async findCustomerByCriteriaInCompany(
async updateCustomerInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: unknown
): Promise<Result<Collection<CustomerListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}
customer: Customer,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
const result = await this.repository.update(customer, transaction);
if (result.isFailure) return Result.fail(result.error);
/**
* Recupera un cliente por su identificador único.
*
* @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> - Cliente encontradoF o error.
*/
async getCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: unknown
): Promise<Result<Customer>> {
return this.repository.getByIdInCompany(companyId, customerId, transaction);
return this.getCustomerByIdInCompany(companyId, customer.id, transaction);
}
/**
* Actualiza parcialmente un cliente existente con nuevos datos.
* No lo guarda en el repositorio.
* Solo en memoria. No lo guarda en el repositorio.
*
* @param companyId - Identificador de la empresa a la que pertenece el cliente.
* @param customerId - Identificador del cliente a actualizar.
@ -95,26 +57,23 @@ export class CustomerService {
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error> - Cliente actualizado o error.
*/
async updateCustomerByIdInCompany(
async patchCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
changes: CustomerPatchProps,
transaction?: unknown
transaction?: Transaction
): Promise<Result<Customer, Error>> {
const customerResult = await this.getCustomerByIdInCompany(companyId, customerId, transaction);
if (customerResult.isFailure) {
return Result.fail(customerResult.error);
}
const customer = customerResult.data;
const updatedCustomer = customer.update(changes);
if (updatedCustomer.isFailure) {
return Result.fail(updatedCustomer.error);
const updated = customerResult.data.update(changes);
if (updated.isFailure) {
return Result.fail(updated.error);
}
return Result.ok(updatedCustomer.data);
return Result.ok(updated.data);
}
/**
@ -128,8 +87,57 @@ export class CustomerService {
async deleteCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: unknown
): Promise<Result<void>> {
transaction?: Transaction
): Promise<Result<boolean, Error>> {
return this.repository.deleteByIdInCompany(companyId, customerId, transaction);
}
/**
*
* Comprueba si existe o no en persistencia un cliente con el ID proporcionado
*
* @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 el cliente o no.
*/
async existsByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, customerId, transaction);
}
/**
* Recupera un cliente por su identificador único.
*
* @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> - Cliente encontrado o error.
*/
async getCustomerByIdInCompany(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
return this.repository.getByIdInCompany(companyId, customerId, transaction);
}
/**
* 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 clientes o error.
*/
async findCustomerByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<CustomerListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}
}

View File

@ -1,2 +1,3 @@
export * from "./customer-application.service";
export * from "./presenters";
export * from "./use-cases";

View File

@ -1,10 +1,10 @@
import { CompositeSpecification, UniqueID } from "@repo/rdx-ddd";
import { CustomerService } from "../../domain";
import { CustomerApplicationService } from "../../application";
import { logger } from "../../helpers";
export class CustomerNotExistsInCompanySpecification extends CompositeSpecification<UniqueID> {
constructor(
private readonly service: CustomerService,
private readonly service: CustomerApplicationService,
private readonly companyId: UniqueID,
private readonly transaction?: unknown
) {

View File

@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { CreateCustomerRequestDTO } from "../../../../common";
import { logger } from "../../..//helpers";
import { CustomerService } from "../../../domain";
import { CustomerApplicationService } from "../../../domain";
import { CustomerFullPresenter } from "../../presenters";
import { CustomerNotExistsInCompanySpecification } from "../../specs";
import { mapDTOToCreateCustomerProps } from "./map-dto-to-create-customer-props";
@ -16,7 +16,7 @@ type CreateCustomerUseCaseInput = {
export class CreateCustomerUseCase {
constructor(
private readonly service: CustomerService,
private readonly service: CustomerApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}

View File

@ -1,7 +1,7 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerService } from "../../domain";
import { CustomerApplicationService } from "../../application";
type DeleteCustomerUseCaseInput = {
companyId: UniqueID;
@ -10,7 +10,7 @@ type DeleteCustomerUseCaseInput = {
export class DeleteCustomerUseCase {
constructor(
private readonly service: CustomerService,
private readonly service: CustomerApplicationService,
private readonly transactionManager: ITransactionManager
) {}

View File

@ -1,7 +1,7 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerService } from "../../domain";
import { CustomerApplicationService } from "../../application";
import { CustomerFullPresenter } from "../presenters";
type GetCustomerUseCaseInput = {
@ -11,7 +11,7 @@ type GetCustomerUseCaseInput = {
export class GetCustomerUseCase {
constructor(
private readonly service: CustomerService,
private readonly service: CustomerApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}

View File

@ -4,7 +4,7 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { ListCustomersResponseDTO } from "../../../common/dto";
import { CustomerService } from "../../domain";
import { CustomerApplicationService } from "../../application";
import { ListCustomersPresenter } from "../presenters";
type ListCustomersUseCaseInput = {
@ -14,7 +14,7 @@ type ListCustomersUseCaseInput = {
export class ListCustomersUseCase {
constructor(
private readonly service: CustomerService,
private readonly service: CustomerApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}

View File

@ -3,7 +3,8 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
import { CustomerPatchProps, CustomerService } from "../../../domain";
import { CustomerPatchProps } from "../../../domain";
import { CustomerApplicationService } from "../../customer-application.service";
import { CustomerFullPresenter } from "../../presenters";
import { mapDTOToUpdateCustomerPatchProps } from "./map-dto-to-update-customer-props";
@ -15,7 +16,7 @@ type UpdateCustomerUseCaseInput = {
export class UpdateCustomerUseCase {
constructor(
private readonly service: CustomerService,
private readonly service: CustomerApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
@ -44,7 +45,7 @@ export class UpdateCustomerUseCase {
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const updatedCustomer = await this.service.updateCustomerByIdInCompany(
const updatedCustomer = await this.service.patchCustomerByIdInCompany(
companyId,
customerId,
patchProps,
@ -55,7 +56,11 @@ export class UpdateCustomerUseCase {
return Result.fail(updatedCustomer.error);
}
const customerOrError = await this.service.saveCustomer(updatedCustomer.data, transaction);
const customerOrError = await this.service.updateCustomerInCompany(
companyId,
updatedCustomer.data,
transaction
);
const customer = customerOrError.data;
const dto = presenter.toOutput(customer);
return Result.ok(dto);

View File

@ -0,0 +1,8 @@
import { DomainError } from "@repo/rdx-ddd";
export class CustomerNotFoundError extends DomainError {
public readonly code = "CUSTOMER_ID" as const;
}
export const isCustomerNotFoundError = (e: unknown): e is CustomerNotFoundError =>
e instanceof CustomerNotFoundError;

View File

@ -0,0 +1 @@
export * from "./customer-not-found-error";

View File

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

View File

@ -10,10 +10,23 @@ import { Customer } from "../aggregates";
*/
export interface ICustomerRepository {
/**
* Guarda (crea o actualiza) un Customer en la base de datos.
* Retorna el objeto actualizado tras la operación.
*
* Crea un nuevo cliente
*
* @param customer - El cliente nuevo a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
save(customer: Customer, transaction: unknown): Promise<Result<Customer, Error>>;
create(customer: Customer, transaction: unknown): Promise<Result<void, Error>>;
/**
* Actualiza un cliente existente.
*
* @param customer - El cliente a actualizar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
update(customer: Customer, transaction: unknown): Promise<Result<void, Error>>;
/**
* Comprueba si existe un Customer con un `id` dentro de una `company`.
@ -21,7 +34,7 @@ export interface ICustomerRepository {
existsByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
transaction: unknown
): Promise<Result<boolean, Error>>;
/**
@ -31,7 +44,7 @@ export interface ICustomerRepository {
getByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
transaction: unknown
): Promise<Result<Customer, Error>>;
/**
@ -42,7 +55,7 @@ export interface ICustomerRepository {
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: unknown
transaction: unknown
): Promise<Result<Collection<CustomerListDTO>, Error>>;
/**
@ -54,5 +67,5 @@ export interface ICustomerRepository {
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<void, Error>>;
): Promise<Result<boolean, Error>>;
}

View File

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

View File

@ -6,13 +6,13 @@ import {
} from "@erp/core/api";
import {
CreateCustomerUseCase,
CustomerApplicationService,
CustomerFullPresenter,
GetCustomerUseCase,
ListCustomersPresenter,
ListCustomersUseCase,
UpdateCustomerUseCase,
} from "../application";
import { GetCustomerUseCase } from "../application/use-cases/get-customer.use-case";
import { CustomerService } from "../domain";
import { CustomerDomainMapper, CustomerListMapper } from "./mappers";
import { CustomerRepository } from "./sequelize";
@ -21,7 +21,7 @@ export type CustomerDeps = {
mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry;
repo: CustomerRepository;
service: CustomerService;
service: CustomerApplicationService;
build: {
list: () => ListCustomersUseCase;
get: () => GetCustomerUseCase;
@ -32,7 +32,7 @@ export type CustomerDeps = {
};
export function buildCustomerDependencies(params: ModuleParams): CustomerDeps {
const { database, logger } = params;
const { database } = params;
const transactionManager = new SequelizeTransactionManager(database);
// Mapper Registry
@ -43,7 +43,7 @@ export function buildCustomerDependencies(params: ModuleParams): CustomerDeps {
// Repository & Services
const repo = new CustomerRepository({ mapperRegistry, database });
const service = new CustomerService(repo);
const service = new CustomerApplicationService(repo);
// Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry();

View File

@ -0,0 +1,16 @@
import { ApiErrorMapper, ConflictApiError, ErrorToApiRule } from "@erp/core/api";
import { CustomerNotFoundError, isCustomerNotFoundError } from "../../domain";
// Crea una regla específica (prioridad alta para sobreescribir mensajes)
const customerNotFoundRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isCustomerNotFoundError(e),
build: (e) =>
new ConflictApiError(
(e as CustomerNotFoundError).message || "Customer with the provided id not exists."
),
};
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const customersApiErrorMapper: ApiErrorMapper =
ApiErrorMapper.default().register(customerNotFoundRule);

View File

@ -1,4 +1,9 @@
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import {
EntityNotFoundError,
InfrastructureRepositoryError,
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";
@ -13,34 +18,69 @@ export class CustomerRepository
{
/**
*
* Guarda un nuevo cliente o actualiza uno existente.
* Crea un nuevo cliente
*
* @param customer - El cliente a guardar.
* @param customer - El cliente nuevo a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<Customer, Error>
* @returns Result<void, Error>
*/
async save(customer: Customer, transaction: Transaction): Promise<Result<Customer, Error>> {
async create(customer: Customer, transaction?: Transaction): Promise<Result<void, Error>> {
try {
const mapper: ICustomerDomainMapper = this._registry.getDomainMapper({
resource: "customer",
});
const dto = mapper.mapToPersistence(customer);
const mapperData = mapper.mapToPersistence(customer);
if (mapperData.isFailure) {
return Result.fail(mapperData.error);
if (dto.isFailure) {
return Result.fail(dto.error);
}
const { data } = mapperData;
const { data } = dto;
const [instance] = await CustomerModel.upsert(data, { transaction, returning: true });
const savedCustomer = mapper.mapToDomain(instance);
return savedCustomer;
await CustomerModel.create(data, {
include: [{ all: true }],
transaction,
});
return Result.ok();
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
* Actualiza un cliente existente.
*
* @param customer - El cliente a actualizar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async update(customer: Customer, transaction?: Transaction): Promise<Result<void, Error>> {
try {
const mapper: ICustomerDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
const dto = mapper.mapToPersistence(customer);
const { id, ...updatePayload } = dto.data;
const [affected] = await CustomerModel.update(updatePayload, {
where: { id /*, version */ },
//fields: Object.keys(updatePayload),
transaction,
individualHooks: true,
});
if (affected === 0) {
return Result.fail(
new InfrastructureRepositoryError("Concurrency conflict or not found update customer")
);
}
return Result.ok();
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
* Comprueba si existe un Customer con un `id` dentro de una `company`.
*
@ -60,7 +100,7 @@ export class CustomerRepository
transaction,
});
return Result.ok(Boolean(count > 0));
} catch (error: any) {
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
@ -94,7 +134,7 @@ export class CustomerRepository
const customer = mapper.mapToDomain(row);
return customer;
} catch (error: any) {
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
@ -138,8 +178,6 @@ export class CustomerRepository
company_id: companyId.toString(),
};
console.log(query);
const { rows, count } = await CustomerModel.findAndCountAll({
...query,
transaction,
@ -158,20 +196,24 @@ export class CustomerRepository
* @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>
* @returns Result<boolean, Error>
*/
async deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<void>> {
): Promise<Result<boolean, Error>> {
try {
const deleted = await CustomerModel.destroy({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
return Result.ok<void>();
if (deleted === 0) {
return Result.fail(new EntityNotFoundError("Customer", "id", id.toString()));
}
return Result.ok(true);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}

View File

@ -45,7 +45,7 @@ import { PropsWithChildren, createContext } from "react";
export type CustomersContextType = {};
export type CustomersContextParamsType = {
//service: CustomerService;
//service: CustomerApplicationService;
};
export const CustomersContext = createContext<CustomersContextType>({});

View File

@ -11,3 +11,6 @@ export class ApplicationError extends BaseError<"application"> {
super("ApplicationError", message, code, options);
}
}
export const isApplicationError = (e: unknown): e is ApplicationError =>
e instanceof ApplicationError;

View File

@ -19,3 +19,5 @@ export class DomainError extends BaseError<"domain"> {
super("DomainError", message, code, options);
}
}
export const isDomainError = (e: unknown): e is DomainError => e instanceof DomainError;

View File

@ -3,7 +3,14 @@ import { BaseError } from "./base-error";
/** Errores de infraestructura: DB, red, serialización, proveedores externos */
export class InfrastructureError extends BaseError<"infrastructure"> {
public readonly layer = "infrastructure" as const;
constructor(message: string, code = "INFRASTRUCTURE_ERROR", options?: ErrorOptions & { metadata?: Record<string, unknown> }) {
constructor(
message: string,
code = "INFRASTRUCTURE_ERROR",
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
super("InfrastructureError", message, code, options);
}
}
export const isInfrastructureError = (e: unknown): e is InfrastructureError =>
e instanceof InfrastructureError;