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-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"; 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 { export class InvoiceSeriesTransactionRequiredError extends Error {
public constructor() { public constructor(options?: ErrorOptions) {
super("InvoiceSeries number assignment requires an active transaction"); super("Invoice series number assignment requires an active transaction.", options);
this.name = "InvoiceSeriesTransactionRequiredError"; 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 "./errors";
export * from "./repositories"; export * from "./repositories";
export * from "./services"; 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 { 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"; import type { InvoiceSeries, InvoiceSeriesCode } from "../../../domain";
export interface IInvoiceSeriesRepository { export interface IInvoiceSeriesRepository {
findActiveByCompany(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<Collection<InvoiceSeries>, Error>>;
findByCodeInCompany(params: { findByCodeInCompany(params: {
companyId: UniqueID; companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode; invoiceSeriesCode: InvoiceSeriesCode;

View File

@ -1 +1,2 @@
export * from "./invoice-series-finder";
export * from "./invoice-series-number-assigner"; 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 type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { type InvoiceNumber, type InvoiceSeriesCode } from "../../../domain"; import { type InvoiceNumber, type InvoiceSeriesCode } from "../../../domain";
import { import {
InactiveInvoiceSeriesError, InvoiceSeriesInactiveError,
InvoiceSeriesNotFoundError,
InvoiceSeriesTransactionRequiredError, InvoiceSeriesTransactionRequiredError,
} from "../errors"; } from "../errors";
import type { IInvoiceSeriesRepository } from "../repositories"; import type { IInvoiceSeriesRepository } from "../repositories";
@ -45,15 +45,13 @@ export class InvoiceSeriesNumberAssigner implements IInvoiceSeriesNumberAssigner
} }
if (invoiceSeriesResult.data.isNone()) { if (invoiceSeriesResult.data.isNone()) {
return Result.fail( return Result.fail(new InvoiceSeriesNotFoundError());
new EntityNotFoundError("InvoiceSeries", "code", params.invoiceSeriesCode.toPrimitive())
);
} }
const invoiceSeries = invoiceSeriesResult.data.unwrap(); const invoiceSeries = invoiceSeriesResult.data.unwrap();
if (!invoiceSeries.isActive) { if (!invoiceSeries.isActive) {
return Result.fail(new InactiveInvoiceSeriesError(invoiceSeries.code.toPrimitive())); return Result.fail(new InvoiceSeriesInactiveError(invoiceSeries.code.toPrimitive()));
} }
const invoiceNumberResult = invoiceSeries.assignNextInvoiceNumber(); 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 "./di";
export * from "./errors";
export * from "./models"; export * from "./models";
export * from "./repositories"; export * from "./repositories";
export * from "./services"; export * from "./services";

View File

@ -1,7 +1,11 @@
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; 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 { IInvoiceSeriesNumberAssigner } from "../../invoice-series";
import type { IIssuedInvoiceRepository } from "../repositories"; import type { IIssuedInvoiceRepository } from "../repositories";
@ -33,9 +37,15 @@ export class IssuedInvoiceCreator implements IIssuedInvoiceCreator {
async create(params: IIssuedInvoiceCreatorParams): Promise<Result<IssuedInvoice, Error>> { async create(params: IIssuedInvoiceCreatorParams): Promise<Result<IssuedInvoice, Error>> {
const { companyId, id, props, transaction } = params; 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({ const numberResult = await this.invoiceSeriesNumberAssigner.assignNextNumber({
companyId, companyId,
invoiceSeriesCode: props.series, invoiceSeriesCode: invoiceSeriesCodeResult.data,
transaction, 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 "./aggregates";
export * from "./errors";
export * from "./value-objects"; 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 { Result } from "@repo/rdx-utils";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { InvalidInvoiceSeriesCodeError } from "../errors";
type InvoiceSeriesCodeProps = { type InvoiceSeriesCodeProps = {
value: string; value: string;
}; };
export class InvoiceSeriesCode extends ValueObject<InvoiceSeriesCodeProps> { export class InvoiceSeriesCode extends ValueObject<InvoiceSeriesCodeProps> {
private static readonly MAX_LENGTH = 10; 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) { private static validate(value: string) {
return z return z
@ -26,13 +26,7 @@ export class InvoiceSeriesCode extends ValueObject<InvoiceSeriesCodeProps> {
const validationResult = InvoiceSeriesCode.validate(value); const validationResult = InvoiceSeriesCode.validate(value);
if (!validationResult.success) { if (!validationResult.success) {
return Result.fail( return Result.fail(new InvalidInvoiceSeriesCodeError(validationResult.error.message));
new DomainValidationError(
InvoiceSeriesCode.ERROR_CODE,
InvoiceSeriesCode.FIELD,
validationResult.error.message
)
);
} }
return Result.ok(new InvoiceSeriesCode({ value: validationResult.data })); 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 { Result } from "@repo/rdx-utils";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { InvalidInvoiceSeriesNextNumberError } from "../errors";
type InvoiceSeriesNextNumberProps = { type InvoiceSeriesNextNumberProps = {
value: number; value: number;
}; };
export class InvoiceSeriesNextNumber extends ValueObject<InvoiceSeriesNextNumberProps> { 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) { private static validate(value: number) {
return z return z
.number() .number()
@ -22,13 +21,7 @@ export class InvoiceSeriesNextNumber extends ValueObject<InvoiceSeriesNextNumber
const validationResult = InvoiceSeriesNextNumber.validate(value); const validationResult = InvoiceSeriesNextNumber.validate(value);
if (!validationResult.success) { if (!validationResult.success) {
return Result.fail( return Result.fail(new InvalidInvoiceSeriesNextNumberError(validationResult.error.message));
new DomainValidationError(
InvoiceSeriesNextNumber.ERROR_CODE,
InvoiceSeriesNextNumber.FIELD,
validationResult.error.message
)
);
} }
return Result.ok(new InvoiceSeriesNextNumber({ value: validationResult.data })); 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 { Result } from "@repo/rdx-utils";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { InvalidInvoiceSeriesPaddingLengthError } from "../errors";
type InvoiceSeriesPaddingLengthProps = { type InvoiceSeriesPaddingLengthProps = {
value: number; value: number;
}; };
export class InvoiceSeriesPaddingLength extends ValueObject<InvoiceSeriesPaddingLengthProps> { 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) { private static validate(value: number) {
return z return z
.number() .number()
@ -23,11 +22,7 @@ export class InvoiceSeriesPaddingLength extends ValueObject<InvoiceSeriesPadding
if (!validationResult.success) { if (!validationResult.success) {
return Result.fail( return Result.fail(
new DomainValidationError( new InvalidInvoiceSeriesPaddingLengthError(validationResult.error.message)
InvoiceSeriesPaddingLength.ERROR_CODE,
InvoiceSeriesPaddingLength.FIELD,
validationResult.error.message
)
); );
} }

View File

@ -19,6 +19,7 @@ import {
InvoiceStatus, InvoiceStatus,
type ItemAmount, type ItemAmount,
} from "../../common/value-objects"; } from "../../common/value-objects";
import { InvalidInvoiceSeriesCodeError } from "../../invoice-series";
import { import {
type IProformaItemCreateProps, type IProformaItemCreateProps,
type IProformaItems, type IProformaItems,
@ -363,13 +364,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
private validateCanBeIssued(): Result<void, Error> { private validateCanBeIssued(): Result<void, Error> {
if (this.series.isNone()) { if (this.series.isNone()) {
return Result.fail( return Result.fail(InvalidInvoiceSeriesCodeError.required());
new DomainValidationError(
"MISSING_SERIES",
"series",
"Series is required to issue the proforma"
)
);
} }
if (this.description.isNone()) { if (this.description.isNone()) {

View File

@ -1,10 +1,12 @@
import type { IModuleServer } from "@erp/core/api"; import type { IModuleServer } from "@erp/core/api";
import { import {
buildInvoiceSeriesDependencies,
buildIssuedInvoicePublicServices, buildIssuedInvoicePublicServices,
buildIssuedInvoicesDependencies, buildIssuedInvoicesDependencies,
buildProformaPublicServices, buildProformaPublicServices,
buildProformasDependencies, buildProformasDependencies,
invoiceSeriesRouter,
issuedInvoicesRouter, issuedInvoicesRouter,
models, models,
proformasRouter, proformasRouter,
@ -28,6 +30,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
// 1) Dominio interno // 1) Dominio interno
const invoiceSeriesInternal = buildInvoiceSeriesDependencies(params);
const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params); const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params);
const proformasInternal = buildProformasDependencies(params); const proformasInternal = buildProformasDependencies(params);
@ -49,6 +52,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
// Implementación privada del módulo // Implementación privada del módulo
internal: { internal: {
invoiceSeries: invoiceSeriesInternal,
issuedInvoices: issuedInvoicesInternal, issuedInvoices: issuedInvoicesInternal,
proformas: proformasInternal, proformas: proformasInternal,
}, },
@ -66,6 +70,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
const { app, baseRoutePath, logger, getInternal, getService } = params; const { app, baseRoutePath, logger, getInternal, getService } = params;
// Registro de rutas HTTP // Registro de rutas HTTP
invoiceSeriesRouter(params);
issuedInvoicesRouter(params); issuedInvoicesRouter(params);
proformasRouter(params); proformasRouter(params);

View File

@ -1 +1,2 @@
export * from "./invoice-series.di";
export * from "./invoice-series-repository.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 "./di";
export * from "./express";
export * from "./persistence"; export * from "./persistence";

View File

@ -7,6 +7,9 @@ import {
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { import {
type InvalidInvoiceSeriesCodeError,
type InvalidInvoiceSeriesNextNumberError,
type InvalidInvoiceSeriesPaddingLengthError,
InvoiceSeries, InvoiceSeries,
InvoiceSeriesCode, InvoiceSeriesCode,
type InvoiceSeriesInternalProps, type InvoiceSeriesInternalProps,
@ -21,17 +24,21 @@ export class InvoiceSeriesDomainMapper {
const id = extractOrPushError(UniqueID.create(raw.id), "id", errors); const id = extractOrPushError(UniqueID.create(raw.id), "id", errors);
const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_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( const codeResult = InvoiceSeriesCode.create(raw.code);
InvoiceSeriesNextNumber.create(raw.next_number), if (codeResult.isFailure) {
"next_number", return Result.fail(codeResult.error as InvalidInvoiceSeriesCodeError);
errors }
);
const paddingLength = extractOrPushError( const nextNumberResult = InvoiceSeriesNextNumber.create(raw.next_number);
InvoiceSeriesPaddingLength.create(raw.padding_length), if (nextNumberResult.isFailure) {
"padding_length", return Result.fail(nextNumberResult.error as InvalidInvoiceSeriesNextNumberError);
errors }
);
const paddingLengthResult = InvoiceSeriesPaddingLength.create(raw.padding_length);
if (paddingLengthResult.isFailure) {
return Result.fail(paddingLengthResult.error as InvalidInvoiceSeriesPaddingLengthError);
}
if (errors.length > 0) { if (errors.length > 0) {
return Result.fail( return Result.fail(
@ -41,9 +48,9 @@ export class InvoiceSeriesDomainMapper {
const props: InvoiceSeriesInternalProps = { const props: InvoiceSeriesInternalProps = {
companyId: companyId!, companyId: companyId!,
code: code!, code: codeResult.data,
nextNumber: nextNumber!, nextNumber: nextNumberResult.data,
paddingLength: paddingLength!, paddingLength: paddingLengthResult.data,
isDefault: raw.is_default, isDefault: raw.is_default,
isActive: raw.is_active, isActive: raw.is_active,
}; };

View File

@ -1,13 +1,17 @@
import { import {
InfrastructureRepositoryError,
SequelizeRepository, SequelizeRepository,
translateSequelizeError, translateSequelizeError,
} from "@erp/core/api"; } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd"; 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 { Sequelize, Transaction } from "sequelize";
import type { IInvoiceSeriesRepository } from "../../../../../application"; import type { IInvoiceSeriesRepository } from "../../../../../application";
import {
InvoiceSeriesConcurrencyError,
InvoiceSeriesLockRequiredError,
InvoiceSeriesTransactionRequiredError,
} from "../../../../../application/invoice-series";
import type { InvoiceSeries, InvoiceSeriesCode } from "../../../../../domain"; import type { InvoiceSeries, InvoiceSeriesCode } from "../../../../../domain";
import { CustomerInvoiceSeriesModel } from "../../../../common"; import { CustomerInvoiceSeriesModel } from "../../../../common";
import type { InvoiceSeriesDomainMapper } from "../mappers"; import type { InvoiceSeriesDomainMapper } from "../mappers";
@ -23,6 +27,41 @@ export class SequelizeInvoiceSeriesRepository
super({ database }); 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: { public async findByCodeInCompany(params: {
companyId: UniqueID; companyId: UniqueID;
invoiceSeriesCode: InvoiceSeriesCode; invoiceSeriesCode: InvoiceSeriesCode;
@ -52,11 +91,7 @@ export class SequelizeInvoiceSeriesRepository
const transaction = params.transaction as Transaction; const transaction = params.transaction as Transaction;
if (!transaction) { if (!transaction) {
return Result.fail( return Result.fail(new InvoiceSeriesLockRequiredError());
new InfrastructureRepositoryError(
"InvoiceSeries row lock requires an active transaction"
)
);
} }
const row = await CustomerInvoiceSeriesModel.findOne({ const row = await CustomerInvoiceSeriesModel.findOne({
@ -79,6 +114,10 @@ export class SequelizeInvoiceSeriesRepository
transaction?: unknown transaction?: unknown
): Promise<Result<void, Error>> { ): Promise<Result<void, Error>> {
try { try {
if (!transaction) {
return Result.fail(new InvoiceSeriesTransactionRequiredError());
}
const dtoResult = this.domainMapper.mapToPersistence(invoiceSeries); const dtoResult = this.domainMapper.mapToPersistence(invoiceSeries);
if (dtoResult.isFailure) { if (dtoResult.isFailure) {
@ -92,11 +131,7 @@ export class SequelizeInvoiceSeriesRepository
}); });
if (affectedRows !== 1) { if (affectedRows !== 1) {
return Result.fail( return Result.fail(new InvoiceSeriesConcurrencyError());
new InfrastructureRepositoryError(
`InvoiceSeries ${id} could not be updated or no longer exists`
)
);
} }
return Result.ok(); return Result.ok();

View File

@ -5,9 +5,19 @@ import {
ApiErrorMapper, ApiErrorMapper,
ConflictApiError, ConflictApiError,
type ErrorToApiRule, type ErrorToApiRule,
InternalApiError,
ValidationApiError, ValidationApiError,
} from "@erp/core/api"; } from "@erp/core/api";
import {
InvoiceSeriesConcurrencyError,
InvoiceSeriesInactiveError,
InvoiceSeriesLockRequiredError,
InvoiceSeriesNotFoundError,
InvoiceSeriesTransactionRequiredError,
IssuedInvoiceNumberAlreadyExistsError,
isIssuedInvoiceNumberAlreadyExistsError,
} from "../../../application";
import { import {
type CustomerInvoiceIdAlreadyExistsError, type CustomerInvoiceIdAlreadyExistsError,
type IssuedInvoiceItemMismatch, 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 // Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const issuedInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() export const issuedInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(issuedInvoiceNumberAlreadyExistsRule)
.register(invoiceSeriesConcurrencyRule)
.register(invoiceSeriesLockRequiredRule)
.register(invoiceSeriesTransactionRequiredRule)
.register(invoiceSeriesInactiveRule)
.register(invoiceSeriesNotFoundRule)
.register(invoiceDuplicateRule) .register(invoiceDuplicateRule)
.register(issuedinvoiceItemMismatchError); .register(issuedinvoiceItemMismatchError);

View File

@ -17,10 +17,15 @@ import {
type OrderItem, type OrderItem,
type Sequelize, type Sequelize,
type Transaction, type Transaction,
UniqueConstraintError,
type WhereOptions, type WhereOptions,
} from "sequelize"; } from "sequelize";
import type { IIssuedInvoiceRepository, IssuedInvoiceSummary } from "../../../../../application"; import {
IssuedInvoiceNumberAlreadyExistsError,
type IIssuedInvoiceRepository,
type IssuedInvoiceSummary,
} from "../../../../../application";
import type { IssuedInvoice } from "../../../../../domain"; import type { IssuedInvoice } from "../../../../../domain";
import { import {
CustomerInvoiceItemModel, CustomerInvoiceItemModel,
@ -37,6 +42,8 @@ export class IssuedInvoiceRepository
extends SequelizeRepository<IssuedInvoice> extends SequelizeRepository<IssuedInvoice>
implements IIssuedInvoiceRepository implements IIssuedInvoiceRepository
{ {
private static readonly INVOICE_NUMBER_UNIQUE_INDEX = "idx_invoice_company_series_number";
constructor( constructor(
private readonly domainMapper: SequelizeIssuedInvoiceDomainMapper, private readonly domainMapper: SequelizeIssuedInvoiceDomainMapper,
private readonly summaryMapper: SequelizeIssuedInvoiceSummaryMapper, private readonly summaryMapper: SequelizeIssuedInvoiceSummaryMapper,
@ -92,10 +99,33 @@ export class IssuedInvoiceRepository
return Result.ok(); return Result.ok();
} catch (err: unknown) { } catch (err: unknown) {
if (this.isIssuedInvoiceNumberAlreadyExistsError(err)) {
return Result.fail(new IssuedInvoiceNumberAlreadyExistsError({ cause: err as Error }));
}
return Result.fail(translateSequelizeError(err)); 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`. * Comprueba si existe una factura con un `id` dentro de una `company`.
* *

View File

@ -2,18 +2,35 @@ import {
ApiErrorMapper, ApiErrorMapper,
ConflictApiError, ConflictApiError,
type ErrorToApiRule, type ErrorToApiRule,
InternalApiError,
ValidationApiError, ValidationApiError,
} from "@erp/core/api"; } from "@erp/core/api";
import { CompanyReportProfileNotFoundError, isCompanyReportProfileNotFoundError } from "../../../application"; import {
CompanyReportProfileNotFoundError,
InvoiceSeriesConcurrencyError,
InvoiceSeriesInactiveError,
InvoiceSeriesLockRequiredError,
InvoiceSeriesNotFoundError,
InvoiceSeriesTransactionRequiredError,
IssuedInvoiceNumberAlreadyExistsError,
isCompanyReportProfileNotFoundError,
isIssuedInvoiceNumberAlreadyExistsError,
} from "../../../application";
import { import {
type CustomerInvoiceIdAlreadyExistsError, type CustomerInvoiceIdAlreadyExistsError,
type EntityIsNotProformaError, type EntityIsNotProformaError,
type InvalidInvoiceSeriesCodeError,
type InvalidInvoiceSeriesNextNumberError,
type InvalidInvoiceSeriesPaddingLengthError,
type InvalidProformaTransitionError, type InvalidProformaTransitionError,
type ProformaCannotBeConvertedToInvoiceError, type ProformaCannotBeConvertedToInvoiceError,
type ProformaItemMismatch, type ProformaItemMismatch,
isCustomerInvoiceIdAlreadyExistsError, isCustomerInvoiceIdAlreadyExistsError,
isEntityIsNotProformaError, isEntityIsNotProformaError,
isInvalidInvoiceSeriesCodeError,
isInvalidInvoiceSeriesNextNumberError,
isInvalidInvoiceSeriesPaddingLengthError,
isInvalidProformaTransitionError, isInvalidProformaTransitionError,
isProformaCannotBeConvertedToInvoiceError, isProformaCannotBeConvertedToInvoiceError,
isProformaCannotBeDeletedError, 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 // Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() 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(companyReportProfileNotFoundRule)
.register(proformaDuplicateRule) .register(proformaDuplicateRule)
.register(proformaItemMismatchError) .register(proformaItemMismatchError)

View File

@ -1,2 +1,3 @@
export * from "./invoice-series";
export * from "./issued-invoices"; export * from "./issued-invoices";
export * from "./proformas"; 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 "./issued-invoices";
export * from "./invoice-series";
export * from "./item-position.dto"; export * from "./item-position.dto";
export * from "./payment-method-ref.dto"; export * from "./payment-method-ref.dto";
export * from "./payment-term-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>;