From 05f4b1337b76141b6c261e31235cc807483b2685 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 7 Jul 2026 22:52:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(series-de-facturas):=20implementar=20la=20?= =?UTF-8?q?funcionalidad=20de=20gesti=C3=B3n=20de=20series=20de=20facturas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se han añadido las dependencias de las series de facturas y los servicios internos. - Se han creado un buscador de series de facturas y generadores de instantáneas para mostrar una lista de series de facturas. - Se ha implementado un caso de uso para mostrar una lista de series de facturas activas por empresa. - Se ha desarrollado la gestión de errores de la API para los errores relacionados con las series de facturas. - Se han introducido nuevas clases de error para gestionar la validación de las series de facturas y los problemas de concurrencia. - Se han añadido rutas y controladores de Express para la gestión de series de facturas. - Se han definido DTO de respuesta para mostrar una lista de series de facturas. - Se han actualizado los DTO comunes para incluir esquemas relacionados con las series de facturas. --- .../application/invoice-series/di/index.ts | 3 + .../di/invoice-series-finder.di.ts | 8 ++ .../di/invoice-series-snapshot-builders.di.ts | 7 ++ .../di/invoice-series-use-cases.di.ts | 17 +++ .../errors/inactive-invoice-series.error.ts | 6 - .../invoice-series/errors/index.ts | 5 +- .../invoice-series-concurrency.error.ts | 10 ++ .../errors/invoice-series-inactive.error.ts | 10 ++ .../invoice-series-lock-required.error.ts | 10 ++ .../errors/invoice-series-not-found.error.ts | 10 ++ ...voice-series-transaction-required.error.ts | 9 +- .../api/application/invoice-series/index.ts | 2 + .../invoice-series-repository.interface.ts | 7 +- .../invoice-series/services/index.ts | 1 + .../services/invoice-series-finder.ts | 23 ++++ .../invoice-series-number-assigner.ts | 10 +- .../invoice-series/snapshot-builders/index.ts | 1 + .../snapshot-builders/summary/index.ts | 1 + ...invoice-series-summary.snapshot-builder.ts | 21 ++++ .../invoice-series/use-cases/index.ts | 1 + .../use-cases/list-invoice-series.use-case.ts | 40 ++++++ .../issued-invoices/errors/index.ts | 1 + ...ued-invoice-number-already-exists.error.ts | 11 ++ .../api/application/issued-invoices/index.ts | 1 + .../services/issued-invoice-creator.ts | 14 ++- .../api/domain/invoice-series/errors/index.ts | 3 + .../invalid-invoice-series-code.error.ts | 16 +++ ...nvalid-invoice-series-next-number.error.ts | 13 ++ ...lid-invoice-series-padding-length.error.ts | 16 +++ .../src/api/domain/invoice-series/index.ts | 1 + .../value-objects/invoice-series-code.vo.ts | 14 +-- .../invoice-series-next-number.vo.ts | 15 +-- .../invoice-series-padding-length.vo.ts | 13 +- .../aggregates/proforma.aggregate.ts | 9 +- modules/customer-invoices/src/api/index.ts | 5 + .../infrastructure/invoice-series/di/index.ts | 1 + .../invoice-series/di/invoice-series.di.ts | 36 ++++++ .../express/controllers/index.ts | 1 + .../list-invoice-series.controller.ts | 41 ++++++ .../invoice-series/express/index.ts | 3 + .../invoice-series-api-error-mapper.ts | 87 +++++++++++++ .../express/invoice-series.routes.ts | 24 ++++ .../infrastructure/invoice-series/index.ts | 1 + .../mappers/invoice-series-domain.mapper.ts | 35 +++--- .../repositories/invoice-series.repository.ts | 59 +++++++-- .../issued-invoices-api-error-mapper.ts | 76 ++++++++++++ .../repositories/issued-invoice.repository.ts | 32 ++++- .../express/proformas-api-error-mapper.ts | 117 +++++++++++++++++- .../src/common/dto/response/index.ts | 1 + .../dto/response/invoice-series/index.ts | 1 + .../list-invoice-series.response.dto.ts | 9 ++ .../src/common/dto/shared/index.ts | 1 + .../common/dto/shared/invoice-series/index.ts | 1 + .../invoice-series-summary.dto.ts | 11 ++ 54 files changed, 788 insertions(+), 83 deletions(-) create mode 100644 modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-finder.di.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-snapshot-builders.di.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-use-cases.di.ts delete mode 100644 modules/customer-invoices/src/api/application/invoice-series/errors/inactive-invoice-series.error.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-concurrency.error.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-inactive.error.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-lock-required.error.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-not-found.error.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-finder.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/index.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/index.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/invoice-series-summary.snapshot-builder.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/use-cases/index.ts create mode 100644 modules/customer-invoices/src/api/application/invoice-series/use-cases/list-invoice-series.use-case.ts create mode 100644 modules/customer-invoices/src/api/application/issued-invoices/errors/index.ts create mode 100644 modules/customer-invoices/src/api/application/issued-invoices/errors/issued-invoice-number-already-exists.error.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/errors/index.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-code.error.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-next-number.error.ts create mode 100644 modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-padding-length.error.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series.di.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/list-invoice-series.controller.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/express/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series-api-error-mapper.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series.routes.ts create mode 100644 modules/customer-invoices/src/common/dto/response/invoice-series/index.ts create mode 100644 modules/customer-invoices/src/common/dto/response/invoice-series/list-invoice-series.response.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/invoice-series/index.ts create mode 100644 modules/customer-invoices/src/common/dto/shared/invoice-series/invoice-series-summary.dto.ts diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/index.ts b/modules/customer-invoices/src/api/application/invoice-series/di/index.ts index 0ba92c62..4704cea8 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/di/index.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/di/index.ts @@ -1 +1,4 @@ +export * from "./invoice-series-finder.di"; export * from "./invoice-series-number-assigner.di"; +export * from "./invoice-series-snapshot-builders.di"; +export * from "./invoice-series-use-cases.di"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-finder.di.ts b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-finder.di.ts new file mode 100644 index 00000000..77937961 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-finder.di.ts @@ -0,0 +1,8 @@ +import type { IInvoiceSeriesRepository } from "../repositories"; +import { type IInvoiceSeriesFinder, InvoiceSeriesFinder } from "../services"; + +export function buildInvoiceSeriesFinder( + repository: IInvoiceSeriesRepository +): IInvoiceSeriesFinder { + return new InvoiceSeriesFinder(repository); +} diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-snapshot-builders.di.ts b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-snapshot-builders.di.ts new file mode 100644 index 00000000..dfb297a2 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-snapshot-builders.di.ts @@ -0,0 +1,7 @@ +import { InvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders"; + +export function buildInvoiceSeriesSnapshotBuilders() { + return { + summary: new InvoiceSeriesSummarySnapshotBuilder(), + }; +} diff --git a/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-use-cases.di.ts b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-use-cases.di.ts new file mode 100644 index 00000000..7304be3d --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/di/invoice-series-use-cases.di.ts @@ -0,0 +1,17 @@ +import type { ITransactionManager } from "@erp/core/api"; + +import type { IInvoiceSeriesFinder } from "../services"; +import type { IInvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders"; +import { ListInvoiceSeriesUseCase } from "../use-cases"; + +export function buildListInvoiceSeriesUseCase(deps: { + finder: IInvoiceSeriesFinder; + summarySnapshotBuilder: IInvoiceSeriesSummarySnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new ListInvoiceSeriesUseCase( + deps.finder, + deps.summarySnapshotBuilder, + deps.transactionManager + ); +} diff --git a/modules/customer-invoices/src/api/application/invoice-series/errors/inactive-invoice-series.error.ts b/modules/customer-invoices/src/api/application/invoice-series/errors/inactive-invoice-series.error.ts deleted file mode 100644 index 6d7e91a8..00000000 --- a/modules/customer-invoices/src/api/application/invoice-series/errors/inactive-invoice-series.error.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class InactiveInvoiceSeriesError extends Error { - public constructor(code: string) { - super(`Invoice series "${code}" is inactive and cannot assign numbers`); - this.name = "InactiveInvoiceSeriesError"; - } -} diff --git a/modules/customer-invoices/src/api/application/invoice-series/errors/index.ts b/modules/customer-invoices/src/api/application/invoice-series/errors/index.ts index 7c870314..1278066a 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/errors/index.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/index.ts @@ -1,2 +1,5 @@ -export * from "./inactive-invoice-series.error"; +export * from "./invoice-series-concurrency.error"; +export * from "./invoice-series-inactive.error"; +export * from "./invoice-series-lock-required.error"; +export * from "./invoice-series-not-found.error"; export * from "./invoice-series-transaction-required.error"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-concurrency.error.ts b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-concurrency.error.ts new file mode 100644 index 00000000..d9b9f3be --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-concurrency.error.ts @@ -0,0 +1,10 @@ +export class InvoiceSeriesConcurrencyError extends Error { + public constructor(options?: ErrorOptions) { + super("Invoice series could not be updated consistently during number assignment.", options); + this.name = "InvoiceSeriesConcurrencyError"; + } +} + +export const isInvoiceSeriesConcurrencyError = ( + error: unknown +): error is InvoiceSeriesConcurrencyError => error instanceof InvoiceSeriesConcurrencyError; diff --git a/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-inactive.error.ts b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-inactive.error.ts new file mode 100644 index 00000000..4d7356b5 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-inactive.error.ts @@ -0,0 +1,10 @@ +export class InvoiceSeriesInactiveError extends Error { + public constructor(code: string, options?: ErrorOptions) { + super(`Invoice series "${code}" is inactive and cannot assign invoice numbers.`, options); + this.name = "InvoiceSeriesInactiveError"; + } +} + +export const isInvoiceSeriesInactiveError = ( + error: unknown +): error is InvoiceSeriesInactiveError => error instanceof InvoiceSeriesInactiveError; diff --git a/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-lock-required.error.ts b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-lock-required.error.ts new file mode 100644 index 00000000..568ddd11 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-lock-required.error.ts @@ -0,0 +1,10 @@ +export class InvoiceSeriesLockRequiredError extends Error { + public constructor(options?: ErrorOptions) { + super("Invoice series row lock requires an active transaction.", options); + this.name = "InvoiceSeriesLockRequiredError"; + } +} + +export const isInvoiceSeriesLockRequiredError = ( + error: unknown +): error is InvoiceSeriesLockRequiredError => error instanceof InvoiceSeriesLockRequiredError; diff --git a/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-not-found.error.ts b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-not-found.error.ts new file mode 100644 index 00000000..cfeb1720 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-not-found.error.ts @@ -0,0 +1,10 @@ +export class InvoiceSeriesNotFoundError extends Error { + public constructor(options?: ErrorOptions) { + super("Invoice series was not found for the provided company and code.", options); + this.name = "InvoiceSeriesNotFoundError"; + } +} + +export const isInvoiceSeriesNotFoundError = ( + error: unknown +): error is InvoiceSeriesNotFoundError => error instanceof InvoiceSeriesNotFoundError; diff --git a/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-transaction-required.error.ts b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-transaction-required.error.ts index 55402b23..2c4c19d6 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-transaction-required.error.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/errors/invoice-series-transaction-required.error.ts @@ -1,6 +1,11 @@ export class InvoiceSeriesTransactionRequiredError extends Error { - public constructor() { - super("InvoiceSeries number assignment requires an active transaction"); + public constructor(options?: ErrorOptions) { + super("Invoice series number assignment requires an active transaction.", options); this.name = "InvoiceSeriesTransactionRequiredError"; } } + +export const isInvoiceSeriesTransactionRequiredError = ( + error: unknown +): error is InvoiceSeriesTransactionRequiredError => + error instanceof InvoiceSeriesTransactionRequiredError; diff --git a/modules/customer-invoices/src/api/application/invoice-series/index.ts b/modules/customer-invoices/src/api/application/invoice-series/index.ts index bfb90d7c..70fee2b8 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/index.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/index.ts @@ -2,3 +2,5 @@ export * from "./di"; export * from "./errors"; export * from "./repositories"; export * from "./services"; +export * from "./snapshot-builders"; +export * from "./use-cases"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts b/modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts index ce61eff3..5d600a66 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/repositories/invoice-series-repository.interface.ts @@ -1,9 +1,14 @@ import type { UniqueID } from "@repo/rdx-ddd"; -import type { Maybe, Result } from "@repo/rdx-utils"; +import type { Collection, Maybe, Result } from "@repo/rdx-utils"; import type { InvoiceSeries, InvoiceSeriesCode } from "../../../domain"; export interface IInvoiceSeriesRepository { + findActiveByCompany(params: { + companyId: UniqueID; + transaction?: unknown; + }): Promise, Error>>; + findByCodeInCompany(params: { companyId: UniqueID; invoiceSeriesCode: InvoiceSeriesCode; diff --git a/modules/customer-invoices/src/api/application/invoice-series/services/index.ts b/modules/customer-invoices/src/api/application/invoice-series/services/index.ts index 46387972..c7cc7e50 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/services/index.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/services/index.ts @@ -1 +1,2 @@ +export * from "./invoice-series-finder"; export * from "./invoice-series-number-assigner"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-finder.ts b/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-finder.ts new file mode 100644 index 00000000..7f1d96b6 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-finder.ts @@ -0,0 +1,23 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Collection, Result } from "@repo/rdx-utils"; + +import type { InvoiceSeries } from "../../../domain"; +import type { IInvoiceSeriesRepository } from "../repositories"; + +export interface IInvoiceSeriesFinder { + findActiveByCompany( + companyId: UniqueID, + transaction?: unknown + ): Promise, Error>>; +} + +export class InvoiceSeriesFinder implements IInvoiceSeriesFinder { + public constructor(private readonly repository: IInvoiceSeriesRepository) {} + + public findActiveByCompany(companyId: UniqueID, transaction?: unknown) { + return this.repository.findActiveByCompany({ + companyId, + transaction, + }); + } +} diff --git a/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts b/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts index 1b6bc5db..3bf652c8 100644 --- a/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts +++ b/modules/customer-invoices/src/api/application/invoice-series/services/invoice-series-number-assigner.ts @@ -1,10 +1,10 @@ -import { EntityNotFoundError } from "@erp/core/api"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { type InvoiceNumber, type InvoiceSeriesCode } from "../../../domain"; import { - InactiveInvoiceSeriesError, + InvoiceSeriesInactiveError, + InvoiceSeriesNotFoundError, InvoiceSeriesTransactionRequiredError, } from "../errors"; import type { IInvoiceSeriesRepository } from "../repositories"; @@ -45,15 +45,13 @@ export class InvoiceSeriesNumberAssigner implements IInvoiceSeriesNumberAssigner } if (invoiceSeriesResult.data.isNone()) { - return Result.fail( - new EntityNotFoundError("InvoiceSeries", "code", params.invoiceSeriesCode.toPrimitive()) - ); + return Result.fail(new InvoiceSeriesNotFoundError()); } const invoiceSeries = invoiceSeriesResult.data.unwrap(); if (!invoiceSeries.isActive) { - return Result.fail(new InactiveInvoiceSeriesError(invoiceSeries.code.toPrimitive())); + return Result.fail(new InvoiceSeriesInactiveError(invoiceSeries.code.toPrimitive())); } const invoiceNumberResult = invoiceSeries.assignNextInvoiceNumber(); diff --git a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/index.ts b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/index.ts new file mode 100644 index 00000000..f1eb3e1e --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/index.ts @@ -0,0 +1 @@ +export * from "./summary"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/index.ts b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/index.ts new file mode 100644 index 00000000..cf458449 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series-summary.snapshot-builder"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/invoice-series-summary.snapshot-builder.ts b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/invoice-series-summary.snapshot-builder.ts new file mode 100644 index 00000000..00241b52 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/snapshot-builders/summary/invoice-series-summary.snapshot-builder.ts @@ -0,0 +1,21 @@ +import type { ISnapshotBuilder } from "@erp/core/api"; + +import type { InvoiceSeriesSummaryDTO } from "../../../../../common"; +import type { InvoiceSeries } from "../../../../domain"; + +export interface IInvoiceSeriesSummarySnapshotBuilder + extends ISnapshotBuilder {} + +export class InvoiceSeriesSummarySnapshotBuilder + implements IInvoiceSeriesSummarySnapshotBuilder +{ + public toOutput(invoiceSeries: InvoiceSeries): InvoiceSeriesSummaryDTO { + return { + id: invoiceSeries.id.toString(), + code: invoiceSeries.code.toPrimitive(), + next_number: invoiceSeries.nextNumber.toPrimitive(), + padding_length: invoiceSeries.paddingLength.toPrimitive(), + is_default: invoiceSeries.isDefault, + }; + } +} diff --git a/modules/customer-invoices/src/api/application/invoice-series/use-cases/index.ts b/modules/customer-invoices/src/api/application/invoice-series/use-cases/index.ts new file mode 100644 index 00000000..8c8a3dfd --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/use-cases/index.ts @@ -0,0 +1 @@ +export * from "./list-invoice-series.use-case"; diff --git a/modules/customer-invoices/src/api/application/invoice-series/use-cases/list-invoice-series.use-case.ts b/modules/customer-invoices/src/api/application/invoice-series/use-cases/list-invoice-series.use-case.ts new file mode 100644 index 00000000..1baa99f3 --- /dev/null +++ b/modules/customer-invoices/src/api/application/invoice-series/use-cases/list-invoice-series.use-case.ts @@ -0,0 +1,40 @@ +import type { ITransactionManager } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IInvoiceSeriesFinder } from "../services"; +import type { IInvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders"; + +type ListInvoiceSeriesUseCaseInput = { + companyId: UniqueID; +}; + +export class ListInvoiceSeriesUseCase { + public constructor( + private readonly finder: IInvoiceSeriesFinder, + private readonly summarySnapshotBuilder: IInvoiceSeriesSummarySnapshotBuilder, + private readonly transactionManager: ITransactionManager + ) {} + + public execute(params: ListInvoiceSeriesUseCaseInput) { + const { companyId } = params; + + return this.transactionManager.complete(async (transaction: unknown) => { + try { + const result = await this.finder.findActiveByCompany(companyId, transaction); + + if (result.isFailure) { + return Result.fail(result.error); + } + + const items = result.data.map((invoiceSeries) => + this.summarySnapshotBuilder.toOutput(invoiceSeries) + ); + + return Result.ok({ items }); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customer-invoices/src/api/application/issued-invoices/errors/index.ts b/modules/customer-invoices/src/api/application/issued-invoices/errors/index.ts new file mode 100644 index 00000000..3e0dc92a --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/errors/index.ts @@ -0,0 +1 @@ +export * from "./issued-invoice-number-already-exists.error"; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/errors/issued-invoice-number-already-exists.error.ts b/modules/customer-invoices/src/api/application/issued-invoices/errors/issued-invoice-number-already-exists.error.ts new file mode 100644 index 00000000..9e4ec50e --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/errors/issued-invoice-number-already-exists.error.ts @@ -0,0 +1,11 @@ +export class IssuedInvoiceNumberAlreadyExistsError extends Error { + public constructor(options?: ErrorOptions) { + super("Invoice number already exists for this company and series.", options); + this.name = "IssuedInvoiceNumberAlreadyExistsError"; + } +} + +export const isIssuedInvoiceNumberAlreadyExistsError = ( + error: unknown +): error is IssuedInvoiceNumberAlreadyExistsError => + error instanceof IssuedInvoiceNumberAlreadyExistsError; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/index.ts b/modules/customer-invoices/src/api/application/issued-invoices/index.ts index 21fd64a4..b71ee7dc 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/index.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/index.ts @@ -1,4 +1,5 @@ export * from "./di"; +export * from "./errors"; export * from "./models"; export * from "./repositories"; export * from "./services"; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-creator.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-creator.ts index 44cd5ee0..47f5eb63 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-creator.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-creator.ts @@ -1,7 +1,11 @@ import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { type IIssuedInvoiceCreateProps, IssuedInvoice } from "../../../domain"; +import { + type IIssuedInvoiceCreateProps, + InvoiceSeriesCode, + IssuedInvoice, +} from "../../../domain"; import type { IInvoiceSeriesNumberAssigner } from "../../invoice-series"; import type { IIssuedInvoiceRepository } from "../repositories"; @@ -33,9 +37,15 @@ export class IssuedInvoiceCreator implements IIssuedInvoiceCreator { async create(params: IIssuedInvoiceCreatorParams): Promise> { const { companyId, id, props, transaction } = params; + const invoiceSeriesCodeResult = InvoiceSeriesCode.create(props.series.toPrimitive()); + + if (invoiceSeriesCodeResult.isFailure) { + return Result.fail(invoiceSeriesCodeResult.error); + } + const numberResult = await this.invoiceSeriesNumberAssigner.assignNextNumber({ companyId, - invoiceSeriesCode: props.series, + invoiceSeriesCode: invoiceSeriesCodeResult.data, transaction, }); diff --git a/modules/customer-invoices/src/api/domain/invoice-series/errors/index.ts b/modules/customer-invoices/src/api/domain/invoice-series/errors/index.ts new file mode 100644 index 00000000..f3895882 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/errors/index.ts @@ -0,0 +1,3 @@ +export * from "./invalid-invoice-series-code.error"; +export * from "./invalid-invoice-series-next-number.error"; +export * from "./invalid-invoice-series-padding-length.error"; diff --git a/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-code.error.ts b/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-code.error.ts new file mode 100644 index 00000000..9cf2e57c --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-code.error.ts @@ -0,0 +1,16 @@ +import { DomainError } from "@repo/rdx-ddd"; + +export class InvalidInvoiceSeriesCodeError extends DomainError { + public constructor(message = "Invoice series code is invalid.", options?: ErrorOptions) { + super(message, options); + this.name = "InvalidInvoiceSeriesCodeError"; + } + + public static required(options?: ErrorOptions) { + return new InvalidInvoiceSeriesCodeError("Invoice series code is required.", options); + } +} + +export const isInvalidInvoiceSeriesCodeError = ( + error: unknown +): error is InvalidInvoiceSeriesCodeError => error instanceof InvalidInvoiceSeriesCodeError; diff --git a/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-next-number.error.ts b/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-next-number.error.ts new file mode 100644 index 00000000..d6c6fa13 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-next-number.error.ts @@ -0,0 +1,13 @@ +import { DomainError } from "@repo/rdx-ddd"; + +export class InvalidInvoiceSeriesNextNumberError extends DomainError { + public constructor(message = "Invoice series next number is invalid.", options?: ErrorOptions) { + super(message, options); + this.name = "InvalidInvoiceSeriesNextNumberError"; + } +} + +export const isInvalidInvoiceSeriesNextNumberError = ( + error: unknown +): error is InvalidInvoiceSeriesNextNumberError => + error instanceof InvalidInvoiceSeriesNextNumberError; diff --git a/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-padding-length.error.ts b/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-padding-length.error.ts new file mode 100644 index 00000000..f1fc7772 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/invoice-series/errors/invalid-invoice-series-padding-length.error.ts @@ -0,0 +1,16 @@ +import { DomainError } from "@repo/rdx-ddd"; + +export class InvalidInvoiceSeriesPaddingLengthError extends DomainError { + public constructor( + message = "Invoice series padding length is invalid.", + options?: ErrorOptions + ) { + super(message, options); + this.name = "InvalidInvoiceSeriesPaddingLengthError"; + } +} + +export const isInvalidInvoiceSeriesPaddingLengthError = ( + error: unknown +): error is InvalidInvoiceSeriesPaddingLengthError => + error instanceof InvalidInvoiceSeriesPaddingLengthError; diff --git a/modules/customer-invoices/src/api/domain/invoice-series/index.ts b/modules/customer-invoices/src/api/domain/invoice-series/index.ts index 3ad2d38e..ed09726f 100644 --- a/modules/customer-invoices/src/api/domain/invoice-series/index.ts +++ b/modules/customer-invoices/src/api/domain/invoice-series/index.ts @@ -1,2 +1,3 @@ export * from "./aggregates"; +export * from "./errors"; export * from "./value-objects"; diff --git a/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-code.vo.ts b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-code.vo.ts index 77834dde..a379799d 100644 --- a/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-code.vo.ts +++ b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-code.vo.ts @@ -1,15 +1,15 @@ -import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { z } from "zod/v4"; +import { InvalidInvoiceSeriesCodeError } from "../errors"; + type InvoiceSeriesCodeProps = { value: string; }; export class InvoiceSeriesCode extends ValueObject { private static readonly MAX_LENGTH = 10; - private static readonly FIELD = "invoiceSeriesCode"; - private static readonly ERROR_CODE = "INVALID_INVOICE_SERIES_CODE"; private static validate(value: string) { return z @@ -26,13 +26,7 @@ export class InvoiceSeriesCode extends ValueObject { const validationResult = InvoiceSeriesCode.validate(value); if (!validationResult.success) { - return Result.fail( - new DomainValidationError( - InvoiceSeriesCode.ERROR_CODE, - InvoiceSeriesCode.FIELD, - validationResult.error.message - ) - ); + return Result.fail(new InvalidInvoiceSeriesCodeError(validationResult.error.message)); } return Result.ok(new InvoiceSeriesCode({ value: validationResult.data })); diff --git a/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-next-number.vo.ts b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-next-number.vo.ts index f834dd4e..470cb939 100644 --- a/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-next-number.vo.ts +++ b/modules/customer-invoices/src/api/domain/invoice-series/value-objects/invoice-series-next-number.vo.ts @@ -1,15 +1,14 @@ -import { DomainValidationError, ValueObject } from "@repo/rdx-ddd"; +import { ValueObject } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { z } from "zod/v4"; +import { InvalidInvoiceSeriesNextNumberError } from "../errors"; + type InvoiceSeriesNextNumberProps = { value: number; }; export class InvoiceSeriesNextNumber extends ValueObject { - private static readonly FIELD = "nextNumber"; - private static readonly ERROR_CODE = "INVALID_INVOICE_SERIES_NEXT_NUMBER"; - private static validate(value: number) { return z .number() @@ -22,13 +21,7 @@ export class InvoiceSeriesNextNumber extends ValueObject { - private static readonly FIELD = "paddingLength"; - private static readonly ERROR_CODE = "INVALID_INVOICE_SERIES_PADDING_LENGTH"; - private static validate(value: number) { return z .number() @@ -23,11 +22,7 @@ export class InvoiceSeriesPaddingLength extends ValueObject implements IP private validateCanBeIssued(): Result { if (this.series.isNone()) { - return Result.fail( - new DomainValidationError( - "MISSING_SERIES", - "series", - "Series is required to issue the proforma" - ) - ); + return Result.fail(InvalidInvoiceSeriesCodeError.required()); } if (this.description.isNone()) { diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index da2ec2be..cbdfff88 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -1,10 +1,12 @@ import type { IModuleServer } from "@erp/core/api"; import { + buildInvoiceSeriesDependencies, buildIssuedInvoicePublicServices, buildIssuedInvoicesDependencies, buildProformaPublicServices, buildProformasDependencies, + invoiceSeriesRouter, issuedInvoicesRouter, models, proformasRouter, @@ -28,6 +30,7 @@ export const customerInvoicesAPIModule: IModuleServer = { const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; // 1) Dominio interno + const invoiceSeriesInternal = buildInvoiceSeriesDependencies(params); const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params); const proformasInternal = buildProformasDependencies(params); @@ -49,6 +52,7 @@ export const customerInvoicesAPIModule: IModuleServer = { // Implementación privada del módulo internal: { + invoiceSeries: invoiceSeriesInternal, issuedInvoices: issuedInvoicesInternal, proformas: proformasInternal, }, @@ -66,6 +70,7 @@ export const customerInvoicesAPIModule: IModuleServer = { const { app, baseRoutePath, logger, getInternal, getService } = params; // Registro de rutas HTTP + invoiceSeriesRouter(params); issuedInvoicesRouter(params); proformasRouter(params); diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts index 0f9cfcfc..f18b0791 100644 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/index.ts @@ -1 +1,2 @@ +export * from "./invoice-series.di"; export * from "./invoice-series-repository.di"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series.di.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series.di.ts new file mode 100644 index 00000000..c2a6638b --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/di/invoice-series.di.ts @@ -0,0 +1,36 @@ +import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; + +import { + type ListInvoiceSeriesUseCase, + buildInvoiceSeriesFinder, + buildInvoiceSeriesSnapshotBuilders, + buildListInvoiceSeriesUseCase, +} from "../../../application"; + +import { buildInvoiceSeriesRepository } from "./invoice-series-repository.di"; + +export type InvoiceSeriesInternalDeps = { + useCases: { + listInvoiceSeries: () => ListInvoiceSeriesUseCase; + }; +}; + +export function buildInvoiceSeriesDependencies(params: ModuleParams): InvoiceSeriesInternalDeps { + const { database } = params; + + const transactionManager = buildTransactionManager(database); + const repository = buildInvoiceSeriesRepository(database); + const finder = buildInvoiceSeriesFinder(repository); + const snapshotBuilders = buildInvoiceSeriesSnapshotBuilders(); + + return { + useCases: { + listInvoiceSeries: () => + buildListInvoiceSeriesUseCase({ + finder, + summarySnapshotBuilder: snapshotBuilders.summary, + transactionManager, + }), + }, + }; +} diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/index.ts new file mode 100644 index 00000000..e3460916 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/index.ts @@ -0,0 +1 @@ +export * from "./list-invoice-series.controller"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/list-invoice-series.controller.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/list-invoice-series.controller.ts new file mode 100644 index 00000000..21579783 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/controllers/list-invoice-series.controller.ts @@ -0,0 +1,41 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import { ListInvoiceSeriesResponseSchema } from "../../../../../common"; +import type { ListInvoiceSeriesUseCase } from "../../../../application"; +import { invoiceSeriesApiErrorMapper } from "../invoice-series-api-error-mapper"; + +export class ListInvoiceSeriesController extends ExpressController { + public constructor(private readonly useCase: ListInvoiceSeriesUseCase) { + super(); + this.errorMapper = invoiceSeriesApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId"), + forbidQueryFieldGuard("company_id") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const result = await this.useCase.execute({ companyId }); + + return result.match( + (data) => { + const dto = ListInvoiceSeriesResponseSchema.parse(data); + return this.ok(dto); + }, + (error) => this.handleError(error) + ); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/index.ts new file mode 100644 index 00000000..df8a16c7 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/index.ts @@ -0,0 +1,3 @@ +export * from "./controllers"; +export * from "./invoice-series-api-error-mapper"; +export * from "./invoice-series.routes"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series-api-error-mapper.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series-api-error-mapper.ts new file mode 100644 index 00000000..dbd272c4 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series-api-error-mapper.ts @@ -0,0 +1,87 @@ +import { + ApiErrorMapper, + type ErrorToApiRule, + InternalApiError, + ValidationApiError, +} from "@erp/core/api"; + +import { + InvoiceSeriesConcurrencyError, + InvoiceSeriesLockRequiredError, + InvoiceSeriesTransactionRequiredError, +} from "../../../application"; +import { + type InvalidInvoiceSeriesCodeError, + type InvalidInvoiceSeriesNextNumberError, + type InvalidInvoiceSeriesPaddingLengthError, + isInvalidInvoiceSeriesCodeError, + isInvalidInvoiceSeriesNextNumberError, + isInvalidInvoiceSeriesPaddingLengthError, +} from "../../../domain"; + +const invalidInvoiceSeriesCodeRule: ErrorToApiRule = { + priority: 130, + matches: (error) => isInvalidInvoiceSeriesCodeError(error), + build: (error) => + new ValidationApiError( + (error as InvalidInvoiceSeriesCodeError).message || "Invoice series code is invalid." + ), +}; + +const invalidInvoiceSeriesNextNumberRule: ErrorToApiRule = { + priority: 130, + matches: (error) => isInvalidInvoiceSeriesNextNumberError(error), + build: (error) => + new ValidationApiError( + (error as InvalidInvoiceSeriesNextNumberError).message || + "Invoice series next number is invalid." + ), +}; + +const invalidInvoiceSeriesPaddingLengthRule: ErrorToApiRule = { + priority: 130, + matches: (error) => isInvalidInvoiceSeriesPaddingLengthError(error), + build: (error) => + new ValidationApiError( + (error as InvalidInvoiceSeriesPaddingLengthError).message || + "Invoice series padding length is invalid." + ), +}; + +const invoiceSeriesTransactionRequiredRule: ErrorToApiRule = { + priority: 130, + matches: (error) => error instanceof InvoiceSeriesTransactionRequiredError, + build: (error) => + new InternalApiError( + (error as InvoiceSeriesTransactionRequiredError).message || + "Invoice series operations require an active transaction." + ), +}; + +const invoiceSeriesLockRequiredRule: ErrorToApiRule = { + priority: 130, + matches: (error) => error instanceof InvoiceSeriesLockRequiredError, + build: (error) => + new InternalApiError( + (error as InvoiceSeriesLockRequiredError).message || + "Invoice series row lock requires an active transaction." + ), +}; + +const invoiceSeriesConcurrencyRule: ErrorToApiRule = { + priority: 130, + matches: (error) => error instanceof InvoiceSeriesConcurrencyError, + build: (error) => + new InternalApiError( + (error as InvoiceSeriesConcurrencyError).message || + "Invoice series persistence ended in an unexpected concurrency state." + ), +}; + +export const invoiceSeriesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() + .register(invoiceSeriesConcurrencyRule) + .register(invoiceSeriesLockRequiredRule) + .register(invoiceSeriesTransactionRequiredRule) + .register(invalidInvoiceSeriesPaddingLengthRule) + .register(invalidInvoiceSeriesNextNumberRule) + .register(invalidInvoiceSeriesCodeRule); diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series.routes.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series.routes.ts new file mode 100644 index 00000000..0e227b0b --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/express/invoice-series.routes.ts @@ -0,0 +1,24 @@ +import { type StartParams } from "@erp/core/api"; +import { requireIdentityTenant } from "@erp/identity/api"; +import { type NextFunction, type Request, type Response, Router } from "express"; + +import type { InvoiceSeriesInternalDeps } from "../di/invoice-series.di"; +import { ListInvoiceSeriesController } from "./controllers"; + +export const invoiceSeriesRouter = (params: StartParams) => { + const { app, config, getInternal } = params; + + const deps = getInternal("customer-invoices", "invoiceSeries"); + + const router: Router = Router({ mergeParams: true }); + + router.use(...requireIdentityTenant(params)); + + router.get("/", (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.listInvoiceSeries(); + const controller = new ListInvoiceSeriesController(useCase); + return controller.execute(req, res, next); + }); + + app.use(`${config.server.apiBasePath}/invoice-series`, router); +}; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts index 96e04610..3e34d105 100644 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/index.ts @@ -1,2 +1,3 @@ export * from "./di"; +export * from "./express"; export * from "./persistence"; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts index 665957c0..15e2c8ef 100644 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/mappers/invoice-series-domain.mapper.ts @@ -7,6 +7,9 @@ import { import { Maybe, Result } from "@repo/rdx-utils"; import { + type InvalidInvoiceSeriesCodeError, + type InvalidInvoiceSeriesNextNumberError, + type InvalidInvoiceSeriesPaddingLengthError, InvoiceSeries, InvoiceSeriesCode, type InvoiceSeriesInternalProps, @@ -21,17 +24,21 @@ export class InvoiceSeriesDomainMapper { const id = extractOrPushError(UniqueID.create(raw.id), "id", errors); const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); - const code = extractOrPushError(InvoiceSeriesCode.create(raw.code), "code", errors); - const nextNumber = extractOrPushError( - InvoiceSeriesNextNumber.create(raw.next_number), - "next_number", - errors - ); - const paddingLength = extractOrPushError( - InvoiceSeriesPaddingLength.create(raw.padding_length), - "padding_length", - errors - ); + + const codeResult = InvoiceSeriesCode.create(raw.code); + if (codeResult.isFailure) { + return Result.fail(codeResult.error as InvalidInvoiceSeriesCodeError); + } + + const nextNumberResult = InvoiceSeriesNextNumber.create(raw.next_number); + if (nextNumberResult.isFailure) { + return Result.fail(nextNumberResult.error as InvalidInvoiceSeriesNextNumberError); + } + + const paddingLengthResult = InvoiceSeriesPaddingLength.create(raw.padding_length); + if (paddingLengthResult.isFailure) { + return Result.fail(paddingLengthResult.error as InvalidInvoiceSeriesPaddingLengthError); + } if (errors.length > 0) { return Result.fail( @@ -41,9 +48,9 @@ export class InvoiceSeriesDomainMapper { const props: InvoiceSeriesInternalProps = { companyId: companyId!, - code: code!, - nextNumber: nextNumber!, - paddingLength: paddingLength!, + code: codeResult.data, + nextNumber: nextNumberResult.data, + paddingLength: paddingLengthResult.data, isDefault: raw.is_default, isActive: raw.is_active, }; diff --git a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts index bea43dd8..fbb4bc45 100644 --- a/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/invoice-series/persistence/sequelize/repositories/invoice-series.repository.ts @@ -1,13 +1,17 @@ import { - InfrastructureRepositoryError, SequelizeRepository, translateSequelizeError, } from "@erp/core/api"; import type { UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; +import { Collection, Result } from "@repo/rdx-utils"; import type { Sequelize, Transaction } from "sequelize"; import type { IInvoiceSeriesRepository } from "../../../../../application"; +import { + InvoiceSeriesConcurrencyError, + InvoiceSeriesLockRequiredError, + InvoiceSeriesTransactionRequiredError, +} from "../../../../../application/invoice-series"; import type { InvoiceSeries, InvoiceSeriesCode } from "../../../../../domain"; import { CustomerInvoiceSeriesModel } from "../../../../common"; import type { InvoiceSeriesDomainMapper } from "../mappers"; @@ -23,6 +27,41 @@ export class SequelizeInvoiceSeriesRepository super({ database }); } + public async findActiveByCompany(params: { + companyId: UniqueID; + transaction?: unknown; + }) { + try { + const rows = await CustomerInvoiceSeriesModel.findAll({ + where: { + company_id: params.companyId.toString(), + is_active: true, + }, + order: [ + ["is_default", "DESC"], + ["code", "ASC"], + ], + transaction: params.transaction as Transaction | undefined, + }); + + const invoiceSeriesRows: InvoiceSeries[] = []; + + for (const row of rows) { + const invoiceSeriesResult = this.domainMapper.mapToDomain(row); + + if (invoiceSeriesResult.isFailure) { + return Result.fail(invoiceSeriesResult.error); + } + + invoiceSeriesRows.push(invoiceSeriesResult.data); + } + + return Result.ok(new Collection(invoiceSeriesRows)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + public async findByCodeInCompany(params: { companyId: UniqueID; invoiceSeriesCode: InvoiceSeriesCode; @@ -52,11 +91,7 @@ export class SequelizeInvoiceSeriesRepository const transaction = params.transaction as Transaction; if (!transaction) { - return Result.fail( - new InfrastructureRepositoryError( - "InvoiceSeries row lock requires an active transaction" - ) - ); + return Result.fail(new InvoiceSeriesLockRequiredError()); } const row = await CustomerInvoiceSeriesModel.findOne({ @@ -79,6 +114,10 @@ export class SequelizeInvoiceSeriesRepository transaction?: unknown ): Promise> { try { + if (!transaction) { + return Result.fail(new InvoiceSeriesTransactionRequiredError()); + } + const dtoResult = this.domainMapper.mapToPersistence(invoiceSeries); if (dtoResult.isFailure) { @@ -92,11 +131,7 @@ export class SequelizeInvoiceSeriesRepository }); if (affectedRows !== 1) { - return Result.fail( - new InfrastructureRepositoryError( - `InvoiceSeries ${id} could not be updated or no longer exists` - ) - ); + return Result.fail(new InvoiceSeriesConcurrencyError()); } return Result.ok(); diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/issued-invoices-api-error-mapper.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/issued-invoices-api-error-mapper.ts index ee3c898e..0901c64d 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/issued-invoices-api-error-mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/express/issued-invoices-api-error-mapper.ts @@ -5,9 +5,19 @@ import { ApiErrorMapper, ConflictApiError, type ErrorToApiRule, + InternalApiError, ValidationApiError, } from "@erp/core/api"; +import { + InvoiceSeriesConcurrencyError, + InvoiceSeriesInactiveError, + InvoiceSeriesLockRequiredError, + InvoiceSeriesNotFoundError, + InvoiceSeriesTransactionRequiredError, + IssuedInvoiceNumberAlreadyExistsError, + isIssuedInvoiceNumberAlreadyExistsError, +} from "../../../application"; import { type CustomerInvoiceIdAlreadyExistsError, type IssuedInvoiceItemMismatch, @@ -36,7 +46,73 @@ const issuedinvoiceItemMismatchError: ErrorToApiRule = { ), }; +const invoiceSeriesNotFoundRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesNotFoundError, + build: (e) => + new ConflictApiError( + (e as InvoiceSeriesNotFoundError).message || + "Invoice series was not found for the provided company and code." + ), +}; + +const invoiceSeriesInactiveRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesInactiveError, + build: (e) => + new ConflictApiError( + (e as InvoiceSeriesInactiveError).message || + "Invoice series is inactive and cannot assign invoice numbers." + ), +}; + +const invoiceSeriesTransactionRequiredRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesTransactionRequiredError, + build: (e) => + new InternalApiError( + (e as InvoiceSeriesTransactionRequiredError).message || + "Invoice series number assignment requires an active transaction." + ), +}; + +const invoiceSeriesLockRequiredRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesLockRequiredError, + build: (e) => + new InternalApiError( + (e as InvoiceSeriesLockRequiredError).message || + "Invoice series row lock requires an active transaction." + ), +}; + +const invoiceSeriesConcurrencyRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesConcurrencyError, + build: (e) => + new ConflictApiError( + (e as InvoiceSeriesConcurrencyError).message || + "Invoice series could not be updated consistently during number assignment." + ), +}; + +const issuedInvoiceNumberAlreadyExistsRule: ErrorToApiRule = { + priority: 130, + matches: (e) => isIssuedInvoiceNumberAlreadyExistsError(e), + build: (e) => + new ConflictApiError( + (e as IssuedInvoiceNumberAlreadyExistsError).message || + "Invoice number already exists for this company and series." + ), +}; + // Cómo aplicarla: crea una nueva instancia del mapper con la regla extra export const issuedInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() + .register(issuedInvoiceNumberAlreadyExistsRule) + .register(invoiceSeriesConcurrencyRule) + .register(invoiceSeriesLockRequiredRule) + .register(invoiceSeriesTransactionRequiredRule) + .register(invoiceSeriesInactiveRule) + .register(invoiceSeriesNotFoundRule) .register(invoiceDuplicateRule) .register(issuedinvoiceItemMismatchError); diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice.repository.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice.repository.ts index 1fa0eb5f..cbae09a3 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice.repository.ts @@ -17,10 +17,15 @@ import { type OrderItem, type Sequelize, type Transaction, + UniqueConstraintError, type WhereOptions, } from "sequelize"; -import type { IIssuedInvoiceRepository, IssuedInvoiceSummary } from "../../../../../application"; +import { + IssuedInvoiceNumberAlreadyExistsError, + type IIssuedInvoiceRepository, + type IssuedInvoiceSummary, +} from "../../../../../application"; import type { IssuedInvoice } from "../../../../../domain"; import { CustomerInvoiceItemModel, @@ -37,6 +42,8 @@ export class IssuedInvoiceRepository extends SequelizeRepository implements IIssuedInvoiceRepository { + private static readonly INVOICE_NUMBER_UNIQUE_INDEX = "idx_invoice_company_series_number"; + constructor( private readonly domainMapper: SequelizeIssuedInvoiceDomainMapper, private readonly summaryMapper: SequelizeIssuedInvoiceSummaryMapper, @@ -92,10 +99,33 @@ export class IssuedInvoiceRepository return Result.ok(); } catch (err: unknown) { + if (this.isIssuedInvoiceNumberAlreadyExistsError(err)) { + return Result.fail(new IssuedInvoiceNumberAlreadyExistsError({ cause: err as Error })); + } + return Result.fail(translateSequelizeError(err)); } } + private isIssuedInvoiceNumberAlreadyExistsError(error: unknown): boolean { + if (!(error instanceof UniqueConstraintError)) { + return false; + } + + const fields = Object.keys(error.fields ?? {}); + const hasExpectedFields = + fields.includes("company_id") && + fields.includes("series") && + fields.includes("invoice_number") && + fields.includes("is_proforma"); + + const byIndexName = error.errors.some( + (detail) => detail.validatorKey === "not_unique" && detail.path === "idx_invoice_company_series_number" + ); + + return hasExpectedFields || byIndexName || error.message.includes(IssuedInvoiceRepository.INVOICE_NUMBER_UNIQUE_INDEX); + } + /** * Comprueba si existe una factura con un `id` dentro de una `company`. * diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts index fe0c6b06..088f1f07 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts @@ -2,18 +2,35 @@ import { ApiErrorMapper, ConflictApiError, type ErrorToApiRule, + InternalApiError, ValidationApiError, } from "@erp/core/api"; -import { CompanyReportProfileNotFoundError, isCompanyReportProfileNotFoundError } from "../../../application"; +import { + CompanyReportProfileNotFoundError, + InvoiceSeriesConcurrencyError, + InvoiceSeriesInactiveError, + InvoiceSeriesLockRequiredError, + InvoiceSeriesNotFoundError, + InvoiceSeriesTransactionRequiredError, + IssuedInvoiceNumberAlreadyExistsError, + isCompanyReportProfileNotFoundError, + isIssuedInvoiceNumberAlreadyExistsError, +} from "../../../application"; import { type CustomerInvoiceIdAlreadyExistsError, type EntityIsNotProformaError, + type InvalidInvoiceSeriesCodeError, + type InvalidInvoiceSeriesNextNumberError, + type InvalidInvoiceSeriesPaddingLengthError, type InvalidProformaTransitionError, type ProformaCannotBeConvertedToInvoiceError, type ProformaItemMismatch, isCustomerInvoiceIdAlreadyExistsError, isEntityIsNotProformaError, + isInvalidInvoiceSeriesCodeError, + isInvalidInvoiceSeriesNextNumberError, + isInvalidInvoiceSeriesPaddingLengthError, isInvalidProformaTransitionError, isProformaCannotBeConvertedToInvoiceError, isProformaCannotBeDeletedError, @@ -88,8 +105,106 @@ const companyReportProfileNotFoundRule: ErrorToApiRule = { ), }; +const invalidInvoiceSeriesCodeRule: ErrorToApiRule = { + priority: 130, + matches: (e) => isInvalidInvoiceSeriesCodeError(e), + build: (e) => + new ValidationApiError( + (e as InvalidInvoiceSeriesCodeError).message || "Invoice series code is invalid." + ), +}; + +const invalidInvoiceSeriesNextNumberRule: ErrorToApiRule = { + priority: 130, + matches: (e) => isInvalidInvoiceSeriesNextNumberError(e), + build: (e) => + new ValidationApiError( + (e as InvalidInvoiceSeriesNextNumberError).message || + "Invoice series next number is invalid." + ), +}; + +const invalidInvoiceSeriesPaddingLengthRule: ErrorToApiRule = { + priority: 130, + matches: (e) => isInvalidInvoiceSeriesPaddingLengthError(e), + build: (e) => + new ValidationApiError( + (e as InvalidInvoiceSeriesPaddingLengthError).message || + "Invoice series padding length is invalid." + ), +}; + +const invoiceSeriesNotFoundRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesNotFoundError, + build: (e) => + new ConflictApiError( + (e as InvoiceSeriesNotFoundError).message || + "Invoice series was not found for the provided company and code." + ), +}; + +const invoiceSeriesInactiveRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesInactiveError, + build: (e) => + new ConflictApiError( + (e as InvoiceSeriesInactiveError).message || + "Invoice series is inactive and cannot assign invoice numbers." + ), +}; + +const invoiceSeriesTransactionRequiredRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesTransactionRequiredError, + build: (e) => + new InternalApiError( + (e as InvoiceSeriesTransactionRequiredError).message || + "Invoice series number assignment requires an active transaction." + ), +}; + +const invoiceSeriesLockRequiredRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesLockRequiredError, + build: (e) => + new InternalApiError( + (e as InvoiceSeriesLockRequiredError).message || + "Invoice series row lock requires an active transaction." + ), +}; + +const invoiceSeriesConcurrencyRule: ErrorToApiRule = { + priority: 130, + matches: (e) => e instanceof InvoiceSeriesConcurrencyError, + build: (e) => + new ConflictApiError( + (e as InvoiceSeriesConcurrencyError).message || + "Invoice series could not be updated consistently during number assignment." + ), +}; + +const issuedInvoiceNumberAlreadyExistsRule: ErrorToApiRule = { + priority: 130, + matches: (e) => isIssuedInvoiceNumberAlreadyExistsError(e), + build: (e) => + new ConflictApiError( + (e as IssuedInvoiceNumberAlreadyExistsError).message || + "Invoice number already exists for this company and series." + ), +}; + // Cómo aplicarla: crea una nueva instancia del mapper con la regla extra export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() + .register(issuedInvoiceNumberAlreadyExistsRule) + .register(invoiceSeriesConcurrencyRule) + .register(invoiceSeriesLockRequiredRule) + .register(invoiceSeriesTransactionRequiredRule) + .register(invoiceSeriesInactiveRule) + .register(invoiceSeriesNotFoundRule) + .register(invalidInvoiceSeriesPaddingLengthRule) + .register(invalidInvoiceSeriesNextNumberRule) + .register(invalidInvoiceSeriesCodeRule) .register(companyReportProfileNotFoundRule) .register(proformaDuplicateRule) .register(proformaItemMismatchError) diff --git a/modules/customer-invoices/src/common/dto/response/index.ts b/modules/customer-invoices/src/common/dto/response/index.ts index de2b09ca..e4a0d309 100644 --- a/modules/customer-invoices/src/common/dto/response/index.ts +++ b/modules/customer-invoices/src/common/dto/response/index.ts @@ -1,2 +1,3 @@ +export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; diff --git a/modules/customer-invoices/src/common/dto/response/invoice-series/index.ts b/modules/customer-invoices/src/common/dto/response/invoice-series/index.ts new file mode 100644 index 00000000..e77badcc --- /dev/null +++ b/modules/customer-invoices/src/common/dto/response/invoice-series/index.ts @@ -0,0 +1 @@ +export * from "./list-invoice-series.response.dto"; diff --git a/modules/customer-invoices/src/common/dto/response/invoice-series/list-invoice-series.response.dto.ts b/modules/customer-invoices/src/common/dto/response/invoice-series/list-invoice-series.response.dto.ts new file mode 100644 index 00000000..e45a7309 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/response/invoice-series/list-invoice-series.response.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +import { InvoiceSeriesSummarySchema } from "../../shared/invoice-series"; + +export const ListInvoiceSeriesResponseSchema = z.object({ + items: z.array(InvoiceSeriesSummarySchema), +}); + +export type ListInvoiceSeriesResponseDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/shared/index.ts b/modules/customer-invoices/src/common/dto/shared/index.ts index 33adbd78..dbe6afd9 100644 --- a/modules/customer-invoices/src/common/dto/shared/index.ts +++ b/modules/customer-invoices/src/common/dto/shared/index.ts @@ -1,4 +1,5 @@ export * from "./issued-invoices"; +export * from "./invoice-series"; export * from "./item-position.dto"; export * from "./payment-method-ref.dto"; export * from "./payment-term-ref.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/invoice-series/index.ts b/modules/customer-invoices/src/common/dto/shared/invoice-series/index.ts new file mode 100644 index 00000000..9857cb68 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/invoice-series/index.ts @@ -0,0 +1 @@ +export * from "./invoice-series-summary.dto"; diff --git a/modules/customer-invoices/src/common/dto/shared/invoice-series/invoice-series-summary.dto.ts b/modules/customer-invoices/src/common/dto/shared/invoice-series/invoice-series-summary.dto.ts new file mode 100644 index 00000000..4867e202 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/shared/invoice-series/invoice-series-summary.dto.ts @@ -0,0 +1,11 @@ +import { z } from "zod/v4"; + +export const InvoiceSeriesSummarySchema = z.object({ + id: z.uuid(), + code: z.string(), + next_number: z.number().int().nonnegative(), + padding_length: z.number().int().nonnegative(), + is_default: z.boolean(), +}); + +export type InvoiceSeriesSummaryDTO = z.infer;