feat(series-de-facturas): implementar la funcionalidad de gestión de series de facturas

- 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.
This commit is contained in:
David Arranz 2026-07-07 22:52:14 +02:00
parent db4fa55cc0
commit 05f4b1337b
54 changed files with 788 additions and 83 deletions

View File

@ -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";

View File

@ -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);
}

View File

@ -0,0 +1,7 @@
import { InvoiceSeriesSummarySnapshotBuilder } from "../snapshot-builders";
export function buildInvoiceSeriesSnapshotBuilders() {
return {
summary: new InvoiceSeriesSummarySnapshotBuilder(),
};
}

View File

@ -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
);
}

View File

@ -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";
}
}

View File

@ -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";

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -2,3 +2,5 @@ export * from "./di";
export * from "./errors";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";
export * from "./use-cases";

View File

@ -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<Result<Collection<InvoiceSeries>, Error>>;
findByCodeInCompany(params: {
companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode;

View File

@ -1 +1,2 @@
export * from "./invoice-series-finder";
export * from "./invoice-series-number-assigner";

View File

@ -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<Result<Collection<InvoiceSeries>, Error>>;
}
export class InvoiceSeriesFinder implements IInvoiceSeriesFinder {
public constructor(private readonly repository: IInvoiceSeriesRepository) {}
public findActiveByCompany(companyId: UniqueID, transaction?: unknown) {
return this.repository.findActiveByCompany({
companyId,
transaction,
});
}
}

View File

@ -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();

View File

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

View File

@ -0,0 +1 @@
export * from "./invoice-series-summary.snapshot-builder";

View File

@ -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<InvoiceSeries, InvoiceSeriesSummaryDTO> {}
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,
};
}
}

View File

@ -0,0 +1 @@
export * from "./list-invoice-series.use-case";

View File

@ -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);
}
});
}
}

View File

@ -0,0 +1 @@
export * from "./issued-invoice-number-already-exists.error";

View File

@ -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;

View File

@ -1,4 +1,5 @@
export * from "./di";
export * from "./errors";
export * from "./models";
export * from "./repositories";
export * from "./services";

View File

@ -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<Result<IssuedInvoice, Error>> {
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,
});

View File

@ -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";

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,2 +1,3 @@
export * from "./aggregates";
export * from "./errors";
export * from "./value-objects";

View File

@ -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<InvoiceSeriesCodeProps> {
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<InvoiceSeriesCodeProps> {
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 }));

View File

@ -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<InvoiceSeriesNextNumberProps> {
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<InvoiceSeriesNextNumber
const validationResult = InvoiceSeriesNextNumber.validate(value);
if (!validationResult.success) {
return Result.fail(
new DomainValidationError(
InvoiceSeriesNextNumber.ERROR_CODE,
InvoiceSeriesNextNumber.FIELD,
validationResult.error.message
)
);
return Result.fail(new InvalidInvoiceSeriesNextNumberError(validationResult.error.message));
}
return Result.ok(new InvoiceSeriesNextNumber({ value: validationResult.data }));

View File

@ -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 { InvalidInvoiceSeriesPaddingLengthError } from "../errors";
type InvoiceSeriesPaddingLengthProps = {
value: number;
};
export class InvoiceSeriesPaddingLength extends ValueObject<InvoiceSeriesPaddingLengthProps> {
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<InvoiceSeriesPadding
if (!validationResult.success) {
return Result.fail(
new DomainValidationError(
InvoiceSeriesPaddingLength.ERROR_CODE,
InvoiceSeriesPaddingLength.FIELD,
validationResult.error.message
)
new InvalidInvoiceSeriesPaddingLengthError(validationResult.error.message)
);
}

View File

@ -19,6 +19,7 @@ import {
InvoiceStatus,
type ItemAmount,
} from "../../common/value-objects";
import { InvalidInvoiceSeriesCodeError } from "../../invoice-series";
import {
type IProformaItemCreateProps,
type IProformaItems,
@ -363,13 +364,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
private validateCanBeIssued(): Result<void, Error> {
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()) {

View File

@ -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);

View File

@ -1 +1,2 @@
export * from "./invoice-series.di";
export * from "./invoice-series-repository.di";

View File

@ -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,
}),
},
};
}

View File

@ -0,0 +1 @@
export * from "./list-invoice-series.controller";

View File

@ -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)
);
}
}

View File

@ -0,0 +1,3 @@
export * from "./controllers";
export * from "./invoice-series-api-error-mapper";
export * from "./invoice-series.routes";

View File

@ -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);

View File

@ -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<InvoiceSeriesInternalDeps>("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);
};

View File

@ -1,2 +1,3 @@
export * from "./di";
export * from "./express";
export * from "./persistence";

View File

@ -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,
};

View File

@ -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<Result<void, Error>> {
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();

View File

@ -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);

View File

@ -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<IssuedInvoice>
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`.
*

View File

@ -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)

View File

@ -1,2 +1,3 @@
export * from "./invoice-series";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -0,0 +1 @@
export * from "./list-invoice-series.response.dto";

View File

@ -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<typeof ListInvoiceSeriesResponseSchema>;

View File

@ -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";

View File

@ -0,0 +1 @@
export * from "./invoice-series-summary.dto";

View File

@ -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<typeof InvoiceSeriesSummarySchema>;